@10x-media/form-builder 0.1.0-beta.15 → 0.1.0-beta.17

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,17 @@
1
1
  # @10x-media/form-builder
2
2
 
3
+ ## 0.1.0-beta.17
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `renderResults` to `<Poll>`: a render prop that replaces the built-in `<FormResults>` wherever the poll shows results (after voting, when closed, and for a recorded outcome). It receives the exact `FormResultsProps` the default rendering gets, so hosts can draw result rows from their own data, keyed by each bucket's option value, or wrap `<FormResults>` in their own layout. Omitting the prop changes nothing.
8
+
9
+ ## 0.1.0-beta.16
10
+
11
+ ### Patch Changes
12
+
13
+ - Fix every REST submission returning 500 on beta.15. The vote-change endpoint delegates ordinary creates to Payload's stock create handler, which re-parses the request body the endpoint had already consumed; a consumed fetch body stays non-null, so the second read threw "Body is unusable". The endpoint now hides the spent stream before delegating (the parsed `data`/`file` are already on the request), and finds the stock handler by its own endpoint tag instead of handler identity, so a host-wrapped handler can no longer recurse. Local API submissions were never affected.
14
+
3
15
  ## 0.1.0-beta.15
4
16
 
5
17
  ### Minor Changes
@@ -1,6 +1,8 @@
1
1
  import { FormProps } from "./Form.js";
2
+ import { FormResultsProps } from "./FormResults.js";
2
3
  import { fetchFormResults } from "./fetchResults.js";
3
4
  import { VotedSubmission } from "../submissions/resolveVotedSubmission.js";
5
+ import { ReactNode } from "react";
4
6
 
5
7
  //#region src/react/Poll.d.ts
6
8
  type PollProps = FormProps & {
@@ -28,6 +30,13 @@ type PollProps = FormProps & {
28
30
  * tracks the new pick itself.
29
31
  */
30
32
  currentVote?: Pick<VotedSubmission, 'value' | 'pick'> | null;
33
+ /**
34
+ * Replaces the built-in `<FormResults>` wherever the poll shows results (after voting, when
35
+ * closed, and for a recorded outcome). Receives exactly what `<FormResults>` receives at that
36
+ * site, so the default is `(props) => <FormResults {...props} />`; surrounding chrome (the
37
+ * closed/final notices, the change-vote button) stays the poll's.
38
+ */
39
+ renderResults?: (props: FormResultsProps) => ReactNode;
31
40
  };
32
41
  /**
33
42
  * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows
@@ -49,6 +58,7 @@ declare const Poll: ({
49
58
  apiRoute,
50
59
  onSuccess,
51
60
  currentVote,
61
+ renderResults,
52
62
  ...formProps
53
63
  }: PollProps) => import("react/jsx-runtime").JSX.Element | null;
54
64
  //#endregion
@@ -45,7 +45,8 @@ const resolveVoteStorage = (configured) => {
45
45
  * integrity (bypassable): server-enforced
46
46
  * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.
47
47
  */
48
- const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchFormResults, apiRoute, onSuccess, currentVote, ...formProps }) => {
48
+ const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchFormResults, apiRoute, onSuccess, currentVote, renderResults, ...formProps }) => {
49
+ const renderResultsView = renderResults ?? ((props) => /* @__PURE__ */ jsx(FormResults, { ...props }));
49
50
  const key = storageKey ?? `fb-poll-${formProps.form.id}`;
50
51
  const poll = formProps.form.poll;
51
52
  const closed = isPollClosed(poll);
@@ -122,7 +123,7 @@ const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchForm
122
123
  children: [/* @__PURE__ */ jsx("p", {
123
124
  className: "fb-poll__final",
124
125
  children: translate(keys.pollFinalResult)
125
- }), loadFailed ? resultsError : results ? /* @__PURE__ */ jsx(FormResults, {
126
+ }), loadFailed ? resultsError : results ? renderResultsView({
126
127
  results,
127
128
  winningValues,
128
129
  currentValues: pick,
@@ -135,7 +136,7 @@ const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchForm
135
136
  children: [/* @__PURE__ */ jsx("p", {
136
137
  className: "fb-poll__closed",
137
138
  children: translate(keys.pollClosed)
138
- }), loadFailed ? resultsError : results ? /* @__PURE__ */ jsx(FormResults, {
139
+ }), loadFailed ? resultsError : results ? renderResultsView({
139
140
  results,
140
141
  currentValues: pick,
141
142
  t: formProps.t,
@@ -150,7 +151,7 @@ const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchForm
150
151
  if (loadFailed) return resultsError;
151
152
  return results ? /* @__PURE__ */ jsxs("div", {
152
153
  className: "fb-poll fb-poll--voted",
153
- children: [/* @__PURE__ */ jsx(FormResults, {
154
+ children: [renderResultsView({
154
155
  results,
155
156
  currentValues: pick,
156
157
  t: formProps.t,
@@ -1 +1 @@
1
- {"version":3,"file":"Poll.js","names":[],"sources":["../../src/react/Poll.tsx"],"sourcesContent":["'use client'\n\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { answerValues } from '../poll/votes/answerValues'\nimport type { VotedSubmission } from '../submissions/resolveVotedSubmission'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { VoteStorage } from './adapters'\nimport { Form, type FormProps } from './Form'\nimport { FormResults } from './FormResults'\nimport { type FetchResultsResult, fetchFormResults } from './fetchResults'\n\nexport type PollProps = FormProps & {\n\t/** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */\n\tresultsField: string\n\t/**\n\t * Key for the per-browser voted guard (localStorage by default; overridable or disabled via\n\t * `adapters.voteStorage`). Default `fb-poll-{form.id}`.\n\t */\n\tstorageKey?: string\n\t/**\n\t * Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;\n\t * `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`\n\t * option pass `hasVotedCookie(cookieHeader, form.id)` here.\n\t */\n\thasVoted?: boolean\n\t/**\n\t * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module\n\t * scope, `useCallback`, or `useMemo`): an inline function re-runs the load effect and double-fetches.\n\t */\n\tfetchResultsImpl?: typeof fetchFormResults\n\t/**\n\t * The voter's server-resolved current vote (`resolveVotedSubmission`, read from the httpOnly voted\n\t * cookie). Presence implies voted; `pick` marks the voter's option in results and `value` prefills\n\t * the form when they change their vote. Read at mount: after a client-side change the component\n\t * tracks the new pick itself.\n\t */\n\tcurrentVote?: Pick<VotedSubmission, 'value' | 'pick'> | null\n}\n\nconst localStorageVoteStorage: VoteStorage = {\n\tread: (key) => {\n\t\ttry {\n\t\t\treturn window.localStorage.getItem(key) != null\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t},\n\twrite: (key) => {\n\t\ttry {\n\t\t\twindow.localStorage.setItem(key, '1')\n\t\t} catch {\n\t\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t\t}\n\t},\n}\n\n/** `false` disables client persistence entirely (`hasVoted` still marks voted). */\nconst noopVoteStorage: VoteStorage = { read: () => false, write: () => {} }\n\nconst resolveVoteStorage = (configured: VoteStorage | false | undefined): VoteStorage => {\n\tif (configured === false) {\n\t\treturn noopVoteStorage\n\t}\n\treturn configured ?? localStorageVoteStorage\n}\n\n/**\n * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows\n * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated\n * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open\n * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A\n * recorded `outcome.winningValues` (via `resolvePollOutcome`) supersedes everything: a translated final\n * notice plus results with every winning bucket highlighted (a tie highlights more than one). A\n * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`\n * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not\n * integrity (bypassable): server-enforced\n * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.\n */\nexport const Poll = ({\n\tresultsField,\n\tstorageKey,\n\thasVoted,\n\tfetchResultsImpl = fetchFormResults,\n\tapiRoute,\n\tonSuccess,\n\tcurrentVote,\n\t...formProps\n}: PollProps) => {\n\tconst key = storageKey ?? `fb-poll-${formProps.form.id}`\n\tconst poll = formProps.form.poll\n\tconst closed = isPollClosed(poll)\n\tconst allowChange = pollConfigOf(poll)?.allowChange === true\n\tconst winningValues = poll?.outcome?.winningValues\n\tconst finalized = Array.isArray(winningValues) && winningValues.length > 0\n\tconst resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === 'afterClose'\n\t// Server-known voted state renders the results view on the very first paint; the effect below\n\t// only adds the localStorage read.\n\tconst [voted, setVoted] = useState(hasVoted === true || currentVote != null)\n\tconst [changing, setChanging] = useState(false)\n\tconst [pick, setPick] = useState<string[] | undefined>(currentVote?.pick)\n\tconst [prefill, setPrefill] = useState<unknown>(currentVote?.value)\n\t// Changing is offered only when the signed cookie verifiably identifies a submission: at mount\n\t// via a resolved `currentVote`, or after an in-session vote (the server just issued the cookie).\n\t// A localStorage-only voted flag proves nothing about the cookie, and a \"change\" without one\n\t// would create a second submission instead of updating.\n\tconst [changeReady, setChangeReady] = useState(currentVote != null)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\tconst [loadFailed, setLoadFailed] = useState(false)\n\tconst translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t])\n\tconst configuredVoteStorage = formProps.adapters?.voteStorage\n\tconst voteStorage = useMemo(\n\t\t() => resolveVoteStorage(configuredVoteStorage),\n\t\t[configuredVoteStorage]\n\t)\n\n\tconst loadResults = useCallback(async () => {\n\t\tconst result: FetchResultsResult = await fetchResultsImpl({\n\t\t\tformId: formProps.form.id,\n\t\t\tfield: resultsField,\n\t\t\tapiRoute,\n\t\t})\n\t\t// A failed load surfaces as an error, not an empty result set: `[]` would read as \"no votes yet\".\n\t\tif (result.ok) {\n\t\t\tsetResults(result.results)\n\t\t\tsetLoadFailed(false)\n\t\t} else {\n\t\t\tsetLoadFailed(true)\n\t\t}\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tconst resultsError = (\n\t\t<p className=\"fb-poll__error\" role=\"alert\">\n\t\t\t{translate(keys.pollResultsError)}\n\t\t</p>\n\t)\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted === true || currentVote != null || voteStorage.read(key)\n\t\tif (already) {\n\t\t\tsetVoted(true)\n\t\t}\n\t\tif ((already && !resultsAwaitClose) || closed || finalized) {\n\t\t\tvoid loadResults()\n\t\t}\n\t}, [hasVoted, currentVote, key, loadResults, closed, resultsAwaitClose, finalized, voteStorage])\n\n\tconst handleSuccess = useCallback<NonNullable<FormProps['onSuccess']>>(\n\t\t(submissionId, result) => {\n\t\t\tvoteStorage.write(key)\n\t\t\tsetVoted(true)\n\t\t\tsetChanging(false)\n\t\t\tsetChangeReady(true)\n\t\t\t// Track the pick client-side so the \"your vote\" marker is fresh right after a (re-)vote,\n\t\t\t// without waiting for a server-resolved currentVote on the next page load.\n\t\t\tif (result?.values) {\n\t\t\t\tconst entry = result.values.find((row) => row.field === resultsField)\n\t\t\t\tsetPrefill(entry?.value)\n\t\t\t\tsetPick(answerValues(result.values, resultsField))\n\t\t\t}\n\t\t\tif (!resultsAwaitClose) {\n\t\t\t\tvoid loadResults()\n\t\t\t}\n\t\t\t// Forward the resolved success response so a Poll host gets the same onSuccess payload as a Form host.\n\t\t\tonSuccess?.(submissionId, result)\n\t\t},\n\t\t[key, loadResults, onSuccess, resultsAwaitClose, voteStorage, resultsField]\n\t)\n\n\tif (finalized) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--final\">\n\t\t\t\t<p className=\"fb-poll__final\">{translate(keys.pollFinalResult)}</p>\n\t\t\t\t{loadFailed ? (\n\t\t\t\t\tresultsError\n\t\t\t\t) : results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\twinningValues={winningValues}\n\t\t\t\t\t\tcurrentValues={pick}\n\t\t\t\t\t\tt={formProps.t}\n\t\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (closed) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--closed\">\n\t\t\t\t<p className=\"fb-poll__closed\">{translate(keys.pollClosed)}</p>\n\t\t\t\t{loadFailed ? (\n\t\t\t\t\tresultsError\n\t\t\t\t) : results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\tcurrentValues={pick}\n\t\t\t\t\t\tt={formProps.t}\n\t\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t\t/>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (voted && !changing) {\n\t\tif (resultsAwaitClose) {\n\t\t\treturn <p className=\"fb-poll__await-close\">{translate(keys.pollResultsAfterClose)}</p>\n\t\t}\n\t\tif (loadFailed) {\n\t\t\treturn resultsError\n\t\t}\n\t\treturn results ? (\n\t\t\t<div className=\"fb-poll fb-poll--voted\">\n\t\t\t\t<FormResults\n\t\t\t\t\tresults={results}\n\t\t\t\t\tcurrentValues={pick}\n\t\t\t\t\tt={formProps.t}\n\t\t\t\t\tlocale={formProps.locale}\n\t\t\t\t/>\n\t\t\t\t{allowChange && changeReady ? (\n\t\t\t\t\t<button className=\"fb-poll__change\" type=\"button\" onClick={() => setChanging(true)}>\n\t\t\t\t\t\t{translate(keys.pollChangeVote)}\n\t\t\t\t\t</button>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t) : null\n\t}\n\n\t// While changing, the current pick prefills the results field; the server identifies the\n\t// submission to update from the httpOnly voted cookie, so the client sends a normal submit.\n\tconst initialValues =\n\t\tchanging && prefill !== undefined\n\t\t\t? { ...(formProps.initialValues ?? {}), [resultsField]: prefill }\n\t\t\t: formProps.initialValues\n\treturn (\n\t\t<Form\n\t\t\t{...formProps}\n\t\t\tinitialValues={initialValues}\n\t\t\tapiRoute={apiRoute}\n\t\t\tonSuccess={handleSuccess}\n\t\t/>\n\t)\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"Poll.js","names":[],"sources":["../../src/react/Poll.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport { useCallback, useEffect, useMemo, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { answerValues } from '../poll/votes/answerValues'\nimport type { VotedSubmission } from '../submissions/resolveVotedSubmission'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { VoteStorage } from './adapters'\nimport { Form, type FormProps } from './Form'\nimport { FormResults, type FormResultsProps } from './FormResults'\nimport { type FetchResultsResult, fetchFormResults } from './fetchResults'\n\nexport type PollProps = FormProps & {\n\t/** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */\n\tresultsField: string\n\t/**\n\t * Key for the per-browser voted guard (localStorage by default; overridable or disabled via\n\t * `adapters.voteStorage`). Default `fb-poll-{form.id}`.\n\t */\n\tstorageKey?: string\n\t/**\n\t * Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;\n\t * `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`\n\t * option pass `hasVotedCookie(cookieHeader, form.id)` here.\n\t */\n\thasVoted?: boolean\n\t/**\n\t * Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module\n\t * scope, `useCallback`, or `useMemo`): an inline function re-runs the load effect and double-fetches.\n\t */\n\tfetchResultsImpl?: typeof fetchFormResults\n\t/**\n\t * The voter's server-resolved current vote (`resolveVotedSubmission`, read from the httpOnly voted\n\t * cookie). Presence implies voted; `pick` marks the voter's option in results and `value` prefills\n\t * the form when they change their vote. Read at mount: after a client-side change the component\n\t * tracks the new pick itself.\n\t */\n\tcurrentVote?: Pick<VotedSubmission, 'value' | 'pick'> | null\n\t/**\n\t * Replaces the built-in `<FormResults>` wherever the poll shows results (after voting, when\n\t * closed, and for a recorded outcome). Receives exactly what `<FormResults>` receives at that\n\t * site, so the default is `(props) => <FormResults {...props} />`; surrounding chrome (the\n\t * closed/final notices, the change-vote button) stays the poll's.\n\t */\n\trenderResults?: (props: FormResultsProps) => ReactNode\n}\n\nconst localStorageVoteStorage: VoteStorage = {\n\tread: (key) => {\n\t\ttry {\n\t\t\treturn window.localStorage.getItem(key) != null\n\t\t} catch {\n\t\t\treturn false\n\t\t}\n\t},\n\twrite: (key) => {\n\t\ttry {\n\t\t\twindow.localStorage.setItem(key, '1')\n\t\t} catch {\n\t\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t\t}\n\t},\n}\n\n/** `false` disables client persistence entirely (`hasVoted` still marks voted). */\nconst noopVoteStorage: VoteStorage = { read: () => false, write: () => {} }\n\nconst resolveVoteStorage = (configured: VoteStorage | false | undefined): VoteStorage => {\n\tif (configured === false) {\n\t\treturn noopVoteStorage\n\t}\n\treturn configured ?? localStorageVoteStorage\n}\n\n/**\n * A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows\n * `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated\n * notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open\n * `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A\n * recorded `outcome.winningValues` (via `resolvePollOutcome`) supersedes everything: a translated final\n * notice plus results with every winning bucket highlighted (a tie highlights more than one). A\n * per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`\n * (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not\n * integrity (bypassable): server-enforced\n * one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.\n */\nexport const Poll = ({\n\tresultsField,\n\tstorageKey,\n\thasVoted,\n\tfetchResultsImpl = fetchFormResults,\n\tapiRoute,\n\tonSuccess,\n\tcurrentVote,\n\trenderResults,\n\t...formProps\n}: PollProps) => {\n\tconst renderResultsView =\n\t\trenderResults ?? ((props: FormResultsProps): ReactNode => <FormResults {...props} />)\n\tconst key = storageKey ?? `fb-poll-${formProps.form.id}`\n\tconst poll = formProps.form.poll\n\tconst closed = isPollClosed(poll)\n\tconst allowChange = pollConfigOf(poll)?.allowChange === true\n\tconst winningValues = poll?.outcome?.winningValues\n\tconst finalized = Array.isArray(winningValues) && winningValues.length > 0\n\tconst resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === 'afterClose'\n\t// Server-known voted state renders the results view on the very first paint; the effect below\n\t// only adds the localStorage read.\n\tconst [voted, setVoted] = useState(hasVoted === true || currentVote != null)\n\tconst [changing, setChanging] = useState(false)\n\tconst [pick, setPick] = useState<string[] | undefined>(currentVote?.pick)\n\tconst [prefill, setPrefill] = useState<unknown>(currentVote?.value)\n\t// Changing is offered only when the signed cookie verifiably identifies a submission: at mount\n\t// via a resolved `currentVote`, or after an in-session vote (the server just issued the cookie).\n\t// A localStorage-only voted flag proves nothing about the cookie, and a \"change\" without one\n\t// would create a second submission instead of updating.\n\tconst [changeReady, setChangeReady] = useState(currentVote != null)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\tconst [loadFailed, setLoadFailed] = useState(false)\n\tconst translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t])\n\tconst configuredVoteStorage = formProps.adapters?.voteStorage\n\tconst voteStorage = useMemo(\n\t\t() => resolveVoteStorage(configuredVoteStorage),\n\t\t[configuredVoteStorage]\n\t)\n\n\tconst loadResults = useCallback(async () => {\n\t\tconst result: FetchResultsResult = await fetchResultsImpl({\n\t\t\tformId: formProps.form.id,\n\t\t\tfield: resultsField,\n\t\t\tapiRoute,\n\t\t})\n\t\t// A failed load surfaces as an error, not an empty result set: `[]` would read as \"no votes yet\".\n\t\tif (result.ok) {\n\t\t\tsetResults(result.results)\n\t\t\tsetLoadFailed(false)\n\t\t} else {\n\t\t\tsetLoadFailed(true)\n\t\t}\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tconst resultsError = (\n\t\t<p className=\"fb-poll__error\" role=\"alert\">\n\t\t\t{translate(keys.pollResultsError)}\n\t\t</p>\n\t)\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted === true || currentVote != null || voteStorage.read(key)\n\t\tif (already) {\n\t\t\tsetVoted(true)\n\t\t}\n\t\tif ((already && !resultsAwaitClose) || closed || finalized) {\n\t\t\tvoid loadResults()\n\t\t}\n\t}, [hasVoted, currentVote, key, loadResults, closed, resultsAwaitClose, finalized, voteStorage])\n\n\tconst handleSuccess = useCallback<NonNullable<FormProps['onSuccess']>>(\n\t\t(submissionId, result) => {\n\t\t\tvoteStorage.write(key)\n\t\t\tsetVoted(true)\n\t\t\tsetChanging(false)\n\t\t\tsetChangeReady(true)\n\t\t\t// Track the pick client-side so the \"your vote\" marker is fresh right after a (re-)vote,\n\t\t\t// without waiting for a server-resolved currentVote on the next page load.\n\t\t\tif (result?.values) {\n\t\t\t\tconst entry = result.values.find((row) => row.field === resultsField)\n\t\t\t\tsetPrefill(entry?.value)\n\t\t\t\tsetPick(answerValues(result.values, resultsField))\n\t\t\t}\n\t\t\tif (!resultsAwaitClose) {\n\t\t\t\tvoid loadResults()\n\t\t\t}\n\t\t\t// Forward the resolved success response so a Poll host gets the same onSuccess payload as a Form host.\n\t\t\tonSuccess?.(submissionId, result)\n\t\t},\n\t\t[key, loadResults, onSuccess, resultsAwaitClose, voteStorage, resultsField]\n\t)\n\n\tif (finalized) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--final\">\n\t\t\t\t<p className=\"fb-poll__final\">{translate(keys.pollFinalResult)}</p>\n\t\t\t\t{loadFailed\n\t\t\t\t\t? resultsError\n\t\t\t\t\t: results\n\t\t\t\t\t\t? renderResultsView({\n\t\t\t\t\t\t\t\tresults,\n\t\t\t\t\t\t\t\twinningValues,\n\t\t\t\t\t\t\t\tcurrentValues: pick,\n\t\t\t\t\t\t\t\tt: formProps.t,\n\t\t\t\t\t\t\t\tlocale: formProps.locale,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (closed) {\n\t\treturn (\n\t\t\t<div className=\"fb-poll fb-poll--closed\">\n\t\t\t\t<p className=\"fb-poll__closed\">{translate(keys.pollClosed)}</p>\n\t\t\t\t{loadFailed\n\t\t\t\t\t? resultsError\n\t\t\t\t\t: results\n\t\t\t\t\t\t? renderResultsView({\n\t\t\t\t\t\t\t\tresults,\n\t\t\t\t\t\t\t\tcurrentValues: pick,\n\t\t\t\t\t\t\t\tt: formProps.t,\n\t\t\t\t\t\t\t\tlocale: formProps.locale,\n\t\t\t\t\t\t\t})\n\t\t\t\t\t\t: null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (voted && !changing) {\n\t\tif (resultsAwaitClose) {\n\t\t\treturn <p className=\"fb-poll__await-close\">{translate(keys.pollResultsAfterClose)}</p>\n\t\t}\n\t\tif (loadFailed) {\n\t\t\treturn resultsError\n\t\t}\n\t\treturn results ? (\n\t\t\t<div className=\"fb-poll fb-poll--voted\">\n\t\t\t\t{renderResultsView({\n\t\t\t\t\tresults,\n\t\t\t\t\tcurrentValues: pick,\n\t\t\t\t\tt: formProps.t,\n\t\t\t\t\tlocale: formProps.locale,\n\t\t\t\t})}\n\t\t\t\t{allowChange && changeReady ? (\n\t\t\t\t\t<button className=\"fb-poll__change\" type=\"button\" onClick={() => setChanging(true)}>\n\t\t\t\t\t\t{translate(keys.pollChangeVote)}\n\t\t\t\t\t</button>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t) : null\n\t}\n\n\t// While changing, the current pick prefills the results field; the server identifies the\n\t// submission to update from the httpOnly voted cookie, so the client sends a normal submit.\n\tconst initialValues =\n\t\tchanging && prefill !== undefined\n\t\t\t? { ...(formProps.initialValues ?? {}), [resultsField]: prefill }\n\t\t\t: formProps.initialValues\n\treturn (\n\t\t<Form\n\t\t\t{...formProps}\n\t\t\tinitialValues={initialValues}\n\t\t\tapiRoute={apiRoute}\n\t\t\tonSuccess={handleSuccess}\n\t\t/>\n\t)\n}\n"],"mappings":";;;;;;;;;;;;AAmDA,MAAM,0BAAuC;CAC5C,OAAO,QAAQ;EACd,IAAI;GACH,OAAO,OAAO,aAAa,QAAQ,GAAG,KAAK;EAC5C,QAAQ;GACP,OAAO;EACR;CACD;CACA,QAAQ,QAAQ;EACf,IAAI;GACH,OAAO,aAAa,QAAQ,KAAK,GAAG;EACrC,QAAQ,CAER;CACD;AACD;;AAGA,MAAM,kBAA+B;CAAE,YAAY;CAAO,aAAa,CAAC;AAAE;AAE1E,MAAM,sBAAsB,eAA6D;CACxF,IAAI,eAAe,OAClB,OAAO;CAER,OAAO,cAAc;AACtB;;;;;;;;;;;;;AAcA,MAAa,QAAQ,EACpB,cACA,YACA,UACA,mBAAmB,kBACnB,UACA,WACA,aACA,eACA,GAAG,gBACa;CAChB,MAAM,oBACL,mBAAmB,UAAuC,oBAAC,aAAD,EAAa,GAAI,MAAQ,CAAA;CACpF,MAAM,MAAM,cAAc,WAAW,UAAU,KAAK;CACpD,MAAM,OAAO,UAAU,KAAK;CAC5B,MAAM,SAAS,aAAa,IAAI;CAChC,MAAM,cAAc,aAAa,IAAI,GAAG,gBAAgB;CACxD,MAAM,gBAAgB,MAAM,SAAS;CACrC,MAAM,YAAY,MAAM,QAAQ,aAAa,KAAK,cAAc,SAAS;CACzE,MAAM,oBAAoB,CAAC,UAAU,CAAC,aAAa,MAAM,sBAAsB;CAG/E,MAAM,CAAC,OAAO,YAAY,SAAS,aAAa,QAAQ,eAAe,IAAI;CAC3E,MAAM,CAAC,UAAU,eAAe,SAAS,KAAK;CAC9C,MAAM,CAAC,MAAM,WAAW,SAA+B,aAAa,IAAI;CACxE,MAAM,CAAC,SAAS,cAAc,SAAkB,aAAa,KAAK;CAKlE,MAAM,CAAC,aAAa,kBAAkB,SAAS,eAAe,IAAI;CAClE,MAAM,CAAC,SAAS,cAAc,SAAoC,IAAI;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAAS,KAAK;CAClD,MAAM,YAAY,cAAc,UAAU,KAAK,cAAc,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;CAC/E,MAAM,wBAAwB,UAAU,UAAU;CAClD,MAAM,cAAc,cACb,mBAAmB,qBAAqB,GAC9C,CAAC,qBAAqB,CACvB;CAEA,MAAM,cAAc,YAAY,YAAY;EAC3C,MAAM,SAA6B,MAAM,iBAAiB;GACzD,QAAQ,UAAU,KAAK;GACvB,OAAO;GACP;EACD,CAAC;EAED,IAAI,OAAO,IAAI;GACd,WAAW,OAAO,OAAO;GACzB,cAAc,KAAK;EACpB,OACC,cAAc,IAAI;CAEpB,GAAG;EAAC;EAAkB,UAAU,KAAK;EAAI;EAAc;CAAQ,CAAC;CAEhE,MAAM,eACL,oBAAC,KAAD;EAAG,WAAU;EAAiB,MAAK;YACjC,UAAU,KAAK,gBAAgB;CAC9B,CAAA;CAGJ,gBAAgB;EACf,MAAM,UAAU,aAAa,QAAQ,eAAe,QAAQ,YAAY,KAAK,GAAG;EAChF,IAAI,SACH,SAAS,IAAI;EAEd,IAAK,WAAW,CAAC,qBAAsB,UAAU,WAChD,YAAiB;CAEnB,GAAG;EAAC;EAAU;EAAa;EAAK;EAAa;EAAQ;EAAmB;EAAW;CAAW,CAAC;CAE/F,MAAM,gBAAgB,aACpB,cAAc,WAAW;EACzB,YAAY,MAAM,GAAG;EACrB,SAAS,IAAI;EACb,YAAY,KAAK;EACjB,eAAe,IAAI;EAGnB,IAAI,QAAQ,QAAQ;GAEnB,WADc,OAAO,OAAO,MAAM,QAAQ,IAAI,UAAU,YACzC,GAAG,KAAK;GACvB,QAAQ,aAAa,OAAO,QAAQ,YAAY,CAAC;EAClD;EACA,IAAI,CAAC,mBACJ,YAAiB;EAGlB,YAAY,cAAc,MAAM;CACjC,GACA;EAAC;EAAK;EAAa;EAAW;EAAmB;EAAa;CAAY,CAC3E;CAEA,IAAI,WACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAkB,UAAU,KAAK,eAAe;EAAK,CAAA,GACjE,aACE,eACA,UACC,kBAAkB;GAClB;GACA;GACA,eAAe;GACf,GAAG,UAAU;GACb,QAAQ,UAAU;EACnB,CAAC,IACA,IACA;;CAIP,IAAI,QACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAmB,UAAU,KAAK,UAAU;EAAK,CAAA,GAC7D,aACE,eACA,UACC,kBAAkB;GAClB;GACA,eAAe;GACf,GAAG,UAAU;GACb,QAAQ,UAAU;EACnB,CAAC,IACA,IACA;;CAIP,IAAI,SAAS,CAAC,UAAU;EACvB,IAAI,mBACH,OAAO,oBAAC,KAAD;GAAG,WAAU;aAAwB,UAAU,KAAK,qBAAqB;EAAK,CAAA;EAEtF,IAAI,YACH,OAAO;EAER,OAAO,UACN,qBAAC,OAAD;GAAK,WAAU;aAAf,CACE,kBAAkB;IAClB;IACA,eAAe;IACf,GAAG,UAAU;IACb,QAAQ,UAAU;GACnB,CAAC,GACA,eAAe,cACf,oBAAC,UAAD;IAAQ,WAAU;IAAkB,MAAK;IAAS,eAAe,YAAY,IAAI;cAC/E,UAAU,KAAK,cAAc;GACvB,CAAA,IACL,IACA;OACF;CACL;CAIA,MAAM,gBACL,YAAY,YAAY,KAAA,IACrB;EAAE,GAAI,UAAU,iBAAiB,CAAC;GAAK,eAAe;CAAQ,IAC9D,UAAU;CACd,OACC,oBAAC,MAAD;EACC,GAAI;EACW;EACL;EACV,WAAW;CACX,CAAA;AAEH"}
@@ -5,6 +5,8 @@ import { APIError, addDataAndFileToRequest, addLocalesToRequestFromData } from "
5
5
  //#region src/submissions/voteChangeEndpoint.ts
6
6
  const FORM_SUBMISSIONS_SLUG = "form-submissions";
7
7
  const FORMS_SLUG = "forms";
8
+ /** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */
9
+ const VOTE_SUBMIT_ENDPOINT_TAG = "vote-submit";
8
10
  const isComplete = (doc) => doc.status == null || doc.status === "complete";
9
11
  /**
10
12
  * The submission a cookie-identified re-vote should update, or null when the request must fall
@@ -60,8 +62,12 @@ const buildVoteSubmitEndpoint = () => {
60
62
  });
61
63
  if (!target) {
62
64
  const endpoints = req.payload.collections[FORM_SUBMISSIONS_SLUG]?.config.endpoints;
63
- const stockCreate = (Array.isArray(endpoints) ? endpoints : []).find((endpoint) => endpoint.method === "post" && endpoint.path === "/" && endpoint.handler !== handler);
65
+ const stockCreate = (Array.isArray(endpoints) ? endpoints : []).find((endpoint) => endpoint.method === "post" && endpoint.path === "/" && endpoint.custom?.formBuilder !== "vote-submit");
64
66
  if (!stockCreate) throw new APIError("form-builder: stock create endpoint not found", 500);
67
+ if (req.body) Object.defineProperty(req, "body", {
68
+ configurable: true,
69
+ value: null
70
+ });
65
71
  return stockCreate.handler(req);
66
72
  }
67
73
  req.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId;
@@ -85,7 +91,7 @@ const buildVoteSubmitEndpoint = () => {
85
91
  path: "/",
86
92
  method: "post",
87
93
  handler,
88
- custom: { formBuilder: "vote-submit" }
94
+ custom: { formBuilder: VOTE_SUBMIT_ENDPOINT_TAG }
89
95
  };
90
96
  };
91
97
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"voteChangeEndpoint.js","names":[],"sources":["../../src/submissions/voteChangeEndpoint.ts"],"sourcesContent":["import {\n\tAPIError,\n\taddDataAndFileToRequest,\n\taddLocalesToRequestFromData,\n\ttype Endpoint,\n\ttype PayloadRequest,\n} from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { formIdOf } from './formIdOf'\nimport { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from './votedCookie'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\n\nconst isComplete = (doc: { status?: unknown }): boolean =>\n\tdoc.status == null || doc.status === 'complete'\n\n/**\n * The submission a cookie-identified re-vote should update, or null when the request must fall\n * through to a plain create. Guards run cheapest-first: no valid signed cookie for the posted form\n * means no form fetch at all, so the delegation overhead on ordinary submissions is one header\n * parse. A token is honored only when the form is an `allowChange` poll and it names a still\n * existing, complete submission of that same form; anything else (pruned row, cross-form replay,\n * legacy `1` marker, tampering) makes the caller a new voter rather than an error.\n */\nexport const resolveVoteChangeTarget = async (args: {\n\treq: PayloadRequest\n\tformId: number | string | undefined | null\n}): Promise<{ submissionId: number | string } | null> => {\n\tconst { req, formId } = args\n\tif (formId == null) {\n\t\treturn null\n\t}\n\tconst submissionId = votedSubmissionIdFromCookie(\n\t\treq.headers?.get('cookie'),\n\t\tformId,\n\t\treq.payload.secret\n\t)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst form = await req.payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) {\n\t\treturn null\n\t}\n\tconst submission = await req.payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (submission == null) {\n\t\treturn null\n\t}\n\tconst stored = submission as { form?: unknown; status?: unknown }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId) || !isComplete(stored)) {\n\t\treturn null\n\t}\n\treturn { submissionId: submission.id as number | string }\n}\n\n/**\n * Custom root `POST /form-submissions` endpoint. Payload matches a collection's custom endpoints\n * ahead of its built-in REST routes, so this handler sees every REST create first: when the posted\n * form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns\n * the request into an in-place update (create-grade hooks opt in via the context flag); otherwise\n * it delegates to the stock create handler found in the same sanitized endpoint list, keeping the\n * default path semantics Payload's, not a reimplementation. The update runs `overrideAccess`\n * because the collection is deliberately update-closed to every API caller; the signed cookie is\n * the credential here, verified above rather than by collection access.\n */\nexport const buildVoteSubmitEndpoint = (): Endpoint => {\n\tconst handler = async (req: PayloadRequest): Promise<Response> => {\n\t\tawait addDataAndFileToRequest(req)\n\t\taddLocalesToRequestFromData(req)\n\t\tconst data = (req.data ?? {}) as {\n\t\t\tform?: number | string\n\t\t\tvalues?: unknown\n\t\t}\n\t\tconst target = await resolveVoteChangeTarget({ req, formId: data.form })\n\t\tif (!target) {\n\t\t\tconst endpoints = req.payload.collections[FORM_SUBMISSIONS_SLUG]?.config.endpoints\n\t\t\tconst registered: Endpoint[] = Array.isArray(endpoints) ? endpoints : []\n\t\t\tconst stockCreate = registered.find(\n\t\t\t\t(endpoint) =>\n\t\t\t\t\tendpoint.method === 'post' && endpoint.path === '/' && endpoint.handler !== handler\n\t\t\t)\n\t\t\tif (!stockCreate) {\n\t\t\t\tthrow new APIError('form-builder: stock create endpoint not found', 500)\n\t\t\t}\n\t\t\treturn stockCreate.handler(req)\n\t\t}\n\t\treq.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId\n\t\t// Slug-agnostic cast (the `createSubmission` idiom): a host's generated types pin the\n\t\t// runtime-registered collection's `form` id flavor and `values` JSON shape, which this\n\t\t// framework-level write cannot know. Bound, because `update` is a prototype method.\n\t\tconst update = req.payload.update.bind(req.payload) as unknown as (options: {\n\t\t\tcollection: string\n\t\t\tid: number | string\n\t\t\tdata: { form?: number | string; values?: unknown }\n\t\t\tdepth?: number\n\t\t\toverrideAccess?: boolean\n\t\t\treq?: PayloadRequest\n\t\t}) => Promise<unknown>\n\t\tconst doc = await update({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: target.submissionId,\n\t\t\tdata: { form: data.form, values: data.values },\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\treturn Response.json({ doc, message: req.t('general:updatedSuccessfully') }, { status: 200 })\n\t}\n\treturn { path: '/', method: 'post', handler, custom: { formBuilder: 'vote-submit' } }\n}\n"],"mappings":";;;;;AAWA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;AAEnB,MAAM,cAAc,QACnB,IAAI,UAAU,QAAQ,IAAI,WAAW;;;;;;;;;AAUtC,MAAa,0BAA0B,OAAO,SAGW;CACxD,MAAM,EAAE,KAAK,WAAW;CACxB,IAAI,UAAU,MACb,OAAO;CAER,MAAM,eAAe,4BACpB,IAAI,SAAS,IAAI,QAAQ,GACzB,QACA,IAAI,QAAQ,MACb;CACA,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,OAAO,MAAM,IAAI,QACrB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,MAAM,gBAAgB,QAAQ,aAAa,KAAK,IAAI,GAAG,gBAAgB,MAC1E,OAAO;CAER,MAAM,aAAa,MAAM,IAAI,QAC3B,SAAS;EACT,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,KAAK,CAAC,WAAW,MAAM,GAC/E,OAAO;CAER,OAAO,EAAE,cAAc,WAAW,GAAsB;AACzD;;;;;;;;;;;AAYA,MAAa,gCAA0C;CACtD,MAAM,UAAU,OAAO,QAA2C;EACjE,MAAM,wBAAwB,GAAG;EACjC,4BAA4B,GAAG;EAC/B,MAAM,OAAQ,IAAI,QAAQ,CAAC;EAI3B,MAAM,SAAS,MAAM,wBAAwB;GAAE;GAAK,QAAQ,KAAK;EAAK,CAAC;EACvE,IAAI,CAAC,QAAQ;GACZ,MAAM,YAAY,IAAI,QAAQ,YAAY,wBAAwB,OAAO;GAEzE,MAAM,eADyB,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC,GACxC,MAC7B,aACA,SAAS,WAAW,UAAU,SAAS,SAAS,OAAO,SAAS,YAAY,OAC9E;GACA,IAAI,CAAC,aACJ,MAAM,IAAI,SAAS,iDAAiD,GAAG;GAExE,OAAO,YAAY,QAAQ,GAAG;EAC/B;EACA,IAAI,QAAQ,2BAA2B,OAAO;EAY9C,MAAM,MAAM,MARG,IAAI,QAAQ,OAAO,KAAK,IAAI,OAQpB,EAAE;GACxB,YAAY;GACZ,IAAI,OAAO;GACX,MAAM;IAAE,MAAM,KAAK;IAAM,QAAQ,KAAK;GAAO;GAC7C,OAAO;GACP,gBAAgB;GAChB;EACD,CAAC;EACD,OAAO,SAAS,KAAK;GAAE;GAAK,SAAS,IAAI,EAAE,6BAA6B;EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;CAC7F;CACA,OAAO;EAAE,MAAM;EAAK,QAAQ;EAAQ;EAAS,QAAQ,EAAE,aAAa,cAAc;CAAE;AACrF"}
1
+ {"version":3,"file":"voteChangeEndpoint.js","names":[],"sources":["../../src/submissions/voteChangeEndpoint.ts"],"sourcesContent":["import {\n\tAPIError,\n\taddDataAndFileToRequest,\n\taddLocalesToRequestFromData,\n\ttype Endpoint,\n\ttype PayloadRequest,\n} from 'payload'\nimport { pollConfigOf } from '../form/pollState'\nimport { formIdOf } from './formIdOf'\nimport { VOTE_CHANGE_CONTEXT_KEY, votedSubmissionIdFromCookie } from './votedCookie'\n\nconst FORM_SUBMISSIONS_SLUG = 'form-submissions'\nconst FORMS_SLUG = 'forms'\n\n/** Marks the vote-submit endpoint in the sanitized endpoint list (`custom.formBuilder`). */\nexport const VOTE_SUBMIT_ENDPOINT_TAG = 'vote-submit'\n\nconst isComplete = (doc: { status?: unknown }): boolean =>\n\tdoc.status == null || doc.status === 'complete'\n\n/**\n * The submission a cookie-identified re-vote should update, or null when the request must fall\n * through to a plain create. Guards run cheapest-first: no valid signed cookie for the posted form\n * means no form fetch at all, so the delegation overhead on ordinary submissions is one header\n * parse. A token is honored only when the form is an `allowChange` poll and it names a still\n * existing, complete submission of that same form; anything else (pruned row, cross-form replay,\n * legacy `1` marker, tampering) makes the caller a new voter rather than an error.\n */\nexport const resolveVoteChangeTarget = async (args: {\n\treq: PayloadRequest\n\tformId: number | string | undefined | null\n}): Promise<{ submissionId: number | string } | null> => {\n\tconst { req, formId } = args\n\tif (formId == null) {\n\t\treturn null\n\t}\n\tconst submissionId = votedSubmissionIdFromCookie(\n\t\treq.headers?.get('cookie'),\n\t\tformId,\n\t\treq.payload.secret\n\t)\n\tif (submissionId == null) {\n\t\treturn null\n\t}\n\tconst form = await req.payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (form?.pollEnabled !== true || pollConfigOf(form.poll)?.allowChange !== true) {\n\t\treturn null\n\t}\n\tconst submission = await req.payload\n\t\t.findByID({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: submissionId,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\t.catch(() => null)\n\tif (submission == null) {\n\t\treturn null\n\t}\n\tconst stored = submission as { form?: unknown; status?: unknown }\n\tif (String(formIdOf(stored.form) ?? '') !== String(formId) || !isComplete(stored)) {\n\t\treturn null\n\t}\n\treturn { submissionId: submission.id as number | string }\n}\n\n/**\n * Custom root `POST /form-submissions` endpoint. Payload matches a collection's custom endpoints\n * ahead of its built-in REST routes, so this handler sees every REST create first: when the posted\n * form is an `allowChange` poll and the voted cookie identifies the caller's submission, it turns\n * the request into an in-place update (create-grade hooks opt in via the context flag); otherwise\n * it delegates to the stock create handler found in the same sanitized endpoint list, keeping the\n * default path semantics Payload's, not a reimplementation. The update runs `overrideAccess`\n * because the collection is deliberately update-closed to every API caller; the signed cookie is\n * the credential here, verified above rather than by collection access.\n */\nexport const buildVoteSubmitEndpoint = (): Endpoint => {\n\tconst handler = async (req: PayloadRequest): Promise<Response> => {\n\t\tawait addDataAndFileToRequest(req)\n\t\taddLocalesToRequestFromData(req)\n\t\tconst data = (req.data ?? {}) as {\n\t\t\tform?: number | string\n\t\t\tvalues?: unknown\n\t\t}\n\t\tconst target = await resolveVoteChangeTarget({ req, formId: data.form })\n\t\tif (!target) {\n\t\t\tconst endpoints = req.payload.collections[FORM_SUBMISSIONS_SLUG]?.config.endpoints\n\t\t\tconst registered: Endpoint[] = Array.isArray(endpoints) ? endpoints : []\n\t\t\t// Next root-POST match excluding our own tag (never handler identity: a host-wrapped\n\t\t\t// handler would find itself and recurse). First-match mirrors handleEndpoints routing,\n\t\t\t// so another plugin's interceptor still wins exactly as it would without us.\n\t\t\tconst stockCreate = registered.find(\n\t\t\t\t(endpoint) =>\n\t\t\t\t\tendpoint.method === 'post' &&\n\t\t\t\t\tendpoint.path === '/' &&\n\t\t\t\t\tendpoint.custom?.formBuilder !== VOTE_SUBMIT_ENDPOINT_TAG\n\t\t\t)\n\t\t\tif (!stockCreate) {\n\t\t\t\tthrow new APIError('form-builder: stock create endpoint not found', 500)\n\t\t\t}\n\t\t\t// A consumed fetch body stays non-null, so Payload's addDataAndFileToRequest wrapper\n\t\t\t// would re-read it and 500; req.data/req.file are already populated, so hide the spent\n\t\t\t// stream (defineProperty: `body` is a getter-only prototype accessor).\n\t\t\tif (req.body) {\n\t\t\t\tObject.defineProperty(req, 'body', { configurable: true, value: null })\n\t\t\t}\n\t\t\treturn stockCreate.handler(req)\n\t\t}\n\t\treq.context[VOTE_CHANGE_CONTEXT_KEY] = target.submissionId\n\t\t// Slug-agnostic cast (the `createSubmission` idiom): a host's generated types pin the\n\t\t// runtime-registered collection's `form` id flavor and `values` JSON shape, which this\n\t\t// framework-level write cannot know. Bound, because `update` is a prototype method.\n\t\tconst update = req.payload.update.bind(req.payload) as unknown as (options: {\n\t\t\tcollection: string\n\t\t\tid: number | string\n\t\t\tdata: { form?: number | string; values?: unknown }\n\t\t\tdepth?: number\n\t\t\toverrideAccess?: boolean\n\t\t\treq?: PayloadRequest\n\t\t}) => Promise<unknown>\n\t\tconst doc = await update({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\tid: target.submissionId,\n\t\t\tdata: { form: data.form, values: data.values },\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t})\n\t\treturn Response.json({ doc, message: req.t('general:updatedSuccessfully') }, { status: 200 })\n\t}\n\treturn { path: '/', method: 'post', handler, custom: { formBuilder: VOTE_SUBMIT_ENDPOINT_TAG } }\n}\n"],"mappings":";;;;;AAWA,MAAM,wBAAwB;AAC9B,MAAM,aAAa;;AAGnB,MAAa,2BAA2B;AAExC,MAAM,cAAc,QACnB,IAAI,UAAU,QAAQ,IAAI,WAAW;;;;;;;;;AAUtC,MAAa,0BAA0B,OAAO,SAGW;CACxD,MAAM,EAAE,KAAK,WAAW;CACxB,IAAI,UAAU,MACb,OAAO;CAER,MAAM,eAAe,4BACpB,IAAI,SAAS,IAAI,QAAQ,GACzB,QACA,IAAI,QAAQ,MACb;CACA,IAAI,gBAAgB,MACnB,OAAO;CAER,MAAM,OAAO,MAAM,IAAI,QACrB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,MAAM,gBAAgB,QAAQ,aAAa,KAAK,IAAI,GAAG,gBAAgB,MAC1E,OAAO;CAER,MAAM,aAAa,MAAM,IAAI,QAC3B,SAAS;EACT,YAAY;EACZ,IAAI;EACJ,OAAO;EACP,gBAAgB;EAChB;CACD,CAAC,EACA,YAAY,IAAI;CAClB,IAAI,cAAc,MACjB,OAAO;CAER,MAAM,SAAS;CACf,IAAI,OAAO,SAAS,OAAO,IAAI,KAAK,EAAE,MAAM,OAAO,MAAM,KAAK,CAAC,WAAW,MAAM,GAC/E,OAAO;CAER,OAAO,EAAE,cAAc,WAAW,GAAsB;AACzD;;;;;;;;;;;AAYA,MAAa,gCAA0C;CACtD,MAAM,UAAU,OAAO,QAA2C;EACjE,MAAM,wBAAwB,GAAG;EACjC,4BAA4B,GAAG;EAC/B,MAAM,OAAQ,IAAI,QAAQ,CAAC;EAI3B,MAAM,SAAS,MAAM,wBAAwB;GAAE;GAAK,QAAQ,KAAK;EAAK,CAAC;EACvE,IAAI,CAAC,QAAQ;GACZ,MAAM,YAAY,IAAI,QAAQ,YAAY,wBAAwB,OAAO;GAKzE,MAAM,eAJyB,MAAM,QAAQ,SAAS,IAAI,YAAY,CAAC,GAIxC,MAC7B,aACA,SAAS,WAAW,UACpB,SAAS,SAAS,OAClB,SAAS,QAAQ,gBAAA,aACnB;GACA,IAAI,CAAC,aACJ,MAAM,IAAI,SAAS,iDAAiD,GAAG;GAKxE,IAAI,IAAI,MACP,OAAO,eAAe,KAAK,QAAQ;IAAE,cAAc;IAAM,OAAO;GAAK,CAAC;GAEvE,OAAO,YAAY,QAAQ,GAAG;EAC/B;EACA,IAAI,QAAQ,2BAA2B,OAAO;EAY9C,MAAM,MAAM,MARG,IAAI,QAAQ,OAAO,KAAK,IAAI,OAQpB,EAAE;GACxB,YAAY;GACZ,IAAI,OAAO;GACX,MAAM;IAAE,MAAM,KAAK;IAAM,QAAQ,KAAK;GAAO;GAC7C,OAAO;GACP,gBAAgB;GAChB;EACD,CAAC;EACD,OAAO,SAAS,KAAK;GAAE;GAAK,SAAS,IAAI,EAAE,6BAA6B;EAAE,GAAG,EAAE,QAAQ,IAAI,CAAC;CAC7F;CACA,OAAO;EAAE,MAAM;EAAK,QAAQ;EAAQ;EAAS,QAAQ,EAAE,aAAa,yBAAyB;CAAE;AAChG"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@10x-media/form-builder",
3
- "version": "0.1.0-beta.15",
3
+ "version": "0.1.0-beta.17",
4
4
  "description": "End-to-end forms platform for Payload: author, validate, render, collect, aggregate, and act.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -99,8 +99,8 @@
99
99
  "tsdown": "0.22.1",
100
100
  "typescript": "5.9.3",
101
101
  "vitest": "4.1.7",
102
- "@10x-media/payload-test-harness": "0.0.0",
103
102
  "@10x-media/tsconfig": "0.0.0",
103
+ "@10x-media/payload-test-harness": "0.0.0",
104
104
  "@10x-media/vitest-config": "0.0.0",
105
105
  "@10x-media/tsdown-config": "0.0.0"
106
106
  },