@wral/form
v0.1.0
Published
Lit web component for standalone form pages on WRAL sites: renders an api-forms document as a plain accessible form, submits it anonymously (e-mail only, no My WRAL account), and shows the confirmation. First page: Brain Game Registration.
Keywords
Readme
<wral-form>
Lit web component for standalone page forms on WRAL sites: one
api-forms document rendered as a
plain, accessible form and submitted anonymously, with no My WRAL account.
The first page is Brain Game Registration, which replaces the legacy
Diesel form at legacy.wral.com/wral-tv/page/2503123/ and keeps its seven
fields.
It is deliberately not Report It. Report It (wral-report-it) is the guided
newsroom intake with sign-in, media and a Studio queue. A page form is a
registration, sign-up or contact form that lives on its own page, is
e-mailed to one team, and must never change how Report It looks or works.
api-forms guarantees that with the standalone flag (below).
Usage
From the CDN (self-contained, registers the element):
<script type="module"
src="https://cdn.wral.com/@wral/form/v0/define.standalone.js"></script>
<wral-form form-id="braingame" api="https://api.wral.com/forms" noun="registration"></wral-form>From npm:
import '@wral/form/define'; // registers wral-form
// or
import { WralForm } from '@wral/form';Pre-size it in the host page's <head> to avoid layout shift:
<style>wral-form { display: block; min-height: 60vh; }</style>.
Attributes and properties
| Name | Kind | Default | Purpose |
| --- | --- | --- | --- |
| form-id | attribute | — | The api-forms document to render and submit (its formId) |
| api | attribute | https://api.wral.com/forms | api-forms base URL (dev: https://api.wral.com/dev/forms) |
| noun | attribute | form | What one submission is called in copy: "registration", "entry", "request" |
| submit-label | attribute | Submit | The submit button's text |
| save-content | attribute | false | true asks api-forms to write a content-stage record as well as e-mailing. Leave it off for page forms: a record would appear in the Report It queue |
| formsClient | property | — | Any object with getForm, createAnonSession, putRequest (see src/lib/forms-client.mjs); wins over api. The demo client implements it offline |
Events: wral-form:submitted ({formId, requestId, reference}) and
wral-form:error ({formId, kind, status}), both bubbling and composed.
Styling: design tokens are read as var(--wral-…, fallback), the same
layer the Report It page inlines, with literal fallbacks, so the form renders
correctly with no token stylesheet on the page. --wral-form-max-width
overrides the column width (default 680px).
How it works
The fields come from the document. On load the component fetches
GET {api}/v1/forms/{formId} — the single-form read api-forms added for page
forms — and walks the document (src/lib/flow.mjs) exactly as the server
re-walks it at submit: each collect node is a fieldset of typed fields
(text, textarea, select, consent, datetime, url, location),
each choice node a radio group (a select past eight options) whose answer
extends the path, a gate is passed through, a redirect terminal is shown
as a notice. Labels, limits, patterns, autocomplete tokens and the
confirmation copy all come from the document, and the server validates
against the same document. A document with a media field is refused: an
anonymous, unsaved submission can carry no files, and uploads belong to
Report It.
The response also says what the stage allows. The read carries the same
anonymous: {enabled, humanCheck, saveContent} block as the listing. When
anonymous submission is off, the form's allowAnonymous is false, or a
proof-of-human provider is required (this component draws no captcha widget
yet), the page says the form is unavailable at load rather than at submit.
Client-side validation mirrors the server (src/lib/validate.mjs):
required fields with the field's label in the message, lengths from the
document, pattern on text fields, option membership on selects, consent
must be checked, a real web address, a date and time. Errors render inline
under the field, the first invalid control takes focus, and a server 400
that names a field is pointed back at that field.
Submit is api-forms' anonymous flow (DEV-1270):
- Mint a uuid
requestIdonce per submission and keep it across retries. POST /v1/anon/sessionswith{formId, requestId, saveContent: false}→ a session token, cached until shortly before it expires.PUT /v1/requests/{requestId}with the token and the keyed answers for the walked path. The response carries theRI-…reference and the confirmation copy the done view shows, with a summary of the answers.
With saveContent: false nothing is written to the content stage: the
submission is validated, rate-limited and e-mailed like a Report It
submission, and the desk e-mail is its only copy. A delivery failure comes
back as a 502 the visitor retries with the same requestId, and a retry
sends a fresh e-mail. A 401 re-mints the session once and retries; a 409
(the document changed) refetches it and asks the visitor to check their
answers; a 429 shows the server's message. "Submit another" mints a new
requestId.
The api-forms contract for page forms
A page form is an ordinary form document with standalone: true,
allowAnonymous not false, no gate node and budget.maxFiles: 0.
api-forms keeps standalone forms out of GET /v1/forms, which is everything
the Report It component knows about (entry screen, hand-offs, ?flow=
links, drafts), and serves them by GET /v1/forms/{formId} alone. Its
route may carry program and noun so the desk e-mail reads
"[Brain Game] … registration" rather than "Report It submission". See
api-forms' README, "Standalone page forms".
Adding a page form: a seed document and a route in api-forms, a Sailthru
list for the team, and a page carrying <wral-form form-id="…">. Nothing
about Report It changes.
Development
Requires Node 18+.
npm install
npm run dev # harness on http://localhost:5176
npm test # node --test over src/lib
npm run lint
npm run build # dist/index.mjs + dist/define.mjs (lit external)
# and dist/define.standalone.js (self-contained, for <script src>)The harness (index.html) mounts the component against the in-memory demo
client (src/demo/demo-client.mjs), which serves the Brain Game fixture
(src/demo/fixtures/form-braingame.json, a copy of api-forms' seed) and
logs submits to the console. ?demofail=1 makes the first send fail as a
502, ?demo401=1 expires the session once, ?anonoff=1 shows the
stage-has-anonymous-off state. ?api=<base> talks to a real stage instead,
for example a local api-forms at http://localhost:3011/forms or the dev
stage at https://api.wral.com/dev/forms.
Release
Push a vX.Y.Z tag: the pipeline tests, builds, publishes to npm and
uploads dist/ to cdn.wral.com/@wral/form/vX.Y.Z/ (immutable) plus the
rolling aliases /vX/, /vX.Y/ and /latest/ (5-minute TTL). Repo
variables: NPM_TOKEN, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
AWS_DEFAULT_REGION, S3_BUCKET=cdn.wral.com, S3_PREFIX=@wral/form,
AWS_DISTRIBUTION_ID.
Published pages pin the exact version, so a release reaches a page only once its pin is bumped and the page republished.
Pages
pages/brain-game-registration.html is the wral.com page. It pins the
component to an exact release and points api at the prod api-forms stage.
Like the Report It page, it is a publisher publication whose template is an
HTML asset in the editorial DAM (content: {},
template: {"$ref": "https://assets.wral.com/<asset id>"}).
| Page | api-forms stage | DAM asset |
| --- | --- | --- |
| www.wral.com/brain-game-registration/ (site wral.com) | prod (/forms) | not yet uploaded |
| /test/brain-game-registration/ (site staging.wral.com) | dev (/dev/forms) for rehearsal | not yet uploaded |
To publish or change a page:
- Upload the HTML as a new asset:
POST https://api.wral.com/dam/v1/asset/<new uuid>/upload-urlwithdam:write, then PUT the file withContent-Type: text/html. POST https://api.wral.com/publisher/v1/publicationswithpublisher:write:{site, path, content: {}, template: {"$ref": …}, metadata: {httpStatusCode: 200}}.- Invalidate the path on the site's CloudFront distribution (
E2YSTU8KLPZ0ZRfor wral.com,E2PUK7RNR73BP4for staging). - For the legacy path, a redirect publication:
{site: "wral.com", path: "/wral-tv/page/2503123/", content: {url, title, description}, template: {"$ref": "https://api.wral.com/template/v1/templates/generic.redirect?satisfy=1.*"}, metadata: {httpStatusCode: 301, headers: [["Location", url]]}}.
Leave the previous asset in place. Republishing with its $ref is the rollback.
Before the page works on a stage
- api-forms with the single-form read and the standalone exclusion deployed, with
ANON_SUBMIT_ENABLEDon (prod has had it since 2026-09-29). - api-content with the
standaloneflag on the form model, so the seed is accepted. - The
braingamedocument seeded into that stage's content stage (node scripts/seed-forms.mjs braingamein api-forms). - The route's Sailthru list (
reportit-braingame) with[email protected]subscribed, or the stage's list override for dev.
Until then the page says the form is temporarily unavailable.
