-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfield-array.ts
More file actions
237 lines (217 loc) · 6.88 KB
/
Copy pathfield-array.ts
File metadata and controls
237 lines (217 loc) · 6.88 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
/**
* Reactive dynamic field arrays for repeating form groups.
*
* @module bquery/forms
*/
import { isPromise } from '../core/utils/type-guards';
import { computed, signal } from '../reactive/index';
import type {
FieldArrayConfig,
FieldArrayKeyFn,
FormField,
FormFieldArray,
ValidationResult,
Validator,
} from './types';
const resolveResult = (result: ValidationResult): string | undefined =>
result === true || result === undefined ? undefined : (result as string);
/**
* Enforce the stable-key contract for a keyed field array. Throws a descriptive
* error naming the offending key on the first violation (missing or duplicate),
* so "stable item ids" failures surface where they happen instead of as silent
* DOM-reuse bugs downstream. No-op when `getKey` is not configured.
*
* @internal
*/
const assertStableKeys = <T>(
items: readonly FormField<T>[],
getKey: FieldArrayKeyFn<T> | undefined
): void => {
if (!getKey) return;
const seen = new Map<string | number, number>();
for (let index = 0; index < items.length; index += 1) {
const key = getKey(items[index].value.peek(), index);
if (
(typeof key !== 'string' && typeof key !== 'number') ||
(typeof key === 'string' && key === '') ||
(typeof key === 'number' && !Number.isFinite(key))
) {
throw new Error(
`bQuery forms: createFieldArray() getKey returned an invalid key (${String(
key
)}) for item at index ${index}. Keys must be a non-empty string or a finite number.`
);
}
const previous = seen.get(key);
if (previous !== undefined) {
throw new Error(
`bQuery forms: createFieldArray() requires stable, unique item keys, but getKey returned "${String(
key
)}" for both index ${previous} and index ${index}.`
);
}
seen.set(key, index);
}
};
const destroyItem = <T>(item: FormField<T>): void => {
const destroyable = item as FormField<T> & {
destroy?: () => void;
dispose?: () => void;
};
if (typeof destroyable.destroy === 'function') {
destroyable.destroy();
return;
}
if (typeof destroyable.dispose === 'function') {
destroyable.dispose();
}
};
/**
* Create a reactive array of fields with mutation helpers.
*
* Useful for "list of items" UIs such as invoice line items or contact lists.
* Each item is wrapped in a {@link FormField} via the supplied `factory`.
*
* @example
* ```ts
* import { createFieldArray, useFormField, required } from '@bquery/bquery/forms';
*
* const tags = createFieldArray<string>({
* initial: ['react', 'forms'],
* factory: (value) => useFormField(value, { validators: [required()] }),
* });
*
* tags.add('reactive');
* tags.remove(0);
* tags.move(0, 1);
* console.log(tags.getValues());
* ```
*/
export const createFieldArray = <T>(config: FieldArrayConfig<T>): FormFieldArray<T> => {
const { getKey } = config;
const initialItems: readonly T[] = config.initial ?? [];
const buildInitial = (): FormField<T>[] => {
const built = initialItems.map((value) => config.factory(value));
assertStableKeys(built, getKey);
return built;
};
const items = signal<readonly FormField<T>[]>(buildInitial());
const length = computed(() => items.value.length);
const error = signal('');
const add = function (value: T): FormField<T> {
if (arguments.length === 0) {
throw new TypeError('createFieldArray.add() requires a value.');
}
const next = config.factory(value as T);
const updated = [...items.peek(), next];
assertStableKeys(updated, getKey);
items.value = updated;
return next;
};
const insert = (index: number, value: T): FormField<T> => {
const current = items.peek();
const clamped = Math.max(0, Math.min(index, current.length));
const next = config.factory(value);
const updated = [...current.slice(0, clamped), next, ...current.slice(clamped)];
assertStableKeys(updated, getKey);
items.value = updated;
return next;
};
const remove = (index: number): boolean => {
const current = items.peek();
if (index < 0 || index >= current.length) return false;
destroyItem(current[index]);
const updated = [...current.slice(0, index), ...current.slice(index + 1)];
items.value = updated;
return true;
};
const move = (from: number, to: number): void => {
const current = items.peek();
if (from < 0 || from >= current.length) return;
if (to < 0 || to >= current.length) return;
if (from === to) return;
const next = current.slice();
const [removed] = next.splice(from, 1);
next.splice(to, 0, removed);
items.value = next;
};
const clear = (): void => {
for (const item of items.peek()) {
destroyItem(item);
}
items.value = [];
};
const getValues = (): T[] => items.value.map((f) => f.value.value);
const reset = (): void => {
for (const item of items.peek()) {
destroyItem(item);
}
items.value = buildInitial();
error.value = '';
};
const keyAt = (index: number): string | number | undefined => {
if (!getKey) return undefined;
const current = items.peek();
if (index < 0 || index >= current.length) return undefined;
return getKey(current[index].value.peek(), index);
};
const keys = (): (string | number)[] => {
if (!getKey) return [];
return items.peek().map((item, index) => getKey(item.value.peek(), index));
};
const validate = async (): Promise<boolean> => {
let ok = true;
// First validate each item's own validators by triggering their fields' setError if a
// public `validate()` is exposed. The default `FormField` from createForm doesn't expose
// it, so item validation is the responsibility of the factory (e.g. useFormField).
for (const item of items.peek()) {
const itemAny = item as FormField<T> & { validate?: () => Promise<boolean> };
if (typeof itemAny.validate === 'function') {
const itemOk = await itemAny.validate();
if (!itemOk) ok = false;
}
}
const validators: Validator<readonly T[]>[] | undefined = config.validators;
if (validators && validators.length > 0) {
const values: readonly T[] = items.peek().map((f) => f.value.peek());
for (const validator of validators) {
const result = validator(values);
const resolved = isPromise(result) ? await result : result;
const msg = resolveResult(resolved);
if (msg) {
error.value = msg;
return false;
}
}
error.value = '';
} else {
error.value = '';
}
return ok;
};
const destroy = (): void => {
for (const item of items.peek()) {
destroyItem(item);
}
items.value = [];
items.dispose();
length.dispose();
error.dispose();
};
return {
items,
length,
error,
add,
insert,
remove,
move,
clear,
validate,
reset,
getValues,
keyAt,
keys,
destroy,
};
};