Docs
Views

Form view

Turn a view into a form that creates a record, and share it on a public link your application serves.

The Form view works like Notion forms: a view of the table shows a form instead of rows, and each response creates one record through actions.create. People choose which columns to ask, in what order, with their own wording, and can share the form on a public link. React and Vue behave the same.

Form ships in the table's own registry items, with no extra install: React uses the shadcn primitives and react-day-picker already in the item, Vue uses Reka UI and @internationalized/date (a Vue registry dependency). The step-by-step layout is built on the shadcn Questionnaire: the React item lists the questionnaire shadcn component and its @shadcn/react package as dependencies, so the shadcn CLI installs them with the table; the Vue item ships its own copy.

Enable

Add "form" to displayModes. The mode is offered when the table can create records: actions.create exists and allowCreate is not false. Without them, "form" is withheld even if listed, and a link asking for it falls back to the default mode.

form-config.ts
export const requestConfig = defineTableConfig({
  ...productConfig,
  table: {
    ...productConfig.table,
    displayModes: ["table", "form"],
    form: {
      submitLabel: "Send request",
      hiddenValues: { status: "Draft" },
    },
  },
});

table.form is optional. Set it to false to turn the mode off, or to an object of default settings (the shape below) that every Form view starts from. A renderer passed as displayModeRenderers.form replaces the built-in one.

Try the Form view

Each response creates a project through actions.create, with the Planned status set by hiddenValues. Switch Display mode to Table to see it. The preview uses the store-opening projects shared by every view guide; see the example setup for project-config.ts and the in-memory host.

Page layout with conditions

Choose Hardware as the category: Serial number appears and Budget becomes required. Choose Other to be asked for details. The rules are explained in conditional questions.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Steps with a review

The same questions and rules, one section per step, then a review of the answers with a Change button for each one. The last step asks for the site with the location editor. See step by step.

Expand ↗

Sample data. Changes stay in this preview.

Expand ↗

Sample data. Changes stay in this preview.

Form settings

Choose Form under View settings › Display mode. View settings › Form then shows a summary of the form (questions, sections, consents, hidden fields, layout and languages), Edit form and Reset. Forms are edited in the form builder. Settings are saved with the view (config.form) and in the <tableId>-form URL key, like the other display modes. Reset returns to the table's form settings.

SettingDefaultDescription
titleNoneHeading of the form.
descriptionNoneText under the title.
questionsEvery column a form may ask, in column orderThe questions, and optional section breaks, consents and hidden fields, in order; see below.
rulesNoneShow, hide or require questions from earlier answers; see Conditional questions.
layout"page""page" shows every question at once, "steps" one question or section at a time; see Step by step.
reviewfalseIn the steps layout, end with a review of the answers before sending.
hiddenValuesNoneFixed values saved with every response, for columns the form does not ask, for example { status: "Draft" }.
submitLabel"Submit"Label of the submit button.
successMessage"Thank you, your response has been recorded."Text of the screen shown after a response.
closedMessage"This form is no longer accepting responses."Text shown instead of the questions while the form is closed.
allowAnotherResponsetrueShow Submit another response on that screen.
redirectUrlNoneWhere your application may send people after a response. Only http(s):// addresses and paths starting with a single / are kept. The form never navigates by itself; see Redirect.
localesNoneLanguages the form is written in; see Languages.
defaultLocaleThe first of localesLanguage of plain texts, and the fallback of missing translations.
editButtontrueShow Edit form above the form in the Form view. The form stays editable from View settings.

Settings apply in this order: the built-in defaults, then table.form, then the view.

Each question is { id, columnId, label?, help?, placeholder?, required?, optionLabels? }:

  • columnId is the column that receives the answer. A column is asked at most once.

  • id is stable and defaults to the column id.

  • label replaces the column name, help shows under the label, placeholder shows in the empty input.

  • required makes the question mandatory. Questions are optional by default.

  • optionLabels replaces the labels of the column's options, by option value, for example to translate them.

Every text of a form (title, description, labels, help, placeholders, option labels, section texts, submit label, success and closed messages) is a plain string or one string per language; see Languages.

A section break is an item of questions too: { id, kind: "section", title?, description? }. It starts a titled group of the next questions: a heading in the page layout, one step in the steps layout. Rules can show or hide a section, and its questions with it, by its id.

A consent (kind: "consent") and a hidden field (kind: "hidden") are items of questions too; see Consent and Hidden fields. A fixed value for a column the form asks is ignored.

Form builder

Edit form above the Form view, or in View settings › Form, opens a near full-screen builder over the view:

  • The top bar holds the layout (one page or step by step), the Editing language switcher with Add language, the form's settings, Share form when the host provides formLinks, Save and Close.

  • The outline on the left lists the questions, sections and consents in order, the hidden fields, and Not in the form: the columns the form does not ask, each of which can be asked or given a fixed value saved with every response. Drag an entry by its grip, or press Alt + ↑ / ↓, to reorder it. Add offers a column to ask, a section, a consent or a hidden field.

  • The middle shows a live preview in the language and layout being edited, with the rules applied. Nothing is sent. Show as closed previews the closed message.

  • The right pane holds the selected item's properties, including a question's conditions.

Changes stay in the builder until Save (or Ctrl/Cmd + S). Closing with unsaved changes asks whether to discard them. Share form publishes the saved form. On phones the builder fills the screen, with Questions, Preview and Properties tabs. editButton: false hides Edit form above the form.

Columns a form may ask

A form never asks a column flagged form: false, readonly, readOnly, editable: false, computed, system or hidden, computed columns (accessorFn), or the metadata ids tables commonly carry (id, _id, uuid, createdAt, updatedBy… in camel or snake case). form: true opts a column in. When the host declares the table's create form (getFormConfig), a form asks only that form's fields, without its hidden or disabled ones. These limits apply to the default questions of a Form view without its own questions, to the Add menu, to saved questions and to buildPublicFormSnapshot.

A saved Form view looks like this:

request-view.ts
export const requestFormView = {
  id: "request",
  name: "Request",
  config: {
    displayMode: "form",
    form: {
      title: "Project request",
      description: "Tell us about the project; we reply within two days.",
      questions: [
        { id: "name", columnId: "name", label: "Project name", required: true },
        { id: "category", columnId: "category", required: true },
        { id: "price", columnId: "price", label: "Budget", help: "In euros, excluding tax." },
        { id: "dueDate", columnId: "dueDate", label: "Wanted by" },
      ],
      hiddenValues: { status: "Draft" },
      submitLabel: "Send request",
      successMessage: "Thank you! Your request is in the Draft column.",
    },
  },
};

Questions and answers

Each question uses the table's own controls, chosen from the column type:

Column typeControlAnswer
text, stringText fieldText
codeMulti-line textText
numberText field, shown with the column's numberFormat once leftNumber (a comma is accepted as the decimal separator)
dateCalendar in a popover, in the form's language, starting on the locale's first day of the weekYYYY-MM-DD
select, tagThe table's dropdown; options show as tags when the column uses displayVariant: "tag", colored per coloredTagsThe option's value
multiSelectList of checkboxesArray of option values
booleanSwitchtrue or false
url, imageText fieldA full http:// or https:// address
locationThe table's location editorA place; see location columns

JSON, custom and dynamicType columns, and computed columns (accessorFn), have no form control: the settings list them under "Not available in forms". Selection and actions columns are never offered.

Answers are checked in the browser against the required flags and the column types (numbers, valid dates, web addresses, known options). Errors show under their question, linked with aria-describedby and aria-invalid; a summary ("2 answers need attention.") is announced, and focus moves to the first invalid question.

The record passed to actions.create holds the fixed values, then the answers, keyed by column id. Answers to questions hidden by a rule are left out, as are empty answers; a yes/no question always sends true or false. When create fails, its fieldErrors show on their questions and its error in the summary. After a success, the table refreshes its rows and the form shows the success message.

Conditional questions

Questions can appear, disappear or become required from earlier answers. In the form builder, select a question: its conditions are edited in its properties, under Conditions. Choose Add a condition:

  1. Pick what the rule does: Show this question when…, Hide this question when… or Require this question when….

  2. Fill a condition card: the Question to read, the Comparison and the Value. Comparisons use short labels such as "is", "is not", "contains", "before", "between" or "in the last". Values use the table's controls: choice questions offer their options (as tags for tag columns), lists offer checkboxes, dates a calendar, ranges From and To, relative dates a number of days. On a wide card the three sit on one line; on a narrow one they stack.

  3. For several conditions, choose Add condition, then set Match with the All / Any control: all of the conditions (AND) or any of them (OR). Add group adds one nested group, shown as an indented card with its own All/Any control, for example "Category is Hardware and (Budget > 1000 or Wanted by is in the next 7 days)".

Conditions are saved with the rest of the form when you choose Save in the builder.

Each rule is summarised in the question's properties, such as "Shown when Category is Hardware and Budget > 1000". A condition that cannot work shows its problem on its card, for example "This question is no longer asked." or "This comparison does not fit the question.". Rules are saved with the view in form.rules, like the other settings. Record create, edit and bulk forms use the same rules in code; see Conditional fields.

A rule is { id, when, then }. when is a group { join: "and" | "or", items } whose items are conditions { fieldId, operator, value? } or nested groups; conditions read question ids. then is { action, questionIds }, where questionIds lists question or section ids:

request-rules.ts
import type { FormRule } from "@/components/ui/yayaw-table/utils/form-conditions";
// Vue: "@/components/ui/yayaw-table-vue/form-conditions"

const categoryIs = (value: string) => ({ fieldId: "category", operator: "is" as const, value });

export const requestRules: FormRule[] = [
  {
    id: "hardware-serial",
    when: { join: "and", items: [categoryIs("Hardware")] },
    then: { action: "show", questionIds: ["serialNumber"] },
  },
  {
    id: "hardware-budget",
    when: { join: "and", items: [categoryIs("Hardware")] },
    then: { action: "require", questionIds: ["price"] },
  },
  {
    id: "other-details",
    when: { join: "and", items: [categoryIs("Other")] },
    then: { action: "show", questionIds: ["details"] },
  },
];

Comparisons

The comparisons offered depend on the type of the question read:

Question typeComparisons (operator)Value
Text, multi-line text, web addressis (is), is not (isNot), contains (contains), does not contain (notContains), starts with (startsWith)Text
Number= (eq), ≠ (neq), < (lt), ≤ (lte), > (gt), ≥ (gte), is between (between)Number, or [from, to] for between
Dateis on (on), is before (before), is after (after), is between (between), is in the last … days (inLast), is in the next … days (inNext)YYYY-MM-DD, [from, to], or a number of days
Single choice, tagis (is), is not (isNot), is any of (isAnyOf), is none of (isNoneOf)An option value, or a list of them
Multiple choicecontains any of (containsAny), contains all of (containsAll), contains none of (containsNone)A list of option values
Yes/nois checked (isChecked), is unchecked (isUnchecked)None

Every type but yes/no also has is empty (isEmpty) and is not empty (isNotEmpty). Text is compared trimmed and without regard to case, dates by day, and a range may leave one end open (null).

How rules combine

  • A question that some Show rule targets stays hidden until one of those rules matches.

  • Hide wins over Show. Hiding a section hides its questions.

  • Require makes a question mandatory while it is shown: its label gets the asterisk and the control required and aria-required.

  • A set action ({ action: "set", questionIds, value }) writes a value into a shown question. It is available in JSON and code, not in the settings panel.

  • A hidden answer counts as empty for every other condition, so chains settle: when A shows B and B shows C, changing A so that B hides also hides C.

Rules that cannot act are dropped when the form loads instead of breaking it: a condition on a question that is no longer asked, a comparison that does not fit the question type, a missing value or unknown option, an empty group, a rule without target, a question depending on itself, or rules that depend on each other in a loop.

Hidden questions

A hidden question is neither checked nor sent. It is skipped by validation, even when it is required, and its answer is removed from the response, including an answer typed before the question was hidden. The record passed to actions.create therefore never holds answers to hidden questions.

Public forms apply the same rules on the server: acceptPublicFormResponse evaluates the snapshot's rules against the submitted answers, ignores the answers of hidden questions, requires the shown required ones and applies set values. A crafted request cannot fill a question the rules hide.

The engine has no UI dependency and can run anywhere: evaluateForm(rules, values, fields) returns the visible and required ids, validateRules lists problems with their codes (missingField, unknownField, operator, missingValue, unknownOption, emptyGroup, noTargets, unknownTarget, selfReference, cycle, laterQuestion), and normalizeRules returns the rules that can act with the dropped ones and their reason. They are exported by utils/form-conditions in React and form-conditions in Vue.

Step by step

Under Form settings › Layout, turn on Step by step to ask one question at a time (layout: "steps"). Turn on Review answers before sending (review: true) to end with a summary of the answers, each with a Change button that returns to its step.

To ask several questions per step, group them with Add section. Each section is one step, with its own title and description; questions placed before the first section form the first step. Sections can be moved, edited and removed like questions; removing one also removes the rules left without a target. In the page layout, sections show as headings.

guided-request-view.ts
export const guidedRequestView = {
  id: "guided-request",
  name: "Guided request",
  config: {
    displayMode: "form",
    form: {
      title: "Guided project request",
      layout: "steps",
      review: true,
      questions: [
        { id: "about", kind: "section", title: "About the project" },
        { id: "name", columnId: "name", label: "Project name", required: true },
        { id: "category", columnId: "category", required: true },
        { id: "details", columnId: "details", label: "Tell us more" },
        { id: "planning", kind: "section", title: "Budget and planning" },
        { id: "price", columnId: "price", label: "Budget" },
        { id: "serialNumber", columnId: "serialNumber", label: "Serial number" },
        { id: "dueDate", columnId: "dueDate", label: "Wanted by" },
      ],
      rules: requestRules,
    },
  },
};

In the steps layout:

  • Progress shows "Step 2 of 5" above the questions.

  • Next checks the questions of the step and focuses the first invalid one; Enter in a single-line field also goes to Next. Back returns to the previous step.

  • Skip appears when no shown question of the step is required.

  • Steps whose questions are all hidden by rules are skipped, and a step appears as soon as a rule shows it. The progress counts only the steps that are shown.

  • If sending fails, the form goes back to the first step that has an error.

  • In this layout, Require rules and set actions may only read earlier questions (problem code laterQuestion); Show and Hide rules may read any question.

Answers can survive a reload. YayawTableForm takes draftStorageKey to keep the answers and the current step in the browser's localStorage under that key until the response is sent. For your own storage, control them instead: value and onValueChange for the answers, step and onStepChange for the step (a question or section id, "start" for the questions before the first section, or "review"); Vue uses v-model:value and v-model:step. readFormProgress(key) and writeFormProgress(key, progress) read and write the stored form. Avoid draftStorageKey for sensitive answers on shared devices.

Languages

A form can be written in several languages. Every form text is a FormText: a plain string, as before, or one string per language:

localized-request-view.ts
export const localizedRequestForm = {
  locales: ["en", "fr"],
  defaultLocale: "en",
  title: { en: "Project request", fr: "Demande de projet" },
  questions: [
    { id: "name", columnId: "name", label: { en: "Project name", fr: "Nom du projet" }, required: true },
  ],
  submitLabel: { en: "Send request", fr: "Envoyer la demande" },
};

The form shows each text in its locale: the exact locale (fr-CA), then its language (fr), then the form's defaultLocale, then the text's own default (the column name, a built-in label), else the first version available. A plain string is the same in every language, so forms saved before keep working unchanged. table.form.locales lists the host's languages, which every form offers; defaultLocale defaults to the first of locales.

In the builder, the Editing switcher picks the language being written, Add language adds one and Default language sets defaultLocale. Each text that other languages have but the one being edited lacks shows a Missing translation badge; untranslated texts fall back as above. When a form has several languages, the Form view shows a Language switcher to preview each of them. YayawTableForm shows the language of its locale prop.

A consent is a checkbox bound to no column, for example to accept a privacy policy (GDPR):

{
  id: "privacy",
  kind: "consent",
  text: { en: "I agree to the processing of my answers as described in the {link}." },
  link: { label: { en: "privacy policy" }, href: "/privacy" },
  version: "2026-09",
}
  • text is the statement; {link} marks where the link goes. Without {link}, the link follows the text in parentheses. Unset, it reads "I agree to the processing of my answers."

  • link is { label?, href? }: href is an https:// address or a path of your site; without it the label shows unlinked.

  • version records which terms were accepted (default "1").

A consent is always required: an unchecked box blocks the response, and rules cannot hide it. In the steps layout it shows where it is placed, or on the last step. It is never written to a column. Accepted consents reach your server as metadata.consents: id, version, the statement as shown in the response's language, href, locale and acceptedAt, set by your server.

Hidden fields

A hidden field is never shown. It reads a value from the page with each response:

SourceValue
{ type: "urlParam", name }A URL parameter, such as utm_source
{ type: "pageUrl" }The page address
{ type: "referrer" }The referring page
{ type: "locale" }The form's language
{ type: "static", value }A fixed text
{ id: "campaign", kind: "hidden", source: { type: "urlParam", name: "utm_campaign" }, columnId: "campaign" }

With columnId, a column the form does not ask, the value is written to that column and checked like an answer of its type. Without it, the value is kept in metadata.context under the field's id. Hidden field values come from the browser and are untrusted: the server accepts only the sources of the snapshot, as text capped at 500 characters. Page and referrer addresses must be http(s) addresses; they are kept without their query and fragment, and dropped when longer than 2,048 characters. A fixed text always comes from the snapshot, never from the browser.

Server contract

YayawTableForm calls onSubmit(values, meta). values is the record, keyed by column id. meta holds:

  • context: your context prop, unchanged;

  • consents: the consents checked, by consent id (true);

  • fields: the hidden field values read from the page, by field id (untrusted);

  • locale: the language the form was shown in;

  • metadata: the response's metadata as the browser sees it.

For a public form, send consents, fields and locale to your server with the values, and pass them to acceptPublicFormResponse(snapshot, values, { consents, fields, locale, acceptedAt, translate }). acceptedAt (a Date or a string) is stamped on each accepted consent; translate passes the label overrides your page used (form.<key>), so built-in statements are recorded as shown. An accepted response returns { ok: true, values, metadata }: write values to the table and keep metadata (consents, context) with the response. withFormServerContext(accepted, { pageId, revision, formToken }) adds what your server knows to metadata.server; keys are word characters, values text, numbers or yes/no. Browser values never reach metadata.server.

Standalone form

YayawTableForm renders the same form outside a table. It imports no nuqs, jotai, TanStack Query or table provider, so it can be mounted on a public page without the table's state. React: @/components/ui/yayaw-table/form/yayaw-table-form. Vue: @/components/ui/yayaw-table-vue/form/YayawTableForm.vue.

PropTypeDescription
columnsFormColumn[]Table columns; only the asked ones are needed, such as snapshot.columns.
formFormViewSettingsSettings, for example formSettingsFromView(view) or snapshot.form.
onSubmit(values, meta) => FormSubmitResultCreates the record. meta holds context, consents, fields, locale and metadata; see Server contract. Resolve { ok: true }, or { errors, message } with messages keyed by column id. It may return a promise.
validate(values) => Record<string, string> | undefinedExtra checks after the built-in ones; may return a promise.
onSuccess({ values, redirectUrl }) => voidCalled after a success. Your application decides whether to follow redirectUrl.
translationsRecord<string, string>Label overrides, as { submit } or { "form.submit" }.
translate(key, fallback) => stringLabel overrides as a function; wins over translations.
localestringThe reader's language: form texts are shown in it (see Languages); built-in labels are English, or French for fr* locales. Default "en".
contextRecord<string, unknown>Host data passed to onSubmit unchanged (campaign, referrer, captcha token).
extraFieldsReactNodeReact only: host fields rendered before the submit button, such as a honeypot or a captcha. Vue uses the extra-fields slot.
closedbooleanShow "This form is no longer accepting responses." instead of the questions.
value, onValueChangeFormDraft, (draft) => voidControlled answers, by column id. Vue: v-model:value.
step, onStepChangestring, (step) => voidControlled current step of the steps layout. Vue: v-model:step.
draftStorageKeystringKeep the answers and the step in localStorage under this key until the response is sent.
import {
  acceptPublicFormResponse,
  formSettingsFromView,
  publicFormSnapshot,
} from "@/components/ui/yayaw-table/utils/form-view";
// Vue: "@/components/ui/yayaw-table-vue/form-view"

formSettingsFromView(view, defaults?) reads the form settings of a saved view. The model in utils/form-view (Vue: form-view) has no UI or state dependency, so your server can import it too.

The table cannot serve pages. To publish a form, your application provides actions.formLinks; the table then shows Share form above the form of a saved view. An unsaved view shows "Save this view to share its form." instead.

formLinks?: {
  status: (viewId: string) => Promise<{ published: boolean; url?: string; acceptsResponses?: boolean } | null>;
  /** Build the snapshot on your server from the saved view; ignore the deprecated second argument. */
  publish: (viewId: string, snapshot?: PublicFormSnapshot) => Promise<{ url: string }>;
  unpublish: (viewId: string) => Promise<void>;
  setAcceptingResponses?: (viewId: string, accepting: boolean) => Promise<void>;
};

Share form opens a popover, or a drawer on phones:

  • Publish to the web calls publish(viewId) and shows the returned link, read-only, with Copy link and Open. Turning it off calls unpublish(viewId).

  • Accept responses calls setAcceptingResponses(viewId, accepting). It only appears when you provide that function. An unset acceptsResponses in status means the link accepts responses.

  • Update public form publishes the current settings again. Republishing is manual, so edits in progress never reach the public link until someone chooses to update it.

status(viewId) is read when the popover opens.

Build the snapshot on your server

publish(viewId) asks your server to publish the saved view. Load that view from your own storage and build the public copy with buildPublicFormSnapshot, from your own column definitions:

const snapshot = buildPublicFormSnapshot({
  view, // the saved view, as your server stores it
  columns, // your server's column definitions
  allowedColumnIds: ["name", "category", "price", "dueDate", "status"],
});

It keeps only the columns that exist, that a form may ask and that are listed in allowedColumnIds (unset allows every such column). A fixed value is kept only when it fits its column: a known option, a number, a date, a web address or a yes/no value. Questions, sections, the rules that can act and the layout are included. An optional defaults passes your table-level table.form settings.

The table still passes a snapshot built in the browser as a second argument, for hosts written before this change. It is deprecated: anything in the browser can be altered, so ignore it and build the snapshot from the saved view. A host that stored the browser's snapshot should switch to buildPublicFormSnapshot and republish its forms.

The snapshot holds only what a public page needs:

interface PublicFormSnapshot {
  version: 1;
  viewId?: string;
  /** Send to the browser with `columns`: questions, sections, rules and layout. */
  form: FormViewSettings;
  /** Only the asked columns: id, header, type, options, and displayVariant, coloredTags, numberFormat when set. */
  columns: FormColumn[];
  /** Keep on the server: acceptPublicFormResponse adds them to each response. */
  hiddenValues: Record<string, FormHiddenValue>;
  /** Keep on the server: hidden fields with their sources, columns and fixed texts. */
  hiddenFields?: FormHiddenField[];
  /** Keep on the server: the columns hidden fields write. */
  hiddenColumns?: FormColumn[];
}

It contains no rows, filters, other columns or table settings. form carries no fixed values, and its hidden fields keep only their sources: their columns and fixed texts stay in hiddenFields.

Your application's responsibilities

A public form writes into your database on behalf of anonymous visitors. The table gives you the snapshot and the checks; the rest belongs to your server:

  1. Authorize publishing. Only people allowed to create records in the table (and to share it) may call publish, unpublish and setAcceptingResponses. Check it on the server.

  2. Build the snapshot on the server with buildPublicFormSnapshot({ view, columns, allowedColumnIds }), from the view you stored and your own column definitions, limited to the columns public visitors may fill. Ignore the snapshot the browser passes, so a crafted request cannot add columns, questions or fixed values you do not allow.

  3. Store the snapshot with the view, the table and a random public token. Serve the form on a route keyed by that token rather than by the view id.

  4. Serve a public route that renders YayawTableForm with snapshot.form and snapshot.columns only. Never send hiddenValues, hiddenFields, hiddenColumns, rows or other columns to the browser.

  5. Re-validate every response with acceptPublicFormResponse(snapshot, input, { consents, fields, locale, acceptedAt }). It keeps only the asked columns, applies the rules (answers to hidden questions are dropped, shown required questions are checked), checks the answers and consents again, reads the hidden fields and adds the fixed values. Refuse responses when the form is unpublished or closed.

  6. Write with your own server code, scoped to that one table, and never trust client values for columns the form does not ask.

  7. Limit abuse: rate-limit per address and per form, cap the body size, and add a honeypot or a captcha through extraFields and context.

  8. Republish after schema changes. The snapshot is a copy: renamed or removed columns, new options and rule changes reach the public form only when it is updated.

Redirect after a response

redirectUrl is a setting, not a navigation. onSuccess receives it, and your public page decides whether to follow it. Check it against your own allowed destinations before redirecting.

Next.js example

The server keeps the snapshots; db stands for your data layer.

app/forms/form-links.ts
"use server";

import { randomBytes } from "node:crypto";
import { buildPublicFormSnapshot } from "@/components/ui/yayaw-table/utils/form-view";
import { requestColumns } from "@/lib/request-columns";
import { requireEditor } from "@/lib/auth";
import { db } from "@/lib/db";

/** Columns anonymous visitors may fill in this table. */
const PUBLIC_COLUMNS = ["name", "category", "price", "dueDate", "serialNumber", "details", "status"];

// The table also passes a browser-built snapshot as a deprecated second argument: not read.
export async function publishForm(viewId: string) {
  await requireEditor("requests");
  // The saved view as stored by your views API, not as sent by the browser.
  const view = await db.tableViews.find("requests", viewId);
  if (!view) {
    throw new Error("Save this view before sharing it.");
  }
  const snapshot = buildPublicFormSnapshot({
    view,
    columns: requestColumns,
    allowedColumnIds: PUBLIC_COLUMNS,
  });
  const existing = await db.publicForms.findByView(viewId);
  const token = existing?.token ?? randomBytes(16).toString("base64url");
  await db.publicForms.upsert({ viewId, token, snapshot });
  return { url: `${process.env.APP_URL}/f/${token}` };
}

export async function formStatus(viewId: string) {
  await requireEditor("requests");
  const form = await db.publicForms.findByView(viewId);
  return form
    ? { published: true, url: `${process.env.APP_URL}/f/${form.token}`, acceptsResponses: form.accepting }
    : { published: false };
}

export async function unpublishForm(viewId: string) {
  await requireEditor("requests");
  await db.publicForms.deleteByView(viewId);
}

export async function setAccepting(viewId: string, accepting: boolean) {
  await requireEditor("requests");
  await db.publicForms.update(viewId, { accepting });
}

The table passes them as actions.formLinks:

app/requests/actions.ts
formLinks: {
  status: formStatus,
  publish: publishForm,
  unpublish: unpublishForm,
  setAcceptingResponses: setAccepting,
},

The public page loads the snapshot on the server and sends only the form and its columns:

app/f/[token]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PublicForm } from "./public-form";

export default async function Page({ params }: { params: Promise<{ token: string }> }) {
  const { token } = await params;
  const published = await db.publicForms.findByToken(token);
  if (!published) {
    notFound();
  }
  const { form, columns } = published.snapshot;
  return (
    <main className="mx-auto max-w-3xl px-4 py-10">
      <PublicForm closed={!published.accepting} columns={columns} form={form} token={token} />
    </main>
  );
}
app/f/[token]/public-form.tsx
"use client";

import { useState } from "react";
import { YayawTableForm } from "@/components/ui/yayaw-table/form/yayaw-table-form";
import type { FormColumn, FormViewSettings } from "@/components/ui/yayaw-table/utils/form-view";
import { submitPublicForm } from "./submit";

export function PublicForm(props: {
  token: string;
  form: FormViewSettings;
  columns: FormColumn[];
  closed: boolean;
}) {
  const [website, setWebsite] = useState("");
  return (
    <YayawTableForm
      closed={props.closed}
      columns={props.columns}
      context={{ website }}
      extraFields={
        // Honeypot: not displayed to people, often filled by bots.
        <input
          autoComplete="off"
          className="hidden"
          name="website"
          onChange={(event) => setWebsite(event.target.value)}
          tabIndex={-1}
          value={website}
        />
      }
      form={props.form}
      onSubmit={(values, { consents, context, fields, locale }) =>
        submitPublicForm(props.token, values, { consents, context, fields, locale })
      }
    />
  );
}

The server action re-validates the response against the stored snapshot, rules included, before writing it:

app/f/[token]/submit.ts
"use server";

import { headers } from "next/headers";
import {
  acceptPublicFormResponse,
  type FormSubmitResult,
  formLabel,
} from "@/components/ui/yayaw-table/utils/form-view";
import { db } from "@/lib/db";
import { rateLimit } from "@/lib/rate-limit";

export async function submitPublicForm(
  token: string,
  input: unknown,
  meta: { consents?: unknown; context?: Record<string, unknown>; fields?: unknown; locale?: unknown }
): Promise<FormSubmitResult> {
  const address = (await headers()).get("x-forwarded-for") ?? "unknown";
  if (!(await rateLimit(`form:${token}:${address}`, { limit: 10, windowSeconds: 600 }))) {
    return { ok: false, message: "Too many responses. Try again later." };
  }
  const published = await db.publicForms.findByToken(token);
  if (!published?.accepting) {
    return { ok: false, message: formLabel("closed", "en") };
  }
  if (meta.context?.website) {
    return { ok: true }; // Honeypot filled: pretend it worked, store nothing.
  }
  const checked = acceptPublicFormResponse(published.snapshot, input, {
    consents: meta.consents,
    fields: meta.fields,
    locale: meta.locale,
    acceptedAt: new Date(),
  });
  if (!checked.ok) {
    const errors = Object.fromEntries(
      Object.entries(checked.errors).map(([columnId, code]) => [columnId, formLabel(code, "en")])
    );
    return { ok: false, errors };
  }
  // Only the shown questions' answers and the fixed values, written by your own code.
  const record = await db.requests.create(checked.values);
  // Consents and unbound hidden fields are never columns: keep them with the response.
  await db.responseMetadata.create({ recordId: record.id, metadata: checked.metadata });
  return { ok: true };
}

formLabel(code, locale) turns the error codes of acceptPublicFormResponse (errorRequired, errorNumber, errorDate, errorUrl, errorOption, errorLocation, and errorConsent keyed by consent id) into the built-in English or French messages.

Nuxt example

The same flow with a Nuxt server route. Publishing works as in the Next.js example, behind your own API routes.

pages/f/[token].vue
<script setup lang="ts">
import YayawTableForm from "@/components/ui/yayaw-table-vue/form/YayawTableForm.vue";
import type { FormSubmitMeta } from "@/components/ui/yayaw-table-vue/form-view";

const token = useRoute().params.token as string;
const { data } = await useFetch(`/api/forms/${token}`); // { form, columns, closed }
const website = ref("");

const submit = (values: Record<string, unknown>, meta: FormSubmitMeta) =>
  $fetch(`/api/forms/${token}`, {
    method: "POST",
    body: { values, consents: meta.consents, fields: meta.fields, locale: meta.locale, website: website.value },
  });
</script>

<template>
  <YayawTableForm
    v-if="data"
    :closed="data.closed"
    :columns="data.columns"
    :form="data.form"
    :on-submit="submit"
  >
    <template #extra-fields>
      <input v-model="website" autocomplete="off" class="hidden" name="website" tabindex="-1" />
    </template>
  </YayawTableForm>
</template>
server/api/forms/[token].post.ts
import { acceptPublicFormResponse, formLabel } from "@/components/ui/yayaw-table-vue/form-view";
import { db } from "~/server/utils/db";

export default defineEventHandler(async (event) => {
  const token = getRouterParam(event, "token") ?? "";
  const published = await db.publicForms.findByToken(token);
  if (!published?.accepting) {
    return { ok: false, message: formLabel("closed", "en") };
  }
  const body = await readBody(event);
  if (body?.website) {
    return { ok: true };
  }
  const checked = acceptPublicFormResponse(published.snapshot, body?.values, {
    consents: body?.consents,
    fields: body?.fields,
    locale: body?.locale,
    acceptedAt: new Date(),
  });
  if (!checked.ok) {
    const errors = Object.fromEntries(
      Object.entries(checked.errors).map(([columnId, code]) => [columnId, formLabel(code, "en")])
    );
    return { ok: false, errors };
  }
  const record = await db.requests.create(checked.values);
  await db.responseMetadata.create({ recordId: record.id, metadata: checked.metadata });
  return { ok: true };
});

The GET route returns { form: snapshot.form, columns: snapshot.columns, closed: !accepting } and nothing else. Add rate limiting in a server middleware.

Translations

The Form view has built-in English and French labels; French is used when the locale starts with fr. Override any of them with flat form.<key> keys in the table's translations, in React and Vue, for example "form.submit": "Send". YayawTableForm takes the same keys, with or without the form. prefix.

  • Form: submit, submitting, success, successTitle, another, required, choose, pickDate, clearDate, closed, noQuestions, submitError.

  • Errors: errorRequired, errorNumber, errorDate, errorUrl, errorOption, errorLocation, errorConsent, and errorSummary with {count} in a plural form: {count, plural, one {1 answer needs attention.} other {{count} answers need attention.}}.

  • Settings: settings, settingsTitle, title, description, questions, ask, moveUp, moveDown, editQuestion (these four with {label}), label, help, placeholder, requiredToggle, hidden, hiddenHint, hiddenValue (with {label}), none, excluded (with {columns}), afterSubmit, submitLabel, successMessage, allowAnother, redirectUrl, redirectHint, reset.

  • Languages: editingLanguage, previewLanguage, addLanguage, defaultLanguage, languages, translationHint (with {language}), missingTranslation, closedMessage, optionLabels, optionLabel (with {option}).

  • Consents and hidden fields: addConsent, consent, consentStatement, consentText, consentTextLink and consentLinkHint (with {link}), consentLinkLabel, linkText, linkUrl, consentVersion, consentNote, newTab, addHiddenField, hiddenFields, hiddenFieldsHint, hiddenField, hiddenSource, sourceUrlParam, sourcePageUrl, sourceReferrer, sourceLocale, sourceStatic, paramName, staticValue, saveIn, responseDetails.

  • Form builder: editForm, builderDescription, builderOutline, builderPreview, builderProperties, builderPreviewNote, builderShowClosed, save, unsavedChanges, builderSaved, discardTitle, discardDescription, discard, keepEditing, saveAndClose, addItem, addQuestion, noColumnsLeft, notInForm, notInFormHint, askQuestion, removeQuestion, remove, fixedValue, reorderHint, builderMoved (with {label}, {position} and {count}), builderAdded and builderRemoved (with {label}), columnOf (with {label}), untitledForm, questionKind, hasConditions, inTheView, editButtonSetting, editButtonHint, resetHint, saveToPublish, summaryHint, summaryReview, and the plural counts summaryQuestions, summarySections, summaryConsents, summaryHiddenFields.

  • Sharing: share, publish, publishHint, publicLink, copy, copied, open, acceptResponses, republish, republishHint, saveFirst, shareError, loading, close.

  • Layout and steps: layout, layoutPage, layoutSteps, review, next, back, skip, stepProgress (with {current} and {total}), reviewTitle, reviewChange (with {label}), noAnswer, yes, no.

  • Sections: addSection, section, sectionTitle, sectionDescription, untitledSection, editSection, removeSection (these two with {label}).

  • Rule editor: conditions, addRule, ruleAction, actionShow, actionHide, actionRequire, ruleJoin, joinAnd, joinOr, ruleField, ruleOperator, ruleValue, ruleFrom, ruleTo, ruleDays, chooseQuestion, addCondition, addGroup, ruleGroup, removeCondition, removeGroup, removeRule, editConditions, conditionsTitle (with {label}), conditionsDescription, noConditions, conditionsProblem, done, joinAll, joinAny, ruleDaysUnit.

  • Rule problems: issueMissingField, issueUnknownField, issueOperator, issueMissingValue, issueUnknownOption, issueEmptyGroup, issueCycle, issueLaterQuestion, issueSelfReference, issueTarget.

  • Rule summaries: thenShow, thenHide, thenRequire (with {condition}), thenSet (with {value} and {condition}), ruleAnd, ruleOr, ruleCustom, and one phrase per comparison with {field} and {value}: opIs, opIsNot, opContains, opNotContains, opStartsWith, opEq, opNeq, opLt, opLte, opGt, opGte, opBetween (with {from} and {to} instead of {value}), opOn, opBefore, opAfter, opInLast, opInNext, opIsAnyOf, opIsNoneOf, opContainsAny, opContainsAll, opContainsNone, opIsChecked, opIsUnchecked, opIsEmpty, opIsNotEmpty.

  • Comparison labels: the comparison menu shows short, complete labels, one per comparison: cmpIs, cmpIsNot, cmpContains, cmpNotContains, cmpStartsWith, cmpEq, cmpNeq, cmpLt, cmpLte, cmpGt, cmpGte, cmpBetween, cmpOn, cmpBefore, cmpAfter, cmpInLast, cmpInNext, cmpIsAnyOf, cmpIsNoneOf, cmpContainsAny, cmpContainsAll, cmpContainsNone, cmpIsChecked, cmpIsUnchecked, cmpIsEmpty, cmpIsNotEmpty, for example "is", "before" or "in the last". The op* phrases above remain for the summaries.

The mode's name in the display mode picker is views.display.form in React and display.form in Vue.

TableDisplayMode gains "form"

TableDisplayMode now includes "form". If your code keeps an exhaustive Record<TableDisplayMode, …> map, such as icons or labels per mode, add a form entry so it keeps compiling:

const modeLabels: Record<TableDisplayMode, string> = {
  // ...
  form: "Form",
};