mirror of
https://github.com/morris/vanilla-todo.git
synced 2025-08-21 05:11:20 +02:00
refactor for pure functional business logic
This commit is contained in:
@@ -4,19 +4,14 @@ import { AppFps } from './AppFps.js';
|
||||
import { AppIcon } from './AppIcon.js';
|
||||
import { TodoFrameCustom } from './TodoFrameCustom.js';
|
||||
import { TodoFrameDays } from './TodoFrameDays.js';
|
||||
import { TodoLogic } from './TodoLogic.js';
|
||||
import { TodoStore } from './TodoStore.js';
|
||||
import { formatDateId } from './util.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
export function TodoApp(el) {
|
||||
let todoData = {
|
||||
items: [],
|
||||
customLists: [],
|
||||
at: formatDateId(new Date()),
|
||||
customAt: 0,
|
||||
};
|
||||
let todoData = TodoLogic.initTodoData();
|
||||
|
||||
el.innerHTML = `
|
||||
<header class="app-header">
|
||||
|
@@ -82,7 +82,7 @@ export function TodoCustomList(el) {
|
||||
}
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('deleteTodoList', {
|
||||
new CustomEvent('deleteCustomTodoList', {
|
||||
detail: list,
|
||||
bubbles: true,
|
||||
}),
|
||||
@@ -115,15 +115,15 @@ export function TodoCustomList(el) {
|
||||
e.detail.index = e.detail.index ?? 0;
|
||||
});
|
||||
|
||||
el.addEventListener('todoCustomList', (e) => {
|
||||
el.addEventListener('customTodoList', (e) => {
|
||||
list = e.detail;
|
||||
update();
|
||||
});
|
||||
|
||||
function save() {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('saveTodoList', {
|
||||
detail: { list, title: inputEl.value.trim() },
|
||||
new CustomEvent('editCustomTodoList', {
|
||||
detail: { ...list, title: inputEl.value.trim() },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
@@ -1,16 +1,13 @@
|
||||
import { AppIcon } from './AppIcon.js';
|
||||
import { AppSortable } from './AppSortable.js';
|
||||
import { TodoCustomList } from './TodoCustomList.js';
|
||||
import { TodoLogic } from './TodoLogic.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
export function TodoFrameCustom(el) {
|
||||
let todoData = {
|
||||
customLists: [],
|
||||
items: [],
|
||||
customAt: 0,
|
||||
};
|
||||
let todoData = TodoLogic.initTodoData();
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="leftcontrols">
|
||||
@@ -42,9 +39,7 @@ export function TodoFrameCustom(el) {
|
||||
);
|
||||
|
||||
el.querySelector('.add').addEventListener('click', () => {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('addTodoList', { detail: {}, bubbles: true }),
|
||||
);
|
||||
el.dispatchEvent(new CustomEvent('addCustomTodoList', { bubbles: true }));
|
||||
// TODO seek if not at end
|
||||
});
|
||||
|
||||
@@ -52,9 +47,9 @@ export function TodoFrameCustom(el) {
|
||||
if (!e.detail.data.list) return;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('moveTodoList', {
|
||||
new CustomEvent('moveCustomTodoList', {
|
||||
detail: {
|
||||
list: e.detail.data.list,
|
||||
...e.detail.data.list,
|
||||
index: e.detail.index,
|
||||
},
|
||||
bubbles: true,
|
||||
@@ -63,9 +58,7 @@ export function TodoFrameCustom(el) {
|
||||
});
|
||||
|
||||
el.addEventListener('draggableOver', (e) => {
|
||||
if (!e.detail.data.list) return;
|
||||
|
||||
updatePositions();
|
||||
if (e.detail.data.list) updatePositions();
|
||||
});
|
||||
|
||||
el.addEventListener('todoData', (e) => {
|
||||
@@ -74,14 +67,15 @@ export function TodoFrameCustom(el) {
|
||||
});
|
||||
|
||||
function update() {
|
||||
const lists = getLists();
|
||||
const customLists = TodoLogic.getCustomTodoLists(todoData);
|
||||
|
||||
const container = el.querySelector('.container');
|
||||
const obsolete = new Set(container.children);
|
||||
const childrenByKey = new Map();
|
||||
|
||||
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child));
|
||||
|
||||
const children = lists.map((list) => {
|
||||
const children = customLists.map((list) => {
|
||||
let child = childrenByKey.get(list.id);
|
||||
|
||||
if (child) {
|
||||
@@ -93,7 +87,7 @@ export function TodoFrameCustom(el) {
|
||||
TodoCustomList(child);
|
||||
}
|
||||
|
||||
child.dispatchEvent(new CustomEvent('todoCustomList', { detail: list }));
|
||||
child.dispatchEvent(new CustomEvent('customTodoList', { detail: list }));
|
||||
|
||||
return child;
|
||||
});
|
||||
@@ -136,21 +130,4 @@ export function TodoFrameCustom(el) {
|
||||
// Update collapsible on changing heights
|
||||
el.dispatchEvent(new CustomEvent('collapse', { bubbles: true }));
|
||||
}
|
||||
|
||||
function getLists() {
|
||||
return todoData.customLists
|
||||
.map((list) => ({
|
||||
id: list.id,
|
||||
index: list.index,
|
||||
title: list.title,
|
||||
items: getItemsForList(list.id),
|
||||
}))
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
function getItemsForList(listId) {
|
||||
return todoData.items
|
||||
.filter((item) => item.listId === listId)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
}
|
||||
|
@@ -1,18 +1,14 @@
|
||||
import { AppDatePicker } from './AppDatePicker.js';
|
||||
import { AppIcon } from './AppIcon.js';
|
||||
import { TodoDay } from './TodoDay.js';
|
||||
import { formatDateId } from './util.js';
|
||||
import { TodoLogic } from './TodoLogic.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
export function TodoFrameDays(el) {
|
||||
const RANGE = 14;
|
||||
|
||||
let todoData = {
|
||||
items: [],
|
||||
at: formatDateId(new Date()),
|
||||
};
|
||||
let todoData = TodoLogic.initTodoData();
|
||||
|
||||
el.innerHTML = `
|
||||
<nav class="leftcontrols">
|
||||
@@ -100,7 +96,7 @@ export function TodoFrameDays(el) {
|
||||
});
|
||||
|
||||
function update() {
|
||||
const days = getDays();
|
||||
const listsByDay = TodoLogic.getTodoListsByDay(todoData, RANGE);
|
||||
|
||||
const container = el.querySelector('.container');
|
||||
const obsolete = new Set(container.children);
|
||||
@@ -108,7 +104,7 @@ export function TodoFrameDays(el) {
|
||||
|
||||
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child));
|
||||
|
||||
const children = days.map((day) => {
|
||||
const children = listsByDay.map((day) => {
|
||||
let child = childrenByKey.get(day.id);
|
||||
|
||||
if (child) {
|
||||
@@ -147,28 +143,4 @@ export function TodoFrameDays(el) {
|
||||
|
||||
el.style.height = `${height + 50}px`;
|
||||
}
|
||||
|
||||
function getDays() {
|
||||
const days = [];
|
||||
|
||||
for (let i = 0; i < 2 * RANGE; ++i) {
|
||||
const t = new Date(todoData.at);
|
||||
t.setDate(t.getDate() - RANGE + i);
|
||||
const id = formatDateId(t);
|
||||
|
||||
days.push({
|
||||
id,
|
||||
items: getItemsForDay(id),
|
||||
position: -RANGE + i,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function getItemsForDay(dateId) {
|
||||
return todoData.items
|
||||
.filter((item) => item.listId === dateId)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
}
|
||||
|
@@ -50,7 +50,7 @@ export function TodoItem(el) {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('checkTodoItem', {
|
||||
detail: {
|
||||
item,
|
||||
...item,
|
||||
done: !item.done,
|
||||
},
|
||||
bubbles: true,
|
||||
@@ -121,9 +121,9 @@ export function TodoItem(el) {
|
||||
}
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('saveTodoItem', {
|
||||
new CustomEvent('editTodoItem', {
|
||||
detail: {
|
||||
item,
|
||||
...item,
|
||||
label,
|
||||
},
|
||||
bubbles: true,
|
||||
|
@@ -20,7 +20,7 @@ export function TodoList(el) {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent('moveTodoItem', {
|
||||
detail: {
|
||||
item: e.detail.data.item,
|
||||
...e.detail.data.item,
|
||||
index: e.detail.index,
|
||||
},
|
||||
bubbles: true,
|
||||
|
212
public/scripts/TodoLogic.js
Normal file
212
public/scripts/TodoLogic.js
Normal file
@@ -0,0 +1,212 @@
|
||||
import { formatDateId } from './util.js';
|
||||
import { uuid } from './uuid.js';
|
||||
|
||||
export class TodoLogic {
|
||||
static initTodoData(now = new Date()) {
|
||||
return {
|
||||
items: [],
|
||||
customLists: [],
|
||||
at: formatDateId(now),
|
||||
customAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
static getTodoListsByDay(data, range) {
|
||||
const listsByDay = [];
|
||||
|
||||
for (let i = 0; i < 2 * range; ++i) {
|
||||
const t = new Date(data.at);
|
||||
t.setDate(t.getDate() - range + i);
|
||||
const id = formatDateId(t);
|
||||
|
||||
listsByDay.push({
|
||||
id,
|
||||
items: TodoLogic.getTodoItemsForList(data, id),
|
||||
position: -range + i,
|
||||
});
|
||||
}
|
||||
|
||||
return listsByDay;
|
||||
}
|
||||
|
||||
static getTodoItemsForList(data, listId) {
|
||||
return data.items
|
||||
.filter((item) => item.listId === listId)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
static addTodoItem(data, input) {
|
||||
let index = 0;
|
||||
|
||||
for (const item of data.items) {
|
||||
if (item.listId === input.listId) {
|
||||
index = Math.max(index, item.index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
items: [
|
||||
...data.items,
|
||||
{
|
||||
...input,
|
||||
id: uuid(),
|
||||
index,
|
||||
done: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static checkTodoItem(data, input) {
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map((item) =>
|
||||
item.id === input.id ? { ...item, done: input.done } : item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static editTodoItem(data, input) {
|
||||
return {
|
||||
...data,
|
||||
items: data.items.map((item) =>
|
||||
item.id === input.id ? { ...item, label: input.label } : item,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static moveTodoItem(data, input) {
|
||||
const itemToMove = data.items.find((item) => item.id === input.id);
|
||||
|
||||
// Reinsert item at target list and index
|
||||
let list = data.items.filter(
|
||||
(item) => item.listId === input.listId && item.id !== input.id,
|
||||
);
|
||||
list.splice(input.index, 0, { ...itemToMove, listId: input.listId });
|
||||
list = TodoLogic.setIndexes(list);
|
||||
|
||||
// Reinsert updated list
|
||||
let items = data.items.filter(
|
||||
(item) => item.listId !== input.listId && item.id !== input.id,
|
||||
);
|
||||
items = [...items, ...list];
|
||||
|
||||
return {
|
||||
...data,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
static deleteTodoItem(data, input) {
|
||||
return {
|
||||
...data,
|
||||
items: data.items.filter((item) => item.id !== input.id),
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
static getCustomTodoLists(data) {
|
||||
return data.customLists
|
||||
.map((list) => ({
|
||||
id: list.id,
|
||||
index: list.index,
|
||||
title: list.title,
|
||||
items: TodoLogic.getTodoItemsForList(data, list.id),
|
||||
}))
|
||||
.sort((a, b) => a.index - b.index);
|
||||
}
|
||||
|
||||
static addCustomTodoList(data) {
|
||||
let index = 0;
|
||||
|
||||
for (const customList of data.customLists) {
|
||||
index = Math.max(index, customList.index + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
customLists: [
|
||||
...data.customLists,
|
||||
{
|
||||
id: uuid(),
|
||||
index,
|
||||
title: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
static editCustomTodoList(data, input) {
|
||||
return {
|
||||
...data,
|
||||
customLists: data.customLists.map((customList) =>
|
||||
customList.id === input.id
|
||||
? { ...customList, title: input.title }
|
||||
: customList,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
static moveCustomTodoList(data, input) {
|
||||
const customListToMove = data.customLists.find(
|
||||
(customList) => customList.id === input.id,
|
||||
);
|
||||
|
||||
let customLists = data.customLists
|
||||
.filter((customList) => customList.id !== input.id)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
customLists.splice(input.index, 0, customListToMove);
|
||||
customLists = TodoLogic.setIndexes(customLists);
|
||||
|
||||
return {
|
||||
...data,
|
||||
customLists,
|
||||
};
|
||||
}
|
||||
|
||||
static deleteCustomTodoList(data, input) {
|
||||
return {
|
||||
...data,
|
||||
customLists: data.customLists.filter(
|
||||
(customList) => customList.id !== input.id,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
static seekDays(data, delta) {
|
||||
const t = new Date(`${data.at}T00:00:00`);
|
||||
t.setDate(t.getDate() + delta);
|
||||
|
||||
return { ...data, at: formatDateId(t) };
|
||||
}
|
||||
|
||||
static seekToToday(data) {
|
||||
return { ...data, at: formatDateId(new Date()) };
|
||||
}
|
||||
|
||||
static seekToDate(data, date) {
|
||||
return { ...data, at: formatDateId(date) };
|
||||
}
|
||||
|
||||
static seekCustomTodoLists(data, delta) {
|
||||
return {
|
||||
...data,
|
||||
customAt: Math.max(
|
||||
0,
|
||||
Math.min(data.customLists.length - 1, data.customAt + delta),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
static setIndexes(array) {
|
||||
return array.map((item, index) =>
|
||||
item.index === index ? item : { ...item, index },
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,158 +1,36 @@
|
||||
import { formatDateId, uuid } from './util.js';
|
||||
import { TodoLogic } from './TodoLogic.js';
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} el
|
||||
*/
|
||||
export function TodoStore(el) {
|
||||
const todoData = {
|
||||
items: [],
|
||||
customLists: [],
|
||||
at: formatDateId(new Date()),
|
||||
customAt: 0,
|
||||
};
|
||||
|
||||
let storeTimeout;
|
||||
let todoData = TodoLogic.initTodoData();
|
||||
let saveTimeout;
|
||||
|
||||
el.addEventListener('loadTodoStore', load);
|
||||
|
||||
el.addEventListener('addTodoItem', (e) => {
|
||||
let index = 0;
|
||||
|
||||
for (const item of todoData.items) {
|
||||
if (item.listId === e.detail.listId) {
|
||||
index = Math.max(index, item.index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
todoData.items.push({
|
||||
id: uuid(),
|
||||
listId: e.detail.listId,
|
||||
index,
|
||||
label: e.detail.label,
|
||||
done: false,
|
||||
for (const action of [
|
||||
'addTodoItem',
|
||||
'checkTodoItem',
|
||||
'editTodoItem',
|
||||
'moveTodoItem',
|
||||
'deleteTodoItem',
|
||||
'addCustomTodoList',
|
||||
'editCustomTodoList',
|
||||
'moveCustomTodoList',
|
||||
'deleteCustomTodoList',
|
||||
'seekDays',
|
||||
'seekToToday',
|
||||
'seekToDate',
|
||||
'seekCustomTodoLists',
|
||||
]) {
|
||||
el.addEventListener(action, (e) => {
|
||||
todoData = TodoLogic[action](todoData, e.detail);
|
||||
update();
|
||||
});
|
||||
}
|
||||
|
||||
dispatch({ items: todoData.items });
|
||||
});
|
||||
|
||||
el.addEventListener('checkTodoItem', (e) => {
|
||||
if (e.detail.item.done === e.detail.done) return;
|
||||
|
||||
e.detail.item.done = e.detail.done;
|
||||
dispatch({ items: todoData.items });
|
||||
});
|
||||
|
||||
el.addEventListener('saveTodoItem', (e) => {
|
||||
if (e.detail.item.label === e.detail.label) return;
|
||||
|
||||
e.detail.item.label = e.detail.label;
|
||||
dispatch({ items: todoData.items });
|
||||
});
|
||||
|
||||
el.addEventListener('moveTodoItem', (e) => {
|
||||
const movedItem = todoData.items.find(
|
||||
(item) => item.id === e.detail.item.id,
|
||||
);
|
||||
|
||||
const listItems = todoData.items.filter(
|
||||
(item) => item.listId === e.detail.listId && item !== movedItem,
|
||||
);
|
||||
|
||||
listItems.sort((a, b) => a.index - b.index);
|
||||
|
||||
movedItem.listId = e.detail.listId;
|
||||
listItems.splice(e.detail.index, 0, movedItem);
|
||||
|
||||
listItems.forEach((item, index) => {
|
||||
item.index = index;
|
||||
});
|
||||
|
||||
dispatch({ items: todoData.items });
|
||||
});
|
||||
|
||||
el.addEventListener('deleteTodoItem', (e) =>
|
||||
dispatch({
|
||||
items: todoData.items.filter((item) => item.id !== e.detail.id),
|
||||
}),
|
||||
);
|
||||
|
||||
el.addEventListener('addTodoList', (e) => {
|
||||
let index = 0;
|
||||
|
||||
for (const customList of todoData.customLists) {
|
||||
index = Math.max(index, customList.index + 1);
|
||||
}
|
||||
|
||||
todoData.customLists.push({
|
||||
id: uuid(),
|
||||
index,
|
||||
title: e.detail.title || '',
|
||||
});
|
||||
|
||||
dispatch({ customLists: todoData.customLists });
|
||||
});
|
||||
|
||||
el.addEventListener('saveTodoList', (e) => {
|
||||
const list = todoData.customLists.find((l) => l.id === e.detail.list.id);
|
||||
|
||||
if (list.title === e.detail.title) return;
|
||||
|
||||
list.title = e.detail.title;
|
||||
|
||||
dispatch({ customLists: todoData.customLists });
|
||||
});
|
||||
|
||||
el.addEventListener('moveTodoList', (e) => {
|
||||
const movedListIndex = todoData.customLists.findIndex(
|
||||
(list) => list.id === e.detail.list.id,
|
||||
);
|
||||
const movedList = todoData.customLists[movedListIndex];
|
||||
|
||||
todoData.customLists.splice(movedListIndex, 1);
|
||||
todoData.customLists.sort((a, b) => a.index - b.index);
|
||||
todoData.customLists.splice(e.detail.index, 0, movedList);
|
||||
|
||||
todoData.customLists.forEach((item, index) => {
|
||||
item.index = index;
|
||||
});
|
||||
|
||||
dispatch({ customLists: todoData.customLists });
|
||||
});
|
||||
|
||||
el.addEventListener('deleteTodoList', (e) =>
|
||||
dispatch({
|
||||
customLists: todoData.customLists.filter(
|
||||
(customList) => customList.id !== e.detail.id,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
el.addEventListener('seekDays', (e) => {
|
||||
const t = new Date(`${todoData.at}T00:00:00`);
|
||||
t.setDate(t.getDate() + e.detail);
|
||||
|
||||
dispatch({ at: formatDateId(t) });
|
||||
});
|
||||
|
||||
el.addEventListener('seekToToday', () =>
|
||||
dispatch({ at: formatDateId(new Date()) }),
|
||||
);
|
||||
|
||||
el.addEventListener('seekToDate', (e) =>
|
||||
dispatch({ at: formatDateId(e.detail) }),
|
||||
);
|
||||
|
||||
el.addEventListener('seekCustomTodoLists', (e) =>
|
||||
dispatch({
|
||||
customAt: Math.max(
|
||||
0,
|
||||
Math.min(todoData.customLists.length - 1, todoData.customAt + e.detail),
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
function dispatch(next) {
|
||||
Object.assign(todoData, next);
|
||||
function update() {
|
||||
save();
|
||||
|
||||
el.dispatchEvent(
|
||||
@@ -164,23 +42,22 @@ export function TodoStore(el) {
|
||||
}
|
||||
|
||||
function load() {
|
||||
if (!localStorage || !localStorage.todo) {
|
||||
dispatch(todoData);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(JSON.parse(localStorage.todo));
|
||||
if (localStorage?.todo) {
|
||||
todoData = { ...todoData, ...JSON.parse(localStorage.todo) };
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(err);
|
||||
}
|
||||
|
||||
update();
|
||||
}
|
||||
|
||||
function save() {
|
||||
clearTimeout(storeTimeout);
|
||||
clearTimeout(saveTimeout);
|
||||
|
||||
storeTimeout = setTimeout(() => {
|
||||
saveTimeout = setTimeout(() => {
|
||||
try {
|
||||
localStorage.todo = JSON.stringify(todoData);
|
||||
} catch (err) {
|
||||
|
@@ -1,12 +1,3 @@
|
||||
export function uuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c == 'x' ? r : (r & 0x3) | 0x8;
|
||||
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Date} date
|
||||
* @returns {string}
|
||||
|
8
public/scripts/uuid.js
Normal file
8
public/scripts/uuid.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export function uuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0,
|
||||
v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user