inmake DOCS
Docs / Generative / Forms

Forms

The "forms" rule fragment — composed into the CRM & Studio agents' prompts and served as drop-core://rules/forms.

FORMS — a kind:'form' field is a REAL public form: visitors submit it and the records land in the store. Three parts must line up, and a form that is missing any of them silently accepts nothing.

THE SLUG IS THE JOIN KEY — the schema field's form:"<slug>", the submit URL's /form/{locale}/<slug>, the gate doc forms/{slug} (and, when an element kit places the form, the element data's form-slug key) must all be the SAME string. NOTHING validates the agreement anywhere — a mismatch just 404s every submission with no other symptom, so check the slug in all places before debugging anything else.

  1. SCHEMA (the structure): {key:"contact", kind:"form", form:"<store-unique-slug>", fields:[…]} — ONE ordered list mixing static content (a heading, an intro, consent text) with the submittable inputs {key, kind:"variable", input:"input"|"text"|"toggle"}. input:"image"|"file"|"files" are UPLOAD variables (see UPLOADS below) — a visitor attaches a file; the submission carries a claim, not bytes. The variable's schema entry carries ONLY key+input. The static entries are ORDINARY fields and MUST use a kind from FIELD KINDS (a heading is {key:"headline", kind:"text"}) — kind:"variable" is the ONLY extra kind a form adds; never invent one (kind:"headline" is not a thing).

  2. DOCUMENT VALUE (the labels + the validation contract): under the form field's key, every variable key holds its own descriptor — {type:"<same as input>", label, description?, required?, pattern?, pattern_message?, max_length?}; an upload variable's descriptor takes max_size (bytes, platform ceiling 10 MiB), accept (MIME types, image/*, or .pdf extensions — it can only NARROW the platform list: images, PDF, plain text, Office; never SVG/HTML/scripts/archives) and, for files, max_files (ceiling 5). This is per-locale document data: the page renders its labels from it and BOTH the render server and the core API validate against it, so pattern_message must be written in the document's language. Static fields inside the form keep their normal values. Seed it with your document-value tool right after you author a form field; if you have no write tool for the document, say that the labels and constraints are edited on the form card in the document editor.

"contact": {
  "headline": {"type": "raw", "value": "Get in touch"},
  "email":    {"type": "input", "label": "E-mail", "required": true,
               "pattern": "^[^@ ]+@[^@ ]+[.][^@ ]+$",
               "pattern_message": "Enter a valid e-mail address"},
  "message":  {"type": "text", "label": "Message", "required": true, "max_length": 2000},
  "subscribe":{"type": "toggle", "label": "Send me news"}
}

CLEARING A CONSTRAINT — document writes MERGE: re-sending a descriptor WITHOUT a key does not remove that key. To drop a wrong constraint you must overwrite it with an empty value ("pattern": "", "required": false, "max_length": 0) — omit it and the old one stays, so a broken pattern keeps rejecting every submission no matter how often you "remove" it.

PATTERN — the value you store IS the regular expression source, compiled as-is. Backslashes are the classic trap: whatever you send must arrive with EXACTLY the backslashes the regex needs (\d, not \\d), and a double-escaped pattern silently matches NOTHING, so every submission is rejected with your pattern_message. Play it safe and write patterns that need no backslash at all — for e-mail copy ^[^@ ]+@[^@ ]+[.][^@ ]+$ verbatim ([.] = a literal dot, [0-9] = a digit, [^@ ] = anything but @/space). Keep the pattern permissive; it is a typo check, not an RFC.

  1. FORM DOC (the on/off gate): forms/{slug} — {name, active, origins, require_source, captcha}. Public submissions 404 until active:true; an inactive form is indistinguishable from a missing one, so a form that "does nothing" is almost always this. [formActivation — filled in at runtime] origins is an allowlist of browser origins — exact entries ("https://shop.example.com") or one-label wildcards ("https://*.example.com" matches shop.example.com, not the apex, not two labels; scheme and port must match exactly); absent/empty = unrestricted, a mismatch = 401. require_source:true refuses a submission that arrives without the placing page's context (400 "Source required") — the render server supplies it from the Referer, so only enable it once the form's records carry a source; a form embedded deeper than one reference, or a page sent without a Referer, would then be refused. captcha: {enabled, min_score?, low_score?} requires an invisible reCAPTCHA v3 token on every submission (min_score 0.1–0.9, default 0.5; low_score: "reject" (default) refuses a low score, "flag" keeps the record marked suspect) — it needs the store's own key first (Settings › Web › Spam protection: site key + secret); without that key every submission of the form is refused, so never enable it on a store that has none.

RENDERING — nothing is generated for you: the template writes the markup AND the submit script.

  • Markup: bind the visitor-facing strings from the descriptors ({{contact.email.label}}, {{contact.email.description}}) — never hardcode them — and give every input a name EQUAL to its variable key. Keep an empty error element for the server's message.
  • Submit: a JSON POST to `${window.DROP_BASE}/form/${window.DROP_LOCALE}/<slug>` (both globals are injected into every rendered page; DROP_BASE is '' at the domain root). A native <form action=…> POST does NOT work — the endpoint parses JSON, so preventDefault() and fetch.
  • Payload: EXACTLY the declared variable keys — an undeclared key rejects the WHOLE submission. Toggles are real booleans (true/false, never "on"), upload variables carry the claim from UPLOADS ({"upload":"<ticket>"}, an array of them for files), the rest are strings.
  • Responses: 200 {id,status} · 400 {error,message} — show message, it is already localized · 403 guard or reCAPTCHA ({error, reason, message} — show message too) · 404 form inactive/unknown.
  • reCAPTCHA: the platform adds it for a protected form on its own — a page script attaches the token to your fetch. Never load reCAPTCHA (api.js, grecaptcha) in a template and never add a captcha widget. The platform prints no reCAPTCHA notice; when the store hides Google's badge, add a short line (e.g. under the submit button) only if the user asks for one.
  • The live site and the site preview run the template's JS and the submission goes through. The in-editor preview pane ALSO runs it (so the form binds, validates and shows its own error state), but a write guard refuses every non-GET request with a synthetic 403 — nothing reaches the store, and the editor raises a "blocked in preview" notice. So a form that reports your error string in the editor is expected; verify a real submission on the site preview.
<form class="contact-form" novalidate data-success="{{t "form.sent"}}" data-error="{{t "form.failed"}}">
  <label>{{contact.email.label}}<input name="email" type="email" required></label>
  <label>{{contact.message.label}}<textarea name="message" required></textarea></label>
  <label class="inline"><input name="subscribe" type="checkbox"> {{contact.subscribe.label}}</label>
  <button type="submit">{{t "form.send"}}</button>
  <p class="form-msg" hidden></p>
</form>
document.querySelector('.contact-form')?.addEventListener('submit', async (e) => {
  e.preventDefault();
  const f = e.currentTarget, msg = f.querySelector('.form-msg');
  const payload = {
    email: f.email.value.trim(),
    message: f.message.value.trim(),
    subscribe: f.subscribe.checked,        // boolean, not "on"
  };
  const res = await fetch(`${window.DROP_BASE}/form/${window.DROP_LOCALE}/contact`, {
    method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(payload),
  });
  const body = await res.json().catch(() => ({}));
  msg.hidden = false;
  msg.textContent = res.ok ? f.dataset.success : (body.message || f.dataset.error);
  if (res.ok) f.reset();
});

The button and the thank-you / failure copy follow NO HARDCODED TEXT like every other string: translations keys here (create the form group's keys in every locale first), bound into the markup and read back by the script — on a form COMPONENT, their own fields (ONE FORM ON MANY PAGES, below).

UPLOADS (input:"image"|"file"|"files") — two steps, both through the render server, never a direct bucket write: (1) when the visitor picks a file, POST it as multipart/form-data (a file part + the text field key = the variable key) to `${window.DROP_BASE}/form/${window.DROP_LOCALE}/<slug>/upload`; the answer is {upload, name, size, content_type} (400 {message} when the type or size is refused — show it). (2) Put {upload: "<the ticket>"} under the variable key in the submission (files: an array, at most max_files). The ticket is valid for 24 h and for ONE submission; the stored record holds {path, name, size, content_type} — a private object with no public URL (the admin downloads it from the record page). Keep the submit button disabled while an upload is in flight.

async function uploadField(input, key) {            // input: <input type="file" name="cv">
  const fd = new FormData();
  fd.append('key', key);
  fd.append('file', input.files[0]);
  const res = await fetch(`${window.DROP_BASE}/form/${window.DROP_LOCALE}/contact/upload`, {method: 'POST', body: fd});
  const body = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(body.message || input.form.dataset.error);
  return {upload: body.upload};                      // → payload.cv = await uploadField(f.cv, 'cv')
}

ONE FORM ON MANY PAGES — do NOT copy the markup and the submit script into every page's template. Put the kind:'form' field on its OWN document (a components/contact doc with its own schema + template, which owns the markup and the JS), then give each page a kind:'reference' field pointing at it with render: true on the stored value — {"type":"document","collection":"components","document":"contact","ref":"/data/{locale}/components/contact","render":true}. The page template then embeds it with {{{ contact_form.html }}} and nothing else. The labels + constraints (part 2 above) live in the COMPONENT's document value, edited once; the render server resolves the submission's contract against the component doc, so validation follows the form wherever it is embedded. A reference WITHOUT render:true hands the page only data — the form will not appear.

A COMPONENT'S JS MUST NOT READ window.DROP_DATA — that global is the PAGE's data, and the component sits under whatever key the page's reference field happens to use (DROP_DATA.contact_form.contact_form.success here, and something else on the next page), so any path you hardcode breaks the moment a second page embeds the form under a different key. Bind the strings the script needs into the markup with Handlebars — which DOES see the component's own document — and read them back from the DOM: <form class="contact-form" novalidate data-success="{{ document.contact.success }}" data-error="{{ document.contact.error }}">, then var successCopy = form.dataset.success;. Give the success/error copy their own static text fields so they stay editable and localized; the server's 400 message is already localized, so show that as-is when it is present.

RECORDS — submissions land in forms/{slug}/records as {status, locale, data:{…}, source, created, note} (status: new/open/done/unresolved/closed; created is the submission time, timestamp moves on admin edits). Submissions are visitor-authored PII and are NOT readable through your tools (the read guard refuses forms/*/records) — when the user asks what came in, point them at the admin Forms page, where a human works through the records.