Skip to content

Commit 1d201b0

Browse files
feat(console): a public form's thank-you panel shows the record field values its {{record.field_name}} tokens name (#28493)
domain:ui#1 · os-tesla · session_01L9qibySWZrcTi5MPXZ61kx <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao6dvKNTFV98GfggJtMngWxP)_ Fixes #28444 Clause-②: no Metadata-protocol: semantic Skills-docs: updated here — content/docs/ui/public-data-collection.mdx, content/docs/ui/forms.mdx ## 维护者速读 - 改了什么:公开表单提交后的感谢面板,`title` / `message` 里的 `{{record.case_number}}` 这类占位符现在会替换成提交应答 `fields` 里的值(例如工单号 CASE-0042),不再把占位符原文显示给访客。 - 为什么改:#28005 裁决 B 的 UI 半边;规范(#28440)与服务端(#28479)都已合并,服务端已经在应答里返回这些值,但 Console 还没有填。 - 风险与回滚:只影响声明了占位符的公开表单感谢面板;没有占位符的面板逐字不变;应答缺值时显示为空,值一律按纯文本显示。回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/components/FormPage.tsx` 的 `fillThankYouPanel` 与 `readThankYouAnswerFields`,以及提交 `switch` 里新的 `thank-you` 分支。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx`,预期 9 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes `ui/apps/console/src/components/FormPage.tsx`: - `readThankYouAnswerFields(answer)` reads `fields` off the public submit answer through `PublicFormSubmitResponseSchema` (`@objectstack/spec/api`). An answer that is not that shape (the schema is strict) carries no fields. - `fillThankYouPanel(panel, fields)` replaces each token in the panel's `title` and `message`. Which tokens a text holds is `thankYouRecordFieldNames` (`@objectstack/spec/ui`); each named field's token is then its literal `{{record.` + name + `}}` spelling, split out of the text with a plain string `split`. There is no regex in the Console for this token. A name with no value in the answer (or an `Object.prototype` key such as `constructor`) fills as empty. A filled value is never read for tokens again, and the page draws the result as a React text child, so a value is text, never markup. - The submit `switch` gets its own `thank-you` arm: on the public route it stores the answer's fields, and the confirmation panel draws the filled text. The internal route's answer is `CreateDataResponse`, not this shape, so it is not read (see Acceptance notes). `ui/packages/data-objectstack/**` is untouched: the public submit is a direct `apiFetch` in `FormPage.tsx` (`submitPublic`), which hands back the bare JSON body, so `fields` is not dropped anywhere on the way. Docs (the card's Skills-docs rider): `content/docs/ui/forms.mdx` (section 8, after the `kind` table) and `content/docs/ui/public-data-collection.mdx` (after the `submitBehavior` paragraph) each gain one sentence and one example, and both say only an engine-generated field (`autonumber`) may be named. Root changeset `.changeset/28444-thank-you-record-fields.md`: `@object-ui/console` minor (Metadata-protocol `semantic`). ## Tests New `ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx` (the real `FormPage` on `/f/:slug`, fetch stubbed): - A message and a title with `{{record.case_number}}` show the answer's `CASE-0042`. - An answer with no `fields` renders the token as empty, and no `{{` reaches the DOM. - A value carrying an `img` tag and a `b` tag shows as literal text; no such element is created. - An answer with a key beyond `id` and `fields` fails the strict schema and fills nothing. - `fillThankYouPanel`: a value that itself spells another name's token is not filled again; `{{record.constructor}}` fills as empty; a panel with no token is returned as authored. - `readThankYouAnswerFields`: reads `fields` off the public answer; reads none off a `CreateDataResponse` or `undefined`. Ablation (one run, not kept): `node scripts/ablation-replace.mjs` replaced the `fillThankYouPanel(...)` call in the render with the raw panel text. The rendered tests went 4 failed, 5 passed (all four render pins red; the five helper unit pins stay green, as they call the helper directly). Restored; blob back to the HEAD blob `a4ac3681`, `git diff HEAD` empty. ## Acceptance notes - **Translated panel text (the card's rider), measured by reading the code, not live.** `GET /forms/:slug` serves the translated view's `config` (`translateMetadataDocument('view', …)` in `packages/rest/src/form-routes.ts`), so the Console reads the tokens of the translated text. The submit answer's `fields` is built from `thankYouRecordFieldNames(match.form?.submitBehavior)`, the base text. So a translation that names a field the base text names fills; a translation that names a field the base does not renders that token empty, never as the token text. Not widened. - **Internal route.** An internal (`/forms/:name`) form that declares a `thank-you` panel with tokens renders them empty: its answer is `CreateDataResponse`, which `PublicFormSubmitResponseSchema` (strict) refuses, and the card scopes the fill to the public answer. Before this PR it showed the token text. Filling it from the internal answer's `record` would be a second source; left for a card if wanted. - **Empty heading.** A `title` that is only a token with no value now renders an empty heading rather than the default "Thank you!" (the card: empty, never the token text). - `submitRedirect.ts` keeps its own `RECORD_TOKEN_RE` for the `redirect` arm; it is unchanged here. ## Verification Measured on head `b88d54a9` (main base `9b4d1933`). After merging `origin/main` at `8c3f9d93` (head `60fc7181`; the merge brings only #28490, no file this PR touches), `apps/console/src/components/` was re-run: 55 files, 594 tests passed. <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run apps/console/src/components/`: 55 files, 594 tests passed (includes the new file and `FormPage.submit.test.tsx`). - `pnpm --filter @object-ui/console type-check`: exit 0, 0 errors (after a full dts build of `./packages/**` and the `@object-ui/console^...` closure; the console tsconfig includes `src`, so the new test file is compiled). </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 66 families, every one exit 0, including `node scripts/check-sdui-manifest.mjs` and its self-test, `pnpm check:test-source-alias`, `pnpm check:cross-package-test-inputs`, `pnpm check:undeclared-dep-imports`, `pnpm check:doc-authoring`, `pnpm check:docs-transcript-drift`, `node scripts/check-changeset-no-major.mjs --base origin/main`, `pnpm --filter @objectstack/spec run check:docs`, and `pnpm --dir ui exec vitest run scripts/` (171 files, 4901 passed, 2 skipped). `pnpm check:query-options-erasure` hit a 280 s per-gate timeout in the first sweep and passed (exit 0) when re-run without one. `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED. `pnpm check:new-line-citations` (ui): 0 new citations. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibySWZrcTi5MPXZ61kx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8c3f9d9 commit 1d201b0

5 files changed

Lines changed: 247 additions & 3 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/console': minor
3+
---
4+
5+
feat(console): a public form's thank-you panel shows the record field values its `{{record.field_name}}` tokens name
6+
7+
Clause-②: no
8+
Metadata-protocol: semantic
9+
10+
A `thank-you` panel's `title` and `message` may name an engine-generated field of the record just submitted (an `autonumber` field, such as a case number) as `{{record.field_name}}`. On the public form route the Console now replaces each such token with the value the submit answer carries for it under `fields` (`PublicFormSubmitResponseSchema`, `@objectstack/spec/api`), so a web-to-case visitor sees their case number instead of the token's own text:
11+
12+
```ts
13+
submitBehavior: { kind: 'thank-you', message: 'Your case number is {{record.case_number}}.' }
14+
```
15+
16+
- Which tokens a text holds is read by the spec's `thankYouRecordFieldNames`; the Console has no token grammar of its own.
17+
- A token the answer carries no value for shows as empty, never as the token's text.
18+
- A value is drawn as plain text, never as markup, and a filled value is never read for tokens again.

‎content/docs/ui/forms.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,12 @@ formViews: {
385385
| `continue` | Re-read prefill values and reset state — user can submit another response without reload. |
386386
| `next-record` | Stub for queue contexts; falls back to thank-you when no queue is wired. |
387387

388+
On the public path, a `thank-you` panel's `title` and `message` may name the record just submitted as `{{record.field_name}}`, and the Console replaces each token with the value the submit answer carries for it, drawn as plain text; only an engine-generated field (an `autonumber` field, such as a case number) may be named, and a token with no value in the answer shows as empty:
389+
390+
```ts
391+
submitBehavior: { kind: 'thank-you', title: 'Case opened', message: 'Your case number is {{record.case_number}}.' }
392+
```
393+
388394
### What a `redirect` `url` may be
389395

390396
`url` is **not** a free-form address. It was ruled on 2026-08-11 ([#7496](https://github.com/objectstack-ai/objectstack/issues/7496)) and the schema enforces it, so a URL outside this shape is a parse error at authoring time rather than a surprise in the browser:

‎content/docs/ui/public-data-collection.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ formViews: {
3535

3636
The `submitBehavior` above sets the **copy**, not the behavior: `thank-you` is already the default on the public path, because an anonymous submitter may not read the record back. The internal path defaults the other way — a `type: 'form'` action lands the operator on the record it just created — so do not carry a public form's confirmation panel over to an internal one by habit. See [`submitBehavior` is mode-aware](/docs/ui/forms#the-default-is-mode-aware).
3737

38+
The panel can also show the visitor a reference to the record they just created: name the field in `title` or `message` as `{{record.field_name}}`, and the Console fills it from the submit answer. Only an engine-generated field (an `autonumber` field, such as a case number) may be named; `os validate` and the publish gate refuse any other.
39+
40+
```ts
41+
submitBehavior: { kind: 'thank-you', title: 'Thanks!', message: 'Your case number is {{record.case_number}}.' },
42+
```
43+
3844
That `sharing` block wires the anonymous endpoints automatically:
3945

4046
```
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A public form's `thank-you` panel fills its `{{record.field_name}}` tokens
5+
* from the submit answer's `fields` (`PublicFormSubmitResponseSchema`): a
6+
* web-to-case visitor sees their case number, never the token's own text. A
7+
* name the answer carries no value for fills as empty, and a value is drawn as
8+
* text, never as markup.
9+
*/
10+
11+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
12+
import { render, screen, waitFor } from '@testing-library/react';
13+
import userEvent from '@testing-library/user-event';
14+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
15+
import { createI18n } from '@object-ui/i18n';
16+
import { FormPage, fillThankYouPanel, readThankYouAnswerFields } from './FormPage';
17+
18+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
19+
20+
/** The public `/forms/:slug` resolver payload, with a `thank-you` panel. */
21+
function publicPayload(panel: { title?: string; message?: string }) {
22+
return {
23+
slug: 'report-issue',
24+
object: 'support_case',
25+
label: 'Report an issue',
26+
form: {
27+
type: 'simple',
28+
sections: [{ fields: ['subject'] }],
29+
submitBehavior: { kind: 'thank-you', ...panel },
30+
},
31+
objectSchema: {
32+
name: 'support_case',
33+
fields: {
34+
subject: { type: 'text', label: 'Subject' },
35+
case_number: { type: 'autonumber', label: 'Case number' },
36+
},
37+
},
38+
};
39+
}
40+
41+
function stubFetch(payload: unknown, answer: unknown) {
42+
return vi.fn(async (url: string) => {
43+
const body = String(url).includes('/forms/report-issue/submit') ? answer : payload;
44+
return {
45+
ok: true,
46+
status: 200,
47+
statusText: 'OK',
48+
json: async () => body,
49+
text: async () => JSON.stringify(body),
50+
} as unknown as Response;
51+
});
52+
}
53+
54+
async function submitPublic(panel: { title?: string; message?: string }, answer: unknown) {
55+
vi.stubGlobal('fetch', stubFetch(publicPayload(panel), answer));
56+
const view = render(
57+
<MemoryRouter initialEntries={['/f/report-issue']}>
58+
<Routes>
59+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
60+
</Routes>
61+
</MemoryRouter>,
62+
);
63+
await screen.findByLabelText(/Subject/);
64+
await userEvent.type(screen.getByLabelText(/Subject/), 'Printer jams');
65+
await userEvent.click(screen.getByRole('button', { name: /Submit/ }));
66+
return view;
67+
}
68+
69+
beforeEach(() => {
70+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
71+
});
72+
73+
afterEach(() => {
74+
vi.unstubAllGlobals();
75+
});
76+
77+
describe('public thank-you panel — record field tokens', () => {
78+
it('shows the answer\'s value for a `{{record.case_number}}` token in the message and the title', async () => {
79+
await submitPublic(
80+
{ title: 'Case {{record.case_number}} opened', message: 'Your case number is {{record.case_number}}.' },
81+
{ id: 'case-1', fields: { case_number: 'CASE-0042' } },
82+
);
83+
84+
expect(await screen.findByText('Your case number is CASE-0042.')).toBeInTheDocument();
85+
expect(screen.getByRole('heading', { name: 'Case CASE-0042 opened' })).toBeInTheDocument();
86+
});
87+
88+
it('fills a token the answer carries no value for as empty, never as the token text', async () => {
89+
const { container } = await submitPublic(
90+
{ message: 'Your case number is {{record.case_number}}.' },
91+
{ id: 'case-1' },
92+
);
93+
94+
expect(await screen.findByText('Your case number is .')).toBeInTheDocument();
95+
expect(container.textContent).not.toContain('{{');
96+
});
97+
98+
it('draws a value as text, never as markup', async () => {
99+
const value = '<img src="x" alt="injected"><b>bold</b>';
100+
const { container } = await submitPublic(
101+
{ message: 'Case: {{record.case_number}}' },
102+
{ id: 'case-1', fields: { case_number: value } },
103+
);
104+
105+
expect(await screen.findByText(`Case: ${value}`)).toBeInTheDocument();
106+
expect(container.querySelector('img')).toBeNull();
107+
expect(container.querySelector('b')).toBeNull();
108+
});
109+
110+
it('reads the answer through the spec\'s shape: an answer with keys beyond `id` and `fields` fills nothing', async () => {
111+
await submitPublic(
112+
{ message: 'Your case number is {{record.case_number}}.' },
113+
{ id: 'case-1', fields: { case_number: 'CASE-0042' }, record: { case_number: 'CASE-0042' } },
114+
);
115+
116+
await waitFor(() => expect(screen.getByText('Your case number is .')).toBeInTheDocument());
117+
});
118+
});
119+
120+
describe('fillThankYouPanel', () => {
121+
it('never reads a filled value for tokens again', () => {
122+
const filled = fillThankYouPanel(
123+
{ message: '{{record.case_number}} / {{record.ref_code}}' },
124+
{ case_number: '{{record.ref_code}}', ref_code: 'R-7' },
125+
);
126+
expect(filled.message).toBe('{{record.ref_code}} / R-7');
127+
});
128+
129+
it('fills a name that is an Object.prototype key as empty', () => {
130+
expect(fillThankYouPanel({ message: 'x{{record.constructor}}y' }, {}).message).toBe('xy');
131+
});
132+
133+
it('leaves a panel with no token as authored', () => {
134+
const panel = { title: 'Thanks', message: 'We got it.' };
135+
expect(fillThankYouPanel(panel, { case_number: 'CASE-1' })).toEqual(panel);
136+
});
137+
});
138+
139+
describe('readThankYouAnswerFields', () => {
140+
it('reads `fields` off the public submit answer', () => {
141+
expect(readThankYouAnswerFields({ id: 'a', fields: { case_number: 7 } })).toEqual({ case_number: 7 });
142+
});
143+
144+
it('reads none off an answer that is not the public submit shape', () => {
145+
expect(readThankYouAnswerFields({ object: 'x', id: 'a', record: { case_number: 7 } })).toBeUndefined();
146+
expect(readThankYouAnswerFields(undefined)).toBeUndefined();
147+
});
148+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ import {
169169
import { pickLocalized, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n';
170170
import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell';
171171
import type { ObjectFormSection } from '@object-ui/types';
172+
import { PublicFormSubmitResponseSchema } from '@objectstack/spec/api';
173+
import { thankYouRecordFieldNames } from '@objectstack/spec/ui';
172174
import { resolveSubmitRedirect } from './submitRedirect';
173175

174176
const API_BASE = (import.meta.env.VITE_SERVER_URL || '') + '/api/v1';
@@ -1185,6 +1187,58 @@ export function resolveSubmitBehavior(
11851187
return mode === 'internal' ? { kind: 'created-record' } : { kind: 'thank-you' };
11861188
}
11871189

1190+
/** The values a public submit answer carries for its `thank-you` panel, keyed by field name. */
1191+
export type ThankYouAnswerFields = Readonly<Record<string, string | number>>;
1192+
1193+
/**
1194+
* The `fields` of a public submit answer (`PublicFormSubmitResponseSchema`,
1195+
* `@objectstack/spec/api`): the stored values of the engine-generated fields
1196+
* the form's `thank-you` panel names. An answer that is not that shape, or one
1197+
* without `fields`, carries none.
1198+
*/
1199+
export function readThankYouAnswerFields(answer: unknown): ThankYouAnswerFields | undefined {
1200+
const parsed = PublicFormSubmitResponseSchema.safeParse(answer);
1201+
return parsed.success ? parsed.data.fields : undefined;
1202+
}
1203+
1204+
/**
1205+
* A `thank-you` panel's `title` and `message` with each `{{record.field_name}}`
1206+
* token replaced by the answer's value for that field.
1207+
*
1208+
* Which tokens a text holds is `thankYouRecordFieldNames`' answer, the spec's
1209+
* one reading of the token, so this page carries no grammar of its own: each
1210+
* named field's token is its literal `{{record.` + name + `}}` spelling. A
1211+
* name the answer carries no value for fills as empty, never as the token's
1212+
* own text. A filled value is never read for tokens again, and the page draws
1213+
* the result as a React text child, so a value shows as text and never as
1214+
* markup.
1215+
*/
1216+
export function fillThankYouPanel(
1217+
panel: { title?: string; message?: string },
1218+
fields: ThankYouAnswerFields | undefined,
1219+
): { title?: string; message?: string } {
1220+
const names = thankYouRecordFieldNames({ kind: 'thank-you', ...panel });
1221+
if (names.length === 0) return panel;
1222+
const fill = (text: string | undefined): string | undefined => {
1223+
if (text === undefined) return undefined;
1224+
// Literal runs still to be read for tokens, interleaved with filled values.
1225+
let parts: Array<{ text: string; filled: boolean }> = [{ text, filled: false }];
1226+
for (const name of names) {
1227+
const token = `{{record.${name}}}`;
1228+
const value = fields && Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : '';
1229+
parts = parts.flatMap((part) =>
1230+
part.filled
1231+
? [part]
1232+
: part.text.split(token).flatMap((run, i) =>
1233+
i === 0 ? [{ text: run, filled: false }] : [{ text: value, filled: true }, { text: run, filled: false }],
1234+
),
1235+
);
1236+
}
1237+
return parts.map((part) => part.text).join('');
1238+
};
1239+
return { title: fill(panel.title), message: fill(panel.message) };
1240+
}
1241+
11881242
/**
11891243
* Strip the transport envelope off a data-API body, if there is one.
11901244
*
@@ -2474,6 +2528,11 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24742528
/** The `error` banner's id, which a row the banner names points at (objectstack-ai/objectstack#27417). */
24752529
const errorBannerId = `form-error:${useId()}`;
24762530
const [submitted, setSubmitted] = useState(false);
2531+
/**
2532+
* The values the public submit answer carries for the `thank-you` panel's
2533+
* `{{record.field_name}}` tokens ({@link readThankYouAnswerFields}).
2534+
*/
2535+
const [thankYouFields, setThankYouFields] = useState<ThankYouAnswerFields | undefined>(undefined);
24772536
/**
24782537
* The outcome of a `redirect` submit behaviour: the in-app route to go to
24792538
* once `delayMs` has elapsed, or the fact that the authored destination was
@@ -2864,8 +2923,13 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28642923
setCompletedSteps(new Set());
28652924
break;
28662925
}
2867-
case 'next-record':
28682926
case 'thank-you':
2927+
// Only the public answer carries the panel's values: the internal
2928+
// create answers `CreateDataResponse`, which is not that shape.
2929+
if (mode === 'public') setThankYouFields(readThankYouAnswerFields(result));
2930+
setSubmitted(true);
2931+
break;
2932+
case 'next-record':
28692933
default:
28702934
setSubmitted(true);
28712935
break;
@@ -2954,8 +3018,10 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
29543018
// away instead. It carries no author-supplied title/message, so it renders
29553019
// the generic confirmation.
29563020
if (submitted && (behavior.kind === 'thank-you' || behavior.kind === 'created-record')) {
2957-
const title = behavior.kind === 'thank-you' ? behavior.title : undefined;
2958-
const message = behavior.kind === 'thank-you' ? behavior.message : undefined;
3021+
const { title, message } =
3022+
behavior.kind === 'thank-you'
3023+
? fillThankYouPanel({ title: behavior.title, message: behavior.message }, thankYouFields)
3024+
: { title: undefined, message: undefined };
29593025
return (
29603026
<div className="mx-auto max-w-2xl p-6">
29613027
<div className="rounded-md border bg-card p-6 text-center">

0 commit comments

Comments
 (0)