Files
waters-boards/server/public/js/table.js
T
christianandClaude Opus 5.5 fcf3805626
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
Waters Boards: self-hosted work management to replace monday.com
Boards with groups, items, subitems and 18 column types; table, kanban,
calendar, timeline, chart and files views; updates with @mentions,
replies and attachments; inbox, My work, search, automations; Google
sign-in restricted to company domains; live updates over SSE; a
monday.com API importer; phone layout and installable PWA; Docker +
Gitea Actions deploy to the NAS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:34:47 -04:00

313 lines
22 KiB
JavaScript

// Main table view: colored groups, inline-editable cells, drag & drop, column menus,
// subitems, footers, multi-select bulk actions. Becomes a card list on phones.
import { html, useState, useRef, useEffect } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, navigate, boardPath, mutateBoard, loadBoard } from './store.js';
import { Icon, InlineEdit, menu, openPopover, closePopover, ColorPicker, promptDialog, AvatarStack, textOn, fmtDate, fmtRange, openModal } from './ui.js';
import { Cell, Summary, COLUMN_TYPES, colWidth, labelOf } from './cells.js';
import * as A from './actions.js';
import { ColumnSettingsModal } from './boardModals.js';
const NAME_W = 340;
const selection = new Set();
let dragItem = null;
let dragGroup = null;
function setSel(fn) { fn(selection); update(); }
export function TableView({ board, view, items, subs, matches, hidden, filter, mobile }) {
const boardId = board.board.id;
const cols = board.columns.filter((c) => !hidden.includes(c.id));
const groups = [...board.groups].sort((a, b) => a.position - b.position);
const byGroup = {};
for (const it of items) (byGroup[it.group_id] ||= []).push(it);
const [expanded, setExpanded] = useState({});
const filtering = !!matches || !!filter.sort;
useEffect(() => () => selection.clear(), [boardId]);
if (mobile) return html`<${MobileList} board=${board} groups=${groups} byGroup=${byGroup} subs=${subs} view=${view} />`;
const totalW = NAME_W + cols.reduce((a, c) => a + colWidth(c), 0) + 60;
return html`<div class="table-scroll">
<div class="table" style=${{ minWidth: totalW }}>
${groups.map((g) => {
const gItems = byGroup[g.id] || [];
if (filtering && !gItems.length) return null;
return html`<${Group} key=${g.id} board=${board} group=${g} items=${gItems} cols=${cols} subs=${subs} view=${view}
expanded=${expanded} setExpanded=${setExpanded} totalW=${totalW} filtering=${filtering} />`;
})}
<button class="add-group-btn" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add new group</button>
</div>
${selection.size ? html`<${BulkBar} board=${board} />` : null}
</div>`;
}
function Group({ board, group: g, items, cols, subs, view, expanded, setExpanded, totalW, filtering }) {
const boardId = board.board.id;
const [dropOver, setDropOver] = useState(false);
const allSel = items.length && items.every((i) => selection.has(i.id));
const groupMenu = (e) => menu(e, [
{ label: g.collapsed ? 'Expand group' : 'Collapse group', icon: g.collapsed ? 'chevDown' : 'chevRight', onClick: () => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed }) },
{ label: 'Collapse all groups', icon: 'chevUp', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: true })) },
{ label: 'Expand all groups', icon: 'chevDown', onClick: () => board.groups.forEach((x) => A.patchGroup(boardId, x.id, { collapsed: false })) },
{ divider: true },
{ label: 'Add group', icon: 'plus', onClick: () => A.addGroup(boardId, { top: false }) },
{ label: 'Rename group', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename group', value: g.title }); if (t) A.patchGroup(boardId, g.id, { title: t }); } },
{ label: 'Change group color', icon: 'gear', keepOpen: true, onClick: (ev) => openPopover(ev.currentTarget, ColorPicker, { value: g.color, onPick: (c) => A.patchGroup(boardId, g.id, { color: c }) }) },
{ label: 'Duplicate group', icon: 'copy', onClick: async () => { await api.post(`/api/groups/${g.id}/duplicate`); loadBoard(boardId); } },
{ label: 'Move group up', icon: 'chevUp', onClick: () => moveGroup(board, g, -1) },
{ label: 'Move group down', icon: 'chevDown', onClick: () => moveGroup(board, g, 1) },
{ label: 'Select all items', icon: 'check', onClick: () => setSel((s) => items.forEach((i) => s.add(i.id))) },
{ divider: true },
{ label: 'Delete group', icon: 'trash', danger: true, onClick: () => A.deleteGroup(boardId, g.id) },
]);
const onGroupDragStart = (e) => { dragGroup = g.id; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', g.id); };
const onDrop = (e) => {
e.preventDefault(); setDropOver(false);
if (dragGroup && dragGroup !== g.id) {
const sorted = [...board.groups].sort((a, b) => a.position - b.position).filter((x) => x.id !== dragGroup);
const idx = sorted.findIndex((x) => x.id === g.id);
A.patchGroup(boardId, dragGroup, { position: A.between(sorted[idx - 1]?.position, g.position) });
dragGroup = null;
} else if (dragItem && !dragItem.parent_id && dragItem.group_id !== g.id) {
const last = items[items.length - 1];
A.moveItem(boardId, dragItem.id, { group_id: g.id, position: (last?.position ?? 0) + 1 });
dragItem = null;
}
};
if (g.collapsed) {
return html`<div class="group collapsed" style=${{ '--g': g.color }} onDragOver=${(e) => { e.preventDefault(); setDropOver(true); }} onDragLeave=${() => setDropOver(false)} onDrop=${onDrop}>
<div class="group-collapsed-row ${dropOver ? 'drop' : ''}" style=${{ width: totalW }}>
<div class="gc-name" style=${{ width: NAME_W }} draggable="true" onDragStart=${onGroupDragStart}>
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: false })}><${Icon} name="chevRight" /></button>
<div><div class="group-title" style=${{ color: g.color }}>${g.title}</div><div class="muted small">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</div></div>
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
</div>
${cols.map((c) => html`<div key=${c.id} class="gc-cell" style=${{ width: colWidth(c) }}><div class="gc-label muted small">${c.title}</div><${Summary} board=${board} col=${c} items=${items} /></div>`)}
</div></div>`;
}
return html`<section class="group" style=${{ '--g': g.color }}>
<div class="group-head">
<button class="icon-btn group-menu-btn" onClick=${groupMenu}><${Icon} name="dots" /></button>
<button class="icon-btn" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: true })} style=${{ color: g.color }}><${Icon} name="chevDown" /></button>
<span class="group-title" style=${{ color: g.color }} draggable="true" onDragStart=${onGroupDragStart}>
<${InlineEdit} value=${g.title} onSave=${(t) => t && A.patchGroup(boardId, g.id, { title: t })} /></span>
<span class="muted small group-count">${items.length} ${items.length === 1 ? board.board.item_label : 'items'}</span>
</div>
<div class="group-table ${dropOver ? 'drop' : ''}" onDragOver=${(e) => { if (dragGroup || (dragItem && !dragItem.parent_id)) { e.preventDefault(); setDropOver(true); } }}
onDragLeave=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDropOver(false); }} onDrop=${onDrop}>
<div class="trow thead">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
<span class="row-bar first" />
<label class="row-check"><input type="checkbox" checked=${allSel} onChange=${(e) => setSel((s) => items.forEach((i) => e.target.checked ? s.add(i.id) : s.delete(i.id)))} /></label>
<span class="th-title">${board.board.item_label}</span>
</div>
${cols.map((c) => html`<${ColumnHeader} key=${c.id} board=${board} col=${c} />`)}
<div class="tcell add-col"><button class="icon-btn" onClick=${(e) => addColumnMenu(e, boardId)} title="Add column"><${Icon} name="plus" /></button></div>
</div>
${items.map((it) => html`<${Row} key=${it.id} board=${board} item=${it} cols=${cols} view=${view} group=${g}
subs=${subs[it.id] || []} expanded=${!!expanded[it.id]} toggle=${() => setExpanded({ ...expanded, [it.id]: !expanded[it.id] })} siblings=${items} />`)}
<${AddRow} board=${board} group=${g} cols=${cols} />
<div class="trow tfoot">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}><span class="row-bar last" /></div>
${cols.map((c) => html`<div key=${c.id} class="tcell" style=${{ width: colWidth(c) }}><${Summary} board=${board} col=${c} items=${items} /></div>`)}
</div>
</div>
</section>`;
}
function moveGroup(board, g, dir) {
const sorted = [...board.groups].sort((a, b) => a.position - b.position);
const i = sorted.findIndex((x) => x.id === g.id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return;
const other = sorted[j];
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, other.position) : A.between(other.position, sorted[j + 1]?.position);
A.patchGroup(board.board.id, g.id, { position: pos });
}
export function addColumnMenu(e, boardId, after) {
menu(e, Object.entries(COLUMN_TYPES).map(([type, t]) => ({
label: t.label, icon: t.icon, onClick: () => A.addColumn(boardId, type, { after }),
})), { class: 'col-type-menu' });
}
function ColumnHeader({ board, col }) {
const boardId = board.board.id;
const w = colWidth(col);
const startResize = (e) => {
e.preventDefault(); e.stopPropagation();
const x0 = e.clientX;
const move = (ev) => mutateBoard(boardId, () => { col.width = Math.max(70, Math.min(600, w + ev.clientX - x0)); });
const up = () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', up); A.patchColumn(boardId, col.id, { width: col.width }); };
window.addEventListener('pointermove', move); window.addEventListener('pointerup', up);
};
const colMenu = (e) => menu(e, [
{ label: 'Settings', icon: 'gear', onClick: () => openModal(ColumnSettingsModal, { boardId, colId: col.id }) },
{ label: 'Rename', icon: 'edit', onClick: async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); } },
{ label: 'Sort ascending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'asc' } }); update(); } },
{ label: 'Sort descending', icon: 'sort', onClick: async () => { const { getFilter } = await import('./board.js'); Object.assign(getFilter(boardId), { sort: { colId: col.id, dir: 'desc' } }); update(); } },
{ label: 'Add column to the right', icon: 'plus', keepOpen: true, onClick: (ev) => addColumnMenu(ev, boardId, col.id) },
{ label: 'Duplicate column', icon: 'copy', onClick: async () => {
const r = await api.post(`/api/boards/${boardId}/columns`, { type: col.type, title: `${col.title} copy`, settings: col.settings, after: col.id });
for (const it of board.items) if (it.vals[col.id] != null && col.type !== 'files') await api.patch(`/api/items/${it.id}`, { vals: { [r.id]: it.vals[col.id] } });
loadBoard(boardId);
} },
{ label: 'Move left', icon: 'chevLeft', onClick: () => moveCol(board, col, -1) },
{ label: 'Move right', icon: 'chevRight', onClick: () => moveCol(board, col, 1) },
{ divider: true },
{ label: 'Delete column', icon: 'trash', danger: true, onClick: () => A.deleteColumn(boardId, col.id) },
]);
return html`<div class="tcell th" style=${{ width: w }} onDblClick=${async () => { const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); }}>
<span class="th-title" title=${col.title}>${col.title}</span>
<button class="icon-btn th-menu" onClick=${colMenu}><${Icon} name="dots" size=${14} /></button>
<span class="col-resize" onPointerDown=${startResize} />
</div>`;
}
function moveCol(board, col, dir) {
const sorted = [...board.columns].sort((a, b) => a.position - b.position);
const i = sorted.findIndex((c) => c.id === col.id);
const j = i + dir;
if (j < 0 || j >= sorted.length) return;
const pos = dir < 0 ? A.between(sorted[j - 1]?.position, sorted[j].position) : A.between(sorted[j].position, sorted[j + 1]?.position);
mutateBoard(board.board.id, (b) => { col.position = pos; b.columns.sort((a, z) => a.position - z.position); });
A.patchColumn(board.board.id, col.id, { position: pos });
}
function Row({ board, item, cols, view, group, subs, expanded, toggle, siblings, isSub }) {
const boardId = board.board.id;
const [dropPos, setDropPos] = useState(null);
const nUpdates = board.updateCounts?.[item.id] || 0;
const open = () => navigate(boardPath(boardId, { viewId: view?.id, itemId: item.id }));
const sel = selection.has(item.id);
const active = state.route.itemId === item.id;
const rowMenu = (e) => menu(e, [
{ label: 'Open', icon: 'expand', onClick: open },
!isSub && { label: 'Add subitem', icon: 'subitem', onClick: async () => { if (!expanded) toggle(); await A.createItem(boardId, { parent_id: item.id, name: 'Subitem' }); } },
{ label: 'Duplicate', icon: 'copy', onClick: () => A.duplicateItems(boardId, [item.id]) },
!isSub && { label: 'Move to group', icon: 'move', keepOpen: true, onClick: (ev) => menu(ev, board.groups.map((g) => ({ label: g.title, onClick: () => A.moveItemsToGroup(boardId, [item.id], g.id) }))) },
!isSub && { label: 'Move to board', icon: 'board', keepOpen: true, onClick: (ev) => menu(ev, state.boards.filter((b) => b.id !== boardId).map((b) => ({ label: b.name, onClick: () => A.moveItemToBoard(boardId, item.id, b.id) }))) },
{ label: 'Copy link', icon: 'link', onClick: () => navigator.clipboard?.writeText(location.origin + boardPath(boardId, { itemId: item.id })) },
{ divider: true },
{ label: 'Archive', icon: 'archive', onClick: () => A.archiveItems(boardId, [item.id]) },
{ label: 'Delete', icon: 'trash', danger: true, onClick: () => A.deleteItems(boardId, [item.id]) },
]);
const onDrop = (e) => {
if (!dragItem || dragItem.id === item.id) return;
if (!!dragItem.parent_id !== !!item.parent_id || (isSub && dragItem.parent_id !== item.parent_id)) return;
e.preventDefault(); e.stopPropagation();
const idx = siblings.findIndex((s) => s.id === item.id);
const pos = dropPos === 'above' ? A.between(siblings[idx - 1]?.position, item.position) : A.between(item.position, siblings[idx + 1]?.position);
A.moveItem(boardId, dragItem.id, isSub ? { position: pos } : { group_id: group.id, position: pos });
dragItem = null; setDropPos(null);
};
return html`
<div class="trow item-row ${sel ? 'selected' : ''} ${active ? 'active' : ''} ${isSub ? 'sub' : ''} ${dropPos ? 'drop-' + dropPos : ''}"
onContextMenu=${rowMenu}
onDragOver=${(e) => { if (dragItem && dragItem.id !== item.id) { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); setDropPos(e.clientY < r.top + r.height / 2 ? 'above' : 'below'); } }}
onDragLeave=${() => setDropPos(null)} onDrop=${onDrop}>
<div class="tcell name-cell sticky" style=${{ width: NAME_W }} draggable="true"
onDragStart=${(e) => { dragItem = item; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', item.id); }}
onDragEnd=${() => { dragItem = null; }}>
<button class="icon-btn row-menu" onClick=${rowMenu}><${Icon} name="dots" size=${14} /></button>
<span class="row-bar" />
<label class="row-check" onClick=${(e) => e.stopPropagation()}><input type="checkbox" checked=${sel} onChange=${(e) => setSel((s) => e.target.checked ? s.add(item.id) : s.delete(item.id))} /></label>
${isSub ? html`<span class="sub-indent" />` : html`<button class="icon-btn sub-toggle ${subs.length ? 'has' : ''} ${expanded ? 'open' : ''}" onClick=${toggle} title=${subs.length ? `${subs.length} subitems` : 'Add subitem'}>
<${Icon} name=${expanded ? 'chevDown' : 'chevRight'} size=${14} />${subs.length ? html`<span class="sub-count">${subs.length}</span>` : null}</button>`}
<span class="item-name"><${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} /></span>
<button class="icon-btn open-item" onClick=${open} title="Open"><${Icon} name="expand" size=${13} /></button>
<button class="icon-btn updates-btn ${nUpdates ? 'has' : ''}" onClick=${open} title="Updates">
<${Icon} name=${nUpdates ? 'message' : 'messagePlus'} size=${18} />${nUpdates ? html`<span class="badge">${nUpdates}</span>` : null}</button>
</div>
${cols.map((c) => html`<div key=${c.id} class="tcell tc-${c.type}" style=${{ width: colWidth(c) }}><${Cell} board=${board} col=${c} item=${item} /></div>`)}
<div class="tcell add-col" />
</div>
${expanded && !isSub ? html`<div class="subitems">
${subs.sort((a, b) => a.position - b.position).map((s) => html`<${Row} key=${s.id} board=${board} item=${s} cols=${cols} view=${view} group=${group} subs=${[]} isSub=${true} siblings=${subs} />`)}
<${AddRow} board=${board} group=${group} cols=${cols} parent=${item} />
</div>` : null}`;
}
function AddRow({ board, group, cols, parent }) {
const [v, setV] = useState('');
const ref = useRef();
const add = async () => {
const name = v.trim();
if (!name) return;
setV('');
await A.createItem(board.board.id, parent ? { parent_id: parent.id, name } : { group_id: group.id, name });
ref.current?.focus();
};
return html`<div class="trow add-row ${parent ? 'sub' : ''}">
<div class="tcell name-cell sticky" style=${{ width: NAME_W }}>
<span class="row-bar faded" />
<span class="row-check-spacer" />
${parent ? html`<span class="sub-indent" />` : null}
<input ref=${ref} class="add-input" placeholder=${parent ? '+ Add subitem' : `+ Add ${board.board.item_label.toLowerCase()}`} value=${v}
onInput=${(e) => setV(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') add(); if (e.key === 'Escape') { setV(''); e.target.blur(); } }} onBlur=${add} />
</div>
<div class="tcell filler" style=${{ width: cols.reduce((a, c) => a + colWidth(c), 0) }} />
</div>`;
}
function BulkBar({ board }) {
const boardId = board.board.id;
const ids = [...selection].filter((id) => board.items.some((i) => i.id === id));
if (!ids.length) { selection.clear(); return null; }
const done = () => setSel((s) => s.clear());
const statusCols = board.columns.filter((c) => c.type === 'status');
return html`<div class="bulk-bar">
<span class="bulk-count">${ids.length}</span><span class="bulk-label">${ids.length === 1 ? 'Item' : 'Items'} selected</span>
<button class="bulk-act" onClick=${() => { A.duplicateItems(boardId, ids); done(); }}><${Icon} name="copy" /><span>Duplicate</span></button>
${statusCols.length ? html`<button class="bulk-act" onClick=${(e) => menu(e, statusCols.flatMap((c) => [{ label: c.title, disabled: true },
...(c.settings.labels || []).map((l) => ({ label: l.name, onClick: async () => { await api.post('/api/items/bulk', { ids, action: 'set', vals: { [c.id]: l.id } }); loadBoard(boardId); } }))]))}>
<${Icon} name="check" /><span>Set status</span></button>` : null}
<button class="bulk-act" onClick=${(e) => menu(e, board.groups.map((g) => ({ label: g.title, onClick: () => { A.moveItemsToGroup(boardId, ids, g.id); done(); } })))}><${Icon} name="move" /><span>Move to</span></button>
<button class="bulk-act" onClick=${() => { A.archiveItems(boardId, ids); done(); }}><${Icon} name="archive" /><span>Archive</span></button>
<button class="bulk-act danger" onClick=${async () => { if (await A.deleteItems(boardId, ids)) done(); }}><${Icon} name="trash" /><span>Delete</span></button>
<button class="icon-btn bulk-close" onClick=${done}><${Icon} name="x" /></button>
</div>`;
}
// ─── Phone layout: groups of cards ───────────────────────
function MobileList({ board, groups, byGroup, subs, view }) {
const boardId = board.board.id;
const status = board.columns.find((c) => c.type === 'status');
const people = board.columns.find((c) => c.type === 'people');
const date = board.columns.find((c) => c.type === 'date' || c.type === 'timeline');
const [adding, setAdding] = useState(null);
return html`<div class="mobile-list">
${groups.map((g) => {
const items = byGroup[g.id] || [];
return html`<section key=${g.id} class="m-group" style=${{ '--g': g.color }}>
<button class="m-group-head" onClick=${() => A.patchGroup(boardId, g.id, { collapsed: !g.collapsed })}>
<${Icon} name=${g.collapsed ? 'chevRight' : 'chevDown'} /><span style=${{ color: g.color }}>${g.title}</span><span class="muted small">${items.length}</span>
</button>
${!g.collapsed ? html`
${items.map((it) => {
const l = status && labelOf(status, it.vals[status.id]);
const dv = date && it.vals[date.id];
const n = (subs[it.id] || []).length;
return html`<button key=${it.id} class="m-card" onClick=${() => navigate(boardPath(boardId, { viewId: view?.id, itemId: it.id }))}>
<div class="m-card-top"><span class="m-name">${it.name}</span>${people ? html`<${AvatarStack} ids=${it.vals[people.id] || []} size=${24} />` : null}</div>
<div class="m-card-meta">
${status ? html`<span class="m-status" style=${{ background: l?.color || 'var(--status-empty)', color: l ? textOn(l.color) : 'var(--muted)' }}>${l?.name || status.title}</span>` : null}
${dv ? html`<span class="m-date"><${Icon} name="calendar" size=${13} /> ${date.type === 'date' ? fmtDate(dv) : fmtRange(dv)}</span>` : null}
${n ? html`<span class="muted small"><${Icon} name="subitem" size=${13} /> ${n}</span>` : null}
${board.updateCounts?.[it.id] ? html`<span class="muted small"><${Icon} name="message" size=${13} /> ${board.updateCounts[it.id]}</span>` : null}
</div></button>`;
})}
${adding === g.id ? html`<input class="input m-add-input" placeholder="Name" autofocus
onKeyDown=${async (e) => { if (e.key === 'Enter' && e.target.value.trim()) { const name = e.target.value.trim(); e.target.value = ''; await A.createItem(boardId, { group_id: g.id, name }); } if (e.key === 'Escape') setAdding(null); }}
onBlur=${() => setAdding(null)} />`
: html`<button class="m-add" onClick=${() => setAdding(g.id)}><${Icon} name="plus" /> Add ${board.board.item_label.toLowerCase()}</button>`}
` : null}
</section>`;
})}
<button class="btn ghost m-add-group" onClick=${() => A.addGroup(boardId, { top: false })}><${Icon} name="plus" /> Add group</button>
</div>`;
}