diff --git a/.changeset/flow-node-inspector-readonly-reason.md b/.changeset/flow-node-inspector-readonly-reason.md new file mode 100644 index 00000000000..5ff00ab1d41 --- /dev/null +++ b/.changeset/flow-node-inspector-readonly-reason.md @@ -0,0 +1,12 @@ +--- +'@object-ui/app-shell': patch +--- + +fix(app-shell): a read-only flow node inspector says why its controls are disabled, on hover, on keyboard focus and to assistive tech + +Clause-②: yes (widening) +Metadata-protocol: none + +On a read-only package, Studio's flow node inspector disabled every control (ID, Label, Node Type, the config fields, Remove node) and gave no reason anywhere: no tooltip, no accessible description, and Tab skipped the whole panel. The inspector body now carries the treatment Studio's field rail and validation-rule editor carry: it is a tab stop described by the reason, and hovering any disabled control in it shows the reason in a tooltip. + +The reason is the host's to give, because the inspector is shared by hosts whose read-only causes differ. `MetadataInspectorProps` gains an optional `readOnlyReason?: string`; an inspector that honours it draws the reason while `readOnly` is true. Studio's Automations pillar passes the read-only package sentence. A host that passes nothing renders exactly as before. diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspector-registry.ts b/ui/packages/app-shell/src/views/metadata-admin/inspector-registry.ts index 65b524a5f23..9d45f66f3df 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspector-registry.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspector-registry.ts @@ -66,6 +66,15 @@ export interface MetadataInspectorProps { onBlockingIssuesChange?: (count: number) => void; /** Whether the host is in edit mode. False → disable inputs. */ readOnly: boolean; + /** + * Why the inspector is read-only, as the sentence to show, when the host + * knows it. The cause is the host's (a read-only package, view mode, a + * walled item), so the host says it; an inspector that honours it explains + * its disabled controls on hover, on keyboard focus and as an accessible + * description. Omitted, the inspector draws no reason. Ignored while + * `readOnly` is false. + */ + readOnlyReason?: string; /** Active UI locale for i18n. */ locale: SupportedLocale; } diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.readOnlyReason-28985.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.readOnlyReason-28985.test.tsx new file mode 100644 index 00000000000..35592d514f3 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.readOnlyReason-28985.test.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#28985 — a read-only flow node inspector says why its controls are + * disabled, on hover, on keyboard focus and to assistive tech, when its host + * hands it the reason (`readOnlyReason`). + * + * Measured on the card: on a read-only package every control of the node + * inspector (ID, Label, Node Type, the config fields, Remove node) was disabled + * with no `title`, no `aria-describedby` and no tab stop, so nothing said why. + * The inspector body now carries the treatment Studio's field rail and + * validation-rule editor carry: a tab stop described by an `sr-only` copy of + * the reason, and the trigger of a tooltip that shows it. + * + * The reason is the host's: the inspector is shared by hosts whose read-only + * causes differ, so a host that passes none gets the panel it always had. + * Studio's Automations pillar passing the package's reason is pinned in + * `StudioDesignSurface.automationsReadOnly-11124.test.tsx`. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../previews/useFlowNodePalette', () => ({ + useActionConfigSchemas: () => ({}), + useFlowNodePalette: () => [], +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { FlowNodeInspector } from './FlowNodeInspector'; +import { t } from '../i18n'; + +afterEach(cleanup); + +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not found', { status: 404 })), +); + +const REASON = 'Read-only package: switch to or create a writable package to edit.'; + +const DRAFT = { + name: 'f', + label: 'F', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'set_status', + type: 'update_record', + label: 'Set status', + config: { objectName: 'task', fields: { status: 'done' } }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'set_status' }, + { id: 'e2', source: 'set_status', target: 'end' }, + ], +}; + +function mount({ readOnly, readOnlyReason }: { readOnly: boolean; readOnlyReason?: string }) { + return render( + {}} + onClearSelection={() => {}} + readOnly={readOnly} + readOnlyReason={readOnlyReason} + locale="en-US" + />, + ); +} + +/** The panel's form controls, Remove node included — the ones the reason is about. */ +const controls = (root: HTMLElement) => [ + ...root.querySelectorAll('input, select, textarea, button[role="combobox"]'), + screen.getByRole('button', { name: t('engine.inspector.flowNode.remove', 'en-US') }), +]; + +describe('Flow node inspector, read-only with a reason from its host (objectstack#28985)', () => { + it('the panel is a tab stop described by the reason, named by the node, and holds the disabled controls', () => { + const { container } = mount({ readOnly: true, readOnlyReason: REASON }); + const body = container.querySelector('[data-disabled-reason]') as HTMLElement; + expect(body).not.toBeNull(); + expect(body).toHaveAttribute('tabindex', '0'); + expect(body).toHaveAccessibleDescription(REASON); + expect(body).toHaveAccessibleName('Set status'); + + const all = controls(body); + // ID, Label, Node Type and Remove node alone are four; a vacuous pass is not one. + expect(all.length).toBeGreaterThanOrEqual(4); + for (const el of all) { + expect(body).toContainElement(el); + expect(el).toBeDisabled(); + } + }); + + it('hovering the disabled "Remove node" opens a tooltip with the reason', async () => { + const user = userEvent.setup(); + mount({ readOnly: true, readOnlyReason: REASON }); + const remove = screen.getByRole('button', { name: t('engine.inspector.flowNode.remove', 'en-US') }); + expect(remove).toBeDisabled(); + await user.hover(remove); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); + + it('a keyboard user reaches the reason: Tab lands on the described panel, which opens the tooltip', async () => { + const user = userEvent.setup(); + const { container } = mount({ readOnly: true, readOnlyReason: REASON }); + const body = container.querySelector('[data-disabled-reason]') as HTMLElement; + await user.tab(); + expect(body).toHaveFocus(); + expect(await screen.findByRole('tooltip')).toHaveTextContent(REASON); + }); +}); + +describe('Flow node inspector — the controls (objectstack#28985)', () => { + it('a host that passes no reason gets the panel it always had: no tab stop, no description', () => { + const { container } = mount({ readOnly: true }); + expect(container.querySelector('[data-disabled-reason]')).toBeNull(); + expect(container.querySelector('[tabindex="0"]')).toBeNull(); + for (const el of controls(container)) expect(el).toBeDisabled(); + }); + + it('a blank reason counts as none', () => { + const { container } = mount({ readOnly: true, readOnlyReason: ' ' }); + expect(container.querySelector('[data-disabled-reason]')).toBeNull(); + }); + + it('a writable inspector ignores a reason: the controls are live and nothing is described', () => { + const { container } = mount({ readOnly: false, readOnlyReason: REASON }); + expect(container.querySelector('[data-disabled-reason]')).toBeNull(); + expect(screen.queryByText(REASON)).toBeNull(); + const all = controls(container); + expect(all.length).toBeGreaterThanOrEqual(4); + for (const el of all) expect(el).toBeEnabled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx index f73ca16247b..aea27e40249 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx @@ -22,7 +22,7 @@ import * as React from 'react'; import { Plus } from 'lucide-react'; import { resolveFlowTriggerKind } from '@objectstack/spec/automation'; -import { Input, Label } from '@object-ui/components'; +import { Input, Label, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@object-ui/components'; import type { MetadataInspectorProps } from '../inspector-registry.js'; import { t, tFormat } from '../i18n.js'; import { @@ -284,7 +284,17 @@ function FlowNodeIdField({ ); } -export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, onSelectionChange, locale, readOnly }: MetadataInspectorProps) { +export function FlowNodeInspector({ + selection, + draft, + onPatch, + onClearSelection, + onSelectionChange, + locale, + readOnly, + readOnlyReason, +}: MetadataInspectorProps) { + const readOnlyReasonId = React.useId(); // Resolve the selection to a node + how to write it back — a top-level draft // node, or a node nested inside a container region (#2670). Every edit goes // through loc.write, so the inspector never branches on where the node lives. @@ -694,7 +704,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection, // read-only — those live on the container's Advanced JSON). const nested = !!loc?.nested; - return ( + const shell = ( ); + + const reason = readOnly ? readOnlyReason?.trim() : undefined; + if (!reason) return shell; + // Read-only with a reason from the host: every control above is disabled for + // that one reason, and a disabled control takes neither hover nor focus. The + // inspector body is the tooltip trigger, so hovering any of them shows it, and + // a tab stop described by an `sr-only` copy, so a keyboard or screen-reader + // user reaches it too. One trigger for the panel, not one per control: the + // reason is the same for all of them. Same treatment as Studio's field rail + // and validation-rule editor. + return ( + + + +
+ {shell} + {reason} +
+
+ {reason} +
+
+ ); } diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx index 55a1c771877..0c18370dfcc 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.automationsReadOnly-11124.test.tsx @@ -213,6 +213,12 @@ describe('Automations pillar on a read-only package (objectui#11124)', () => { const label = await inspectStartNode(); expect(label).toBeDisabled(); expect(within(screen.getByRole('complementary')).getByLabelText('ID')).toBeDisabled(); + // objectstack#28985 — the pillar hands the inspector the package's reason, + // and the inspector body is the tab stop described by it. + const body = screen.getByTestId('flow-node-readonly-reason'); + expect(body).toContainElement(label); + expect(body).toHaveAttribute('tabindex', '0'); + expect(body).toHaveAccessibleDescription(READ_ONLY_REASON); }); it('offers no canvas edit: no "add connected node" or "insert node" handle', async () => { @@ -256,6 +262,7 @@ describe('Automations pillar on a writable package — the control (objectui#111 const label = await inspectStartNode(); expect(label).toBeEnabled(); + expect(screen.queryByTestId('flow-node-readonly-reason')).toBeNull(); fireEvent.click(toggle); await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 }); diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index cc0162af5e9..712ee9d3389 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -10721,6 +10721,7 @@ export function AutomationsPillar({ // the Data pillar threads it (objectui#2259). objectstack#28570 — // a customized flow's editors are open. readOnly: editLocked, + readOnlyReason: editLocked ? t('engine.studio.pkg.readonlyHint', locale) : undefined, locale, })}