Waters Boards: self-hosted work management to replace monday.com
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
Deploy Waters Boards to NAS / deploy (push) Canceled after 0s
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>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
// 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`<div class="page-loading"><${Spinner} /></div>`;
|
||||
if (data.missing) return html`<${Empty} title="This board doesn't exist anymore"><button class="btn primary" onClick=${() => navigate('/')}>Go home</button><//>`;
|
||||
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`<div class="board-page">
|
||||
<${BoardHeader} data=${data} view=${view} mobile=${mobile} />
|
||||
<${Toolbar} data=${data} view=${view} f=${f} mobile=${mobile} />
|
||||
<div class="board-body view-${view?.type}">
|
||||
<${ViewComp} key=${view?.id} board=${data} view=${view} items=${filtered.items} subs=${filtered.subs} matches=${filtered.matches} hidden=${hidden} filter=${f} mobile=${mobile} />
|
||||
</div>
|
||||
${itemId ? html`<${ItemPanel} key=${itemId} board=${data} itemId=${itemId} viewId=${view?.id} />` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<header class="board-header">
|
||||
<div class="board-title-row">
|
||||
${mobile ? html`<button class="icon-btn" onClick=${() => update({ sidebarOpen: true })} aria-label="Menu"><${Icon} name="menu" size=${20} /></button>` : null}
|
||||
<h1 class="board-title"><${InlineEdit} value=${b.name} onSave=${(n) => n && A.renameBoard(b.id, n)} /></h1>
|
||||
<button class="icon-btn star ${data.favorite ? 'on' : ''}" onClick=${() => A.toggleFavorite(b.id)} title="Favorite"><${Icon} name="star" fill=${data.favorite} /></button>
|
||||
<div class="spacer" />
|
||||
${!mobile ? html`
|
||||
<button class="btn ghost sm" onClick=${() => openModal(ActivityModal, { boardId: b.id }, { class: 'wide' })}><${Icon} name="activity" /> Activity</button>
|
||||
<button class="btn ghost sm" onClick=${() => openModal(AutomationsModal, { boardId: b.id }, { class: 'wide' })}><${Icon} name="bolt" /> Automate${data.automations.length ? ` / ${data.automations.filter((a) => a.enabled).length}` : ''}</button>
|
||||
<span class="header-people" title="People on this board"><${AvatarStack} ids=${people} size=${26} max=${4} /></span>` : null}
|
||||
<button class="icon-btn" onClick=${openMenu} aria-label="Board menu"><${Icon} name="dots" size=${20} /></button>
|
||||
</div>
|
||||
${b.description && !mobile ? html`<p class="board-desc">${b.description}</p>` : null}
|
||||
<${ViewTabs} data=${data} view=${view} />
|
||||
</header>`;
|
||||
}
|
||||
|
||||
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`<nav class="view-tabs">
|
||||
${data.views.map((v) => html`<button key=${v.id} class="view-tab ${v.id === view?.id ? 'active' : ''}"
|
||||
onClick=${() => 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`<span class="tab-dots" onClick=${(e) => viewMenu(e, v)}><${Icon} name="dots" size=${14} /></span>` : null}
|
||||
</button>`)}
|
||||
<button class="view-tab add" onClick=${addView} title="Add view"><${Icon} name="plus" size=${14} /></button>
|
||||
</nav>`;
|
||||
}
|
||||
|
||||
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`<div class="toolbar">
|
||||
<div class="split-btn">
|
||||
<button class="btn primary sm" onClick=${newItem}>New ${b.item_label.toLowerCase()}</button>
|
||||
<button class="btn primary sm caret" onClick=${(e) => menu(e, [
|
||||
{ label: 'New group of items', icon: 'group', onClick: () => A.addGroup(b.id) },
|
||||
{ label: 'Import items from CSV', icon: 'upload', onClick: () => openModal(ImportCsvModal, { boardId: b.id }) },
|
||||
])}><${Icon} name="chevDown" size=${14} /></button>
|
||||
</div>
|
||||
${searchOpen || f.q ? html`<div class="tb-search"><${Icon} name="search" />
|
||||
<input placeholder="Search this board" value=${f.q} autofocus onInput=${(e) => setFilter(b.id, { q: e.target.value })}
|
||||
onBlur=${() => { if (!f.q) setSearchOpen(false); }} onKeyDown=${(e) => { if (e.key === 'Escape') { setFilter(b.id, { q: '' }); setSearchOpen(false); } }} />
|
||||
${f.q ? html`<button class="icon-btn" onClick=${() => setFilter(b.id, { q: '' })}><${Icon} name="x" size=${14} /></button>` : null}</div>`
|
||||
: html`<button class="tb-btn" onClick=${() => setSearchOpen(true)}><${Icon} name="search" /> ${mobile ? '' : 'Search'}</button>`}
|
||||
<button class="tb-btn ${nPeople ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, PersonFilter, { boardId: b.id })}>
|
||||
${nPeople === 1 ? html`<${Avatar} user=${state.usersById[f.people[0]]} size=${20} />` : html`<${Icon} name="person" />`} ${mobile ? '' : 'Person'}${nPeople > 1 ? ` / ${nPeople}` : ''}</button>
|
||||
<button class="tb-btn ${f.rules.length ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, FilterBuilder, { boardId: b.id }, { keepOnScroll: true })}>
|
||||
<${Icon} name="filter" /> ${mobile ? '' : 'Filter'}${f.rules.length ? ` / ${f.rules.length}` : ''}</button>
|
||||
<button class="tb-btn ${f.sort ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, SortBuilder, { boardId: b.id })}>
|
||||
<${Icon} name="sort" /> ${mobile ? '' : 'Sort'}</button>
|
||||
${view?.type === 'table' || view?.type === 'kanban' ? html`<button class="tb-btn ${hidden.length ? 'on' : ''}" onClick=${(e) => openPopover(e.currentTarget, HideColumns, { boardId: b.id, view })}>
|
||||
<${Icon} name="eyeOff" /> ${mobile ? '' : 'Hide'}${hidden.length ? ` / ${hidden.length}` : ''}</button>` : null}
|
||||
${activeFilterCount(f) || f.sort ? html`<button class="tb-btn subtle" onClick=${() => setFilter(b.id, { q: '', people: [], rules: [], sort: null })}>Clear all</button>` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<div class="pop-pad person-filter">
|
||||
<div class="pop-title">Filter by person</div>
|
||||
<div class="avatar-row">
|
||||
${users.map((u) => html`<button key=${u.id} class="avatar-toggle ${f.people.includes(u.id) ? 'on' : ''}" onClick=${() => toggle(u.id)} title=${u.name}><${Avatar} user=${u} size=${32} /></button>`)}
|
||||
${!users.length ? html`<span class="muted">No one is assigned on this board yet.</span>` : null}
|
||||
</div>
|
||||
${s.me && ids.has(s.me.id) ? html`<button class="btn ghost sm" onClick=${() => setFilter(boardId, { people: [s.me.id] })}>Just me</button>` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<div class="pop-pad filter-builder">
|
||||
<div class="pop-title">Advanced filters <span class="muted small">Only you see these</span></div>
|
||||
${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`<div key=${i} class="filter-rule">
|
||||
<span class="muted small">${i === 0 ? 'Where' : 'And'}</span>
|
||||
<select class="input sm" value=${r.colId} onChange=${(e) => { 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`<option value=${c.id}>${c.title}</option>`)}
|
||||
</select>
|
||||
<select class="input sm" value=${r.op} onChange=${(e) => set({ op: e.target.value })}>${ops.map(([v, l]) => html`<option value=${v}>${l}</option>`)}</select>
|
||||
<${RuleValue} col=${col} rule=${r} set=${set} />
|
||||
<button class="icon-btn" onClick=${() => setRules(f.rules.filter((_, j) => j !== i))}><${Icon} name="x" /></button>
|
||||
</div>`;
|
||||
})}
|
||||
<div class="row gap"><button class="btn ghost sm" onClick=${add}><${Icon} name="plus" /> New filter</button>
|
||||
${f.rules.length ? html`<button class="btn ghost sm" onClick=${() => setRules([])}>Clear</button>` : null}</div>
|
||||
${!f.rules.length ? html`<div class="quick-filters">${data.columns.filter((c) => c.type === 'status').slice(0, 2).map((c) => html`
|
||||
<div key=${c.id} class="qf-col"><div class="qf-title">${c.title}</div>
|
||||
${(c.settings.labels || []).map((l) => html`<button key=${l.id} class="qf-opt" onClick=${() => setRules([{ colId: c.id, op: 'is', values: [l.id] }])}>
|
||||
<i style=${{ background: l.color }} />${l.name}<span class="muted">${data.items.filter((it) => it.vals[c.id] === l.id).length}</span></button>`)}</div>`)}</div>` : null}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function RuleValue({ col, rule, set }) {
|
||||
if (!col || ['empty', 'not_empty'].includes(rule.op)) return html`<span />`;
|
||||
if (col.type === 'status' || col.type === 'dropdown') {
|
||||
return html`<select class="input sm" multiple size="3" onChange=${(e) => set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
|
||||
${(col.settings.labels || []).map((l) => html`<option value=${l.id} selected=${rule.values.includes(l.id)}>${l.name}</option>`)}
|
||||
${col.type === 'status' ? html`<option value="__empty" selected=${rule.values.includes('__empty')}>(empty)</option>` : null}
|
||||
</select>`;
|
||||
}
|
||||
if (col.type === 'people') {
|
||||
return html`<select class="input sm" multiple size="3" onChange=${(e) => set({ values: [...e.target.selectedOptions].map((o) => o.value) })}>
|
||||
${state.users.map((u) => html`<option value=${u.id} selected=${rule.values.includes(u.id)}>${u.name}</option>`)}</select>`;
|
||||
}
|
||||
if (col.type === 'date' || col.type === 'timeline') {
|
||||
return html`<input type="date" class="input sm" value=${rule.values[0] || ''} onInput=${(e) => set({ values: [e.target.value] })} />`;
|
||||
}
|
||||
return html`<input class="input sm" value=${rule.values[0] || ''} placeholder="Value" onInput=${(e) => 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`<div class="pop-pad sort-builder">
|
||||
<div class="pop-title">Sort by</div>
|
||||
<div class="row gap">
|
||||
<select class="input sm" value=${sort.colId} onChange=${(e) => setFilter(boardId, { sort: e.target.value ? { ...sort, colId: e.target.value } : null })}>
|
||||
<option value="">Choose column</option>
|
||||
<option value="__name">${data.board.item_label}</option>
|
||||
${data.columns.map((c) => html`<option value=${c.id}>${c.title}</option>`)}
|
||||
</select>
|
||||
<select class="input sm" value=${sort.dir} disabled=${!f.sort} onChange=${(e) => setFilter(boardId, { sort: { ...sort, dir: e.target.value } })}>
|
||||
<option value="asc">Ascending</option><option value="desc">Descending</option>
|
||||
</select>
|
||||
${f.sort ? html`<button class="icon-btn" onClick=${() => setFilter(boardId, { sort: null })}><${Icon} name="x" /></button>` : null}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<div class="pop-pad hide-cols">
|
||||
<div class="pop-title">Display columns <span class="muted small">Saved for everyone on this view</span></div>
|
||||
<label class="check-row"><input type="checkbox" checked=${!hidden.length} onChange=${(e) => save(e.target.checked ? [] : data.columns.map((c) => c.id))} /> All columns</label>
|
||||
${data.columns.map((c) => html`<label key=${c.id} class="check-row">
|
||||
<input type="checkbox" checked=${!hidden.includes(c.id)} onChange=${(e) => save(e.target.checked ? hidden.filter((x) => x !== c.id) : [...hidden, c.id])} />
|
||||
<span class="type-dot" style=${{ background: COLUMN_TYPES[c.type]?.color }}><${Icon} name=${COLUMN_TYPES[c.type]?.icon || 'edit'} size=${11} /></span>${c.title}</label>`)}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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`<div class="dialog"><${ModalHeader} title="Move board" />
|
||||
<label class="field-label">Workspace</label>
|
||||
<select class="input" value=${ws} onChange=${(e) => { setWs(e.target.value); setFolder(''); }}>${s.workspaces.map((w) => html`<option value=${w.id}>${w.name}</option>`)}</select>
|
||||
<label class="field-label">Folder</label>
|
||||
<select class="input" value=${folder} onChange=${(e) => setFolder(e.target.value)}>
|
||||
<option value="">(no folder)</option>
|
||||
${s.folders.filter((f) => f.workspace_id === ws).map((f) => html`<option value=${f.id}>${f.parent_id ? '— ' : ''}${f.name}</option>`)}
|
||||
</select>
|
||||
<div class="modal-actions"><button class="btn primary" onClick=${save}>Move</button></div></div>`;
|
||||
}
|
||||
Reference in New Issue
Block a user