@10x-media/form-builder 0.1.0-beta.3 → 0.1.0-beta.4
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 +42 -0
- package/README.md +2 -2
- package/dist/actions/body/serializeBody.d.ts +19 -4
- package/dist/actions/body/serializeBody.js +3 -1
- package/dist/actions/body/serializeBody.js.map +1 -1
- package/dist/actions/builtin/confirmation.js +13 -4
- package/dist/actions/builtin/confirmation.js.map +1 -1
- package/dist/actions/builtin/emailTeam.js +13 -4
- package/dist/actions/builtin/emailTeam.js.map +1 -1
- package/dist/actions/builtin/index.d.ts +13 -1
- package/dist/actions/builtin/index.js +18 -7
- package/dist/actions/builtin/index.js.map +1 -1
- package/dist/actions/builtin/signedWebhook.js +2 -16
- package/dist/actions/builtin/signedWebhook.js.map +1 -1
- package/dist/actions/runActions.js +2 -0
- package/dist/actions/runActions.js.map +1 -1
- package/dist/aggregation/resolveResultsRequest.d.ts +22 -6
- package/dist/aggregation/resolveResultsRequest.js +18 -7
- package/dist/aggregation/resolveResultsRequest.js.map +1 -1
- package/dist/client/FlowBuilder.js +98 -53
- package/dist/client/FlowBuilder.js.map +1 -1
- package/dist/client/admin.css +47 -5
- package/dist/client/flowAuthoring.js +35 -0
- package/dist/client/flowAuthoring.js.map +1 -0
- package/dist/client/synthesizeClientField.js +6 -2
- package/dist/client/synthesizeClientField.js.map +1 -1
- package/dist/collections/formSubmissions.js +47 -9
- package/dist/collections/formSubmissions.js.map +1 -1
- package/dist/collections/forms.js +180 -34
- package/dist/collections/forms.js.map +1 -1
- package/dist/conditions/conditionType.js +9 -2
- package/dist/conditions/conditionType.js.map +1 -1
- package/dist/conditions/normalizeConditions.js +2 -1
- package/dist/conditions/normalizeConditions.js.map +1 -1
- package/dist/consent/builtin/index.d.ts +11 -1
- package/dist/consent/builtin/index.js +15 -5
- package/dist/consent/builtin/index.js.map +1 -1
- package/dist/consent/builtin/static.js +7 -3
- package/dist/consent/builtin/static.js.map +1 -1
- package/dist/exports/react.d.ts +10 -6
- package/dist/exports/react.js +6 -2
- package/dist/exports/rsc.d.ts +6 -1
- package/dist/exports/rsc.js +6 -1
- package/dist/exports/types.d.ts +4 -4
- package/dist/fields/buildFieldBlocks.js +17 -8
- package/dist/fields/buildFieldBlocks.js.map +1 -1
- package/dist/fields/builtin/consent.js +8 -4
- package/dist/fields/builtin/consent.js.map +1 -1
- package/dist/fields/builtin/file.js +8 -4
- package/dist/fields/builtin/file.js.map +1 -1
- package/dist/fields/builtin/index.d.ts +11 -1
- package/dist/fields/builtin/index.js +21 -9
- package/dist/fields/builtin/index.js.map +1 -1
- package/dist/fields/builtin/message.js +26 -0
- package/dist/fields/builtin/message.js.map +1 -0
- package/dist/fields/builtin/repeater.js +7 -3
- package/dist/fields/builtin/repeater.js.map +1 -1
- package/dist/fields/builtin/select.js +7 -3
- package/dist/fields/builtin/select.js.map +1 -1
- package/dist/fields/localizedIf.d.ts +13 -0
- package/dist/fields/localizedIf.js +12 -0
- package/dist/fields/localizedIf.js.map +1 -0
- package/dist/fields/sharedConfig.js +64 -51
- package/dist/fields/sharedConfig.js.map +1 -1
- package/dist/fields/types.d.ts +7 -2
- package/dist/flow/normalizeFlow.js +7 -1
- package/dist/flow/normalizeFlow.js.map +1 -1
- package/dist/flow/stepLabel.js +15 -0
- package/dist/flow/stepLabel.js.map +1 -0
- package/dist/form/pollState.d.ts +22 -0
- package/dist/form/pollState.js +17 -0
- package/dist/form/pollState.js.map +1 -0
- package/dist/form/toFormDocument.d.ts +60 -0
- package/dist/form/toFormDocument.js +39 -0
- package/dist/form/toFormDocument.js.map +1 -0
- package/dist/form/types.d.ts +48 -0
- package/dist/index.d.ts +69 -18
- package/dist/index.js +24 -13
- package/dist/index.js.map +1 -1
- package/dist/plugin/registerCollections.js +15 -36
- package/dist/plugin/registerCollections.js.map +1 -1
- package/dist/plugin/uploadsCollection.d.ts +11 -0
- package/dist/plugin/uploadsCollection.js +44 -0
- package/dist/plugin/uploadsCollection.js.map +1 -0
- package/dist/poll/applyPollOptions.js +17 -0
- package/dist/poll/applyPollOptions.js.map +1 -0
- package/dist/poll/buildPollOptionSourceFields.js +53 -0
- package/dist/poll/buildPollOptionSourceFields.js.map +1 -0
- package/dist/poll/definePollOptionSource.d.ts +35 -0
- package/dist/poll/definePollOptionSource.js +6 -0
- package/dist/poll/definePollOptionSource.js.map +1 -0
- package/dist/poll/registry.d.ts +16 -0
- package/dist/poll/registry.js +16 -0
- package/dist/poll/registry.js.map +1 -0
- package/dist/poll/resolvePollOptions.d.ts +28 -0
- package/dist/poll/resolvePollOptions.js +64 -0
- package/dist/poll/resolvePollOptions.js.map +1 -0
- package/dist/poll/resolvePollOutcome.d.ts +21 -0
- package/dist/poll/resolvePollOutcome.js +36 -0
- package/dist/poll/resolvePollOutcome.js.map +1 -0
- package/dist/prefill/valuesFromSearchParams.js +1 -1
- package/dist/prefill/valuesFromSearchParams.js.map +1 -1
- package/dist/presentations/types.d.ts +3 -2
- package/dist/react/Form.d.ts +13 -31
- package/dist/react/Form.js +96 -83
- package/dist/react/Form.js.map +1 -1
- package/dist/react/FormContext.d.ts +35 -1
- package/dist/react/FormContext.js.map +1 -1
- package/dist/react/FormControls.d.ts +55 -0
- package/dist/react/FormControls.js +56 -0
- package/dist/react/FormControls.js.map +1 -0
- package/dist/react/FormResults.d.ts +6 -3
- package/dist/react/FormResults.js +37 -29
- package/dist/react/FormResults.js.map +1 -1
- package/dist/react/FormSteps.d.ts +17 -0
- package/dist/react/FormSteps.js +35 -0
- package/dist/react/FormSteps.js.map +1 -0
- package/dist/react/Poll.d.ts +17 -6
- package/dist/react/Poll.js +66 -18
- package/dist/react/Poll.js.map +1 -1
- package/dist/react/fetchResults.d.ts +1 -1
- package/dist/react/fetchResults.js +1 -1
- package/dist/react/fetchResults.js.map +1 -1
- package/dist/react/renderers/file.js +5 -1
- package/dist/react/renderers/file.js.map +1 -1
- package/dist/react/renderers/index.js +2 -0
- package/dist/react/renderers/index.js.map +1 -1
- package/dist/react/renderers/message.js +35 -0
- package/dist/react/renderers/message.js.map +1 -0
- package/dist/react/state.d.ts +25 -1
- package/dist/react/state.js +14 -1
- package/dist/react/state.js.map +1 -1
- package/dist/react/uploadFile.d.ts +2 -2
- package/dist/react/uploadFile.js +1 -1
- package/dist/react/uploadFile.js.map +1 -1
- package/dist/submissions/runSubmission.js +70 -33
- package/dist/submissions/runSubmission.js.map +1 -1
- package/dist/submissions/validateSubmission.js +41 -7
- package/dist/submissions/validateSubmission.js.map +1 -1
- package/dist/submissions/votedCookie.d.ts +13 -0
- package/dist/submissions/votedCookie.js +27 -0
- package/dist/submissions/votedCookie.js.map +1 -0
- package/dist/translations/de.js +219 -0
- package/dist/translations/de.js.map +1 -0
- package/dist/translations/en.js +45 -6
- package/dist/translations/en.js.map +1 -1
- package/dist/translations/index.d.ts +2 -1
- package/dist/translations/index.js +6 -2
- package/dist/translations/index.js.map +1 -1
- package/dist/translations/keys.d.ts +43 -4
- package/dist/translations/keys.js +44 -5
- package/dist/translations/keys.js.map +1 -1
- package/dist/uploads/types.d.ts +1 -2
- package/dist/validation/validateUrl.js +22 -0
- package/dist/validation/validateUrl.js.map +1 -0
- package/package.json +2 -1
- package/styles/form-builder.css +10 -0
- package/dist/collections/uploads.d.ts +0 -30
- package/dist/collections/uploads.js +0 -55
- package/dist/collections/uploads.js.map +0 -1
- package/dist/presentations/registry.d.ts +0 -15
- package/dist/presentations/registry.js +0 -15
- package/dist/presentations/registry.js.map +0 -1
- package/dist/react/toFormDocument.d.ts +0 -24
- package/dist/react/toFormDocument.js +0 -22
- package/dist/react/toFormDocument.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FormControls.js","names":[],"sources":["../../src/react/FormControls.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport { cn } from './cn'\nimport { useFormContext } from './FormContext'\n\n/** Props passed to `renderSubmit`. */\nexport type SubmitButtonRenderProps = {\n\tlabel: string\n\tsubmitting: boolean\n}\n\n/** Props passed to `renderNext`. */\nexport type NextButtonRenderProps = {\n\tlabel: string\n\tsubmitting: boolean\n\tonClick: () => void\n}\n\n/** Props passed to `renderBack`. */\nexport type BackButtonRenderProps = {\n\tlabel: string\n\tsubmitting: boolean\n\tonClick: () => void\n}\n\nexport type FormControlsProps = {\n\t/** Override the context-resolved back label for this instance. */\n\tbackLabel?: string\n\t/** Override the context-resolved next label for this instance. */\n\tnextLabel?: string\n\t/** Override the context-resolved submit label for this instance. */\n\tsubmitLabel?: string\n\t/** Additional CSS class on the wrapping `.fb-form__controls` element. */\n\tclassName?: string\n\t/** CSS class forwarded to the default \"Back\" `<button>`. Ignored when `renderBack` is provided. */\n\tbackButtonClassName?: string\n\t/** CSS class forwarded to the default \"Next\" `<button>`. Ignored when `renderNext` is provided. */\n\tnextButtonClassName?: string\n\t/** CSS class forwarded to the default submit `<button>`. Ignored when `renderSubmit` is provided. */\n\tsubmitButtonClassName?: string\n\t/** Replace the default \"Back\" button entirely. */\n\trenderBack?: (props: BackButtonRenderProps) => ReactNode\n\t/** Replace the default \"Next\" button entirely. */\n\trenderNext?: (props: NextButtonRenderProps) => ReactNode\n\t/** Replace the default submit button entirely. Receives the resolved label and submitting state. */\n\trenderSubmit?: (props: SubmitButtonRenderProps) => ReactNode\n}\n\n/**\n * The form's navigation chrome: back on non-first steps, next on non-terminal steps, submit on\n * the terminal step (a single-step form is first and terminal, so it gets only submit). Driven\n * entirely by the form context, so it composes into custom `children` layouts exactly like the\n * default field loop's chrome. Labels resolve from context (`<Form>` props, the form's\n * `response.submitLabel`, translated defaults) unless overridden per button here. Must render\n * inside `<Form>`, within its `<form>` element so the submit button submits.\n */\nexport const FormControls = ({\n\tbackLabel,\n\tnextLabel,\n\tsubmitLabel,\n\tclassName,\n\tbackButtonClassName,\n\tnextButtonClassName,\n\tsubmitButtonClassName,\n\trenderBack,\n\trenderNext,\n\trenderSubmit,\n}: FormControlsProps) => {\n\tconst { state, step, labels } = useFormContext()\n\tconst submitting = state.submitting\n\tconst back = backLabel ?? labels.back\n\tconst next = nextLabel ?? labels.next\n\tconst submit = submitLabel ?? labels.submit\n\treturn (\n\t\t<div className={cn('fb-form__controls', className)}>\n\t\t\t{!step.isFirst ? (\n\t\t\t\trenderBack ? (\n\t\t\t\t\trenderBack({ label: back, submitting, onClick: step.goBack })\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName={cn('fb-form__back', backButtonClassName)}\n\t\t\t\t\t\tonClick={step.goBack}\n\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t>\n\t\t\t\t\t\t{back}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t) : null}\n\t\t\t{step.isTerminal ? (\n\t\t\t\trenderSubmit ? (\n\t\t\t\t\trenderSubmit({ label: submit, submitting })\n\t\t\t\t) : (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"submit\"\n\t\t\t\t\t\tclassName={cn('fb-form__submit', submitButtonClassName)}\n\t\t\t\t\t\tdisabled={submitting}\n\t\t\t\t\t>\n\t\t\t\t\t\t{submit}\n\t\t\t\t\t</button>\n\t\t\t\t)\n\t\t\t) : renderNext ? (\n\t\t\t\trenderNext({ label: next, submitting, onClick: step.goNext })\n\t\t\t) : (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tclassName={cn('fb-form__next', nextButtonClassName)}\n\t\t\t\t\tonClick={step.goNext}\n\t\t\t\t\tdisabled={submitting}\n\t\t\t\t>\n\t\t\t\t\t{next}\n\t\t\t\t</button>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n"],"mappings":";;;;;;;;;;;;;AAyDA,MAAa,gBAAgB,EAC5B,WACA,WACA,aACA,WACA,qBACA,qBACA,uBACA,YACA,YACA,mBACwB;CACxB,MAAM,EAAE,OAAO,MAAM,WAAW,eAAe;CAC/C,MAAM,aAAa,MAAM;CACzB,MAAM,OAAO,aAAa,OAAO;CACjC,MAAM,OAAO,aAAa,OAAO;CACjC,MAAM,SAAS,eAAe,OAAO;CACrC,OACC,qBAAC,OAAD;EAAK,WAAW,GAAG,qBAAqB,SAAS;YAAjD,CACE,CAAC,KAAK,UACN,aACC,WAAW;GAAE,OAAO;GAAM;GAAY,SAAS,KAAK;EAAO,CAAC,IAE5D,oBAAC,UAAD;GACC,MAAK;GACL,WAAW,GAAG,iBAAiB,mBAAmB;GAClD,SAAS,KAAK;GACd,UAAU;aAET;EACM,CAAA,IAEN,MACH,KAAK,aACL,eACC,aAAa;GAAE,OAAO;GAAQ;EAAW,CAAC,IAE1C,oBAAC,UAAD;GACC,MAAK;GACL,WAAW,GAAG,mBAAmB,qBAAqB;GACtD,UAAU;aAET;EACM,CAAA,IAEN,aACH,WAAW;GAAE,OAAO;GAAM;GAAY,SAAS,KAAK;EAAO,CAAC,IAE5D,oBAAC,UAAD;GACC,MAAK;GACL,WAAW,GAAG,iBAAiB,mBAAmB;GAClD,SAAS,KAAK;GACd,UAAU;aAET;EACM,CAAA,CAEL;;AAEP"}
|
|
@@ -7,18 +7,21 @@ type FormResultsProps = {
|
|
|
7
7
|
/** One field aggregation or several (a survey summary), resolved server-side and passed in. */results: FieldAggregation | FieldAggregation[]; /** Localized translator for built-in copy. Defaults to identity (returns the key). */
|
|
8
8
|
t?: RendererTranslate;
|
|
9
9
|
locale?: string; /** Show the raw count beside each option. Default true. */
|
|
10
|
-
showCounts?: boolean;
|
|
10
|
+
showCounts?: boolean; /** The recorded outcome's stable option value; the matching bucket is highlighted as the winner. */
|
|
11
|
+
winningValue?: string;
|
|
11
12
|
};
|
|
12
13
|
/**
|
|
13
14
|
* Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,
|
|
14
15
|
* data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never
|
|
15
16
|
* fetches). The option label, count, and percentage are real text (the accessible content); the bar fill
|
|
16
|
-
* is `aria-hidden` visual sugar sized by inline width.
|
|
17
|
+
* is `aria-hidden` visual sugar sized by inline width. With a `winningValue`, the matching bucket gets
|
|
18
|
+
* the winner class and a translated badge (real text, so the outcome is accessible).
|
|
17
19
|
*/
|
|
18
20
|
declare const FormResults: ({
|
|
19
21
|
results,
|
|
20
22
|
t,
|
|
21
|
-
showCounts
|
|
23
|
+
showCounts,
|
|
24
|
+
winningValue
|
|
22
25
|
}: FormResultsProps) => ReactNode;
|
|
23
26
|
//#endregion
|
|
24
27
|
export { FormResults, FormResultsProps };
|
|
@@ -4,7 +4,7 @@ import { en } from "../translations/en.js";
|
|
|
4
4
|
import { makeTranslate } from "../translations/makeTranslate.js";
|
|
5
5
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
//#region src/react/FormResults.tsx
|
|
7
|
-
const OneResult = ({ result, t, showCounts }) => {
|
|
7
|
+
const OneResult = ({ result, t, showCounts, winningValue }) => {
|
|
8
8
|
if (result.total === 0) return /* @__PURE__ */ jsxs("section", {
|
|
9
9
|
className: "fb-results__field",
|
|
10
10
|
children: [result.label ? /* @__PURE__ */ jsx("h3", {
|
|
@@ -24,31 +24,37 @@ const OneResult = ({ result, t, showCounts }) => {
|
|
|
24
24
|
}) : null,
|
|
25
25
|
/* @__PURE__ */ jsx("ul", {
|
|
26
26
|
className: "fb-results__list",
|
|
27
|
-
children: result.buckets.map((bucket) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
27
|
+
children: result.buckets.map((bucket) => {
|
|
28
|
+
const isWinner = winningValue != null && bucket.value === winningValue;
|
|
29
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
30
|
+
className: isWinner ? "fb-results__row fb-results__bucket--winner" : "fb-results__row",
|
|
31
|
+
children: [
|
|
32
|
+
/* @__PURE__ */ jsxs("span", {
|
|
33
|
+
className: "fb-results__option",
|
|
34
|
+
children: [bucket.label, isWinner ? /* @__PURE__ */ jsx("span", {
|
|
35
|
+
className: "fb-results__winner-badge",
|
|
36
|
+
children: t(keys.resultsWinner)
|
|
37
|
+
}) : null]
|
|
38
|
+
}),
|
|
39
|
+
/* @__PURE__ */ jsx("span", {
|
|
40
|
+
className: "fb-results__bar",
|
|
41
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
42
|
+
className: "fb-results__bar-fill",
|
|
43
|
+
style: { width: `${bucket.percentage}%` },
|
|
44
|
+
"aria-hidden": "true"
|
|
45
|
+
})
|
|
46
|
+
}),
|
|
47
|
+
/* @__PURE__ */ jsxs("span", {
|
|
48
|
+
className: "fb-results__pct",
|
|
49
|
+
children: [bucket.percentage, "%"]
|
|
50
|
+
}),
|
|
51
|
+
showCounts ? /* @__PURE__ */ jsx("span", {
|
|
52
|
+
className: "fb-results__count",
|
|
53
|
+
children: bucket.count
|
|
54
|
+
}) : null
|
|
55
|
+
]
|
|
56
|
+
}, bucket.value);
|
|
57
|
+
})
|
|
52
58
|
}),
|
|
53
59
|
/* @__PURE__ */ jsxs("p", {
|
|
54
60
|
className: "fb-results__total",
|
|
@@ -66,16 +72,18 @@ const OneResult = ({ result, t, showCounts }) => {
|
|
|
66
72
|
* Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,
|
|
67
73
|
* data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never
|
|
68
74
|
* fetches). The option label, count, and percentage are real text (the accessible content); the bar fill
|
|
69
|
-
* is `aria-hidden` visual sugar sized by inline width.
|
|
75
|
+
* is `aria-hidden` visual sugar sized by inline width. With a `winningValue`, the matching bucket gets
|
|
76
|
+
* the winner class and a translated badge (real text, so the outcome is accessible).
|
|
70
77
|
*/
|
|
71
|
-
const FormResults = ({ results, t, showCounts = true }) => {
|
|
78
|
+
const FormResults = ({ results, t, showCounts = true, winningValue }) => {
|
|
72
79
|
const translate = t ?? makeTranslate(en);
|
|
73
80
|
return /* @__PURE__ */ jsx("div", {
|
|
74
81
|
className: "fb-results",
|
|
75
82
|
children: (Array.isArray(results) ? results : [results]).map((result) => /* @__PURE__ */ jsx(OneResult, {
|
|
76
83
|
result,
|
|
77
84
|
t: translate,
|
|
78
|
-
showCounts
|
|
85
|
+
showCounts,
|
|
86
|
+
winningValue
|
|
79
87
|
}, result.field))
|
|
80
88
|
});
|
|
81
89
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FormResults.js","names":[],"sources":["../../src/react/FormResults.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { RendererTranslate } from './contract'\n\nexport type FormResultsProps = {\n\t/** One field aggregation or several (a survey summary), resolved server-side and passed in. */\n\tresults: FieldAggregation | FieldAggregation[]\n\t/** Localized translator for built-in copy. Defaults to identity (returns the key). */\n\tt?: RendererTranslate\n\tlocale?: string\n\t/** Show the raw count beside each option. Default true. */\n\tshowCounts?: boolean\n}\n\nconst OneResult = ({\n\tresult,\n\tt,\n\tshowCounts,\n}: {\n\tresult: FieldAggregation\n\tt: RendererTranslate\n\tshowCounts: boolean\n}): ReactNode => {\n\tif (result.total === 0) {\n\t\treturn (\n\t\t\t<section className=\"fb-results__field\">\n\t\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t\t<p className=\"fb-results__empty\">{t(keys.resultsNoResponses)}</p>\n\t\t\t</section>\n\t\t)\n\t}\n\treturn (\n\t\t<section className=\"fb-results__field\">\n\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t<ul className=\"fb-results__list\">\n\t\t\t\t{result.buckets.map((bucket) => (\n\t\t\t\t\t<li
|
|
1
|
+
{"version":3,"file":"FormResults.js","names":[],"sources":["../../src/react/FormResults.tsx"],"sourcesContent":["'use client'\n\nimport type { ReactNode } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\nimport type { RendererTranslate } from './contract'\n\nexport type FormResultsProps = {\n\t/** One field aggregation or several (a survey summary), resolved server-side and passed in. */\n\tresults: FieldAggregation | FieldAggregation[]\n\t/** Localized translator for built-in copy. Defaults to identity (returns the key). */\n\tt?: RendererTranslate\n\tlocale?: string\n\t/** Show the raw count beside each option. Default true. */\n\tshowCounts?: boolean\n\t/** The recorded outcome's stable option value; the matching bucket is highlighted as the winner. */\n\twinningValue?: string\n}\n\nconst OneResult = ({\n\tresult,\n\tt,\n\tshowCounts,\n\twinningValue,\n}: {\n\tresult: FieldAggregation\n\tt: RendererTranslate\n\tshowCounts: boolean\n\twinningValue?: string\n}): ReactNode => {\n\tif (result.total === 0) {\n\t\treturn (\n\t\t\t<section className=\"fb-results__field\">\n\t\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t\t<p className=\"fb-results__empty\">{t(keys.resultsNoResponses)}</p>\n\t\t\t</section>\n\t\t)\n\t}\n\treturn (\n\t\t<section className=\"fb-results__field\">\n\t\t\t{result.label ? <h3 className=\"fb-results__label\">{result.label}</h3> : null}\n\t\t\t<ul className=\"fb-results__list\">\n\t\t\t\t{result.buckets.map((bucket) => {\n\t\t\t\t\tconst isWinner = winningValue != null && bucket.value === winningValue\n\t\t\t\t\treturn (\n\t\t\t\t\t\t<li\n\t\t\t\t\t\t\tkey={bucket.value}\n\t\t\t\t\t\t\tclassName={\n\t\t\t\t\t\t\t\tisWinner ? 'fb-results__row fb-results__bucket--winner' : 'fb-results__row'\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<span className=\"fb-results__option\">\n\t\t\t\t\t\t\t\t{bucket.label}\n\t\t\t\t\t\t\t\t{isWinner ? (\n\t\t\t\t\t\t\t\t\t<span className=\"fb-results__winner-badge\">{t(keys.resultsWinner)}</span>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className=\"fb-results__bar\">\n\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\tclassName=\"fb-results__bar-fill\"\n\t\t\t\t\t\t\t\t\tstyle={{ width: `${bucket.percentage}%` }}\n\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t<span className=\"fb-results__pct\">{bucket.percentage}%</span>\n\t\t\t\t\t\t\t{showCounts ? <span className=\"fb-results__count\">{bucket.count}</span> : null}\n\t\t\t\t\t\t</li>\n\t\t\t\t\t)\n\t\t\t\t})}\n\t\t\t</ul>\n\t\t\t<p className=\"fb-results__total\">\n\t\t\t\t{result.total} {t(keys.resultsResponses)}\n\t\t\t\t{result.truncated ? ` (${t(keys.resultsTruncated)})` : ''}\n\t\t\t</p>\n\t\t</section>\n\t)\n}\n\n/**\n * Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,\n * data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never\n * fetches). The option label, count, and percentage are real text (the accessible content); the bar fill\n * is `aria-hidden` visual sugar sized by inline width. With a `winningValue`, the matching bucket gets\n * the winner class and a translated badge (real text, so the outcome is accessible).\n */\nexport const FormResults = ({\n\tresults,\n\tt,\n\tshowCounts = true,\n\twinningValue,\n}: FormResultsProps): ReactNode => {\n\tconst translate: RendererTranslate = t ?? makeTranslate(en)\n\tconst list = Array.isArray(results) ? results : [results]\n\treturn (\n\t\t<div className=\"fb-results\">\n\t\t\t{list.map((result) => (\n\t\t\t\t<OneResult\n\t\t\t\t\tkey={result.field}\n\t\t\t\t\tresult={result}\n\t\t\t\t\tt={translate}\n\t\t\t\t\tshowCounts={showCounts}\n\t\t\t\t\twinningValue={winningValue}\n\t\t\t\t/>\n\t\t\t))}\n\t\t</div>\n\t)\n}\n"],"mappings":";;;;;;AAqBA,MAAM,aAAa,EAClB,QACA,GACA,YACA,mBAMgB;CAChB,IAAI,OAAO,UAAU,GACpB,OACC,qBAAC,WAAD;EAAS,WAAU;YAAnB,CACE,OAAO,QAAQ,oBAAC,MAAD;GAAI,WAAU;aAAqB,OAAO;EAAU,CAAA,IAAI,MACxE,oBAAC,KAAD;GAAG,WAAU;aAAqB,EAAE,KAAK,kBAAkB;EAAK,CAAA,CACxD;;CAGX,OACC,qBAAC,WAAD;EAAS,WAAU;YAAnB;GACE,OAAO,QAAQ,oBAAC,MAAD;IAAI,WAAU;cAAqB,OAAO;GAAU,CAAA,IAAI;GACxE,oBAAC,MAAD;IAAI,WAAU;cACZ,OAAO,QAAQ,KAAK,WAAW;KAC/B,MAAM,WAAW,gBAAgB,QAAQ,OAAO,UAAU;KAC1D,OACC,qBAAC,MAAD;MAEC,WACC,WAAW,+CAA+C;gBAH5D;OAMC,qBAAC,QAAD;QAAM,WAAU;kBAAhB,CACE,OAAO,OACP,WACA,oBAAC,QAAD;SAAM,WAAU;mBAA4B,EAAE,KAAK,aAAa;QAAQ,CAAA,IACrE,IACC;;OACN,oBAAC,QAAD;QAAM,WAAU;kBACf,oBAAC,QAAD;SACC,WAAU;SACV,OAAO,EAAE,OAAO,GAAG,OAAO,WAAW,GAAG;SACxC,eAAY;QACZ,CAAA;OACI,CAAA;OACN,qBAAC,QAAD;QAAM,WAAU;kBAAhB,CAAmC,OAAO,YAAW,GAAO;;OAC3D,aAAa,oBAAC,QAAD;QAAM,WAAU;kBAAqB,OAAO;OAAY,CAAA,IAAI;MACvE;QApBE,OAAO,KAoBT;IAEN,CAAC;GACE,CAAA;GACJ,qBAAC,KAAD;IAAG,WAAU;cAAb;KACE,OAAO;KAAM;KAAE,EAAE,KAAK,gBAAgB;KACtC,OAAO,YAAY,KAAK,EAAE,KAAK,gBAAgB,EAAE,KAAK;IACrD;;EACK;;AAEX;;;;;;;;AASA,MAAa,eAAe,EAC3B,SACA,GACA,aAAa,MACb,mBACkC;CAClC,MAAM,YAA+B,KAAK,cAAc,EAAE;CAE1D,OACC,oBAAC,OAAD;EAAK,WAAU;aAFH,MAAM,QAAQ,OAAO,IAAI,UAAU,CAAC,OAAO,GAGhD,KAAK,WACV,oBAAC,WAAD;GAES;GACR,GAAG;GACS;GACE;EACd,GALK,OAAO,KAKZ,CACD;CACG,CAAA;AAEP"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/react/FormSteps.d.ts
|
|
2
|
+
type FormStepsProps = {
|
|
3
|
+
/** Additional CSS class on the wrapping `.fb-form-steps` list. */className?: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Step-progress indicator for multi-step forms: an ordered list of the flow's steps with the
|
|
7
|
+
* current one marked via `aria-current="step"` and a `--current` class. Step labels use the
|
|
8
|
+
* step's title, falling back to the translated "Step {n}" (same semantics as the admin flow
|
|
9
|
+
* builder). Carries no styling opinions beyond `fb-form-steps` classes. Renders nothing when
|
|
10
|
+
* the form has fewer than two steps. Must render inside `<Form>`.
|
|
11
|
+
*/
|
|
12
|
+
declare const FormSteps: ({
|
|
13
|
+
className
|
|
14
|
+
}: FormStepsProps) => import("react/jsx-runtime").JSX.Element | null;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { FormSteps, FormStepsProps };
|
|
17
|
+
//# sourceMappingURL=FormSteps.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { keys } from "../translations/keys.js";
|
|
3
|
+
import { stepLabel } from "../flow/stepLabel.js";
|
|
4
|
+
import { cn } from "./cn.js";
|
|
5
|
+
import { useFormContext } from "./FormContext.js";
|
|
6
|
+
import { jsx } from "react/jsx-runtime";
|
|
7
|
+
//#region src/react/FormSteps.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Step-progress indicator for multi-step forms: an ordered list of the flow's steps with the
|
|
10
|
+
* current one marked via `aria-current="step"` and a `--current` class. Step labels use the
|
|
11
|
+
* step's title, falling back to the translated "Step {n}" (same semantics as the admin flow
|
|
12
|
+
* builder). Carries no styling opinions beyond `fb-form-steps` classes. Renders nothing when
|
|
13
|
+
* the form has fewer than two steps. Must render inside `<Form>`.
|
|
14
|
+
*/
|
|
15
|
+
const FormSteps = ({ className }) => {
|
|
16
|
+
const { step, t } = useFormContext();
|
|
17
|
+
const steps = step.flow?.steps;
|
|
18
|
+
if (!steps || steps.length < 2) return null;
|
|
19
|
+
const fallbackTitle = t(keys.flowStepFallbackTitle);
|
|
20
|
+
return /* @__PURE__ */ jsx("ol", {
|
|
21
|
+
className: cn("fb-form-steps", className),
|
|
22
|
+
children: steps.map((flowStep, index) => {
|
|
23
|
+
const current = index === step.stepIndex;
|
|
24
|
+
return /* @__PURE__ */ jsx("li", {
|
|
25
|
+
className: cn("fb-form-steps__step", current && "fb-form-steps__step--current"),
|
|
26
|
+
"aria-current": current ? "step" : void 0,
|
|
27
|
+
children: stepLabel(flowStep, index, fallbackTitle)
|
|
28
|
+
}, flowStep.id);
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
//#endregion
|
|
33
|
+
export { FormSteps };
|
|
34
|
+
|
|
35
|
+
//# sourceMappingURL=FormSteps.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FormSteps.js","names":[],"sources":["../../src/react/FormSteps.tsx"],"sourcesContent":["'use client'\n\nimport { stepLabel } from '../flow/stepLabel'\nimport { keys } from '../translations/keys'\nimport { cn } from './cn'\nimport { useFormContext } from './FormContext'\n\nexport type FormStepsProps = {\n\t/** Additional CSS class on the wrapping `.fb-form-steps` list. */\n\tclassName?: string\n}\n\n/**\n * Step-progress indicator for multi-step forms: an ordered list of the flow's steps with the\n * current one marked via `aria-current=\"step\"` and a `--current` class. Step labels use the\n * step's title, falling back to the translated \"Step {n}\" (same semantics as the admin flow\n * builder). Carries no styling opinions beyond `fb-form-steps` classes. Renders nothing when\n * the form has fewer than two steps. Must render inside `<Form>`.\n */\nexport const FormSteps = ({ className }: FormStepsProps) => {\n\tconst { step, t } = useFormContext()\n\tconst steps = step.flow?.steps\n\tif (!steps || steps.length < 2) {\n\t\treturn null\n\t}\n\tconst fallbackTitle = t(keys.flowStepFallbackTitle)\n\treturn (\n\t\t<ol className={cn('fb-form-steps', className)}>\n\t\t\t{steps.map((flowStep, index) => {\n\t\t\t\tconst current = index === step.stepIndex\n\t\t\t\treturn (\n\t\t\t\t\t<li\n\t\t\t\t\t\tkey={flowStep.id}\n\t\t\t\t\t\tclassName={cn('fb-form-steps__step', current && 'fb-form-steps__step--current')}\n\t\t\t\t\t\taria-current={current ? 'step' : undefined}\n\t\t\t\t\t>\n\t\t\t\t\t\t{stepLabel(flowStep, index, fallbackTitle)}\n\t\t\t\t\t</li>\n\t\t\t\t)\n\t\t\t})}\n\t\t</ol>\n\t)\n}\n"],"mappings":";;;;;;;;;;;;;;AAmBA,MAAa,aAAa,EAAE,gBAAgC;CAC3D,MAAM,EAAE,MAAM,MAAM,eAAe;CACnC,MAAM,QAAQ,KAAK,MAAM;CACzB,IAAI,CAAC,SAAS,MAAM,SAAS,GAC5B,OAAO;CAER,MAAM,gBAAgB,EAAE,KAAK,qBAAqB;CAClD,OACC,oBAAC,MAAD;EAAI,WAAW,GAAG,iBAAiB,SAAS;YAC1C,MAAM,KAAK,UAAU,UAAU;GAC/B,MAAM,UAAU,UAAU,KAAK;GAC/B,OACC,oBAAC,MAAD;IAEC,WAAW,GAAG,uBAAuB,WAAW,8BAA8B;IAC9E,gBAAc,UAAU,SAAS,KAAA;cAEhC,UAAU,UAAU,OAAO,aAAa;GACtC,GALE,SAAS,EAKX;EAEN,CAAC;CACE,CAAA;AAEN"}
|
package/dist/react/Poll.d.ts
CHANGED
|
@@ -3,8 +3,13 @@ import { fetchFormResults } from "./fetchResults.js";
|
|
|
3
3
|
|
|
4
4
|
//#region src/react/Poll.d.ts
|
|
5
5
|
type PollProps = FormProps & {
|
|
6
|
-
/** The choice field whose results are shown after voting (should match the form's public `resultsField`). */resultsField: string; /** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */
|
|
7
|
-
storageKey?: string;
|
|
6
|
+
/** The choice field whose results are shown after voting (should match the form's public `poll.resultsField`). */resultsField: string; /** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */
|
|
7
|
+
storageKey?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Server-known voted state, ORed with the localStorage guard: `true` marks the visitor as voted;
|
|
10
|
+
* `false`/omitted falls back to localStorage. SSR hosts using the plugin's `poll.votedCookie`
|
|
11
|
+
* option pass `hasVotedCookie(cookieHeader, form.id)` here.
|
|
12
|
+
*/
|
|
8
13
|
hasVoted?: boolean;
|
|
9
14
|
/**
|
|
10
15
|
* Injectable results fetch (testing); defaults to `fetchFormResults`. Pass a stable reference (module
|
|
@@ -13,10 +18,16 @@ type PollProps = FormProps & {
|
|
|
13
18
|
fetchResultsImpl?: typeof fetchFormResults;
|
|
14
19
|
};
|
|
15
20
|
/**
|
|
16
|
-
* A poll: renders `<Form>`
|
|
17
|
-
* `<FormResults>`.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows
|
|
22
|
+
* `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated
|
|
23
|
+
* notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open
|
|
24
|
+
* `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A
|
|
25
|
+
* recorded `outcome.winningValue` (via `resolvePollOutcome`) supersedes everything: a translated final
|
|
26
|
+
* notice plus results with the winning bucket highlighted. A
|
|
27
|
+
* per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`
|
|
28
|
+
* (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not
|
|
29
|
+
* integrity (bypassable): server-enforced
|
|
30
|
+
* one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.
|
|
20
31
|
*/
|
|
21
32
|
declare const Poll: ({
|
|
22
33
|
resultsField,
|
package/dist/react/Poll.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
|
+
import { keys } from "../translations/keys.js";
|
|
3
|
+
import { isPollClosed } from "../form/pollState.js";
|
|
4
|
+
import { en } from "../translations/en.js";
|
|
5
|
+
import { makeTranslate } from "../translations/makeTranslate.js";
|
|
2
6
|
import { Form } from "./Form.js";
|
|
3
7
|
import { FormResults } from "./FormResults.js";
|
|
4
8
|
import { fetchFormResults } from "./fetchResults.js";
|
|
5
|
-
import { jsx } from "react/jsx-runtime";
|
|
6
|
-
import { useCallback, useEffect, useState } from "react";
|
|
9
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
7
11
|
//#region src/react/Poll.tsx
|
|
8
12
|
const readVoted = (key) => {
|
|
9
13
|
try {
|
|
@@ -18,15 +22,27 @@ const writeVoted = (key) => {
|
|
|
18
22
|
} catch {}
|
|
19
23
|
};
|
|
20
24
|
/**
|
|
21
|
-
* A poll: renders `<Form>`
|
|
22
|
-
* `<FormResults>`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
+
* A poll: renders `<Form>` while open and not yet voted, then fetches the aggregate results and shows
|
|
26
|
+
* `<FormResults>`. Lifecycle comes from `form.poll`: past `closesAt` the poll is closed (a translated
|
|
27
|
+
* notice plus results, which the endpoint serves for any visibility once closed); a voted-but-open
|
|
28
|
+
* `afterClose` poll shows a translated wait notice instead of fetching (the endpoint would refuse). A
|
|
29
|
+
* recorded `outcome.winningValue` (via `resolvePollOutcome`) supersedes everything: a translated final
|
|
30
|
+
* notice plus results with the winning bucket highlighted. A
|
|
31
|
+
* per-browser localStorage flag (`storageKey`) skips straight to results on revisit; `hasVoted: true`
|
|
32
|
+
* (e.g. from the server-set voted cookie) marks voted regardless of localStorage. The guard is UX, not
|
|
33
|
+
* integrity (bypassable): server-enforced
|
|
34
|
+
* one-per-identity dedup composes via `req.user` (authed forms) or a `notAlreadySubmitted` rule.
|
|
25
35
|
*/
|
|
26
36
|
const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchFormResults, apiRoute, onSuccess, ...formProps }) => {
|
|
27
37
|
const key = storageKey ?? `fb-poll-${formProps.form.id}`;
|
|
38
|
+
const poll = formProps.form.poll;
|
|
39
|
+
const closed = isPollClosed(poll);
|
|
40
|
+
const winningValue = poll?.outcome?.winningValue;
|
|
41
|
+
const finalized = typeof winningValue === "string" && winningValue.length > 0;
|
|
42
|
+
const resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === "afterClose";
|
|
28
43
|
const [voted, setVoted] = useState(false);
|
|
29
44
|
const [results, setResults] = useState(null);
|
|
45
|
+
const translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.t]);
|
|
30
46
|
const loadResults = useCallback(async () => {
|
|
31
47
|
const result = await fetchResultsImpl({
|
|
32
48
|
formId: formProps.form.id,
|
|
@@ -41,30 +57,62 @@ const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchForm
|
|
|
41
57
|
apiRoute
|
|
42
58
|
]);
|
|
43
59
|
useEffect(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
60
|
+
const already = hasVoted === true || readVoted(key);
|
|
61
|
+
if (already) setVoted(true);
|
|
62
|
+
if (already && !resultsAwaitClose || closed || finalized) loadResults();
|
|
48
63
|
}, [
|
|
49
64
|
hasVoted,
|
|
50
65
|
key,
|
|
51
|
-
loadResults
|
|
66
|
+
loadResults,
|
|
67
|
+
closed,
|
|
68
|
+
resultsAwaitClose,
|
|
69
|
+
finalized
|
|
52
70
|
]);
|
|
53
71
|
const handleSuccess = useCallback((submissionId) => {
|
|
54
72
|
writeVoted(key);
|
|
55
73
|
setVoted(true);
|
|
56
|
-
loadResults();
|
|
74
|
+
if (!resultsAwaitClose) loadResults();
|
|
57
75
|
onSuccess?.(submissionId);
|
|
58
76
|
}, [
|
|
59
77
|
key,
|
|
60
78
|
loadResults,
|
|
61
|
-
onSuccess
|
|
79
|
+
onSuccess,
|
|
80
|
+
resultsAwaitClose
|
|
62
81
|
]);
|
|
63
|
-
if (
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
82
|
+
if (finalized) return /* @__PURE__ */ jsxs("div", {
|
|
83
|
+
className: "fb-poll fb-poll--final",
|
|
84
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
85
|
+
className: "fb-poll__final",
|
|
86
|
+
children: translate(keys.pollFinalResult)
|
|
87
|
+
}), results ? /* @__PURE__ */ jsx(FormResults, {
|
|
88
|
+
results,
|
|
89
|
+
winningValue,
|
|
90
|
+
t: formProps.t,
|
|
91
|
+
locale: formProps.locale
|
|
92
|
+
}) : null]
|
|
93
|
+
});
|
|
94
|
+
if (closed) return /* @__PURE__ */ jsxs("div", {
|
|
95
|
+
className: "fb-poll fb-poll--closed",
|
|
96
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
97
|
+
className: "fb-poll__closed",
|
|
98
|
+
children: translate(keys.pollClosed)
|
|
99
|
+
}), results ? /* @__PURE__ */ jsx(FormResults, {
|
|
100
|
+
results,
|
|
101
|
+
t: formProps.t,
|
|
102
|
+
locale: formProps.locale
|
|
103
|
+
}) : null]
|
|
104
|
+
});
|
|
105
|
+
if (voted) {
|
|
106
|
+
if (resultsAwaitClose) return /* @__PURE__ */ jsx("p", {
|
|
107
|
+
className: "fb-poll__await-close",
|
|
108
|
+
children: translate(keys.pollResultsAfterClose)
|
|
109
|
+
});
|
|
110
|
+
return results ? /* @__PURE__ */ jsx(FormResults, {
|
|
111
|
+
results,
|
|
112
|
+
t: formProps.t,
|
|
113
|
+
locale: formProps.locale
|
|
114
|
+
}) : null;
|
|
115
|
+
}
|
|
68
116
|
return /* @__PURE__ */ jsx(Form, {
|
|
69
117
|
...formProps,
|
|
70
118
|
apiRoute,
|
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, useState } from 'react'\nimport type { FieldAggregation } from '../aggregation/types'\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 `resultsField`). */\n\tresultsField: string\n\t/** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */\n\tstorageKey?: string\n\t
|
|
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 } from '../form/pollState'\nimport { en } from '../translations/en'\nimport { keys } from '../translations/keys'\nimport { makeTranslate } from '../translations/makeTranslate'\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/** localStorage key for the per-browser voted guard. Default `fb-poll-{form.id}`. */\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}\n\nconst readVoted = (key: string): boolean => {\n\ttry {\n\t\treturn window.localStorage.getItem(key) != null\n\t} catch {\n\t\treturn false\n\t}\n}\n\nconst writeVoted = (key: string): void => {\n\ttry {\n\t\twindow.localStorage.setItem(key, '1')\n\t} catch {\n\t\t// Private mode / storage disabled: the guard is best-effort UX, never integrity.\n\t}\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.winningValue` (via `resolvePollOutcome`) supersedes everything: a translated final\n * notice plus results with the winning bucket highlighted. 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\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 winningValue = poll?.outcome?.winningValue\n\tconst finalized = typeof winningValue === 'string' && winningValue.length > 0\n\tconst resultsAwaitClose = !closed && !finalized && poll?.resultsVisibility === 'afterClose'\n\tconst [voted, setVoted] = useState(false)\n\tconst [results, setResults] = useState<FieldAggregation[] | null>(null)\n\tconst translate = useMemo(() => formProps.t ?? makeTranslate(en), [formProps.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\tsetResults(result.ok ? result.results : [])\n\t}, [fetchResultsImpl, formProps.form.id, resultsField, apiRoute])\n\n\tuseEffect(() => {\n\t\tconst already = hasVoted === true || readVoted(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, key, loadResults, closed, resultsAwaitClose, finalized])\n\n\tconst handleSuccess = useCallback(\n\t\t(submissionId?: string) => {\n\t\t\twriteVoted(key)\n\t\t\tsetVoted(true)\n\t\t\tif (!resultsAwaitClose) {\n\t\t\t\tvoid loadResults()\n\t\t\t}\n\t\t\tonSuccess?.(submissionId)\n\t\t},\n\t\t[key, loadResults, onSuccess, resultsAwaitClose]\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{results ? (\n\t\t\t\t\t<FormResults\n\t\t\t\t\t\tresults={results}\n\t\t\t\t\t\twinningValue={winningValue}\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{results ? (\n\t\t\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t)\n\t}\n\n\tif (voted) {\n\t\tif (resultsAwaitClose) {\n\t\t\treturn <p className=\"fb-poll__await-close\">{translate(keys.pollResultsAfterClose)}</p>\n\t\t}\n\t\treturn results ? (\n\t\t\t<FormResults results={results} t={formProps.t} locale={formProps.locale} />\n\t\t) : null\n\t}\n\n\treturn <Form {...formProps} apiRoute={apiRoute} onSuccess={handleSuccess} />\n}\n"],"mappings":";;;;;;;;;;;AA8BA,MAAM,aAAa,QAAyB;CAC3C,IAAI;EACH,OAAO,OAAO,aAAa,QAAQ,GAAG,KAAK;CAC5C,QAAQ;EACP,OAAO;CACR;AACD;AAEA,MAAM,cAAc,QAAsB;CACzC,IAAI;EACH,OAAO,aAAa,QAAQ,KAAK,GAAG;CACrC,QAAQ,CAER;AACD;;;;;;;;;;;;;AAcA,MAAa,QAAQ,EACpB,cACA,YACA,UACA,mBAAmB,kBACnB,UACA,WACA,GAAG,gBACa;CAChB,MAAM,MAAM,cAAc,WAAW,UAAU,KAAK;CACpD,MAAM,OAAO,UAAU,KAAK;CAC5B,MAAM,SAAS,aAAa,IAAI;CAChC,MAAM,eAAe,MAAM,SAAS;CACpC,MAAM,YAAY,OAAO,iBAAiB,YAAY,aAAa,SAAS;CAC5E,MAAM,oBAAoB,CAAC,UAAU,CAAC,aAAa,MAAM,sBAAsB;CAC/E,MAAM,CAAC,OAAO,YAAY,SAAS,KAAK;CACxC,MAAM,CAAC,SAAS,cAAc,SAAoC,IAAI;CACtE,MAAM,YAAY,cAAc,UAAU,KAAK,cAAc,EAAE,GAAG,CAAC,UAAU,CAAC,CAAC;CAE/E,MAAM,cAAc,YAAY,YAAY;EAC3C,MAAM,SAA6B,MAAM,iBAAiB;GACzD,QAAQ,UAAU,KAAK;GACvB,OAAO;GACP;EACD,CAAC;EACD,WAAW,OAAO,KAAK,OAAO,UAAU,CAAC,CAAC;CAC3C,GAAG;EAAC;EAAkB,UAAU,KAAK;EAAI;EAAc;CAAQ,CAAC;CAEhE,gBAAgB;EACf,MAAM,UAAU,aAAa,QAAQ,UAAU,GAAG;EAClD,IAAI,SACH,SAAS,IAAI;EAEd,IAAK,WAAW,CAAC,qBAAsB,UAAU,WAChD,YAAiB;CAEnB,GAAG;EAAC;EAAU;EAAK;EAAa;EAAQ;EAAmB;CAAS,CAAC;CAErE,MAAM,gBAAgB,aACpB,iBAA0B;EAC1B,WAAW,GAAG;EACd,SAAS,IAAI;EACb,IAAI,CAAC,mBACJ,YAAiB;EAElB,YAAY,YAAY;CACzB,GACA;EAAC;EAAK;EAAa;EAAW;CAAiB,CAChD;CAEA,IAAI,WACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAkB,UAAU,KAAK,eAAe;EAAK,CAAA,GACjE,UACA,oBAAC,aAAD;GACU;GACK;GACd,GAAG,UAAU;GACb,QAAQ,UAAU;EAClB,CAAA,IACE,IACA;;CAIP,IAAI,QACH,OACC,qBAAC,OAAD;EAAK,WAAU;YAAf,CACC,oBAAC,KAAD;GAAG,WAAU;aAAmB,UAAU,KAAK,UAAU;EAAK,CAAA,GAC7D,UACA,oBAAC,aAAD;GAAsB;GAAS,GAAG,UAAU;GAAG,QAAQ,UAAU;EAAS,CAAA,IACvE,IACA;;CAIP,IAAI,OAAO;EACV,IAAI,mBACH,OAAO,oBAAC,KAAD;GAAG,WAAU;aAAwB,UAAU,KAAK,qBAAqB;EAAK,CAAA;EAEtF,OAAO,UACN,oBAAC,aAAD;GAAsB;GAAS,GAAG,UAAU;GAAG,QAAQ,UAAU;EAAS,CAAA,IACvE;CACL;CAEA,OAAO,oBAAC,MAAD;EAAM,GAAI;EAAqB;EAAU,WAAW;CAAgB,CAAA;AAC5E"}
|
|
@@ -17,7 +17,7 @@ type FetchResultsResult = {
|
|
|
17
17
|
/**
|
|
18
18
|
* Fetch aggregate poll/survey results from the form-builder results endpoint
|
|
19
19
|
* (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public
|
|
20
|
-
* access by the form's
|
|
20
|
+
* access by the form's poll opt-in and results visibility. Pure: inject `fetchImpl` in tests.
|
|
21
21
|
*/
|
|
22
22
|
declare const fetchFormResults: (input: FetchResultsInput) => Promise<FetchResultsResult>;
|
|
23
23
|
//#endregion
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Fetch aggregate poll/survey results from the form-builder results endpoint
|
|
4
4
|
* (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public
|
|
5
|
-
* access by the form's
|
|
5
|
+
* access by the form's poll opt-in and results visibility. Pure: inject `fetchImpl` in tests.
|
|
6
6
|
*/
|
|
7
7
|
const fetchFormResults = async (input) => {
|
|
8
8
|
const { formId, field, apiRoute = "/api", fetchImpl = fetch } = input;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetchResults.js","names":[],"sources":["../../src/react/fetchResults.ts"],"sourcesContent":["import type { FieldAggregation } from '../aggregation/types'\n\nexport type FetchResultsInput = {\n\tformId: number | string\n\t/** Restrict to one field. Omit to get every public/enumerable field. */\n\tfield?: string\n\t/** Payload API route prefix; defaults to `/api`. */\n\tapiRoute?: string\n\t/** Injectable for testing; defaults to global `fetch`. */\n\tfetchImpl?: typeof fetch\n}\n\nexport type FetchResultsResult =\n\t| { ok: true; results: FieldAggregation[] }\n\t| { ok: false; message?: string }\n\n/**\n * Fetch aggregate poll/survey results from the form-builder results endpoint\n * (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public\n * access by the form's
|
|
1
|
+
{"version":3,"file":"fetchResults.js","names":[],"sources":["../../src/react/fetchResults.ts"],"sourcesContent":["import type { FieldAggregation } from '../aggregation/types'\n\nexport type FetchResultsInput = {\n\tformId: number | string\n\t/** Restrict to one field. Omit to get every public/enumerable field. */\n\tfield?: string\n\t/** Payload API route prefix; defaults to `/api`. */\n\tapiRoute?: string\n\t/** Injectable for testing; defaults to global `fetch`. */\n\tfetchImpl?: typeof fetch\n}\n\nexport type FetchResultsResult =\n\t| { ok: true; results: FieldAggregation[] }\n\t| { ok: false; message?: string }\n\n/**\n * Fetch aggregate poll/survey results from the form-builder results endpoint\n * (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public\n * access by the form's poll opt-in and results visibility. Pure: inject `fetchImpl` in tests.\n */\nexport const fetchFormResults = async (input: FetchResultsInput): Promise<FetchResultsResult> => {\n\tconst { formId, field, apiRoute = '/api', fetchImpl = fetch } = input\n\tconst query = field ? `?field=${encodeURIComponent(field)}` : ''\n\tlet response: Response\n\ttry {\n\t\tresponse = await fetchImpl(`${apiRoute}/forms/${formId}/results${query}`, { method: 'GET' })\n\t} catch (error) {\n\t\treturn { ok: false, message: error instanceof Error ? error.message : 'Network error' }\n\t}\n\tif (!response.ok) {\n\t\treturn { ok: false, message: `Request failed (${response.status})` }\n\t}\n\tconst body = (await response.json().catch(() => ({}))) as { results?: FieldAggregation[] }\n\treturn { ok: true, results: body.results ?? [] }\n}\n"],"mappings":";;;;;;AAqBA,MAAa,mBAAmB,OAAO,UAA0D;CAChG,MAAM,EAAE,QAAQ,OAAO,WAAW,QAAQ,YAAY,UAAU;CAChE,MAAM,QAAQ,QAAQ,UAAU,mBAAmB,KAAK,MAAM;CAC9D,IAAI;CACJ,IAAI;EACH,WAAW,MAAM,UAAU,GAAG,SAAS,SAAS,OAAO,UAAU,SAAS,EAAE,QAAQ,MAAM,CAAC;CAC5F,SAAS,OAAO;EACf,OAAO;GAAE,IAAI;GAAO,SAAS,iBAAiB,QAAQ,MAAM,UAAU;EAAgB;CACvF;CACA,IAAI,CAAC,SAAS,IACb,OAAO;EAAE,IAAI;EAAO,SAAS,mBAAmB,SAAS,OAAO;CAAG;CAGpE,OAAO;EAAE,IAAI;EAAM,UAAS,MADR,SAAS,KAAK,EAAE,aAAa,CAAC,EAAE,GACnB,WAAW,CAAC;CAAE;AAChD"}
|
|
@@ -24,7 +24,7 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
|
|
|
24
24
|
const [uploading, setUploading] = useState(false);
|
|
25
25
|
const [filename, setFilename] = useState(void 0);
|
|
26
26
|
const [localError, setLocalError] = useState(void 0);
|
|
27
|
-
const collection = typeof field.
|
|
27
|
+
const collection = typeof field.uploadsCollection === "string" ? field.uploadsCollection : void 0;
|
|
28
28
|
const types = typesOf(field.mimeTypes);
|
|
29
29
|
const accept = types.length > 0 ? types.join(",") : void 0;
|
|
30
30
|
const maxSize = typeof field.maxSize === "number" && field.maxSize >= 0 ? field.maxSize : void 0;
|
|
@@ -38,6 +38,10 @@ const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur
|
|
|
38
38
|
setLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }));
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
|
+
if (!collection) {
|
|
42
|
+
setLocalError(t(keys.fileUploadMisconfigured));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
41
45
|
setUploading(true);
|
|
42
46
|
setLocalError(void 0);
|
|
43
47
|
const result = await uploadFile({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file.js","names":[],"sources":["../../../src/react/renderers/file.tsx"],"sourcesContent":["'use client'\n\nimport { type ChangeEvent, useId, useState } from 'react'\nimport { keys } from '../../translations/keys'\nimport { formatBytes } from '../../uploads/formatBytes'\nimport { resolveMessage } from '../../validation/message'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { uploadFile } from '../uploadFile'\n\nconst typesOf = (mimeTypes: unknown): string[] => {\n\tif (!Array.isArray(mimeTypes)) {\n\t\treturn []\n\t}\n\treturn mimeTypes.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Headless file renderer: a native file input that uploads the chosen file to the upload collection and\n * stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the\n * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear\n * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.\n */\nexport const fileRenderer = defineFieldRenderer<string | number>(\n\t({ field, name, value, onChange, onBlur, errors, warnings, required, disabled, t }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\tconst [uploading, setUploading] = useState(false)\n\t\tconst [filename, setFilename] = useState<string | undefined>(undefined)\n\t\tconst [localError, setLocalError] = useState<string | undefined>(undefined)\n\n\t\tconst collection
|
|
1
|
+
{"version":3,"file":"file.js","names":[],"sources":["../../../src/react/renderers/file.tsx"],"sourcesContent":["'use client'\n\nimport { type ChangeEvent, useId, useState } from 'react'\nimport { keys } from '../../translations/keys'\nimport { formatBytes } from '../../uploads/formatBytes'\nimport { resolveMessage } from '../../validation/message'\nimport { defineFieldRenderer } from '../contract'\nimport { FieldShell } from '../primitives/FieldShell'\nimport { uploadFile } from '../uploadFile'\n\nconst typesOf = (mimeTypes: unknown): string[] => {\n\tif (!Array.isArray(mimeTypes)) {\n\t\treturn []\n\t}\n\treturn mimeTypes.filter((entry): entry is string => typeof entry === 'string')\n}\n\n/**\n * Headless file renderer: a native file input that uploads the chosen file to the upload collection and\n * stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the\n * stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear\n * control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.\n */\nexport const fileRenderer = defineFieldRenderer<string | number>(\n\t({ field, name, value, onChange, onBlur, errors, warnings, required, disabled, t }) => {\n\t\tconst id = useId()\n\t\tconst describedById = `${id}-desc`\n\t\tconst [uploading, setUploading] = useState(false)\n\t\tconst [filename, setFilename] = useState<string | undefined>(undefined)\n\t\tconst [localError, setLocalError] = useState<string | undefined>(undefined)\n\n\t\t// Stamped server-side onto the block from plugin config; absent only for stale form docs saved\n\t\t// before uploads were configured (uploading then fails with a visible error, never a guess).\n\t\tconst collection =\n\t\t\ttypeof field.uploadsCollection === 'string' ? field.uploadsCollection : undefined\n\t\tconst types = typesOf(field.mimeTypes)\n\t\tconst accept = types.length > 0 ? types.join(',') : undefined\n\t\t// Zero is a real limit, matching resolveFileRef server-side (filesize > maxSize rejects any non-empty file).\n\t\tconst maxSize =\n\t\t\ttypeof field.maxSize === 'number' && field.maxSize >= 0 ? field.maxSize : undefined\n\t\tconst hints = [\n\t\t\ttypes.length > 0\n\t\t\t\t? resolveMessage(t(keys.fileHintAccepted), { types: types.join(', ') })\n\t\t\t\t: undefined,\n\t\t\tmaxSize !== undefined\n\t\t\t\t? resolveMessage(t(keys.fileHintMaxSize), { max: formatBytes(maxSize) })\n\t\t\t\t: undefined,\n\t\t].filter((hint): hint is string => typeof hint === 'string')\n\t\tconst allErrors = localError ? [...errors, localError] : errors\n\t\t// Show the just-uploaded filename, or a generic indicator when the field already holds an id (recall/prefill): the filename is not part of the client value.\n\t\tconst hasValue = value !== undefined && value !== null && value !== ''\n\t\tconst displayName = filename ?? (hasValue ? 'Uploaded file' : undefined)\n\n\t\tconst handleChange = async (event: ChangeEvent<HTMLInputElement>) => {\n\t\t\tconst selected = event.target.files?.[0]\n\t\t\tif (!selected) {\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (maxSize !== undefined && selected.size > maxSize) {\n\t\t\t\tsetLocalError(resolveMessage(t(keys.fileTooLarge), { max: formatBytes(maxSize) }))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif (!collection) {\n\t\t\t\tsetLocalError(t(keys.fileUploadMisconfigured))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tsetUploading(true)\n\t\t\tsetLocalError(undefined)\n\t\t\tconst result = await uploadFile({ file: selected, collection })\n\t\t\tsetUploading(false)\n\t\t\tif (result.ok) {\n\t\t\t\tsetFilename(selected.name)\n\t\t\t\tonChange(result.id)\n\t\t\t} else {\n\t\t\t\tsetLocalError(result.message ?? 'Upload failed')\n\t\t\t}\n\t\t}\n\n\t\tconst clear = () => {\n\t\t\tsetFilename(undefined)\n\t\t\tsetLocalError(undefined)\n\t\t\tonChange('')\n\t\t}\n\n\t\treturn (\n\t\t\t<FieldShell\n\t\t\t\tid={id}\n\t\t\t\tlabel={field.label}\n\t\t\t\tdescription={typeof field.description === 'string' ? field.description : undefined}\n\t\t\t\trequired={required}\n\t\t\t\terrors={allErrors}\n\t\t\t\twarnings={warnings}\n\t\t\t\tdescribedById={describedById}\n\t\t\t>\n\t\t\t\t<input\n\t\t\t\t\tid={id}\n\t\t\t\t\tname={name}\n\t\t\t\t\ttype=\"file\"\n\t\t\t\t\taccept={accept}\n\t\t\t\t\tdisabled={disabled || uploading}\n\t\t\t\t\tonChange={(event) => {\n\t\t\t\t\t\tvoid handleChange(event)\n\t\t\t\t\t}}\n\t\t\t\t\tonBlur={onBlur}\n\t\t\t\t\taria-invalid={allErrors.length > 0}\n\t\t\t\t\taria-describedby={describedById}\n\t\t\t\t/>\n\t\t\t\t{hints.length > 0 ? <p className=\"fb-field__file-hint\">{hints.join(' · ')}</p> : null}\n\t\t\t\t{uploading ? (\n\t\t\t\t\t<p className=\"fb-field__file-status\" aria-live=\"polite\">\n\t\t\t\t\t\tUploading\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t\t{displayName ? (\n\t\t\t\t\t<p className=\"fb-field__file-name\">\n\t\t\t\t\t\t<span>{displayName}</span>\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tclassName=\"fb-field__file-clear\"\n\t\t\t\t\t\t\tonClick={clear}\n\t\t\t\t\t\t\tdisabled={disabled}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\tRemove\n\t\t\t\t\t\t</button>\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\t\t\t</FieldShell>\n\t\t)\n\t}\n)\n"],"mappings":";;;;;;;;;;AAUA,MAAM,WAAW,cAAiC;CACjD,IAAI,CAAC,MAAM,QAAQ,SAAS,GAC3B,OAAO,CAAC;CAET,OAAO,UAAU,QAAQ,UAA2B,OAAO,UAAU,QAAQ;AAC9E;;;;;;;AAQA,MAAa,eAAe,qBAC1B,EAAE,OAAO,MAAM,OAAO,UAAU,QAAQ,QAAQ,UAAU,UAAU,UAAU,QAAQ;CACtF,MAAM,KAAK,MAAM;CACjB,MAAM,gBAAgB,GAAG,GAAG;CAC5B,MAAM,CAAC,WAAW,gBAAgB,SAAS,KAAK;CAChD,MAAM,CAAC,UAAU,eAAe,SAA6B,KAAA,CAAS;CACtE,MAAM,CAAC,YAAY,iBAAiB,SAA6B,KAAA,CAAS;CAI1E,MAAM,aACL,OAAO,MAAM,sBAAsB,WAAW,MAAM,oBAAoB,KAAA;CACzE,MAAM,QAAQ,QAAQ,MAAM,SAAS;CACrC,MAAM,SAAS,MAAM,SAAS,IAAI,MAAM,KAAK,GAAG,IAAI,KAAA;CAEpD,MAAM,UACL,OAAO,MAAM,YAAY,YAAY,MAAM,WAAW,IAAI,MAAM,UAAU,KAAA;CAC3E,MAAM,QAAQ,CACb,MAAM,SAAS,IACZ,eAAe,EAAE,KAAK,gBAAgB,GAAG,EAAE,OAAO,MAAM,KAAK,IAAI,EAAE,CAAC,IACpE,KAAA,GACH,YAAY,KAAA,IACT,eAAe,EAAE,KAAK,eAAe,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,IACrE,KAAA,CACJ,EAAE,QAAQ,SAAyB,OAAO,SAAS,QAAQ;CAC3D,MAAM,YAAY,aAAa,CAAC,GAAG,QAAQ,UAAU,IAAI;CAGzD,MAAM,cAAc,aADH,UAAU,KAAA,KAAa,UAAU,QAAQ,UAAU,KACxB,kBAAkB,KAAA;CAE9D,MAAM,eAAe,OAAO,UAAyC;EACpE,MAAM,WAAW,MAAM,OAAO,QAAQ;EACtC,IAAI,CAAC,UACJ;EAED,IAAI,YAAY,KAAA,KAAa,SAAS,OAAO,SAAS;GACrD,cAAc,eAAe,EAAE,KAAK,YAAY,GAAG,EAAE,KAAK,YAAY,OAAO,EAAE,CAAC,CAAC;GACjF;EACD;EACA,IAAI,CAAC,YAAY;GAChB,cAAc,EAAE,KAAK,uBAAuB,CAAC;GAC7C;EACD;EACA,aAAa,IAAI;EACjB,cAAc,KAAA,CAAS;EACvB,MAAM,SAAS,MAAM,WAAW;GAAE,MAAM;GAAU;EAAW,CAAC;EAC9D,aAAa,KAAK;EAClB,IAAI,OAAO,IAAI;GACd,YAAY,SAAS,IAAI;GACzB,SAAS,OAAO,EAAE;EACnB,OACC,cAAc,OAAO,WAAW,eAAe;CAEjD;CAEA,MAAM,cAAc;EACnB,YAAY,KAAA,CAAS;EACrB,cAAc,KAAA,CAAS;EACvB,SAAS,EAAE;CACZ;CAEA,OACC,qBAAC,YAAD;EACK;EACJ,OAAO,MAAM;EACb,aAAa,OAAO,MAAM,gBAAgB,WAAW,MAAM,cAAc,KAAA;EAC/D;EACV,QAAQ;EACE;EACK;YAPhB;GASC,oBAAC,SAAD;IACK;IACE;IACN,MAAK;IACG;IACR,UAAU,YAAY;IACtB,WAAW,UAAU;KACpB,aAAkB,KAAK;IACxB;IACQ;IACR,gBAAc,UAAU,SAAS;IACjC,oBAAkB;GAClB,CAAA;GACA,MAAM,SAAS,IAAI,oBAAC,KAAD;IAAG,WAAU;cAAuB,MAAM,KAAK,KAAK;GAAK,CAAA,IAAI;GAChF,YACA,oBAAC,KAAD;IAAG,WAAU;IAAwB,aAAU;cAAS;GAErD,CAAA,IACA;GACH,cACA,qBAAC,KAAD;IAAG,WAAU;cAAb,CACC,oBAAC,QAAD,EAAA,UAAO,YAAkB,CAAA,GACzB,oBAAC,UAAD;KACC,MAAK;KACL,WAAU;KACV,SAAS;KACC;eACV;IAEO,CAAA,CACN;QACA;EACO;;AAEd,CACD"}
|
|
@@ -4,6 +4,7 @@ import { consentRenderer } from "./consent.js";
|
|
|
4
4
|
import { dateRenderer } from "./date.js";
|
|
5
5
|
import { emailRenderer } from "./email.js";
|
|
6
6
|
import { fileRenderer } from "./file.js";
|
|
7
|
+
import { messageRenderer } from "./message.js";
|
|
7
8
|
import { numberRenderer } from "./number.js";
|
|
8
9
|
import { repeaterRenderer } from "./repeater.js";
|
|
9
10
|
import { selectRenderer } from "./select.js";
|
|
@@ -18,6 +19,7 @@ const defaultRenderers = {
|
|
|
18
19
|
date: dateRenderer,
|
|
19
20
|
email: emailRenderer,
|
|
20
21
|
file: fileRenderer,
|
|
22
|
+
message: messageRenderer,
|
|
21
23
|
number: numberRenderer,
|
|
22
24
|
repeater: repeaterRenderer,
|
|
23
25
|
select: selectRenderer,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/react/renderers/index.ts"],"sourcesContent":["import type { FieldRenderer } from '../contract'\nimport { calculationRenderer } from './calculation'\nimport { checkboxRenderer } from './checkbox'\nimport { consentRenderer } from './consent'\nimport { dateRenderer } from './date'\nimport { emailRenderer } from './email'\nimport { fileRenderer } from './file'\nimport { numberRenderer } from './number'\nimport { repeaterRenderer } from './repeater'\nimport { selectRenderer } from './select'\nimport { textRenderer } from './text'\nimport { textareaRenderer } from './textarea'\n\n/** The built-in field renderers, keyed by field-type slug. Override via the renderer registry. */\nexport const defaultRenderers: Record<string, FieldRenderer> = {\n\tcalculation: calculationRenderer as FieldRenderer,\n\tcheckbox: checkboxRenderer as FieldRenderer,\n\tconsent: consentRenderer as FieldRenderer,\n\tdate: dateRenderer as FieldRenderer,\n\temail: emailRenderer as FieldRenderer,\n\tfile: fileRenderer as FieldRenderer,\n\tnumber: numberRenderer as FieldRenderer,\n\trepeater: repeaterRenderer as FieldRenderer,\n\tselect: selectRenderer as FieldRenderer,\n\ttext: textRenderer as FieldRenderer,\n\ttextarea: textareaRenderer as FieldRenderer,\n}\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/react/renderers/index.ts"],"sourcesContent":["import type { FieldRenderer } from '../contract'\nimport { calculationRenderer } from './calculation'\nimport { checkboxRenderer } from './checkbox'\nimport { consentRenderer } from './consent'\nimport { dateRenderer } from './date'\nimport { emailRenderer } from './email'\nimport { fileRenderer } from './file'\nimport { messageRenderer } from './message'\nimport { numberRenderer } from './number'\nimport { repeaterRenderer } from './repeater'\nimport { selectRenderer } from './select'\nimport { textRenderer } from './text'\nimport { textareaRenderer } from './textarea'\n\n/** The built-in field renderers, keyed by field-type slug. Override via the renderer registry. */\nexport const defaultRenderers: Record<string, FieldRenderer> = {\n\tcalculation: calculationRenderer as FieldRenderer,\n\tcheckbox: checkboxRenderer as FieldRenderer,\n\tconsent: consentRenderer as FieldRenderer,\n\tdate: dateRenderer as FieldRenderer,\n\temail: emailRenderer as FieldRenderer,\n\tfile: fileRenderer as FieldRenderer,\n\tmessage: messageRenderer as FieldRenderer,\n\tnumber: numberRenderer as FieldRenderer,\n\trepeater: repeaterRenderer as FieldRenderer,\n\tselect: selectRenderer as FieldRenderer,\n\ttext: textRenderer as FieldRenderer,\n\ttextarea: textareaRenderer as FieldRenderer,\n}\n"],"mappings":";;;;;;;;;;;;;;AAeA,MAAa,mBAAkD;CAC9D,aAAa;CACb,UAAU;CACV,SAAS;CACT,MAAM;CACN,OAAO;CACP,MAAM;CACN,SAAS;CACT,QAAQ;CACR,UAAU;CACV,QAAQ;CACR,MAAM;CACN,UAAU;AACX"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { serializeBody } from "../../actions/body/serializeBody.js";
|
|
3
|
+
import { defineFieldRenderer } from "../contract.js";
|
|
4
|
+
import { FormContext } from "../FormContext.js";
|
|
5
|
+
import { jsx } from "react/jsx-runtime";
|
|
6
|
+
import { useContext, useMemo } from "react";
|
|
7
|
+
//#region src/react/renderers/message.tsx
|
|
8
|
+
/**
|
|
9
|
+
* Display-only renderer for a `message` field: serializes the authored `content` rich text to HTML
|
|
10
|
+
* inline between fields. Recall tokens (`{{name}}`) resolve against the current answers (calc
|
|
11
|
+
* values included). Never binds form state; the field writes nothing to submissions.
|
|
12
|
+
*/
|
|
13
|
+
const messageRenderer = defineFieldRenderer(({ field }) => {
|
|
14
|
+
const context = useContext(FormContext);
|
|
15
|
+
const values = context?.effectiveValues ?? context?.state.values;
|
|
16
|
+
const html = useMemo(() => {
|
|
17
|
+
const answered = Object.entries(values ?? {}).filter(([, value]) => value != null && value !== "").map(([name, value]) => ({
|
|
18
|
+
field: name,
|
|
19
|
+
value
|
|
20
|
+
}));
|
|
21
|
+
return serializeBody(field.content, {
|
|
22
|
+
values: answered,
|
|
23
|
+
descriptors: []
|
|
24
|
+
});
|
|
25
|
+
}, [field.content, values]);
|
|
26
|
+
if (html === "") return null;
|
|
27
|
+
return /* @__PURE__ */ jsx("div", {
|
|
28
|
+
className: "fb-field__message",
|
|
29
|
+
dangerouslySetInnerHTML: { __html: html }
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
//#endregion
|
|
33
|
+
export { messageRenderer };
|
|
34
|
+
|
|
35
|
+
//# sourceMappingURL=message.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message.js","names":[],"sources":["../../../src/react/renderers/message.tsx"],"sourcesContent":["'use client'\n\nimport { useContext, useMemo } from 'react'\nimport { serializeBody } from '../../actions/body/serializeBody'\nimport type { SubmissionValue } from '../../submissions/types'\nimport { defineFieldRenderer } from '../contract'\nimport { FormContext } from '../FormContext'\n\n/**\n * Display-only renderer for a `message` field: serializes the authored `content` rich text to HTML\n * inline between fields. Recall tokens (`{{name}}`) resolve against the current answers (calc\n * values included). Never binds form state; the field writes nothing to submissions.\n */\nexport const messageRenderer = defineFieldRenderer(({ field }) => {\n\tconst context = useContext(FormContext)\n\tconst values = context?.effectiveValues ?? context?.state.values\n\tconst html = useMemo(() => {\n\t\tconst answered: SubmissionValue[] = Object.entries(values ?? {})\n\t\t\t.filter(([, value]) => value != null && value !== '')\n\t\t\t.map(([name, value]) => ({ field: name, value }))\n\t\treturn serializeBody(field.content, { values: answered, descriptors: [] })\n\t}, [field.content, values])\n\tif (html === '') {\n\t\treturn null\n\t}\n\t// Safe to inject: serializeBody HTML-escapes all text (recall values included) and sanitizes link URLs.\n\t// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is produced by our escaping serializer, never raw user input\n\treturn <div className=\"fb-field__message\" dangerouslySetInnerHTML={{ __html: html }} />\n})\n"],"mappings":";;;;;;;;;;;;AAaA,MAAa,kBAAkB,qBAAqB,EAAE,YAAY;CACjE,MAAM,UAAU,WAAW,WAAW;CACtC,MAAM,SAAS,SAAS,mBAAmB,SAAS,MAAM;CAC1D,MAAM,OAAO,cAAc;EAC1B,MAAM,WAA8B,OAAO,QAAQ,UAAU,CAAC,CAAC,EAC7D,QAAQ,GAAG,WAAW,SAAS,QAAQ,UAAU,EAAE,EACnD,KAAK,CAAC,MAAM,YAAY;GAAE,OAAO;GAAM;EAAM,EAAE;EACjD,OAAO,cAAc,MAAM,SAAS;GAAE,QAAQ;GAAU,aAAa,CAAC;EAAE,CAAC;CAC1E,GAAG,CAAC,MAAM,SAAS,MAAM,CAAC;CAC1B,IAAI,SAAS,IACZ,OAAO;CAIR,OAAO,oBAAC,OAAD;EAAK,WAAU;EAAoB,yBAAyB,EAAE,QAAQ,KAAK;CAAI,CAAA;AACvF,CAAC"}
|
package/dist/react/state.d.ts
CHANGED
|
@@ -10,6 +10,30 @@ type FormState = {
|
|
|
10
10
|
submitAttempted: boolean;
|
|
11
11
|
submitError?: string;
|
|
12
12
|
};
|
|
13
|
+
type FormAction = {
|
|
14
|
+
type: 'SET_VALUE';
|
|
15
|
+
name: string;
|
|
16
|
+
value: unknown;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'TOUCH';
|
|
19
|
+
name: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'SET_FIELD_ISSUES';
|
|
22
|
+
name: string;
|
|
23
|
+
errors: string[];
|
|
24
|
+
warnings: string[];
|
|
25
|
+
} | {
|
|
26
|
+
type: 'SET_ALL_ISSUES';
|
|
27
|
+
errors: FieldErrors;
|
|
28
|
+
warnings: FieldErrors;
|
|
29
|
+
} | {
|
|
30
|
+
type: 'SUBMIT_START';
|
|
31
|
+
} | {
|
|
32
|
+
type: 'SUBMIT_SUCCESS';
|
|
33
|
+
} | {
|
|
34
|
+
type: 'SUBMIT_ERROR';
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
13
37
|
//#endregion
|
|
14
|
-
export { FormState };
|
|
38
|
+
export { FieldErrors, FormAction, FormState };
|
|
15
39
|
//# sourceMappingURL=state.d.ts.map
|