diff --git a/.changeset/28444-thank-you-record-fields.md b/.changeset/28444-thank-you-record-fields.md new file mode 100644 index 00000000000..fee5b2e54ea --- /dev/null +++ b/.changeset/28444-thank-you-record-fields.md @@ -0,0 +1,18 @@ +--- +'@object-ui/console': minor +--- + +feat(console): a public form's thank-you panel shows the record field values its `{{record.field_name}}` tokens name + +Clause-②: no +Metadata-protocol: semantic + +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: + +```ts +submitBehavior: { kind: 'thank-you', message: 'Your case number is {{record.case_number}}.' } +``` + +- Which tokens a text holds is read by the spec's `thankYouRecordFieldNames`; the Console has no token grammar of its own. +- A token the answer carries no value for shows as empty, never as the token's text. +- A value is drawn as plain text, never as markup, and a filled value is never read for tokens again. diff --git a/content/docs/ui/forms.mdx b/content/docs/ui/forms.mdx index d5f884cdec4..f477cc1707b 100644 --- a/content/docs/ui/forms.mdx +++ b/content/docs/ui/forms.mdx @@ -385,6 +385,12 @@ formViews: { | `continue` | Re-read prefill values and reset state — user can submit another response without reload. | | `next-record` | Stub for queue contexts; falls back to thank-you when no queue is wired. | +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: + +```ts +submitBehavior: { kind: 'thank-you', title: 'Case opened', message: 'Your case number is {{record.case_number}}.' } +``` + ### What a `redirect` `url` may be `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: diff --git a/content/docs/ui/public-data-collection.mdx b/content/docs/ui/public-data-collection.mdx index e152029976c..1459a19d3a0 100644 --- a/content/docs/ui/public-data-collection.mdx +++ b/content/docs/ui/public-data-collection.mdx @@ -35,6 +35,12 @@ formViews: { 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). +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. + +```ts +submitBehavior: { kind: 'thank-you', title: 'Thanks!', message: 'Your case number is {{record.case_number}}.' }, +``` + That `sharing` block wires the anonymous endpoints automatically: ``` diff --git a/ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx b/ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx new file mode 100644 index 00000000000..712123ff81e --- /dev/null +++ b/ui/apps/console/src/components/FormPage.thankYouRecordFields-28444.test.tsx @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A public form's `thank-you` panel fills its `{{record.field_name}}` tokens + * from the submit answer's `fields` (`PublicFormSubmitResponseSchema`): a + * web-to-case visitor sees their case number, never the token's own text. A + * name the answer carries no value for fills as empty, and a value is drawn as + * text, never as markup. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { createI18n } from '@object-ui/i18n'; +import { FormPage, fillThankYouPanel, readThankYouAnswerFields } from './FormPage'; + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +/** The public `/forms/:slug` resolver payload, with a `thank-you` panel. */ +function publicPayload(panel: { title?: string; message?: string }) { + return { + slug: 'report-issue', + object: 'support_case', + label: 'Report an issue', + form: { + type: 'simple', + sections: [{ fields: ['subject'] }], + submitBehavior: { kind: 'thank-you', ...panel }, + }, + objectSchema: { + name: 'support_case', + fields: { + subject: { type: 'text', label: 'Subject' }, + case_number: { type: 'autonumber', label: 'Case number' }, + }, + }, + }; +} + +function stubFetch(payload: unknown, answer: unknown) { + return vi.fn(async (url: string) => { + const body = String(url).includes('/forms/report-issue/submit') ? answer : payload; + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body), + } as unknown as Response; + }); +} + +async function submitPublic(panel: { title?: string; message?: string }, answer: unknown) { + vi.stubGlobal('fetch', stubFetch(publicPayload(panel), answer)); + const view = render( + + + } /> + + , + ); + await screen.findByLabelText(/Subject/); + await userEvent.type(screen.getByLabelText(/Subject/), 'Printer jams'); + await userEvent.click(screen.getByRole('button', { name: /Submit/ })); + return view; +} + +beforeEach(() => { + createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('public thank-you panel — record field tokens', () => { + it('shows the answer\'s value for a `{{record.case_number}}` token in the message and the title', async () => { + await submitPublic( + { title: 'Case {{record.case_number}} opened', message: 'Your case number is {{record.case_number}}.' }, + { id: 'case-1', fields: { case_number: 'CASE-0042' } }, + ); + + expect(await screen.findByText('Your case number is CASE-0042.')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Case CASE-0042 opened' })).toBeInTheDocument(); + }); + + it('fills a token the answer carries no value for as empty, never as the token text', async () => { + const { container } = await submitPublic( + { message: 'Your case number is {{record.case_number}}.' }, + { id: 'case-1' }, + ); + + expect(await screen.findByText('Your case number is .')).toBeInTheDocument(); + expect(container.textContent).not.toContain('{{'); + }); + + it('draws a value as text, never as markup', async () => { + const value = 'injectedbold'; + const { container } = await submitPublic( + { message: 'Case: {{record.case_number}}' }, + { id: 'case-1', fields: { case_number: value } }, + ); + + expect(await screen.findByText(`Case: ${value}`)).toBeInTheDocument(); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('b')).toBeNull(); + }); + + it('reads the answer through the spec\'s shape: an answer with keys beyond `id` and `fields` fills nothing', async () => { + await submitPublic( + { message: 'Your case number is {{record.case_number}}.' }, + { id: 'case-1', fields: { case_number: 'CASE-0042' }, record: { case_number: 'CASE-0042' } }, + ); + + await waitFor(() => expect(screen.getByText('Your case number is .')).toBeInTheDocument()); + }); +}); + +describe('fillThankYouPanel', () => { + it('never reads a filled value for tokens again', () => { + const filled = fillThankYouPanel( + { message: '{{record.case_number}} / {{record.ref_code}}' }, + { case_number: '{{record.ref_code}}', ref_code: 'R-7' }, + ); + expect(filled.message).toBe('{{record.ref_code}} / R-7'); + }); + + it('fills a name that is an Object.prototype key as empty', () => { + expect(fillThankYouPanel({ message: 'x{{record.constructor}}y' }, {}).message).toBe('xy'); + }); + + it('leaves a panel with no token as authored', () => { + const panel = { title: 'Thanks', message: 'We got it.' }; + expect(fillThankYouPanel(panel, { case_number: 'CASE-1' })).toEqual(panel); + }); +}); + +describe('readThankYouAnswerFields', () => { + it('reads `fields` off the public submit answer', () => { + expect(readThankYouAnswerFields({ id: 'a', fields: { case_number: 7 } })).toEqual({ case_number: 7 }); + }); + + it('reads none off an answer that is not the public submit shape', () => { + expect(readThankYouAnswerFields({ object: 'x', id: 'a', record: { case_number: 7 } })).toBeUndefined(); + expect(readThankYouAnswerFields(undefined)).toBeUndefined(); + }); +}); diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index 52a32cbcf08..a4ac3681315 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -169,6 +169,8 @@ import { import { pickLocalized, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; import type { FormFieldSpec, FormSectionSpec, FormViewSpec } from '@object-ui/app-shell'; import type { ObjectFormSection } from '@object-ui/types'; +import { PublicFormSubmitResponseSchema } from '@objectstack/spec/api'; +import { thankYouRecordFieldNames } from '@objectstack/spec/ui'; import { resolveSubmitRedirect } from './submitRedirect'; const API_BASE = (import.meta.env.VITE_SERVER_URL || '') + '/api/v1'; @@ -1185,6 +1187,58 @@ export function resolveSubmitBehavior( return mode === 'internal' ? { kind: 'created-record' } : { kind: 'thank-you' }; } +/** The values a public submit answer carries for its `thank-you` panel, keyed by field name. */ +export type ThankYouAnswerFields = Readonly>; + +/** + * The `fields` of a public submit answer (`PublicFormSubmitResponseSchema`, + * `@objectstack/spec/api`): the stored values of the engine-generated fields + * the form's `thank-you` panel names. An answer that is not that shape, or one + * without `fields`, carries none. + */ +export function readThankYouAnswerFields(answer: unknown): ThankYouAnswerFields | undefined { + const parsed = PublicFormSubmitResponseSchema.safeParse(answer); + return parsed.success ? parsed.data.fields : undefined; +} + +/** + * A `thank-you` panel's `title` and `message` with each `{{record.field_name}}` + * token replaced by the answer's value for that field. + * + * Which tokens a text holds is `thankYouRecordFieldNames`' answer, the spec's + * one reading of the token, so this page carries no grammar of its own: each + * named field's token is its literal `{{record.` + name + `}}` spelling. A + * name the answer carries no value for fills as empty, never as the token's + * own text. A filled value is never read for tokens again, and the page draws + * the result as a React text child, so a value shows as text and never as + * markup. + */ +export function fillThankYouPanel( + panel: { title?: string; message?: string }, + fields: ThankYouAnswerFields | undefined, +): { title?: string; message?: string } { + const names = thankYouRecordFieldNames({ kind: 'thank-you', ...panel }); + if (names.length === 0) return panel; + const fill = (text: string | undefined): string | undefined => { + if (text === undefined) return undefined; + // Literal runs still to be read for tokens, interleaved with filled values. + let parts: Array<{ text: string; filled: boolean }> = [{ text, filled: false }]; + for (const name of names) { + const token = `{{record.${name}}}`; + const value = fields && Object.prototype.hasOwnProperty.call(fields, name) ? String(fields[name]) : ''; + parts = parts.flatMap((part) => + part.filled + ? [part] + : part.text.split(token).flatMap((run, i) => + i === 0 ? [{ text: run, filled: false }] : [{ text: value, filled: true }, { text: run, filled: false }], + ), + ); + } + return parts.map((part) => part.text).join(''); + }; + return { title: fill(panel.title), message: fill(panel.message) }; +} + /** * Strip the transport envelope off a data-API body, if there is one. * @@ -2474,6 +2528,11 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { /** The `error` banner's id, which a row the banner names points at (objectstack-ai/objectstack#27417). */ const errorBannerId = `form-error:${useId()}`; const [submitted, setSubmitted] = useState(false); + /** + * The values the public submit answer carries for the `thank-you` panel's + * `{{record.field_name}}` tokens ({@link readThankYouAnswerFields}). + */ + const [thankYouFields, setThankYouFields] = useState(undefined); /** * The outcome of a `redirect` submit behaviour: the in-app route to go to * once `delayMs` has elapsed, or the fact that the authored destination was @@ -2864,8 +2923,13 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { setCompletedSteps(new Set()); break; } - case 'next-record': case 'thank-you': + // Only the public answer carries the panel's values: the internal + // create answers `CreateDataResponse`, which is not that shape. + if (mode === 'public') setThankYouFields(readThankYouAnswerFields(result)); + setSubmitted(true); + break; + case 'next-record': default: setSubmitted(true); break; @@ -2954,8 +3018,10 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { // away instead. It carries no author-supplied title/message, so it renders // the generic confirmation. if (submitted && (behavior.kind === 'thank-you' || behavior.kind === 'created-record')) { - const title = behavior.kind === 'thank-you' ? behavior.title : undefined; - const message = behavior.kind === 'thank-you' ? behavior.message : undefined; + const { title, message } = + behavior.kind === 'thank-you' + ? fillThankYouPanel({ title: behavior.title, message: behavior.message }, thankYouFields) + : { title: undefined, message: undefined }; return (