@10x-media/form-builder 0.1.0-beta.18 → 0.1.0-beta.19

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.19
4
+
5
+ ### Minor Changes
6
+
7
+ - The from-address and department selects now load their options while a form is still being created, instead of sitting empty until the first save. Both option sets are request-scoped (they depend on who is asking, not on the form document), so their endpoints now live at id-less paths (`GET /api/forms/from-addresses`, `GET /api/forms/departments`); the old `/:id/`-prefixed routes still answer on the same handlers for anything that hardcoded them. `EndpointOptionsSelect` and `RecipientsSelect` gain a `scope: 'document' | 'request'` clientProp (default `'document'`, unchanged behaviour) so a host field backed by its own request-scoped endpoint can opt into the same create-mode loading. Document-scoped selects (poll options, consent sources) are unaffected.
8
+
3
9
  ## 0.1.0-beta.18
4
10
 
5
11
  ### Minor Changes
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 10x Media GmbH
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 10x Media GmbH
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![Banner](./assets/banner.jpg)
2
+
1
3
  # @10x-media/form-builder
2
4
 
3
5
  An end-to-end forms platform for Payload v3: author forms in the admin, validate server-side, render headless on your frontend, collect typed submissions, aggregate results, and act on them. Simple by default for editors, with a definition seam (`defineFormField`, `defineValidationRule`, `defineAction`, and friends) wherever developers need depth, and 100% native to Payload.
@@ -124,7 +124,10 @@ const buildRecipientField = (name, labelKey, localize, opts = {}) => {
124
124
  components: { Field: {
125
125
  path: RECIPIENTS_FIELD_REF,
126
126
  clientProps: {
127
- ...opts.endpoint ? { endpoint: opts.endpoint } : {},
127
+ ...opts.endpoint ? {
128
+ endpoint: opts.endpoint,
129
+ scope: "request"
130
+ } : {},
128
131
  ...opts.recipients?.allowCustom === false ? { allowCustom: false } : {},
129
132
  ...opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {},
130
133
  ...opts.recipients?.tokenFieldTypes ? { tokenFieldTypes: opts.recipients.tokenFieldTypes } : {},
@@ -1 +1 @@
1
- {"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, fieldNamesOfType } from '../fields/fieldNamesOfType'\nimport { localizedIf } from '../fields/localizedIf'\nimport { interpolate } from '../recall/interpolate'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport type {\n\tRecipientResolveArgs,\n\tRecipientSource,\n\tRecipientSourceRegistry,\n} from './recipientSources'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Async recipient resolution for the email actions, extending `resolveRecipients` with server-resolved\n * sources. A registered source value calls its `resolve` (each returned address reduced to one by\n * `firstAddress`, so a source cannot inject headers or extra recipients either); a `{{token}}`/plain\n * email resolves exactly as before. Returns a clean address list (`[]` when nothing resolves) so the\n * caller can skip an empty send. A thrown resolver propagates, failing the action loudly rather than\n * sending to a shortened list.\n */\nexport const resolveRecipientEntries = async (\n\tvalue: unknown,\n\topts: {\n\t\tresolve: (name: string) => string\n\t\tsources?: Map<string, RecipientSource>\n\t\tsourceArgs?: RecipientResolveArgs\n\t}\n): Promise<string[]> => {\n\tconst out: string[] = []\n\tfor (const entry of toList(value)) {\n\t\tconst source = opts.sources?.get(entry)\n\t\tif (source) {\n\t\t\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\t\t\tconst resolved = opts.sourceArgs ? await source.resolve(opts.sourceArgs) : []\n\t\t\tfor (const address of resolved) {\n\t\t\t\tconst clean = firstAddress(address)\n\t\t\t\tif (clean) {\n\t\t\t\t\tout.push(clean)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tconst clean = firstAddress(interpolate(entry, opts.resolve))\n\t\tif (clean) {\n\t\t\tout.push(clean)\n\t\t}\n\t}\n\treturn out\n}\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t\t/** Stored values of registered recipient sources; a member is a valid recipient by itself. */\n\t\tsourceValues?: Set<string>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\t// A registered source value is a valid recipient on its own: checked first, so a same-named form\n\t\t\t// field cannot shadow it and `allowCustom: false` cannot block it (it is not a custom address).\n\t\t\tif (opts.sourceValues?.has(entry)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t\tsources?: RecipientSourceRegistry\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\tconst sourceList = Object.values(opts.sources ?? {})\n\tconst sourceValues = sourceList.length > 0 ? new Set(sourceList.map((s) => s.value)) : undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t\tsourceValues,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t...(sourceList.length > 0\n\t\t\t\t\t\t\t? { sources: sourceList.map((s) => ({ value: s.value, label: s.label })) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"emailRecipients.js","names":[],"sources":["../../src/actions/emailRecipients.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport type { DepartmentEmailsResolver } from '../email/departments'\nimport { fieldNames, fieldNamesOfType } from '../fields/fieldNamesOfType'\nimport { localizedIf } from '../fields/localizedIf'\nimport { interpolate } from '../recall/interpolate'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport type {\n\tRecipientResolveArgs,\n\tRecipientSource,\n\tRecipientSourceRegistry,\n} from './recipientSources'\n\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\nconst TOKEN_RE = /^\\{\\{\\s*([\\w.-]+)\\s*\\}\\}$/\n\n/** A plausible email address (a permissive shape check, not full RFC validation). */\nexport const isPlausibleEmail = (value: string): boolean => EMAIL_RE.test(value.trim())\n\n/** The field name inside a `{{name}}` recipient token, or undefined when the string is not a token. */\nexport const parseFieldToken = (value: string): string | undefined => {\n\tconst match = TOKEN_RE.exec(value.trim())\n\treturn match ? match[1] : undefined\n}\n\nexport const isFieldToken = (value: string): boolean => parseFieldToken(value) !== undefined\n\nconst toList = (value: unknown): string[] =>\n\tArray.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string')\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n/**\n * The first plausible email in a resolved value, or empty. A recipient entry names a single address,\n * so this drops anything past a separator or CR/LF that a `{{field}}` token might interpolate from\n * visitor input, closing SMTP header injection and extra-recipient injection.\n */\nexport const firstAddress = (value: string): string =>\n\tvalue\n\t\t.split(/[,;\\n\\r]+/)\n\t\t.map((part) => part.trim())\n\t\t.find(isPlausibleEmail) ?? ''\n\n/**\n * Resolve a stored recipient value (a `string[]`, or a legacy single string) to the comma-separated\n * list `payload.sendEmail` accepts: each entry is interpolated (a `{{field}}` token resolves from the\n * submission, a plain email passes through), sanitized to a single address, empties dropped, joined.\n */\nexport const resolveRecipients = (value: unknown, resolve: (name: string) => string): string =>\n\ttoList(value)\n\t\t.map((entry) => firstAddress(interpolate(entry, resolve)))\n\t\t.filter((entry) => entry.length > 0)\n\t\t.join(', ')\n\n/**\n * Async recipient resolution for the email actions, extending `resolveRecipients` with server-resolved\n * sources. A registered source value calls its `resolve` (each returned address reduced to one by\n * `firstAddress`, so a source cannot inject headers or extra recipients either); a `{{token}}`/plain\n * email resolves exactly as before. Returns a clean address list (`[]` when nothing resolves) so the\n * caller can skip an empty send. A thrown resolver propagates, failing the action loudly rather than\n * sending to a shortened list.\n */\nexport const resolveRecipientEntries = async (\n\tvalue: unknown,\n\topts: {\n\t\tresolve: (name: string) => string\n\t\tsources?: Map<string, RecipientSource>\n\t\tsourceArgs?: RecipientResolveArgs\n\t}\n): Promise<string[]> => {\n\tconst out: string[] = []\n\tfor (const entry of toList(value)) {\n\t\tconst source = opts.sources?.get(entry)\n\t\tif (source) {\n\t\t\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\t\t\tconst resolved = opts.sourceArgs ? await source.resolve(opts.sourceArgs) : []\n\t\t\tfor (const address of resolved) {\n\t\t\t\tconst clean = firstAddress(address)\n\t\t\t\tif (clean) {\n\t\t\t\t\tout.push(clean)\n\t\t\t\t}\n\t\t\t}\n\t\t\tcontinue\n\t\t}\n\t\tconst clean = firstAddress(interpolate(entry, opts.resolve))\n\t\tif (clean) {\n\t\t\tout.push(clean)\n\t\t}\n\t}\n\treturn out\n}\n\n/**\n * Field `validate` for a recipient list: unset is fine; otherwise every entry must be a valid email\n * or a `{{field}}` token naming an existing field of an allowed token type. When `allowCustom` is\n * false, a non-token entry must additionally be a member of the resolved options (`resolveAllowed`,\n * e.g. the department addresses), matching the client constraint on the server. Fails closed if the\n * options resolver throws (mirroring the `from` field). Reads the form's `fields` off `data`. Like\n * `from`, Payload runs this on every save, so the resolver is consulted each save under\n * `allowCustom: false`; `buildRecipientField` memoizes it per request so all recipient fields share one call.\n */\nexport const validateRecipients =\n\t(opts: {\n\t\ttokenFieldTypes?: string[]\n\t\tallowCustom?: boolean\n\t\tfieldTokens?: boolean\n\t\tresolveAllowed?: (req: PayloadRequest) => Set<string> | Promise<Set<string>>\n\t\t/** Stored values of registered recipient sources; a member is a valid recipient by itself. */\n\t\tsourceValues?: Set<string>\n\t}) =>\n\tasync (\n\t\tvalue: unknown,\n\t\t{ data, req }: { data?: unknown; req: PayloadRequest }\n\t): Promise<string | true> => {\n\t\tconst list = toList(value)\n\t\tif (list.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\tconst fields =\n\t\t\tdata && typeof data === 'object' ? (data as Record<string, unknown>).fields : undefined\n\t\tconst { tokenFieldTypes, allowCustom, fieldTokens, resolveAllowed } = opts\n\t\tconst allowedFields = new Set(\n\t\t\ttokenFieldTypes && tokenFieldTypes.length > 0\n\t\t\t\t? fieldNamesOfType(fields, tokenFieldTypes)\n\t\t\t\t: fieldNames(fields)\n\t\t)\n\t\t// Only resolve the membership set when it is actually enforced (allowCustom explicitly false),\n\t\t// so the default (free-typed) path pays no resolver cost. Compare case-insensitively, matching\n\t\t// the client's dedupe, since email delivery ignores case.\n\t\tlet allowedValues: Set<string> | undefined\n\t\tif (allowCustom === false) {\n\t\t\tif (resolveAllowed) {\n\t\t\t\ttry {\n\t\t\t\t\tallowedValues = await resolveAllowed(req)\n\t\t\t\t} catch {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientOptionsUnavailable)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tallowedValues = new Set()\n\t\t\t}\n\t\t}\n\t\tfor (const entry of list) {\n\t\t\t// A registered source value is a valid recipient on its own: checked first, so a same-named form\n\t\t\t// field cannot shadow it and `allowCustom: false` cannot block it (it is not a custom address).\n\t\t\tif (opts.sourceValues?.has(entry)) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst token = parseFieldToken(entry)\n\t\t\tif (token) {\n\t\t\t\t// `fieldTokens: false` disables recipient tokens; enforce it on the server, not just the client.\n\t\t\t\tif (fieldTokens === false) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t\t}\n\t\t\t\tif (!allowedFields.has(token)) {\n\t\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientUnknownField)\n\t\t\t\t}\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (!isPlausibleEmail(entry)) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientInvalid)\n\t\t\t}\n\t\t\tif (allowCustom === false && !allowedValues?.has(entry.trim().toLowerCase())) {\n\t\t\t\treturn asTranslate(req.t)(keys.validationRecipientNotAllowed)\n\t\t\t}\n\t\t}\n\t\treturn true\n\t}\n\n/** Host-configurable behavior for the recipient fields (plugin option `email.recipients`). */\nexport type RecipientsConfig = {\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n}\n\nconst RECIPIENTS_FIELD_REF = '@10x-media/form-builder/client#RecipientsSelect'\n\nconst DEPARTMENT_ALLOWED_CACHE = 'formBuilderDepartmentAllowedSet'\n\n/**\n * The department option values as a lowercased Set, memoized on `req.context` so every recipient\n * field validated in one save shares a single `departments` call. Payload validates the fields\n * concurrently, so the in-flight promise is cached synchronously (not the resolved value) to avoid a\n * resolve-per-field race. Lowercased because email delivery ignores case, matching the client dedupe.\n */\nconst resolveDepartmentAllowed = (\n\treq: PayloadRequest,\n\tdepartments: DepartmentEmailsResolver\n): Promise<Set<string>> => {\n\tconst cached = req.context?.[DEPARTMENT_ALLOWED_CACHE]\n\tif (cached instanceof Promise) {\n\t\treturn cached as Promise<Set<string>>\n\t}\n\tconst promise = Promise.resolve()\n\t\t.then(() => departments({ req }))\n\t\t.then((options) => new Set(options.map((option) => option.value.trim().toLowerCase())))\n\tif (req.context) {\n\t\treq.context[DEPARTMENT_ALLOWED_CACHE] = promise\n\t}\n\treturn promise\n}\n\n/**\n * A `text hasMany` field rendered by `RecipientsSelect`, used for every email address list. `endpoint`\n * (when set) supplies preset options (e.g. the host's departments); `recipients` narrows the field's\n * behavior; `width` sets `admin.width` so a pair can share a row; `departments` (when set) backs\n * server-side `allowCustom: false` enforcement with the resolved option values.\n */\n// biome-ignore lint/complexity/useMaxParams: the field identity (name, label, localize) plus its grouped options is the minimal surface\nexport const buildRecipientField = (\n\tname: string,\n\tlabelKey: string,\n\tlocalize: boolean,\n\topts: {\n\t\tendpoint?: string\n\t\trecipients?: RecipientsConfig\n\t\twidth?: string\n\t\tdepartments?: DepartmentEmailsResolver\n\t\tsources?: RecipientSourceRegistry\n\t} = {}\n): TextField => {\n\tconst { departments } = opts\n\tconst resolveAllowed = departments\n\t\t? (req: PayloadRequest) => resolveDepartmentAllowed(req, departments)\n\t\t: undefined\n\tconst sourceList = Object.values(opts.sources ?? {})\n\tconst sourceValues = sourceList.length > 0 ? new Set(sourceList.map((s) => s.value)) : undefined\n\treturn {\n\t\tname,\n\t\ttype: 'text',\n\t\thasMany: true,\n\t\tlabel: labelFor(labelKey),\n\t\tvalidate: validateRecipients({\n\t\t\ttokenFieldTypes: opts.recipients?.tokenFieldTypes ?? ['email'],\n\t\t\tallowCustom: opts.recipients?.allowCustom,\n\t\t\tfieldTokens: opts.recipients?.fieldTokens,\n\t\t\tresolveAllowed,\n\t\t\tsourceValues,\n\t\t}),\n\t\t...localizedIf(localize),\n\t\tadmin: {\n\t\t\t...(opts.width ? { width: opts.width } : {}),\n\t\t\tcomponents: {\n\t\t\t\tField: {\n\t\t\t\t\tpath: RECIPIENTS_FIELD_REF,\n\t\t\t\t\tclientProps: {\n\t\t\t\t\t\t// The departments option set is request-scoped, so it loads before the first save.\n\t\t\t\t\t\t...(opts.endpoint ? { endpoint: opts.endpoint, scope: 'request' } : {}),\n\t\t\t\t\t\t...(opts.recipients?.allowCustom === false ? { allowCustom: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.fieldTokens === false ? { fieldTokens: false } : {}),\n\t\t\t\t\t\t...(opts.recipients?.tokenFieldTypes\n\t\t\t\t\t\t\t? { tokenFieldTypes: opts.recipients.tokenFieldTypes }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t\t...(sourceList.length > 0\n\t\t\t\t\t\t\t? { sources: sourceList.map((s) => ({ value: s.value, label: s.label })) }\n\t\t\t\t\t\t\t: {}),\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t}\n}\n"],"mappings":"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"}
@@ -66,9 +66,9 @@ const validateFromField = (resolver, sourceValues) => async (value, { req }) =>
66
66
  };
67
67
  /**
68
68
  * The `from` select shared by `emailTeam` and `confirmation`: an `EndpointOptionsSelect` backed by
69
- * the forms collection's `/:id/from-addresses` endpoint (registered only when `email.fromAddresses`
70
- * is set). That route's document id goes unused server-side: the option set is request-scoped, not
71
- * per-form, so this reuses the existing doc-scoped component as-is instead of adding an id-less mode.
69
+ * the forms collection's `/from-addresses` endpoint (registered when `email.fromAddresses` or
70
+ * `email.fromSources` is set). The option set is request-scoped, not per-form, so the select is
71
+ * marked `scope: 'request'` and its options load while the form is still being created.
72
72
  */
73
73
  const buildFromField = (resolver, sources) => ({
74
74
  name: "from",
@@ -79,6 +79,7 @@ const buildFromField = (resolver, sources) => ({
79
79
  path: FROM_FIELD_REF,
80
80
  clientProps: {
81
81
  endpoint: "from-addresses",
82
+ scope: "request",
82
83
  descriptionKey: keys.actionConfigFromDescription
83
84
  }
84
85
  } } }
@@ -1 +1 @@
1
- {"version":3,"file":"fromAddresses.js","names":[],"sources":["../../src/actions/fromAddresses.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport { isPlausibleEmail } from './emailRecipients'\nimport type { RecipientResolveArgs } from './recipientSources'\n\n/** One selectable \"from\" address for the built-in email actions. */\nexport type FromAddressOption = { label: string; value: string }\n\n/**\n * A sender the plugin resolves server-side at send time (plugin option `email.fromSources`), the\n * from-side counterpart of a `RecipientSource`. `value` is the namespaced string stored on the\n * action (e.g. `tenant:default`), so it cannot collide with a literal address and stays\n * audit-stable while the address it resolves to follows the host; `label` is what the editor sees\n * in the from select. `resolve` returns the address to send from right now (reduced to a single\n * address), or null/empty to send with the email adapter's default sender. A throw fails the\n * action loudly (and retries on the queued path) rather than sending as the wrong identity.\n */\nexport type FromAddressSource = {\n\tvalue: string\n\tlabel: string | Record<string, string>\n\tresolve: (args: RecipientResolveArgs) => Promise<string | null> | string | null\n}\n\nexport type FromAddressSourceRegistry = Record<string, FromAddressSource>\n\n/**\n * The `from` handed to `payload.sendEmail`: a stored source value re-resolves through its source\n * with the run-time args; anything else (a literal picked from `fromAddresses`, which never\n * touches a source at send) is forwarded verbatim, and no configured value means no `from` at\n * all. Mirrors `resolveRecipientEntries`: a throwing source propagates.\n */\nexport const resolveSendFrom = async (opts: {\n\tconfigured: string | undefined\n\tsources?: Map<string, FromAddressSource>\n\tsourceArgs?: RecipientResolveArgs\n}): Promise<string | undefined> => {\n\tconst { configured, sources, sourceArgs } = opts\n\tif (!configured) {\n\t\treturn undefined\n\t}\n\tconst source = sources?.get(configured)\n\tif (!source) {\n\t\treturn configured\n\t}\n\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\tconst resolved = sourceArgs ? await source.resolve(sourceArgs) : null\n\tif (!resolved) {\n\t\treturn undefined\n\t}\n\treturn firstSender(resolved) || undefined\n}\n\n/** A bare plausible address, or a `Name <addr>` display form wrapping one (quotes and commas in the name included). */\nconst isPlausibleSender = (value: string): boolean => {\n\tif (isPlausibleEmail(value)) {\n\t\treturn true\n\t}\n\tconst bracketed = /^[^<>]*<([^<>\\s]+)>$/.exec(value)\n\treturn Boolean(bracketed?.[1] && isPlausibleEmail(bracketed[1]))\n}\n\n/**\n * The sender-side counterpart of `firstAddress`: one sender only, but `Name <addr>` display form\n * survives because that is the documented shape of a `from`. Order matters: cut at line breaks\n * first (the header-injection vector), accept the whole remaining line so a quoted display name\n * may contain commas, and only then comma-split to clamp a multi-address result to its first\n * entry. An implausible result becomes empty (send with the adapter default) rather than a\n * broken header.\n */\nconst firstSender = (value: string): string => {\n\tconst line = (value.split(/[\\n\\r]+/)[0] ?? '').trim()\n\tif (isPlausibleSender(line)) {\n\t\treturn line\n\t}\n\tconst [first] = line.split(/[,;]+/)\n\tconst cleaned = (first ?? '').trim()\n\treturn isPlausibleSender(cleaned) ? cleaned : ''\n}\n\n/**\n * Host seam resolving the selectable `from` addresses for `emailTeam`/`confirmation`\n * (plugin option `email.fromAddresses`). Multi-tenant hosts derive tenant scoping from `req`\n * (host header, cookie, or auth context) and return only that tenant's allowed senders. `value`\n * is the literal string handed to `payload.sendEmail`'s `from` (e.g. `'Name <addr@x.com>'` or a\n * plain address). Absent keeps the email adapter's default sender and adds no `from` field at all.\n */\nexport type FromAddressesResolver = (args: {\n\treq: PayloadRequest\n}) => Promise<FromAddressOption[]> | FromAddressOption[]\n\nconst FROM_FIELD_REF = '@10x-media/form-builder/client#EndpointOptionsSelect'\n\n/**\n * Validate for the `from` field, closed over the host resolver (mirrors the confirmation action's\n * `toField` and poll's `resultsField`): unset is fine, otherwise the value must be one of the\n * resolver's options for this request. A throwing resolver fails closed with a translated message\n * rather than surfacing a raw error on save.\n *\n * Failing closed has an operational cost worth knowing: Payload runs this on every save, not only\n * when `from` changed, so for as long as the resolver is down no form carrying an email action with\n * a `from` set can be saved at all, including edits that never touch the address. That is the\n * deliberate trade: failing open would persist a sender the host can no longer vouch for, and\n * unlike `toField` and `resultsField` this seam depends on host infrastructure that can be down.\n * A resolver reaching a flaky upstream should cache or fall back internally rather than throw.\n */\nexport const validateFromField =\n\t(resolver: FromAddressesResolver | undefined, sourceValues?: Set<string>) =>\n\tasync (value: unknown, { req }: { req: PayloadRequest }): Promise<string | true> => {\n\t\tif (typeof value !== 'string' || value.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\t// A registered source value validates by membership alone, no resolver round trip.\n\t\tif (sourceValues?.has(value)) {\n\t\t\treturn true\n\t\t}\n\t\tif (!resolver) {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnknown)\n\t\t}\n\t\tlet options: FromAddressOption[]\n\t\ttry {\n\t\t\toptions = await resolver({ req })\n\t\t} catch {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnavailable)\n\t\t}\n\t\treturn options.some((option) => option.value === value)\n\t\t\t? true\n\t\t\t: asTranslate(req.t)(keys.validationFromUnknown)\n\t}\n\n/**\n * The `from` select shared by `emailTeam` and `confirmation`: an `EndpointOptionsSelect` backed by\n * the forms collection's `/:id/from-addresses` endpoint (registered only when `email.fromAddresses`\n * is set). That route's document id goes unused server-side: the option set is request-scoped, not\n * per-form, so this reuses the existing doc-scoped component as-is instead of adding an id-less mode.\n */\nexport const buildFromField = (\n\tresolver: FromAddressesResolver | undefined,\n\tsources?: FromAddressSourceRegistry\n): TextField => ({\n\tname: 'from',\n\ttype: 'text',\n\tlabel: labelFor(keys.actionConfigFrom),\n\tvalidate: validateFromField(\n\t\tresolver,\n\t\tsources ? new Set(Object.values(sources).map((source) => source.value)) : undefined\n\t),\n\tadmin: {\n\t\tcomponents: {\n\t\t\tField: {\n\t\t\t\tpath: FROM_FIELD_REF,\n\t\t\t\tclientProps: {\n\t\t\t\t\tendpoint: 'from-addresses',\n\t\t\t\t\tdescriptionKey: keys.actionConfigFromDescription,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n})\n\nexport type ResolveFromAddressesRequestArgs = {\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq: PayloadRequest\n\tresolver?: FromAddressesResolver\n\tsources?: FromAddressSourceRegistry\n}\n\n/**\n * A source entry as the from select shows it. A string label is display text served raw (matching\n * how `RecipientsSelect` receives source labels); a per-locale record picks the request's admin\n * language, then English, then any value.\n */\nconst sourceOption = (source: FromAddressSource, req: PayloadRequest): FromAddressOption => {\n\tif (typeof source.label === 'string') {\n\t\treturn { label: source.label, value: source.value }\n\t}\n\tconst label =\n\t\tsource.label[req.i18n.language] ??\n\t\tsource.label.en ??\n\t\tObject.values(source.label)[0] ??\n\t\tsource.value\n\treturn { label, value: source.value }\n}\n\nexport type ResolveFromAddressesRequestResult = {\n\tstatus: number\n\tbody: { options: FromAddressOption[] } | { errors: { message: string }[] }\n}\n\n/**\n * Authorize and resolve the `GET /:id/from-addresses` request backing the `from` selects:\n * authenticated callers get the host resolver's current options for this request; anonymous\n * callers are always refused. The route id is unused (see `buildFromField`). Statuses mirror\n * the poll-options endpoint: 403 unauthenticated, 503 when the resolver throws (fail closed).\n */\nexport const resolveFromAddressesRequest = async (\n\targs: ResolveFromAddressesRequestArgs\n): Promise<ResolveFromAddressesRequestResult> => {\n\tconst { isAuthed, req, resolver, sources } = args\n\tif (!isAuthed) {\n\t\treturn { status: 403, body: { errors: [{ message: 'Forbidden' }] } }\n\t}\n\ttry {\n\t\t// Sources lead: the send-time-resolved sender is the tenant identity, static literals are\n\t\t// the exceptions an editor picks deliberately.\n\t\tconst sourceOptions = Object.values(sources ?? {}).map((source) => sourceOption(source, req))\n\t\tconst resolved = resolver ? await resolver({ req }) : []\n\t\treturn { status: 200, body: { options: [...sourceOptions, ...resolved] } }\n\t} catch {\n\t\treturn { status: 503, body: { errors: [{ message: 'From addresses unavailable' }] } }\n\t}\n}\n"],"mappings":";;;;;;;;;;AAgCA,MAAa,kBAAkB,OAAO,SAIH;CAClC,MAAM,EAAE,YAAY,SAAS,eAAe;CAC5C,IAAI,CAAC,YACJ;CAED,MAAM,SAAS,SAAS,IAAI,UAAU;CACtC,IAAI,CAAC,QACJ,OAAO;CAGR,MAAM,WAAW,aAAa,MAAM,OAAO,QAAQ,UAAU,IAAI;CACjE,IAAI,CAAC,UACJ;CAED,OAAO,YAAY,QAAQ,KAAK,KAAA;AACjC;;AAGA,MAAM,qBAAqB,UAA2B;CACrD,IAAI,iBAAiB,KAAK,GACzB,OAAO;CAER,MAAM,YAAY,uBAAuB,KAAK,KAAK;CACnD,OAAO,QAAQ,YAAY,MAAM,iBAAiB,UAAU,EAAE,CAAC;AAChE;;;;;;;;;AAUA,MAAM,eAAe,UAA0B;CAC9C,MAAM,QAAQ,MAAM,MAAM,SAAS,EAAE,MAAM,IAAI,KAAK;CACpD,IAAI,kBAAkB,IAAI,GACzB,OAAO;CAER,MAAM,CAAC,SAAS,KAAK,MAAM,OAAO;CAClC,MAAM,WAAW,SAAS,IAAI,KAAK;CACnC,OAAO,kBAAkB,OAAO,IAAI,UAAU;AAC/C;AAaA,MAAM,iBAAiB;;;;;;;;;;;;;;AAevB,MAAa,qBACX,UAA6C,iBAC9C,OAAO,OAAgB,EAAE,UAA2D;CACnF,IAAI,OAAO,UAAU,YAAY,MAAM,WAAW,GACjD,OAAO;CAGR,IAAI,cAAc,IAAI,KAAK,GAC1B,OAAO;CAER,IAAI,CAAC,UACJ,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,qBAAqB;CAErD,IAAI;CACJ,IAAI;EACH,UAAU,MAAM,SAAS,EAAE,IAAI,CAAC;CACjC,QAAQ;EACP,OAAO,YAAY,IAAI,CAAC,EAAE,KAAK,yBAAyB;CACzD;CACA,OAAO,QAAQ,MAAM,WAAW,OAAO,UAAU,KAAK,IACnD,OACA,YAAY,IAAI,CAAC,EAAE,KAAK,qBAAqB;AACjD;;;;;;;AAQD,MAAa,kBACZ,UACA,aACgB;CAChB,MAAM;CACN,MAAM;CACN,OAAO,SAAS,KAAK,gBAAgB;CACrC,UAAU,kBACT,UACA,UAAU,IAAI,IAAI,OAAO,OAAO,OAAO,EAAE,KAAK,WAAW,OAAO,KAAK,CAAC,IAAI,KAAA,CAC3E;CACA,OAAO,EACN,YAAY,EACX,OAAO;EACN,MAAM;EACN,aAAa;GACZ,UAAU;GACV,gBAAgB,KAAK;EACtB;CACD,EACD,EACD;AACD;;;;;;AAeA,MAAM,gBAAgB,QAA2B,QAA2C;CAC3F,IAAI,OAAO,OAAO,UAAU,UAC3B,OAAO;EAAE,OAAO,OAAO;EAAO,OAAO,OAAO;CAAM;CAOnD,OAAO;EAAE,OAJR,OAAO,MAAM,IAAI,KAAK,aACtB,OAAO,MAAM,MACb,OAAO,OAAO,OAAO,KAAK,EAAE,MAC5B,OAAO;EACQ,OAAO,OAAO;CAAM;AACrC;;;;;;;AAaA,MAAa,8BAA8B,OAC1C,SACgD;CAChD,MAAM,EAAE,UAAU,KAAK,UAAU,YAAY;CAC7C,IAAI,CAAC,UACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAEpE,IAAI;EAGH,MAAM,gBAAgB,OAAO,OAAO,WAAW,CAAC,CAAC,EAAE,KAAK,WAAW,aAAa,QAAQ,GAAG,CAAC;EAC5F,MAAM,WAAW,WAAW,MAAM,SAAS,EAAE,IAAI,CAAC,IAAI,CAAC;EACvD,OAAO;GAAE,QAAQ;GAAK,MAAM,EAAE,SAAS,CAAC,GAAG,eAAe,GAAG,QAAQ,EAAE;EAAE;CAC1E,QAAQ;EACP,OAAO;GAAE,QAAQ;GAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,6BAA6B,CAAC,EAAE;EAAE;CACrF;AACD"}
1
+ {"version":3,"file":"fromAddresses.js","names":[],"sources":["../../src/actions/fromAddresses.ts"],"sourcesContent":["import type { PayloadRequest, TextField } from 'payload'\nimport { keys } from '../translations/keys'\nimport { asTranslate, labelFor } from '../translations/server'\nimport { isPlausibleEmail } from './emailRecipients'\nimport type { RecipientResolveArgs } from './recipientSources'\n\n/** One selectable \"from\" address for the built-in email actions. */\nexport type FromAddressOption = { label: string; value: string }\n\n/**\n * A sender the plugin resolves server-side at send time (plugin option `email.fromSources`), the\n * from-side counterpart of a `RecipientSource`. `value` is the namespaced string stored on the\n * action (e.g. `tenant:default`), so it cannot collide with a literal address and stays\n * audit-stable while the address it resolves to follows the host; `label` is what the editor sees\n * in the from select. `resolve` returns the address to send from right now (reduced to a single\n * address), or null/empty to send with the email adapter's default sender. A throw fails the\n * action loudly (and retries on the queued path) rather than sending as the wrong identity.\n */\nexport type FromAddressSource = {\n\tvalue: string\n\tlabel: string | Record<string, string>\n\tresolve: (args: RecipientResolveArgs) => Promise<string | null> | string | null\n}\n\nexport type FromAddressSourceRegistry = Record<string, FromAddressSource>\n\n/**\n * The `from` handed to `payload.sendEmail`: a stored source value re-resolves through its source\n * with the run-time args; anything else (a literal picked from `fromAddresses`, which never\n * touches a source at send) is forwarded verbatim, and no configured value means no `from` at\n * all. Mirrors `resolveRecipientEntries`: a throwing source propagates.\n */\nexport const resolveSendFrom = async (opts: {\n\tconfigured: string | undefined\n\tsources?: Map<string, FromAddressSource>\n\tsourceArgs?: RecipientResolveArgs\n}): Promise<string | undefined> => {\n\tconst { configured, sources, sourceArgs } = opts\n\tif (!configured) {\n\t\treturn undefined\n\t}\n\tconst source = sources?.get(configured)\n\tif (!source) {\n\t\treturn configured\n\t}\n\t// A source only resolves with the run-time args; there are none at authoring/validation time.\n\tconst resolved = sourceArgs ? await source.resolve(sourceArgs) : null\n\tif (!resolved) {\n\t\treturn undefined\n\t}\n\treturn firstSender(resolved) || undefined\n}\n\n/** A bare plausible address, or a `Name <addr>` display form wrapping one (quotes and commas in the name included). */\nconst isPlausibleSender = (value: string): boolean => {\n\tif (isPlausibleEmail(value)) {\n\t\treturn true\n\t}\n\tconst bracketed = /^[^<>]*<([^<>\\s]+)>$/.exec(value)\n\treturn Boolean(bracketed?.[1] && isPlausibleEmail(bracketed[1]))\n}\n\n/**\n * The sender-side counterpart of `firstAddress`: one sender only, but `Name <addr>` display form\n * survives because that is the documented shape of a `from`. Order matters: cut at line breaks\n * first (the header-injection vector), accept the whole remaining line so a quoted display name\n * may contain commas, and only then comma-split to clamp a multi-address result to its first\n * entry. An implausible result becomes empty (send with the adapter default) rather than a\n * broken header.\n */\nconst firstSender = (value: string): string => {\n\tconst line = (value.split(/[\\n\\r]+/)[0] ?? '').trim()\n\tif (isPlausibleSender(line)) {\n\t\treturn line\n\t}\n\tconst [first] = line.split(/[,;]+/)\n\tconst cleaned = (first ?? '').trim()\n\treturn isPlausibleSender(cleaned) ? cleaned : ''\n}\n\n/**\n * Host seam resolving the selectable `from` addresses for `emailTeam`/`confirmation`\n * (plugin option `email.fromAddresses`). Multi-tenant hosts derive tenant scoping from `req`\n * (host header, cookie, or auth context) and return only that tenant's allowed senders. `value`\n * is the literal string handed to `payload.sendEmail`'s `from` (e.g. `'Name <addr@x.com>'` or a\n * plain address). Absent keeps the email adapter's default sender and adds no `from` field at all.\n */\nexport type FromAddressesResolver = (args: {\n\treq: PayloadRequest\n}) => Promise<FromAddressOption[]> | FromAddressOption[]\n\nconst FROM_FIELD_REF = '@10x-media/form-builder/client#EndpointOptionsSelect'\n\n/**\n * Validate for the `from` field, closed over the host resolver (mirrors the confirmation action's\n * `toField` and poll's `resultsField`): unset is fine, otherwise the value must be one of the\n * resolver's options for this request. A throwing resolver fails closed with a translated message\n * rather than surfacing a raw error on save.\n *\n * Failing closed has an operational cost worth knowing: Payload runs this on every save, not only\n * when `from` changed, so for as long as the resolver is down no form carrying an email action with\n * a `from` set can be saved at all, including edits that never touch the address. That is the\n * deliberate trade: failing open would persist a sender the host can no longer vouch for, and\n * unlike `toField` and `resultsField` this seam depends on host infrastructure that can be down.\n * A resolver reaching a flaky upstream should cache or fall back internally rather than throw.\n */\nexport const validateFromField =\n\t(resolver: FromAddressesResolver | undefined, sourceValues?: Set<string>) =>\n\tasync (value: unknown, { req }: { req: PayloadRequest }): Promise<string | true> => {\n\t\tif (typeof value !== 'string' || value.length === 0) {\n\t\t\treturn true\n\t\t}\n\t\t// A registered source value validates by membership alone, no resolver round trip.\n\t\tif (sourceValues?.has(value)) {\n\t\t\treturn true\n\t\t}\n\t\tif (!resolver) {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnknown)\n\t\t}\n\t\tlet options: FromAddressOption[]\n\t\ttry {\n\t\t\toptions = await resolver({ req })\n\t\t} catch {\n\t\t\treturn asTranslate(req.t)(keys.validationFromUnavailable)\n\t\t}\n\t\treturn options.some((option) => option.value === value)\n\t\t\t? true\n\t\t\t: asTranslate(req.t)(keys.validationFromUnknown)\n\t}\n\n/**\n * The `from` select shared by `emailTeam` and `confirmation`: an `EndpointOptionsSelect` backed by\n * the forms collection's `/from-addresses` endpoint (registered when `email.fromAddresses` or\n * `email.fromSources` is set). The option set is request-scoped, not per-form, so the select is\n * marked `scope: 'request'` and its options load while the form is still being created.\n */\nexport const buildFromField = (\n\tresolver: FromAddressesResolver | undefined,\n\tsources?: FromAddressSourceRegistry\n): TextField => ({\n\tname: 'from',\n\ttype: 'text',\n\tlabel: labelFor(keys.actionConfigFrom),\n\tvalidate: validateFromField(\n\t\tresolver,\n\t\tsources ? new Set(Object.values(sources).map((source) => source.value)) : undefined\n\t),\n\tadmin: {\n\t\tcomponents: {\n\t\t\tField: {\n\t\t\t\tpath: FROM_FIELD_REF,\n\t\t\t\tclientProps: {\n\t\t\t\t\tendpoint: 'from-addresses',\n\t\t\t\t\tscope: 'request',\n\t\t\t\t\tdescriptionKey: keys.actionConfigFromDescription,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t},\n})\n\nexport type ResolveFromAddressesRequestArgs = {\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq: PayloadRequest\n\tresolver?: FromAddressesResolver\n\tsources?: FromAddressSourceRegistry\n}\n\n/**\n * A source entry as the from select shows it. A string label is display text served raw (matching\n * how `RecipientsSelect` receives source labels); a per-locale record picks the request's admin\n * language, then English, then any value.\n */\nconst sourceOption = (source: FromAddressSource, req: PayloadRequest): FromAddressOption => {\n\tif (typeof source.label === 'string') {\n\t\treturn { label: source.label, value: source.value }\n\t}\n\tconst label =\n\t\tsource.label[req.i18n.language] ??\n\t\tsource.label.en ??\n\t\tObject.values(source.label)[0] ??\n\t\tsource.value\n\treturn { label, value: source.value }\n}\n\nexport type ResolveFromAddressesRequestResult = {\n\tstatus: number\n\tbody: { options: FromAddressOption[] } | { errors: { message: string }[] }\n}\n\n/**\n * Authorize and resolve the `GET /:id/from-addresses` request backing the `from` selects:\n * authenticated callers get the host resolver's current options for this request; anonymous\n * callers are always refused. The route id is unused (see `buildFromField`). Statuses mirror\n * the poll-options endpoint: 403 unauthenticated, 503 when the resolver throws (fail closed).\n */\nexport const resolveFromAddressesRequest = async (\n\targs: ResolveFromAddressesRequestArgs\n): Promise<ResolveFromAddressesRequestResult> => {\n\tconst { isAuthed, req, resolver, sources } = args\n\tif (!isAuthed) {\n\t\treturn { status: 403, body: { errors: [{ message: 'Forbidden' }] } }\n\t}\n\ttry {\n\t\t// Sources lead: the send-time-resolved sender is the tenant identity, static literals are\n\t\t// the exceptions an editor picks deliberately.\n\t\tconst sourceOptions = Object.values(sources ?? {}).map((source) => sourceOption(source, req))\n\t\tconst resolved = resolver ? await resolver({ req }) : []\n\t\treturn { status: 200, body: { options: [...sourceOptions, ...resolved] } }\n\t} catch {\n\t\treturn { status: 503, body: { errors: [{ message: 'From addresses unavailable' }] } }\n\t}\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { TranslationKey } from "../translations/keys.js";
2
+ import { EndpointOptionsScope } from "./endpointOptions.js";
2
3
 
3
4
  //#region src/client/EndpointOptionsSelect.d.ts
4
5
  /** Standard text-field client props plus this component's `clientProps`. */
@@ -18,6 +19,12 @@ type EndpointOptionsSelectProps = {
18
19
  * `{ options: { label, value }[] }`.
19
20
  */
20
21
  endpoint: string;
22
+ /**
23
+ * What the options depend on; see {@link EndpointOptionsScope}. Default `'document'`. Pass
24
+ * `'request'` for an endpoint registered at the id-less path so options load while the document
25
+ * is still being created.
26
+ */
27
+ scope?: EndpointOptionsScope;
21
28
  /**
22
29
  * Field description as a translation key, resolved client-side. Payload drops `admin.description`
23
30
  * functions from client fields, so a translated description must travel as a key; a static
@@ -32,13 +39,15 @@ type EndpointOptionsSelectProps = {
32
39
  isMulti?: boolean;
33
40
  };
34
41
  /**
35
- * A select whose options load from a document-scoped plugin endpoint, for stored values whose
36
- * valid choices only the server knows (e.g. a poll's source-resolved options). The pattern: pass
37
- * `endpoint` via `clientProps`, the component reads the document id from `useDocumentInfo` and the
38
- * API route from `useConfig`, fetches once per document with the admin cookie, and renders
39
- * translated loading/error states. The stored value stays selectable even when it is missing from
40
- * the fetched options (or the fetch failed), so opening an old document never silently drops data;
41
- * unsaved documents skip the fetch since the server cannot resolve options for them yet.
42
+ * A select whose options load from a plugin endpoint, for stored values whose valid choices only
43
+ * the server knows (e.g. a poll's source-resolved options). The pattern: pass `endpoint` via
44
+ * `clientProps`, the component reads the document id from `useDocumentInfo` and the API route
45
+ * from `useConfig`, fetches once with the admin cookie, and renders translated loading/error
46
+ * states. The stored value stays selectable even when it is missing from the fetched options (or
47
+ * the fetch failed), so opening an old document never silently drops data. Document scope (the
48
+ * default) skips the fetch until the document is saved, since the server cannot resolve options
49
+ * for it yet; request scope fetches immediately, create mode included, and ignores the id
50
+ * entirely so the first save never re-fetches.
42
51
  */
43
52
  declare const EndpointOptionsSelect: (props: EndpointOptionsSelectProps) => import("react/jsx-runtime").JSX.Element;
44
53
  //#endregion
@@ -8,13 +8,15 @@ import { jsx, jsxs } from "react/jsx-runtime";
8
8
  import { useEffect, useState } from "react";
9
9
  //#region src/client/EndpointOptionsSelect.tsx
10
10
  /**
11
- * A select whose options load from a document-scoped plugin endpoint, for stored values whose
12
- * valid choices only the server knows (e.g. a poll's source-resolved options). The pattern: pass
13
- * `endpoint` via `clientProps`, the component reads the document id from `useDocumentInfo` and the
14
- * API route from `useConfig`, fetches once per document with the admin cookie, and renders
15
- * translated loading/error states. The stored value stays selectable even when it is missing from
16
- * the fetched options (or the fetch failed), so opening an old document never silently drops data;
17
- * unsaved documents skip the fetch since the server cannot resolve options for them yet.
11
+ * A select whose options load from a plugin endpoint, for stored values whose valid choices only
12
+ * the server knows (e.g. a poll's source-resolved options). The pattern: pass `endpoint` via
13
+ * `clientProps`, the component reads the document id from `useDocumentInfo` and the API route
14
+ * from `useConfig`, fetches once with the admin cookie, and renders translated loading/error
15
+ * states. The stored value stays selectable even when it is missing from the fetched options (or
16
+ * the fetch failed), so opening an old document never silently drops data. Document scope (the
17
+ * default) skips the fetch until the document is saved, since the server cannot resolve options
18
+ * for it yet; request scope fetches immediately, create mode included, and ignores the id
19
+ * entirely so the first save never re-fetches.
18
20
  */
19
21
  const EndpointOptionsSelect = (props) => {
20
22
  const isMulti = props.isMulti === true;
@@ -25,14 +27,17 @@ const EndpointOptionsSelect = (props) => {
25
27
  const { id, collectionSlug } = useDocumentInfo();
26
28
  const { config } = useConfig();
27
29
  const apiRoute = config.routes.api;
30
+ const scope = props.scope ?? "document";
31
+ const docId = scope === "request" ? void 0 : id;
28
32
  const [state, setState] = useState({ status: "idle" });
29
33
  useEffect(() => {
30
- if (id == null || !collectionSlug) return;
34
+ if (!collectionSlug || scope === "document" && docId == null) return;
31
35
  const controller = new AbortController();
32
36
  const url = buildEndpointOptionsUrl({
33
37
  apiRoute,
34
38
  collectionSlug,
35
- id,
39
+ id: docId,
40
+ scope,
36
41
  endpoint: props.endpoint
37
42
  });
38
43
  setState({ status: "loading" });
@@ -53,7 +58,8 @@ const EndpointOptionsSelect = (props) => {
53
58
  }, [
54
59
  apiRoute,
55
60
  collectionSlug,
56
- id,
61
+ docId,
62
+ scope,
57
63
  props.endpoint
58
64
  ]);
59
65
  const selectedValues = isMulti ? Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry.length > 0) : [] : typeof value === "string" && value.length > 0 ? [value] : [];
@@ -1 +1 @@
1
- {"version":3,"file":"EndpointOptionsSelect.js","names":["useTranslation"],"sources":["../../src/client/EndpointOptionsSelect.tsx"],"sourcesContent":["'use client'\n\nimport {\n\tFieldDescription,\n\tFieldLabel,\n\tReactSelect,\n\ttype ReactSelectOption,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseField,\n} from '@payloadcms/ui'\nimport { type CSSProperties, useEffect, useState } from 'react'\nimport { keys, type TranslationKey } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport {\n\tbuildEndpointOptionsUrl,\n\ttype EndpointOption,\n\tparseEndpointOptions,\n} from './endpointOptions'\nimport { toStaticLabel } from './toStaticLabel'\n\n/** Standard text-field client props plus this component's `clientProps`. */\nexport type EndpointOptionsSelectProps = {\n\t/** The field path within the document (Payload-injected, not the endpoint path). */\n\tpath?: string\n\tfield?: { label?: unknown; admin?: { description?: unknown; width?: string } }\n\tlabel?: unknown\n\t/**\n\t * Endpoint subpath under the current document's collection API route; `'poll-options'` fetches\n\t * `GET {routes.api}/{collectionSlug}/{id}/poll-options`. The endpoint must return\n\t * `{ options: { label, value }[] }`.\n\t */\n\tendpoint: string\n\t/**\n\t * Field description as a translation key, resolved client-side. Payload drops `admin.description`\n\t * functions from client fields, so a translated description must travel as a key; a static\n\t * `admin.description` string still renders when this is unset.\n\t */\n\tdescriptionKey?: TranslationKey\n\t/** Mirrors ReactSelect's `isClearable`; defaults to true. */\n\tisClearable?: boolean\n\t/**\n\t * Render a multi-value select bound to a `string[]`, for a `hasMany` field (e.g. a poll outcome\n\t * with tied winners). Default false: a single-value select bound to a `string`.\n\t */\n\tisMulti?: boolean\n}\n\ntype FetchState =\n\t| { status: 'error' | 'idle' | 'loading'; options?: never }\n\t| { status: 'loaded'; options: EndpointOption[] }\n\n/**\n * A select whose options load from a document-scoped plugin endpoint, for stored values whose\n * valid choices only the server knows (e.g. a poll's source-resolved options). The pattern: pass\n * `endpoint` via `clientProps`, the component reads the document id from `useDocumentInfo` and the\n * API route from `useConfig`, fetches once per document with the admin cookie, and renders\n * translated loading/error states. The stored value stays selectable even when it is missing from\n * the fetched options (or the fetch failed), so opening an old document never silently drops data;\n * unsaved documents skip the fetch since the server cannot resolve options for them yet.\n */\nexport const EndpointOptionsSelect = (props: EndpointOptionsSelectProps) => {\n\tconst isMulti = props.isMulti === true\n\tconst { path, setValue, value } = useField<string | string[]>({ path: props.path })\n\tconst label = toStaticLabel(props.field?.label ?? props.label)\n\tconst { t } = useTranslation()\n\tconst description = props.descriptionKey\n\t\t? t(props.descriptionKey)\n\t\t: toStaticLabel(props.field?.admin?.description)\n\tconst { id, collectionSlug } = useDocumentInfo()\n\tconst { config } = useConfig()\n\tconst apiRoute = config.routes.api\n\tconst [state, setState] = useState<FetchState>({ status: 'idle' })\n\n\tuseEffect(() => {\n\t\tif (id == null || !collectionSlug) {\n\t\t\treturn\n\t\t}\n\t\tconst controller = new AbortController()\n\t\tconst url = buildEndpointOptionsUrl({\n\t\t\tapiRoute,\n\t\t\tcollectionSlug,\n\t\t\tid,\n\t\t\tendpoint: props.endpoint,\n\t\t})\n\t\tsetState({ status: 'loading' })\n\t\tfetch(url, {\n\t\t\tcredentials: 'include',\n\t\t\theaders: { Accept: 'application/json' },\n\t\t\tsignal: controller.signal,\n\t\t})\n\t\t\t.then(async (response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new Error(`Options request failed with ${response.status}`)\n\t\t\t\t}\n\t\t\t\tsetState({ status: 'loaded', options: parseEndpointOptions(await response.json()) })\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tif (!controller.signal.aborted) {\n\t\t\t\t\tsetState({ status: 'error' })\n\t\t\t\t}\n\t\t\t})\n\t\treturn () => controller.abort()\n\t}, [apiRoute, collectionSlug, id, props.endpoint])\n\n\t// A missing fetched option (or a failed fetch) must never silently drop a stored value, so every\n\t// selected value the options don't cover is kept selectable by its own label.\n\tconst selectedValues: string[] = isMulti\n\t\t? Array.isArray(value)\n\t\t\t? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n\t\t\t: []\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\tconst options: ReactSelectOption[] = state.status === 'loaded' ? state.options : []\n\tconst missing = selectedValues\n\t\t.filter((selected) => !options.some((option) => option.value === selected))\n\t\t.map((selected) => ({ label: selected, value: selected }))\n\tconst allOptions = missing.length > 0 ? [...options, ...missing] : options\n\tconst selectedOptions = selectedValues\n\t\t.map((selected) => allOptions.find((option) => option.value === selected))\n\t\t.filter((option): option is ReactSelectOption => option !== undefined)\n\n\tconst handleChange = (selected: ReactSelectOption | ReactSelectOption[] | null) => {\n\t\tif (isMulti) {\n\t\t\tconst chosen = Array.isArray(selected) ? selected : selected ? [selected] : []\n\t\t\tsetValue(chosen.map((option) => option.value as string))\n\t\t\treturn\n\t\t}\n\t\tconst chosen = Array.isArray(selected) ? selected[0] : selected\n\t\tsetValue(chosen ? (chosen.value as string) : '')\n\t}\n\n\tconst fieldStyle = props.field?.admin?.width\n\t\t? ({ '--field-width': props.field.admin.width } as CSSProperties)\n\t\t: undefined\n\n\treturn (\n\t\t<div className=\"field-type\" style={{ marginBlockEnd: '1rem', ...fieldStyle }}>\n\t\t\t<FieldLabel label={label} path={path} />\n\t\t\t<ReactSelect\n\t\t\t\toptions={allOptions}\n\t\t\t\tvalue={isMulti ? selectedOptions : (selectedOptions[0] ?? undefined)}\n\t\t\t\tisMulti={isMulti}\n\t\t\t\tisClearable={props.isClearable !== false}\n\t\t\t\tisLoading={state.status === 'loading'}\n\t\t\t\tplaceholder={state.status === 'loading' ? t(keys.endpointOptionsLoading) : undefined}\n\t\t\t\tshowError={state.status === 'error'}\n\t\t\t\tonChange={handleChange}\n\t\t\t/>\n\t\t\t<FieldDescription\n\t\t\t\tdescription={state.status === 'error' ? t(keys.endpointOptionsError) : description}\n\t\t\t\tpath={path}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"EndpointOptionsSelect.js","names":["useTranslation"],"sources":["../../src/client/EndpointOptionsSelect.tsx"],"sourcesContent":["'use client'\n\nimport {\n\tFieldDescription,\n\tFieldLabel,\n\tReactSelect,\n\ttype ReactSelectOption,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseField,\n} from '@payloadcms/ui'\nimport { type CSSProperties, useEffect, useState } from 'react'\nimport { keys, type TranslationKey } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport {\n\tbuildEndpointOptionsUrl,\n\ttype EndpointOption,\n\ttype EndpointOptionsScope,\n\tparseEndpointOptions,\n} from './endpointOptions'\nimport { toStaticLabel } from './toStaticLabel'\n\n/** Standard text-field client props plus this component's `clientProps`. */\nexport type EndpointOptionsSelectProps = {\n\t/** The field path within the document (Payload-injected, not the endpoint path). */\n\tpath?: string\n\tfield?: { label?: unknown; admin?: { description?: unknown; width?: string } }\n\tlabel?: unknown\n\t/**\n\t * Endpoint subpath under the current document's collection API route; `'poll-options'` fetches\n\t * `GET {routes.api}/{collectionSlug}/{id}/poll-options`. The endpoint must return\n\t * `{ options: { label, value }[] }`.\n\t */\n\tendpoint: string\n\t/**\n\t * What the options depend on; see {@link EndpointOptionsScope}. Default `'document'`. Pass\n\t * `'request'` for an endpoint registered at the id-less path so options load while the document\n\t * is still being created.\n\t */\n\tscope?: EndpointOptionsScope\n\t/**\n\t * Field description as a translation key, resolved client-side. Payload drops `admin.description`\n\t * functions from client fields, so a translated description must travel as a key; a static\n\t * `admin.description` string still renders when this is unset.\n\t */\n\tdescriptionKey?: TranslationKey\n\t/** Mirrors ReactSelect's `isClearable`; defaults to true. */\n\tisClearable?: boolean\n\t/**\n\t * Render a multi-value select bound to a `string[]`, for a `hasMany` field (e.g. a poll outcome\n\t * with tied winners). Default false: a single-value select bound to a `string`.\n\t */\n\tisMulti?: boolean\n}\n\ntype FetchState =\n\t| { status: 'error' | 'idle' | 'loading'; options?: never }\n\t| { status: 'loaded'; options: EndpointOption[] }\n\n/**\n * A select whose options load from a plugin endpoint, for stored values whose valid choices only\n * the server knows (e.g. a poll's source-resolved options). The pattern: pass `endpoint` via\n * `clientProps`, the component reads the document id from `useDocumentInfo` and the API route\n * from `useConfig`, fetches once with the admin cookie, and renders translated loading/error\n * states. The stored value stays selectable even when it is missing from the fetched options (or\n * the fetch failed), so opening an old document never silently drops data. Document scope (the\n * default) skips the fetch until the document is saved, since the server cannot resolve options\n * for it yet; request scope fetches immediately, create mode included, and ignores the id\n * entirely so the first save never re-fetches.\n */\nexport const EndpointOptionsSelect = (props: EndpointOptionsSelectProps) => {\n\tconst isMulti = props.isMulti === true\n\tconst { path, setValue, value } = useField<string | string[]>({ path: props.path })\n\tconst label = toStaticLabel(props.field?.label ?? props.label)\n\tconst { t } = useTranslation()\n\tconst description = props.descriptionKey\n\t\t? t(props.descriptionKey)\n\t\t: toStaticLabel(props.field?.admin?.description)\n\tconst { id, collectionSlug } = useDocumentInfo()\n\tconst { config } = useConfig()\n\tconst apiRoute = config.routes.api\n\tconst scope = props.scope ?? 'document'\n\t// Request scope never puts the id in the URL; deriving undefined here keeps the effect from\n\t// re-fetching when the first save assigns one.\n\tconst docId = scope === 'request' ? undefined : id\n\tconst [state, setState] = useState<FetchState>({ status: 'idle' })\n\n\tuseEffect(() => {\n\t\tif (!collectionSlug || (scope === 'document' && docId == null)) {\n\t\t\treturn\n\t\t}\n\t\tconst controller = new AbortController()\n\t\tconst url = buildEndpointOptionsUrl({\n\t\t\tapiRoute,\n\t\t\tcollectionSlug,\n\t\t\tid: docId,\n\t\t\tscope,\n\t\t\tendpoint: props.endpoint,\n\t\t})\n\t\tsetState({ status: 'loading' })\n\t\tfetch(url, {\n\t\t\tcredentials: 'include',\n\t\t\theaders: { Accept: 'application/json' },\n\t\t\tsignal: controller.signal,\n\t\t})\n\t\t\t.then(async (response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new Error(`Options request failed with ${response.status}`)\n\t\t\t\t}\n\t\t\t\tsetState({ status: 'loaded', options: parseEndpointOptions(await response.json()) })\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tif (!controller.signal.aborted) {\n\t\t\t\t\tsetState({ status: 'error' })\n\t\t\t\t}\n\t\t\t})\n\t\treturn () => controller.abort()\n\t}, [apiRoute, collectionSlug, docId, scope, props.endpoint])\n\n\t// A missing fetched option (or a failed fetch) must never silently drop a stored value, so every\n\t// selected value the options don't cover is kept selectable by its own label.\n\tconst selectedValues: string[] = isMulti\n\t\t? Array.isArray(value)\n\t\t\t? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n\t\t\t: []\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\tconst options: ReactSelectOption[] = state.status === 'loaded' ? state.options : []\n\tconst missing = selectedValues\n\t\t.filter((selected) => !options.some((option) => option.value === selected))\n\t\t.map((selected) => ({ label: selected, value: selected }))\n\tconst allOptions = missing.length > 0 ? [...options, ...missing] : options\n\tconst selectedOptions = selectedValues\n\t\t.map((selected) => allOptions.find((option) => option.value === selected))\n\t\t.filter((option): option is ReactSelectOption => option !== undefined)\n\n\tconst handleChange = (selected: ReactSelectOption | ReactSelectOption[] | null) => {\n\t\tif (isMulti) {\n\t\t\tconst chosen = Array.isArray(selected) ? selected : selected ? [selected] : []\n\t\t\tsetValue(chosen.map((option) => option.value as string))\n\t\t\treturn\n\t\t}\n\t\tconst chosen = Array.isArray(selected) ? selected[0] : selected\n\t\tsetValue(chosen ? (chosen.value as string) : '')\n\t}\n\n\tconst fieldStyle = props.field?.admin?.width\n\t\t? ({ '--field-width': props.field.admin.width } as CSSProperties)\n\t\t: undefined\n\n\treturn (\n\t\t<div className=\"field-type\" style={{ marginBlockEnd: '1rem', ...fieldStyle }}>\n\t\t\t<FieldLabel label={label} path={path} />\n\t\t\t<ReactSelect\n\t\t\t\toptions={allOptions}\n\t\t\t\tvalue={isMulti ? selectedOptions : (selectedOptions[0] ?? undefined)}\n\t\t\t\tisMulti={isMulti}\n\t\t\t\tisClearable={props.isClearable !== false}\n\t\t\t\tisLoading={state.status === 'loading'}\n\t\t\t\tplaceholder={state.status === 'loading' ? t(keys.endpointOptionsLoading) : undefined}\n\t\t\t\tshowError={state.status === 'error'}\n\t\t\t\tonChange={handleChange}\n\t\t\t/>\n\t\t\t<FieldDescription\n\t\t\t\tdescription={state.status === 'error' ? t(keys.endpointOptionsError) : description}\n\t\t\t\tpath={path}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { TranslationKey } from "../translations/keys.js";
2
+ import { EndpointOptionsScope } from "./endpointOptions.js";
2
3
 
3
4
  //#region src/client/RecipientsSelect.d.ts
4
5
  type RecipientsSelectProps = {
@@ -13,7 +14,8 @@ type RecipientsSelectProps = {
13
14
  };
14
15
  label?: unknown;
15
16
  readOnly?: boolean; /** Endpoint subpath supplying preset address options (e.g. `'departments'`); omit for none. */
16
- endpoint?: string; /** Allow free-typed emails (default true). */
17
+ endpoint?: string; /** What the endpoint's options depend on; see {@link EndpointOptionsScope}. Default `'document'`. */
18
+ scope?: EndpointOptionsScope; /** Allow free-typed emails (default true). */
17
19
  allowCustom?: boolean; /** Offer the form's own fields as recipient tokens (default true). */
18
20
  fieldTokens?: boolean; /** Field types eligible as tokens (default `['email']`). */
19
21
  tokenFieldTypes?: string[]; /** Registered recipient sources, offered as their own option group (labels resolved for the admin locale). */
@@ -47,13 +47,16 @@ const RecipientsSelect = (props) => {
47
47
  const apiRoute = config.routes.api;
48
48
  const readOnly = props.readOnly === true || disabled === true;
49
49
  const [state, setState] = useState({ status: "idle" });
50
+ const scope = props.scope ?? "document";
51
+ const docId = scope === "request" ? void 0 : id;
50
52
  useEffect(() => {
51
- if (!props.endpoint || id == null || !collectionSlug) return;
53
+ if (!props.endpoint || !collectionSlug || scope === "document" && docId == null) return;
52
54
  const controller = new AbortController();
53
55
  const url = buildEndpointOptionsUrl({
54
56
  apiRoute,
55
57
  collectionSlug,
56
- id,
58
+ id: docId,
59
+ scope,
57
60
  endpoint: props.endpoint
58
61
  });
59
62
  setState({ status: "loading" });
@@ -74,7 +77,8 @@ const RecipientsSelect = (props) => {
74
77
  }, [
75
78
  apiRoute,
76
79
  collectionSlug,
77
- id,
80
+ docId,
81
+ scope,
78
82
  props.endpoint
79
83
  ]);
80
84
  const presetOptions = state.status === "loaded" ? state.options : [];
@@ -1 +1 @@
1
- {"version":3,"file":"RecipientsSelect.js","names":["useTranslation"],"sources":["../../src/client/RecipientsSelect.tsx"],"sourcesContent":["'use client'\n\nimport {\n\tFieldDescription,\n\tFieldError,\n\tFieldLabel,\n\tReactSelect,\n\ttype ReactSelectOption,\n\tRenderCustomComponent,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseField,\n\tuseFormFields,\n} from '@payloadcms/ui'\nimport { reduceFieldsToValues } from 'payload/shared'\nimport { type CSSProperties, useEffect, useMemo, useState } from 'react'\nimport { isPlausibleEmail } from '../actions/emailRecipients'\nimport { fieldNames, fieldNamesOfType } from '../fields/fieldNamesOfType'\nimport { keys, type TranslationKey } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport {\n\tbuildEndpointOptionsUrl,\n\ttype EndpointOption,\n\tparseEndpointOptions,\n} from './endpointOptions'\nimport type { FieldRow } from './synthesizeClientField'\nimport { toStaticLabel } from './toStaticLabel'\n\nexport type RecipientsSelectProps = {\n\tpath?: string\n\tfield?: { label?: unknown; required?: boolean; admin?: { description?: unknown; width?: string } }\n\tlabel?: unknown\n\treadOnly?: boolean\n\t/** Endpoint subpath supplying preset address options (e.g. `'departments'`); omit for none. */\n\tendpoint?: string\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n\t/** Registered recipient sources, offered as their own option group (labels resolved for the admin locale). */\n\tsources?: { value: string; label: string | Record<string, string> }[]\n\tdescriptionKey?: TranslationKey\n}\n\n/** A source label is a plain string or a per-locale record; resolve it for the admin UI language. */\nconst resolveSourceLabel = (label: string | Record<string, string>, language: string): string =>\n\ttypeof label === 'string' ? label : (label[language] ?? label.en ?? Object.values(label)[0] ?? '')\n\ntype FetchState =\n\t| { status: 'error' | 'idle' | 'loading' }\n\t| { status: 'loaded'; options: EndpointOption[] }\n\nconst tokenOptionsFromData = (\n\tdata: Record<string, unknown>,\n\ttypes?: string[]\n): ReactSelectOption[] => {\n\tconst rows = Array.isArray(data.fields) ? (data.fields as unknown[]) : []\n\tconst labels = new Map<string, string>()\n\tfor (const row of rows) {\n\t\tif (!row || typeof row !== 'object') {\n\t\t\tcontinue\n\t\t}\n\t\tconst { name, label } = row as FieldRow\n\t\tif (typeof name === 'string' && typeof label === 'string' && label.length > 0) {\n\t\t\tlabels.set(name.trim(), label)\n\t\t}\n\t}\n\tconst names =\n\t\ttypes && types.length > 0 ? fieldNamesOfType(data.fields, types) : fieldNames(data.fields)\n\treturn names.map((name) => ({ label: labels.get(name) ?? name, value: `{{${name}}}` }))\n}\n\n/**\n * A creatable, multi-value recipient field: pick a department (preset options from `endpoint`), pick a\n * form-field token (`{{field}}`, offered when `fieldTokens`), or type any email and press Enter\n * (`allowCustom`). Renders as native Payload badges (drag-reorderable), stores a `string[]`, honors\n * `admin.width` and the standard field props. Unknown or legacy stored values stay selectable.\n */\nexport const RecipientsSelect = (props: RecipientsSelectProps) => {\n\tconst allowCustom = props.allowCustom !== false\n\tconst fieldTokens = props.fieldTokens !== false\n\tconst {\n\t\tcustomComponents: { Description, Error: ErrorComponent, Label } = {},\n\t\tdisabled,\n\t\tpath,\n\t\tsetValue,\n\t\tshowError,\n\t\tvalue,\n\t} = useField<string[] | string>({ path: props.path })\n\tconst { t, i18n } = useTranslation()\n\tconst sourceOptions: ReactSelectOption[] = useMemo(\n\t\t() =>\n\t\t\t(props.sources ?? []).map((source) => ({\n\t\t\t\tvalue: source.value,\n\t\t\t\tlabel: resolveSourceLabel(source.label, i18n.language),\n\t\t\t})),\n\t\t[props.sources, i18n.language]\n\t)\n\tconst label = toStaticLabel(props.field?.label ?? props.label)\n\tconst description = props.descriptionKey\n\t\t? t(props.descriptionKey)\n\t\t: toStaticLabel(props.field?.admin?.description)\n\tconst { id, collectionSlug } = useDocumentInfo()\n\tconst { config } = useConfig()\n\tconst apiRoute = config.routes.api\n\tconst readOnly = props.readOnly === true || disabled === true\n\tconst [state, setState] = useState<FetchState>({ status: 'idle' })\n\n\tuseEffect(() => {\n\t\tif (!props.endpoint || id == null || !collectionSlug) {\n\t\t\treturn\n\t\t}\n\t\tconst controller = new AbortController()\n\t\tconst url = buildEndpointOptionsUrl({ apiRoute, collectionSlug, id, endpoint: props.endpoint })\n\t\tsetState({ status: 'loading' })\n\t\tfetch(url, {\n\t\t\tcredentials: 'include',\n\t\t\theaders: { Accept: 'application/json' },\n\t\t\tsignal: controller.signal,\n\t\t})\n\t\t\t.then(async (response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new Error(`Options request failed with ${response.status}`)\n\t\t\t\t}\n\t\t\t\tsetState({ status: 'loaded', options: parseEndpointOptions(await response.json()) })\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tif (!controller.signal.aborted) {\n\t\t\t\t\tsetState({ status: 'error' })\n\t\t\t\t}\n\t\t\t})\n\t\treturn () => controller.abort()\n\t}, [apiRoute, collectionSlug, id, props.endpoint])\n\n\tconst presetOptions: ReactSelectOption[] = state.status === 'loaded' ? state.options : []\n\n\tconst tokenJson = useFormFields(([fields]) =>\n\t\tfieldTokens\n\t\t\t? JSON.stringify(\n\t\t\t\t\ttokenOptionsFromData(reduceFieldsToValues(fields, true), props.tokenFieldTypes)\n\t\t\t\t)\n\t\t\t: '[]'\n\t)\n\tconst tokenOptions = useMemo(() => JSON.parse(tokenJson) as ReactSelectOption[], [tokenJson])\n\n\tconst stored: string[] = Array.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n\tconst byValue = useMemo(() => {\n\t\tconst map = new Map<string, ReactSelectOption>()\n\t\tfor (const option of [...presetOptions, ...tokenOptions, ...sourceOptions]) {\n\t\t\tmap.set(option.value as string, option)\n\t\t}\n\t\treturn map\n\t}, [presetOptions, tokenOptions, sourceOptions])\n\n\tconst selected = stored.map((entry) => byValue.get(entry) ?? { label: entry, value: entry })\n\n\tconst groupedOptions = useMemo(() => {\n\t\tconst groups: { label: string; options: ReactSelectOption[] }[] = []\n\t\tif (presetOptions.length > 0) {\n\t\t\tgroups.push({ label: t(keys.recipientsGroupDepartments), options: presetOptions })\n\t\t}\n\t\tif (tokenOptions.length > 0) {\n\t\t\tgroups.push({ label: t(keys.recipientsGroupFields), options: tokenOptions })\n\t\t}\n\t\tif (sourceOptions.length > 0) {\n\t\t\tgroups.push({ label: t(keys.recipientsGroupSources), options: sourceOptions })\n\t\t}\n\t\treturn groups\n\t}, [presetOptions, tokenOptions, sourceOptions, t])\n\n\tconst handleChange = (selection: ReactSelectOption | ReactSelectOption[] | null) => {\n\t\tif (readOnly) {\n\t\t\treturn\n\t\t}\n\t\tconst chosen = Array.isArray(selection) ? selection : selection ? [selection] : []\n\t\tconst entries = chosen\n\t\t\t.map((option) => String(option.value).trim())\n\t\t\t.filter((entry) => entry.length > 0 && (isPlausibleEmail(entry) || byValue.has(entry)))\n\t\tconst deduped = new Map<string, string>()\n\t\tfor (const entry of entries) {\n\t\t\tconst key = entry.toLowerCase()\n\t\t\tif (!deduped.has(key)) {\n\t\t\t\tdeduped.set(key, entry)\n\t\t\t}\n\t\t}\n\t\tsetValue(Array.from(deduped.values()))\n\t}\n\n\tconst fieldStyle = props.field?.admin?.width\n\t\t? ({ '--field-width': props.field.admin.width } as CSSProperties)\n\t\t: undefined\n\n\treturn (\n\t\t<div\n\t\t\tclassName={['field-type', showError && 'error', readOnly && 'read-only']\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.join(' ')}\n\t\t\tid={path ? `field-${path.replace(/\\./g, '__')}` : undefined}\n\t\t\tstyle={{ marginBlockEnd: '1rem', ...fieldStyle }}\n\t\t>\n\t\t\t<RenderCustomComponent\n\t\t\t\tCustomComponent={Label}\n\t\t\t\tFallback={<FieldLabel label={label} path={path} required={props.field?.required} />}\n\t\t\t/>\n\t\t\t<div className=\"field-type__wrap\">\n\t\t\t\t<RenderCustomComponent\n\t\t\t\t\tCustomComponent={ErrorComponent}\n\t\t\t\t\tFallback={<FieldError path={path} showError={showError} />}\n\t\t\t\t/>\n\t\t\t\t<ReactSelect\n\t\t\t\t\toptions={groupedOptions}\n\t\t\t\t\tvalue={selected}\n\t\t\t\t\tisMulti\n\t\t\t\t\tisSortable\n\t\t\t\t\tisCreatable={allowCustom}\n\t\t\t\t\tisClearable={!readOnly}\n\t\t\t\t\tdisabled={readOnly}\n\t\t\t\t\tisLoading={state.status === 'loading'}\n\t\t\t\t\tshowError={showError}\n\t\t\t\t\tplaceholder={state.status === 'loading' ? t(keys.endpointOptionsLoading) : undefined}\n\t\t\t\t\tonChange={handleChange}\n\t\t\t\t\tfilterOption={(option, rawInput) => {\n\t\t\t\t\t\tif (!option) {\n\t\t\t\t\t\t\treturn allowCustom && isPlausibleEmail(rawInput)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconst query = rawInput.toLowerCase()\n\t\t\t\t\t\treturn `${option.label} ${option.value}`.toLowerCase().includes(query)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<RenderCustomComponent\n\t\t\t\tCustomComponent={Description}\n\t\t\t\tFallback={\n\t\t\t\t\t<FieldDescription\n\t\t\t\t\t\tdescription={state.status === 'error' ? t(keys.endpointOptionsError) : description}\n\t\t\t\t\t\tpath={path}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"RecipientsSelect.js","names":["useTranslation"],"sources":["../../src/client/RecipientsSelect.tsx"],"sourcesContent":["'use client'\n\nimport {\n\tFieldDescription,\n\tFieldError,\n\tFieldLabel,\n\tReactSelect,\n\ttype ReactSelectOption,\n\tRenderCustomComponent,\n\tuseConfig,\n\tuseDocumentInfo,\n\tuseField,\n\tuseFormFields,\n} from '@payloadcms/ui'\nimport { reduceFieldsToValues } from 'payload/shared'\nimport { type CSSProperties, useEffect, useMemo, useState } from 'react'\nimport { isPlausibleEmail } from '../actions/emailRecipients'\nimport { fieldNames, fieldNamesOfType } from '../fields/fieldNamesOfType'\nimport { keys, type TranslationKey } from '../translations/keys'\nimport { useTranslation } from '../translations/useTranslation'\nimport {\n\tbuildEndpointOptionsUrl,\n\ttype EndpointOption,\n\ttype EndpointOptionsScope,\n\tparseEndpointOptions,\n} from './endpointOptions'\nimport type { FieldRow } from './synthesizeClientField'\nimport { toStaticLabel } from './toStaticLabel'\n\nexport type RecipientsSelectProps = {\n\tpath?: string\n\tfield?: { label?: unknown; required?: boolean; admin?: { description?: unknown; width?: string } }\n\tlabel?: unknown\n\treadOnly?: boolean\n\t/** Endpoint subpath supplying preset address options (e.g. `'departments'`); omit for none. */\n\tendpoint?: string\n\t/** What the endpoint's options depend on; see {@link EndpointOptionsScope}. Default `'document'`. */\n\tscope?: EndpointOptionsScope\n\t/** Allow free-typed emails (default true). */\n\tallowCustom?: boolean\n\t/** Offer the form's own fields as recipient tokens (default true). */\n\tfieldTokens?: boolean\n\t/** Field types eligible as tokens (default `['email']`). */\n\ttokenFieldTypes?: string[]\n\t/** Registered recipient sources, offered as their own option group (labels resolved for the admin locale). */\n\tsources?: { value: string; label: string | Record<string, string> }[]\n\tdescriptionKey?: TranslationKey\n}\n\n/** A source label is a plain string or a per-locale record; resolve it for the admin UI language. */\nconst resolveSourceLabel = (label: string | Record<string, string>, language: string): string =>\n\ttypeof label === 'string' ? label : (label[language] ?? label.en ?? Object.values(label)[0] ?? '')\n\ntype FetchState =\n\t| { status: 'error' | 'idle' | 'loading' }\n\t| { status: 'loaded'; options: EndpointOption[] }\n\nconst tokenOptionsFromData = (\n\tdata: Record<string, unknown>,\n\ttypes?: string[]\n): ReactSelectOption[] => {\n\tconst rows = Array.isArray(data.fields) ? (data.fields as unknown[]) : []\n\tconst labels = new Map<string, string>()\n\tfor (const row of rows) {\n\t\tif (!row || typeof row !== 'object') {\n\t\t\tcontinue\n\t\t}\n\t\tconst { name, label } = row as FieldRow\n\t\tif (typeof name === 'string' && typeof label === 'string' && label.length > 0) {\n\t\t\tlabels.set(name.trim(), label)\n\t\t}\n\t}\n\tconst names =\n\t\ttypes && types.length > 0 ? fieldNamesOfType(data.fields, types) : fieldNames(data.fields)\n\treturn names.map((name) => ({ label: labels.get(name) ?? name, value: `{{${name}}}` }))\n}\n\n/**\n * A creatable, multi-value recipient field: pick a department (preset options from `endpoint`), pick a\n * form-field token (`{{field}}`, offered when `fieldTokens`), or type any email and press Enter\n * (`allowCustom`). Renders as native Payload badges (drag-reorderable), stores a `string[]`, honors\n * `admin.width` and the standard field props. Unknown or legacy stored values stay selectable.\n */\nexport const RecipientsSelect = (props: RecipientsSelectProps) => {\n\tconst allowCustom = props.allowCustom !== false\n\tconst fieldTokens = props.fieldTokens !== false\n\tconst {\n\t\tcustomComponents: { Description, Error: ErrorComponent, Label } = {},\n\t\tdisabled,\n\t\tpath,\n\t\tsetValue,\n\t\tshowError,\n\t\tvalue,\n\t} = useField<string[] | string>({ path: props.path })\n\tconst { t, i18n } = useTranslation()\n\tconst sourceOptions: ReactSelectOption[] = useMemo(\n\t\t() =>\n\t\t\t(props.sources ?? []).map((source) => ({\n\t\t\t\tvalue: source.value,\n\t\t\t\tlabel: resolveSourceLabel(source.label, i18n.language),\n\t\t\t})),\n\t\t[props.sources, i18n.language]\n\t)\n\tconst label = toStaticLabel(props.field?.label ?? props.label)\n\tconst description = props.descriptionKey\n\t\t? t(props.descriptionKey)\n\t\t: toStaticLabel(props.field?.admin?.description)\n\tconst { id, collectionSlug } = useDocumentInfo()\n\tconst { config } = useConfig()\n\tconst apiRoute = config.routes.api\n\tconst readOnly = props.readOnly === true || disabled === true\n\tconst [state, setState] = useState<FetchState>({ status: 'idle' })\n\n\tconst scope = props.scope ?? 'document'\n\t// Request scope never puts the id in the URL; deriving undefined here keeps the effect from\n\t// re-fetching when the first save assigns one.\n\tconst docId = scope === 'request' ? undefined : id\n\n\tuseEffect(() => {\n\t\tif (!props.endpoint || !collectionSlug || (scope === 'document' && docId == null)) {\n\t\t\treturn\n\t\t}\n\t\tconst controller = new AbortController()\n\t\tconst url = buildEndpointOptionsUrl({\n\t\t\tapiRoute,\n\t\t\tcollectionSlug,\n\t\t\tid: docId,\n\t\t\tscope,\n\t\t\tendpoint: props.endpoint,\n\t\t})\n\t\tsetState({ status: 'loading' })\n\t\tfetch(url, {\n\t\t\tcredentials: 'include',\n\t\t\theaders: { Accept: 'application/json' },\n\t\t\tsignal: controller.signal,\n\t\t})\n\t\t\t.then(async (response) => {\n\t\t\t\tif (!response.ok) {\n\t\t\t\t\tthrow new Error(`Options request failed with ${response.status}`)\n\t\t\t\t}\n\t\t\t\tsetState({ status: 'loaded', options: parseEndpointOptions(await response.json()) })\n\t\t\t})\n\t\t\t.catch(() => {\n\t\t\t\tif (!controller.signal.aborted) {\n\t\t\t\t\tsetState({ status: 'error' })\n\t\t\t\t}\n\t\t\t})\n\t\treturn () => controller.abort()\n\t}, [apiRoute, collectionSlug, docId, scope, props.endpoint])\n\n\tconst presetOptions: ReactSelectOption[] = state.status === 'loaded' ? state.options : []\n\n\tconst tokenJson = useFormFields(([fields]) =>\n\t\tfieldTokens\n\t\t\t? JSON.stringify(\n\t\t\t\t\ttokenOptionsFromData(reduceFieldsToValues(fields, true), props.tokenFieldTypes)\n\t\t\t\t)\n\t\t\t: '[]'\n\t)\n\tconst tokenOptions = useMemo(() => JSON.parse(tokenJson) as ReactSelectOption[], [tokenJson])\n\n\tconst stored: string[] = Array.isArray(value)\n\t\t? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)\n\t\t: typeof value === 'string' && value.length > 0\n\t\t\t? [value]\n\t\t\t: []\n\n\tconst byValue = useMemo(() => {\n\t\tconst map = new Map<string, ReactSelectOption>()\n\t\tfor (const option of [...presetOptions, ...tokenOptions, ...sourceOptions]) {\n\t\t\tmap.set(option.value as string, option)\n\t\t}\n\t\treturn map\n\t}, [presetOptions, tokenOptions, sourceOptions])\n\n\tconst selected = stored.map((entry) => byValue.get(entry) ?? { label: entry, value: entry })\n\n\tconst groupedOptions = useMemo(() => {\n\t\tconst groups: { label: string; options: ReactSelectOption[] }[] = []\n\t\tif (presetOptions.length > 0) {\n\t\t\tgroups.push({ label: t(keys.recipientsGroupDepartments), options: presetOptions })\n\t\t}\n\t\tif (tokenOptions.length > 0) {\n\t\t\tgroups.push({ label: t(keys.recipientsGroupFields), options: tokenOptions })\n\t\t}\n\t\tif (sourceOptions.length > 0) {\n\t\t\tgroups.push({ label: t(keys.recipientsGroupSources), options: sourceOptions })\n\t\t}\n\t\treturn groups\n\t}, [presetOptions, tokenOptions, sourceOptions, t])\n\n\tconst handleChange = (selection: ReactSelectOption | ReactSelectOption[] | null) => {\n\t\tif (readOnly) {\n\t\t\treturn\n\t\t}\n\t\tconst chosen = Array.isArray(selection) ? selection : selection ? [selection] : []\n\t\tconst entries = chosen\n\t\t\t.map((option) => String(option.value).trim())\n\t\t\t.filter((entry) => entry.length > 0 && (isPlausibleEmail(entry) || byValue.has(entry)))\n\t\tconst deduped = new Map<string, string>()\n\t\tfor (const entry of entries) {\n\t\t\tconst key = entry.toLowerCase()\n\t\t\tif (!deduped.has(key)) {\n\t\t\t\tdeduped.set(key, entry)\n\t\t\t}\n\t\t}\n\t\tsetValue(Array.from(deduped.values()))\n\t}\n\n\tconst fieldStyle = props.field?.admin?.width\n\t\t? ({ '--field-width': props.field.admin.width } as CSSProperties)\n\t\t: undefined\n\n\treturn (\n\t\t<div\n\t\t\tclassName={['field-type', showError && 'error', readOnly && 'read-only']\n\t\t\t\t.filter(Boolean)\n\t\t\t\t.join(' ')}\n\t\t\tid={path ? `field-${path.replace(/\\./g, '__')}` : undefined}\n\t\t\tstyle={{ marginBlockEnd: '1rem', ...fieldStyle }}\n\t\t>\n\t\t\t<RenderCustomComponent\n\t\t\t\tCustomComponent={Label}\n\t\t\t\tFallback={<FieldLabel label={label} path={path} required={props.field?.required} />}\n\t\t\t/>\n\t\t\t<div className=\"field-type__wrap\">\n\t\t\t\t<RenderCustomComponent\n\t\t\t\t\tCustomComponent={ErrorComponent}\n\t\t\t\t\tFallback={<FieldError path={path} showError={showError} />}\n\t\t\t\t/>\n\t\t\t\t<ReactSelect\n\t\t\t\t\toptions={groupedOptions}\n\t\t\t\t\tvalue={selected}\n\t\t\t\t\tisMulti\n\t\t\t\t\tisSortable\n\t\t\t\t\tisCreatable={allowCustom}\n\t\t\t\t\tisClearable={!readOnly}\n\t\t\t\t\tdisabled={readOnly}\n\t\t\t\t\tisLoading={state.status === 'loading'}\n\t\t\t\t\tshowError={showError}\n\t\t\t\t\tplaceholder={state.status === 'loading' ? t(keys.endpointOptionsLoading) : undefined}\n\t\t\t\t\tonChange={handleChange}\n\t\t\t\t\tfilterOption={(option, rawInput) => {\n\t\t\t\t\t\tif (!option) {\n\t\t\t\t\t\t\treturn allowCustom && isPlausibleEmail(rawInput)\n\t\t\t\t\t\t}\n\t\t\t\t\t\tconst query = rawInput.toLowerCase()\n\t\t\t\t\t\treturn `${option.label} ${option.value}`.toLowerCase().includes(query)\n\t\t\t\t\t}}\n\t\t\t\t/>\n\t\t\t</div>\n\t\t\t<RenderCustomComponent\n\t\t\t\tCustomComponent={Description}\n\t\t\t\tFallback={\n\t\t\t\t\t<FieldDescription\n\t\t\t\t\t\tdescription={state.status === 'error' ? t(keys.endpointOptionsError) : description}\n\t\t\t\t\t\tpath={path}\n\t\t\t\t\t/>\n\t\t\t\t}\n\t\t\t/>\n\t\t</div>\n\t)\n}\n"],"mappings":"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"}
@@ -0,0 +1,11 @@
1
+ //#region src/client/endpointOptions.d.ts
2
+ /**
3
+ * What an endpoint-backed select's options depend on. `'document'` (the default) needs a saved
4
+ * document: the URL carries the id and the fetch waits for one. `'request'` means the server
5
+ * resolves options from the request alone (tenant, locale, auth), so the URL has no id segment
6
+ * and the options load while the document is still being created.
7
+ */
8
+ type EndpointOptionsScope = 'document' | 'request';
9
+ //#endregion
10
+ export { EndpointOptionsScope };
11
+ //# sourceMappingURL=endpointOptions.d.ts.map
@@ -1,15 +1,24 @@
1
1
  import { formatAdminURL } from "payload/shared";
2
2
  //#region src/client/endpointOptions.ts
3
3
  /**
4
- * URL for a document-scoped collection endpoint (`{api}/{collection}/{id}/{endpoint}`), built the
5
- * way Payload's own admin components build fetch URLs: `formatAdminURL` over `config.routes.api`,
6
- * which handles a Next `basePath` and returns a same-origin relative URL so the admin cookie rides
7
- * along without CORS concerns.
4
+ * URL for a collection endpoint serving select options, built the way Payload's own admin
5
+ * components build fetch URLs: `formatAdminURL` over `config.routes.api`, which handles a Next
6
+ * `basePath` and returns a same-origin relative URL so the admin cookie rides along without CORS
7
+ * concerns. Document scope yields `{api}/{collection}/{id}/{endpoint}`; request scope yields
8
+ * `{api}/{collection}/{endpoint}`.
8
9
  */
9
- const buildEndpointOptionsUrl = (args) => formatAdminURL({
10
- apiRoute: args.apiRoute,
11
- path: `/${args.collectionSlug}/${encodeURIComponent(String(args.id))}/${args.endpoint.replace(/^\/+/, "")}`
12
- });
10
+ const buildEndpointOptionsUrl = (args) => {
11
+ const endpoint = args.endpoint.replace(/^\/+/, "");
12
+ if (args.scope === "request") return formatAdminURL({
13
+ apiRoute: args.apiRoute,
14
+ path: `/${args.collectionSlug}/${endpoint}`
15
+ });
16
+ if (args.id == null) throw new Error(`form-builder: a document-scoped "${endpoint}" URL needs a document id`);
17
+ return formatAdminURL({
18
+ apiRoute: args.apiRoute,
19
+ path: `/${args.collectionSlug}/${encodeURIComponent(String(args.id))}/${endpoint}`
20
+ });
21
+ };
13
22
  /**
14
23
  * Narrow an endpoint response to `{ options }`. Entries without a string value are dropped and
15
24
  * labels fall back to the value; a body without an options array throws so the select surfaces its
@@ -1 +1 @@
1
- {"version":3,"file":"endpointOptions.js","names":[],"sources":["../../src/client/endpointOptions.ts"],"sourcesContent":["import { formatAdminURL } from 'payload/shared'\n\nexport type EndpointOption = { label: string; value: string }\n\n/**\n * URL for a document-scoped collection endpoint (`{api}/{collection}/{id}/{endpoint}`), built the\n * way Payload's own admin components build fetch URLs: `formatAdminURL` over `config.routes.api`,\n * which handles a Next `basePath` and returns a same-origin relative URL so the admin cookie rides\n * along without CORS concerns.\n */\nexport const buildEndpointOptionsUrl = (args: {\n\tapiRoute: string\n\tcollectionSlug: string\n\tid: number | string\n\tendpoint: string\n}): string =>\n\tformatAdminURL({\n\t\tapiRoute: args.apiRoute,\n\t\tpath: `/${args.collectionSlug}/${encodeURIComponent(String(args.id))}/${args.endpoint.replace(/^\\/+/, '')}`,\n\t})\n\n/**\n * Narrow an endpoint response to `{ options }`. Entries without a string value are dropped and\n * labels fall back to the value; a body without an options array throws so the select surfaces its\n * error state instead of silently rendering empty.\n */\nexport const parseEndpointOptions = (body: unknown): EndpointOption[] => {\n\tconst options =\n\t\tbody != null && typeof body === 'object' ? (body as { options?: unknown }).options : undefined\n\tif (!Array.isArray(options)) {\n\t\tthrow new Error('Malformed options response: expected { options: [] }')\n\t}\n\treturn options\n\t\t.filter(\n\t\t\t(option): option is { label?: unknown; value: string } =>\n\t\t\t\toption != null && typeof option === 'object' && typeof option.value === 'string'\n\t\t)\n\t\t.map((option) => ({\n\t\t\tvalue: option.value,\n\t\t\tlabel:\n\t\t\t\ttypeof option.label === 'string' && option.label.length > 0 ? option.label : option.value,\n\t\t}))\n}\n"],"mappings":";;;;;;;;AAUA,MAAa,2BAA2B,SAMvC,eAAe;CACd,UAAU,KAAK;CACf,MAAM,IAAI,KAAK,eAAe,GAAG,mBAAmB,OAAO,KAAK,EAAE,CAAC,EAAE,GAAG,KAAK,SAAS,QAAQ,QAAQ,EAAE;AACzG,CAAC;;;;;;AAOF,MAAa,wBAAwB,SAAoC;CACxE,MAAM,UACL,QAAQ,QAAQ,OAAO,SAAS,WAAY,KAA+B,UAAU,KAAA;CACtF,IAAI,CAAC,MAAM,QAAQ,OAAO,GACzB,MAAM,IAAI,MAAM,sDAAsD;CAEvE,OAAO,QACL,QACC,WACA,UAAU,QAAQ,OAAO,WAAW,YAAY,OAAO,OAAO,UAAU,QAC1E,EACC,KAAK,YAAY;EACjB,OAAO,OAAO;EACd,OACC,OAAO,OAAO,UAAU,YAAY,OAAO,MAAM,SAAS,IAAI,OAAO,QAAQ,OAAO;CACtF,EAAE;AACJ"}
1
+ {"version":3,"file":"endpointOptions.js","names":[],"sources":["../../src/client/endpointOptions.ts"],"sourcesContent":["import { formatAdminURL } from 'payload/shared'\n\nexport type EndpointOption = { label: string; value: string }\n\n/**\n * What an endpoint-backed select's options depend on. `'document'` (the default) needs a saved\n * document: the URL carries the id and the fetch waits for one. `'request'` means the server\n * resolves options from the request alone (tenant, locale, auth), so the URL has no id segment\n * and the options load while the document is still being created.\n */\nexport type EndpointOptionsScope = 'document' | 'request'\n\n/**\n * URL for a collection endpoint serving select options, built the way Payload's own admin\n * components build fetch URLs: `formatAdminURL` over `config.routes.api`, which handles a Next\n * `basePath` and returns a same-origin relative URL so the admin cookie rides along without CORS\n * concerns. Document scope yields `{api}/{collection}/{id}/{endpoint}`; request scope yields\n * `{api}/{collection}/{endpoint}`.\n */\nexport const buildEndpointOptionsUrl = (args: {\n\tapiRoute: string\n\tcollectionSlug: string\n\tid?: number | string\n\tendpoint: string\n\tscope?: EndpointOptionsScope\n}): string => {\n\tconst endpoint = args.endpoint.replace(/^\\/+/, '')\n\tif (args.scope === 'request') {\n\t\treturn formatAdminURL({ apiRoute: args.apiRoute, path: `/${args.collectionSlug}/${endpoint}` })\n\t}\n\tif (args.id == null) {\n\t\t// The callers guard on this; throwing keeps a future one from fetching `/undefined/...`.\n\t\tthrow new Error(`form-builder: a document-scoped \"${endpoint}\" URL needs a document id`)\n\t}\n\treturn formatAdminURL({\n\t\tapiRoute: args.apiRoute,\n\t\tpath: `/${args.collectionSlug}/${encodeURIComponent(String(args.id))}/${endpoint}`,\n\t})\n}\n\n/**\n * Narrow an endpoint response to `{ options }`. Entries without a string value are dropped and\n * labels fall back to the value; a body without an options array throws so the select surfaces its\n * error state instead of silently rendering empty.\n */\nexport const parseEndpointOptions = (body: unknown): EndpointOption[] => {\n\tconst options =\n\t\tbody != null && typeof body === 'object' ? (body as { options?: unknown }).options : undefined\n\tif (!Array.isArray(options)) {\n\t\tthrow new Error('Malformed options response: expected { options: [] }')\n\t}\n\treturn options\n\t\t.filter(\n\t\t\t(option): option is { label?: unknown; value: string } =>\n\t\t\t\toption != null && typeof option === 'object' && typeof option.value === 'string'\n\t\t)\n\t\t.map((option) => ({\n\t\t\tvalue: option.value,\n\t\t\tlabel:\n\t\t\t\ttypeof option.label === 'string' && option.label.length > 0 ? option.label : option.value,\n\t\t}))\n}\n"],"mappings":";;;;;;;;;AAmBA,MAAa,2BAA2B,SAM1B;CACb,MAAM,WAAW,KAAK,SAAS,QAAQ,QAAQ,EAAE;CACjD,IAAI,KAAK,UAAU,WAClB,OAAO,eAAe;EAAE,UAAU,KAAK;EAAU,MAAM,IAAI,KAAK,eAAe,GAAG;CAAW,CAAC;CAE/F,IAAI,KAAK,MAAM,MAEd,MAAM,IAAI,MAAM,oCAAoC,SAAS,0BAA0B;CAExF,OAAO,eAAe;EACrB,UAAU,KAAK;EACf,MAAM,IAAI,KAAK,eAAe,GAAG,mBAAmB,OAAO,KAAK,EAAE,CAAC,EAAE,GAAG;CACzE,CAAC;AACF;;;;;;AAOA,MAAa,wBAAwB,SAAoC;CACxE,MAAM,UACL,QAAQ,QAAQ,OAAO,SAAS,WAAY,KAA+B,UAAU,KAAA;CACtF,IAAI,CAAC,MAAM,QAAQ,OAAO,GACzB,MAAM,IAAI,MAAM,sDAAsD;CAEvE,OAAO,QACL,QACC,WACA,UAAU,QAAQ,OAAO,WAAW,YAAY,OAAO,OAAO,UAAU,QAC1E,EACC,KAAK,YAAY;EACjB,OAAO,OAAO;EACd,OACC,OAAO,OAAO,UAAU,YAAY,OAAO,MAAM,SAAS,IAAI,OAAO,QAAQ,OAAO;CACtF,EAAE;AACJ"}
@@ -12,6 +12,16 @@ import { resolvePollOptionsRequest } from "../poll/resolvePollOptionsRequest.js"
12
12
  * (anonymous callers get a 403 from the helper, not here). Extracted from `buildFormsCollection`
13
13
  * so the collection builder stays focused on field and hook composition.
14
14
  */
15
+ /** A request-scoped GET route at its id-less path plus the legacy doc-scoped path, one handler. */
16
+ const requestScopedRoutes = (name, handler) => [{
17
+ path: `/${name}`,
18
+ method: "get",
19
+ handler
20
+ }, {
21
+ path: `/:id/${name}`,
22
+ method: "get",
23
+ handler
24
+ }];
15
25
  const buildFormsEndpoints = ({ resultsAccess, pollResultsTypes, pollVotesEnabled, consentSources, fromAddresses, fromSources, departments }) => [
16
26
  {
17
27
  path: "/:id/results",
@@ -72,31 +82,23 @@ const buildFormsEndpoints = ({ resultsAccess, pollResultsTypes, pollVotesEnabled
72
82
  return Response.json(body, { status });
73
83
  }
74
84
  }] : [],
75
- ...fromAddresses || fromSources ? [{
76
- path: "/:id/from-addresses",
77
- method: "get",
78
- handler: async (req) => {
79
- const { status, body } = await resolveFromAddressesRequest({
80
- isAuthed: Boolean(req.user),
81
- req,
82
- resolver: fromAddresses,
83
- sources: fromSources
84
- });
85
- return Response.json(body, { status });
86
- }
87
- }] : [],
88
- ...departments ? [{
89
- path: "/:id/departments",
90
- method: "get",
91
- handler: async (req) => {
92
- const { status, body } = await resolveDepartmentsRequest({
93
- isAuthed: Boolean(req.user),
94
- req,
95
- resolver: departments
96
- });
97
- return Response.json(body, { status });
98
- }
99
- }] : []
85
+ ...fromAddresses || fromSources ? requestScopedRoutes("from-addresses", async (req) => {
86
+ const { status, body } = await resolveFromAddressesRequest({
87
+ isAuthed: Boolean(req.user),
88
+ req,
89
+ resolver: fromAddresses,
90
+ sources: fromSources
91
+ });
92
+ return Response.json(body, { status });
93
+ }) : [],
94
+ ...departments ? requestScopedRoutes("departments", async (req) => {
95
+ const { status, body } = await resolveDepartmentsRequest({
96
+ isAuthed: Boolean(req.user),
97
+ req,
98
+ resolver: departments
99
+ });
100
+ return Response.json(body, { status });
101
+ }) : []
100
102
  ];
101
103
  //#endregion
102
104
  export { buildFormsEndpoints };
@@ -1 +1 @@
1
- {"version":3,"file":"formsEndpoints.js","names":[],"sources":["../../src/collections/formsEndpoints.ts"],"sourcesContent":["import type { CollectionConfig, PayloadRequest } from 'payload'\nimport {\n\ttype FromAddressesResolver,\n\ttype FromAddressSourceRegistry,\n\tresolveFromAddressesRequest,\n} from '../actions/fromAddresses'\nimport {\n\ttype FormResultsAccess,\n\tresolveFormResultsRequest,\n} from '../aggregation/resolveResultsRequest'\nimport { resolveConsentSourcesRequest } from '../consent/resolveConsentSourcesRequest'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { type DepartmentEmailsResolver, resolveDepartmentsRequest } from '../email/departments'\nimport { resolvePollCloseRequest } from '../poll/resolvePollCloseRequest'\nimport { resolvePollOptionsRequest } from '../poll/resolvePollOptionsRequest'\n\ntype FormsEndpointDeps = {\n\t/** Host seam gating anonymous results reads (plugin option `results.access`). */\n\tresultsAccess?: FormResultsAccess\n\t/** The poll-eligible field-type names; the `/:id/results` endpoint restricts reads to these. */\n\tpollResultsTypes: string[]\n\t/** Whether the hidden tally store backs poll reads and the close endpoint's outcome aggregation. */\n\tpollVotesEnabled: boolean\n\t/** The plugin `consent.sources` option; present registers the `/:id/consent-sources` endpoint. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** The plugin `email.fromAddresses` option; this or `fromSources` present registers the `/:id/from-addresses` endpoint. */\n\tfromAddresses?: FromAddressesResolver\n\t/** The plugin `email.fromSources` option; served ahead of the resolver's literals. */\n\tfromSources?: FromAddressSourceRegistry\n\t/** The plugin `email.departments` option; present registers the `/:id/departments` endpoint. */\n\tdepartments?: DepartmentEmailsResolver\n}\n\n/**\n * The forms collection's built-in endpoints: poll results/options/close, plus the optional\n * consent-sources, from-addresses, and departments option endpoints that only exist when the\n * matching plugin option is set. Each delegates to its request helper, which owns the auth check\n * (anonymous callers get a 403 from the helper, not here). Extracted from `buildFormsCollection`\n * so the collection builder stays focused on field and hook composition.\n */\nexport const buildFormsEndpoints = ({\n\tresultsAccess,\n\tpollResultsTypes,\n\tpollVotesEnabled,\n\tconsentSources,\n\tfromAddresses,\n\tfromSources,\n\tdepartments,\n}: FormsEndpointDeps): Exclude<CollectionConfig['endpoints'], false | undefined> => [\n\t{\n\t\tpath: '/:id/results',\n\t\tmethod: 'get',\n\t\thandler: async (req: PayloadRequest) => {\n\t\t\tconst field = typeof req.query?.field === 'string' ? req.query.field : undefined\n\t\t\tconst { status, body } = await resolveFormResultsRequest({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\tfield,\n\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\treq,\n\t\t\t\taccess: resultsAccess,\n\t\t\t\teligibleTypes: pollResultsTypes,\n\t\t\t\tpollVotesEnabled,\n\t\t\t})\n\t\t\treturn Response.json(body, { status })\n\t\t},\n\t},\n\t{\n\t\tpath: '/:id/poll-options',\n\t\tmethod: 'get',\n\t\thandler: async (req: PayloadRequest) => {\n\t\t\tconst { status, body } = await resolvePollOptionsRequest({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\treq,\n\t\t\t})\n\t\t\treturn Response.json(body, { status })\n\t\t},\n\t},\n\t// Trusted admin action (authenticated only): close a poll now and resolve its outcome. A POST\n\t// because it mutates; auth is `Boolean(req.user)` so an anonymous caller gets 403 from the helper.\n\t{\n\t\tpath: '/:id/close',\n\t\tmethod: 'post',\n\t\thandler: async (req: PayloadRequest) => {\n\t\t\tconst { status, body } = await resolvePollCloseRequest({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\tpollVotesEnabled,\n\t\t\t\treq,\n\t\t\t})\n\t\t\treturn Response.json(body, { status })\n\t\t},\n\t},\n\t...(consentSources\n\t\t? [\n\t\t\t\t{\n\t\t\t\t\tpath: '/:id/consent-sources',\n\t\t\t\t\tmethod: 'get' as const,\n\t\t\t\t\thandler: async (req: PayloadRequest) => {\n\t\t\t\t\t\tconst { status, body } = await resolveConsentSourcesRequest({\n\t\t\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\t\t\t\treq,\n\t\t\t\t\t\t\tresolver: consentSources,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn Response.json(body, { status })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t: []),\n\t// The route id is unused: the from-addresses set is request-scoped (e.g. per tenant), not\n\t// per-form. Registered as a doc-scoped route only so the admin field can reuse\n\t// EndpointOptionsSelect unmodified (see buildFromField).\n\t...(fromAddresses || fromSources\n\t\t? [\n\t\t\t\t{\n\t\t\t\t\tpath: '/:id/from-addresses',\n\t\t\t\t\tmethod: 'get' as const,\n\t\t\t\t\thandler: async (req: PayloadRequest) => {\n\t\t\t\t\t\tconst { status, body } = await resolveFromAddressesRequest({\n\t\t\t\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\t\t\t\treq,\n\t\t\t\t\t\t\tresolver: fromAddresses,\n\t\t\t\t\t\t\tsources: fromSources,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn Response.json(body, { status })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t: []),\n\t// Same request-scoped, id-unused shape as from-addresses: registered doc-scoped only so the\n\t// recipient selects can fetch it (see RecipientsSelect / buildRecipientField).\n\t...(departments\n\t\t? [\n\t\t\t\t{\n\t\t\t\t\tpath: '/:id/departments',\n\t\t\t\t\tmethod: 'get' as const,\n\t\t\t\t\thandler: async (req: PayloadRequest) => {\n\t\t\t\t\t\tconst { status, body } = await resolveDepartmentsRequest({\n\t\t\t\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\t\t\t\treq,\n\t\t\t\t\t\t\tresolver: departments,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn Response.json(body, { status })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t: []),\n]\n"],"mappings":";;;;;;;;;;;;;;AAwCA,MAAa,uBAAuB,EACnC,eACA,kBACA,kBACA,gBACA,eACA,aACA,kBACmF;CACnF;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,QAAQ,OAAO,IAAI,OAAO,UAAU,WAAW,IAAI,MAAM,QAAQ,KAAA;GACvE,MAAM,EAAE,QAAQ,SAAS,MAAM,0BAA0B;IACxD,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB;IACA,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA,QAAQ;IACR,eAAe;IACf;GACD,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD;CACA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,0BAA0B;IACxD,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB,UAAU,QAAQ,IAAI,IAAI;IAC1B;GACD,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD;CAGA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,wBAAwB;IACtD,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA;GACD,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD;CACA,GAAI,iBACD,CACA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,6BAA6B;IAC3D,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA,UAAU;GACX,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD,CACD,IACC,CAAC;CAIJ,GAAI,iBAAiB,cAClB,CACA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,4BAA4B;IAC1D,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA,UAAU;IACV,SAAS;GACV,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD,CACD,IACC,CAAC;CAGJ,GAAI,cACD,CACA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,0BAA0B;IACxD,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA,UAAU;GACX,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD,CACD,IACC,CAAC;AACL"}
1
+ {"version":3,"file":"formsEndpoints.js","names":[],"sources":["../../src/collections/formsEndpoints.ts"],"sourcesContent":["import type { CollectionConfig, PayloadRequest } from 'payload'\nimport {\n\ttype FromAddressesResolver,\n\ttype FromAddressSourceRegistry,\n\tresolveFromAddressesRequest,\n} from '../actions/fromAddresses'\nimport {\n\ttype FormResultsAccess,\n\tresolveFormResultsRequest,\n} from '../aggregation/resolveResultsRequest'\nimport { resolveConsentSourcesRequest } from '../consent/resolveConsentSourcesRequest'\nimport type { ConsentSourcesResolver } from '../consent/types'\nimport { type DepartmentEmailsResolver, resolveDepartmentsRequest } from '../email/departments'\nimport { resolvePollCloseRequest } from '../poll/resolvePollCloseRequest'\nimport { resolvePollOptionsRequest } from '../poll/resolvePollOptionsRequest'\n\ntype FormsEndpointDeps = {\n\t/** Host seam gating anonymous results reads (plugin option `results.access`). */\n\tresultsAccess?: FormResultsAccess\n\t/** The poll-eligible field-type names; the `/:id/results` endpoint restricts reads to these. */\n\tpollResultsTypes: string[]\n\t/** Whether the hidden tally store backs poll reads and the close endpoint's outcome aggregation. */\n\tpollVotesEnabled: boolean\n\t/** The plugin `consent.sources` option; present registers the `/:id/consent-sources` endpoint. */\n\tconsentSources?: ConsentSourcesResolver\n\t/** The plugin `email.fromAddresses` option; this or `fromSources` present registers the `/:id/from-addresses` endpoint. */\n\tfromAddresses?: FromAddressesResolver\n\t/** The plugin `email.fromSources` option; served ahead of the resolver's literals. */\n\tfromSources?: FromAddressSourceRegistry\n\t/** The plugin `email.departments` option; present registers the `/:id/departments` endpoint. */\n\tdepartments?: DepartmentEmailsResolver\n}\n\n/**\n * The forms collection's built-in endpoints: poll results/options/close, plus the optional\n * consent-sources, from-addresses, and departments option endpoints that only exist when the\n * matching plugin option is set. Each delegates to its request helper, which owns the auth check\n * (anonymous callers get a 403 from the helper, not here). Extracted from `buildFormsCollection`\n * so the collection builder stays focused on field and hook composition.\n */\n/** A request-scoped GET route at its id-less path plus the legacy doc-scoped path, one handler. */\nconst requestScopedRoutes = (\n\tname: string,\n\thandler: (req: PayloadRequest) => Promise<Response>\n): Exclude<CollectionConfig['endpoints'], false | undefined> => [\n\t{ path: `/${name}`, method: 'get', handler },\n\t{ path: `/:id/${name}`, method: 'get', handler },\n]\n\nexport const buildFormsEndpoints = ({\n\tresultsAccess,\n\tpollResultsTypes,\n\tpollVotesEnabled,\n\tconsentSources,\n\tfromAddresses,\n\tfromSources,\n\tdepartments,\n}: FormsEndpointDeps): Exclude<CollectionConfig['endpoints'], false | undefined> => [\n\t{\n\t\tpath: '/:id/results',\n\t\tmethod: 'get',\n\t\thandler: async (req: PayloadRequest) => {\n\t\t\tconst field = typeof req.query?.field === 'string' ? req.query.field : undefined\n\t\t\tconst { status, body } = await resolveFormResultsRequest({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\tfield,\n\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\treq,\n\t\t\t\taccess: resultsAccess,\n\t\t\t\teligibleTypes: pollResultsTypes,\n\t\t\t\tpollVotesEnabled,\n\t\t\t})\n\t\t\treturn Response.json(body, { status })\n\t\t},\n\t},\n\t{\n\t\tpath: '/:id/poll-options',\n\t\tmethod: 'get',\n\t\thandler: async (req: PayloadRequest) => {\n\t\t\tconst { status, body } = await resolvePollOptionsRequest({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\treq,\n\t\t\t})\n\t\t\treturn Response.json(body, { status })\n\t\t},\n\t},\n\t// Trusted admin action (authenticated only): close a poll now and resolve its outcome. A POST\n\t// because it mutates; auth is `Boolean(req.user)` so an anonymous caller gets 403 from the helper.\n\t{\n\t\tpath: '/:id/close',\n\t\tmethod: 'post',\n\t\thandler: async (req: PayloadRequest) => {\n\t\t\tconst { status, body } = await resolvePollCloseRequest({\n\t\t\t\tpayload: req.payload,\n\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\tpollVotesEnabled,\n\t\t\t\treq,\n\t\t\t})\n\t\t\treturn Response.json(body, { status })\n\t\t},\n\t},\n\t...(consentSources\n\t\t? [\n\t\t\t\t{\n\t\t\t\t\tpath: '/:id/consent-sources',\n\t\t\t\t\tmethod: 'get' as const,\n\t\t\t\t\thandler: async (req: PayloadRequest) => {\n\t\t\t\t\t\tconst { status, body } = await resolveConsentSourcesRequest({\n\t\t\t\t\t\t\tpayload: req.payload,\n\t\t\t\t\t\t\tformId: req.routeParams?.id as number | string | undefined,\n\t\t\t\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\t\t\t\treq,\n\t\t\t\t\t\t\tresolver: consentSources,\n\t\t\t\t\t\t})\n\t\t\t\t\t\treturn Response.json(body, { status })\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t]\n\t\t: []),\n\t// The from-addresses and departments sets are request-scoped (e.g. per tenant), never per-form.\n\t// Each lives at the id-less path the selects call (which is what lets their options load while\n\t// a form is still being created), with the legacy `/:id/` route kept on the same handler for\n\t// integrators that hardcoded it.\n\t...(fromAddresses || fromSources\n\t\t? requestScopedRoutes('from-addresses', async (req: PayloadRequest) => {\n\t\t\t\tconst { status, body } = await resolveFromAddressesRequest({\n\t\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\t\treq,\n\t\t\t\t\tresolver: fromAddresses,\n\t\t\t\t\tsources: fromSources,\n\t\t\t\t})\n\t\t\t\treturn Response.json(body, { status })\n\t\t\t})\n\t\t: []),\n\t...(departments\n\t\t? requestScopedRoutes('departments', async (req: PayloadRequest) => {\n\t\t\t\tconst { status, body } = await resolveDepartmentsRequest({\n\t\t\t\t\tisAuthed: Boolean(req.user),\n\t\t\t\t\treq,\n\t\t\t\t\tresolver: departments,\n\t\t\t\t})\n\t\t\t\treturn Response.json(body, { status })\n\t\t\t})\n\t\t: []),\n]\n"],"mappings":";;;;;;;;;;;;;;;AAyCA,MAAM,uBACL,MACA,YAC+D,CAC/D;CAAE,MAAM,IAAI;CAAQ,QAAQ;CAAO;AAAQ,GAC3C;CAAE,MAAM,QAAQ;CAAQ,QAAQ;CAAO;AAAQ,CAChD;AAEA,MAAa,uBAAuB,EACnC,eACA,kBACA,kBACA,gBACA,eACA,aACA,kBACmF;CACnF;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,QAAQ,OAAO,IAAI,OAAO,UAAU,WAAW,IAAI,MAAM,QAAQ,KAAA;GACvE,MAAM,EAAE,QAAQ,SAAS,MAAM,0BAA0B;IACxD,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB;IACA,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA,QAAQ;IACR,eAAe;IACf;GACD,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD;CACA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,0BAA0B;IACxD,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB,UAAU,QAAQ,IAAI,IAAI;IAC1B;GACD,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD;CAGA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,wBAAwB;IACtD,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA;GACD,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD;CACA,GAAI,iBACD,CACA;EACC,MAAM;EACN,QAAQ;EACR,SAAS,OAAO,QAAwB;GACvC,MAAM,EAAE,QAAQ,SAAS,MAAM,6BAA6B;IAC3D,SAAS,IAAI;IACb,QAAQ,IAAI,aAAa;IACzB,UAAU,QAAQ,IAAI,IAAI;IAC1B;IACA,UAAU;GACX,CAAC;GACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;EACtC;CACD,CACD,IACC,CAAC;CAKJ,GAAI,iBAAiB,cAClB,oBAAoB,kBAAkB,OAAO,QAAwB;EACrE,MAAM,EAAE,QAAQ,SAAS,MAAM,4BAA4B;GAC1D,UAAU,QAAQ,IAAI,IAAI;GAC1B;GACA,UAAU;GACV,SAAS;EACV,CAAC;EACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;CACtC,CAAC,IACA,CAAC;CACJ,GAAI,cACD,oBAAoB,eAAe,OAAO,QAAwB;EAClE,MAAM,EAAE,QAAQ,SAAS,MAAM,0BAA0B;GACxD,UAAU,QAAQ,IAAI,IAAI;GAC1B;GACA,UAAU;EACX,CAAC;EACD,OAAO,SAAS,KAAK,MAAM,EAAE,OAAO,CAAC;CACtC,CAAC,IACA,CAAC;AACL"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@10x-media/form-builder",
3
- "version": "0.1.0-beta.18",
3
+ "version": "0.1.0-beta.19",
4
4
  "description": "End-to-end forms platform for Payload: author, validate, render, collect, aggregate, and act.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -100,9 +100,9 @@
100
100
  "typescript": "5.9.3",
101
101
  "vitest": "4.1.7",
102
102
  "@10x-media/tsconfig": "0.0.0",
103
- "@10x-media/tsdown-config": "0.0.0",
103
+ "@10x-media/payload-test-harness": "0.0.0",
104
104
  "@10x-media/vitest-config": "0.0.0",
105
- "@10x-media/payload-test-harness": "0.0.0"
105
+ "@10x-media/tsdown-config": "0.0.0"
106
106
  },
107
107
  "publishConfig": {
108
108
  "access": "public"