@taylordb/forms-cms
v1.0.0
Published
Schema-bound form-builder layer for @taylordb/cms: custom TinaCMS field widgets (single-use column blocks, a non-native select with per-option info), a dependency-pure schema toolkit that binds each question to a real TaylorDB column, and generic block re
Downloads
671
Readme
@taylordb/forms-cms
The schema-bound form-builder layer for @taylordb/cms: lets a
content editor assemble a TaylorDB-backed form (a @taylordb/forms-taylordb
form) in the in-page CMS drawer — without ever being able to author a form
that doesn't match the database schema.
The core idea is column-first templates: instead of generic "text question /
choice question" blocks, the Tina collection gets one block template per
bindable TaylorDB column. The template name is the column, and its "Answer
type" select is limited to the answer-types actually compatible with that
column's descriptor — the exact compatibility check defineTaylorForm enforces
(which throws on a mismatch). The editor can only ever produce valid pairings:
taylorSchema column generated block template
────────────────── ─────────────────────────────────────────────
fullName (singleLineText) → "Full name · text field (Short text / …)"
email (email) → "Email · email field (Email)"
jobs (link) → "Jobs · link field (linked records)"Three entries
| Entry | Contents | Imports |
| --- | --- | --- |
| @taylordb/forms-cms | The React field widgets (unique-blocks, field-select) as CMS plugins | react, tinacms |
| @taylordb/forms-cms/schema | The schema toolkit: compatibility, labels, Tina field/template factories | type-only tinacms, @taylordb/forms-taylordb |
| @taylordb/forms-cms/typeform | The block renderer (CmsFormSteps): one block → Typeform-UI JSX with click-to-edit wiring | react, tinacms/dist/react, @taylordb/forms-ui-typeform (optional peer, ≥2.0.5) |
The split matters: tina/config.ts is bundled by the tinacms CLI (Node) and
imported by the in-page editor (browser), so everything it needs lives in
the dependency-pure /schema entry. The widget entry is only ever loaded by the
editor at runtime, via @taylordb/cms's fields extension point. The
/typeform entry is the only one that touches a renderer package — apps that
draw the form themselves never import it.
Install
pnpm add @taylordb/forms-cms @taylordb/forms-taylordb tinacmsreact and tinacms are peer dependencies — the host app owns them.
Quickstart
1. Derive your bindable columns (app code)
Only the app knows which columns are off-limits (primary key, system columns,
completion flags), so it binds the generic scan to its real taylorSchema:
// src/shared/taylordb-schema-meta.ts
import { bindableColumns, type BindableColumn } from "@taylordb/forms-cms/schema"
import { taylorSchema } from "../server/taylordb/types"
const RESERVED = new Set(["id", "submitted", "createdAt", "updatedAt", "searchText"])
export function bindableSubmissionColumns(): BindableColumn[] {
return bindableColumns(taylorSchema.submissions, { reserved: RESERVED, allowLink: true })
}
export function bindableJobColumns(): BindableColumn[] {
return bindableColumns(taylorSchema.jobs, { reserved: new Set(["id", "submissions"]) })
}bindableColumns skips reserved + attachment columns, turns link columns
into a single linked_records binding (when allowLink), and drops anything
with no renderer-compatible answer-type.
2. Generate the collection templates (tina/config.ts)
import {
columnStepTemplates,
screenTemplate,
uniqueBlocksUi,
} from "@taylordb/forms-cms/schema"
import { bindableJobColumns, bindableSubmissionColumns } from "../src/shared/taylordb-schema-meta"
const screens = [
screenTemplate("welcome", "Welcome screen"),
screenTemplate("statement", "Statement (display-only)"),
screenTemplate("end", "End screen"),
]
const stepTemplates = columnStepTemplates({
columns: bindableSubmissionColumns(), // one template per column
linkedColumns: bindableJobColumns(), // sub-fields of link-column steps
screens,
})
// in the collection:
{
type: "object",
name: "steps",
label: "Questions & screens",
list: true,
templates: stepTemplates,
ui: uniqueBlocksUi({ reusable: ["statement"], pinFirst: "welcome", pinLast: "end" }),
}uniqueBlocksUi opts the list into the unique-blocks widget: every column
template is single-use (a used column drops out of the "+" menu),
reusable templates stay addable any number of times, and pinFirst /
pinLast are single-use and position-locked — the welcome screen is always
first, the end screen always last, and their drag handles are disabled.
3. Register the widgets on the editor
import { TaylorCMSDev } from "@taylordb/cms/dev"
<TaylorCMSDev
load={async () => ({
config: (await import("../tina/config")).default,
schemaJson: (await import("virtual:tina-schema")).default,
fields: (await import("@taylordb/forms-cms")).formFieldPlugins, // ← the widgets
})}
/>Or imperatively, given a CMS instance: registerFormFields(cms).
4. Consume the saved document
The saved doc's steps array is the single source of truth for both the form
schema (defineTaylorForm) and the rendered UI. Two package helpers matter
here:
import { normalizeBlocks } from "@taylordb/forms-cms/schema"
// Tina's live GraphQL union members carry `__typename` ("TaylordbStepsPhone_number")
// instead of `_template`; raw JSON already has `_template`. Normalize both:
const blocks = normalizeBlocks<MyBlock>(doc.steps, "TaylordbSteps")The typenamePrefix is collection + list field in PascalCase (collection
taylordb, field steps → "TaylordbSteps").
Pre-filter before building the form. defineTaylorForm throws on an
invalid column/answer-type pairing, and a doc can be briefly incomplete
mid-edit (a just-added block has no questionType yet). Filter the block list
against the same schema rules before it reaches the builder — see the
playground's taylordb-form-blocks.ts (usableTaylordbBlocks) for the
pattern.
The widgets (@taylordb/forms-cms)
Both are generic — no schema knowledge; they only shape Tina's own blocks /
select fields. A schema opts in by name via ui.component (use the factories
below rather than hand-writing the ui objects).
unique-blocks
A drop-in replacement for Tina's blocks field (it delegates rendering,
drag-drop and the "+" menu to Tina's own component) that adds:
- Single-use templates — once added, a template disappears from the "+" menu. This is what turns the list into an "add a question for an unused column" flow.
reusableTemplates— exempt names (e.g. a statement screen), addable any number of times.pinFirst/pinLast— one template each that is single-use and position-locked: always the first / last item (an order violation is corrected immediately), with its drag handle disabled and dimmed.- A helper line under the list ("… N of M fields still available").
Registered name: unique-blocks (UNIQUE_BLOCKS_COMPONENT). Configure via
uniqueBlocksUi({ reusable?, pinFirst?, pinLast? }).
field-select
A custom replacement for Tina's native <select>:
- Renders its label / description / error through Tina's
wrapFieldsWithMeta, so it sits visually flush with the built-in fields. - An optional per-option ⓘ tooltip (
optionInfo: { value: text }) — used to show a column's field type or an answer-type's description. - Optional sibling de-duplication (
dedupeSiblings: true): hides values already chosen by other items in the same list (the item's own value stays). - The dropdown expands inline (in document flow, never clipped by the drawer's scroll container) and closes on outside click / Escape.
Registered name: field-select (FIELD_SELECT_COMPONENT). Configure via
answerTypeSelect(...) / columnSelect(...).
Registration exports
import {
formFieldPlugins, // both plugins, ready for @taylordb/cms's `fields` prop
registerFormFields, // (cms) => void — imperative alternative
fieldSelectFieldPlugin, uniqueBlocksFieldPlugin, // individually
FieldSelect, UniqueBlocks, // raw components
} from "@taylordb/forms-cms"The renderer (@taylordb/forms-cms/typeform)
CmsFormSteps renders an (already gated) block list into
@taylordb/forms-ui-typeform steps, in document order — screens, questions
per answer-type, linked-records, file uploads (mediaKind: 'video' renders
the in-browser recorder). Because it maps over the same list the app turns
into sharedSteps, step ids (block._template, the column) and order stay
in lockstep by construction.
import { CmsFormSteps } from "@taylordb/forms-cms/typeform"
<Form sharedSteps={form.sharedSteps} …>
<CmsFormSteps blocks={usableBlocks(blocks)} />
</Form>Pass gated blocks. The renderer draws what it's given; run the doc through your app's
usableBlockspre-filter first (only the app knows itstaylorSchema). Mid-edit/incomplete blocks are then skipped until valid.Click-to-edit is built in: titles/descriptions carry
data-tina-field; the primary action button gets the attribute on the<button>element itself (via the renderer packages'buttonPropspass-through — needsforms-ui/forms-ui-typeform≥0.4.5/2.0.5), so it works whether the label is abuttonTextoverride or a locale default — clicking a default-labelled button opens the emptybuttonTextfield. Inputs are wrapped in an attributed element mapping toplaceholder(text-ish types) or the block itself.renderInputoverrides replace the control for individual answer-types without forking the renderer:<CmsFormSteps blocks={blocks} renderInput={{ rating: (block) => <MyRating max={block.max} /> }} />Locale note: doc copy (titles, placeholders,
buttonTextoverrides) is single-language — an override wins over the locale default in every locale. For multilingual forms, use one doc per locale and pickrelativePathby locale.
The schema toolkit (@taylordb/forms-cms/schema)
Compatibility & metadata
| Export | What it is |
| --- | --- |
| RENDERER_QUESTION_TYPES | The answer-types a Typeform-style renderer can draw (file_upload intentionally omitted until attachment upload is wired). Compatibility is intersected with this list. |
| LINKED_RECORDS_TYPE | 'linked_records' — the forced answer-type for link columns. |
| compatibleQuestionTypes(descriptor) | Renderer-supported answer-types compatible with a column descriptor (via describeQuestionDescriptorCompatibility — the same check defineTaylorForm runs). |
| bindableColumns(columns, { reserved?, allowLink? }) | Scan a table's descriptors into BindableColumn[] (see Quickstart). |
| BindableColumn | { column, descriptor, allowedTypes }. |
Labels
| Export | What it is |
| --- | --- |
| QUESTION_TYPE_LABELS / questionTypeLabel(type) | Short human titles for answer-types (phone_number → "Phone number") — used as select option labels; the slug stays the stored value. |
| QUESTION_TYPE_DESCRIPTIONS / questionTypeInfo(types) | Longer per-type descriptions — the ⓘ tooltip copy. |
| fieldTypeLabel(descriptor) | Friendly name for a column's data type ("phone field", "multi-select field"). |
| columnTitle(column) | Humanize a column slug (phoneNumber → "Phone number"). TaylorDB descriptors carry no display-name metadata, so titles are derived; acronym tails lower-case (websiteURL → "Website url"). |
Field factories (widget contract in one place)
| Export | Produces |
| --- | --- |
| answerTypeSelect({ allowedTypes, name?, label?, required? }) | The "Answer type" field-select (human-titled options + ⓘ descriptions, no dedupe — questions may share a type). |
| columnSelect({ columns, name?, label?, required?, dedupeSiblings? }) | A column picker field-select (humanized titles, field-type ⓘ, optional sibling dedupe). |
| uniqueBlocksUi({ reusable?, pinFirst?, pinLast? }) | The ui object for a blocks list using unique-blocks. |
| placeholderField, maxLengthField | Common presentation fields. |
| choiceOptionsField({ image?, description? }) | value/label(+sub-label)(+image) options list for choice questions. |
| inputShapingFields(allowedTypes) | The presentation fields relevant to a set of answer-types (placeholder, min/max, countries, choice options, …). |
Template generation
| Export | Produces |
| --- | --- |
| screenTemplate(name, label) | A display-only screen template (id/label/description/buttonText). |
| columnQuestionTemplate(col) | One column-bound question template — name = column, label = "Title · field type (answer types)", fields = common + answerTypeSelect + inputShapingFields. |
| linkedRecordsTemplate(col, linkedColumns) | A link column → repeatable linked-records step whose linkedFields items pick from the linked table's columns (columnSelect with sibling dedupe). |
| columnStepTemplates({ columns, linkedColumns?, screens? }) | The full list: screens first, then one template per column (link columns become linked-records templates). This is the one call most configs need. |
Payload normalization
| Export | What it does |
| --- | --- |
| normalizeBlocks<T>(rawSteps, typenamePrefix) | Decode Tina's live-GraphQL __typename union members into _template blocks (raw JSON passes through). Top level only — nested lists keep their __typename. |
How the guarantees compose
bindableColumnsonly surfaces columns with ≥1 compatible answer-type.columnQuestionTemplatelimits each block'squestionTypeselect to those types — the editor can't pick anything else.unique-blocksprevents binding the same column twice.- Your app-side pre-filter (see Quickstart step 4) drops incomplete/stale
blocks from saved docs before
defineTaylorForm— which remains the final, throwing authority on schema validity.
Full working example: apps/forms/playground (tina/config.ts,
src/shared/taylordb-schema-meta.ts, src/shared/taylordb-form-blocks.ts, the
/taylordb route).
