From d0c302b149bb34350b4f3759ceb8b5d3e0a878c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 23:51:19 +0000 Subject: [PATCH 1/6] wip(studio): #27521 Studio rows 24-39 suggestions Claude-Session: https://claude.ai/code/session_01Utp6CbkBiXQKn2AfkfK8tM Co-authored-by: Claude --- ...hangesPanel.packageLessNote-11591.test.tsx | 4 +- .../inspectors/ConditionBuilder.tsx | 27 +++++--- .../inspectors/FlowNodeInspector.tsx | 2 +- .../inspectors/NavFiltersEditor.tsx | 2 +- .../inspectors/ObjectDefaultInspector.tsx | 2 +- .../inspectors/ObjectFieldInspector.tsx | 68 +++++++++++++++---- .../inspectors/VariableTextInput.tsx | 2 +- .../metadata-admin/previews/FlowCanvas.tsx | 21 ++++-- .../metadata-admin/previews/FlowPreview.tsx | 12 ++++ .../views/studio-design/AddFieldPopover.tsx | 19 +++++- .../DataPillar.addFieldPopover-12516.test.tsx | 14 ++-- .../studio-design/ObjectFormDesigner.tsx | 4 +- .../studio-design/ObjectValidationsPanel.tsx | 42 +++++++++++- .../studio-design/StudioDesignSurface.tsx | 46 +++++++++++-- .../confirmNoteSeparate-11591.test.ts | 2 +- ui/packages/i18n/src/locales/en.ts | 8 +-- ui/packages/i18n/src/locales/zh.ts | 8 +-- ui/packages/i18n/src/namespaces/studio/en.ts | 30 +++++--- ui/packages/i18n/src/namespaces/studio/zh.ts | 29 +++++--- 19 files changed, 261 insertions(+), 81 deletions(-) diff --git a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx index 18790c087bf..0d9f5701c47 100644 --- a/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx +++ b/ui/packages/app-shell/src/preview/__tests__/DraftChangesPanel.packageLessNote-11591.test.tsx @@ -100,7 +100,7 @@ describe('the confirm note says what this scope publishes (objectui#11591)', () renderSheet('com.example.showcase'); await waitFor(async () => - expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package atomically.'), + expect(await confirmNote()).toBe('Publishing releases the 1 pending draft of this package: it goes live, or nothing changes.'), ); }); @@ -111,6 +111,6 @@ describe('the confirm note says what this scope publishes (objectui#11591)', () ]); renderSheet('com.example.showcase'); - expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package atomically.'); + expect(await confirmNote()).toBe('Publishing releases all 2 pending drafts of this package together: all of them go live, or none do.'); }); }); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx index f2683be26e0..0360a4cd32d 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx @@ -31,7 +31,7 @@ import { BOOLEAN_VALUE_TYPES, expandEmptyOperator, type EmptyOperatorArm } from import { useObjectFields } from '../previews/useObjectFields.js'; import { CelPredicateField } from '../CelPredicateField.js'; import type { CelLintIssue } from '../celAuthoring.js'; -import { t, useMetadataLocale } from '../i18n.js'; +import { t, tFormat, useMetadataLocale } from '../i18n.js'; type Op = '==' | '!=' | '>' | '<' | '>=' | '<=' | 'truthy' | 'falsy'; @@ -921,27 +921,33 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f * advertisement and the placeholder teaches its whole example, both unchanged. */ const offeredRoots = roots ?? (scope === 'record' ? RECORD_CONDITION_ROOTS : undefined); + const locale = useMetadataLocale(); const subjectOptions = React.useMemo(() => { const visible = fields.filter((f) => !f.hidden); + // A field reads by its label, with the subject spelling beside it. const fieldOpts = visible.map((f) => ({ value: `${fieldPrefix}${f.name}`, - label: `${fieldPrefix}${f.name}`, + label: f.label || `${fieldPrefix}${f.name}`, + hint: f.label ? `${fieldPrefix}${f.name}` : undefined, })); // The whole-record `previous` comes first: `previous == null` is how an // author branches on the create leg of a create-or-update trigger, and it // reads as the head of the group rather than as one more field. const previousOpts = includePrevious ? [ - { value: 'previous', label: 'previous' }, - ...visible.map((f) => ({ value: `previous.${f.name}`, label: `previous.${f.name}` })), + { value: 'previous', label: 'previous', hint: undefined }, + ...visible.map((f) => ({ + value: `previous.${f.name}`, + label: f.label ? tFormat('engine.inspector.condition.previousField', locale, { field: f.label }) : `previous.${f.name}`, + hint: f.label ? `previous.${f.name}` : undefined, + })), ] : []; - const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value })); + const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: undefined })); return [...fieldOpts, ...previousOpts, ...contextOpts]; - }, [fields, fieldPrefix, includePrevious, contextSubjects]); + }, [fields, fieldPrefix, includePrevious, contextSubjects, locale]); // The raw-expression editor's CEL assists (#1582): field-existence lint + // autocomplete need the bare field-name catalog and a locale-bound `t`. - const locale = useMetadataLocale(); const tLocal = React.useCallback((k: string) => t(k, locale), [locale]); const fieldNames = React.useMemo( () => fields.filter((f) => !f.hidden).map((f) => f.name), @@ -1145,7 +1151,12 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f update(i, { value: e.target.value })} - placeholder={t('designer.field.optValue', locale)} - disabled={disabled} - className="h-7 min-w-0 flex-1 text-xs font-mono" - /> update(i, { label: e.target.value })} + onChange={(e) => updateLabel(i, e.target.value)} placeholder={t('designer.field.optLabel', locale)} + aria-label={t('designer.field.optLabel', locale)} disabled={disabled} + data-testid="option-label" className="h-7 min-w-0 flex-1 text-xs" /> + updateValue(i, e.target.value)} + placeholder={t('designer.field.optValue', locale)} + aria-label={t('designer.field.optValueName', locale)} + disabled={disabled} + data-testid="option-value" + className="h-7 min-w-0 flex-1 text-xs font-mono" + />
- {kindAllowed && ( + {showKind && ( diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx index 2b2c92bfdd9..ada2d56f8a9 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.tsx @@ -178,6 +178,11 @@ export interface FlowCanvasProps { * `freshNodeId` (`flow-problems`) over the draft alone. */ mintNodeId?: () => string; + /** + * The object catalog's labels by object name: a node card names the object + * it works on by its label, not its API name. Absent, the name shows. + */ + objectLabels?: ReadonlyMap; } export function FlowCanvas({ @@ -202,6 +207,7 @@ export function FlowCanvas({ onSelectNested, onPatch, mintNodeId, + objectLabels, }: FlowCanvasProps) { // objectui#3172 — the ONE geometry boundary: nodes enter the canvas with the // retired `ui: {x,y}` spelling already lifted onto the spec's `position`, so @@ -1256,7 +1262,7 @@ export function FlowCanvas({ locale={locale} type={node.type} label={node.label || node.id} - summary={nodeSummary(node, locale)} + summary={nodeSummary(node, locale, objectLabels)} position={pos} selected={selectedId === node.id} editable={editable} @@ -1509,9 +1515,14 @@ function layoutBranchLabels( * The words it adds of its own (a branch count, an approver count, `code`) * read in the designer `locale` (objectui#10862); config values pass through. */ -function nodeSummary(node: FlowDesignerNode, locale?: string): string | undefined { +function nodeSummary( + node: FlowDesignerNode, + locale?: string, + objectLabels?: ReadonlyMap, +): string | undefined { const c = node.config as Record | undefined; const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined); + const object = (name: string | undefined) => (name ? objectLabels?.get(name) || name : undefined); const block = (key: string, inner: string) => { const b = (node as Record)[key]; return b && typeof b === 'object' ? str((b as Record)[inner]) : undefined; @@ -1527,12 +1538,12 @@ function nodeSummary(node: FlowDesignerNode, locale?: string): string | undefine return ( pick('condition') || pick('criteria') || - pick('objectName') || + object(pick('objectName')) || cfgBlock('schedule', 'expression') || cfgBlock('schedule', 'cron') || pick('cron') || pick('schedule') || - cfgBlock('timeRelative', 'object') || + object(cfgBlock('timeRelative', 'object')) || pick('triggerType') ); } @@ -1574,7 +1585,7 @@ function nodeSummary(node: FlowDesignerNode, locale?: string): string | undefine return behavior || undefined; } return ( - pick('objectName') || + object(pick('objectName')) || block('connectorConfig', 'actionId') || block('waitEventConfig', 'timerDuration') || block('waitEventConfig', 'eventType') || diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx index 247a9492334..70659175020 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/FlowPreview.tsx @@ -336,6 +336,17 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa // refused or unregistered flow's status (the stored `status` stays as read). const registration = React.useContext(FlowRegistrationContext); const runStatus = deriveFlowRunStatus(runtime, d.status, registration); + // The node cards name the object a node works on by its label. + const objectCatalog = useObjectCatalog(); + const objectLabels = React.useMemo( + () => + new Map( + (objectCatalog.status === 'loaded' ? objectCatalog.data : NO_OBJECTS).flatMap((o) => + o.label ? [[o.name, o.label] as const] : [], + ), + ), + [objectCatalog], + ); // objectui#12454 — a host that carries the meta and the tab row itself. const chrome = React.useContext(FlowPreviewChromeContext); @@ -482,6 +493,7 @@ export function FlowPreview({ draft, editing, selection, onSelectionChange, onPa selectedId={selectedId} selectedEdgeId={selectedEdgeId} locale={locale} + objectLabels={objectLabels} activeNodeId={runHL?.activeNodeId ?? null} visitedNodeIds={runHL?.visitedNodeIds} traversedEdgeIds={runHL?.traversedEdgeIds} diff --git a/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx b/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx index 5848b197704..80b0cce6108 100644 --- a/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx +++ b/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.tsx @@ -97,6 +97,7 @@ export function AddFieldPopover({ const labelId = React.useId(); const apiNameId = React.useId(); const apiNameHintId = React.useId(); + const createReasonId = React.useId(); const setOpen = (next: boolean) => { if (openProp === undefined) setOpenState(next); @@ -131,6 +132,13 @@ export function AddFieldPopover({ : derivedTaken ? fieldNameTakenRefusal(derived, takenNames, locale) : t('engine.studio.data.addFieldApiNameNotDerived', locale); + // Why Create is off, said on the button itself: it stays focusable and + // describes the missing step instead of being skipped by Tab. + const createReason = canCreate + ? null + : !trimmed + ? t('engine.studio.data.addFieldNeedsLabel', locale) + : (nameHint ?? t('engine.studio.data.addFieldApiNameNotDerived', locale)); return ( @@ -205,12 +213,19 @@ export function AddFieldPopover({ + {createReason && ( + + {createReason} + + )}
diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx index d57bb840940..ea40b32c5b9 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.addFieldPopover-12516.test.tsx @@ -128,11 +128,11 @@ describe('Data pillar — Add field asks for the label and type first (objectui# const type = within(popover).getByRole('combobox', { name: t('designer.field.type', 'en') }); expect(type).toHaveAttribute('data-field-type', 'text'); const create = within(popover).getByTestId('add-field-create'); - expect(create).toBeDisabled(); + expect(create).toHaveAttribute('aria-disabled', 'true'); await userEvent.type(label, ' '); - expect(create).toBeDisabled(); + expect(create).toHaveAttribute('aria-disabled', 'true'); await userEvent.type(label, 'Score'); - expect(create).toBeEnabled(); + expect(create).not.toHaveAttribute('aria-disabled'); }); it('Create adds the field named as typed with the chosen type, and opens it in the inspector', async () => { @@ -214,7 +214,7 @@ describe('Data pillar — Add field asks for the label and type first (objectui# const popover = await screen.findByTestId('add-field-popover'); await userEvent.type(within(popover).getByTestId('add-field-label-input'), '满意度'); const create = within(popover).getByTestId('add-field-create'); - expect(create).toBeDisabled(); + expect(create).toHaveAttribute('aria-disabled', 'true'); expect(within(popover).getByTestId('add-field-api-name-hint')).toHaveTextContent( t('engine.studio.data.addFieldApiNameNotDerived', 'en'), ); @@ -222,11 +222,11 @@ describe('Data pillar — Add field asks for the label and type first (objectui# // Typed through the same normaliser as the inspector's API name box. expect(within(popover).getByTestId('add-field-api-name-input')).toHaveValue('satisfaction'); expect(within(popover).queryByTestId('add-field-api-name-hint')).toBeNull(); - expect(create).toBeEnabled(); + expect(create).not.toHaveAttribute('aria-disabled'); // A typed name another field holds is refused here too. await userEvent.clear(within(popover).getByTestId('add-field-api-name-input')); await userEvent.type(within(popover).getByTestId('add-field-api-name-input'), 'name'); - expect(create).toBeDisabled(); + expect(create).toHaveAttribute('aria-disabled', 'true'); expect(within(popover).getByTestId('add-field-api-name-hint')).toHaveTextContent('name'); expect(mockClient.save).not.toHaveBeenCalled(); }); @@ -238,7 +238,7 @@ describe('Data pillar — Add field asks for the label and type first (objectui# await userEvent.type(within(popover).getByTestId('add-field-label-input'), 'Health Score'); expect(within(popover).getByTestId('add-field-api-name-preview')).toHaveTextContent('health_score'); expect(within(popover).queryByTestId('add-field-api-name-input')).toBeNull(); - expect(within(popover).getByTestId('add-field-create')).toBeEnabled(); + expect(within(popover).getByTestId('add-field-create')).not.toHaveAttribute('aria-disabled'); }); it('the Records grid "+" opens the same popover, and focus goes back to it on Cancel', async () => { diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx index c8b26f09f45..aec0624c6bc 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx @@ -564,7 +564,9 @@ function Section({ {title} )} - {tFormat('engine.studio.data.fieldCount', locale, { count: fieldIds.length })} + {tFormat(fieldIds.length === 1 ? 'engine.studio.data.fieldCountOne' : 'engine.studio.data.fieldCount', locale, { + count: fieldIds.length, + })}
{isDeclared && !readOnly && ( diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx index 84f0b2498c4..37d77fd6780 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.tsx @@ -100,7 +100,7 @@ import { } from '@object-ui/components'; import { ConditionBuilder, RECORD_CONDITION_SUBJECTS } from '../metadata-admin/inspectors/ConditionBuilder.js'; import { expressionSource, writeExpressionSource } from '../metadata-admin/inspectors/expression-envelope.js'; -import { readFields } from '../metadata-admin/previews/object-fields-io.js'; +import { readFields, toFieldNameOrEmpty } from '../metadata-admin/previews/object-fields-io.js'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import type { ExpressionInput } from '@objectstack/spec/shared'; import { ScriptValidationSchema, type ScriptValidationParsed } from '@objectstack/spec/data'; @@ -350,6 +350,22 @@ function nextRuleName(existing: string[]): string { return name; } +/** The longest rule name {@link ruleNameFromMessage} derives, before a suffix. */ +const DERIVED_RULE_NAME_MAX = 48; + +/** + * A rule name read off its message (`Hours over 40` → `hours_over_40`), made + * unique against `taken`; `null` when the message yields no machine name (a + * message in a non-Latin script), so the minted name stays. + */ +function ruleNameFromMessage(message: string, taken: ReadonlySet): string | null { + const base = toFieldNameOrEmpty(message).slice(0, DERIVED_RULE_NAME_MAX).replace(/_+$/, ''); + if (!base) return null; + let name = base; + for (let i = 2; taken.has(name); i++) name = `${base}_${i}`; + return name; +} + /** * Which key carries a rule's CEL guard, per rule type. * @@ -1013,6 +1029,23 @@ export function ObjectValidationsPanel({ setUnsaved((prev) => prev.map((r) => (r.name === name ? next : r))); }; + // The names this panel minted for rules it added: such a name follows the + // rule's message until the author edits the name, never an authored one. + const derivedNames = React.useRef(new Set()); + const patchMessage = (name: string, message: string) => { + const next = derivedNames.current.has(name) + ? ruleNameFromMessage(message, new Set(listed.map((r) => r.name ?? '').filter((n) => n !== name))) + : null; + if (!next || next === name) { + patchRule(name, { message }); + return; + } + derivedNames.current.delete(name); + derivedNames.current.add(next); + patchRule(name, { message, name: next }); + setSelected(next); + }; + const patchRule = (name: string, patch: Partial) => { const pending = unsaved.find((r) => r.name === name); if (pending) { @@ -1024,6 +1057,7 @@ export function ObjectValidationsPanel({ /** Put a new rule in place: written when it lacks nothing, held otherwise (objectui#11820, objectstack#26731). */ const placeNewRule = (rule: ValidationRuleDraft) => { + if (rule.name) derivedNames.current.add(rule.name); if (missingPart(rule) === null) commit([...rules, rule]); else setUnsaved((prev) => [...prev, rule]); setSelected(rule.name ?? null); @@ -1039,8 +1073,9 @@ export function ObjectValidationsPanel({ * and the editor opens on the condition. */ const addPreset = (preset: ValidationPreset, plan: Extract) => { + const taken = listed.map((r) => r.name ?? ''); const rule: ValidationRuleDraft = { - ...newRule(preset.type, nextRuleName(listed.map((r) => r.name ?? '')), firstField), + ...newRule(preset.type, ruleNameFromMessage(plan.message, new Set(taken)) ?? nextRuleName(taken), firstField), message: plan.message, }; if (plan.fields) rule.fields = plan.fields; @@ -1323,6 +1358,7 @@ export function ObjectValidationsPanel({ disabled={disabled} onChange={(e) => { const name = e.target.value; + derivedNames.current.delete(sel.name!); patchRule(sel.name!, { name }); setSelected(name); }} @@ -1344,7 +1380,7 @@ export function ObjectValidationsPanel({ patchRule(sel.name!, { message: e.target.value })} + onChange={(e) => patchMessage(sel.name!, e.target.value)} placeholder={t('engine.studio.rules.messagePlaceholder', locale)} className={`w-full rounded border bg-background px-2 py-1 text-[12px] ${DISABLED_LOOK}`} /> 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 a72dc91de47..602a069b529 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -1688,6 +1688,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React return new Map([...publishRefusals].filter(([key]) => listed.has(key))); }, [feedCount, pendingEntries, publishRefusals]); + // The publish toast's way to the Access pillar; set once the router's + // navigate is in hand below. + const openAccessRef = React.useRef<() => void>(() => {}); + // `options` is the review sheet's (objectui#12929): its checked backfill // choices, or nothing. Only the package batch door below carries it; the // sheet offers no choice in the package-less scope. @@ -1859,10 +1863,28 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // wrote. A field that stopped short leaves the publish standing, so the // toast still says what went live, as a warning that carries why. const receipt = payload?.backfillApplied; + // A published app or object reaches members only through a permission + // set, so the toast points the author to Access. + const reachesMembers = + Array.isArray(published) && + published.some((p) => { + const type = (p as { type?: unknown } | null)?.type; + return type === 'app' || type === 'object'; + }); + const access = reachesMembers + ? { + action: { + label: t('engine.studio.publishedOpenAccess', locale), + onClick: () => openAccessRef.current(), + }, + } + : {}; if (receipt) { const description = formatBackfillReceipt(receipt, locale); - if (receipt.success === false) toast.warning(title, { description }); - else toast.success(title, { description }); + if (receipt.success === false) toast.warning(title, { description, ...access }); + else toast.success(title, { description, ...access }); + } else if (reachesMembers) { + toast.success(title, { description: t('engine.studio.publishedAccessHint', locale), ...access }); } else { toast.success(title); } @@ -1934,6 +1956,11 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React // package ships an app, offer Open app — opened in a new tab so the builder // context survives. (App → builder is the reverse bridge, tracked separately.) const shellNavigate = useNavigate(); + React.useEffect(() => { + openAccessRef.current = () => { + if (confirmLeavePillar()) shellNavigate(`/studio/${scopeSegment}/access`); + }; + }); // objectui#11181 — `label` is the app's own `I18nLabel` (a plain string or an // inline locale map), held as the spec types it and never `String()`ed: a map // would read `[object Object]`. Only the presence of `packageApp` is read @@ -6942,7 +6969,18 @@ export function DataPillar({ }; }, [client, current, packageId, publishNonce, objReloadNonce, recordObjRead]); - const fieldCount = React.useMemo(() => readFields(objDraft.fields).entries.length, [objDraft]); + // The header counts the fields the canvas shows, and names the platform's + // own columns apart: "2 fields (+8 system)". + const fieldCount = React.useMemo(() => { + const all = readFields(objDraft.fields).entries.length; + const own = formFieldNames(objDraft.fields).length; + return { own, system: all - own }; + }, [objDraft]); + const fieldCountText = + tFormat(fieldCount.own === 1 ? 'engine.studio.data.fieldCountOne' : 'engine.studio.data.fieldCount', locale, { + count: fieldCount.own, + }) + + (fieldCount.system > 0 ? ` ${tFormat('engine.studio.data.systemFieldCount', locale, { count: fieldCount.system })}` : ''); /** * The design-mode grid's columns: the object's own fields in metadata order, @@ -7573,7 +7611,7 @@ export function DataPillar({ title={current ? current.label : t('engine.studio.data.objects', locale)} titleProps={current ? undefined : { className: 'text-[13px] font-normal text-muted-foreground' }} apiName={current?.name} - meta={current && objLoaded ? tFormat('engine.studio.data.fieldCount', locale, { count: fieldCount }) : undefined} + meta={current && objLoaded ? fieldCountText : undefined} status={objDraftStatus} actions={ <> diff --git a/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts b/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts index 13a8dc6e2d8..e3bb7182b56 100644 --- a/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts +++ b/ui/packages/i18n/src/__tests__/confirmNoteSeparate-11591.test.ts @@ -43,7 +43,7 @@ const LANGS = Object.keys(builtInLocales) as LocaleCode[]; /** The atomicity claim, as each pack's package sentence spells it. */ const CLAIM: Record = { - en: /atomically/u, + en: /go live, or none do|goes live, or nothing changes/u, de: /atomar/u, es: /atómicamente/u, fr: /atomiquement/u, diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ca3a3ccf5af..4e14133a782 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2109,7 +2109,7 @@ const en = { title: 'Studio', // What a principal without `studio.access` reads at the Studio URL they // opened (objectui#12035), in place of the silent redirect home. - accessRequired: 'You need Studio access to open Studio.', + accessRequired: 'You need Studio access to open Studio. An administrator can grant it with a permission set in Setup → Permission Sets.', }, saveAdvisoryTitle: 'Saved — the authoring check raised {{count}} advisory finding(s)', saveAdvisoryTitle_one: 'Saved — the authoring check raised {{count}} advisory finding', @@ -3828,9 +3828,9 @@ const en = { detailChangedKeys: 'Also changed:', detailNewKeys: 'Includes:', generatedGroup: 'Generated by the platform ({{n}})', - confirmNote: 'Publishing releases all {{count}} pending drafts of this package atomically.', - confirmNote_one: 'Publishing releases the {{count}} pending draft of this package atomically.', - confirmNote_other: 'Publishing releases all {{count}} pending drafts of this package atomically.', + confirmNote: 'Publishing releases all {{count}} pending drafts of this package together: all of them go live, or none do.', + confirmNote_one: 'Publishing releases the {{count}} pending draft of this package: it goes live, or nothing changes.', + confirmNote_other: 'Publishing releases all {{count}} pending drafts of this package together: all of them go live, or none do.', // objectui#11591 — the package-less sheet: no package to batch, so each // draft publishes by itself and one that fails stays pending. confirmNoteSeparate: 'Publishing releases the {{count}} pending drafts one at a time: a draft that fails stays pending while the others go live.', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 875385d9684..2ce8a894562 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1783,7 +1783,7 @@ const zh = { console: { studio: { title: 'Studio', - accessRequired: '你需要 Studio 访问权限才能打开 Studio。', + accessRequired: '你需要 Studio 访问权限才能打开 Studio。管理员可以在“设置 → 权限集”中通过权限集授予。', }, saveAdvisoryTitle: '已保存 — 编辑检查提出了 {{count}} 条建议', saveAdvisoryTitle_one: '已保存 — 编辑检查提出了 {{count}} 条建议', @@ -3310,9 +3310,9 @@ const zh = { detailChangedKeys: '其他变更:', detailNewKeys: '包含:', generatedGroup: '平台生成({{n}})', - confirmNote: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', - confirmNote_one: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', - confirmNote_other: '发布将一次性(原子地)发布此包全部 {{count}} 个待发布草稿。', + confirmNote: '发布将一次性发布此包全部 {{count}} 个待发布草稿:要么全部生效,要么都不生效。', + confirmNote_one: '发布将一次性发布此包全部 {{count}} 个待发布草稿:要么全部生效,要么都不生效。', + confirmNote_other: '发布将一次性发布此包全部 {{count}} 个待发布草稿:要么全部生效,要么都不生效。', // objectui#11591 — the package-less sheet: no package to batch, so each // draft publishes by itself and one that fails stays pending. confirmNoteSeparate: '发布会逐个单独发布这 {{count}} 个待发布草稿:发布失败的草稿仍保持待发布,其余草稿照常发布。', diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 66c729b6602..4eb6d5e2448 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -362,6 +362,8 @@ const STUDIO_EN: Record = { 'designer.field.optMalformedHint': 'It is kept exactly as authored. Repair it in the JSON source, or remove it here.', 'designer.field.addValue': 'Add value', 'designer.field.optValue': 'value', + // The option's stored value, filled in from its label on a new option. + 'designer.field.optValueName': 'Stored value', 'designer.field.optLabel': 'Label', 'designer.field.optColor': 'Color', 'designer.field.optKind': 'Lifecycle kind', @@ -1369,7 +1371,8 @@ const STUDIO_EN: Record = { 'engine.inspector.condition.always': 'Always — no condition.', 'engine.inspector.condition.and': 'AND', 'engine.inspector.condition.or': 'OR', - 'engine.inspector.condition.subjectPlaceholder': 'field / context', + 'engine.inspector.condition.subjectPlaceholder': 'Pick a field', + 'engine.inspector.condition.previousField': '{field} (before the change)', 'engine.inspector.condition.remove': 'Remove condition', 'engine.inspector.condition.valuePlaceholder': 'value', 'engine.inspector.condition.add': 'Add condition', @@ -2319,6 +2322,9 @@ const STUDIO_EN: Record = { 'engine.studio.data.newFieldLabel': 'New field', 'engine.studio.data.nameFieldLabel': 'Name', 'engine.studio.data.fieldCount': '{count} fields', + 'engine.studio.data.fieldCountOne': '{count} field', + // The platform's own columns, counted apart from the author's fields. + 'engine.studio.data.systemFieldCount': '(+{count} system)', 'engine.studio.data.pickObject': 'Select an object', 'engine.studio.data.objects': 'Objects', 'engine.studio.data.searchObjects': 'Search objects…', @@ -2344,6 +2350,7 @@ const STUDIO_EN: Record = { 'engine.studio.data.tab.api': 'API', 'engine.studio.data.addField': 'Add field', 'engine.studio.data.addFieldCreate': 'Create', + 'engine.studio.data.addFieldNeedsLabel': 'Type a label first.', 'engine.studio.data.addFieldApiNameNotDerived': 'The label has no Latin letters to make an API name from. Type one: lowercase letters, digits and underscores.', 'engine.studio.data.addFieldApiNameTaken': 'Another field is already named {name}. Type a different API name.', 'engine.studio.data.editFieldProps': 'Edit field properties', @@ -3063,12 +3070,15 @@ const STUDIO_EN: Record = { 'engine.studio.pillar.access': 'Access', // Header · package-level publish + app bridge 'engine.studio.changes': 'Changes', - 'engine.studio.publishTitle': 'Confirm and publish all pending drafts at once (whole package · one atomic release)', + 'engine.studio.publishTitle': 'Review and publish every pending draft in this package together', 'engine.studio.publishNoneTitle': 'No drafts pending publish', - 'engine.studio.publishedAll': 'Published all drafts in this package (one atomic release)', + 'engine.studio.publishedAll': 'Published all drafts in this package', // objectui#11807 — the publish toast names how many items went live. - 'engine.studio.publishedAllCount': 'Published {count} items in this package (one atomic release)', - 'engine.studio.publishedAllCountOne': 'Published {count} item in this package (one atomic release)', + 'engine.studio.publishedAllCount': 'Published {count} items in this package', + 'engine.studio.publishedAllCountOne': 'Published {count} item in this package', + // A published app or object reaches members only through a permission set. + 'engine.studio.publishedAccessHint': 'Members can use it once a permission set grants them access.', + 'engine.studio.publishedOpenAccess': 'Open Access', // objectui#12929 — the batch door's backfill receipt, one clause per field // the review sheet named: the rows the server wrote, and why a field stopped. 'engine.studio.backfillWritten': 'Default backfilled into {field}: {count} record(s)', @@ -3160,23 +3170,23 @@ const STUDIO_EN: Record = { 'engine.studio.rules.structured': '“{type}” rules carry structured configuration (state-machine transitions / format constraints etc.) and cannot be edited here yet — maintain them in the code package. Message: {message}', 'engine.studio.rules.none2': '(none)', 'engine.studio.rules.deleteRule': 'Delete rule', - 'engine.studio.rules.nameLabel': 'Rule name (snake_case, unique)', + 'engine.studio.rules.nameLabel': 'API name (filled in from the message until you edit it)', 'engine.studio.rules.messageLabel': 'Error message (shown to the user when validation fails)', 'engine.studio.rules.messagePlaceholder': 'e.g. Completion date is required when status is Done', - 'engine.studio.rules.celPre': 'Fail condition (CEL) — when the condition is ', + 'engine.studio.rules.celPre': 'When this condition is ', 'engine.studio.rules.celTrue': 'true', 'engine.studio.rules.celMid': ', the save is rejected with the message above.', // A conditional rule's `when`: it says when the `then` rule applies. 'engine.studio.rules.whenPre': 'Guard (CEL) — when the condition is ', 'engine.studio.rules.whenMid': ', the “Then” rule below applies; otherwise the “Otherwise” rule, if one is set.', 'engine.studio.rules.severity': 'Severity', - 'engine.studio.rules.severityError': 'error (rejects the save)', + 'engine.studio.rules.severityError': 'Error — the save is rejected', 'engine.studio.rules.enabled': 'Enabled', 'engine.studio.rules.delete': 'Delete', 'engine.studio.rules.type': 'Type', 'engine.studio.rules.newType': 'New validation type', - 'engine.studio.rules.typeScript': 'Script — CEL fail condition', - 'engine.studio.rules.typeCrossField': 'Cross-field — CEL over multiple fields', + 'engine.studio.rules.typeScript': 'Condition — reject the save when it is true', + 'engine.studio.rules.typeCrossField': 'Compare fields — a condition over several fields', 'engine.studio.rules.typeStateMachine': 'State machine — allowed transitions', 'engine.studio.rules.typeFormat': 'Format — regex / built-in format', 'engine.studio.rules.typeJsonSchema': 'JSON schema — validate a JSON field', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 0ad91d22553..b16760da6bb 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -299,6 +299,7 @@ const STUDIO_ZH: Record = { 'designer.field.optMalformedHint': '它按原样保留。请在 JSON 源码中修复,或在此处删除。', 'designer.field.addValue': '添加选项', 'designer.field.optValue': '值', + 'designer.field.optValueName': '存储值', 'designer.field.optLabel': '显示名', 'designer.field.optColor': '颜色', 'designer.field.optKind': '生命周期类别', @@ -1225,7 +1226,8 @@ const STUDIO_ZH: Record = { 'engine.inspector.condition.always': '始终生效 —— 无条件。', 'engine.inspector.condition.and': '且', 'engine.inspector.condition.or': '或', - 'engine.inspector.condition.subjectPlaceholder': '字段 / 上下文', + 'engine.inspector.condition.subjectPlaceholder': '选择字段', + 'engine.inspector.condition.previousField': '{field}(修改前)', 'engine.inspector.condition.remove': '移除条件', 'engine.inspector.condition.valuePlaceholder': '值', 'engine.inspector.condition.add': '添加条件', @@ -2089,7 +2091,9 @@ const STUDIO_ZH: Record = { // Data pillar 'engine.studio.data.newFieldLabel': '新字段', 'engine.studio.data.nameFieldLabel': '名称', - 'engine.studio.data.fieldCount': '{count} 字段', + 'engine.studio.data.fieldCount': '{count} 个字段', + 'engine.studio.data.fieldCountOne': '{count} 个字段', + 'engine.studio.data.systemFieldCount': '(另有 {count} 个系统字段)', 'engine.studio.data.pickObject': '选择一个对象', 'engine.studio.data.objects': '对象', 'engine.studio.data.searchObjects': '搜索对象…', @@ -2114,6 +2118,7 @@ const STUDIO_ZH: Record = { 'engine.studio.data.tab.api': 'API', 'engine.studio.data.addField': '添加字段', 'engine.studio.data.addFieldCreate': '创建', + 'engine.studio.data.addFieldNeedsLabel': '请先填写标签。', 'engine.studio.data.addFieldApiNameNotDerived': '标签里没有可用来生成 API 名称的英文字母,请手动填写(小写字母、数字和下划线)。', 'engine.studio.data.addFieldApiNameTaken': '已有字段名为 {name},请填写其他 API 名称。', 'engine.studio.data.editFieldProps': '编辑字段属性', @@ -2720,12 +2725,14 @@ const STUDIO_ZH: Record = { 'engine.studio.pillar.access': '权限', // Header · package-level publish + app bridge 'engine.studio.changes': '变更', - 'engine.studio.publishTitle': '一次性确认并发布全部待发布草稿(整包 · 一次原子发布)', + 'engine.studio.publishTitle': '检查并一并发布本软件包的全部待发布草稿', 'engine.studio.publishNoneTitle': '没有待发布的草稿', - 'engine.studio.publishedAll': '已发布本软件包的全部草稿(一次原子发布)', + 'engine.studio.publishedAll': '已发布本软件包的全部草稿', // objectui#11807 — the publish toast names how many items went live. - 'engine.studio.publishedAllCount': '已发布本软件包的 {count} 项(一次原子发布)', - 'engine.studio.publishedAllCountOne': '已发布本软件包的 {count} 项(一次原子发布)', + 'engine.studio.publishedAllCount': '已发布本软件包的 {count} 项', + 'engine.studio.publishedAllCountOne': '已发布本软件包的 {count} 项', + 'engine.studio.publishedAccessHint': '成员需要通过权限集获得访问后才能使用。', + 'engine.studio.publishedOpenAccess': '打开权限', // objectui#12929 — the batch door's backfill receipt, one clause per field // the review sheet named: the rows the server wrote, and why a field stopped. 'engine.studio.backfillWritten': '已为 {field} 补默认值:{count} 条记录', @@ -2806,22 +2813,22 @@ const STUDIO_ZH: Record = { 'engine.studio.rules.structured': '「{type}」类型的规则带有结构化配置(状态机转移表 / 格式约束等),暂不支持在此编辑 —— 请在代码包中维护。消息:{message}', 'engine.studio.rules.none2': '(无)', 'engine.studio.rules.deleteRule': '删除规则', - 'engine.studio.rules.nameLabel': '规则名(snake_case,唯一)', + 'engine.studio.rules.nameLabel': 'API 名称(编辑前随错误消息自动生成)', 'engine.studio.rules.messageLabel': '错误消息(校验失败时展示给用户)', 'engine.studio.rules.messagePlaceholder': '例如:完成日期在状态为已完成时必填', - 'engine.studio.rules.celPre': '失败条件(CEL)—— 条件为 ', + 'engine.studio.rules.celPre': '条件为 ', 'engine.studio.rules.celTrue': '真', 'engine.studio.rules.celMid': ' 时,拒绝保存并显示上面的消息。', 'engine.studio.rules.whenPre': '前提条件(CEL)—— 条件为 ', 'engine.studio.rules.whenMid': ' 时,应用下面的「Then」规则;否则应用「Otherwise」规则(如已设置)。', 'engine.studio.rules.severity': '严重度', - 'engine.studio.rules.severityError': 'error(拒绝保存)', + 'engine.studio.rules.severityError': '错误——拒绝保存', 'engine.studio.rules.enabled': '启用', 'engine.studio.rules.delete': '删除', 'engine.studio.rules.type': '类型', 'engine.studio.rules.newType': '新建验证类型', - 'engine.studio.rules.typeScript': 'Script —— CEL 失败条件', - 'engine.studio.rules.typeCrossField': 'Cross-field —— 跨字段 CEL', + 'engine.studio.rules.typeScript': '条件——条件为真时拒绝保存', + 'engine.studio.rules.typeCrossField': '字段比较——涉及多个字段的条件', 'engine.studio.rules.typeStateMachine': 'State machine —— 允许的状态流转', 'engine.studio.rules.typeFormat': 'Format —— 正则 / 内置格式', 'engine.studio.rules.typeJsonSchema': 'JSON schema —— 校验 JSON 字段', From 6dae91c476c1e6aa6208d50023eb7540cd32e58b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 00:30:06 +0000 Subject: [PATCH 2/6] fix(studio): plainer Studio authoring for #27521 rows 25-38, with pins Claude-Session: https://claude.ai/code/session_01Utp6CbkBiXQKn2AfkfK8tM Co-authored-by: Claude --- .../27521-studio-authoring-suggestions.md | 18 +++ .../src/preview/DraftChangesPanel.tsx | 2 +- ...ditionBuilder.subjectLabels-27521.test.tsx | 93 ++++++++++++ .../inspectors/ConditionBuilder.tsx | 30 ++-- ...dInspector.optionLabelFirst-27521.test.tsx | 143 ++++++++++++++++++ .../inspectorStrings.i18n-10586.test.tsx | 2 +- .../FlowCanvas.objectLabel-27521.test.tsx | 63 ++++++++ ...ddFieldPopover.createReason-27521.test.tsx | 64 ++++++++ ...taPillar.hiddenSystemFields-11780.test.tsx | 12 ++ .../DataPillar.railSwitchFlush-27511.test.tsx | 10 +- ...ationsPanel.newRuleComplete-26731.test.tsx | 3 +- ...lidationsPanel.newRuleWaits-11820.test.tsx | 19 +-- ...ectValidationsPanel.presets-11861.test.tsx | 7 +- ...nsPanel.ruleNameFromMessage-27521.test.tsx | 121 +++++++++++++++ ...lidationsPanel.sharedSelect-11865.test.tsx | 8 +- .../ObjectValidationsPanel.test.tsx | 6 +- .../studio-design/ObjectValidationsPanel.tsx | 1 + ...idationsPanel.typedEmptyOps-11894.test.tsx | 4 +- .../ObjectValidationsPanel.whenKey.test.tsx | 8 +- ...esignSurface.autosaveSwitch-11232.test.tsx | 14 +- ...urface.draftVersionConflict-11773.test.tsx | 4 +- ...signSurface.publishBackfill-12929.test.tsx | 12 +- ...oDesignSurface.publishCount-11807.test.tsx | 53 ++++++- 23 files changed, 638 insertions(+), 59 deletions(-) create mode 100644 .changeset/27521-studio-authoring-suggestions.md create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.optionLabelFirst-27521.test.tsx create mode 100644 ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.objectLabel-27521.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/AddFieldPopover.createReason-27521.test.tsx create mode 100644 ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.ruleNameFromMessage-27521.test.tsx diff --git a/.changeset/27521-studio-authoring-suggestions.md b/.changeset/27521-studio-authoring-suggestions.md new file mode 100644 index 00000000000..af92822bebb --- /dev/null +++ b/.changeset/27521-studio-authoring-suggestions.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +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 + +Clause-②: no +Metadata-protocol: none + +- **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. +- **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. +- **Flow canvas**: a node card names the object it works on by the object's label. +- **Object header**: counts the fields the canvas shows, and the platform's own columns apart (`2 fields (+8 system)`); "1 field" is singular. +- **Add field**: a Create that cannot run yet stays focusable and says what is missing. +- **Read-only inspectors**: disabled text areas take the muted disabled look. +- **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. +- **Studio access refusal** names who grants access (an administrator, in Setup → Permission Sets), in `en` and `zh`. diff --git a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx index a2f96ea9e4e..abf7401edba 100644 --- a/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/ui/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -1669,7 +1669,7 @@ export function DraftChangesPanel({ ? t('preview.changes.confirmNote', { count: entries!.length, defaultValue: - 'Publishing releases all {{count}} pending drafts of this package atomically.', + 'Publishing releases all {{count}} pending drafts of this package together: all of them go live, or none do.', }) : t('preview.changes.confirmNoteSeparate', { count: entries!.length, diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx new file mode 100644 index 00000000000..dcd70d1539d --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.subjectLabels-27521.test.tsx @@ -0,0 +1,93 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ConditionBuilder — a field subject reads by its label (objectstack#27521, + * Studio row 26). + * + * The Validations pillar's "Reject the save when…" listed `record.estimated_hours` + * under the placeholder "field / context", while the list-view filter in the + * same product showed labels. A field subject now reads by its label, with the + * spelling the CEL uses beside it; the compiled CEL is unchanged. A field with + * no label keeps its spelling, and the context subjects are as before. The + * label is the mount's choice (`labelSubjects`): a mount that lists spellings + * keeps them. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, cleanup, screen, fireEvent, within } from '@testing-library/react'; + +const state = vi.hoisted(() => ({ + metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } }, +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); + +import { ConditionBuilder } from './ConditionBuilder'; +import { t } from '../i18n'; + +afterEach(cleanup); + +const FIELDS = [ + { name: 'estimated_hours', label: 'Estimated Hours', type: 'number' }, + { name: 'code', type: 'text' }, +]; + +function mount(includePrevious = false, labels = true) { + const onChange = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: t('engine.inspector.condition.add', 'en-US') })); + return { onChange }; +} + +async function openSubjects() { + const trigger = screen.getAllByRole('combobox')[0]; + expect(trigger).toHaveTextContent(t('engine.inspector.condition.subjectPlaceholder', 'en-US')); + fireEvent.keyDown(trigger, { key: 'ArrowDown' }); + return within(await screen.findByRole('listbox')).getAllByRole('option'); +} + +describe('ConditionBuilder · field subjects read by label (objectstack#27521)', () => { + it('a labelled field reads by its label with its spelling beside it; an unlabelled one by its spelling', async () => { + mount(); + const options = await openSubjects(); + const texts = options.map((o) => o.textContent); + expect(texts).toContain('Estimated Hoursrecord.estimated_hours'); + expect(texts).toContain('record.code'); + // The context subjects are unchanged. + expect(texts).toContain('user.id'); + }); + + it('picking the labelled field still compiles to its CEL spelling', async () => { + const { onChange } = mount(); + const options = await openSubjects(); + fireEvent.click(options.find((o) => o.textContent?.startsWith('Estimated Hours'))!); + fireEvent.change(screen.getByPlaceholderText(t('engine.inspector.condition.valuePlaceholder', 'en-US')), { + target: { value: '40' }, + }); + expect(JSON.stringify(onChange.mock.calls.at(-1)?.[0])).toContain('record.estimated_hours'); + }); + + it('a previous-value subject names the field and that it is the value before the change', async () => { + mount(true); + const texts = (await openSubjects()).map((o) => o.textContent); + expect(texts).toContain('Estimated Hours (before the change)previous.estimated_hours'); + }); + + it('CONTROL — a mount that does not ask for labels lists the spellings', async () => { + mount(false, false); + const texts = (await openSubjects()).map((o) => o.textContent); + expect(texts).toContain('record.estimated_hours'); + expect(texts.some((x) => x?.includes('Estimated Hours'))).toBe(false); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx index 0360a4cd32d..963183b4ba5 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ConditionBuilder.tsx @@ -820,7 +820,7 @@ function initFrom(value: string, valueOf: SubjectValueOf): { rows: Row[]; join: return { rows: [], join: '&&', raw: !!value }; } -export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue }: { +export function ConditionBuilder({ label, value, onCommit, objectName, fields: fieldsProp, disabled, onBlockingIssuesChange, subjects, scope, roots, scopeIssue, labelSubjects = false }: { label?: string; value: string; onCommit: (cel: string) => void; @@ -906,6 +906,11 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f * builder renders no verdict of its own, so it does not read this. */ scopeIssue?: boolean; + /** + * The subject dropdown names a field by its label, with the CEL spelling + * beside it. Off by default, so a mount that lists spellings keeps them. + */ + labelSubjects?: boolean; }) { const { fields: hookFields } = useObjectFields(objectName); const fields = fieldsProp ?? hookFields; @@ -924,28 +929,27 @@ export function ConditionBuilder({ label, value, onCommit, objectName, fields: f const locale = useMetadataLocale(); const subjectOptions = React.useMemo(() => { const visible = fields.filter((f) => !f.hidden); - // A field reads by its label, with the subject spelling beside it. - const fieldOpts = visible.map((f) => ({ - value: `${fieldPrefix}${f.name}`, - label: f.label || `${fieldPrefix}${f.name}`, - hint: f.label ? `${fieldPrefix}${f.name}` : undefined, - })); + // With `labelSubjects`, a field reads by its label, its spelling beside it. + const named = (spelling: string, label: string | undefined) => + labelSubjects && label ? { value: spelling, label, hint: spelling } : { value: spelling, label: spelling, hint: undefined }; + const fieldOpts = visible.map((f) => named(`${fieldPrefix}${f.name}`, f.label)); // The whole-record `previous` comes first: `previous == null` is how an // author branches on the create leg of a create-or-update trigger, and it // reads as the head of the group rather than as one more field. const previousOpts = includePrevious ? [ { value: 'previous', label: 'previous', hint: undefined }, - ...visible.map((f) => ({ - value: `previous.${f.name}`, - label: f.label ? tFormat('engine.inspector.condition.previousField', locale, { field: f.label }) : `previous.${f.name}`, - hint: f.label ? `previous.${f.name}` : undefined, - })), + ...visible.map((f) => + named( + `previous.${f.name}`, + f.label ? tFormat('engine.inspector.condition.previousField', locale, { field: f.label }) : undefined, + ), + ), ] : []; const contextOpts = contextSubjects.map((c) => ({ value: c.value, label: c.label ?? c.value, hint: undefined })); return [...fieldOpts, ...previousOpts, ...contextOpts]; - }, [fields, fieldPrefix, includePrevious, contextSubjects, locale]); + }, [fields, fieldPrefix, includePrevious, contextSubjects, locale, labelSubjects]); // The raw-expression editor's CEL assists (#1582): field-existence lint + // autocomplete need the bare field-name catalog and a locale-bound `t`. const tLocal = React.useCallback((k: string) => t(k, locale), [locale]); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.optionLabelFirst-27521.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.optionLabelFirst-27521.test.tsx new file mode 100644 index 00000000000..6939dba8e95 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.optionLabelFirst-27521.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The option editor reads label first, fills a new option's value in from its + * label, and draws the lifecycle kind only where a kind steers something + * (objectstack#27521, Studio row 25). + * + * A Studio author typed three labels into a new picklist, left the monospace + * `value` boxes empty, and the inspector kept saying the field "needs at least + * one option": a row with no value is never written. Every option also offered + * a "No kind" picker of Open / Won / Lost on a field no record path follows. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { FieldSchema } from '@objectstack/spec/data'; + +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => ({ + list: vi.fn().mockResolvedValue([]), + listDrafts: vi.fn().mockResolvedValue([]), + }), +})); + +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [], loading: false, error: null }), +})); + +import { ObjectFieldInspector } from './ObjectFieldInspector'; + +afterEach(cleanup); + +type Def = Record; + +function renderField(name: string, fields: Record, stageField?: string | false) { + const onPatch = vi.fn(); + const initial: Record = { name: 'probe_widget', fields }; + if (stageField !== undefined) initial.stageField = stageField; + function Host() { + const [draft, setDraft] = React.useState(initial); + return ( + ) => { + onPatch(patch); + setDraft((d) => ({ ...d, ...patch })); + }} + onClearSelection={vi.fn()} + onSelectionChange={vi.fn()} + readOnly={false} + locale="en-US" + /> + ); + } + render(); + const savedOptions = () => { + const last = onPatch.mock.calls.at(-1); + return ((last?.[0].fields as Record | undefined)?.[name]?.options ?? []) as Def[]; + }; + return { onPatch, savedOptions }; +} + +const labels = () => screen.getAllByTestId('option-label') as HTMLInputElement[]; +const values = () => screen.getAllByTestId('option-value') as HTMLInputElement[]; + +describe('ObjectFieldInspector · options read label first (objectstack#27521)', () => { + it('a new option: Label comes before the value, and typing the label fills the value in', () => { + const { savedOptions } = renderField('size', { size: { type: 'select', label: 'Size' } }); + + const row = labels()[0].parentElement!; + expect(row.firstElementChild).toBe(labels()[0]); + expect(row.lastElementChild).toBe(values()[0]); + + fireEvent.change(labels()[0], { target: { value: 'Extra Large' } }); + expect(values()[0].value).toBe('extra_large'); + expect(savedOptions()).toEqual([{ value: 'extra_large', label: 'Extra Large' }]); + expect(FieldSchema.safeParse({ name: 'size', type: 'select', label: 'Size', options: savedOptions() }).success).toBe(true); + }); + + it('three labelled new options are three written options, so nothing holds the save', () => { + const { savedOptions } = renderField('size', { size: { type: 'select', label: 'Size' } }); + const add = screen.getByRole('button', { name: /Add value/ }); + fireEvent.change(labels()[0], { target: { value: 'Small' } }); + fireEvent.click(add); + fireEvent.change(labels()[1], { target: { value: 'Medium' } }); + fireEvent.click(add); + fireEvent.change(labels()[2], { target: { value: 'Large' } }); + + expect(savedOptions().map((o) => o.value)).toEqual(['small', 'medium', 'large']); + expect(screen.queryByTestId('field-held-hint')).toBeNull(); + }); + + it('a value typed by hand stops following the label', () => { + const { savedOptions } = renderField('size', { size: { type: 'select', label: 'Size' } }); + fireEvent.change(labels()[0], { target: { value: 'Small' } }); + fireEvent.change(values()[0], { target: { value: 's' } }); + fireEvent.change(labels()[0], { target: { value: 'Small size' } }); + expect(savedOptions()).toEqual([{ value: 's', label: 'Small size' }]); + }); + + it('CONTROL — relabelling an authored option keeps the value its records store', () => { + const { savedOptions } = renderField('size', { + size: { type: 'select', label: 'Size', options: [{ value: 'sm', label: 'Small' }] }, + }); + fireEvent.change(labels()[0], { target: { value: 'Tiny' } }); + expect(savedOptions()).toEqual([{ value: 'sm', label: 'Tiny' }]); + }); + + it('no kind picker on a select no record path follows', () => { + renderField('size', { + size: { type: 'select', label: 'Size', options: [{ value: 'sm', label: 'Small' }] }, + }); + expect(screen.queryByTestId('option-kind')).toBeNull(); + // POSITIVE CONTROL for the query above: the row is drawn. + expect(labels()).toHaveLength(1); + }); + + it('the kind picker shows on the field the object names as its stage field', () => { + renderField('phase_code', { phase_code: { type: 'select', label: 'Phase', options: [{ value: 'a', label: 'A' }] } }, 'phase_code'); + expect(screen.getAllByTestId('option-kind')).toHaveLength(1); + }); + + it('the kind picker shows on the field the record path finds by name when no stage field is set', () => { + renderField('status', { status: { type: 'select', label: 'Status', options: [{ value: 'a', label: 'A' }] } }); + expect(screen.getAllByTestId('option-kind')).toHaveLength(1); + }); + + it('`stageField: false` turns the path off, and with it the picker', () => { + renderField('status', { status: { type: 'select', label: 'Status', options: [{ value: 'a', label: 'A' }] } }, false); + expect(screen.queryByTestId('option-kind')).toBeNull(); + }); + + it('an option that already declares a kind keeps the picker, so the kind stays editable', () => { + renderField('size', { + size: { type: 'select', label: 'Size', options: [{ value: 'sm', label: 'Small', kind: 'open' }, { value: 'lg', label: 'Large' }] }, + }); + expect(screen.getAllByTestId('option-kind')).toHaveLength(2); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx index 4432b2c3eba..ee483548d01 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/inspectorStrings.i18n-10586.test.tsx @@ -445,7 +445,7 @@ const CONDITION_EN = [ 'Always — no condition.', 'Expression', 'Add condition', - 'field / context', + 'Pick a field', 'Remove condition', 'value', 'equals', diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.objectLabel-27521.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.objectLabel-27521.test.tsx new file mode 100644 index 00000000000..cad5933639b --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/FlowCanvas.objectLabel-27521.test.tsx @@ -0,0 +1,63 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27521 (Studio row 30) — a node card names the object it works + * on by the object's label. + * + * A record-triggered flow's Start card read `START field_service_work_…`, the + * API name cut at the card's width; the Update Records card did the same. The + * host hands the canvas the object catalog's labels, and a card whose object + * has one reads it. An object the catalog does not label keeps its name. + */ + +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 { FlowCanvas } from './FlowCanvas'; +import type { FlowDesignerEdge, FlowDesignerNode } from './flow-canvas-layout'; + +// The palette's engine overlay answers "engine absent", so no read reaches a socket. +vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('not found', { status: 404 })), +); + +afterEach(() => { + cleanup(); + localStorage.clear(); +}); + +const nodes: FlowDesignerNode[] = [ + { id: 'start', type: 'start', label: 'Start', config: { objectName: 'field_service_work_order' } }, + { id: 'upd', type: 'update_record', label: 'Close order', config: { objectName: 'field_service_visit' } }, + { id: 'end', type: 'end', label: 'End' }, +]; +const edges: FlowDesignerEdge[] = [ + { id: 'e1', source: 'start', target: 'upd' }, + { id: 'e2', source: 'upd', target: 'end' }, +]; + +const summaries = () => Array.from(document.querySelectorAll('span[title]')).map((el) => el.textContent); + +describe('FlowCanvas · node cards name objects by label (objectstack#27521)', () => { + it('the Start and Update cards read the object labels the host hands in', () => { + render( + , + ); + expect(screen.getByTitle('Work Order')).toHaveTextContent('Work Order'); + expect(summaries()).not.toContain('field_service_work_order'); + // An object the catalog does not label keeps its name. + expect(screen.getByTitle('field_service_visit')).toHaveTextContent('field_service_visit'); + }); + + it('CONTROL — with no catalog the cards keep the API names', () => { + render(); + expect(screen.getByTitle('field_service_work_order')).toBeTruthy(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.createReason-27521.test.tsx b/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.createReason-27521.test.tsx new file mode 100644 index 00000000000..84b558619c8 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/AddFieldPopover.createReason-27521.test.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#27521 (Studio row 33) — the Add field popover's Create button + * says why it is off, and Tab still reaches it. + * + * A natively disabled Create gave no reason and was skipped by Tab, so a + * keyboard or screen-reader author met a form that would not submit with + * nothing to say why. It is now `aria-disabled`, focusable, and described by + * the missing step; a submit while it is off creates nothing. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; + +import { t } from '../metadata-admin/i18n'; +import { AddFieldPopover } from './AddFieldPopover'; + +afterEach(cleanup); + +function renderOpen(takenNames: string[] = []) { + const onCreate = vi.fn(); + render( + + + , + ); + return { onCreate, create: () => screen.getByTestId('add-field-create') }; +} + +const typeLabel = (text: string) => + fireEvent.change(screen.getByTestId('add-field-label-input'), { target: { value: text } }); + +describe('Add field · Create says why it is off (objectstack#27521)', () => { + it('with no label: focusable, described by "type a label", and a click creates nothing', () => { + const { onCreate, create } = renderOpen(); + expect(create()).not.toBeDisabled(); + expect(create()).toHaveAttribute('aria-disabled', 'true'); + expect(create()).toHaveAccessibleDescription(t('engine.studio.data.addFieldNeedsLabel', 'en-US')); + create().focus(); + expect(create()).toHaveFocus(); + fireEvent.click(create()); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('a label that derives no API name: the reason is the API-name hint', () => { + const { create } = renderOpen(); + typeLabel('满意度'); + expect(create()).toHaveAttribute('aria-disabled', 'true'); + expect(create()).toHaveAccessibleDescription(t('engine.studio.data.addFieldApiNameNotDerived', 'en-US')); + }); + + it('CONTROL — with a label that names the field, Create is on, undescribed, and creates', () => { + const { onCreate, create } = renderOpen(); + typeLabel('Health Score'); + expect(create()).not.toHaveAttribute('aria-disabled'); + expect(create()).not.toHaveAttribute('aria-describedby'); + expect(screen.queryByTestId('add-field-create-reason')).toBeNull(); + fireEvent.click(create()); + expect(onCreate).toHaveBeenCalledWith('Health Score', 'text', undefined); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx index f20d06a79c3..ee67842f9c3 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.hiddenSystemFields-11780.test.tsx @@ -327,3 +327,15 @@ describe('isStudioHiddenSystemField — both marks, as booleans (objectui#11780) expect(isStudioHiddenSystemField(undefined)).toBe(false); }); }); + +// objectstack#27521 (Studio row 32) — the object header counted every column, +// the platform's included, while the canvas drew the author's fields only: it +// read "8 fields" over four. It now counts what the canvas shows and names the +// platform's own columns apart. +describe('the object header counts the author’s fields and names the system ones apart (objectstack#27521)', () => { + it('reads "4 fields (+4 system)" for four author fields beside four platform columns', async () => { + renderPillar(); + await waitFor(() => expect(listed('grid-columns')).toContain('name'), { timeout: 4000 }); + expect(await screen.findByText('4 fields (+4 system)')).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/DataPillar.railSwitchFlush-27511.test.tsx b/ui/packages/app-shell/src/views/studio-design/DataPillar.railSwitchFlush-27511.test.tsx index 964de997444..63c4758a740 100644 --- a/ui/packages/app-shell/src/views/studio-design/DataPillar.railSwitchFlush-27511.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/DataPillar.railSwitchFlush-27511.test.tsx @@ -167,7 +167,7 @@ const SLOW = { timeout: 8000 }; const fieldNamesOf = (body: Record) => readFields(body.fields).entries.map((e) => e.name); const savesRead = () => server.saves.map((s) => [s.name, fieldNamesOf(s.body)]); -const fieldCount = () => screen.getByText(/^\d+ fields$/); +const fieldCount = () => screen.getByText(/^\d+ fields?( \(\+\d+ system\))?$/); async function openTask(): Promise { render( @@ -193,7 +193,7 @@ describe('Data pillar — an object switch inside the autosave debounce (objects expect(server.saves).toEqual([]); fireEvent.click(screen.getByRole('button', { name: /Note/ })); - await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW); + await waitFor(() => expect(fieldCount()).toHaveTextContent('1 field'), SLOW); await pastDebounce(); // Exactly one save, to Task, carrying the edit; nothing was sent as Note's. expect(savesRead()).toEqual([['acme_task', ['title', 'status', 'new_field']]]); @@ -221,7 +221,7 @@ describe('Data pillar — an object switch inside the autosave debounce (objects release(); await new Promise((r) => setTimeout(r, 0)); }); - await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW); + await waitFor(() => expect(fieldCount()).toHaveTextContent('1 field'), SLOW); await pastDebounce(); expect(savesRead()).toEqual([['acme_task', ['title', 'status', 'new_field']]]); }, 30000); @@ -241,14 +241,14 @@ describe('Data pillar — an object switch inside the autosave debounce (objects // Agreed to: the edit is dropped and Note opens; nothing more was sent. confirmSpy.mockReturnValue(true); fireEvent.click(screen.getByRole('button', { name: /Note/ })); - await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW); + await waitFor(() => expect(fieldCount()).toHaveTextContent('1 field'), SLOW); expect(server.saves).toHaveLength(1); }, 30000); it('a switch with nothing pending opens the next object at once and sends nothing', async () => { await openTask(); fireEvent.click(screen.getByRole('button', { name: /Note/ })); - await waitFor(() => expect(fieldCount()).toHaveTextContent('1 fields'), SLOW); + await waitFor(() => expect(fieldCount()).toHaveTextContent('1 field'), SLOW); await pastDebounce(); expect(server.saves).toEqual([]); expect(confirmSpy).not.toHaveBeenCalled(); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleComplete-26731.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleComplete-26731.test.tsx index d97bf9639a0..ab70d9c64d6 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleComplete-26731.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleComplete-26731.test.tsx @@ -124,7 +124,8 @@ describe('a new field rule waits for its field and message (objectstack#26731)', const fieldName = /\((\w+)\)$/.exec(fieldLabel)![1]; expect(written[1]).toMatchObject({ type, - name: 'validation_2', + // objectstack#27521 — the minted name follows the message. + name: 'bad_value', field: fieldName, message: 'Bad value', events: ['insert', 'update'], diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleWaits-11820.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleWaits-11820.test.tsx index b0cae09be07..98c89ad1748 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleWaits-11820.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.newRuleWaits-11820.test.tsx @@ -96,7 +96,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => it('a new Script rule is listed and open, but not written, and says what it needs', () => { const onPatch = vi.fn(); render(); - addFromMenu('Script — CEL fail condition'); + addFromMenu('Condition — reject the save when it is true'); expect(onPatch).not.toHaveBeenCalled(); // Listed, marked, and open in the editor. @@ -112,7 +112,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => it('starts on Create + Update', () => { render(); - addFromMenu('Script — CEL fail condition'); + addFromMenu('Condition — reject the save when it is true'); expect(screen.getByRole('checkbox', { name: 'Create' })).toBeChecked(); expect(screen.getByRole('checkbox', { name: 'Update' })).toBeChecked(); // objectui#11923 — the row offers only the spec's events: no Delete box. @@ -122,7 +122,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => it('edits before the condition stay unsent and ride along when it is written', () => { const onPatch = vi.fn(); render(); - addFromMenu('Script — CEL fail condition'); + addFromMenu('Condition — reject the save when it is true'); fireEvent.change(screen.getByPlaceholderText('e.g. Completion date is required when status is Done'), { target: { value: 'Too big' }, }); @@ -135,7 +135,8 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => expect(written[0]).toEqual((baseDraft.validations as unknown[])[0]); expect(written[1]).toMatchObject({ type: 'script', - name: 'validation_2', + // objectstack#27521 — the minted name follows the message. + name: 'too_big', message: 'Too big', condition: GUARD, events: ['insert', 'update'], @@ -149,7 +150,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => // Written, it is an ordinary rule: no mark, no held line, still open. expect(screen.queryByTestId('rule-unsaved')).toBeNull(); expect(screen.queryByTestId('rule-held')).toBeNull(); - expect(screen.getByDisplayValue('validation_2')).toBeInTheDocument(); + expect(screen.getByDisplayValue('too_big')).toBeInTheDocument(); }); it('a Conditional rule waits for its Then rule’s condition too, and says so', () => { @@ -176,7 +177,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => it('deleting a rule that was never written writes nothing', () => { const onPatch = vi.fn(); render(); - addFromMenu('Cross-field — CEL over multiple fields'); + addFromMenu('Compare fields — a condition over several fields'); fireEvent.click(screen.getByTestId('rule-delete')); expect(onPatch).not.toHaveBeenCalled(); expect(screen.queryByText('validation_2')).toBeNull(); @@ -196,7 +197,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => }); expect(onPatch).toHaveBeenCalledTimes(1); const written = lastValidations(onPatch)[1]; - expect(written).toMatchObject({ type: 'format', name: 'validation_2', events: ['insert', 'update'] }); + expect(written).toMatchObject({ type: 'format', name: 'bad_name', events: ['insert', 'update'] }); expect(ObjectSchema.safeParse({ ...baseDraft, validations: [written] }).success).toBe(true); expect(screen.queryByTestId('rule-unsaved')).toBeNull(); }); @@ -209,7 +210,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => }; render(); expect(screen.getByTestId('rule-type')).toHaveTextContent('Format — regex / built-in format'); - await pickType('Script — CEL fail condition'); + await pickType('Condition — reject the save when it is true'); expect(onPatch).toHaveBeenCalledTimes(1); const written = lastValidations(onPatch)[0]; expect(written).toMatchObject({ type: 'script', name: 'code_format', message: 'bad', condition: 'false' }); @@ -221,7 +222,7 @@ describe('a new validation rule waits for its condition (objectui#11820)', () => it('control — the existing rule list and its editor are unchanged by an unsaved rule', () => { const onPatch = vi.fn(); render(); - addFromMenu('Script — CEL fail condition'); + addFromMenu('Condition — reject the save when it is true'); fireEvent.click(screen.getByText('no_negative')); fireEvent.change(screen.getByDisplayValue('no'), { target: { value: 'never negative' } }); expect(onPatch).toHaveBeenCalledTimes(1); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.presets-11861.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.presets-11861.test.tsx index 43c4328fcd3..9b678cb3987 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.presets-11861.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.presets-11861.test.tsx @@ -214,7 +214,8 @@ describe('the Validations New menu opens on presets (objectui#11861)', () => { const cel = 'record.start_date != null && record.end_date != null && record.end_date < record.start_date'; expect(lastValidations(onPatch)[0]).toMatchObject({ type: 'cross_field', - name: 'validation_1', + // objectstack#27521 — a preset's rule is named from its message. + name: 'end_date_must_be_on_or_after_start_date', condition: cel, // fields[0] is where the server attaches the violation: the end date. fields: ['end_date', 'start_date'], @@ -252,7 +253,7 @@ describe('the Validations New menu opens on presets (objectui#11861)', () => { expect(screen.getByTestId('rule-held')).toBeInTheDocument(); expect(screen.getByRole('checkbox', { name: 'Create' })).toBeChecked(); expect(screen.getByRole('checkbox', { name: 'Update' })).toBeChecked(); - const group = screen.getByRole('group', { name: /Fail condition/ }); + const group = screen.getByRole('group', { name: /When this condition is/ }); await waitFor(() => expect(document.activeElement).toBe(group)); typeGuard(GUARD); @@ -266,7 +267,7 @@ describe('the Validations New menu opens on presets (objectui#11861)', () => { fireEvent.click(presetButton('not_negative')); // The menu hands focus back to "New", as it always did. await waitFor(() => expect(document.activeElement).toBe(screen.getByText('New'))); - expect(document.activeElement).not.toBe(screen.getByRole('group', { name: /Fail condition/ })); + expect(document.activeElement).not.toBe(screen.getByRole('group', { name: /When this condition is/ })); }); it('a preset the object cannot serve is disabled, says what it needs, and never takes a system or hidden field', () => { diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.ruleNameFromMessage-27521.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.ruleNameFromMessage-27521.test.tsx new file mode 100644 index 00000000000..a9d8f01ca3f --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.ruleNameFromMessage-27521.test.tsx @@ -0,0 +1,121 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27521 (Studio row 26) — a new rule's name is read off its + * message, and an authored rule's name never moves. + * + * A new rule opened as `validation_1` under "Rule name (snake_case, unique)", + * so the author either kept a name that says nothing or had to type a machine + * identifier. The name the panel mints now follows the message until the + * author edits the name; the rule written parses through the spec's own + * `ObjectSchema`, the parse the draft save applies. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { ObjectSchema } from '@objectstack/spec/data'; + +import { ObjectValidationsPanel } from './ObjectValidationsPanel'; + +afterEach(() => cleanup()); + +const baseDraft: Record = { + name: 'invoice', + label: 'Invoice', + fields: { + name: { type: 'text', label: 'Name' }, + hours: { type: 'number', label: 'Hours' }, + }, + validations: [ + { type: 'script', name: 'no_negative', message: 'Hours can’t be negative', condition: 'record.hours < 0', severity: 'error' }, + ], +}; + +function Harness({ onPatch }: { onPatch: (p: Record) => void }) { + const [draft, setDraft] = React.useState(baseDraft); + return ( + { + onPatch(p); + setDraft((d) => ({ ...d, ...p })); + }} + /> + ); +} + +function addFromMenu(label: string) { + fireEvent.click(screen.getByText('New')); + fireEvent.click(screen.getByRole('button', { name: 'Advanced' })); + fireEvent.click(screen.getByRole('button', { name: label })); +} + +const nameBox = () => screen.getByRole('textbox', { name: /API name/ }) as HTMLInputElement; +const messageBox = () => screen.getByRole('textbox', { name: /Error message/ }) as HTMLInputElement; + +function typeGuard(cel: string) { + fireEvent.click(screen.getByRole('button', { name: /Expression/ })); + const box = screen.getAllByRole('combobox').find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement; + fireEvent.change(box, { target: { value: cel } }); +} + +function lastValidations(onPatch: ReturnType): Array> { + const calls = onPatch.mock.calls; + return calls[calls.length - 1][0].validations as Array>; +} + +describe('a new rule is named from its message (objectstack#27521)', () => { + it('the minted name follows the message, and the written rule parses', () => { + const onPatch = vi.fn(); + render(); + addFromMenu('Condition — reject the save when it is true'); + expect(nameBox().value).toBe('validation_2'); + + fireEvent.change(messageBox(), { target: { value: 'Hours over 40' } }); + expect(nameBox().value).toBe('hours_over_40'); + typeGuard('record.hours > 40'); + + const written = lastValidations(onPatch).find((r) => r.name === 'hours_over_40'); + expect(written).toMatchObject({ message: 'Hours over 40', condition: 'record.hours > 40' }); + expect(ObjectSchema.safeParse({ ...baseDraft, validations: lastValidations(onPatch) }).success).toBe(true); + }); + + it('a name the author typed stops following the message', () => { + render(); + addFromMenu('Condition — reject the save when it is true'); + fireEvent.change(nameBox(), { target: { value: 'my_rule' } }); + fireEvent.change(messageBox(), { target: { value: 'Hours over 40' } }); + expect(nameBox().value).toBe('my_rule'); + }); + + it('a derived name another rule holds gets a suffix', () => { + render(); + addFromMenu('Condition — reject the save when it is true'); + fireEvent.change(messageBox(), { target: { value: 'No negative' } }); + expect(nameBox().value).toBe('no_negative_2'); + }); + + it('a message with no Latin letters keeps the minted name', () => { + render(); + addFromMenu('Condition — reject the save when it is true'); + fireEvent.change(messageBox(), { target: { value: '工时超过 四十' } }); + expect(nameBox().value).toBe('validation_2'); + }); + + it('CONTROL — editing an authored rule’s message keeps its name', () => { + const onPatch = vi.fn(); + render(); + fireEvent.click(screen.getByText('no_negative')); + fireEvent.change(messageBox(), { target: { value: 'Hours must be zero or more' } }); + expect(nameBox().value).toBe('no_negative'); + expect(lastValidations(onPatch)[0]).toMatchObject({ name: 'no_negative', message: 'Hours must be zero or more' }); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.sharedSelect-11865.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.sharedSelect-11865.test.tsx index acfa685532f..661d05bbdc6 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.sharedSelect-11865.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.sharedSelect-11865.test.tsx @@ -154,8 +154,8 @@ describe('the rule editor pickers are the shared Select (objectui#11865)', () => * and re-picking it writes nothing. */ const WRITES: ReadonlyArray = [ - ['rule-type', 'Script — CEL fail condition', '{"validations":[{"name":"code_format","message":"Bad code","severity":"warning","active":true,"type":"script","condition":"false"}]}', []], - ['rule-type', 'Cross-field — CEL over multiple fields', '{"validations":[{"name":"code_format","message":"Bad code","severity":"warning","active":true,"type":"cross_field","condition":"false","fields":["status"]}]}', []], + ['rule-type', 'Condition — reject the save when it is true', '{"validations":[{"name":"code_format","message":"Bad code","severity":"warning","active":true,"type":"script","condition":"false"}]}', []], + ['rule-type', 'Compare fields — a condition over several fields', '{"validations":[{"name":"code_format","message":"Bad code","severity":"warning","active":true,"type":"cross_field","condition":"false","fields":["status"]}]}', []], ['rule-type', 'State machine — allowed transitions', '{"validations":[{"name":"code_format","message":"Bad code","severity":"warning","active":true,"type":"state_machine","field":"status","transitions":{}}]}', []], ['rule-type', 'Format — regex / built-in format', null, []], ['rule-type', 'JSON schema — validate a JSON field', '{"validations":[{"name":"code_format","message":"Bad code","severity":"warning","active":true,"type":"json_schema","field":"status","schema":{}}]}', []], @@ -169,7 +169,7 @@ const WRITES: ReadonlyArray { it('a severity the editor does not offer is shown, not the first option', () => { renderPanel({ ...RULE, severity: 'critical' }); - // The native control showed "error (rejects the save)" here. + // The native control showed "Error — the save is rejected" here. expect(screen.getByTestId('rule-severity')).toHaveTextContent('critical'); }); }); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.test.tsx index eec0d0709c7..d8ebcd6d1a0 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectValidationsPanel.test.tsx @@ -45,7 +45,7 @@ describe('ObjectValidationsPanel', () => { fireEvent.click(screen.getByRole('button', { name: 'Advanced' })); // Target the menu