1
0
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:
Morris Brodersen
2023-11-30 11:42:02 +01:00
parent 2815a1eb4c
commit dd8dc8c4af
15 changed files with 449 additions and 252 deletions

View File

@@ -4,19 +4,14 @@ import { AppFps } from './AppFps.js';
import { AppIcon } from './AppIcon.js'; import { AppIcon } from './AppIcon.js';
import { TodoFrameCustom } from './TodoFrameCustom.js'; import { TodoFrameCustom } from './TodoFrameCustom.js';
import { TodoFrameDays } from './TodoFrameDays.js'; import { TodoFrameDays } from './TodoFrameDays.js';
import { TodoLogic } from './TodoLogic.js';
import { TodoStore } from './TodoStore.js'; import { TodoStore } from './TodoStore.js';
import { formatDateId } from './util.js';
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
*/ */
export function TodoApp(el) { export function TodoApp(el) {
let todoData = { let todoData = TodoLogic.initTodoData();
items: [],
customLists: [],
at: formatDateId(new Date()),
customAt: 0,
};
el.innerHTML = ` el.innerHTML = `
<header class="app-header"> <header class="app-header">

View File

@@ -82,7 +82,7 @@ export function TodoCustomList(el) {
} }
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('deleteTodoList', { new CustomEvent('deleteCustomTodoList', {
detail: list, detail: list,
bubbles: true, bubbles: true,
}), }),
@@ -115,15 +115,15 @@ export function TodoCustomList(el) {
e.detail.index = e.detail.index ?? 0; e.detail.index = e.detail.index ?? 0;
}); });
el.addEventListener('todoCustomList', (e) => { el.addEventListener('customTodoList', (e) => {
list = e.detail; list = e.detail;
update(); update();
}); });
function save() { function save() {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('saveTodoList', { new CustomEvent('editCustomTodoList', {
detail: { list, title: inputEl.value.trim() }, detail: { ...list, title: inputEl.value.trim() },
bubbles: true, bubbles: true,
}), }),
); );

View File

@@ -1,16 +1,13 @@
import { AppIcon } from './AppIcon.js'; import { AppIcon } from './AppIcon.js';
import { AppSortable } from './AppSortable.js'; import { AppSortable } from './AppSortable.js';
import { TodoCustomList } from './TodoCustomList.js'; import { TodoCustomList } from './TodoCustomList.js';
import { TodoLogic } from './TodoLogic.js';
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
*/ */
export function TodoFrameCustom(el) { export function TodoFrameCustom(el) {
let todoData = { let todoData = TodoLogic.initTodoData();
customLists: [],
items: [],
customAt: 0,
};
el.innerHTML = ` el.innerHTML = `
<div class="leftcontrols"> <div class="leftcontrols">
@@ -42,9 +39,7 @@ export function TodoFrameCustom(el) {
); );
el.querySelector('.add').addEventListener('click', () => { el.querySelector('.add').addEventListener('click', () => {
el.dispatchEvent( el.dispatchEvent(new CustomEvent('addCustomTodoList', { bubbles: true }));
new CustomEvent('addTodoList', { detail: {}, bubbles: true }),
);
// TODO seek if not at end // TODO seek if not at end
}); });
@@ -52,9 +47,9 @@ export function TodoFrameCustom(el) {
if (!e.detail.data.list) return; if (!e.detail.data.list) return;
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('moveTodoList', { new CustomEvent('moveCustomTodoList', {
detail: { detail: {
list: e.detail.data.list, ...e.detail.data.list,
index: e.detail.index, index: e.detail.index,
}, },
bubbles: true, bubbles: true,
@@ -63,9 +58,7 @@ export function TodoFrameCustom(el) {
}); });
el.addEventListener('draggableOver', (e) => { el.addEventListener('draggableOver', (e) => {
if (!e.detail.data.list) return; if (e.detail.data.list) updatePositions();
updatePositions();
}); });
el.addEventListener('todoData', (e) => { el.addEventListener('todoData', (e) => {
@@ -74,14 +67,15 @@ export function TodoFrameCustom(el) {
}); });
function update() { function update() {
const lists = getLists(); const customLists = TodoLogic.getCustomTodoLists(todoData);
const container = el.querySelector('.container'); const container = el.querySelector('.container');
const obsolete = new Set(container.children); const obsolete = new Set(container.children);
const childrenByKey = new Map(); const childrenByKey = new Map();
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child)); 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); let child = childrenByKey.get(list.id);
if (child) { if (child) {
@@ -93,7 +87,7 @@ export function TodoFrameCustom(el) {
TodoCustomList(child); TodoCustomList(child);
} }
child.dispatchEvent(new CustomEvent('todoCustomList', { detail: list })); child.dispatchEvent(new CustomEvent('customTodoList', { detail: list }));
return child; return child;
}); });
@@ -136,21 +130,4 @@ export function TodoFrameCustom(el) {
// Update collapsible on changing heights // Update collapsible on changing heights
el.dispatchEvent(new CustomEvent('collapse', { bubbles: true })); 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);
}
} }

View File

@@ -1,18 +1,14 @@
import { AppDatePicker } from './AppDatePicker.js'; import { AppDatePicker } from './AppDatePicker.js';
import { AppIcon } from './AppIcon.js'; import { AppIcon } from './AppIcon.js';
import { TodoDay } from './TodoDay.js'; import { TodoDay } from './TodoDay.js';
import { formatDateId } from './util.js'; import { TodoLogic } from './TodoLogic.js';
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
*/ */
export function TodoFrameDays(el) { export function TodoFrameDays(el) {
const RANGE = 14; const RANGE = 14;
let todoData = TodoLogic.initTodoData();
let todoData = {
items: [],
at: formatDateId(new Date()),
};
el.innerHTML = ` el.innerHTML = `
<nav class="leftcontrols"> <nav class="leftcontrols">
@@ -100,7 +96,7 @@ export function TodoFrameDays(el) {
}); });
function update() { function update() {
const days = getDays(); const listsByDay = TodoLogic.getTodoListsByDay(todoData, RANGE);
const container = el.querySelector('.container'); const container = el.querySelector('.container');
const obsolete = new Set(container.children); const obsolete = new Set(container.children);
@@ -108,7 +104,7 @@ export function TodoFrameDays(el) {
obsolete.forEach((child) => childrenByKey.set(child.dataset.key, child)); 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); let child = childrenByKey.get(day.id);
if (child) { if (child) {
@@ -147,28 +143,4 @@ export function TodoFrameDays(el) {
el.style.height = `${height + 50}px`; 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);
}
} }

View File

@@ -50,7 +50,7 @@ export function TodoItem(el) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('checkTodoItem', { new CustomEvent('checkTodoItem', {
detail: { detail: {
item, ...item,
done: !item.done, done: !item.done,
}, },
bubbles: true, bubbles: true,
@@ -121,9 +121,9 @@ export function TodoItem(el) {
} }
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('saveTodoItem', { new CustomEvent('editTodoItem', {
detail: { detail: {
item, ...item,
label, label,
}, },
bubbles: true, bubbles: true,

View File

@@ -20,7 +20,7 @@ export function TodoList(el) {
el.dispatchEvent( el.dispatchEvent(
new CustomEvent('moveTodoItem', { new CustomEvent('moveTodoItem', {
detail: { detail: {
item: e.detail.data.item, ...e.detail.data.item,
index: e.detail.index, index: e.detail.index,
}, },
bubbles: true, bubbles: true,

212
public/scripts/TodoLogic.js Normal file
View 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 },
);
}
}

View File

@@ -1,158 +1,36 @@
import { formatDateId, uuid } from './util.js'; import { TodoLogic } from './TodoLogic.js';
/** /**
* @param {HTMLElement} el * @param {HTMLElement} el
*/ */
export function TodoStore(el) { export function TodoStore(el) {
const todoData = { let todoData = TodoLogic.initTodoData();
items: [], let saveTimeout;
customLists: [],
at: formatDateId(new Date()),
customAt: 0,
};
let storeTimeout;
el.addEventListener('loadTodoStore', load); el.addEventListener('loadTodoStore', load);
el.addEventListener('addTodoItem', (e) => { for (const action of [
let index = 0; 'addTodoItem',
'checkTodoItem',
for (const item of todoData.items) { 'editTodoItem',
if (item.listId === e.detail.listId) { 'moveTodoItem',
index = Math.max(index, item.index + 1); 'deleteTodoItem',
} 'addCustomTodoList',
} 'editCustomTodoList',
'moveCustomTodoList',
todoData.items.push({ 'deleteCustomTodoList',
id: uuid(), 'seekDays',
listId: e.detail.listId, 'seekToToday',
index, 'seekToDate',
label: e.detail.label, 'seekCustomTodoLists',
done: false, ]) {
el.addEventListener(action, (e) => {
todoData = TodoLogic[action](todoData, e.detail);
update();
}); });
}
dispatch({ items: todoData.items }); function update() {
});
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);
save(); save();
el.dispatchEvent( el.dispatchEvent(
@@ -164,23 +42,22 @@ export function TodoStore(el) {
} }
function load() { function load() {
if (!localStorage || !localStorage.todo) {
dispatch(todoData);
return;
}
try { try {
dispatch(JSON.parse(localStorage.todo)); if (localStorage?.todo) {
todoData = { ...todoData, ...JSON.parse(localStorage.todo) };
}
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn(err); console.warn(err);
} }
update();
} }
function save() { function save() {
clearTimeout(storeTimeout); clearTimeout(saveTimeout);
storeTimeout = setTimeout(() => { saveTimeout = setTimeout(() => {
try { try {
localStorage.todo = JSON.stringify(todoData); localStorage.todo = JSON.stringify(todoData);
} catch (err) { } catch (err) {

View File

@@ -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 * @param {Date} date
* @returns {string} * @returns {string}

8
public/scripts/uuid.js Normal file
View 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);
});
}

View File

@@ -0,0 +1,12 @@
import { expect, test } from '@playwright/test';
import '../coverage.mjs';
test('Add custom to-do list', async ({ page }) => {
await page.goto('http://localhost:8080');
const add = page.locator('.todo-frame.-custom .add');
await add.click();
const title = page.locator('.todo-custom-list > .header > .title');
await expect(title).toHaveText('...');
});

View File

@@ -0,0 +1,20 @@
import { expect, test } from '@playwright/test';
import '../coverage.mjs';
test('Delete custom to-do list', async ({ page }) => {
await page.goto('http://localhost:8080');
const add = page.locator('.todo-frame.-custom .add');
await add.click();
const title = page.locator('.todo-custom-list > .header > .title');
await title.click();
const button = page.locator('.todo-custom-list > .header > .form > .delete');
await expect(button).toBeVisible();
await button.click();
await expect(title).not.toBeAttached();
});

View File

@@ -1,16 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
import '../coverage.mjs'; import '../coverage.mjs';
test('Add custom to-do list', async ({ page }) => {
await page.goto('http://localhost:8080');
const add = page.locator('.todo-frame.-custom .add');
await add.click();
const title = page.locator('.todo-custom-list > .header > .title');
await expect(title).toHaveText('...');
});
test('Edit custom to-do list title (Enter)', async ({ page }) => { test('Edit custom to-do list title (Enter)', async ({ page }) => {
await page.goto('http://localhost:8080'); await page.goto('http://localhost:8080');

View File

@@ -0,0 +1,143 @@
import { expect, test } from '@playwright/test';
import TodoLogicModule from '../../public/scripts/TodoLogic.js';
import '../coverage.mjs';
const { TodoLogic } = TodoLogicModule;
test('TodoLogic.initTodoData', () => {
const data = TodoLogic.initTodoData(new Date(0));
expect(data).toEqual({
at: '1970-01-01',
customAt: 0,
customLists: [],
items: [],
});
});
test('TodoLogic.addTodoItem', () => {
let data = TodoLogic.initTodoData(new Date(0));
data = TodoLogic.addTodoItem(data, { label: 'foo', listId: '1970-01-01' });
expect(data).toEqual({
at: '1970-01-01',
customAt: 0,
customLists: [],
items: [
{
id: expect.stringMatching(/./),
listId: '1970-01-01',
label: 'foo',
index: 0,
done: false,
},
],
});
data = TodoLogic.addTodoItem(data, { label: 'bar', listId: '1970-01-01' });
expect(data.items).toEqual([
{
id: expect.stringMatching(/./),
listId: '1970-01-01',
label: 'foo',
index: 0,
done: false,
},
{
id: expect.stringMatching(/./),
listId: '1970-01-01',
label: 'bar',
index: 1,
done: false,
},
]);
data = TodoLogic.addTodoItem(data, { label: 'baz', listId: '1970-01-02' });
expect(data.items).toEqual([
{
id: expect.stringMatching(/./),
listId: '1970-01-01',
label: 'foo',
index: 0,
done: false,
},
{
id: expect.stringMatching(/./),
listId: '1970-01-01',
label: 'bar',
index: 1,
done: false,
},
{
id: expect.stringMatching(/./),
listId: '1970-01-02',
label: 'baz',
index: 0,
done: false,
},
]);
});
test('TodoLogic.moveTodoItem', () => {
let data = TodoLogic.initTodoData(new Date(0));
data = {
...data,
items: [
{
id: 'a',
listId: '1970-01-01',
label: 'foo',
index: 0,
done: false,
},
{
id: 'b',
listId: '1970-01-01',
label: 'bar',
index: 1,
done: false,
},
{
id: 'c',
listId: '1970-01-02',
label: 'baz',
index: 0,
done: false,
},
],
};
data = TodoLogic.moveTodoItem(data, {
id: 'a',
listId: '1970-01-01',
index: 1,
});
expect(data.items).toEqual([
{
id: 'c',
listId: '1970-01-02',
label: 'baz',
index: 0,
done: false,
},
{
id: 'b',
listId: '1970-01-01',
label: 'bar',
index: 0,
done: false,
},
{
id: 'a',
listId: '1970-01-01',
label: 'foo',
index: 1,
done: false,
},
]);
});