Skip to content

Commit 637cfc6

Browse files
fix(studio): plainer authoring in Studio — option labels first, named rules, labelled fields, honest counts (#27830)
domain:ui#3 · os-bill · session_01Utp6CaJoinvGQSbHeDaWBe <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgJ7T2SaogZw24tZ9auWbCH7)_ Part of #27521 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 第 24–39 行建议里能在 Studio 内直接改的 9 行落地:选项先填显示名并自动生成存储值、校验规则名随错误消息生成、条件字段按标签列出、流程节点卡显示对象标签、字段数分开计系统字段、“创建”按钮说明为何不可用、只读文本框变灰、发布提示去掉“原子”并引导去“权限”、无 Studio 权限时说明找谁开通(第 38 行,席位放宽文件面到 Console 调用点后补做)。 - 为什么改:浏览器走查发现这些地方要么说开发者术语,要么让作者卡住(例如只填了三个显示名,却提示“至少需要一个选项”)。 - 风险与回滚:只改 Studio 界面与中英文文案,不动元数据协议;已有选项改名时存储值不变,已有规则改消息时规则名不变。回滚即还原本 PR。 - 先看:`ObjectFieldInspector.tsx`(选项编辑器)、`ObjectValidationsPanel.tsx`(规则名生成)、`StudioDesignSurface.tsx`(字段数与发布提示)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.optionLabelFirst-27521.test.tsx`,应 9 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Studio rows 24–39 of #27521, re-measured on `main` at `e5cce096` (code reading plus jsdom pins; no browser pass in this run). Each row is taken here with a pin, handed to the seat as a card, or answered as already done or will not do. | Row | Bucket | What, or why not | |---|---|---| | 24 | Card #27833 | The New Package dialog's English help is `ManifestSchema`'s own `.describe()` by design (`package-schema.ts`, "Help text"), and the landing's overlay wording is pinned by `BuilderLanding.environmentOverlayCopy-12171`. A name-only first step is a dialog redesign plus a wording producer in `packages/spec`. | | 25 | Taken | Options read Label first; a new option's value is filled in from its label until typed, so labelled options are written and the "needs at least one option" hint clears. An authored option's value never moves. The kind picker shows only on the field the record path follows (`detectStatusField`) or where an option already declares a kind. Pin: `ObjectFieldInspector.optionLabelFirst-27521`. | | 26 | Taken | The Validations condition builder lists fields by label with the CEL spelling beside it (opt-in `labelSubjects`, so the flow mounts keep their spellings), under "Pick a field". A new rule's API name follows its message until edited; an authored name never moves. Type, severity, name and guard labels in plain words. Pins: `ConditionBuilder.subjectLabels-27521`, `ObjectValidationsPanel.ruleNameFromMessage-27521`. | | 27 | Card #27834 (suggestion half) | Suggestion half only (defect half is #27799): a "Triggering record" target, the object pre-filled from the trigger, field pickers for keys. A feature in the Update Records node editor. | | 28 | Taken (wording) + Card #27835 (naming) | "Atomic" is gone from the publish title, toast and confirm note (`en`, `zh`); the confirm note still says all go live or none do. Carrier check: #27356 row 17 landed in #27432 for flow nodes only. Naming other types' changes in words (a view, a validation rule by its label, keys as words) is a card. | | 29 | Will not do | Toast placement is console-wide (`ConsoleToaster`: top-centre by objectui#13340, bottom-right refused by objectui#7482). | | 30 | Taken (node cards) + will not do (header) | Node cards name the object by its catalog label (the host's one `useObjectCatalog` read). Pin: `FlowCanvas.objectLabel-27521`. The flow header's one-row truncation with the whole text on hover is designed (objectui#13135). The Interfaces rail row was not measured in a browser (see notes). | | 31 | Card #27836 | "Add view" with a type choice in the Interfaces list-view inspector. | | 32 | Taken | The object header counts the fields the canvas shows and the platform's columns apart ("2 fields (+8 system)"); "1 field" is singular there and on each form section. Pin: appended case in `DataPillar.hiddenSystemFields-11780`. | | 33 | Taken | Add field's Create stays focusable when it cannot run, `aria-disabled`, described by the missing step. Pin: `AddFieldPopover.createReason-27521`. | | 34 | Taken (text areas) + already done (Publish) | Disabled plain text areas in the object, field, flow-node and variable inspectors wear the primitive's disabled look. Pin: `ObjectDefaultInspector.readOnlyLook-27521`. Publish's reason on a read-only package is already there (the tooltip wrapper of objectui#12303). The number inputs read are the shared `Input` primitive, which already greys out. | | 35 | Taken | A publish that put an app or object live says members need a permission set and offers "Open Access". Pin: appended cases in `StudioDesignSurface.publishCount-11807`. | | 36 | Card #27837 | Formula editor: a field picker, plain parser errors, and the error line under the footer (the footer is not overlaid in `InspectorShell`, so the overlap needs a browser read). | | 37 | Will not do | Studio declares phones unsupported ("Studio is built for a desktop screen"); below 1024 px the pillar names ride the accessible name and tooltip by design (objectui#12725). | | 38 | Taken | The refusal names who grants Studio access (an administrator, in Setup → Permission Sets): `en` and `zh` packs, the Console's inline `defaultValue` in `StudioRoute.tsx` (the call-site gate ties it to `en`, objectui#3810) and the route test's `NOTICE`, together. The seat widened the file surface to the Console call site for this row. Pin: `studioAccessGrantor-27521`. | | 39 | Card #27838 | Node help is the engine's descriptor text (`useFlowNodePalette` adopts `description`), so the producer is `service-automation` / `plugin-approvals`; #27838 carries it with #27356 row 15's words half. | ## Tests New pins: the seven files named above. Existing tests that read the old copy or behaviour were moved with it: the rule-type labels, the derived rule names, the guard group's name, the "N fields" load signal ("1 field" now), the publish toast's Access action, the confirm note's sentence and its `en` claim marker. <details><summary>Readings</summary> - Row 38 on `8170cb82`: `studioAccessGrantor-27521`, `StudioRoute.test.tsx`, `StudioRoute.accessNotice-12035`: 43/43; `pnpm --dir ui exec vitest run scripts/`: 169 files passed. - `pnpm --filter @object-ui/app-shell type-check` on the tree of `eb92b33e` (under the verify lock, with the object-label pin's test): VERDICT command-exit 0. - `vitest run` over `studio-design/`, `metadata-admin/inspectors/`, `metadata-admin/previews/`, `preview/`, `i18n/src/__tests__/` and `StudioRoute.accessNotice-12035`: first run 755 files, 38 tests red, every one a pin of the old copy or behaviour; after moving them, the 25 affected files plus the new pins: 265/265 green; the last two pins 4/4. </details> ## Acceptance notes - **Line budget**: 358 changed lines (added plus removed) of source, copy and changeset, 788 of tests, over the card's ~600 guideline on the test side. Most of the test lines are the new pins; the rows left out are listed above, not cut for size. - **Cards filed from this pass**: #27833 (row 24), #27834 (row 27 suggestion half), #27835 (row 28 naming), #27836 (row 31), #27837 (row 36), #27838 (row 39, with #27356 row 15). Row 29 is answered will-not-do. - **Not measured in a browser** this run: the Interfaces rail's `field_service_work_order....` row (the rail reads the target's label, else its name; a draft-only object's label may be missing there) and row 36's footer overlap. - **Other packs**: `preview.changes.confirmNote` and `console.studio.accessRequired` changed in all ten packs; all ten carry both sentences (`check:i18n-drift`). - `docs/qa/platform-checklist/areas/studio-authoring.json` still quotes the old "(one atomic release)" toast; it is outside this card's file surface and is left as is. ## Verification Gates derived by `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 derived, 34 run, all exit 0 (`--ran` reconciliation: 0 NOT-MEASURED, 0 UNRUN). The per-gate lines are in the report comment on #27521. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CbkBiXQKn2AfkfK8tM)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7f80732 commit 637cfc6

52 files changed

Lines changed: 1065 additions & 185 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
'@object-ui/console': patch
5+
---
6+
7+
fix(studio): plainer authoring in Studio — option labels first, named validation rules, labelled condition fields, object labels on flow cards, honest field counts, a Create that says why it is off, and a publish toast that points to Access
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **Picklist options** read Label first, and a new option's stored value is filled in from its label until the author types one, so three labelled options are three saved options. An authored option's value never moves when it is relabelled. The lifecycle kind (Open / Won / Lost) is offered only on the field the record path follows, or where an option already declares one.
13+
- **Validation rules**: a new rule's API name follows its message (`Hours over 40` → `hours_over_40`) until the name is edited; an authored rule keeps its name. The condition builder lists fields by label, with the CEL spelling beside it, under "Pick a field". The rule type, severity and name labels are in plain words.
14+
- **Flow canvas**: a node card names the object it works on by the object's label.
15+
- **Object header**: counts the fields the canvas shows, and the platform's own columns apart (`2 fields (+8 system)`); "1 field" is singular.
16+
- **Add field**: a Create that cannot run yet stays focusable and says what is missing.
17+
- **Read-only inspectors**: disabled text areas take the muted disabled look.
18+
- **Publish**: the "one atomic release" wording is plain ("together: all of them go live, or none do"), and a publish that put an app or object live says members need a permission set, with an "Open Access" action.
19+
- **Studio access refusal** names who grants access (an administrator, in Setup → Permission Sets), in every language pack and in the Console's own fallback sentence. The publish confirm note's all-or-nothing wording is translated in every pack too.

‎ui/apps/console/src/components/StudioRoute.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ const pathname = () => screen.getByTestId('pathname').textContent;
152152
* answers. That the packs answer in the caller's language is the sibling
153153
* file's pin.
154154
*/
155-
const NOTICE = 'You need Studio access to open Studio.';
155+
const NOTICE = 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.';
156156
const WAY_HOME = 'Back to home';
157157

158158
/**

‎ui/apps/console/src/components/StudioRoute.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ function StudioAccessRequired({ home }: { home: string }) {
9898
</div>
9999
<EmptyTitle>
100100
{t('console.studio.accessRequired', {
101-
defaultValue: 'You need Studio access to open Studio.',
101+
defaultValue: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.',
102102
})}
103103
</EmptyTitle>
104104
<div className="mt-4">

‎ui/packages/app-shell/src/preview/DraftChangesPanel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1669,7 +1669,7 @@ export function DraftChangesPanel({
16691669
? t('preview.changes.confirmNote', {
16701670
count: entries!.length,
16711671
defaultValue:
1672-
'Publishing releases all {{count}} pending drafts of this package atomically.',
1672+
'Publishing releases all {{count}} pending drafts of this package together: all of them go live, or none do.',
16731673
})
16741674
: t('preview.changes.confirmNoteSeparate', {
16751675
count: entries!.length,

‎ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe('the confirm note says what this scope publishes (objectui#11591)', ()
100100
renderSheet('com.example.showcase');
101101

102102
await waitFor(async () =>
103-
expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package atomically.'),
103+
expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package: it goes live, or nothing changes.'),
104104
);
105105
});
106106

@@ -111,6 +111,6 @@ describe('the confirm note says what this scope publishes (objectui#11591)', ()
111111
]);
112112
renderSheet('com.example.showcase');
113113

114-
expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package atomically.');
114+
expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package together: all of them go live, or none do.');
115115
});
116116
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ConditionBuilder — a field subject reads by its label (objectstack#27521,
5+
* Studio row 26).
6+
*
7+
* The Validations pillar's "Reject the save when…" listed `record.estimated_hours`
8+
* under the placeholder "field / context", while the list-view filter in the
9+
* same product showed labels. A field subject now reads by its label, with the
10+
* spelling the CEL uses beside it; the compiled CEL is unchanged. A field with
11+
* no label keeps its spelling, and the context subjects are as before. The
12+
* label is the mount's choice (`labelSubjects`): a mount that lists spellings
13+
* keeps them.
14+
*/
15+
16+
import '@testing-library/jest-dom/vitest';
17+
import * as React from 'react';
18+
import { describe, it, expect, afterEach, vi } from 'vitest';
19+
import { render, cleanup, screen, fireEvent, within } from '@testing-library/react';
20+
21+
const state = vi.hoisted(() => ({
22+
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
23+
}));
24+
vi.mock('../useMetadata', () => ({
25+
useMetadataClient: () => state.metadataClient,
26+
}));
27+
28+
import { ConditionBuilder } from './ConditionBuilder';
29+
import { t } from '../i18n';
30+
31+
afterEach(cleanup);
32+
33+
const FIELDS = [
34+
{ name: 'estimated_hours', label: 'Estimated Hours', type: 'number' },
35+
{ name: 'code', type: 'text' },
36+
];
37+
38+
function mount(includePrevious = false, labels = true) {
39+
const onChange = vi.fn();
40+
render(
41+
<ConditionBuilder
42+
value=""
43+
onCommit={onChange}
44+
fields={FIELDS}
45+
subjects={includePrevious ? { includePrevious: true } : undefined}
46+
labelSubjects={labels}
47+
/>,
48+
);
49+
fireEvent.click(screen.getByRole('button', { name: t('engine.inspector.condition.add', 'en-US') }));
50+
return { onChange };
51+
}
52+
53+
async function openSubjects() {
54+
const trigger = screen.getAllByRole('combobox')[0];
55+
expect(trigger).toHaveTextContent(t('engine.inspector.condition.subjectPlaceholder', 'en-US'));
56+
fireEvent.keyDown(trigger, { key: 'ArrowDown' });
57+
return within(await screen.findByRole('listbox')).getAllByRole('option');
58+
}
59+
60+
describe('ConditionBuilder · field subjects read by label (objectstack#27521)', () => {
61+
it('a labelled field reads by its label with its spelling beside it; an unlabelled one by its spelling', async () => {
62+
mount();
63+
const options = await openSubjects();
64+
const texts = options.map((o) => o.textContent);
65+
expect(texts).toContain('Estimated Hoursrecord.estimated_hours');
66+
expect(texts).toContain('record.code');
67+
// The context subjects are unchanged.
68+
expect(texts).toContain('user.id');
69+
});
70+
71+
it('picking the labelled field still compiles to its CEL spelling', async () => {
72+
const { onChange } = mount();
73+
const options = await openSubjects();
74+
fireEvent.click(options.find((o) => o.textContent?.startsWith('Estimated Hours'))!);
75+
fireEvent.change(screen.getByPlaceholderText(t('engine.inspector.condition.valuePlaceholder', 'en-US')), {
76+
target: { value: '40' },
77+
});
78+
expect(JSON.stringify(onChange.mock.calls.at(-1)?.[0])).toContain('record.estimated_hours');
79+
});
80+
81+
it('a previous-value subject names the field and that it is the value before the change', async () => {
82+
mount(true);
83+
const texts = (await openSubjects()).map((o) => o.textContent);
84+
expect(texts).toContain('Estimated Hours (before the change)previous.estimated_hours');
85+
});
86+
87+
it('CONTROL — a mount that does not ask for labels lists the spellings', async () => {
88+
mount(false, false);
89+
const texts = (await openSubjects()).map((o) => o.textContent);
90+
expect(texts).toContain('record.estimated_hours');
91+
expect(texts.some((x) => x?.includes('Estimated Hours'))).toBe(false);
92+
});
93+
});

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import { BOOLEAN_VALUE_TYPES, expandEmptyOperator, type EmptyOperatorArm } from
3131
import { useObjectFields } from '../previews/useObjectFields.js';
3232
import { CelPredicateField } from '../CelPredicateField.js';
3333
import type { CelLintIssue } from '../celAuthoring.js';
34-
import { t, useMetadataLocale } from '../i18n.js';
34+
import { t, tFormat, useMetadataLocale } from '../i18n.js';
3535

3636
type Op = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'truthy' | 'falsy';
3737

@@ -820,7 +820,7 @@ function initFrom(value: string, valueOf: SubjectValueOf): { rows: Row[]; join:
820820
return { rows: [], join: '&&', raw: !!value };
821821
}
822822

823-
export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue }: {
823+
export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue, labelSubjects = false }: {
824824
label?: string;
825825
value: string;
826826
onCommit: (cel: string) => void;
@@ -906,6 +906,11 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
906906
* builder renders no verdict of its own, so it does not read this.
907907
*/
908908
scopeIssue?: boolean;
909+
/**
910+
* The subject dropdown names a field by its label, with the CEL spelling
911+
* beside it. Off by default, so a mount that lists spellings keeps them.
912+
*/
913+
labelSubjects?: boolean;
909914
}) {
910915
const { fields: hookFields } = useObjectFields(objectName);
911916
const fields = fieldsProp ?? hookFields;
@@ -921,27 +926,32 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
921926
* advertisement and the placeholder teaches its whole example, both unchanged.
922927
*/
923928
const offeredRoots = roots ?? (scope === 'record' ? RECORD_CONDITION_ROOTS : undefined);
929+
const locale = useMetadataLocale();
924930
const subjectOptions = React.useMemo(() => {
925931
const visible = fields.filter((f) => !f.hidden);
926-
const fieldOpts = visible.map((f) => ({
927-
value: `${fieldPrefix}${f.name}`,
928-
label: `${fieldPrefix}${f.name}`,
929-
}));
932+
// With `labelSubjects`, a field reads by its label, its spelling beside it.
933+
const named = (spelling: string, label: string | undefined) =>
934+
labelSubjects && label ? { value: spelling, label, hint: spelling } : { value: spelling, label: spelling, hint: undefined };
935+
const fieldOpts = visible.map((f) => named(`${fieldPrefix}${f.name}`, f.label));
930936
// The whole-record `previous` comes first: `previous == null` is how an
931937
// author branches on the create leg of a create-or-update trigger, and it
932938
// reads as the head of the group rather than as one more field.
933939
const previousOpts = includePrevious
934940
? [
935-
{ value: 'previous', label: 'previous' },
936-
...visible.map((f) => ({ value: `previous.${f.name}`, label: `previous.${f.name}` })),
941+
{ value: 'previous', label: 'previous', hint: undefined },
942+
...visible.map((f) =>
943+
named(
944+
`previous.${f.name}`,
945+
f.label ? tFormat('engine.inspector.condition.previousField', locale, { field: f.label }) : undefined,
946+
),
947+
),
937948
]
938949
: [];
939-
const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value }));
950+
const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: undefined }));
940951
return [...fieldOpts, ...previousOpts, ...contextOpts];
941-
}, [fields, fieldPrefix, includePrevious, contextSubjects]);
952+
}, [fields, fieldPrefix, includePrevious, contextSubjects, locale, labelSubjects]);
942953
// The raw-expression editor's CEL assists (#1582): field-existence lint +
943954
// autocomplete need the bare field-name catalog and a locale-bound `t`.
944-
const locale = useMetadataLocale();
945955
const tLocal = React.useCallback((k: string) => t(k, locale), [locale]);
946956
const fieldNames = React.useMemo(
947957
() => fields.filter((f) => !f.hidden).map((f) => f.name),
@@ -1145,7 +1155,12 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f
11451155
<Select value={r.subject} onValueChange={(v) => update(rows.map((x, j) => j === i ? { ...x, subject: v } : x))} disabled={disabled}>
11461156
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder={tLocal('engine.inspector.condition.subjectPlaceholder')} /></SelectTrigger>
11471157
<SelectContent>
1148-
{subjectOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
1158+
{subjectOptions.map((o) => (
1159+
<SelectItem key={o.value} value={o.value}>
1160+
{o.label}
1161+
{o.hint && <span className="ml-1.5 font-mono text-[10px] text-muted-foreground">{o.hint}</span>}
1162+
</SelectItem>
1163+
))}
11491164
{r.subject && !subjectOptions.some((o) => o.value === r.subject) && (
11501165
<SelectItem value={r.subject}>{r.subject}</SelectItem>
11511166
)}

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -862,7 +862,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
862862
disabled={readOnly}
863863
rows={6}
864864
placeholder="{ }"
865-
className="w-full rounded border bg-background px-2 py-1.5 font-mono text-xs"
865+
className="w-full rounded border bg-background px-2 py-1.5 font-mono text-xs disabled:cursor-not-allowed disabled:opacity-50"
866866
/>
867867
{advError && <div className="text-xs text-destructive">{advError}</div>}
868868
</div>

‎ui/packages/app-shell/src/views/metadata-admin/inspectors/NavFiltersEditor.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export function FiltersEditor({
7373
onChange={(e) => update(i, field, e.target.value)}
7474
placeholder={t('engine.inspector.appNav.filtersValue', locale)}
7575
disabled={disabled}
76-
className="h-7 w-0 min-w-0 flex-1 rounded border bg-background px-2 font-mono text-xs"
76+
className="h-7 w-0 min-w-0 flex-1 rounded border bg-background px-2 font-mono text-xs disabled:cursor-not-allowed disabled:opacity-50"
7777
data-testid={`nav-filter-value-${field || 'new'}`}
7878
/>
7979
<button
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27521 (Studio row 34) — on a read-only package the inspectors'
5+
* text areas look disabled, the way their inputs do.
6+
*
7+
* Measured in a Studio browser pass on the read-only showcase package: the
8+
* object's Description and a field's Description were `disabled` but kept the
9+
* editable look beside greyed inputs. Those text areas are plain elements, so
10+
* they carried none of the `disabled:` utilities the shared primitives wear.
11+
* The expected look is read off the primitive, as the Validations panel's pin
12+
* (objectui#11781) reads it.
13+
*/
14+
15+
import '@testing-library/jest-dom/vitest';
16+
import * as React from 'react';
17+
import { afterEach, describe, expect, it, vi } from 'vitest';
18+
import { cleanup, render } from '@testing-library/react';
19+
import { Textarea } from '@object-ui/components';
20+
21+
vi.mock('../useMetadata', () => ({
22+
useMetadataClient: () => ({
23+
list: vi.fn().mockResolvedValue([]),
24+
listDrafts: vi.fn().mockResolvedValue([]),
25+
}),
26+
}));
27+
28+
vi.mock('../previews/useObjectFields', () => ({
29+
useObjectFields: () => ({ fields: [], loading: false, error: null }),
30+
}));
31+
32+
import { ObjectDefaultInspector } from './ObjectDefaultInspector';
33+
import { ObjectFieldInspector } from './ObjectFieldInspector';
34+
35+
afterEach(cleanup);
36+
37+
/** The `disabled:` utilities the shared Textarea primitive wears. */
38+
function primitiveDisabledLook(): string[] {
39+
const { container, unmount } = render(<Textarea disabled />);
40+
const tokens = (container.querySelector('textarea') as HTMLTextAreaElement).className
41+
.split(/\s+/)
42+
.filter((c) => c.startsWith('disabled:'));
43+
unmount();
44+
return tokens;
45+
}
46+
47+
const draft = {
48+
name: 'probe_widget',
49+
label: 'Probe widget',
50+
description: 'An object',
51+
fields: { notes: { type: 'textarea', label: 'Notes', description: 'Free text' } },
52+
};
53+
54+
function expectDisabledLook(container: HTMLElement) {
55+
const look = primitiveDisabledLook();
56+
expect(look.length).toBeGreaterThan(0);
57+
const areas = [...container.querySelectorAll('textarea')];
58+
// A vacuous pass is not one: the inspector draws at least one text area.
59+
expect(areas.length).toBeGreaterThan(0);
60+
for (const el of areas) {
61+
expect(el).toBeDisabled();
62+
for (const token of look) expect(el.className.split(/\s+/)).toContain(token);
63+
}
64+
}
65+
66+
describe('read-only inspector text areas look disabled (objectstack#27521)', () => {
67+
it('the object inspector’s Description', () => {
68+
const { container } = render(
69+
<ObjectDefaultInspector type="object" name="probe_widget" draft={draft} onPatch={() => {}} readOnly locale="en-US" />,
70+
);
71+
expectDisabledLook(container);
72+
});
73+
74+
it('the field inspector’s text areas', () => {
75+
const { container } = render(
76+
<ObjectFieldInspector
77+
type="object"
78+
name="probe_widget"
79+
draft={draft}
80+
selection={{ kind: 'field', id: 'notes' }}
81+
onPatch={() => {}}
82+
onClearSelection={() => {}}
83+
onSelectionChange={() => {}}
84+
readOnly
85+
locale="en-US"
86+
/>,
87+
);
88+
expectDisabledLook(container);
89+
});
90+
});

0 commit comments

Comments
 (0)