Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .changeset/6868-inline-edit-server-verdict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/react': minor
---

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. `<InlineEditSaveBar>` 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.

`@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`
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.
27 changes: 26 additions & 1 deletion packages/plugin-detail/src/DetailSection.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand DownExpand Up@@ -143,6 +143,14 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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
* `<InlineEditProvider>` — 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) : '';
Expand DownExpand Up@@ -336,7 +344,24 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
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 `<InlineEditSaveBar>` 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{serverFieldErrors[field.name]}
</p>
)}
</div>
) : (
<div
Expand Down
30 changes: 23 additions & 7 deletions packages/plugin-detail/src/HeaderHighlight.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -189,13 +189,29 @@ export const HeaderHighlight: React.FC<HeaderHighlightProps> = ({
</span>

{editorActive ? (
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => inline!.setField(field.name, v)}
dataSource={dataSource}
autoFocus={inline!.autoFocusField === field.name}
/>
<>
<InlineFieldInput
field={enrichedField}
value={value}
onChange={(v) => 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] && (
<p
role="alert"
data-inline-field-hint={field.name}
className="mt-1 text-xs text-destructive"
>
{inline!.fieldErrors[field.name]}
</p>
)}
</>
) : (
<div
className={cn(
Expand Down
172 changes: 165 additions & 7 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -97,14 +141,66 @@ 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)
.replace(/^\[[^\]]+\]\s*/, '')
.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.
*
* 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.
*/
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
Expand DownExpand Up@@ -146,9 +242,42 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
const inline = useInlineEdit();
const [conflict, setConflict] = React.useState<ConcurrentUpdateConflict | null>(null);
const [conflictBusy, setConflictBusy] = React.useState(false);
/** 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;

/**
* 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 `<ConcurrentUpdateDialog>` from a 409. A
* single-field draft shows the classic per-field before/after; a multi-field
Expand DownExpand Up@@ -198,14 +327,21 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
}
inline.setSaving(true);
inline.setError(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.
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.
Expand All@@ -220,12 +356,14 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// 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.
presentRefusal(err, inFlightField);
}
} finally {
inline.setSaving(false);
}
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict]);
}, [inline, onFieldSave, canAtomic, data, dataSource, objectName, recordId, refresh, buildConflict, presentRefusal]);

const closeConflict = React.useCallback(() => {
setConflict(null);
Expand DownExpand Up@@ -258,11 +396,13 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
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.
presentRefusal(err);
} finally {
closeConflict();
}
}, [conflict, canAtomic, inline, dataSource, objectName, recordId, refresh, closeConflict]);
}, [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.
Expand DownExpand Up@@ -316,12 +456,30 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="region"
aria-label={t('detail.editFieldsInline')}
>
{/* 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 && (
<div
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.error}
{inline.fieldErrors ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
</ul>
) : (
inline.error
)}
</div>
)}
{/* The lock REASON is surfaced by DetailView's approval-lock band; here
Expand Down
Loading
Loading