@wral/report-it
v0.3.2
Published
Lit web component for the Report It guided viewer-submission flow on WRAL.com: chat-style form walking, embedded My WRAL sign-in, submit-time media uploads, rendered against api-forms.
Keywords
Readme
<wral-report-it>
Lit web component for the Report It guided viewer-submission flow on WRAL.com (DEV-1099): a chat-style walk through a form's flow tree, an embedded My WRAL sign-in gate, and submit-time media uploads, rendered against api-forms.
Usage
From the CDN (self-contained, registers the elements):
<script type="module"
src="https://cdn.wral.com/@wral/report-it/v0/define.standalone.js"></script>
<wral-report-it api="https://api.wral.com/forms"></wral-report-it>From npm:
import '@wral/report-it/define'; // registers wral-report-it,
// wral-report-media, wral-code-inputPre-sizing (avoid layout shift)
Keep this first in the host page's <head>:
<style>wral-report-it { display: block; min-height: 70vh; }</style>Attributes and properties
| Name | Kind | Default | Purpose |
| --- | --- | --- | --- |
| api | attribute | https://api.wral.com/forms | api-forms base URL (dev: https://api.wral.com/dev/forms) |
| client | property | — | Full I/O surface override (API + auth); wins over api. See src/demo/demo-client.mjs for the shape |
| facts-api | attribute | — | api-user-facts v1 base on the same stage as api (prod https://api.wral.com/user-facts/v1, dev https://api.wral.com/dev/user-facts/v1). Unset, or on another stage: nothing is read from, offered to or written to My WRAL |
| facts | attribute | all | The facts Report It may offer or record, separated by spaces or commas, any case: preferred_name phone_number street_address zipcode report_it_submission. facts="" turns them all off. An unknown name is ignored and named once in the console. A page republish changes it, no release, but cached copies of the page keep the old value for hours |
| flow | attribute | — | Open one form directly by its formId (e.g. investigates). The host page's ?flow=<formId> does the same; the attribute wins |
Styling hooks: design tokens are read as var(--wral-…, fallback) with
prototype-accurate fallbacks, so the component renders correctly with no
token stylesheet on the page. --report-it-max-width overrides the
column width (default 680px).
How a submission flows
GET {api}/v1/forms(public) — one button per published form (forms withlisted: falseare served but not shown; see below).- The chosen form's tree walks client-side:
choicechips,collectcards (text/textarea/url/datetime/location/media/consent/select), thegate(My WRAL email + one-time code — skipped with a confirmation card when already signed in),redirectdead-ends, and theterminalreview screen. - Nothing uploads until the viewer confirms submit. Then:
POST /v1/uploads(Bearer) → presigned S3 PUTs with per-file progress and retry (expired presigns re-mint per file) →PUT /v1/requests/{requestId}(idempotent — retries return the same reference) → confirmation.
Hand-offs, unlisted forms and deep links (DEV-1305)
A choice option may carry form (and optionally path) instead of
next: choosing it switches to that form document and replays the
path choice steps there first. News → "5 On Your Side" lands on the
consumer form's complaint card, past its own "5 On Your Side or
Investigates?" question. The submission is made against the target
form, with the preset steps at the head of its path; a path through
a hand-off option itself is never sent (the API rejects one).
The form the viewer came from is kept as an origin (src/lib/handoff.mjs),
persisted with the draft: the transcript reads as one conversation, a
reload rebuilds it, and Back from the first card after a hand-off
returns to the choice that made it, skipping the preset steps. A
hand-off to a form that is not loaded shows the load-error notice and
stays put.
An unlisted form (listed: false, e.g. investigates) is fetched with
the rest and reachable by hand-off or deep link, just not offered on the
entry screen. ?flow=<formId> on the host page (or the flow
attribute) opens a form directly; a draft for that form (or one handed
off to or from it) resumes instead, and a draft for any other form
yields to the link.
Fields
textfields passinputModeandautocompletefrom the form document through to the input, and each collect card is a real<form autocomplete="on">, so the browser can fill name, phone, address and zip from the viewer's saved details. Nothing is prefilled from My WRAL; answers reach My WRAL only as described below. (Autofill inside shadow DOM varies by browser — verify on iOS Safari and Android Chrome.)- A
textfield'spatternis checked before submit the way the server checks it (a zip gets its own message). consentcopy comes from the field'stext. WithrequiredIf: "media"the checkbox appears only once a file is attached, is required only then, and is not sent without files.
Saving details to My WRAL (DEV-1335)
Off unless the host sets facts-api to an api-user-facts v1 base on the
same stage as api. Then a submission that passed the sign-in card
signed in (never an anonymous one, never a form without a gate) can do
two things, both as the viewer, with the ID token, for the account the
report was sent as only, and only when that is the account that passed
the gate: if another tab signs in someone else before Submit, or after a
reload the gate's email is not the signed-in token's, nothing is offered
or recorded (factsEligible in src/lib/facts-offer.mjs):
Offer to save the viewer's own details (
src/lib/user-facts.mjs). A field counts only when its key and itsautocompleteare one of these pairs, and its answer can be shaped to what the API accepts (src/lib/fact-shapes.mjs; anything else is not offered, and the report keeps it as typed):| key / autocomplete | fact | saved as | box starts | | --- | --- | --- | --- | |
name/name|preferred_name| single-spaced, at most 100 characters | checked | |phone/tel|phone_number| E.164, US and Canada only ((919) 555-0100→+19195550100) | checked | |address/street-address|street_address| one single-spaced line, 1-200 characters with a digit and a letter; never cut | checked | |zip/postal-code|zipcode| the first five digits;00000is not offered | checked |Fill, never replace. An account created on this page load gets the v0.2 step before the confirmation, opening with the reference number. Any other account sees the confirmation at once; Report It then reads
GET /user/<fact>(presence only, one uncached GET per fact) and offers, in a card under the confirmation that names the signed-in email, only what the profile lacks. Every box starts checked (since 0.3.2), and the question names exactly what one click on Save saves; the viewer unchecks what they do not want saved. Every save is create-only (If-None-Match: *): a value saved meanwhile wins and is reported as already in the account. Phone and street address are offered only whereGET /fieldsmarks themsensitive(kept out ofGET /userand its browser caches; only their owner reads them, by name). A fact under an emergency write stop inGET /fieldsis not offered. WithoutGET /fields, a new account is offered its name and zip only (the v0.2 offer). A name or zip save clears the dashboard'swral_user_factscache; nothing is kept in browser storage.List the report on the account: one
report_it_submissionitem, nonce = the requestId, value = the reference, written only while the confirmation that announces it is on screen (a new account's step holds it until Save or No thanks; a step left unanswered records nothing), with Remove it from my account. If the sign-in has ended or the device is now another account's, Remove it says so once, with no Try again. Never for Investigates or removal requests (by form, and by the routesstudio+email:investigatesandemail:expunge). A pointer only: the record'saccount.accountIdstays the authority on whose report it is.
Nothing the viewer typed is saved to My WRAL without their click. The
sign-in card no longer says contact information "fills in automatically
next time": Report It never fills a form in from My WRAL. The
.client surface gains accountId(), accountEmail(), factFields(),
readFactPresence(names, {expectSub}), saveFacts(items, {expectSub}),
recordSubmission(ids, {expectSub, stillWanted}) and removeSubmission(ids,
{expectSub}); saveUserFacts is gone. A .client without them gets no
facts paths. In the demo: ?demoexisting=1 (existing accounts, name and
zip on file), ?demofactsfail=1 (the first fact of the first save
fails), ?demofactsread=fail|slow, ?demofields=old (an API before
DEV-1335), ?demostop=<fact> (that fact under a write stop),
?demoswitch=1 (another account signs in after the submit).
Skipping the account (DEV-1270)
The sign-in card files its alternatives under a collapsed "Other ways
to send this" disclosure — deliberately present but never the path of
least resistance. It always offers the newsroom desk number. It offers
an in-form anonymous submission only when the server advertises it:
GET /v1/forms carries anonymous: {enabled, humanCheck}, the client
records it (client.anonymousCapability()), and the route renders from
that alone — never from anything baked into the page. A deployment with
enabled: false, or an older API with no block at all, leaves the route
dark and the phone number as the only anonymous option. A form
suppresses the route regardless with allowAnonymous: false.
humanCheck is null today (no proof-of-human provider is configured;
the API defends the route with per-caller limits). When a provider is
configured server-side it becomes {provider, siteKey} and the
component will render that widget in the confirmation card and pass its
token as humanToken — the server is the only party that can verify a
captcha, so the server is the only party that may ask for one.
The session itself: createAnonSession({formId, requestId}) posts to
/v1/anon/sessions unauthenticated and holds the returned token; every
auth-bearing call then presents it as the bearer in place of the My WRAL
identity until endAnonSession() — called after a successful submit and
on Start over, so a stale anonymous session can never ride along with a
signed-in one.
Choosing it takes a confirmation card stating what the viewer gives up,
with Sign in instead as the primary action. The choice is recorded
on the gate step ({kind: 'gate', anonymous: true}), so it survives a
reload; the session is minted at submit against the requestId, so an
abandoned form leaves nothing behind, and a mid-upload expiry re-mints
in place instead of opening the sign-in card.
Auth uses @wral/sdk-auth-audience directly (sendCode/verifyCode);
requireAuth/authFetch are deliberately avoided — their 401 paths
redirect to /login/, which would destroy an unsubmitted form. The
bearer on uploads and submit is the viewer's Cognito ID token (not
the access token the SDK's getAuthHeaders sends): same sub, but it
also carries the verified email, which api-forms writes onto the
record beside the account id (src/lib/id-token.mjs). A 401
mid-submit re-opens the gate in place and resumes where it parked.
Drafts
The answer stack persists to sessionStorage
(wral-report-it:draft:v1) so a reload or app switch loses nothing.
Attached files cannot persist — restored drafts show a re-attach
notice for any files that were selected. Drafts are discarded when the
form changed incompatibly, and flagged for review when the flowVersion
moved but the answers still fit.
Development
npm install
npm test # node --test over src/lib (the pure logic; the component's wiring,
# including the facts flow, is not covered: walk it in the dev harness)
npm run lint
npm run dev # vite harness at http://localhost:5173The harness runs against the live dev API by default (real My WRAL
login — your email receives a real code) or fully offline with the
in-memory demo client (any email, any six digits). Live dev pairs the
dev forms API with the dev user-facts stage; it trusts the production
Cognito pool, so use a test account there. The demo client
serves the form documents in src/demo/fixtures/ (copies of api-forms'
seeds), so hand-offs, ?flow=investigates and the files-only consent
all work offline. Append
?demofail=1 to make the second upload fail once and exercise the
retry UX. The Anonymous route checkbox (demo mode only, also
settable with ?anon=1) makes the demo client advertise anonymous
submission the way a real server does, lighting up the in-form option;
unchecked it stays dark. In live-dev mode the route follows the dev API's
own anonymous block, so a real anonymous submission can be exercised
end to end against the dev content stage.
Release
npm version patch && git push && git push --tags — the v*.*.* tag
pipeline publishes to npm and to
cdn.wral.com/@wral/report-it/{vX.Y.Z,vX.Y,vX,latest}/.
Repo variables: NPM_TOKEN, AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION,
S3_BUCKET=cdn.wral.com, S3_PREFIX=@wral/report-it,
AWS_DISTRIBUTION_ID.
The published pages pin the exact version (next section), so a release
reaches wral.com only once the pin in pages/report-it.html is bumped and
the page republished.
Pages
pages/report-it.html is the page template for both wral.com and the
prod-wired staging page. It pins the component to an exact release
(cdn.wral.com/@wral/report-it/vX.Y.Z/define.standalone.js, immutable);
bump the pin and republish to roll a release out. Each page 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/report-it/ (site wral.com) | prod (/forms) | 29d37933-02c6-45c6-9cd4-b13e2e453ef9 (v0.3.1 pin: the save card's why line and benefits link), the same asset as the staging page below. Earlier: 0931ef97-a746-4c22-8926-84ead8168fff (v0.3.0 pin, DEV-1335), 7744bd64-9379-484d-8c1b-0d02b669ac4a (v0.2.4 pin, menu fix), 4d0b598d-73e5-4449-b535-49dcc4246d55 (DEV-1109 cutover) |
| /test/report-it/ (site staging.wral.com) | prod (/forms): real desk lists and the Studio queue | 29d37933-02c6-45c6-9cd4-b13e2e453ef9 (v0.3.1 pin; otherwise byte-identical to 0931ef97…). Earlier: 0931ef97-a746-4c22-8926-84ead8168fff (v0.3.0 pin, DEV-1335; otherwise byte-identical to 7744bd64…), 7744bd64-9379-484d-8c1b-0d02b669ac4a (v0.2.4 pin, approved logo, SEO metadata and OG image, component kept under the open site menu), 4d0b598d-73e5-4449-b535-49dcc4246d55 (before the menu fix), bd4f49c7-9155-489c-af2e-34913c109052 (v0.2.4 pin, before the metadata), defda1de-ba36-4250-ac7d-7c1d85bfc49e (same page on latest), daf0a481-9b33-4222-99c3-da4111fbf009 (facts-api, 2026-10-01), 39c268dd-06eb-46f9-a6bf-74d04af45bae (DEV-1300) |
| /test/report-it-dev/ | dev (/dev/forms): the ReportIt_Dev list and the dev content stage | 3c78d978-bcd3-44f9-9906-c417db80de14, an older copy of the page that still loads the CDN latest alias (only the api attribute and its comment differ) |
To change a page:
- Upload the new HTML as a new asset. Use
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, sending{site, path, content: {}, template: {"$ref": …}, metadata: {httpStatusCode: 200}}.- Invalidate the path on the site's CloudFront distribution (
E2YSTU8KLPZ0ZRfor wral.com,E2PUK7RNR73BP4for staging), because the page is cached for about 6 h. The publisher trigger also invalidates a republished path on its own.
Leave the previous asset in place. Republishing with its $ref is the rollback.
Notes
wral-code-input+ helpers are copied fromwebapps.wral-web.audience-loginwith one fix: the original'scode-completeguard could never fire when typing left-to-right (only on paste). Worth upstreaming.- The county list for
locationfields is hardcoded (src/lib/counties.mjs) — the form model carries none; the component prefersfield.countiesif a form document ever ships it. - Required
locationfields need a county or a written place — one notch stricter than the server schema, deliberately.
