// Board page: header, view tabs, toolbar (search / person / filter / sort / hide) and the active view.
import { html, useState, useEffect, useMemo } from '../vendor/preact-htm.mjs';
import { api } from './api.js';
import { state, update, useStore, loadBoard, loadTree, navigate, boardPath, mutateBoard, serverCall, toast } from './store.js';
import { Icon, Avatar, AvatarStack, InlineEdit, menu, openPopover, openModal, closeModal, ModalHeader, Spinner, Empty, promptDialog, confirmDialog, useMobile } from './ui.js';
import { COLUMN_TYPES, cellText } from './cells.js';
import * as A from './actions.js';
import { TableView } from './table.js';
import { KanbanView } from './kanban.js';
import { CalendarView } from './calendar.js';
import { TimelineView } from './timeline.js';
import { ChartView, FilesView } from './chart.js';
import { ItemPanel } from './item.js';
import { AutomationsModal } from './automations.js';
import { ActivityModal, ImportCsvModal } from './boardModals.js';
export const VIEW_TYPES = {
table: { label: 'Table', icon: 'table', desc: 'Groups, columns and items' },
kanban: { label: 'Kanban', icon: 'kanban', desc: 'Cards by status' },
calendar: { label: 'Calendar', icon: 'calendar', desc: 'Items by date' },
timeline: { label: 'Timeline', icon: 'timeline', desc: 'Gantt-style schedule' },
chart: { label: 'Chart', icon: 'chart', desc: 'Counts by status, person…' },
files: { label: 'Files', icon: 'paperclip', desc: 'Every file on the board' },
};
// Ephemeral per-board filter state (not shared with others).
const filters = {};
export function getFilter(boardId) { return (filters[boardId] ||= { q: '', people: [], rules: [], sort: null }); }
function setFilter(boardId, patch) { Object.assign(getFilter(boardId), patch); update(); }
const activeFilterCount = (f) => (f.q ? 1 : 0) + (f.people.length ? 1 : 0) + f.rules.length;
// Returns top-level items passing the filter. A parent stays visible if any subitem matches.
export function filterItems(board, f) {
const subs = {};
for (const it of board.items) if (it.parent_id) (subs[it.parent_id] ||= []).push(it);
const q = f.q.trim().toLowerCase();
const peopleCols = board.columns.filter((c) => c.type === 'people');
const matches = (it) => {
if (q) {
const hay = [it.name, ...board.columns.map((c) => cellText(board, c, it))].join(' ').toLowerCase();
if (!hay.includes(q)) return false;
}
if (f.people.length && !peopleCols.some((c) => (it.vals[c.id] || []).some((u) => f.people.includes(u)))) return false;
for (const r of f.rules) {
const col = board.columns.find((c) => c.id === r.colId);
if (!col) continue;
const v = it.vals[col.id];
const empty = v == null || v === '' || (Array.isArray(v) && !v.length);
if (r.op === 'empty' && !empty) return false;
if (r.op === 'not_empty' && empty) return false;
if (r.op === 'is' || r.op === 'is_not') {
const vs = Array.isArray(v) ? v : [v ?? '__empty'];
const hit = r.values.some((x) => vs.includes(x) || (x === '__empty' && empty));
if (r.op === 'is' && !hit) return false;
if (r.op === 'is_not' && hit) return false;
}
if (r.op === 'contains' && !cellText(board, col, it).toLowerCase().includes(String(r.values[0] || '').toLowerCase())) return false;
if (r.op === 'before' || r.op === 'after') {
const d = col.type === 'timeline' ? v?.from : v && String(v).slice(0, 10);
if (!d) return false;
if (r.op === 'before' && !(d < r.values[0])) return false;
if (r.op === 'after' && !(d > r.values[0])) return false;
}
}
return true;
};
const anyFilter = activeFilterCount(f) > 0;
let out = board.items.filter((it) => !it.parent_id && (!anyFilter || matches(it) || (subs[it.id] || []).some(matches)));
if (f.sort) {
const col = f.sort.colId === '__name' ? null : board.columns.find((c) => c.id === f.sort.colId);
const key = (it) => {
if (!col) return it.name.toLowerCase();
const v = it.vals[col.id];
if (col.type === 'number' || col.type === 'rating') return v ?? -Infinity;
if (col.type === 'date') return v || '';
if (col.type === 'timeline') return v?.from || '';
if (col.type === 'status') { const i = (col.settings.labels || []).findIndex((l) => l.id === v); return i < 0 ? 999 : i; }
if (col.type === 'created') return it.created_at;
if (col.type === 'updated') return it.updated_at;
return cellText(board, col, it).toLowerCase();
};
const dir = f.sort.dir === 'desc' ? -1 : 1;
out = [...out].sort((a, b) => { const x = key(a); const y = key(b); return (x > y ? 1 : x < y ? -1 : 0) * dir; });
} else out = [...out].sort((a, b) => a.position - b.position);
return { items: out, subs, matches: anyFilter ? matches : null };
}
export function BoardPage({ boardId, viewId, itemId }) {
const s = useStore();
const mobile = useMobile();
const data = s.boardData[boardId];
useEffect(() => { loadBoard(boardId).catch(() => {}); }, [boardId]);
if (!data) return html`
<${Spinner} />
`;
if (data.missing) return html`<${Empty} title="This board doesn't exist anymore"> navigate('/')}>Go home />`;
const view = data.views.find((v) => v.id === viewId) || data.views[0];
const f = getFilter(boardId);
const filtered = filterItems(data, f);
const hidden = view?.settings?.hidden || [];
const ViewComp = { table: TableView, kanban: KanbanView, calendar: CalendarView, timeline: TimelineView, chart: ChartView, files: FilesView }[view?.type] || TableView;
return html`
<${BoardHeader} data=${data} view=${view} mobile=${mobile} />
<${Toolbar} data=${data} view=${view} f=${f} mobile=${mobile} />
<${ViewComp} key=${view?.id} board=${data} view=${view} items=${filtered.items} subs=${filtered.subs} matches=${filtered.matches} hidden=${hidden} filter=${f} mobile=${mobile} />
${itemId ? html`<${ItemPanel} key=${itemId} board=${data} itemId=${itemId} viewId=${view?.id} />` : null}
`;
}
function BoardHeader({ data, view, mobile }) {
const b = data.board;
const people = useMemo(() => {
const ids = new Set();
for (const c of data.columns) if (c.type === 'people') for (const it of data.items) for (const u of it.vals[c.id] || []) ids.add(u);
return [...ids];
}, [data]);
const openMenu = (e) => menu(e, [
{ label: 'Rename board', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename board', value: b.name }); if (n) A.renameBoard(b.id, n); } },
{ label: 'Edit description', icon: 'info', onClick: async () => {
const n = await promptDialog({ title: 'Board description', value: b.description || '', confirm: 'Save' });
if (n != null) { mutateBoard(b.id, (d) => { d.board.description = n; }); api.patch(`/api/boards/${b.id}`, { description: n }); }
} },
{ label: 'Rename items…', icon: 'edit', hint: b.item_label, onClick: async () => {
const n = await promptDialog({ title: 'What do you call items on this board?', value: b.item_label, placeholder: 'e.g. Task, Request, Equipment' });
if (n) { mutateBoard(b.id, (d) => { d.board.item_label = n; }); api.patch(`/api/boards/${b.id}`, { item_label: n }); }
} },
{ divider: true },
{ label: 'Move to…', icon: 'move', onClick: () => openModal(MoveBoardModal, { board: b }) },
{ label: 'Duplicate board', icon: 'copy', onClick: () => A.duplicateBoard(b.id) },
{ label: 'Automations', icon: 'bolt', onClick: () => openModal(AutomationsModal, { boardId: b.id }, { class: 'wide' }) },
{ label: 'Activity log', icon: 'activity', onClick: () => openModal(ActivityModal, { boardId: b.id }, { class: 'wide' }) },
{ divider: true },
{ label: 'Export to CSV / Excel', icon: 'download', onClick: () => { location.href = `/api/boards/${b.id}/export.csv`; } },
{ label: 'Import from CSV', icon: 'upload', onClick: () => openModal(ImportCsvModal, { boardId: b.id }) },
{ divider: true },
{ label: 'Delete board', icon: 'trash', danger: true, onClick: () => A.deleteBoard(b.id) },
], { align: 'right' });
return html``;
}
function ViewTabs({ data, view }) {
const b = data.board;
const addView = (e) => menu(e, Object.entries(VIEW_TYPES).map(([type, t]) => ({
label: t.label, icon: t.icon, hint: t.desc,
onClick: async () => {
const r = await serverCall(b.id, api.post(`/api/boards/${b.id}/views`, { type, name: t.label }));
await loadBoard(b.id);
navigate(boardPath(b.id, { viewId: r.id }));
},
})));
const viewMenu = (e, v) => menu(e, [
{ label: 'Rename', icon: 'edit', onClick: async () => { const n = await promptDialog({ title: 'Rename view', value: v.name }); if (n) { mutateBoard(b.id, () => { v.name = n; }); api.patch(`/api/views/${v.id}`, { name: n }); } } },
{ label: 'Delete', icon: 'trash', danger: true, disabled: data.views.length <= 1, onClick: async () => {
if (!(await confirmDialog({ title: `Delete view "${v.name}"?`, body: 'Only the view is removed — items are untouched.' }))) return;
await serverCall(b.id, api.del(`/api/views/${v.id}`)); await loadBoard(b.id); navigate(boardPath(b.id));
} },
]);
return html`
${data.views.map((v) => html` navigate(boardPath(b.id, { viewId: v.id }))} onContextMenu=${(e) => viewMenu(e, v)}>
<${Icon} name=${VIEW_TYPES[v.type]?.icon || 'table'} size=${14} /> ${v.name}
${v.id === view?.id ? html` viewMenu(e, v)}><${Icon} name="dots" size=${14} /> ` : null}
`)}
<${Icon} name="plus" size=${14} />
`;
}
function Toolbar({ data, view, f, mobile }) {
const b = data.board;
const [searchOpen, setSearchOpen] = useState(!!f.q);
const firstGroup = [...data.groups].sort((a, z) => a.position - z.position)[0];
const newItem = async () => {
const it = await A.createItem(b.id, { group_id: firstGroup?.id, name: `New ${b.item_label.toLowerCase()}`, top: true });
if (it) navigate(boardPath(b.id, { viewId: view?.id, itemId: it.id }));
};
const hidden = view?.settings?.hidden || [];
const nPeople = f.people.length;
return html``;
}
function PersonFilter({ boardId }) {
const s = useStore();
const f = getFilter(boardId);
const data = s.boardData[boardId];
const ids = new Set();
for (const c of data.columns) if (c.type === 'people') for (const it of data.items) for (const u of it.vals[c.id] || []) ids.add(u);
const users = [...ids].map((i) => s.usersById[i]).filter(Boolean);
const toggle = (id) => setFilter(boardId, { people: f.people.includes(id) ? f.people.filter((x) => x !== id) : [...f.people, id] });
return html`
Filter by person
${users.map((u) => html` toggle(u.id)} title=${u.name}><${Avatar} user=${u} size=${32} /> `)}
${!users.length ? html`No one is assigned on this board yet. ` : null}
${s.me && ids.has(s.me.id) ? html`
setFilter(boardId, { people: [s.me.id] })}>Just me ` : null}
`;
}
const OPS = {
status: [['is', 'is'], ['is_not', 'is not']], dropdown: [['is', 'is'], ['is_not', 'is not'], ['empty', 'is empty']],
people: [['is', 'is'], ['is_not', 'is not'], ['empty', 'is empty']],
date: [['before', 'is before'], ['after', 'is after'], ['empty', 'is empty'], ['not_empty', 'is not empty']],
timeline: [['before', 'starts before'], ['after', 'starts after'], ['empty', 'is empty']],
checkbox: [['not_empty', 'is checked'], ['empty', 'is not checked']],
};
const defaultOps = [['contains', 'contains'], ['empty', 'is empty'], ['not_empty', 'is not empty']];
function FilterBuilder({ boardId }) {
const s = useStore();
const data = s.boardData[boardId];
const f = getFilter(boardId);
const cols = data.columns.filter((c) => !['files', 'item_id', 'updated', 'created'].includes(c.type));
const setRules = (rules) => setFilter(boardId, { rules });
const add = () => { const c = cols[0]; if (c) setRules([...f.rules, { colId: c.id, op: (OPS[c.type] || defaultOps)[0][0], values: [] }]); };
return html`
Advanced filters Only you see these
${f.rules.map((r, i) => {
const col = data.columns.find((c) => c.id === r.colId);
const ops = OPS[col?.type] || defaultOps;
const set = (patch) => setRules(f.rules.map((x, j) => (j === i ? { ...x, ...patch } : x)));
return html`
${i === 0 ? 'Where' : 'And'}
{ const c = data.columns.find((x) => x.id === e.target.value); set({ colId: c.id, op: (OPS[c.type] || defaultOps)[0][0], values: [] }); }}>
${cols.map((c) => html`${c.title} `)}
set({ op: e.target.value })}>${ops.map(([v, l]) => html`${l} `)}
<${RuleValue} col=${col} rule=${r} set=${set} />
setRules(f.rules.filter((_, j) => j !== i))}><${Icon} name="x" />
`;
})}
<${Icon} name="plus" /> New filter
${f.rules.length ? html` setRules([])}>Clear ` : null}
${!f.rules.length ? html`
${data.columns.filter((c) => c.type === 'status').slice(0, 2).map((c) => html`
${c.title}
${(c.settings.labels || []).map((l) => html`
setRules([{ colId: c.id, op: 'is', values: [l.id] }])}>
${l.name}${data.items.filter((it) => it.vals[c.id] === l.id).length} `)}
`)}
` : null}
`;
}
function RuleValue({ col, rule, set }) {
if (!col || ['empty', 'not_empty'].includes(rule.op)) return html` `;
if (col.type === 'status' || col.type === 'dropdown') {
return html` set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
${(col.settings.labels || []).map((l) => html`${l.name} `)}
${col.type === 'status' ? html`(empty) ` : null}
`;
}
if (col.type === 'people') {
return html` set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
${state.users.map((u) => html`${u.name} `)} `;
}
if (col.type === 'date' || col.type === 'timeline') {
return html` set({ values: [e.target.value] })} />`;
}
return html` set({ values: [e.target.value] })} />`;
}
function SortBuilder({ boardId }) {
const s = useStore();
const data = s.boardData[boardId];
const f = getFilter(boardId);
const sort = f.sort || { colId: '', dir: 'asc' };
return html`
Sort by
setFilter(boardId, { sort: e.target.value ? { ...sort, colId: e.target.value } : null })}>
Choose column
${data.board.item_label}
${data.columns.map((c) => html`${c.title} `)}
setFilter(boardId, { sort: { ...sort, dir: e.target.value } })}>
Ascending Descending
${f.sort ? html` setFilter(boardId, { sort: null })}><${Icon} name="x" /> ` : null}
`;
}
function HideColumns({ boardId, view }) {
const s = useStore();
const data = s.boardData[boardId];
const v = data.views.find((x) => x.id === view.id) || view;
const hidden = v.settings?.hidden || [];
const save = (h) => { mutateBoard(boardId, () => { v.settings = { ...v.settings, hidden: h }; }); api.patch(`/api/views/${v.id}`, { settings: { ...v.settings, hidden: h } }); };
return html``;
}
function MoveBoardModal({ board }) {
const s = useStore();
const [ws, setWs] = useState(board.workspace_id);
const [folder, setFolder] = useState(board.folder_id || '');
const save = async () => {
await api.patch(`/api/boards/${board.id}`, { workspace_id: ws, folder_id: folder || null });
await loadTree(); loadBoard(board.id);
closeModal();
toast('Board moved');
};
return html`<${ModalHeader} title="Move board" />
Workspace
{ setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`${w.name} `)}
Folder
setFolder(e.target.value)}>
(no folder)
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`${f.parent_id ? '— ' : ''}${f.name} `)}
Move
`;
}