Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2248-readonly-task-board.fixed.md
Original file line number Diff line number Diff line change
@@ -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)).
30 changes: 18 additions & 12 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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 : "";
Expand Down Expand Up @@ -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();
Expand All @@ -9118,7 +9119,8 @@ function BoardView({ roster, tasks, rail, scope, counts: counts2, dragging, erro
dragging,
onMove,
onDrag,
archiveFull
archiveFull,
locked
}))
)
)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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("");
Expand All @@ -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("");
Expand Down Expand Up @@ -9224,6 +9229,7 @@ function KanbanDialog({ roster, actions, leaving = false }) {
error,
draft,
archiveFull,
locked,
onDraft: setDraft,
onSelect: setScope,
onMove: move,
Expand Down
32 changes: 19 additions & 13 deletions core/src/client/kanban.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,20 +294,21 @@ 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
return h('section', {
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)
Expand All @@ -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 : ''
Expand Down Expand Up @@ -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,
})))))
}

Expand All @@ -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))
Expand Down Expand Up @@ -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('')
Expand All @@ -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('')
Expand All @@ -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,
})))
}
Expand Down
75 changes: 73 additions & 2 deletions core/test/kanban.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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')
})
Loading
Loading