// 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`
`;
}
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` { e.preventDefault(); setDropOver(true); }} onDragLeave=${() => setDropOver(false)} onDrop=${onDrop}>
${g.title}
${items.length} ${items.length === 1 ? board.board.item_label : 'items'}
${cols.map((c) => html`
${c.title}
<${Summary} board=${board} col=${c} items=${items} />
`)}
`;
}
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} />
`;
}
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`
`;
})}
`;
}