From 8338edd4eff9585326fb01e22dd870e4b0e9da15 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 03:32:05 +0000 Subject: [PATCH] fix(plugin-detail): show an object-level rule's refusal without the _record label (objectui#13292) Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/13292-record-level-refusal.md | 7 + .../plugin-detail/src/InlineEditSaveBar.tsx | 110 ++++++++++++++- ...tSaveBar.recordLevelRefusal-13292.test.tsx | 132 ++++++++++++++++++ ...veBar.serverVerdictFieldHint-6868.test.tsx | 11 +- 4 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 .changeset/13292-record-level-refusal.md create mode 100644 packages/plugin-detail/src/__tests__/InlineEditSaveBar.recordLevelRefusal-13292.test.tsx diff --git a/.changeset/13292-record-level-refusal.md b/.changeset/13292-record-level-refusal.md new file mode 100644 index 0000000000..fd3f207559 --- /dev/null +++ b/.changeset/13292-record-level-refusal.md @@ -0,0 +1,7 @@ +--- +'@object-ui/plugin-detail': patch +--- + +The inline-edit save bar shows an object-level rule's refusal as the rule's own message, without a field label (objectui#13292). + +The server names an object-level rule (a `script` rule, or one that lists no fields) with the `_record` sentinel. The bar used to print that sentinel as a field label, so the banner read `_record: …`. Now an entry that names `_record`, or names no field of the record, is shown alone in the banner and is not published as a per-field hint. Field-level entries are unchanged: they keep their label and their hint beside the input. diff --git a/packages/plugin-detail/src/InlineEditSaveBar.tsx b/packages/plugin-detail/src/InlineEditSaveBar.tsx index 1941e47825..2861661d39 100644 --- a/packages/plugin-detail/src/InlineEditSaveBar.tsx +++ b/packages/plugin-detail/src/InlineEditSaveBar.tsx @@ -201,6 +201,53 @@ function attributeInlineRefusal( return [{ field: inFlightField, message }]; } +/** + * The `field` the server names on an OBJECT-LEVEL refusal (objectui#13292). + * + * `@objectstack/objectql`'s rule validator attributes a rule to its first + * declared field and, when the rule declares none (a `script` rule, or a rule + * whose `fields` is empty), to this sentinel instead — the documented contract + * ("`_record` for an object-level rule"). It names no input, so it is never a + * label and never a per-field hint. + */ +const RECORD_LEVEL_FIELD = '_record'; + +/** + * A refusal split by what each entry is ABOUT (objectui#13292): `fieldLevel` + * entries name a field of the record and are presented as today — labelled in + * the banner and published as a per-field hint; `recordLevel` messages name no + * field, so they are shown alone, the way `form.tsx` falls back to the + * envelope's own text when an entry names no field it renders. + */ +interface SplitRefusal { + fieldLevel: WriteFieldError[]; + recordLevel: string[]; +} + +/** + * Sort attributed entries into field-level and record-level. + * + * `extractFieldErrors` is shared and keeps any entry with a non-empty `field`, + * so the `_record` sentinel (and a `field` naming nothing on the object) passes + * through it; deciding what such an entry is belongs HERE, on the surface that + * presents it — not in the shared normaliser. + */ +function splitRefusal( + entries: WriteFieldError[], + isRecordField: (name: string) => boolean, +): SplitRefusal { + const fieldLevel: WriteFieldError[] = []; + const recordLevel: string[] = []; + for (const entry of entries) { + if (entry.field !== RECORD_LEVEL_FIELD && isRecordField(entry.field)) { + fieldLevel.push(entry); + } else if (entry.message) { + recordLevel.push(entry.message); + } + } + return { fieldLevel, recordLevel }; +} + /** * Issue a partial-record update through whichever method the DataSource * exposes. Mirrors the update/updateOne/patch fallback + `ifMatch` OCC token @@ -250,6 +297,37 @@ export const InlineEditSaveBar: React.FC = ({ const canAtomic = !!dataSource && !!objectName && recordId != null; + /** + * Record-level lines of the last refusal, held beside the banner text they + * were rendered into (objectui#13292). Shown only while the session's + * `error` is still that same text, so the context's own clears (`enter()`, + * teardown, the next save) retire them without a second reset path. + */ + const [recordRefusal, setRecordRefusal] = React.useState<{ + error: string; + messages: string[]; + } | null>(null); + + /** + * Whether `name` is a field of the record this bar is editing — the only + * names that may carry a label and a per-field hint (objectui#13292). This + * surface holds no object schema, so it accepts what it can see: a key the + * user staged, a key of the server record, or a name the host resolves to a + * label of its own (a host that echoes the machine name back resolves + * nothing). + */ + const isRecordField = React.useCallback( + (name: string) => { + if (inline && Object.prototype.hasOwnProperty.call(inline.draft, name)) return true; + if (data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, name)) { + return true; + } + const label = fieldLabelFor?.(name); + return !!label && label !== name; + }, + [inline, data, fieldLabelFor], + ); + /** * Present a rejected save. A field-scoped refusal is published to the shared * session as a per-field map, which the field renderers (`DetailSection`, @@ -267,15 +345,27 @@ export const InlineEditSaveBar: React.FC = ({ if (!inline) return; const attributed = attributeInlineRefusal(err, inFlightField); if (!attributed) { + setRecordRefusal(null); inline.setFieldErrors(null); inline.setError(cleanError(err)); return; } const labelOf = (name: string) => fieldLabelFor?.(name) || name; - inline.setFieldErrors(Object.fromEntries(attributed.map((r) => [r.field, r.message]))); - inline.setError(attributed.map((r) => `${labelOf(r.field)}: ${r.message}`).join('; ')); + const { fieldLevel, recordLevel } = splitRefusal(attributed, isRecordField); + // objectui#13292: an object-level rule's refusal names no field, so its + // message reads alone — no label prefix, and no per-field hint. + const lines = [ + ...fieldLevel.map((r) => `${labelOf(r.field)}: ${r.message}`), + ...recordLevel, + ]; + const error = lines.length > 0 ? lines.join('; ') : cleanError(err); + setRecordRefusal(recordLevel.length > 0 ? { error, messages: recordLevel } : null); + inline.setFieldErrors( + fieldLevel.length > 0 ? Object.fromEntries(fieldLevel.map((r) => [r.field, r.message])) : null, + ); + inline.setError(error); }, - [inline, fieldLabelFor], + [inline, fieldLabelFor, isRecordField], ); /** @@ -446,6 +536,9 @@ export const InlineEditSaveBar: React.FC = ({ // Render nothing unless a provider is present and the record is being edited. if (!inline || !inline.editing) return null; + const recordMessages = + recordRefusal && recordRefusal.error === inline.error ? recordRefusal.messages : []; + return ( <>
= ({ role="alert" className="mr-auto max-w-md rounded-md border border-destructive/20 bg-destructive/10 px-2 py-1 text-xs text-destructive" > - {inline.fieldErrors ? ( + {inline.fieldErrors || recordMessages.length > 0 ? (
    - {Object.entries(inline.fieldErrors).map(([field, message]) => ( + {Object.entries(inline.fieldErrors ?? {}).map(([field, message]) => (
  • {labelForField(field)} {': '} {message}
  • ))} + {/* objectui#13292: record-level reasons name no field — the + rule's own sentence, with no label in front of it. */} + {recordMessages.map((message, i) => ( +
  • + {message} +
  • + ))}
) : ( inline.error diff --git a/packages/plugin-detail/src/__tests__/InlineEditSaveBar.recordLevelRefusal-13292.test.tsx b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.recordLevelRefusal-13292.test.tsx new file mode 100644 index 0000000000..1e3e4de0c2 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.recordLevelRefusal-13292.test.tsx @@ -0,0 +1,132 @@ +/** + * 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. + * + * objectui#13292 — an OBJECT-LEVEL rule's refusal names no field. The engine + * attributes such a rule (a `script` rule, or one whose `fields` is empty) to + * the `_record` sentinel, and the inline save bar used to print it as a label: + * the banner read `_record: `. + * + * The rulings these pin: + * (a) a `_record` entry shows the rule's message ALONE — no label, no + * sentinel text anywhere, no per-field hint; + * (b) a `field` naming nothing on the record is treated the same; + * (c) a real field's entry is unchanged — "Label: message" and its hint; + * (d) a mixed refusal shows both shapes. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; +import { InlineEditSaveBar } from '../InlineEditSaveBar'; + +const RULE_MESSAGE = 'Only Convert Lead marks a lead as Converted'; + +/** A `VALIDATION_FAILED` as `@objectstack/client` delivers it (`details.fields`). */ +function validationRefusal(fields: Array>) { + return { + code: 'VALIDATION_FAILED', + message: RULE_MESSAGE, + details: { error: RULE_MESSAGE, code: 'VALIDATION_FAILED', fields }, + }; +} + +/** A host that echoes the machine name back when it knows no label, as the console does. */ +const LABELS: Record = { status: 'Status', converted: 'Converted' }; +const fieldLabelFor = (n: string) => LABELS[n] ?? n; + +function Harness() { + const inline = useInlineEdit()!; + return ( + <> + + + + {JSON.stringify(inline.fieldErrors)} + + ); +} + +function renderAndSave(err: unknown) { + const update = vi.fn().mockRejectedValue(err); + render( + + + + , + ); + fireEvent.click(screen.getByText('edit-enter')); + fireEvent.click(screen.getByText('edit-status')); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + return update; +} + +const banner = () => screen.getByRole('alert'); +const publishedHints = () => JSON.parse(screen.getByTestId('published-hints').textContent || 'null'); +const fieldLines = () => + Array.from(document.querySelectorAll('[data-inline-field-error]')).map((li) => li.textContent); +const recordLines = () => + Array.from(document.querySelectorAll('[data-inline-record-error]')).map((li) => li.textContent); + +describe('objectui#13292 — a record-level refusal reads without a field label', () => { + it('(a) shows a `_record` entry as the rule message alone, with no sentinel and no hint', async () => { + renderAndSave(validationRefusal([{ field: '_record', code: 'rule_violation', message: RULE_MESSAGE }])); + + await waitFor(() => expect(banner()).toBeInTheDocument()); + expect(banner().textContent).toBe(RULE_MESSAGE); + expect(document.body.textContent).not.toContain('_record'); + expect(fieldLines()).toEqual([]); + expect(publishedHints()).toBeNull(); + }); + + it('(b) treats a `field` naming nothing on the record the same as `_record`', async () => { + renderAndSave(validationRefusal([{ field: 'no_such_field', code: 'rule_violation', message: RULE_MESSAGE }])); + + await waitFor(() => expect(banner()).toBeInTheDocument()); + expect(banner().textContent).toBe(RULE_MESSAGE); + expect(document.body.textContent).not.toContain('no_such_field'); + expect(publishedHints()).toBeNull(); + }); + + it('(c) CONTROL: a real field keeps its label in the banner and its per-field hint', async () => { + renderAndSave(validationRefusal([{ field: 'converted', code: 'rule_violation', message: RULE_MESSAGE }])); + + await waitFor(() => expect(fieldLines()).toEqual([`Converted: ${RULE_MESSAGE}`])); + expect(recordLines()).toEqual([]); + expect(publishedHints()).toEqual({ converted: RULE_MESSAGE }); + }); + + it('(d) a mixed refusal labels the field entry and leaves the record-level message bare', async () => { + renderAndSave( + validationRefusal([ + { field: 'status', code: 'invalid_option', message: 'Status is not allowed here' }, + { field: '_record', code: 'rule_violation', message: RULE_MESSAGE }, + ]), + ); + + await waitFor(() => expect(fieldLines()).toEqual(['Status: Status is not allowed here'])); + expect(recordLines()).toEqual([RULE_MESSAGE]); + expect(banner().textContent).not.toContain('_record'); + expect(publishedHints()).toEqual({ status: 'Status is not allowed here' }); + }); + + it('retires the record-level line with the session', async () => { + renderAndSave(validationRefusal([{ field: '_record', code: 'rule_violation', message: RULE_MESSAGE }])); + await waitFor(() => expect(banner()).toBeInTheDocument()); + + fireEvent.click(screen.getByText('edit-cancel')); + fireEvent.click(screen.getByText('edit-enter')); + expect(screen.queryByRole('alert')).toBeNull(); + expect(recordLines()).toEqual([]); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx index 6ce186f6bc..5522461c3c 100644 --- a/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx @@ -68,7 +68,10 @@ function Harness() { const LABELS: Record = { status: 'Stage', budget: 'Budget' }; -function renderAtomic(update: ReturnType) { +function renderAtomic( + update: ReturnType, + data: Record = { updated_at: 'v1' }, +) { return render( @@ -76,7 +79,7 @@ function renderAtomic(update: ReturnType) { dataSource={{ update }} objectName="crm_opportunity" recordId="o1" - data={{ updated_at: 'v1' }} + data={data} refresh={vi.fn()} fieldLabelFor={(n) => LABELS[n]} /> @@ -131,7 +134,9 @@ describe('objectui#6868 — a VALIDATION_FAILED becomes a per-field hint (atomic const update = vi.fn().mockRejectedValue( validationRefusal([{ field: 'close_date', message: 'Close date must be in the future' }]), ); - renderAtomic(update); + // `close_date` is a field of the record (objectui#13292: a name the bar + // cannot place on the record is record-level, not an unlabelled field). + renderAtomic(update, { updated_at: 'v1', close_date: null }); stageBoth(); save(); await waitFor(() =>