@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/dist/index.js
CHANGED
|
@@ -945,6 +945,9 @@ var FIELD_TYPES = [
|
|
|
945
945
|
];
|
|
946
946
|
var BUILDER_DEFAULTS = {
|
|
947
947
|
"builder.formBuilder": "Form builder",
|
|
948
|
+
"builder.basicSettings": "Basic settings",
|
|
949
|
+
"builder.formTitle": "Form title",
|
|
950
|
+
"builder.formDescription": "Form description",
|
|
948
951
|
"builder.moveUp": "Move {{title}} up",
|
|
949
952
|
"builder.moveDown": "Move {{title}} down",
|
|
950
953
|
"builder.delete": "Delete {{title}}",
|
|
@@ -1110,7 +1113,7 @@ function FormBuilder({
|
|
|
1110
1113
|
}) {
|
|
1111
1114
|
const resolvedComponents = { ...DEFAULT_COMPONENTS, ...componentOverrides };
|
|
1112
1115
|
const components = GUARDED_COMPONENTS;
|
|
1113
|
-
const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextInput } = components;
|
|
1116
|
+
const { Button, Checkbox, ErrorMessage, Fieldset, IconButton, Section, Select, TextArea, TextInput } = components;
|
|
1114
1117
|
const ToolbarSlot = slots?.toolbar;
|
|
1115
1118
|
const FieldEditorSlot = slots?.fieldEditor;
|
|
1116
1119
|
const OptionEditorSlot = slots?.optionEditor;
|
|
@@ -1413,6 +1416,42 @@ function FormBuilder({
|
|
|
1413
1416
|
}
|
|
1414
1417
|
},
|
|
1415
1418
|
children: /* @__PURE__ */ jsxs(Fieldset, { className: "form-engine-builder__controls", disabled: readOnly, children: [
|
|
1419
|
+
/* @__PURE__ */ jsx(
|
|
1420
|
+
Section,
|
|
1421
|
+
{
|
|
1422
|
+
className: "form-engine-builder__basic-settings",
|
|
1423
|
+
headingId: "builder-basic-settings-heading",
|
|
1424
|
+
title: translate("builder.basicSettings"),
|
|
1425
|
+
children: /* @__PURE__ */ jsxs("div", { className: "form-engine-builder__grid", children: [
|
|
1426
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
1427
|
+
translate("builder.formTitle"),
|
|
1428
|
+
/* @__PURE__ */ jsx(
|
|
1429
|
+
TextInput,
|
|
1430
|
+
{
|
|
1431
|
+
name: "title",
|
|
1432
|
+
required: true,
|
|
1433
|
+
error: schema.title.trim().length === 0,
|
|
1434
|
+
helperText: schema.title.trim().length === 0 ? translate("builder.required") : "",
|
|
1435
|
+
value: schema.title,
|
|
1436
|
+
onChange: (value) => setSourceText({ kind: "form" }, "title", value)
|
|
1437
|
+
}
|
|
1438
|
+
)
|
|
1439
|
+
] }),
|
|
1440
|
+
/* @__PURE__ */ jsxs("label", { children: [
|
|
1441
|
+
translate("builder.formDescription"),
|
|
1442
|
+
/* @__PURE__ */ jsx(
|
|
1443
|
+
TextArea,
|
|
1444
|
+
{
|
|
1445
|
+
name: "description",
|
|
1446
|
+
rows: 3,
|
|
1447
|
+
value: schema.description ?? "",
|
|
1448
|
+
onChange: (value) => setSourceText({ kind: "form" }, "description", value)
|
|
1449
|
+
}
|
|
1450
|
+
)
|
|
1451
|
+
] })
|
|
1452
|
+
] })
|
|
1453
|
+
}
|
|
1454
|
+
),
|
|
1416
1455
|
pagesEnabled ? PagesSlot === void 0 ? /* @__PURE__ */ jsx(
|
|
1417
1456
|
Section,
|
|
1418
1457
|
{
|
|
@@ -2179,7 +2218,7 @@ import {
|
|
|
2179
2218
|
validateAnswers,
|
|
2180
2219
|
validatePageAnswers
|
|
2181
2220
|
} from "@form-engine-ts/core";
|
|
2182
|
-
import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useState as useState2 } from "react";
|
|
2221
|
+
import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useRef, useState as useState2 } from "react";
|
|
2183
2222
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
2184
2223
|
var FormContext = createContext2(null);
|
|
2185
2224
|
function issuesByField(issues) {
|
|
@@ -2207,6 +2246,7 @@ function FormProvider({
|
|
|
2207
2246
|
const [submitStatus, setSubmitStatus] = useState2("idle");
|
|
2208
2247
|
const [submitError, setSubmitError] = useState2(null);
|
|
2209
2248
|
const [validationPageIndex, setValidationPageIndex] = useState2(null);
|
|
2249
|
+
const submissionInFlight = useRef(false);
|
|
2210
2250
|
const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
|
|
2211
2251
|
const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
|
|
2212
2252
|
useEffect(() => {
|
|
@@ -2260,6 +2300,7 @@ function FormProvider({
|
|
|
2260
2300
|
}, [initialValues]);
|
|
2261
2301
|
const submit = useCallback2(
|
|
2262
2302
|
async (beforeSubmit) => {
|
|
2303
|
+
if (submissionInFlight.current) return { status: "cancelled" };
|
|
2263
2304
|
const validation = validateAnswers(validSchema, values);
|
|
2264
2305
|
if (!validation.valid) {
|
|
2265
2306
|
setErrors(issuesByField(validation.issues));
|
|
@@ -2272,21 +2313,24 @@ function FormProvider({
|
|
|
2272
2313
|
setValidationPageIndex(null);
|
|
2273
2314
|
setSubmitError(null);
|
|
2274
2315
|
const visibleValues = selectVisibleAnswers(validSchema, values);
|
|
2316
|
+
submissionInFlight.current = true;
|
|
2275
2317
|
try {
|
|
2318
|
+
setSubmitStatus("submitting");
|
|
2276
2319
|
if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
|
|
2277
2320
|
setSubmitStatus("idle");
|
|
2278
2321
|
return { status: "cancelled" };
|
|
2279
2322
|
}
|
|
2280
|
-
|
|
2281
|
-
await onSubmit(visibleValues);
|
|
2323
|
+
const response = await onSubmit(visibleValues);
|
|
2282
2324
|
if (resetOnSuccess) setValues({ ...initialValues });
|
|
2283
2325
|
setSubmitStatus("success");
|
|
2284
|
-
return { status: "success" };
|
|
2326
|
+
return response === void 0 ? { status: "success" } : { status: "success", response };
|
|
2285
2327
|
} catch (cause) {
|
|
2286
2328
|
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
2287
2329
|
setSubmitError(error);
|
|
2288
2330
|
setSubmitStatus("error");
|
|
2289
2331
|
return { status: "error", error };
|
|
2332
|
+
} finally {
|
|
2333
|
+
submissionInFlight.current = false;
|
|
2290
2334
|
}
|
|
2291
2335
|
},
|
|
2292
2336
|
[initialValues, onSubmit, resetOnSuccess, validSchema, values]
|
|
@@ -2347,17 +2391,128 @@ function useField(fieldId) {
|
|
|
2347
2391
|
return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
|
|
2348
2392
|
}
|
|
2349
2393
|
|
|
2394
|
+
// src/receipt.ts
|
|
2395
|
+
import { useEffect as useEffect2, useMemo as useMemo3, useState as useState3 } from "react";
|
|
2396
|
+
function submissionReceiptQueryKey(formId, formVersion) {
|
|
2397
|
+
return `${formId}:v${formVersion}`;
|
|
2398
|
+
}
|
|
2399
|
+
function receiptKey(namespace, formId, formVersion) {
|
|
2400
|
+
return `${namespace}:${formId}:v${formVersion}`;
|
|
2401
|
+
}
|
|
2402
|
+
function browserStorage() {
|
|
2403
|
+
if (typeof window === "undefined") return null;
|
|
2404
|
+
try {
|
|
2405
|
+
return window.localStorage;
|
|
2406
|
+
} catch {
|
|
2407
|
+
return null;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
function parseReceipt(serialized) {
|
|
2411
|
+
try {
|
|
2412
|
+
const value = JSON.parse(serialized);
|
|
2413
|
+
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") {
|
|
2414
|
+
return null;
|
|
2415
|
+
}
|
|
2416
|
+
const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
|
|
2417
|
+
return {
|
|
2418
|
+
formId: value.formId,
|
|
2419
|
+
formVersion: value.formVersion,
|
|
2420
|
+
submittedAt: value.submittedAt,
|
|
2421
|
+
...submissionId === void 0 ? {} : { submissionId }
|
|
2422
|
+
};
|
|
2423
|
+
} catch {
|
|
2424
|
+
return null;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
2428
|
+
const namespace = options.namespace ?? "form_engine_receipt";
|
|
2429
|
+
if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
|
|
2430
|
+
const get = async (formId, formVersion) => {
|
|
2431
|
+
const storage = browserStorage();
|
|
2432
|
+
if (storage === null) return null;
|
|
2433
|
+
try {
|
|
2434
|
+
const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
|
|
2435
|
+
if (serialized === null) return null;
|
|
2436
|
+
const receipt = parseReceipt(serialized);
|
|
2437
|
+
return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
|
|
2438
|
+
} catch {
|
|
2439
|
+
return null;
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
return {
|
|
2443
|
+
get,
|
|
2444
|
+
async getBatch(queries) {
|
|
2445
|
+
const receipts = await Promise.all(queries.map((query) => get(query.formId, query.formVersion)));
|
|
2446
|
+
return new Map(
|
|
2447
|
+
receipts.flatMap(
|
|
2448
|
+
(receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
|
|
2449
|
+
)
|
|
2450
|
+
);
|
|
2451
|
+
},
|
|
2452
|
+
async save(receipt) {
|
|
2453
|
+
const storage = browserStorage();
|
|
2454
|
+
if (storage === null) return;
|
|
2455
|
+
storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
|
|
2456
|
+
},
|
|
2457
|
+
async remove(formId, formVersion) {
|
|
2458
|
+
const storage = browserStorage();
|
|
2459
|
+
if (storage === null) return;
|
|
2460
|
+
storage.removeItem(receiptKey(namespace, formId, formVersion));
|
|
2461
|
+
}
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
function useSubmissionReceipts(store, queries) {
|
|
2465
|
+
const querySignature = JSON.stringify(queries.map(({ formId, formVersion }) => [formId, formVersion]));
|
|
2466
|
+
const stableQueries = useMemo3(() => {
|
|
2467
|
+
const parsed = JSON.parse(querySignature);
|
|
2468
|
+
if (!Array.isArray(parsed)) return [];
|
|
2469
|
+
return parsed.flatMap(
|
|
2470
|
+
(entry) => Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "number" && Number.isSafeInteger(entry[1]) ? [{ formId: entry[0], formVersion: entry[1] }] : []
|
|
2471
|
+
);
|
|
2472
|
+
}, [querySignature]);
|
|
2473
|
+
const [state, setState] = useState3({
|
|
2474
|
+
receipts: /* @__PURE__ */ new Map(),
|
|
2475
|
+
isLoading: stableQueries.length > 0,
|
|
2476
|
+
error: null
|
|
2477
|
+
});
|
|
2478
|
+
useEffect2(() => {
|
|
2479
|
+
let active = true;
|
|
2480
|
+
if (stableQueries.length === 0) {
|
|
2481
|
+
setState({ receipts: /* @__PURE__ */ new Map(), isLoading: false, error: null });
|
|
2482
|
+
return () => {
|
|
2483
|
+
active = false;
|
|
2484
|
+
};
|
|
2485
|
+
}
|
|
2486
|
+
setState((current) => ({ ...current, isLoading: true, error: null }));
|
|
2487
|
+
void store.getBatch(stableQueries).then((receipts) => {
|
|
2488
|
+
if (active) setState({ receipts, isLoading: false, error: null });
|
|
2489
|
+
}).catch((cause) => {
|
|
2490
|
+
if (!active) return;
|
|
2491
|
+
setState({
|
|
2492
|
+
receipts: /* @__PURE__ */ new Map(),
|
|
2493
|
+
isLoading: false,
|
|
2494
|
+
error: cause instanceof Error ? cause : new Error(String(cause))
|
|
2495
|
+
});
|
|
2496
|
+
});
|
|
2497
|
+
return () => {
|
|
2498
|
+
active = false;
|
|
2499
|
+
};
|
|
2500
|
+
}, [stableQueries, store]);
|
|
2501
|
+
return state;
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2350
2504
|
// src/renderer.tsx
|
|
2351
2505
|
import {
|
|
2506
|
+
selectVisibleAnswers as selectVisibleAnswers2,
|
|
2352
2507
|
validateAnswers as validateAnswers2
|
|
2353
2508
|
} from "@form-engine-ts/core";
|
|
2354
2509
|
import {
|
|
2355
2510
|
Fragment as Fragment2,
|
|
2356
|
-
useEffect as
|
|
2511
|
+
useEffect as useEffect3,
|
|
2357
2512
|
useId,
|
|
2358
|
-
useMemo as
|
|
2359
|
-
useRef,
|
|
2360
|
-
useState as
|
|
2513
|
+
useMemo as useMemo4,
|
|
2514
|
+
useRef as useRef2,
|
|
2515
|
+
useState as useState4
|
|
2361
2516
|
} from "react";
|
|
2362
2517
|
import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
2363
2518
|
function describedBy(field, error, helpId, errorId) {
|
|
@@ -2473,11 +2628,17 @@ function DefaultField(props) {
|
|
|
2473
2628
|
/* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
|
|
2474
2629
|
] });
|
|
2475
2630
|
let control;
|
|
2631
|
+
const textConstraints = field.type === "text" || field.type === "textarea" ? {
|
|
2632
|
+
minLength: field.minLength,
|
|
2633
|
+
maxLength: field.maxLength,
|
|
2634
|
+
...field.pattern === void 0 ? {} : { pattern: field.pattern }
|
|
2635
|
+
} : {};
|
|
2476
2636
|
if (field.type === "textarea") {
|
|
2477
2637
|
control = /* @__PURE__ */ jsx3(
|
|
2478
2638
|
"textarea",
|
|
2479
2639
|
{
|
|
2480
2640
|
...ariaProps,
|
|
2641
|
+
...textConstraints,
|
|
2481
2642
|
id: inputId,
|
|
2482
2643
|
name: field.id,
|
|
2483
2644
|
placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
|
|
@@ -2490,6 +2651,7 @@ function DefaultField(props) {
|
|
|
2490
2651
|
"input",
|
|
2491
2652
|
{
|
|
2492
2653
|
...ariaProps,
|
|
2654
|
+
...textConstraints,
|
|
2493
2655
|
id: inputId,
|
|
2494
2656
|
name: field.id,
|
|
2495
2657
|
type: "number",
|
|
@@ -2534,6 +2696,11 @@ function DefaultField(props) {
|
|
|
2534
2696
|
return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
|
|
2535
2697
|
label,
|
|
2536
2698
|
control,
|
|
2699
|
+
(field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
|
|
2700
|
+
fieldId: field.id,
|
|
2701
|
+
current: typeof value === "string" ? value.length : 0,
|
|
2702
|
+
max: field.maxLength
|
|
2703
|
+
}) : null,
|
|
2537
2704
|
/* @__PURE__ */ jsx3(FieldMessage, { props })
|
|
2538
2705
|
] });
|
|
2539
2706
|
}
|
|
@@ -2569,31 +2736,62 @@ function ContextFormRenderer({
|
|
|
2569
2736
|
autoSaveKey,
|
|
2570
2737
|
beforeSubmit,
|
|
2571
2738
|
onDraftSave,
|
|
2739
|
+
submissionGuards = [],
|
|
2740
|
+
receiptStore,
|
|
2572
2741
|
slots = {}
|
|
2573
2742
|
}) {
|
|
2574
2743
|
const form = useForm();
|
|
2575
2744
|
const prefix = useId().replace(/:/g, "");
|
|
2576
|
-
const formRef =
|
|
2577
|
-
const loadedDraftKey =
|
|
2578
|
-
const [draftRestored, setDraftRestored] =
|
|
2579
|
-
const [currentPageIndex, setCurrentPageIndex] =
|
|
2580
|
-
const [focusFieldId, setFocusFieldId] =
|
|
2745
|
+
const formRef = useRef2(null);
|
|
2746
|
+
const loadedDraftKey = useRef2(null);
|
|
2747
|
+
const [draftRestored, setDraftRestored] = useState4(false);
|
|
2748
|
+
const [currentPageIndex, setCurrentPageIndex] = useState4(0);
|
|
2749
|
+
const [focusFieldId, setFocusFieldId] = useState4(null);
|
|
2750
|
+
const [confirmation, setConfirmation] = useState4(null);
|
|
2751
|
+
const [guardMessage, setGuardMessage] = useState4(null);
|
|
2752
|
+
const [receipt, setReceipt] = useState4(null);
|
|
2753
|
+
const [receiptLoaded, setReceiptLoaded] = useState4(receiptStore === void 0);
|
|
2754
|
+
const rendererSubmissionInFlight = useRef2(false);
|
|
2581
2755
|
const pages = form.schema.pages;
|
|
2582
|
-
const visiblePageIndexes =
|
|
2756
|
+
const visiblePageIndexes = useMemo4(
|
|
2583
2757
|
() => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
|
|
2584
2758
|
[form.pageVisibility, pages]
|
|
2585
2759
|
);
|
|
2586
2760
|
const activePage = pages?.[currentPageIndex];
|
|
2587
2761
|
const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
|
|
2588
2762
|
const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
|
|
2589
|
-
|
|
2763
|
+
const visibleValues = useMemo4(() => selectVisibleAnswers2(form.schema, form.values), [form.schema, form.values]);
|
|
2764
|
+
const submitState = confirmation === null ? form.submitStatus : "confirming";
|
|
2765
|
+
const interactionLocked = submitState === "confirming" || submitState === "submitting";
|
|
2766
|
+
useEffect3(() => {
|
|
2767
|
+
let active = true;
|
|
2768
|
+
if (receiptStore === void 0) {
|
|
2769
|
+
setReceipt(null);
|
|
2770
|
+
setReceiptLoaded(true);
|
|
2771
|
+
return () => {
|
|
2772
|
+
active = false;
|
|
2773
|
+
};
|
|
2774
|
+
}
|
|
2775
|
+
setReceiptLoaded(false);
|
|
2776
|
+
void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
|
|
2777
|
+
if (active) setReceipt(stored);
|
|
2778
|
+
}).catch(() => {
|
|
2779
|
+
if (active) setReceipt(null);
|
|
2780
|
+
}).finally(() => {
|
|
2781
|
+
if (active) setReceiptLoaded(true);
|
|
2782
|
+
});
|
|
2783
|
+
return () => {
|
|
2784
|
+
active = false;
|
|
2785
|
+
};
|
|
2786
|
+
}, [form.schema.id, form.schema.version, receiptStore]);
|
|
2787
|
+
useEffect3(() => {
|
|
2590
2788
|
if (pages === void 0 || visiblePageIndexes.length === 0) {
|
|
2591
2789
|
setCurrentPageIndex(0);
|
|
2592
2790
|
return;
|
|
2593
2791
|
}
|
|
2594
2792
|
if (!visiblePageIndexes.includes(currentPageIndex)) setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2595
2793
|
}, [currentPageIndex, pages, visiblePageIndexes]);
|
|
2596
|
-
|
|
2794
|
+
useEffect3(() => {
|
|
2597
2795
|
if (focusFieldId === null) return;
|
|
2598
2796
|
const fieldContainer = [...formRef.current?.querySelectorAll("[data-field-id]") ?? []].find(
|
|
2599
2797
|
(element) => element.dataset.fieldId === focusFieldId
|
|
@@ -2604,7 +2802,7 @@ function ContextFormRenderer({
|
|
|
2604
2802
|
setFocusFieldId(null);
|
|
2605
2803
|
}
|
|
2606
2804
|
}, [focusFieldId]);
|
|
2607
|
-
|
|
2805
|
+
useEffect3(() => {
|
|
2608
2806
|
if (autoSaveKey === void 0 || typeof globalThis.localStorage === "undefined") return;
|
|
2609
2807
|
const loadIdentity = `${autoSaveKey}:${form.schema.id}:${form.schema.version}`;
|
|
2610
2808
|
if (loadedDraftKey.current === loadIdentity) return;
|
|
@@ -2616,7 +2814,7 @@ function ContextFormRenderer({
|
|
|
2616
2814
|
form.restoreValues(draft.values);
|
|
2617
2815
|
setDraftRestored(true);
|
|
2618
2816
|
}, [autoSaveKey, form.restoreValues, form.schema.id, form.schema.version]);
|
|
2619
|
-
|
|
2817
|
+
useEffect3(() => {
|
|
2620
2818
|
if (form.submitStatus === "success") return;
|
|
2621
2819
|
const timeout = globalThis.setTimeout(() => {
|
|
2622
2820
|
onDraftSave?.(form.values);
|
|
@@ -2635,6 +2833,7 @@ function ContextFormRenderer({
|
|
|
2635
2833
|
if (fieldId !== void 0) setFocusFieldId(fieldId);
|
|
2636
2834
|
};
|
|
2637
2835
|
const handleNext = () => {
|
|
2836
|
+
if (interactionLocked) return;
|
|
2638
2837
|
const result = form.validatePage(currentPageIndex);
|
|
2639
2838
|
if (!result.valid) {
|
|
2640
2839
|
focusFirstIssue(result.issues[0]?.fieldId);
|
|
@@ -2643,34 +2842,122 @@ function ContextFormRenderer({
|
|
|
2643
2842
|
const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
|
|
2644
2843
|
if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
|
|
2645
2844
|
};
|
|
2646
|
-
const
|
|
2845
|
+
const runSubmissionGuards = async (guards) => {
|
|
2846
|
+
const findings = [];
|
|
2847
|
+
let confirmationMessage;
|
|
2848
|
+
let requiresConfirmation = false;
|
|
2849
|
+
for (const guard of guards) {
|
|
2850
|
+
const result = await guard(form.schema, visibleValues);
|
|
2851
|
+
if (result.status === "allow") continue;
|
|
2852
|
+
findings.push(...result.findings);
|
|
2853
|
+
if (result.status === "block") {
|
|
2854
|
+
return {
|
|
2855
|
+
status: "block",
|
|
2856
|
+
findings,
|
|
2857
|
+
...result.message === void 0 ? {} : { message: result.message }
|
|
2858
|
+
};
|
|
2859
|
+
}
|
|
2860
|
+
requiresConfirmation = true;
|
|
2861
|
+
confirmationMessage ??= result.message;
|
|
2862
|
+
}
|
|
2863
|
+
return !requiresConfirmation ? { status: "allow" } : {
|
|
2864
|
+
status: "confirm",
|
|
2865
|
+
findings,
|
|
2866
|
+
...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
|
|
2867
|
+
};
|
|
2868
|
+
};
|
|
2869
|
+
const submitValues = async (guardsConfirmed = false) => {
|
|
2870
|
+
if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
|
|
2871
|
+
return { status: "cancelled" };
|
|
2872
|
+
}
|
|
2647
2873
|
const validation = validateAnswers2(form.schema, form.values);
|
|
2648
2874
|
const firstInvalidFieldId = validation.issues[0]?.fieldId;
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2875
|
+
if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
|
|
2876
|
+
rendererSubmissionInFlight.current = true;
|
|
2877
|
+
try {
|
|
2878
|
+
const guardResult = await runSubmissionGuards(submissionGuards);
|
|
2879
|
+
if (guardResult.status === "block") {
|
|
2880
|
+
setGuardMessage(guardResult.message ?? form.translate("form.submissionBlocked"));
|
|
2881
|
+
return { status: "cancelled" };
|
|
2882
|
+
}
|
|
2883
|
+
if (guardResult.status === "confirm") {
|
|
2884
|
+
setGuardMessage(null);
|
|
2885
|
+
setConfirmation({
|
|
2886
|
+
findings: guardResult.findings,
|
|
2887
|
+
...guardResult.message === void 0 ? {} : { message: guardResult.message }
|
|
2888
|
+
});
|
|
2889
|
+
return { status: "cancelled" };
|
|
2890
|
+
}
|
|
2891
|
+
} finally {
|
|
2892
|
+
rendererSubmissionInFlight.current = false;
|
|
2893
|
+
}
|
|
2657
2894
|
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2895
|
+
setGuardMessage(null);
|
|
2896
|
+
rendererSubmissionInFlight.current = true;
|
|
2897
|
+
try {
|
|
2898
|
+
const result = await form.submit(beforeSubmit);
|
|
2899
|
+
if (result.status === "invalid") {
|
|
2900
|
+
const invalidPageIndex = pages?.findIndex(
|
|
2901
|
+
(page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
|
|
2902
|
+
);
|
|
2903
|
+
if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
|
|
2904
|
+
focusFirstIssue(firstInvalidFieldId);
|
|
2905
|
+
return result;
|
|
2906
|
+
}
|
|
2907
|
+
if (result.status !== "success") return result;
|
|
2908
|
+
if (receiptStore !== void 0) {
|
|
2909
|
+
const response = result.response;
|
|
2910
|
+
const storedReceipt = {
|
|
2911
|
+
formId: form.schema.id,
|
|
2912
|
+
formVersion: form.schema.version,
|
|
2913
|
+
submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2914
|
+
...response?.submissionId === void 0 ? {} : { submissionId: response.submissionId }
|
|
2915
|
+
};
|
|
2916
|
+
await receiptStore.save(storedReceipt);
|
|
2917
|
+
setReceipt(storedReceipt);
|
|
2918
|
+
}
|
|
2919
|
+
if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
|
|
2920
|
+
globalThis.localStorage.removeItem(autoSaveKey);
|
|
2921
|
+
setDraftRestored(false);
|
|
2922
|
+
}
|
|
2923
|
+
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2924
|
+
return result;
|
|
2925
|
+
} finally {
|
|
2926
|
+
rendererSubmissionInFlight.current = false;
|
|
2662
2927
|
}
|
|
2663
|
-
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2664
|
-
return result;
|
|
2665
2928
|
};
|
|
2666
2929
|
const handleSubmit = (event) => {
|
|
2667
2930
|
event.preventDefault();
|
|
2931
|
+
if (interactionLocked) return;
|
|
2668
2932
|
void submitValues();
|
|
2669
2933
|
};
|
|
2934
|
+
const confirmSubmission = () => {
|
|
2935
|
+
setConfirmation(null);
|
|
2936
|
+
void submitValues(true);
|
|
2937
|
+
};
|
|
2938
|
+
const cancelSubmission = () => {
|
|
2939
|
+
setConfirmation(null);
|
|
2940
|
+
};
|
|
2941
|
+
const resetReceipt = async () => {
|
|
2942
|
+
if (receiptStore === void 0) return;
|
|
2943
|
+
await receiptStore.remove(form.schema.id, form.schema.version);
|
|
2944
|
+
setReceipt(null);
|
|
2945
|
+
form.reset();
|
|
2946
|
+
};
|
|
2670
2947
|
const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
|
|
2671
2948
|
const canPrev = pages !== void 0 && activeVisibleIndex > 0;
|
|
2672
2949
|
const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
|
|
2673
|
-
const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting:
|
|
2950
|
+
const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting: interactionLocked, onSubmit: () => void submitValues() }) ?? /* @__PURE__ */ jsx3("button", { className: "fe-submit", type: "submit", disabled: interactionLocked, children: form.translate(form.schema.submitLabelKey ?? "form.submit") });
|
|
2951
|
+
if (!receiptLoaded) return null;
|
|
2952
|
+
if (receipt !== null) {
|
|
2953
|
+
return /* @__PURE__ */ jsx3("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
|
|
2954
|
+
receipt,
|
|
2955
|
+
...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
|
|
2956
|
+
}) ?? /* @__PURE__ */ jsxs2("div", { role: "status", children: [
|
|
2957
|
+
form.translate("form.alreadySubmitted"),
|
|
2958
|
+
receiptStore === void 0 ? null : /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => void resetReceipt(), children: form.translate("form.submitAnother") })
|
|
2959
|
+
] }) });
|
|
2960
|
+
}
|
|
2674
2961
|
return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
|
|
2675
2962
|
slots.renderHeader?.({
|
|
2676
2963
|
title: form.schema.title,
|
|
@@ -2718,7 +3005,8 @@ function ContextFormRenderer({
|
|
|
2718
3005
|
translate: form.translate,
|
|
2719
3006
|
inputId: `${prefix}-${field.id}`,
|
|
2720
3007
|
errorId: `${prefix}-${field.id}-error`,
|
|
2721
|
-
helpId: `${prefix}-${field.id}-help
|
|
3008
|
+
helpId: `${prefix}-${field.id}-help`,
|
|
3009
|
+
...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
|
|
2722
3010
|
};
|
|
2723
3011
|
if (slots.renderField !== void 0) {
|
|
2724
3012
|
return /* @__PURE__ */ jsx3(Fragment2, { children: slots.renderField({
|
|
@@ -2733,6 +3021,19 @@ function ContextFormRenderer({
|
|
|
2733
3021
|
const Component = components[field.type];
|
|
2734
3022
|
return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
|
|
2735
3023
|
}) }),
|
|
3024
|
+
guardMessage === null ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: guardMessage }),
|
|
3025
|
+
confirmation === null ? null : slots.renderSubmissionConfirmation?.({
|
|
3026
|
+
findings: confirmation.findings,
|
|
3027
|
+
message: confirmation.message ?? form.translate("form.confirmSensitiveData"),
|
|
3028
|
+
schema: form.schema,
|
|
3029
|
+
visibleValues,
|
|
3030
|
+
onConfirm: confirmSubmission,
|
|
3031
|
+
onCancel: cancelSubmission
|
|
3032
|
+
}) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
|
|
3033
|
+
/* @__PURE__ */ jsx3("p", { children: confirmation.message ?? form.translate("form.confirmSensitiveData") }),
|
|
3034
|
+
/* @__PURE__ */ jsx3("button", { type: "button", onClick: confirmSubmission, children: form.translate("form.confirmSubmission") }),
|
|
3035
|
+
/* @__PURE__ */ jsx3("button", { type: "button", onClick: cancelSubmission, children: form.translate("form.cancelSubmission") })
|
|
3036
|
+
] }),
|
|
2736
3037
|
validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
|
|
2737
3038
|
validationIssues.length,
|
|
2738
3039
|
" validation error",
|
|
@@ -2755,7 +3056,9 @@ function ContextFormRenderer({
|
|
|
2755
3056
|
totalPages: visiblePageIndexes.length,
|
|
2756
3057
|
canPrev,
|
|
2757
3058
|
canNext,
|
|
2758
|
-
onPrev: () =>
|
|
3059
|
+
onPrev: () => {
|
|
3060
|
+
if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
|
|
3061
|
+
},
|
|
2759
3062
|
onNext: handleNext
|
|
2760
3063
|
}) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
|
|
2761
3064
|
canPrev ? /* @__PURE__ */ jsx3(
|
|
@@ -2763,11 +3066,12 @@ function ContextFormRenderer({
|
|
|
2763
3066
|
{
|
|
2764
3067
|
className: "btn-prev",
|
|
2765
3068
|
type: "button",
|
|
3069
|
+
disabled: interactionLocked,
|
|
2766
3070
|
onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
|
|
2767
3071
|
children: form.translate("form.back")
|
|
2768
3072
|
}
|
|
2769
3073
|
) : null,
|
|
2770
|
-
canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
|
|
3074
|
+
canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
|
|
2771
3075
|
] }),
|
|
2772
3076
|
canNext ? null : renderSubmitButton()
|
|
2773
3077
|
] }),
|
|
@@ -2785,6 +3089,12 @@ var RENDERER_MESSAGES = {
|
|
|
2785
3089
|
"form.next": "Next",
|
|
2786
3090
|
"form.step": "Step {{current}} / {{total}}",
|
|
2787
3091
|
"form.draftRestored": "Draft restored",
|
|
3092
|
+
"form.submissionBlocked": "Submission blocked because sensitive data was detected.",
|
|
3093
|
+
"form.confirmSensitiveData": "Sensitive data may be included. Confirm before submitting.",
|
|
3094
|
+
"form.confirmSubmission": "Confirm submission",
|
|
3095
|
+
"form.cancelSubmission": "Cancel",
|
|
3096
|
+
"form.alreadySubmitted": "Already submitted.",
|
|
3097
|
+
"form.submitAnother": "Submit another response",
|
|
2788
3098
|
"validation.required": "This field is required."
|
|
2789
3099
|
};
|
|
2790
3100
|
var defaultRendererTranslator = {
|
|
@@ -2823,8 +3133,11 @@ export {
|
|
|
2823
3133
|
FormBuilder,
|
|
2824
3134
|
FormProvider,
|
|
2825
3135
|
FormRenderer,
|
|
3136
|
+
createLocalStorageSubmissionReceiptStore,
|
|
2826
3137
|
resolveInitialFieldType,
|
|
3138
|
+
submissionReceiptQueryKey,
|
|
2827
3139
|
useField,
|
|
2828
3140
|
useForm,
|
|
2829
|
-
useFormBuilder
|
|
3141
|
+
useFormBuilder,
|
|
3142
|
+
useSubmissionReceipts
|
|
2830
3143
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@form-engine-ts/react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -42,14 +42,15 @@
|
|
|
42
42
|
"typescript"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@form-engine-ts/core": "2.
|
|
45
|
+
"@form-engine-ts/core": "2.8.0",
|
|
46
|
+
"@form-engine-ts/privacy": "2.8.0"
|
|
46
47
|
},
|
|
47
48
|
"peerDependencies": {
|
|
48
49
|
"react": ">=18.2 <20",
|
|
49
50
|
"react-dom": ">=18.2 <20"
|
|
50
51
|
},
|
|
51
52
|
"scripts": {
|
|
52
|
-
"build": "tsup src/index.ts src/styles.css --format esm,cjs --dts --clean --external react --external react/jsx-runtime --external @form-engine-ts/core",
|
|
53
|
+
"build": "tsup src/index.ts src/styles.css --format esm,cjs --dts --clean --external react --external react/jsx-runtime --external @form-engine-ts/core --external @form-engine-ts/privacy",
|
|
53
54
|
"check": "biome check . && tsc --noEmit",
|
|
54
55
|
"test": "vitest run --globals",
|
|
55
56
|
"typecheck": "tsc --noEmit"
|