@77-zhike/app-sdk 0.2.0-d7b1.7 → 0.2.0-d7b1.8
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/package.json +9 -3
- package/src/forms/array-contract.d.ts +32 -0
- package/src/forms/array-contract.js +138 -0
- package/src/forms/factory.js +9 -4
- package/src/forms/field-array.d.ts +16 -0
- package/src/forms/field-array.js +52 -0
- package/src/forms/index.d.ts +5 -1
- package/src/forms/schema.d.ts +30 -11
- package/src/forms/schema.js +29 -6
- package/src/forms/state.d.ts +6 -4
- package/src/forms/state.js +14 -24
- package/src/forms/validation.d.ts +3 -3
- package/src/forms/validation.js +24 -26
- package/src/ui/validation.js +3 -3
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@77-zhike/app-sdk",
|
|
3
|
-
"version": "0.2.0-d7b1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0-d7b1.8",
|
|
4
|
+
"description": "智客 Integration App 一期 SDK 与本地 Toolchain",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/tonyjiang88215/TruthLoop.git",
|
|
8
|
+
"directory": "developer-tools/packages/app-sdk"
|
|
9
|
+
},
|
|
5
10
|
"type": "module",
|
|
6
11
|
"engines": {
|
|
7
12
|
"node": ">=20"
|
|
@@ -53,5 +58,6 @@
|
|
|
53
58
|
"react": {
|
|
54
59
|
"optional": true
|
|
55
60
|
}
|
|
56
|
-
}
|
|
61
|
+
},
|
|
62
|
+
"gitHead": "5ebb33176120cba2e1c70440cc56dc041e1cf924"
|
|
57
63
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const ARRAY_LIMITS: Readonly<{ arrays: number; rowFields: number; rows: number; instances: number; valueBytes: number; messageBytes: number }>;
|
|
2
|
+
export function validFieldKey(key: unknown): boolean;
|
|
3
|
+
export function formBytes(value: unknown): number;
|
|
4
|
+
export function formObject(value: unknown, keys: string[]): void;
|
|
5
|
+
export interface FieldAddress { field: string; array?: string; index?: number }
|
|
6
|
+
export interface ArrayBinding { name: string; rowKey: string; formInstanceId: string; structureRevision: number }
|
|
7
|
+
export type ArrayAction = { kind: 'append'; value: Record<string, unknown> } | { kind: 'insertBefore'; beforeKey: string; value: Record<string, unknown> } | { kind: 'remove'; rowKey: string } | { kind: 'move'; rowKey: string; before: string | null };
|
|
8
|
+
export interface ArrayOperation { formId: string; formInstanceId: string; arrayName: string; baseStructureRevision: number; action: ArrayAction }
|
|
9
|
+
export interface ArraySnapshot {
|
|
10
|
+
formId: string; name: string; formInstanceId: string; revision: number; structureRevision: number;
|
|
11
|
+
rows: { key: string; index: number; value: Record<string, unknown>; errors: Record<string, string>; validatingFields: Record<string, boolean> }[];
|
|
12
|
+
submitting: boolean; canAppend: boolean; error: string | null;
|
|
13
|
+
}
|
|
14
|
+
export type ErrorTree<T = string> = Record<string, T | (Record<string, T> | null)[]>;
|
|
15
|
+
export function parseFieldPath(path: string): FieldAddress;
|
|
16
|
+
export function fieldAtPath<F>(fields: Record<string, F>, path: string): F;
|
|
17
|
+
export function valueAtPath(values: Record<string, unknown>, path: string): unknown;
|
|
18
|
+
export function setPathValue(values: Record<string, unknown>, path: string, value: unknown): void;
|
|
19
|
+
export function rulePath(path: string): string;
|
|
20
|
+
export function leafPaths(fields: Record<string, unknown>, values: Record<string, unknown>): string[];
|
|
21
|
+
export function rhfPath(path: string): string;
|
|
22
|
+
export function publicPath(path: string): string;
|
|
23
|
+
export function assertFieldValue(value: unknown, field: unknown, draft?: boolean): void;
|
|
24
|
+
export function assertArrayCapacity(fields: Record<string, unknown>, values: Record<string, unknown>, draft?: boolean): void;
|
|
25
|
+
export function normalizeFormValues<T extends Record<string, unknown>>(fields: Record<string, unknown>, raw: T): T;
|
|
26
|
+
export function assertErrorTree(tree: unknown, fields: Record<string, unknown>, values?: Record<string, unknown>, boolean?: boolean, maxText?: number): void;
|
|
27
|
+
export function assertArrayErrors(errors: unknown, fields: Record<string, unknown>, maxText?: number): void;
|
|
28
|
+
export function flattenErrors<T = string>(tree?: ErrorTree<T>): Record<string, T>;
|
|
29
|
+
export function nestErrors<T = string>(flat: Record<string, T>): ErrorTree<T>;
|
|
30
|
+
export function assertArrayBinding(binding: unknown): ArrayBinding;
|
|
31
|
+
export function assertArrayOperation(input: unknown): ArrayOperation;
|
|
32
|
+
export function assertArraySnapshot(snapshot: unknown, formId: string, name: string, fields: Record<string, unknown>): ArraySnapshot;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { assertJsonValue } from '../ui/validation.js';
|
|
2
|
+
import { isDateTime, isPlainDate, parseNumberDraft } from './values.js';
|
|
3
|
+
import { assertRecordRef, recordObjectsForField } from './record-options.js';
|
|
4
|
+
|
|
5
|
+
export const ARRAY_LIMITS = Object.freeze({ arrays: 3, rowFields: 12, rows: 50, instances: 300, valueBytes: 65536, messageBytes: 196608 });
|
|
6
|
+
const fail = () => { throw new Error('runtime_form_array_invalid'); };
|
|
7
|
+
export const validFieldKey = key => typeof key === 'string' && /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(key) && !['constructor', 'prototype'].includes(key);
|
|
8
|
+
export const formBytes = value => new TextEncoder().encode(JSON.stringify(value)).length;
|
|
9
|
+
export function formObject(value, keys) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
11
|
+
}
|
|
12
|
+
export function parseFieldPath(path) {
|
|
13
|
+
if (validFieldKey(path)) return { field: path };
|
|
14
|
+
const match = typeof path === 'string' && /^([a-zA-Z][a-zA-Z0-9_]{0,63})\[(0|[1-9][0-9]*)\]\.([a-zA-Z][a-zA-Z0-9_]{0,63})$/.exec(path);
|
|
15
|
+
if (!match || !validFieldKey(match[1]) || !validFieldKey(match[3]) || !Number.isSafeInteger(Number(match[2])) || Number(match[2]) >= ARRAY_LIMITS.rows) fail();
|
|
16
|
+
return { array: match[1], index: Number(match[2]), field: match[3] };
|
|
17
|
+
}
|
|
18
|
+
export function fieldAtPath(fields, path) {
|
|
19
|
+
const address = parseFieldPath(path);
|
|
20
|
+
const field = address.array ? fields[address.array]?.item?.fields[address.field] : fields[address.field];
|
|
21
|
+
if (!field || address.array && fields[address.array].type !== 'array') fail();
|
|
22
|
+
return field;
|
|
23
|
+
}
|
|
24
|
+
export function valueAtPath(values, path) {
|
|
25
|
+
const address = parseFieldPath(path);
|
|
26
|
+
return address.array ? values[address.array]?.[address.index]?.[address.field] : values[address.field];
|
|
27
|
+
}
|
|
28
|
+
export function setPathValue(values, path, value) {
|
|
29
|
+
const address = parseFieldPath(path);
|
|
30
|
+
if (address.array) values[address.array][address.index][address.field] = value;
|
|
31
|
+
else values[address.field] = value;
|
|
32
|
+
}
|
|
33
|
+
export function rulePath(path) {
|
|
34
|
+
const address = parseFieldPath(path);
|
|
35
|
+
return address.array ? `${address.array}[*].${address.field}` : address.field;
|
|
36
|
+
}
|
|
37
|
+
export function leafPaths(fields, values) {
|
|
38
|
+
return Object.entries(fields).flatMap(([name, field]) => field.type === 'array'
|
|
39
|
+
? (values[name] ?? []).flatMap((_row, index) => Object.keys(field.item.fields).map(key => `${name}[${index}].${key}`)) : [name]);
|
|
40
|
+
}
|
|
41
|
+
export const rhfPath = path => path.replace(/\[(\d+)\]/g, '.$1');
|
|
42
|
+
export const publicPath = path => path.replace(/\.(\d+)\./g, '[$1].');
|
|
43
|
+
export function assertFieldValue(value, field, draft = false) {
|
|
44
|
+
if (field.type === 'array') {
|
|
45
|
+
if (!Array.isArray(value) || value.length > field.maxItems || Object.keys(value).length !== value.length) fail();
|
|
46
|
+
for (const row of value) {
|
|
47
|
+
formObject(row, Object.keys(field.item.fields));
|
|
48
|
+
if (Object.keys(row).length !== Object.keys(field.item.fields).length) fail();
|
|
49
|
+
for (const [key, leaf] of Object.entries(field.item.fields)) assertFieldValue(row[key], leaf, draft);
|
|
50
|
+
}
|
|
51
|
+
} else if (field.type === 'boolean') { if (typeof value !== 'boolean') fail(); }
|
|
52
|
+
else if (field.type === 'number') { if (value !== null && !(draft && typeof value === 'string') && (typeof value !== 'number' || !Number.isFinite(value))) fail(); }
|
|
53
|
+
else if (field.type === 'record') { if (value !== null) assertRecordRef(value, recordObjectsForField(field)); }
|
|
54
|
+
else if (field.type === 'plainDate' || field.type === 'dateTime') { if (value !== null && !(draft && typeof value === 'string') && !(field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value))) fail(); }
|
|
55
|
+
else if (typeof value !== 'string' || value.length > 10000) fail();
|
|
56
|
+
}
|
|
57
|
+
export function assertArrayCapacity(fields, values, draft = false) {
|
|
58
|
+
if (!Object.values(fields).some(field => field.type === 'array')) return;
|
|
59
|
+
formObject(values, Object.keys(fields));
|
|
60
|
+
if (Object.keys(fields).length !== Object.keys(values).length) fail();
|
|
61
|
+
for (const [name, field] of Object.entries(fields)) assertFieldValue(values[name], field, draft);
|
|
62
|
+
if (leafPaths(fields, values).length > ARRAY_LIMITS.instances || formBytes(values) > ARRAY_LIMITS.valueBytes) throw new Error('runtime_form_limit_exceeded');
|
|
63
|
+
}
|
|
64
|
+
export function normalizeFormValues(fields, raw) {
|
|
65
|
+
return Object.fromEntries(Object.entries(fields).map(([name, field]) => {
|
|
66
|
+
const value = raw[name];
|
|
67
|
+
return [name, field.type === 'array' ? value.map(row => normalizeFormValues(field.item.fields, row))
|
|
68
|
+
: field.type === 'number' ? parseNumberDraft(value).value
|
|
69
|
+
: field.type === 'dateTime' ? isDateTime(value) ? new Date(value).toISOString() : null
|
|
70
|
+
: field.type === 'plainDate' ? isPlainDate(value) ? value : null : structuredClone(value)];
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
/** errors / validatingFields 的索引只对应这次快照;无错误行必须是显式 null。 */
|
|
74
|
+
export function assertErrorTree(tree, fields, values, boolean = false, maxText = 4096) {
|
|
75
|
+
formObject(tree, Object.keys(fields));
|
|
76
|
+
for (const [name, error] of Object.entries(tree)) {
|
|
77
|
+
const field = fields[name];
|
|
78
|
+
if (field.type === 'array') {
|
|
79
|
+
if (!Array.isArray(error) || error.length > (values?.[name]?.length ?? field.maxItems) || Object.keys(error).length !== error.length) fail();
|
|
80
|
+
for (const row of error) if (row !== null) assertErrorTree(row, field.item.fields, undefined, boolean, maxText);
|
|
81
|
+
} else if (boolean ? typeof error !== 'boolean' : typeof error !== 'string' || !error.trim() || error.length > maxText) fail();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function assertArrayErrors(errors, fields, maxText = 4096) {
|
|
85
|
+
formObject(errors, Object.keys(fields).filter(name => fields[name].type === 'array'));
|
|
86
|
+
if (Object.values(errors).some(value => typeof value !== 'string' || !value.trim() || value.length > maxText)) fail();
|
|
87
|
+
}
|
|
88
|
+
export function flattenErrors(tree = {}) {
|
|
89
|
+
return Object.fromEntries(Object.entries(tree).flatMap(([name, value]) => Array.isArray(value)
|
|
90
|
+
? value.flatMap((row, index) => Object.entries(row ?? {}).map(([key, error]) => [`${name}[${index}].${key}`, error])) : [[name, value]]));
|
|
91
|
+
}
|
|
92
|
+
export function nestErrors(flat) {
|
|
93
|
+
const tree = {};
|
|
94
|
+
for (const [path, error] of Object.entries(flat)) {
|
|
95
|
+
const address = parseFieldPath(path);
|
|
96
|
+
if (!address.array) tree[path] = error;
|
|
97
|
+
else {
|
|
98
|
+
const rows = tree[address.array] ??= [];
|
|
99
|
+
while (rows.length <= address.index) rows.push(null);
|
|
100
|
+
(rows[address.index] ??= {})[address.field] = error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return tree;
|
|
104
|
+
}
|
|
105
|
+
export function assertArrayBinding(binding) {
|
|
106
|
+
formObject(binding, ['name', 'rowKey', 'formInstanceId', 'structureRevision']);
|
|
107
|
+
if (!validFieldKey(binding.name) || !opaque(binding.rowKey) || !opaque(binding.formInstanceId) || !revision(binding.structureRevision)) fail();
|
|
108
|
+
return binding;
|
|
109
|
+
}
|
|
110
|
+
const opaque = value => typeof value === 'string' && value.length > 0 && value.length <= 200;
|
|
111
|
+
const revision = value => Number.isSafeInteger(value) && value >= 0;
|
|
112
|
+
export function assertArrayOperation(input) {
|
|
113
|
+
formObject(input, ['formId', 'formInstanceId', 'arrayName', 'baseStructureRevision', 'action']);
|
|
114
|
+
if (!opaque(input.formId) || !opaque(input.formInstanceId) || !validFieldKey(input.arrayName) || !revision(input.baseStructureRevision)) fail();
|
|
115
|
+
const action = input.action;
|
|
116
|
+
const keys = { append: ['kind', 'value'], insertBefore: ['kind', 'beforeKey', 'value'], remove: ['kind', 'rowKey'], move: ['kind', 'rowKey', 'before'] }[action?.kind];
|
|
117
|
+
if (!keys) fail();
|
|
118
|
+
formObject(action, keys);
|
|
119
|
+
if (Object.keys(action).length !== keys.length || keys.includes('rowKey') && !opaque(action.rowKey) || keys.includes('beforeKey') && !opaque(action.beforeKey) || keys.includes('before') && action.before !== null && !opaque(action.before)) fail();
|
|
120
|
+
return input;
|
|
121
|
+
}
|
|
122
|
+
export function assertArraySnapshot(snapshot, formId, name, fields) {
|
|
123
|
+
assertJsonValue(snapshot, { maxBytes: ARRAY_LIMITS.messageBytes, maxEntries: 4096 });
|
|
124
|
+
formObject(snapshot, ['formId', 'name', 'formInstanceId', 'revision', 'structureRevision', 'rows', 'submitting', 'canAppend', 'error']);
|
|
125
|
+
const field = fields[name];
|
|
126
|
+
if (snapshot.formId !== formId || snapshot.name !== name || !opaque(snapshot.formInstanceId) || !revision(snapshot.revision) || !revision(snapshot.structureRevision) || field?.type !== 'array' || !Array.isArray(snapshot.rows) || snapshot.rows.length > field.maxItems || typeof snapshot.submitting !== 'boolean' || typeof snapshot.canAppend !== 'boolean') fail();
|
|
127
|
+
const keys = new Set();
|
|
128
|
+
for (const [index, row] of snapshot.rows.entries()) {
|
|
129
|
+
formObject(row, ['key', 'index', 'value', 'errors', 'validatingFields']);
|
|
130
|
+
if (!opaque(row.key) || keys.has(row.key) || row.index !== index) fail();
|
|
131
|
+
keys.add(row.key);
|
|
132
|
+
assertFieldValue([row.value], { ...field, maxItems: 1 });
|
|
133
|
+
assertErrorTree(row.errors, field.item.fields);
|
|
134
|
+
assertErrorTree(row.validatingFields, field.item.fields, undefined, true);
|
|
135
|
+
}
|
|
136
|
+
if (snapshot.error !== null && (typeof snapshot.error !== 'string' || !snapshot.error || snapshot.error.length > 4096) || formBytes(snapshot) > ARRAY_LIMITS.messageBytes) fail();
|
|
137
|
+
return snapshot;
|
|
138
|
+
}
|
package/src/forms/factory.js
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
|
+
import { createFieldArrays } from './field-array.js';
|
|
1
2
|
import { compileForm } from './schema.js';
|
|
2
3
|
import { createWithState } from './state.js';
|
|
3
4
|
import { useNormalizedRecordOptions, recordObjectsForField } from './record-options.js';
|
|
4
5
|
import { boundComponents, formComponent } from './components.generated.js';
|
|
5
6
|
import { useNormalizedOptions } from './normalize-options.js';
|
|
6
|
-
export function createFormHook(React, { useBindCallback = () => callback => callback } = {}) {
|
|
7
|
+
export function createFormHook(React, { useBindCallback = () => callback => callback, useInvoke = () => () => Promise.reject(new Error('runtime_host_interaction_unavailable')) } = {}) {
|
|
7
8
|
const { createElement, useId, useMemo, useRef } = React;
|
|
8
9
|
|
|
9
10
|
return function useForm(options) {
|
|
10
11
|
const formId = useId();
|
|
11
12
|
const bindCallback = useBindCallback();
|
|
13
|
+
const invoke = useInvoke();
|
|
12
14
|
const initial = useRef(options);
|
|
13
15
|
return useMemo(() => {
|
|
14
16
|
const { initialValues } = initial.current;
|
|
15
17
|
const { schema, ...validation } = compileForm(initial.current, formId, bindCallback);
|
|
18
|
+
const arrays = createFieldArrays(React, formId, schema, invoke);
|
|
16
19
|
const field = type => function Field({ children, ...props }) {
|
|
20
|
+
const binding = arrays.useBinding(props.name);
|
|
17
21
|
if (type === 'Combobox' || type === 'RecordCombobox') {
|
|
18
22
|
const { options, ...rest } = props;
|
|
19
23
|
const normalized = type === 'RecordCombobox'
|
|
20
|
-
? useNormalizedRecordOptions({ useRef, useMemo }, options, recordObjectsForField(schema.fields[props.name]))
|
|
24
|
+
? useNormalizedRecordOptions({ useRef, useMemo }, options, recordObjectsForField(binding.arrayBinding ? schema.fields[binding.arrayBinding.name].item.fields[binding.name] : schema.fields[props.name]))
|
|
21
25
|
: useNormalizedOptions({ useRef, useMemo }, options);
|
|
22
|
-
return createElement(type, { ...rest, ...normalized, formId });
|
|
26
|
+
return createElement(type, { ...rest, ...normalized, ...binding, formId });
|
|
23
27
|
}
|
|
24
|
-
return createElement(type, { ...props, formId }, children);
|
|
28
|
+
return createElement(type, { ...props, ...binding, formId }, children);
|
|
25
29
|
};
|
|
26
30
|
return Object.freeze({
|
|
31
|
+
FieldArray: arrays.FieldArray,
|
|
27
32
|
WithState: createWithState(React, formId, schema, initialValues),
|
|
28
33
|
Form: function Form({ children, onSubmit }) {
|
|
29
34
|
return createElement(formComponent, { formId, schema, initialValues, onSubmit, ...validation }, children);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import type { ArrayName, FormSchema, FormValues } from './schema.js';
|
|
3
|
+
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
4
|
+
export type ArrayOperationResult = { ok: true; rowKey?: string } | { ok: false; code: string; message: string };
|
|
5
|
+
type Row<S extends FormSchema, N extends ArrayName<S>> = FormValues<S>[N] extends (infer R)[] ? R : never;
|
|
6
|
+
export interface FieldArrayHandle<R> {
|
|
7
|
+
readonly ready: boolean;
|
|
8
|
+
readonly rows: readonly { readonly key: string; readonly index: number; readonly value: ReadonlyValue<R>; readonly errors: Readonly<Partial<Record<keyof R, string>>>; readonly validatingFields: Readonly<Partial<Record<keyof R, boolean>>> }[];
|
|
9
|
+
readonly pending: boolean; readonly submitting: boolean; readonly canAppend: boolean;
|
|
10
|
+
readonly error: string | null; readonly operationError: string | null;
|
|
11
|
+
append(value: R): Promise<ArrayOperationResult>;
|
|
12
|
+
insertBefore(beforeKey: string, value: R): Promise<ArrayOperationResult>;
|
|
13
|
+
remove(rowKey: string): Promise<ArrayOperationResult>;
|
|
14
|
+
move(rowKey: string, position: { before: string | null }): Promise<ArrayOperationResult>;
|
|
15
|
+
}
|
|
16
|
+
export type FieldArrayComponent<S extends FormSchema> = <N extends ArrayName<S>>(props: { name: N; children: (array: FieldArrayHandle<Row<S, N>>) => ReactNode }) => ReactNode;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { assertArraySnapshot, parseFieldPath } from './array-contract.js';
|
|
2
|
+
import { freezeFormState } from './state.js';
|
|
3
|
+
|
|
4
|
+
export function createFieldArrays(React, formId, schema, invoke) {
|
|
5
|
+
const { createContext, createElement, Fragment, useContext, useEffect, useRef, useState, useMemo } = React;
|
|
6
|
+
const Context = createContext(null);
|
|
7
|
+
// 只串行化结构操作;行值仍由 Host 持有,不能乐观修改第二份草稿。
|
|
8
|
+
const gate = { pending: false };
|
|
9
|
+
function Observer({ name, children }) {
|
|
10
|
+
if (schema.fields[name]?.type !== 'array' || typeof children !== 'function') throw new Error('runtime_form_array_invalid');
|
|
11
|
+
const latest = useRef(null), alive = useRef(true);
|
|
12
|
+
const [snapshot, setSnapshot] = useState(null), [pending, setPending] = useState(false), [operationError, setError] = useState(null);
|
|
13
|
+
useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []);
|
|
14
|
+
const onState = useMemo(() => next => {
|
|
15
|
+
assertArraySnapshot(next, formId, name, schema.fields);
|
|
16
|
+
if (!alive.current || latest.current && latest.current.formInstanceId === next.formInstanceId && latest.current.revision >= next.revision) return;
|
|
17
|
+
latest.current = freezeFormState(next); setSnapshot(latest.current);
|
|
18
|
+
}, [name]);
|
|
19
|
+
async function operate(action) {
|
|
20
|
+
const state = latest.current;
|
|
21
|
+
const reject = (code, message) => { if (alive.current) setError(message); return { ok: false, code, message }; };
|
|
22
|
+
if (!alive.current || !state) return reject('owner_closed', '表单尚未就绪');
|
|
23
|
+
if (gate.pending || state.submitting) return reject('busy', '请等待当前操作完成');
|
|
24
|
+
gate.pending = true; setPending(true); setError(null);
|
|
25
|
+
try {
|
|
26
|
+
const result = await invoke('formArray', { formId, formInstanceId: state.formInstanceId, arrayName: name, baseStructureRevision: state.structureRevision, action });
|
|
27
|
+
if (result?.snapshot) onState(result.snapshot);
|
|
28
|
+
if (!result || typeof result.ok !== 'boolean') throw new Error('runtime_form_array_invalid');
|
|
29
|
+
if (!result.ok) return reject(result.code, result.message);
|
|
30
|
+
return { ok: true, ...(result.rowKey ? { rowKey: result.rowKey } : {}) };
|
|
31
|
+
} catch { return reject('owner_closed', '操作未确认,请检查当前明细后再操作'); }
|
|
32
|
+
finally { gate.pending = false; if (alive.current) setPending(false); }
|
|
33
|
+
}
|
|
34
|
+
const handle = Object.freeze({ ready: Boolean(snapshot), rows: snapshot?.rows ?? [], pending, submitting: snapshot?.submitting ?? false,
|
|
35
|
+
canAppend: Boolean(snapshot?.canAppend && !pending), error: snapshot?.error ?? null, operationError,
|
|
36
|
+
append: value => operate({ kind: 'append', value }), insertBefore: (beforeKey, value) => operate({ kind: 'insertBefore', beforeKey, value }),
|
|
37
|
+
remove: rowKey => operate({ kind: 'remove', rowKey }), move: (rowKey, { before }) => operate({ kind: 'move', rowKey, before }),
|
|
38
|
+
});
|
|
39
|
+
return createElement(Fragment, null, createElement('FormArrayObserver', { formId, name, onState }),
|
|
40
|
+
createElement(Context.Provider, { value: snapshot }, children(handle)));
|
|
41
|
+
}
|
|
42
|
+
function useBinding(name) {
|
|
43
|
+
const snapshot = useContext(Context);
|
|
44
|
+
if (name === undefined) return {};
|
|
45
|
+
const path = parseFieldPath(name);
|
|
46
|
+
if (!path.array) return { name };
|
|
47
|
+
if (!snapshot || snapshot.name !== path.array || !snapshot.rows[path.index] || !schema.fields[path.array].item.fields[path.field]) throw new Error('runtime_form_array_binding_missing');
|
|
48
|
+
return { name: path.field, arrayBinding: { name: path.array, rowKey: snapshot.rows[path.index].key, formInstanceId: snapshot.formInstanceId, structureRevision: snapshot.structureRevision } };
|
|
49
|
+
}
|
|
50
|
+
function FieldArray(props) { return createElement(Observer, { ...props, key: props.name }); }
|
|
51
|
+
return { FieldArray, useBinding };
|
|
52
|
+
}
|
package/src/forms/index.d.ts
CHANGED
|
@@ -3,14 +3,17 @@ import type { ComponentType, ReactNode } from "react";
|
|
|
3
3
|
import type { FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions } from "./schema.js";
|
|
4
4
|
import type { Option, OptionsPage, OptionsProvider } from "./options.js";
|
|
5
5
|
import type { RecordOptionsProvider } from "./record-options.js";
|
|
6
|
+
import type { FieldArrayComponent } from "./field-array.js";
|
|
7
|
+
export type { FieldArrayHandle, ArrayOperationResult } from "./field-array.js";
|
|
6
8
|
import type { WithStateComponent } from "./state.js";
|
|
7
9
|
export type { FormStateSelection, SelectedFormState } from "./state.js";
|
|
8
10
|
export type { RecordRef, RecordObjectOption, RecordOption, RecordOptionsPage, RecordOptionsProvider } from "./record-options.js";
|
|
9
11
|
export type { Option, OptionsPage, OptionsProvider } from "./options.js";
|
|
10
12
|
export { Forms } from "./schema.js";
|
|
11
|
-
export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult } from "./schema.js";
|
|
13
|
+
export type { FormField, FormSchema, FormValues, FieldName, FormSubmitResult, FormOptions, FormValidationResult, FormFieldErrors, FormArrayErrors, ArrayName } from "./schema.js";
|
|
12
14
|
export interface FormHandle<S extends FormSchema> {
|
|
13
15
|
WithState: WithStateComponent<S>;
|
|
16
|
+
FieldArray: FieldArrayComponent<S>;
|
|
14
17
|
Form: ComponentType<{
|
|
15
18
|
children?: ReactNode;
|
|
16
19
|
onSubmit: (values: FormValues<S>) => FormSubmitResult<S> | Promise<FormSubmitResult<S>>;
|
|
@@ -69,6 +72,7 @@ export interface FormHandle<S extends FormSchema> {
|
|
|
69
72
|
description?: string;
|
|
70
73
|
placeholder?: string;
|
|
71
74
|
disabled?: boolean;
|
|
75
|
+
step?: number;
|
|
72
76
|
}>;
|
|
73
77
|
PlainDateInput: ComponentType<{
|
|
74
78
|
name: FieldName<S, "plainDate">;
|
package/src/forms/schema.d.ts
CHANGED
|
@@ -7,26 +7,44 @@ export type FormField = (
|
|
|
7
7
|
| { type: "number"; label: string; required?: boolean; min?: number; max?: number }
|
|
8
8
|
| { type: "plainDate" | "dateTime"; label: string; required?: boolean; min?: string; max?: string }
|
|
9
9
|
| ({ type: "record"; label: string; required?: boolean } & ({ objectSlug: string; objects?: never } | { objectSlug?: never; objects: readonly [RecordObjectOption] }))) & { messages?: RuleMessages };
|
|
10
|
-
export
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
export type LeafDefinition = FormField | { type: FormField['type']; label: (text: string) => unknown };
|
|
11
|
+
export interface ArrayField<F extends Record<string, LeafDefinition> = Record<string, LeafDefinition>> {
|
|
12
|
+
type: 'array'; label: string; minItems: number; maxItems: number; item: { type: 'object'; fields: F };
|
|
13
|
+
messages?: { min?: string; max?: string };
|
|
14
|
+
}
|
|
15
|
+
export type ArrayBuilder<F extends Record<string, LeafDefinition>> = Omit<ArrayField<F>, 'label'> & {
|
|
16
|
+
label(text: string): ArrayBuilder<F>;
|
|
17
|
+
min(count: number, options?: MessageOption): ArrayBuilder<F>;
|
|
18
|
+
max(count: number, options?: MessageOption): ArrayBuilder<F>;
|
|
19
|
+
custom(validator: (rows: ReadonlyValue<RowValues<F>[]>) => void | string | Promise<void | string>): ArrayBuilder<F>;
|
|
16
20
|
};
|
|
21
|
+
export interface FormSchema { fields: Readonly<Record<string, LeafDefinition | ArrayField | ArrayBuilder<Record<string, LeafDefinition>>>>; }
|
|
22
|
+
export type ValueOf<F> = F extends { type: 'array'; item: { fields: infer R extends Record<string, LeafDefinition> } } ? RowValues<R>[]
|
|
23
|
+
: F extends { type: 'boolean' } ? boolean : F extends { type: 'record' } ? (F extends { objectSlug: infer O extends string } ? RecordRef & { objectSlug: O } : RecordRef) | null
|
|
24
|
+
: F extends { type: 'number' } ? number | null : F extends { type: 'plainDate' | 'dateTime' } ? string | null : string;
|
|
25
|
+
export type RowValues<F extends Record<string, LeafDefinition>> = { [K in keyof F]: ValueOf<F[K]> };
|
|
26
|
+
export type FormValues<S extends FormSchema> = { [K in keyof S['fields']]: ValueOf<S['fields'][K]> };
|
|
27
|
+
export type ArrayName<S extends FormSchema> = { [K in keyof S['fields']]: S['fields'][K]['type'] extends 'array' ? K : never }[keyof S['fields']] & string;
|
|
17
28
|
export type FieldName<S extends FormSchema, T extends FormField['type']> = {
|
|
18
|
-
[K in keyof S['fields']]: S['fields'][K]
|
|
19
|
-
}[keyof
|
|
29
|
+
[K in keyof S['fields'] & string]: S['fields'][K] extends { type: 'array'; item: { fields: infer F extends Record<string, LeafDefinition> } }
|
|
30
|
+
? `${K}[${number}].${{ [P in keyof F & string]: F[P]['type'] extends T ? P : never }[keyof F & string]}`
|
|
31
|
+
: S['fields'][K]['type'] extends T ? K : never;
|
|
32
|
+
}[keyof S['fields'] & string];
|
|
33
|
+
export type FormFieldErrors<S extends FormSchema, E = string> = { [K in keyof S['fields']]?: S['fields'][K] extends { type: 'array'; item: { fields: infer F } }
|
|
34
|
+
? (Partial<Record<keyof F, E>> | null)[] : E };
|
|
35
|
+
export type FormArrayErrors<S extends FormSchema> = Partial<Record<ArrayName<S>, string>>;
|
|
20
36
|
/** void 兼容既有提交;失败保留输入,字段名必须属于当前表单。 */
|
|
21
37
|
export type FormSubmitResult<S extends FormSchema> = void | { ok: true } | {
|
|
22
38
|
ok: false;
|
|
23
39
|
code: string;
|
|
24
40
|
message: string;
|
|
25
|
-
fieldErrors?:
|
|
41
|
+
fieldErrors?: FormFieldErrors<S>;
|
|
42
|
+
arrayErrors?: FormArrayErrors<S>;
|
|
26
43
|
};
|
|
27
44
|
|
|
28
45
|
export type FormValidationResult<S extends FormSchema> = void | {
|
|
29
|
-
fieldErrors?:
|
|
46
|
+
fieldErrors?: FormFieldErrors<S>;
|
|
47
|
+
arrayErrors?: FormArrayErrors<S>;
|
|
30
48
|
formError?: string;
|
|
31
49
|
};
|
|
32
50
|
type FieldValue<F extends FormField> = FormValues<{ fields: { value: F } }>['value'];
|
|
@@ -52,7 +70,8 @@ export type FieldBuilder<F extends FormField> = Omit<F, 'label'> & {
|
|
|
52
70
|
} : {});
|
|
53
71
|
// builder 的 label 是方法,wire 的 label 是字符串;只有编译后才跨 Worker 边界。
|
|
54
72
|
export const Forms: {
|
|
55
|
-
schema<const F extends Record<string,
|
|
73
|
+
schema<const F extends Record<string, LeafDefinition | ArrayBuilder<Record<string, LeafDefinition>>>>(fields: F): { readonly fields: F };
|
|
74
|
+
array<const F extends Record<string, LeafDefinition>>(fields: F): ArrayBuilder<F>;
|
|
56
75
|
string(): FieldBuilder<Extract<FormField, { type: 'string' }>>;
|
|
57
76
|
number(): FieldBuilder<Extract<FormField, { type: 'number' }>>;
|
|
58
77
|
boolean(): FieldBuilder<Extract<FormField, { type: 'boolean' }>>;
|
package/src/forms/schema.js
CHANGED
|
@@ -3,6 +3,8 @@ import { recordObjectsForField } from './record-options.js';
|
|
|
3
3
|
import { freezeFormState } from './state.js';
|
|
4
4
|
import { createValidationCallback } from './validation.js';
|
|
5
5
|
|
|
6
|
+
import { validFieldKey, assertArrayCapacity } from './array-contract.js';
|
|
7
|
+
|
|
6
8
|
const descriptors = new WeakMap();
|
|
7
9
|
const fail = () => { throw new Error('runtime_form_schema_invalid'); };
|
|
8
10
|
const message = value => {
|
|
@@ -18,7 +20,7 @@ function builder(field, validators = []) {
|
|
|
18
20
|
if (!options || Object.keys(options).some(key => key !== 'message')) fail();
|
|
19
21
|
messages[key] = message(options.message);
|
|
20
22
|
} else delete messages[key];
|
|
21
|
-
return next({ [field.type === 'string' ? `${key}Length` : key]: value, messages });
|
|
23
|
+
return next({ [field.type === 'array' ? `${key}Items` : field.type === 'string' ? `${key}Length` : key]: value, messages });
|
|
22
24
|
};
|
|
23
25
|
const methods = {
|
|
24
26
|
label(value) { return next({ label: value }); },
|
|
@@ -29,10 +31,11 @@ function builder(field, validators = []) {
|
|
|
29
31
|
return next({}, [...validators, validator]);
|
|
30
32
|
},
|
|
31
33
|
};
|
|
32
|
-
if (['string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
|
|
34
|
+
if (['array', 'string', 'number', 'plainDate', 'dateTime'].includes(field.type)) {
|
|
33
35
|
methods.min = (value, options) => rule('min', value, options);
|
|
34
36
|
methods.max = (value, options) => rule('max', value, options);
|
|
35
37
|
}
|
|
38
|
+
if (field.type === 'array') { delete methods.required; delete methods.optional; }
|
|
36
39
|
if (field.type === 'string') for (const format of ['email', 'url']) methods[format] = options => {
|
|
37
40
|
const messages = { ...field.messages };
|
|
38
41
|
if (options === undefined) delete messages.format;
|
|
@@ -51,6 +54,10 @@ export const Forms = Object.freeze({
|
|
|
51
54
|
},
|
|
52
55
|
...Object.fromEntries(['string', 'number', 'boolean', 'plainDate', 'dateTime'].map(type => [type, () => builder({ type })])),
|
|
53
56
|
enum: options => builder({ type: 'enum', options: Array.isArray(options) ? options.map(option => Object.freeze({ ...option })) : options }),
|
|
57
|
+
array(fields) {
|
|
58
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || !Object.keys(fields).length || Object.keys(fields).length > 12 || Object.values(fields).some(field => !descriptors.has(field) || field.type === 'array')) fail();
|
|
59
|
+
return builder({ type: 'array', minItems: 0, maxItems: 50, item: { type: 'object', fields: { ...fields } } });
|
|
60
|
+
},
|
|
54
61
|
record: objectSlug => builder({ type: 'record', objectSlug }),
|
|
55
62
|
});
|
|
56
63
|
|
|
@@ -58,13 +65,26 @@ export const Forms = Object.freeze({
|
|
|
58
65
|
export function compileForm(options, formId, bindCallback = callback => callback) {
|
|
59
66
|
const { schema, validate, validationMode = 'onBlur' } = options;
|
|
60
67
|
if (!schema || !schema.fields || typeof schema.fields !== 'object' || Array.isArray(schema.fields) || Object.keys(schema).some(key => key !== 'fields')
|
|
61
|
-
|| Object.
|
|
68
|
+
|| Object.values(schema.fields).filter(field => field.type !== 'array').length > 30 || Object.values(schema.fields).filter(field => field.type === 'array').length > 3 || !['onBlur', 'onChange', 'onSubmit'].includes(validationMode)
|
|
62
69
|
|| (validate !== undefined && typeof validate !== 'function')) fail();
|
|
63
70
|
const fields = {}, validators = {};
|
|
64
71
|
for (const [name, input] of Object.entries(schema.fields)) {
|
|
65
|
-
if (
|
|
72
|
+
if (!validFieldKey(name)) fail();
|
|
66
73
|
const descriptor = descriptors.get(input);
|
|
67
|
-
const
|
|
74
|
+
const source = descriptor?.field ?? input;
|
|
75
|
+
if (source?.type === 'array') {
|
|
76
|
+
if (Object.keys(source).some(key => !['type', 'label', 'minItems', 'maxItems', 'item', 'messages'].includes(key)) || typeof source.label !== 'string' || !source.label.trim() || source.label.length > 200
|
|
77
|
+
|| !Number.isSafeInteger(source.minItems) || source.minItems < 0 || !Number.isSafeInteger(source.maxItems) || source.maxItems < 1 || source.maxItems > 50 || source.minItems > source.maxItems
|
|
78
|
+
|| source.item?.type !== 'object' || Object.keys(source.item).some(key => !['type', 'fields'].includes(key)) || !source.item.fields || Object.values(source.item.fields).some(field => field.type === 'array')
|
|
79
|
+
|| !Object.keys(source.item.fields).length || Object.keys(source.item.fields).length > 12) fail();
|
|
80
|
+
const child = compileForm({ schema: { fields: source.item.fields } }, formId, bindCallback);
|
|
81
|
+
fields[name] = { ...source, item: { type: 'object', fields: child.schema.fields } };
|
|
82
|
+
if (source.messages) { if (Object.keys(source.messages).some(key => !['min', 'max'].includes(key))) fail(); Object.values(source.messages).forEach(message); }
|
|
83
|
+
for (const [key, checks] of Object.entries(child.validators)) validators[`${name}[*].${key}`] = checks;
|
|
84
|
+
if (descriptor?.validators.length) validators[name] = descriptor.validators.map(bindCallback);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const field = structuredClone(source);
|
|
68
88
|
if (!field || !['string', 'boolean', 'enum', 'number', 'plainDate', 'dateTime', 'record'].includes(field.type)
|
|
69
89
|
|| typeof field.label !== 'string' || descriptor && !field.label.trim() || field.label.length > 200) fail();
|
|
70
90
|
const allowed = ['type', 'label', 'required', 'messages', ...(field.type === 'string' ? ['minLength', 'maxLength', 'format'] : field.type === 'enum' ? ['options'] : field.type === 'record' ? ['objectSlug', 'objects'] : ['number', 'plainDate', 'dateTime'].includes(field.type) ? ['min', 'max'] : [])];
|
|
@@ -99,7 +119,10 @@ export function compileForm(options, formId, bindCallback = callback => callback
|
|
|
99
119
|
const validation = { fields: Object.keys(validators), form: Boolean(validate), mode: validationMode };
|
|
100
120
|
const remote = Boolean(validation.fields.length || validate);
|
|
101
121
|
const configured = remote || options.validationMode !== undefined || Object.values(schema.fields).some(field => descriptors.has(field));
|
|
102
|
-
|
|
122
|
+
if (options.initialValues !== undefined) assertArrayCapacity(fields, options.initialValues);
|
|
123
|
+
const result = { schema: wire, ...(configured ? { validation } : {}), ...(remote ? {
|
|
103
124
|
onValidate: createValidationCallback(formId, fields, validators, validate ? bindCallback(validate) : undefined),
|
|
104
125
|
} : {}) };
|
|
126
|
+
Object.defineProperty(result, 'validators', { value: validators });
|
|
127
|
+
return result;
|
|
105
128
|
}
|
package/src/forms/state.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
-
import type { FormSchema, FormValues } from './schema.js';
|
|
2
|
+
import type { FormSchema, FormValues, FormFieldErrors, FormArrayErrors } from './schema.js';
|
|
3
3
|
export type FormStateSelection<S extends FormSchema> = {
|
|
4
4
|
values?: true | readonly (keyof S['fields'] & string)[];
|
|
5
|
+
arrayErrors?: true | readonly (keyof FormArrayErrors<S> & string)[];
|
|
5
6
|
errors?: true | readonly (keyof S['fields'] & string)[];
|
|
6
7
|
submitting?: boolean;
|
|
7
8
|
validating?: boolean;
|
|
@@ -12,17 +13,18 @@ type Names<S extends FormSchema, T> = T extends true ? keyof FormValues<S> : T e
|
|
|
12
13
|
type ReadonlyValue<T> = T extends object ? { readonly [K in keyof T]: ReadonlyValue<T[K]> } : T;
|
|
13
14
|
export type SelectedFormState<S extends FormSchema, Selection extends FormStateSelection<S>> =
|
|
14
15
|
(Selection extends { values: infer T } ? { readonly values: ReadonlyValue<Pick<FormValues<S>, Names<S, T>>> } : {})
|
|
15
|
-
& (Selection extends { errors: infer T } ? { readonly errors:
|
|
16
|
+
& (Selection extends { errors: infer T } ? { readonly errors: ReadonlyValue<Pick<FormFieldErrors<S>, Names<S, T>>> } : {})
|
|
17
|
+
& (Selection extends { arrayErrors: infer T } ? { readonly arrayErrors: Readonly<Pick<FormArrayErrors<S>, Extract<Names<S, T>, keyof FormArrayErrors<S>>>> } : {})
|
|
16
18
|
& (Selection extends { submitting: true } ? { readonly submitting: boolean } : {})
|
|
17
19
|
& (Selection extends { validating: true } ? { readonly validating: boolean } : {})
|
|
18
20
|
& (Selection extends { formError: true } ? { readonly formError: string | null } : {})
|
|
19
|
-
& (Selection extends { validatingFields: infer T } ? { readonly validatingFields:
|
|
21
|
+
& (Selection extends { validatingFields: infer T } ? { readonly validatingFields: ReadonlyValue<Pick<FormFieldErrors<S, boolean>, Names<S, T>>> } : {});
|
|
20
22
|
export type WithStateComponent<S extends FormSchema> = <const Selection extends FormStateSelection<S>>(props: {
|
|
21
23
|
selection: Selection;
|
|
22
24
|
children: (state: SelectedFormState<S, Selection>) => ReactNode;
|
|
23
25
|
}) => ReactNode;
|
|
24
26
|
|
|
25
|
-
export type StateSelection = { values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
|
|
27
|
+
export type StateSelection = { arrayErrors?: true | readonly string[]; values?: true | readonly string[]; errors?: true | readonly string[]; submitting?: boolean; validating?: boolean; validatingFields?: true | readonly string[]; formError?: boolean };
|
|
26
28
|
export function selectedNames(selection: true | readonly string[] | undefined, fields: Record<string, unknown>): string[];
|
|
27
29
|
export function assertFormStateSelection(selection: unknown, fields: Record<string, unknown>): StateSelection;
|
|
28
30
|
export function assertFormStateSnapshot(snapshot: unknown, formId: string, fields: Record<string, unknown>, selection: StateSelection): unknown;
|
package/src/forms/state.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
+
import { assertFieldValue, assertErrorTree, assertArrayErrors, normalizeFormValues, formBytes, ARRAY_LIMITS } from './array-contract.js';
|
|
1
2
|
import { assertJsonValue } from '../ui/validation.js';
|
|
2
|
-
import { isDateTime, isPlainDate } from './values.js';
|
|
3
|
-
import { assertRecordRef, recordObjectsForField } from './record-options.js';
|
|
4
3
|
const fail = () => { throw new Error('runtime_form_state_invalid'); };
|
|
5
4
|
const only = (value, keys) => {
|
|
6
5
|
if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(key => !keys.includes(key))) fail();
|
|
@@ -9,47 +8,40 @@ export function selectedNames(selection, fields) {
|
|
|
9
8
|
return selection === true ? Object.keys(fields) : Array.isArray(selection) ? selection : [];
|
|
10
9
|
}
|
|
11
10
|
export function assertFormStateSelection(selection, fields) {
|
|
12
|
-
only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'formError']);
|
|
13
|
-
for (const kind of ['values', 'errors', 'validatingFields']) {
|
|
11
|
+
only(selection, ['values', 'errors', 'submitting', 'validating', 'validatingFields', 'arrayErrors', 'formError']);
|
|
12
|
+
for (const kind of ['values', 'errors', 'validatingFields', 'arrayErrors']) {
|
|
14
13
|
const names = selection[kind];
|
|
15
14
|
if (names === undefined || names === true) continue;
|
|
16
|
-
if (!Array.isArray(names) || names.length >
|
|
15
|
+
if (!Array.isArray(names) || names.length > 33 || new Set(names).size !== names.length || names.some(name => typeof name !== 'string' || !Object.hasOwn(fields, name))) fail();
|
|
16
|
+
if (kind === 'arrayErrors' && names.some(name => fields[name].type !== 'array')) fail();
|
|
17
17
|
}
|
|
18
18
|
for (const key of ['submitting', 'validating', 'formError']) if (selection[key] !== undefined && typeof selection[key] !== 'boolean') fail();
|
|
19
19
|
return selection;
|
|
20
20
|
}
|
|
21
|
-
function assertValue(value, field) {
|
|
22
|
-
switch (field.type) {
|
|
23
|
-
case 'boolean': if (typeof value !== 'boolean') fail(); break;
|
|
24
|
-
case 'number': if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) fail(); break;
|
|
25
|
-
case 'plainDate': if (value !== null && !isPlainDate(value)) fail(); break;
|
|
26
|
-
case 'dateTime': if (value !== null && !isDateTime(value)) fail(); break;
|
|
27
|
-
case 'record': if (value !== null) assertRecordRef(value, recordObjectsForField(field)); break;
|
|
28
|
-
default: if (typeof value !== 'string' || value.length > 10000) fail();
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
21
|
export function assertFormStateSnapshot(snapshot, formId, fields, selection) {
|
|
32
|
-
assertJsonValue(snapshot);
|
|
22
|
+
assertJsonValue(snapshot, Object.values(fields).some(field => field.type === 'array') ? { maxBytes: ARRAY_LIMITS.messageBytes, maxEntries: 4096 } : undefined);
|
|
33
23
|
assertFormStateSelection(selection, fields);
|
|
34
|
-
const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
|
|
24
|
+
const keys = ['formId', 'revision', ...(selection.values !== undefined ? ['values'] : []), ...(selection.errors !== undefined ? ['errors'] : []), ...['submitting', 'validating', 'formError'].filter(key => selection[key]), ...(selection.arrayErrors !== undefined ? ['arrayErrors'] : []), ...(selection.validatingFields !== undefined ? ['validatingFields'] : [])];
|
|
35
25
|
only(snapshot, keys);
|
|
36
26
|
if (Object.keys(snapshot).length !== keys.length || snapshot.formId !== formId || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 1) fail();
|
|
37
27
|
if (selection.values !== undefined) {
|
|
38
28
|
const names = selectedNames(selection.values, fields);
|
|
39
29
|
only(snapshot.values, names);
|
|
40
30
|
if (Object.keys(snapshot.values).length !== names.length) fail();
|
|
41
|
-
for (const name of names)
|
|
31
|
+
for (const name of names) assertFieldValue(snapshot.values[name], fields[name]);
|
|
42
32
|
}
|
|
43
33
|
if (selection.errors !== undefined) {
|
|
44
34
|
only(snapshot.errors, selectedNames(selection.errors, fields));
|
|
45
|
-
|
|
35
|
+
assertErrorTree(snapshot.errors, fields, snapshot.values);
|
|
46
36
|
}
|
|
47
37
|
for (const key of ['submitting', 'validating']) if (selection[key] && typeof snapshot[key] !== 'boolean') fail();
|
|
48
38
|
if (selection.formError && snapshot.formError !== null && (typeof snapshot.formError !== 'string' || !snapshot.formError || snapshot.formError.length > 4096)) fail();
|
|
49
39
|
if (selection.validatingFields !== undefined) {
|
|
50
40
|
only(snapshot.validatingFields, selectedNames(selection.validatingFields, fields));
|
|
51
|
-
|
|
41
|
+
assertErrorTree(snapshot.validatingFields, fields, snapshot.values, true);
|
|
52
42
|
}
|
|
43
|
+
if (selection.arrayErrors !== undefined) { only(snapshot.arrayErrors, selectedNames(selection.arrayErrors, fields)); assertArrayErrors(snapshot.arrayErrors, fields); }
|
|
44
|
+
if (Object.values(fields).some(field => field.type === 'array') && formBytes(snapshot) > ARRAY_LIMITS.messageBytes) fail();
|
|
53
45
|
return snapshot;
|
|
54
46
|
}
|
|
55
47
|
export function freezeFormState(value) {
|
|
@@ -58,11 +50,9 @@ export function freezeFormState(value) {
|
|
|
58
50
|
}
|
|
59
51
|
export function initialFormState(formId, fields, initialValues, selection) {
|
|
60
52
|
const snapshot = { formId, revision: 1 };
|
|
61
|
-
if (selection.values !== undefined) snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name =>
|
|
62
|
-
const value = initialValues[name];
|
|
63
|
-
return [name, fields[name].type === 'dateTime' && value ? new Date(value).toISOString() : value && typeof value === 'object' ? { ...value } : value];
|
|
64
|
-
}));
|
|
53
|
+
if (selection.values !== undefined) { const values = normalizeFormValues(fields, initialValues); snapshot.values = Object.fromEntries(selectedNames(selection.values, fields).map(name => [name, values[name]])); }
|
|
65
54
|
if (selection.errors !== undefined) snapshot.errors = {};
|
|
55
|
+
if (selection.arrayErrors !== undefined) snapshot.arrayErrors = {};
|
|
66
56
|
for (const key of ['submitting', 'validating']) if (selection[key]) snapshot[key] = false;
|
|
67
57
|
if (selection.formError) snapshot.formError = null;
|
|
68
58
|
if (selection.validatingFields !== undefined) snapshot.validatingFields = {};
|
|
@@ -2,8 +2,8 @@ export const VALIDATION_TIMEOUT_MS: 10000;
|
|
|
2
2
|
export interface ValidationMetadata { fields: string[]; form: boolean; mode: 'onBlur' | 'onChange' | 'onSubmit'; }
|
|
3
3
|
export interface ValidationRequest {
|
|
4
4
|
formId: string; runId: number; reason: 'blur' | 'change' | 'submit';
|
|
5
|
-
values: Record<string,
|
|
5
|
+
values: Record<string, unknown>; fields: string[]; includeForm: boolean;
|
|
6
6
|
}
|
|
7
|
-
export interface ValidationResponse { formId: string; runId: number; fieldErrors: Record<string, string>; formError?: string; }
|
|
7
|
+
export interface ValidationResponse { formId: string; runId: number; fieldErrors: Record<string, string | (Record<string, string> | null)[]>; arrayErrors?: Record<string, string>; formError?: string; }
|
|
8
8
|
export function assertValidationMetadata(value: unknown, fields: Record<string, unknown>): ValidationMetadata;
|
|
9
|
-
export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'
|
|
9
|
+
export function assertValidationResponse(value: unknown, request: Pick<ValidationRequest, 'formId' | 'runId'> & { values?: Record<string, unknown> }, fields: Record<string, unknown>): ValidationResponse;
|
package/src/forms/validation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertFieldValue, assertArrayCapacity, assertErrorTree, assertArrayErrors, fieldAtPath, valueAtPath, rulePath, flattenErrors, nestErrors, formBytes, ARRAY_LIMITS } from './array-contract.js';
|
|
1
2
|
import { freezeFormState } from './state.js';
|
|
2
3
|
import { isDateTime, isPlainDate } from './values.js';
|
|
3
4
|
import { assertRecordRef, recordObjectsForField } from './record-options.js';
|
|
@@ -10,16 +11,18 @@ const only = (value, keys) => {
|
|
|
10
11
|
const validMessage = value => typeof value === 'string' && value.trim() && value.length <= 300;
|
|
11
12
|
export function assertValidationMetadata(value, fields) {
|
|
12
13
|
only(value, ['fields', 'form', 'mode']);
|
|
13
|
-
if (!Array.isArray(value.fields) || value.fields.length >
|
|
14
|
-
|| value.fields.some(name => typeof name
|
|
14
|
+
if (!Array.isArray(value.fields) || value.fields.length > 69 || new Set(value.fields).size !== value.fields.length
|
|
15
|
+
|| value.fields.some(name => { try { fieldAtPath(fields, typeof name === 'string' ? name.replace('[*]', '[0]') : name); return false; } catch { return true; } })
|
|
15
16
|
|| typeof value.form !== 'boolean' || !['onBlur', 'onChange', 'onSubmit'].includes(value.mode)) fail();
|
|
16
17
|
return value;
|
|
17
18
|
}
|
|
18
19
|
export function assertValidationResponse(value, request, fields) {
|
|
19
|
-
only(value, ['formId', 'runId', 'fieldErrors', 'formError']);
|
|
20
|
+
only(value, ['formId', 'runId', 'fieldErrors', 'arrayErrors', 'formError']);
|
|
20
21
|
if (value.formId !== request.formId || value.runId !== request.runId) fail();
|
|
21
|
-
|
|
22
|
-
if (
|
|
22
|
+
assertErrorTree(value.fieldErrors, fields, request.values, false, 300);
|
|
23
|
+
if (value.arrayErrors !== undefined) assertArrayErrors(value.arrayErrors, fields, 300);
|
|
24
|
+
if (value.formError !== undefined && !validMessage(value.formError)) fail();
|
|
25
|
+
if (Object.values(fields).some(field => field.type === 'array') && formBytes({ ...value, values: request.values }) > ARRAY_LIMITS.messageBytes - 32768) fail();
|
|
23
26
|
return value;
|
|
24
27
|
}
|
|
25
28
|
function assertRequest(request, formId, fields, validators, validate) {
|
|
@@ -27,17 +30,12 @@ function assertRequest(request, formId, fields, validators, validate) {
|
|
|
27
30
|
if (request.formId !== formId || !Number.isSafeInteger(request.runId) || request.runId < 1
|
|
28
31
|
|| !['blur', 'change', 'submit'].includes(request.reason) || typeof request.includeForm !== 'boolean'
|
|
29
32
|
|| request.includeForm && !validate || !Array.isArray(request.fields) || new Set(request.fields).size !== request.fields.length
|
|
30
|
-
|| request.fields.some(name => !Object.hasOwn(validators, name))) fail();
|
|
33
|
+
|| request.fields.length > 303 || request.fields.some(name => !Object.hasOwn(validators, rulePath(name)))) fail();
|
|
31
34
|
only(request.values, Object.keys(fields));
|
|
32
35
|
if (Object.keys(request.values).length !== Object.keys(fields).length) fail();
|
|
33
|
-
for (const [name, field] of Object.entries(fields))
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
else if (field.type === 'record') { if (value !== null) assertRecordRef(value, recordObjectsForField(field)); }
|
|
37
|
-
else if (field.type === 'number') { if (value !== null && (typeof value !== 'number' || !Number.isFinite(value))) fail(); }
|
|
38
|
-
else if (['plainDate', 'dateTime'].includes(field.type)) { if (value !== null && !(field.type === 'plainDate' ? isPlainDate(value) : isDateTime(value))) fail(); }
|
|
39
|
-
else if (typeof value !== 'string' || value.length > 10000) fail();
|
|
40
|
-
}
|
|
36
|
+
for (const [name, field] of Object.entries(fields)) assertFieldValue(request.values[name], field);
|
|
37
|
+
assertArrayCapacity(fields, request.values);
|
|
38
|
+
for (const path of request.fields) if (valueAtPath(request.values, path) === undefined) fail();
|
|
41
39
|
}
|
|
42
40
|
export function createValidationCallback(formId, fields, validators, validate) {
|
|
43
41
|
let busy = false;
|
|
@@ -47,16 +45,16 @@ export function createValidationCallback(formId, fields, validators, validate) {
|
|
|
47
45
|
busy = true;
|
|
48
46
|
const request = freezeFormState(structuredClone(input));
|
|
49
47
|
let active = true, timer, position = 0;
|
|
50
|
-
const fieldErrors = {};
|
|
48
|
+
const fieldErrors = {}, arrayErrors = {};
|
|
51
49
|
const runField = async () => {
|
|
52
50
|
while (active && position < request.fields.length) {
|
|
53
|
-
const name = request.fields[position++], value = request.values
|
|
54
|
-
if (!
|
|
55
|
-
for (const validator of validators[name]) {
|
|
51
|
+
const name = request.fields[position++], value = valueAtPath(request.values, name), field = fieldAtPath(fields, name);
|
|
52
|
+
if (!field.required && (value === '' || value === null)) continue;
|
|
53
|
+
for (const validator of validators[rulePath(name)]) {
|
|
56
54
|
if (!active) return;
|
|
57
55
|
const error = await validator(value);
|
|
58
56
|
if (!active) return;
|
|
59
|
-
if (error !== undefined) { if (!validMessage(error)) fail(); fieldErrors[name] = error; break; }
|
|
57
|
+
if (error !== undefined) { if (!validMessage(error)) fail(); (field.type === 'array' ? arrayErrors : fieldErrors)[name] = error; break; }
|
|
60
58
|
}
|
|
61
59
|
}
|
|
62
60
|
};
|
|
@@ -67,13 +65,13 @@ export function createValidationCallback(formId, fields, validators, validate) {
|
|
|
67
65
|
if (!active) return;
|
|
68
66
|
const result = request.includeForm ? await validate(request.values) : undefined;
|
|
69
67
|
if (!active) return;
|
|
70
|
-
if (result !== undefined) { only(result, ['fieldErrors', 'formError']); }
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
if (result !== undefined) { only(result, ['fieldErrors', 'arrayErrors', 'formError']); }
|
|
69
|
+
if (result?.fieldErrors !== undefined) assertErrorTree(result.fieldErrors, fields, request.values, false, 300);
|
|
70
|
+
if (result?.arrayErrors !== undefined) assertArrayErrors(result.arrayErrors, fields, 300);
|
|
71
|
+
// 每个叶子合并,不能用整行覆盖同一行其他字段的跨字段错误。
|
|
72
|
+
const arrays = { ...result?.arrayErrors, ...arrayErrors };
|
|
73
|
+
const response = { formId, runId: request.runId, fieldErrors: nestErrors({ ...flattenErrors(result?.fieldErrors), ...fieldErrors }),
|
|
74
|
+
...(Object.keys(arrays).length ? { arrayErrors: arrays } : {}), ...(result?.formError === undefined ? {} : { formError: result.formError }) };
|
|
77
75
|
return assertValidationResponse(response, request, fields);
|
|
78
76
|
})(),
|
|
79
77
|
new Promise((_, reject) => { timer = setTimeout(() => { active = false; reject(new Error('runtime_form_validation_timeout')); }, VALIDATION_TIMEOUT_MS); }),
|
package/src/ui/validation.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const fail = () => { throw new Error('runtime_ui_contract_invalid'); };
|
|
2
2
|
|
|
3
3
|
/** 在序列化之前校验,避免 Date、非有限数字或 undefined 被 JSON 静默改写。 */
|
|
4
|
-
export function assertJsonValue(value) {
|
|
4
|
+
export function assertJsonValue(value, { maxBytes = 32 * 1024, maxEntries = 1000 } = {}) {
|
|
5
5
|
const ancestors = new Set();
|
|
6
6
|
let entries = 0;
|
|
7
7
|
function visit(item, depth) {
|
|
@@ -14,7 +14,7 @@ export function assertJsonValue(value) {
|
|
|
14
14
|
const keys = Reflect.ownKeys(item).filter(key => !array || key !== 'length');
|
|
15
15
|
if (keys.some(key => typeof key !== 'string') || (array && keys.length !== item.length)) fail();
|
|
16
16
|
entries += keys.length;
|
|
17
|
-
if (entries >
|
|
17
|
+
if (entries > maxEntries) fail();
|
|
18
18
|
ancestors.add(item);
|
|
19
19
|
for (const key of keys) {
|
|
20
20
|
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
@@ -24,7 +24,7 @@ export function assertJsonValue(value) {
|
|
|
24
24
|
ancestors.delete(item);
|
|
25
25
|
}
|
|
26
26
|
visit(value, 0);
|
|
27
|
-
if (new TextEncoder().encode(JSON.stringify(value)).byteLength >
|
|
27
|
+
if (new TextEncoder().encode(JSON.stringify(value)).byteLength > maxBytes) fail();
|
|
28
28
|
return value;
|
|
29
29
|
}
|
|
30
30
|
|