From 08c15a22ba192ffae50276d15e39bc7041d16945 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:25:18 +0000 Subject: [PATCH 1/3] test(app-shell): pin the required-field-in-gated-section refusal on both view inspector hosts Red-first pins for the author-time refusal ruled on the card (ruling 5749269225, letter c): an object-required field inside a form section gated on an identity predicate must be reported as a blocking inspector issue and must hold Save shut, on the scoped (ViewInspector) and the home (ViewDefaultInspector) path alike. Controls: a record-scoped gate, a not-required field, and the field moved out of the section draw nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy --- ...tPage.requiredInGatedSection-6900.test.tsx | 203 ++++++++++++++++++ ...ector.requiredInGatedSection-6900.test.tsx | 165 ++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.requiredInGatedSection-6900.test.tsx diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx new file mode 100644 index 0000000000..79d892c625 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Save refuses a form view that puts an OBJECT-required field inside a + * predicate-gated section — on BOTH hosts of the view inspector + * (objectui#6900, landed by ruling 5749269225, letter c). + * + * ## The hazard + * + * The server validates a record against the object's field definitions and + * never reads a form view (ruled objectstack#13252, option C rejected). So a + * form whose section is shown only when `'sales_manager' in + * current_user.positions` and which holds the object-required `salary` saves + * fine as metadata, and then cannot be completed by anyone the predicate hides + * the section from: the record save is refused naming a field they cannot see. + * + * ## Why the refusal rides the inspector's blocking channel + * + * The ruling puts it in the channel ALREADY licensed to gate Save — the + * inspector's blocking issues — and keeps the live Zod pass + * (`validateMetadataDraft`) advisory. `ResourceEditPage.schemaAdvisory.test.tsx` + * (objectui#6980) pins that asymmetry and is untouched by this card. + * + * ## Both hosts + * + * `ResourceEditPage` mounts the view inspector two ways: the DEFAULT inspector + * when nothing is selected (`ViewDefaultInspector`) and the SCOPED one when the + * canvas emits a variant selection (`ViewInspector`). Each is pinned here, so + * the refusal cannot be wired on one of the two paths an author reaches the + * same editor by. + * + * Only the canvas is stubbed (so a preview exists, and so the scoped leg can + * emit its selection). The object catalog arrives through the real + * `useObjectFields` read of `client.get('object', …)`, and the predicate's + * roots are read by the real `@objectstack/formula` parser. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +/** Canonical wire shape — the same spelling as the objectui#6237 fixtures. */ +const cel = (source: string) => ({ dialect: 'cel', source }); +const IDENTITY_GATE = cel("'sales_manager' in current_user.positions"); +const RECORD_GATE = cel("record.status == 'sent'"); + +const state = vi.hoisted(() => ({ + salaryRequired: true, + gate: null as unknown, +})); + +function employeeObject(): Record { + return { + name: 'employee', + label: 'Employee', + fields: { + subject: { type: 'text', label: 'Subject' }, + salary: { type: 'number', label: 'Salary', ...(state.salaryRequired ? { required: true } : {}) }, + }, + }; +} + +function formView(): Record { + return { + name: 'employee_form', + label: 'Employee form', + object: 'employee', + viewKind: 'form', + config: { + type: 'simple', + sections: [ + { name: 'basics', label: 'Basics', fields: ['subject'] }, + { name: 'pay', label: 'Compensation', visibleWhen: state.gate, fields: ['salary'] }, + ], + }, + }; +} + +const mockClient = { + list: vi.fn(async () => []), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => { + const view = formView(); + return { effective: view, code: view, editable: true }; + }), + getDraft: vi.fn(async () => null), + get: vi.fn(async (type: string, name: string) => + type === 'object' && name === 'employee' ? employeeObject() : null, + ), + saveDraft: vi.fn(async () => ({})), +}; + +vi.mock('./useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + useMetadataClient: () => mockClient, + useMetadataTypes: () => ({ + entries: [{ type: 'view', name: 'view', label: 'View', allowOrgOverride: true }], + }), + }; +}); + +import { MetadataResourceEditPage } from './ResourceEditPage'; +import { registerBuiltinInspectors } from './inspectors'; +import { registerMetadataPreview, getMetadataPreview, type MetadataSelection } from './preview-registry'; + +registerBuiltinInspectors(); + +/** + * Canvas stand-in. It exists so the host takes its split-editor branch; its one + * button emits the variant selection the real tab strip emits, which is what + * swaps the DEFAULT inspector for the SCOPED one. + */ +function StubViewCanvas({ + onSelectionChange, +}: { + onSelectionChange?: (next: MetadataSelection | null) => void; +}) { + return ( + + ); +} + +const realViewPreview = getMetadataPreview('view'); + +beforeEach(() => { + state.salaryRequired = true; + state.gate = IDENTITY_GATE; + registerMetadataPreview('view', StubViewCanvas as never); +}); + +afterEach(() => { + cleanup(); + if (realViewPreview) registerMetadataPreview('view', realViewPreview); +}); + +/** The Save icon button, identified by its title in either state. */ +const saveButton = () => + screen.getByRole('button', { name: /Save \(⌘S\)|Fix the CEL syntax errors before saving\./ }); + +/** + * Let the lazily imported CEL parser and the object-catalog read settle, so a + * negative leg's green is observed AFTER the verdict it would have flipped. + */ +async function settle() { + await act(async () => { + await import('@objectstack/formula'); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +async function openEditor(host: 'default' | 'scoped') { + render( + + + , + ); + const select = await screen.findByRole('button', { name: 'select the form variant' }); + if (host === 'scoped') fireEvent.click(select); + // Wait for the inspector (either host) to mount its curated Label field. + return screen.findByLabelText('Label'); +} + +/** Dirty the draft, so Save is live at all and only the gate can hold it. */ +function dirty(label: HTMLElement) { + fireEvent.change(label, { target: { value: 'Employee form (edited)' } }); +} + +describe.each([['default'], ['scoped']] as const)( + 'MetadataResourceEditPage — the %s view inspector refuses a required field in a gated section (objectui#6900)', + (host) => { + it('Save stays disabled on an identity-gated section holding an object-required field', async () => { + const label = await openEditor(host); + await screen.findByTestId('view-gated-required-issues', {}, { timeout: 4000 }); + dirty(label); + await settle(); + expect(saveButton()).toBeDisabled(); + }); + + it('control: the same form with the field NOT required on the object saves', async () => { + state.salaryRequired = false; + const label = await openEditor(host); + dirty(label); + await settle(); + await waitFor(() => expect(saveButton()).toBeEnabled(), { timeout: 4000 }); + expect(screen.queryByTestId('view-gated-required-issues')).toBeNull(); + }); + + it('control: the same section gated on a RECORD predicate saves', async () => { + state.gate = RECORD_GATE; + const label = await openEditor(host); + dirty(label); + await settle(); + await waitFor(() => expect(saveButton()).toBeEnabled(), { timeout: 4000 }); + expect(screen.queryByTestId('view-gated-required-issues')).toBeNull(); + }); + }, +); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.requiredInGatedSection-6900.test.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.requiredInGatedSection-6900.test.tsx new file mode 100644 index 0000000000..ba876dcb54 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.requiredInGatedSection-6900.test.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The view variant inspector REPORTS an object-required field inside a + * predicate-gated form section as a blocking issue, and shows the refusal + * (objectui#6900, ruling 5749269225, letter c). + * + * The ruled behaviour: refuse at authoring time, name both facts — the field is + * required on the OBJECT, and its section is shown only under a predicate — and + * teach the three remedies (move the field out, drop the predicate, or make the + * field not required on the object). It is fenced for the predicate shapes the + * object's own field rules cannot restate (identity, feature, host and page + * state); a `record.*` predicate draws nothing, because it can be written as + * the field's server-enforced `requiredWhen` and the build-time lint already + * teaches that. + * + * Both routes to the same editor are exercised: SCOPED through `ViewInspector` + * and HOME through `ViewDefaultInspector`. The Save gate those reports feed is + * pinned from the host side in + * {@link file://../ResourceEditPage.requiredInGatedSection-6900.test.tsx}. + * + * The object catalog is read by the real `useObjectFields` (a stubbed + * `client.get('object', …)` answering the spec document), and the predicate's + * roots by the real `@objectstack/formula` parser. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, waitFor, act } from '@testing-library/react'; + +const cel = (source: string) => ({ dialect: 'cel', source }); +const IDENTITY_GATE = "'sales_manager' in current_user.positions"; + +const state = vi.hoisted(() => ({ + salaryRequired: true, + metadataClient: { + get: null as unknown as (type: string, name: string) => Promise, + list: async () => [] as unknown[], + }, +})); + +vi.mock('../useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => state.metadataClient }; +}); + +import { ViewInspector, ViewDefaultInspector } from './ViewInspector'; + +beforeEach(() => { + state.salaryRequired = true; + state.metadataClient.get = async (type: string, name: string) => + type === 'object' && name === 'employee' + ? { + name: 'employee', + fields: { + subject: { type: 'text', label: 'Subject' }, + salary: { type: 'number', label: 'Salary', ...(state.salaryRequired ? { required: true } : {}) }, + }, + } + : null; +}); + +afterEach(() => { + cleanup(); +}); + +type Section = Record; + +function formDraft(sections: Section[]): Record { + return { + name: 'employee_form', + label: 'Employee form', + object: 'employee', + viewKind: 'form', + config: { type: 'simple', sections }, + }; +} + +const gatedPay = (gate: unknown = cel(IDENTITY_GATE)): Section[] => [ + { name: 'basics', label: 'Basics', fields: ['subject'] }, + { name: 'pay', label: 'Compensation', visibleWhen: gate, fields: ['salary'] }, +]; + +function renderHost(host: 'scoped' | 'home', draft: Record, report: (n: number) => void) { + const common = { + type: 'view', + name: 'employee_form', + draft, + onPatch: () => {}, + onSelectionChange: () => {}, + readOnly: false, + locale: 'en-US' as never, + onBlockingIssuesChange: report, + }; + if (host === 'scoped') { + return render( + {}} />, + ); + } + return render(); +} + +/** Let the catalog read and the lazy parser settle before a negative reading. */ +async function settle() { + await act(async () => { + await import('@objectstack/formula'); + await new Promise((r) => setTimeout(r, 0)); + }); +} + +describe.each([['scoped'], ['home']] as const)( + 'ViewVariantInspector on the %s path — a required field in a gated section (objectui#6900)', + (host) => { + it('reports ONE blocking issue and names both facts plus the three remedies', async () => { + const report = vi.fn(); + renderHost(host, formDraft(gatedPay()), report); + + await waitFor(() => expect(report.mock.calls.at(-1)?.[0]).toBe(1), { timeout: 4000 }); + const alert = await screen.findByTestId('view-gated-required-issues'); + const text = alert.textContent ?? ''; + // The field, and that it is required ON THE OBJECT. + expect(text).toContain('"Salary" (salary) is required on object "employee"'); + // The section, and its predicate gate. + expect(text).toContain('section "Compensation"'); + expect(text).toContain(IDENTITY_GATE); + // The three remedies. + expect(text).toContain('Move "Salary" into a section that has no visibleWhen'); + expect(text).toContain('Remove visibleWhen from section "Compensation"'); + expect(text).toContain('Make "Salary" not required on object "employee"'); + }); + + it('control: a RECORD-scoped gate draws no issue', async () => { + const report = vi.fn(); + renderHost(host, formDraft(gatedPay(cel("record.status == 'sent'"))), report); + await settle(); + expect(report.mock.calls.at(-1)?.[0]).toBe(0); + expect(screen.queryByTestId('view-gated-required-issues')).toBeNull(); + }); + + it('control: the field NOT required on the object draws no issue', async () => { + state.salaryRequired = false; + const report = vi.fn(); + renderHost(host, formDraft(gatedPay()), report); + await settle(); + expect(report.mock.calls.at(-1)?.[0]).toBe(0); + expect(screen.queryByTestId('view-gated-required-issues')).toBeNull(); + }); + + it('control: the field moved OUT of the gated section draws no issue', async () => { + const report = vi.fn(); + renderHost( + host, + formDraft([ + { name: 'basics', label: 'Basics', fields: ['subject', 'salary'] }, + { name: 'pay', label: 'Compensation', visibleWhen: cel(IDENTITY_GATE), fields: [] }, + ]), + report, + ); + await settle(); + expect(report.mock.calls.at(-1)?.[0]).toBe(0); + expect(screen.queryByTestId('view-gated-required-issues')).toBeNull(); + }); + }, +); From 5a8db2327fe0d97636b8a2047310593775fda870 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 09:29:57 +0000 Subject: [PATCH 2/3] feat(app-shell): the view inspector refuses an object-required field inside a predicate-gated form section Lands the author-time refusal ruled on objectstack#13252 (option B) in the channel ruling 5749269225 (letter c) names: the view inspector's blocking issues, which already gate Save. validateMetadataDraft stays advisory; the objectui#6980 pin is untouched. - inspectors/requiredInGatedSection.ts: reads the form body's sections (sections, else the legacy groups alias when sections is absent), each section's visibleWhen (string or cel envelope) and its members (names and { field } entries); reads the predicate's roots with @objectstack/formula's collectCelRootIdentifiers, loaded lazily; fences the step-1 rows 7-11 roots only (current_user, user, ctx, os, features, app, page). data is read as the row spelling (row 6), since no runtime tier binds it as the host adapter any more. - ViewVariantInspector: a second blocking term for the form family, beside the list-only CEL term, reported through the same effect; renders the refusal naming the field, that it is required on the object, the section and its predicate, and the three remedies. - useObjectFields: ObjectFieldInfo carries an optional required flag, read from the object document the hook already fetches. - i18n: the refusal copy in the en and zh tables. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy --- .../6900-required-in-gated-section-refusal.md | 21 ++ ...tPage.requiredInGatedSection-6900.test.tsx | 13 +- .../src/views/metadata-admin/i18n.ts | 15 + .../inspectors/ViewVariantInspector.tsx | 69 ++++- .../inspectors/requiredInGatedSection.test.ts | 143 +++++++++ .../inspectors/requiredInGatedSection.ts | 283 ++++++++++++++++++ .../previews/useObjectFields.ts | 12 + 7 files changed, 542 insertions(+), 14 deletions(-) create mode 100644 .changeset/6900-required-in-gated-section-refusal.md create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/requiredInGatedSection.test.ts create mode 100644 packages/app-shell/src/views/metadata-admin/inspectors/requiredInGatedSection.ts diff --git a/.changeset/6900-required-in-gated-section-refusal.md b/.changeset/6900-required-in-gated-section-refusal.md new file mode 100644 index 0000000000..8f954df68f --- /dev/null +++ b/.changeset/6900-required-in-gated-section-refusal.md @@ -0,0 +1,21 @@ +--- +'@object-ui/app-shell': patch +--- + +The metadata designer's view inspector now refuses a form view that puts an object-required field +inside a section gated on identity, feature, host or page state (objectui#6900, ruling 5749269225). + +When a form section's `visibleWhen` reads `current_user` (or its aliases `user`, `ctx.user`, +`os.user`), `features`, `app` or `page`, and the section holds a field the bound object declares +`required`, the inspector reports one blocking issue per such field through its existing +blocking-issues channel. The editor's Save stays disabled while any is reported, on both the +selected-variant inspector and the no-selection view panel. The inspector shows the refusal: it +names the field and says it is required on the object, names the section and its predicate, and +lists the three fixes (move the field out of the section, drop the predicate, or make the field +not required on the object). + +The server never evaluates a form section's predicate, so without this the view saves and the form +cannot be completed whenever the section is hidden. A section gated only on `record`, `previous`, +`parent`, `data` or a bare field draws no issue; those predicate shapes stay with the build-time +lint's `requiredWhen` teaching. The live schema check of the draft stays advisory and does not gate +Save. Nothing changes on the server. diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx index 79d892c625..5e8df9449e 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx @@ -162,8 +162,10 @@ async function openEditor(host: 'default' | 'scoped') { ); const select = await screen.findByRole('button', { name: 'select the form variant' }); if (host === 'scoped') fireEvent.click(select); - // Wait for the inspector (either host) to mount its curated Label field. - return screen.findByLabelText('Label'); + // Wait for the inspector (either host) to mount its curated Label field — + // found by its placeholder, since the variant's spec form below it carries + // "Label" inputs of its own (one per section). + return screen.findByPlaceholderText('e.g. All Leads'); } /** Dirty the draft, so Save is live at all and only the gate can hold it. */ @@ -176,10 +178,13 @@ describe.each([['default'], ['scoped']] as const)( (host) => { it('Save stays disabled on an identity-gated section holding an object-required field', async () => { const label = await openEditor(host); - await screen.findByTestId('view-gated-required-issues', {}, { timeout: 4000 }); + // Dirty FIRST: an unedited draft keeps Save disabled on its own, so only + // an edited one can show that the refusal is what holds it shut. dirty(label); await settle(); - expect(saveButton()).toBeDisabled(); + await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); + // And the refusal the author reads is on screen beside it. + expect(await screen.findByTestId('view-gated-required-issues')).toHaveTextContent('Salary'); }); it('control: the same form with the field NOT required on the object saves', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index a1e45eabbd..8f832508f3 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -504,6 +504,14 @@ const ENGINE_STRINGS_EN: Record = { 'engine.inspector.view.object': 'Object', 'engine.inspector.view.objectPlaceholder': 'e.g. crm_lead', 'engine.inspector.view.noSchema': 'Spec schema unavailable — basic properties only.', + // objectui#6900 — an object-required field inside a predicate-gated form + // section (ruling 5749269225): name both facts, then the three remedies. + 'engine.inspector.view.gatedRequired.title': 'Save is blocked: a required field sits in a conditional section.', + 'engine.inspector.view.gatedRequired.issue': 'Field "{fieldLabel}" ({field}) is required on object "{object}", but it sits in section "{section}", which is shown only when {predicate}. The server never evaluates that condition, so whenever the section is hidden the form cannot be completed — the record save is refused over a field the user cannot see.', + 'engine.inspector.view.gatedRequired.remedies': 'Fix it one of three ways:', + 'engine.inspector.view.gatedRequired.remedyMove': 'Move "{fieldLabel}" into a section that has no visibleWhen.', + 'engine.inspector.view.gatedRequired.remedyDrop': 'Remove visibleWhen from section "{section}".', + 'engine.inspector.view.gatedRequired.remedyUnrequire': 'Make "{fieldLabel}" not required on object "{object}".', // Conditional formatting editor (list/grid views) 'engine.inspector.view.cf.title': 'Conditional formatting', 'engine.inspector.view.cf.add': 'Add rule', @@ -2479,6 +2487,13 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.inspector.view.object': '对象', 'engine.inspector.view.objectPlaceholder': '例如:crm_lead', 'engine.inspector.view.noSchema': 'spec 模式不可用 —— 仅显示基础属性。', + // objectui#6900 — 见英文表同名条目。 + 'engine.inspector.view.gatedRequired.title': '保存已被拦截:必填字段位于条件区块中。', + 'engine.inspector.view.gatedRequired.issue': '字段“{fieldLabel}”({field})在对象“{object}”上是必填的,但它位于区块“{section}”中,而该区块仅在 {predicate} 时显示。服务端从不计算这个条件,因此区块被隐藏时表单无法完成——保存记录会被拒绝,报错指向一个用户看不到的字段。', + 'engine.inspector.view.gatedRequired.remedies': '可用以下三种方式之一修复:', + 'engine.inspector.view.gatedRequired.remedyMove': '把“{fieldLabel}”移到没有 visibleWhen 的区块中。', + 'engine.inspector.view.gatedRequired.remedyDrop': '去掉区块“{section}”上的 visibleWhen。', + 'engine.inspector.view.gatedRequired.remedyUnrequire': '在对象“{object}”上把“{fieldLabel}”改为非必填。', // 条件格式化编辑器(列表/表格视图) 'engine.inspector.view.cf.title': '条件格式化', 'engine.inspector.view.cf.add': '添加规则', diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx index 78a37fcc00..f0c1e4fbe8 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/ViewVariantInspector.tsx @@ -84,7 +84,8 @@ import { } from '../view-schema.js'; import { isFormFamilyKey } from '../view-variant-model.js'; import { mergeServerFields } from '../mergeServerFields.js'; -import { t } from '../i18n.js'; +import { t, tFormat } from '../i18n.js'; +import { useRequiredInGatedSectionIssues } from './requiredInGatedSection.js'; /** * Variant-body fields this inspector renders with its own controls, pruned @@ -111,14 +112,18 @@ export interface ViewVariantInspectorProps extends MetadataDefaultInspectorProps /** Clear the current selection (scoped mode only). */ onClearSelection?: () => void; /** - * Report how many BLOCKING author-time issues this inspector is showing — - * conditional-formatting rules whose CEL does not parse (objectui#4527). + * Report how many BLOCKING author-time issues this inspector is showing. Two + * kinds, one per view family: * - * Only the SCOPED path can carry this today: `ViewInspector` is a - * `MetadataInspectorProps` component and forwards the host's callback, while - * `ViewDefaultInspector` (the home panel) is a - * `MetadataDefaultInspectorProps` component whose contract has no such - * channel — so on that path it is simply absent and nothing is reported. + * - list: conditional-formatting rules whose CEL does not parse + * (objectui#4527); + * - form: an object-required field inside a section gated on a predicate + * the object's field rules cannot restate (objectui#6900) — see + * `requiredInGatedSection.ts`. + * + * Both hosts carry it: `ViewInspector` (scoped) forwards the host's callback, + * and `ViewDefaultInspector` (home) spreads `MetadataDefaultInspectorProps`, + * whose contract has declared the same member since objectui#4527 phase 2. */ onBlockingIssuesChange?: (count: number) => void; /** @@ -259,7 +264,8 @@ export function ViewVariantInspector({ * lands after the author switched variants cannot gate the one now on * screen. Mismatch is read as 0 at aggregation time rather than repaired * by a reset effect. The formatting editor is rendered for list families - * only, so a form variant never reports anything but 0. */ + * only, so this term is always 0 on a form variant; the form family's own + * term follows below. */ const [celErrors, setCelErrors] = React.useState<{ variant: string; count: number }>({ variant: variantKey, count: 0, @@ -274,8 +280,21 @@ export function ViewVariantInspector({ }, [variantKey], ); - const blockingIssues = + const celBlocking = celErrors.variant === variantKey && !isFormFamily ? celErrors.count : 0; + + /* ─── Required field in a gated section → the same Save gate (objectui#6900) + * + * The form family's term, beside — never inside — the CEL term above. Ruling + * 5749269225 (letter c) lands the author-time refusal here, in the channel + * already licensed to gate Save; the live Zod pass stays advisory. Computed + * from the form body's sections and the bound object's catalog, which + * `useObjectFields` above already reads. */ + const gatedRequiredIssues = useRequiredInGatedSectionIssues( + isFormFamily ? variant : undefined, + objectFields, + ); + const blockingIssues = celBlocking + (isFormFamily ? gatedRequiredIssues.length : 0); // Held in a ref so an unmemoized host callback cannot re-fire the effect. const onBlockingIssuesChangeRef = React.useRef(onBlockingIssuesChange); React.useEffect(() => { @@ -422,6 +441,36 @@ export function ViewVariantInspector({ disabled={readOnly} /> + {isFormFamily && gatedRequiredIssues.length > 0 && ( +
+

{t('engine.inspector.view.gatedRequired.title', locale)}

+ {gatedRequiredIssues.map((issue) => { + const vars = { + field: issue.field, + fieldLabel: issue.fieldLabel, + object: binding.value, + section: issue.section, + predicate: issue.predicate, + }; + return ( +
+

{tFormat('engine.inspector.view.gatedRequired.issue', locale, vars)}

+

{t('engine.inspector.view.gatedRequired.remedies', locale)}

+
    +
  • {tFormat('engine.inspector.view.gatedRequired.remedyMove', locale, vars)}
  • +
  • {tFormat('engine.inspector.view.gatedRequired.remedyDrop', locale, vars)}
  • +
  • {tFormat('engine.inspector.view.gatedRequired.remedyUnrequire', locale, vars)}
  • +
+
+ ); + })} +
+ )} + {!isFormFamily && (
{ + const res = collectCelRootIdentifiers(source); + return res.ok ? res.roots : null; +}; + +const FIELDS: ObjectFieldInfo[] = [ + { name: 'subject', label: 'Subject', type: 'text', hidden: false }, + { name: 'salary', label: 'Salary', type: 'number', hidden: false, required: true }, + { name: 'bonus', label: 'Bonus', type: 'number', hidden: false, required: true }, +]; + +const cel = (source: string) => ({ dialect: 'cel', source }); + +function issuesFor(body: Record) { + return findRequiredInGatedSections(readFormSections(body), FIELDS, rootsOf); +} + +function gated(visibleWhen: unknown, fields: unknown[] = ['salary']) { + return { sections: [{ name: 'pay', label: 'Compensation', visibleWhen, fields }] }; +} + +describe('requiredInGatedSection — the fenced rows (objectui#6900)', () => { + it.each([ + ['row 7 current_user', "'sales_manager' in current_user.positions", ['current_user']], + ['row 7 alias user', "user.role == 'admin'", ['user']], + ['row 7 alias ctx.user', "ctx.user.id != ''", ['ctx']], + ['row 7 alias os.user', "os.user.id != ''", ['os']], + ['row 7 mixed with a record root', "record.status == 'sent' && 'hr' in current_user.positions", ['current_user']], + ['row 8 features', 'features.multiOrgEnabled', ['features']], + ['row 9 app', "app.locale == 'en'", ['app']], + ['row 11 page', "page.selectedProjectId != ''", ['page']], + ])('%s is refused', (_row, source, roots) => { + const issues = issuesFor(gated(cel(source))); + expect(issues).toEqual([ + { + sectionIndex: 0, + section: 'Compensation', + predicate: source, + roots, + field: 'salary', + fieldLabel: 'Salary', + }, + ]); + }); +}); + +describe('requiredInGatedSection — rows that draw nothing (objectui#6900)', () => { + it.each([ + ['row 1 record', "record.status == 'sent'"], + ['row 2 has() macro', 'has(record.owner_id)'], + ['row 3 previous', "previous.status == 'draft'"], + ['row 4 constant', 'true'], + ['row 5 bare shorthand', "status == 'sent'"], + ['row 6 data as the row alias', "data.type == 'list'"], + ['row 12 parent', 'parent.x == 1'], + ['a predicate that does not parse', 'record.amount >'], + ])('%s', (_row, source) => { + expect(issuesFor(gated(cel(source)))).toEqual([]); + }); + + it('a blank predicate and a non-CEL dialect are not read', () => { + expect(issuesFor(gated(' '))).toEqual([]); + expect(issuesFor(gated({ dialect: 'template', source: 'current_user.x' }))).toEqual([]); + }); + + it('a section with no predicate draws nothing', () => { + expect(issuesFor({ sections: [{ name: 'pay', fields: ['salary'] }] })).toEqual([]); + }); +}); + +describe('requiredInGatedSection — the section spellings (objectui#6900)', () => { + const GATE = "'sales_manager' in current_user.positions"; + + it('reads a bare-string predicate as well as the envelope', () => { + expect(issuesFor(gated(GATE))).toHaveLength(1); + }); + + it('reads `{ field }` member entries as well as name strings', () => { + expect(issuesFor(gated(cel(GATE), [{ field: 'salary', label: 'Pay' }, 'subject']))).toHaveLength(1); + }); + + it('reports each required member of a gated section once', () => { + const issues = issuesFor(gated(cel(GATE), ['salary', 'bonus', 'subject', 'salary'])); + expect(issues.map((i) => i.field)).toEqual(['salary', 'bonus']); + }); + + it('reads the legacy `groups` alias when `sections` is absent', () => { + const body = { groups: [{ label: 'Compensation', visibleWhen: cel(GATE), fields: ['salary'] }] }; + expect(issuesFor(body)).toHaveLength(1); + }); + + it('`sections` wins over `groups` when present — an empty array included', () => { + const groups = [{ label: 'Compensation', visibleWhen: cel(GATE), fields: ['salary'] }]; + expect(issuesFor({ sections: [], groups })).toEqual([]); + }); + + it('titles a section by label, then name, then position', () => { + const body = { + sections: [ + { name: 'pay', visibleWhen: cel(GATE), fields: ['salary'] }, + { visibleWhen: cel(GATE), fields: ['bonus'] }, + ], + }; + expect(issuesFor(body).map((i) => i.section)).toEqual(['pay', '#2']); + }); + + it('parses only the predicates of gated sections that hold a required member', () => { + const body = { + sections: [ + { name: 'a', visibleWhen: cel(GATE), fields: ['subject'] }, + { name: 'b', visibleWhen: cel(GATE), fields: ['salary'] }, + { name: 'c', visibleWhen: cel(GATE), fields: ['bonus'] }, + { name: 'd', fields: ['salary'] }, + ], + }; + expect(predicatesToRead(readFormSections(body), FIELDS)).toEqual([GATE]); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/requiredInGatedSection.ts b/packages/app-shell/src/views/metadata-admin/inspectors/requiredInGatedSection.ts new file mode 100644 index 0000000000..3e64199c30 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/inspectors/requiredInGatedSection.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The author-time refusal of an OBJECT-required field placed inside a + * predicate-gated form section (objectui#6900). + * + * ## The hazard + * + * The server validates a record against the object's field definitions and + * never reads a form view — ruled on objectstack#13252 (option C, "a validator + * consuming view metadata", rejected). So a form view whose section is shown + * only when a predicate holds, and which holds a field the object declares + * `required`, saves fine as METADATA and then cannot be completed by anyone + * the predicate hides the section from: the record save is refused naming a + * field they cannot see. + * + * ## Where the refusal lives, and why it may block + * + * Ruling 5749269225 (letter c) lands it as a blocking issue in the view + * inspector's `onBlockingIssuesChange` channel — the channel already licensed + * to gate Save — and keeps the live Zod pass (`validateMetadataDraft`) + * advisory. The channel's licence is that the server ACCEPTS the draft it + * refuses, which holds here: nothing server-side reads a section predicate. + * + * ## Which predicates are fenced — the step-1 table's rows 7–11, and only those + * + * The step-1 measurement on the card sorted section predicates into twelve + * shapes by whether the object's own field rule (`requiredWhen`, which the + * server DOES enforce) can restate them. The SPLIT ruling fences only the rows + * that cannot be restated — {@link GATED_SECTION_ROOTS}: identity + * (`current_user` and its ADR-0068 aliases `user`, `ctx.user`, `os.user`), + * `features`, `app` (host) and `page` (page state). A `record` / `previous` + * predicate, a bare field, a constant and the reverse-direction `parent` draw + * nothing: those rows are served by the build-time lint teaching, ⛔ no fence. + * + * `data` is deliberately NOT a fenced root. The table made it binding- + * dependent — row 6 when it names the row, row 10 when it names the host's + * data-source adapter — and on this tree no runtime tier binds it as the + * adapter any more (`buildExpressionScope` publishes none, objectui#8166; + * `SchemaRenderer` dropped its `data: dataSource`, objectui#9308). What is left + * is the row spelling, which `@objectstack/lint` already teaches to rewrite as + * `record.FIELD` — row 6. + * + * ## The roots are read by the parser, never by a pattern + * + * `collectCelRootIdentifiers` is the `@objectstack/formula` export the server's + * own closed-root sites use. It is loaded LAZILY, as every CEL affordance in + * this designer is (`celAuthoring.ts`): the parser stays off the eager graph, + * and the one symbol is destructured in the callback so the namespace does not + * escape into the cached promise. A source that does not parse is not this + * check's verdict to give — syntax belongs to the CEL gates — so it draws + * nothing here, and neither does an engine that failed to load. + * + * ## The spellings a form view's sections arrive in + * + * - `sections[]`, or the legacy `groups[]` alias when `sections` is ABSENT — + * the precedence `@objectstack/spec`'s own fold applies (`sections` wins when + * present, an empty array included); + * - a section's `visibleWhen` as a bare CEL string or a `{ dialect, source }` + * envelope (a non-`cel` dialect is not read); + * - a member as a field NAME string or a `{ field }` entry. + * + * Every layout arm (`simple`, `tabbed`, `wizard`, `split`, `drawer`, `modal`) + * reads the same `sections[]`, so none is special-cased. A `{ group }` section + * inherits its predicate from the object's `fieldGroups`, not from this view, + * and is outside this check. + */ + +import * as React from 'react'; +import type { ObjectFieldInfo } from '../previews/useObjectFields.js'; + +/** + * The expression roots whose section predicate the object's field rules cannot + * restate — the step-1 table's rows 7–11. See the module note for why `data` + * is not here. + */ +export const GATED_SECTION_ROOTS: readonly string[] = [ + 'current_user', + 'user', + 'ctx', + 'os', + 'features', + 'app', + 'page', +]; + +const GATED = new Set(GATED_SECTION_ROOTS); + +/** One form section, read into the shape this check needs. */ +export interface FormSectionRef { + /** Position in the section list the view renders. */ + index: number; + /** What the author sees the section called: its label, else its name, else `#N`. */ + title: string; + /** The `visibleWhen` CEL source, or `null` when the section is not gated. */ + predicate: string | null; + /** Member field names, in declared order. */ + members: string[]; +} + +/** One refusal: an object-required field inside a section gated on a fenced root. */ +export interface RequiredInGatedSectionIssue { + sectionIndex: number; + section: string; + predicate: string; + /** The fenced roots the predicate reads. */ + roots: string[]; + field: string; + fieldLabel: string; +} + +/** Reads a predicate's top-level roots; `null` when it does not parse. */ +export type CelRootReader = (source: string) => string[] | null; + +function isRecord(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +/** The CEL source of a section's `visibleWhen`, or `null` when there is none. */ +export function sectionPredicateSource(section: Record): string | null { + const raw = section.visibleWhen; + let source: unknown; + if (typeof raw === 'string') source = raw; + else if (isRecord(raw)) { + if (raw.dialect !== undefined && raw.dialect !== 'cel') return null; + source = raw.source; + } + if (typeof source !== 'string') return null; + const trimmed = source.trim(); + return trimmed ? trimmed : null; +} + +/** A section's member field names: string entries and `{ field }` entries. */ +export function sectionMemberNames(section: Record): string[] { + const fields = section.fields; + if (!Array.isArray(fields)) return []; + const out: string[] = []; + for (const entry of fields) { + if (typeof entry === 'string' && entry) out.push(entry); + else if (isRecord(entry) && typeof entry.field === 'string' && entry.field) out.push(entry.field); + } + return out; +} + +/** The sections a form view body renders, in order. */ +export function readFormSections(body: Record | undefined): FormSectionRef[] { + if (!body) return []; + const list = body.sections !== undefined ? body.sections : body.groups; + if (!Array.isArray(list)) return []; + const out: FormSectionRef[] = []; + list.forEach((section, index) => { + if (!isRecord(section)) return; + const label = typeof section.label === 'string' && section.label ? section.label : ''; + const name = typeof section.name === 'string' && section.name ? section.name : ''; + out.push({ + index, + title: label || name || `#${index + 1}`, + predicate: sectionPredicateSource(section), + members: sectionMemberNames(section), + }); + }); + return out; +} + +/** The field names the object declares `required: true`, with their labels. */ +function requiredFieldLabels(fields: readonly ObjectFieldInfo[]): Map { + const out = new Map(); + for (const f of fields) if (f.required === true) out.set(f.name, f.label || f.name); + return out; +} + +/** + * The predicates worth parsing: gated sections that hold at least one + * object-required member. Deduplicated, in first-seen order. + */ +export function predicatesToRead( + sections: readonly FormSectionRef[], + fields: readonly ObjectFieldInfo[], +): string[] { + const required = requiredFieldLabels(fields); + const seen = new Set(); + for (const s of sections) { + if (s.predicate && s.members.some((m) => required.has(m))) seen.add(s.predicate); + } + return [...seen]; +} + +/** Every object-required field sitting in a section gated on a fenced root. */ +export function findRequiredInGatedSections( + sections: readonly FormSectionRef[], + fields: readonly ObjectFieldInfo[], + rootsOf: CelRootReader, +): RequiredInGatedSectionIssue[] { + const required = requiredFieldLabels(fields); + const issues: RequiredInGatedSectionIssue[] = []; + for (const s of sections) { + if (!s.predicate) continue; + const members = s.members.filter((m) => required.has(m)); + if (members.length === 0) continue; + const roots = rootsOf(s.predicate); + if (!roots) continue; + const gated = [...new Set(roots.filter((r) => GATED.has(r)))]; + if (gated.length === 0) continue; + for (const field of new Set(members)) { + issues.push({ + sectionIndex: s.index, + section: s.title, + predicate: s.predicate, + roots: gated, + field, + fieldLabel: required.get(field) ?? field, + }); + } + } + return issues; +} + +/* ── The lazily loaded root reader ─────────────────────────────────────── */ + +type CollectRoots = (source: string) => { ok: true; roots: string[] } | { ok: false; error: string }; + +let readerCached: Promise | null = null; + +function loadRootReader(): Promise { + if (!readerCached) { + readerCached = import('@objectstack/formula') + .then(({ collectCelRootIdentifiers }) => + typeof collectCelRootIdentifiers === 'function' ? (collectCelRootIdentifiers as CollectRoots) : null, + ) + .catch(() => null); + } + return readerCached; +} + +function readRoots(reader: CollectRoots | null, source: string): string[] | null { + if (!reader) return null; + try { + const res = reader(source); + return res.ok ? res.roots : null; + } catch { + return null; + } +} + +/** + * The refusals for one form view body against its object's field catalog. + * + * Empty while the parser loads, while the catalog loads, and when either is + * unavailable — this check refuses only what it has read. The parsed roots are + * STAMPED with the predicate list they answer (a primitive key), so a verdict + * that lands after the author edited a predicate cannot describe the new one. + */ +export function useRequiredInGatedSectionIssues( + body: Record | undefined, + fields: readonly ObjectFieldInfo[], +): RequiredInGatedSectionIssue[] { + const sections = readFormSections(body); + const key = JSON.stringify(predicatesToRead(sections, fields)); + const [parsed, setParsed] = React.useState<{ key: string; roots: Record }>({ + key: '[]', + roots: {}, + }); + + React.useEffect(() => { + const sources = JSON.parse(key) as string[]; + if (sources.length === 0) return; + let cancelled = false; + void loadRootReader().then((reader) => { + if (cancelled) return; + const roots: Record = {}; + for (const source of sources) roots[source] = readRoots(reader, source); + setParsed({ key, roots }); + }); + return () => { + cancelled = true; + }; + }, [key]); + + if (parsed.key !== key) return []; + return findRequiredInGatedSections(sections, fields, (source) => + Object.prototype.hasOwnProperty.call(parsed.roots, source) ? parsed.roots[source] : null, + ); +} diff --git a/packages/app-shell/src/views/metadata-admin/previews/useObjectFields.ts b/packages/app-shell/src/views/metadata-admin/previews/useObjectFields.ts index 9fce803fef..346e778b30 100644 --- a/packages/app-shell/src/views/metadata-admin/previews/useObjectFields.ts +++ b/packages/app-shell/src/views/metadata-admin/previews/useObjectFields.ts @@ -26,6 +26,17 @@ export interface ObjectFieldInfo { /** Raw field type id (e.g. 'text', 'lookup'). */ type: string; hidden: boolean; + /** + * `true` when the object declares the field `required` — the write-time + * contract the server enforces on every save. Absent otherwise. + * + * Carried for the view inspector's refusal of a required field placed in a + * predicate-gated form section (objectui#6900): the served object document + * already holds the flag, so this is the same payload read once more, not a + * second data path. Hosts that pass an override catalog and leave it out get + * no such refusal. + */ + required?: boolean; } export interface UseObjectFieldsResult { @@ -81,6 +92,7 @@ export function useObjectFields( : e.name, type: typeof e.def.type === 'string' ? (e.def.type as string) : 'text', hidden: e.def.hidden === true, + ...(e.def.required === true ? { required: true } : {}), })); setState({ fields, loading: false, error: null }); }) From 8fe6d58647225ec949bd556326a9d45d4ea607c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 11:39:14 +0000 Subject: [PATCH 3/3] fix(app-shell): the inspector-blocked Save title names no one issue kind The editor titled every inspector-blocked Save "Fix the CEL syntax errors before saving.", and the inspector channel now also carries the required-field-in-gated-section refusal, so that title was false on it. Per ruling 5831744213 (clarifying 5749269225: "unchanged" binds the objectui#6980 pin's Save-gate asymmetry, not its tooltip wording): - i18n: perm.inspector.saveBlocked, en and zh. - ResourceEditPage: the inspector-blocked Save title reads it. PermissionMatrixEditor and ObjectHooksPanel keep perm.cel.saveBlocked. - The five pins asserting the old title flip their title string only: ResourceEditPage.celGate, .defaultGate, .schemaAdvisory, .serverRefusalGate and .requiredInGatedSection-6900. One new pin: the refusal holding Save shows the neutral title. - Changeset: one paragraph on the new Save title. Claude-Session: https://claude.ai/code/session_01KUxVUa7e39aNjhkKi1gsoy Co-authored-by: Claude --- .changeset/6900-required-in-gated-section-refusal.md | 5 +++++ .../metadata-admin/ResourceEditPage.celGate.test.tsx | 5 +++-- .../ResourceEditPage.defaultGate.test.tsx | 5 +++-- ...ourceEditPage.requiredInGatedSection-6900.test.tsx | 11 ++++++++++- .../ResourceEditPage.schemaAdvisory.test.tsx | 5 +++-- .../ResourceEditPage.serverRefusalGate.test.tsx | 3 ++- .../src/views/metadata-admin/ResourceEditPage.tsx | 4 +++- packages/app-shell/src/views/metadata-admin/i18n.ts | 5 +++++ 8 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.changeset/6900-required-in-gated-section-refusal.md b/.changeset/6900-required-in-gated-section-refusal.md index 8f954df68f..822709da28 100644 --- a/.changeset/6900-required-in-gated-section-refusal.md +++ b/.changeset/6900-required-in-gated-section-refusal.md @@ -14,6 +14,11 @@ names the field and says it is required on the object, names the section and its lists the three fixes (move the field out of the section, drop the predicate, or make the field not required on the object). +Because the inspector's blocking channel now carries more than CEL faults, the editor's disabled +Save button reads "Fix the issues shown in the inspector before saving." whenever an inspector issue +holds it, a CEL fault included, instead of "Fix the CEL syntax errors before saving." The +permission-matrix and object-hooks Save buttons keep the CEL wording. + The server never evaluates a form section's predicate, so without this the view saves and the form cannot be completed whenever the section is hidden. A section gated only on `record`, `previous`, `parent`, `data` or a bare field draws no issue; those predicate shapes stay with the build-time diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.celGate.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.celGate.test.tsx index 5e42beae84..23cdeb43d9 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.celGate.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.celGate.test.tsx @@ -112,8 +112,9 @@ afterEach(() => { }); /** The Save button is an icon button identified by its title. */ +// Title flipped to the neutral inspector copy by ruling 5831744213 (objectui#6900). const saveButton = () => - screen.getByRole('button', { name: /Save \(⌘S\)|Fix the CEL syntax errors before saving\./ }); + screen.getByRole('button', { name: /Save \(⌘S\)|Fix the issues shown in the inspector before saving\./ }); /** Open the editor, select the block, and switch its visibility control to raw CEL. */ async function openBlockCel() { @@ -138,7 +139,7 @@ describe('MetadataResourceEditPage — Save is gated on the block inspector’s fireEvent.change(box, { target: { value: 'record.amount >' } }); await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); - expect(saveButton()).toHaveAttribute('title', 'Fix the CEL syntax errors before saving.'); + expect(saveButton()).toHaveAttribute('title', 'Fix the issues shown in the inspector before saving.'); }); it('re-enables Save once the predicate parses again', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.defaultGate.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.defaultGate.test.tsx index 9f5e73808c..2ad28c8f9e 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.defaultGate.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.defaultGate.test.tsx @@ -110,8 +110,9 @@ afterEach(() => { }); /** The Save icon button, identified by its title in either state. */ +// Title flipped to the neutral inspector copy by ruling 5831744213 (objectui#6900). const saveButton = () => - screen.getByRole('button', { name: /Save \(⌘S\)|Fix the CEL syntax errors before saving\./ }); + screen.getByRole('button', { name: /Save \(⌘S\)|Fix the issues shown in the inspector before saving\./ }); const ruleBox = () => screen.getByTestId('cf-rule-0').querySelector('[role="combobox"]') as HTMLTextAreaElement; @@ -138,7 +139,7 @@ describe('MetadataResourceEditPage — Save is gated on the DEFAULT inspector’ fireEvent.change(box, { target: { value: 'record.amount >' } }); await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); - expect(saveButton()).toHaveAttribute('title', 'Fix the CEL syntax errors before saving.'); + expect(saveButton()).toHaveAttribute('title', 'Fix the issues shown in the inspector before saving.'); }); it('re-enables Save once the condition parses again', async () => { diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx index 5e8df9449e..7e578cf09c 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.requiredInGatedSection-6900.test.tsx @@ -140,8 +140,9 @@ afterEach(() => { }); /** The Save icon button, identified by its title in either state. */ +// Title flipped to the neutral inspector copy by ruling 5831744213 (objectui#6900). const saveButton = () => - screen.getByRole('button', { name: /Save \(⌘S\)|Fix the CEL syntax errors before saving\./ }); + screen.getByRole('button', { name: /Save \(⌘S\)|Fix the issues shown in the inspector before saving\./ }); /** * Let the lazily imported CEL parser and the object-catalog read settle, so a @@ -187,6 +188,14 @@ describe.each([['default'], ['scoped']] as const)( expect(await screen.findByTestId('view-gated-required-issues')).toHaveTextContent('Salary'); }); + it('the Save title held by this refusal is the neutral inspector copy, not the CEL one (ruling 5831744213)', async () => { + const label = await openEditor(host); + dirty(label); + await settle(); + await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); + expect(saveButton()).toHaveAttribute('title', 'Fix the issues shown in the inspector before saving.'); + }); + it('control: the same form with the field NOT required on the object saves', async () => { state.salaryRequired = false; const label = await openEditor(host); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.schemaAdvisory.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.schemaAdvisory.test.tsx index 6fba68b93d..c349d7bc89 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.schemaAdvisory.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.schemaAdvisory.test.tsx @@ -166,8 +166,9 @@ afterEach(() => { }); /** The Save button is an icon button identified by its title. */ +// Title flipped to the neutral inspector copy by ruling 5831744213 (objectui#6900). const saveButton = () => - screen.getByRole('button', { name: /Save \(⌘S\)|Fix the CEL syntax errors before saving\./ }); + screen.getByRole('button', { name: /Save \(⌘S\)|Fix the issues shown in the inspector before saving\./ }); /** * Dirty the draft with a predicate that parses, then WAIT FOR THE BANNER. @@ -224,7 +225,7 @@ describe('MetadataResourceEditPage — client Zod issues are ADVISORY, inspector // differently on purpose. fireEvent.change(box, { target: { value: 'record.amount >' } }); await waitFor(() => expect(saveButton()).toBeDisabled(), { timeout: 4000 }); - expect(saveButton()).toHaveAttribute('title', 'Fix the CEL syntax errors before saving.'); + expect(saveButton()).toHaveAttribute('title', 'Fix the issues shown in the inspector before saving.'); expect(screen.getByTestId('metadata-validation-banner')).toHaveTextContent(SERVER_ONLY_KEY); }); }); diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverRefusalGate.test.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverRefusalGate.test.tsx index c88d3998fb..b2a3763b49 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverRefusalGate.test.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.serverRefusalGate.test.tsx @@ -179,9 +179,10 @@ afterEach(() => { }); /** The Save button, found by its title in every state this suite reaches. */ +// Title flipped to the neutral inspector copy by ruling 5831744213 (objectui#6900). const saveButton = () => screen.getByRole('button', { - name: /Save \(⌘S\)|The server refused this draft|Fix the CEL syntax errors before saving\.|No changes to save/, + name: /Save \(⌘S\)|The server refused this draft|Fix the issues shown in the inspector before saving\.|No changes to save/, }); /** diff --git a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx index 05b0a8e1c4..08e198fe76 100644 --- a/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx +++ b/packages/app-shell/src/views/metadata-admin/ResourceEditPage.tsx @@ -2200,7 +2200,9 @@ function MetadataResourceEditPageImpl({ saving ? t('engine.edit.saving', locale) : inspectorBlocking > 0 - ? t('perm.cel.saveBlocked', locale) + // Neutral: the inspector channel carries CEL faults AND the + // objectui#6900 required-field refusal (ruling 5831744213). + ? t('perm.inspector.saveBlocked', locale) : refusalBlocking > 0 ? tFormat('engine.validation.serverRefused', locale, { issue: saveRefusal?.summary ?? '', diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 8f832508f3..c778277dfb 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1294,6 +1294,9 @@ const ENGINE_STRINGS_EN: Record = { 'perm.cel.type.unknownHint': 'the engine cannot prove a single type. Wrap operands in double() / int() / string() to pin it — only proven-Number formulas are offered as dataset measures.', 'perm.cel.saveBlocked': 'Fix the CEL syntax errors before saving.', + // The editor's inspector-blocked Save title (objectui#6900, ruling 5831744213): + // that channel carries more than CEL faults, so its copy names no one kind. + 'perm.inspector.saveBlocked': 'Fix the issues shown in the inspector before saving.', 'perm.cel.test.title': 'Test policy', 'perm.cel.test.help': 'Dry-run this predicate against a sample record and acting user — the same CEL engine the server uses — to see whether the row is allowed or denied before you save.', @@ -3305,6 +3308,8 @@ const ENGINE_STRINGS_ZH: Record = { 'perm.cel.type.unknownHint': '引擎无法证明唯一类型。用 double() / int() / string() 包裹操作数以固定类型——只有可证明为数字的公式才能作为数据集度量。', 'perm.cel.saveBlocked': '请先修复 CEL 语法错误再保存。', + // objectui#6900 — 见英文表同名条目。 + 'perm.inspector.saveBlocked': '请先修复检查器中列出的问题再保存。', 'perm.cel.test.title': '测试策略', 'perm.cel.test.help': '用示例记录与操作用户对该条件进行试运行(与服务端同一 CEL 引擎),在保存前查看该行是允许还是拒绝。', 'perm.cel.test.run': '测试',