Skip to content

feat(forms): create draft content entries from form submissions#2099

Open
rmakk wants to merge 2 commits into
emdash-cms:mainfrom
rmakk:forms-content-mapping
Open

feat(forms): create draft content entries from form submissions#2099
rmakk wants to merge 2 commits into
emdash-cms:mainfrom
rmakk:forms-content-mapping

Conversation

@rmakk

@rmakk rmakk commented Jul 17, 2026

Copy link
Copy Markdown

What does this PR do?

Implements the proposal from #1672: an optional contentMapping setting in FormSettings that 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.

contentMapping?: {
  collection: string;                 // target collection slug, e.g. "events"
  fieldMappings: Record<string, string | {
    field: string;
    transform?: "portableText" | "string" | "number" | "date";
  }>;
  slugFrom?: string;                  // form field to derive the entry slug from
  metadata?: Record<string, unknown>; // constant fields, e.g. { source: "form" }
};

It follows the guidance from the discussion:

  • Draft-only (@masonjames suggested "default to (or perhaps only) make a draft post" — this PR goes with only; entries are always created as drafts. A status option can be added later if wanted, but anonymous submissions publishing content directly seemed like a footgun.)
  • Mappings are validated when the form is saved, not only at submit time: the target collection must exist, mapped form/collection fields (and slugFrom) must exist, metadata keys 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.
  • The submission is preserved even when content creation fails: the entry is created after the submission is stored, and a create failure is logged, never thrown — the submitter still gets a success response.
  • Slug generation reuses the core content-create behavior. This needed two small additive changes to the plugin content API in emdash core:
    • ctx.content.create now runs slug generation exactly like the REST/admin create path — ContentRepository.generateUniqueSlug on a reserved slug key (used for slugFrom), falling back to the entry's title/name. Previously plugin-created entries always had a null slug.
    • ctx.content.getCollection(slug) (on the content:read surface) returns a collection definition with its fields, which powers the save-time validation above.
  • The forms plugin adds content:write to its declared capabilities; it is only exercised for forms that have contentMapping configured.

Scope note: this PR is the backend + validation + tests only — the mapping is configurable via the forms/create / forms/update plugin 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

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main. (n/a — no admin UI changes in this PR)
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: Create content entries from form submissions (forms plugin) #1672

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Code (Claude Fable 5), reviewed by me

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:

packages/plugins/forms:  Test Files  3 passed (3)   Tests  47 passed (47)
packages/core (tests/integration/plugins/capabilities.test.ts):  87 passed (87)

🤖 Generated with Claude Code

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-bot

changeset-bot Bot commented Jul 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8c8acc2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/plugin-forms Minor
emdash Minor
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@rmakk

rmakk commented Jul 17, 2026

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

github-actions Bot added a commit that referenced this pull request Jul 17, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. AGENTS.md violation: module-scope mutable singleton. content-mapping.ts keeps a let keyCounter = 0 counter and mutates it from generateKey(). AGENTS.md requires module-scope singletons to live on globalThis because Vite can duplicate modules across SSR chunks. Use a Symbol.for key on globalThis.
  2. Metadata constant validation is incomplete. validateContentMapping only checks that a metadata key is a real collection field. A null/undefined constant for a required target field will pass validation but fail every submit-time insert against the NOT NULL column. The validator should reject required fields covered by nullish metadata.
  3. 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 be required when the target collection field is required would close the loophole.
  4. Form duplication skips validation. formsDuplicateHandler spreads the existing definition (including contentMapping) into a new form without re-running validateContentMapping. If the target collection or fields changed since the original was saved, the duplicate’s mapping is invalid from the start.
  5. Plugin slug generation ignores site locale. ctx.content.create calls generateUniqueSlug(...) without a locale, while the REST create path scopes uniqueness to the effective locale and creates entries in that locale. Plugin-created entries therefore default to en and 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-189

    Module-scope mutable state (let keyCounter = 0) is used for Portable Text _key generation. AGENTS.md says module-scope singletons must live on globalThis because 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-76

    The metadata key check verifies the key exists as a collection field, but it does not verify the constant value. A null or undefined metadata constant for a required target field will pass validation and then fail every submit-time insert on the NOT NULL column. 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-82

    The 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 be required when 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-235

    formsDuplicateHandler creates a brand-new form by spreading the existing definition, including contentMapping, but never re-runs validateContentMapping. 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 before forms(ctx).put(id, duplicate) so duplication behaves like the other save paths.

  • [suggestion] packages/core/src/plugins/context.ts:441

    Plugin ctx.content.create derives a slug without passing a locale, while handleContentCreate scopes uniqueness to the effective locale and inserts the entry in that locale. This means plugin-created entries default to en and use global slug uniqueness, which diverges from the REST/admin path on non-English sites. Consider threading ctx.site.locale (or adding locale support to ContentWriteInput) 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>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review overlap and removed review/needs-review No maintainer or bot review yet labels Jul 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@swissky swissky added the bot:review Trigger an emdashbot code review on this PR label Jul 18, 2026
@emdashbot emdashbot Bot removed the bot:review Trigger an emdashbot code review on this PR label Jul 18, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-review confirms the PR is in good shape. The author has addressed all five findings from the previous review:

  1. Module-scope key counter is now stored on globalThis via Symbol.for (packages/plugins/forms/src/content-mapping.ts).
  2. Metadata constants for required fields now reject null/undefined at save time (packages/plugins/forms/src/content-mapping.ts).
  3. Required collection fields mapped from optional form fields are rejected (targetsWithRequiredSource check in packages/plugins/forms/src/content-mapping.ts).
  4. Form duplication now re-runs validateContentMapping before saving the duplicate (packages/plugins/forms/src/handlers/forms.ts).
  5. 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.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants