@form-engine-ts/react 2.6.0 → 2.7.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 +7 -0
- package/dist/index.cjs +228 -20
- package/dist/index.d.cts +50 -2
- package/dist/index.d.ts +50 -2
- package/dist/index.js +232 -24
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -116,3 +116,10 @@ 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.
|
package/dist/index.cjs
CHANGED
|
@@ -23,6 +23,7 @@ __export(index_exports, {
|
|
|
23
23
|
FormBuilder: () => FormBuilder,
|
|
24
24
|
FormProvider: () => FormProvider,
|
|
25
25
|
FormRenderer: () => FormRenderer,
|
|
26
|
+
createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
|
|
26
27
|
resolveInitialFieldType: () => resolveInitialFieldType,
|
|
27
28
|
useField: () => useField,
|
|
28
29
|
useForm: () => useForm,
|
|
@@ -2225,6 +2226,7 @@ function FormProvider({
|
|
|
2225
2226
|
const [submitStatus, setSubmitStatus] = (0, import_react3.useState)("idle");
|
|
2226
2227
|
const [submitError, setSubmitError] = (0, import_react3.useState)(null);
|
|
2227
2228
|
const [validationPageIndex, setValidationPageIndex] = (0, import_react3.useState)(null);
|
|
2229
|
+
const submissionInFlight = (0, import_react3.useRef)(false);
|
|
2228
2230
|
const visibility = (0, import_react3.useMemo)(() => (0, import_core3.calculateFieldVisibility)(validSchema, values), [validSchema, values]);
|
|
2229
2231
|
const pageVisibility = (0, import_react3.useMemo)(() => (0, import_core3.calculatePageVisibility)(validSchema, values), [validSchema, values]);
|
|
2230
2232
|
(0, import_react3.useEffect)(() => {
|
|
@@ -2278,6 +2280,7 @@ function FormProvider({
|
|
|
2278
2280
|
}, [initialValues]);
|
|
2279
2281
|
const submit = (0, import_react3.useCallback)(
|
|
2280
2282
|
async (beforeSubmit) => {
|
|
2283
|
+
if (submissionInFlight.current) return { status: "cancelled" };
|
|
2281
2284
|
const validation = (0, import_core3.validateAnswers)(validSchema, values);
|
|
2282
2285
|
if (!validation.valid) {
|
|
2283
2286
|
setErrors(issuesByField(validation.issues));
|
|
@@ -2290,12 +2293,13 @@ function FormProvider({
|
|
|
2290
2293
|
setValidationPageIndex(null);
|
|
2291
2294
|
setSubmitError(null);
|
|
2292
2295
|
const visibleValues = (0, import_core3.selectVisibleAnswers)(validSchema, values);
|
|
2296
|
+
submissionInFlight.current = true;
|
|
2293
2297
|
try {
|
|
2298
|
+
setSubmitStatus("submitting");
|
|
2294
2299
|
if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
|
|
2295
2300
|
setSubmitStatus("idle");
|
|
2296
2301
|
return { status: "cancelled" };
|
|
2297
2302
|
}
|
|
2298
|
-
setSubmitStatus("submitting");
|
|
2299
2303
|
await onSubmit(visibleValues);
|
|
2300
2304
|
if (resetOnSuccess) setValues({ ...initialValues });
|
|
2301
2305
|
setSubmitStatus("success");
|
|
@@ -2305,6 +2309,8 @@ function FormProvider({
|
|
|
2305
2309
|
setSubmitError(error);
|
|
2306
2310
|
setSubmitStatus("error");
|
|
2307
2311
|
return { status: "error", error };
|
|
2312
|
+
} finally {
|
|
2313
|
+
submissionInFlight.current = false;
|
|
2308
2314
|
}
|
|
2309
2315
|
},
|
|
2310
2316
|
[initialValues, onSubmit, resetOnSuccess, validSchema, values]
|
|
@@ -2365,6 +2371,64 @@ function useField(fieldId) {
|
|
|
2365
2371
|
return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
|
|
2366
2372
|
}
|
|
2367
2373
|
|
|
2374
|
+
// src/receipt.ts
|
|
2375
|
+
function receiptKey(namespace, formId, formVersion) {
|
|
2376
|
+
return `${namespace}:${formId}:v${formVersion}`;
|
|
2377
|
+
}
|
|
2378
|
+
function browserStorage() {
|
|
2379
|
+
if (typeof window === "undefined") return null;
|
|
2380
|
+
try {
|
|
2381
|
+
return window.localStorage;
|
|
2382
|
+
} catch {
|
|
2383
|
+
return null;
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
function parseReceipt(serialized) {
|
|
2387
|
+
try {
|
|
2388
|
+
const value = JSON.parse(serialized);
|
|
2389
|
+
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") {
|
|
2390
|
+
return null;
|
|
2391
|
+
}
|
|
2392
|
+
const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
|
|
2393
|
+
return {
|
|
2394
|
+
formId: value.formId,
|
|
2395
|
+
formVersion: value.formVersion,
|
|
2396
|
+
submittedAt: value.submittedAt,
|
|
2397
|
+
...submissionId === void 0 ? {} : { submissionId }
|
|
2398
|
+
};
|
|
2399
|
+
} catch {
|
|
2400
|
+
return null;
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
2404
|
+
const namespace = options.namespace ?? "form_engine_receipt";
|
|
2405
|
+
if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
|
|
2406
|
+
return {
|
|
2407
|
+
async get(formId, formVersion) {
|
|
2408
|
+
const storage = browserStorage();
|
|
2409
|
+
if (storage === null) return null;
|
|
2410
|
+
try {
|
|
2411
|
+
const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
|
|
2412
|
+
if (serialized === null) return null;
|
|
2413
|
+
const receipt = parseReceipt(serialized);
|
|
2414
|
+
return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
|
|
2415
|
+
} catch {
|
|
2416
|
+
return null;
|
|
2417
|
+
}
|
|
2418
|
+
},
|
|
2419
|
+
async save(receipt) {
|
|
2420
|
+
const storage = browserStorage();
|
|
2421
|
+
if (storage === null) return;
|
|
2422
|
+
storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
|
|
2423
|
+
},
|
|
2424
|
+
async remove(formId, formVersion) {
|
|
2425
|
+
const storage = browserStorage();
|
|
2426
|
+
if (storage === null) return;
|
|
2427
|
+
storage.removeItem(receiptKey(namespace, formId, formVersion));
|
|
2428
|
+
}
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2368
2432
|
// src/renderer.tsx
|
|
2369
2433
|
var import_core4 = require("@form-engine-ts/core");
|
|
2370
2434
|
var import_react4 = require("react");
|
|
@@ -2482,11 +2546,17 @@ function DefaultField(props) {
|
|
|
2482
2546
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(RequiredMark, { required: field.required })
|
|
2483
2547
|
] });
|
|
2484
2548
|
let control;
|
|
2549
|
+
const textConstraints = field.type === "text" || field.type === "textarea" ? {
|
|
2550
|
+
minLength: field.minLength,
|
|
2551
|
+
maxLength: field.maxLength,
|
|
2552
|
+
...field.pattern === void 0 ? {} : { pattern: field.pattern }
|
|
2553
|
+
} : {};
|
|
2485
2554
|
if (field.type === "textarea") {
|
|
2486
2555
|
control = /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
2487
2556
|
"textarea",
|
|
2488
2557
|
{
|
|
2489
2558
|
...ariaProps,
|
|
2559
|
+
...textConstraints,
|
|
2490
2560
|
id: inputId,
|
|
2491
2561
|
name: field.id,
|
|
2492
2562
|
placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
|
|
@@ -2499,6 +2569,7 @@ function DefaultField(props) {
|
|
|
2499
2569
|
"input",
|
|
2500
2570
|
{
|
|
2501
2571
|
...ariaProps,
|
|
2572
|
+
...textConstraints,
|
|
2502
2573
|
id: inputId,
|
|
2503
2574
|
name: field.id,
|
|
2504
2575
|
type: "number",
|
|
@@ -2543,6 +2614,11 @@ function DefaultField(props) {
|
|
|
2543
2614
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
|
|
2544
2615
|
label,
|
|
2545
2616
|
control,
|
|
2617
|
+
(field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
|
|
2618
|
+
fieldId: field.id,
|
|
2619
|
+
current: typeof value === "string" ? value.length : 0,
|
|
2620
|
+
max: field.maxLength
|
|
2621
|
+
}) : null,
|
|
2546
2622
|
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)(FieldMessage, { props })
|
|
2547
2623
|
] });
|
|
2548
2624
|
}
|
|
@@ -2578,6 +2654,8 @@ function ContextFormRenderer({
|
|
|
2578
2654
|
autoSaveKey,
|
|
2579
2655
|
beforeSubmit,
|
|
2580
2656
|
onDraftSave,
|
|
2657
|
+
submissionGuards = [],
|
|
2658
|
+
receiptStore,
|
|
2581
2659
|
slots = {}
|
|
2582
2660
|
}) {
|
|
2583
2661
|
const form = useForm();
|
|
@@ -2587,6 +2665,11 @@ function ContextFormRenderer({
|
|
|
2587
2665
|
const [draftRestored, setDraftRestored] = (0, import_react4.useState)(false);
|
|
2588
2666
|
const [currentPageIndex, setCurrentPageIndex] = (0, import_react4.useState)(0);
|
|
2589
2667
|
const [focusFieldId, setFocusFieldId] = (0, import_react4.useState)(null);
|
|
2668
|
+
const [confirmation, setConfirmation] = (0, import_react4.useState)(null);
|
|
2669
|
+
const [guardMessage, setGuardMessage] = (0, import_react4.useState)(null);
|
|
2670
|
+
const [receipt, setReceipt] = (0, import_react4.useState)(null);
|
|
2671
|
+
const [receiptLoaded, setReceiptLoaded] = (0, import_react4.useState)(receiptStore === void 0);
|
|
2672
|
+
const rendererSubmissionInFlight = (0, import_react4.useRef)(false);
|
|
2590
2673
|
const pages = form.schema.pages;
|
|
2591
2674
|
const visiblePageIndexes = (0, import_react4.useMemo)(
|
|
2592
2675
|
() => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
|
|
@@ -2595,6 +2678,29 @@ function ContextFormRenderer({
|
|
|
2595
2678
|
const activePage = pages?.[currentPageIndex];
|
|
2596
2679
|
const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
|
|
2597
2680
|
const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
|
|
2681
|
+
const submitState = confirmation === null ? form.submitStatus : "confirming";
|
|
2682
|
+
const interactionLocked = submitState === "confirming" || submitState === "submitting";
|
|
2683
|
+
(0, import_react4.useEffect)(() => {
|
|
2684
|
+
let active = true;
|
|
2685
|
+
if (receiptStore === void 0) {
|
|
2686
|
+
setReceipt(null);
|
|
2687
|
+
setReceiptLoaded(true);
|
|
2688
|
+
return () => {
|
|
2689
|
+
active = false;
|
|
2690
|
+
};
|
|
2691
|
+
}
|
|
2692
|
+
setReceiptLoaded(false);
|
|
2693
|
+
void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
|
|
2694
|
+
if (active) setReceipt(stored);
|
|
2695
|
+
}).catch(() => {
|
|
2696
|
+
if (active) setReceipt(null);
|
|
2697
|
+
}).finally(() => {
|
|
2698
|
+
if (active) setReceiptLoaded(true);
|
|
2699
|
+
});
|
|
2700
|
+
return () => {
|
|
2701
|
+
active = false;
|
|
2702
|
+
};
|
|
2703
|
+
}, [form.schema.id, form.schema.version, receiptStore]);
|
|
2598
2704
|
(0, import_react4.useEffect)(() => {
|
|
2599
2705
|
if (pages === void 0 || visiblePageIndexes.length === 0) {
|
|
2600
2706
|
setCurrentPageIndex(0);
|
|
@@ -2644,6 +2750,7 @@ function ContextFormRenderer({
|
|
|
2644
2750
|
if (fieldId !== void 0) setFocusFieldId(fieldId);
|
|
2645
2751
|
};
|
|
2646
2752
|
const handleNext = () => {
|
|
2753
|
+
if (interactionLocked) return;
|
|
2647
2754
|
const result = form.validatePage(currentPageIndex);
|
|
2648
2755
|
if (!result.valid) {
|
|
2649
2756
|
focusFirstIssue(result.issues[0]?.fieldId);
|
|
@@ -2652,34 +2759,120 @@ function ContextFormRenderer({
|
|
|
2652
2759
|
const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
|
|
2653
2760
|
if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
|
|
2654
2761
|
};
|
|
2655
|
-
const
|
|
2762
|
+
const runSubmissionGuards = async (guards) => {
|
|
2763
|
+
const findings = [];
|
|
2764
|
+
let confirmationMessage;
|
|
2765
|
+
let requiresConfirmation = false;
|
|
2766
|
+
for (const guard of guards) {
|
|
2767
|
+
const result = await guard(form.schema, (0, import_core4.selectVisibleAnswers)(form.schema, form.values));
|
|
2768
|
+
if (result.status === "allow") continue;
|
|
2769
|
+
findings.push(...result.findings);
|
|
2770
|
+
if (result.status === "block") {
|
|
2771
|
+
return {
|
|
2772
|
+
status: "block",
|
|
2773
|
+
findings,
|
|
2774
|
+
...result.message === void 0 ? {} : { message: result.message }
|
|
2775
|
+
};
|
|
2776
|
+
}
|
|
2777
|
+
requiresConfirmation = true;
|
|
2778
|
+
confirmationMessage ??= result.message;
|
|
2779
|
+
}
|
|
2780
|
+
return !requiresConfirmation ? { status: "allow" } : {
|
|
2781
|
+
status: "confirm",
|
|
2782
|
+
findings,
|
|
2783
|
+
...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
|
|
2784
|
+
};
|
|
2785
|
+
};
|
|
2786
|
+
const submitValues = async (guardsConfirmed = false) => {
|
|
2787
|
+
if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
|
|
2788
|
+
return { status: "cancelled" };
|
|
2789
|
+
}
|
|
2656
2790
|
const validation = (0, import_core4.validateAnswers)(form.schema, form.values);
|
|
2657
2791
|
const firstInvalidFieldId = validation.issues[0]?.fieldId;
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2792
|
+
if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
|
|
2793
|
+
rendererSubmissionInFlight.current = true;
|
|
2794
|
+
try {
|
|
2795
|
+
const guardResult = await runSubmissionGuards(submissionGuards);
|
|
2796
|
+
if (guardResult.status === "block") {
|
|
2797
|
+
setGuardMessage(guardResult.message ?? "Submission blocked because sensitive data was detected.");
|
|
2798
|
+
return { status: "cancelled" };
|
|
2799
|
+
}
|
|
2800
|
+
if (guardResult.status === "confirm") {
|
|
2801
|
+
setGuardMessage(null);
|
|
2802
|
+
setConfirmation({
|
|
2803
|
+
findings: guardResult.findings,
|
|
2804
|
+
...guardResult.message === void 0 ? {} : { message: guardResult.message }
|
|
2805
|
+
});
|
|
2806
|
+
return { status: "cancelled" };
|
|
2807
|
+
}
|
|
2808
|
+
} finally {
|
|
2809
|
+
rendererSubmissionInFlight.current = false;
|
|
2810
|
+
}
|
|
2666
2811
|
}
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2812
|
+
setGuardMessage(null);
|
|
2813
|
+
rendererSubmissionInFlight.current = true;
|
|
2814
|
+
try {
|
|
2815
|
+
const result = await form.submit(beforeSubmit);
|
|
2816
|
+
if (result.status === "invalid") {
|
|
2817
|
+
const invalidPageIndex = pages?.findIndex(
|
|
2818
|
+
(page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
|
|
2819
|
+
);
|
|
2820
|
+
if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
|
|
2821
|
+
focusFirstIssue(firstInvalidFieldId);
|
|
2822
|
+
return result;
|
|
2823
|
+
}
|
|
2824
|
+
if (result.status !== "success") return result;
|
|
2825
|
+
if (receiptStore !== void 0) {
|
|
2826
|
+
const storedReceipt = {
|
|
2827
|
+
formId: form.schema.id,
|
|
2828
|
+
formVersion: form.schema.version,
|
|
2829
|
+
submittedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2830
|
+
};
|
|
2831
|
+
await receiptStore.save(storedReceipt);
|
|
2832
|
+
setReceipt(storedReceipt);
|
|
2833
|
+
}
|
|
2834
|
+
if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
|
|
2835
|
+
globalThis.localStorage.removeItem(autoSaveKey);
|
|
2836
|
+
setDraftRestored(false);
|
|
2837
|
+
}
|
|
2838
|
+
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2839
|
+
return result;
|
|
2840
|
+
} finally {
|
|
2841
|
+
rendererSubmissionInFlight.current = false;
|
|
2671
2842
|
}
|
|
2672
|
-
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2673
|
-
return result;
|
|
2674
2843
|
};
|
|
2675
2844
|
const handleSubmit = (event) => {
|
|
2676
2845
|
event.preventDefault();
|
|
2846
|
+
if (interactionLocked) return;
|
|
2677
2847
|
void submitValues();
|
|
2678
2848
|
};
|
|
2849
|
+
const confirmSubmission = () => {
|
|
2850
|
+
setConfirmation(null);
|
|
2851
|
+
void submitValues(true);
|
|
2852
|
+
};
|
|
2853
|
+
const cancelSubmission = () => {
|
|
2854
|
+
setConfirmation(null);
|
|
2855
|
+
};
|
|
2856
|
+
const resetReceipt = async () => {
|
|
2857
|
+
if (receiptStore === void 0) return;
|
|
2858
|
+
await receiptStore.remove(form.schema.id, form.schema.version);
|
|
2859
|
+
setReceipt(null);
|
|
2860
|
+
form.reset();
|
|
2861
|
+
};
|
|
2679
2862
|
const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
|
|
2680
2863
|
const canPrev = pages !== void 0 && activeVisibleIndex > 0;
|
|
2681
2864
|
const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
|
|
2682
|
-
const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting:
|
|
2865
|
+
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") });
|
|
2866
|
+
if (!receiptLoaded) return null;
|
|
2867
|
+
if (receipt !== null) {
|
|
2868
|
+
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
|
|
2869
|
+
receipt,
|
|
2870
|
+
...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
|
|
2871
|
+
}) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { role: "status", children: [
|
|
2872
|
+
"Already submitted.",
|
|
2873
|
+
receiptStore === void 0 ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: () => void resetReceipt(), children: "Submit another response" })
|
|
2874
|
+
] }) });
|
|
2875
|
+
}
|
|
2683
2876
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
|
|
2684
2877
|
slots.renderHeader?.({
|
|
2685
2878
|
title: form.schema.title,
|
|
@@ -2727,7 +2920,8 @@ function ContextFormRenderer({
|
|
|
2727
2920
|
translate: form.translate,
|
|
2728
2921
|
inputId: `${prefix}-${field.id}`,
|
|
2729
2922
|
errorId: `${prefix}-${field.id}-error`,
|
|
2730
|
-
helpId: `${prefix}-${field.id}-help
|
|
2923
|
+
helpId: `${prefix}-${field.id}-help`,
|
|
2924
|
+
...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
|
|
2731
2925
|
};
|
|
2732
2926
|
if (slots.renderField !== void 0) {
|
|
2733
2927
|
return /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_react4.Fragment, { children: slots.renderField({
|
|
@@ -2742,6 +2936,16 @@ function ContextFormRenderer({
|
|
|
2742
2936
|
const Component = components[field.type];
|
|
2743
2937
|
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
2938
|
}) }),
|
|
2939
|
+
guardMessage === null ? null : /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("div", { role: "alert", children: guardMessage }),
|
|
2940
|
+
confirmation === null ? null : slots.renderSubmissionConfirmation?.({
|
|
2941
|
+
findings: confirmation.findings,
|
|
2942
|
+
onConfirm: confirmSubmission,
|
|
2943
|
+
onCancel: cancelSubmission
|
|
2944
|
+
}) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
|
|
2945
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("p", { children: confirmation.message ?? "Sensitive data may be included. Confirm before submitting." }),
|
|
2946
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: confirmSubmission, children: "Confirm submission" }),
|
|
2947
|
+
/* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { type: "button", onClick: cancelSubmission, children: "Cancel" })
|
|
2948
|
+
] }),
|
|
2745
2949
|
validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "fe-validation-summary", role: "alert", children: [
|
|
2746
2950
|
validationIssues.length,
|
|
2747
2951
|
" validation error",
|
|
@@ -2764,7 +2968,9 @@ function ContextFormRenderer({
|
|
|
2764
2968
|
totalPages: visiblePageIndexes.length,
|
|
2765
2969
|
canPrev,
|
|
2766
2970
|
canNext,
|
|
2767
|
-
onPrev: () =>
|
|
2971
|
+
onPrev: () => {
|
|
2972
|
+
if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
|
|
2973
|
+
},
|
|
2768
2974
|
onNext: handleNext
|
|
2769
2975
|
}) ?? /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_jsx_runtime3.Fragment, { children: [
|
|
2770
2976
|
canPrev ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
|
|
@@ -2772,11 +2978,12 @@ function ContextFormRenderer({
|
|
|
2772
2978
|
{
|
|
2773
2979
|
className: "btn-prev",
|
|
2774
2980
|
type: "button",
|
|
2981
|
+
disabled: interactionLocked,
|
|
2775
2982
|
onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
|
|
2776
2983
|
children: form.translate("form.back")
|
|
2777
2984
|
}
|
|
2778
2985
|
) : null,
|
|
2779
|
-
canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
|
|
2986
|
+
canNext ? /* @__PURE__ */ (0, import_jsx_runtime3.jsx)("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
|
|
2780
2987
|
] }),
|
|
2781
2988
|
canNext ? null : renderSubmitButton()
|
|
2782
2989
|
] }),
|
|
@@ -2833,6 +3040,7 @@ function FormRenderer(props) {
|
|
|
2833
3040
|
FormBuilder,
|
|
2834
3041
|
FormProvider,
|
|
2835
3042
|
FormRenderer,
|
|
3043
|
+
createLocalStorageSubmissionReceiptStore,
|
|
2836
3044
|
resolveInitialFieldType,
|
|
2837
3045
|
useField,
|
|
2838
3046
|
useForm,
|
package/dist/index.d.cts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ComponentType, MouseEvent } from 'react';
|
|
3
3
|
import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, FormValues, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
|
|
4
|
+
import { SensitiveDataFinding } from '@form-engine-ts/privacy';
|
|
4
5
|
|
|
5
6
|
/** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
|
|
6
7
|
type BuilderPolicy = FormPolicy;
|
|
@@ -84,6 +85,21 @@ interface FormBuilderResult {
|
|
|
84
85
|
}
|
|
85
86
|
declare function useFormBuilder({ schema, onChange, policy, idFactory, factories }: FormBuilderOptions): FormBuilderResult;
|
|
86
87
|
|
|
88
|
+
interface SubmissionReceipt {
|
|
89
|
+
readonly formId: string;
|
|
90
|
+
readonly formVersion: number;
|
|
91
|
+
readonly submissionId?: string;
|
|
92
|
+
readonly submittedAt: string;
|
|
93
|
+
}
|
|
94
|
+
interface SubmissionReceiptStore {
|
|
95
|
+
get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
|
|
96
|
+
save(receipt: SubmissionReceipt): Promise<void>;
|
|
97
|
+
remove(formId: string, formVersion: number): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
declare function createLocalStorageSubmissionReceiptStore(options?: {
|
|
100
|
+
readonly namespace?: string;
|
|
101
|
+
}): SubmissionReceiptStore;
|
|
102
|
+
|
|
87
103
|
interface ComponentBaseProps {
|
|
88
104
|
readonly id?: string;
|
|
89
105
|
readonly className?: string;
|
|
@@ -249,6 +265,19 @@ type SubmitResult = {
|
|
|
249
265
|
readonly status: "error";
|
|
250
266
|
readonly error: Error;
|
|
251
267
|
};
|
|
268
|
+
type SubmissionGuardResult = {
|
|
269
|
+
readonly status: "allow";
|
|
270
|
+
} | {
|
|
271
|
+
readonly status: "confirm";
|
|
272
|
+
readonly findings: readonly SensitiveDataFinding[];
|
|
273
|
+
readonly message?: string;
|
|
274
|
+
} | {
|
|
275
|
+
readonly status: "block";
|
|
276
|
+
readonly findings: readonly SensitiveDataFinding[];
|
|
277
|
+
readonly message?: string;
|
|
278
|
+
};
|
|
279
|
+
type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
|
|
280
|
+
type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
|
|
252
281
|
interface FormRendererSlots {
|
|
253
282
|
readonly renderHeader?: (props: {
|
|
254
283
|
readonly title: string;
|
|
@@ -287,6 +316,24 @@ interface FormRendererSlots {
|
|
|
287
316
|
readonly error: Error;
|
|
288
317
|
readonly onRetry?: () => void;
|
|
289
318
|
}) => ReactNode;
|
|
319
|
+
readonly renderSubmissionConfirmation?: (props: {
|
|
320
|
+
readonly findings: readonly SensitiveDataFinding[];
|
|
321
|
+
readonly onConfirm: () => void;
|
|
322
|
+
readonly onCancel: () => void;
|
|
323
|
+
}) => ReactNode;
|
|
324
|
+
readonly renderAlreadySubmitted?: (props: {
|
|
325
|
+
readonly receipt: SubmissionReceipt;
|
|
326
|
+
readonly onReset?: () => void;
|
|
327
|
+
}) => ReactNode;
|
|
328
|
+
readonly renderCharacterCount?: (props: {
|
|
329
|
+
readonly fieldId: string;
|
|
330
|
+
readonly current: number;
|
|
331
|
+
readonly max: number;
|
|
332
|
+
}) => ReactNode;
|
|
333
|
+
}
|
|
334
|
+
interface SubmissionProtectionProps {
|
|
335
|
+
readonly submissionGuards?: readonly SubmissionGuard[];
|
|
336
|
+
readonly receiptStore?: SubmissionReceiptStore;
|
|
290
337
|
}
|
|
291
338
|
type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
|
|
292
339
|
|
|
@@ -365,9 +412,10 @@ interface FieldComponentProps {
|
|
|
365
412
|
readonly inputId: string;
|
|
366
413
|
readonly errorId: string;
|
|
367
414
|
readonly helpId: string;
|
|
415
|
+
readonly renderCharacterCount?: FormRendererSlots["renderCharacterCount"];
|
|
368
416
|
}
|
|
369
417
|
type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentProps>>>;
|
|
370
|
-
interface FormRendererPresentationProps {
|
|
418
|
+
interface FormRendererPresentationProps extends SubmissionProtectionProps {
|
|
371
419
|
readonly components?: FieldComponents;
|
|
372
420
|
readonly className?: string;
|
|
373
421
|
readonly successMessageKey?: string;
|
|
@@ -388,4 +436,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
|
|
|
388
436
|
type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
|
|
389
437
|
declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
|
|
390
438
|
|
|
391
|
-
export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
|
|
439
|
+
export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptStore, type SubmitResult, type SubmitStatus, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, useField, useForm, useFormBuilder };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ComponentType, MouseEvent } from 'react';
|
|
3
3
|
import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, FormValues, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
|
|
4
|
+
import { SensitiveDataFinding } from '@form-engine-ts/privacy';
|
|
4
5
|
|
|
5
6
|
/** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
|
|
6
7
|
type BuilderPolicy = FormPolicy;
|
|
@@ -84,6 +85,21 @@ interface FormBuilderResult {
|
|
|
84
85
|
}
|
|
85
86
|
declare function useFormBuilder({ schema, onChange, policy, idFactory, factories }: FormBuilderOptions): FormBuilderResult;
|
|
86
87
|
|
|
88
|
+
interface SubmissionReceipt {
|
|
89
|
+
readonly formId: string;
|
|
90
|
+
readonly formVersion: number;
|
|
91
|
+
readonly submissionId?: string;
|
|
92
|
+
readonly submittedAt: string;
|
|
93
|
+
}
|
|
94
|
+
interface SubmissionReceiptStore {
|
|
95
|
+
get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
|
|
96
|
+
save(receipt: SubmissionReceipt): Promise<void>;
|
|
97
|
+
remove(formId: string, formVersion: number): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
declare function createLocalStorageSubmissionReceiptStore(options?: {
|
|
100
|
+
readonly namespace?: string;
|
|
101
|
+
}): SubmissionReceiptStore;
|
|
102
|
+
|
|
87
103
|
interface ComponentBaseProps {
|
|
88
104
|
readonly id?: string;
|
|
89
105
|
readonly className?: string;
|
|
@@ -249,6 +265,19 @@ type SubmitResult = {
|
|
|
249
265
|
readonly status: "error";
|
|
250
266
|
readonly error: Error;
|
|
251
267
|
};
|
|
268
|
+
type SubmissionGuardResult = {
|
|
269
|
+
readonly status: "allow";
|
|
270
|
+
} | {
|
|
271
|
+
readonly status: "confirm";
|
|
272
|
+
readonly findings: readonly SensitiveDataFinding[];
|
|
273
|
+
readonly message?: string;
|
|
274
|
+
} | {
|
|
275
|
+
readonly status: "block";
|
|
276
|
+
readonly findings: readonly SensitiveDataFinding[];
|
|
277
|
+
readonly message?: string;
|
|
278
|
+
};
|
|
279
|
+
type SubmissionGuard = (schema: FormSchema, values: Record<string, unknown>) => SubmissionGuardResult | Promise<SubmissionGuardResult>;
|
|
280
|
+
type FormSubmitState = "idle" | "submitting" | "confirming" | "success" | "error";
|
|
252
281
|
interface FormRendererSlots {
|
|
253
282
|
readonly renderHeader?: (props: {
|
|
254
283
|
readonly title: string;
|
|
@@ -287,6 +316,24 @@ interface FormRendererSlots {
|
|
|
287
316
|
readonly error: Error;
|
|
288
317
|
readonly onRetry?: () => void;
|
|
289
318
|
}) => ReactNode;
|
|
319
|
+
readonly renderSubmissionConfirmation?: (props: {
|
|
320
|
+
readonly findings: readonly SensitiveDataFinding[];
|
|
321
|
+
readonly onConfirm: () => void;
|
|
322
|
+
readonly onCancel: () => void;
|
|
323
|
+
}) => ReactNode;
|
|
324
|
+
readonly renderAlreadySubmitted?: (props: {
|
|
325
|
+
readonly receipt: SubmissionReceipt;
|
|
326
|
+
readonly onReset?: () => void;
|
|
327
|
+
}) => ReactNode;
|
|
328
|
+
readonly renderCharacterCount?: (props: {
|
|
329
|
+
readonly fieldId: string;
|
|
330
|
+
readonly current: number;
|
|
331
|
+
readonly max: number;
|
|
332
|
+
}) => ReactNode;
|
|
333
|
+
}
|
|
334
|
+
interface SubmissionProtectionProps {
|
|
335
|
+
readonly submissionGuards?: readonly SubmissionGuard[];
|
|
336
|
+
readonly receiptStore?: SubmissionReceiptStore;
|
|
290
337
|
}
|
|
291
338
|
type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
|
|
292
339
|
|
|
@@ -365,9 +412,10 @@ interface FieldComponentProps {
|
|
|
365
412
|
readonly inputId: string;
|
|
366
413
|
readonly errorId: string;
|
|
367
414
|
readonly helpId: string;
|
|
415
|
+
readonly renderCharacterCount?: FormRendererSlots["renderCharacterCount"];
|
|
368
416
|
}
|
|
369
417
|
type FieldComponents = Partial<Record<FieldType, ComponentType<FieldComponentProps>>>;
|
|
370
|
-
interface FormRendererPresentationProps {
|
|
418
|
+
interface FormRendererPresentationProps extends SubmissionProtectionProps {
|
|
371
419
|
readonly components?: FieldComponents;
|
|
372
420
|
readonly className?: string;
|
|
373
421
|
readonly successMessageKey?: string;
|
|
@@ -388,4 +436,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
|
|
|
388
436
|
type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
|
|
389
437
|
declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
|
|
390
438
|
|
|
391
|
-
export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmitResult, type SubmitStatus, resolveInitialFieldType, useField, useForm, useFormBuilder };
|
|
439
|
+
export { type BeforeSubmit, type BuilderActionContext, type BuilderActionError, type BuilderActionResult, type BuilderButtonProps, type BuilderCheckboxProps, type BuilderFactories, type BuilderFieldEditorSlotProps, type BuilderFieldsetProps, type BuilderIconButtonProps, type BuilderIdKind, type BuilderLocalizationSlotProps, type BuilderOptionEditorSlotProps, type BuilderPagesSlotProps, type BuilderPolicy, type BuilderSectionProps, type BuilderSelectOption, type BuilderSelectProps, type BuilderTextAreaProps, type BuilderTextInputProps, type BuilderTextTarget, type BuilderToolbarSlotProps, type BuilderTranslationActionsSlotProps, type ComponentBaseProps, type FieldComponentProps, type FieldComponents, type FieldState, FormBuilder, type FormBuilderActions, type FormBuilderComponents, type FormBuilderFeatures, type FormBuilderOptions, type FormBuilderProps, type FormBuilderResult, type FormBuilderSlots, type FormContextValue, FormProvider, type FormProviderProps, FormRenderer, type FormRendererPresentationProps, type FormRendererProps, type FormRendererSlots, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptStore, type SubmitResult, type SubmitStatus, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, useField, useForm, useFormBuilder };
|
package/dist/index.js
CHANGED
|
@@ -2179,7 +2179,7 @@ import {
|
|
|
2179
2179
|
validateAnswers,
|
|
2180
2180
|
validatePageAnswers
|
|
2181
2181
|
} 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";
|
|
2182
|
+
import { createContext as createContext2, useCallback as useCallback2, useContext as useContext2, useEffect, useMemo as useMemo2, useRef, useState as useState2 } from "react";
|
|
2183
2183
|
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
2184
2184
|
var FormContext = createContext2(null);
|
|
2185
2185
|
function issuesByField(issues) {
|
|
@@ -2207,6 +2207,7 @@ function FormProvider({
|
|
|
2207
2207
|
const [submitStatus, setSubmitStatus] = useState2("idle");
|
|
2208
2208
|
const [submitError, setSubmitError] = useState2(null);
|
|
2209
2209
|
const [validationPageIndex, setValidationPageIndex] = useState2(null);
|
|
2210
|
+
const submissionInFlight = useRef(false);
|
|
2210
2211
|
const visibility = useMemo2(() => calculateFieldVisibility(validSchema, values), [validSchema, values]);
|
|
2211
2212
|
const pageVisibility = useMemo2(() => calculatePageVisibility(validSchema, values), [validSchema, values]);
|
|
2212
2213
|
useEffect(() => {
|
|
@@ -2260,6 +2261,7 @@ function FormProvider({
|
|
|
2260
2261
|
}, [initialValues]);
|
|
2261
2262
|
const submit = useCallback2(
|
|
2262
2263
|
async (beforeSubmit) => {
|
|
2264
|
+
if (submissionInFlight.current) return { status: "cancelled" };
|
|
2263
2265
|
const validation = validateAnswers(validSchema, values);
|
|
2264
2266
|
if (!validation.valid) {
|
|
2265
2267
|
setErrors(issuesByField(validation.issues));
|
|
@@ -2272,12 +2274,13 @@ function FormProvider({
|
|
|
2272
2274
|
setValidationPageIndex(null);
|
|
2273
2275
|
setSubmitError(null);
|
|
2274
2276
|
const visibleValues = selectVisibleAnswers(validSchema, values);
|
|
2277
|
+
submissionInFlight.current = true;
|
|
2275
2278
|
try {
|
|
2279
|
+
setSubmitStatus("submitting");
|
|
2276
2280
|
if (beforeSubmit !== void 0 && await beforeSubmit(visibleValues) === "cancel") {
|
|
2277
2281
|
setSubmitStatus("idle");
|
|
2278
2282
|
return { status: "cancelled" };
|
|
2279
2283
|
}
|
|
2280
|
-
setSubmitStatus("submitting");
|
|
2281
2284
|
await onSubmit(visibleValues);
|
|
2282
2285
|
if (resetOnSuccess) setValues({ ...initialValues });
|
|
2283
2286
|
setSubmitStatus("success");
|
|
@@ -2287,6 +2290,8 @@ function FormProvider({
|
|
|
2287
2290
|
setSubmitError(error);
|
|
2288
2291
|
setSubmitStatus("error");
|
|
2289
2292
|
return { status: "error", error };
|
|
2293
|
+
} finally {
|
|
2294
|
+
submissionInFlight.current = false;
|
|
2290
2295
|
}
|
|
2291
2296
|
},
|
|
2292
2297
|
[initialValues, onSubmit, resetOnSuccess, validSchema, values]
|
|
@@ -2347,8 +2352,67 @@ function useField(fieldId) {
|
|
|
2347
2352
|
return { field, value: form.values[fieldId], error: form.errors[fieldId], setValue };
|
|
2348
2353
|
}
|
|
2349
2354
|
|
|
2355
|
+
// src/receipt.ts
|
|
2356
|
+
function receiptKey(namespace, formId, formVersion) {
|
|
2357
|
+
return `${namespace}:${formId}:v${formVersion}`;
|
|
2358
|
+
}
|
|
2359
|
+
function browserStorage() {
|
|
2360
|
+
if (typeof window === "undefined") return null;
|
|
2361
|
+
try {
|
|
2362
|
+
return window.localStorage;
|
|
2363
|
+
} catch {
|
|
2364
|
+
return null;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
function parseReceipt(serialized) {
|
|
2368
|
+
try {
|
|
2369
|
+
const value = JSON.parse(serialized);
|
|
2370
|
+
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") {
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
const submissionId = "submissionId" in value && typeof value.submissionId === "string" ? value.submissionId : void 0;
|
|
2374
|
+
return {
|
|
2375
|
+
formId: value.formId,
|
|
2376
|
+
formVersion: value.formVersion,
|
|
2377
|
+
submittedAt: value.submittedAt,
|
|
2378
|
+
...submissionId === void 0 ? {} : { submissionId }
|
|
2379
|
+
};
|
|
2380
|
+
} catch {
|
|
2381
|
+
return null;
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
2385
|
+
const namespace = options.namespace ?? "form_engine_receipt";
|
|
2386
|
+
if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
|
|
2387
|
+
return {
|
|
2388
|
+
async get(formId, formVersion) {
|
|
2389
|
+
const storage = browserStorage();
|
|
2390
|
+
if (storage === null) return null;
|
|
2391
|
+
try {
|
|
2392
|
+
const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
|
|
2393
|
+
if (serialized === null) return null;
|
|
2394
|
+
const receipt = parseReceipt(serialized);
|
|
2395
|
+
return receipt?.formId === formId && receipt.formVersion === formVersion ? receipt : null;
|
|
2396
|
+
} catch {
|
|
2397
|
+
return null;
|
|
2398
|
+
}
|
|
2399
|
+
},
|
|
2400
|
+
async save(receipt) {
|
|
2401
|
+
const storage = browserStorage();
|
|
2402
|
+
if (storage === null) return;
|
|
2403
|
+
storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
|
|
2404
|
+
},
|
|
2405
|
+
async remove(formId, formVersion) {
|
|
2406
|
+
const storage = browserStorage();
|
|
2407
|
+
if (storage === null) return;
|
|
2408
|
+
storage.removeItem(receiptKey(namespace, formId, formVersion));
|
|
2409
|
+
}
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2350
2413
|
// src/renderer.tsx
|
|
2351
2414
|
import {
|
|
2415
|
+
selectVisibleAnswers as selectVisibleAnswers2,
|
|
2352
2416
|
validateAnswers as validateAnswers2
|
|
2353
2417
|
} from "@form-engine-ts/core";
|
|
2354
2418
|
import {
|
|
@@ -2356,7 +2420,7 @@ import {
|
|
|
2356
2420
|
useEffect as useEffect2,
|
|
2357
2421
|
useId,
|
|
2358
2422
|
useMemo as useMemo3,
|
|
2359
|
-
useRef,
|
|
2423
|
+
useRef as useRef2,
|
|
2360
2424
|
useState as useState3
|
|
2361
2425
|
} from "react";
|
|
2362
2426
|
import { Fragment as Fragment3, jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
@@ -2473,11 +2537,17 @@ function DefaultField(props) {
|
|
|
2473
2537
|
/* @__PURE__ */ jsx3(RequiredMark, { required: field.required })
|
|
2474
2538
|
] });
|
|
2475
2539
|
let control;
|
|
2540
|
+
const textConstraints = field.type === "text" || field.type === "textarea" ? {
|
|
2541
|
+
minLength: field.minLength,
|
|
2542
|
+
maxLength: field.maxLength,
|
|
2543
|
+
...field.pattern === void 0 ? {} : { pattern: field.pattern }
|
|
2544
|
+
} : {};
|
|
2476
2545
|
if (field.type === "textarea") {
|
|
2477
2546
|
control = /* @__PURE__ */ jsx3(
|
|
2478
2547
|
"textarea",
|
|
2479
2548
|
{
|
|
2480
2549
|
...ariaProps,
|
|
2550
|
+
...textConstraints,
|
|
2481
2551
|
id: inputId,
|
|
2482
2552
|
name: field.id,
|
|
2483
2553
|
placeholder: field.placeholderKey === void 0 ? void 0 : translate(field.placeholderKey),
|
|
@@ -2490,6 +2560,7 @@ function DefaultField(props) {
|
|
|
2490
2560
|
"input",
|
|
2491
2561
|
{
|
|
2492
2562
|
...ariaProps,
|
|
2563
|
+
...textConstraints,
|
|
2493
2564
|
id: inputId,
|
|
2494
2565
|
name: field.id,
|
|
2495
2566
|
type: "number",
|
|
@@ -2534,6 +2605,11 @@ function DefaultField(props) {
|
|
|
2534
2605
|
return /* @__PURE__ */ jsxs2("div", { className: `fe-field fe-field--${field.type}`, "data-field-id": field.id, children: [
|
|
2535
2606
|
label,
|
|
2536
2607
|
control,
|
|
2608
|
+
(field.type === "text" || field.type === "textarea") && field.maxLength !== void 0 ? props.renderCharacterCount?.({
|
|
2609
|
+
fieldId: field.id,
|
|
2610
|
+
current: typeof value === "string" ? value.length : 0,
|
|
2611
|
+
max: field.maxLength
|
|
2612
|
+
}) : null,
|
|
2537
2613
|
/* @__PURE__ */ jsx3(FieldMessage, { props })
|
|
2538
2614
|
] });
|
|
2539
2615
|
}
|
|
@@ -2569,15 +2645,22 @@ function ContextFormRenderer({
|
|
|
2569
2645
|
autoSaveKey,
|
|
2570
2646
|
beforeSubmit,
|
|
2571
2647
|
onDraftSave,
|
|
2648
|
+
submissionGuards = [],
|
|
2649
|
+
receiptStore,
|
|
2572
2650
|
slots = {}
|
|
2573
2651
|
}) {
|
|
2574
2652
|
const form = useForm();
|
|
2575
2653
|
const prefix = useId().replace(/:/g, "");
|
|
2576
|
-
const formRef =
|
|
2577
|
-
const loadedDraftKey =
|
|
2654
|
+
const formRef = useRef2(null);
|
|
2655
|
+
const loadedDraftKey = useRef2(null);
|
|
2578
2656
|
const [draftRestored, setDraftRestored] = useState3(false);
|
|
2579
2657
|
const [currentPageIndex, setCurrentPageIndex] = useState3(0);
|
|
2580
2658
|
const [focusFieldId, setFocusFieldId] = useState3(null);
|
|
2659
|
+
const [confirmation, setConfirmation] = useState3(null);
|
|
2660
|
+
const [guardMessage, setGuardMessage] = useState3(null);
|
|
2661
|
+
const [receipt, setReceipt] = useState3(null);
|
|
2662
|
+
const [receiptLoaded, setReceiptLoaded] = useState3(receiptStore === void 0);
|
|
2663
|
+
const rendererSubmissionInFlight = useRef2(false);
|
|
2581
2664
|
const pages = form.schema.pages;
|
|
2582
2665
|
const visiblePageIndexes = useMemo3(
|
|
2583
2666
|
() => pages === void 0 ? [] : pages.flatMap((page, index) => form.pageVisibility[page.id] === true ? [index] : []),
|
|
@@ -2586,6 +2669,29 @@ function ContextFormRenderer({
|
|
|
2586
2669
|
const activePage = pages?.[currentPageIndex];
|
|
2587
2670
|
const activeVisibleIndex = visiblePageIndexes.indexOf(currentPageIndex);
|
|
2588
2671
|
const fieldIds = activePage === void 0 ? void 0 : new Set(activePage.questionIds);
|
|
2672
|
+
const submitState = confirmation === null ? form.submitStatus : "confirming";
|
|
2673
|
+
const interactionLocked = submitState === "confirming" || submitState === "submitting";
|
|
2674
|
+
useEffect2(() => {
|
|
2675
|
+
let active = true;
|
|
2676
|
+
if (receiptStore === void 0) {
|
|
2677
|
+
setReceipt(null);
|
|
2678
|
+
setReceiptLoaded(true);
|
|
2679
|
+
return () => {
|
|
2680
|
+
active = false;
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
setReceiptLoaded(false);
|
|
2684
|
+
void receiptStore.get(form.schema.id, form.schema.version).then((stored) => {
|
|
2685
|
+
if (active) setReceipt(stored);
|
|
2686
|
+
}).catch(() => {
|
|
2687
|
+
if (active) setReceipt(null);
|
|
2688
|
+
}).finally(() => {
|
|
2689
|
+
if (active) setReceiptLoaded(true);
|
|
2690
|
+
});
|
|
2691
|
+
return () => {
|
|
2692
|
+
active = false;
|
|
2693
|
+
};
|
|
2694
|
+
}, [form.schema.id, form.schema.version, receiptStore]);
|
|
2589
2695
|
useEffect2(() => {
|
|
2590
2696
|
if (pages === void 0 || visiblePageIndexes.length === 0) {
|
|
2591
2697
|
setCurrentPageIndex(0);
|
|
@@ -2635,6 +2741,7 @@ function ContextFormRenderer({
|
|
|
2635
2741
|
if (fieldId !== void 0) setFocusFieldId(fieldId);
|
|
2636
2742
|
};
|
|
2637
2743
|
const handleNext = () => {
|
|
2744
|
+
if (interactionLocked) return;
|
|
2638
2745
|
const result = form.validatePage(currentPageIndex);
|
|
2639
2746
|
if (!result.valid) {
|
|
2640
2747
|
focusFirstIssue(result.issues[0]?.fieldId);
|
|
@@ -2643,34 +2750,120 @@ function ContextFormRenderer({
|
|
|
2643
2750
|
const nextPageIndex = visiblePageIndexes[activeVisibleIndex + 1];
|
|
2644
2751
|
if (nextPageIndex !== void 0) setCurrentPageIndex(nextPageIndex);
|
|
2645
2752
|
};
|
|
2646
|
-
const
|
|
2753
|
+
const runSubmissionGuards = async (guards) => {
|
|
2754
|
+
const findings = [];
|
|
2755
|
+
let confirmationMessage;
|
|
2756
|
+
let requiresConfirmation = false;
|
|
2757
|
+
for (const guard of guards) {
|
|
2758
|
+
const result = await guard(form.schema, selectVisibleAnswers2(form.schema, form.values));
|
|
2759
|
+
if (result.status === "allow") continue;
|
|
2760
|
+
findings.push(...result.findings);
|
|
2761
|
+
if (result.status === "block") {
|
|
2762
|
+
return {
|
|
2763
|
+
status: "block",
|
|
2764
|
+
findings,
|
|
2765
|
+
...result.message === void 0 ? {} : { message: result.message }
|
|
2766
|
+
};
|
|
2767
|
+
}
|
|
2768
|
+
requiresConfirmation = true;
|
|
2769
|
+
confirmationMessage ??= result.message;
|
|
2770
|
+
}
|
|
2771
|
+
return !requiresConfirmation ? { status: "allow" } : {
|
|
2772
|
+
status: "confirm",
|
|
2773
|
+
findings,
|
|
2774
|
+
...confirmationMessage === void 0 ? {} : { message: confirmationMessage }
|
|
2775
|
+
};
|
|
2776
|
+
};
|
|
2777
|
+
const submitValues = async (guardsConfirmed = false) => {
|
|
2778
|
+
if (rendererSubmissionInFlight.current || confirmation !== null && !guardsConfirmed) {
|
|
2779
|
+
return { status: "cancelled" };
|
|
2780
|
+
}
|
|
2647
2781
|
const validation = validateAnswers2(form.schema, form.values);
|
|
2648
2782
|
const firstInvalidFieldId = validation.issues[0]?.fieldId;
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2783
|
+
if (validation.valid && !guardsConfirmed && submissionGuards.length > 0) {
|
|
2784
|
+
rendererSubmissionInFlight.current = true;
|
|
2785
|
+
try {
|
|
2786
|
+
const guardResult = await runSubmissionGuards(submissionGuards);
|
|
2787
|
+
if (guardResult.status === "block") {
|
|
2788
|
+
setGuardMessage(guardResult.message ?? "Submission blocked because sensitive data was detected.");
|
|
2789
|
+
return { status: "cancelled" };
|
|
2790
|
+
}
|
|
2791
|
+
if (guardResult.status === "confirm") {
|
|
2792
|
+
setGuardMessage(null);
|
|
2793
|
+
setConfirmation({
|
|
2794
|
+
findings: guardResult.findings,
|
|
2795
|
+
...guardResult.message === void 0 ? {} : { message: guardResult.message }
|
|
2796
|
+
});
|
|
2797
|
+
return { status: "cancelled" };
|
|
2798
|
+
}
|
|
2799
|
+
} finally {
|
|
2800
|
+
rendererSubmissionInFlight.current = false;
|
|
2801
|
+
}
|
|
2657
2802
|
}
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2803
|
+
setGuardMessage(null);
|
|
2804
|
+
rendererSubmissionInFlight.current = true;
|
|
2805
|
+
try {
|
|
2806
|
+
const result = await form.submit(beforeSubmit);
|
|
2807
|
+
if (result.status === "invalid") {
|
|
2808
|
+
const invalidPageIndex = pages?.findIndex(
|
|
2809
|
+
(page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
|
|
2810
|
+
);
|
|
2811
|
+
if (invalidPageIndex !== void 0 && invalidPageIndex >= 0) setCurrentPageIndex(invalidPageIndex);
|
|
2812
|
+
focusFirstIssue(firstInvalidFieldId);
|
|
2813
|
+
return result;
|
|
2814
|
+
}
|
|
2815
|
+
if (result.status !== "success") return result;
|
|
2816
|
+
if (receiptStore !== void 0) {
|
|
2817
|
+
const storedReceipt = {
|
|
2818
|
+
formId: form.schema.id,
|
|
2819
|
+
formVersion: form.schema.version,
|
|
2820
|
+
submittedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2821
|
+
};
|
|
2822
|
+
await receiptStore.save(storedReceipt);
|
|
2823
|
+
setReceipt(storedReceipt);
|
|
2824
|
+
}
|
|
2825
|
+
if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
|
|
2826
|
+
globalThis.localStorage.removeItem(autoSaveKey);
|
|
2827
|
+
setDraftRestored(false);
|
|
2828
|
+
}
|
|
2829
|
+
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2830
|
+
return result;
|
|
2831
|
+
} finally {
|
|
2832
|
+
rendererSubmissionInFlight.current = false;
|
|
2662
2833
|
}
|
|
2663
|
-
setCurrentPageIndex(visiblePageIndexes[0] ?? 0);
|
|
2664
|
-
return result;
|
|
2665
2834
|
};
|
|
2666
2835
|
const handleSubmit = (event) => {
|
|
2667
2836
|
event.preventDefault();
|
|
2837
|
+
if (interactionLocked) return;
|
|
2668
2838
|
void submitValues();
|
|
2669
2839
|
};
|
|
2840
|
+
const confirmSubmission = () => {
|
|
2841
|
+
setConfirmation(null);
|
|
2842
|
+
void submitValues(true);
|
|
2843
|
+
};
|
|
2844
|
+
const cancelSubmission = () => {
|
|
2845
|
+
setConfirmation(null);
|
|
2846
|
+
};
|
|
2847
|
+
const resetReceipt = async () => {
|
|
2848
|
+
if (receiptStore === void 0) return;
|
|
2849
|
+
await receiptStore.remove(form.schema.id, form.schema.version);
|
|
2850
|
+
setReceipt(null);
|
|
2851
|
+
form.reset();
|
|
2852
|
+
};
|
|
2670
2853
|
const validationIssues = Object.values(form.errors).filter((issue) => issue !== void 0);
|
|
2671
2854
|
const canPrev = pages !== void 0 && activeVisibleIndex > 0;
|
|
2672
2855
|
const canNext = pages !== void 0 && activeVisibleIndex < visiblePageIndexes.length - 1;
|
|
2673
|
-
const renderSubmitButton = () => slots.renderSubmitButton?.({ isSubmitting:
|
|
2856
|
+
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") });
|
|
2857
|
+
if (!receiptLoaded) return null;
|
|
2858
|
+
if (receipt !== null) {
|
|
2859
|
+
return /* @__PURE__ */ jsx3("div", { className: `fe-form fe-already-submitted ${className}`.trim(), children: slots.renderAlreadySubmitted?.({
|
|
2860
|
+
receipt,
|
|
2861
|
+
...receiptStore === void 0 ? {} : { onReset: () => void resetReceipt() }
|
|
2862
|
+
}) ?? /* @__PURE__ */ jsxs2("div", { role: "status", children: [
|
|
2863
|
+
"Already submitted.",
|
|
2864
|
+
receiptStore === void 0 ? null : /* @__PURE__ */ jsx3("button", { type: "button", onClick: () => void resetReceipt(), children: "Submit another response" })
|
|
2865
|
+
] }) });
|
|
2866
|
+
}
|
|
2674
2867
|
return /* @__PURE__ */ jsxs2("form", { ref: formRef, className: `fe-form ${className}`.trim(), noValidate: true, onSubmit: handleSubmit, children: [
|
|
2675
2868
|
slots.renderHeader?.({
|
|
2676
2869
|
title: form.schema.title,
|
|
@@ -2718,7 +2911,8 @@ function ContextFormRenderer({
|
|
|
2718
2911
|
translate: form.translate,
|
|
2719
2912
|
inputId: `${prefix}-${field.id}`,
|
|
2720
2913
|
errorId: `${prefix}-${field.id}-error`,
|
|
2721
|
-
helpId: `${prefix}-${field.id}-help
|
|
2914
|
+
helpId: `${prefix}-${field.id}-help`,
|
|
2915
|
+
...slots.renderCharacterCount === void 0 ? {} : { renderCharacterCount: slots.renderCharacterCount }
|
|
2722
2916
|
};
|
|
2723
2917
|
if (slots.renderField !== void 0) {
|
|
2724
2918
|
return /* @__PURE__ */ jsx3(Fragment2, { children: slots.renderField({
|
|
@@ -2733,6 +2927,16 @@ function ContextFormRenderer({
|
|
|
2733
2927
|
const Component = components[field.type];
|
|
2734
2928
|
return Component === void 0 ? /* @__PURE__ */ jsx3(DefaultField, { ...props }, field.id) : /* @__PURE__ */ jsx3(Component, { ...props }, field.id);
|
|
2735
2929
|
}) }),
|
|
2930
|
+
guardMessage === null ? null : /* @__PURE__ */ jsx3("div", { role: "alert", children: guardMessage }),
|
|
2931
|
+
confirmation === null ? null : slots.renderSubmissionConfirmation?.({
|
|
2932
|
+
findings: confirmation.findings,
|
|
2933
|
+
onConfirm: confirmSubmission,
|
|
2934
|
+
onCancel: cancelSubmission
|
|
2935
|
+
}) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-submission-confirmation", role: "dialog", "aria-modal": "true", children: [
|
|
2936
|
+
/* @__PURE__ */ jsx3("p", { children: confirmation.message ?? "Sensitive data may be included. Confirm before submitting." }),
|
|
2937
|
+
/* @__PURE__ */ jsx3("button", { type: "button", onClick: confirmSubmission, children: "Confirm submission" }),
|
|
2938
|
+
/* @__PURE__ */ jsx3("button", { type: "button", onClick: cancelSubmission, children: "Cancel" })
|
|
2939
|
+
] }),
|
|
2736
2940
|
validationIssues.length === 0 ? null : slots.renderValidationSummary?.({ issues: validationIssues }) ?? /* @__PURE__ */ jsxs2("div", { className: "fe-validation-summary", role: "alert", children: [
|
|
2737
2941
|
validationIssues.length,
|
|
2738
2942
|
" validation error",
|
|
@@ -2755,7 +2959,9 @@ function ContextFormRenderer({
|
|
|
2755
2959
|
totalPages: visiblePageIndexes.length,
|
|
2756
2960
|
canPrev,
|
|
2757
2961
|
canNext,
|
|
2758
|
-
onPrev: () =>
|
|
2962
|
+
onPrev: () => {
|
|
2963
|
+
if (!interactionLocked) setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0);
|
|
2964
|
+
},
|
|
2759
2965
|
onNext: handleNext
|
|
2760
2966
|
}) ?? /* @__PURE__ */ jsxs2(Fragment3, { children: [
|
|
2761
2967
|
canPrev ? /* @__PURE__ */ jsx3(
|
|
@@ -2763,11 +2969,12 @@ function ContextFormRenderer({
|
|
|
2763
2969
|
{
|
|
2764
2970
|
className: "btn-prev",
|
|
2765
2971
|
type: "button",
|
|
2972
|
+
disabled: interactionLocked,
|
|
2766
2973
|
onClick: () => setCurrentPageIndex(visiblePageIndexes[activeVisibleIndex - 1] ?? 0),
|
|
2767
2974
|
children: form.translate("form.back")
|
|
2768
2975
|
}
|
|
2769
2976
|
) : null,
|
|
2770
|
-
canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", onClick: handleNext, children: form.translate("form.next") }) : null
|
|
2977
|
+
canNext ? /* @__PURE__ */ jsx3("button", { className: "btn-next", type: "button", disabled: interactionLocked, onClick: handleNext, children: form.translate("form.next") }) : null
|
|
2771
2978
|
] }),
|
|
2772
2979
|
canNext ? null : renderSubmitButton()
|
|
2773
2980
|
] }),
|
|
@@ -2823,6 +3030,7 @@ export {
|
|
|
2823
3030
|
FormBuilder,
|
|
2824
3031
|
FormProvider,
|
|
2825
3032
|
FormRenderer,
|
|
3033
|
+
createLocalStorageSubmissionReceiptStore,
|
|
2826
3034
|
resolveInitialFieldType,
|
|
2827
3035
|
useField,
|
|
2828
3036
|
useForm,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@form-engine-ts/react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.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.7.0",
|
|
46
|
+
"@form-engine-ts/privacy": "2.7.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"
|