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
25 changes: 25 additions & 0 deletions .changeset/13178-stripped-stored-expression.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 77 additions & 3 deletions packages/app-shell/src/providers/writeWarningToast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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<string, string>();
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<string, unknown>) => {
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();

Expand Down Expand Up @@ -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.
Expand 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<string, string>();
for (const reason of reasons) {
Expand Down
30 changes: 28 additions & 2 deletions packages/app-shell/src/providers/writeWarningToast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DroppedFieldsEvent['reason'], StrippedLine> = {
const STRIPPED_LINE: Record<DroppedFieldsEvent['reason'] | 'stored_expression', StrippedLine> = {
readonly: (t: TranslateFn, fields: string) =>
t('detail.writeStrippedReadonly', {
fields,
Expand All @@ -150,6 +168,14 @@ const STRIPPED_LINE: Record<DroppedFieldsEvent['reason'], StrippedLine> = {
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}}',
}),
};

/**
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1349,6 +1349,7 @@ const ar = {
writeStrippedByState: "غير قابلة للتعديل في الحالة الحالية لهذا السجل، لذلك لم تُطبَّق: {{fields}}",
writeStrippedPrimaryKey: "معرّف السجل لا يمكن تغييره عند الحفظ، لذلك لم تُطبَّق: {{fields}}",
writeStrippedComputed: "يحسبها الخادم بواسطة صيغة، لذلك لم تُطبَّق القيمة المُرسَلة: {{fields}}",
writeStrippedStoredExpression: "تُحسب من السجل، لذلك حُفظت القيمة المحسوبة بدلًا من القيمة المُرسَلة: {{fields}}",
writeStrippedUnknownReason: "لم تُطبَّق من قِبل الخادم: {{fields}}",
approvalPendingEditable: "قيد الموافقة · قابل للتعديل",
approvalPendingTooltip: "يحتوي هذا السجل على طلب موافقة معلق، لكن هذه الخطوة لا تزال تسمح بالتعديل",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1219,6 +1219,7 @@ const ja = {
writeStrippedByState: "次の項目はこのレコードの現在の状態では編集できないため反映されませんでした: {{fields}}",
writeStrippedPrimaryKey: "次の項目はレコードの識別子で、保存では変更できないため反映されませんでした: {{fields}}",
writeStrippedComputed: "次の項目はサーバーが数式で計算するため、送信した値は反映されませんでした: {{fields}}",
writeStrippedStoredExpression: "次の項目はレコードから計算されるため、送信した値ではなく計算値が保存されました: {{fields}}",
writeStrippedUnknownReason: "次の項目はサーバーで反映されませんでした: {{fields}}",
approvalPendingEditable: "承認中 · 編集可能",
approvalPendingTooltip: "このレコードには承認待ちのリクエストがありますが、このステップでは編集できます",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1207,6 +1207,7 @@ const ko = {
writeStrippedByState: "다음 필드는 이 레코드의 현재 상태에서 편집할 수 없으므로 적용되지 않았습니다: {{fields}}",
writeStrippedPrimaryKey: "다음 필드는 레코드의 식별자이며 저장으로 변경할 수 없으므로 적용되지 않았습니다: {{fields}}",
writeStrippedComputed: "다음 필드는 서버가 수식으로 계산하므로 보낸 값이 적용되지 않았습니다: {{fields}}",
writeStrippedStoredExpression: "다음 필드는 레코드에서 계산되므로 보낸 값 대신 계산된 값이 저장되었습니다: {{fields}}",
writeStrippedUnknownReason: "다음 필드는 서버에서 적용되지 않았습니다: {{fields}}",
approvalPendingEditable: "승인 진행 중 · 편집 가능",
approvalPendingTooltip: "이 레코드에 대기 중인 승인 요청이 있지만 이 단계에서는 편집할 수 있습니다",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,6 +1294,7 @@ const ru = {
writeStrippedByState: "Поля недоступны для редактирования в текущем состоянии записи, поэтому не применены: {{fields}}",
writeStrippedPrimaryKey: "Идентификатор записи нельзя изменить при сохранении, поэтому поля не применены: {{fields}}",
writeStrippedComputed: "Поля вычисляются сервером по формуле, поэтому отправленные значения не применены: {{fields}}",
writeStrippedStoredExpression: "Поля вычисляются из записи, поэтому вместо отправленных значений сохранены вычисленные: {{fields}}",
writeStrippedUnknownReason: "Не применены сервером: {{fields}}",
approvalPendingEditable: "На согласовании · редактирование доступно",
approvalPendingTooltip: "У этой записи есть ожидающий запрос на согласование, но этот шаг всё ещё разрешает редактирование",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1100,6 +1100,7 @@ const zh = {
writeStrippedByState: '以下字段在记录当前状态下不可编辑,未生效:{{fields}}',
writeStrippedPrimaryKey: '以下字段是记录的标识符,保存时无法修改,未生效:{{fields}}',
writeStrippedComputed: '以下字段由服务端按公式计算,提交的值未生效:{{fields}}',
writeStrippedStoredExpression: '以下字段的值由记录计算得出,已保存计算值而非提交的值:{{fields}}',
writeStrippedUnknownReason: '以下字段未被服务端写入:{{fields}}',
approvalPendingEditable: '审批中 · 可编辑',
approvalPendingTooltip: '该记录有待审批的请求,但当前审批节点仍允许编辑',
Expand Down
Loading