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
12 changes: 12 additions & 0 deletions .changeset/flow-node-inspector-readonly-reason.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<FlowNodeInspector
type="flow"
name="f"
draft={DRAFT}
selection={{ kind: 'node', id: 'set_status', label: 'Set status' }}
onPatch={() => {}}
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<HTMLElement>('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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = (
<InspectorShell
kindLabel={t('engine.inspector.flowNode.kind', locale)}
title={node.label || node.id}
Expand Down Expand Up @@ -884,4 +894,35 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
)}
</InspectorShell>
);

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 (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
role="group"
aria-label={flowNodeDisplayName(node, locale)}
tabIndex={0}
aria-describedby={readOnlyReasonId}
data-disabled-reason=""
data-testid="flow-node-readonly-reason"
className="flex h-full min-h-0 flex-col focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{shell}
<span id={readOnlyReasonId} className="sr-only">{reason}</span>
</div>
</TooltipTrigger>
<TooltipContent side="left" align="start">{reason}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})}
</div>
Expand Down
Loading