From 3b6fb5ce62b16f75a19c02a4af0d54cabceddc97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:46:16 +0000 Subject: [PATCH 1/3] feat(data-objectstack): carry a write answer's warnings on the write-warning event The spec declares `warnings` (advisory validation-rule hits, element ValidateDataIssueSchema) on CreateDataResponseSchema / UpdateDataResponseSchema. The single-record create/update emit site now parses it through the spec schema and carries it as an optional `warnings` member on WriteWarningEvent; the event is emitted when droppedFields or warnings is non-empty. An answer without the key emits nothing new. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- packages/data-objectstack/src/index.ts | 75 ++++++++++--- .../src/onWriteWarning.test.ts | 101 ++++++++++++++++++ 2 files changed, 162 insertions(+), 14 deletions(-) diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 1a202c9730..c113f4ec3c 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -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 @@ -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. */ @@ -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). @@ -3840,6 +3874,10 @@ export class ObjectStackAdapter implements DataSource { * 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', @@ -3849,15 +3887,24 @@ export class ObjectStackAdapter implements DataSource { sent?: Record | 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 } | 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 } | 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 } : {}), + }); } /** diff --git a/packages/data-objectstack/src/onWriteWarning.test.ts b/packages/data-objectstack/src/onWriteWarning.test.ts index a4050ac52a..419ed08857 100644 --- a/packages/data-objectstack/src/onWriteWarning.test.ts +++ b/packages/data-objectstack/src/onWriteWarning.test.ts @@ -271,3 +271,104 @@ describe('ObjectStackAdapter.onWriteWarning', () => { warn.mockRestore(); }); }); + +// ── a write answer's `warnings` (objectui#12107) ─────────────────────────── +// +// The spec declares `warnings` (advisory validation-rule hits, element +// `ValidateDataIssueSchema`) on the single-record create/update answers. It +// rides THIS event: emitted when either list is non-empty, `droppedFields` +// stays required (empty when the event exists only for warnings), and an +// answer without the key changes nothing. + +const ONE_WARNING = [ + { + field: 'discount', + code: 'validation_rule', + message: 'Discounts above 30% need a manager note', + rule: 'discount_cap', + severity: 'warning' as const, + }, +]; + +describe('ObjectStackAdapter.onWriteWarning — write answer warnings (objectui#12107)', () => { + it('emits a create write-warning carrying the answer warnings, with no dropped fields', async () => { + const create = vi.fn().mockResolvedValue({ record: { id: 'r1', discount: 40 }, warnings: ONE_WARNING }); + const ds = makeDS({ create }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + const rec = await ds.create('quote', { discount: 40 }); + + expect(rec).toEqual({ id: 'r1', discount: 40 }); + expect(events).toEqual([ + { operation: 'create', resource: 'quote', id: 'r1', droppedFields: [], warnings: ONE_WARNING }, + ]); + }); + + it('emits an update write-warning carrying the answer warnings', async () => { + const update = vi.fn().mockResolvedValue({ record: { id: 'r1' }, warnings: ONE_WARNING }); + const ds = makeDS({ update }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + await ds.update('quote', 'r1', { discount: 40 }); + + expect(events).toEqual([ + { operation: 'update', resource: 'quote', id: 'r1', droppedFields: [], warnings: ONE_WARNING }, + ]); + }); + + it('CONTROL: an answer without the key, or with an empty list, emits nothing', async () => { + const create = vi + .fn() + .mockResolvedValueOnce({ record: { id: 'r1' } }) + .mockResolvedValueOnce({ record: { id: 'r2' }, warnings: [] }); + const update = vi.fn().mockResolvedValue({ record: { id: 'r1' } }); + const ds = makeDS({ create, update }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + await ds.create('quote', {}); + await ds.create('quote', {}); + await ds.update('quote', 'r1', {}); + + expect(events).toEqual([]); + }); + + it('CONTROL: droppedFields is unchanged when both lists are present; warnings ride beside it', async () => { + const update = vi + .fn() + .mockResolvedValue({ record: { id: 'r1' }, droppedFields: ONE_DROP, warnings: ONE_WARNING }); + const ds = makeDS({ update }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + await ds.update('approval_case', 'r1', { approval_status: 'approved' }); + + expect(events).toEqual([ + { + operation: 'update', + resource: 'approval_case', + id: 'r1', + droppedFields: ONE_DROP, + warnings: ONE_WARNING, + }, + ]); + }); + + it('parses each entry through the spec schema: an entry it refuses is not carried', async () => { + const create = vi.fn().mockResolvedValue({ + record: { id: 'r1' }, + warnings: [{ field: 'discount' }, 'nope', ONE_WARNING[0]], + }); + const ds = makeDS({ create }); + const events: WriteWarningEvent[] = []; + ds.onWriteWarning((e: WriteWarningEvent) => events.push(e)); + + await ds.create('quote', {}); + + expect(events).toEqual([ + { operation: 'create', resource: 'quote', id: 'r1', droppedFields: [], warnings: ONE_WARNING }, + ]); + }); +}); From a1661768dc8f6cbbfdeeb60bc5e8ebc0c7927b03 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:47:21 +0000 Subject: [PATCH 2/3] feat(app-shell): toast a write answer's advisory warnings after save emitWriteWarning now shows the event's `warnings` messages, verbatim, in their own toast on the same sink, under one new heading key (detail.writeWarningsTitle, all ten packs). The stripped-fields toast is unchanged; an event without warnings toasts exactly as before. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .../src/providers/writeWarningToast.test.ts | 113 ++++++++++++++++++ .../src/providers/writeWarningToast.ts | 39 ++++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + 12 files changed, 162 insertions(+) diff --git a/packages/app-shell/src/providers/writeWarningToast.test.ts b/packages/app-shell/src/providers/writeWarningToast.test.ts index 3918d9a57e..5616f310d9 100644 --- a/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -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. */ @@ -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) { + 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[] = []; + 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(); + }); +}); diff --git a/packages/app-shell/src/providers/writeWarningToast.ts b/packages/app-shell/src/providers/writeWarningToast.ts index 3e2bd2c947..3b249b964d 100644 --- a/packages/app-shell/src/providers/writeWarningToast.ts +++ b/packages/app-shell/src/providers/writeWarningToast.ts @@ -209,6 +209,15 @@ 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, @@ -216,6 +225,36 @@ export async function emitWriteWarning( adapter: Pick | null, fieldLabel: FieldLabelFn, sink: WriteWarningSink, +): Promise { + 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 | null, + fieldLabel: FieldLabelFn, + sink: WriteWarningSink, ): Promise { const byReason = new Map(); for (const d of ev.droppedFields) { diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 4042ffc94a..17005c97fc 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1369,6 +1369,7 @@ const ar = { lockedByApproval: "مقفل للموافقة", lockedTooltip: "يحتوي هذا السجل على طلب موافقة معلق؛ التعديل مقفل", writeStrippedTitle: "تم الحفظ، لكن بعض الحقول لم تُطبَّق", + writeWarningsTitle: "تم الحفظ، مع تحذيرات", writeStrippedReadonly: "للقراءة فقط، لذلك لم تُطبَّق: {{fields}}", writeStrippedByState: "غير قابلة للتعديل في الحالة الحالية لهذا السجل، لذلك لم تُطبَّق: {{fields}}", writeStrippedPrimaryKey: "معرّف السجل لا يمكن تغييره عند الحفظ، لذلك لم تُطبَّق: {{fields}}", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 837b1fadba..fcfd82b1f9 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1209,6 +1209,7 @@ const de = { lockedByApproval: "Zur Genehmigung gesperrt", lockedTooltip: "Dieser Datensatz hat eine ausstehende Genehmigungsanfrage; die Bearbeitung ist gesperrt", writeStrippedTitle: "Gespeichert — einige Felder wurden jedoch nicht übernommen", + writeWarningsTitle: "Gespeichert — mit Warnungen", writeStrippedReadonly: "Schreibgeschützt und daher nicht übernommen: {{fields}}", writeStrippedByState: "Im aktuellen Status dieses Datensatzes nicht bearbeitbar und daher nicht übernommen: {{fields}}", writeStrippedPrimaryKey: "Die Kennung des Datensatzes kann beim Speichern nicht geändert werden und wurde daher nicht übernommen: {{fields}}", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 28f6d0f842..20dd69ef42 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1339,6 +1339,7 @@ const en = { lockedByApproval: 'Locked for approval', lockedTooltip: 'This record has a pending approval request; editing is locked', writeStrippedTitle: 'Saved — but some fields did not take effect', + writeWarningsTitle: 'Saved — with warnings', writeStrippedReadonly: 'Read-only, so it did not take effect: {{fields}}', writeStrippedByState: "Not editable in this record's current state, so it did not take effect: {{fields}}", writeStrippedPrimaryKey: "The record's identifier cannot be changed by a save, so it did not take effect: {{fields}}", diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 1d7aa47709..2fc1582ca8 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1248,6 +1248,7 @@ const es = { lockedByApproval: "Bloqueado para aprobación", lockedTooltip: "Este registro tiene una solicitud de aprobación pendiente; la edición está bloqueada", writeStrippedTitle: "Guardado, pero algunos campos no se aplicaron", + writeWarningsTitle: "Guardado, con advertencias", writeStrippedReadonly: "De solo lectura, por lo que no se aplicó: {{fields}}", writeStrippedByState: "No editable en el estado actual de este registro, por lo que no se aplicó: {{fields}}", writeStrippedPrimaryKey: "El identificador del registro no se puede cambiar al guardar, por lo que no se aplicó: {{fields}}", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index e46c32d756..10f32fa04d 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1246,6 +1246,7 @@ const fr = { lockedByApproval: "Verrouillé pour approbation", lockedTooltip: "Cet enregistrement a une demande d'approbation en attente ; la modification est verrouillée", writeStrippedTitle: "Enregistré — mais certains champs n'ont pas été appliqués", + writeWarningsTitle: "Enregistré — avec des avertissements", writeStrippedReadonly: "En lecture seule, donc non appliqué : {{fields}}", writeStrippedByState: "Non modifiable dans l'état actuel de cet enregistrement, donc non appliqué : {{fields}}", writeStrippedPrimaryKey: "L'identifiant de l'enregistrement ne peut pas être modifié lors d'un enregistrement, donc non appliqué : {{fields}}", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 70cf4ca945..8e73d07fee 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1221,6 +1221,7 @@ const ja = { lockedByApproval: "承認のためロック中", lockedTooltip: "このレコードには承認待ちのリクエストがあります。編集はロックされています", writeStrippedTitle: "保存しました(一部の項目は反映されていません)", + writeWarningsTitle: "保存しました(警告があります)", writeStrippedReadonly: "次の項目は読み取り専用のため反映されませんでした: {{fields}}", writeStrippedByState: "次の項目はこのレコードの現在の状態では編集できないため反映されませんでした: {{fields}}", writeStrippedPrimaryKey: "次の項目はレコードの識別子で、保存では変更できないため反映されませんでした: {{fields}}", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 1047df673e..7660eba0a0 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1209,6 +1209,7 @@ const ko = { lockedByApproval: "승인을 위해 잠김", lockedTooltip: "이 레코드에 대기 중인 승인 요청이 있습니다. 편집이 잠겨 있습니다", writeStrippedTitle: "저장되었지만 일부 필드는 적용되지 않았습니다", + writeWarningsTitle: "저장되었지만 경고가 있습니다", writeStrippedReadonly: "다음 필드는 읽기 전용이므로 적용되지 않았습니다: {{fields}}", writeStrippedByState: "다음 필드는 이 레코드의 현재 상태에서 편집할 수 없으므로 적용되지 않았습니다: {{fields}}", writeStrippedPrimaryKey: "다음 필드는 레코드의 식별자이며 저장으로 변경할 수 없으므로 적용되지 않았습니다: {{fields}}", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 17a1888487..49af922350 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1245,6 +1245,7 @@ const pt = { lockedByApproval: "Bloqueado para aprovação", lockedTooltip: "Este registro tem uma solicitação de aprovação pendente; a edição está bloqueada", writeStrippedTitle: "Salvo, mas alguns campos não foram aplicados", + writeWarningsTitle: "Salvo, com avisos", writeStrippedReadonly: "Somente leitura, portanto não foi aplicado: {{fields}}", writeStrippedByState: "Não editável no estado atual deste registro, portanto não foi aplicado: {{fields}}", writeStrippedPrimaryKey: "O identificador do registro não pode ser alterado ao salvar, portanto não foi aplicado: {{fields}}", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 8d21e26afd..a26a53f308 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1307,6 +1307,7 @@ const ru = { lockedByApproval: "Заблокировано для согласования", lockedTooltip: "У этой записи есть ожидающий запрос на согласование; редактирование заблокировано", writeStrippedTitle: "Сохранено, но часть полей не применена", + writeWarningsTitle: "Сохранено, но есть предупреждения", writeStrippedReadonly: "Поля только для чтения, поэтому не применены: {{fields}}", writeStrippedByState: "Поля недоступны для редактирования в текущем состоянии записи, поэтому не применены: {{fields}}", writeStrippedPrimaryKey: "Идентификатор записи нельзя изменить при сохранении, поэтому поля не применены: {{fields}}", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index f980db105b..ce222b9dc1 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1104,6 +1104,7 @@ const zh = { lockedByApproval: '审批中已锁定', lockedTooltip: '该记录有待审批的请求,编辑已被锁定', writeStrippedTitle: '已保存,但部分字段未生效', + writeWarningsTitle: '已保存,但有警告', writeStrippedReadonly: '以下字段为只读,未生效:{{fields}}', writeStrippedByState: '以下字段在记录当前状态下不可编辑,未生效:{{fields}}', writeStrippedPrimaryKey: '以下字段是记录的标识符,保存时无法修改,未生效:{{fields}}', From 849e0cfa4cfe678636526752447f8ce4e5d83ff0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 13:47:52 +0000 Subject: [PATCH 3/3] chore(changeset): declare the write answer warnings release Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/12107-write-answer-warnings.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/12107-write-answer-warnings.md diff --git a/.changeset/12107-write-answer-warnings.md b/.changeset/12107-write-answer-warnings.md new file mode 100644 index 0000000000..d7df21fa3a --- /dev/null +++ b/.changeset/12107-write-answer-warnings.md @@ -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.