From 0039b0b12125e00e3bad47767b719183c398861c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:38:08 +0000 Subject: [PATCH 1/3] fix(plugin-detail): map the server's VALIDATION_FAILED into per-field inline-edit hints The inline-edit save bar surfaced the backend's raw string when a write was refused, leaving the user to guess which edited field was wrong. The refusal has always been field-scoped; this reads it through @object-ui/react's extractFieldErrors -- the same single normaliser the form surface uses -- and renders one reason per rejected field, named by that field's label. Attribution never guesses: an envelope entry with no usable `field` is dropped, and callback (drawer) mode attributes from the call shape, where onFieldSave carries exactly one key per call. Non field-scoped failures keep the cleaned single-line message. Also records the maintainer's ruling on objectui#6868 in both modules' headers: the server is the validation authority on this surface. That was an absence and is now a decision. No client-side evaluator was added. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .changeset/6868-inline-edit-server-verdict.md | 31 +++ .../plugin-detail/src/InlineEditSaveBar.tsx | 165 ++++++++++++- .../plugin-detail/src/InlineFieldInput.tsx | 30 +++ ...veBar.serverVerdictFieldHint-6868.test.tsx | 223 ++++++++++++++++++ 4 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 .changeset/6868-inline-edit-server-verdict.md create mode 100644 packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx diff --git a/.changeset/6868-inline-edit-server-verdict.md b/.changeset/6868-inline-edit-server-verdict.md new file mode 100644 index 0000000000..24f462ee7f --- /dev/null +++ b/.changeset/6868-inline-edit-server-verdict.md @@ -0,0 +1,31 @@ +--- +'@object-ui/plugin-detail': patch +--- + +Inline edit: a rejected save now says WHICH field the server refused, and why. + +Editing a record in place on a detail page and hitting Save used to surface the +backend's own string when the write was refused — `VALIDATION_FAILED: +Validation failed for crm_opportunity` — leaving the user to guess which of the +fields they had just edited was the problem. The refusal has always been +field-scoped (`@objectstack/objectql`'s validators throw `VALIDATION_FAILED` +with `fields[]`, and both the REST layer and the runtime dispatcher pass those +entries through intact); the inline surface was the last one still dropping +them. `` now renders one reason per rejected field, named by +that field's own label — the same treatment record forms have had since #3222. + +Attribution never guesses. It reads the envelope through +`@object-ui/react`'s `extractFieldErrors`, the single in-repo normaliser the +form surface already uses, and an entry with no usable `field` is dropped +rather than pinned on whichever input is nearby. In the drawer's callback mode, +where persistence loops `onFieldSave(field, value)` one key at a time, a +rejection is attributed to the key that was in flight — a fact about the write, +not an inference. Anything that is not field-scoped (a network failure, a +permission denial) keeps the cleaned single-line message it had before. + +Also recorded in code, per the maintainer's ruling on objectui#6868: **the +server is the validation authority on the inline-edit surface.** That was +previously an absence — `InlineFieldInput` runs no rules and takes no `error` +prop — and it is now a decision, written into both modules' headers with a +pointer to the ruling. No client-side rule evaluator was added, and none should +be: the server is the only rule source, and this surface only presents it. diff --git a/packages/plugin-detail/src/InlineEditSaveBar.tsx b/packages/plugin-detail/src/InlineEditSaveBar.tsx index 95502a69cd..38b5db1fba 100644 --- a/packages/plugin-detail/src/InlineEditSaveBar.tsx +++ b/packages/plugin-detail/src/InlineEditSaveBar.tsx @@ -25,12 +25,56 @@ * and **Cmd/Ctrl+Enter** saves — both respecting `saving`/`locked`. * * The bar renders nothing unless the record is actively being edited. + * + * ## Validation authority on this surface: the SERVER (objectui#6868) + * + * ⚖️ Ruled by the maintainer on 2026-08-31 (decision batch #13, on + * https://github.com/objectstack-ai/objectui/issues/6868). Recorded here + * because, until that ruling, the inline-edit surface's lack of client-side + * validation was an ABSENCE, and an absence and a decision look identical in + * code. This comment is the difference. + * + * The ruling, in its own words: + * + * 1. 正式记录:行内编辑面的校验权威是**服务端**…此前这是一个「缺席」,现在是 + * 一个「决定」——在相应模块注释写明并指向本裁定。 + * 2. 交付物:把服务端 `VALIDATION_FAILED` 拒绝**映射为就地字段提示**(拒绝信息 + * 已含字段与规则,缺的只是呈现层)——用户不再看到原始服务器错误。 + * 3. ⛔ 不抽取共享求值器。 + * 4. ⛔ 照旧不写第二套校验实现——服务端是唯一规则源,前端只做呈现。 + * + * What that means for anyone editing this module: + * + * - ⛔ Do NOT add a client-side rule evaluator here, and do NOT call + * `buildValidationRules` from this surface. The form surface's producer + * emits a react-hook-form rule DESCRIPTOR, not a verdict; RHF is the only + * evaluator in the repo, and this package depends on neither. Wiring one + * up would create a second rule source that can disagree with the server — + * which is precisely how AI-authored metadata gets a green form and a + * rejected write. + * - ✅ DO present what the server already decided. The refusal envelope is + * field-scoped: `@objectstack/objectql`'s validators throw + * `VALIDATION_FAILED` with `fields[]`, and `@object-ui/react`'s + * `extractFieldErrors` is the ONE in-repo normaliser for it (the same one + * `form.tsx` uses). This module reads that normaliser and renders per-field + * reasons; it never re-derives a rule. + * + * The rule kinds the engine refuses on this surface were measured on a real + * ObjectQL engine before the ruling: `required` (empty AND null), `minLength`, + * `maxLength`, `email`, `url`, `min`, `max` — every one a `VALIDATION_FAILED` + * with the prior value left in storage. So no invalid value reachable here + * survives the write; the only defect was the SHAPE of the refusal the user saw. */ import * as React from 'react'; import { Button, cn } from '@object-ui/components'; import { Check, X, Loader2 } from 'lucide-react'; -import { useInlineEdit } from '@object-ui/react'; +import { + useInlineEdit, + extractFieldErrors, + extractWriteErrorMessage, + type WriteFieldError, +} from '@object-ui/react'; import { useDetailTranslation } from './useDetailTranslation'; import { ConcurrentUpdateDialog, @@ -97,7 +141,14 @@ export type BuildConflict = ( err: ConcurrentUpdateErrorShape, ) => ConcurrentUpdateConflict; -/** Strip noisy backend prefixes so the inline error reads cleanly. */ +/** + * Strip noisy backend prefixes so the inline error reads cleanly. + * + * The LAST-RESORT channel since objectui#6868: it is what the user sees only + * when the refusal is not field-scoped (a transport failure, a permission + * denial, a bare `Error`). A `VALIDATION_FAILED` never reaches it — that path + * goes through {@link attributeInlineRefusal} and renders per field. + */ function cleanError(err: any): string { const raw = err?.message || err?.error || String(err ?? 'Save failed'); return String(raw) @@ -105,6 +156,52 @@ function cleanError(err: any): string { .replace(/^[A-Z][A-Z0-9_]+:\s*/, ''); } +/** + * Attribute a rejected inline save to the FIELDS it is about, or answer `null` + * when it is not field-scoped (objectui#6868 deliverable 2). + * + * Two sources, in strict order, and neither of them guesses: + * + * 1. **The envelope.** `extractFieldErrors` — `@object-ui/react`'s single + * in-repo normaliser, the same one `form.tsx` reads — accepts the three + * shapes a `VALIDATION_FAILED` can arrive in (`validationErrors` from + * `@object-ui/data-objectstack`'s re-wrap, `details.fields` from the raw + * `@objectstack/client` error, or a bare `fields`) and drops any entry with + * no usable `field`. That drop is the point: a wrong mark on an innocent + * input is worse than the undirected string it replaces. This is the ONLY + * source for the DataSource path, whose write is one atomic multi-key + * update — nothing else there can say which key the server refused. + * + * 2. **The call shape, in callback mode only.** The drawer's persistence + * contract loops `onFieldSave(field, value)` one field at a time, so a + * rejection from that call belongs to that field by CONSTRUCTION, not by + * inference — the write in flight carried exactly one key. `inFlightField` + * is passed only from that loop; the atomic path passes `undefined`, so a + * multi-key write can never be attributed this way. + * + * ⚠️ This function evaluates NOTHING. It reads the server's verdict and says + * which input it belongs beside. Adding a rule check here would be the second + * validation implementation the ruling forbids. + * + * Exported for its pin test to reach, and deliberately NOT re-exported from + * `src/index.tsx` — which lists every published name explicitly — so the + * package's public surface is unchanged (the same treatment `BuildConflict` + * gets above). + */ +export function attributeInlineRefusal( + err: unknown, + inFlightField?: string, +): WriteFieldError[] | null { + const fromEnvelope = extractFieldErrors(err); + if (fromEnvelope) return fromEnvelope; + if (!inFlightField) return null; + // Field-scoped by the call shape, but the envelope carried no per-field text + // — fall back to the envelope's top-level reason rather than marking an input + // with no reason next to it (the rule `form.tsx` applies to the same case). + const message = extractWriteErrorMessage(err) || cleanError(err); + return [{ field: inFlightField, message }]; +} + /** * Issue a partial-record update through whichever method the DataSource * exposes. Mirrors the update/updateOne/patch fallback + `ifMatch` OCC token @@ -146,6 +243,19 @@ export const InlineEditSaveBar: React.FC = ({ const inline = useInlineEdit(); const [conflict, setConflict] = React.useState(null); const [conflictBusy, setConflictBusy] = React.useState(false); + /** + * Per-field reasons for the last refusal, or `null` when it was not + * field-scoped (objectui#6868). Rendered ONLY while `inline.error` is set — + * the context clears that on `enter()` and on teardown, so this local state + * can never outlive its session and re-appear over a later edit. + */ + const [refusals, setRefusals] = React.useState(null); + + /** User-facing name for a rejected field; the machine name when the host resolves none. */ + const labelForField = React.useCallback( + (name: string) => fieldLabelFor?.(name) || name, + [fieldLabelFor], + ); const canAtomic = !!dataSource && !!objectName && recordId != null; @@ -198,14 +308,21 @@ export const InlineEditSaveBar: React.FC = ({ } inline.setSaving(true); inline.setError(null); + setRefusals(null); + // Callback mode persists ONE key per call, so the key in flight is what a + // rejection is about. Stays `undefined` on the atomic path, where the write + // carries every edited key at once and only the envelope can attribute it. + let inFlightField: string | undefined; try { if (onFieldSave) { // Callback mode (drawer): persist each edited field sequentially so a // single backend rejection short-circuits, preserving the caller's // per-field contract. for (const [field, value] of entries) { + inFlightField = field; await onFieldSave(field, value); } + inFlightField = undefined; } else if (canAtomic) { // DataSource mode (record page): ONE atomic write of only the edited // keys, OCC-guarded by the record's current updated_at. @@ -220,12 +337,21 @@ export const InlineEditSaveBar: React.FC = ({ // Stay in edit mode; the dialog drives reload / overwrite. setConflict(buildConflict(draft, err)); } else { - inline.setError(cleanError(err)); + // objectui#6868: the server is the validation authority here, so a + // refusal is PRESENTED, never re-derived. A field-scoped one becomes a + // per-field reason; everything else keeps the cleaned string. + const attributed = attributeInlineRefusal(err, inFlightField); + setRefusals(attributed); + inline.setError( + attributed + ? attributed.map((r) => `${labelForField(r.field)}: ${r.message}`).join('; ') + : cleanError(err), + ); } } finally { inline.setSaving(false); } - }, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]); + }, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, labelForField]); const closeConflict = React.useCallback(() => { setConflict(null); @@ -258,11 +384,19 @@ export const InlineEditSaveBar: React.FC = ({ await refresh?.(); inline?.reset(); } catch (err) { - inline?.setError(cleanError(err)); + // Same presentation contract as the first save — an overwrite the server + // refuses on validation grounds gets per-field reasons, not a raw string. + const attributed = attributeInlineRefusal(err); + setRefusals(attributed); + inline?.setError( + attributed + ? attributed.map((r) => `${labelForField(r.field)}: ${r.message}`).join('; ') + : cleanError(err), + ); } finally { closeConflict(); } - }, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]); + }, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict, labelForField]); // Record-level keyboard shortcuts for the shared edit session // (objectui#2572 item 5): Cmd/Ctrl+Enter commits the draft, Esc cancels. @@ -316,12 +450,29 @@ export const InlineEditSaveBar: React.FC = ({ role="region" aria-label={t('detail.editFieldsInline')} > + {/* objectui#6868 deliverable 2: a field-scoped refusal is rendered as + one reason PER FIELD, named by the field's own label, instead of the + raw server string the user used to get. `refusals` is gated on + `inline.error` so a stale attribution from an earlier session can + never surface — the context clears `error` on enter/teardown. */} {inline.error && (
- {inline.error} + {refusals ? ( +
    + {refusals.map((r) => ( +
  • + {labelForField(r.field)} + {': '} + {r.message} +
  • + ))} +
+ ) : ( + inline.error + )}
)} {/* The lock REASON is surfaced by DetailView's approval-lock band; here diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx index 9937ced940..0ca8aba393 100644 --- a/packages/plugin-detail/src/InlineFieldInput.tsx +++ b/packages/plugin-detail/src/InlineFieldInput.tsx @@ -220,6 +220,36 @@ export interface InlineFieldInputProps { * Editability GATING (computed types, `readonly`, system fields, object * lifecycle) stays with the host — this component only renders the editor once * the host has decided the field is editable. + * + * ## Why there is no validation in this component (objectui#6868) + * + * ⚖️ This is a DECISION, not an omission. The maintainer ruled on 2026-08-31 + * (decision batch #13, on + * https://github.com/objectstack-ai/objectui/issues/6868) that **the server is + * the validation authority on the inline-edit surface**. Recorded here because + * this component is where the question gets asked: it imports from + * `@object-ui/fields` but never calls `buildValidationRules`, and it takes no + * `error` prop — which for two years read as an oversight and is now a ruling. + * + * ⛔ Do NOT "fix" that by adding rules here. The ruling refuses both an + * extracted shared evaluator and a second validation implementation on this + * surface: the server is the only rule source, and the front end only presents + * its verdict. Every rule kind reachable through this component was measured + * against a real ObjectQL engine and refused server-side with + * `VALIDATION_FAILED` (`required` empty and null, `minLength`, `maxLength`, + * `email`, `url`, `min`, `max`), with the prior value left in storage. + * + * ✅ The presentation half lives in ``, which turns the + * server's field-scoped refusal into a reason per field. See that module's + * header for the ruling in full. + * + * ⚠️ One measured caveat for anyone extending this later: the widgets' + * published `error` slot (objectui#3222) marks `aria-invalid` — it does NOT + * render the message. Measured at 2c3cd1b: `NumberField` given `error` sets + * `aria-invalid="true"` and renders no text, exactly as on the form surface + * where `form.tsx` — not the widget — draws the visible message. So threading + * `error` through here would buy the a11y marking, and the visible hint would + * still be this package's markup to render. */ export const InlineFieldInput: React.FC = ({ field, diff --git a/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx new file mode 100644 index 0000000000..445304ecfd --- /dev/null +++ b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx @@ -0,0 +1,223 @@ +/** + * 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#6868 — the inline-edit surface's validation authority is the SERVER + * (maintainer ruling, 2026-08-31, decision batch #13), and the deliverable that + * follows from it is presentation only: a `VALIDATION_FAILED` refusal must + * reach the user as a reason PER FIELD instead of the raw server string + * `cleanError` used to produce. + * + * These pin the acceptance contract of that mapping: + * + * - the atomic multi-key write attributes each rejected key from the + * ENVELOPE, and the raw server string stops being shown; + * - callback mode attributes from the CALL SHAPE (one key per + * `onFieldSave`), which is a fact about the write, not an inference; + * - a refusal that is NOT field-scoped keeps the cleaned string — no input + * is marked on a guess; + * - an envelope entry with no usable `field` is DROPPED rather than pinned + * on whichever input happens to be nearby, which would be worse than the + * undirected string it replaces; + * - the attribution cannot outlive its edit session. + * + * ⚠️ These assert PRESENTATION of a server verdict. Nothing here evaluates a + * rule, and nothing here may start to: the ruling forbids a second validation + * implementation on this surface. + */ + +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'; + +/** The raw server text the user must STOP seeing once the mapping is in place. */ +const RAW_SERVER_TEXT = 'VALIDATION_FAILED: Validation failed for crm_opportunity'; + +/** + * A `VALIDATION_FAILED` exactly as `@objectstack/client` delivers it: the + * client sets `details` to the parsed body's `details`, falling back to the + * WHOLE body — and the validation envelope has no `details` key, so `fields[]` + * lands there. (`@object-ui/data-objectstack`'s re-wrap moves the same entries + * onto `validationErrors`; the shared normaliser reads both.) + */ +function validationRefusal(fields: Array>) { + return { + code: 'VALIDATION_FAILED', + message: RAW_SERVER_TEXT, + details: { error: 'Validation failed', code: 'VALIDATION_FAILED', fields }, + }; +} + +function Harness() { + const inline = useInlineEdit()!; + return ( + <> + + + + + + ); +} + +const LABELS: Record = { status: 'Stage', budget: 'Budget' }; + +function renderAtomic(update: ReturnType) { + return render( + + + LABELS[n]} + /> + , + ); +} + +const stageBoth = () => { + fireEvent.click(screen.getByText('edit-enter')); + fireEvent.click(screen.getByText('edit-status')); + fireEvent.click(screen.getByText('edit-budget')); +}; +const save = () => fireEvent.click(screen.getByRole('button', { name: 'Save' })); + +/** Every hint the bar is currently showing, keyed by the field it is attributed to. */ +function hints(): Record { + const out: Record = {}; + for (const li of Array.from(document.querySelectorAll('[data-inline-field-error]'))) { + out[li.getAttribute('data-inline-field-error')!] = li.textContent ?? ''; + } + return out; +} + +describe('objectui#6868 — a VALIDATION_FAILED becomes a per-field hint (atomic mode)', () => { + it('attributes EVERY rejected key from the envelope, by label, and stops showing the raw server string', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([ + { field: 'status', code: 'required', message: 'Stage is required' }, + { field: 'budget', code: 'min', message: 'Budget must be at least 0' }, + ]), + ); + renderAtomic(update); + stageBoth(); + save(); + + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + // THE deliverable: one reason per rejected field, named by its own label. + await waitFor(() => + expect(hints()).toEqual({ + status: 'Stage: Stage is required', + budget: 'Budget: Budget must be at least 0', + }), + ); + // ...and the raw server text the user used to be shown is gone. + expect(screen.getByRole('alert').textContent).not.toContain('VALIDATION_FAILED'); + expect(screen.queryByText(RAW_SERVER_TEXT)).toBeNull(); + // The draft is kept — the refusal is a correction prompt, not a discard. + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + }); + + it('falls back to the field machine name when the host resolves no label', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ field: 'close_date', message: 'Close date must be in the future' }]), + ); + renderAtomic(update); + stageBoth(); + save(); + await waitFor(() => + expect(hints()).toEqual({ close_date: 'close_date: Close date must be in the future' }), + ); + }); + + it('DROPS an entry with no usable `field` rather than marking an innocent input', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ code: 'required', message: 'something is required' }]), + ); + renderAtomic(update); + stageBoth(); + save(); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + // No attribution at all — and the user still gets the cleaned reason. + expect(hints()).toEqual({}); + expect(screen.getByRole('alert').textContent).toContain('Validation failed for crm_opportunity'); + }); + + it('leaves a NON field-scoped failure on the cleaned string (no invented attribution)', async () => { + const update = vi.fn().mockRejectedValue(new Error('[api] NETWORK_ERROR: connection reset')); + renderAtomic(update); + stageBoth(); + save(); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(hints()).toEqual({}); + expect(screen.getByRole('alert').textContent).toContain('connection reset'); + }); + + it('does not let an attribution outlive its edit session', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ field: 'status', message: 'Stage is required' }]), + ); + renderAtomic(update); + stageBoth(); + save(); + await waitFor(() => expect(Object.keys(hints())).toEqual(['status'])); + + fireEvent.click(screen.getByText('edit-cancel')); + fireEvent.click(screen.getByText('edit-enter')); + // A fresh session shows no stale hint and no stale banner. + expect(hints()).toEqual({}); + expect(screen.queryByRole('alert')).toBeNull(); + }); +}); + +describe('objectui#6868 — callback (drawer) mode attributes from the CALL SHAPE', () => { + it('names the field whose own onFieldSave rejected, even with no fields[] in the envelope', async () => { + // Rejects only the SECOND key, and carries no per-field entries at all — + // the drawer's per-field contract is what makes this attributable. + const onFieldSave = vi.fn(async (field: string) => { + if (field === 'budget') { + throw { code: 'VALIDATION_FAILED', message: RAW_SERVER_TEXT, details: { error: 'Budget must be at least 0' } }; + } + }); + render( + + + LABELS[n]} /> + , + ); + stageBoth(); + save(); + + await waitFor(() => expect(onFieldSave).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(hints()).toEqual({ budget: 'Budget: Budget must be at least 0' })); + expect(screen.getByRole('alert').textContent).not.toContain('VALIDATION_FAILED'); + }); + + it('still prefers the ENVELOPE over the call shape when the server names fields', async () => { + const onFieldSave = vi.fn(async (field: string) => { + if (field === 'status') { + throw validationRefusal([{ field: 'budget', message: 'Budget must be at least 0' }]); + } + }); + render( + + + LABELS[n]} /> + , + ); + stageBoth(); + save(); + + // `status` was in flight, but the server said `budget` — the server wins. + await waitFor(() => expect(hints()).toEqual({ budget: 'Budget: Budget must be at least 0' })); + }); +}); From 2560846b9cad351547c9ff1a5db96ca66ef93c7e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 00:53:30 +0000 Subject: [PATCH 2/3] refactor(plugin-detail): keep attributeInlineRefusal module-local Its pin drives it through the rendered save bar, which is the path that actually reaches the mapping, so the export bought nothing and cost a react-refresh/only-export-components warning this file did not have before (measured: baseline 7 no-explicit-any + 0 react-refresh; now identical). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- packages/plugin-detail/src/InlineEditSaveBar.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/plugin-detail/src/InlineEditSaveBar.tsx b/packages/plugin-detail/src/InlineEditSaveBar.tsx index 38b5db1fba..55982442bf 100644 --- a/packages/plugin-detail/src/InlineEditSaveBar.tsx +++ b/packages/plugin-detail/src/InlineEditSaveBar.tsx @@ -183,12 +183,11 @@ function cleanError(err: any): string { * which input it belongs beside. Adding a rule check here would be the second * validation implementation the ruling forbids. * - * Exported for its pin test to reach, and deliberately NOT re-exported from - * `src/index.tsx` — which lists every published name explicitly — so the - * package's public surface is unchanged (the same treatment `BuildConflict` - * gets above). + * Module-local on purpose: its pin drives it through the rendered bar, which is + * how the mapping is actually reached, so exporting it would widen the module's + * name surface (and its fast-refresh footprint) for nothing. */ -export function attributeInlineRefusal( +function attributeInlineRefusal( err: unknown, inFlightField?: string, ): WriteFieldError[] | null { From 2012c0a8791d00ce143661020ee98d7ada59deb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 01:15:17 +0000 Subject: [PATCH 3/3] feat(react,plugin-detail): render the server's refusal beside the input it refused Option A, authorised by the PM after the measurement showed the honest completion of the objectui#6868 ruling needs one adjacent file: the save bar and the field rows are siblings under InlineEditProvider in both persistence modes, so there was no channel between the component that receives a refusal and the components that render the fields it is about. - @object-ui/react: InlineEditContextValue gains `fieldErrors` (field machine name to the server's reason, nullable) and `setFieldErrors`, the exact companions of the `error`/`setError` pair it already carried. Cleared by enter() and teardown like `error`, so an attribution cannot outlive its session. Additive; nothing removed, nothing reshaped. - InlineEditSaveBar publishes the attributed refusal onto the session instead of holding it locally, and keeps the record-level summary for a field that is collapsed or scrolled out of view. - DetailSection and HeaderHighlight read it and draw the reason under the input, with role="alert". - InlineFieldInput takes `error` and forwards it to the widgets' published #3222 slot (aria-invalid), and marks the terminal raw input directly. The prop is no longer dead now that a value exists to put in it. Presentation only. Nothing here evaluates a rule: the server remains the sole validation authority on this surface. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM --- .changeset/6868-inline-edit-server-verdict.md | 13 ++ packages/plugin-detail/src/DetailSection.tsx | 27 +++- .../plugin-detail/src/HeaderHighlight.tsx | 30 +++- .../plugin-detail/src/InlineEditSaveBar.tsx | 82 ++++++----- .../plugin-detail/src/InlineFieldInput.tsx | 41 ++++-- ...veBar.serverVerdictFieldHint-6868.test.tsx | 134 ++++++++++++++++++ .../react/src/context/InlineEditContext.tsx | 40 +++++- 7 files changed, 311 insertions(+), 56 deletions(-) diff --git a/.changeset/6868-inline-edit-server-verdict.md b/.changeset/6868-inline-edit-server-verdict.md index 24f462ee7f..86be7d3a4c 100644 --- a/.changeset/6868-inline-edit-server-verdict.md +++ b/.changeset/6868-inline-edit-server-verdict.md @@ -1,5 +1,6 @@ --- '@object-ui/plugin-detail': patch +'@object-ui/react': minor --- Inline edit: a rejected save now says WHICH field the server refused, and why. @@ -23,6 +24,18 @@ rejection is attributed to the key that was in flight — a fact about the write not an inference. Anything that is not field-scoped (a network failure, a permission denial) keeps the cleaned single-line message it had before. +`@object-ui/react` gains one additive public API member to carry this, and it +is the reason that package's entry is `minor` rather than `patch`: +`InlineEditContextValue` now has **`fieldErrors`** — a nullable map of field +machine name to the server's reason — alongside a **`setFieldErrors`** setter, +the exact companions of the `error` / `setError` pair that interface already +carried. Nothing is removed and nothing changes shape, so every existing host +and consumer compiles and behaves as before; a host that never reads the new +member sees no difference. It exists because the save bar and the field rows +are SIBLINGS under `InlineEditProvider` in both persistence modes, so before +this there was no channel between the component that receives a refusal and the +components that render the fields it is about. + Also recorded in code, per the maintainer's ruling on objectui#6868: **the server is the validation authority on the inline-edit surface.** That was previously an absence — `InlineFieldInput` runs no rules and takes no `error` diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 960ec26aa4..e2ef435f3d 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -25,7 +25,7 @@ import { LazyIcon, } from '@object-ui/components'; import { ChevronDown, ChevronRight, Copy, Check, Eye, EyeOff, Pencil } from 'lucide-react'; -import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; +import { SchemaRenderer, toRenderableSchema, useInlineEdit } from '@object-ui/react'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import type { DetailViewSection as DetailViewSectionType, DetailViewField, FieldMetadata } from '@object-ui/types'; import { applyDetailAutoLayout } from './autoLayout'; @@ -143,6 +143,14 @@ export const DetailSection: React.FC = ({ const [showEmptyOverride, setShowEmptyOverride] = React.useState(false); const { t } = useDetailTranslation(); const { fieldLabel, translateOptions } = useSafeFieldLabel(); + /** + * The SERVER's per-field refusals from the last rejected inline save + * (objectui#6868), read straight off the shared edit session so the reason + * lands beside the input the server named. `null` outside an + * `` — a bare / read-only `DetailSection` simply has no + * session to read, exactly as it has no draft. + */ + const serverFieldErrors = useInlineEdit()?.fieldErrors ?? null; const handleCopyField = React.useCallback((fieldName: string, value: any) => { const textValue = value !== null && value !== undefined ? String(value) : ''; @@ -336,7 +344,24 @@ export const DetailSection: React.FC = ({ onChange={(v) => onFieldChange?.(field.name, v)} dataSource={dataSource} autoFocus={autoFocusField === field.name} + error={serverFieldErrors?.[field.name]} /> + {/* The SERVER's reason for refusing this field, in place + (objectui#6868). Published by `` onto the + shared session after a rejected save; the widget's #3222 slot + only marks `aria-invalid`, so the visible text is drawn here — + exactly as `form.tsx` draws it on the form surface rather than + leaving it to the widget. Nothing on this path evaluates a + rule: the server is the validation authority on this surface. */} + {serverFieldErrors?.[field.name] && ( +

+ {serverFieldErrors[field.name]} +

+ )} ) : (
= ({ {editorActive ? ( - inline!.setField(field.name, v)} - dataSource={dataSource} - autoFocus={inline!.autoFocusField === field.name} - /> + <> + inline!.setField(field.name, v)} + dataSource={dataSource} + autoFocus={inline!.autoFocusField === field.name} + error={inline!.fieldErrors?.[field.name]} + /> + {/* The SERVER's reason for refusing this field, in place + (objectui#6868) — the highlights strip shares ONE edit + session with the details body, so a refusal marks the + field wherever the user is editing it. */} + {inline!.fieldErrors?.[field.name] && ( +

+ {inline!.fieldErrors[field.name]} +

+ )} + ) : (
= ({ const inline = useInlineEdit(); const [conflict, setConflict] = React.useState(null); const [conflictBusy, setConflictBusy] = React.useState(false); - /** - * Per-field reasons for the last refusal, or `null` when it was not - * field-scoped (objectui#6868). Rendered ONLY while `inline.error` is set — - * the context clears that on `enter()` and on teardown, so this local state - * can never outlive its session and re-appear over a later edit. - */ - const [refusals, setRefusals] = React.useState(null); - /** User-facing name for a rejected field; the machine name when the host resolves none. */ const labelForField = React.useCallback( (name: string) => fieldLabelFor?.(name) || name, @@ -258,6 +250,34 @@ export const InlineEditSaveBar: React.FC = ({ const canAtomic = !!dataSource && !!objectName && recordId != null; + /** + * Present a rejected save. A field-scoped refusal is published to the shared + * session as a per-field map, which the field renderers (`DetailSection`, + * `HeaderHighlight`) draw beside the input the server named — the in-place + * hint objectui#6868 asks for. The record-level `error` is set either way, so + * the bar keeps a summary for a field that is collapsed or scrolled out of + * view, the same reason `form.tsx` keeps its banner. + * + * Publishing through the context rather than local state is also what makes + * the attribution unable to outlive its session: `enter()` and teardown clear + * both slots, so a stale hint cannot reappear over a later edit. + */ + const presentRefusal = React.useCallback( + (err: unknown, inFlightField?: string) => { + if (!inline) return; + const attributed = attributeInlineRefusal(err, inFlightField); + if (!attributed) { + 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('; ')); + }, + [inline, fieldLabelFor], + ); + /** * Build the conflict payload for `` from a 409. A * single-field draft shows the classic per-field before/after; a multi-field @@ -307,7 +327,7 @@ export const InlineEditSaveBar: React.FC = ({ } inline.setSaving(true); inline.setError(null); - setRefusals(null); + inline.setFieldErrors(null); // Callback mode persists ONE key per call, so the key in flight is what a // rejection is about. Stays `undefined` on the atomic path, where the write // carries every edited key at once and only the envelope can attribute it. @@ -337,20 +357,13 @@ export const InlineEditSaveBar: React.FC = ({ setConflict(buildConflict(draft, err)); } else { // objectui#6868: the server is the validation authority here, so a - // refusal is PRESENTED, never re-derived. A field-scoped one becomes a - // per-field reason; everything else keeps the cleaned string. - const attributed = attributeInlineRefusal(err, inFlightField); - setRefusals(attributed); - inline.setError( - attributed - ? attributed.map((r) => `${labelForField(r.field)}: ${r.message}`).join('; ') - : cleanError(err), - ); + // refusal is PRESENTED, never re-derived. + presentRefusal(err, inFlightField); } } finally { inline.setSaving(false); } - }, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, labelForField]); + }, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]); const closeConflict = React.useCallback(() => { setConflict(null); @@ -385,17 +398,11 @@ export const InlineEditSaveBar: React.FC = ({ } catch (err) { // Same presentation contract as the first save — an overwrite the server // refuses on validation grounds gets per-field reasons, not a raw string. - const attributed = attributeInlineRefusal(err); - setRefusals(attributed); - inline?.setError( - attributed - ? attributed.map((r) => `${labelForField(r.field)}: ${r.message}`).join('; ') - : cleanError(err), - ); + presentRefusal(err); } finally { closeConflict(); } - }, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict, labelForField]); + }, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict, presentRefusal]); // Record-level keyboard shortcuts for the shared edit session // (objectui#2572 item 5): Cmd/Ctrl+Enter commits the draft, Esc cancels. @@ -449,23 +456,24 @@ export const InlineEditSaveBar: React.FC = ({ role="region" aria-label={t('detail.editFieldsInline')} > - {/* objectui#6868 deliverable 2: a field-scoped refusal is rendered as - one reason PER FIELD, named by the field's own label, instead of the - raw server string the user used to get. `refusals` is gated on - `inline.error` so a stale attribution from an earlier session can - never surface — the context clears `error` on enter/teardown. */} + {/* objectui#6868: a field-scoped refusal reads as one reason PER FIELD, + named by the field's own label, instead of the raw server string. + The SAME reasons are drawn beside each input by the field renderers + (they read `fieldErrors` off this session); this summary stays + because a rejected field can be collapsed or scrolled out of view — + the reason `form.tsx` keeps its banner too. */} {inline.error && (
- {refusals ? ( + {inline.fieldErrors ? (
    - {refusals.map((r) => ( -
  • - {labelForField(r.field)} + {Object.entries(inline.fieldErrors).map(([field, message]) => ( +
  • + {labelForField(field)} {': '} - {r.message} + {message}
  • ))}
diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx index 0ca8aba393..0c4760ac59 100644 --- a/packages/plugin-detail/src/InlineFieldInput.tsx +++ b/packages/plugin-detail/src/InlineFieldInput.tsx @@ -202,6 +202,18 @@ export interface InlineFieldInputProps { dataSource?: any; /** Auto-focus the underlying input on mount (wired to the entered field). */ autoFocus?: boolean; + /** + * The SERVER's reason for refusing this field on the last save, or undefined + * (objectui#6868). Forwarded to the widget's published objectui#3222 `error` + * slot, which marks `aria-invalid` — it does NOT render text, on this surface + * or on the form one, so the visible hint is drawn by the HOST beside this + * input (see `DetailSection` / `HeaderHighlight`). + * + * ⚠️ Never a client-side verdict. The ruling on objectui#6868 makes the + * server the validation authority here; this prop only carries what the + * server already decided. + */ + error?: string; } /** @@ -257,6 +269,7 @@ export const InlineFieldInput: React.FC = ({ onChange, dataSource, autoFocus, + error, }) => { const editType = field.type; // Per-field widget override (ADR-0056 P1) — honor a `widget` hint before the @@ -319,12 +332,14 @@ export const InlineFieldInput: React.FC = ({ field={{ ...(field as any), multiple: true }} value={Array.isArray(value) ? value : value == null || value === '' ? [] : [value]} onChange={(v) => onChange(v)} + error={error} /> ) : ( onChange(v)} + error={error} /> ); } @@ -335,6 +350,7 @@ export const InlineFieldInput: React.FC = ({ field={field as any} value={!!value} onChange={(v) => onChange(v)} + error={error} /> ); } @@ -344,16 +360,16 @@ export const InlineFieldInput: React.FC = ({ // preview, replace, add and remove files (objectui image inline-edit showing a // bare URL string). if (editType === 'image') { - return onChange(v)} />; + return onChange(v)} error={error} />; } if (editType === 'avatar') { - return onChange(v)} />; + return onChange(v)} error={error} />; } if (editType === 'signature') { - return onChange(v)} />; + return onChange(v)} error={error} />; } if (editType === 'file' || editType === 'video' || editType === 'audio') { - return onChange(v)} />; + return onChange(v)} error={error} />; } // Reference fields (lookup / master_detail / tree / user / owner) store an id // but may arrive `$expand`-ed as a record object. A plain text input would @@ -384,6 +400,7 @@ export const InlineFieldInput: React.FC = ({ value={value} onChange={(v: any) => onChange(v)} dataSource={dataSource} + error={error} /> ); } @@ -394,13 +411,13 @@ export const InlineFieldInput: React.FC = ({ // (`decimal`/`integer` are not spec FieldTypes — metadata should declare // `number` with `scale`, so they are deliberately not aliased here.) if (editType === 'number') { - return onChange(v)} autoFocus={autoFocus} />; + return onChange(v)} autoFocus={autoFocus} error={error} />; } if (editType === 'currency') { - return onChange(v)} autoFocus={autoFocus} />; + return onChange(v)} autoFocus={autoFocus} error={error} />; } if (editType === 'percent') { - return onChange(v)} autoFocus={autoFocus} />; + return onChange(v)} autoFocus={autoFocus} error={error} />; } // Structured-value composites → the SAME widgets the create/edit dialog uses // (objectui#4216). Their stored value is an OBJECT, and the terminal text @@ -422,13 +439,13 @@ export const InlineFieldInput: React.FC = ({ // latitude / the coordinate box), so entering inline edit on the field lands // the caret in the same place a single-input type would. if (editType === 'address') { - return onChange(v)} autoFocus={autoFocus} />; + return onChange(v)} autoFocus={autoFocus} error={error} />; } if (editType === 'location') { - return onChange(v)} autoFocus={autoFocus} />; + return onChange(v)} autoFocus={autoFocus} error={error} />; } if (editType === 'geolocation') { - return onChange(v)} autoFocus={autoFocus} />; + return onChange(v)} autoFocus={autoFocus} error={error} />; } const isDate = editType === 'date' || editType === 'datetime'; // Everything the switch did not route, and that is not class D, edits with @@ -466,6 +483,7 @@ export const InlineFieldInput: React.FC = ({ value={value} onChange={(v: any) => onChange(v)} autoFocus={autoFocus} + error={error} /> ); } @@ -495,6 +513,9 @@ export const InlineFieldInput: React.FC = ({ // coercion on both sides), not the lossy fallback the guard hunts for. data-testid={isDate ? undefined : INLINE_PLAIN_TEXT_INPUT_TESTID} autoFocus={autoFocus} + // No widget sits behind this branch to honour the #3222 slot, so the + // refusal marking is applied to the element itself. + aria-invalid={error ? true : undefined} className="w-full px-2 py-1.5 text-sm border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring" value={inputValue} onChange={(e) => { 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 445304ecfd..6ce186f6bc 100644 --- a/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx @@ -33,6 +33,8 @@ 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'; +import { DetailSection } from '../DetailSection'; +import { HeaderHighlight } from '../HeaderHighlight'; /** The raw server text the user must STOP seeing once the mapping is in place. */ const RAW_SERVER_TEXT = 'VALIDATION_FAILED: Validation failed for crm_opportunity'; @@ -221,3 +223,135 @@ describe('objectui#6868 — callback (drawer) mode attributes from the CALL SHAP await waitFor(() => expect(hints()).toEqual({ budget: 'Budget: Budget must be at least 0' })); }); }); + +/** + * The hint IN PLACE — beside the input, which is what the ruling's 就地字段提示 + * actually asks for. The save bar and the field renderers are SIBLINGS under + * one `InlineEditProvider`, exactly as both real hosts mount them + * (`app-shell/RecordDetailView.tsx:2340`/`:2509`, `RecordDetailDrawer.tsx:346`/ + * `:398`), so these drive the real transport rather than a stand-in for it. + */ +describe('objectui#6868 — the reason renders beside the input it is about', () => { + const objectSchema = { fields: { status: { type: 'text' }, budget: { type: 'number' } } }; + const section = { + fields: [ + { name: 'status', label: 'Stage' }, + { name: 'budget', label: 'Budget' }, + ], + } as any; + + /** One session: the details body and the save bar, as the record page mounts them. */ + function renderBodyAndBar(update: ReturnType) { + function Body() { + const inline = useInlineEdit()!; + return ( + inline.setField(f, v)} + autoFocusField={inline.autoFocusField} + /> + ); + } + return render( + + + + LABELS[n]} + /> + , + ); + } + + const inPlaceHint = (field: string) => + document.querySelector(`[data-inline-field-hint="${field}"]`); + + it('draws the server reason under the refused field, and only under that field', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ field: 'budget', code: 'min', message: 'Budget must be at least 0' }]), + ); + const { container } = renderBodyAndBar(update); + stageBoth(); + // CONTROL that must hit: the body really rendered editors for both fields, + // so a later zero on `status` is a real absence and not an empty harness. + expect(container.querySelectorAll('input').length).toBeGreaterThanOrEqual(2); + save(); + + await waitFor(() => expect(inPlaceHint('budget')?.textContent).toBe('Budget must be at least 0')); + // The field the server did NOT refuse carries no hint. + expect(inPlaceHint('status')).toBeNull(); + // ...and it is announced, not merely printed. + expect(inPlaceHint('budget')?.getAttribute('role')).toBe('alert'); + }); + + it('marks the refused input aria-invalid, and leaves the accepted one unmarked', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ field: 'budget', message: 'Budget must be at least 0' }]), + ); + renderBodyAndBar(update); + stageBoth(); + save(); + + await waitFor(() => expect(inPlaceHint('budget')).not.toBeNull()); + const marked = Array.from(document.querySelectorAll('[aria-invalid="true"]')); + expect(marked.length).toBe(1); + }); + + it('clears the in-place hint when the session is cancelled and re-entered', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ field: 'budget', message: 'Budget must be at least 0' }]), + ); + renderBodyAndBar(update); + stageBoth(); + save(); + await waitFor(() => expect(inPlaceHint('budget')).not.toBeNull()); + + fireEvent.click(screen.getByText('edit-cancel')); + fireEvent.click(screen.getByText('edit-enter')); + expect(inPlaceHint('budget')).toBeNull(); + expect(document.querySelectorAll('[aria-invalid="true"]').length).toBe(0); + }); + + it('reaches the highlights strip too — one session, both surfaces', async () => { + const update = vi.fn().mockRejectedValue( + validationRefusal([{ field: 'budget', message: 'Budget must be at least 0' }]), + ); + function Strip() { + return ( + + ); + } + render( + + + + LABELS[n]} + /> + , + ); + stageBoth(); + save(); + await waitFor(() => expect(update).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(inPlaceHint('budget')?.textContent).toBe('Budget must be at least 0'), + ); + }); +}); diff --git a/packages/react/src/context/InlineEditContext.tsx b/packages/react/src/context/InlineEditContext.tsx index 1e2926c153..0239d80892 100644 --- a/packages/react/src/context/InlineEditContext.tsx +++ b/packages/react/src/context/InlineEditContext.tsx @@ -142,6 +142,34 @@ export interface InlineEditContextValue { saving: boolean; /** Last save error message, or null (driven by the save bar). */ error: string | null; + /** + * Per-field reasons from the last rejected save, keyed by field MACHINE NAME + * — or `null` when the refusal was not field-scoped (objectui#6868). + * + * The record-level companion to `error`, and driven by the same component: + * `` reads the server's `VALIDATION_FAILED` envelope + * through `extractFieldErrors` and publishes the result here, so the field + * renderers (`DetailSection`, `HeaderHighlight`) can draw the reason beside + * the input the server actually refused. Before this slot existed the save + * bar and the field rows had no channel between them — they are SIBLINGS + * under this provider in both persistence modes — so an attributed refusal + * could only be shown record-level, which is not the in-place hint the + * objectui#6868 ruling asked for. + * + * ⚠️ PRESENTATION ONLY. These are the SERVER's verdicts, transported. The + * ruling makes the server the validation authority on the inline-edit + * surface: nothing may populate this slot from a client-side rule check, and + * nothing may read it as an authorization or acceptance decision. It is text + * to render beside an input, nothing more. + * + * Keyed by machine name because that is what both ends already use — the + * draft key the field renderers set, and the `field` the server names — so + * no spelling translation sits between the refusal and the input. + * + * Cleared by `enter()` and by teardown, exactly like `error`, so an + * attribution can never outlive the session that produced it. + */ + fieldErrors: Record | null; /** Enter inline-edit mode, optionally focused on `field`. No-op when `!canEdit`. */ enter: (field?: string) => void; /** Stage a single field edit into the draft. */ @@ -154,6 +182,11 @@ export interface InlineEditContextValue { setSaving: (saving: boolean) => void; /** Set or clear the save error message (used by the save bar). */ setError: (error: string | null) => void; + /** + * Publish or clear the per-field reasons for a rejected save (used by the + * save bar). Pass `null` when the refusal was not field-scoped. + */ + setFieldErrors: (errors: Record | null) => void; } const InlineEditContext = React.createContext(null); @@ -215,6 +248,7 @@ export const InlineEditProvider: React.FC = ({ const [autoFocusField, setAutoFocusField] = React.useState(null); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + const [fieldErrors, setFieldErrors] = React.useState | null>(null); const enter = React.useCallback( (field?: string) => { @@ -224,6 +258,7 @@ export const InlineEditProvider: React.FC = ({ setAutoFocusField(field ?? null); setEditing(true); setError(null); + setFieldErrors(null); }, [canEdit], ); @@ -242,6 +277,7 @@ export const InlineEditProvider: React.FC = ({ setAutoFocusField(null); setSaving(false); setError(null); + setFieldErrors(null); }, []); const value = React.useMemo( @@ -257,14 +293,16 @@ export const InlineEditProvider: React.FC = ({ autoFocusField, saving, error, + fieldErrors, enter, setField, cancel: teardown, reset: teardown, setSaving, setError, + setFieldErrors, }), - [editing, canEdit, locked, pending, approvalProgress, approvalIsSubmitter, lockedReason, draft, autoFocusField, saving, error, enter, setField, teardown], + [editing, canEdit, locked, pending, approvalProgress, approvalIsSubmitter, lockedReason, draft, autoFocusField, saving, error, fieldErrors, enter, setField, teardown], ); return {children};