@10x-media/form-builder 0.1.0-beta.20 → 0.1.0-beta.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/actions/defineAction.d.ts +9 -0
  3. package/dist/actions/defineAction.js.map +1 -1
  4. package/dist/actions/dispatch.js +49 -5
  5. package/dist/actions/dispatch.js.map +1 -1
  6. package/dist/actions/dispatchContext.js +13 -0
  7. package/dist/actions/dispatchContext.js.map +1 -0
  8. package/dist/actions/registry.js +3 -1
  9. package/dist/actions/registry.js.map +1 -1
  10. package/dist/actions/task.js +8 -2
  11. package/dist/actions/task.js.map +1 -1
  12. package/dist/client/ConsentRetentionNotice.d.ts +11 -0
  13. package/dist/client/ConsentRetentionNotice.js +23 -0
  14. package/dist/client/ConsentRetentionNotice.js.map +1 -0
  15. package/dist/collections/formSubmissions.js +26 -1
  16. package/dist/collections/formSubmissions.js.map +1 -1
  17. package/dist/collections/settingsFields.d.ts +2 -1
  18. package/dist/collections/settingsFields.js +11 -1
  19. package/dist/collections/settingsFields.js.map +1 -1
  20. package/dist/exports/client.d.ts +2 -1
  21. package/dist/exports/client.js +2 -1
  22. package/dist/react/state.d.ts +6 -0
  23. package/dist/react/state.js +8 -2
  24. package/dist/react/state.js.map +1 -1
  25. package/dist/react/submitForm.js +1 -1
  26. package/dist/react/submitForm.js.map +1 -1
  27. package/dist/react/useField.js +2 -1
  28. package/dist/react/useField.js.map +1 -1
  29. package/dist/submissions/voteChangeEndpoint.js +6 -1
  30. package/dist/submissions/voteChangeEndpoint.js.map +1 -1
  31. package/dist/translations/de.js +3 -0
  32. package/dist/translations/de.js.map +1 -1
  33. package/dist/translations/en.js +3 -0
  34. package/dist/translations/en.js.map +1 -1
  35. package/dist/translations/keys.d.ts +3 -0
  36. package/dist/translations/keys.js +3 -0
  37. package/dist/translations/keys.js.map +1 -1
  38. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.22
4
+
5
+ ### Minor Changes
6
+
7
+ - Two retention gaps made visible. A non-persisting form that carries a consent field now shows an admin sidebar notice saying the consent proof is discarded with the pruned row, since that combination is only right when the consent record lives elsewhere (a double opt-in provider); it stays a notice, not a save error, because that setup is legitimate. And submissions kept after an essential action failed are now stamped `actionFailed: true` (indexed, read-only), so an operator can filter the accumulated rows, replay the addresses once the provider recovers, and clear them.
8
+
9
+ ## 0.1.0-beta.21
10
+
11
+ ### Minor Changes
12
+
13
+ - Two submit/validate fixes. Actions can declare `essential: true` on `defineAction`: an essential action runs inline before the response (never queued, bounded by the dispatch deadline), its failure or timeout turns the submit into an error the visitor sees with a translated plugin message, the remaining actions are skipped, and the submission is kept even on a `persistSubmissions: false` form so a failed provider handoff never loses what the visitor sent. Fire-and-forget actions are unchanged. And blurring a pristine field no longer reveals its required error: reveal now needs the field to be dirty (changed since mount or the last reset) or its step submitted, which also stops a freshly reset form from showing errors when clicked.
14
+
3
15
  ## 0.1.0-beta.20
4
16
 
5
17
  ### Minor Changes
@@ -29,6 +29,15 @@ type ActionDefinition<TConfig extends Record<string, unknown> = Record<string, u
29
29
  type: string; /** i18n-key or literal (resolved like a field label), or a per-locale record. */
30
30
  label: string | Record<string, string>;
31
31
  config?: Field[];
32
+ /**
33
+ * This action's failure is the submission's failure: it runs inline before the response (never
34
+ * queued, bounded by the dispatch deadline), a throw or timeout turns the submit into an error
35
+ * the visitor sees, the remaining actions are skipped, and the submission is kept even on a
36
+ * `persistSubmissions: false` form so what the visitor sent is never lost. For an action that
37
+ * IS the point of the submission (a signup provider that is the system of record); leave unset
38
+ * for notifications and other fire-and-forget work.
39
+ */
40
+ essential?: boolean;
32
41
  run: (args: ActionRunArgs<TConfig>) => Promise<void> | void;
33
42
  };
34
43
  /** Erased shape stored in the registry; config re-narrows per matched type at execution. */
@@ -1 +1 @@
1
- {"version":3,"file":"defineAction.js","names":[],"sources":["../../src/actions/defineAction.ts"],"sourcesContent":["import type { Field, Payload, PayloadRequest } from 'payload'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\n\n/** Context passed to an action's `run` when a submission completes. */\nexport type ActionRunArgs<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\tform: { id: number | string; title?: string }\n\tsubmissionId: number | string\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\t/** The verified form-context reference the submission was made through, or null when it had none. */\n\tcontext: FormContextReference | null\n\tconfig: TConfig\n\tpayload: Payload\n\treq?: PayloadRequest\n\tlocale: string\n\tt: Translate\n\t/** Serialize a rich text (or legacy string) body config into channel-ready HTML. */\n\trenderBody: (body: unknown) => Promise<string>\n}\n\n/**\n * A post-submit action type, authored once: `config` is the admin `Field[]` for authoring;\n * `run` executes when a submission completes. Built-ins use this same primitive.\n */\nexport type ActionDefinition<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\ttype: string\n\t/** i18n-key or literal (resolved like a field label), or a per-locale record. */\n\tlabel: string | Record<string, string>\n\tconfig?: Field[]\n\trun: (args: ActionRunArgs<TConfig>) => Promise<void> | void\n}\n\n/** Erased shape stored in the registry; config re-narrows per matched type at execution. */\nexport type AnyActionDefinition = ActionDefinition<Record<string, unknown>>\n\nexport const defineAction = <TConfig extends Record<string, unknown> = Record<string, unknown>>(\n\tdefinition: ActionDefinition<TConfig>\n): ActionDefinition<TConfig> => definition\n"],"mappings":";AAqCA,MAAa,gBACZ,eAC+B"}
1
+ {"version":3,"file":"defineAction.js","names":[],"sources":["../../src/actions/defineAction.ts"],"sourcesContent":["import type { Field, Payload, PayloadRequest } from 'payload'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\n\n/** Context passed to an action's `run` when a submission completes. */\nexport type ActionRunArgs<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\tform: { id: number | string; title?: string }\n\tsubmissionId: number | string\n\tvalues: SubmissionValue[]\n\tdescriptors: SubmissionDescriptor[]\n\t/** The verified form-context reference the submission was made through, or null when it had none. */\n\tcontext: FormContextReference | null\n\tconfig: TConfig\n\tpayload: Payload\n\treq?: PayloadRequest\n\tlocale: string\n\tt: Translate\n\t/** Serialize a rich text (or legacy string) body config into channel-ready HTML. */\n\trenderBody: (body: unknown) => Promise<string>\n}\n\n/**\n * A post-submit action type, authored once: `config` is the admin `Field[]` for authoring;\n * `run` executes when a submission completes. Built-ins use this same primitive.\n */\nexport type ActionDefinition<TConfig extends Record<string, unknown> = Record<string, unknown>> = {\n\ttype: string\n\t/** i18n-key or literal (resolved like a field label), or a per-locale record. */\n\tlabel: string | Record<string, string>\n\tconfig?: Field[]\n\t/**\n\t * This action's failure is the submission's failure: it runs inline before the response (never\n\t * queued, bounded by the dispatch deadline), a throw or timeout turns the submit into an error\n\t * the visitor sees, the remaining actions are skipped, and the submission is kept even on a\n\t * `persistSubmissions: false` form so what the visitor sent is never lost. For an action that\n\t * IS the point of the submission (a signup provider that is the system of record); leave unset\n\t * for notifications and other fire-and-forget work.\n\t */\n\tessential?: boolean\n\trun: (args: ActionRunArgs<TConfig>) => Promise<void> | void\n}\n\n/** Erased shape stored in the registry; config re-narrows per matched type at execution. */\nexport type AnyActionDefinition = ActionDefinition<Record<string, unknown>>\n\nexport const defineAction = <TConfig extends Record<string, unknown> = Record<string, unknown>>(\n\tdefinition: ActionDefinition<TConfig>\n): ActionDefinition<TConfig> => definition\n"],"mappings":";AA8CA,MAAa,gBACZ,eAC+B"}
@@ -1,3 +1,5 @@
1
+ import { isEssentialAction } from "./registry.js";
2
+ import { ESSENTIAL_ACTION_FAILED_CONTEXT_KEY } from "./dispatchContext.js";
1
3
  import { ACTIONS_TASK_SLUG, runActionsForSubmission } from "./task.js";
2
4
  const canQueue = (payload) => typeof payload.jobs?.queue === "function";
3
5
  const deadline = (ms) => new Promise((resolve) => {
@@ -8,31 +10,72 @@ const deadline = (ms) => new Promise((resolve) => {
8
10
  * action work. With no actions, returns immediately. When a job runner is present, enqueues the native
9
11
  * `form-builder-actions` task and returns (action work happens out of band). Otherwise runs the actions
10
12
  * inline but bounded by a deadline so a missing worker still delivers without hanging the request; any
11
- * error is swallowed (logged via `payload.logger`). Never rejects.
13
+ * error is swallowed (logged via `payload.logger`). Never rejects; the return reports whether an
14
+ * essential pass failed, so the caller can withhold success signals (the created event, the 201).
12
15
  */
13
16
  const dispatchActions = async (args) => {
14
17
  const { payload, registry, req, formId, submissionId } = args;
15
- if ((args.actions ?? []).length === 0 && args.persistSubmissions !== false) return;
18
+ const actions = args.actions ?? [];
19
+ const hasEssential = actions.some((instance) => isEssentialAction(registry, instance));
20
+ if (hasEssential) {
21
+ const ms = args.deadlineMs ?? 5e3;
22
+ const work = runActionsForSubmission({
23
+ input: {
24
+ formId,
25
+ submissionId,
26
+ subset: "essential"
27
+ },
28
+ registry,
29
+ payload,
30
+ req,
31
+ richText: args.richText
32
+ }).then((results) => ({
33
+ timedOut: false,
34
+ failed: results.some((result) => !result.ok)
35
+ }), (error) => {
36
+ payload.logger?.error(`@10x-media/form-builder: essential action pass for submission ${String(submissionId)} threw: ${error instanceof Error ? error.message : String(error)}`);
37
+ return {
38
+ timedOut: false,
39
+ failed: true
40
+ };
41
+ });
42
+ const outcome = await Promise.race([work, deadline(ms).then(() => ({
43
+ timedOut: true,
44
+ failed: true
45
+ }))]);
46
+ if (outcome.failed) {
47
+ if (outcome.timedOut) payload.logger?.error(`@10x-media/form-builder: essential action pass for submission ${String(submissionId)} outlived its ${ms}ms deadline; treating the submission as failed (the work may still complete)`);
48
+ if (req) req.context = {
49
+ ...req.context ?? {},
50
+ [ESSENTIAL_ACTION_FAILED_CONTEXT_KEY]: true
51
+ };
52
+ return { essentialFailed: true };
53
+ }
54
+ }
55
+ if ((hasEssential ? actions.filter((instance) => !isEssentialAction(registry, instance)) : actions).length === 0 && args.persistSubmissions !== false) return { essentialFailed: false };
56
+ const subset = hasEssential ? { subset: "rest" } : {};
16
57
  if (args.hasRunner && canQueue(payload)) {
17
58
  try {
18
59
  await payload.jobs.queue({
19
60
  task: ACTIONS_TASK_SLUG,
20
61
  input: {
21
62
  formId: String(formId),
22
- submissionId: String(submissionId)
63
+ submissionId: String(submissionId),
64
+ ...subset
23
65
  },
24
66
  req
25
67
  });
26
68
  } catch (error) {
27
69
  payload.logger?.error(`@10x-media/form-builder: failed to enqueue actions for submission ${String(submissionId)}: ${error instanceof Error ? error.message : String(error)}`);
28
70
  }
29
- return;
71
+ return { essentialFailed: false };
30
72
  }
31
73
  const ms = args.deadlineMs ?? 5e3;
32
74
  const work = runActionsForSubmission({
33
75
  input: {
34
76
  formId,
35
- submissionId
77
+ submissionId,
78
+ ...subset
36
79
  },
37
80
  registry,
38
81
  payload,
@@ -42,6 +85,7 @@ const dispatchActions = async (args) => {
42
85
  payload.logger?.error(`@10x-media/form-builder: inline action dispatch for submission ${String(submissionId)} threw: ${error instanceof Error ? error.message : String(error)}`);
43
86
  });
44
87
  await Promise.race([work, deadline(ms)]);
88
+ return { essentialFailed: false };
45
89
  };
46
90
  //#endregion
47
91
  export { dispatchActions };
@@ -1 +1 @@
1
- {"version":3,"file":"dispatch.js","names":[],"sources":["../../src/actions/dispatch.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport type { ActionRegistry } from './registry'\nimport type { ActionInstance } from './runActions'\nimport { ACTIONS_TASK_SLUG, runActionsForSubmission } from './task'\n\n/** Default cap on inline action work so a missing worker never hangs the submission response. */\nexport const INLINE_DISPATCH_DEADLINE_MS = 5_000\n\nexport type DispatchActionsArgs = {\n\tactions: ActionInstance[] | null | undefined\n\tformId: number | string\n\tsubmissionId: number | string\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\t/** Whether a job runner is likely present (queued path); otherwise the bounded-inline fallback runs. */\n\thasRunner: boolean\n\t/** The owning form's `persistSubmissions`; when explicitly `false`, run the completion even with no actions so the row is pruned. */\n\tpersistSubmissions?: boolean\n\tdeadlineMs?: number\n\trichText?: RichTextBodyOption\n}\n\nconst canQueue = (payload: Payload): boolean => typeof payload.jobs?.queue === 'function'\n\nconst deadline = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => {\n\t\tconst timer = setTimeout(resolve, ms)\n\t\ttimer.unref?.()\n\t})\n\n/**\n * Dispatch a submission's post-submit actions without throwing and without blocking the response on slow\n * action work. With no actions, returns immediately. When a job runner is present, enqueues the native\n * `form-builder-actions` task and returns (action work happens out of band). Otherwise runs the actions\n * inline but bounded by a deadline so a missing worker still delivers without hanging the request; any\n * error is swallowed (logged via `payload.logger`). Never rejects.\n */\nexport const dispatchActions = async (args: DispatchActionsArgs): Promise<void> => {\n\tconst { payload, registry, req, formId, submissionId } = args\n\tconst actions = args.actions ?? []\n\t// Nothing to run and nothing to prune: skip. An action-less form that opted out of persistence still\n\t// runs the completion below, so `runActionsForSubmission` can delete the row out of band.\n\tif (actions.length === 0 && args.persistSubmissions !== false) {\n\t\treturn\n\t}\n\n\tif (args.hasRunner && canQueue(payload)) {\n\t\ttry {\n\t\t\tawait payload.jobs.queue({\n\t\t\t\ttask: ACTIONS_TASK_SLUG,\n\t\t\t\tinput: { formId: String(formId), submissionId: String(submissionId) },\n\t\t\t\treq,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: failed to enqueue actions for submission ${String(submissionId)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn\n\t}\n\n\tconst ms = args.deadlineMs ?? INLINE_DISPATCH_DEADLINE_MS\n\t// Guard the action arm itself (not just the race) so a rejection AFTER the deadline wins is never an unhandled rejection.\n\tconst work = runActionsForSubmission({\n\t\tinput: { formId, submissionId },\n\t\tregistry,\n\t\tpayload,\n\t\treq,\n\t\trichText: args.richText,\n\t}).catch((error) => {\n\t\tpayload.logger?.error(\n\t\t\t`@10x-media/form-builder: inline action dispatch for submission ${String(submissionId)} threw: ${\n\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t}`\n\t\t)\n\t})\n\tawait Promise.race([work, deadline(ms)])\n}\n"],"mappings":";AAwBA,MAAM,YAAY,YAA8B,OAAO,QAAQ,MAAM,UAAU;AAE/E,MAAM,YAAY,OACjB,IAAI,SAAS,YAAY;CAExB,WADyB,SAAS,EAC9B,EAAE,QAAQ;AACf,CAAC;;;;;;;;AASF,MAAa,kBAAkB,OAAO,SAA6C;CAClF,MAAM,EAAE,SAAS,UAAU,KAAK,QAAQ,iBAAiB;CAIzD,KAHgB,KAAK,WAAW,CAAC,GAGrB,WAAW,KAAK,KAAK,uBAAuB,OACvD;CAGD,IAAI,KAAK,aAAa,SAAS,OAAO,GAAG;EACxC,IAAI;GACH,MAAM,QAAQ,KAAK,MAAM;IACxB,MAAM;IACN,OAAO;KAAE,QAAQ,OAAO,MAAM;KAAG,cAAc,OAAO,YAAY;IAAE;IACpE;GACD,CAAC;EACF,SAAS,OAAO;GACf,QAAQ,QAAQ,MACf,qEAAqE,OAAO,YAAY,EAAE,IACzF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;EACD;EACA;CACD;CAEA,MAAM,KAAK,KAAK,cAAA;CAEhB,MAAM,OAAO,wBAAwB;EACpC,OAAO;GAAE;GAAQ;EAAa;EAC9B;EACA;EACA;EACA,UAAU,KAAK;CAChB,CAAC,EAAE,OAAO,UAAU;EACnB,QAAQ,QAAQ,MACf,kEAAkE,OAAO,YAAY,EAAE,UACtF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;CACD,CAAC;CACD,MAAM,QAAQ,KAAK,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;AACxC"}
1
+ {"version":3,"file":"dispatch.js","names":[],"sources":["../../src/actions/dispatch.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport { ESSENTIAL_ACTION_FAILED_CONTEXT_KEY } from './dispatchContext'\nimport { type ActionRegistry, isEssentialAction } from './registry'\nimport type { ActionInstance } from './runActions'\nimport { ACTIONS_TASK_SLUG, runActionsForSubmission } from './task'\n\nexport { ESSENTIAL_ACTION_FAILED_CONTEXT_KEY }\n\n/** Default cap on inline action work so a missing worker never hangs the submission response. */\nexport const INLINE_DISPATCH_DEADLINE_MS = 5_000\n\nexport type DispatchActionsArgs = {\n\tactions: ActionInstance[] | null | undefined\n\tformId: number | string\n\tsubmissionId: number | string\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\t/** Whether a job runner is likely present (queued path); otherwise the bounded-inline fallback runs. */\n\thasRunner: boolean\n\t/** The owning form's `persistSubmissions`; when explicitly `false`, run the completion even with no actions so the row is pruned. */\n\tpersistSubmissions?: boolean\n\tdeadlineMs?: number\n\trichText?: RichTextBodyOption\n}\n\nconst canQueue = (payload: Payload): boolean => typeof payload.jobs?.queue === 'function'\n\nconst deadline = (ms: number): Promise<void> =>\n\tnew Promise((resolve) => {\n\t\tconst timer = setTimeout(resolve, ms)\n\t\ttimer.unref?.()\n\t})\n\n/**\n * Dispatch a submission's post-submit actions without throwing and without blocking the response on slow\n * action work. With no actions, returns immediately. When a job runner is present, enqueues the native\n * `form-builder-actions` task and returns (action work happens out of band). Otherwise runs the actions\n * inline but bounded by a deadline so a missing worker still delivers without hanging the request; any\n * error is swallowed (logged via `payload.logger`). Never rejects; the return reports whether an\n * essential pass failed, so the caller can withhold success signals (the created event, the 201).\n */\nexport const dispatchActions = async (\n\targs: DispatchActionsArgs\n): Promise<{ essentialFailed: boolean }> => {\n\tconst { payload, registry, req, formId, submissionId } = args\n\tconst actions = args.actions ?? []\n\tconst hasEssential = actions.some((instance) => isEssentialAction(registry, instance))\n\n\t// Essential actions run first, inline and awaited, never queued: their failure is the\n\t// submission's failure, which the submit endpoint turns into an error response via the context\n\t// flag. On failure the remaining actions are skipped and so is the pruning completion, so a\n\t// `persistSubmissions: false` form keeps the row: the provider never received it, and the\n\t// stored copy is the only record of what the visitor sent.\n\tif (hasEssential) {\n\t\tconst ms = args.deadlineMs ?? INLINE_DISPATCH_DEADLINE_MS\n\t\tconst work = runActionsForSubmission({\n\t\t\tinput: { formId, submissionId, subset: 'essential' },\n\t\t\tregistry,\n\t\t\tpayload,\n\t\t\treq,\n\t\t\trichText: args.richText,\n\t\t}).then(\n\t\t\t(results) => ({ timedOut: false, failed: results.some((result) => !result.ok) }),\n\t\t\t(error) => {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: essential action pass for submission ${String(submissionId)} threw: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t\treturn { timedOut: false, failed: true }\n\t\t\t}\n\t\t)\n\t\tconst outcome = await Promise.race([\n\t\t\twork,\n\t\t\tdeadline(ms).then(() => ({ timedOut: true, failed: true })),\n\t\t])\n\t\tif (outcome.failed) {\n\t\t\tif (outcome.timedOut) {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: essential action pass for submission ${String(submissionId)} outlived its ${ms}ms deadline; treating the submission as failed (the work may still complete)`\n\t\t\t\t)\n\t\t\t}\n\t\t\tif (req) {\n\t\t\t\treq.context = { ...(req.context ?? {}), [ESSENTIAL_ACTION_FAILED_CONTEXT_KEY]: true }\n\t\t\t}\n\t\t\treturn { essentialFailed: true }\n\t\t}\n\t}\n\n\t// Nothing to run and nothing to prune: skip. An action-less form that opted out of persistence still\n\t// runs the completion below, so `runActionsForSubmission` can delete the row out of band.\n\tconst rest = hasEssential\n\t\t? actions.filter((instance) => !isEssentialAction(registry, instance))\n\t\t: actions\n\tif (rest.length === 0 && args.persistSubmissions !== false) {\n\t\treturn { essentialFailed: false }\n\t}\n\n\t// With an essential pass already run, the closing pass covers only the rest (subset threading\n\t// keeps the queued task from re-running the essential actions).\n\tconst subset = hasEssential ? ({ subset: 'rest' } as const) : {}\n\n\tif (args.hasRunner && canQueue(payload)) {\n\t\ttry {\n\t\t\tawait payload.jobs.queue({\n\t\t\t\ttask: ACTIONS_TASK_SLUG,\n\t\t\t\tinput: { formId: String(formId), submissionId: String(submissionId), ...subset },\n\t\t\t\treq,\n\t\t\t})\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: failed to enqueue actions for submission ${String(submissionId)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn { essentialFailed: false }\n\t}\n\n\tconst ms = args.deadlineMs ?? INLINE_DISPATCH_DEADLINE_MS\n\t// Guard the action arm itself (not just the race) so a rejection AFTER the deadline wins is never an unhandled rejection.\n\tconst work = runActionsForSubmission({\n\t\tinput: { formId, submissionId, ...subset },\n\t\tregistry,\n\t\tpayload,\n\t\treq,\n\t\trichText: args.richText,\n\t}).catch((error) => {\n\t\tpayload.logger?.error(\n\t\t\t`@10x-media/form-builder: inline action dispatch for submission ${String(submissionId)} threw: ${\n\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t}`\n\t\t)\n\t})\n\tawait Promise.race([work, deadline(ms)])\n\treturn { essentialFailed: false }\n}\n"],"mappings":";;;AA2BA,MAAM,YAAY,YAA8B,OAAO,QAAQ,MAAM,UAAU;AAE/E,MAAM,YAAY,OACjB,IAAI,SAAS,YAAY;CAExB,WADyB,SAAS,EAC9B,EAAE,QAAQ;AACf,CAAC;;;;;;;;;AAUF,MAAa,kBAAkB,OAC9B,SAC2C;CAC3C,MAAM,EAAE,SAAS,UAAU,KAAK,QAAQ,iBAAiB;CACzD,MAAM,UAAU,KAAK,WAAW,CAAC;CACjC,MAAM,eAAe,QAAQ,MAAM,aAAa,kBAAkB,UAAU,QAAQ,CAAC;CAOrF,IAAI,cAAc;EACjB,MAAM,KAAK,KAAK,cAAA;EAChB,MAAM,OAAO,wBAAwB;GACpC,OAAO;IAAE;IAAQ;IAAc,QAAQ;GAAY;GACnD;GACA;GACA;GACA,UAAU,KAAK;EAChB,CAAC,EAAE,MACD,aAAa;GAAE,UAAU;GAAO,QAAQ,QAAQ,MAAM,WAAW,CAAC,OAAO,EAAE;EAAE,KAC7E,UAAU;GACV,QAAQ,QAAQ,MACf,iEAAiE,OAAO,YAAY,EAAE,UACrF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;GACA,OAAO;IAAE,UAAU;IAAO,QAAQ;GAAK;EACxC,CACD;EACA,MAAM,UAAU,MAAM,QAAQ,KAAK,CAClC,MACA,SAAS,EAAE,EAAE,YAAY;GAAE,UAAU;GAAM,QAAQ;EAAK,EAAE,CAC3D,CAAC;EACD,IAAI,QAAQ,QAAQ;GACnB,IAAI,QAAQ,UACX,QAAQ,QAAQ,MACf,iEAAiE,OAAO,YAAY,EAAE,gBAAgB,GAAG,6EAC1G;GAED,IAAI,KACH,IAAI,UAAU;IAAE,GAAI,IAAI,WAAW,CAAC;KAAK,sCAAsC;GAAK;GAErF,OAAO,EAAE,iBAAiB,KAAK;EAChC;CACD;CAOA,KAHa,eACV,QAAQ,QAAQ,aAAa,CAAC,kBAAkB,UAAU,QAAQ,CAAC,IACnE,SACM,WAAW,KAAK,KAAK,uBAAuB,OACpD,OAAO,EAAE,iBAAiB,MAAM;CAKjC,MAAM,SAAS,eAAgB,EAAE,QAAQ,OAAO,IAAc,CAAC;CAE/D,IAAI,KAAK,aAAa,SAAS,OAAO,GAAG;EACxC,IAAI;GACH,MAAM,QAAQ,KAAK,MAAM;IACxB,MAAM;IACN,OAAO;KAAE,QAAQ,OAAO,MAAM;KAAG,cAAc,OAAO,YAAY;KAAG,GAAG;IAAO;IAC/E;GACD,CAAC;EACF,SAAS,OAAO;GACf,QAAQ,QAAQ,MACf,qEAAqE,OAAO,YAAY,EAAE,IACzF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;EACD;EACA,OAAO,EAAE,iBAAiB,MAAM;CACjC;CAEA,MAAM,KAAK,KAAK,cAAA;CAEhB,MAAM,OAAO,wBAAwB;EACpC,OAAO;GAAE;GAAQ;GAAc,GAAG;EAAO;EACzC;EACA;EACA;EACA,UAAU,KAAK;CAChB,CAAC,EAAE,OAAO,UAAU;EACnB,QAAQ,QAAQ,MACf,kEAAkE,OAAO,YAAY,EAAE,UACtF,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;CACD,CAAC;CACD,MAAM,QAAQ,KAAK,CAAC,MAAM,SAAS,EAAE,CAAC,CAAC;CACvC,OAAO,EAAE,iBAAiB,MAAM;AACjC"}
@@ -0,0 +1,13 @@
1
+ //#region src/actions/dispatchContext.ts
2
+ /**
3
+ * `req.context` key the dispatcher sets to `true` when an essential action failed (or outlived its
4
+ * deadline) for the submission this request created. The submit endpoint reads it to turn the 201
5
+ * into an error response; the submission itself stays stored, prune included, so a failed handoff
6
+ * to a provider never deletes the only copy of what the visitor sent. A leaf module (no imports)
7
+ * because both the dispatcher and the submit endpoint need it across an import cycle.
8
+ */
9
+ const ESSENTIAL_ACTION_FAILED_CONTEXT_KEY = "formBuilderEssentialActionFailed";
10
+ //#endregion
11
+ export { ESSENTIAL_ACTION_FAILED_CONTEXT_KEY };
12
+
13
+ //# sourceMappingURL=dispatchContext.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dispatchContext.js","names":[],"sources":["../../src/actions/dispatchContext.ts"],"sourcesContent":["/**\n * `req.context` key the dispatcher sets to `true` when an essential action failed (or outlived its\n * deadline) for the submission this request created. The submit endpoint reads it to turn the 201\n * into an error response; the submission itself stays stored, prune included, so a failed handoff\n * to a provider never deletes the only copy of what the visitor sent. A leaf module (no imports)\n * because both the dispatcher and the submit endpoint need it across an import cycle.\n */\nexport const ESSENTIAL_ACTION_FAILED_CONTEXT_KEY = 'formBuilderEssentialActionFailed'\n"],"mappings":";;;;;;;;AAOA,MAAa,sCAAsC"}
@@ -6,7 +6,9 @@ import { applyRegistryConfig } from "../plugin/applyRegistryConfig.js";
6
6
  * Mirrors the field-type and validation-rule registry convention.
7
7
  */
8
8
  const resolveActions = (defaults, config = {}) => applyRegistryConfig(new Map(Object.entries(defaults)), config);
9
+ /** Whether a stored action instance's definition declares its failure to be the submission's failure. */
10
+ const isEssentialAction = (registry, instance) => registry.get(instance.blockType)?.essential === true;
9
11
  //#endregion
10
- export { resolveActions };
12
+ export { isEssentialAction, resolveActions };
11
13
 
12
14
  //# sourceMappingURL=registry.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry.js","names":[],"sources":["../../src/actions/registry.ts"],"sourcesContent":["import { applyRegistryConfig, type RegistryConfig } from '../plugin/applyRegistryConfig'\nimport type { AnyActionDefinition } from './defineAction'\n\nexport type ActionRegistry = Map<string, AnyActionDefinition>\n\n/** `false` removes a built-in, `true` keeps it, a definition adds or replaces one. */\nexport type ActionOption = boolean | AnyActionDefinition\n\nexport type ActionsConfig = RegistryConfig<AnyActionDefinition>\n\n/**\n * Resolve the active action registry from built-in defaults and a consumer override map. `false`\n * removes a type, `true` keeps the default (no-op when none exists), a definition adds or replaces.\n * Mirrors the field-type and validation-rule registry convention.\n */\nexport const resolveActions = (\n\tdefaults: Record<string, AnyActionDefinition>,\n\tconfig: ActionsConfig = {}\n): ActionRegistry => applyRegistryConfig(new Map(Object.entries(defaults)), config)\n"],"mappings":";;;;;;;AAeA,MAAa,kBACZ,UACA,SAAwB,CAAC,MACL,oBAAoB,IAAI,IAAI,OAAO,QAAQ,QAAQ,CAAC,GAAG,MAAM"}
1
+ {"version":3,"file":"registry.js","names":[],"sources":["../../src/actions/registry.ts"],"sourcesContent":["import { applyRegistryConfig, type RegistryConfig } from '../plugin/applyRegistryConfig'\nimport type { AnyActionDefinition } from './defineAction'\n\nexport type ActionRegistry = Map<string, AnyActionDefinition>\n\n/** `false` removes a built-in, `true` keeps it, a definition adds or replaces one. */\nexport type ActionOption = boolean | AnyActionDefinition\n\nexport type ActionsConfig = RegistryConfig<AnyActionDefinition>\n\n/**\n * Resolve the active action registry from built-in defaults and a consumer override map. `false`\n * removes a type, `true` keeps the default (no-op when none exists), a definition adds or replaces.\n * Mirrors the field-type and validation-rule registry convention.\n */\nexport const resolveActions = (\n\tdefaults: Record<string, AnyActionDefinition>,\n\tconfig: ActionsConfig = {}\n): ActionRegistry => applyRegistryConfig(new Map(Object.entries(defaults)), config)\n\n/** Whether a stored action instance's definition declares its failure to be the submission's failure. */\nexport const isEssentialAction = (\n\tregistry: ActionRegistry,\n\tinstance: { blockType: string }\n): boolean => registry.get(instance.blockType)?.essential === true\n"],"mappings":";;;;;;;AAeA,MAAa,kBACZ,UACA,SAAwB,CAAC,MACL,oBAAoB,IAAI,IAAI,OAAO,QAAQ,QAAQ,CAAC,GAAG,MAAM;;AAGlF,MAAa,qBACZ,UACA,aACa,SAAS,IAAI,SAAS,SAAS,GAAG,cAAc"}
@@ -1,4 +1,5 @@
1
1
  import { asFieldTranslate } from "../translations/server.js";
2
+ import { isEssentialAction } from "./registry.js";
2
3
  import { FORMS_SLUG } from "../collections/forms.js";
3
4
  import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
4
5
  import { runActions } from "./runActions.js";
@@ -44,8 +45,13 @@ const runActionsForSubmission = async (args) => {
44
45
  }).catch(() => null);
45
46
  if (!form) return [];
46
47
  const t = asFieldTranslate(req?.i18n?.t ?? ((key) => key));
48
+ const subset = input.subset ?? "all";
47
49
  const results = await runActions({
48
- actions: asActions(form.actions),
50
+ actions: asActions(form.actions).filter((instance) => {
51
+ if (subset === "all") return true;
52
+ const isEssential = isEssentialAction(registry, instance);
53
+ return subset === "essential" ? isEssential : !isEssential;
54
+ }),
49
55
  registry,
50
56
  richText,
51
57
  form: {
@@ -62,7 +68,7 @@ const runActionsForSubmission = async (args) => {
62
68
  t
63
69
  });
64
70
  for (const result of results) if (!result.ok) payload.logger?.error(`@10x-media/form-builder: action "${result.type}" failed for submission ${String(submission.id)}: ${result.error ?? "unknown error"}`);
65
- if (form.persistSubmissions === false) await payload.delete({
71
+ if (subset !== "essential" && form.persistSubmissions === false) await payload.delete({
66
72
  collection: FORM_SUBMISSIONS_SLUG,
67
73
  id: submission.id,
68
74
  overrideAccess: true,
@@ -1 +1 @@
1
- {"version":3,"file":"task.js","names":[],"sources":["../../src/actions/task.ts"],"sourcesContent":["import type { Config, Payload, PayloadRequest, TaskConfig, TypedLocale } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\nimport { asFieldTranslate } from '../translations/server'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport type { ActionRegistry } from './registry'\nimport type { ActionInstance, ActionResult } from './runActions'\nimport { runActions } from './runActions'\n\nexport const ACTIONS_TASK_SLUG = 'form-builder-actions'\n\n/** Input the dispatch path enqueues; the handler re-loads everything else from the DB. */\nexport type ActionsTaskInput = { formId: number | string; submissionId: number | string }\n\nconst asActions = (value: unknown): ActionInstance[] =>\n\tArray.isArray(value) ? (value as ActionInstance[]) : []\n\nconst asValues = (value: unknown): SubmissionValue[] =>\n\tArray.isArray(value) ? (value as SubmissionValue[]) : []\n\nconst asDescriptors = (value: unknown): SubmissionDescriptor[] =>\n\tArray.isArray(value) ? (value as SubmissionDescriptor[]) : []\n\n/** The verified `{ relationTo, value }` stored on a submission, or null when it carried no context. */\nconst asContext = (value: unknown): FormContextReference | null => {\n\tif (value && typeof value === 'object') {\n\t\tconst { relationTo, value: reference } = value as Record<string, unknown>\n\t\tif (\n\t\t\ttypeof relationTo === 'string' &&\n\t\t\t(typeof reference === 'string' || typeof reference === 'number')\n\t\t) {\n\t\t\treturn { relationTo, value: reference }\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Load the form and submission by id and run the form's actions through the shared, failure-isolating\n * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and\n * run) by returning early. Used by both the queued task handler and the inline fallback.\n */\nexport const runActionsForSubmission = async (args: {\n\tinput: ActionsTaskInput\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\trichText?: RichTextBodyOption\n}): Promise<ActionResult[]> => {\n\tconst { input, registry, payload, req, richText } = args\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: input.submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!submission) {\n\t\treturn []\n\t}\n\n\t// The submission's own stored locale (set from req.locale at submit) is authoritative, so the form\n\t// is loaded at it. A localized action config, notably the emailTeam `to`, then resolves to the\n\t// submission's locale even on the queued path, where the job runner's req may carry a different\n\t// (or no) locale than the visitor who submitted.\n\tconst locale = typeof submission.locale === 'string' ? submission.locale : (req?.locale ?? 'en')\n\n\tconst form = await payload\n\t\t.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: input.formId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\t// Cast: the stored locale is a plain string; a host's concrete locale union is unknowable from\n\t\t\t// the plugin, and an unrecognized code just falls back on read, so this narrows (zero runtime\n\t\t\t// delta) to satisfy a host whose `findByID` locale is a real union.\n\t\t\tlocale: locale as TypedLocale,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn []\n\t}\n\n\tconst t: Translate = asFieldTranslate(req?.i18n?.t ?? ((key: string) => key))\n\n\tconst results = await runActions({\n\t\tactions: asActions(form.actions),\n\t\tregistry,\n\t\trichText,\n\t\tform: { id: form.id, title: typeof form.title === 'string' ? form.title : undefined },\n\t\tsubmissionId: submission.id,\n\t\tvalues: asValues(submission.values),\n\t\tdescriptors: asDescriptors(submission.descriptors),\n\t\tcontext: asContext(submission.context),\n\t\tpayload,\n\t\treq,\n\t\tlocale,\n\t\tt,\n\t})\n\t// A failed action (SMTP down, webhook non-2xx, missing adapter) is isolated per action; surface it\n\t// so a silently undelivered email/webhook is visible instead of the submission looking successful.\n\tfor (const result of results) {\n\t\tif (!result.ok) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: action \"${result.type}\" failed for submission ${String(submission.id)}: ${result.error ?? 'unknown error'}`\n\t\t\t)\n\t\t}\n\t}\n\t// A form can opt out of storing submissions (a pure signup that only POSTs to a provider): prune the\n\t// row after the whole action pass, regardless of individual action success (every action already got\n\t// the values). Best-effort: a delete failure is logged, never thrown. Uploads referenced in the values\n\t// are host-owned and not cascaded (documented).\n\tif (form.persistSubmissions === false) {\n\t\tawait payload\n\t\t\t.delete({ collection: FORM_SUBMISSIONS_SLUG, id: submission.id, overrideAccess: true, req })\n\t\t\t.catch((error) => {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: failed to prune submission ${String(submission.id)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t})\n\t}\n\treturn results\n}\n\n/** Native Payload jobs task that runs a submission's post-submit actions out of band. */\nexport const buildActionsTask = (\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): TaskConfig =>\n\t({\n\t\tslug: ACTIONS_TASK_SLUG,\n\t\tinputSchema: [\n\t\t\t{ name: 'formId', type: 'text', required: true },\n\t\t\t{ name: 'submissionId', type: 'text', required: true },\n\t\t],\n\t\thandler: async ({ input, req }) => {\n\t\t\tawait runActionsForSubmission({\n\t\t\t\tinput: input as ActionsTaskInput,\n\t\t\t\tregistry,\n\t\t\t\tpayload: req.payload,\n\t\t\t\treq,\n\t\t\t\trichText,\n\t\t\t})\n\t\t\treturn { output: {} }\n\t\t},\n\t}) as TaskConfig\n\n/** Register the actions task on `config.jobs.tasks`, creating the jobs config if absent. */\nexport const registerActionsTask = (\n\tconfig: Config,\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): void => {\n\tconfig.jobs ??= {}\n\tconfig.jobs.tasks ??= []\n\tconfig.jobs.tasks.push(buildActionsTask(registry, richText))\n}\n"],"mappings":";;;;;AAYA,MAAa,oBAAoB;AAKjC,MAAM,aAAa,UAClB,MAAM,QAAQ,KAAK,IAAK,QAA6B,CAAC;AAEvD,MAAM,YAAY,UACjB,MAAM,QAAQ,KAAK,IAAK,QAA8B,CAAC;AAExD,MAAM,iBAAiB,UACtB,MAAM,QAAQ,KAAK,IAAK,QAAmC,CAAC;;AAG7D,MAAM,aAAa,UAAgD;CAClE,IAAI,SAAS,OAAO,UAAU,UAAU;EACvC,MAAM,EAAE,YAAY,OAAO,cAAc;EACzC,IACC,OAAO,eAAe,aACrB,OAAO,cAAc,YAAY,OAAO,cAAc,WAEvD,OAAO;GAAE;GAAY,OAAO;EAAU;CAExC;CACA,OAAO;AACR;;;;;;AAOA,MAAa,0BAA0B,OAAO,SAMf;CAC9B,MAAM,EAAE,OAAO,UAAU,SAAS,KAAK,aAAa;CACpD,MAAM,aAAa,MAAM,QACvB,SAAS;EACT,YAAY;EACZ,IAAI,MAAM;EACV,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,CAAC,YACJ,OAAO,CAAC;CAOT,MAAM,SAAS,OAAO,WAAW,WAAW,WAAW,WAAW,SAAU,KAAK,UAAU;CAE3F,MAAM,OAAO,MAAM,QACjB,SAAS;EACT,YAAY;EACZ,IAAI,MAAM;EACV,OAAO;EACP,gBAAgB;EAIR;EACR;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO,CAAC;CAGT,MAAM,IAAe,iBAAiB,KAAK,MAAM,OAAO,QAAgB,IAAI;CAE5E,MAAM,UAAU,MAAM,WAAW;EAChC,SAAS,UAAU,KAAK,OAAO;EAC/B;EACA;EACA,MAAM;GAAE,IAAI,KAAK;GAAI,OAAO,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ,KAAA;EAAU;EACpF,cAAc,WAAW;EACzB,QAAQ,SAAS,WAAW,MAAM;EAClC,aAAa,cAAc,WAAW,WAAW;EACjD,SAAS,UAAU,WAAW,OAAO;EACrC;EACA;EACA;EACA;CACD,CAAC;CAGD,KAAK,MAAM,UAAU,SACpB,IAAI,CAAC,OAAO,IACX,QAAQ,QAAQ,MACf,oCAAoC,OAAO,KAAK,0BAA0B,OAAO,WAAW,EAAE,EAAE,IAAI,OAAO,SAAS,iBACrH;CAOF,IAAI,KAAK,uBAAuB,OAC/B,MAAM,QACJ,OAAO;EAAE,YAAY;EAAuB,IAAI,WAAW;EAAI,gBAAgB;EAAM;CAAI,CAAC,EAC1F,OAAO,UAAU;EACjB,QAAQ,QAAQ,MACf,uDAAuD,OAAO,WAAW,EAAE,EAAE,IAC5E,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK,GAEvD;CACD,CAAC;CAEH,OAAO;AACR;;AAGA,MAAa,oBACZ,UACA,cAEC;CACA,MAAM;CACN,aAAa,CACZ;EAAE,MAAM;EAAU,MAAM;EAAQ,UAAU;CAAK,GAC/C;EAAE,MAAM;EAAgB,MAAM;EAAQ,UAAU;CAAK,CACtD;CACA,SAAS,OAAO,EAAE,OAAO,UAAU;EAClC,MAAM,wBAAwB;GACtB;GACP;GACA,SAAS,IAAI;GACb;GACA;EACD,CAAC;EACD,OAAO,EAAE,QAAQ,CAAC,EAAE;CACrB;AACD;;AAGD,MAAa,uBACZ,QACA,UACA,aACU;CACV,OAAO,SAAS,CAAC;CACjB,OAAO,KAAK,UAAU,CAAC;CACvB,OAAO,KAAK,MAAM,KAAK,iBAAiB,UAAU,QAAQ,CAAC;AAC5D"}
1
+ {"version":3,"file":"task.js","names":[],"sources":["../../src/actions/task.ts"],"sourcesContent":["import type { Config, Payload, PayloadRequest, TaskConfig, TypedLocale } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { FormContextReference } from '../context/formContext'\nimport type { Translate } from '../fields/types'\nimport type { SubmissionDescriptor, SubmissionValue } from '../submissions/types'\nimport { asFieldTranslate } from '../translations/server'\nimport type { RichTextBodyOption } from './body/serializeBody'\nimport { type ActionRegistry, isEssentialAction } from './registry'\nimport type { ActionInstance, ActionResult } from './runActions'\nimport { runActions } from './runActions'\n\nexport const ACTIONS_TASK_SLUG = 'form-builder-actions'\n\n/** Input the dispatch path enqueues; the handler re-loads everything else from the DB. */\n/** `subset` filters by the action definitions' `essential` flag; absent runs everything ('all'). */\nexport type ActionsTaskInput = {\n\tformId: number | string\n\tsubmissionId: number | string\n\tsubset?: 'essential' | 'rest'\n}\n\nconst asActions = (value: unknown): ActionInstance[] =>\n\tArray.isArray(value) ? (value as ActionInstance[]) : []\n\nconst asValues = (value: unknown): SubmissionValue[] =>\n\tArray.isArray(value) ? (value as SubmissionValue[]) : []\n\nconst asDescriptors = (value: unknown): SubmissionDescriptor[] =>\n\tArray.isArray(value) ? (value as SubmissionDescriptor[]) : []\n\n/** The verified `{ relationTo, value }` stored on a submission, or null when it carried no context. */\nconst asContext = (value: unknown): FormContextReference | null => {\n\tif (value && typeof value === 'object') {\n\t\tconst { relationTo, value: reference } = value as Record<string, unknown>\n\t\tif (\n\t\t\ttypeof relationTo === 'string' &&\n\t\t\t(typeof reference === 'string' || typeof reference === 'number')\n\t\t) {\n\t\t\treturn { relationTo, value: reference }\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Load the form and submission by id and run the form's actions through the shared, failure-isolating\n * `runActions`. Tolerates a missing form or submission (the row may have been deleted between enqueue and\n * run) by returning early. Used by both the queued task handler and the inline fallback.\n */\nexport const runActionsForSubmission = async (args: {\n\tinput: ActionsTaskInput\n\tregistry: ActionRegistry\n\tpayload: Payload\n\treq?: PayloadRequest\n\trichText?: RichTextBodyOption\n}): Promise<ActionResult[]> => {\n\tconst { input, registry, payload, req, richText } = args\n\tconst submission = await payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: input.submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!submission) {\n\t\treturn []\n\t}\n\n\t// The submission's own stored locale (set from req.locale at submit) is authoritative, so the form\n\t// is loaded at it. A localized action config, notably the emailTeam `to`, then resolves to the\n\t// submission's locale even on the queued path, where the job runner's req may carry a different\n\t// (or no) locale than the visitor who submitted.\n\tconst locale = typeof submission.locale === 'string' ? submission.locale : (req?.locale ?? 'en')\n\n\tconst form = await payload\n\t\t.findByID({\n\t\t\tcollection: FORMS_SLUG,\n\t\t\tid: input.formId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\t// Cast: the stored locale is a plain string; a host's concrete locale union is unknowable from\n\t\t\t// the plugin, and an unrecognized code just falls back on read, so this narrows (zero runtime\n\t\t\t// delta) to satisfy a host whose `findByID` locale is a real union.\n\t\t\tlocale: locale as TypedLocale,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn []\n\t}\n\n\tconst t: Translate = asFieldTranslate(req?.i18n?.t ?? ((key: string) => key))\n\n\tconst subset = input.subset ?? 'all'\n\tconst selected = asActions(form.actions).filter((instance) => {\n\t\tif (subset === 'all') {\n\t\t\treturn true\n\t\t}\n\t\tconst isEssential = isEssentialAction(registry, instance)\n\t\treturn subset === 'essential' ? isEssential : !isEssential\n\t})\n\n\tconst results = await runActions({\n\t\tactions: selected,\n\t\tregistry,\n\t\trichText,\n\t\tform: { id: form.id, title: typeof form.title === 'string' ? form.title : undefined },\n\t\tsubmissionId: submission.id,\n\t\tvalues: asValues(submission.values),\n\t\tdescriptors: asDescriptors(submission.descriptors),\n\t\tcontext: asContext(submission.context),\n\t\tpayload,\n\t\treq,\n\t\tlocale,\n\t\tt,\n\t})\n\t// A failed action (SMTP down, webhook non-2xx, missing adapter) is isolated per action; surface it\n\t// so a silently undelivered email/webhook is visible instead of the submission looking successful.\n\tfor (const result of results) {\n\t\tif (!result.ok) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: action \"${result.type}\" failed for submission ${String(submission.id)}: ${result.error ?? 'unknown error'}`\n\t\t\t)\n\t\t}\n\t}\n\t// A form can opt out of storing submissions (a pure signup that only POSTs to a provider): prune the\n\t// row after the whole action pass, regardless of individual action success (every action already got\n\t// the values). Best-effort: a delete failure is logged, never thrown. Uploads referenced in the values\n\t// are host-owned and not cascaded (documented).\n\t// Never on the essential pass: essential actions run first and their failure keeps the row (the\n\t// dispatcher then skips this completion entirely), so pruning belongs to the closing pass alone.\n\tif (subset !== 'essential' && form.persistSubmissions === false) {\n\t\tawait payload\n\t\t\t.delete({ collection: FORM_SUBMISSIONS_SLUG, id: submission.id, overrideAccess: true, req })\n\t\t\t.catch((error) => {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: failed to prune submission ${String(submission.id)}: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t})\n\t}\n\treturn results\n}\n\n/** Native Payload jobs task that runs a submission's post-submit actions out of band. */\nexport const buildActionsTask = (\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): TaskConfig =>\n\t({\n\t\tslug: ACTIONS_TASK_SLUG,\n\t\tinputSchema: [\n\t\t\t{ name: 'formId', type: 'text', required: true },\n\t\t\t{ name: 'submissionId', type: 'text', required: true },\n\t\t],\n\t\thandler: async ({ input, req }) => {\n\t\t\tawait runActionsForSubmission({\n\t\t\t\tinput: input as ActionsTaskInput,\n\t\t\t\tregistry,\n\t\t\t\tpayload: req.payload,\n\t\t\t\treq,\n\t\t\t\trichText,\n\t\t\t})\n\t\t\treturn { output: {} }\n\t\t},\n\t}) as TaskConfig\n\n/** Register the actions task on `config.jobs.tasks`, creating the jobs config if absent. */\nexport const registerActionsTask = (\n\tconfig: Config,\n\tregistry: ActionRegistry,\n\trichText?: RichTextBodyOption\n): void => {\n\tconfig.jobs ??= {}\n\tconfig.jobs.tasks ??= []\n\tconfig.jobs.tasks.push(buildActionsTask(registry, richText))\n}\n"],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ //#region src/client/ConsentRetentionNotice.d.ts
2
+ /**
3
+ * Sidebar notice mounted by the forms collection when a non-persisting form carries a consent
4
+ * field (see `buildDefaultSettingsFields`): the consent proof is written at submit and pruned with
5
+ * the row, so the combination only makes sense when the consent record lives elsewhere. A banner,
6
+ * not a save error, because that external-record setup is legitimate; it just must not be silent.
7
+ */
8
+ declare const ConsentRetentionNotice: () => import("react/jsx-runtime").JSX.Element;
9
+ //#endregion
10
+ export { ConsentRetentionNotice };
11
+ //# sourceMappingURL=ConsentRetentionNotice.d.ts.map
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { keys } from "../translations/keys.js";
3
+ import { useTranslation as useTranslation$1 } from "../translations/useTranslation.js";
4
+ import { Banner } from "@payloadcms/ui";
5
+ import { jsx } from "react/jsx-runtime";
6
+ //#region src/client/ConsentRetentionNotice.tsx
7
+ /**
8
+ * Sidebar notice mounted by the forms collection when a non-persisting form carries a consent
9
+ * field (see `buildDefaultSettingsFields`): the consent proof is written at submit and pruned with
10
+ * the row, so the combination only makes sense when the consent record lives elsewhere. A banner,
11
+ * not a save error, because that external-record setup is legitimate; it just must not be silent.
12
+ */
13
+ const ConsentRetentionNotice = () => {
14
+ const { t } = useTranslation$1();
15
+ return /* @__PURE__ */ jsx(Banner, {
16
+ type: "info",
17
+ children: t(keys.formConsentRetentionNotice)
18
+ });
19
+ };
20
+ //#endregion
21
+ export { ConsentRetentionNotice };
22
+
23
+ //# sourceMappingURL=ConsentRetentionNotice.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ConsentRetentionNotice.js","names":["useTranslation"],"sources":["../../src/client/ConsentRetentionNotice.tsx"],"sourcesContent":["'use client'\n\nimport { Banner } from '@payloadcms/ui'\nimport { keys } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\n\n/**\n * Sidebar notice mounted by the forms collection when a non-persisting form carries a consent\n * field (see `buildDefaultSettingsFields`): the consent proof is written at submit and pruned with\n * the row, so the combination only makes sense when the consent record lives elsewhere. A banner,\n * not a save error, because that external-record setup is legitimate; it just must not be silent.\n */\nexport const ConsentRetentionNotice = () => {\n\tconst { t } = useTranslation()\n\treturn <Banner type=\"info\">{t(keys.formConsentRetentionNotice)}</Banner>\n}\n"],"mappings":";;;;;;;;;;;;AAYA,MAAa,+BAA+B;CAC3C,MAAM,EAAE,MAAMA,iBAAe;CAC7B,OAAO,oBAAC,QAAD;EAAQ,MAAK;YAAQ,EAAE,KAAK,0BAA0B;CAAU,CAAA;AACxE"}
@@ -35,7 +35,7 @@ const makeAfterChange = (args) => async ({ doc, operation, req }) => {
35
35
  overrideAccess: true,
36
36
  req
37
37
  }).catch(() => null);
38
- await dispatchActions({
38
+ const { essentialFailed } = await dispatchActions({
39
39
  actions: form?.actions ?? null,
40
40
  formId,
41
41
  submissionId: doc.id,
@@ -46,6 +46,17 @@ const makeAfterChange = (args) => async ({ doc, operation, req }) => {
46
46
  persistSubmissions: form?.persistSubmissions,
47
47
  richText: args.richText
48
48
  });
49
+ if (essentialFailed) {
50
+ await payload.update.bind(payload)({
51
+ collection: FORM_SUBMISSIONS_SLUG,
52
+ id: doc.id,
53
+ data: { actionFailed: true },
54
+ depth: 0,
55
+ overrideAccess: true,
56
+ req
57
+ });
58
+ return doc;
59
+ }
49
60
  try {
50
61
  await resolveEventSink(args.events).emit({
51
62
  type: "submission.created",
@@ -131,6 +142,20 @@ const buildSubmissionsCollection = ({ registry, ruleRegistry, calcSources, calcF
131
142
  update: isLoggedIn
132
143
  }
133
144
  },
145
+ {
146
+ name: "actionFailed",
147
+ type: "checkbox",
148
+ index: true,
149
+ label: labelForKey(keys.submissionActionFailedFlag),
150
+ access: {
151
+ create: () => false,
152
+ update: () => false
153
+ },
154
+ admin: {
155
+ readOnly: true,
156
+ condition: (data) => data?.actionFailed === true
157
+ }
158
+ },
134
159
  {
135
160
  name: "answers",
136
161
  type: "ui",
@@ -1 +1 @@
1
- {"version":3,"file":"formSubmissions.js","names":[],"sources":["../../src/collections/formSubmissions.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionConfig, Field } from 'payload'\nimport type { RichTextBodyOption } from '../actions/body/serializeBody'\nimport { dispatchActions } from '../actions/dispatch'\nimport type { ActionRegistry } from '../actions/registry'\nimport type { ActionInstance } from '../actions/runActions'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { resolveEventSink } from '../events/resolveEventSink'\nimport type { FormEventSink } from '../events/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { pollConfigOf } from '../form/pollState'\nimport { isLoggedIn } from '../plugin/access'\nimport type { CollectionOverrides } from '../plugin/collectionOverrides'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { makeVoteTallyHook } from '../poll/votes/voteTallyHook'\nimport { buildSpamGuard } from '../spam/spamGuard'\nimport type { ResolvedSpamConfig } from '../spam/types'\nimport { formIdOf } from '../submissions/formIdOf'\nimport { validateSubmission } from '../submissions/validateSubmission'\nimport { verifyContext } from '../submissions/verifyContext'\nimport { buildVoteSubmitEndpoint } from '../submissions/voteChangeEndpoint'\nimport {\n\tPOLL_CONTEXT_KEY,\n\ttype PollContextState,\n\tsignVotedCookieValue,\n\tVOTED_COOKIE_MAX_AGE_SECONDS,\n\tvoteChangeTargetOf,\n\tvotedCookieName,\n} from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { FORMS_SLUG } from './forms'\n\nexport const FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\ntype BuildSubmissionsCollectionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources; submission validation re-resolves them fresh (a failure rejects the submission). */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions; threaded into submit-time calc evaluation. */\n\tcalcFunctions?: Record<string, CalcFunction>\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\tactionRegistry?: ActionRegistry\n\tevents?: FormEventSink\n\t/** Whether a job runner is likely present; gates the queued vs bounded-inline dispatch path. */\n\thasRunner?: boolean\n\t/** Body serialization customization forwarded to the inline action dispatch path. */\n\trichText?: RichTextBodyOption\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Resolved spam config; when active, prepends the spam guard before validation. `false` disables it. */\n\tspam?: ResolvedSpamConfig | false\n\t/**\n\t * Opt-in (`poll.votedCookie`): set an httpOnly `fb-voted-{formId}` cookie on poll submission\n\t * creates. An `allowChange` poll sets its signed-id cookie regardless of this flag.\n\t */\n\tvotedCookie?: boolean\n\t/** Registered poll option sources; submission validation resolves allowed values through them. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Resolved `poll.votes` option; `false` skips registering the append-only vote tally hook. */\n\tpollVotes?: false | { overrides?: CollectionOverrides }\n\t/**\n\t * When `true`, reveals the standalone `locale`, `values`, `descriptors`, `consent`, and `meta`\n\t * fields in the admin UI. Default `false`, because the `SubmissionAnswers` UI component already\n\t * represents them fully (locale and meta are folded into its Submission-details section).\n\t */\n\tshowRawFields?: boolean\n\toverrides?: CollectionOverrides\n}\n\n/**\n * On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.\n * Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw\n * past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded\n * and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued\n * path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field\n * existed.\n */\nconst makeAfterChange =\n\t(args: {\n\t\tactionRegistry: ActionRegistry\n\t\tevents?: FormEventSink\n\t\thasRunner: boolean\n\t\trichText?: RichTextBodyOption\n\t}): CollectionAfterChangeHook =>\n\tasync ({ doc, operation, req }) => {\n\t\tif (operation !== 'create' || (doc.status != null && doc.status !== 'complete')) {\n\t\t\treturn doc\n\t\t}\n\t\tconst { payload } = req\n\t\ttry {\n\t\t\tconst formId = formIdOf(doc.form)\n\t\t\tif (formId == null) {\n\t\t\t\treturn doc\n\t\t\t}\n\t\t\tconst form = await payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\n\t\t\tawait dispatchActions({\n\t\t\t\tactions: (form?.actions ?? null) as ActionInstance[] | null,\n\t\t\t\tformId,\n\t\t\t\tsubmissionId: doc.id as number | string,\n\t\t\t\tregistry: args.actionRegistry,\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\thasRunner: args.hasRunner,\n\t\t\t\tpersistSubmissions: form?.persistSubmissions as boolean | undefined,\n\t\t\t\trichText: args.richText,\n\t\t\t})\n\n\t\t\ttry {\n\t\t\t\tawait resolveEventSink(args.events).emit({\n\t\t\t\t\ttype: 'submission.created',\n\t\t\t\t\tformId: String(formId),\n\t\t\t\t\tsubmissionId: String(doc.id),\n\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: submission.created sink threw: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn doc\n\t}\n\nconst isPollContextState = (value: unknown): value is PollContextState =>\n\tvalue != null &&\n\ttypeof value === 'object' &&\n\ttypeof (value as PollContextState).pollEnabled === 'boolean'\n\n/**\n * Voted-cookie hook: after a submission to a poll-enabled form is created (or updated through the\n * vote-change path), appends an httpOnly `fb-voted-{formId}` cookie via `req.responseHeaders`,\n * Payload's supported channel for hook-set response headers (its own auth strategies use it;\n * `handleEndpoints` merges it into every REST response). The value stays the legacy `1` marker\n * under the `poll.votedCookie` plugin option; an `allowChange` poll instead carries the signed\n * submission id (set regardless of the option, because re-vote identification depends on it) and\n * refreshes it on every change. Reads the poll state `validateSubmission` stashed on\n * `req.context`, falling back to a form fetch when absent. httpOnly keeps the marker readable\n * only server-side (`hasVotedCookie`/`resolveVotedSubmission`); the client `<Poll>` keeps its\n * localStorage guard.\n */\nconst makeVotedCookieHook = (args: { votedCookie: boolean }): CollectionAfterChangeHook => {\n\treturn async ({ doc, operation, req }) => {\n\t\tconst changing = operation === 'update' && voteChangeTargetOf(req) != null\n\t\tif (operation !== 'create' && !changing) {\n\t\t\treturn doc\n\t\t}\n\t\tconst formId = formIdOf(doc.form)\n\t\tif (formId == null) {\n\t\t\treturn doc\n\t\t}\n\t\tconst stashed = req.context?.[POLL_CONTEXT_KEY]\n\t\tlet state = isPollContextState(stashed) ? stashed : undefined\n\t\tif (state === undefined) {\n\t\t\tconst form = await req.payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\t\t\tstate = {\n\t\t\t\tpollEnabled: form?.pollEnabled === true,\n\t\t\t\tallowChange: pollConfigOf(form?.poll)?.allowChange === true,\n\t\t\t}\n\t\t}\n\t\tif (!state.pollEnabled || !(args.votedCookie || state.allowChange)) {\n\t\t\treturn doc\n\t\t}\n\t\tconst value = state.allowChange\n\t\t\t? signVotedCookieValue(req.payload, doc.id as number | string)\n\t\t\t: '1'\n\t\t// A signed id is a bearer capability (it authorizes changing that vote), so it follows the\n\t\t// host's Payload auth-cookie transport policy: `Secure` whenever the admin user collection's\n\t\t// `auth.cookies.secure` is on. The legacy `1` marker stays a plain UX flag either way.\n\t\tconst adminUserSlug = req.payload.config.admin?.user\n\t\t// String-indexed cast: a host's generated types key `collections` to its own slugs, which\n\t\t// this framework-level read cannot know.\n\t\tconst collections = req.payload.collections as Record<\n\t\t\tstring,\n\t\t\t{ config: { auth?: { cookies?: { secure?: boolean } } } } | undefined\n\t\t>\n\t\tconst authCookies = adminUserSlug ? collections[adminUserSlug]?.config.auth?.cookies : undefined\n\t\tconst secure = state.allowChange && authCookies?.secure === true ? '; Secure' : ''\n\t\treq.responseHeaders ??= new Headers()\n\t\treq.responseHeaders.append(\n\t\t\t'Set-Cookie',\n\t\t\t`${votedCookieName(formId)}=${value}; Path=/; Max-Age=${VOTED_COOKIE_MAX_AGE_SECONDS}; HttpOnly; SameSite=Lax${secure}`\n\t\t)\n\t\treturn doc\n\t}\n}\n\nexport const buildSubmissionsCollection = ({\n\tregistry,\n\truleRegistry,\n\tcalcSources,\n\tcalcFunctions,\n\tconsentSources,\n\tconsentSnapshot,\n\tactionRegistry = new Map(),\n\tevents,\n\thasRunner = false,\n\trichText,\n\tuploadSlug,\n\tspam,\n\tvotedCookie = false,\n\tpollSourceRegistry,\n\tpollVotes = false,\n\tshowRawFields = false,\n\toverrides,\n}: BuildSubmissionsCollectionArgs): CollectionConfig => {\n\tconst defaultFields: Field[] = [\n\t\t{ name: 'form', type: 'relationship', relationTo: FORMS_SLUG, required: true },\n\t\t{\n\t\t\tname: 'status',\n\t\t\ttype: 'select',\n\t\t\tdefaultValue: 'complete',\n\t\t\toptions: [\n\t\t\t\t{ label: labelForKey(keys.statusComplete), value: 'complete' },\n\t\t\t\t{ label: labelForKey(keys.statusPartial), value: 'partial' },\n\t\t\t],\n\t\t\t// Not clearable: aggregation counts `status: complete` submissions, so a cleared status\n\t\t\t// would drop the submission out of every poll result with nothing in the UI saying so.\n\t\t\tadmin: { isClearable: false },\n\t\t\t// Defense-in-depth at the REST layer: anonymous clients cannot set status via the API.\n\t\t\t// The validateSubmission hook also forces 'complete' server-side, so this covers both paths.\n\t\t\taccess: { create: isLoggedIn, update: isLoggedIn },\n\t\t},\n\t\t// answers UI appears first so it is the dominant view when opening a submission document.\n\t\t{\n\t\t\tname: 'answers',\n\t\t\ttype: 'ui',\n\t\t\tadmin: {\n\t\t\t\tcomponents: { Field: '@10x-media/form-builder/rsc#SubmissionAnswers' },\n\t\t\t},\n\t\t},\n\t\t{ name: 'locale', type: 'text', admin: { hidden: !showRawFields } },\n\t\t{ name: 'values', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'descriptors', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'consent', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'meta', type: 'json', admin: { hidden: !showRawFields } },\n\t\t// The verified reference to the document this form was rendered for (see verifyContext). Only forms\n\t\t// rendered with a signed context carry one, so the group is shown only when present. Readable, so it\n\t\t// is a legible audit record of which document a submission came through.\n\t\t{\n\t\t\tname: 'context',\n\t\t\ttype: 'group',\n\t\t\tlabel: labelForKey(keys.submissionContext),\n\t\t\t// A Payload group always materializes as an object, so gate on a populated `relationTo`\n\t\t\t// (not the group itself) to show it only for submissions made through a signed context.\n\t\t\tadmin: { readOnly: true, condition: (data) => Boolean(data?.context?.relationTo) },\n\t\t\tfields: [\n\t\t\t\t{ name: 'relationTo', type: 'text' },\n\t\t\t\t{ name: 'value', type: 'text' },\n\t\t\t],\n\t\t},\n\t]\n\n\treturn {\n\t\t...(overrides ?? {}),\n\t\tslug: FORM_SUBMISSIONS_SLUG,\n\t\tlabels: {\n\t\t\tsingular: labelForKey(keys.collectionSubmissionSingular),\n\t\t\tplural: labelForKey(keys.collectionSubmissionPlural),\n\t\t\t...(overrides?.labels ?? {}),\n\t\t},\n\t\tadmin: {\n\t\t\tgroup: 'Forms',\n\t\t\tdefaultColumns: ['form', 'status', 'locale', 'createdAt'],\n\t\t\t...(overrides?.admin ?? {}),\n\t\t},\n\t\taccess: {\n\t\t\tcreate: () => true,\n\t\t\tread: isLoggedIn,\n\t\t\tupdate: () => false,\n\t\t\t...(overrides?.access ?? {}),\n\t\t},\n\t\thooks: {\n\t\t\t...(overrides?.hooks ?? {}),\n\t\t\t// Spam guard + validateSubmission must remain first: they enforce the security invariant\n\t\t\t// that anonymous callers cannot bypass post-submit actions or supply a forged status.\n\t\t\t// Consumer beforeValidate hooks are appended after so they run on already-validated data.\n\t\t\tbeforeValidate: [\n\t\t\t\t...(spam ? [buildSpamGuard(spam)] : []),\n\t\t\t\t// Verify + store the signed form context before validation stores the answers, and independently\n\t\t\t\t// of spam so it runs even with spam off.\n\t\t\t\tverifyContext(),\n\t\t\t\tvalidateSubmission({\n\t\t\t\t\tregistry,\n\t\t\t\t\truleRegistry,\n\t\t\t\t\tcalcSources,\n\t\t\t\t\tcalcFunctions,\n\t\t\t\t\tconsentSources,\n\t\t\t\t\tconsentSnapshot,\n\t\t\t\t\tuploadSlug,\n\t\t\t\t\tpollSourceRegistry,\n\t\t\t\t\tstrictUploadOwnership: spam ? spam.uploadOwnership === 'strict' : false,\n\t\t\t\t}),\n\t\t\t\t...(overrides?.hooks?.beforeValidate ?? []),\n\t\t\t],\n\t\t\tafterChange: [\n\t\t\t\t// The tally hook runs first and does not swallow errors: a bump failure must throw so\n\t\t\t\t// Payload rolls the submission create/update back inside the operation transaction,\n\t\t\t\t// rather than being absorbed by the dispatch hook's swallow-all error boundary below.\n\t\t\t\t...(pollVotes !== false ? [makeVoteTallyHook()] : []),\n\t\t\t\tmakeAfterChange({ actionRegistry, events, hasRunner, richText }),\n\t\t\t\t// Always registered: it self-gates on the option and on the form's allowChange flag.\n\t\t\t\tmakeVotedCookieHook({ votedCookie }),\n\t\t\t\t...(overrides?.hooks?.afterChange ?? []),\n\t\t\t],\n\t\t},\n\t\t// The vote-submit endpoint must stay first so it shadows the stock REST create (custom\n\t\t// endpoints match before built-ins); `endpoints: false` from a host disables ours too.\n\t\tendpoints:\n\t\t\toverrides?.endpoints === false\n\t\t\t\t? false\n\t\t\t\t: [buildVoteSubmitEndpoint(), ...(overrides?.endpoints ?? [])],\n\t\tfields: overrides?.fields ? overrides.fields({ defaultFields }) : defaultFields,\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"formSubmissions.js","names":[],"sources":["../../src/collections/formSubmissions.ts"],"sourcesContent":["import type { CollectionAfterChangeHook, CollectionConfig, Field, PayloadRequest } from 'payload'\nimport type { RichTextBodyOption } from '../actions/body/serializeBody'\nimport { dispatchActions } from '../actions/dispatch'\nimport type { ActionRegistry } from '../actions/registry'\nimport type { ActionInstance } from '../actions/runActions'\nimport type { CalcFunction, CalcSource } from '../calc/registry'\nimport type { ConsentSnapshotMode } from '../consent/captureConsent'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { resolveEventSink } from '../events/resolveEventSink'\nimport type { FormEventSink } from '../events/types'\nimport type { FieldTypeRegistry } from '../fields/registry'\nimport { pollConfigOf } from '../form/pollState'\nimport { isLoggedIn } from '../plugin/access'\nimport type { CollectionOverrides } from '../plugin/collectionOverrides'\nimport type { PollOptionSourceRegistry } from '../poll/registry'\nimport { makeVoteTallyHook } from '../poll/votes/voteTallyHook'\nimport { buildSpamGuard } from '../spam/spamGuard'\nimport type { ResolvedSpamConfig } from '../spam/types'\nimport { formIdOf } from '../submissions/formIdOf'\nimport { validateSubmission } from '../submissions/validateSubmission'\nimport { verifyContext } from '../submissions/verifyContext'\nimport { buildVoteSubmitEndpoint } from '../submissions/voteChangeEndpoint'\nimport {\n\tPOLL_CONTEXT_KEY,\n\ttype PollContextState,\n\tsignVotedCookieValue,\n\tVOTED_COOKIE_MAX_AGE_SECONDS,\n\tvoteChangeTargetOf,\n\tvotedCookieName,\n} from '../submissions/votedCookie'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\nimport type { ValidationRuleRegistry } from '../validation/registry'\nimport { FORMS_SLUG } from './forms'\n\nexport const FORM_SUBMISSIONS_SLUG = 'form-submissions'\n\ntype BuildSubmissionsCollectionArgs = {\n\tregistry: FieldTypeRegistry\n\truleRegistry: ValidationRuleRegistry\n\t/** Registered calc sources; submission validation re-resolves them fresh (a failure rejects the submission). */\n\tcalcSources?: Record<string, CalcSource>\n\t/** Registered calc functions; threaded into submit-time calc evaluation. */\n\tcalcFunctions?: Record<string, CalcFunction>\n\t/** The host's consent sources resolver (plugin option `consent.sources`); absent when no sources are configured. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** What each consent proof snapshots of the agreed wording (plugin option `consent.snapshot`). */\n\tconsentSnapshot?: ConsentSnapshotMode\n\tactionRegistry?: ActionRegistry\n\tevents?: FormEventSink\n\t/** Whether a job runner is likely present; gates the queued vs bounded-inline dispatch path. */\n\thasRunner?: boolean\n\t/** Body serialization customization forwarded to the inline action dispatch path. */\n\trichText?: RichTextBodyOption\n\t/** The plugin-configured uploads collection slug; absent when uploads are disabled. */\n\tuploadSlug?: string\n\t/** Resolved spam config; when active, prepends the spam guard before validation. `false` disables it. */\n\tspam?: ResolvedSpamConfig | false\n\t/**\n\t * Opt-in (`poll.votedCookie`): set an httpOnly `fb-voted-{formId}` cookie on poll submission\n\t * creates. An `allowChange` poll sets its signed-id cookie regardless of this flag.\n\t */\n\tvotedCookie?: boolean\n\t/** Registered poll option sources; submission validation resolves allowed values through them. */\n\tpollSourceRegistry?: PollOptionSourceRegistry\n\t/** Resolved `poll.votes` option; `false` skips registering the append-only vote tally hook. */\n\tpollVotes?: false | { overrides?: CollectionOverrides }\n\t/**\n\t * When `true`, reveals the standalone `locale`, `values`, `descriptors`, `consent`, and `meta`\n\t * fields in the admin UI. Default `false`, because the `SubmissionAnswers` UI component already\n\t * represents them fully (locale and meta are folded into its Submission-details section).\n\t */\n\tshowRawFields?: boolean\n\toverrides?: CollectionOverrides\n}\n\n/**\n * On a completed submission create, dispatch the form's post-submit actions and emit `submission.created`.\n * Both are side effects on an already-written row, so the whole body is wrapped: nothing it does can throw\n * past the hook (a failed action or sink must never fail the submission write). Dispatch is itself bounded\n * and non-throwing; the inline fallback is awaited so the bound caps the added latency, while the queued\n * path returns immediately. The form's `actions` are null-guarded for legacy rows created before the field\n * existed.\n */\nconst makeAfterChange =\n\t(args: {\n\t\tactionRegistry: ActionRegistry\n\t\tevents?: FormEventSink\n\t\thasRunner: boolean\n\t\trichText?: RichTextBodyOption\n\t}): CollectionAfterChangeHook =>\n\tasync ({ doc, operation, req }) => {\n\t\tif (operation !== 'create' || (doc.status != null && doc.status !== 'complete')) {\n\t\t\treturn doc\n\t\t}\n\t\tconst { payload } = req\n\t\ttry {\n\t\t\tconst formId = formIdOf(doc.form)\n\t\t\tif (formId == null) {\n\t\t\t\treturn doc\n\t\t\t}\n\t\t\tconst form = await payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\n\t\t\tconst { essentialFailed } = await dispatchActions({\n\t\t\t\tactions: (form?.actions ?? null) as ActionInstance[] | null,\n\t\t\t\tformId,\n\t\t\t\tsubmissionId: doc.id as number | string,\n\t\t\t\tregistry: args.actionRegistry,\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\thasRunner: args.hasRunner,\n\t\t\t\tpersistSubmissions: form?.persistSubmissions as boolean | undefined,\n\t\t\t\trichText: args.richText,\n\t\t\t})\n\t\t\t// A failed-essential submission is kept for recovery but is not a completed signup: no\n\t\t\t// created event, so a sink-driven automation never treats it as one. The stamp is what an\n\t\t\t// operator filters on to find and clear the kept rows. Field access blocks every API\n\t\t\t// writer, so the slug-agnostic override write here is the only path that can set it.\n\t\t\tif (essentialFailed) {\n\t\t\t\tconst update = payload.update.bind(payload) as unknown as (options: {\n\t\t\t\t\tcollection: string\n\t\t\t\t\tid: number | string\n\t\t\t\t\tdata: { actionFailed: boolean }\n\t\t\t\t\tdepth?: number\n\t\t\t\t\toverrideAccess?: boolean\n\t\t\t\t\treq?: PayloadRequest\n\t\t\t\t}) => Promise<unknown>\n\t\t\t\tawait update({\n\t\t\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\t\t\tid: doc.id as number | string,\n\t\t\t\t\tdata: { actionFailed: true },\n\t\t\t\t\tdepth: 0,\n\t\t\t\t\toverrideAccess: true,\n\t\t\t\t\treq,\n\t\t\t\t})\n\t\t\t\treturn doc\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\tawait resolveEventSink(args.events).emit({\n\t\t\t\t\ttype: 'submission.created',\n\t\t\t\t\tformId: String(formId),\n\t\t\t\t\tsubmissionId: String(doc.id),\n\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t})\n\t\t\t} catch (error) {\n\t\t\t\tpayload.logger?.error(\n\t\t\t\t\t`@10x-media/form-builder: submission.created sink threw: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`\n\t\t\t\t)\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tpayload.logger?.error(\n\t\t\t\t`@10x-media/form-builder: afterChange dispatch failed for submission ${String(doc.id)}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`\n\t\t\t)\n\t\t}\n\t\treturn doc\n\t}\n\nconst isPollContextState = (value: unknown): value is PollContextState =>\n\tvalue != null &&\n\ttypeof value === 'object' &&\n\ttypeof (value as PollContextState).pollEnabled === 'boolean'\n\n/**\n * Voted-cookie hook: after a submission to a poll-enabled form is created (or updated through the\n * vote-change path), appends an httpOnly `fb-voted-{formId}` cookie via `req.responseHeaders`,\n * Payload's supported channel for hook-set response headers (its own auth strategies use it;\n * `handleEndpoints` merges it into every REST response). The value stays the legacy `1` marker\n * under the `poll.votedCookie` plugin option; an `allowChange` poll instead carries the signed\n * submission id (set regardless of the option, because re-vote identification depends on it) and\n * refreshes it on every change. Reads the poll state `validateSubmission` stashed on\n * `req.context`, falling back to a form fetch when absent. httpOnly keeps the marker readable\n * only server-side (`hasVotedCookie`/`resolveVotedSubmission`); the client `<Poll>` keeps its\n * localStorage guard.\n */\nconst makeVotedCookieHook = (args: { votedCookie: boolean }): CollectionAfterChangeHook => {\n\treturn async ({ doc, operation, req }) => {\n\t\tconst changing = operation === 'update' && voteChangeTargetOf(req) != null\n\t\tif (operation !== 'create' && !changing) {\n\t\t\treturn doc\n\t\t}\n\t\tconst formId = formIdOf(doc.form)\n\t\tif (formId == null) {\n\t\t\treturn doc\n\t\t}\n\t\tconst stashed = req.context?.[POLL_CONTEXT_KEY]\n\t\tlet state = isPollContextState(stashed) ? stashed : undefined\n\t\tif (state === undefined) {\n\t\t\tconst form = await req.payload\n\t\t\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t\t\t.catch(() => null)\n\t\t\tstate = {\n\t\t\t\tpollEnabled: form?.pollEnabled === true,\n\t\t\t\tallowChange: pollConfigOf(form?.poll)?.allowChange === true,\n\t\t\t}\n\t\t}\n\t\tif (!state.pollEnabled || !(args.votedCookie || state.allowChange)) {\n\t\t\treturn doc\n\t\t}\n\t\tconst value = state.allowChange\n\t\t\t? signVotedCookieValue(req.payload, doc.id as number | string)\n\t\t\t: '1'\n\t\t// A signed id is a bearer capability (it authorizes changing that vote), so it follows the\n\t\t// host's Payload auth-cookie transport policy: `Secure` whenever the admin user collection's\n\t\t// `auth.cookies.secure` is on. The legacy `1` marker stays a plain UX flag either way.\n\t\tconst adminUserSlug = req.payload.config.admin?.user\n\t\t// String-indexed cast: a host's generated types key `collections` to its own slugs, which\n\t\t// this framework-level read cannot know.\n\t\tconst collections = req.payload.collections as Record<\n\t\t\tstring,\n\t\t\t{ config: { auth?: { cookies?: { secure?: boolean } } } } | undefined\n\t\t>\n\t\tconst authCookies = adminUserSlug ? collections[adminUserSlug]?.config.auth?.cookies : undefined\n\t\tconst secure = state.allowChange && authCookies?.secure === true ? '; Secure' : ''\n\t\treq.responseHeaders ??= new Headers()\n\t\treq.responseHeaders.append(\n\t\t\t'Set-Cookie',\n\t\t\t`${votedCookieName(formId)}=${value}; Path=/; Max-Age=${VOTED_COOKIE_MAX_AGE_SECONDS}; HttpOnly; SameSite=Lax${secure}`\n\t\t)\n\t\treturn doc\n\t}\n}\n\nexport const buildSubmissionsCollection = ({\n\tregistry,\n\truleRegistry,\n\tcalcSources,\n\tcalcFunctions,\n\tconsentSources,\n\tconsentSnapshot,\n\tactionRegistry = new Map(),\n\tevents,\n\thasRunner = false,\n\trichText,\n\tuploadSlug,\n\tspam,\n\tvotedCookie = false,\n\tpollSourceRegistry,\n\tpollVotes = false,\n\tshowRawFields = false,\n\toverrides,\n}: BuildSubmissionsCollectionArgs): CollectionConfig => {\n\tconst defaultFields: Field[] = [\n\t\t{ name: 'form', type: 'relationship', relationTo: FORMS_SLUG, required: true },\n\t\t{\n\t\t\tname: 'status',\n\t\t\ttype: 'select',\n\t\t\tdefaultValue: 'complete',\n\t\t\toptions: [\n\t\t\t\t{ label: labelForKey(keys.statusComplete), value: 'complete' },\n\t\t\t\t{ label: labelForKey(keys.statusPartial), value: 'partial' },\n\t\t\t],\n\t\t\t// Not clearable: aggregation counts `status: complete` submissions, so a cleared status\n\t\t\t// would drop the submission out of every poll result with nothing in the UI saying so.\n\t\t\tadmin: { isClearable: false },\n\t\t\t// Defense-in-depth at the REST layer: anonymous clients cannot set status via the API.\n\t\t\t// The validateSubmission hook also forces 'complete' server-side, so this covers both paths.\n\t\t\taccess: { create: isLoggedIn, update: isLoggedIn },\n\t\t},\n\t\t// Stamped by the dispatcher when an essential action failed and the row was kept: the flag is\n\t\t// what lets an operator filter these ownerless rows and clear them once the provider has been\n\t\t// fixed and the addresses replayed. Hidden while unset; never client-writable.\n\t\t{\n\t\t\tname: 'actionFailed',\n\t\t\ttype: 'checkbox',\n\t\t\tindex: true,\n\t\t\tlabel: labelForKey(keys.submissionActionFailedFlag),\n\t\t\taccess: { create: () => false, update: () => false },\n\t\t\tadmin: { readOnly: true, condition: (data) => data?.actionFailed === true },\n\t\t},\n\t\t// answers UI appears first so it is the dominant view when opening a submission document.\n\t\t{\n\t\t\tname: 'answers',\n\t\t\ttype: 'ui',\n\t\t\tadmin: {\n\t\t\t\tcomponents: { Field: '@10x-media/form-builder/rsc#SubmissionAnswers' },\n\t\t\t},\n\t\t},\n\t\t{ name: 'locale', type: 'text', admin: { hidden: !showRawFields } },\n\t\t{ name: 'values', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'descriptors', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'consent', type: 'json', admin: { hidden: !showRawFields } },\n\t\t{ name: 'meta', type: 'json', admin: { hidden: !showRawFields } },\n\t\t// The verified reference to the document this form was rendered for (see verifyContext). Only forms\n\t\t// rendered with a signed context carry one, so the group is shown only when present. Readable, so it\n\t\t// is a legible audit record of which document a submission came through.\n\t\t{\n\t\t\tname: 'context',\n\t\t\ttype: 'group',\n\t\t\tlabel: labelForKey(keys.submissionContext),\n\t\t\t// A Payload group always materializes as an object, so gate on a populated `relationTo`\n\t\t\t// (not the group itself) to show it only for submissions made through a signed context.\n\t\t\tadmin: { readOnly: true, condition: (data) => Boolean(data?.context?.relationTo) },\n\t\t\tfields: [\n\t\t\t\t{ name: 'relationTo', type: 'text' },\n\t\t\t\t{ name: 'value', type: 'text' },\n\t\t\t],\n\t\t},\n\t]\n\n\treturn {\n\t\t...(overrides ?? {}),\n\t\tslug: FORM_SUBMISSIONS_SLUG,\n\t\tlabels: {\n\t\t\tsingular: labelForKey(keys.collectionSubmissionSingular),\n\t\t\tplural: labelForKey(keys.collectionSubmissionPlural),\n\t\t\t...(overrides?.labels ?? {}),\n\t\t},\n\t\tadmin: {\n\t\t\tgroup: 'Forms',\n\t\t\tdefaultColumns: ['form', 'status', 'locale', 'createdAt'],\n\t\t\t...(overrides?.admin ?? {}),\n\t\t},\n\t\taccess: {\n\t\t\tcreate: () => true,\n\t\t\tread: isLoggedIn,\n\t\t\tupdate: () => false,\n\t\t\t...(overrides?.access ?? {}),\n\t\t},\n\t\thooks: {\n\t\t\t...(overrides?.hooks ?? {}),\n\t\t\t// Spam guard + validateSubmission must remain first: they enforce the security invariant\n\t\t\t// that anonymous callers cannot bypass post-submit actions or supply a forged status.\n\t\t\t// Consumer beforeValidate hooks are appended after so they run on already-validated data.\n\t\t\tbeforeValidate: [\n\t\t\t\t...(spam ? [buildSpamGuard(spam)] : []),\n\t\t\t\t// Verify + store the signed form context before validation stores the answers, and independently\n\t\t\t\t// of spam so it runs even with spam off.\n\t\t\t\tverifyContext(),\n\t\t\t\tvalidateSubmission({\n\t\t\t\t\tregistry,\n\t\t\t\t\truleRegistry,\n\t\t\t\t\tcalcSources,\n\t\t\t\t\tcalcFunctions,\n\t\t\t\t\tconsentSources,\n\t\t\t\t\tconsentSnapshot,\n\t\t\t\t\tuploadSlug,\n\t\t\t\t\tpollSourceRegistry,\n\t\t\t\t\tstrictUploadOwnership: spam ? spam.uploadOwnership === 'strict' : false,\n\t\t\t\t}),\n\t\t\t\t...(overrides?.hooks?.beforeValidate ?? []),\n\t\t\t],\n\t\t\tafterChange: [\n\t\t\t\t// The tally hook runs first and does not swallow errors: a bump failure must throw so\n\t\t\t\t// Payload rolls the submission create/update back inside the operation transaction,\n\t\t\t\t// rather than being absorbed by the dispatch hook's swallow-all error boundary below.\n\t\t\t\t...(pollVotes !== false ? [makeVoteTallyHook()] : []),\n\t\t\t\tmakeAfterChange({ actionRegistry, events, hasRunner, richText }),\n\t\t\t\t// Always registered: it self-gates on the option and on the form's allowChange flag.\n\t\t\t\tmakeVotedCookieHook({ votedCookie }),\n\t\t\t\t...(overrides?.hooks?.afterChange ?? []),\n\t\t\t],\n\t\t},\n\t\t// The vote-submit endpoint must stay first so it shadows the stock REST create (custom\n\t\t// endpoints match before built-ins); `endpoints: false` from a host disables ours too.\n\t\tendpoints:\n\t\t\toverrides?.endpoints === false\n\t\t\t\t? false\n\t\t\t\t: [buildVoteSubmitEndpoint(), ...(overrides?.endpoints ?? [])],\n\t\tfields: overrides?.fields ? overrides.fields({ defaultFields }) : defaultFields,\n\t}\n}\n"],"mappings":"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"}
@@ -5,7 +5,8 @@ import { Field } from "payload";
5
5
  type DefaultSettingsFields = {
6
6
  multistep: Field;
7
7
  pollEnabled: Field;
8
- persistSubmissions: Field;
8
+ persistSubmissions: Field; /** Sidebar notice shown when a non-persisting form carries a consent field: proofs are pruned with the row. */
9
+ consentRetentionNotice: Field;
9
10
  };
10
11
  /**
11
12
  * Composes the three form-level flag fields. Receives the defaults and returns the final field
@@ -28,6 +28,15 @@ const buildDefaultSettingsFields = () => ({
28
28
  defaultValue: true,
29
29
  label: labelForKey(keys.formPersistSubmissions),
30
30
  admin: { position: "sidebar" }
31
+ },
32
+ consentRetentionNotice: {
33
+ name: "consentRetentionNotice",
34
+ type: "ui",
35
+ admin: {
36
+ position: "sidebar",
37
+ condition: (data) => data?.persistSubmissions === false && Array.isArray(data?.fields) && (data.fields ?? []).some((field) => field?.blockType === "consent"),
38
+ components: { Field: "@10x-media/form-builder/client#ConsentRetentionNotice" }
39
+ }
31
40
  }
32
41
  });
33
42
  const composeSettingsFields = (settings) => {
@@ -36,7 +45,8 @@ const composeSettingsFields = (settings) => {
36
45
  return [
37
46
  defaultFields.multistep,
38
47
  defaultFields.pollEnabled,
39
- defaultFields.persistSubmissions
48
+ defaultFields.persistSubmissions,
49
+ defaultFields.consentRetentionNotice
40
50
  ];
41
51
  };
42
52
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"settingsFields.js","names":[],"sources":["../../src/collections/settingsFields.ts"],"sourcesContent":["import type { Field } from 'payload'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\n\n/** The default form-level flag fields, keyed by flag. What the `settings.fields` seam receives. */\nexport type DefaultSettingsFields = {\n\tmultistep: Field\n\tpollEnabled: Field\n\tpersistSubmissions: Field\n}\n\n/**\n * Composes the three form-level flag fields. Receives the defaults and returns the final field\n * array placed verbatim at the forms collection root, so a host can relocate a flag (strip\n * `admin.position`), wrap them in a row, append its own, or drop one.\n */\nexport type SettingsFieldsOverride = (args: { defaultFields: DefaultSettingsFields }) => Field[]\n\n/** The plugin `settings` option: the three form-level flags and their composition seam. */\nexport type SettingsOption = {\n\tfields?: SettingsFieldsOverride\n}\n\n/**\n * The three flags: behavior, never localized, sidebar checkboxes by default. `multistep` gates the\n * Flow tab and the client's step navigation; `pollEnabled` gates the Poll tab and marks the form a\n * poll; `persistSubmissions` (default checked) tells the plugin whether to keep a submission's row\n * after its actions run, or prune it (a pure signup form's opt-out).\n */\nexport const buildDefaultSettingsFields = (): DefaultSettingsFields => ({\n\tmultistep: {\n\t\tname: 'multistep',\n\t\ttype: 'checkbox',\n\t\tdefaultValue: false,\n\t\tlabel: labelForKey(keys.formMultistep),\n\t\tadmin: { position: 'sidebar' },\n\t},\n\tpollEnabled: {\n\t\tname: 'pollEnabled',\n\t\ttype: 'checkbox',\n\t\tdefaultValue: false,\n\t\tlabel: labelForKey(keys.formPollEnabled),\n\t\tadmin: { position: 'sidebar' },\n\t},\n\tpersistSubmissions: {\n\t\tname: 'persistSubmissions',\n\t\ttype: 'checkbox',\n\t\tdefaultValue: true,\n\t\tlabel: labelForKey(keys.formPersistSubmissions),\n\t\tadmin: { position: 'sidebar' },\n\t},\n})\n\nexport const composeSettingsFields = (settings: SettingsOption | undefined): Field[] => {\n\tconst defaultFields = buildDefaultSettingsFields()\n\tif (settings?.fields) {\n\t\treturn settings.fields({ defaultFields })\n\t}\n\treturn [defaultFields.multistep, defaultFields.pollEnabled, defaultFields.persistSubmissions]\n}\n"],"mappings":";;;;;;;;;AA6BA,MAAa,oCAA2D;CACvE,WAAW;EACV,MAAM;EACN,MAAM;EACN,cAAc;EACd,OAAO,YAAY,KAAK,aAAa;EACrC,OAAO,EAAE,UAAU,UAAU;CAC9B;CACA,aAAa;EACZ,MAAM;EACN,MAAM;EACN,cAAc;EACd,OAAO,YAAY,KAAK,eAAe;EACvC,OAAO,EAAE,UAAU,UAAU;CAC9B;CACA,oBAAoB;EACnB,MAAM;EACN,MAAM;EACN,cAAc;EACd,OAAO,YAAY,KAAK,sBAAsB;EAC9C,OAAO,EAAE,UAAU,UAAU;CAC9B;AACD;AAEA,MAAa,yBAAyB,aAAkD;CACvF,MAAM,gBAAgB,2BAA2B;CACjD,IAAI,UAAU,QACb,OAAO,SAAS,OAAO,EAAE,cAAc,CAAC;CAEzC,OAAO;EAAC,cAAc;EAAW,cAAc;EAAa,cAAc;CAAkB;AAC7F"}
1
+ {"version":3,"file":"settingsFields.js","names":[],"sources":["../../src/collections/settingsFields.ts"],"sourcesContent":["import type { Field } from 'payload'\nimport { keys } from '../translations/keys'\nimport { labelForKey } from '../translations/server'\n\n/** The default form-level flag fields, keyed by flag. What the `settings.fields` seam receives. */\nexport type DefaultSettingsFields = {\n\tmultistep: Field\n\tpollEnabled: Field\n\tpersistSubmissions: Field\n\t/** Sidebar notice shown when a non-persisting form carries a consent field: proofs are pruned with the row. */\n\tconsentRetentionNotice: Field\n}\n\n/**\n * Composes the three form-level flag fields. Receives the defaults and returns the final field\n * array placed verbatim at the forms collection root, so a host can relocate a flag (strip\n * `admin.position`), wrap them in a row, append its own, or drop one.\n */\nexport type SettingsFieldsOverride = (args: { defaultFields: DefaultSettingsFields }) => Field[]\n\n/** The plugin `settings` option: the three form-level flags and their composition seam. */\nexport type SettingsOption = {\n\tfields?: SettingsFieldsOverride\n}\n\n/**\n * The three flags: behavior, never localized, sidebar checkboxes by default. `multistep` gates the\n * Flow tab and the client's step navigation; `pollEnabled` gates the Poll tab and marks the form a\n * poll; `persistSubmissions` (default checked) tells the plugin whether to keep a submission's row\n * after its actions run, or prune it (a pure signup form's opt-out).\n */\nexport const buildDefaultSettingsFields = (): DefaultSettingsFields => ({\n\tmultistep: {\n\t\tname: 'multistep',\n\t\ttype: 'checkbox',\n\t\tdefaultValue: false,\n\t\tlabel: labelForKey(keys.formMultistep),\n\t\tadmin: { position: 'sidebar' },\n\t},\n\tpollEnabled: {\n\t\tname: 'pollEnabled',\n\t\ttype: 'checkbox',\n\t\tdefaultValue: false,\n\t\tlabel: labelForKey(keys.formPollEnabled),\n\t\tadmin: { position: 'sidebar' },\n\t},\n\tpersistSubmissions: {\n\t\tname: 'persistSubmissions',\n\t\ttype: 'checkbox',\n\t\tdefaultValue: true,\n\t\tlabel: labelForKey(keys.formPersistSubmissions),\n\t\tadmin: { position: 'sidebar' },\n\t},\n\t// A visible contradiction notice, not a refusal: a consent field on a non-persisting form is\n\t// legitimate when the consent record lives elsewhere (a double opt-in provider), but the proof\n\t// is pruned with the row, and that must never be silent.\n\tconsentRetentionNotice: {\n\t\tname: 'consentRetentionNotice',\n\t\ttype: 'ui',\n\t\tadmin: {\n\t\t\tposition: 'sidebar',\n\t\t\tcondition: (data) =>\n\t\t\t\t(data as { persistSubmissions?: unknown })?.persistSubmissions === false &&\n\t\t\t\tArray.isArray((data as { fields?: unknown })?.fields) &&\n\t\t\t\t((data as { fields: { blockType?: unknown }[] }).fields ?? []).some(\n\t\t\t\t\t(field) => field?.blockType === 'consent'\n\t\t\t\t),\n\t\t\tcomponents: { Field: '@10x-media/form-builder/client#ConsentRetentionNotice' },\n\t\t},\n\t},\n})\n\nexport const composeSettingsFields = (settings: SettingsOption | undefined): Field[] => {\n\tconst defaultFields = buildDefaultSettingsFields()\n\tif (settings?.fields) {\n\t\treturn settings.fields({ defaultFields })\n\t}\n\treturn [\n\t\tdefaultFields.multistep,\n\t\tdefaultFields.pollEnabled,\n\t\tdefaultFields.persistSubmissions,\n\t\tdefaultFields.consentRetentionNotice,\n\t]\n}\n"],"mappings":";;;;;;;;;AA+BA,MAAa,oCAA2D;CACvE,WAAW;EACV,MAAM;EACN,MAAM;EACN,cAAc;EACd,OAAO,YAAY,KAAK,aAAa;EACrC,OAAO,EAAE,UAAU,UAAU;CAC9B;CACA,aAAa;EACZ,MAAM;EACN,MAAM;EACN,cAAc;EACd,OAAO,YAAY,KAAK,eAAe;EACvC,OAAO,EAAE,UAAU,UAAU;CAC9B;CACA,oBAAoB;EACnB,MAAM;EACN,MAAM;EACN,cAAc;EACd,OAAO,YAAY,KAAK,sBAAsB;EAC9C,OAAO,EAAE,UAAU,UAAU;CAC9B;CAIA,wBAAwB;EACvB,MAAM;EACN,MAAM;EACN,OAAO;GACN,UAAU;GACV,YAAY,SACV,MAA2C,uBAAuB,SACnE,MAAM,QAAS,MAA+B,MAAM,MAClD,KAA+C,UAAU,CAAC,GAAG,MAC7D,UAAU,OAAO,cAAc,SACjC;GACD,YAAY,EAAE,OAAO,wDAAwD;EAC9E;CACD;AACD;AAEA,MAAa,yBAAyB,aAAkD;CACvF,MAAM,gBAAgB,2BAA2B;CACjD,IAAI,UAAU,QACb,OAAO,SAAS,OAAO,EAAE,cAAc,CAAC;CAEzC,OAAO;EACN,cAAc;EACd,cAAc;EACd,cAAc;EACd,cAAc;CACf;AACD"}
@@ -3,6 +3,7 @@ import { CalcExpressionBuilder } from "../client/CalcExpressionBuilder.js";
3
3
  import { ClosePollButton } from "../client/ClosePollButton.js";
4
4
  import { CondensedArray } from "../client/CondensedArray.js";
5
5
  import { ConsentBlockLabel } from "../client/ConsentBlockLabel.js";
6
+ import { ConsentRetentionNotice } from "../client/ConsentRetentionNotice.js";
6
7
  import { ConsentSourceRowLabel } from "../client/ConsentSourceRowLabel.js";
7
8
  import { EndpointOptionsSelect } from "../client/EndpointOptionsSelect.js";
8
9
  import { FieldBlockLabel } from "../client/FieldBlockLabel.js";
@@ -13,4 +14,4 @@ import { RecipientsSelect } from "../client/RecipientsSelect.js";
13
14
  import { RuleDescription } from "../client/RuleDescription.js";
14
15
  import { FieldCountCell } from "../collections/cells/FieldCountCell.js";
15
16
  import { FlowStepsCell } from "../collections/cells/FlowStepsCell.js";
16
- export { ByteSizeField, CalcExpressionBuilder, ClosePollButton, CondensedArray, ConsentBlockLabel, ConsentSourceRowLabel, EndpointOptionsSelect, FieldBlockLabel, FieldCountCell, FieldNameSelect, FlowBuilder, FlowStepsCell, FormConditionField, RecipientsSelect, RuleDescription };
17
+ export { ByteSizeField, CalcExpressionBuilder, ClosePollButton, CondensedArray, ConsentBlockLabel, ConsentRetentionNotice, ConsentSourceRowLabel, EndpointOptionsSelect, FieldBlockLabel, FieldCountCell, FieldNameSelect, FlowBuilder, FlowStepsCell, FormConditionField, RecipientsSelect, RuleDescription };
@@ -4,6 +4,7 @@ import { CalcExpressionBuilder } from "../client/CalcExpressionBuilder.js";
4
4
  import { ClosePollButton } from "../client/ClosePollButton.js";
5
5
  import { CondensedArray } from "../client/CondensedArray.js";
6
6
  import { ConsentBlockLabel } from "../client/ConsentBlockLabel.js";
7
+ import { ConsentRetentionNotice } from "../client/ConsentRetentionNotice.js";
7
8
  import { ConsentSourceRowLabel } from "../client/ConsentSourceRowLabel.js";
8
9
  import { EndpointOptionsSelect } from "../client/EndpointOptionsSelect.js";
9
10
  import { FieldBlockLabel } from "../client/FieldBlockLabel.js";
@@ -14,4 +15,4 @@ import { RecipientsSelect } from "../client/RecipientsSelect.js";
14
15
  import { RuleDescription } from "../client/RuleDescription.js";
15
16
  import { FieldCountCell } from "../collections/cells/FieldCountCell.js";
16
17
  import { FlowStepsCell } from "../collections/cells/FlowStepsCell.js";
17
- export { ByteSizeField, CalcExpressionBuilder, ClosePollButton, CondensedArray, ConsentBlockLabel, ConsentSourceRowLabel, EndpointOptionsSelect, FieldBlockLabel, FieldCountCell, FieldNameSelect, FlowBuilder, FlowStepsCell, FormConditionField, RecipientsSelect, RuleDescription };
18
+ export { ByteSizeField, CalcExpressionBuilder, ClosePollButton, CondensedArray, ConsentBlockLabel, ConsentRetentionNotice, ConsentSourceRowLabel, EndpointOptionsSelect, FieldBlockLabel, FieldCountCell, FieldNameSelect, FlowBuilder, FlowStepsCell, FormConditionField, RecipientsSelect, RuleDescription };
@@ -4,6 +4,12 @@ type FormState = {
4
4
  values: Record<string, unknown>;
5
5
  errors: FieldErrors;
6
6
  touched: Record<string, boolean>;
7
+ /**
8
+ * Fields whose value changed since mount or the last reset. Blur reveals a field's error only
9
+ * once it is dirty (or its step was attempted), so focusing and leaving a pristine field says
10
+ * nothing, and a reset makes every field pristine again.
11
+ */
12
+ dirty: Record<string, boolean>;
7
13
  submitting: boolean;
8
14
  submitted: boolean;
9
15
  /**