@xelqun/forms 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +116 -2
- package/package.json +56 -5
- package/resources/css/xelqun.css +162 -0
- package/resources/js/components/XqFieldWrapper.vue +59 -0
- package/resources/js/components/XqForm.vue +128 -0
- package/resources/js/components/XqNode.vue +22 -0
- package/resources/js/components/XqUnknownNode.vue +15 -0
- package/resources/js/components/defaults.ts +44 -0
- package/resources/js/components/fields/XqCheckboxField.vue +48 -0
- package/resources/js/components/fields/XqDatePickerField.vue +96 -0
- package/resources/js/components/fields/XqFileUploadField.vue +247 -0
- package/resources/js/components/fields/XqRadioField.vue +45 -0
- package/resources/js/components/fields/XqRepeaterField.vue +166 -0
- package/resources/js/components/fields/XqSelectField.vue +151 -0
- package/resources/js/components/fields/XqTextInputField.vue +100 -0
- package/resources/js/components/fields/XqTextareaField.vue +28 -0
- package/resources/js/components/fields/XqToggleField.vue +47 -0
- package/resources/js/components/layouts/XqFieldset.vue +16 -0
- package/resources/js/components/layouts/XqGrid.vue +13 -0
- package/resources/js/components/layouts/XqSection.vue +62 -0
- package/resources/js/components/layouts/XqTabsLayout.vue +64 -0
- package/resources/js/composables/context.ts +22 -0
- package/resources/js/composables/layout.ts +55 -0
- package/resources/js/composables/registry.ts +25 -0
- package/resources/js/composables/useField.ts +41 -0
- package/resources/js/composables/useXqForm.ts +16 -0
- package/resources/js/core/conditions.ts +52 -0
- package/resources/js/core/expressions.ts +131 -0
- package/resources/js/core/http.ts +101 -0
- package/resources/js/core/log.ts +93 -0
- package/resources/js/core/patch.ts +65 -0
- package/resources/js/core/paths.ts +122 -0
- package/resources/js/core/queue.ts +190 -0
- package/resources/js/core/rows.ts +26 -0
- package/resources/js/core/store.ts +560 -0
- package/resources/js/core/values.ts +161 -0
- package/resources/js/index.ts +24 -0
- package/resources/js/lib/utils.ts +18 -0
- package/resources/js/plugin.ts +17 -0
- package/resources/js/schema/types.ts +99 -0
- package/resources/js/ui/XqBadge.vue +33 -0
- package/resources/js/ui/XqButton.vue +59 -0
- package/resources/js/ui/XqCalendar.vue +90 -0
- package/resources/js/ui/XqCard.vue +20 -0
- package/resources/js/ui/XqCheckbox.vue +28 -0
- package/resources/js/ui/XqCombobox.vue +184 -0
- package/resources/js/ui/XqInput.vue +16 -0
- package/resources/js/ui/XqLabel.vue +17 -0
- package/resources/js/ui/XqRadioGroup.vue +24 -0
- package/resources/js/ui/XqRadioGroupItem.vue +31 -0
- package/resources/js/ui/XqSelect.vue +79 -0
- package/resources/js/ui/XqSwitch.vue +27 -0
- package/resources/js/ui/XqTabs.vue +19 -0
- package/resources/js/ui/XqTabsContent.vue +17 -0
- package/resources/js/ui/XqTabsList.vue +21 -0
- package/resources/js/ui/XqTabsTrigger.vue +27 -0
- package/resources/js/ui/XqTextarea.vue +16 -0
- package/resources/js/ui/classes.ts +12 -0
- package/resources/js/ui/index.ts +17 -0
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { router } from '@inertiajs/vue3';
|
|
2
|
+
import { effectScope, nextTick, reactive, ref, watch } from 'vue';
|
|
3
|
+
import type { Ref } from 'vue';
|
|
4
|
+
import type {
|
|
5
|
+
ConditionNode,
|
|
6
|
+
FormSchema,
|
|
7
|
+
LiveConfig,
|
|
8
|
+
SchemaNode,
|
|
9
|
+
SelectOption,
|
|
10
|
+
UpdatePatch,
|
|
11
|
+
UpdateRequest,
|
|
12
|
+
UploadResult,
|
|
13
|
+
} from '../schema/types';
|
|
14
|
+
import { SUPPORTED_SCHEMA_VERSIONS } from '../schema/types';
|
|
15
|
+
import { evaluateCondition } from './conditions';
|
|
16
|
+
import { evaluateExpression } from './expressions';
|
|
17
|
+
import { HttpError, postJson, uploadFile } from './http';
|
|
18
|
+
import { eachNode, mergeNode } from './patch';
|
|
19
|
+
import { clone, deletePath, getPath, overlaps, replaceRow, setPath, startsWithPath } from './paths';
|
|
20
|
+
import { RequestLog } from './log';
|
|
21
|
+
import { RequestQueue } from './queue';
|
|
22
|
+
import { newRowKey } from './rows';
|
|
23
|
+
import { isPlainObject } from './values';
|
|
24
|
+
|
|
25
|
+
export type SetSource = 'user' | 'server' | 'computed';
|
|
26
|
+
|
|
27
|
+
export interface SubmitOptions {
|
|
28
|
+
method?: 'post' | 'put' | 'patch';
|
|
29
|
+
data?: Record<string, unknown>;
|
|
30
|
+
preserveState?: boolean | 'errors';
|
|
31
|
+
onSuccess?: (page: unknown) => void;
|
|
32
|
+
onError?: (errors: Record<string, string>) => void;
|
|
33
|
+
onFinish?: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FormStoreOptions {
|
|
37
|
+
send?: (url: string, request: UpdateRequest, signal: AbortSignal) => Promise<UpdatePatch>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TEXT_TYPES = ['text-input', 'textarea'];
|
|
41
|
+
|
|
42
|
+
export function defaultLiveConfig(type: string | undefined): LiveConfig {
|
|
43
|
+
return TEXT_TYPES.includes(type ?? '')
|
|
44
|
+
? { mode: 'debounce', ms: 400 }
|
|
45
|
+
: { mode: 'change', ms: null };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function prefersReducedMotion(): boolean {
|
|
49
|
+
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type FormStore = ReturnType<typeof createFormStore>;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The client-side form engine: reactive state, node index, conditions,
|
|
56
|
+
* computed values, repeater rows, live updates and submit.
|
|
57
|
+
*/
|
|
58
|
+
export function createFormStore(schema: FormSchema, options: FormStoreOptions = {}) {
|
|
59
|
+
const scope = effectScope(true);
|
|
60
|
+
const versionError = SUPPORTED_SCHEMA_VERSIONS.includes(schema.$xq as 1)
|
|
61
|
+
? null
|
|
62
|
+
: `This page uses Xelqun Forms schema v${schema.$xq}, but the installed @xelqun/forms supports v${SUPPORTED_SCHEMA_VERSIONS.join(', v')}. Update the npm package to match the PHP package.`;
|
|
63
|
+
|
|
64
|
+
if (versionError) {
|
|
65
|
+
console.error(`[xelqun] ${versionError}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const components = reactive(clone(schema.components)) as SchemaNode[];
|
|
69
|
+
const state = reactive({
|
|
70
|
+
data: isPlainObject(schema.state?.data) ? clone(schema.state.data) : {},
|
|
71
|
+
});
|
|
72
|
+
const errors = reactive<Record<string, string>>({});
|
|
73
|
+
const nodes = new Map<string, SchemaNode>();
|
|
74
|
+
const effects = new Map<string, () => void>();
|
|
75
|
+
const editVersions = new Map<string, number>();
|
|
76
|
+
const processing = ref(false);
|
|
77
|
+
const fatalError: Ref<string | null> = ref(versionError);
|
|
78
|
+
const liveError: Ref<string | null> = ref(null);
|
|
79
|
+
const focusRequest = ref<{ path: string; at: number } | null>(null);
|
|
80
|
+
const root: Ref<HTMLElement | null> = ref(null);
|
|
81
|
+
const uploads = new Set<Promise<unknown>>();
|
|
82
|
+
const uploading = ref(0);
|
|
83
|
+
|
|
84
|
+
function get(path: string): any {
|
|
85
|
+
return getPath(state, path);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function set(path: string, value: unknown, source: SetSource = 'user'): void {
|
|
89
|
+
if (source !== 'user' && Object.is(get(path), value)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setPath(state, path, value);
|
|
94
|
+
|
|
95
|
+
if (source === 'user') {
|
|
96
|
+
editVersions.set(path, (editVersions.get(path) ?? 0) + 1);
|
|
97
|
+
delete errors[path];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (source !== 'server') {
|
|
101
|
+
notify(path, source);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function liveConfigFor(path: string, source: SetSource): LiveConfig | null {
|
|
106
|
+
if (!schema.token) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const node = nodes.get(path);
|
|
111
|
+
const explicit = node?.live ?? null;
|
|
112
|
+
let isDependency = false;
|
|
113
|
+
|
|
114
|
+
for (const candidate of nodes.values()) {
|
|
115
|
+
if (candidate.deps?.some((dependency) => overlaps(dependency, path))) {
|
|
116
|
+
isDependency = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!explicit && !isDependency) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (source === 'computed') {
|
|
126
|
+
return { mode: 'debounce', ms: 300 };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return explicit ?? defaultLiveConfig(node?.type);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function notify(path: string, source: SetSource): void {
|
|
133
|
+
const live = liveConfigFor(path, source);
|
|
134
|
+
|
|
135
|
+
if (live) {
|
|
136
|
+
queue.notify(path, live);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function blur(path: string): void {
|
|
141
|
+
queue.blur(path);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function condition(node: ConditionNode | null | undefined, fallback: boolean): boolean {
|
|
145
|
+
return node ? evaluateCondition(node, get) : fallback;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isVisible(node: SchemaNode): boolean {
|
|
149
|
+
return !node.hidden && condition(node.rules?.visible, true);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isRequired(node: SchemaNode): boolean {
|
|
153
|
+
return !!node.required || condition(node.rules?.required, false);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isDisabled(node: SchemaNode): boolean {
|
|
157
|
+
return !!node.disabled || condition(node.rules?.disabled, false);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function index(list: SchemaNode[]): void {
|
|
161
|
+
eachNode(list, (node) => {
|
|
162
|
+
nodes.set(node.key, node);
|
|
163
|
+
|
|
164
|
+
if (node.computed && node.path && !effects.has(node.key)) {
|
|
165
|
+
const path = node.path;
|
|
166
|
+
|
|
167
|
+
scope.run(() => {
|
|
168
|
+
effects.set(
|
|
169
|
+
node.key,
|
|
170
|
+
watch(
|
|
171
|
+
() =>
|
|
172
|
+
node.computed
|
|
173
|
+
? evaluateExpression(node.computed.ast, get)
|
|
174
|
+
: get(path),
|
|
175
|
+
(value) => set(path, value, 'computed'),
|
|
176
|
+
{ immediate: true },
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (node.type === 'repeater' && node.path && !effects.has(`${node.key}::rows`)) {
|
|
183
|
+
const path = node.path;
|
|
184
|
+
|
|
185
|
+
scope.run(() => {
|
|
186
|
+
effects.set(
|
|
187
|
+
`${node.key}::rows`,
|
|
188
|
+
watch(
|
|
189
|
+
() => Object.keys(get(path) ?? {}),
|
|
190
|
+
(keys) => syncRows(node, keys),
|
|
191
|
+
{ immediate: true },
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function unindex(list: SchemaNode[]): void {
|
|
200
|
+
eachNode(list, (node) => {
|
|
201
|
+
nodes.delete(node.key);
|
|
202
|
+
|
|
203
|
+
for (const key of [node.key, `${node.key}::rows`]) {
|
|
204
|
+
effects.get(key)?.();
|
|
205
|
+
effects.delete(key);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function rowsOf(node: SchemaNode): Record<string, SchemaNode[]> {
|
|
211
|
+
node.props ??= {};
|
|
212
|
+
|
|
213
|
+
if (!isPlainObject(node.props.rows)) {
|
|
214
|
+
node.props.rows = {};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return node.props.rows;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Make the repeater's row nodes match the row keys in state.
|
|
222
|
+
*/
|
|
223
|
+
function syncRows(node: SchemaNode, keys: string[]): void {
|
|
224
|
+
const rows = rowsOf(node);
|
|
225
|
+
|
|
226
|
+
for (const key of keys) {
|
|
227
|
+
if (!rows[key]) {
|
|
228
|
+
rows[key] = replaceRow(node.props?.template ?? [], key);
|
|
229
|
+
index(rows[key]);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for (const key of Object.keys(rows)) {
|
|
234
|
+
if (!keys.includes(key)) {
|
|
235
|
+
unindex(rows[key]);
|
|
236
|
+
delete rows[key];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function rowKeys(node: SchemaNode): string[] {
|
|
242
|
+
return Object.keys(get(node.path!) ?? {});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function touchRows(node: SchemaNode): void {
|
|
246
|
+
const path = node.path!;
|
|
247
|
+
|
|
248
|
+
syncRows(node, rowKeys(node));
|
|
249
|
+
editVersions.set(path, (editVersions.get(path) ?? 0) + 1);
|
|
250
|
+
delete errors[path];
|
|
251
|
+
notify(path, 'user');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function addRow(node: SchemaNode): string {
|
|
255
|
+
const key = newRowKey();
|
|
256
|
+
|
|
257
|
+
if (!isPlainObject(get(node.path!))) {
|
|
258
|
+
setPath(state, node.path!, {});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
setPath(state, `${node.path}.${key}`, clone(node.props?.newRow ?? {}));
|
|
262
|
+
touchRows(node);
|
|
263
|
+
|
|
264
|
+
return key;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function removeRow(node: SchemaNode, key: string): void {
|
|
268
|
+
const prefix = `${node.path}.${key}`;
|
|
269
|
+
|
|
270
|
+
deletePath(state, prefix);
|
|
271
|
+
Object.keys(errors)
|
|
272
|
+
.filter((path) => startsWithPath(path, prefix))
|
|
273
|
+
.forEach((path) => delete errors[path]);
|
|
274
|
+
touchRows(node);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function moveRow(node: SchemaNode, key: string, offset: number): void {
|
|
278
|
+
const rows = get(node.path!) ?? {};
|
|
279
|
+
const keys = Object.keys(rows);
|
|
280
|
+
const from = keys.indexOf(key);
|
|
281
|
+
const to = from + offset;
|
|
282
|
+
|
|
283
|
+
if (from < 0 || to < 0 || to >= keys.length) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
keys.splice(to, 0, ...keys.splice(from, 1));
|
|
288
|
+
|
|
289
|
+
const reordered: Record<string, unknown> = {};
|
|
290
|
+
keys.forEach((rowKey) => (reordered[rowKey] = rows[rowKey]));
|
|
291
|
+
setPath(state, node.path!, reordered);
|
|
292
|
+
touchRows(node);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function applyPatch(patch: UpdatePatch, versions: Map<string, number>): void {
|
|
296
|
+
for (const [key, partial] of Object.entries(patch.schema ?? {})) {
|
|
297
|
+
const node = nodes.get(key);
|
|
298
|
+
|
|
299
|
+
if (node) {
|
|
300
|
+
mergeNode(node, partial);
|
|
301
|
+
index([node]);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const [path, value] of Object.entries(patch.set ?? {})) {
|
|
306
|
+
if ((editVersions.get(path) ?? 0) !== (versions.get(path) ?? 0)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
set(path, clone(value), 'server');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
for (const [path, messages] of Object.entries(patch.errors ?? {})) {
|
|
314
|
+
errors[path] = Array.isArray(messages) ? messages[0] : messages;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function dependencyMap(): Record<string, string[]> {
|
|
319
|
+
const map: Record<string, string[]> = {};
|
|
320
|
+
|
|
321
|
+
for (const node of nodes.values()) {
|
|
322
|
+
if (node.deps?.length) {
|
|
323
|
+
map[node.key] = [...node.deps];
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return map;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const send =
|
|
331
|
+
options.send ??
|
|
332
|
+
((url: string, request: UpdateRequest, signal: AbortSignal) =>
|
|
333
|
+
postJson<UpdatePatch>(url, request, signal));
|
|
334
|
+
|
|
335
|
+
const requests = new RequestLog();
|
|
336
|
+
const queue = new RequestQueue({
|
|
337
|
+
log: requests,
|
|
338
|
+
buildRequest: (seq, changed) => ({
|
|
339
|
+
token: schema.token ?? '',
|
|
340
|
+
seq,
|
|
341
|
+
state: { data: clone(state.data) },
|
|
342
|
+
changed,
|
|
343
|
+
deps: dependencyMap(),
|
|
344
|
+
}),
|
|
345
|
+
snapshotVersions: () => new Map(editVersions),
|
|
346
|
+
send: (request, signal) => send(schema.endpoints.update, request, signal),
|
|
347
|
+
apply: (patch, versions) => {
|
|
348
|
+
liveError.value = null;
|
|
349
|
+
applyPatch(patch, versions);
|
|
350
|
+
},
|
|
351
|
+
onError: (error) => {
|
|
352
|
+
if (error instanceof HttpError && error.status === 419) {
|
|
353
|
+
fatalError.value = 'This form has expired. Reload the page to continue.';
|
|
354
|
+
} else {
|
|
355
|
+
liveError.value =
|
|
356
|
+
'Some fields could not be updated. Check your connection and try again.';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
console.error('[xelqun] Live update failed.', error);
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* True while a request that can change this node is in flight.
|
|
365
|
+
*/
|
|
366
|
+
function isLoading(node: SchemaNode): boolean {
|
|
367
|
+
const paths = queue.loadingPaths.value;
|
|
368
|
+
|
|
369
|
+
return !!node.deps?.some((dependency) => paths.some((path) => overlaps(dependency, path)));
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function setErrors(incoming: Record<string, string | string[]>): void {
|
|
373
|
+
clearErrors();
|
|
374
|
+
|
|
375
|
+
for (const [path, messages] of Object.entries(incoming ?? {})) {
|
|
376
|
+
errors[path] = Array.isArray(messages) ? messages[0] : messages;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function clearErrors(): void {
|
|
381
|
+
Object.keys(errors).forEach((path) => delete errors[path]);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Ask the UI to reveal and focus the first field with an error, in schema order.
|
|
386
|
+
*/
|
|
387
|
+
function focusFirstError(): void {
|
|
388
|
+
let first: string | null = null;
|
|
389
|
+
|
|
390
|
+
eachNode(components, (node) => {
|
|
391
|
+
if (first === null && node.path && errors[node.path]) {
|
|
392
|
+
first = node.path;
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
if (first === null) {
|
|
397
|
+
first = Object.keys(errors)[0] ?? null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (first !== null) {
|
|
401
|
+
focusRequest.value = { path: first, at: Date.now() };
|
|
402
|
+
void focusPath(first);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function focusPath(path: string): Promise<void> {
|
|
407
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
408
|
+
await nextTick();
|
|
409
|
+
|
|
410
|
+
const element = root.value?.querySelector<HTMLElement>(
|
|
411
|
+
`[data-xq-path="${CSS.escape(path)}"]`,
|
|
412
|
+
);
|
|
413
|
+
const target = element?.querySelector<HTMLElement>(
|
|
414
|
+
'input:not([type=hidden]), textarea, button, [tabindex]:not([tabindex="-1"])',
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
if (target) {
|
|
418
|
+
element!.scrollIntoView?.({
|
|
419
|
+
block: 'center',
|
|
420
|
+
behavior: prefersReducedMotion() ? 'auto' : 'smooth',
|
|
421
|
+
});
|
|
422
|
+
target.focus({ preventScroll: true });
|
|
423
|
+
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function submit(url: string, submitOptions: SubmitOptions = {}): Promise<void> {
|
|
430
|
+
if (processing.value) {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let failedValidation = false;
|
|
435
|
+
|
|
436
|
+
processing.value = true;
|
|
437
|
+
await Promise.allSettled(uploads);
|
|
438
|
+
await queue.idle();
|
|
439
|
+
|
|
440
|
+
router.visit(url, {
|
|
441
|
+
method: submitOptions.method ?? 'post',
|
|
442
|
+
data: { data: clone(state.data), ...submitOptions.data },
|
|
443
|
+
errorBag: schema.id,
|
|
444
|
+
preserveScroll: true,
|
|
445
|
+
preserveState: submitOptions.preserveState ?? 'errors',
|
|
446
|
+
onError: (incoming) => {
|
|
447
|
+
setErrors(incoming as Record<string, string>);
|
|
448
|
+
failedValidation = true;
|
|
449
|
+
submitOptions.onError?.({ ...errors });
|
|
450
|
+
},
|
|
451
|
+
onSuccess: (page) => {
|
|
452
|
+
clearErrors();
|
|
453
|
+
submitOptions.onSuccess?.(page);
|
|
454
|
+
},
|
|
455
|
+
onFinish: () => {
|
|
456
|
+
processing.value = false;
|
|
457
|
+
|
|
458
|
+
// Fields stay disabled while processing, and a disabled field can't take focus.
|
|
459
|
+
if (failedValidation) {
|
|
460
|
+
focusFirstError();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
submitOptions.onFinish?.();
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function search(
|
|
469
|
+
path: string,
|
|
470
|
+
query: string,
|
|
471
|
+
signal?: AbortSignal,
|
|
472
|
+
): Promise<SelectOption[]> {
|
|
473
|
+
const response = await requests.track(
|
|
474
|
+
'search',
|
|
475
|
+
[path],
|
|
476
|
+
query === '' ? '(empty search)' : `“${query}”`,
|
|
477
|
+
postJson<{ options: SelectOption[] }>(
|
|
478
|
+
schema.endpoints.search,
|
|
479
|
+
{ token: schema.token, path, search: query, state: { data: clone(state.data) } },
|
|
480
|
+
signal,
|
|
481
|
+
),
|
|
482
|
+
signal,
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
return response.options;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function upload(
|
|
489
|
+
path: string,
|
|
490
|
+
file: File,
|
|
491
|
+
onProgress: (percent: number) => void,
|
|
492
|
+
signal?: AbortSignal,
|
|
493
|
+
): Promise<UploadResult> {
|
|
494
|
+
const request = requests.track(
|
|
495
|
+
'upload',
|
|
496
|
+
[path],
|
|
497
|
+
file.name,
|
|
498
|
+
uploadFile(
|
|
499
|
+
schema.endpoints.upload,
|
|
500
|
+
{ token: schema.token ?? '', path, file },
|
|
501
|
+
onProgress,
|
|
502
|
+
signal,
|
|
503
|
+
),
|
|
504
|
+
signal,
|
|
505
|
+
);
|
|
506
|
+
const tracked = request.finally(() => {
|
|
507
|
+
uploads.delete(tracked);
|
|
508
|
+
uploading.value = uploads.size;
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
uploads.add(tracked);
|
|
512
|
+
uploading.value = uploads.size;
|
|
513
|
+
|
|
514
|
+
return request;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function dispose(): void {
|
|
518
|
+
scope.stop();
|
|
519
|
+
effects.clear();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
index(components);
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
id: schema.id,
|
|
526
|
+
schema,
|
|
527
|
+
components,
|
|
528
|
+
state,
|
|
529
|
+
errors,
|
|
530
|
+
nodes,
|
|
531
|
+
processing,
|
|
532
|
+
uploading,
|
|
533
|
+
fatalError,
|
|
534
|
+
liveError,
|
|
535
|
+
focusRequest,
|
|
536
|
+
root,
|
|
537
|
+
queue,
|
|
538
|
+
log: requests.entries,
|
|
539
|
+
get,
|
|
540
|
+
set,
|
|
541
|
+
blur,
|
|
542
|
+
isVisible,
|
|
543
|
+
isRequired,
|
|
544
|
+
isDisabled,
|
|
545
|
+
isLoading,
|
|
546
|
+
evaluateCondition: (node: ConditionNode) => evaluateCondition(node, get),
|
|
547
|
+
rowKeys,
|
|
548
|
+
addRow,
|
|
549
|
+
removeRow,
|
|
550
|
+
moveRow,
|
|
551
|
+
applyPatch,
|
|
552
|
+
setErrors,
|
|
553
|
+
clearErrors,
|
|
554
|
+
focusFirstError,
|
|
555
|
+
submit,
|
|
556
|
+
search,
|
|
557
|
+
upload,
|
|
558
|
+
dispose,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Value semantics shared with Xelqun\Forms\Support\Values (PHP).
|
|
3
|
+
* Keep both files in sync; tests/Fixtures/conformance proves they agree.
|
|
4
|
+
*/
|
|
5
|
+
const NUMERIC_PATTERN = /^\s*[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?\s*$/;
|
|
6
|
+
|
|
7
|
+
export function isNumeric(value: unknown): boolean {
|
|
8
|
+
if (typeof value === 'number') {
|
|
9
|
+
return Number.isFinite(value);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return typeof value === 'string' && NUMERIC_PATTERN.test(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toNumber(value: unknown): number {
|
|
16
|
+
if (typeof value === 'boolean') {
|
|
17
|
+
return value ? 1 : 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (typeof value === 'number') {
|
|
21
|
+
return Number.isFinite(value) ? value : 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof value === 'string' && isNumeric(value)) {
|
|
25
|
+
return Number(value.trim());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isFilled(value: unknown): boolean {
|
|
32
|
+
if (value === null || value === undefined) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof value === 'string') {
|
|
37
|
+
return value.trim() !== '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.length > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isPlainObject(value)) {
|
|
45
|
+
return Object.keys(value).length > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function isTruthy(value: unknown): boolean {
|
|
52
|
+
if (value === null || value === undefined || value === false || value === '') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
return value.length > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (isPlainObject(value)) {
|
|
61
|
+
return Object.keys(value).length > 0;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (typeof value === 'number') {
|
|
65
|
+
return value !== 0 && !Number.isNaN(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function toBoolean(value: unknown): boolean {
|
|
72
|
+
if (typeof value === 'boolean') {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (isNumeric(value)) {
|
|
77
|
+
return toNumber(value) !== 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (typeof value === 'string') {
|
|
81
|
+
return ['true', 'on', 'yes'].includes(value.trim().toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return isTruthy(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isBlank(value: unknown): boolean {
|
|
88
|
+
return value === null || value === undefined || value === '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function equals(left: unknown, right: unknown): boolean {
|
|
92
|
+
if (isBlank(left) || isBlank(right)) {
|
|
93
|
+
return isBlank(left) && isBlank(right);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (typeof left === 'boolean' || typeof right === 'boolean') {
|
|
97
|
+
return toBoolean(left) === toBoolean(right);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isNumeric(left) && isNumeric(right)) {
|
|
101
|
+
return toNumber(left) === toNumber(right);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (isCollection(left) || isCollection(right)) {
|
|
105
|
+
return (
|
|
106
|
+
isCollection(left) &&
|
|
107
|
+
isCollection(right) &&
|
|
108
|
+
JSON.stringify(left) === JSON.stringify(right)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return toText(left) === toText(right);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Returns -1, 0 or 1, or null when the values cannot be compared.
|
|
117
|
+
*/
|
|
118
|
+
export function compare(left: unknown, right: unknown): -1 | 0 | 1 | null {
|
|
119
|
+
if (!isFilled(left) || !isFilled(right) || isCollection(left) || isCollection(right)) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const [a, b] =
|
|
124
|
+
isNumeric(left) && isNumeric(right)
|
|
125
|
+
? [toNumber(left), toNumber(right)]
|
|
126
|
+
: [toText(left), toText(right)];
|
|
127
|
+
|
|
128
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function inList(value: unknown, list: unknown[]): boolean {
|
|
132
|
+
if (Array.isArray(value)) {
|
|
133
|
+
return value.some((item) => inList(item, list));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return list.some((candidate) => equals(value, candidate));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function toText(value: unknown): string {
|
|
140
|
+
if (value === null || value === undefined) {
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (typeof value === 'boolean') {
|
|
145
|
+
return value ? 'true' : 'false';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (typeof value === 'number' || typeof value === 'string') {
|
|
149
|
+
return String(value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return JSON.stringify(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isPlainObject(value: unknown): value is Record<string, any> {
|
|
156
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isCollection(value: unknown): boolean {
|
|
160
|
+
return Array.isArray(value) || isPlainObject(value);
|
|
161
|
+
}
|