diff --git a/changelog.d/2248-readonly-task-board.fixed.md b/changelog.d/2248-readonly-task-board.fixed.md new file mode 100644 index 000000000..1a456c1fd --- /dev/null +++ b/changelog.d/2248-readonly-task-board.fixed.md @@ -0,0 +1 @@ +A read-only FlyBot window no longer draws the task-board writes the host would refuse — the **Add task** form, **Start**, **Complete** and card dragging — and a drop on a column no longer sends a move; the rail, the three columns, the cards and the Done stamps stay readable ([#2248](https://github.com/ThinkFlowLab/FlyBot/issues/2248)). diff --git a/core/client.js b/core/client.js index 78d5a5189..8563b9a3a 100644 --- a/core/client.js +++ b/core/client.js @@ -9012,10 +9012,10 @@ function BoardCard2({ task, roster, movable, onMove, onDrag, partOf: partOf2 }) onDragEnd: () => onDrag?.(null) }); } -function cardProps(task, roster, onMove, onDrag, partOf2) { - return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; +function cardProps(task, roster, onMove, onDrag, partOf2, locked) { + return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { const tree = lanes ? columnTree(tasks) : null; const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; @@ -9026,10 +9026,11 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a "data-column": column, "aria-label": columnTitle(column), onDragOver: (event) => { - if (!dragging) return; + if (locked || !dragging) return; event.preventDefault(); }, onDrop: (event) => { + if (locked) return; event.preventDefault(); const id = event.dataTransfer?.getData("text/plain"); if (id) onMove?.(id, column); @@ -9049,23 +9050,23 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a tree ? tree.nests.map((nest) => (0, import_react15.createElement)( "div", { key: nest.task.id, className: "bt-board-nest" }, - (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag)), + (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), nest.children.map((child) => (0, import_react15.createElement)( "div", { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, - (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag)) + (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) )) )) : null, - linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "") })), + linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "", locked) })), lanes ? lanesOf(plain, roster).map((lane) => (0, import_react15.createElement)( "div", { key: lane.id, className: "bt-board-lane" }, (0, import_react15.createElement)("span", { className: "bt-board-lane-name" }, lane.name), - lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) - )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) + lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) + )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) ); } -function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { +function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { const columns = boardColumns(tasks); const lanes = scope?.kind === "group"; const selectedBot = scope?.kind === "bot" ? scope.botId : ""; @@ -9097,7 +9098,7 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro { className: "bt-board-main" }, error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, - scope?.kind === "bot" && onCreate ? (0, import_react15.createElement)( + scope?.kind === "bot" && onCreate && !locked ? (0, import_react15.createElement)( "form", { className: "bt-board-add", onSubmit: (event) => { event.preventDefault(); @@ -9118,7 +9119,8 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro dragging, onMove, onDrag, - archiveFull + archiveFull, + locked })) ) ) @@ -9148,6 +9150,7 @@ function scopeName(roster, scope) { return ""; } function KanbanDialog({ roster, actions, leaving = false }) { + const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; const rail = boardRail(roster); const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); const [dragging, setDragging] = (0, import_react15.useState)(null); @@ -9176,6 +9179,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { if (columnOf(task) === "done") bucket.done += 1; } const move = async (id, column) => { + if (locked) return; const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; setError(""); @@ -9186,6 +9190,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { } }; const create = async (title) => { + if (locked) return; const text2 = String(title ?? "").trim(); if (!text2 || scope?.kind !== "bot") return; setError(""); @@ -9224,6 +9229,7 @@ function KanbanDialog({ roster, actions, leaving = false }) { error, draft, archiveFull, + locked, onDraft: setDraft, onSelect: setScope, onMove: move, diff --git a/core/src/client/kanban.js b/core/src/client/kanban.js index 0d0f07872..0fc5e2c63 100644 --- a/core/src/client/kanban.js +++ b/core/src/client/kanban.js @@ -294,11 +294,11 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { }) } -function cardProps(task, roster, onMove, onDrag, partOf) { - return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf } +function cardProps(task, roster, onMove, onDrag, partOf, locked) { + return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf } } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { const tree = lanes ? columnTree(tasks) : null const linked = tree ? tree.loose.filter(task => task.parentId || task.parentTitle) : [] const plain = tree ? tree.loose.filter(task => !task.parentId && !task.parentTitle) : tasks @@ -306,8 +306,9 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a className: 'bt-board-column', 'data-column': column, 'aria-label': columnTitle(column), - onDragOver: (event) => { if (!dragging) return; event.preventDefault() }, + onDragOver: (event) => { if (locked || !dragging) return; event.preventDefault() }, onDrop: (event) => { + if (locked) return event.preventDefault() const id = event.dataTransfer?.getData('text/plain') if (id) onMove?.(id, column) @@ -322,18 +323,18 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a dragging ? h('div', { className: 'bt-board-drop' }, dropLabel(column)) : null, tasks.length === 0 && !dragging ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) : null, tree ? tree.nests.map(nest => h('div', { key: nest.task.id, className: 'bt-board-nest' }, - h(BoardCard, cardProps(nest.task, roster, onMove, onDrag)), + h(BoardCard, cardProps(nest.task, roster, onMove, onDrag, undefined, locked)), nest.children.map(child => h('div', { key: child.id, className: 'bt-board-subtask', 'data-parent': nest.task.id }, - h(BoardCard, cardProps(child, roster, onMove, onDrag)))))) : null, - linked.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || '') })), + h(BoardCard, cardProps(child, roster, onMove, onDrag, undefined, locked)))))) : null, + linked.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || '', locked) })), lanes ? lanesOf(plain, roster).map(lane => h('div', { key: lane.id, className: 'bt-board-lane' }, h('span', { className: 'bt-board-lane-name' }, lane.name), - lane.tasks.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })))) - : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag) }))) + lane.tasks.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) })))) + : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) }))) } -export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { +export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { const columns = boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' @@ -362,12 +363,12 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, h('div', { className: 'bt-board-main' }, error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, - scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, + scope?.kind === 'bot' && onCreate && !locked ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, h('div', { className: 'bt-board-columns' }, ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { - key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, + key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, locked, }))))) } @@ -389,6 +390,9 @@ function scopeName(roster, scope) { } export function KanbanDialog({ roster, actions, leaving = false }) { + // #2248: the window lock, read once the way the rest of the client reads it (sidebar.js:371). A + // read-only window draws no board write the host would refuse, and the write entries refuse too. + const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' // Per-Bot task board. The GitHub metrics panel is not part of this dialog (#1363). const rail = boardRail(roster) const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null)) @@ -427,6 +431,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { if (columnOf(task) === 'done') bucket.done += 1 } const move = async (id, column) => { + if (locked) return const task = tasks.find(item => item.id === id) ?? own.find(item => item.id === id) if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return setError('') @@ -437,6 +442,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { } } const create = async (title) => { + if (locked) return const text = String(title ?? '').trim() if (!text || scope?.kind !== 'bot') return setError('') @@ -459,7 +465,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), h(BoardView, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, locked, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/core/test/kanban.test.js b/core/test/kanban.test.js index b1820a3d7..b1d45bd31 100644 --- a/core/test/kanban.test.js +++ b/core/test/kanban.test.js @@ -487,7 +487,7 @@ test('P-364: the rail counts the list the board draws, never the one the other s // effect back to the test and never re-runs one, so a switch case written against it passes whatever // the hook does (#1810). This is a second, stateful context: hook slots per component, an effect // re-run only when its deps change, a setter that re-renders, and the board's poll on a fake clock. -function mountBoard(actions) { +function mountBoard(actions, boardRoster = roster) { const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }) const runtime = { instances: new Map(), key: 'root', index: 0, dirty: false, timer: 0, timers: new Map() } const instance = (key) => { @@ -577,7 +577,7 @@ function mountBoard(actions) { const draw = () => { runtime.dirty = false for (const slot of runtime.instances.values()) slot.pending = [] - tree = expandLive(h(live.KanbanDialog, { roster, actions })) + tree = expandLive(h(live.KanbanDialog, { roster: boardRoster, actions })) commit() return tree } @@ -809,3 +809,74 @@ test('a non-terminal actor entry does not end a Done card, even when it comes fi ]) assert.equal(whenOf(board.column('done'), 'task-done999999'), '2 h') }) + +// P-455: a read-only window's task board still drew every write control and accepted drags, and +// the host refused each one (`bot/read-only`) with nothing on the board saying why. The board reads +// the window lock once, the way the rest of the client reads `roster.readOnly` (sidebar.js:371), +// and draws no write at all; everything readable — the rail, the three columns, the cards, the +// archive and its Done stamp — stays. +test('P-455: a read-only window draws no write on the task board', async () => { + const lockedRoster = { ...roster, readOnly: 'This window is read-only.' } + const open = task('task-open123456', 'open', { title: 'Open card', ownerBotId: 'a' }) + const working = task('task-working456789', 'working', { title: 'Working card', ownerBotId: 'a', progress: 40 }) + const finished = task('task-done789012', 'accepted', { title: 'Done card', ownerBotId: 'a', progress: 100, updatedAt: Date.now() - 2 * HOUR }) + const writes = [] + const actions = { + closeBoard: () => {}, boardClose: () => {}, + tasks: query => Promise.resolve(query?.archive ? [finished] : [open, working]), + taskUpdate: async input => { writes.push(['update', input]); return open }, + taskCreate: async input => { writes.push(['create', input]); return input }, + } + const board = mountBoard(actions, lockedRoster) + board.draw() + await board.settle() + const tree = board.tree + const buttons = all(tree).filter(node => node.type === 'button' && ['Add task', 'Start', 'Complete'].includes(textOf(node))).map(textOf) + const newTaskForms = byClass(tree, 'bt-board-add').length + const draggable = byClass(tree, 'bt-board-card').filter(node => node.props.draggable).map(node => node.props['data-task']) + assert.deepEqual({ buttons, newTaskForms, draggable }, { buttons: [], newTaskForms: 0, draggable: [] }) + // The readable board is untouched by the lock. + assert.equal(byClass(tree, 'bt-board-column').map(node => node.props['data-column']).join('|'), 'not-started|in-progress|done') + assert.equal(byClass(tree, 'bt-board-card').length, 3) + assert.match(textOf(tree), /Open card/) + assert.match(textOf(tree), /Working card/) + assert.match(textOf(tree), /Done card/) + assert.equal(byClass(tree, 'bt-board-when').length, 1) + // A drop on a column writes nothing either, even when a drag value is handed to the handler. + board.column('in-progress').props.onDrop({ preventDefault() {}, dataTransfer: { getData: () => open.id } }) + assert.deepEqual(writes, []) +}) + +// The counter-proof: the same board in a writable window keeps all four writes and still sends +// the two requests. A fix that only makes the read-only case pass by removing the controls for +// every window fails here. +test('counter-proof: a writable board keeps all four writes', async () => { + const open = task('task-open123456', 'open', { title: 'Open card', ownerBotId: 'a' }) + const working = task('task-working456789', 'working', { title: 'Working card', ownerBotId: 'a', progress: 40 }) + const calls = [] + const actions = { + closeBoard: () => {}, boardClose: () => {}, + tasks: query => Promise.resolve(query?.archive ? [] : [open, working]), + taskUpdate: async input => { calls.push(['update', input]); return { ...open, status: 'working' } }, + taskCreate: async input => { calls.push(['create', input]); return { ...open, ...input } }, + } + const board = mountBoard(actions) + board.draw() + await board.settle() + const buttons = all(board.tree).filter(node => node.type === 'button' && ['Add task', 'Start', 'Complete'].includes(textOf(node))).map(textOf) + assert.deepEqual(buttons, ['Add task', 'Start', 'Complete']) + assert.equal(byClass(board.tree, 'bt-board-add').length, 1) + const draggable = byClass(board.tree, 'bt-board-card').filter(node => node.props.draggable).map(node => node.props['data-task']).sort() + assert.deepEqual(draggable, ['task-open123456', 'task-working456789']) + const start = all(board.tree).find(node => node.type === 'button' && textOf(node) === 'Start') + start.props.onClick() + await board.settle() + assert.equal(calls.length, 1); assert.equal(calls[0][0], 'update'); assert.equal(calls[0][1].id, open.id); assert.equal(calls[0][1].column, 'in-progress') + // The form writes too: type a title, then submit it. + const input = all(byClass(board.tree, 'bt-board-add')[0]).find(node => node.type === 'input') + input.props.onChange({ target: { value: 'New card' } }) + board.draw() + byClass(board.tree, 'bt-board-add')[0].props.onSubmit({ preventDefault() {} }) + await board.settle() + assert.equal(calls.length, 2); assert.equal(calls[1][0], 'create'); assert.equal(calls[1][1].botId, 'a'); assert.equal(calls[1][1].title, 'New card'); assert.equal(calls[1][1].column, 'not-started') +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 22f6e3e36..7827709ce 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -48,7 +48,7 @@ The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/cl | `core/src/shared/preview.js` | What a preview may show of a stored line (#130, #1347): `visibleText(text)` unwraps complete `` wraps and then removes leftover marker fragments until neither changes the text — a `from="User">` tail, a `untrusted-message>` whose brackets an older pass already stripped, a nested fragment that would otherwise rebuild a marker — and folds the result to the one line a sidebar row is. `stripFence` is the marker half on its own. It is used where a row is built (`previewOf` in `src/client/text.js`, the group row in `src/client/sidebar.js`, the host's `preview` in `src/host/channel.js`), so no surface has to remember it, and by the details drawer's copy export: `transcriptOf` (`src/client/text.js`) reads each stored turn through the same helper, so a copied conversation carries the words, not an untrusted fence, and `copyBodyOf` drops a leading delegation trace only when its first hop is a Bot's name taken from the roster (`copyNames`; longest first, and no names strips nothing), which keeps a line like `Settings → Models 页面` as written; a copied reply keeps its own line breaks, and only a trace-only line or a fence-marker line is dropped. Tests: `core/test/ui-clickthrough.test.js`, `core/test/details-copy.test.js`. | | `core/src/client/board-card.js` | The task board's card frame (#1363): `BoardCard` draws the identity line, the title, the note and the actions the board passes in, with `TASK_SKIN`. A card carries `data-source` (`task`) and `data-key` (`task:`). The caller is `kanban.js`. Tests: `core/test/board-card.test.js`, `core/test/kanban.test.js`. | | `core/src/shared/board-card.js` | Task-card identity (#1363): `taskCardKey(id)` is `task:` and `taskCard(task)` builds the model `BoardCard` draws. Tests: `core/test/board-card.test.js`. | -| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps `{done}/{total}` exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`), so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. #2069: it counts one list and never both — `own` still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read `1/3` where it now reads `1/2`. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. A card in the Done column also says when it finished — a relative time beside the assignee (`bt-board-when`, `relativeShort`), on a Bot's board and a group's alike (#2066). The stamp is the **first** terminal entry in the card's `actors` (`accepted`, `cancelled` or `failed`), never `updatedAt`: `record()` bumps `updatedAt` on a plain edit too, so taking it would name the edit instead of the finish and taking the last terminal entry would do the same. A row written before that history existed falls back to `updatedAt`, and a card with neither a finite `at` nor a finite `updatedAt` draws no node at all — `relativeShort(undefined)` would print `Invalid Date` and `0` would print 1970. The other columns keep the footer they had. Tests: `core/test/kanban.test.js`. | +| `core/src/client/kanban.js` | Per-Bot task board (#1058, #1099): the **Tasks** row under Inbox (`KanbanRow`, `ui.board`, `actions.openBoard` / `closeBoard`) and `KanbanDialog`. `boardRail` lists the viewer's Bots nested by `createdBy` and the groups they administer; `boardColumns` maps task status onto **Not started** / **In progress** / **Done**. Cards show id, priority, title, note, assignee and progress, and are drawn by the shared `board-card.js` component: a task card says `source: 'task'`, keys `task:` and keeps `data-task` / `data-column` for this board's own selectors (#1265 step 1). The dialog body is `BoardView` (#1363): the rail stays per-Bot and the columns are the task columns only. The GitHub metrics panel (Today / 7 days, Retry, Diagnose, the package-default repository header) is not mounted. A task card is the shared `BoardCard` with `TASK_SKIN`, and dragging it or dropping it on a column only reports the request to the dialog's `onMove` (`actions.taskUpdate`), so the view still never writes. In a group scope, `columnTree` nests subtasks under the overall task (`kind: 'group'`) when they share a column, and a subtask in another column says which overall task it is part of. That scope also says assigning, claiming, and progress live on the board (overall task, subtask, assignee, column, progress) and that @ only wakes someone. Drag or **Start** / **Complete** calls `actions.taskUpdate`; only the Bot's owner gets those actions. A group scope loads `tasks { roomId }`. Since #1252 the default read is active work only and the Done column merges a second read of the finished rows — `tasks { ...query, archive: true, limit: ARCHIVE_SHOWN }`, which is room-scoped for a group and names the Bot for a Bot board, because the host cuts the page before the board can filter by Bot — newest-settled first by `updatedAt` (tiebreak `createdAt`), finished rows only, and cut to that limit, so a room with hundreds of finished cards still renders a twenty-card column while the poll carries the slice and not the history. Since #2045 that page is also counted for what it is: when the archive holds a full page (`ARCHIVE_SHOWN`), the Done heading reads `20 newest` and a Bot's rail row reads `20+ done` rather than a fraction that would present the page as the history, and in a group the page is cut across the room, so every Bot's share of it is a lower bound and reads the same way rather than presenting its slice as its own total; a page under the limit keeps `{done}/{total}` exactly as before. P-364: the rail adds up the list the scope actually draws from, and a room board keeps its active cards in a second list of its own (`groupTasks`), so a room rail counts the room's active cards as well as the archive page, reading `1/4` beside four drawn cards and `0/1` for a Bot whose cards are all still active, while a Bot board's numbers are unchanged. #2069: it counts one list and never both — `own` still holds the Bot board's read after the rail switches to a room, and adding it counted the room's active cards twice, so a room board that draws two cards read `1/3` where it now reads `1/2`. P-360: a Bot board's load effect is keyed by the Bot it shows, so switching the rail re-reads the new Bot's active list and archive page — the page it kept was that of the Bot it left, and filtering it by the new Bot emptied its Done column while the poll kept refilling the old one. That read without `limit` stays uncapped on purpose: it is the id-lookup and tooling path (`statusOf`, a Bot reading a settled row), not what the board polls. Finishing a card moves it off the active list at once, before the next poll. A card in the Done column also says when it finished — a relative time beside the assignee (`bt-board-when`, `relativeShort`), on a Bot's board and a group's alike (#2066). The stamp is the **first** terminal entry in the card's `actors` (`accepted`, `cancelled` or `failed`), never `updatedAt`: `record()` bumps `updatedAt` on a plain edit too, so taking it would name the edit instead of the finish and taking the last terminal entry would do the same. A row written before that history existed falls back to `updatedAt`, and a card with neither a finite `at` nor a finite `updatedAt` draws no node at all — `relativeShort(undefined)` would print `Invalid Date` and `0` would print 1970. The other columns keep the footer they had. #2248: a read-only window (`roster.readOnly` a non-empty string, read once in `KanbanDialog`) draws no board write the host would refuse — no **Add task** form, no **Start** / **Complete**, no `draggable` card, and a drop on a column runs no `taskUpdate` — while the readable board (rail, three columns, cards, archive, Done stamp) and a writable window are unchanged. Tests: `core/test/kanban.test.js`. | | `core/src/client/standing-approvals.js` | Standing tool approvals (#540): `StandingApprovalsCard`, the **Always allowed** card in Bot settings (the Bot's grants) and Group settings (the grants this person gave in the group), one row per kind with **Remove** (`actions.removeStandingApproval`). The approval cards (`member-status.js` `ApprovalCard`, the inbox card) add **Always allow for this Bot** and, for an ask from a group, **Always allow for this group** (`alwaysChoices`; never on a friend's Bot). Host side in [core-host](core-host.md#standing-tool-approvals-540). Tests: `core/test/member-status.test.js`, `core/test/standing-approvals.test.js`. | | `core/src/client/mobile.js` | Phone-width layout, step 1 (#362): `MOBILE_CSS` under `@media (max-width:640px)` for held-reply and floating approval buttons, the chat pointers and Agent inbox cards. Tests: `core/test/mobile-layout.test.js`. | diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 86e00ab9d..7770d2b21 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5158,7 +5158,7 @@ diff --git a/client.js b/client.js .bt-panel-view>.bt-drawer-body[data-move=tab]{animation:bt-veil-in .2s ease-out} .bt-drawer-name-input{animation:bt-veil-in .16s ease-out} .bt-pop-in{display:inline-flex;animation:bt-check-pop ${SPRING_POP.duration}ms ${SPRING_POP.easing}} -@@ -2595,2914 +2595,6816 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2595,2914 +2595,6822 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} :is(.bt-soft,.bt-send,.bt-mini,.bt-tab,.bt-avedit-tab,.bt-pick,.bt-q-submit,.bt-q-btn):active:not(:disabled){scale:.96} @media (prefers-reduced-motion:reduce){.bt-thumb{transition:none}.bt-panel-view>[data-move],.bt-drawer-name-input,.bt-veil,.bt-pop-in,.bt-token,.bt-drop,.bt-newchat,.bt-option-hint,.bt-connector-form,.bt-settings-head>*{animation:none}} `; @@ -10861,8 +10861,8 @@ diff --git a/client.js b/client.js - if (days === 1) return t("Yesterday {time}", { time: clock4 }); - const sameYear = date.getFullYear() === new Date(now).getFullYear(); - return `${date.toLocaleDateString(dateLocale(), { weekday: "short", month: "short", day: "numeric", ...sameYear ? {} : { year: "numeric" } })} ${clock4}`; -+function cardProps(task, roster, onMove, onDrag, partOf2) { -+ return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; ++function cardProps(task, roster, onMove, onDrag, partOf2, locked) { ++ return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf: partOf2 }; } -function nextTurnStart(turns, turn) { - let next; @@ -10871,7 +10871,7 @@ diff --git a/client.js b/client.js - if (candidate > turn && turns[key].first !== void 0 && (next === void 0 || candidate < next)) next = candidate; - } - return next === void 0 ? void 0 : turns[next].first; -+function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { ++function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { + const tree = lanes ? columnTree(tasks) : null; + const linked = tree ? tree.loose.filter((task) => task.parentId || task.parentTitle) : []; + const plain = tree ? tree.loose.filter((task) => !task.parentId && !task.parentTitle) : tasks; @@ -10882,10 +10882,11 @@ diff --git a/client.js b/client.js + "data-column": column, + "aria-label": columnTitle(column), + onDragOver: (event) => { -+ if (!dragging) return; ++ if (locked || !dragging) return; + event.preventDefault(); + }, + onDrop: (event) => { ++ if (locked) return; + event.preventDefault(); + const id = event.dataTransfer?.getData("text/plain"); + if (id) onMove?.(id, column); @@ -10905,20 +10906,20 @@ diff --git a/client.js b/client.js + tree ? tree.nests.map((nest) => (0, import_react15.createElement)( + "div", + { key: nest.task.id, className: "bt-board-nest" }, -+ (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag)), ++ (0, import_react15.createElement)(BoardCard2, cardProps(nest.task, roster, onMove, onDrag, void 0, locked)), + nest.children.map((child) => (0, import_react15.createElement)( + "div", + { key: child.id, className: "bt-board-subtask", "data-parent": nest.task.id }, -+ (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag)) ++ (0, import_react15.createElement)(BoardCard2, cardProps(child, roster, onMove, onDrag, void 0, locked)) + )) + )) : null, -+ linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "") })), ++ linked.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || "", locked) })), + lanes ? lanesOf(plain, roster).map((lane) => (0, import_react15.createElement)( + "div", + { key: lane.id, className: "bt-board-lane" }, + (0, import_react15.createElement)("span", { className: "bt-board-lane-name" }, lane.name), -+ lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) -+ )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })) ++ lane.tasks.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) ++ )) : plain.map((task) => (0, import_react15.createElement)(BoardCard2, { key: task.id, ...cardProps(task, roster, onMove, onDrag, void 0, locked) })) + ); } -function openingTurn(turns, firstTurns, hiddenTurns) { @@ -10929,7 +10930,7 @@ diff --git a/client.js b/client.js - const listed = firstTurns.split(",").map(Number); - const earlier = listed.filter((entry) => entry < turn); - return listed.includes(turn) && earlier.every((entry) => hiddenTurns?.includes(entry)) ? { turn, start } : null; -+function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { ++function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { + const columns = boardColumns(tasks); + const lanes = scope?.kind === "group"; + const selectedBot = scope?.kind === "bot" ? scope.botId : ""; @@ -10961,7 +10962,7 @@ diff --git a/client.js b/client.js + { className: "bt-board-main" }, + error ? (0, import_react15.createElement)("p", { className: "bt-board-error", role: "alert" }, error) : null, + lanes ? (0, import_react15.createElement)("p", { className: "bt-board-hint" }, t("Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.")) : null, -+ scope?.kind === "bot" && onCreate ? (0, import_react15.createElement)( ++ scope?.kind === "bot" && onCreate && !locked ? (0, import_react15.createElement)( + "form", + { className: "bt-board-add", onSubmit: (event) => { + event.preventDefault(); @@ -10982,7 +10983,8 @@ diff --git a/client.js b/client.js + dragging, + onMove, + onDrag, -+ archiveFull ++ archiveFull, ++ locked + })) + ) + ) @@ -11070,6 +11072,7 @@ diff --git a/client.js b/client.js - const target = findBot(arg("bot")); - return { label: target ? t("Stopping {name}", { name: target.name }) : t("Stopping another Bot"), state: "sending" }; +function KanbanDialog({ roster, actions, leaving = false }) { ++ const locked = typeof roster?.readOnly === "string" && roster.readOnly.trim() !== ""; + const rail = boardRail(roster); + const [scope, setScope] = (0, import_react15.useState)(() => rail.bots[0] ? { kind: "bot", botId: rail.bots[0].id } : rail.groups[0] ? { kind: "group", roomId: rail.groups[0].id } : null); + const [dragging, setDragging] = (0, import_react15.useState)(null); @@ -11098,6 +11101,7 @@ diff --git a/client.js b/client.js + if (columnOf(task) === "done") bucket.done += 1; + } + const move = async (id, column) => { ++ if (locked) return; + const task = tasks.find((item) => item.id === id) ?? own.find((item) => item.id === id); + if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return; + setError(""); @@ -11113,6 +11117,7 @@ diff --git a/client.js b/client.js - return { label: t("Updating {name}", { name: target?.name ?? t("a Bot") }), state: "sending" }; + }; + const create = async (title) => { ++ if (locked) return; + const text2 = String(title ?? "").trim(); + if (!text2 || scope?.kind !== "bot") return; + setError(""); @@ -11168,6 +11173,7 @@ diff --git a/client.js b/client.js + error, + draft, + archiveFull, ++ locked, + onDraft: setDraft, + onSelect: setScope, + onMove: move, diff --git a/patches/ds-bot/files/src/client/kanban.js b/patches/ds-bot/files/src/client/kanban.js index 0d0f07872..0fc5e2c63 100644 --- a/patches/ds-bot/files/src/client/kanban.js +++ b/patches/ds-bot/files/src/client/kanban.js @@ -294,11 +294,11 @@ export function BoardCard({ task, roster, movable, onMove, onDrag, partOf }) { }) } -function cardProps(task, roster, onMove, onDrag, partOf) { - return { task, roster, movable: canMoveTask(roster, task), onMove, onDrag, partOf } +function cardProps(task, roster, onMove, onDrag, partOf, locked) { + return { task, roster, movable: !locked && canMoveTask(roster, task), onMove, onDrag, partOf } } -function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull }) { +function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, archiveFull, locked }) { const tree = lanes ? columnTree(tasks) : null const linked = tree ? tree.loose.filter(task => task.parentId || task.parentTitle) : [] const plain = tree ? tree.loose.filter(task => !task.parentId && !task.parentTitle) : tasks @@ -306,8 +306,9 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a className: 'bt-board-column', 'data-column': column, 'aria-label': columnTitle(column), - onDragOver: (event) => { if (!dragging) return; event.preventDefault() }, + onDragOver: (event) => { if (locked || !dragging) return; event.preventDefault() }, onDrop: (event) => { + if (locked) return event.preventDefault() const id = event.dataTransfer?.getData('text/plain') if (id) onMove?.(id, column) @@ -322,18 +323,18 @@ function BoardColumn({ column, tasks, roster, lanes, dragging, onMove, onDrag, a dragging ? h('div', { className: 'bt-board-drop' }, dropLabel(column)) : null, tasks.length === 0 && !dragging ? h('p', { className: 'bt-board-empty' }, t('Nothing in this column.')) : null, tree ? tree.nests.map(nest => h('div', { key: nest.task.id, className: 'bt-board-nest' }, - h(BoardCard, cardProps(nest.task, roster, onMove, onDrag)), + h(BoardCard, cardProps(nest.task, roster, onMove, onDrag, undefined, locked)), nest.children.map(child => h('div', { key: child.id, className: 'bt-board-subtask', 'data-parent': nest.task.id }, - h(BoardCard, cardProps(child, roster, onMove, onDrag)))))) : null, - linked.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || '') })), + h(BoardCard, cardProps(child, roster, onMove, onDrag, undefined, locked)))))) : null, + linked.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, task.parentTitle || '', locked) })), lanes ? lanesOf(plain, roster).map(lane => h('div', { key: lane.id, className: 'bt-board-lane' }, h('span', { className: 'bt-board-lane-name' }, lane.name), - lane.tasks.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag) })))) - : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag) }))) + lane.tasks.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) })))) + : plain.map(task => h(BoardCard, { key: task.id, ...cardProps(task, roster, onMove, onDrag, undefined, locked) }))) } -export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull }) { +export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, draft, onDraft, onSelect, onMove, onDrag, onCreate, archiveFull, locked }) { const columns = boardColumns(tasks) const lanes = scope?.kind === 'group' const selectedBot = scope?.kind === 'bot' ? scope.botId : '' @@ -362,12 +363,12 @@ export function BoardView({ roster, tasks, rail, scope, counts, dragging, error, h('div', { className: 'bt-board-main' }, error ? h('p', { className: 'bt-board-error', role: 'alert' }, error) : null, lanes ? h('p', { className: 'bt-board-hint' }, t('Assign, claim, and progress live on this board: one overall task, a subtask per part, an assignee, a column, and progress. @ only wakes someone.')) : null, - scope?.kind === 'bot' && onCreate ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, + scope?.kind === 'bot' && onCreate && !locked ? h('form', { className: 'bt-board-add', onSubmit: (event) => { event.preventDefault(); onCreate(draft) } }, h('input', { 'aria-label': t('Task title'), placeholder: t('New task'), value: draft ?? '', onChange: (event) => onDraft?.(event.target.value) }), h('button', { type: 'submit', className: 'bt-soft bt-soft-sm' }, t('Add task'))) : null, h('div', { className: 'bt-board-columns' }, ['not-started', 'in-progress', 'done'].map(column => h(BoardColumn, { - key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, + key: column, column, tasks: columns[column], roster, lanes, dragging, onMove, onDrag, archiveFull, locked, }))))) } @@ -389,6 +390,9 @@ function scopeName(roster, scope) { } export function KanbanDialog({ roster, actions, leaving = false }) { + // #2248: the window lock, read once the way the rest of the client reads it (sidebar.js:371). A + // read-only window draws no board write the host would refuse, and the write entries refuse too. + const locked = typeof roster?.readOnly === 'string' && roster.readOnly.trim() !== '' // Per-Bot task board. The GitHub metrics panel is not part of this dialog (#1363). const rail = boardRail(roster) const [scope, setScope] = useState(() => (rail.bots[0] ? { kind: 'bot', botId: rail.bots[0].id } : rail.groups[0] ? { kind: 'group', roomId: rail.groups[0].id } : null)) @@ -427,6 +431,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { if (columnOf(task) === 'done') bucket.done += 1 } const move = async (id, column) => { + if (locked) return const task = tasks.find(item => item.id === id) ?? own.find(item => item.id === id) if (!task || !canMoveTask(roster, task) || columnOf(task) === column) return setError('') @@ -437,6 +442,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { } } const create = async (title) => { + if (locked) return const text = String(title ?? '').trim() if (!text || scope?.kind !== 'bot') return setError('') @@ -459,7 +465,7 @@ export function KanbanDialog({ roster, actions, leaving = false }) { h('span', { className: 'bt-mem-head-gap' }), h('button', { type: 'button', className: 'bt-icon-btn', 'aria-label': t('Close board'), autoFocus: true, onClick: close }, h(CloseIcon))), h(BoardView, { - roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, + roster, tasks: boardTasks, rail, scope, counts, dragging, error, draft, archiveFull, locked, onDraft: setDraft, onSelect: setScope, onMove: move, onDrag: setDragging, onCreate: create, }))) } diff --git a/patches/ds-bot/files/test/kanban.test.js b/patches/ds-bot/files/test/kanban.test.js index b1820a3d7..b1d45bd31 100644 --- a/patches/ds-bot/files/test/kanban.test.js +++ b/patches/ds-bot/files/test/kanban.test.js @@ -487,7 +487,7 @@ test('P-364: the rail counts the list the board draws, never the one the other s // effect back to the test and never re-runs one, so a switch case written against it passes whatever // the hook does (#1810). This is a second, stateful context: hook slots per component, an effect // re-run only when its deps change, a setter that re-renders, and the board's poll on a fake clock. -function mountBoard(actions) { +function mountBoard(actions, boardRoster = roster) { const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity) }) const runtime = { instances: new Map(), key: 'root', index: 0, dirty: false, timer: 0, timers: new Map() } const instance = (key) => { @@ -577,7 +577,7 @@ function mountBoard(actions) { const draw = () => { runtime.dirty = false for (const slot of runtime.instances.values()) slot.pending = [] - tree = expandLive(h(live.KanbanDialog, { roster, actions })) + tree = expandLive(h(live.KanbanDialog, { roster: boardRoster, actions })) commit() return tree } @@ -809,3 +809,74 @@ test('a non-terminal actor entry does not end a Done card, even when it comes fi ]) assert.equal(whenOf(board.column('done'), 'task-done999999'), '2 h') }) + +// P-455: a read-only window's task board still drew every write control and accepted drags, and +// the host refused each one (`bot/read-only`) with nothing on the board saying why. The board reads +// the window lock once, the way the rest of the client reads `roster.readOnly` (sidebar.js:371), +// and draws no write at all; everything readable — the rail, the three columns, the cards, the +// archive and its Done stamp — stays. +test('P-455: a read-only window draws no write on the task board', async () => { + const lockedRoster = { ...roster, readOnly: 'This window is read-only.' } + const open = task('task-open123456', 'open', { title: 'Open card', ownerBotId: 'a' }) + const working = task('task-working456789', 'working', { title: 'Working card', ownerBotId: 'a', progress: 40 }) + const finished = task('task-done789012', 'accepted', { title: 'Done card', ownerBotId: 'a', progress: 100, updatedAt: Date.now() - 2 * HOUR }) + const writes = [] + const actions = { + closeBoard: () => {}, boardClose: () => {}, + tasks: query => Promise.resolve(query?.archive ? [finished] : [open, working]), + taskUpdate: async input => { writes.push(['update', input]); return open }, + taskCreate: async input => { writes.push(['create', input]); return input }, + } + const board = mountBoard(actions, lockedRoster) + board.draw() + await board.settle() + const tree = board.tree + const buttons = all(tree).filter(node => node.type === 'button' && ['Add task', 'Start', 'Complete'].includes(textOf(node))).map(textOf) + const newTaskForms = byClass(tree, 'bt-board-add').length + const draggable = byClass(tree, 'bt-board-card').filter(node => node.props.draggable).map(node => node.props['data-task']) + assert.deepEqual({ buttons, newTaskForms, draggable }, { buttons: [], newTaskForms: 0, draggable: [] }) + // The readable board is untouched by the lock. + assert.equal(byClass(tree, 'bt-board-column').map(node => node.props['data-column']).join('|'), 'not-started|in-progress|done') + assert.equal(byClass(tree, 'bt-board-card').length, 3) + assert.match(textOf(tree), /Open card/) + assert.match(textOf(tree), /Working card/) + assert.match(textOf(tree), /Done card/) + assert.equal(byClass(tree, 'bt-board-when').length, 1) + // A drop on a column writes nothing either, even when a drag value is handed to the handler. + board.column('in-progress').props.onDrop({ preventDefault() {}, dataTransfer: { getData: () => open.id } }) + assert.deepEqual(writes, []) +}) + +// The counter-proof: the same board in a writable window keeps all four writes and still sends +// the two requests. A fix that only makes the read-only case pass by removing the controls for +// every window fails here. +test('counter-proof: a writable board keeps all four writes', async () => { + const open = task('task-open123456', 'open', { title: 'Open card', ownerBotId: 'a' }) + const working = task('task-working456789', 'working', { title: 'Working card', ownerBotId: 'a', progress: 40 }) + const calls = [] + const actions = { + closeBoard: () => {}, boardClose: () => {}, + tasks: query => Promise.resolve(query?.archive ? [] : [open, working]), + taskUpdate: async input => { calls.push(['update', input]); return { ...open, status: 'working' } }, + taskCreate: async input => { calls.push(['create', input]); return { ...open, ...input } }, + } + const board = mountBoard(actions) + board.draw() + await board.settle() + const buttons = all(board.tree).filter(node => node.type === 'button' && ['Add task', 'Start', 'Complete'].includes(textOf(node))).map(textOf) + assert.deepEqual(buttons, ['Add task', 'Start', 'Complete']) + assert.equal(byClass(board.tree, 'bt-board-add').length, 1) + const draggable = byClass(board.tree, 'bt-board-card').filter(node => node.props.draggable).map(node => node.props['data-task']).sort() + assert.deepEqual(draggable, ['task-open123456', 'task-working456789']) + const start = all(board.tree).find(node => node.type === 'button' && textOf(node) === 'Start') + start.props.onClick() + await board.settle() + assert.equal(calls.length, 1); assert.equal(calls[0][0], 'update'); assert.equal(calls[0][1].id, open.id); assert.equal(calls[0][1].column, 'in-progress') + // The form writes too: type a title, then submit it. + const input = all(byClass(board.tree, 'bt-board-add')[0]).find(node => node.type === 'input') + input.props.onChange({ target: { value: 'New card' } }) + board.draw() + byClass(board.tree, 'bt-board-add')[0].props.onSubmit({ preventDefault() {} }) + await board.settle() + assert.equal(calls.length, 2); assert.equal(calls[1][0], 'create'); assert.equal(calls[1][1].botId, 'a'); assert.equal(calls[1][1].title, 'New card'); assert.equal(calls[1][1].column, 'not-started') +})