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
7 changes: 7 additions & 0 deletions .changeset/13292-record-level-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@object-ui/plugin-detail': patch
---

The inline-edit save bar shows an object-level rule's refusal as the rule's own message, without a field label (objectui#13292).

The server names an object-level rule (a `script` rule, or one that lists no fields) with the `_record` sentinel. The bar used to print that sentinel as a field label, so the banner read `_record: …`. Now an entry that names `_record`, or names no field of the record, is shown alone in the banner and is not published as a per-field hint. Field-level entries are unchanged: they keep their label and their hint beside the input.
110 changes: 105 additions & 5 deletions packages/plugin-detail/src/InlineEditSaveBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,53 @@ function attributeInlineRefusal(
return [{ field: inFlightField, message }];
}

/**
* The `field` the server names on an OBJECT-LEVEL refusal (objectui#13292).
*
* `@objectstack/objectql`'s rule validator attributes a rule to its first
* declared field and, when the rule declares none (a `script` rule, or a rule
* whose `fields` is empty), to this sentinel instead — the documented contract
* ("`_record` for an object-level rule"). It names no input, so it is never a
* label and never a per-field hint.
*/
const RECORD_LEVEL_FIELD = '_record';

/**
* A refusal split by what each entry is ABOUT (objectui#13292): `fieldLevel`
* entries name a field of the record and are presented as today — labelled in
* the banner and published as a per-field hint; `recordLevel` messages name no
* field, so they are shown alone, the way `form.tsx` falls back to the
* envelope's own text when an entry names no field it renders.
*/
interface SplitRefusal {
fieldLevel: WriteFieldError[];
recordLevel: string[];
}

/**
* Sort attributed entries into field-level and record-level.
*
* `extractFieldErrors` is shared and keeps any entry with a non-empty `field`,
* so the `_record` sentinel (and a `field` naming nothing on the object) passes
* through it; deciding what such an entry is belongs HERE, on the surface that
* presents it — not in the shared normaliser.
*/
function splitRefusal(
entries: WriteFieldError[],
isRecordField: (name: string) => boolean,
): SplitRefusal {
const fieldLevel: WriteFieldError[] = [];
const recordLevel: string[] = [];
for (const entry of entries) {
if (entry.field !== RECORD_LEVEL_FIELD && isRecordField(entry.field)) {
fieldLevel.push(entry);
} else if (entry.message) {
recordLevel.push(entry.message);
}
}
return { fieldLevel, recordLevel };
}

/**
* Issue a partial-record update through whichever method the DataSource
* exposes. Mirrors the update/updateOne/patch fallback + `ifMatch` OCC token
Expand Down Expand Up @@ -250,6 +297,37 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({

const canAtomic = !!dataSource && !!objectName && recordId != null;

/**
* Record-level lines of the last refusal, held beside the banner text they
* were rendered into (objectui#13292). Shown only while the session's
* `error` is still that same text, so the context's own clears (`enter()`,
* teardown, the next save) retire them without a second reset path.
*/
const [recordRefusal, setRecordRefusal] = React.useState<{
error: string;
messages: string[];
} | null>(null);

/**
* Whether `name` is a field of the record this bar is editing — the only
* names that may carry a label and a per-field hint (objectui#13292). This
* surface holds no object schema, so it accepts what it can see: a key the
* user staged, a key of the server record, or a name the host resolves to a
* label of its own (a host that echoes the machine name back resolves
* nothing).
*/
const isRecordField = React.useCallback(
(name: string) => {
if (inline && Object.prototype.hasOwnProperty.call(inline.draft, name)) return true;
if (data && typeof data === 'object' && Object.prototype.hasOwnProperty.call(data, name)) {
return true;
}
const label = fieldLabelFor?.(name);
return !!label && label !== name;
},
[inline, data, fieldLabelFor],
);

/**
* Present a rejected save. A field-scoped refusal is published to the shared
* session as a per-field map, which the field renderers (`DetailSection`,
Expand All @@ -267,15 +345,27 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
if (!inline) return;
const attributed = attributeInlineRefusal(err, inFlightField);
if (!attributed) {
setRecordRefusal(null);
inline.setFieldErrors(null);
inline.setError(cleanError(err));
return;
}
const labelOf = (name: string) => fieldLabelFor?.(name) || name;
inline.setFieldErrors(Object.fromEntries(attributed.map((r) => [r.field, r.message])));
inline.setError(attributed.map((r) => `${labelOf(r.field)}: ${r.message}`).join('; '));
const { fieldLevel, recordLevel } = splitRefusal(attributed, isRecordField);
// objectui#13292: an object-level rule's refusal names no field, so its
// message reads alone — no label prefix, and no per-field hint.
const lines = [
...fieldLevel.map((r) => `${labelOf(r.field)}: ${r.message}`),
...recordLevel,
];
const error = lines.length > 0 ? lines.join('; ') : cleanError(err);
setRecordRefusal(recordLevel.length > 0 ? { error, messages: recordLevel } : null);
inline.setFieldErrors(
fieldLevel.length > 0 ? Object.fromEntries(fieldLevel.map((r) => [r.field, r.message])) : null,
);
inline.setError(error);
},
[inline, fieldLabelFor],
[inline, fieldLabelFor, isRecordField],
);

/**
Expand Down Expand Up @@ -446,6 +536,9 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
// Render nothing unless a provider is present and the record is being edited.
if (!inline || !inline.editing) return null;

const recordMessages =
recordRefusal && recordRefusal.error === inline.error ? recordRefusal.messages : [];

return (
<>
<div
Expand Down Expand Up @@ -473,15 +566,22 @@ export const InlineEditSaveBar: React.FC<InlineEditSaveBarProps> = ({
role="alert"
className="mr-auto max-w-md rounded-md border border-destructive/20 bg-destructive/10 px-2 py-1 text-xs text-destructive"
>
{inline.fieldErrors ? (
{inline.fieldErrors || recordMessages.length > 0 ? (
<ul className="space-y-0.5">
{Object.entries(inline.fieldErrors).map(([field, message]) => (
{Object.entries(inline.fieldErrors ?? {}).map(([field, message]) => (
<li key={field} data-inline-field-error={field}>
<span className="font-medium">{labelForField(field)}</span>
{': '}
{message}
</li>
))}
{/* objectui#13292: record-level reasons name no field — the
rule's own sentence, with no label in front of it. */}
{recordMessages.map((message, i) => (
<li key={`record-${i}`} data-inline-record-error="">
{message}
</li>
))}
</ul>
) : (
inline.error
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* objectui#13292 — an OBJECT-LEVEL rule's refusal names no field. The engine
* attributes such a rule (a `script` rule, or one whose `fields` is empty) to
* the `_record` sentinel, and the inline save bar used to print it as a label:
* the banner read `_record: <the rule's message>`.
*
* The rulings these pin:
* (a) a `_record` entry shows the rule's message ALONE — no label, no
* sentinel text anywhere, no per-field hint;
* (b) a `field` naming nothing on the record is treated the same;
* (c) a real field's entry is unchanged — "Label: message" and its hint;
* (d) a mixed refusal shows both shapes.
*/

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { InlineEditProvider, useInlineEdit } from '@object-ui/react';
import { InlineEditSaveBar } from '../InlineEditSaveBar';

const RULE_MESSAGE = 'Only Convert Lead marks a lead as Converted';

/** A `VALIDATION_FAILED` as `@objectstack/client` delivers it (`details.fields`). */
function validationRefusal(fields: Array<Record<string, unknown>>) {
return {
code: 'VALIDATION_FAILED',
message: RULE_MESSAGE,
details: { error: RULE_MESSAGE, code: 'VALIDATION_FAILED', fields },
};
}

/** A host that echoes the machine name back when it knows no label, as the console does. */
const LABELS: Record<string, string> = { status: 'Status', converted: 'Converted' };
const fieldLabelFor = (n: string) => LABELS[n] ?? n;

function Harness() {
const inline = useInlineEdit()!;
return (
<>
<button onClick={() => inline.enter('status')}>edit-enter</button>
<button onClick={() => inline.setField('status', 'converted')}>edit-status</button>
<button onClick={() => inline.cancel()}>edit-cancel</button>
<span data-testid="published-hints">{JSON.stringify(inline.fieldErrors)}</span>
</>
);
}

function renderAndSave(err: unknown) {
const update = vi.fn().mockRejectedValue(err);
render(
<InlineEditProvider canEdit>
<Harness />
<InlineEditSaveBar
dataSource={{ update }}
objectName="lead"
recordId="l1"
data={{ updated_at: 'v1', status: 'open', converted: false }}
refresh={vi.fn()}
fieldLabelFor={fieldLabelFor}
/>
</InlineEditProvider>,
);
fireEvent.click(screen.getByText('edit-enter'));
fireEvent.click(screen.getByText('edit-status'));
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
return update;
}

const banner = () => screen.getByRole('alert');
const publishedHints = () => JSON.parse(screen.getByTestId('published-hints').textContent || 'null');
const fieldLines = () =>
Array.from(document.querySelectorAll('[data-inline-field-error]')).map((li) => li.textContent);
const recordLines = () =>
Array.from(document.querySelectorAll('[data-inline-record-error]')).map((li) => li.textContent);

describe('objectui#13292 — a record-level refusal reads without a field label', () => {
it('(a) shows a `_record` entry as the rule message alone, with no sentinel and no hint', async () => {
renderAndSave(validationRefusal([{ field: '_record', code: 'rule_violation', message: RULE_MESSAGE }]));

await waitFor(() => expect(banner()).toBeInTheDocument());
expect(banner().textContent).toBe(RULE_MESSAGE);
expect(document.body.textContent).not.toContain('_record');
expect(fieldLines()).toEqual([]);
expect(publishedHints()).toBeNull();
});

it('(b) treats a `field` naming nothing on the record the same as `_record`', async () => {
renderAndSave(validationRefusal([{ field: 'no_such_field', code: 'rule_violation', message: RULE_MESSAGE }]));

await waitFor(() => expect(banner()).toBeInTheDocument());
expect(banner().textContent).toBe(RULE_MESSAGE);
expect(document.body.textContent).not.toContain('no_such_field');
expect(publishedHints()).toBeNull();
});

it('(c) CONTROL: a real field keeps its label in the banner and its per-field hint', async () => {
renderAndSave(validationRefusal([{ field: 'converted', code: 'rule_violation', message: RULE_MESSAGE }]));

await waitFor(() => expect(fieldLines()).toEqual([`Converted: ${RULE_MESSAGE}`]));
expect(recordLines()).toEqual([]);
expect(publishedHints()).toEqual({ converted: RULE_MESSAGE });
});

it('(d) a mixed refusal labels the field entry and leaves the record-level message bare', async () => {
renderAndSave(
validationRefusal([
{ field: 'status', code: 'invalid_option', message: 'Status is not allowed here' },
{ field: '_record', code: 'rule_violation', message: RULE_MESSAGE },
]),
);

await waitFor(() => expect(fieldLines()).toEqual(['Status: Status is not allowed here']));
expect(recordLines()).toEqual([RULE_MESSAGE]);
expect(banner().textContent).not.toContain('_record');
expect(publishedHints()).toEqual({ status: 'Status is not allowed here' });
});

it('retires the record-level line with the session', async () => {
renderAndSave(validationRefusal([{ field: '_record', code: 'rule_violation', message: RULE_MESSAGE }]));
await waitFor(() => expect(banner()).toBeInTheDocument());

fireEvent.click(screen.getByText('edit-cancel'));
fireEvent.click(screen.getByText('edit-enter'));
expect(screen.queryByRole('alert')).toBeNull();
expect(recordLines()).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,18 @@ function Harness() {

const LABELS: Record<string, string> = { status: 'Stage', budget: 'Budget' };

function renderAtomic(update: ReturnType<typeof vi.fn>) {
function renderAtomic(
update: ReturnType<typeof vi.fn>,
data: Record<string, unknown> = { updated_at: 'v1' },
) {
return render(
<InlineEditProvider canEdit>
<Harness />
<InlineEditSaveBar
dataSource={{ update }}
objectName="crm_opportunity"
recordId="o1"
data={{ updated_at: 'v1' }}
data={data}
refresh={vi.fn()}
fieldLabelFor={(n) => LABELS[n]}
/>
Expand Down Expand Up @@ -131,7 +134,9 @@ describe('objectui#6868 — a VALIDATION_FAILED becomes a per-field hint (atomic
const update = vi.fn().mockRejectedValue(
validationRefusal([{ field: 'close_date', message: 'Close date must be in the future' }]),
);
renderAtomic(update);
// `close_date` is a field of the record (objectui#13292: a name the bar
// cannot place on the record is record-level, not an unlabelled field).
renderAtomic(update, { updated_at: 'v1', close_date: null });
stageBoth();
save();
await waitFor(() =>
Expand Down
Loading