@form-engine-ts/react 2.8.0 → 2.9.1
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 +6 -0
- package/dist/index.cjs +148 -11
- package/dist/index.d.cts +20 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.js +147 -11
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -128,3 +128,9 @@ The Builder basic-settings section edits source `title` and `description` throug
|
|
|
128
128
|
Submission confirmation slots receive the effective message, localized schema, and visible answers. An `onSubmit` result
|
|
129
129
|
may provide `submissionId` and `submittedAt`, which Renderer copies into its receipt. Receipt stores support `getBatch`,
|
|
130
130
|
and `useSubmissionReceipts` loads multiple form/version receipts for list and dashboard surfaces.
|
|
131
|
+
|
|
132
|
+
Receipt persistence is best-effort: `onReceiptError` observes storage failures while the successful completion screen is
|
|
133
|
+
preserved. Pass an SSR-safe `createLocalStorageSubmissionAttemptStore()` as `attemptStore` to reserve an ID immediately
|
|
134
|
+
before submission. Renderer injects it as `attemptId` and `submissionId`, retains it after a failed request, promotes it
|
|
135
|
+
to the receipt after success, and then clears the attempt. Custom receipt stores may omit `getBatch`; the hook falls back
|
|
136
|
+
to concurrent `get` calls.
|
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
|
+
createLocalStorageSubmissionAttemptStore: () => createLocalStorageSubmissionAttemptStore,
|
|
26
27
|
createLocalStorageSubmissionReceiptStore: () => createLocalStorageSubmissionReceiptStore,
|
|
27
28
|
resolveInitialFieldType: () => resolveInitialFieldType,
|
|
28
29
|
submissionReceiptQueryKey: () => submissionReceiptQueryKey,
|
|
@@ -33,6 +34,105 @@ __export(index_exports, {
|
|
|
33
34
|
});
|
|
34
35
|
module.exports = __toCommonJS(index_exports);
|
|
35
36
|
|
|
37
|
+
// src/attempt.ts
|
|
38
|
+
function attemptKey(namespace, formId, formVersion) {
|
|
39
|
+
return `${namespace}:${formId}:v${formVersion}`;
|
|
40
|
+
}
|
|
41
|
+
function browserStorage() {
|
|
42
|
+
if (typeof window === "undefined") return null;
|
|
43
|
+
try {
|
|
44
|
+
return window.localStorage;
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function parseAttempt(serialized) {
|
|
50
|
+
try {
|
|
51
|
+
const value = JSON.parse(serialized);
|
|
52
|
+
if (typeof value !== "object" || value === null || !("attemptId" in value) || typeof value.attemptId !== "string" || value.attemptId.length === 0 || !("formId" in value) || typeof value.formId !== "string" || !("formVersion" in value) || typeof value.formVersion !== "number" || !Number.isSafeInteger(value.formVersion) || !("createdAt" in value) || typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt))) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
attemptId: value.attemptId,
|
|
57
|
+
formId: value.formId,
|
|
58
|
+
formVersion: value.formVersion,
|
|
59
|
+
createdAt: value.createdAt
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function defaultAttemptId() {
|
|
66
|
+
const randomUuid = globalThis.crypto?.randomUUID;
|
|
67
|
+
if (typeof randomUuid === "function") return randomUuid.call(globalThis.crypto);
|
|
68
|
+
return `attempt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
69
|
+
}
|
|
70
|
+
function createLocalStorageSubmissionAttemptStore(options = {}) {
|
|
71
|
+
const namespace = options.namespace ?? "form_engine_attempt";
|
|
72
|
+
if (namespace.trim().length === 0) throw new TypeError("Attempt namespace must not be empty.");
|
|
73
|
+
const memory = /* @__PURE__ */ new Map();
|
|
74
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
75
|
+
const get = async (formId, formVersion) => {
|
|
76
|
+
const key = attemptKey(namespace, formId, formVersion);
|
|
77
|
+
const remembered = memory.get(key);
|
|
78
|
+
if (remembered !== void 0) return remembered;
|
|
79
|
+
const storage = browserStorage();
|
|
80
|
+
if (storage === null) return null;
|
|
81
|
+
try {
|
|
82
|
+
const serialized = storage.getItem(key);
|
|
83
|
+
if (serialized === null) return null;
|
|
84
|
+
const attempt = parseAttempt(serialized);
|
|
85
|
+
if (attempt === null || attempt.formId !== formId || attempt.formVersion !== formVersion) return null;
|
|
86
|
+
memory.set(key, attempt);
|
|
87
|
+
return attempt;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
get,
|
|
94
|
+
async getOrCreate(formId, formVersion, idFactory = defaultAttemptId) {
|
|
95
|
+
const key = attemptKey(namespace, formId, formVersion);
|
|
96
|
+
const pending = inFlight.get(key);
|
|
97
|
+
if (pending !== void 0) return pending;
|
|
98
|
+
const create = (async () => {
|
|
99
|
+
const existing = await get(formId, formVersion);
|
|
100
|
+
if (existing !== null) return existing;
|
|
101
|
+
const attemptId = idFactory();
|
|
102
|
+
if (typeof attemptId !== "string" || attemptId.trim().length === 0) {
|
|
103
|
+
throw new TypeError("Attempt idFactory must return a non-empty string.");
|
|
104
|
+
}
|
|
105
|
+
const attempt = {
|
|
106
|
+
attemptId,
|
|
107
|
+
formId,
|
|
108
|
+
formVersion,
|
|
109
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
110
|
+
};
|
|
111
|
+
memory.set(key, attempt);
|
|
112
|
+
try {
|
|
113
|
+
browserStorage()?.setItem(key, JSON.stringify(attempt));
|
|
114
|
+
} catch {
|
|
115
|
+
}
|
|
116
|
+
return attempt;
|
|
117
|
+
})();
|
|
118
|
+
inFlight.set(key, create);
|
|
119
|
+
try {
|
|
120
|
+
return await create;
|
|
121
|
+
} finally {
|
|
122
|
+
inFlight.delete(key);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
async clear(formId, formVersion) {
|
|
126
|
+
const key = attemptKey(namespace, formId, formVersion);
|
|
127
|
+
memory.delete(key);
|
|
128
|
+
try {
|
|
129
|
+
browserStorage()?.removeItem(key);
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
36
136
|
// src/builder.tsx
|
|
37
137
|
var import_core2 = require("@form-engine-ts/core");
|
|
38
138
|
var import_react2 = require("react");
|
|
@@ -2320,7 +2420,7 @@ function FormProvider({
|
|
|
2320
2420
|
setSubmitError(null);
|
|
2321
2421
|
}, [initialValues]);
|
|
2322
2422
|
const submit = (0, import_react3.useCallback)(
|
|
2323
|
-
async (beforeSubmit) => {
|
|
2423
|
+
async (beforeSubmit, prepareSubmission) => {
|
|
2324
2424
|
if (submissionInFlight.current) return { status: "cancelled" };
|
|
2325
2425
|
const validation = (0, import_core3.validateAnswers)(validSchema, values);
|
|
2326
2426
|
if (!validation.valid) {
|
|
@@ -2341,7 +2441,8 @@ function FormProvider({
|
|
|
2341
2441
|
setSubmitStatus("idle");
|
|
2342
2442
|
return { status: "cancelled" };
|
|
2343
2443
|
}
|
|
2344
|
-
const
|
|
2444
|
+
const submissionValues = prepareSubmission === void 0 ? visibleValues : await prepareSubmission(visibleValues);
|
|
2445
|
+
const response = await onSubmit(submissionValues);
|
|
2345
2446
|
if (resetOnSuccess) setValues({ ...initialValues });
|
|
2346
2447
|
setSubmitStatus("success");
|
|
2347
2448
|
return response === void 0 ? { status: "success" } : { status: "success", response };
|
|
@@ -2420,7 +2521,7 @@ function submissionReceiptQueryKey(formId, formVersion) {
|
|
|
2420
2521
|
function receiptKey(namespace, formId, formVersion) {
|
|
2421
2522
|
return `${namespace}:${formId}:v${formVersion}`;
|
|
2422
2523
|
}
|
|
2423
|
-
function
|
|
2524
|
+
function browserStorage2() {
|
|
2424
2525
|
if (typeof window === "undefined") return null;
|
|
2425
2526
|
try {
|
|
2426
2527
|
return window.localStorage;
|
|
@@ -2449,7 +2550,7 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
|
2449
2550
|
const namespace = options.namespace ?? "form_engine_receipt";
|
|
2450
2551
|
if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
|
|
2451
2552
|
const get = async (formId, formVersion) => {
|
|
2452
|
-
const storage =
|
|
2553
|
+
const storage = browserStorage2();
|
|
2453
2554
|
if (storage === null) return null;
|
|
2454
2555
|
try {
|
|
2455
2556
|
const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
|
|
@@ -2471,12 +2572,12 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
|
2471
2572
|
);
|
|
2472
2573
|
},
|
|
2473
2574
|
async save(receipt) {
|
|
2474
|
-
const storage =
|
|
2575
|
+
const storage = browserStorage2();
|
|
2475
2576
|
if (storage === null) return;
|
|
2476
2577
|
storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
|
|
2477
2578
|
},
|
|
2478
2579
|
async remove(formId, formVersion) {
|
|
2479
|
-
const storage =
|
|
2580
|
+
const storage = browserStorage2();
|
|
2480
2581
|
if (storage === null) return;
|
|
2481
2582
|
storage.removeItem(receiptKey(namespace, formId, formVersion));
|
|
2482
2583
|
}
|
|
@@ -2505,7 +2606,14 @@ function useSubmissionReceipts(store, queries) {
|
|
|
2505
2606
|
};
|
|
2506
2607
|
}
|
|
2507
2608
|
setState((current) => ({ ...current, isLoading: true, error: null }));
|
|
2508
|
-
|
|
2609
|
+
const load = store.getBatch?.(stableQueries) ?? Promise.all(stableQueries.map((query) => store.get(query.formId, query.formVersion))).then(
|
|
2610
|
+
(receipts) => new Map(
|
|
2611
|
+
receipts.flatMap(
|
|
2612
|
+
(receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
|
|
2613
|
+
)
|
|
2614
|
+
)
|
|
2615
|
+
);
|
|
2616
|
+
void load.then((receipts) => {
|
|
2509
2617
|
if (active) setState({ receipts, isLoading: false, error: null });
|
|
2510
2618
|
}).catch((cause) => {
|
|
2511
2619
|
if (!active) return;
|
|
@@ -2749,6 +2857,8 @@ function ContextFormRenderer({
|
|
|
2749
2857
|
onDraftSave,
|
|
2750
2858
|
submissionGuards = [],
|
|
2751
2859
|
receiptStore,
|
|
2860
|
+
attemptStore,
|
|
2861
|
+
onReceiptError,
|
|
2752
2862
|
slots = {}
|
|
2753
2863
|
}) {
|
|
2754
2864
|
const form = useForm();
|
|
@@ -2906,7 +3016,18 @@ function ContextFormRenderer({
|
|
|
2906
3016
|
setGuardMessage(null);
|
|
2907
3017
|
rendererSubmissionInFlight.current = true;
|
|
2908
3018
|
try {
|
|
2909
|
-
|
|
3019
|
+
let submissionAttempt;
|
|
3020
|
+
const result = await form.submit(
|
|
3021
|
+
beforeSubmit,
|
|
3022
|
+
attemptStore === void 0 ? void 0 : async (values) => {
|
|
3023
|
+
submissionAttempt = await attemptStore.getOrCreate(form.schema.id, form.schema.version);
|
|
3024
|
+
return {
|
|
3025
|
+
...values,
|
|
3026
|
+
attemptId: submissionAttempt.attemptId,
|
|
3027
|
+
submissionId: submissionAttempt.attemptId
|
|
3028
|
+
};
|
|
3029
|
+
}
|
|
3030
|
+
);
|
|
2910
3031
|
if (result.status === "invalid") {
|
|
2911
3032
|
const invalidPageIndex = pages?.findIndex(
|
|
2912
3033
|
(page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
|
|
@@ -2918,14 +3039,29 @@ function ContextFormRenderer({
|
|
|
2918
3039
|
if (result.status !== "success") return result;
|
|
2919
3040
|
if (receiptStore !== void 0) {
|
|
2920
3041
|
const response = result.response;
|
|
3042
|
+
const submissionId = response?.submissionId ?? submissionAttempt?.attemptId;
|
|
2921
3043
|
const storedReceipt = {
|
|
2922
3044
|
formId: form.schema.id,
|
|
2923
3045
|
formVersion: form.schema.version,
|
|
2924
3046
|
submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2925
|
-
...
|
|
3047
|
+
...submissionId === void 0 ? {} : { submissionId }
|
|
2926
3048
|
};
|
|
2927
|
-
|
|
2928
|
-
|
|
3049
|
+
try {
|
|
3050
|
+
await receiptStore.save(storedReceipt);
|
|
3051
|
+
setReceipt(storedReceipt);
|
|
3052
|
+
} catch (cause) {
|
|
3053
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
3054
|
+
try {
|
|
3055
|
+
onReceiptError?.(error, storedReceipt);
|
|
3056
|
+
} catch {
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
if (attemptStore !== void 0 && submissionAttempt !== void 0) {
|
|
3061
|
+
try {
|
|
3062
|
+
await attemptStore.clear(form.schema.id, form.schema.version);
|
|
3063
|
+
} catch {
|
|
3064
|
+
}
|
|
2929
3065
|
}
|
|
2930
3066
|
if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
|
|
2931
3067
|
globalThis.localStorage.removeItem(autoSaveKey);
|
|
@@ -3145,6 +3281,7 @@ function FormRenderer(props) {
|
|
|
3145
3281
|
FormBuilder,
|
|
3146
3282
|
FormProvider,
|
|
3147
3283
|
FormRenderer,
|
|
3284
|
+
createLocalStorageSubmissionAttemptStore,
|
|
3148
3285
|
createLocalStorageSubmissionReceiptStore,
|
|
3149
3286
|
resolveInitialFieldType,
|
|
3150
3287
|
submissionReceiptQueryKey,
|
package/dist/index.d.cts
CHANGED
|
@@ -3,6 +3,21 @@ import { ReactNode, ComponentType, MouseEvent } from 'react';
|
|
|
3
3
|
import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
|
|
4
4
|
import { SensitiveDataFinding } from '@form-engine-ts/privacy';
|
|
5
5
|
|
|
6
|
+
interface SubmissionAttempt {
|
|
7
|
+
readonly attemptId: string;
|
|
8
|
+
readonly formId: string;
|
|
9
|
+
readonly formVersion: number;
|
|
10
|
+
readonly createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
interface SubmissionAttemptStore {
|
|
13
|
+
getOrCreate(formId: string, formVersion: number, idFactory?: () => string): Promise<SubmissionAttempt>;
|
|
14
|
+
get(formId: string, formVersion: number): Promise<SubmissionAttempt | null>;
|
|
15
|
+
clear(formId: string, formVersion: number): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
declare function createLocalStorageSubmissionAttemptStore(options?: {
|
|
18
|
+
readonly namespace?: string;
|
|
19
|
+
}): SubmissionAttemptStore;
|
|
20
|
+
|
|
6
21
|
/** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
|
|
7
22
|
type BuilderPolicy = FormPolicy;
|
|
8
23
|
type BuilderIdKind = "field" | "option" | "page";
|
|
@@ -97,7 +112,7 @@ interface SubmissionReceiptQuery {
|
|
|
97
112
|
}
|
|
98
113
|
interface SubmissionReceiptStore {
|
|
99
114
|
get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
|
|
100
|
-
getBatch(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
|
|
115
|
+
getBatch?(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
|
|
101
116
|
save(receipt: SubmissionReceipt): Promise<void>;
|
|
102
117
|
remove(formId: string, formVersion: number): Promise<void>;
|
|
103
118
|
}
|
|
@@ -356,6 +371,8 @@ interface FormRendererSlots {
|
|
|
356
371
|
interface SubmissionProtectionProps {
|
|
357
372
|
readonly submissionGuards?: readonly SubmissionGuard[];
|
|
358
373
|
readonly receiptStore?: SubmissionReceiptStore;
|
|
374
|
+
readonly attemptStore?: SubmissionAttemptStore;
|
|
375
|
+
readonly onReceiptError?: (error: Error, receipt: SubmissionReceipt) => void;
|
|
359
376
|
}
|
|
360
377
|
type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
|
|
361
378
|
|
|
@@ -403,7 +420,7 @@ interface FormContextValue {
|
|
|
403
420
|
readonly restoreValues: (values: FormValues) => void;
|
|
404
421
|
readonly validatePage: (pageIndex: number) => AnswerValidationResult;
|
|
405
422
|
readonly reset: () => void;
|
|
406
|
-
readonly submit: (beforeSubmit?: BeforeSubmit) => Promise<SubmitResult>;
|
|
423
|
+
readonly submit: (beforeSubmit?: BeforeSubmit, prepareSubmission?: (values: FormValues) => FormValues | Promise<FormValues>) => Promise<SubmitResult>;
|
|
407
424
|
readonly translate: (key: string, params?: Readonly<Record<string, string | number>>) => string;
|
|
408
425
|
}
|
|
409
426
|
interface FormProviderProps {
|
|
@@ -458,4 +475,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
|
|
|
458
475
|
type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
|
|
459
476
|
declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
|
|
460
477
|
|
|
461
|
-
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 FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
|
|
478
|
+
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 FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,21 @@ import { ReactNode, ComponentType, MouseEvent } from 'react';
|
|
|
3
3
|
import { QuestionType, FormField, ChoiceOption, FormPage, FormSchema, DisplayCondition, JsonValue, SchemaIssue, FormPolicy, FieldOption, TranslationReport, ValidationError, FormValues, TranslationAdapter, AsyncTranslationAdapter, PopulateTranslationOptions, FormValue, ValidationIssue, AnswerValidationResult, FieldType } from '@form-engine-ts/core';
|
|
4
4
|
import { SensitiveDataFinding } from '@form-engine-ts/privacy';
|
|
5
5
|
|
|
6
|
+
interface SubmissionAttempt {
|
|
7
|
+
readonly attemptId: string;
|
|
8
|
+
readonly formId: string;
|
|
9
|
+
readonly formVersion: number;
|
|
10
|
+
readonly createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
interface SubmissionAttemptStore {
|
|
13
|
+
getOrCreate(formId: string, formVersion: number, idFactory?: () => string): Promise<SubmissionAttempt>;
|
|
14
|
+
get(formId: string, formVersion: number): Promise<SubmissionAttempt | null>;
|
|
15
|
+
clear(formId: string, formVersion: number): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
declare function createLocalStorageSubmissionAttemptStore(options?: {
|
|
18
|
+
readonly namespace?: string;
|
|
19
|
+
}): SubmissionAttemptStore;
|
|
20
|
+
|
|
6
21
|
/** @deprecated Import FormPolicy from @form-engine-ts/core instead. */
|
|
7
22
|
type BuilderPolicy = FormPolicy;
|
|
8
23
|
type BuilderIdKind = "field" | "option" | "page";
|
|
@@ -97,7 +112,7 @@ interface SubmissionReceiptQuery {
|
|
|
97
112
|
}
|
|
98
113
|
interface SubmissionReceiptStore {
|
|
99
114
|
get(formId: string, formVersion: number): Promise<SubmissionReceipt | null>;
|
|
100
|
-
getBatch(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
|
|
115
|
+
getBatch?(queries: readonly SubmissionReceiptQuery[]): Promise<Map<string, SubmissionReceipt>>;
|
|
101
116
|
save(receipt: SubmissionReceipt): Promise<void>;
|
|
102
117
|
remove(formId: string, formVersion: number): Promise<void>;
|
|
103
118
|
}
|
|
@@ -356,6 +371,8 @@ interface FormRendererSlots {
|
|
|
356
371
|
interface SubmissionProtectionProps {
|
|
357
372
|
readonly submissionGuards?: readonly SubmissionGuard[];
|
|
358
373
|
readonly receiptStore?: SubmissionReceiptStore;
|
|
374
|
+
readonly attemptStore?: SubmissionAttemptStore;
|
|
375
|
+
readonly onReceiptError?: (error: Error, receipt: SubmissionReceipt) => void;
|
|
359
376
|
}
|
|
360
377
|
type BeforeSubmit = (values: Readonly<Record<string, unknown>>) => "continue" | "cancel" | Promise<"continue" | "cancel">;
|
|
361
378
|
|
|
@@ -403,7 +420,7 @@ interface FormContextValue {
|
|
|
403
420
|
readonly restoreValues: (values: FormValues) => void;
|
|
404
421
|
readonly validatePage: (pageIndex: number) => AnswerValidationResult;
|
|
405
422
|
readonly reset: () => void;
|
|
406
|
-
readonly submit: (beforeSubmit?: BeforeSubmit) => Promise<SubmitResult>;
|
|
423
|
+
readonly submit: (beforeSubmit?: BeforeSubmit, prepareSubmission?: (values: FormValues) => FormValues | Promise<FormValues>) => Promise<SubmitResult>;
|
|
407
424
|
readonly translate: (key: string, params?: Readonly<Record<string, string | number>>) => string;
|
|
408
425
|
}
|
|
409
426
|
interface FormProviderProps {
|
|
@@ -458,4 +475,4 @@ interface StandaloneFormRendererProps extends FormRendererPresentationProps {
|
|
|
458
475
|
type FormRendererProps = FormRendererPresentationProps | StandaloneFormRendererProps;
|
|
459
476
|
declare function FormRenderer(props: FormRendererProps): react.JSX.Element;
|
|
460
477
|
|
|
461
|
-
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 FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
|
|
478
|
+
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 FormSubmitHandler, type FormSubmitState, type InputComponentProps, type ManualTranslationContext, type StandaloneFormRendererProps, type SubmissionAttempt, type SubmissionAttemptStore, type SubmissionConfirmationSlotProps, type SubmissionGuard, type SubmissionGuardResult, type SubmissionProtectionProps, type SubmissionReceipt, type SubmissionReceiptQuery, type SubmissionReceiptStore, type SubmitResponse, type SubmitResult, type SubmitStatus, type UseSubmissionReceiptsResult, createLocalStorageSubmissionAttemptStore, createLocalStorageSubmissionReceiptStore, resolveInitialFieldType, submissionReceiptQueryKey, useField, useForm, useFormBuilder, useSubmissionReceipts };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
+
// src/attempt.ts
|
|
2
|
+
function attemptKey(namespace, formId, formVersion) {
|
|
3
|
+
return `${namespace}:${formId}:v${formVersion}`;
|
|
4
|
+
}
|
|
5
|
+
function browserStorage() {
|
|
6
|
+
if (typeof window === "undefined") return null;
|
|
7
|
+
try {
|
|
8
|
+
return window.localStorage;
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function parseAttempt(serialized) {
|
|
14
|
+
try {
|
|
15
|
+
const value = JSON.parse(serialized);
|
|
16
|
+
if (typeof value !== "object" || value === null || !("attemptId" in value) || typeof value.attemptId !== "string" || value.attemptId.length === 0 || !("formId" in value) || typeof value.formId !== "string" || !("formVersion" in value) || typeof value.formVersion !== "number" || !Number.isSafeInteger(value.formVersion) || !("createdAt" in value) || typeof value.createdAt !== "string" || !Number.isFinite(Date.parse(value.createdAt))) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
attemptId: value.attemptId,
|
|
21
|
+
formId: value.formId,
|
|
22
|
+
formVersion: value.formVersion,
|
|
23
|
+
createdAt: value.createdAt
|
|
24
|
+
};
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function defaultAttemptId() {
|
|
30
|
+
const randomUuid = globalThis.crypto?.randomUUID;
|
|
31
|
+
if (typeof randomUuid === "function") return randomUuid.call(globalThis.crypto);
|
|
32
|
+
return `attempt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
33
|
+
}
|
|
34
|
+
function createLocalStorageSubmissionAttemptStore(options = {}) {
|
|
35
|
+
const namespace = options.namespace ?? "form_engine_attempt";
|
|
36
|
+
if (namespace.trim().length === 0) throw new TypeError("Attempt namespace must not be empty.");
|
|
37
|
+
const memory = /* @__PURE__ */ new Map();
|
|
38
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
39
|
+
const get = async (formId, formVersion) => {
|
|
40
|
+
const key = attemptKey(namespace, formId, formVersion);
|
|
41
|
+
const remembered = memory.get(key);
|
|
42
|
+
if (remembered !== void 0) return remembered;
|
|
43
|
+
const storage = browserStorage();
|
|
44
|
+
if (storage === null) return null;
|
|
45
|
+
try {
|
|
46
|
+
const serialized = storage.getItem(key);
|
|
47
|
+
if (serialized === null) return null;
|
|
48
|
+
const attempt = parseAttempt(serialized);
|
|
49
|
+
if (attempt === null || attempt.formId !== formId || attempt.formVersion !== formVersion) return null;
|
|
50
|
+
memory.set(key, attempt);
|
|
51
|
+
return attempt;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
get,
|
|
58
|
+
async getOrCreate(formId, formVersion, idFactory = defaultAttemptId) {
|
|
59
|
+
const key = attemptKey(namespace, formId, formVersion);
|
|
60
|
+
const pending = inFlight.get(key);
|
|
61
|
+
if (pending !== void 0) return pending;
|
|
62
|
+
const create = (async () => {
|
|
63
|
+
const existing = await get(formId, formVersion);
|
|
64
|
+
if (existing !== null) return existing;
|
|
65
|
+
const attemptId = idFactory();
|
|
66
|
+
if (typeof attemptId !== "string" || attemptId.trim().length === 0) {
|
|
67
|
+
throw new TypeError("Attempt idFactory must return a non-empty string.");
|
|
68
|
+
}
|
|
69
|
+
const attempt = {
|
|
70
|
+
attemptId,
|
|
71
|
+
formId,
|
|
72
|
+
formVersion,
|
|
73
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
74
|
+
};
|
|
75
|
+
memory.set(key, attempt);
|
|
76
|
+
try {
|
|
77
|
+
browserStorage()?.setItem(key, JSON.stringify(attempt));
|
|
78
|
+
} catch {
|
|
79
|
+
}
|
|
80
|
+
return attempt;
|
|
81
|
+
})();
|
|
82
|
+
inFlight.set(key, create);
|
|
83
|
+
try {
|
|
84
|
+
return await create;
|
|
85
|
+
} finally {
|
|
86
|
+
inFlight.delete(key);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async clear(formId, formVersion) {
|
|
90
|
+
const key = attemptKey(namespace, formId, formVersion);
|
|
91
|
+
memory.delete(key);
|
|
92
|
+
try {
|
|
93
|
+
browserStorage()?.removeItem(key);
|
|
94
|
+
} catch {
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
1
100
|
// src/builder.tsx
|
|
2
101
|
import {
|
|
3
102
|
populateSchemaTranslations
|
|
@@ -2299,7 +2398,7 @@ function FormProvider({
|
|
|
2299
2398
|
setSubmitError(null);
|
|
2300
2399
|
}, [initialValues]);
|
|
2301
2400
|
const submit = useCallback2(
|
|
2302
|
-
async (beforeSubmit) => {
|
|
2401
|
+
async (beforeSubmit, prepareSubmission) => {
|
|
2303
2402
|
if (submissionInFlight.current) return { status: "cancelled" };
|
|
2304
2403
|
const validation = validateAnswers(validSchema, values);
|
|
2305
2404
|
if (!validation.valid) {
|
|
@@ -2320,7 +2419,8 @@ function FormProvider({
|
|
|
2320
2419
|
setSubmitStatus("idle");
|
|
2321
2420
|
return { status: "cancelled" };
|
|
2322
2421
|
}
|
|
2323
|
-
const
|
|
2422
|
+
const submissionValues = prepareSubmission === void 0 ? visibleValues : await prepareSubmission(visibleValues);
|
|
2423
|
+
const response = await onSubmit(submissionValues);
|
|
2324
2424
|
if (resetOnSuccess) setValues({ ...initialValues });
|
|
2325
2425
|
setSubmitStatus("success");
|
|
2326
2426
|
return response === void 0 ? { status: "success" } : { status: "success", response };
|
|
@@ -2399,7 +2499,7 @@ function submissionReceiptQueryKey(formId, formVersion) {
|
|
|
2399
2499
|
function receiptKey(namespace, formId, formVersion) {
|
|
2400
2500
|
return `${namespace}:${formId}:v${formVersion}`;
|
|
2401
2501
|
}
|
|
2402
|
-
function
|
|
2502
|
+
function browserStorage2() {
|
|
2403
2503
|
if (typeof window === "undefined") return null;
|
|
2404
2504
|
try {
|
|
2405
2505
|
return window.localStorage;
|
|
@@ -2428,7 +2528,7 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
|
2428
2528
|
const namespace = options.namespace ?? "form_engine_receipt";
|
|
2429
2529
|
if (namespace.trim().length === 0) throw new TypeError("Receipt namespace must not be empty.");
|
|
2430
2530
|
const get = async (formId, formVersion) => {
|
|
2431
|
-
const storage =
|
|
2531
|
+
const storage = browserStorage2();
|
|
2432
2532
|
if (storage === null) return null;
|
|
2433
2533
|
try {
|
|
2434
2534
|
const serialized = storage.getItem(receiptKey(namespace, formId, formVersion));
|
|
@@ -2450,12 +2550,12 @@ function createLocalStorageSubmissionReceiptStore(options = {}) {
|
|
|
2450
2550
|
);
|
|
2451
2551
|
},
|
|
2452
2552
|
async save(receipt) {
|
|
2453
|
-
const storage =
|
|
2553
|
+
const storage = browserStorage2();
|
|
2454
2554
|
if (storage === null) return;
|
|
2455
2555
|
storage.setItem(receiptKey(namespace, receipt.formId, receipt.formVersion), JSON.stringify(receipt));
|
|
2456
2556
|
},
|
|
2457
2557
|
async remove(formId, formVersion) {
|
|
2458
|
-
const storage =
|
|
2558
|
+
const storage = browserStorage2();
|
|
2459
2559
|
if (storage === null) return;
|
|
2460
2560
|
storage.removeItem(receiptKey(namespace, formId, formVersion));
|
|
2461
2561
|
}
|
|
@@ -2484,7 +2584,14 @@ function useSubmissionReceipts(store, queries) {
|
|
|
2484
2584
|
};
|
|
2485
2585
|
}
|
|
2486
2586
|
setState((current) => ({ ...current, isLoading: true, error: null }));
|
|
2487
|
-
|
|
2587
|
+
const load = store.getBatch?.(stableQueries) ?? Promise.all(stableQueries.map((query) => store.get(query.formId, query.formVersion))).then(
|
|
2588
|
+
(receipts) => new Map(
|
|
2589
|
+
receipts.flatMap(
|
|
2590
|
+
(receipt) => receipt === null ? [] : [[submissionReceiptQueryKey(receipt.formId, receipt.formVersion), receipt]]
|
|
2591
|
+
)
|
|
2592
|
+
)
|
|
2593
|
+
);
|
|
2594
|
+
void load.then((receipts) => {
|
|
2488
2595
|
if (active) setState({ receipts, isLoading: false, error: null });
|
|
2489
2596
|
}).catch((cause) => {
|
|
2490
2597
|
if (!active) return;
|
|
@@ -2738,6 +2845,8 @@ function ContextFormRenderer({
|
|
|
2738
2845
|
onDraftSave,
|
|
2739
2846
|
submissionGuards = [],
|
|
2740
2847
|
receiptStore,
|
|
2848
|
+
attemptStore,
|
|
2849
|
+
onReceiptError,
|
|
2741
2850
|
slots = {}
|
|
2742
2851
|
}) {
|
|
2743
2852
|
const form = useForm();
|
|
@@ -2895,7 +3004,18 @@ function ContextFormRenderer({
|
|
|
2895
3004
|
setGuardMessage(null);
|
|
2896
3005
|
rendererSubmissionInFlight.current = true;
|
|
2897
3006
|
try {
|
|
2898
|
-
|
|
3007
|
+
let submissionAttempt;
|
|
3008
|
+
const result = await form.submit(
|
|
3009
|
+
beforeSubmit,
|
|
3010
|
+
attemptStore === void 0 ? void 0 : async (values) => {
|
|
3011
|
+
submissionAttempt = await attemptStore.getOrCreate(form.schema.id, form.schema.version);
|
|
3012
|
+
return {
|
|
3013
|
+
...values,
|
|
3014
|
+
attemptId: submissionAttempt.attemptId,
|
|
3015
|
+
submissionId: submissionAttempt.attemptId
|
|
3016
|
+
};
|
|
3017
|
+
}
|
|
3018
|
+
);
|
|
2899
3019
|
if (result.status === "invalid") {
|
|
2900
3020
|
const invalidPageIndex = pages?.findIndex(
|
|
2901
3021
|
(page) => firstInvalidFieldId === void 0 ? false : page.questionIds.includes(firstInvalidFieldId)
|
|
@@ -2907,14 +3027,29 @@ function ContextFormRenderer({
|
|
|
2907
3027
|
if (result.status !== "success") return result;
|
|
2908
3028
|
if (receiptStore !== void 0) {
|
|
2909
3029
|
const response = result.response;
|
|
3030
|
+
const submissionId = response?.submissionId ?? submissionAttempt?.attemptId;
|
|
2910
3031
|
const storedReceipt = {
|
|
2911
3032
|
formId: form.schema.id,
|
|
2912
3033
|
formVersion: form.schema.version,
|
|
2913
3034
|
submittedAt: response?.submittedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2914
|
-
...
|
|
3035
|
+
...submissionId === void 0 ? {} : { submissionId }
|
|
2915
3036
|
};
|
|
2916
|
-
|
|
2917
|
-
|
|
3037
|
+
try {
|
|
3038
|
+
await receiptStore.save(storedReceipt);
|
|
3039
|
+
setReceipt(storedReceipt);
|
|
3040
|
+
} catch (cause) {
|
|
3041
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
3042
|
+
try {
|
|
3043
|
+
onReceiptError?.(error, storedReceipt);
|
|
3044
|
+
} catch {
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
if (attemptStore !== void 0 && submissionAttempt !== void 0) {
|
|
3049
|
+
try {
|
|
3050
|
+
await attemptStore.clear(form.schema.id, form.schema.version);
|
|
3051
|
+
} catch {
|
|
3052
|
+
}
|
|
2918
3053
|
}
|
|
2919
3054
|
if (autoSaveKey !== void 0 && typeof globalThis.localStorage !== "undefined") {
|
|
2920
3055
|
globalThis.localStorage.removeItem(autoSaveKey);
|
|
@@ -3133,6 +3268,7 @@ export {
|
|
|
3133
3268
|
FormBuilder,
|
|
3134
3269
|
FormProvider,
|
|
3135
3270
|
FormRenderer,
|
|
3271
|
+
createLocalStorageSubmissionAttemptStore,
|
|
3136
3272
|
createLocalStorageSubmissionReceiptStore,
|
|
3137
3273
|
resolveInitialFieldType,
|
|
3138
3274
|
submissionReceiptQueryKey,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@form-engine-ts/react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"typescript"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@form-engine-ts/core": "2.
|
|
46
|
-
"@form-engine-ts/privacy": "2.
|
|
45
|
+
"@form-engine-ts/core": "2.9.1",
|
|
46
|
+
"@form-engine-ts/privacy": "2.9.1"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
49
|
"react": ">=18.2 <20",
|