diff --git a/.changeset/13178-stripped-stored-expression.md b/.changeset/13178-stripped-stored-expression.md new file mode 100644 index 0000000000..4836be5503 --- /dev/null +++ b/.changeset/13178-stripped-stored-expression.md @@ -0,0 +1,25 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +fix(app-shell): the save warning has a sentence of its own for a value a stored expression replaced + +objectstack-ai/objectstack#24778 gives the write path a fifth strip reason, +`stored_expression`: a stored field whose declared expression the engine computes +on every write keeps the computed value, not the one the caller sent. The save +warning words that reason as what it is — "Computed from the record, so the +computed value was saved instead of the one sent: …" — through a new +`detail.writeStrippedStoredExpression` key in all ten locale packs, and never as +a read-only lock. + +The reason table stays exhaustive over the spec's union, widened ahead by the one +literal so it compiles both against the published `@objectstack/spec` (which does +not name the arm yet) and against objectstack's own spec, which the objectstack +Console Pin Gate builds this package with. + +When the sentence reaches a user: the adapter judges a reported reason against +the spec release this bundle is built with, so until that release declares +`stored_expression` such a report is still shown as "Not applied by the server: …" +— truthful, but without the cause. The new sentence is used from the spec release +that declares the arm on. diff --git a/packages/app-shell/src/providers/writeWarningToast.test.ts b/packages/app-shell/src/providers/writeWarningToast.test.ts index 5616f310d9..1814f10ae7 100644 --- a/packages/app-shell/src/providers/writeWarningToast.test.ts +++ b/packages/app-shell/src/providers/writeWarningToast.test.ts @@ -38,6 +38,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 { createI18n, loadBuiltInLocale, type TranslateFn } from '@object-ui/i18n'; import type { DroppedFieldsEvent, WriteWarningEvent } from '@object-ui/data-objectstack'; /** Stand-in for i18next: returns the caller's English default, interpolated. */ @@ -206,6 +207,73 @@ describe('emitWriteWarning (#3484)', () => { expect(calls[0].description).not.toMatch(/^Not applied by the server/); }); + /** + * The `stored_expression` row (objectui#13178), driven through the REAL + * catalogues rather than the defaultValue stand-in, so the pin is the line a + * user reads in each language. Spelled through `string`: the published spec + * pin does not declare the arm yet (objectstack-ai/objectstack#24778 adds it), + * and this file has to compile against both. + */ + it('words reason `stored_expression` as a computed value saved in place of the one sent, in en and zh (objectui#13178)', async () => { + const storedExpression: string = 'stored_expression'; + expect(await loadBuiltInLocale('zh')).not.toBeNull(); + + const lines = new Map(); + for (const language of ['en', 'zh']) { + const i18n = createI18n({ defaultLanguage: language, detectBrowserLanguage: false }); + await i18n.changeLanguage(language); + const keys: string[] = []; + const realT: TranslateFn = (key: string, opts?: Record) => { + keys.push(key); + return String(i18n.t(key, opts)); + }; + const { sink, calls } = makeSink(); + + await emitWriteWarning( + { + ...EVENT, + droppedFields: [ + { + object: 'andon', + fields: ['type', 'source_method'], + reason: storedExpression as DroppedFieldsEvent['reason'], + }, + ], + }, + realT, + adapter as never, + identityLabel, + sink, + ); + + expect(keys).toContain('detail.writeStrippedStoredExpression'); + expect(calls).toHaveLength(1); + lines.set(language, calls[0].description ?? ''); + } + + expect(lines.get('en')).toBe( + 'Computed from the record, so the computed value was saved instead of the one sent: Andon type, Source method', + ); + expect(lines.get('zh')).toBe( + '以下字段的值由记录计算得出,已保存计算值而非提交的值:Andon type, Source method', + ); + }); + + /** + * The tripwire for the `| 'stored_expression'` widening on `STRIPPED_LINE` + * (objectui#13178). It exists only because the published pin predates + * objectstack-ai/objectstack#24778; on the bump to a release that declares the + * arm it is a silent no-op, which no compiler reports. This makes that bump + * say so instead, in the suite a bump is run through. + */ + it('reminds the pin bump that carries `stored_expression` to retire the widening (objectui#13178)', () => { + expect( + DroppedFieldsEventSchema.shape.reason.options, + "the spec pin now declares `stored_expression`: delete `| 'stored_expression'` from STRIPPED_LINE's key type " + + "and make spec-symbol-batch6.test.ts's `_ReasonIsTheEnum` pin the five-member union", + ).not.toContain('stored_expression'); + }); + it('keeps one line per reason when a save was stripped for several', async () => { const { sink, calls } = makeSink(); @@ -235,7 +303,8 @@ describe('emitWriteWarning (#3484)', () => { * The exhaustiveness pin (objectui#3935), read off the SPEC rather than a hand * list that would drift: `STRIPPED_LINE` is keyed by * `DroppedFieldsEvent['reason']` (widened by `'computed'` from objectui#11206 - * until the 17.6.0 bump, objectui#11438), so a missing key is a type error — but + * until the 17.6.0 bump, objectui#11438, and by `'stored_expression'` from + * objectui#13178 until the bump that declares it), so a missing key is a type error — but * `type-check` and `vitest` are different gates, * and the reason the ternary this replaced survived so long is that nothing in * the test suite could see the gap at all. @@ -244,10 +313,15 @@ describe('emitWriteWarning (#3484)', () => { * spec declares and require each to produce its own sentence rather than the * skew wording. A pin bump that widens the enum makes this red next to the * compile error, in the gate a wording change is normally noticed in. + * + * The reasons driven are the installed spec's PLUS `stored_expression`, which + * the table carries ahead of the pin (objectui#13178): until the pin declares + * it, nothing else here would require that row to be worded distinctly. */ it('gives every reason the pinned spec declares its own wording (#3935)', async () => { - const reasons = DroppedFieldsEventSchema.shape.reason.options; - expect(reasons).toContain('primary_key'); + const declared: readonly string[] = DroppedFieldsEventSchema.shape.reason.options; + expect(declared).toContain('primary_key'); + const reasons = [...new Set([...declared, 'stored_expression'])] as DroppedFieldsEvent['reason'][]; const wordings = new Map(); for (const reason of reasons) { diff --git a/packages/app-shell/src/providers/writeWarningToast.ts b/packages/app-shell/src/providers/writeWarningToast.ts index 3b249b964d..5e4d1f9dd7 100644 --- a/packages/app-shell/src/providers/writeWarningToast.ts +++ b/packages/app-shell/src/providers/writeWarningToast.ts @@ -122,9 +122,27 @@ type StrippedLine = (t: TranslateFn, fields: string) => string; * so the key type was widened by `| 'computed'` to compile against both. Since * `@objectstack/spec` 17.6.0 the pin's own union carries `computed`, so the * widening was a no-op and was deleted at that bump (objectui#11438), as its - * tripwire asked. The key type is the spec union again, exact in both directions. + * tripwire asked. + * + * ⭐ The key type is widened AHEAD again, by `| 'stored_expression'`, for the same + * reason and in the same spelling (objectui#13178). objectstack-ai/objectstack#24778 + * adds a fifth arm, `stored_expression`: a stored field whose declared expression + * the engine computes on every write, so the value the caller sent was replaced + * by the computed one (the field IS written, unlike `computed`). The objectstack + * Console Pin Gate builds this package against objectstack's own spec at the + * objectui commit objectstack pins, so the row has to exist here before that + * change can land there, while the published pin this repository builds against + * does not declare the arm yet. The union with the literal makes the required + * key set the same on both sides (the pin's four plus `stored_expression`, or + * the five the wider spec declares), and an excess-key error cannot arise on + * either; the NEXT arm upstream adds is still a missing key here. + * + * Tripwire: when the pin reaches a release that declares `stored_expression`, + * the `| 'stored_expression'` is a silent no-op and is deleted at that bump, + * which makes the key type the spec union again, exact in both directions. + * `writeWarningToast.test.ts` fails on that bump to say so. */ -const STRIPPED_LINE: Record = { +const STRIPPED_LINE: Record = { readonly: (t: TranslateFn, fields: string) => t('detail.writeStrippedReadonly', { fields, @@ -150,6 +168,14 @@ const STRIPPED_LINE: Record = { defaultValue: 'Calculated by the server from a formula, so the value sent did not take effect: {{fields}}', }), + // Not a strip in the `computed` sense: the field is stored, but with the value + // its expression computes from the record, never the one the caller sent. + stored_expression: (t: TranslateFn, fields: string) => + t('detail.writeStrippedStoredExpression', { + fields, + defaultValue: + 'Computed from the record, so the computed value was saved instead of the one sent: {{fields}}', + }), }; /** diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 2cce073f4c..18b164c189 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1349,6 +1349,7 @@ const ar = { writeStrippedByState: "غير قابلة للتعديل في الحالة الحالية لهذا السجل، لذلك لم تُطبَّق: {{fields}}", writeStrippedPrimaryKey: "معرّف السجل لا يمكن تغييره عند الحفظ، لذلك لم تُطبَّق: {{fields}}", writeStrippedComputed: "يحسبها الخادم بواسطة صيغة، لذلك لم تُطبَّق القيمة المُرسَلة: {{fields}}", + writeStrippedStoredExpression: "تُحسب من السجل، لذلك حُفظت القيمة المحسوبة بدلًا من القيمة المُرسَلة: {{fields}}", writeStrippedUnknownReason: "لم تُطبَّق من قِبل الخادم: {{fields}}", approvalPendingEditable: "قيد الموافقة · قابل للتعديل", approvalPendingTooltip: "يحتوي هذا السجل على طلب موافقة معلق، لكن هذه الخطوة لا تزال تسمح بالتعديل", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 7fb9c7dc18..8804515613 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1207,6 +1207,7 @@ const de = { 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}}", writeStrippedComputed: "Wird vom Server per Formel berechnet, daher wurde der gesendete Wert nicht übernommen: {{fields}}", + writeStrippedStoredExpression: "Wird aus dem Datensatz berechnet, daher wurde statt des gesendeten Werts der berechnete gespeichert: {{fields}}", writeStrippedUnknownReason: "Vom Server nicht übernommen: {{fields}}", approvalPendingEditable: "In Genehmigung · bearbeitbar", approvalPendingTooltip: "Dieser Datensatz hat eine ausstehende Genehmigungsanfrage; dieser Schritt erlaubt weiterhin die Bearbeitung", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index bbc2ad760d..e5ea9cb44c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1328,6 +1328,7 @@ const en = { 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}}", writeStrippedComputed: 'Calculated by the server from a formula, so the value sent did not take effect: {{fields}}', + writeStrippedStoredExpression: 'Computed from the record, so the computed value was saved instead of the one sent: {{fields}}', writeStrippedUnknownReason: 'Not applied by the server: {{fields}}', approvalPendingEditable: 'In approval · editable', approvalPendingTooltip: 'This record has a pending approval request; this step still allows editing', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index e0f8dd5b68..12dc7be349 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1244,6 +1244,7 @@ const es = { 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}}", writeStrippedComputed: "Lo calcula el servidor mediante una fórmula, por lo que el valor enviado no se aplicó: {{fields}}", + writeStrippedStoredExpression: "Se calcula a partir del registro, por lo que se guardó el valor calculado en lugar del enviado: {{fields}}", writeStrippedUnknownReason: "No aplicado por el servidor: {{fields}}", approvalPendingEditable: "En aprobación · editable", approvalPendingTooltip: "Este registro tiene una solicitud de aprobación pendiente; este paso todavía permite la edición", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 6b68e15391..50eaf5bd7f 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1241,6 +1241,7 @@ const fr = { 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}}", writeStrippedComputed: "Calculé par le serveur à partir d'une formule, donc la valeur envoyée n'a pas été appliquée : {{fields}}", + writeStrippedStoredExpression: "Calculé à partir de l'enregistrement, donc la valeur calculée a été enregistrée à la place de celle envoyée : {{fields}}", writeStrippedUnknownReason: "Non appliqué par le serveur : {{fields}}", approvalPendingEditable: "En approbation · modifiable", approvalPendingTooltip: "Cet enregistrement a une demande d'approbation en attente ; cette étape autorise encore la modification", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index d2d24884f9..132b4f7628 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1219,6 +1219,7 @@ const ja = { writeStrippedByState: "次の項目はこのレコードの現在の状態では編集できないため反映されませんでした: {{fields}}", writeStrippedPrimaryKey: "次の項目はレコードの識別子で、保存では変更できないため反映されませんでした: {{fields}}", writeStrippedComputed: "次の項目はサーバーが数式で計算するため、送信した値は反映されませんでした: {{fields}}", + writeStrippedStoredExpression: "次の項目はレコードから計算されるため、送信した値ではなく計算値が保存されました: {{fields}}", writeStrippedUnknownReason: "次の項目はサーバーで反映されませんでした: {{fields}}", approvalPendingEditable: "承認中 · 編集可能", approvalPendingTooltip: "このレコードには承認待ちのリクエストがありますが、このステップでは編集できます", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 214671e48e..8507a86c07 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1207,6 +1207,7 @@ const ko = { writeStrippedByState: "다음 필드는 이 레코드의 현재 상태에서 편집할 수 없으므로 적용되지 않았습니다: {{fields}}", writeStrippedPrimaryKey: "다음 필드는 레코드의 식별자이며 저장으로 변경할 수 없으므로 적용되지 않았습니다: {{fields}}", writeStrippedComputed: "다음 필드는 서버가 수식으로 계산하므로 보낸 값이 적용되지 않았습니다: {{fields}}", + writeStrippedStoredExpression: "다음 필드는 레코드에서 계산되므로 보낸 값 대신 계산된 값이 저장되었습니다: {{fields}}", writeStrippedUnknownReason: "다음 필드는 서버에서 적용되지 않았습니다: {{fields}}", approvalPendingEditable: "승인 진행 중 · 편집 가능", approvalPendingTooltip: "이 레코드에 대기 중인 승인 요청이 있지만 이 단계에서는 편집할 수 있습니다", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 6839879bdb..dbdc574655 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1241,6 +1241,7 @@ const pt = { 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}}", writeStrippedComputed: "Calculado pelo servidor a partir de uma fórmula, portanto o valor enviado não foi aplicado: {{fields}}", + writeStrippedStoredExpression: "Calculado a partir do registro, portanto o valor calculado foi salvo no lugar do enviado: {{fields}}", writeStrippedUnknownReason: "Não aplicado pelo servidor: {{fields}}", approvalPendingEditable: "Em aprovação · editável", approvalPendingTooltip: "Este registro tem uma solicitação de aprovação pendente; esta etapa ainda permite a edição", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 354b8aaf30..ae6cc33fad 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1294,6 +1294,7 @@ const ru = { writeStrippedByState: "Поля недоступны для редактирования в текущем состоянии записи, поэтому не применены: {{fields}}", writeStrippedPrimaryKey: "Идентификатор записи нельзя изменить при сохранении, поэтому поля не применены: {{fields}}", writeStrippedComputed: "Поля вычисляются сервером по формуле, поэтому отправленные значения не применены: {{fields}}", + writeStrippedStoredExpression: "Поля вычисляются из записи, поэтому вместо отправленных значений сохранены вычисленные: {{fields}}", writeStrippedUnknownReason: "Не применены сервером: {{fields}}", approvalPendingEditable: "На согласовании · редактирование доступно", approvalPendingTooltip: "У этой записи есть ожидающий запрос на согласование, но этот шаг всё ещё разрешает редактирование", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index c49e1431e5..8aff1bd078 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1100,6 +1100,7 @@ const zh = { writeStrippedByState: '以下字段在记录当前状态下不可编辑,未生效:{{fields}}', writeStrippedPrimaryKey: '以下字段是记录的标识符,保存时无法修改,未生效:{{fields}}', writeStrippedComputed: '以下字段由服务端按公式计算,提交的值未生效:{{fields}}', + writeStrippedStoredExpression: '以下字段的值由记录计算得出,已保存计算值而非提交的值:{{fields}}', writeStrippedUnknownReason: '以下字段未被服务端写入:{{fields}}', approvalPendingEditable: '审批中 · 可编辑', approvalPendingTooltip: '该记录有待审批的请求,但当前审批节点仍允许编辑',