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
11 changes: 11 additions & 0 deletions .changeset/12107-write-answer-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@object-ui/data-objectstack": minor
"@object-ui/app-shell": patch
"@object-ui/i18n": patch
---

Show a write answer's advisory `warnings` to the user after save (objectui#12107).

- `@object-ui/data-objectstack`: `WriteWarningEvent` gains an optional `warnings` member, typed by the spec's `ValidateDataIssue` (the element of `CreateDataResponseSchema` / `UpdateDataResponseSchema`'s `warnings`). A single-record `create` / `update` parses the answer's `warnings` through `ValidateDataIssueSchema` and emits the write-warning event when either `droppedFields` or `warnings` is non-empty; `droppedFields` stays required and is `[]` when the event exists only for warnings. An answer without the key emits nothing new, including from a runtime that predates it.
- `@object-ui/app-shell`: the write-warning toast shows each distinct warning message as the server wrote it, in its own toast titled by the new `detail.writeWarningsTitle` key. The stripped-fields toast is unchanged.
- `@object-ui/i18n`: adds `detail.writeWarningsTitle` to all ten packs.
113 changes: 113 additions & 0 deletions packages/app-shell/src/providers/writeWarningToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DroppedFieldsEventSchema } from '@objectstack/spec/data';
import { emitWriteWarning, type WriteWarningSink } from './writeWarningToast';
import { ObjectStackAdapter } from '@object-ui/data-objectstack';
import type { DroppedFieldsEvent, WriteWarningEvent } from '@object-ui/data-objectstack';

/** Stand-in for i18next: returns the caller's English default, interpolated. */
Expand Down Expand Up @@ -321,3 +322,115 @@ describe('emitWriteWarning (#3484)', () => {
expect(adapter.getObjectSchema).not.toHaveBeenCalled();
});
});

// ── a write answer's `warnings` (objectui#12107) ───────────────────────────
//
// Through the real chain: the real adapter's create/update reads the answer,
// `onWriteWarning` hands the event to this module, and the sink is what the
// user would have seen. Only the transport (`client.data`) is stubbed.

const ADVISORY = {
field: 'discount',
code: 'validation_rule',
message: 'Discounts above 30% need a manager note',
rule: 'discount_cap',
severity: 'warning' as const,
};

/** A real adapter whose `client.data` is the stub, wired to the toast module. */
function wiredAdapter(data: Record<string, unknown>) {
const ds = new ObjectStackAdapter({
baseUrl: 'http://test.local',
fetch: vi.fn(async () =>
new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
),
});
const internals = ds as unknown as { connected: boolean; connectionState: string; client: unknown };
internals.connected = true;
internals.connectionState = 'connected';
internals.client = { data };
const { sink, calls } = makeSink();
const pending: Promise<void>[] = [];
ds.onWriteWarning((ev) => {
pending.push(emitWriteWarning(ev, t, adapter as never, identityLabel, sink));
});
return { ds, calls, settle: () => Promise.all(pending) };
}

describe('emitWriteWarning — write answer warnings (objectui#12107)', () => {
beforeEach(() => {
vi.clearAllMocks();
adapter.getObjectSchema.mockResolvedValue(ANDON_SCHEMA);
});

it('shows the warning message after a create whose answer carries one', async () => {
const create = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: [ADVISORY] });
const { ds, calls, settle } = wiredAdapter({ create });

await ds.create('quote', { discount: 40 });
await settle();

expect(calls).toEqual([{ title: 'Saved — with warnings', description: ADVISORY.message }]);
});

it('shows the warning message after an update whose answer carries one', async () => {
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: [ADVISORY] });
const { ds, calls, settle } = wiredAdapter({ update });

await ds.update('quote', 'r1', { discount: 40 });
await settle();

expect(calls).toEqual([{ title: 'Saved — with warnings', description: ADVISORY.message }]);
});

it('CONTROL: an answer without the key shows nothing', async () => {
const create = vi.fn().mockResolvedValue({ record: { id: 'r1' } });
const update = vi.fn().mockResolvedValue({ record: { id: 'r1' } });
const { ds, calls, settle } = wiredAdapter({ create, update });

await ds.create('quote', { discount: 40 });
await ds.update('quote', 'r1', { discount: 40 });
await settle();

expect(calls).toEqual([]);
});

it('CONTROL: with both lists present the stripped-fields toast is unchanged and the warnings get their own', async () => {
const update = vi.fn().mockResolvedValue({
record: { id: 'r1' },
droppedFields: [{ object: 'andon', fields: ['type', 'source_method'], reason: 'readonly_when' }],
warnings: [ADVISORY],
});
const { ds, calls, settle } = wiredAdapter({ update });
const strippedOnly = makeSink();
await emitWriteWarning(EVENT, t, adapter as never, identityLabel, strippedOnly.sink);

await ds.update('andon', 'r1', { type: 'x', source_method: 'y' });
await settle();

expect(calls).toHaveLength(2);
expect(calls[0]).toEqual(strippedOnly.calls[0]);
expect(calls[1]).toEqual({ title: 'Saved — with warnings', description: ADVISORY.message });
});

it('shows each distinct message once, one per line', async () => {
const { sink, calls } = makeSink();
const second = { ...ADVISORY, field: 'close_date', rule: 'close_date_past', message: 'Close date is in the past' };

await emitWriteWarning(
{ ...EVENT, droppedFields: [], warnings: [ADVISORY, second, ADVISORY] },
t,
adapter as never,
identityLabel,
sink,
);

expect(calls).toEqual([
{ title: 'Saved — with warnings', description: `${ADVISORY.message}\n${second.message}` },
]);
expect(adapter.getObjectSchema).not.toHaveBeenCalled();
});
});
39 changes: 39 additions & 0 deletions packages/app-shell/src/providers/writeWarningToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,52 @@ function lineFor(reason: DroppedFieldsNotice['reason']): StrippedLine {
*
* Says nothing when there is nothing to say (no events, or every event carried
* an empty field list).
*
* A write answer's `warnings` (objectui#12107) — advisory validation-rule hits
* on a write that also SUCCEEDED — get their OWN toast on the same sink, after
* the stripped-fields one: the stripped toast's title says fields did not take
* effect, which is not what an advisory rule hit means, so folding the two
* under it would misdescribe one of them. Each message is shown as the server
* wrote it (it is the rule author's text); the only copy this module adds is
* the title. An event without `warnings` produces exactly the toast it always
* did.
*/
export async function emitWriteWarning(
ev: WriteWarningEvent,
t: TranslateFn,
adapter: Pick<ObjectStackAdapter, 'getObjectSchema'> | null,
fieldLabel: FieldLabelFn,
sink: WriteWarningSink,
): Promise<void> {
await emitStrippedFields(ev, t, adapter, fieldLabel, sink);
emitAnswerWarnings(ev, t, sink);
}

/**
* The write answer's advisory `warnings`, one line per distinct message, under
* one title. Nothing when the event carries none.
*/
function emitAnswerWarnings(ev: WriteWarningEvent, t: TranslateFn, sink: WriteWarningSink): void {
const messages: string[] = [];
for (const w of ev.warnings ?? []) {
if (w.message && !messages.includes(w.message)) messages.push(w.message);
}
if (messages.length === 0) return;
sink.warning(
t('detail.writeWarningsTitle', {
defaultValue: 'Saved — with warnings',
}),
{ description: messages.join('\n') },
);
}

/** The stripped-fields toast — the channel's original and unchanged message. */
async function emitStrippedFields(
ev: WriteWarningEvent,
t: TranslateFn,
adapter: Pick<ObjectStackAdapter, 'getObjectSchema'> | null,
fieldLabel: FieldLabelFn,
sink: WriteWarningSink,
): Promise<void> {
const byReason = new Map<DroppedFieldsNotice['reason'], string[]>();
for (const d of ev.droppedFields) {
Expand Down
75 changes: 61 additions & 14 deletions packages/data-objectstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ import { isFilterAST, parseFilterAST } from '@objectstack/spec/data';
// and `ValueDataSource` read these same two functions, so the rule-entry form
// below cannot judge a different set than the `$` dialect beside it.
import { isRefusedTextComparand, textComparandRefusalReason } from '@objectstack/spec/data';
import type { ApiError } from '@objectstack/spec/api';
import type { ApiError, ValidateDataIssue } from '@objectstack/spec/api';
import { ValidateDataIssueSchema } from '@objectstack/spec/api';
import { stripReadDecorations } from '@objectstack/spec/kernel';
// #4237 — the metadata save door's advisory reader, shared with `MetadataClient`
// rather than forked. ONE reader, two call sites: the other client class calls it
Expand Down Expand Up @@ -2258,20 +2259,31 @@ const UNATTRIBUTED_STRIP_OBJECT = '';

/**
* Emitted after a create/update whose response carried `droppedFields`
* (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455). The write SUCCEEDED — this is a warning that some
* supplied fields never landed, so the UI can tell the user rather than let it
* pass silently. Subscribe via {@link ObjectStackAdapter.onWriteWarning}.
* (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) or
* `warnings` (objectui#12107). The write SUCCEEDED — this is a warning that
* some supplied fields never landed, or that an advisory validation rule hit,
* so the UI can tell the user rather than let it pass silently. Subscribe via
* {@link ObjectStackAdapter.onWriteWarning}.
*
* `droppedFields` is the two-arm {@link DroppedFieldsNotice} and not
* `DroppedFieldsEvent[]`: the wire is parsed here, and an entry whose `reason`
* this bundle's spec pin cannot name arrives on the explicit skew arm rather
* than being asserted into the union (objectui#4934).
* than being asserted into the union (objectui#4934). It stays REQUIRED and is
* an empty array when the event exists only for `warnings`.
*
* `warnings` is the write answer's own key, typed by the spec's
* `ValidateDataIssueSchema` (the element of `CreateDataResponseSchema` /
* `UpdateDataResponseSchema`'s `warnings`) and parsed through it here. It is
* present only when at least one entry parsed: an answer without the key —
* including one from a runtime that predates it — leaves the event exactly as
* it was.
*/
export interface WriteWarningEvent {
operation: 'create' | 'update';
resource: string;
id?: string | number;
droppedFields: DroppedFieldsNotice[];
warnings?: ValidateDataIssue[];
}

/** Event listener type for write-warning (dropped-fields) events. */
Expand Down Expand Up @@ -2718,6 +2730,28 @@ function sameWireValue(a: unknown, b: unknown): boolean {
return stableStringify(a) === stableStringify(b);
}

/**
* Read a single-record write answer's `warnings` — the advisory validation-rule
* hits the spec declares on `CreateDataResponseSchema` /
* `UpdateDataResponseSchema` (objectui#12107).
*
* Each entry is PARSED through the spec's `ValidateDataIssueSchema`, never
* asserted into it: an entry the schema refuses is not shown, because what the
* toast prints is the entry's `message` and nothing else establishes one. An
* absent key, a non-array or an empty array all answer `[]`, so an answer from
* a runtime that predates the key adds nothing.
*/
function readWriteWarnings(result: unknown): ValidateDataIssue[] {
const raw = (result as { warnings?: unknown } | null | undefined)?.warnings;
if (!Array.isArray(raw) || raw.length === 0) return [];
const out: ValidateDataIssue[] = [];
for (const entry of raw) {
const parsed = ValidateDataIssueSchema.safeParse(entry);
if (parsed.success) out.push(parsed.data);
}
return out;
}

/**
* Drop the fields whose strip changed nothing — the caller supplied exactly
* the value the record already holds (objectui#3484).
Expand Down Expand Up @@ -3840,6 +3874,10 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
* reason routed to the skew arm, a non-string field element refused, a missing
* `object` healed from `resource` — never asserted into the union, and the
* entry itself never dropped for them (objectui#4934, `f75810e7c`).
*
* The same answer's `warnings` (objectui#12107) rides this one event: it is
* emitted when either list is non-empty, so a write whose only news is an
* advisory rule hit still reaches the subscriber, with `droppedFields: []`.
*/
private notifyDroppedFields(
operation: 'create' | 'update',
Expand All @@ -3849,15 +3887,24 @@ export class ObjectStackAdapter<T = unknown> implements DataSource<T> {
sent?: Record<string, unknown> | null,
): void {
const dropped = (result as { droppedFields?: unknown } | null | undefined)?.droppedFields;
if (!Array.isArray(dropped) || dropped.length === 0) return;
const valid = dropped
.filter(isWireDroppedFieldsEntry)
.map((e) => asDroppedFieldsNotice(e, resource));
// A strip that changed nothing is not news — see withoutNoOpDrops (#3484).
const stored = (result as { record?: Record<string, unknown> } | null | undefined)?.record;
const droppedFields = withoutNoOpDrops(valid, sent, stored);
if (droppedFields.length === 0) return;
this.emitWriteWarning({ operation, resource, ...(id !== undefined ? { id } : {}), droppedFields });
let droppedFields: DroppedFieldsNotice[] = [];
if (Array.isArray(dropped) && dropped.length > 0) {
const valid = dropped
.filter(isWireDroppedFieldsEntry)
.map((e) => asDroppedFieldsNotice(e, resource));
// A strip that changed nothing is not news — see withoutNoOpDrops (#3484).
const stored = (result as { record?: Record<string, unknown> } | null | undefined)?.record;
droppedFields = withoutNoOpDrops(valid, sent, stored);
}
const warnings = readWriteWarnings(result);
if (droppedFields.length === 0 && warnings.length === 0) return;
this.emitWriteWarning({
operation,
resource,
...(id !== undefined ? { id } : {}),
droppedFields,
...(warnings.length > 0 ? { warnings } : {}),
});
}

/**
Expand Down
Loading
Loading