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
18 changes: 18 additions & 0 deletions .changeset/28444-thank-you-record-fields.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions content/docs/ui/public-data-collection.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={['/f/report-issue']}>
<Routes>
<Route path="/f/:slug" element={<FormPage mode="public" />} />
</Routes>
</MemoryRouter>,
);
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 = '<img src="x" alt="injected"><b>bold</b>';
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();
});
});
72 changes: 69 additions & 3 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Record<string, string | number>>;

/**
* 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.
*
Expand Down Expand Up @@ -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<ThankYouAnswerFields | undefined>(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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="mx-auto max-w-2xl p-6">
<div className="rounded-md border bg-card p-6 text-center">
Expand Down
Loading