@ram_28/kf-ai-sdk 1.0.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/LICENSE +21 -0
- package/README.md +840 -0
- package/dist/api/client.d.ts +78 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/datetime.d.ts +21 -0
- package/dist/api/datetime.d.ts.map +1 -0
- package/dist/api/index.d.ts +7 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/metadata.d.ts +75 -0
- package/dist/api/metadata.d.ts.map +1 -0
- package/dist/components/hooks/index.d.ts +8 -0
- package/dist/components/hooks/index.d.ts.map +1 -0
- package/dist/components/hooks/useFilter/index.d.ts +5 -0
- package/dist/components/hooks/useFilter/index.d.ts.map +1 -0
- package/dist/components/hooks/useFilter/payloadBuilder.utils.d.ts +33 -0
- package/dist/components/hooks/useFilter/payloadBuilder.utils.d.ts.map +1 -0
- package/dist/components/hooks/useFilter/types.d.ts +137 -0
- package/dist/components/hooks/useFilter/types.d.ts.map +1 -0
- package/dist/components/hooks/useFilter/useFilter.d.ts +3 -0
- package/dist/components/hooks/useFilter/useFilter.d.ts.map +1 -0
- package/dist/components/hooks/useFilter/validation.utils.d.ts +38 -0
- package/dist/components/hooks/useFilter/validation.utils.d.ts.map +1 -0
- package/dist/components/hooks/useForm/apiClient.d.ts +71 -0
- package/dist/components/hooks/useForm/apiClient.d.ts.map +1 -0
- package/dist/components/hooks/useForm/expressionValidator.utils.d.ts +28 -0
- package/dist/components/hooks/useForm/expressionValidator.utils.d.ts.map +1 -0
- package/dist/components/hooks/useForm/index.d.ts +6 -0
- package/dist/components/hooks/useForm/index.d.ts.map +1 -0
- package/dist/components/hooks/useForm/optimizedExpressionValidator.utils.d.ts +88 -0
- package/dist/components/hooks/useForm/optimizedExpressionValidator.utils.d.ts.map +1 -0
- package/dist/components/hooks/useForm/ruleClassifier.utils.d.ts +28 -0
- package/dist/components/hooks/useForm/ruleClassifier.utils.d.ts.map +1 -0
- package/dist/components/hooks/useForm/schemaParser.utils.d.ts +29 -0
- package/dist/components/hooks/useForm/schemaParser.utils.d.ts.map +1 -0
- package/dist/components/hooks/useForm/types.d.ts +412 -0
- package/dist/components/hooks/useForm/types.d.ts.map +1 -0
- package/dist/components/hooks/useForm/useForm.d.ts +3 -0
- package/dist/components/hooks/useForm/useForm.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/apiClient.d.ts +99 -0
- package/dist/components/hooks/useKanban/apiClient.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/context.d.ts +4 -0
- package/dist/components/hooks/useKanban/context.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/dragDropManager.d.ts +27 -0
- package/dist/components/hooks/useKanban/dragDropManager.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/index.d.ts +6 -0
- package/dist/components/hooks/useKanban/index.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/types.d.ts +438 -0
- package/dist/components/hooks/useKanban/types.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/useKanban.d.ts +3 -0
- package/dist/components/hooks/useKanban/useKanban.d.ts.map +1 -0
- package/dist/components/hooks/useKanban/useKanbanSimple.d.ts +62 -0
- package/dist/components/hooks/useKanban/useKanbanSimple.d.ts.map +1 -0
- package/dist/components/hooks/useTable/index.d.ts +3 -0
- package/dist/components/hooks/useTable/index.d.ts.map +1 -0
- package/dist/components/hooks/useTable/types.d.ts +107 -0
- package/dist/components/hooks/useTable/types.d.ts.map +1 -0
- package/dist/components/hooks/useTable/useTable.d.ts +8 -0
- package/dist/components/hooks/useTable/useTable.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -0
- package/dist/components/ui/kanban/Kanban.d.ts +12 -0
- package/dist/components/ui/kanban/Kanban.d.ts.map +1 -0
- package/dist/components/ui/kanban/index.d.ts +2 -0
- package/dist/components/ui/kanban/index.d.ts.map +1 -0
- package/dist/index.cjs +45 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +6522 -0
- package/dist/types/base-fields.d.ts +182 -0
- package/dist/types/base-fields.d.ts.map +1 -0
- package/dist/types/common.d.ts +238 -0
- package/dist/types/common.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/utils/cn.d.ts +7 -0
- package/dist/utils/cn.d.ts.map +1 -0
- package/dist/utils/formatting.d.ts +52 -0
- package/dist/utils/formatting.d.ts.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/package.json +98 -0
- package/sdk/api/client.ts +447 -0
- package/sdk/api/datetime.ts +33 -0
- package/sdk/api/index.ts +61 -0
- package/sdk/api/metadata.ts +148 -0
- package/sdk/components/hooks/index.ts +34 -0
- package/sdk/components/hooks/useFilter/index.ts +37 -0
- package/sdk/components/hooks/useFilter/payloadBuilder.utils.ts +298 -0
- package/sdk/components/hooks/useFilter/types.ts +158 -0
- package/sdk/components/hooks/useFilter/useFilter.llm.txt +497 -0
- package/sdk/components/hooks/useFilter/useFilter.ts +494 -0
- package/sdk/components/hooks/useFilter/validation.utils.ts +401 -0
- package/sdk/components/hooks/useForm/apiClient.ts +441 -0
- package/sdk/components/hooks/useForm/expressionValidator.utils.ts +444 -0
- package/sdk/components/hooks/useForm/index.ts +64 -0
- package/sdk/components/hooks/useForm/optimizedExpressionValidator.utils.ts +482 -0
- package/sdk/components/hooks/useForm/ruleClassifier.utils.ts +424 -0
- package/sdk/components/hooks/useForm/schemaParser.utils.ts +519 -0
- package/sdk/components/hooks/useForm/types.ts +630 -0
- package/sdk/components/hooks/useForm/useForm.llm.txt +340 -0
- package/sdk/components/hooks/useForm/useForm.ts +821 -0
- package/sdk/components/hooks/useKanban/apiClient.ts +494 -0
- package/sdk/components/hooks/useKanban/context.ts +14 -0
- package/sdk/components/hooks/useKanban/dragDropManager.ts +529 -0
- package/sdk/components/hooks/useKanban/index.ts +63 -0
- package/sdk/components/hooks/useKanban/types.ts +606 -0
- package/sdk/components/hooks/useKanban/useKanban.llm.txt +482 -0
- package/sdk/components/hooks/useKanban/useKanban.ts +725 -0
- package/sdk/components/hooks/useKanban/useKanbanSimple.ts +389 -0
- package/sdk/components/hooks/useTable/index.ts +5 -0
- package/sdk/components/hooks/useTable/types.ts +154 -0
- package/sdk/components/hooks/useTable/useTable.llm.txt +344 -0
- package/sdk/components/hooks/useTable/useTable.ts +413 -0
- package/sdk/components/index.ts +15 -0
- package/sdk/components/ui/index.ts +2 -0
- package/sdk/components/ui/kanban/Kanban.tsx +134 -0
- package/sdk/components/ui/kanban/index.ts +11 -0
- package/sdk/index.ts +13 -0
- package/sdk/types/base-fields.ts +221 -0
- package/sdk/types/common.ts +306 -0
- package/sdk/types/index.ts +5 -0
- package/sdk/utils/cn.ts +10 -0
- package/sdk/utils/formatting.ts +212 -0
- package/sdk/utils/index.ts +5 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// USE FORM HOOK
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Main hook that integrates react-hook-form with backend schemas
|
|
5
|
+
|
|
6
|
+
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
7
|
+
import { useForm as useReactHookForm } from "react-hook-form";
|
|
8
|
+
import { useQuery } from "@tanstack/react-query";
|
|
9
|
+
import type { Path } from "react-hook-form";
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
UseFormOptions,
|
|
13
|
+
UseFormReturn,
|
|
14
|
+
BackendSchema,
|
|
15
|
+
ProcessedSchema,
|
|
16
|
+
ProcessedField,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
import { processSchema, extractReferenceFields } from "./schemaParser.utils";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
fetchFormSchemaWithCache,
|
|
23
|
+
fetchRecord,
|
|
24
|
+
submitFormData,
|
|
25
|
+
fetchAllReferenceData,
|
|
26
|
+
cleanFormData,
|
|
27
|
+
} from "./apiClient";
|
|
28
|
+
|
|
29
|
+
import { api } from "../../../api";
|
|
30
|
+
|
|
31
|
+
import { validateCrossField } from "./expressionValidator.utils";
|
|
32
|
+
import {
|
|
33
|
+
validateFieldOptimized,
|
|
34
|
+
getFieldDependencies,
|
|
35
|
+
} from "./optimizedExpressionValidator.utils";
|
|
36
|
+
|
|
37
|
+
// ============================================================
|
|
38
|
+
// MAIN HOOK IMPLEMENTATION
|
|
39
|
+
// ============================================================
|
|
40
|
+
|
|
41
|
+
export function useForm<T extends Record<string, any> = Record<string, any>>(
|
|
42
|
+
options: UseFormOptions<T>
|
|
43
|
+
): UseFormReturn<T> {
|
|
44
|
+
const {
|
|
45
|
+
source,
|
|
46
|
+
operation,
|
|
47
|
+
recordId,
|
|
48
|
+
defaultValues = {},
|
|
49
|
+
mode = "onBlur", // Validation mode - controls when errors are shown (see types.ts for details)
|
|
50
|
+
enabled = true,
|
|
51
|
+
userRole,
|
|
52
|
+
onSuccess,
|
|
53
|
+
onError,
|
|
54
|
+
onSchemaError,
|
|
55
|
+
onSubmitError,
|
|
56
|
+
skipSchemaFetch = false,
|
|
57
|
+
schema: manualSchema,
|
|
58
|
+
draftOnEveryChange = false,
|
|
59
|
+
} = options;
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// STATE MANAGEMENT
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
const [processedSchema, setProcessedSchema] =
|
|
66
|
+
useState<ProcessedSchema | null>(null);
|
|
67
|
+
const [referenceData, setReferenceData] = useState<Record<string, any[]>>({});
|
|
68
|
+
const [submitError, setSubmitError] = useState<Error | null>(null);
|
|
69
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
70
|
+
const [lastFormValues] = useState<Partial<T>>({});
|
|
71
|
+
|
|
72
|
+
// Prevent infinite loop in API calls
|
|
73
|
+
const isComputingRef = useRef(false);
|
|
74
|
+
const computeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
75
|
+
|
|
76
|
+
// Track values that have been synced with server (sent in draft calls)
|
|
77
|
+
// This allows us to detect changes since the last draft, not since form init
|
|
78
|
+
const lastSyncedValuesRef = useRef<Partial<T> | null>(null);
|
|
79
|
+
|
|
80
|
+
// Stable callback refs to prevent dependency loops
|
|
81
|
+
const onSuccessRef = useRef(onSuccess);
|
|
82
|
+
const onErrorRef = useRef(onError);
|
|
83
|
+
const onSubmitErrorRef = useRef(onSubmitError);
|
|
84
|
+
const onSchemaErrorRef = useRef(onSchemaError);
|
|
85
|
+
|
|
86
|
+
// Update refs when callbacks change
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
onSuccessRef.current = onSuccess;
|
|
89
|
+
}, [onSuccess]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
onErrorRef.current = onError;
|
|
93
|
+
}, [onError]);
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
onSubmitErrorRef.current = onSubmitError;
|
|
97
|
+
}, [onSubmitError]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
onSchemaErrorRef.current = onSchemaError;
|
|
101
|
+
}, [onSchemaError]);
|
|
102
|
+
|
|
103
|
+
// ============================================================
|
|
104
|
+
// SCHEMA FETCHING
|
|
105
|
+
// ============================================================
|
|
106
|
+
|
|
107
|
+
const {
|
|
108
|
+
data: schema,
|
|
109
|
+
isLoading: isLoadingSchema,
|
|
110
|
+
error: schemaError,
|
|
111
|
+
refetch: refetchSchema,
|
|
112
|
+
} = useQuery({
|
|
113
|
+
queryKey: ["form-schema", source],
|
|
114
|
+
queryFn: () =>
|
|
115
|
+
skipSchemaFetch
|
|
116
|
+
? Promise.resolve(manualSchema || {})
|
|
117
|
+
: fetchFormSchemaWithCache(source),
|
|
118
|
+
enabled: enabled && (!skipSchemaFetch || !!manualSchema),
|
|
119
|
+
retry: 3,
|
|
120
|
+
staleTime: 30 * 60 * 1000, // 30 minutes - schemas don't change frequently
|
|
121
|
+
gcTime: 60 * 60 * 1000, // 1 hour - keep schemas in cache longer
|
|
122
|
+
throwOnError: false,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ============================================================
|
|
126
|
+
// RECORD FETCHING (for update operations)
|
|
127
|
+
// ============================================================
|
|
128
|
+
|
|
129
|
+
const {
|
|
130
|
+
data: recordData,
|
|
131
|
+
isLoading: isLoadingRecord,
|
|
132
|
+
error: recordError,
|
|
133
|
+
} = useQuery({
|
|
134
|
+
queryKey: ["form-record", source, recordId],
|
|
135
|
+
queryFn: () => fetchRecord<T>(source, recordId!),
|
|
136
|
+
enabled: enabled && operation === "update" && !!recordId,
|
|
137
|
+
retry: 3,
|
|
138
|
+
staleTime: 5 * 60 * 1000, // 5 minutes - records can change more frequently
|
|
139
|
+
gcTime: 15 * 60 * 1000, // 15 minutes - keep records for a reasonable time
|
|
140
|
+
throwOnError: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ============================================================
|
|
144
|
+
// REACT HOOK FORM SETUP
|
|
145
|
+
// ============================================================
|
|
146
|
+
|
|
147
|
+
const defaultFormValues = useMemo(() => {
|
|
148
|
+
const values = { ...defaultValues };
|
|
149
|
+
|
|
150
|
+
// Merge record data for update operations
|
|
151
|
+
if (operation === "update" && recordData) {
|
|
152
|
+
Object.assign(values, recordData);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apply default values from schema
|
|
156
|
+
if (processedSchema) {
|
|
157
|
+
for (const [fieldName, field] of Object.entries(processedSchema.fields)) {
|
|
158
|
+
if (field.defaultValue !== undefined && !(fieldName in values)) {
|
|
159
|
+
(values as any)[fieldName] = field.defaultValue;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return values;
|
|
165
|
+
}, [defaultValues, recordData, operation, processedSchema]);
|
|
166
|
+
|
|
167
|
+
const rhfForm = useReactHookForm<T>({
|
|
168
|
+
mode,
|
|
169
|
+
defaultValues: defaultValues as any,
|
|
170
|
+
values:
|
|
171
|
+
operation === "update" && recordData
|
|
172
|
+
? (defaultFormValues as any)
|
|
173
|
+
: undefined,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// ============================================================
|
|
177
|
+
// SCHEMA PROCESSING
|
|
178
|
+
// ============================================================
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (schema) {
|
|
182
|
+
try {
|
|
183
|
+
const processed = processSchema(
|
|
184
|
+
schema as any,
|
|
185
|
+
{}, // Pass empty object - validation functions get live values from react-hook-form
|
|
186
|
+
userRole
|
|
187
|
+
);
|
|
188
|
+
setProcessedSchema(processed);
|
|
189
|
+
|
|
190
|
+
// Fetch reference data for reference fields
|
|
191
|
+
const refFields = extractReferenceFields(processed);
|
|
192
|
+
if (Object.keys(refFields).length > 0) {
|
|
193
|
+
fetchAllReferenceData(refFields)
|
|
194
|
+
.then(setReferenceData)
|
|
195
|
+
.catch(console.warn);
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.error("Schema processing failed:", error);
|
|
199
|
+
onSchemaErrorRef.current?.(error as Error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}, [schema, userRole]); // Removed onSchemaError - using ref instead
|
|
203
|
+
|
|
204
|
+
// Handle schema and record errors
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (schemaError) {
|
|
207
|
+
onSchemaErrorRef.current?.(schemaError);
|
|
208
|
+
}
|
|
209
|
+
}, [schemaError]);
|
|
210
|
+
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
if (recordError) {
|
|
213
|
+
onErrorRef.current?.(recordError);
|
|
214
|
+
}
|
|
215
|
+
}, [recordError]);
|
|
216
|
+
|
|
217
|
+
// ============================================================
|
|
218
|
+
// COMPUTED FIELD DEPENDENCY TRACKING AND OPTIMIZATION
|
|
219
|
+
// ============================================================
|
|
220
|
+
|
|
221
|
+
// Extract computed field dependencies using optimized analyzer
|
|
222
|
+
const computedFieldDependencies = useMemo(() => {
|
|
223
|
+
if (!processedSchema) return [];
|
|
224
|
+
|
|
225
|
+
const dependencies = new Set<string>();
|
|
226
|
+
const computedFieldNames = new Set(processedSchema.computedFields);
|
|
227
|
+
|
|
228
|
+
// Analyze dependencies from computation rules
|
|
229
|
+
Object.entries(processedSchema.fieldRules).forEach(([fieldName, rules]) => {
|
|
230
|
+
rules.computation.forEach((ruleId) => {
|
|
231
|
+
const rule = processedSchema.rules.computation[ruleId];
|
|
232
|
+
if (rule?.ExpressionTree) {
|
|
233
|
+
const ruleDeps = getFieldDependencies(rule.ExpressionTree);
|
|
234
|
+
ruleDeps.forEach((dep) => {
|
|
235
|
+
// Only add non-computed fields as dependencies
|
|
236
|
+
if (
|
|
237
|
+
processedSchema.fields[dep] &&
|
|
238
|
+
dep !== fieldName &&
|
|
239
|
+
!computedFieldNames.has(dep)
|
|
240
|
+
) {
|
|
241
|
+
dependencies.add(dep);
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Also check formulas (legacy support)
|
|
249
|
+
processedSchema.computedFields.forEach((fieldName: string) => {
|
|
250
|
+
const field = processedSchema.fields[fieldName];
|
|
251
|
+
if (field.backendField.Formula) {
|
|
252
|
+
const fieldDeps = getFieldDependencies(
|
|
253
|
+
field.backendField.Formula.ExpressionTree
|
|
254
|
+
);
|
|
255
|
+
fieldDeps.forEach((dep) => {
|
|
256
|
+
// Only add non-computed fields as dependencies
|
|
257
|
+
if (
|
|
258
|
+
processedSchema.fields[dep] &&
|
|
259
|
+
dep !== fieldName &&
|
|
260
|
+
!computedFieldNames.has(dep)
|
|
261
|
+
) {
|
|
262
|
+
dependencies.add(dep);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
return Array.from(dependencies) as Array<Path<T>>;
|
|
269
|
+
}, [processedSchema]);
|
|
270
|
+
|
|
271
|
+
// Watch dependencies are tracked but not used for automatic computation
|
|
272
|
+
// Computation is triggered manually on blur after validation passes
|
|
273
|
+
|
|
274
|
+
// ============================================================
|
|
275
|
+
// COMPUTATION RULE HANDLING
|
|
276
|
+
// ============================================================
|
|
277
|
+
|
|
278
|
+
// Manual computation trigger - called on blur after validation passes
|
|
279
|
+
const triggerComputationAfterValidation = useCallback(
|
|
280
|
+
async (fieldName: string) => {
|
|
281
|
+
if (!processedSchema || computedFieldDependencies.length === 0) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check if this field is a dependency for any computed fields
|
|
286
|
+
// If draftOnEveryChange is true, trigger for all fields
|
|
287
|
+
// If false (default), only trigger for computed field dependencies
|
|
288
|
+
const shouldTrigger =
|
|
289
|
+
draftOnEveryChange ||
|
|
290
|
+
computedFieldDependencies.includes(fieldName as Path<T>);
|
|
291
|
+
|
|
292
|
+
if (!shouldTrigger) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Prevent concurrent API calls
|
|
297
|
+
if (isComputingRef.current) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Debounce API calls
|
|
302
|
+
if (computeTimeoutRef.current) {
|
|
303
|
+
clearTimeout(computeTimeoutRef.current);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
computeTimeoutRef.current = setTimeout(() => {
|
|
307
|
+
// Additional safety check
|
|
308
|
+
if (!processedSchema) return;
|
|
309
|
+
|
|
310
|
+
// Prevent concurrent API calls
|
|
311
|
+
if (isComputingRef.current) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const currentValues = rhfForm.getValues();
|
|
316
|
+
|
|
317
|
+
// Call draft API to compute fields on server
|
|
318
|
+
const computeFieldsViaAPI = async () => {
|
|
319
|
+
isComputingRef.current = true;
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
// Use API client draft methods
|
|
323
|
+
const client = api<T>(source);
|
|
324
|
+
|
|
325
|
+
// Build payload with only fields that changed since last sync
|
|
326
|
+
const changedFields: Partial<T> = {};
|
|
327
|
+
|
|
328
|
+
// For update mode, always include _id
|
|
329
|
+
if (operation === "update" && recordId && "_id" in currentValues) {
|
|
330
|
+
(changedFields as any)._id = (currentValues as any)._id;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Use lastSyncedValues if available, otherwise use recordData (for update) or empty object (for create)
|
|
334
|
+
const baseline =
|
|
335
|
+
lastSyncedValuesRef.current ??
|
|
336
|
+
(operation === "update" ? recordData : null) ??
|
|
337
|
+
{};
|
|
338
|
+
|
|
339
|
+
// Get computed field names to exclude from payload
|
|
340
|
+
const computedFieldNames = new Set(
|
|
341
|
+
processedSchema.computedFields || []
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// Find fields that changed from baseline (excluding computed fields)
|
|
345
|
+
Object.keys(currentValues).forEach((key) => {
|
|
346
|
+
// Skip _id and computed fields
|
|
347
|
+
if (key === "_id" || computedFieldNames.has(key)) return;
|
|
348
|
+
|
|
349
|
+
const currentValue = (currentValues as any)[key];
|
|
350
|
+
const baselineValue = (baseline as any)[key];
|
|
351
|
+
|
|
352
|
+
// Include if value has changed (using JSON.stringify for deep comparison)
|
|
353
|
+
// For create mode with no baseline, only include non-empty values
|
|
354
|
+
const hasChanged =
|
|
355
|
+
JSON.stringify(currentValue) !== JSON.stringify(baselineValue);
|
|
356
|
+
const isNonEmpty =
|
|
357
|
+
currentValue !== "" &&
|
|
358
|
+
currentValue !== null &&
|
|
359
|
+
currentValue !== undefined;
|
|
360
|
+
|
|
361
|
+
if (hasChanged && isNonEmpty) {
|
|
362
|
+
(changedFields as any)[key] = currentValue;
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const payload = changedFields;
|
|
367
|
+
|
|
368
|
+
// Update lastSyncedValuesRef BEFORE API call with what we're about to send
|
|
369
|
+
// This ensures that even if the API fails, the next draft only sends NEW changes
|
|
370
|
+
const baselineBeforeApiCall = {
|
|
371
|
+
...lastSyncedValuesRef.current,
|
|
372
|
+
} as Partial<T>;
|
|
373
|
+
|
|
374
|
+
// Update baseline with non-computed fields from current form state
|
|
375
|
+
Object.keys(currentValues).forEach((key) => {
|
|
376
|
+
if (!computedFieldNames.has(key)) {
|
|
377
|
+
(baselineBeforeApiCall as any)[key] = (currentValues as any)[
|
|
378
|
+
key
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
lastSyncedValuesRef.current = baselineBeforeApiCall;
|
|
384
|
+
|
|
385
|
+
const computedFieldsResponse =
|
|
386
|
+
operation === "update" && recordId
|
|
387
|
+
? await client.draftPatch(recordId, payload)
|
|
388
|
+
: await client.draft(payload);
|
|
389
|
+
|
|
390
|
+
// Apply computed fields returned from API
|
|
391
|
+
if (
|
|
392
|
+
computedFieldsResponse &&
|
|
393
|
+
typeof computedFieldsResponse === "object"
|
|
394
|
+
) {
|
|
395
|
+
Object.entries(computedFieldsResponse).forEach(
|
|
396
|
+
([fieldName, value]) => {
|
|
397
|
+
const currentValue = currentValues[fieldName as keyof T];
|
|
398
|
+
if (currentValue !== value) {
|
|
399
|
+
rhfForm.setValue(fieldName as Path<T>, value as any, {
|
|
400
|
+
shouldDirty: false,
|
|
401
|
+
shouldValidate: false,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
// Update baseline with computed fields from successful API response
|
|
408
|
+
Object.entries(computedFieldsResponse).forEach(
|
|
409
|
+
([fieldName, value]) => {
|
|
410
|
+
if (computedFieldNames.has(fieldName)) {
|
|
411
|
+
(lastSyncedValuesRef.current as any)[fieldName] = value;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
} catch (error) {
|
|
417
|
+
console.warn("Failed to compute fields via API:", error);
|
|
418
|
+
// Note: lastSyncedValuesRef was already updated before the API call
|
|
419
|
+
// This is correct - we want to track what we ATTEMPTED to send,
|
|
420
|
+
// so the next draft only includes NEW changes, not failed changes again
|
|
421
|
+
// Client-side formula computation fallback has been removed
|
|
422
|
+
// Formula fields remain as-is when API fails
|
|
423
|
+
} finally {
|
|
424
|
+
isComputingRef.current = false;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Call API for computation (no client-side fallback)
|
|
429
|
+
computeFieldsViaAPI();
|
|
430
|
+
}, 300); // 300ms debounce
|
|
431
|
+
},
|
|
432
|
+
[
|
|
433
|
+
processedSchema,
|
|
434
|
+
operation,
|
|
435
|
+
recordId,
|
|
436
|
+
recordData,
|
|
437
|
+
source,
|
|
438
|
+
rhfForm,
|
|
439
|
+
computedFieldDependencies,
|
|
440
|
+
draftOnEveryChange,
|
|
441
|
+
]
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
// ============================================================
|
|
445
|
+
// VALIDATION
|
|
446
|
+
// ============================================================
|
|
447
|
+
|
|
448
|
+
const validateForm = useCallback(async (): Promise<boolean> => {
|
|
449
|
+
if (!processedSchema) {
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const values = rhfForm.getValues();
|
|
454
|
+
|
|
455
|
+
// Basic form validation
|
|
456
|
+
const isValid = await rhfForm.trigger();
|
|
457
|
+
if (!isValid) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Cross-field validation
|
|
462
|
+
// Transform ValidationRule[] to the format expected by validateCrossField
|
|
463
|
+
const transformedRules = processedSchema.crossFieldValidation.map(
|
|
464
|
+
(rule) => ({
|
|
465
|
+
Id: rule.Id,
|
|
466
|
+
Condition: { ExpressionTree: rule.ExpressionTree },
|
|
467
|
+
Message: rule.Message || `Validation failed for ${rule.Name}`,
|
|
468
|
+
})
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const crossFieldErrors = validateCrossField(
|
|
472
|
+
transformedRules,
|
|
473
|
+
values as any,
|
|
474
|
+
referenceData
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
if (crossFieldErrors.length > 0) {
|
|
478
|
+
// Set cross-field errors
|
|
479
|
+
crossFieldErrors.forEach((error, index) => {
|
|
480
|
+
rhfForm.setError(`root.crossField${index}` as any, {
|
|
481
|
+
type: "validate",
|
|
482
|
+
message: error.message,
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
return true;
|
|
489
|
+
}, [
|
|
490
|
+
processedSchema,
|
|
491
|
+
rhfForm.getValues,
|
|
492
|
+
rhfForm.trigger,
|
|
493
|
+
rhfForm.setError,
|
|
494
|
+
referenceData,
|
|
495
|
+
]);
|
|
496
|
+
|
|
497
|
+
// ============================================================
|
|
498
|
+
// FORM SUBMISSION
|
|
499
|
+
// ============================================================
|
|
500
|
+
|
|
501
|
+
const submit = useCallback(async (): Promise<void> => {
|
|
502
|
+
if (!processedSchema) {
|
|
503
|
+
throw new Error("Schema not loaded");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
setIsSubmitting(true);
|
|
507
|
+
setSubmitError(null);
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
// Validate form including cross-field validation
|
|
511
|
+
const isValid = await validateForm();
|
|
512
|
+
if (!isValid) {
|
|
513
|
+
throw new Error("Form validation failed");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const formValues = rhfForm.getValues();
|
|
517
|
+
|
|
518
|
+
// Clean data for submission
|
|
519
|
+
// - For create: includes all non-computed fields
|
|
520
|
+
// - For update: includes only fields that changed from recordData
|
|
521
|
+
const cleanedData = cleanFormData(
|
|
522
|
+
formValues as any,
|
|
523
|
+
processedSchema.computedFields,
|
|
524
|
+
operation,
|
|
525
|
+
recordData as Partial<T> | undefined
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
// Submit data
|
|
529
|
+
const result = await submitFormData<T>(
|
|
530
|
+
source,
|
|
531
|
+
operation,
|
|
532
|
+
cleanedData,
|
|
533
|
+
recordId
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
if (!result.success) {
|
|
537
|
+
throw result.error || new Error("Submission failed");
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// Success callback
|
|
541
|
+
onSuccessRef.current?.(result.data || formValues);
|
|
542
|
+
|
|
543
|
+
// Reset form for create operations
|
|
544
|
+
if (operation === "create") {
|
|
545
|
+
rhfForm.reset();
|
|
546
|
+
}
|
|
547
|
+
} catch (error) {
|
|
548
|
+
const submitError = error as Error;
|
|
549
|
+
setSubmitError(submitError);
|
|
550
|
+
onSubmitErrorRef.current?.(submitError);
|
|
551
|
+
onErrorRef.current?.(submitError);
|
|
552
|
+
throw error;
|
|
553
|
+
} finally {
|
|
554
|
+
setIsSubmitting(false);
|
|
555
|
+
}
|
|
556
|
+
}, [processedSchema, validateForm, rhfForm, source, operation, recordId, recordData]);
|
|
557
|
+
|
|
558
|
+
// ============================================================
|
|
559
|
+
// HANDLE SUBMIT - Simplified API
|
|
560
|
+
// ============================================================
|
|
561
|
+
|
|
562
|
+
// Simplified handleSubmit that always uses SDK's submit function
|
|
563
|
+
const handleSubmit = useCallback(() => {
|
|
564
|
+
return rhfForm.handleSubmit(async () => {
|
|
565
|
+
await submit();
|
|
566
|
+
});
|
|
567
|
+
}, [rhfForm, submit]);
|
|
568
|
+
|
|
569
|
+
// ============================================================
|
|
570
|
+
// FIELD HELPERS
|
|
571
|
+
// ============================================================
|
|
572
|
+
|
|
573
|
+
const getField = useCallback(
|
|
574
|
+
<K extends keyof T>(fieldName: K): ProcessedField | null => {
|
|
575
|
+
return processedSchema?.fields[fieldName as string] || null;
|
|
576
|
+
},
|
|
577
|
+
[processedSchema]
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
const getFields = useCallback((): Record<keyof T, ProcessedField> => {
|
|
581
|
+
if (!processedSchema) return {} as Record<keyof T, ProcessedField>;
|
|
582
|
+
|
|
583
|
+
const typedFields: Record<keyof T, ProcessedField> = {} as any;
|
|
584
|
+
Object.entries(processedSchema.fields).forEach(([key, field]) => {
|
|
585
|
+
(typedFields as any)[key] = field;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
return typedFields;
|
|
589
|
+
}, [processedSchema]);
|
|
590
|
+
|
|
591
|
+
const hasField = useCallback(
|
|
592
|
+
<K extends keyof T>(fieldName: K): boolean => {
|
|
593
|
+
return !!processedSchema?.fields[fieldName as string];
|
|
594
|
+
},
|
|
595
|
+
[processedSchema]
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const isFieldRequired = useCallback(
|
|
599
|
+
<K extends keyof T>(fieldName: K): boolean => {
|
|
600
|
+
return (
|
|
601
|
+
processedSchema?.requiredFields.includes(fieldName as string) || false
|
|
602
|
+
);
|
|
603
|
+
},
|
|
604
|
+
[processedSchema]
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const isFieldComputed = useCallback(
|
|
608
|
+
<K extends keyof T>(fieldName: K): boolean => {
|
|
609
|
+
return (
|
|
610
|
+
processedSchema?.computedFields.includes(fieldName as string) || false
|
|
611
|
+
);
|
|
612
|
+
},
|
|
613
|
+
[processedSchema]
|
|
614
|
+
);
|
|
615
|
+
|
|
616
|
+
// ============================================================
|
|
617
|
+
// OTHER OPERATIONS
|
|
618
|
+
// ============================================================
|
|
619
|
+
|
|
620
|
+
const refreshSchema = useCallback(async (): Promise<void> => {
|
|
621
|
+
await refetchSchema();
|
|
622
|
+
}, [refetchSchema]);
|
|
623
|
+
|
|
624
|
+
const clearErrors = useCallback((): void => {
|
|
625
|
+
rhfForm.clearErrors();
|
|
626
|
+
setSubmitError(null);
|
|
627
|
+
}, [rhfForm]);
|
|
628
|
+
|
|
629
|
+
// ============================================================
|
|
630
|
+
// COMPUTED PROPERTIES
|
|
631
|
+
// ============================================================
|
|
632
|
+
|
|
633
|
+
const isLoadingInitialData =
|
|
634
|
+
isLoadingSchema || (operation === "update" && isLoadingRecord);
|
|
635
|
+
const isLoading = isLoadingInitialData || isSubmitting;
|
|
636
|
+
const loadError = schemaError || recordError;
|
|
637
|
+
const hasError = !!(loadError || submitError);
|
|
638
|
+
|
|
639
|
+
const computedFields = useMemo<Array<keyof T>>(
|
|
640
|
+
() => (processedSchema?.computedFields as Array<keyof T>) || [],
|
|
641
|
+
[processedSchema]
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const requiredFields = useMemo<Array<keyof T>>(
|
|
645
|
+
() => (processedSchema?.requiredFields as Array<keyof T>) || [],
|
|
646
|
+
[processedSchema]
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
// ============================================================
|
|
650
|
+
// RETURN OBJECT
|
|
651
|
+
// ============================================================
|
|
652
|
+
|
|
653
|
+
// Create validation rules from processed schema (client-side only)
|
|
654
|
+
const validationRules = useMemo(() => {
|
|
655
|
+
if (!processedSchema) return {};
|
|
656
|
+
|
|
657
|
+
const rules: Record<string, any> = {};
|
|
658
|
+
|
|
659
|
+
Object.entries(processedSchema.fields).forEach(([fieldName, field]) => {
|
|
660
|
+
const fieldRules: any = {};
|
|
661
|
+
|
|
662
|
+
// Required validation
|
|
663
|
+
if (field.required) {
|
|
664
|
+
fieldRules.required = `${field.label} is required`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Permission-based validation (read-only fields)
|
|
668
|
+
if (!field.permission.editable) {
|
|
669
|
+
fieldRules.disabled = true;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Type-specific validation
|
|
673
|
+
switch (field.type) {
|
|
674
|
+
case "number":
|
|
675
|
+
fieldRules.valueAsNumber = true;
|
|
676
|
+
break;
|
|
677
|
+
case "date":
|
|
678
|
+
case "datetime-local":
|
|
679
|
+
fieldRules.valueAsDate = true;
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Client-side validation rules only
|
|
684
|
+
const validationRuleIds = field.rules.validation;
|
|
685
|
+
if (validationRuleIds.length > 0) {
|
|
686
|
+
fieldRules.validate = {
|
|
687
|
+
expressionValidation: (value: any) => {
|
|
688
|
+
const currentValues = rhfForm.getValues();
|
|
689
|
+
|
|
690
|
+
// Execute client-side validation rules with optimization
|
|
691
|
+
for (const ruleId of validationRuleIds) {
|
|
692
|
+
const rule = processedSchema.rules.validation[ruleId];
|
|
693
|
+
if (rule) {
|
|
694
|
+
const result = validateFieldOptimized<T>(
|
|
695
|
+
fieldName,
|
|
696
|
+
value,
|
|
697
|
+
[rule],
|
|
698
|
+
currentValues,
|
|
699
|
+
lastFormValues as T | undefined
|
|
700
|
+
);
|
|
701
|
+
if (!result.isValid) {
|
|
702
|
+
return result.message || rule.Message || "Invalid value";
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return true;
|
|
707
|
+
},
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
rules[fieldName] = fieldRules;
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
return rules;
|
|
715
|
+
}, [processedSchema, rhfForm, referenceData]);
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Enhanced register function that wraps react-hook-form's register
|
|
719
|
+
*
|
|
720
|
+
* Custom onBlur behavior:
|
|
721
|
+
* 1. Respects validation mode - only triggers validation on blur when mode allows
|
|
722
|
+
* 2. Always fires computation (draft API calls) on blur for fields affecting computed fields
|
|
723
|
+
* 3. Ensures computation only happens when field is valid
|
|
724
|
+
*
|
|
725
|
+
* Mode-specific behavior:
|
|
726
|
+
* - "onBlur", "onTouched", "all": Validates on blur (shows errors)
|
|
727
|
+
* - "onSubmit": Doesn't validate on blur (checks existing errors only)
|
|
728
|
+
* - "onChange": Doesn't validate on blur (validation already happened on change)
|
|
729
|
+
*/
|
|
730
|
+
const register = useCallback(
|
|
731
|
+
<K extends Path<T>>(name: K, options?: any) => {
|
|
732
|
+
const fieldValidation = validationRules[name as string];
|
|
733
|
+
|
|
734
|
+
// Create custom onBlur handler
|
|
735
|
+
const originalOnBlur = options?.onBlur;
|
|
736
|
+
const enhancedOnBlur = async (e: any) => {
|
|
737
|
+
// 1. Call original onBlur if provided
|
|
738
|
+
if (originalOnBlur) {
|
|
739
|
+
await originalOnBlur(e);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// 2. Mode-aware validation check
|
|
743
|
+
let isValid = true;
|
|
744
|
+
|
|
745
|
+
// Modes that should trigger validation on blur
|
|
746
|
+
const shouldTriggerOnBlur =
|
|
747
|
+
mode === "onBlur" || mode === "onTouched" || mode === "all";
|
|
748
|
+
|
|
749
|
+
if (shouldTriggerOnBlur) {
|
|
750
|
+
// Trigger validation (shows errors in UI)
|
|
751
|
+
isValid = await rhfForm.trigger(name);
|
|
752
|
+
} else {
|
|
753
|
+
// For "onSubmit" and "onChange" modes, check existing errors without triggering new validation
|
|
754
|
+
const fieldState = rhfForm.getFieldState(name, rhfForm.formState);
|
|
755
|
+
isValid = !fieldState.error;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// 3. Always fire computation on blur if valid
|
|
759
|
+
// This ensures computed fields update even in "onSubmit" mode
|
|
760
|
+
if (isValid) {
|
|
761
|
+
await triggerComputationAfterValidation(name as string);
|
|
762
|
+
}
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
return rhfForm.register(name, {
|
|
766
|
+
...fieldValidation,
|
|
767
|
+
...options,
|
|
768
|
+
onBlur: enhancedOnBlur,
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
[rhfForm, validationRules, triggerComputationAfterValidation, mode]
|
|
772
|
+
);
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
// Form methods with strict typing
|
|
776
|
+
register,
|
|
777
|
+
handleSubmit,
|
|
778
|
+
watch: rhfForm.watch as any, // Type assertion for complex generic constraints
|
|
779
|
+
setValue: rhfForm.setValue,
|
|
780
|
+
reset: rhfForm.reset,
|
|
781
|
+
|
|
782
|
+
// Flattened form state (NEW - direct access, no nested formState)
|
|
783
|
+
errors: rhfForm.formState.errors,
|
|
784
|
+
isValid: rhfForm.formState.isValid,
|
|
785
|
+
isDirty: rhfForm.formState.isDirty,
|
|
786
|
+
isSubmitting: rhfForm.formState.isSubmitting || isSubmitting,
|
|
787
|
+
isSubmitSuccessful: rhfForm.formState.isSubmitSuccessful,
|
|
788
|
+
|
|
789
|
+
// BACKWARD COMPATIBILITY - Keep formState for existing components
|
|
790
|
+
formState: rhfForm.formState,
|
|
791
|
+
|
|
792
|
+
// Loading states
|
|
793
|
+
isLoadingInitialData,
|
|
794
|
+
isLoadingRecord,
|
|
795
|
+
isLoading,
|
|
796
|
+
|
|
797
|
+
// Error handling
|
|
798
|
+
loadError: loadError as Error | null,
|
|
799
|
+
submitError,
|
|
800
|
+
hasError,
|
|
801
|
+
|
|
802
|
+
// Schema information
|
|
803
|
+
schema: schema as BackendSchema | null,
|
|
804
|
+
processedSchema,
|
|
805
|
+
computedFields,
|
|
806
|
+
requiredFields,
|
|
807
|
+
|
|
808
|
+
// Field helpers
|
|
809
|
+
getField,
|
|
810
|
+
getFields,
|
|
811
|
+
hasField,
|
|
812
|
+
isFieldRequired,
|
|
813
|
+
isFieldComputed,
|
|
814
|
+
|
|
815
|
+
// Operations
|
|
816
|
+
submit,
|
|
817
|
+
refreshSchema,
|
|
818
|
+
validateForm,
|
|
819
|
+
clearErrors,
|
|
820
|
+
};
|
|
821
|
+
}
|