// 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`
${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} />`; })}
${selection.size ? html`<${BulkBar} board=${board} />` : null}
`; } 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``; } return html`
<${InlineEdit} value=${g.title} onSave=${(t) => t && A.patchGroup(boardId, g.id, { title: t })} /> ${items.length} ${items.length === 1 ? board.board.item_label : 'items'}
{ if (dragGroup || (dragItem && !dragItem.parent_id)) { e.preventDefault(); setDropOver(true); } }} onDragLeave=${(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDropOver(false); }} onDrop=${onDrop}>
${board.board.item_label}
${cols.map((c) => html`<${ColumnHeader} key=${c.id} board=${board} col=${c} />`)}
${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} />
${cols.map((c) => html`
<${Summary} board=${board} col=${c} items=${items} />
`)}
`; } 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`
{ const t = await promptDialog({ title: 'Rename column', value: col.title }); if (t) A.patchColumn(boardId, col.id, { title: t }); }}> ${col.title}
`; } 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`
{ 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}>
{ dragItem = item; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', item.id); }} onDragEnd=${() => { dragItem = null; }}> ${isSub ? html`` : html``} <${InlineEdit} value=${item.name} onSave=${(n) => n && A.renameItem(boardId, item.id, n)} />
${cols.map((c) => html`
<${Cell} board=${board} col=${c} item=${item} />
`)}
${expanded && !isSub ? html`
${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} />
` : 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`
${parent ? html`` : null} setV(e.target.value)} onKeyDown=${(e) => { if (e.key === 'Enter') add(); if (e.key === 'Escape') { setV(''); e.target.blur(); } }} onBlur=${add} />
a + colWidth(c), 0) }} />
`; } 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`
${ids.length}${ids.length === 1 ? 'Item' : 'Items'} selected ${statusCols.length ? html`` : null}
`; } // ─── 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`
${groups.map((g) => { const items = byGroup[g.id] || []; return html`
${!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``; })} ${adding === g.id ? html` { 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``} ` : null}
`; })}
`; }