From 8672ed4fb0f3cbb7cadeb43cec5fda11adea9c07 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:42:37 +0800 Subject: [PATCH] client: a read-only window draws no write on the Projects page Settings -> Projects drew a name field, New Project, a Bind to {name} choice and Unbind in a window that holds the team read-only, and the host refuses project-create and project-bind there, so every one of those controls answered a press with a refusal line. The page now reads the window lock once (lockedWindow, the same helper the details drawer uses) and draws no write it would be refused; the Project rows, their names and working folders, the members already bound and Open board stay readable, and a writable window is unchanged. Tests: core/test/project-settings-page.test.js. --- .../2305-projects-readonly-writes.fixed.md | 1 + core/client.js | 10 ++-- core/src/client/settings.js | 12 +++-- core/test/project-settings-page.test.js | 47 +++++++++++++++++++ docs/subsystems/core-client.md | 1 + patches/ds-bot/0008-client-bundle.patch | 12 +++-- patches/ds-bot/0014-flybot-branding.patch | 19 +++++--- .../files/test/project-settings-page.test.js | 47 +++++++++++++++++++ 8 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 changelog.d/2305-projects-readonly-writes.fixed.md diff --git a/changelog.d/2305-projects-readonly-writes.fixed.md b/changelog.d/2305-projects-readonly-writes.fixed.md new file mode 100644 index 000000000..33eef2433 --- /dev/null +++ b/changelog.d/2305-projects-readonly-writes.fixed.md @@ -0,0 +1 @@ +A read-only window's Settings → **Projects** page now draws no write the host would refuse: no name field, no **New Project**, no **Bind to {name}** choice on a Project row and no **Unbind** on a Project or on a binding, because `project-create` and `project-bind` are writes a reading window is refused. **Open board**, the Project rows with their names, working folders and members stay readable, and a writable window draws exactly the tree it drew before. ([#2305](https://github.com/ThinkFlowLab/FlyBot/issues/2305)) diff --git a/core/client.js b/core/client.js index f08ba35f9..7ba82052e 100644 --- a/core/client.js +++ b/core/client.js @@ -19407,6 +19407,7 @@ var projectMembers = (roster) => [ ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null })) ]; function ProjectsList({ projects, roster, call: call4, reload }) { + const locked = lockedWindow(roster); const members = projectMembers(roster); const bind = async (choice, project) => { const [kind, id] = String(choice ?? "").split(":"); @@ -19434,7 +19435,7 @@ function ProjectsList({ projects, roster, call: call4, reload }) { "div", { className: "bt-project-row-controls" }, (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")), - (0, import_react42.createElement)( + locked ? null : (0, import_react42.createElement)( "select", { className: "bt-select", @@ -19464,7 +19465,7 @@ function ProjectsList({ projects, roster, call: call4, reload }) { label: member.name, hint: member.kind === "group" ? t("Group") : t("Bot") }, - (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { + locked ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { void unbind(member); } }, t("Unbind")) )) @@ -19496,9 +19497,12 @@ function ProjectsPage({ roster, actions, run, call: call4 }) { setState({ projects: state.projects, error: error?.message ?? String(error) }); } }; + const locked = lockedWindow(roster); return [ state.error === null ? null : (0, import_react42.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error), - (0, import_react42.createElement)( + // The host refuses `project-create` and `project-bind` in a read-only window, so the page draws + // no control that would call them; the Project list and its facts stay readable. + locked ? null : (0, import_react42.createElement)( SetSection, { key: "new", title: t("New Project") }, (0, import_react42.createElement)( diff --git a/core/src/client/settings.js b/core/src/client/settings.js index 67400e573..0d6ddd727 100644 --- a/core/src/client/settings.js +++ b/core/src/client/settings.js @@ -17,7 +17,7 @@ import { KeyIcon, SecretsPage } from './secret-card.js' import { UsagePage } from './usage-page.js' import { ConnectorsPage } from './connectors.js' import { CapabilitiesPage } from './capability-settings.js' -import { SchedulesPage } from './schedules.js' +import { SchedulesPage, lockedWindow } from './schedules.js' import { FeedbackIcon, FeedbackPage } from './feedback.js' import { FolderMenu } from './folder-menu.js' import { AboutPage } from './about.js' @@ -272,6 +272,7 @@ const projectMembers = (roster) => [ ] export function ProjectsList({ projects, roster, call, reload }) { + const locked = lockedWindow(roster) const members = projectMembers(roster) // The binding is the member's own field (`bot.projectId`, `room.projectId`); the record is only // asked again so the row that shows it is read from the host, not guessed here. @@ -296,7 +297,7 @@ export function ProjectsList({ projects, roster, call, reload }) { }, h('div', { className: 'bt-project-row-controls' }, h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openProjectBoard(project.id) }, t('Open board')), - h('select', { + locked ? null : h('select', { className: 'bt-select', 'aria-label': t('Bind to {name}', { name: project.name }), value: '', onChange: event => { void bind(event.target.value, project) }, }, @@ -308,7 +309,7 @@ export function ProjectsList({ projects, roster, call, reload }) { key: `${member.kind}:${member.id}`, label: member.name, hint: member.kind === 'group' ? t('Group') : t('Bot'), }, - h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))), + locked ? null : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))), ] } @@ -339,9 +340,12 @@ export function ProjectsPage({ roster, actions, run, call }) { setState({ projects: state.projects, error: error?.message ?? String(error) }) } } + const locked = lockedWindow(roster) return [ state.error === null ? null : h('div', { key: 'error', className: 'bt-error', role: 'alert' }, state.error), - h(SetSection, { key: 'new', title: t('New Project') }, + // The host refuses `project-create` and `project-bind` in a read-only window, so the page draws + // no control that would call them; the Project list and its facts stay readable. + locked ? null : h(SetSection, { key: 'new', title: t('New Project') }, h(SetRow, { label: t('Name') }, h('input', { className: 'bt-input', 'aria-label': t('Project name'), value: name, placeholder: t('e.g. Website'), diff --git a/core/test/project-settings-page.test.js b/core/test/project-settings-page.test.js index a9197877a..f04719e0a 100644 --- a/core/test/project-settings-page.test.js +++ b/core/test/project-settings-page.test.js @@ -159,3 +159,50 @@ test('a row opens its board, a Bot binds with its Project id, and unbinding send await new Promise(resolve => setTimeout(resolve, 0)) assert.deepEqual(calls[2], ['project-bind', { kind: 'group', id: 'r1', projectId: null }]) }) + +// #2305: the host refuses `project-create` and `project-bind` in a window that holds the team +// read-only, so the page draws none of the controls that call them — the name field, New Project, +// the row's Bind choice and every Unbind — while the readable half stays: the Project row and its +// facts, Open board, and the members already bound. +const writeControls = (nodes) => [ + ...nodes.filter(node => node.type === 'input' && node.props['aria-label'] === 'Project name'), + ...nodes.filter(node => node.type === 'select' && /^Bind to /.test(node.props['aria-label'] ?? '')), + ...nodes.filter(node => node.type === 'button' && ['New Project', 'Unbind'].includes(text(node))), +] +const lockedRoster = { ...roster, readOnly: 'Another window holds this team read-only.' } + +test('a read-only window draws no write on the Projects page', async () => { + const project = { id: 'project-1', name: 'Site', workDir: '/work/site' } + const call = async () => ({ projects: [project] }) + slots = [] + effects = [] + render(ProjectsPage, { roster: lockedRoster, actions, run: fn => fn, call }) + await flush() + const page = render(ProjectsPage, { roster: lockedRoster, actions, run: fn => fn, call }) + const nodes = all(page) + assert.deepEqual(writeControls(nodes), [], 'a read-only window offers no Projects write') + assert.ok(!nodes.some(node => node.type === 'h3' && text(node) === 'New Project'), 'the create form is not drawn') + assert.ok(nodes.some(node => node.props?.label === 'Site'), 'the Project is still listed') + assert.ok(nodes.some(node => node.props?.hint === '/work/site'), 'the Project facts stay') + assert.ok(nodes.some(node => node.type === 'button' && text(node) === 'Open board'), 'Open board stays') + assert.ok(nodes.some(node => node.props?.label === 'Ops'), 'a bound member is still listed') + + const list = all(ProjectsList({ projects: [project], roster: lockedRoster, call, reload: async () => {} })) + assert.deepEqual(writeControls(list), [], 'the list offers no Bind or Unbind either') + assert.ok(list.some(node => node.type === 'button' && text(node) === 'Open board')) +}) + +test('a writable window keeps every Projects write control', async () => { + const project = { id: 'project-1', name: 'Site', workDir: '/work/site' } + const call = async () => ({ projects: [project] }) + slots = [] + effects = [] + render(ProjectsPage, { roster, actions, run: fn => fn, call }) + await flush() + const nodes = all(render(ProjectsPage, { roster, actions, run: fn => fn, call })) + const controls = writeControls(nodes) + assert.equal(controls.length, 4, 'name field, New Project, Bind and one Unbind') + assert.equal(nodes.filter(node => node.type === 'input' && node.props['aria-label'] === 'Project name').length, 1) + assert.equal(nodes.filter(node => node.type === 'select').length, 1) + assert.equal(nodes.filter(node => node.type === 'button' && text(node) === 'Unbind').length, 1) +}) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 509dc7857..d1e783d99 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -131,6 +131,7 @@ The overlay (`flybot-slots.js` `FlyBotApprovals`) is the fallback, not a second - A friend's Bot settings surface is not the local editor (#160): no rename, avatar editor, model, folder, instructions, Main Bot, Hide, Duplicate, or Delete. The details gear and context menu say **Bot settings**. The menu is Mark as Unread, Bot settings, and Copy conversation ID. Per-friend **Auto-send** and **Remove this Bot** stay on that page (`flybot.bot.details`); Remove closes the overlay so a stale local editor cannot appear. The Bots settings list is local Bots only. - A friend's Bot details overview is that compact panel: handle plus `FlyBotDetails` (Auto-send and a muted Remove link), no Browser tab, no Usage / model / folder. Opening the gear uses `remote-bot-settings` (`RemoteBotSettingsSheet`): a bottom sheet on narrow viewports and a centered card from 520px up, with the scrim behind the dialog (`z-index` stacking like `bt-settings` / `bt-mem`) so the sheet stays clickable through enter/leave animations. The surrounding DSH settings-shell chrome (back/title) is not redesigned (#166). The sheet takes focus when it opens (`tabIndex -1`, `preventScroll`), closes on Escape from its own `onKeyDown` as well as `useEscape`, and gives focus back without scrolling; in the tree a friend's `.bt-row-wrap` (row plus gear) shrinks like a plain row (`.bt-tree-item>.bt-row-wrap`), so the gear never overflows the sidebar and focusing it cannot scroll it sideways (#274). On every wide row (own Bots too) the gear's focus and click run `keepSidebarStill`, which puts back any sideways scroll a focus left on the row's ancestors or the page, now and after the settings dialog opened (#421). The Settings nav's last entry reads **Harness settings ↗** with a tooltip saying it opens DeepSeek Harness's own settings in a separate window; FlyBot does not embed or retitle that dialog (#421). - An own Bot's settings (`BotSettingsForm`) draw the `flybot.bot.details` entries once, as a titled **Sharing** card right after **Instructions** (`FlyBotDetails` with `wrap` → `Card`), so sharing is not at the end of the page; with no entry there is no card. Bot settings and group settings both end with a **More** card (Duplicate / Delete). A write the host refuses is reported on the dialog's own line under its heading, outside the page's scrolling box: `SettingsDialog` owns the sentence `run` stores, and `.bt-settings-scroll` no longer holds it, so scrolling down to the control that was pressed cannot leave the refusal above the viewport with the switch apparently unmoved ([#2194](https://github.com/ThinkFlowLab/FlyBot/issues/2194)). A window that holds the team read-only says so in that same header: `SettingsDialog` prints the host's own sentence from `roster.readOnly` as a `role="status"` line under the heading and above the refused-write line, so the standing state comes first and the answer to one pressed control sits closest to it, and a blank or missing `readOnly` draws nothing at all (#2199). The details drawer's own Settings view is not that dialog and had no header sentence: it now reads the same window lock (`lockedWindow`, `schedules.js`) and, in a read-only window, draws the host's sentence in the form's place instead of mounting `BotSettingsForm` or `GroupSettingsForm`, so no write control the host would refuse is drawn for a Bot or a group, and a writable window keeps the form unchanged (#2387). A refused write in that view follows the same rule as the dialog (#2385): the `bt-error` line is a sibling above `bt-drawer-body bt-set-body`, not its first child, so a press at the bottom of the group page — Pin, Hide, Make admin, Remove — writes the host's refusal where the press happened instead of at the top of the box it was scrolled past. The drawer's overview view holds the same two rules: its refusal line is a sibling above `bt-drawer-body`, and in a read-only window the Bot's name is plain text with no model, permission or working-folder control beside it, above a `role="status"` line carrying the host's own sentence, so the overview offers only what the host will not refuse. Since P-547 those three cards keep their titles and their saved values as plain text as well (`ReadOnlyOverview`: the model's name and provider, the permission preset's label, the working folder's last segment with the full path as its `title`), because the window refuses the writes, not the reading — a value that vanishes with its control leaves the page saying less than the host knows; a writable window keeps every control and draws none of those values twice. Tests: `core/test/details-overview-readonly.test.js`. A group's overview follows it (#2391 left it to P-544): the name is plain text with no rename control, `Remove my Bot` is gone, and the host's one sentence sits above the reads — a shared room's approval card keeps its headline and rows and drops its decisions — while a writable window keeps every control. Tests: `core/test/details-bot-settings-readonly.test.js`, `core/test/details-drawer-refusal-line.test.js`, `core/test/details-overview-readonly.test.js`, `core/test/details-group-overview-readonly.test.js`. The composer card's `:focus-within` border is 55% ink, hover stays 30% (#406). Keyboard Tab order, checked in headless Chrome on a mock bench (#406): sidebar (Search, New…, the Bots, Connect apps, Account) → chat header pill → message box → Add, Dictate, voice → the turn stats; the `shell.overlay` layer (approval cards) comes after the main column in the DOM, and no stop focuses an invisible element. The message box comes before **+** because DSH's DOM puts the input first and FlyBot's CSS only reorders it visually. +- Settings → **Projects** (`ProjectsPage` and `ProjectsList` in `settings.js`) draws the write half of the Project record: a name field, **New Project**, a **Bind to {name}** choice on every Project row and **Unbind** on every binding. In a window that holds the team read-only (`lockedWindow`, `schedules.js`) none of them is drawn, because the host refuses `project-create` and `project-bind`; **Open board**, the Project rows with their names and working folders, and the members already bound stay readable, and a writable window is unchanged. Tests: `core/test/project-settings-page.test.js`. - Every listed Bot row in wide sidebar mode is a `bt-row-wrap`: the session `bt-row` and a sibling gear (`bt-row-settings`, hover/focus) that opens Edit Bot for an own Bot or Bot settings for a friend's (#166, #276). - The Session header is the shell's own: FlyBot registers the Bot pill in `conversation.session.header.utilities` rather than claiming the single `conversation.session.header` slot, so the seats that header renders — the lineage title and `conversation.session.header.actions`, where `@deepseek-ai/ui-subagent` puts the subagent catalog — keep rendering (#803). The floating header style FlyBot used to draw over the Session header is gone. - What the pill does to the page is not in the pill and not in a seat of its own (#803). The shell draws `.utilities` — and the pill with it — only while its chrome shows, which a Session without a first message hides, so a side effect parked in the pill stops there: the friend's empty-chat hint, `body[data-bt-friend-chat]`, the `Message {name}` composer label, the visit ledger, `body[data-bt-room]` and the face-room `@` filter. `installHeaderEffects` (`src/client/header-effects.js`) is installed once from `apply` and keeps them on the Session in the main view. It carries no React, and each effect has its own dependency key: the sessions list answers before the roster, so a single "did the Session change" guard would drop a friend's flags on the first pass, while `beginVisit`/`endVisit` keyed on more than the Session would open a second visit for a chat that was only just opened. `test/header-effects.test.js` drives those transitions on jsdom; `test/header-slot.test.js` pins that no such effect runs in `HeaderPill` and that FlyBot claims no other header seat. diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index 77f909124..27f2d250a 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -24064,7 +24064,7 @@ diff --git a/client.js b/client.js const run = (promise) => Promise.resolve(promise).then(() => { setError(""); return true; -@@ -10779,104 +10779,254 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false +@@ -10779,104 +10779,258 @@ function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = false }); const bot = page === "bots" && id ? roster.byId[id] : void 0; const room2 = page === "groups" && id ? roster.roomsById[id] : void 0; @@ -24203,6 +24203,7 @@ diff --git a/client.js b/client.js + ...(roster?.rooms ?? []).map((room2) => ({ kind: "group", id: room2.id, name: room2.name, projectId: room2.projectId ?? null })) +]; +function ProjectsList({ projects, roster, call: call4, reload }) { ++ const locked = lockedWindow(roster); + const members = projectMembers(roster); + const bind = async (choice, project) => { + const [kind, id] = String(choice ?? "").split(":"); @@ -24230,7 +24231,7 @@ diff --git a/client.js b/client.js + "div", + { className: "bt-project-row-controls" }, + (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => openProjectBoard(project.id) }, t("Open board")), -+ (0, import_react42.createElement)( ++ locked ? null : (0, import_react42.createElement)( + "select", + { + className: "bt-select", @@ -24260,7 +24261,7 @@ diff --git a/client.js b/client.js + label: member.name, + hint: member.kind === "group" ? t("Group") : t("Bot") + }, -+ (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { ++ locked ? null : (0, import_react42.createElement)("button", { type: "button", className: "bt-soft bt-soft-sm", onClick: () => { + void unbind(member); + } }, t("Unbind")) + )) @@ -24292,9 +24293,12 @@ diff --git a/client.js b/client.js + setState({ projects: state.projects, error: error?.message ?? String(error) }); + } + }; ++ const locked = lockedWindow(roster); + return [ + state.error === null ? null : (0, import_react42.createElement)("div", { key: "error", className: "bt-error", role: "alert" }, state.error), -+ (0, import_react42.createElement)( ++ // The host refuses `project-create` and `project-bind` in a read-only window, so the page draws ++ // no control that would call them; the Project list and its facts stay readable. ++ locked ? null : (0, import_react42.createElement)( + SetSection, + { key: "new", title: t("New Project") }, + (0, import_react42.createElement)( diff --git a/patches/ds-bot/0014-flybot-branding.patch b/patches/ds-bot/0014-flybot-branding.patch index edd505962..f0496c965 100644 --- a/patches/ds-bot/0014-flybot-branding.patch +++ b/patches/ds-bot/0014-flybot-branding.patch @@ -249,7 +249,7 @@ diff --git a/src/client/secret-card.js b/src/client/secret-card.js diff --git a/src/client/settings.js b/src/client/settings.js --- a/src/client/settings.js +++ b/src/client/settings.js -@@ -2,13 +2,16 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're +@@ -2,29 +2,33 @@ import { createElement as h, useCallback, useEffect, useRef, useState } from 're import { ACCENT_INKS, INKS } from './inks.js' import { isMainOf } from './sources.js' import { allThemes } from './themes.js' @@ -270,9 +270,10 @@ diff --git a/src/client/settings.js b/src/client/settings.js import { CreateBot } from './create-bot.js' import { KeyIcon, SecretsPage } from './secret-card.js' import { UsagePage } from './usage-page.js' -@@ -16,15 +16,16 @@ import { ConnectorsPage } from './connectors.js' + import { ConnectorsPage } from './connectors.js' import { CapabilitiesPage } from './capability-settings.js' - import { SchedulesPage } from './schedules.js' +-import { SchedulesPage } from './schedules.js' ++import { SchedulesPage, lockedWindow } from './schedules.js' import { FeedbackIcon, FeedbackPage } from './feedback.js' +import { FolderMenu } from './folder-menu.js' import { AboutPage } from './about.js' @@ -391,7 +392,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js const back = bot ? t('Back to Bots') : room ? t('Back to group chats') : usageFocus ? t('All usage') : null const nav = useGlide(shown, { axis: 'y' }) // A page enters from the right when it is one level in, from the left when it comes -@@ -178,38 +178,155 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = +@@ -178,38 +178,159 @@ export function SettingsDialog({ page, id, roster, actions, useUpdate, leaving = // rendered state off screen (content-visibility). const [usageOpened, setUsageOpened] = useState(shown === 'usage') if (shown === 'usage' && !usageOpened) setUsageOpened(true) @@ -468,6 +469,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js +] + +export function ProjectsList({ projects, roster, call, reload }) { ++ const locked = lockedWindow(roster) + const members = projectMembers(roster) + // The binding is the member's own field (`bot.projectId`, `room.projectId`); the record is only + // asked again so the row that shows it is read from the host, not guessed here. @@ -492,7 +494,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js + }, + h('div', { className: 'bt-project-row-controls' }, + h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => openProjectBoard(project.id) }, t('Open board')), -+ h('select', { ++ locked ? null : h('select', { + className: 'bt-select', 'aria-label': t('Bind to {name}', { name: project.name }), value: '', + onChange: event => { void bind(event.target.value, project) }, + }, @@ -504,7 +506,7 @@ diff --git a/src/client/settings.js b/src/client/settings.js + key: `${member.kind}:${member.id}`, label: member.name, + hint: member.kind === 'group' ? t('Group') : t('Bot'), + }, -+ h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))), ++ locked ? null : h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => { void unbind(member) } }, t('Unbind'))))), + ] +} + @@ -535,9 +537,12 @@ diff --git a/src/client/settings.js b/src/client/settings.js + setState({ projects: state.projects, error: error?.message ?? String(error) }) + } + } ++ const locked = lockedWindow(roster) + return [ + state.error === null ? null : h('div', { key: 'error', className: 'bt-error', role: 'alert' }, state.error), -+ h(SetSection, { key: 'new', title: t('New Project') }, ++ // The host refuses `project-create` and `project-bind` in a read-only window, so the page draws ++ // no control that would call them; the Project list and its facts stay readable. ++ locked ? null : h(SetSection, { key: 'new', title: t('New Project') }, + h(SetRow, { label: t('Name') }, + h('input', { + className: 'bt-input', 'aria-label': t('Project name'), value: name, placeholder: t('e.g. Website'), diff --git a/patches/ds-bot/files/test/project-settings-page.test.js b/patches/ds-bot/files/test/project-settings-page.test.js index a9197877a..f04719e0a 100644 --- a/patches/ds-bot/files/test/project-settings-page.test.js +++ b/patches/ds-bot/files/test/project-settings-page.test.js @@ -159,3 +159,50 @@ test('a row opens its board, a Bot binds with its Project id, and unbinding send await new Promise(resolve => setTimeout(resolve, 0)) assert.deepEqual(calls[2], ['project-bind', { kind: 'group', id: 'r1', projectId: null }]) }) + +// #2305: the host refuses `project-create` and `project-bind` in a window that holds the team +// read-only, so the page draws none of the controls that call them — the name field, New Project, +// the row's Bind choice and every Unbind — while the readable half stays: the Project row and its +// facts, Open board, and the members already bound. +const writeControls = (nodes) => [ + ...nodes.filter(node => node.type === 'input' && node.props['aria-label'] === 'Project name'), + ...nodes.filter(node => node.type === 'select' && /^Bind to /.test(node.props['aria-label'] ?? '')), + ...nodes.filter(node => node.type === 'button' && ['New Project', 'Unbind'].includes(text(node))), +] +const lockedRoster = { ...roster, readOnly: 'Another window holds this team read-only.' } + +test('a read-only window draws no write on the Projects page', async () => { + const project = { id: 'project-1', name: 'Site', workDir: '/work/site' } + const call = async () => ({ projects: [project] }) + slots = [] + effects = [] + render(ProjectsPage, { roster: lockedRoster, actions, run: fn => fn, call }) + await flush() + const page = render(ProjectsPage, { roster: lockedRoster, actions, run: fn => fn, call }) + const nodes = all(page) + assert.deepEqual(writeControls(nodes), [], 'a read-only window offers no Projects write') + assert.ok(!nodes.some(node => node.type === 'h3' && text(node) === 'New Project'), 'the create form is not drawn') + assert.ok(nodes.some(node => node.props?.label === 'Site'), 'the Project is still listed') + assert.ok(nodes.some(node => node.props?.hint === '/work/site'), 'the Project facts stay') + assert.ok(nodes.some(node => node.type === 'button' && text(node) === 'Open board'), 'Open board stays') + assert.ok(nodes.some(node => node.props?.label === 'Ops'), 'a bound member is still listed') + + const list = all(ProjectsList({ projects: [project], roster: lockedRoster, call, reload: async () => {} })) + assert.deepEqual(writeControls(list), [], 'the list offers no Bind or Unbind either') + assert.ok(list.some(node => node.type === 'button' && text(node) === 'Open board')) +}) + +test('a writable window keeps every Projects write control', async () => { + const project = { id: 'project-1', name: 'Site', workDir: '/work/site' } + const call = async () => ({ projects: [project] }) + slots = [] + effects = [] + render(ProjectsPage, { roster, actions, run: fn => fn, call }) + await flush() + const nodes = all(render(ProjectsPage, { roster, actions, run: fn => fn, call })) + const controls = writeControls(nodes) + assert.equal(controls.length, 4, 'name field, New Project, Bind and one Unbind') + assert.equal(nodes.filter(node => node.type === 'input' && node.props['aria-label'] === 'Project name').length, 1) + assert.equal(nodes.filter(node => node.type === 'select').length, 1) + assert.equal(nodes.filter(node => node.type === 'button' && text(node) === 'Unbind').length, 1) +})