@form-engine-ts/react 2.6.0 → 2.8.0
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/README.md +12 -0
- package/dist/index.cjs +353 -38
- package/dist/index.d.cts +75 -5
- package/dist/index.d.ts +75 -5
- package/dist/index.js +352 -39
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -116,3 +116,15 @@ translation lifecycle.
|
|
|
116
116
|
Validation runs before `beforeSubmit`. A `"cancel"` result does not call `onSubmit` and preserves values and drafts.
|
|
117
117
|
Header, page-header, field, navigation, submit, validation-summary, completion, and submit-error slots can replace the
|
|
118
118
|
default UI. The localized `completionMessage` is displayed after a successful submission.
|
|
119
|
+
|
|
120
|
+
Pass ordered `submissionGuards` to allow, block, or require confirmation before `onSubmit`. Confirmation receives all
|
|
121
|
+
guard findings through `renderSubmissionConfirmation`. A `receiptStore` prevents accidental repeat submissions and can
|
|
122
|
+
be created with the SSR-safe `createLocalStorageSubmissionReceiptStore`; `renderAlreadySubmitted` customizes its return
|
|
123
|
+
state. Text controls forward schema `minLength`, `maxLength`, and `pattern` constraints to the DOM, and
|
|
124
|
+
`renderCharacterCount` can replace the default count. Guard evaluation, confirmation, receipt persistence, and provider
|
|
125
|
+
submission share an in-flight lock so rapid clicks cannot submit twice.
|
|
126
|
+
|
|
127
|
+
The Builder basic-settings section edits source `title` and `description` through the same policy-aware action pipeline.
|
|
128
|
+
Submission confirmation slots receive the effective message, localized schema, and visible answers. An `onSubmit` result
|
|
129
|
+
may provide `submissionId` and `submittedAt`, which Renderer copies into its receipt. Receipt stores support `getBatch`,
|
|
130
|
+
and `useSubmissionReceipts` loads multiple form/version receipts for list and dashboard surfaces.
|
package/dist/index.cjs
CHANGED
|
@@ -23,10 +23,13 @@ __export(index_exports, {
|
|
|
23
23
|
FormBuilder: () => FormBuilder,
|
|
24
24
|
FormProvider: () => FormProvider,
|
|
25
25
|
FormRenderer: () => FormRenderer,
|
|
26
|
+
createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
|
|
26
27
|
resolveInitialFieldType: () => resolveInitialFieldType,
|
|
28
|
+
submissionReceiptQueryKey: () => submissionReceiptQueryKey,
|
|
27
29
|
useField: () => useField,
|
|
28
30
|
useForm: () => useForm,
|
|
29
|
-
useFormBuilder: () => useFormBuilder
|
|
31
|
+
useFormBuilder: () => useFormBuilder,
|
|
32
|
+
useSubmissionReceipts: () => useSubmissionReceipts
|
|
30
33
|
});
|
|
31
34
|
module.exports = __toCommonJS(index_exports);
|
|
32
35
|
|
|
@@ -971,6 +974,9 @@ var FIELD_TYPES = [
|
|
|
971
974
|
];
|
|
972
975
|
var BUILDER_DEFAULTS = {
|
|
973
976
|
"builder.formBuilder": "Form builder",
|
|
977
|
+
"builder.basicSettings": "Basic settings",
|
|
978
|
+
"builder.formTitle": "Form title",
|
|
979
|
+
"builder.formDescription": "Form description",
|
|
974
980
|
"builder.moveUp": "Move {{title}} up",
|
|
975
981
|
"builder.moveDown": "Move {{title}} down",
|
|
976
982
|
"builder.delete": "Delete {{title}}",
|
|
@@ -1136,7 +1142,7 @@ function FormBuilder({
|
|
|
1136
1142
|
}) {
|
|
1137
1143
|
const resolvedComponents = { ...DEFAULT_COMPONENTS, ...componentOverrides };
|
|
1138
1144
|
const components = GUARDED_COMPONENTS;
|
|
1139
|
-
const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextInput } = components;
|
|
1145
|
+
const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextArea, TextInput } = components;
|
|
1140
1146
|
const ToolbarSlot = slots?.toolbar;
|
|
1141
1147
|
const FieldEditorSlot = slots?.fieldEditor;
|
|
1142
1148
|
const OptionEditorSlot = slots?.optionEditor;
|
|
@@ -1439,6 +1445,42 @@ function FormBuilder({
|
|
|
1439
1445
|
}
|
|
1440
1446
|
},
|
|
1441
1447
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Fieldset, { className: "form-engine-builder__controls", disabled: readOnly, children: [
|
|
1448
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1449
|
+
Section,
|
|
1450
|
+
{
|
|
1451
|
+
className: "form-engine-builder__basic-settings",
|
|
1452
|
+
headingId: "builder-basic-settings-heading",
|
|
1453
|
+
title: translate("builder.basicSettings"),
|
|
1454
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "form-engine-builder__grid", children: [
|
|
1455
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
|
|
1456
|
+
translate("builder.formTitle"),
|
|
1457
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1458
|
+
TextInput,
|
|
1459
|
+
{
|
|
1460
|
+
name: "title",
|
|
1461
|
+
required: true,
|
|
1462
|
+
error: schema.title.trim().length === 0,
|
|
1463
|
+
helperText: schema.title.trim().length === 0 ? translate("builder.required") : "",
|
|
1464
|
+
value: schema.title,
|
|
1465
|
+
onChange: (value) => setSourceText({ kind: "form" }, "title", value)
|
|
1466
|
+
}
|
|
1467
|
+
)
|
|
1468
|
+
] }),
|
|
1469
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("label", { children: [
|
|
1470
|
+
translate("builder.formDescription"),
|
|
1471
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1472
|
+
TextArea,
|
|
1473
|
+
{
|
|
1474
|
+
name: "description",
|
|
1475
|
+
rows: 3,
|
|
1476
|
+
value: schema.description ?? "",
|
|
1477
|
+
onChange: (value) => setSourceText({ kind: "form" }, "description", value)
|
|
1478
|
+
}
|
|
1479
|
+
)
|
|
1480
|
+
] })
|
|
1481
|
+
] })
|
|
1482
|
+
}
|
|
1483
|
+
),
|
|
1442
1484
|
pagesEnabled ? PagesSlot === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
1443
1485
|
Section,
|
|
1444
1486
|
{
|
|
@@ -2225,6 +2267,7 @@ function FormProvider({
|
|
|
2225
2267
|
const [submitStatus, setSubmitStatus] = (0, import_react3.useState)("idle");
|
|
2226
2268
|
const [submitError, setSubmitError] = (0, import_react3.useState)(null);
|
|
2227
2269
|
const [validationPageIndex, setValidationPageIndex] = (0, import_react3.useState)(null);
|
|
2270
|
+
const submissionInFlight = (0, import_react3.useRef)(false);
|
|
2228
2271
|
const visibility = (0, import_react3.useMemo)(() => (0, import_core3.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
|
|
2229
2272
|
const pageVisibility = (0, import_react3.useMemo)(() => (0, import_core3.calculatePageVisibility)(validSchema, values), [validSchema, values]);
|
|
2230
2273
|
(0, import_react3.useEffect)(() => {
|
|
@@ -2278,6 +2321,7 @@ function FormProvider({
|
|
|
2278
2321
|
}, [initialValues]);
|
|
2279
2322
|
const submit = (0, import_react3.useCallback)(
|
|
2280
2323
|
async (beforeSubmit) => {
|
|
2324
|
+
if (submissionInFlight.current) return { status: "cancelled" };
|
|
2281
2325
|
const validation = (0, import_core3.validateAnswers)(validSchema, values);
|
|
2282
2326
|
if (!validation.valid) {
|
|
2283
2327
|
setErrors(issuesByField(validation.issues));
|
|
@@ -2290,21 +2334,24 @@ function FormProvider({
|
|
|
2290
2334
|
setValidationPageIndex(null);
|
|
2291
2335
|
setSubmitError(null);
|
|
2292
2336
|
const visibleValues = (0, import_core3.selectVisibleAnswers)(validSchema, values);
|
|
2337
|
+
submissionInFlight.current = true;
|
|
2293
2338
|
try {
|
|
2339
|
+
setSubmitStatus("submitting");
|
|
2294
2340
|
if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
|
|
2295
2341
|
setSubmitStatus("idle");
|
|
2296
2342
|
return { status: "cancelled" };
|
|
2297
2343
|
}
|
|
2298
|
-
|
|
2299
|
-
await onSubmit(visibleValues);
|
|
2344
|
+
const response = await onSubmit(visibleValues);
|
|
2300
2345
|
if (resetOnSuccess) setValues({ ...initialValues });
|
|
2301
2346
|
setSubmitStatus("success");
|
|
2302
|
-
return { status: "success" };
|
|
2347
|
+
return response === void 0 ? { status: "success" } : { status: "success", response };
|
|
2303
2348
|
} catch (cause) {
|
|
2304
2349
|
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
2305
2350
|
setSubmitError(error);
|
|
2306
2351
|
setSubmitStatus("error");
|
|
2307
2352
|
return { status: "error", error };
|
|
2353
|
+
} finally {
|
|
2354
|
+
submissionInFlight.current = false;
|
|
2308
2355
|
}
|
|
2309
2356
|
},
|
|
2310
2357
|
[initialValues, onSubmit, resetOnSuccess, validSchema, values]
|
|
@@ -2365,9 +2412,119 @@ function useField(fieldId) {
|
|
|
2365
2412
|
return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
|
|
2366
2413
|
}
|
|
2367
2414
|
|
|
2415
|
+
// src/receipt.ts
|
|
2416
|
+
var import_react4 = require("react");
|
|
2417
|
+
function submissionReceiptQueryKey(formId, formVersion) {
|
|
2418
|
+
return `${formId}:v${formVersion}`;
|
|
2419
|
+
}
|
|
2420
|
+
function receiptKey(namespace, formId, formVersion) {
|
|
2421
|
+
return `${namespace}:${formId}:v${formVersion}`;
|
|
2422
|
+
}
|
|
2423
|
+
function browserStorage() {
|
|
2424
|
+
if (typeof window === "undefined") return null;
|
|
2425
|
+
try {
|
|
2426
|
+
return window.localStorage;
|
|
2427
|
+
} catch {
|
|
2428
|
+
return null;
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
function parseReceipt(serialized) {
|
|
2432
|
+
try {
|
|
2433
|
+
const value = JSON.parse(serialized);
|
|
2434
|
+
if (typeof value !== "object" || value === null || !("formId" in value) || typeof value.formId !== "string" || !("formVersion" in value) || typeof value.formVersion !== "number" || !Number.isSafeInteger(value.formVersion) || !("submittedAt" in value) || typeof value.submittedAt !== "string" || !Number.isFinite(Date.parse(value.submittedAt)) || "submissionId" in value && value.submissionId !== void 0 && typeof value.submissionId !== "string") {
|
|
2435
|
+
return null;
|
|
2436
|
+
}
|
|
2437
|
+
const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
|
|
2438
|
+
return {
|
|
2439
|
+
formId: value.formId,
|
|
2440
|
+
formVersion: value.formVersion,
|
|
2441
|
+
submittedAt: value.submittedAt,
|
|
2442
|
+
...submissionId === void 0 ? {} : { submissionId }
|
|
2443
|
+
};
|
|
2444
|
+
} catch {
|
|
2445
|
+
return null;
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
2449
|
+
const namespace = options.namespace ?? "form_engine_receipt";
|
|
2450
|
+
if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
|
|
2451
|
+
const get = async (formId, formVersion) => {
|
|
2452
|
+
const storage = browserStorage();
|
|
2453
|
+
if (storage === null) return null;
|
|
2454
|
+
try {
|
|
2455
|
+
const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
|
|
2456
|
+
if (serialized === null) return null;
|
|
2457
|
+
const receipt = parseReceipt(serialized);
|
|
2458
|
+
return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
|
|
2459
|
+
} catch {
|
|
2460
|
+
return null;
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
return {
|
|
2464
|
+
get,
|
|
2465
|
+
async getBatch(queries) {
|
|
2466
|
+
const receipts = await Promise.all(queries.map((query) => get(query.formId, query.formVersion)));
|
|
2467
|
+
return new Map(
|
|
2468
|
+
receipts.flatMap(
|
|
2469
|
+
(receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
|
|
2470
|
+
)
|
|
2471
|
+
);
|
|
2472
|
+
},
|
|
2473
|
+
async save(receipt) {
|
|
2474
|
+
const storage = browserStorage();
|
|
2475
|
+
if (storage === null) return;
|
|
2476
|
+
storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
|
|
2477
|
+
},
|
|
2478
|
+
async remove(formId, formVersion) {
|
|
2479
|
+
const storage = browserStorage();
|
|
2480
|
+
if (storage === null) return;
|
|
2481
|
+
storage.removeItem(receiptKey(namespace, formId, formVersion));
|
|
2482
|
+
}
|
|
2483
|
+
};
|
|
2484
|
+
}
|
|
2485
|
+
function useSubmissionReceipts(store, queries) {
|
|
2486
|
+
const querySignature = JSON.stringify(queries.map(({ formId, formVersion }) => [formId, formVersion]));
|
|
2487
|
+
const stableQueries = (0, import_react4.useMemo)(() => {
|
|
2488
|
+
const parsed = JSON.parse(querySignature);
|
|
2489
|
+
if (!Array.isArray(parsed)) return [];
|
|
2490
|
+
return parsed.flatMap(
|
|
2491
|
+
(entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number" && Number.isSafeInteger(entry[1]) ? [{ formId: entry[0], formVersion: entry[1] }] : []
|
|
2492
|
+
);
|
|
2493
|
+
}, [querySignature]);
|
|
2494
|
+
const [state, setState] = (0, import_react4.useState)({
|
|
2495
|
+
receipts: /* @__PURE__ */ new Map(),
|
|
2496
|
+
isLoading: stableQueries.length > 0,
|
|
2497
|
+
error: null
|
|
2498
|
+
});
|
|
2499
|
+
(0, import_react4.useEffect)(() => {
|
|
2500
|
+
let active = true;
|
|
2501
|
+
if (stableQueries.length === 0) {
|
|
2502
|
+
setState({ receipts: /* @__PURE__ */ new Map(), isLoading: false, error: null });
|
|
2503
|
+
return () => {
|
|
2504
|
+
active = false;
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
setState((current) => ({ ...current, isLoading: true, error: null }));
|
|
2508
|
+
void store.getBatch(stableQueries).then((receipts) => {
|
|
2509
|
+
if (active) setState({ receipts, isLoading: false, error: null });
|
|
2510
|
+
}).catch((cause) => {
|
|
2511
|
+
if (!active) return;
|
|
2512
|
+
setState({
|
|
2513
|
+
receipts: /* @__PURE__ */ new Map(),
|
|
2514
|
+
isLoading: false,
|
|
2515
|
+
error: cause instanceof Error ? cause : new Error(String(cause))
|
|
2516
|
+
});
|
|
2517
|
+
});
|
|
2518
|
+
return () => {
|
|
2519
|
+
active = false;
|
|
2520
|
+
};
|
|
2521
|
+
}, [stableQueries, store]);
|
|
2522
|
+
return state;
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2368
2525
|
// src/renderer.tsx
|
|
2369
2526
|
var import_core4 = require("@form-engine-ts/core");
|
|
2370
|
-
var
|
|
2527
|
+
var import_react5 = require("react");
|
|
2371
2528
|
var import_jsx_runtime3 = require("react/jsx-runtime");
|
|
2372
2529
|
function describedBy(field, error, helpId, errorId) {
|
|
2373
2530
|
const ids = [field.description === void 0 ? void 0 : helpId, error === void 0 ? void 0 : errorId].filter(
|
|
@@ -2482,11 +2639,17 @@ function DefaultField(props) {
|
|
|
2482
2639
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required })
|
|
2483
2640
|
] });
|
|
2484
2641
|
let control;
|
|
2642
|
+
const textConstraints = field.type === "text" || field.type === "textarea" ? {
|
|
2643
|
+
minLength: field.minLength,
|
|
2644
|
+
maxLength: field.maxLength,
|
|
2645
|
+
...field.pattern === void 0 ? {} : { pattern: field.pattern }
|
|
2646
|
+
} : {};
|
|
2485
2647
|
if (field.type === "textarea") {
|
|
2486
2648
|
control = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2487
2649
|
"textarea",
|
|
2488
2650
|
{
|
|
2489
2651
|
...ariaProps,
|
|
2652
|
+
...textConstraints,
|
|
2490
2653
|
id: inputId,
|
|
2491
2654
|
name: field.id,
|
|
2492
2655
|
placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
|
|
@@ -2499,6 +2662,7 @@ function DefaultField(props) {
|
|
|
2499
2662
|
"input",
|
|
2500
2663
|
{
|
|
2501
2664
|
...ariaProps,
|
|
2665
|
+
...textConstraints,
|
|
2502
2666
|
id: inputId,
|
|
2503
2667
|
name: field.id,
|
|
2504
2668
|
type: "number",
|
|
@@ -2543,6 +2707,11 @@ function DefaultField(props) {
|
|
|
2543
2707
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
|
|
2544
2708
|
label,
|
|
2545
2709
|
control,
|
|
2710
|
+
(field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
|
|
2711
|
+
fieldId: field.id,
|
|
2712
|
+
current: typeof value === "string" ? value.length : 0,
|
|
2713
|
+
max: field.maxLength
|
|
2714
|
+
}) : null,
|
|
2546
2715
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
|
|
2547
2716
|
] });
|
|
2548
2717
|
}
|
|
@@ -2578,31 +2747,62 @@ function ContextFormRenderer({
|
|
|
2578
2747
|
autoSaveKey,
|
|
2579
2748
|
beforeSubmit,
|
|
2580
2749
|
onDraftSave,
|
|
2750
|
+
submissionGuards = [],
|
|
2751
|
+
receiptStore,
|
|
2581
2752
|
slots = {}
|
|
2582
2753
|
}) {
|
|
2583
2754
|
const form = useForm();
|
|
2584
|
-
const prefix = (0,
|
|
2585
|
-
const formRef = (0,
|
|
2586
|
-
const loadedDraftKey = (0,
|
|
2587
|
-
const [draftRestored, setDraftRestored] = (0,
|
|
2588
|
-
const [currentPageIndex, setCurrentPageIndex] = (0,
|
|
2589
|
-
const [focusFieldId, setFocusFieldId] = (0,
|
|
2755
|
+
const prefix = (0, import_react5.useId)().replace(/:/g, "");
|
|
2756
|
+
const formRef = (0, import_react5.useRef)(null);
|
|
2757
|
+
const loadedDraftKey = (0, import_react5.useRef)(null);
|
|
2758
|
+
const [draftRestored, setDraftRestored] = (0, import_react5.useState)(false);
|
|
2759
|
+
const [currentPageIndex, setCurrentPageIndex] = (0, import_react5.useState)(0);
|
|
2760
|
+
const [focusFieldId, setFocusFieldId] = (0, import_react5.useState)(null);
|
|
2761
|
+
const [confirmation, setConfirmation] = (0, import_react5.useState)(null);
|
|
2762
|
+
const [guardMessage, setGuardMessage] = (0, import_react5.useState)(null);
|
|
2763
|
+
const [receipt, setReceipt] = (0, import_react5.useState)(null);
|
|
2764
|
+
const [receiptLoaded, setReceiptLoaded] = (0, import_react5.useState)(receiptStore === void 0);
|
|
2765
|
+
const rendererSubmissionInFlight = (0, import_react5.useRef)(false);
|
|
2590
2766
|
const pages = form.schema.pages;
|
|
2591
|
-
const visiblePageIndexes = (0,
|
|
2767
|
+
const visiblePageIndexes = (0, import_react5.useMemo)(
|
|
2592
2768
|
() => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
|
|
2593
2769
|
[form.pageVisibility, pages]
|
|
2594
2770
|
);
|
|
2595
2771
|
const activePage = pages?.[currentPageIndex];
|
|
2596
2772
|
const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
|
|
2597
2773
|
const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
|
|
2598
|
-
(0,
|
|
2774
|
+
const visibleValues = (0, import_react5.useMemo)(() => (0, import_core4.selectVisibleAnswers)(form.schema, form.values), [form.schema, form.values]);
|
|
2775
|
+
const submitState = confirmation === null ? form.submitStatus : "confirming";
|
|
2776
|
+
const interactionLocked = submitState === "confirming" || submitState === "submitting";
|
|
2777
|
+
(0, import_react5.useEffect)(() => {
|
|
2778
|
+
let active = true;
|
|
2779
|
+
if (receiptStore === void 0) {
|
|
2780
|
+
setReceipt(null);
|
|
2781
|
+
setReceiptLoaded(true);
|
|
2782
|
+
return () => {
|
|
2783
|
+
active = false;
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
setReceiptLoaded(false);
|
|
2787
|
+
void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
|
|
2788
|
+
if (active) setReceipt(stored);
|
|
2789
|
+
}).catch(() => {
|
|
2790
|
+
if (active) setReceipt(null);
|
|
2791
|
+
}).finally(() => {
|
|
2792
|
+
if (active) setReceiptLoaded(true);
|
|
2793
|
+
});
|
|
2794
|
+
return () => {
|
|
2795
|
+
active = false;
|
|
2796
|
+
};
|
|
2797
|
+
}, [form.schema.id, form.schema.version, receiptStore]);
|
|
2798
|
+
(0, import_react5.useEffect)(() => {
|
|
2599
2799
|
if (pages === void 0 || visiblePageIndexes.length === 0) {
|
|
2600
2800
|
setCurrentPageIndex(0);
|
|
2601
2801
|
return;
|
|
2602
2802
|
}
|
|
2603
2803
|
if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2604
2804
|
}, [currentPageIndex, pages, visiblePageIndexes]);
|
|
2605
|
-
(0,
|
|
2805
|
+
(0, import_react5.useEffect)(() => {
|
|
2606
2806
|
if (focusFieldId === null) return;
|
|
2607
2807
|
const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
|
|
2608
2808
|
(element) => element.dataset.fieldId === focusFieldId
|
|
@@ -2613,7 +2813,7 @@ function ContextFormRenderer({
|
|
|
2613
2813
|
setFocusFieldId(null);
|
|
2614
2814
|
}
|
|
2615
2815
|
}, [focusFieldId]);
|
|
2616
|
-
(0,
|
|
2816
|
+
(0, import_react5.useEffect)(() => {
|
|
2617
2817
|
if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
|
|
2618
2818
|
const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
|
|
2619
2819
|
if (loadedDraftKey.current === loadIdentity) return;
|
|
@@ -2625,7 +2825,7 @@ function ContextFormRenderer({
|
|
|
2625
2825
|
form.restoreValues(draft.values);
|
|
2626
2826
|
setDraftRestored(true);
|
|
2627
2827
|
}, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
|
|
2628
|
-
(0,
|
|
2828
|
+
(0, import_react5.useEffect)(() => {
|
|
2629
2829
|
if (form.submitStatus === "success") return;
|
|
2630
2830
|
const timeout = globalThis.setTimeout(() => {
|
|
2631
2831
|
onDraftSave?.(form.values);
|
|
@@ -2644,6 +2844,7 @@ function ContextFormRenderer({
|
|
|
2644
2844
|
if (fieldId !== void 0) setFocusFieldId(fieldId);
|
|
2645
2845
|
};
|
|
2646
2846
|
const handleNext = () => {
|
|
2847
|
+
if (interactionLocked) return;
|
|
2647
2848
|
const result = form.validatePage(currentPageIndex);
|
|
2648
2849
|
if (!result.valid) {
|
|
2649
2850
|
focusFirstIssue(result.issues[0]?.fieldId);
|
|
@@ -2652,34 +2853,122 @@ function ContextFormRenderer({
|
|
|
2652
2853
|
const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
|
|
2653
2854
|
if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
|
|
2654
2855
|
};
|
|
2655
|
-
const
|
|
2856
|
+
const runSubmissionGuards = async (guards) => {
|
|
2857
|
+
const findings = [];
|
|
2858
|
+
let confirmationMessage;
|
|
2859
|
+
let requiresConfirmation = false;
|
|
2860
|
+
for (const guard of guards) {
|
|
2861
|
+
const result = await guard(form.schema, visibleValues);
|
|
2862
|
+
if (result.status === "allow") continue;
|
|
2863
|
+
findings.push(...result.findings);
|
|
2864
|
+
if (result.status === "block") {
|
|
2865
|
+
return {
|
|
2866
|
+
status: "block",
|
|
2867
|
+
findings,
|
|
2868
|
+
...result.message === void 0 ? {} : { message: result.message }
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
requiresConfirmation = true;
|
|
2872
|
+
confirmationMessage ??= result.message;
|
|
2873
|
+
}
|
|
2874
|
+
return !requiresConfirmation ? { status: "allow" } : {
|
|
2875
|
+
status: "confirm",
|
|
2876
|
+
findings,
|
|
2877
|
+
...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
|
|
2878
|
+
};
|
|
2879
|
+
};
|
|
2880
|
+
const submitValues = async (guardsConfirmed = false) => {
|
|
2881
|
+
if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
|
|
2882
|
+
return { status: "cancelled" };
|
|
2883
|
+
}
|
|
2656
2884
|
const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
|
|
2657
2885
|
const firstInvalidFieldId = validation.issues[0]?.fieldId;
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2886
|
+
if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
|
|
2887
|
+
rendererSubmissionInFlight.current = true;
|
|
2888
|
+
try {
|
|
2889
|
+
const guardResult = await runSubmissionGuards(submissionGuards);
|
|
2890
|
+
if (guardResult.status === "block") {
|
|
2891
|
+
setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
|
|
2892
|
+
return { status: "cancelled" };
|
|
2893
|
+
}
|
|
2894
|
+
if (guardResult.status === "confirm") {
|
|
2895
|
+
setGuardMessage(null);
|
|
2896
|
+
setConfirmation({
|
|
2897
|
+
findings: guardResult.findings,
|
|
2898
|
+
...guardResult.message === void 0 ? {} : { message: guardResult.message }
|
|
2899
|
+
});
|
|
2900
|
+
return { status: "cancelled" };
|
|
2901
|
+
}
|
|
2902
|
+
} finally {
|
|
2903
|
+
rendererSubmissionInFlight.current = false;
|
|
2904
|
+
}
|
|
2666
2905
|
}
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2906
|
+
setGuardMessage(null);
|
|
2907
|
+
rendererSubmissionInFlight.current = true;
|
|
2908
|
+
try {
|
|
2909
|
+
const result = await form.submit(beforeSubmit);
|
|
2910
|
+
if (result.status === "invalid") {
|
|
2911
|
+
const invalidPageIndex = pages?.findIndex(
|
|
2912
|
+
(page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
|
|
2913
|
+
);
|
|
2914
|
+
if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
|
|
2915
|
+
focusFirstIssue(firstInvalidFieldId);
|
|
2916
|
+
return result;
|
|
2917
|
+
}
|
|
2918
|
+
if (result.status !== "success") return result;
|
|
2919
|
+
if (receiptStore !== void 0) {
|
|
2920
|
+
const response = result.response;
|
|
2921
|
+
const storedReceipt = {
|
|
2922
|
+
formId: form.schema.id,
|
|
2923
|
+
formVersion: form.schema.version,
|
|
2924
|
+
submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2925
|
+
...response?.submissionId === void 0 ? {} : { submissionId: response.submissionId }
|
|
2926
|
+
};
|
|
2927
|
+
await receiptStore.save(storedReceipt);
|
|
2928
|
+
setReceipt(storedReceipt);
|
|
2929
|
+
}
|
|
2930
|
+
if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
|
|
2931
|
+
globalThis.localStorage.removeItem(autoSaveKey);
|
|
2932
|
+
setDraftRestored(false);
|
|
2933
|
+
}
|
|
2934
|
+
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2935
|
+
return result;
|
|
2936
|
+
} finally {
|
|
2937
|
+
rendererSubmissionInFlight.current = false;
|
|
2671
2938
|
}
|
|
2672
|
-
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2673
|
-
return result;
|
|
2674
2939
|
};
|
|
2675
2940
|
const handleSubmit = (event) => {
|
|
2676
2941
|
event.preventDefault();
|
|
2942
|
+
if (interactionLocked) return;
|
|
2677
2943
|
void submitValues();
|
|
2678
2944
|
};
|
|
2945
|
+
const confirmSubmission = () => {
|
|
2946
|
+
setConfirmation(null);
|
|
2947
|
+
void submitValues(true);
|
|
2948
|
+
};
|
|
2949
|
+
const cancelSubmission = () => {
|
|
2950
|
+
setConfirmation(null);
|
|
2951
|
+
};
|
|
2952
|
+
const resetReceipt = async () => {
|
|
2953
|
+
if (receiptStore === void 0) return;
|
|
2954
|
+
await receiptStore.remove(form.schema.id, form.schema.version);
|
|
2955
|
+
setReceipt(null);
|
|
2956
|
+
form.reset();
|
|
2957
|
+
};
|
|
2679
2958
|
const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
|
|
2680
2959
|
const canPrev = pages !== void 0 && activeVisibleIndex > 0;
|
|
2681
2960
|
const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
|
|
2682
|
-
const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting:
|
|
2961
|
+
const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: interactionLocked, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "fe-submit", type: "submit", disabled: interactionLocked, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
|
|
2962
|
+
if (!receiptLoaded) return null;
|
|
2963
|
+
if (receipt !== null) {
|
|
2964
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
|
|
2965
|
+
receipt,
|
|
2966
|
+
...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
|
|
2967
|
+
}) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { role: "status", children: [
|
|
2968
|
+
form.translate("form.alreadySubmitted"),
|
|
2969
|
+
receiptStore === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: () => void resetReceipt(), children: form.translate("form.submitAnother") })
|
|
2970
|
+
] }) });
|
|
2971
|
+
}
|
|
2683
2972
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
|
|
2684
2973
|
slots.renderHeader?.({
|
|
2685
2974
|
title: form.schema.title,
|
|
@@ -2727,10 +3016,11 @@ function ContextFormRenderer({
|
|
|
2727
3016
|
translate: form.translate,
|
|
2728
3017
|
inputId: `${prefix}-${field.id}`,
|
|
2729
3018
|
errorId: `${prefix}-${field.id}-error`,
|
|
2730
|
-
helpId: `${prefix}-${field.id}-help
|
|
3019
|
+
helpId: `${prefix}-${field.id}-help`,
|
|
3020
|
+
...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
|
|
2731
3021
|
};
|
|
2732
3022
|
if (slots.renderField !== void 0) {
|
|
2733
|
-
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
3023
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react5.Fragment, { children: slots.renderField({
|
|
2734
3024
|
question: field,
|
|
2735
3025
|
value: form.values[field.id],
|
|
2736
3026
|
onChange: (value) => {
|
|
@@ -2742,6 +3032,19 @@ function ContextFormRenderer({
|
|
|
2742
3032
|
const Component = components[field.type];
|
|
2743
3033
|
return Component === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(DefaultField, { ...props }, field.id) : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Component, { ...props }, field.id);
|
|
2744
3034
|
}) }),
|
|
3035
|
+
guardMessage === null ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: guardMessage }),
|
|
3036
|
+
confirmation === null ? null : slots.renderSubmissionConfirmation?.({
|
|
3037
|
+
findings: confirmation.findings,
|
|
3038
|
+
message: confirmation.message ?? form.translate("form.confirmSensitiveData"),
|
|
3039
|
+
schema: form.schema,
|
|
3040
|
+
visibleValues,
|
|
3041
|
+
onConfirm: confirmSubmission,
|
|
3042
|
+
onCancel: cancelSubmission
|
|
3043
|
+
}) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
|
|
3044
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation.message ?? form.translate("form.confirmSensitiveData") }),
|
|
3045
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: confirmSubmission, children: form.translate("form.confirmSubmission") }),
|
|
3046
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: cancelSubmission, children: form.translate("form.cancelSubmission") })
|
|
3047
|
+
] }),
|
|
2745
3048
|
validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
|
|
2746
3049
|
validationIssues.length,
|
|
2747
3050
|
" validation error",
|
|
@@ -2764,7 +3067,9 @@ function ContextFormRenderer({
|
|
|
2764
3067
|
totalPages: visiblePageIndexes.length,
|
|
2765
3068
|
canPrev,
|
|
2766
3069
|
canNext,
|
|
2767
|
-
onPrev: () =>
|
|
3070
|
+
onPrev: () => {
|
|
3071
|
+
if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
|
|
3072
|
+
},
|
|
2768
3073
|
onNext: handleNext
|
|
2769
3074
|
}) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
2770
3075
|
canPrev ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
@@ -2772,11 +3077,12 @@ function ContextFormRenderer({
|
|
|
2772
3077
|
{
|
|
2773
3078
|
className: "btn-prev",
|
|
2774
3079
|
type: "button",
|
|
3080
|
+
disabled: interactionLocked,
|
|
2775
3081
|
onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
|
|
2776
3082
|
children: form.translate("form.back")
|
|
2777
3083
|
}
|
|
2778
3084
|
) : null,
|
|
2779
|
-
canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
|
|
3085
|
+
canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
|
|
2780
3086
|
] }),
|
|
2781
3087
|
canNext ? null : renderSubmitButton()
|
|
2782
3088
|
] }),
|
|
@@ -2794,6 +3100,12 @@ var RENDERER_MESSAGES = {
|
|
|
2794
3100
|
"form.next": "Next",
|
|
2795
3101
|
"form.step": "Step {{current}} / {{total}}",
|
|
2796
3102
|
"form.draftRestored": "Draft restored",
|
|
3103
|
+
"form.submissionBlocked": "Submission blocked because sensitive data was detected.",
|
|
3104
|
+
"form.confirmSensitiveData": "Sensitive data may be included. Confirm before submitting.",
|
|
3105
|
+
"form.confirmSubmission": "Confirm submission",
|
|
3106
|
+
"form.cancelSubmission": "Cancel",
|
|
3107
|
+
"form.alreadySubmitted": "Already submitted.",
|
|
3108
|
+
"form.submitAnother": "Submit another response",
|
|
2797
3109
|
"validation.required": "This field is required."
|
|
2798
3110
|
};
|
|
2799
3111
|
var defaultRendererTranslator = {
|
|
@@ -2833,8 +3145,11 @@ function FormRenderer(props) {
|
|
|
2833
3145
|
FormBuilder,
|
|
2834
3146
|
FormProvider,
|
|
2835
3147
|
FormRenderer,
|
|
3148
|
+
createLocalStorageSubmissionReceiptStore,
|
|
2836
3149
|
resolveInitialFieldType,
|
|
3150
|
+
submissionReceiptQueryKey,
|
|
2837
3151
|
useField,
|
|
2838
3152
|
useForm,
|
|
2839
|
-
useFormBuilder
|
|
3153
|
+
useFormBuilder,
|
|
3154
|
+
useSubmissionReceipts
|
|
2840
3155
|
});
|