// 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">`; 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`
${mobile ? html`` : null}

<${InlineEdit} value=${b.name} onSave=${(n) => n && A.renameBoard(b.id, n)} />

${!mobile ? html` <${AvatarStack} ids=${people} size=${26} max=${4} />` : null}
${b.description && !mobile ? html`

${b.description}

` : null} <${ViewTabs} data=${data} view=${view} />
`; } 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``; } 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`
${searchOpen || f.q ? html`` : html``} ${view?.type === 'table' || view?.type === 'kanban' ? html`` : null} ${activeFilterCount(f) || f.sort ? html`` : null}
`; } 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``)} ${!users.length ? html`No one is assigned on this board yet.` : null}
${s.me && ids.has(s.me.id) ? html`` : 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'} <${RuleValue} col=${col} rule=${r} set=${set} />
`; })}
${f.rules.length ? html`` : 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``)}
`)}
` : 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``; } if (col.type === 'people') { return html``; } 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
${f.sort ? html`` : 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`
Display columns Saved for everyone on this view
${data.columns.map((c) => 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" />
`; }