feat(forms): create draft content entries from form submissions#2099
feat(forms): create draft content entries from form submissions#2099rmakk wants to merge 2 commits into
Conversation
Implements the contentMapping proposal from discussion emdash-cms#1672: an optional per-form setting that creates a draft entry in a target collection from each successful submission. Mappings are validated against the target collection at form-save time (including required field coverage), and the inbox submission is preserved even when content creation fails. Adds getCollection and REST-parity slug generation to the plugin content API in core to support this. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 8c8acc2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
All contributors have signed the CLA ✍️ ✅ |
Scope checkThis PR changes 1,061 lines across 14 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
|
I have read the CLA Document and I hereby sign the CLA |
There was a problem hiding this comment.
This PR implements the approved discussion well: it adds an optional contentMapping form setting, keeps submissions in the inbox, creates draft-only entries, validates mappings at form-save time, and preserves submissions when content creation fails. The core additions (ctx.content.getCollection and slug generation in ctx.content.create) are additive and mirror the REST path. I read the diff, the changed files, ContentRepository, the REST handleContentCreate, and the forms validation code, and checked against AGENTS.md.
The implementation is generally solid and well-tested, but I found a few gaps worth fixing before merge:
- AGENTS.md violation: module-scope mutable singleton.
content-mapping.tskeeps alet keyCounter = 0counter and mutates it fromgenerateKey().AGENTS.mdrequires module-scope singletons to live onglobalThisbecause Vite can duplicate modules across SSR chunks. Use aSymbol.forkey onglobalThis. - Metadata constant validation is incomplete.
validateContentMappingonly checks that a metadata key is a real collection field. Anull/undefinedconstant for a required target field will pass validation but fail every submit-time insert against theNOT NULLcolumn. The validator should reject required fields covered by nullish metadata. - Optional form fields can satisfy required collection fields. A required collection field mapped from an optional form field (or a transform that can return
undefined) may produce an entry with a default/empty value, or, when combined with a failing transform, a logged create failure. Requiring the source form field to berequiredwhen the target collection field is required would close the loophole. - Form duplication skips validation.
formsDuplicateHandlerspreads the existing definition (includingcontentMapping) into a new form without re-runningvalidateContentMapping. If the target collection or fields changed since the original was saved, the duplicate’s mapping is invalid from the start. - Plugin slug generation ignores site locale.
ctx.content.createcallsgenerateUniqueSlug(...)without alocale, while the REST create path scopes uniqueness to the effective locale and creates entries in that locale. Plugin-created entries therefore default toenand use global slug uniqueness, which will behave differently on non-English default sites.
None of these are merge blockers on their own, but (1) is a convention violation and (2)/(3) weaken the save-time validation that is a headline feature of this PR.
Findings
-
[needs fixing]
packages/plugins/forms/src/content-mapping.ts:183-189Module-scope mutable state (
let keyCounter = 0) is used for Portable Text_keygeneration.AGENTS.mdsays module-scope singletons must live onglobalThisbecause Vite can duplicate modules across SSR chunks, so two copies of this module could share/reset the counter and produce duplicate keys.const KEY_COUNTER = Symbol.for("emdash-forms.content-mapping.key-counter"); function nextKeyCounter(): number { const current = ((globalThis as Record<symbol, number>)[KEY_COUNTER] ?? 0) + 1; (globalThis as Record<symbol, number>)[KEY_COUNTER] = current; return current; } /** Generate a Portable Text `_key`, unique within this process */ function generateKey(): string { return `form-${nextKeyCounter().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; } -
[needs fixing]
packages/plugins/forms/src/content-mapping.ts:69-76The metadata key check verifies the key exists as a collection field, but it does not verify the constant value. A
nullorundefinedmetadata constant for a required target field will pass validation and then fail every submit-time insert on theNOT NULLcolumn. Reject required fields covered by nullish metadata constants at save time.for (const key of metadataKeys) { const collectionField = collectionFields.get(key); if (!collectionField) { throw PluginRouteError.badRequest( `Content mapping metadata targets unknown field "${key}" in collection "${mapping.collection}"`, ); } const value = mapping.metadata?.[key]; if (collectionField.required && (value === undefined || value === null)) { throw PluginRouteError.badRequest( `Content mapping metadata for required field "${key}" in collection "${mapping.collection}" must not be null or undefined`, ); } } -
[suggestion]
packages/plugins/forms/src/content-mapping.ts:46-82The required-field coverage check guarantees that a required collection field is referenced by a mapping or metadata, but not that a field mapping will actually supply a value. If an optional form field is mapped to a required collection field and the submitter leaves it empty, the entry will receive the column default (empty string/
0/etc.) or a transform will skip it and a logged create failure occurs. Consider requiring the source form field to berequiredwhen it backs a required target field that isn’t covered by metadata.const requiredFormFields = new Set( pages.flatMap((page) => page.fields.filter((field) => field.required).map((field) => field.name)), ); const mappedTargets = new Set<string>(); const targetsFromOptionalField = new Set<string>(); for (const [formField, target] of Object.entries(mapping.fieldMappings)) { if (!formFieldNames.has(formField)) { throw PluginRouteError.badRequest( `Content mapping references unknown form field "${formField}"`, ); } const targetField = typeof target === "string" ? target : target.field; const collectionField = collectionFields.get(targetField); if (!collectionField) { throw PluginRouteError.badRequest( `Content mapping targets unknown field "${targetField}" in collection "${mapping.collection}"`, ); } if (collectionField.required && !requiredFormFields.has(formField)) { targetsFromOptionalField.add(targetField); } mappedTargets.add(targetField); } // ... for (const field of collection.fields) { if (field.required && !mappedTargets.has(field.slug) && !metadataKeys.has(field.slug)) { throw PluginRouteError.badRequest( `Content mapping does not map required field "${field.slug}" in collection "${mapping.collection}"`, ); } if (field.required && targetsFromOptionalField.has(field.slug) && !metadataKeys.has(field.slug)) { throw PluginRouteError.badRequest( `Content mapping maps required field "${field.slug}" in collection "${mapping.collection}" from an optional form field; mark the source form field as required or cover it with metadata`, ); } } -
[suggestion]
packages/plugins/forms/src/handlers/forms.ts:203-235formsDuplicateHandlercreates a brand-new form by spreading the existing definition, includingcontentMapping, but never re-runsvalidateContentMapping. If the target collection or its fields changed since the original form was saved, the duplicated form will contain an invalid mapping from the moment it is written. Add a validation call beforeforms(ctx).put(id, duplicate)so duplication behaves like the other save paths. -
[suggestion]
packages/core/src/plugins/context.ts:441Plugin
ctx.content.createderives a slug without passing alocale, whilehandleContentCreatescopes uniqueness to the effective locale and inserts the entry in that locale. This means plugin-created entries default toenand use global slug uniqueness, which diverges from the REST/admin path on non-English sites. Consider threadingctx.site.locale(or adding locale support toContentWriteInput) so the plugin API matches the native create behavior.
- Move the Portable Text _key counter to a globalThis singleton - Reject nullish metadata constants for required collection fields - Require required collection fields to be mapped from required form fields (or covered by metadata) - Re-validate the mapping when duplicating a form - Thread the site default locale through plugin content.create so the entry locale and slug uniqueness match the REST create path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
This re-review confirms the PR is in good shape. The author has addressed all five findings from the previous review:
- Module-scope key counter is now stored on
globalThisviaSymbol.for(packages/plugins/forms/src/content-mapping.ts). - Metadata constants for required fields now reject
null/undefinedat save time (packages/plugins/forms/src/content-mapping.ts). - Required collection fields mapped from optional form fields are rejected (
targetsWithRequiredSourcecheck inpackages/plugins/forms/src/content-mapping.ts). - Form duplication now re-runs
validateContentMappingbefore saving the duplicate (packages/plugins/forms/src/handlers/forms.ts). - Plugin slug generation now uses the site's configured default locale and locale-scoped uniqueness, matching the REST create path (
packages/core/src/plugins/context.ts).
The feature continues to follow the approved discussion: mappings are validated on form save, entries are always created as drafts, the inbox submission is preserved even if content creation fails, and the forms plugin correctly declares content:write. The core additions (ctx.content.getCollection and slug generation in ctx.content.create) are additive and mirror the REST path. Tests cover validation, duplicate revalidation, submit-time failures, transforms, and locale-scoped slug behavior. There are no admin UI changes, so Lingui/RTL concerns do not apply; changesets are present for both affected packages.
No remaining blockers or regressions were found.
What does this PR do?
Implements the proposal from #1672: an optional
contentMappingsetting inFormSettingsthat creates a draft content entry in a target collection from each successful form submission. The submission is still stored in the forms inbox regardless — this is additive.It follows the guidance from the discussion:
statusoption can be added later if wanted, but anonymous submissions publishing content directly seemed like a footgun.)slugFrom) must exist,metadatakeys must be real collection fields, and — per @MA2153 — every required collection field must be covered by a mapping or a metadata constant. Page edits that break an existing mapping are also rejected.emdashcore:ctx.content.createnow runs slug generation exactly like the REST/admin create path —ContentRepository.generateUniqueSlugon a reservedslugkey (used forslugFrom), falling back to the entry'stitle/name. Previously plugin-created entries always had anullslug.ctx.content.getCollection(slug)(on thecontent:readsurface) returns a collection definition with its fields, which powers the save-time validation above.content:writeto its declared capabilities; it is only exercised for forms that havecontentMappingconfigured.Scope note: this PR is the backend + validation + tests only — the mapping is configurable via the
forms/create/forms/updateplugin routes. I'd like to follow up with an admin UI for the mapping editor in a separate PR once the shape here has landed.Discussion: #1672
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs — a workflow extracts catalogs on merge tomain. (n/a — no admin UI changes in this PR)AI-generated code disclosure
Screenshots / test output
Tests cover the cases listed in the discussion — no mapping keeps current behavior, a successful mapping creates a draft and keeps the inbox submission, invalid mappings are rejected on form save, and submit-time create failures don't lose the submission — plus per-transform unit tests:
🤖 Generated with Claude Code