@10x-media/form-builder 0.1.0-beta.16 → 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 +6 -0
- package/dist/react/Poll.d.ts +10 -0
- package/dist/react/Poll.js +5 -4
- package/dist/react/Poll.js.map +1 -1
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.1.0-beta.16
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/react/Poll.d.ts
CHANGED
|
@@ -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
|
package/dist/react/Poll.js
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
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: [
|
|
154
|
+
children: [renderResultsView({
|
|
154
155
|
results,
|
|
155
156
|
currentValues: pick,
|
|
156
157
|
t: formProps.t,
|
package/dist/react/Poll.js.map
CHANGED
|
@@ -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":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@10x-media/form-builder",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
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,10 +99,10 @@
|
|
|
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
|
-
"@10x-media/tsdown-config": "0.0.0",
|
|
104
102
|
"@10x-media/tsconfig": "0.0.0",
|
|
105
|
-
"@10x-media/
|
|
103
|
+
"@10x-media/payload-test-harness": "0.0.0",
|
|
104
|
+
"@10x-media/vitest-config": "0.0.0",
|
|
105
|
+
"@10x-media/tsdown-config": "0.0.0"
|
|
106
106
|
},
|
|
107
107
|
"publishConfig": {
|
|
108
108
|
"access": "public"
|