Skills for AEM Forms on Edge Delivery Services: building forms with the Form block.
96%
Does it follow best practices?
Impact
—
Average score across 6 eval scenarios
Passed
No findings from the security scan
Build a custom component for the AEM Forms Form block on Edge Delivery Services end to end — scaffold, authoring schema, decorate logic, model subscriptions, registration, documentation — and prove it works before reporting done.
aem-boilerplate-forms (it has blocks/form/, blocks/form/mappings.js, blocks/form/rules/index.js).blocks/form/components/.Not for: generic Edge Delivery Services blocks (use the building-blocks skill of the aem-edge-delivery-services plugin), AEM Sites HTL components (create-component in aem-cloud-service), or rule-editor logic.
| File | Read when |
|---|---|
references/custom-form-components.md | Always — architecture, folder layout, JSON schema, registration |
references/form-field-types.md | Step 4 — the base HTML the element parameter already contains |
references/authoring-field-types.md | Step 3 — widgets for the properties dialog (text, select, container, …) |
references/subscribe-api.md | Step 4 — subscribe() patterns: own value, child fields, custom events |
scripts/validate-component.mjs | Step 7 — static validator (Node 18+, no dependencies) |
scripts/create-e2e-test.mjs | Step 7 — generates the component's Playwright e2e test + form JSON |
scripts/run-e2e-test.mjs | Step 7 — runs those tests with the project's own Playwright setup (Chromium, no retries) |
assets/e2e/form-harness.js | Test harness copied into the project's e2e test folder by create-e2e-test.mjs |
scripts/preview-component.mjs | Step 8: opens the component in a browser, with a live model inspector, for user feedback |
scripts/validate-registration.js | Optional — in-page check on an already deployed form |
Paths under scripts/, assets/ and references/ are relative to this skill's directory; resolve them to absolute paths before running commands from the repository root.
Run every command in this skill from the repository root — the directory containing package.json and blocks/form/. Do not cd into a subfolder (such as code/) first. Then confirm — ask only for what the request does not already answer:
| Question | Why |
|---|---|
| Component name | Folder, file names, and fd:viewType (lowercase kebab-case, e.g. star-rating) |
| Which OOTB field type does it extend? | Base HTML and AEM resource type |
| Which authorable properties does it need? | Fields in _<name>.json |
What model state does it read/write, and when should model.value be committed? | Drives subscribe wiring and the README contract |
| Is it a panel that watches child fields, or does it react to custom events? | Selects the subscribe pattern |
| Expected behaviour to prove (what the user does, what the model should hold, how often rules fire) | Assertions of the e2e test in Step 7 |
Supported base types: text-input, number-input, drop-down, button, checkbox, radio-group, checkbox-group, panel, date-input, multiline-input, file-input, email, telephone-input.
Check the name is not already used: blocks/form/components/<name>/ must not exist and <name> must not be in mappings.js.
Create blocks/form/components/<name>/{<name>.js,<name>.css,_<name>.json} by hand: copy the base type's blocks/form/models/form-components/_<base>.json, set definitions[0].id and models[0].id to <name>, set template["fd:viewType"] to <name>, and fix "..." include paths so they resolve from the component folder (../../models/form-common/...).
Don't run the repo's create:custom-component script (node tools/forms-scaffolder.js) yourself: it takes no flags and asks its three questions at an interactive prompt, which an agent's shell can't answer. If the user prefers it, they run npm run create:custom-component in their own terminal. It creates the same three files and usually also registers the component (mappings, _form.json filter, models/_component-definition.json). Do not assume it did — Step 7 verifies.
_<name>.json)Read references/authoring-field-types.md to choose the widget for each property. Add custom fields to models[0].fields; reuse shared field groups instead of redefining them:
{ "...": "../../models/form-common/_basic-validation-fields.json#/fields" }Rules: every field has component and a unique name; select/multiselect/radio-group/checkbox-group have options; keep fieldType, resourceType, and fd:viewType from the scaffold.
decorateRead references/form-field-types.md (base HTML) and references/subscribe-api.md, then pick the pattern:
| The component… | Use |
|---|---|
| Reacts to its own value / enum / visible changes | subscribe(fieldDiv, formId, cb, { listenChanges: true }) — handle 'register' and 'change' |
| Watches child fields (panel) | Same on the parent; inside 'register', subscribe() each child wrapper found with [data-id="${child.id}"] |
Reacts to custom events (custom:<event>) | fieldModel.subscribe(cb, '<event>') inside 'register' — never fieldDiv.addEventListener |
Implementation rules:
export default function decorate(fieldDiv, fieldJson, container, formId) and return fieldDiv.subscribe from '../../rules/index.js'; relative imports always include .js.fieldJson.properties.<prop>.fieldDiv; scope DOM queries to it.model.value or dispatch new Event('change', { bubbles: true }) on the underlying input.model.value only from the input's native change event (blocks/form/rules/index.js), which text-like inputs fire on blur/commit, not on each keystroke. So rules on the field's change event run once per edit, when the user leaves the field. If the component must commit on every keystroke (live counters, search-as-you-type, values derived while typing), handle the input event in the component and set model.value there, comparing with the current value first. When reformatting the display while typing (masks, grouping), don't dispatch change on each keystroke, or you turn the blur commit into a per-keystroke one.blocks/form/mappings.js — add <name> to customComponents (or to OOTBComponentDecorators for OOTB-style decorators). Must exactly equal fd:viewType.NO_CSS_COMPONENTS when the project defines that set; otherwise keep <name>.css.blocks/form/_form.json — make sure <name> is in the "form" filter components so authors can insert it.models/_component-definition.json — make sure the custom-components group includes ../blocks/form/components/<name>/_<name>.json#/definitions (unless the project includes components by glob)..eslintrc.cjs, eslint.config.*, …) for per-model limits. The xwalk config usually sets 'xwalk/max-cells': it limits each model in the built component-models.json to 4 cells unless the model id has its own entry. A form component inherits its base type's fields, so it needs one. Add '<name>': <cells> next to the existing entries. The validator (Step 7, gate A) reports the exact count after npm run build:json. Follow any other per-model lint options the repository defines the same way.Create blocks/form/components/<name>/README.md (use an existing component README as a model):
| Section | Required | Content |
|---|---|---|
# <Component Name> | Yes | One-line summary |
## How to Use / ## Authoring Properties | Yes | How to configure it; table of custom properties |
## Model Subscriptions | If applicable | Model changes / custom events it listens to |
## Events Dispatched | If applicable | Custom events it fires |
## Model Properties Set | If applicable | What it writes and when model.value is committed: on blur/change (the OOTB default), per keystroke (component-handled input), or external only. Also how often the field's change rules fire |
## Usage Notes | Yes | Base type, fd:viewType, key behaviour |
If the project keeps a component catalog (for example a component-registry.md), add a one-line row for the component too.
Do not report the component as done until gates A–D pass. Fix and re-run on failure.
A. Static validation — from the repository root:
node <skill-dir>/scripts/validate-component.mjs <name>Checks folder/file naming, valid JSON, definition/model ids, fd:viewType = name, fieldType/resourceType, resolvable "..." includes, unique field names, ES-module syntax, default export, subscribe import path and { listenChanges: true }, .js import extensions, legacy/anti-patterns (fieldModel.subscribe(…, 'change'), non-bubbling change, #id child selectors), README sections, registration in mappings.js / _form.json / _component-definition.json, whether the build output contains the component, and, when the ESLint config sets xwalk/max-cells, whether the built model's cell count fits its limit (it prints the entry to add). Exit 0 = no errors; 1 = errors (must fix); warnings must be fixed or explicitly justified in the report. Use --json for machine-readable output.
B. Lint — if package.json has a lint script, run npm run lint and fix every issue in the new/changed files.
C. Build — npm run build:json succeeds, and re-running gate A shows build.json passing.
D. End-to-end test — no deployed form or URL is needed. The harness renders a local Edge Delivery Services page whose form block holds a form JSON in <pre><code>, loads the project's head.html (aem.js, scripts.js) and serves the repository root, so the real form block, rule engine, mappings.js and the component run as in production.
Generate the test (add --props '<json>' for authored property values):
node <skill-dir>/scripts/create-e2e-test.mjs <name> --props '{"maxStars":5}'It writes the tests into the repository's own Playwright test folder: <testDir>/custom-components/form-harness.js and <testDir>/custom-components/<name>/{<name>.form.json,<name>.spec.js}, where <testDir> comes from the repo's Playwright config (default test/e2e). Missing folders are created. A minimal root playwright.config.mjs is added only if the repo has no Playwright config. It warns if the test folder isn't excluded by .hlxignore (it would be published to Edge Delivery Services) or by ESLint. Relay those warnings to the user with the suggested lines; don't edit the ignore files unasked. The form JSON contains a field with ":type": "<name>" plus a following text field. For panels, add the children the component expects; for rules, add events/rules to the JSON. Keep action ending in /<form id>: createForm() derives the formId passed to components from it, and on a mismatch subscribe() callbacks never fire.
Replace every TODO in the spec (they fail on purpose). Cover, with the harness helpers renderForm, field, getModel, setModel, dispatchModelEvent, trackChanges:
data-component-status="loaded";setModel(page, id, 'value', …) (or enum, visible, …), then assert the component's UI;getModel and, with trackChanges, how many value changes rules would see (e.g. exactly 1 for commit-on-complete);dispatchModelEvent(page, id, 'custom:<event>')) and keyboard operation where relevant.Run it from the repository root; all tests must pass with none skipped:
node <skill-dir>/scripts/run-e2e-test.mjs <name>It runs the repo's own Playwright and config on that folder only, without retries and limited to the chromium project when the config has one. In other words: npx playwright test <testDir>/custom-components/<name>/ --project=chromium --retries=0. The tests become part of the repo's e2e suite, so they also run with its usual command (e.g. npm run test:e2e). If the repo's config fails to load, or doesn't exist, the runner falls back to a temporary standalone config (Chromium, no retries, written to the OS temp folder). For example, a globalSetup: … : null is rejected by newer Playwright. The runner prints why; pass that on to the user so they can fix the repo config. The harness also fails a test on console errors from /blocks/form/, including exceptions thrown by decorate(): mappings.js swallows those and still marks the field loaded. If @playwright/test is missing (exit 2), install it as a dev dependency (npm i -D @playwright/test && npx playwright install chromium). If that is not possible, keep the spec, report the test as not run, and give the command.
scripts/validate-registration.js can additionally be evaluated on an already deployed form ((<file contents>)('<name>')) to confirm registration there.
Once gates A–D pass, let the user try the component before you finish.
Start the preview from the repository root as a background process (it keeps running):
node <skill-dir>/scripts/preview-component.mjs <name>It renders the e2e form JSON (<testDir>/custom-components/<name>/<name>.form.json) in a local form block with the project's aem.js/scripts.js. If the project has @playwright/test, it opens a visible Chromium window. An inspector panel shows the field's live model (value, valid, visible, enabled, …) and a log of model changes. It prints PREVIEW_URL=<url>. Without Playwright or a display (or with --no-browser) it only serves the page, so give the user the URL to open. To preview other property values, edit the form JSON or pass --form <file>.
Tell the user the URL and what to try: each interaction, the authored properties, and keyboard use. Then ask, using the agent's question tool if it has one: does it look and behave as expected, and what should change?
For each piece of feedback: change the component, add or adjust e2e assertions for the new behaviour, re-run gates A–D, and ask the user to reload the page (files are served fresh on each request). Ask again.
Repeat until the user approves, then stop the preview process.
If the user can't be reached (non-interactive run), skip this step and say so in the report, with the preview command.
Finish with: files created/changed, the validator summary (errors/warnings), lint and build results, the e2e results (passed/failed per test, or why it could not run), the preview feedback rounds and changes made (or why the preview was skipped), and any justified warnings.
| File | Purpose |
|---|---|
blocks/form/components/<name>/<name>.js | Component logic (decorate) |
blocks/form/components/<name>/<name>.css | Component styles |
blocks/form/components/<name>/_<name>.json | Definition + authoring model |
blocks/form/components/<name>/README.md | Component documentation |
blocks/form/mappings.js | Registration (customComponents, OOTBComponentDecorators) |
blocks/form/_form.json | "form" filter — which components authors can insert |
models/_component-definition.json | Component palette (custom-components group) |
blocks/form/rules/index.js | subscribe source |
| Mistake | Fix |
|---|---|
Folder name ≠ JS file name ≠ fd:viewType | All three must be identical |
Not in customComponents | JS/CSS never load — validator register.mappings |
subscribe without { listenChanges: true } or legacy fieldModel.subscribe(cb, 'change') | Use the standard pattern in references/subscribe-api.md |
Child field selected with #id | Use [data-id="${child.id}"] (the wrapper) |
Custom event handled via addEventListener | fieldModel.subscribe(cb, '<event>') in 'register' |
| Assuming the model updates while the user types | OOTB commits on blur (change); handle input in the component if rules need each keystroke, and document it in ## Model Properties Set |
Dispatching change on every keystroke while formatting | Turns the blur commit into a per-keystroke one; let the native change commit, or commit deliberately |
Forgot npm run build:json | Properties missing in the Universal Editor — validator build.json |
No xwalk/max-cells entry for the new model | npm run lint fails with "Avoid using more than 4 cells"; add '<name>': <cells> to the ESLint config. Validator lint.max-cells gives the number |
| Reporting done without running Step 7 | Always run gates A–D; report any gate that could not run |
| Leaving the preview server running | Stop the background preview process once the user approves or the step is skipped |
Trusting data-component-status="loaded" alone | It is set even when decorate() throws — the e2e harness checks the console |
User: "Create a star rating component based on number-input with a configurable maximum number of stars."
blocks/form/components/star-rating/ with star-rating.js, star-rating.css and _star-rating.json, the last copied from _number-input.json (Step 2)._star-rating.json: add { "component": "number", "name": "maxStars", "label": "Maximum stars", "valueType": "number" }.star-rating.js: hide the number input, render maxStars buttons, set model.value on click, sync the selected stars on 'change' of value, using subscribe(..., { listenChanges: true }).mappings.js / _form.json, write README.md, run npm run build:json.node <skill-dir>/scripts/validate-component.mjs star-rating → PASS: 0 error(s); npm run lint clean.node <skill-dir>/scripts/create-e2e-test.mjs star-rating --props '{"maxStars":5}', then write the spec: the stars render, maxStars: 3 renders 3 stars, setModel(… 'value', 4) selects 4 stars, clicking star 3 sets the model to 3 with exactly one value change, keyboard Enter on a star works. Then node <skill-dir>/scripts/run-e2e-test.mjs star-rating → all passed.