@duffcloudservices/site-forms
v0.7.8
Published
Shared <DcsForm/> runtime for DCS customer sites — renders managed form definitions from .dcs/forms/<formId>.yaml
Maintainers
Readme
@duffcloudservices/site-forms
Shared <DcsForm/> runtime for DCS customer sites. Renders a managed
form definition (created in the portal Form Manager) from a build-time
.dcs/forms/<formId>.yaml snapshot, validates user input, and posts
submissions to the public site-forms API.
This is the single import surface for managed-form runtime code on customer sites — do not redefine field components per site.
Inside the
dcs-againworkspace the package is also reachable as@duffcloudservices/site-formsviaworkspace:*(the workspace name and the published name are the same). In sibling customer-site repos install the published package from public npm — seePUBLISHING.mdfor the consumption story and registry setup.
Install
In a workspace app (inside dcs-again)
// portal/package.json
{
"dependencies": {
"@duffcloudservices/site-forms": "workspace:*"
}
}Then pnpm install from the repo root.
In a sibling customer-site repo (e.g. ktbraunlaw, kept)
pnpm add @duffcloudservices/site-formsSee PUBLISHING.md for the registry, version, and
release-workflow details.
Stylesheet
The library build extracts its CSS; dist/index.js does not inject it. Import
it once in the site's entry (or theme index.ts):
import '@duffcloudservices/site-forms/style.css'The ./style.css export resolves to dist/site-forms.css from 0.7.8 on (C-1828;
0.7.7 and earlier pointed at a dist/style.css the build never emitted).
Vite setup
Three pieces are required in the consuming site:
vite-plugin-yamlso YAML modules return parsed objects:// vite.config.ts import yaml from '@modyfi/vite-plugin-yaml' export default defineConfig({ plugins: [vue(), yaml()], })Without it, the loader falls back to parsing raw strings via
js-yaml, which works but pays the parse cost at boot.A
formsModulesloader in your site that does theimport.meta.globfrom a path Vite can resolve (see below).Env vars the source reads (see the caveat below):
| Variable | Purpose | | ----------------------- | ---------------------------------------------------- | |
VITE_DCS_PUBLIC_API| Base URL of the DCS public API (no trailing slash). | |VITE_DCS_SITE_SLUG| Default site slug used when the prop is omitted. |Caveat (C-1564, W80): the published
distdoes NOT read either variable. The library build statically erasesimport.meta.env(the dist carries{}there), so a site that installs this package from npm cannot configure it through env. Use theapi-base/site-slugprops. No fleet site needsapi-basein production: see Public API base.
Public API base
<DcsForm> resolves where it posts in this order:
- the
api-baseprop; VITE_DCS_PUBLIC_API(workspace-source consumers only, per the caveat above);- in the browser on
localhost/127.0.0.1/::1: the relative/api/v1(the dev proxy); - on any other host: the package default,
package.jsonconfig.publicApiBase(https://api.duffcloudservices.com/api/v1, the production DCS public API).
Step 4 is the documented production contract, not a fallback: no fleet
production build sets the env or passes the prop, and they all post to the
default. In a production build it is silent. In a non-production build
on a non-localhost host (a LAN or tunnel dev server, a dev-mode preview) it logs
one console.warn per page load, because there a form under test is posting to
the production API. The mode is process.env.NODE_ENV === 'production', which
survives the library build and is replaced by the consuming bundler (Vite and
VitePress define it for dependencies). Under Node (SSR / prerender) the base is
'' and nothing is posted.
Why the formsModules prop is required in real sites
<DcsForm/> ships with an internal import.meta.glob('/.dcs/forms/*.yaml')
fallback, but Vite resolves the leading / against the consumer's
Vite project root (the directory containing vite.config.ts).
On every customer-site repo today, the .dcs/forms/ directory lives
at the repo root, one or more levels above the Vite root
(typically site/ or docs/). The internal glob therefore matches
nothing and you get:
[@duffcloudservices/site-forms] No form definition found for "contact".
Expected a YAML at /.dcs/forms/contact.yaml.The fix is a one-file loader the rest of your site imports from.
Vue SPA (vite.config.ts in site/)
// site/src/dcs-forms.ts
const modules = import.meta.glob('../../.dcs/forms/*.yaml', {
eager: true,
import: 'default',
})
export const dcsFormsModules: Record<string, unknown> = modulesVitePress (vite block in docs/.vitepress/config.ts)
// docs/.vitepress/dcs-forms-loader.ts
const modules = import.meta.glob('../../.dcs/forms/*.yaml', {
eager: true,
import: 'default',
})
export const dcsFormsModules: Record<string, unknown> = modulesThe relative depth (../../) depends on where the loader file lives
relative to the repo root. Adjust as needed.
Usage
Place a YAML file at <site>/.dcs/forms/contact.yaml:
formId: contact
submission:
kind: lead
fields:
- id: name
type: text
label: Name
required: true
- id: email
type: email
label: Email
required: true
- id: message
type: textarea
label: Message
required: trueThen in any page component:
<script setup lang="ts">
import { DcsForm } from '@duffcloudservices/site-forms'
import { dcsFormsModules } from '@/dcs-forms'
</script>
<template>
<DcsForm
form-id="contact"
:forms-modules="dcsFormsModules"
@submit-success="onSuccess"
@submit-error="onError"
/>
</template>Props
| Prop | Type | Default | Notes |
| -------------------- | --------------------------------- | -------------------------------------- | ----------------------------------------------------------- |
| formId | string (required) | — | Matches .dcs/forms/<formId>.yaml. |
| siteSlug | string | import.meta.env.VITE_DCS_SITE_SLUG | Path segment in the submission URL. |
| definitionOverride | PortalFormDefinition | — | Used by the portal preview iframe to show in-flight edits. |
| apiBase | string | the production public API (see Public API base) | Override for tests / non-prod environments. Silent default in production builds. |
| captchaToken | string | — | Attached to the submission payload when set. |
| formsModules | Record<string, unknown> | internal fallback glob (rarely matches) | Required in real sites. Pass a Record<string, unknown> from your own import.meta.glob('../../.dcs/forms/*.yaml', { eager: true, import: 'default' }) — see Vite setup section. |
| initialValues | FormValues (Record<string, unknown>) | — | Runtime values merged over the YAML defaultValues at init and on reset. Merge order: YAML default < initialValues < user input. Every key must be a value field the definition declares; an unknown or layout-only id throws at setup. Read once, not watched. See Page context. |
Page context (initialValues)
Use initialValues to record where a lead came from. Declare a hidden field
in .dcs/forms/<formId>.yaml (and in the portal form, per the site-forms
3-way schema sync; the server stores only what the definition declares), then
set it per page:
<DcsForm
form-id="inquiry"
:forms-modules="dcsFormsModules"
:initial-values="{ course: 'pinehurst-no-2' }"
/>- A
hiddenfield has no input, so it keeps the initial value through user edits,reset()and submission. It appears in the submittedvalues. - A visible field shows the initial value and the visitor can change it; the typed value wins.
- A key the definition does not declare (or a
section-heading/html-blockid) throws an error naming the key and the declared fields. A typo fails the prerender or dev server instead of silently losing the context. When the form definition itself is missing,initialValuesis ignored so the "not configured" state still renders. - Added in the release after 0.7.5 (C-1767).
Emits
| Event | Payload | When |
| ------------------ | ---------------------- | ------------------------------------------- |
| submit-success | DcsFormSubmitSuccess | API responded 2xx with a JSON (or empty) body. |
| submit-error | DcsFormSubmitError | Network error, non-2xx after retries, or a 2xx whose body is not JSON (nonJsonResponse: true). |
| validation-error | FormErrors | Submit attempted with invalid required/regex/etc. fields. |
A 2xx with a non-JSON body is a FAILURE (0.6.0, C-301)
Before 0.6.0 the submission helper swallowed every body-parse failure and
returned null, so a POST answered by HTTP 200 text/html — a static
host's SPA shell, a Front Door catch-all, a captive-portal or corporate-proxy
interstitial — was indistinguishable from a quiet success. The visitor saw the
success state and the success chime while nothing was stored: silent lead
loss.
Now:
| Response | Behaviour |
| ------------------------------ | ------------------------------------------------------ |
| 2xx + JSON body | success, response = parsed JSON (unchanged) |
| 2xx + empty body (incl. 204) | success, response = null (unchanged) |
| 2xx + any other body | submit-error with nonJsonResponse: true, never retried (retrying could double-store a lead), plus a console.error naming the resolved base |
| non-2xx | submit-error; see "The error line" below |
The error line (0.7.8, C-1828)
A 4xx used to render Submission failed (404): <whole response body> in the
visible alert, which on a static host is kilobytes of HTML. Now
error.message is only the package's own sentence (Submission failed (404)),
a short server reason (a JSON error/message string or clipped plain text,
at most 200 characters, never an HTML page) travels as DcsFormSubmitError.detail
and renders behind a <details> toggle (.dcs-form__submit-error-detail), and
the body, trimmed to 500 characters, goes to console.error.
Pre-hydration submit (0.7.8, C-1828)
The rendered <form> carries method="post", and the default submit button is
disabled until the component has mounted. A prerendered form clicked before
the app hydrates therefore never submits as a GET with the visitor's values in
the URL. A site that replaces the actions slot should bind its own button's
disabled to the slot's hydrated flag.
The assertion is inlined here on purpose rather than delegated to
@duffcloudservices/cms-core's platformFetch: site-forms is a leaf package
that sites pin independently (coron8 sits on 0.1.4), and a new cms-core
dependency would make the fix unpublishable to exactly the sites that most need
it.
Slots
Every slot exposes scoped data so consumers (KT Braun, Kept) can swap shadcn primitives in without forking field components.
| Slot | Scope | Default |
| ---------- | ------------------------------------------------------------------ | ---------------------------------------------------- |
| header | { definition } | Empty; page/section headings live outside the managed form |
| progress | { current, total, step } | Step N of M — Title (multi-step only) |
| actions | { isFirstStep, isLastStep, submitting, hydrated, prev, next } | Plain <button> elements (submit disabled until hydrated) |
| success | { definition } | definition.successMessage |
| missing | { formId } | Friendly fallback when the YAML can't be found |
Per-field components (DcsFormText etc.) expose #input slots so a
shadcn site can replace the underlying primitive while keeping the
wrapper, label, help, and error-message structure.
Composables
For sites that want a fully custom layout, drop <DcsForm/> and use
the underlying composables directly:
import {
useDcsForm,
validateForm,
submitFormValues,
parseFormYaml,
} from '@duffcloudservices/site-forms'useDcsForm({ definition })— reactivevalues,errors,steps,next,prev,validateAll,collectSubmissionValues, etc.validateForm(def, values, fieldIds?)— pure validator usable in any setting (server-side, tests, custom adapters).submitFormValues({ apiBase, siteSlug, payload })— one-shot POST with a single retry on 5xx andmultipart/form-datawhen files are present.fetchSiteHandlesPhi({ apiBase, siteSlug })— the site's PHI posture (C-415), memoized per site.<DcsForm/>already calls it; a custom layout should use it rather than inventing a second HIPAA signal.PHI_GUIDANCE_COPYis the standard wording,resetSitePhiPostureCache()clears the memo (tests / the portal preview iframe).
File fields emit a single File by default. When attachmentPolicy.maxFiles
is greater than 1, the file field emits File[], enables multiple
selection, previews selected files, and allows removing files before submit.
Visual editor integration
The form root carries data-form-key="<formId>" and every field
wrapper carries data-form-field-key="<fieldId>". The portal preview
iframe bridge uses these to discover managed forms, show the preview
affordance, and route preview click / context-menu actions into the same
portal FormManagerSheet. Do not strip these attributes in custom
layouts.
definitionOverride is a preview-only draft path for the iframe. The
durable form truth still lives in the form definition saved by the
portal and in the committed .dcs/forms/<formId>.yaml snapshot consumed
by the site runtime.
For the cross-package first-party component contract (runtime markers,
bridge discovery, portal entry points, rollout, validation), see
../FIRST-PARTY-COMPONENTS.md.
Schema validation
In dev (import.meta.env.DEV === true) the runtime validates each
loaded definition against the JSON Schema bundled in
src/schema/form-definition.schema.json (snapshot of
contracts/dist/form-definition.schema.json) and logs failures via
console.warn. Production builds skip the warning to avoid noisy
end-user consoles.
When the contracts schema is regenerated (pnpm --filter @dcs/contracts
generate), refresh the snapshot:
Copy-Item ../../contracts/dist/form-definition.schema.json ./src/schema/form-definition.schema.json -Force
pnpm --filter @duffcloudservices/site-forms test --runScripts
pnpm --filter @duffcloudservices/site-forms build # vite library build (esm + dts)
pnpm --filter @duffcloudservices/site-forms test # vitest --run
pnpm --filter @duffcloudservices/site-forms type-check # vue-tsc --noEmitCompliance requirements for form submissions
Sensitive forms (isSensitive: true)
Forms flagged as sensitive (law firm intake, HIPAA intake, any privilege-sensitive questionnaire) carry platform-enforced rules that form authors and site integrators must not override:
Notification emails suppress submission content. When
isSensitive: true, the portal notification email for a new submission contains only a portal link — never field values, names, or any submission body. This is required for attorney-client privilege (ABA Rule 1.6) and HIPAA confidentiality.Access audit logging. Every time a portal user opens a sensitive-form submission, the platform writes an audit log entry (
PortalAuditLog, event:submission_viewed). This cannot be disabled.Set via portal, not YAML. The
isSensitiveflag lives in thePortalSiteFormstable (managed via the portal Form Manager). It is not part of the.dcs/forms/*.yamlsnapshot — the runtime itself has no concept of sensitivity.
Form version tracking
Every submission row stores formVersion (the schema version of the form at submission time). If a form's fields change after submissions are collected, old submissions remain interpretable: the portal can reconstruct what was shown by looking up the version-keyed schema. This is required for compliance audit trails.
General form requirements
All DCS-managed forms that collect personal data must:
- Link a Privacy Policy URL in proximity to the submit action
- Store
formVersionId, submission timestamp, and submitter IP with every submission row (handled automatically by the platform) - Not collect unnecessary data fields (data minimization)
Intake questionnaires (attorneys, healthcare)
Use the Legal Intake — Standard form template (created via the portal Form Manager → Templates) when building intake forms for law firms. This template:
- Pre-populates the attorney-client privilege disclaimer and consent checkbox (non-removable)
- Automatically sets
isSensitive: true - Includes matter-type and adverse-parties fields for conflict screening
For healthcare intake, mark each PHI-bearing field phi: true in the YAML. There is no hipaa form kind (the kinds are freeform, contact, revenue-contractor, resume-submission); PHI handling is driven by the per-field phi marker plus the site-level predicate below.
PHI guidance on HIPAA-mode tenants (C-415)
On a HIPAA-mode tenant, <DcsForm/> renders a standard guidance line immediately above the first free-text field:
Please don't include personal health details. A general description of what you need is enough — we'll go over anything specific with you directly.
It is on by default and there is deliberately no prop and no YAML flag to force it on or off. The verdict comes from the platform: on mount the component GETs /api/v1/sites/{slug}/forms/compliance (memoized per site; the slug-free /api/v1/forms/compliance when the build has no baked slug), which is a boolean projection of the server's one site-level PHI predicate, portal.Service.SiteHandlesPHI — authoritative input SiteAgentConfigs/{slug}/default.HipaaMode, the same row the C-352 redaction gate reads. A site-local switch could disagree with the tenant's real posture, which is exactly the class C-352 fixed.
If the read fails the component renders nothing; the server-side redactions remain the actual control. Style it via .dcs-form__phi-guidance; probe for it with [data-form-phi-guidance] (the copy contains no "HIPAA"/"protected health information" legalese by design — it is guidance for a visitor, not a legal notice).
Related docs
- Authoring guide —
.docs/forms/AUTHORING.mdcovers the YAML schema, worked examples, validation flow, HIPAA guardrails, and the hand-coded →<DcsForm/>migration recipe. - Publishing —
PUBLISHING.mdcovers the registry, OIDC trusted publishing, version bump policy, and the exact dep line sibling customer-site repos should add. - First-party visual-editor contract —
../FIRST-PARTY-COMPONENTS.mdcaptures the shared adaptation model used by forms and future component families. - Validation CLI —
cli/forms/README.mddocumentsdcs forms validateanddcs forms doctor, which lint the.dcs/forms/*.yamlfiles in a customer-site repo.
Ownership
Per packages/README.md: external/consumer-facing docs live here, not
in repo-root docs. Cross-cutting details (e.g. the public submissions
API contract) belong in contracts/README.md.
