@10x-media/form-builder 0.1.0-beta.1 → 0.1.0-beta.3

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 (177) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +4 -2
  3. package/dist/actions/body/converters.d.ts +20 -0
  4. package/dist/actions/body/converters.js +57 -0
  5. package/dist/actions/body/converters.js.map +1 -0
  6. package/dist/actions/body/escapeHtml.d.ts +6 -0
  7. package/dist/actions/body/escapeHtml.js +14 -0
  8. package/dist/actions/body/escapeHtml.js.map +1 -0
  9. package/dist/actions/body/serializeBody.d.ts +35 -0
  10. package/dist/actions/body/serializeBody.js +76 -0
  11. package/dist/actions/body/serializeBody.js.map +1 -0
  12. package/dist/actions/body/serializeSlate.js +31 -0
  13. package/dist/actions/body/serializeSlate.js.map +1 -0
  14. package/dist/actions/body/wildcards.d.ts +10 -0
  15. package/dist/actions/body/wildcards.js +33 -0
  16. package/dist/actions/body/wildcards.js.map +1 -0
  17. package/dist/actions/builtin/confirmation.js +28 -12
  18. package/dist/actions/builtin/confirmation.js.map +1 -1
  19. package/dist/actions/builtin/emailTeam.js +7 -9
  20. package/dist/actions/builtin/emailTeam.js.map +1 -1
  21. package/dist/actions/builtin/signedWebhook.js +21 -3
  22. package/dist/actions/builtin/signedWebhook.js.map +1 -1
  23. package/dist/actions/defineAction.d.ts +2 -1
  24. package/dist/actions/defineAction.js.map +1 -1
  25. package/dist/actions/dispatch.js +2 -1
  26. package/dist/actions/dispatch.js.map +1 -1
  27. package/dist/actions/runActions.js +8 -1
  28. package/dist/actions/runActions.js.map +1 -1
  29. package/dist/actions/task.js +7 -5
  30. package/dist/actions/task.js.map +1 -1
  31. package/dist/calc/normalizeCalc.js +5 -17
  32. package/dist/calc/normalizeCalc.js.map +1 -1
  33. package/dist/calc/types.d.ts +8 -2
  34. package/dist/calc/types.js +22 -0
  35. package/dist/calc/types.js.map +1 -0
  36. package/dist/client/ByteSizeField.d.ts +21 -0
  37. package/dist/client/ByteSizeField.js +56 -0
  38. package/dist/client/ByteSizeField.js.map +1 -0
  39. package/dist/client/ConditionBuilder.js +2 -1
  40. package/dist/client/ConditionBuilder.js.map +1 -1
  41. package/dist/client/FieldNameSelect.d.ts +20 -0
  42. package/dist/client/FieldNameSelect.js +58 -0
  43. package/dist/client/FieldNameSelect.js.map +1 -0
  44. package/dist/client/FlowBuilder.d.ts +0 -1
  45. package/dist/client/FlowBuilder.js +4 -6
  46. package/dist/client/FlowBuilder.js.map +1 -1
  47. package/dist/client/FormConditionField.js +1 -4
  48. package/dist/client/FormConditionField.js.map +1 -1
  49. package/dist/client/admin.css +192 -0
  50. package/dist/client/toStaticLabel.js +13 -0
  51. package/dist/client/toStaticLabel.js.map +1 -0
  52. package/dist/collections/formSubmissions.js +60 -39
  53. package/dist/collections/formSubmissions.js.map +1 -1
  54. package/dist/collections/forms.js +143 -115
  55. package/dist/collections/forms.js.map +1 -1
  56. package/dist/collections/uploads.d.ts +2 -1
  57. package/dist/collections/uploads.js +22 -13
  58. package/dist/collections/uploads.js.map +1 -1
  59. package/dist/exports/client.d.ts +3 -1
  60. package/dist/exports/client.js +3 -1
  61. package/dist/exports/react.d.ts +7 -2
  62. package/dist/exports/react.js +6 -2
  63. package/dist/exports/types.d.ts +2 -1
  64. package/dist/fields/buildFieldBlocks.js +22 -12
  65. package/dist/fields/buildFieldBlocks.js.map +1 -1
  66. package/dist/fields/builtin/calculation.js +115 -2
  67. package/dist/fields/builtin/calculation.js.map +1 -1
  68. package/dist/fields/builtin/date.js +29 -0
  69. package/dist/fields/builtin/date.js.map +1 -0
  70. package/dist/fields/builtin/file.d.ts +15 -0
  71. package/dist/fields/builtin/file.js +79 -3
  72. package/dist/fields/builtin/file.js.map +1 -1
  73. package/dist/fields/builtin/index.d.ts +14 -0
  74. package/dist/fields/builtin/index.js +13 -2
  75. package/dist/fields/builtin/index.js.map +1 -1
  76. package/dist/fields/builtin/repeater.js +44 -0
  77. package/dist/fields/builtin/repeater.js.map +1 -0
  78. package/dist/fields/fieldNamesOfType.js +25 -0
  79. package/dist/fields/fieldNamesOfType.js.map +1 -0
  80. package/dist/fields/sharedConfig.js +30 -18
  81. package/dist/fields/sharedConfig.js.map +1 -1
  82. package/dist/fields/types.d.ts +2 -1
  83. package/dist/index.d.ts +38 -2
  84. package/dist/index.js +17 -5
  85. package/dist/index.js.map +1 -1
  86. package/dist/plugin/collectionOverrides.d.ts +22 -0
  87. package/dist/plugin/registerCollections.js +39 -10
  88. package/dist/plugin/registerCollections.js.map +1 -1
  89. package/dist/react/Form.d.ts +32 -3
  90. package/dist/react/Form.js +50 -8
  91. package/dist/react/Form.js.map +1 -1
  92. package/dist/react/FormContext.js.map +1 -1
  93. package/dist/react/Poll.js +1 -1
  94. package/dist/react/captcha/HcaptchaCaptcha.d.ts +28 -0
  95. package/dist/react/captcha/HcaptchaCaptcha.js +53 -0
  96. package/dist/react/captcha/HcaptchaCaptcha.js.map +1 -0
  97. package/dist/react/captcha/RecaptchaCaptcha.d.ts +41 -0
  98. package/dist/react/captcha/RecaptchaCaptcha.js +93 -0
  99. package/dist/react/captcha/RecaptchaCaptcha.js.map +1 -0
  100. package/dist/react/captcha/TurnstileCaptcha.d.ts +28 -0
  101. package/dist/react/captcha/TurnstileCaptcha.js +53 -0
  102. package/dist/react/captcha/TurnstileCaptcha.js.map +1 -0
  103. package/dist/react/captcha/types.d.ts +8 -0
  104. package/dist/react/captcha/useCaptchaScript.js +45 -0
  105. package/dist/react/captcha/useCaptchaScript.js.map +1 -0
  106. package/dist/react/captcha/vendors.js +8 -0
  107. package/dist/react/captcha/vendors.js.map +1 -0
  108. package/dist/react/primitives/Input.d.ts +1 -1
  109. package/dist/react/primitives/Input.js.map +1 -1
  110. package/dist/react/renderers/calculation.js +1 -1
  111. package/dist/react/renderers/checkbox.js +1 -1
  112. package/dist/react/renderers/consent.js +1 -1
  113. package/dist/react/renderers/date.js +37 -0
  114. package/dist/react/renderers/date.js.map +1 -0
  115. package/dist/react/renderers/email.js +1 -1
  116. package/dist/react/renderers/file.js +20 -7
  117. package/dist/react/renderers/file.js.map +1 -1
  118. package/dist/react/renderers/index.js +4 -0
  119. package/dist/react/renderers/index.js.map +1 -1
  120. package/dist/react/renderers/number.js +1 -1
  121. package/dist/react/renderers/repeater.js +113 -0
  122. package/dist/react/renderers/repeater.js.map +1 -0
  123. package/dist/react/renderers/select.js +1 -1
  124. package/dist/react/renderers/text.js +1 -1
  125. package/dist/react/renderers/textarea.js +1 -1
  126. package/dist/spam/captcha.d.ts +3 -2
  127. package/dist/spam/captcha.js +3 -2
  128. package/dist/spam/captcha.js.map +1 -1
  129. package/dist/spam/clientIp.js +9 -0
  130. package/dist/spam/clientIp.js.map +1 -0
  131. package/dist/spam/identify.js +3 -6
  132. package/dist/spam/identify.js.map +1 -1
  133. package/dist/spam/providers/hcaptcha.d.ts +17 -0
  134. package/dist/spam/providers/hcaptcha.js +18 -0
  135. package/dist/spam/providers/hcaptcha.js.map +1 -0
  136. package/dist/spam/providers/recaptcha.d.ts +23 -0
  137. package/dist/spam/providers/recaptcha.js +23 -0
  138. package/dist/spam/providers/recaptcha.js.map +1 -0
  139. package/dist/spam/providers/siteverify.js +34 -0
  140. package/dist/spam/providers/siteverify.js.map +1 -0
  141. package/dist/spam/providers/turnstile.d.ts +17 -0
  142. package/dist/spam/providers/turnstile.js +18 -0
  143. package/dist/spam/providers/turnstile.js.map +1 -0
  144. package/dist/spam/spamGuard.js +2 -5
  145. package/dist/spam/spamGuard.js.map +1 -1
  146. package/dist/spam/types.d.ts +2 -2
  147. package/dist/submissions/SubmissionAnswers.d.ts +2 -4
  148. package/dist/submissions/SubmissionAnswers.js +110 -34
  149. package/dist/submissions/SubmissionAnswers.js.map +1 -1
  150. package/dist/submissions/SubmissionAnswersClient.js +142 -0
  151. package/dist/submissions/SubmissionAnswersClient.js.map +1 -0
  152. package/dist/submissions/runSubmission.js +54 -2
  153. package/dist/submissions/runSubmission.js.map +1 -1
  154. package/dist/submissions/types.d.ts +2 -1
  155. package/dist/translations/en.js +52 -5
  156. package/dist/translations/en.js.map +1 -1
  157. package/dist/translations/keys.d.ts +51 -4
  158. package/dist/translations/keys.js +52 -5
  159. package/dist/translations/keys.js.map +1 -1
  160. package/dist/uploads/formatBytes.d.ts +9 -0
  161. package/dist/uploads/formatBytes.js +25 -0
  162. package/dist/uploads/formatBytes.js.map +1 -0
  163. package/dist/uploads/resolveFileRef.js +1 -1
  164. package/dist/uploads/resolveFileRef.js.map +1 -1
  165. package/dist/validation/builtin/index.d.ts +14 -0
  166. package/dist/validation/builtin/index.js +12 -1
  167. package/dist/validation/builtin/index.js.map +1 -1
  168. package/dist/validation/builtin/maxDate.js +23 -0
  169. package/dist/validation/builtin/maxDate.js.map +1 -0
  170. package/dist/validation/builtin/minDate.js +23 -0
  171. package/dist/validation/builtin/minDate.js.map +1 -0
  172. package/dist/validation/builtin/validateDateBound.js +18 -0
  173. package/dist/validation/builtin/validateDateBound.js.map +1 -0
  174. package/dist/validation/runValidation.js +1 -1
  175. package/dist/validation/runValidation.js.map +1 -1
  176. package/package.json +4 -3
  177. package/styles/form-builder.css +0 -190
@@ -0,0 +1,17 @@
1
+ import { CaptchaProvider } from "../types.js";
2
+
3
+ //#region src/spam/providers/turnstile.d.ts
4
+ type TurnstileProviderOptions = {
5
+ /** Turnstile secret key (server-side, never the site key). */secretKey: string; /** Override the siteverify endpoint. */
6
+ verifyUrl?: string; /** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */
7
+ ipHeader?: string; /** Verification request timeout in ms. Default 5000. */
8
+ timeoutMs?: number;
9
+ };
10
+ /**
11
+ * Cloudflare Turnstile verification adapter. Network errors, timeouts, and non-2xx responses
12
+ * fail closed (the submission is rejected).
13
+ */
14
+ declare const turnstileProvider: (options: TurnstileProviderOptions) => CaptchaProvider;
15
+ //#endregion
16
+ export { TurnstileProviderOptions, turnstileProvider };
17
+ //# sourceMappingURL=turnstile.d.ts.map
@@ -0,0 +1,18 @@
1
+ import { defineCaptchaProvider } from "../captcha.js";
2
+ import { postSiteverify, siteverifyParams } from "./siteverify.js";
3
+ //#region src/spam/providers/turnstile.ts
4
+ const TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
5
+ /**
6
+ * Cloudflare Turnstile verification adapter. Network errors, timeouts, and non-2xx responses
7
+ * fail closed (the submission is rejected).
8
+ */
9
+ const turnstileProvider = (options) => defineCaptchaProvider({
10
+ type: "turnstile",
11
+ verify: async ({ token, req }) => {
12
+ return (await postSiteverify(options.verifyUrl ?? TURNSTILE_VERIFY_URL, siteverifyParams(options, token, req), options.timeoutMs ?? 5e3))?.success === true;
13
+ }
14
+ });
15
+ //#endregion
16
+ export { turnstileProvider };
17
+
18
+ //# sourceMappingURL=turnstile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"turnstile.js","names":[],"sources":["../../../src/spam/providers/turnstile.ts"],"sourcesContent":["import { defineCaptchaProvider } from '../captcha'\nimport type { CaptchaProvider } from '../types'\nimport { DEFAULT_SITEVERIFY_TIMEOUT, postSiteverify, siteverifyParams } from './siteverify'\n\nconst TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'\n\nexport type TurnstileProviderOptions = {\n\t/** Turnstile secret key (server-side, never the site key). */\n\tsecretKey: string\n\t/** Override the siteverify endpoint. */\n\tverifyUrl?: string\n\t/** Header read for the client IP forwarded as `remoteip`. Default 'x-forwarded-for'. */\n\tipHeader?: string\n\t/** Verification request timeout in ms. Default 5000. */\n\ttimeoutMs?: number\n}\n\n/**\n * Cloudflare Turnstile verification adapter. Network errors, timeouts, and non-2xx responses\n * fail closed (the submission is rejected).\n */\nexport const turnstileProvider = (options: TurnstileProviderOptions): CaptchaProvider =>\n\tdefineCaptchaProvider({\n\t\ttype: 'turnstile',\n\t\tverify: async ({ token, req }) => {\n\t\t\tconst result = await postSiteverify(\n\t\t\t\toptions.verifyUrl ?? TURNSTILE_VERIFY_URL,\n\t\t\t\tsiteverifyParams(options, token, req),\n\t\t\t\toptions.timeoutMs ?? DEFAULT_SITEVERIFY_TIMEOUT\n\t\t\t)\n\t\t\treturn result?.success === true\n\t\t},\n\t})\n"],"mappings":";;;AAIA,MAAM,uBAAuB;;;;;AAiB7B,MAAa,qBAAqB,YACjC,sBAAsB;CACrB,MAAM;CACN,QAAQ,OAAO,EAAE,OAAO,UAAU;EAMjC,QAAO,MALc,eACpB,QAAQ,aAAa,sBACrB,iBAAiB,SAAS,OAAO,GAAG,GACpC,QAAQ,aAAA,GACT,IACe,YAAY;CAC5B;AACD,CAAC"}
@@ -1,13 +1,10 @@
1
1
  import { keys } from "../translations/keys.js";
2
2
  import { asTranslate } from "../translations/server.js";
3
+ import { firstHop } from "./clientIp.js";
3
4
  import { IDENTITY_CONTEXT_KEY } from "./constants.js";
4
5
  import { extractReservedValues, isHoneypotTripped } from "./reserved.js";
5
6
  import { APIError } from "payload";
6
7
  //#region src/spam/spamGuard.ts
7
- const firstHop = (req, header) => {
8
- const raw = req.headers?.get(header);
9
- return raw ? raw.split(",")[0]?.trim() ?? void 0 : void 0;
10
- };
11
8
  /**
12
9
  * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before
13
10
  * the form load + field validation. On create it: resolves the request identity once (stashing it on
@@ -49,7 +46,7 @@ const buildSpamGuard = (spam) => async ({ data, operation, req }) => {
49
46
  spam: { captcha: captchaChecked ? "passed" : "skipped" }
50
47
  };
51
48
  if (spam.metadata.ip) {
52
- const ip = firstHop(req, spam.ipHeader);
49
+ const ip = firstHop(req.headers, spam.ipHeader);
53
50
  if (ip) serverMeta.ip = ip;
54
51
  }
55
52
  if (spam.metadata.ua) {
@@ -1 +1 @@
1
- {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\nconst firstHop = (req: { headers?: Headers }, header: string): string | undefined => {\n\tconst raw = req.headers?.get(header)\n\treturn raw ? (raw.split(',')[0]?.trim() ?? undefined) : undefined\n}\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tif (spam.rateLimit !== false && identity != null) {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t}\n\n\t\tconst honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotField)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped' },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;AAQA,MAAM,YAAY,KAA4B,WAAuC;CACpF,MAAM,MAAM,IAAI,SAAS,IAAI,MAAM;CACnC,OAAO,MAAO,IAAI,MAAM,GAAG,EAAE,IAAI,KAAK,KAAK,KAAA,IAAa,KAAA;AACzD;;;;;;;;;;AAWA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI,KAAK,cAAc,SAAS,YAAY,MAAM;EACjD,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;CAEjD;CAEA,MAAM,gBAAgB,KAAK,aAAa,QAAQ,OAAO,KAAK,SAAS;CAErE,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,aAAa;CACvF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,kBAAkB,QAAQ,kBAAkB,QAAQ,GACzE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM,EAAE,SAAS,iBAAiB,WAAW,UAAU;CACxD;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,KAAK,KAAK,QAAQ;EACtC,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
1
+ {"version":3,"file":"spamGuard.js","names":[],"sources":["../../src/spam/spamGuard.ts"],"sourcesContent":["import { APIError, type CollectionBeforeValidateHook } from 'payload'\nimport type { SubmissionValue } from '../submissions/types'\nimport { keys } from '../translations/keys'\nimport { asTranslate } from '../translations/server'\nimport { firstHop } from './clientIp'\nimport { IDENTITY_CONTEXT_KEY } from './constants'\nimport { extractReservedValues, isHoneypotTripped } from './reserved'\nimport type { ResolvedSpamConfig } from './types'\n\n/**\n * Submissions spam guard, prepended before `validateSubmission` in `beforeValidate` so it rejects before\n * the form load + field validation. On create it: resolves the request identity once (stashing it on\n * `req.context` for upload-ownership verification downstream); rate-limits per form + identity (429,\n * fail-open when identity is null); strips reserved `values` entries (honeypot, captcha token); rejects a\n * filled honeypot with a generic error; verifies a configured captcha; and writes a server-authoritative\n * `meta` (timestamp + spam signal, with opt-in ip/ua). App-level rate limiting is defense-in-depth that\n * complements edge/CDN/WAF limiting, not a DoS replacement.\n */\nexport const buildSpamGuard =\n\t(spam: ResolvedSpamConfig): CollectionBeforeValidateHook =>\n\tasync ({ data, operation, req }) => {\n\t\tif (operation !== 'create' || !data) {\n\t\t\treturn data\n\t\t}\n\t\tconst t = asTranslate(req.i18n.t)\n\t\t// Honeypot + captcha are anti-bot measures for the anonymous public path; an authenticated request\n\t\t// (admin panel, logged-in API client) is already gated by auth and never carries a decoy/token, so\n\t\t// checking it would falsely reject legitimate authed creates. Rate-limit + ownership still apply.\n\t\tconst authenticated = Boolean(req.user)\n\n\t\tconst identity = await spam.identify(req)\n\t\tif (identity != null) {\n\t\t\treq.context[IDENTITY_CONTEXT_KEY] = identity\n\t\t}\n\n\t\tif (spam.rateLimit !== false && identity != null) {\n\t\t\tconst formKey = data.form != null ? String(data.form) : 'unknown'\n\t\t\tconst { ok } = await spam.rateLimit.limiter.check({\n\t\t\t\tkey: `submissions:${formKey}:${identity}`,\n\t\t\t\tmax: spam.rateLimit.max,\n\t\t\t\twindow: spam.rateLimit.window,\n\t\t\t\treq,\n\t\t\t})\n\t\t\tif (!ok) {\n\t\t\t\tthrow new APIError(t(keys.spamRateLimited), 429)\n\t\t\t}\n\t\t}\n\n\t\tconst honeypotField = spam.honeypot === false ? null : spam.honeypot.fieldName\n\t\tconst values = (data.values as SubmissionValue[] | undefined) ?? []\n\t\tconst { cleaned, honeypot, captchaToken } = extractReservedValues(values, honeypotField)\n\t\tdata.values = cleaned\n\n\t\tif (!authenticated && honeypotField !== null && isHoneypotTripped(honeypot)) {\n\t\t\tthrow new APIError(t(keys.spamRejected), 400)\n\t\t}\n\n\t\tconst captchaChecked = Boolean(spam.captcha) && !authenticated\n\t\tif (spam.captcha && !authenticated) {\n\t\t\tconst passed =\n\t\t\t\ttypeof captchaToken === 'string' && captchaToken.length > 0\n\t\t\t\t\t? await spam.captcha.verify({ token: captchaToken, req }).catch(() => false)\n\t\t\t\t\t: false\n\t\t\tif (!passed) {\n\t\t\t\tthrow new APIError(t(keys.spamCaptchaFailed), 400)\n\t\t\t}\n\t\t}\n\n\t\tconst serverMeta: Record<string, unknown> = {\n\t\t\tat: new Date().toISOString(),\n\t\t\tspam: { captcha: captchaChecked ? 'passed' : 'skipped' },\n\t\t}\n\t\tif (spam.metadata.ip) {\n\t\t\tconst ip = firstHop(req.headers, spam.ipHeader)\n\t\t\tif (ip) {\n\t\t\t\tserverMeta.ip = ip\n\t\t\t}\n\t\t}\n\t\tif (spam.metadata.ua) {\n\t\t\tconst ua = req.headers?.get('user-agent')\n\t\t\tif (ua) {\n\t\t\t\tserverMeta.ua = ua\n\t\t\t}\n\t\t}\n\t\t// Preserve an authenticated (trusted) caller's own `meta` keys, with server fields winning. Anonymous\n\t\t// `meta` is never trusted, so it is discarded.\n\t\tconst clientMeta =\n\t\t\tauthenticated && data.meta != null && typeof data.meta === 'object'\n\t\t\t\t? (data.meta as Record<string, unknown>)\n\t\t\t\t: {}\n\t\tdata.meta = { ...clientMeta, ...serverMeta }\n\n\t\treturn data\n\t}\n"],"mappings":";;;;;;;;;;;;;;;;AAkBA,MAAa,kBACX,SACD,OAAO,EAAE,MAAM,WAAW,UAAU;CACnC,IAAI,cAAc,YAAY,CAAC,MAC9B,OAAO;CAER,MAAM,IAAI,YAAY,IAAI,KAAK,CAAC;CAIhC,MAAM,gBAAgB,QAAQ,IAAI,IAAI;CAEtC,MAAM,WAAW,MAAM,KAAK,SAAS,GAAG;CACxC,IAAI,YAAY,MACf,IAAI,QAAQ,wBAAwB;CAGrC,IAAI,KAAK,cAAc,SAAS,YAAY,MAAM;EACjD,MAAM,UAAU,KAAK,QAAQ,OAAO,OAAO,KAAK,IAAI,IAAI;EACxD,MAAM,EAAE,OAAO,MAAM,KAAK,UAAU,QAAQ,MAAM;GACjD,KAAK,eAAe,QAAQ,GAAG;GAC/B,KAAK,KAAK,UAAU;GACpB,QAAQ,KAAK,UAAU;GACvB;EACD,CAAC;EACD,IAAI,CAAC,IACJ,MAAM,IAAI,SAAS,EAAE,KAAK,eAAe,GAAG,GAAG;CAEjD;CAEA,MAAM,gBAAgB,KAAK,aAAa,QAAQ,OAAO,KAAK,SAAS;CAErE,MAAM,EAAE,SAAS,UAAU,iBAAiB,sBAD5B,KAAK,UAA4C,CAAC,GACQ,aAAa;CACvF,KAAK,SAAS;CAEd,IAAI,CAAC,iBAAiB,kBAAkB,QAAQ,kBAAkB,QAAQ,GACzE,MAAM,IAAI,SAAS,EAAE,KAAK,YAAY,GAAG,GAAG;CAG7C,MAAM,iBAAiB,QAAQ,KAAK,OAAO,KAAK,CAAC;CACjD,IAAI,KAAK,WAAW,CAAC;MAKhB,EAHH,OAAO,iBAAiB,YAAY,aAAa,SAAS,IACvD,MAAM,KAAK,QAAQ,OAAO;GAAE,OAAO;GAAc;EAAI,CAAC,EAAE,YAAY,KAAK,IACzE,QAEH,MAAM,IAAI,SAAS,EAAE,KAAK,iBAAiB,GAAG,GAAG;CAAA;CAInD,MAAM,aAAsC;EAC3C,qBAAI,IAAI,KAAK,GAAE,YAAY;EAC3B,MAAM,EAAE,SAAS,iBAAiB,WAAW,UAAU;CACxD;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ;EAC9C,IAAI,IACH,WAAW,KAAK;CAElB;CACA,IAAI,KAAK,SAAS,IAAI;EACrB,MAAM,KAAK,IAAI,SAAS,IAAI,YAAY;EACxC,IAAI,IACH,WAAW,KAAK;CAElB;CAOA,KAAK,OAAO;EAAE,GAHb,iBAAiB,KAAK,QAAQ,QAAQ,OAAO,KAAK,SAAS,WACvD,KAAK,OACN,CAAC;EACwB,GAAG;CAAW;CAE3C,OAAO;AACR"}
@@ -22,7 +22,7 @@ type CaptchaVerifyArgs = {
22
22
  token: string;
23
23
  req: PayloadRequest;
24
24
  };
25
- /** A captcha adapter. v1 ships the seam only (no built-in provider). Build with `defineCaptchaProvider`. */
25
+ /** A captcha adapter. Use a bundled provider (Turnstile/reCAPTCHA/hCaptcha) or build one with `defineCaptchaProvider`. */
26
26
  type CaptchaProvider = {
27
27
  type: string;
28
28
  verify(args: CaptchaVerifyArgs): Promise<boolean>;
@@ -41,7 +41,7 @@ type SpamConfig = {
41
41
  fieldName?: string;
42
42
  }; /** Per-identity rate limit on submission create. Default on (60s window, max 5). `false` disables. */
43
43
  rateLimit?: false | RateLimitConfig; /** Per-identity rate limit on upload create. Default on (60s window, max 20). `false` disables. */
44
- uploadRateLimit?: false | RateLimitConfig; /** A captcha provider (none by default; seam only in v1). */
44
+ uploadRateLimit?: false | RateLimitConfig; /** A captcha provider (none by default): `turnstileProvider`/`recaptchaProvider`/`hcaptchaProvider` or a custom adapter. */
45
45
  captcha?: CaptchaProvider; /** Identity resolution for rate-limiting, upload ownership, and (future) poll dedup. Default: user id, else trusted IP header. */
46
46
  identify?: IdentifyFn; /** Header read for the client IP (proxy-dependent, best-effort). Default 'x-forwarded-for'. */
47
47
  ipHeader?: string; /** Opt-in capture of client metadata onto the submission `meta`. Off by default. */
@@ -2,10 +2,8 @@ import { UIFieldServerProps } from "payload";
2
2
 
3
3
  //#region src/submissions/SubmissionAnswers.d.ts
4
4
  /**
5
- * Read-only, localized submission view (server component). Renders each answered field's snapshot
6
- * label next to its type-aware formatted value via the field type's `format`, using the request
7
- * locale and i18n, with no client bundle. The built-in field types are formatted directly; threading
8
- * the full resolved registry (custom types) to the view is a future enhancement.
5
+ * Server component. Formats submission data into serializable props and delegates
6
+ * rendering to the client component which uses Payload's UI components.
9
7
  */
10
8
  declare const SubmissionAnswers: ({
11
9
  data,
@@ -2,25 +2,23 @@ import { keys } from "../translations/keys.js";
2
2
  import { asFieldTranslate } from "../translations/server.js";
3
3
  import { defaultFieldDefinitions } from "../fields/builtin/index.js";
4
4
  import { buildRegistry } from "../fields/registry.js";
5
- import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { SubmissionAnswersClient } from "./SubmissionAnswersClient.js";
6
+ import { jsx } from "react/jsx-runtime";
6
7
  //#region src/submissions/SubmissionAnswers.tsx
7
8
  const registry = buildRegistry(defaultFieldDefinitions);
8
- /** A stored file answer's link, when the captured `FileRef` carries a url; null otherwise. */
9
- const fileHref = (raw) => {
10
- if (raw && typeof raw === "object" && "url" in raw) {
11
- const ref = raw;
12
- if (typeof ref.url === "string" && ref.url.length > 0) return {
13
- url: ref.url,
14
- filename: typeof ref.filename === "string" ? ref.filename : ref.url
15
- };
9
+ const formatDate = (iso, locale) => {
10
+ try {
11
+ return new Intl.DateTimeFormat(locale, {
12
+ dateStyle: "medium",
13
+ timeStyle: "short"
14
+ }).format(new Date(iso));
15
+ } catch {
16
+ return iso;
16
17
  }
17
- return null;
18
18
  };
19
19
  /**
20
- * Read-only, localized submission view (server component). Renders each answered field's snapshot
21
- * label next to its type-aware formatted value via the field type's `format`, using the request
22
- * locale and i18n, with no client bundle. The built-in field types are formatted directly; threading
23
- * the full resolved registry (custom types) to the view is a future enhancement.
20
+ * Server component. Formats submission data into serializable props and delegates
21
+ * rendering to the client component which uses Payload's UI components.
24
22
  */
25
23
  const SubmissionAnswers = ({ data, req }) => {
26
24
  const doc = data ?? {};
@@ -28,27 +26,105 @@ const SubmissionAnswers = ({ data, req }) => {
28
26
  const values = doc.values ?? [];
29
27
  const locale = doc.locale ?? req.locale ?? "en";
30
28
  const t = asFieldTranslate(req.i18n.t);
31
- if (descriptors.length === 0) return /* @__PURE__ */ jsx("p", { children: t(keys.submissionNoAnswers) });
29
+ const rawConsent = doc.consent ?? [];
30
+ const meta = doc.meta;
32
31
  const valueByField = new Map(values.map((entry) => [entry.field, entry.value]));
33
- return /* @__PURE__ */ jsxs("div", {
34
- className: "form-builder-submission-answers",
35
- children: [/* @__PURE__ */ jsx("h4", { children: t(keys.submissionAnswers) }), /* @__PURE__ */ jsx("dl", { children: descriptors.map((descriptor) => {
36
- const definition = registry.get(descriptor.fieldType);
37
- const raw = valueByField.get(descriptor.field);
38
- const formatted = definition?.format ? definition.format({
39
- value: raw,
40
- config: {},
41
- optionLabels: descriptor.optionLabels,
42
- locale,
43
- t
44
- }) : raw == null ? "" : String(raw);
45
- const href = descriptor.fieldType === "file" ? fileHref(raw) : null;
46
- return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("dt", { children: descriptor.label }), /* @__PURE__ */ jsx("dd", { children: href ? /* @__PURE__ */ jsx("a", {
47
- href: href.url,
48
- rel: "noreferrer",
49
- children: href.filename
50
- }) : formatted })] }, descriptor.field);
51
- }) })]
32
+ const answers = [];
33
+ const repeaters = [];
34
+ for (const descriptor of descriptors) {
35
+ const raw = valueByField.get(descriptor.field);
36
+ if (descriptor.fieldType === "repeater") {
37
+ const rows = Array.isArray(raw) ? raw : [];
38
+ const subDescs = descriptor.subFieldDescriptors ?? [];
39
+ repeaters.push({
40
+ field: descriptor.field,
41
+ label: descriptor.label,
42
+ rows: rows.map((row, rowIndex) => ({
43
+ id: String(rowIndex),
44
+ subFields: subDescs.map((sub) => {
45
+ const subDef = registry.get(sub.fieldType);
46
+ const subRaw = row[sub.field];
47
+ const formatted = subDef?.format ? subDef.format({
48
+ value: subRaw,
49
+ config: {},
50
+ optionLabels: sub.optionLabels,
51
+ locale,
52
+ t
53
+ }) : subRaw == null ? "" : String(subRaw);
54
+ return {
55
+ label: sub.label,
56
+ value: formatted || "—"
57
+ };
58
+ })
59
+ }))
60
+ });
61
+ continue;
62
+ }
63
+ const def = registry.get(descriptor.fieldType);
64
+ const formatted = def?.format ? def.format({
65
+ value: raw,
66
+ config: {},
67
+ optionLabels: descriptor.optionLabels,
68
+ locale,
69
+ t
70
+ }) : raw == null ? "" : String(raw);
71
+ if (descriptor.fieldType === "file") {
72
+ const ref = raw && typeof raw === "object" && "url" in raw ? raw : null;
73
+ const url = typeof ref?.url === "string" ? ref.url : void 0;
74
+ const filename = typeof ref?.filename === "string" ? ref.filename : url ?? "";
75
+ answers.push({
76
+ field: descriptor.field,
77
+ label: descriptor.label,
78
+ value: filename,
79
+ href: url
80
+ });
81
+ } else answers.push({
82
+ field: descriptor.field,
83
+ label: descriptor.label,
84
+ value: formatted || "—",
85
+ multiline: descriptor.fieldType === "textarea"
86
+ });
87
+ }
88
+ const consent = rawConsent.map((entry) => ({
89
+ ...entry,
90
+ at: formatDate(entry.at, locale)
91
+ }));
92
+ const metaItems = [];
93
+ if (meta) {
94
+ if (meta.at) metaItems.push({
95
+ label: t(keys.submissionMetaReceivedAt),
96
+ value: formatDate(meta.at, locale)
97
+ });
98
+ if (meta.ip) metaItems.push({
99
+ label: t(keys.submissionMetaIp),
100
+ value: meta.ip
101
+ });
102
+ if (meta.ua) metaItems.push({
103
+ label: t(keys.submissionMetaUserAgent),
104
+ value: String(meta.ua)
105
+ });
106
+ if (meta.spam) {
107
+ const captcha = typeof meta.spam === "object" && "captcha" in meta.spam ? String(meta.spam.captcha) : "—";
108
+ metaItems.push({
109
+ label: t(keys.submissionMetaCaptcha),
110
+ value: captcha
111
+ });
112
+ }
113
+ }
114
+ return /* @__PURE__ */ jsx(SubmissionAnswersClient, {
115
+ answers,
116
+ repeaters,
117
+ consent,
118
+ meta: metaItems,
119
+ labels: {
120
+ answers: t(keys.submissionAnswers),
121
+ consent: t(keys.submissionConsent),
122
+ submissionDetails: t(keys.submissionDetails),
123
+ agreed: t(keys.submissionConsentAgreed),
124
+ declined: t(keys.submissionConsentDeclined),
125
+ row: t(keys.repeaterRow),
126
+ empty: t(keys.submissionNoAnswers)
127
+ }
52
128
  });
53
129
  };
54
130
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"SubmissionAnswers.js","names":[],"sources":["../../src/submissions/SubmissionAnswers.tsx"],"sourcesContent":["import type { UIFieldServerProps } from 'payload'\nimport { defaultFieldDefinitions } from '../fields/builtin'\nimport { buildRegistry } from '../fields/registry'\nimport { keys } from '../translations/keys'\nimport { asFieldTranslate } from '../translations/server'\nimport type { SubmissionDescriptor, SubmissionValue } from './types'\n\nconst registry = buildRegistry(defaultFieldDefinitions)\n\ntype SubmissionDoc = {\n\tvalues?: SubmissionValue[]\n\tdescriptors?: SubmissionDescriptor[]\n\tlocale?: string\n}\n\n/** A stored file answer's link, when the captured `FileRef` carries a url; null otherwise. */\nconst fileHref = (raw: unknown): { url: string; filename: string } | null => {\n\tif (raw && typeof raw === 'object' && 'url' in raw) {\n\t\tconst ref = raw as { url?: unknown; filename?: unknown }\n\t\tif (typeof ref.url === 'string' && ref.url.length > 0) {\n\t\t\treturn { url: ref.url, filename: typeof ref.filename === 'string' ? ref.filename : ref.url }\n\t\t}\n\t}\n\treturn null\n}\n\n/**\n * Read-only, localized submission view (server component). Renders each answered field's snapshot\n * label next to its type-aware formatted value via the field type's `format`, using the request\n * locale and i18n, with no client bundle. The built-in field types are formatted directly; threading\n * the full resolved registry (custom types) to the view is a future enhancement.\n */\nexport const SubmissionAnswers = ({ data, req }: UIFieldServerProps) => {\n\tconst doc = (data ?? {}) as SubmissionDoc\n\tconst descriptors = doc.descriptors ?? []\n\tconst values = doc.values ?? []\n\tconst locale = doc.locale ?? req.locale ?? 'en'\n\tconst t = asFieldTranslate(req.i18n.t)\n\n\tif (descriptors.length === 0) {\n\t\treturn <p>{t(keys.submissionNoAnswers)}</p>\n\t}\n\n\tconst valueByField = new Map(values.map((entry) => [entry.field, entry.value]))\n\n\treturn (\n\t\t<div className=\"form-builder-submission-answers\">\n\t\t\t<h4>{t(keys.submissionAnswers)}</h4>\n\t\t\t<dl>\n\t\t\t\t{descriptors.map((descriptor) => {\n\t\t\t\t\tconst definition = registry.get(descriptor.fieldType)\n\t\t\t\t\tconst raw = valueByField.get(descriptor.field)\n\t\t\t\t\tconst formatted = definition?.format\n\t\t\t\t\t\t? definition.format({\n\t\t\t\t\t\t\t\tvalue: raw,\n\t\t\t\t\t\t\t\tconfig: {},\n\t\t\t\t\t\t\t\toptionLabels: descriptor.optionLabels,\n\t\t\t\t\t\t\t\tlocale,\n\t\t\t\t\t\t\t\tt,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: raw == null\n\t\t\t\t\t\t\t? ''\n\t\t\t\t\t\t\t: String(raw)\n\t\t\t\t\tconst href = descriptor.fieldType === 'file' ? fileHref(raw) : null\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<div key={descriptor.field}>\n\t\t\t\t\t\t\t<dt>{descriptor.label}</dt>\n\t\t\t\t\t\t\t<dd>\n\t\t\t\t\t\t\t\t{href ? (\n\t\t\t\t\t\t\t\t\t<a href={href.url} rel=\"noreferrer\">\n\t\t\t\t\t\t\t\t\t\t{href.filename}\n\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\tformatted\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t</dd>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</dl>\n\t\t</div>\n\t)\n}\n"],"mappings":";;;;;;AAOA,MAAM,WAAW,cAAc,uBAAuB;;AAStD,MAAM,YAAY,QAA2D;CAC5E,IAAI,OAAO,OAAO,QAAQ,YAAY,SAAS,KAAK;EACnD,MAAM,MAAM;EACZ,IAAI,OAAO,IAAI,QAAQ,YAAY,IAAI,IAAI,SAAS,GACnD,OAAO;GAAE,KAAK,IAAI;GAAK,UAAU,OAAO,IAAI,aAAa,WAAW,IAAI,WAAW,IAAI;EAAI;CAE7F;CACA,OAAO;AACR;;;;;;;AAQA,MAAa,qBAAqB,EAAE,MAAM,UAA8B;CACvE,MAAM,MAAO,QAAQ,CAAC;CACtB,MAAM,cAAc,IAAI,eAAe,CAAC;CACxC,MAAM,SAAS,IAAI,UAAU,CAAC;CAC9B,MAAM,SAAS,IAAI,UAAU,IAAI,UAAU;CAC3C,MAAM,IAAI,iBAAiB,IAAI,KAAK,CAAC;CAErC,IAAI,YAAY,WAAW,GAC1B,OAAO,oBAAC,KAAD,EAAA,UAAI,EAAE,KAAK,mBAAmB,EAAK,CAAA;CAG3C,MAAM,eAAe,IAAI,IAAI,OAAO,KAAK,UAAU,CAAC,MAAM,OAAO,MAAM,KAAK,CAAC,CAAC;CAE9E,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,MAAD,EAAA,UAAK,EAAE,KAAK,iBAAiB,EAAM,CAAA,GACnC,oBAAC,MAAD,EAAA,UACE,YAAY,KAAK,eAAe;GAChC,MAAM,aAAa,SAAS,IAAI,WAAW,SAAS;GACpD,MAAM,MAAM,aAAa,IAAI,WAAW,KAAK;GAC7C,MAAM,YAAY,YAAY,SAC3B,WAAW,OAAO;IAClB,OAAO;IACP,QAAQ,CAAC;IACT,cAAc,WAAW;IACzB;IACA;GACD,CAAC,IACA,OAAO,OACN,KACA,OAAO,GAAG;GACd,MAAM,OAAO,WAAW,cAAc,SAAS,SAAS,GAAG,IAAI;GAC/D,OACC,qBAAC,OAAD,EAAA,UAAA,CACC,oBAAC,MAAD,EAAA,UAAK,WAAW,MAAU,CAAA,GAC1B,oBAAC,MAAD,EAAA,UACE,OACA,oBAAC,KAAD;IAAG,MAAM,KAAK;IAAK,KAAI;cACrB,KAAK;GACJ,CAAA,IAEH,UAEE,CAAA,CACA,EAAA,GAXK,WAAW,KAWhB;EAEP,CAAC,EACE,CAAA,CACA;;AAEP"}
1
+ {"version":3,"file":"SubmissionAnswers.js","names":[],"sources":["../../src/submissions/SubmissionAnswers.tsx"],"sourcesContent":["import type { UIFieldServerProps } from 'payload'\nimport { defaultFieldDefinitions } from '../fields/builtin'\nimport { buildRegistry } from '../fields/registry'\nimport { keys } from '../translations/keys'\nimport { asFieldTranslate } from '../translations/server'\nimport type {\n\tAnswerItem,\n\tConsentItem,\n\tMetaItem,\n\tRepeaterItem,\n\tSubmissionAnswersLabels,\n} from './SubmissionAnswersClient'\nimport { SubmissionAnswersClient } from './SubmissionAnswersClient'\nimport type { SubmissionDescriptor, SubmissionValue } from './types'\n\nconst registry = buildRegistry(defaultFieldDefinitions)\n\ntype ConsentEntry = {\n\tfield: string\n\tagreed: boolean\n\tref?: string\n\tversionRef?: string\n\tat: string\n}\n\ntype MetaSpam = { captcha?: string }\n\ntype SubmissionMeta = {\n\tat?: string\n\tip?: string\n\tua?: string\n\tspam?: MetaSpam\n\t[key: string]: unknown\n}\n\ntype SubmissionDoc = {\n\tvalues?: SubmissionValue[]\n\tdescriptors?: SubmissionDescriptor[]\n\tlocale?: string\n\tconsent?: ConsentEntry[]\n\tmeta?: SubmissionMeta\n}\n\nconst formatDate = (iso: string, locale: string): string => {\n\ttry {\n\t\treturn new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(\n\t\t\tnew Date(iso)\n\t\t)\n\t} catch {\n\t\treturn iso\n\t}\n}\n\n/**\n * Server component. Formats submission data into serializable props and delegates\n * rendering to the client component which uses Payload's UI components.\n */\nexport const SubmissionAnswers = ({ data, req }: UIFieldServerProps) => {\n\tconst doc = (data ?? {}) as SubmissionDoc\n\tconst descriptors = doc.descriptors ?? []\n\tconst values = doc.values ?? []\n\tconst locale = doc.locale ?? req.locale ?? 'en'\n\tconst t = asFieldTranslate(req.i18n.t)\n\tconst rawConsent = doc.consent ?? []\n\tconst meta = doc.meta\n\n\tconst valueByField = new Map(values.map((entry) => [entry.field, entry.value]))\n\n\tconst answers: AnswerItem[] = []\n\tconst repeaters: RepeaterItem[] = []\n\n\tfor (const descriptor of descriptors) {\n\t\tconst raw = valueByField.get(descriptor.field)\n\n\t\tif (descriptor.fieldType === 'repeater') {\n\t\t\tconst rows = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : []\n\t\t\tconst subDescs = descriptor.subFieldDescriptors ?? []\n\t\t\trepeaters.push({\n\t\t\t\tfield: descriptor.field,\n\t\t\t\tlabel: descriptor.label,\n\t\t\t\trows: rows.map((row, rowIndex) => ({\n\t\t\t\t\tid: String(rowIndex),\n\t\t\t\t\tsubFields: subDescs.map((sub) => {\n\t\t\t\t\t\tconst subDef = registry.get(sub.fieldType)\n\t\t\t\t\t\tconst subRaw = row[sub.field]\n\t\t\t\t\t\tconst formatted = subDef?.format\n\t\t\t\t\t\t\t? subDef.format({\n\t\t\t\t\t\t\t\t\tvalue: subRaw,\n\t\t\t\t\t\t\t\t\tconfig: {},\n\t\t\t\t\t\t\t\t\toptionLabels: sub.optionLabels,\n\t\t\t\t\t\t\t\t\tlocale,\n\t\t\t\t\t\t\t\t\tt,\n\t\t\t\t\t\t\t\t})\n\t\t\t\t\t\t\t: subRaw == null\n\t\t\t\t\t\t\t\t? ''\n\t\t\t\t\t\t\t\t: String(subRaw)\n\t\t\t\t\t\treturn { label: sub.label, value: formatted || '—' }\n\t\t\t\t\t}),\n\t\t\t\t})),\n\t\t\t})\n\t\t\tcontinue\n\t\t}\n\n\t\tconst def = registry.get(descriptor.fieldType)\n\t\tconst formatted = def?.format\n\t\t\t? def.format({ value: raw, config: {}, optionLabels: descriptor.optionLabels, locale, t })\n\t\t\t: raw == null\n\t\t\t\t? ''\n\t\t\t\t: String(raw)\n\n\t\tif (descriptor.fieldType === 'file') {\n\t\t\tconst ref =\n\t\t\t\traw && typeof raw === 'object' && 'url' in raw\n\t\t\t\t\t? (raw as { url?: string; filename?: string })\n\t\t\t\t\t: null\n\t\t\tconst url = typeof ref?.url === 'string' ? ref.url : undefined\n\t\t\tconst filename = typeof ref?.filename === 'string' ? ref.filename : (url ?? '')\n\t\t\tanswers.push({ field: descriptor.field, label: descriptor.label, value: filename, href: url })\n\t\t} else {\n\t\t\tanswers.push({\n\t\t\t\tfield: descriptor.field,\n\t\t\t\tlabel: descriptor.label,\n\t\t\t\tvalue: formatted || '—',\n\t\t\t\tmultiline: descriptor.fieldType === 'textarea',\n\t\t\t})\n\t\t}\n\t}\n\n\tconst consent: ConsentItem[] = rawConsent.map((entry) => ({\n\t\t...entry,\n\t\tat: formatDate(entry.at, locale),\n\t}))\n\n\tconst metaItems: MetaItem[] = []\n\tif (meta) {\n\t\tif (meta.at)\n\t\t\tmetaItems.push({\n\t\t\t\tlabel: t(keys.submissionMetaReceivedAt),\n\t\t\t\tvalue: formatDate(meta.at, locale),\n\t\t\t})\n\t\tif (meta.ip) metaItems.push({ label: t(keys.submissionMetaIp), value: meta.ip })\n\t\tif (meta.ua) metaItems.push({ label: t(keys.submissionMetaUserAgent), value: String(meta.ua) })\n\t\tif (meta.spam) {\n\t\t\tconst captcha =\n\t\t\t\ttypeof meta.spam === 'object' && 'captcha' in meta.spam\n\t\t\t\t\t? String((meta.spam as MetaSpam).captcha)\n\t\t\t\t\t: '—'\n\t\t\tmetaItems.push({ label: t(keys.submissionMetaCaptcha), value: captcha })\n\t\t}\n\t}\n\n\tconst labels: SubmissionAnswersLabels = {\n\t\tanswers: t(keys.submissionAnswers),\n\t\tconsent: t(keys.submissionConsent),\n\t\tsubmissionDetails: t(keys.submissionDetails),\n\t\tagreed: t(keys.submissionConsentAgreed),\n\t\tdeclined: t(keys.submissionConsentDeclined),\n\t\trow: t(keys.repeaterRow),\n\t\tempty: t(keys.submissionNoAnswers),\n\t}\n\n\treturn (\n\t\t<SubmissionAnswersClient\n\t\t\tanswers={answers}\n\t\t\trepeaters={repeaters}\n\t\t\tconsent={consent}\n\t\t\tmeta={metaItems}\n\t\t\tlabels={labels}\n\t\t/>\n\t)\n}\n"],"mappings":"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"}
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ import { FieldLabel, TextInput } from "@payloadcms/ui";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/submissions/SubmissionAnswersClient.tsx
5
+ const noop = (_e) => {};
6
+ const pillStyle = (agreed) => ({
7
+ display: "inline-block",
8
+ padding: "2px 8px",
9
+ borderRadius: "9999px",
10
+ fontSize: "0.75rem",
11
+ fontWeight: 600,
12
+ background: agreed ? "var(--theme-success-500)" : "var(--theme-error-500)",
13
+ color: "#fff"
14
+ });
15
+ const rowHeading = {
16
+ fontSize: "0.75rem",
17
+ fontWeight: 600,
18
+ color: "var(--theme-elevation-500)",
19
+ margin: "0 0 4px"
20
+ };
21
+ const SubmissionAnswersClient = ({ answers, repeaters, consent, meta, labels }) => {
22
+ if (!(answers.length > 0 || repeaters.length > 0 || consent.length > 0 || meta.length > 0)) return /* @__PURE__ */ jsx("p", {
23
+ style: { color: "var(--theme-elevation-500)" },
24
+ children: labels.empty
25
+ });
26
+ return /* @__PURE__ */ jsxs("div", {
27
+ className: "field-type",
28
+ style: {
29
+ display: "flex",
30
+ flexDirection: "column",
31
+ gap: "32px"
32
+ },
33
+ children: [
34
+ (answers.length > 0 || repeaters.length > 0) && /* @__PURE__ */ jsxs("section", { children: [
35
+ /* @__PURE__ */ jsx(FieldLabel, { label: labels.answers }),
36
+ answers.map(({ field, label, value, href }) => href ? /* @__PURE__ */ jsxs("div", {
37
+ className: "field-type",
38
+ children: [/* @__PURE__ */ jsx(FieldLabel, { label }), /* @__PURE__ */ jsx("a", {
39
+ href,
40
+ target: "_blank",
41
+ rel: "noreferrer",
42
+ style: {
43
+ display: "block",
44
+ padding: "8px 0",
45
+ color: "var(--theme-text)",
46
+ textDecoration: "underline"
47
+ },
48
+ children: value
49
+ })]
50
+ }, field) : /* @__PURE__ */ jsx(TextInput, {
51
+ path: `sa-${field}`,
52
+ label,
53
+ value,
54
+ readOnly: true,
55
+ onChange: noop
56
+ }, field)),
57
+ repeaters.map(({ field, label, rows }) => /* @__PURE__ */ jsxs("div", {
58
+ className: "field-type",
59
+ children: [/* @__PURE__ */ jsx(FieldLabel, { label }), rows.length === 0 ? /* @__PURE__ */ jsx(TextInput, {
60
+ path: `sa-${field}-empty`,
61
+ label: "",
62
+ value: "—",
63
+ readOnly: true,
64
+ onChange: noop
65
+ }) : /* @__PURE__ */ jsx("div", {
66
+ style: {
67
+ display: "flex",
68
+ flexDirection: "column",
69
+ gap: "8px",
70
+ marginTop: "4px"
71
+ },
72
+ children: rows.map((row) => /* @__PURE__ */ jsxs("div", {
73
+ style: {
74
+ border: "1px solid var(--theme-elevation-150)",
75
+ borderRadius: "4px",
76
+ padding: "4px 12px 0"
77
+ },
78
+ children: [/* @__PURE__ */ jsx("p", {
79
+ style: rowHeading,
80
+ children: labels.row.replace("{n}", String(Number(row.id) + 1))
81
+ }), row.subFields.map((subField) => /* @__PURE__ */ jsx(TextInput, {
82
+ path: `sa-${field}-${row.id}-${subField.label}`,
83
+ label: subField.label,
84
+ value: subField.value,
85
+ readOnly: true,
86
+ onChange: noop
87
+ }, subField.label))]
88
+ }, row.id))
89
+ })]
90
+ }, field))
91
+ ] }),
92
+ consent.length > 0 && /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx(FieldLabel, { label: labels.consent }), consent.map((entry) => /* @__PURE__ */ jsxs("div", {
93
+ className: "field-type",
94
+ children: [/* @__PURE__ */ jsx(FieldLabel, { label: entry.field }), /* @__PURE__ */ jsxs("div", {
95
+ style: {
96
+ display: "flex",
97
+ flexDirection: "column",
98
+ gap: "4px"
99
+ },
100
+ children: [/* @__PURE__ */ jsxs("div", {
101
+ style: {
102
+ display: "flex",
103
+ alignItems: "center",
104
+ gap: "8px",
105
+ flexWrap: "wrap"
106
+ },
107
+ children: [/* @__PURE__ */ jsx("span", {
108
+ style: pillStyle(entry.agreed),
109
+ children: entry.agreed ? labels.agreed : labels.declined
110
+ }), entry.ref ? /* @__PURE__ */ jsxs("a", {
111
+ href: entry.ref,
112
+ target: "_blank",
113
+ rel: "noreferrer",
114
+ style: {
115
+ fontSize: "0.8rem",
116
+ color: "var(--theme-text)"
117
+ },
118
+ children: [entry.ref, entry.versionRef ? ` (v${entry.versionRef})` : ""]
119
+ }) : null]
120
+ }), /* @__PURE__ */ jsx("span", {
121
+ style: {
122
+ fontSize: "0.75rem",
123
+ color: "var(--theme-elevation-500)"
124
+ },
125
+ children: entry.at
126
+ })]
127
+ })]
128
+ }, entry.field))] }),
129
+ meta.length > 0 && /* @__PURE__ */ jsxs("section", { children: [/* @__PURE__ */ jsx(FieldLabel, { label: labels.submissionDetails }), meta.map((item) => /* @__PURE__ */ jsx(TextInput, {
130
+ path: `sa-meta-${item.label}`,
131
+ label: item.label,
132
+ value: item.value,
133
+ readOnly: true,
134
+ onChange: noop
135
+ }, item.label))] })
136
+ ]
137
+ });
138
+ };
139
+ //#endregion
140
+ export { SubmissionAnswersClient };
141
+
142
+ //# sourceMappingURL=SubmissionAnswersClient.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SubmissionAnswersClient.js","names":[],"sources":["../../src/submissions/SubmissionAnswersClient.tsx"],"sourcesContent":["'use client'\n\nimport { FieldLabel, TextInput } from '@payloadcms/ui'\nimport type { ChangeEvent } from 'react'\n\nexport type AnswerItem = {\n\tfield: string\n\tlabel: string\n\tvalue: string\n\thref?: string\n\tmultiline?: boolean\n}\n\nexport type RepeaterRow = {\n\tid: string\n\tsubFields: Array<{ label: string; value: string }>\n}\n\nexport type RepeaterItem = {\n\tfield: string\n\tlabel: string\n\trows: RepeaterRow[]\n}\n\nexport type ConsentItem = {\n\tfield: string\n\tagreed: boolean\n\tref?: string\n\tversionRef?: string\n\tat: string\n}\n\nexport type MetaItem = {\n\tlabel: string\n\tvalue: string\n}\n\nexport type SubmissionAnswersLabels = {\n\tanswers: string\n\tconsent: string\n\tsubmissionDetails: string\n\tagreed: string\n\tdeclined: string\n\t/** Row heading template containing `{n}`, the 1-based row number. */\n\trow: string\n\tempty: string\n}\n\ntype Props = {\n\tanswers: AnswerItem[]\n\trepeaters: RepeaterItem[]\n\tconsent: ConsentItem[]\n\tmeta: MetaItem[]\n\tlabels: SubmissionAnswersLabels\n}\n\nconst noop = (_e: ChangeEvent<HTMLInputElement>) => {}\n\nconst pillStyle = (agreed: boolean): React.CSSProperties => ({\n\tdisplay: 'inline-block',\n\tpadding: '2px 8px',\n\tborderRadius: '9999px',\n\tfontSize: '0.75rem',\n\tfontWeight: 600,\n\tbackground: agreed ? 'var(--theme-success-500)' : 'var(--theme-error-500)',\n\tcolor: '#fff',\n})\n\nconst rowHeading: React.CSSProperties = {\n\tfontSize: '0.75rem',\n\tfontWeight: 600,\n\tcolor: 'var(--theme-elevation-500)',\n\tmargin: '0 0 4px',\n}\n\nexport const SubmissionAnswersClient = ({ answers, repeaters, consent, meta, labels }: Props) => {\n\tconst hasContent =\n\t\tanswers.length > 0 || repeaters.length > 0 || consent.length > 0 || meta.length > 0\n\n\tif (!hasContent) {\n\t\treturn <p style={{ color: 'var(--theme-elevation-500)' }}>{labels.empty}</p>\n\t}\n\n\treturn (\n\t\t<div className=\"field-type\" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>\n\t\t\t{(answers.length > 0 || repeaters.length > 0) && (\n\t\t\t\t<section>\n\t\t\t\t\t<FieldLabel label={labels.answers} />\n\t\t\t\t\t{answers.map(({ field, label, value, href }) =>\n\t\t\t\t\t\thref ? (\n\t\t\t\t\t\t\t<div key={field} className=\"field-type\">\n\t\t\t\t\t\t\t\t<FieldLabel label={label} />\n\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\thref={href}\n\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\tdisplay: 'block',\n\t\t\t\t\t\t\t\t\t\tpadding: '8px 0',\n\t\t\t\t\t\t\t\t\t\tcolor: 'var(--theme-text)',\n\t\t\t\t\t\t\t\t\t\ttextDecoration: 'underline',\n\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{value}\n\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\t\tkey={field}\n\t\t\t\t\t\t\t\tpath={`sa-${field}`}\n\t\t\t\t\t\t\t\tlabel={label}\n\t\t\t\t\t\t\t\tvalue={value}\n\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\tonChange={noop}\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t)\n\t\t\t\t\t)}\n\n\t\t\t\t\t{repeaters.map(({ field, label, rows }) => (\n\t\t\t\t\t\t<div key={field} className=\"field-type\">\n\t\t\t\t\t\t\t<FieldLabel label={label} />\n\t\t\t\t\t\t\t{rows.length === 0 ? (\n\t\t\t\t\t\t\t\t<TextInput path={`sa-${field}-empty`} label=\"\" value=\"—\" readOnly onChange={noop} />\n\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tstyle={{ display: 'flex', flexDirection: 'column', gap: '8px', marginTop: '4px' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{rows.map((row) => (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\tkey={row.id}\n\t\t\t\t\t\t\t\t\t\t\tstyle={{\n\t\t\t\t\t\t\t\t\t\t\t\tborder: '1px solid var(--theme-elevation-150)',\n\t\t\t\t\t\t\t\t\t\t\t\tborderRadius: '4px',\n\t\t\t\t\t\t\t\t\t\t\t\tpadding: '4px 12px 0',\n\t\t\t\t\t\t\t\t\t\t\t}}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<p style={rowHeading}>\n\t\t\t\t\t\t\t\t\t\t\t\t{labels.row.replace('{n}', String(Number(row.id) + 1))}\n\t\t\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t\t\t{row.subFields.map((subField) => (\n\t\t\t\t\t\t\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\t\t\t\t\t\t\tkey={subField.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\tpath={`sa-${field}-${row.id}-${subField.label}`}\n\t\t\t\t\t\t\t\t\t\t\t\t\tlabel={subField.label}\n\t\t\t\t\t\t\t\t\t\t\t\t\tvalue={subField.value}\n\t\t\t\t\t\t\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\t\t\t\t\t\t\tonChange={noop}\n\t\t\t\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t))}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</section>\n\t\t\t)}\n\n\t\t\t{consent.length > 0 && (\n\t\t\t\t<section>\n\t\t\t\t\t<FieldLabel label={labels.consent} />\n\t\t\t\t\t{consent.map((entry) => (\n\t\t\t\t\t\t<div key={entry.field} className=\"field-type\">\n\t\t\t\t\t\t\t<FieldLabel label={entry.field} />\n\t\t\t\t\t\t\t<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>\n\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\tstyle={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t<span style={pillStyle(entry.agreed)}>\n\t\t\t\t\t\t\t\t\t\t{entry.agreed ? labels.agreed : labels.declined}\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t{entry.ref ? (\n\t\t\t\t\t\t\t\t\t\t<a\n\t\t\t\t\t\t\t\t\t\t\thref={entry.ref}\n\t\t\t\t\t\t\t\t\t\t\ttarget=\"_blank\"\n\t\t\t\t\t\t\t\t\t\t\trel=\"noreferrer\"\n\t\t\t\t\t\t\t\t\t\t\tstyle={{ fontSize: '0.8rem', color: 'var(--theme-text)' }}\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t{entry.ref}\n\t\t\t\t\t\t\t\t\t\t\t{entry.versionRef ? ` (v${entry.versionRef})` : ''}\n\t\t\t\t\t\t\t\t\t\t</a>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<span style={{ fontSize: '0.75rem', color: 'var(--theme-elevation-500)' }}>\n\t\t\t\t\t\t\t\t\t{entry.at}\n\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t))}\n\t\t\t\t</section>\n\t\t\t)}\n\n\t\t\t{meta.length > 0 && (\n\t\t\t\t<section>\n\t\t\t\t\t<FieldLabel label={labels.submissionDetails} />\n\t\t\t\t\t{meta.map((item) => (\n\t\t\t\t\t\t<TextInput\n\t\t\t\t\t\t\tkey={item.label}\n\t\t\t\t\t\t\tpath={`sa-meta-${item.label}`}\n\t\t\t\t\t\t\tlabel={item.label}\n\t\t\t\t\t\t\tvalue={item.value}\n\t\t\t\t\t\t\treadOnly\n\t\t\t\t\t\t\tonChange={noop}\n\t\t\t\t\t\t/>\n\t\t\t\t\t))}\n\t\t\t\t</section>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
@@ -66,8 +66,8 @@ const runSubmission = async (input) => {
66
66
  const definition = registry.get(instance.blockType);
67
67
  const raw = incoming.get(instance.name);
68
68
  if (!definition || calcExpressionOf(instance)) continue;
69
- const effectiveRaw = instance.blockType === "consent" && isEmpty(raw) ? false : raw;
70
- if (isEmpty(effectiveRaw)) continue;
69
+ const effectiveRaw = instance.blockType === "consent" && isEmpty(raw) ? false : instance.blockType === "repeater" && isEmpty(raw) ? [] : raw;
70
+ if (isEmpty(effectiveRaw) && instance.blockType !== "repeater") continue;
71
71
  const value = coerce(definition.value, effectiveRaw);
72
72
  coercedAnswers[instance.name] = value;
73
73
  coercedByName.set(instance.name, value);
@@ -179,6 +179,58 @@ const runSubmission = async (input) => {
179
179
  });
180
180
  continue;
181
181
  }
182
+ if (instance.blockType === "repeater") {
183
+ const rows = Array.isArray(value) ? value : [];
184
+ const subFields = Array.isArray(instance.subFields) ? instance.subFields : [];
185
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
186
+ const row = rows[rowIndex] ?? {};
187
+ for (const subField of subFields) {
188
+ const subDef = registry.get(subField.blockType);
189
+ if (!subDef) continue;
190
+ if (!evaluateCondition(subField.visibleWhen, row)) continue;
191
+ if (!evaluateCondition(subField.validateWhen, row)) continue;
192
+ const subValue = row[subField.name];
193
+ const { errors: subErrors } = await runValidation({
194
+ field: subField,
195
+ fieldDefinition: subDef,
196
+ value: subValue,
197
+ fieldType: subField.blockType,
198
+ ruleRegistry,
199
+ answers: row,
200
+ locale,
201
+ t,
202
+ operation,
203
+ event: "submit",
204
+ mode: "server",
205
+ req,
206
+ payload,
207
+ formId
208
+ });
209
+ for (const issue of subErrors.filter((e) => e.severity === "error")) errors.push({
210
+ path: `${instance.name}[${rowIndex}].${subField.name}`,
211
+ message: issue.message
212
+ });
213
+ }
214
+ }
215
+ if (errors.length > 0) continue;
216
+ const subFieldDescriptors = subFields.map((sf) => ({
217
+ field: sf.name,
218
+ label: sf.label ?? sf.name,
219
+ fieldType: sf.blockType,
220
+ ...sf.options ? { optionLabels: optionLabelsFor(sf) ?? void 0 } : {}
221
+ }));
222
+ outValues.push({
223
+ field: instance.name,
224
+ value: rows
225
+ });
226
+ descriptors.push({
227
+ field: instance.name,
228
+ label: instance.label ?? instance.name,
229
+ fieldType: instance.blockType,
230
+ ...subFieldDescriptors.length > 0 ? { subFieldDescriptors } : {}
231
+ });
232
+ continue;
233
+ }
182
234
  if (isEmpty(raw)) continue;
183
235
  outValues.push({
184
236
  field: instance.name,