diff --git a/.changeset/forms-content-mapping.md b/.changeset/forms-content-mapping.md new file mode 100644 index 0000000000..883e4db8e4 --- /dev/null +++ b/.changeset/forms-content-mapping.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/plugin-forms": minor +--- + +Adds an optional `contentMapping` form setting that creates a draft content entry in a target collection from each successful submission, with per-field transforms (`portableText`, `string`, `number`, `date`), an optional `slugFrom` field, and constant `metadata` fields. Mappings are validated against the target collection when the form is saved — including that every required collection field is covered — and the submission is always stored in the forms inbox even if content creation fails. diff --git a/.changeset/plugin-content-create-slugs.md b/.changeset/plugin-content-create-slugs.md new file mode 100644 index 0000000000..de98629fc6 --- /dev/null +++ b/.changeset/plugin-content-create-slugs.md @@ -0,0 +1,5 @@ +--- +"emdash": minor +--- + +Adds collection schema lookup and slug generation to the plugin content API. `ctx.content.getCollection(slug)` returns a collection definition with its fields, and `ctx.content.create` now derives a unique slug the same way the admin and REST create paths do — from a reserved `slug` key when provided, falling back to the entry's `title` or `name` field. Entries are also created in the site's configured default locale instead of always `en`. Previously, plugin-created entries always had a null slug. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 488fe2789f..1c84f4d383 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -243,6 +243,8 @@ export type { PluginStorageConfig, StorageCollection, KVAccess, + CollectionFieldInfo, + CollectionInfo, ContentAccess, MediaAccess, HttpAccess, diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 8945a94748..671001f528 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -17,6 +17,7 @@ import { TaxonomyRepository, type Taxonomy } from "../database/repositories/taxo import { UserRepository } from "../database/repositories/user.js"; import { withTransaction } from "../database/transaction.js"; import type { Database } from "../database/types.js"; +import { getI18nConfig } from "../i18n/config.js"; import { resolveAndValidateExternalUrl, SsrfError, @@ -24,6 +25,7 @@ import { } from "../import/ssrf.js"; import { enrichImageMetadata } from "../media/enrich.js"; import { markContentMediaUsageCollectionStaleSafely } from "../media/usage/content-refresh.js"; +import { SchemaRegistry } from "../schema/registry.js"; import { invalidateSiteSettingsCache } from "../settings/index.js"; import type { Storage } from "../storage/types.js"; import { CronAccessImpl } from "./cron.js"; @@ -36,6 +38,7 @@ import type { KVAccess, CronAccess, EmailAccess, + CollectionInfo, ContentAccess, ContentAccessWithWrite, MediaAccess, @@ -180,6 +183,34 @@ function splitSeoFromInput(input: ContentWriteInput): { return { fields, seo }; } +/** + * Extract the reserved `seo` and `slug` keys from a plugin-supplied create + * input. `slug` is only honored on create — `update` ignores it. + */ +function splitCreateInput(input: ContentWriteInput): { + fields: Record; + seo: ContentItemSeoInput | undefined; + slug: string | undefined; +} { + const { slug, ...rest } = input; + // Reject non-string slug values rather than silently dropping them. + if (slug !== undefined && (typeof slug !== "string" || slug.length === 0)) { + throw new Error("content.slug must be a non-empty string"); + } + const { fields, seo } = splitSeoFromInput(rest); + return { fields, seo, slug }; +} + +/** + * Derive slug source text from content fields. Matches `getSlugSource` in + * the REST content-create handler (api/handlers/content.ts). + */ +function getSlugSource(data: Record): string | null { + if (typeof data.title === "string" && data.title.length > 0) return data.title; + if (typeof data.name === "string" && data.name.length > 0) return data.name; + return null; +} + /** * Reject writing SEO to a collection that does not have it enabled. * Matches the REST API behavior (VALIDATION_ERROR). @@ -236,8 +267,26 @@ function taxonomyToTermInfo(term: Taxonomy): TaxonomyTermInfo { export function createContentAccess(db: Kysely): ContentAccess { const contentRepo = new ContentRepository(db); const seoRepo = new SeoRepository(db); + const schemaRegistry = new SchemaRegistry(db); return { + async getCollection(collection: string): Promise { + const result = await schemaRegistry.getCollectionWithFields(collection); + if (!result) return null; + + return { + slug: result.slug, + label: result.label, + labelSingular: result.labelSingular ?? null, + fields: result.fields.map((field) => ({ + slug: field.slug, + label: field.label, + type: field.type, + required: field.required, + })), + }; + }, + async get(collection: string, id: string): Promise { const item = await contentRepo.findById(collection, id); if (!item) return null; @@ -374,7 +423,7 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces ...readAccess, async create(collection: string, data: ContentWriteInput): Promise { - const { fields, seo } = splitSeoFromInput(data); + const { fields, seo, slug: slugInput } = splitCreateInput(data); let contentMutated = false; try { @@ -384,9 +433,24 @@ export function createContentAccessWithWrite(db: Kysely): ContentAcces const hasSeo = await assertSeoEnabled(trxSeoRepo, collection, seo); + // Same slug generation as the REST create path: the slug + // source — the reserved `slug` key, falling back to the + // entry's title/name — is slugified and de-duplicated by + // ContentRepository.generateUniqueSlug. Entries default to + // the site's configured default locale, and slug + // uniqueness is scoped to it, again matching the REST + // path. + const effectiveLocale = getI18nConfig()?.defaultLocale; + const slugSource = slugInput ?? getSlugSource(fields); + const slug = slugSource + ? await trxContentRepo.generateUniqueSlug(collection, slugSource, effectiveLocale) + : null; + const item = await trxContentRepo.create({ type: collection, + slug, data: fields, + locale: effectiveLocale, }); contentMutated = true; diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 75b5879a4d..e908d10bd6 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -114,6 +114,8 @@ export type { SiteInfo, UserInfo, UserAccess, + CollectionFieldInfo, + CollectionInfo, ContentItem, MediaItem, ContentListOptions, diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index a9e52caba6..5cd29c3861 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -241,11 +241,41 @@ export interface ContentListOptions { * key is extracted and routed to the core SEO panel (the `_emdash_seo` * table), matching the shape accepted by the REST API. Passing `seo` for a * collection that does not have SEO enabled throws a validation error. + * + * The reserved `slug` key is honored by `create` only: it is treated as + * slug source text and run through the same slug generation the admin/REST + * create path uses (slugified, de-duplicated with a numeric suffix). When + * omitted, the slug is derived from the entry's `title` or `name` field, + * again matching the REST create path. `update` ignores the key. */ export type ContentWriteInput = Record & { seo?: ContentItemSeoInput; + slug?: string; }; +/** + * A content collection field definition, as exposed to plugins via + * `content.getCollection`. + */ +export interface CollectionFieldInfo { + slug: string; + label: string; + /** Field type slug (e.g. "text", "number", "portableText"). */ + type: string; + required: boolean; +} + +/** + * A content collection definition, as exposed to plugins via + * `content.getCollection`. + */ +export interface CollectionInfo { + slug: string; + label: string; + labelSingular: string | null; + fields: CollectionFieldInfo[]; +} + /** * Taxonomy definition returned from the taxonomy API (e.g. "category", "tag"). */ @@ -292,6 +322,8 @@ export interface ContentAccess { // Read operations (requires read:content) get(collection: string, id: string): Promise; list(collection: string, options?: ContentListOptions): Promise>; + /** Look up a collection definition (with its fields) by slug. */ + getCollection(collection: string): Promise; // Write operations (requires write:content) - optional on interface create?(collection: string, data: ContentWriteInput): Promise; diff --git a/packages/core/tests/integration/plugins/capabilities.test.ts b/packages/core/tests/integration/plugins/capabilities.test.ts index 287493f196..b085c258cf 100644 --- a/packages/core/tests/integration/plugins/capabilities.test.ts +++ b/packages/core/tests/integration/plugins/capabilities.test.ts @@ -14,6 +14,7 @@ import { runMigrations } from "../../../src/database/migrations/runner.js"; import { OptionsRepository } from "../../../src/database/repositories/options.js"; import { UserRepository } from "../../../src/database/repositories/user.js"; import type { Database as DbSchema } from "../../../src/database/types.js"; +import { setI18nConfig } from "../../../src/i18n/config.js"; import { PluginContextFactory, createContentAccess, @@ -265,6 +266,46 @@ describe("Capability Enforcement Integration (v2)", () => { }); }); + describe("getCollection", () => { + beforeEach(async () => { + await sql` + INSERT INTO _emdash_collections (id, slug, label, label_singular) + VALUES ('col-events', 'events', 'Events', 'Event') + `.execute(db); + await sql` + INSERT INTO _emdash_fields (id, collection_id, slug, label, type, column_type, required, sort_order) + VALUES + ('field-title', 'col-events', 'title', 'Title', 'text', 'TEXT', 1, 0), + ('field-body', 'col-events', 'body', 'Body', 'portableText', 'JSON', 0, 1) + `.execute(db); + }); + + it("returns the collection definition with its fields", async () => { + const access = createContentAccess(db); + const collection = await access.getCollection("events"); + + expect(collection).toEqual({ + slug: "events", + label: "Events", + labelSingular: "Event", + fields: [ + { slug: "title", label: "Title", type: "text", required: true }, + { slug: "body", label: "Body", type: "portableText", required: false }, + ], + }); + }); + + it("returns null for an unknown collection", async () => { + const access = createContentAccess(db); + expect(await access.getCollection("nonexistent")).toBeNull(); + }); + + it("is available on write access too", async () => { + const access = createContentAccessWithWrite(db); + expect((await access.getCollection("events"))?.slug).toBe("events"); + }); + }); + describe("taxonomy read access", () => { beforeEach(async () => { // Migrations seed the default `category`/`tag` defs — clear them @@ -439,6 +480,81 @@ describe("Capability Enforcement Integration (v2)", () => { const found = await access.get("posts", created.id); expect(found).not.toBeNull(); }); + + it("creates drafts by default", async () => { + const access = createContentAccessWithWrite(db); + + const created = await access.create("posts", { title: "Draft Check" }); + + expect(created.status).toBe("draft"); + }); + + it("generates a unique slug from the title, matching the REST create path", async () => { + const access = createContentAccessWithWrite(db); + + // "Hello World" slugifies to "hello-world", which the fixture + // post-1 already uses — the generator appends a suffix. + const created = await access.create("posts", { + title: "Hello World", + content: "Body", + }); + + expect(created.slug).toBe("hello-world-1"); + }); + + it("derives the slug from the reserved slug key when provided", async () => { + const access = createContentAccessWithWrite(db); + + const created = await access.create("posts", { + title: "Ignored For Slug", + slug: "My Custom Slug!", + }); + + expect(created.slug).toBe("my-custom-slug"); + }); + + it("de-duplicates slugs provided via the reserved key", async () => { + const access = createContentAccessWithWrite(db); + + const created = await access.create("posts", { + title: "Whatever", + slug: "hello-world", + }); + + expect(created.slug).toBe("hello-world-1"); + }); + + it("creates content without a slug when no source is available", async () => { + const access = createContentAccessWithWrite(db); + + const created = await access.create("posts", { content: "no title or name" }); + + expect(created.slug).toBeNull(); + }); + + it("rejects non-string slug values", async () => { + const access = createContentAccessWithWrite(db); + + await expect(access.create("posts", { title: "X", slug: 42 as never })).rejects.toThrow( + /content\.slug must be a non-empty string/, + ); + }); + + it("creates entries in the configured default locale with locale-scoped slugs", async () => { + setI18nConfig({ defaultLocale: "fr", locales: ["fr", "en"] }); + try { + const access = createContentAccessWithWrite(db); + + // "hello-world" exists only in locale "en" (fixture post-1), + // so the French entry can take the plain slug. + const created = await access.create("posts", { title: "Hello World" }); + + expect(created.locale).toBe("fr"); + expect(created.slug).toBe("hello-world"); + } finally { + setI18nConfig(null); + } + }); }); describe("SEO panel integration", () => { diff --git a/packages/plugins/forms/src/content-mapping.ts b/packages/plugins/forms/src/content-mapping.ts new file mode 100644 index 0000000000..b0169b47b9 --- /dev/null +++ b/packages/plugins/forms/src/content-mapping.ts @@ -0,0 +1,217 @@ +/** + * Submission → content entry mapping. + * + * Implements the `contentMapping` form setting: validates a mapping against + * the target collection when a form is saved, and builds a draft content + * entry from a successful submission. Content creation is additive — the + * submission is stored in the forms inbox regardless, and a create failure + * never loses the submission or fails the submit request. + */ + +import type { RouteContext } from "emdash"; +import { PluginRouteError } from "emdash"; + +import type { ContentMapping, ContentMappingTransform, FormPage } from "./types.js"; + +// ─── Save-time Validation ──────────────────────────────────────── + +/** + * Validate a content mapping against the target collection and the form's + * fields. Called when a form is saved — not only at submit time — so + * misconfigurations surface to the editor instead of silently failing + * content creation later. + * + * Throws `PluginRouteError.badRequest` describing the first problem found. + */ +export async function validateContentMapping( + ctx: RouteContext, + mapping: ContentMapping, + pages: FormPage[], +): Promise { + if (!ctx.content) { + throw PluginRouteError.internal("Content access is not available"); + } + + const collection = await ctx.content.getCollection(mapping.collection); + if (!collection) { + throw PluginRouteError.badRequest( + `Content mapping targets unknown collection "${mapping.collection}"`, + ); + } + + const formFields = new Map( + pages.flatMap((page) => page.fields.map((field) => [field.name, field] as const)), + ); + const collectionFields = new Map(collection.fields.map((field) => [field.slug, field])); + + const mappedTargets = new Set(); + const targetsWithRequiredSource = new Set(); + for (const [formField, target] of Object.entries(mapping.fieldMappings)) { + const source = formFields.get(formField); + if (!source) { + throw PluginRouteError.badRequest( + `Content mapping references unknown form field "${formField}"`, + ); + } + const targetField = typeof target === "string" ? target : target.field; + if (!collectionFields.has(targetField)) { + throw PluginRouteError.badRequest( + `Content mapping targets unknown field "${targetField}" in collection "${mapping.collection}"`, + ); + } + mappedTargets.add(targetField); + if (source.required) { + targetsWithRequiredSource.add(targetField); + } + } + + if (mapping.slugFrom && !formFields.has(mapping.slugFrom)) { + throw PluginRouteError.badRequest( + `Content mapping slugFrom references unknown form field "${mapping.slugFrom}"`, + ); + } + + // Metadata keys become entry fields, so an unknown key would make every + // submit-time create fail against the collection table — and a nullish + // constant cannot satisfy a required field. + const metadata = mapping.metadata ?? {}; + const metadataKeys = new Set(Object.keys(metadata)); + 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}"`, + ); + } + if (collectionField.required && (metadata[key] === undefined || metadata[key] === null)) { + throw PluginRouteError.badRequest( + `Content mapping metadata for required field "${key}" in collection "${mapping.collection}" must not be null`, + ); + } + } + + // Every required field of the target collection must receive a value at + // submit time: either a metadata constant (checked non-null above) or a + // mapping whose source form field is itself required — an optional form + // field left empty would create an entry missing required data. + for (const field of collection.fields) { + if (!field.required || metadataKeys.has(field.slug)) continue; + if (!mappedTargets.has(field.slug)) { + throw PluginRouteError.badRequest( + `Content mapping does not map required field "${field.slug}" in collection "${mapping.collection}"`, + ); + } + if (!targetsWithRequiredSource.has(field.slug)) { + throw PluginRouteError.badRequest( + `Content mapping maps required field "${field.slug}" in collection "${mapping.collection}" from an optional form field — mark the form field as required or cover the field with metadata`, + ); + } + } +} + +// ─── Submit-time Entry Creation ────────────────────────────────── + +/** + * Build the content entry data for a validated submission. + * + * Empty values are skipped rather than written as empty fields. When + * `slugFrom` is set, the reserved `slug` key is populated with the raw + * field value — the core content API runs it through the same slug + * generation as the admin/REST create path. + */ +export function buildContentEntry( + mapping: ContentMapping, + data: Record, +): Record { + const entry: Record = { ...mapping.metadata }; + + for (const [formField, target] of Object.entries(mapping.fieldMappings)) { + const value = data[formField]; + if (value === undefined || value === null || value === "") continue; + + const targetField = typeof target === "string" ? target : target.field; + const transform = typeof target === "string" ? undefined : target.transform; + const transformed = applyTransform(value, transform); + if (transformed !== undefined) { + entry[targetField] = transformed; + } + } + + if (mapping.slugFrom) { + const slugSource = data[mapping.slugFrom]; + if (typeof slugSource === "string" && slugSource.length > 0) { + entry.slug = slugSource; + } + } + + return entry; +} + +/** + * Coerce a submitted value for its target field. Values that cannot be + * coerced (e.g. a non-numeric string with the `number` transform) return + * `undefined` and are skipped rather than failing the whole entry. + */ +export function applyTransform(value: unknown, transform?: ContentMappingTransform): unknown { + switch (transform) { + case "portableText": + return textToPortableText(toDisplayString(value)); + case "string": + return toDisplayString(value); + case "number": { + const num = Number(value); + return Number.isNaN(num) ? undefined : num; + } + case "date": { + const parsed = Date.parse(toDisplayString(value)); + return Number.isNaN(parsed) ? undefined : new Date(parsed).toISOString(); + } + default: + return value; + } +} + +/** Join checkbox-group arrays with a comma; stringify scalars. */ +function toDisplayString(value: unknown): string { + if (Array.isArray(value)) { + return value.map((item) => String(item)).join(", "); + } + // eslint-disable-next-line typescript/no-base-to-string -- form field value is a scalar at runtime + return String(value); +} + +/** Blank line(s) separating paragraphs */ +const PARAGRAPH_BREAK_RE = /\r?\n\s*\r?\n/; + +/** + * Convert plain text to Portable Text: blank-line-separated paragraphs + * become `normal`-style blocks. + */ +export function textToPortableText(text: string): unknown[] { + const paragraphs = text + .split(PARAGRAPH_BREAK_RE) + .map((paragraph) => paragraph.trim()) + .filter((paragraph) => paragraph.length > 0); + + return paragraphs.map((paragraph) => ({ + _type: "block", + _key: generateKey(), + style: "normal", + markDefs: [], + children: [{ _type: "span", _key: generateKey(), text: paragraph, marks: [] }], + })); +} + +// The `_key` counter lives on `globalThis` because Vite can duplicate this +// module across SSR chunks — a plain module-scope variable would become two +// independent counters. +const KEY_COUNTER = Symbol.for("emdash-forms:content-mapping-key-counter"); +const g = globalThis as Record; + +/** Generate a Portable Text `_key`, unique within this process */ +function generateKey(): string { + const current = g[KEY_COUNTER]; + const next = (typeof current === "number" ? current : 0) + 1; + g[KEY_COUNTER] = next; + return `form-${next.toString(36)}-${Math.random().toString(36).slice(2, 7)}`; +} diff --git a/packages/plugins/forms/src/handlers/forms.ts b/packages/plugins/forms/src/handlers/forms.ts index bceea9bbdc..0054cea8cc 100644 --- a/packages/plugins/forms/src/handlers/forms.ts +++ b/packages/plugins/forms/src/handlers/forms.ts @@ -8,13 +8,14 @@ import type { RouteContext, StorageCollection } from "emdash"; import { PluginRouteError } from "emdash"; import { ulid } from "ulidx"; +import { validateContentMapping } from "../content-mapping.js"; import type { FormCreateInput, FormDeleteInput, FormDuplicateInput, FormUpdateInput, } from "../schemas.js"; -import type { FormDefinition } from "../types.js"; +import type { FormDefinition, FormSettings } from "../types.js"; /** Typed access to plugin storage collections */ function forms(ctx: RouteContext): StorageCollection { @@ -57,6 +58,13 @@ export async function formsCreateHandler(ctx: RouteContext) { // Validate field names are unique across all pages validateFieldNames(input.pages); + // Validate the content mapping against the target collection at save + // time — not only at submit time — so misconfigurations surface to the + // editor immediately. + if (input.settings.contentMapping) { + await validateContentMapping(ctx, input.settings.contentMapping, input.pages); + } + const now = new Date().toISOString(); const id = ulid(); const form: FormDefinition = { @@ -76,6 +84,7 @@ export async function formsCreateHandler(ctx: RouteContext) { submitLabel: input.settings.submitLabel ?? "Submit", nextLabel: input.settings.nextLabel, prevLabel: input.settings.prevLabel, + contentMapping: input.settings.contentMapping ?? undefined, }, status: "active", submissionCount: 0, @@ -126,7 +135,7 @@ export async function formsUpdateHandler(ctx: RouteContext) { name: input.name ?? existing.name, slug: input.slug ?? existing.slug, pages: input.pages ?? existing.pages, - settings: input.settings ? { ...existing.settings, ...input.settings } : existing.settings, + settings: mergeSettings(existing.settings, input.settings), status: input.status ?? existing.status, updatedAt: new Date().toISOString(), }; @@ -135,6 +144,12 @@ export async function formsUpdateHandler(ctx: RouteContext) { if (updated.settings.redirectUrl === "") updated.settings.redirectUrl = undefined; if (updated.settings.webhookUrl === "") updated.settings.webhookUrl = undefined; + // Validate the merged result so page edits that break an existing + // mapping (e.g. renaming a mapped form field) are caught at save time. + if (updated.settings.contentMapping) { + await validateContentMapping(ctx, updated.settings.contentMapping, updated.pages); + } + await forms(ctx).put(input.id, updated); // Update digest cron if settings changed @@ -217,6 +232,12 @@ export async function formsDuplicateHandler(ctx: RouteContext }>) { const names = new Set(); for (const page of pages) { diff --git a/packages/plugins/forms/src/handlers/submit.ts b/packages/plugins/forms/src/handlers/submit.ts index 9b4d0054fb..56069665c9 100644 --- a/packages/plugins/forms/src/handlers/submit.ts +++ b/packages/plugins/forms/src/handlers/submit.ts @@ -9,6 +9,7 @@ import type { RouteContext, StorageCollection } from "emdash"; import { PluginRouteError } from "emdash"; import { ulid } from "ulidx"; +import { buildContentEntry } from "../content-mapping.js"; import { formatSubmissionText, formatWebhookPayload } from "../format.js"; import type { SubmitInput } from "../schemas.js"; import { verifyTurnstile } from "../turnstile.js"; @@ -184,7 +185,24 @@ export async function submitHandler(ctx: RouteContext) { lastSubmissionAt: new Date().toISOString(), }); - // 7. Immediate email notifications (not digest) + // 7. Create a draft content entry when the form maps submissions to a + // collection. The submission is already stored above — a create failure + // must never lose it or fail the request, so errors are logged instead + // of thrown. + if (settings.contentMapping && ctx.content?.create) { + try { + const entry = buildContentEntry(settings.contentMapping, result.data); + await ctx.content.create(settings.contentMapping.collection, entry); + } catch (err: unknown) { + ctx.log.error("Failed to create content entry from submission", { + error: String(err), + submissionId, + collection: settings.contentMapping.collection, + }); + } + } + + // 8. Immediate email notifications (not digest) if (settings.notifyEmails.length > 0 && !settings.digestEnabled && ctx.email) { const text = formatSubmissionText(form, result.data, files); for (const email of settings.notifyEmails) { @@ -203,7 +221,7 @@ export async function submitHandler(ctx: RouteContext) { } } - // 8. Autoresponder + // 9. Autoresponder if (settings.autoresponder && ctx.email) { const emailField = allFields.find((f) => f.type === "email"); const submitterEmail = emailField ? result.data[emailField.name] : null; @@ -220,7 +238,7 @@ export async function submitHandler(ctx: RouteContext) { } } - // 9. Webhook (fire and forget) + // 10. Webhook (fire and forget) if (settings.webhookUrl && ctx.http) { const payload = formatWebhookPayload(form, submissionId, result.data, files); ctx.http @@ -237,7 +255,7 @@ export async function submitHandler(ctx: RouteContext) { }); } - // 10. Return success + // 11. Return success return { success: true, message: settings.confirmationMessage, diff --git a/packages/plugins/forms/src/index.ts b/packages/plugins/forms/src/index.ts index 3234c5f94f..6576ff4464 100644 --- a/packages/plugins/forms/src/index.ts +++ b/packages/plugins/forms/src/index.ts @@ -72,7 +72,7 @@ export function formsPlugin( adminEntry: "@emdash-cms/plugin-forms/admin", componentsEntry: "@emdash-cms/plugin-forms/astro", options, - capabilities: ["email:send", "media:write", "network:request"], + capabilities: ["content:write", "email:send", "media:write", "network:request"], allowedHosts: ["*"], adminPages: [ { path: "/", label: "Forms", icon: "list" }, @@ -93,7 +93,9 @@ export function createPlugin(_options: FormsPluginOptions = {}): ResolvedPlugin return definePlugin({ id: "emdash-forms", version, - capabilities: ["email:send", "media:write", "network:request"], + // content:write powers the optional per-form content mapping — it is + // only exercised for forms that have `contentMapping` configured. + capabilities: ["content:write", "email:send", "media:write", "network:request"], allowedHosts: ["*"], storage: FORMS_STORAGE_CONFIG, diff --git a/packages/plugins/forms/src/schemas.ts b/packages/plugins/forms/src/schemas.ts index b693c9dabe..0c0f35bb33 100644 --- a/packages/plugins/forms/src/schemas.ts +++ b/packages/plugins/forms/src/schemas.ts @@ -89,6 +89,23 @@ const autoresponderSchema = z }) .optional(); +const contentFieldMappingSchema = z.union([ + z.string().min(1), + z.object({ + field: z.string().min(1), + transform: z.enum(["portableText", "string", "number", "date"]).optional(), + }), +]); + +const contentMappingSchema = z.object({ + collection: z.string().min(1), + fieldMappings: z + .record(z.string().min(1), contentFieldMappingSchema) + .refine((m) => Object.keys(m).length > 0, "At least one field mapping is required"), + slugFrom: z.string().min(1).optional(), + metadata: z.record(z.string().min(1), z.unknown()).optional(), +}); + const formSettingsSchema = z.object({ confirmationMessage: z.string().min(1).default("Thank you for your submission."), redirectUrl: httpUrl.optional().or(z.literal("")), @@ -102,6 +119,8 @@ const formSettingsSchema = z.object({ submitLabel: z.string().min(1).default("Submit"), nextLabel: z.string().optional(), prevLabel: z.string().optional(), + // null clears the mapping on update + contentMapping: contentMappingSchema.nullable().optional(), }); // ─── Form CRUD Schemas ────────────────────────────────────────── diff --git a/packages/plugins/forms/src/types.ts b/packages/plugins/forms/src/types.ts index 6de927aa8d..654271d8ff 100644 --- a/packages/plugins/forms/src/types.ts +++ b/packages/plugins/forms/src/types.ts @@ -52,8 +52,40 @@ export interface FormSettings { nextLabel?: string; /** Label for Previous button on multi-page forms */ prevLabel?: string; + /** Create a draft content entry from each successful submission */ + contentMapping?: ContentMapping; } +// ─── Content Mapping ───────────────────────────────────────────── + +/** + * Maps form submissions to content entries. + * + * When configured, each successful submission also creates a draft entry + * in the target collection. The submission is always stored in the forms + * inbox regardless — content creation is additive and a create failure + * never loses the submission or fails the submit request. + */ +export interface ContentMapping { + /** Target collection slug, e.g. "events" */ + collection: string; + /** Form field name → target collection field (optionally with a transform) */ + fieldMappings: Record; + /** Form field whose value derives the entry slug */ + slugFrom?: string; + /** Constant fields merged into every created entry, e.g. { source: "form" } */ + metadata?: Record; +} + +export interface ContentFieldMapping { + /** Target collection field slug */ + field: string; + /** Coerce the submitted value before writing it to the entry */ + transform?: ContentMappingTransform; +} + +export type ContentMappingTransform = "portableText" | "string" | "number" | "date"; + // ─── Form Fields ───────────────────────────────────────────────── export interface FormField { diff --git a/packages/plugins/forms/tests/content-mapping.test.ts b/packages/plugins/forms/tests/content-mapping.test.ts new file mode 100644 index 0000000000..0200d7869a --- /dev/null +++ b/packages/plugins/forms/tests/content-mapping.test.ts @@ -0,0 +1,607 @@ +/** + * Tests for the submission → content entry mapping. + * + * Covers the behaviors agreed in discussion #1672: no mapping keeps the + * 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 never lose the submission. + */ + +import type { CollectionInfo, RouteContext } from "emdash"; +import { describe, expect, it, vi } from "vitest"; + +import { + applyTransform, + buildContentEntry, + textToPortableText, + validateContentMapping, +} from "../src/content-mapping.js"; +import { + formsCreateHandler, + formsDuplicateHandler, + formsUpdateHandler, +} from "../src/handlers/forms.js"; +import { submitHandler } from "../src/handlers/submit.js"; +import type { + FormCreateInput, + FormDuplicateInput, + FormUpdateInput, + SubmitInput, +} from "../src/schemas.js"; +import type { ContentMapping, FormDefinition, FormField, FormPage } from "../src/types.js"; + +// ─── Fixtures ──────────────────────────────────────────────────── + +function makeField(overrides: Partial & { name: string }): FormField { + return { + id: overrides.name, + type: "text", + label: overrides.name, + required: false, + width: "full", + ...overrides, + }; +} + +const formPages: FormPage[] = [ + { + fields: [ + makeField({ name: "event_title", required: true }), + makeField({ name: "event_details", type: "textarea" }), + makeField({ name: "attendee_count", type: "number" }), + makeField({ name: "event_date", type: "date" }), + ], + }, +]; + +const eventsCollection: CollectionInfo = { + slug: "events", + label: "Events", + labelSingular: "Event", + fields: [ + { slug: "title", label: "Title", type: "text", required: true }, + { slug: "body", label: "Body", type: "portableText", required: false }, + { slug: "attendees", label: "Attendees", type: "number", required: false }, + { slug: "starts_at", label: "Starts at", type: "date", required: false }, + { slug: "source", label: "Source", type: "text", required: false }, + ], +}; + +const validMapping: ContentMapping = { + collection: "events", + fieldMappings: { + event_title: "title", + event_details: { field: "body", transform: "portableText" }, + attendee_count: { field: "attendees", transform: "number" }, + }, + slugFrom: "event_title", + metadata: { source: "form" }, +}; + +function makeForm(overrides: Partial = {}): FormDefinition { + return { + name: "Event Submission", + slug: "event-submission", + pages: formPages, + settings: { + confirmationMessage: "Thanks", + notifyEmails: [], + digestEnabled: false, + digestHour: 9, + retentionDays: 0, + spamProtection: "none", + submitLabel: "Submit", + }, + status: "active", + submissionCount: 0, + lastSubmissionAt: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +// ─── Test Context ──────────────────────────────────────────────── + +interface MemoryCollection { + items: Map; + get(id: string): Promise; + put(id: string, data: unknown): Promise; + delete(id: string): Promise; + query(options?: { + where?: Record; + limit?: number; + }): Promise<{ items: Array<{ id: string; data: unknown }>; hasMore: boolean; cursor?: string }>; + count(where?: Record): Promise; +} + +function createMemoryCollection(): MemoryCollection { + const items = new Map(); + + async function query(options: { where?: Record; limit?: number } = {}) { + const matched = [...items.entries()].filter(([, data]) => + Object.entries(options.where ?? {}).every( + (entry) => (data as Record)[entry[0]] === entry[1], + ), + ); + return { + items: matched.map(([id, data]) => ({ id, data })), + hasMore: false, + cursor: undefined, + }; + } + + return { + items, + async get(id: string) { + return items.get(id) ?? null; + }, + async put(id: string, data: unknown) { + items.set(id, data); + }, + async delete(id: string) { + return items.delete(id); + }, + query, + async count(where: Record = {}) { + return (await query({ where })).items.length; + }, + }; +} + +interface TestContext { + ctx: RouteContext; + forms: MemoryCollection; + submissions: MemoryCollection; + created: Array<{ collection: string; data: Record }>; + log: { error: ReturnType }; +} + +function createTestContext( + input: TInput, + options: { + collections?: Record; + failCreate?: boolean; + } = {}, +): TestContext { + const forms = createMemoryCollection(); + const submissions = createMemoryCollection(); + const created: Array<{ collection: string; data: Record }> = []; + const log = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const collections = options.collections ?? { events: eventsCollection }; + + const content = { + async get() { + return null; + }, + async list() { + return { items: [], hasMore: false }; + }, + async getCollection(slug: string) { + return collections[slug] ?? null; + }, + async create(collection: string, data: Record) { + if (options.failCreate) { + throw new Error("content create failed"); + } + created.push({ collection, data }); + return { + id: "entry-1", + type: collection, + slug: null, + status: "draft", + locale: "en", + data, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + publishedAt: null, + }; + }, + }; + + const ctx = { + plugin: { id: "emdash-forms", version: "0.0.0" }, + storage: { forms, submissions }, + kv: { + async get() { + return null; + }, + async set() {}, + async delete() {}, + async list() { + return []; + }, + }, + content, + log, + site: { name: "Test Site", url: "https://example.com", locale: "en" }, + url: (path: string) => `https://example.com${path}`, + input, + request: new Request("https://example.com/submit"), + requestMeta: { ip: null, userAgent: null, referer: null, geo: undefined }, + } as unknown as RouteContext; + + return { ctx, forms, submissions, created, log }; +} + +// ─── Transforms ────────────────────────────────────────────────── + +describe("applyTransform", () => { + it("passes values through without a transform", () => { + expect(applyTransform("hello")).toBe("hello"); + expect(applyTransform(5)).toBe(5); + }); + + it("stringifies values with the string transform", () => { + expect(applyTransform(42, "string")).toBe("42"); + }); + + it("joins checkbox-group arrays with the string transform", () => { + expect(applyTransform(["news", "sports"], "string")).toBe("news, sports"); + }); + + it("coerces numeric strings with the number transform", () => { + expect(applyTransform("42", "number")).toBe(42); + }); + + it("skips non-numeric values with the number transform", () => { + expect(applyTransform("not a number", "number")).toBeUndefined(); + }); + + it("converts parseable dates to ISO strings with the date transform", () => { + expect(applyTransform("2026-08-01", "date")).toBe("2026-08-01T00:00:00.000Z"); + }); + + it("skips unparseable values with the date transform", () => { + expect(applyTransform("not a date", "date")).toBeUndefined(); + }); + + it("converts text to Portable Text blocks with the portableText transform", () => { + const blocks = applyTransform("First paragraph.\n\nSecond paragraph.", "portableText"); + + expect(blocks).toMatchObject([ + { + _type: "block", + style: "normal", + markDefs: [], + children: [{ _type: "span", text: "First paragraph.", marks: [] }], + }, + { + _type: "block", + style: "normal", + markDefs: [], + children: [{ _type: "span", text: "Second paragraph.", marks: [] }], + }, + ]); + }); +}); + +describe("textToPortableText", () => { + it("returns an empty array for blank text", () => { + expect(textToPortableText(" \n\n ")).toEqual([]); + }); + + it("assigns unique keys to blocks and spans", () => { + const blocks = textToPortableText("One.\n\nTwo.") as Array<{ + _key: string; + children: Array<{ _key: string }>; + }>; + + const keys = blocks.flatMap((block) => [block._key, ...block.children.map((c) => c._key)]); + expect(new Set(keys).size).toBe(keys.length); + }); +}); + +// ─── Building Entries ──────────────────────────────────────────── + +describe("buildContentEntry", () => { + it("maps submitted values onto target fields", () => { + const entry = buildContentEntry(validMapping, { + event_title: "Community BBQ", + attendee_count: 25, + }); + + expect(entry.title).toBe("Community BBQ"); + expect(entry.attendees).toBe(25); + }); + + it("merges metadata constants into the entry", () => { + const entry = buildContentEntry(validMapping, { event_title: "Community BBQ" }); + + expect(entry.source).toBe("form"); + }); + + it("sets the reserved slug key from slugFrom", () => { + const entry = buildContentEntry(validMapping, { event_title: "Community BBQ" }); + + expect(entry.slug).toBe("Community BBQ"); + }); + + it("skips empty and missing values", () => { + const entry = buildContentEntry(validMapping, { + event_title: "Community BBQ", + event_details: "", + }); + + expect(entry).not.toHaveProperty("body"); + expect(entry).not.toHaveProperty("attendees"); + }); +}); + +// ─── Save-time Validation ──────────────────────────────────────── + +describe("validateContentMapping", () => { + it("accepts a valid mapping", async () => { + const { ctx } = createTestContext(undefined); + + await expect(validateContentMapping(ctx, validMapping, formPages)).resolves.toBeUndefined(); + }); + + it("rejects an unknown target collection", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping(ctx, { ...validMapping, collection: "missing" }, formPages), + ).rejects.toThrow(/unknown collection "missing"/); + }); + + it("rejects a mapping from an unknown form field", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping( + ctx, + { ...validMapping, fieldMappings: { ...validMapping.fieldMappings, nope: "title" } }, + formPages, + ), + ).rejects.toThrow(/unknown form field "nope"/); + }); + + it("rejects a mapping onto an unknown collection field", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping( + ctx, + { ...validMapping, fieldMappings: { event_title: "title", event_date: "nope" } }, + formPages, + ), + ).rejects.toThrow(/unknown field "nope"/); + }); + + it("rejects a slugFrom that references an unknown form field", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping(ctx, { ...validMapping, slugFrom: "nope" }, formPages), + ).rejects.toThrow(/slugFrom references unknown form field "nope"/); + }); + + it("rejects metadata that targets an unknown collection field", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping(ctx, { ...validMapping, metadata: { nope: 1 } }, formPages), + ).rejects.toThrow(/metadata targets unknown field "nope"/); + }); + + it("rejects a mapping that leaves a required collection field unmapped", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping( + ctx, + { collection: "events", fieldMappings: { event_details: "body" } }, + formPages, + ), + ).rejects.toThrow(/does not map required field "title"/); + }); + + it("accepts a required collection field covered by metadata", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping( + ctx, + { + collection: "events", + fieldMappings: { event_details: "body" }, + metadata: { title: "Form submission" }, + }, + formPages, + ), + ).resolves.toBeUndefined(); + }); + + it("rejects a nullish metadata constant for a required collection field", async () => { + const { ctx } = createTestContext(undefined); + + await expect( + validateContentMapping( + ctx, + { + collection: "events", + fieldMappings: { event_details: "body" }, + metadata: { title: null }, + }, + formPages, + ), + ).rejects.toThrow( + /metadata for required field "title" in collection "events" must not be null/, + ); + }); + + it("rejects a required collection field mapped from an optional form field", async () => { + const { ctx } = createTestContext(undefined); + + // event_details is optional — an empty submission would leave the + // required "title" field without a value. + await expect( + validateContentMapping( + ctx, + { collection: "events", fieldMappings: { event_details: "title" } }, + formPages, + ), + ).rejects.toThrow(/from an optional form field/); + }); +}); + +// ─── Form Save Handlers ────────────────────────────────────────── + +function makeCreateInput(mapping: ContentMapping | undefined): FormCreateInput { + return { + name: "Event Submission", + slug: "event-submission", + pages: formPages, + settings: { + confirmationMessage: "Thanks", + notifyEmails: [], + digestEnabled: false, + digestHour: 9, + retentionDays: 0, + spamProtection: "none", + submitLabel: "Submit", + contentMapping: mapping, + }, + }; +} + +describe("formsCreateHandler with content mapping", () => { + it("creates a form with a valid mapping", async () => { + const { ctx, forms } = createTestContext(makeCreateInput(validMapping)); + + const result = await formsCreateHandler(ctx); + + expect(result.settings.contentMapping).toEqual(validMapping); + expect(forms.items.size).toBe(1); + }); + + it("rejects an invalid mapping at save time", async () => { + const { ctx, forms } = createTestContext( + makeCreateInput({ ...validMapping, collection: "missing" }), + ); + + await expect(formsCreateHandler(ctx)).rejects.toThrow(/unknown collection "missing"/); + expect(forms.items.size).toBe(0); + }); +}); + +describe("formsUpdateHandler with content mapping", () => { + it("rejects a page edit that breaks an existing mapping", async () => { + const input: FormUpdateInput = { + id: "form-1", + pages: [{ fields: [makeField({ name: "renamed_title", required: true })] }], + }; + const { ctx, forms } = createTestContext(input); + await forms.put( + "form-1", + makeForm({ settings: { ...makeForm().settings, contentMapping: validMapping } }), + ); + + await expect(formsUpdateHandler(ctx)).rejects.toThrow(/unknown form field "event_title"/); + }); + + it("clears the mapping when contentMapping is null", async () => { + const input: FormUpdateInput = { + id: "form-1", + settings: { contentMapping: null }, + }; + const { ctx, forms } = createTestContext(input); + await forms.put( + "form-1", + makeForm({ settings: { ...makeForm().settings, contentMapping: validMapping } }), + ); + + const result = await formsUpdateHandler(ctx); + + expect(result.settings.contentMapping).toBeUndefined(); + }); +}); + +describe("formsDuplicateHandler with content mapping", () => { + it("re-validates the mapping when duplicating a form", async () => { + const input: FormDuplicateInput = { id: "form-1" }; + // The target collection no longer exists, so the stale mapping must + // be rejected instead of copied into the duplicate. + const { ctx, forms } = createTestContext(input, { collections: {} }); + await forms.put( + "form-1", + makeForm({ settings: { ...makeForm().settings, contentMapping: validMapping } }), + ); + + await expect(formsDuplicateHandler(ctx)).rejects.toThrow(/unknown collection "events"/); + }); +}); + +// ─── Submit Handler ────────────────────────────────────────────── + +function makeSubmitInput(data: Record): SubmitInput { + return { formId: "form-1", data }; +} + +describe("submitHandler with content mapping", () => { + const submission = { + event_title: "Community BBQ", + event_details: "Bring a dish.\n\nAll welcome.", + attendee_count: "25", + }; + + it("keeps the current behavior when no mapping is configured", async () => { + const { ctx, forms, submissions, created } = createTestContext(makeSubmitInput(submission)); + await forms.put("form-1", makeForm()); + + const result = await submitHandler(ctx); + + expect(result).toMatchObject({ success: true }); + expect(submissions.items.size).toBe(1); + expect(created).toHaveLength(0); + }); + + it("creates a draft entry and keeps the inbox submission", async () => { + const { ctx, forms, submissions, created } = createTestContext(makeSubmitInput(submission)); + await forms.put( + "form-1", + makeForm({ settings: { ...makeForm().settings, contentMapping: validMapping } }), + ); + + const result = await submitHandler(ctx); + + expect(result).toMatchObject({ success: true }); + // The submission is stored in the inbox regardless of the mapping + expect(submissions.items.size).toBe(1); + + expect(created).toHaveLength(1); + expect(created[0]!.collection).toBe("events"); + expect(created[0]!.data).toMatchObject({ + title: "Community BBQ", + attendees: 25, + source: "form", + slug: "Community BBQ", + }); + expect(created[0]!.data.body).toMatchObject([ + { _type: "block", children: [{ _type: "span", text: "Bring a dish." }] }, + { _type: "block", children: [{ _type: "span", text: "All welcome." }] }, + ]); + }); + + it("preserves the submission and still succeeds when content creation fails", async () => { + const { ctx, forms, submissions, created, log } = createTestContext( + makeSubmitInput(submission), + { failCreate: true }, + ); + await forms.put( + "form-1", + makeForm({ settings: { ...makeForm().settings, contentMapping: validMapping } }), + ); + + const result = await submitHandler(ctx); + + expect(result).toMatchObject({ success: true }); + expect(submissions.items.size).toBe(1); + expect(created).toHaveLength(0); + expect(log.error).toHaveBeenCalledWith( + "Failed to create content entry from submission", + expect.objectContaining({ collection: "events" }), + ); + }); +});