Skip to content

Commit f83461b

Browse files
authored
[form] Implement FormErrors container (#250)
1 parent 723de54 commit f83461b

File tree

8 files changed

+131
-56
lines changed

8 files changed

+131
-56
lines changed

.changeset/new-deer-enter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@sjsf/form": patch
3+
---
4+
5+
Implement `FormErrors` container to maintain unique error messages

packages/form/src/form/create-form.svelte.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ import {
8282
FORM_FIELDS_STATE_MAP,
8383
FORM_ID_FROM_PATH,
8484
internalRegisterFieldPath,
85-
internalAssignErrors,
8685
FORM_ROOT_PATH,
8786
FORM_ERRORS,
8887
FORM_PATHS_TRIE_REF,
8988
internalHasFieldState,
9089
FORM_ID_PREFIX,
90+
FormErrors,
9191
} from "./internals.js";
9292
import { FIELD_SUBMITTED } from "./field-state.js";
9393

@@ -309,15 +309,16 @@ export function createForm<T>(options: FormOptions<T>): FormState<T> {
309309
const idFromPath = $derived(
310310
weakMemoize(idCache, (path) => idBuilder.fromPath(path) as Id)
311311
);
312-
const errors = $derived(
313-
Array.isArray(options.initialErrors)
314-
? internalAssignErrors(
315-
pathsTrieRef,
316-
new SvelteMap(),
317-
options.initialErrors
318-
)
319-
: new SvelteMap(options.initialErrors)
320-
);
312+
const errors = $derived.by(() => {
313+
const errors = new FormErrors(pathsTrieRef);
314+
const initial = options.initialErrors;
315+
if (initial === undefined) {
316+
return errors;
317+
}
318+
return Array.isArray(initial)
319+
? errors.updateErrors(initial)
320+
: errors.assign(initial);
321+
});
321322
const disabled = $derived(options.disabled ?? false);
322323
const fieldsValidationMode = $derived(options.fieldsValidationMode ?? 0);
323324
const keyedArrays: KeyedArraysMap = $derived(

packages/form/src/form/errors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export class InvalidValidatorError extends Error {}
1313

1414
export type FieldErrors = Readonly<string[]>;
1515

16+
// TODO: Remove in v4
17+
/** @deprecated */
1618
export type FormErrorsMap = SvelteMap<FieldPath, string[]>;
1719

1820
export type FormSubmission<Output> = Task<

packages/form/src/form/internals.ts

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import type { SvelteMap } from "svelte/reactivity";
1+
import { SvelteMap } from "svelte/reactivity";
22

33
import { getNodeByKeys, insertValue } from "@/lib/trie.js";
44
import type { RPath } from "@/core/index.js";
55

6-
import type { FormErrorsMap } from "./errors.js";
76
import type { ValidationError } from "./validator.js";
8-
import type { PathTrieRef } from "./model.js";
7+
import type { PathTrieRef, Update } from "./model.js";
98
import type { FieldPath } from "./id.js";
109
import type { FieldState } from "./field-state.js";
1110

@@ -57,28 +56,103 @@ export function internalRegisterFieldPath(
5756
return p;
5857
}
5958

60-
export function internalAssignErrors(
61-
ref: PathTrieRef<FieldPath>,
62-
map: FormErrorsMap,
63-
errors: ReadonlyArray<ValidationError>
64-
): FormErrorsMap {
65-
map.clear();
66-
for (const { path, message } of errors) {
67-
const p = internalRegisterFieldPath(ref, path);
68-
const arr = map.get(p);
69-
if (arr) {
70-
arr.push(message);
71-
} else {
72-
map.set(p, [message]);
73-
}
74-
}
75-
return map;
76-
}
77-
7859
export function internalHasFieldState(
7960
map: SvelteMap<FieldPath, FieldState>,
8061
path: FieldPath,
8162
state: FieldState
8263
) {
8364
return ((map.get(path) ?? 0) & state) > 0;
8465
}
66+
67+
/**
68+
This class must maintain two invariants:
69+
- Field errors list should contain at leas one error
70+
- Errors in the field errors list must be unique
71+
**/
72+
export class FormErrors {
73+
#map = new SvelteMap<
74+
FieldPath,
75+
{
76+
set: Set<string>;
77+
array: string[];
78+
}
79+
>();
80+
81+
constructor(private readonly ref: PathTrieRef<FieldPath>) {}
82+
83+
assign(entries: Iterable<readonly [FieldPath, string[]]>) {
84+
this.#map.clear();
85+
for (const entry of entries) {
86+
let array = entry[1];
87+
const set = new Set(array);
88+
if (array.length > set.size) {
89+
array = Array.from(set);
90+
}
91+
this.#map.set(entry[0], {
92+
set,
93+
array,
94+
});
95+
}
96+
return this;
97+
}
98+
99+
updateErrors(errors: ReadonlyArray<ValidationError>): this {
100+
this.#map.clear();
101+
for (const { path, message } of errors) {
102+
const p = internalRegisterFieldPath(this.ref, path);
103+
const field = this.#map.get(p);
104+
if (field) {
105+
const l = field.set.size;
106+
field.set.add(message);
107+
if (l < field.set.size) {
108+
field.array.push(message);
109+
}
110+
} else {
111+
const array = [message];
112+
this.#map.set(p, {
113+
set: new Set(array),
114+
array,
115+
});
116+
}
117+
}
118+
return this;
119+
}
120+
121+
getFieldErrors(path: FieldPath): ReadonlyArray<string> | undefined {
122+
return this.#map.get(path)?.array;
123+
}
124+
125+
updateFieldErrors(path: FieldPath, errors: Update<string[]>) {
126+
if (typeof errors === "function") {
127+
const arr = this.#map.get(path)?.array ?? [];
128+
errors = errors(arr);
129+
}
130+
if (errors.length > 0) {
131+
const set = new Set(errors);
132+
this.#map.set(path, {
133+
set,
134+
array: Array.from(set),
135+
});
136+
} else {
137+
this.#map.delete(path);
138+
}
139+
return errors.length === 0;
140+
}
141+
142+
hasErrors() {
143+
return this.#map.size > 0;
144+
}
145+
146+
clear() {
147+
this.#map.clear()
148+
}
149+
150+
*[Symbol.iterator]() {
151+
const casted: [FieldPath, string[]] = [[] as RPath as FieldPath, []];
152+
for (const pair of this.#map) {
153+
casted[0] = pair[0];
154+
casted[1] = pair[1].array;
155+
yield casted;
156+
}
157+
}
158+
}

packages/form/src/form/state/attributes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,8 @@ export function ariaInvalidProp<T extends AriaAttributes, FT>(
176176
config: Config,
177177
ctx: FormState<FT>
178178
): T {
179-
obj["aria-invalid"] = ctx[FORM_ERRORS].has(config.path);
179+
obj["aria-invalid"] =
180+
ctx[FORM_ERRORS].getFieldErrors(config.path) !== undefined;
180181
return obj;
181182
}
182183

packages/form/src/form/state/errors.ts

Lines changed: 10 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import type { FieldPath } from "../id.js";
77
import {
88
FORM_ERRORS,
99
FORM_PATHS_TRIE_REF,
10-
internalAssignErrors,
1110
internalRegisterFieldPath,
1211
} from "../internals.js";
1312
import type { Update } from "../model.js";
@@ -23,7 +22,7 @@ export function getFieldErrors<T>(
2322
ctx: FormState<T>,
2423
path: FieldPath
2524
): FieldErrors {
26-
return ctx[FORM_ERRORS].get(path) ?? NO_ERRORS;
25+
return ctx[FORM_ERRORS].getFieldErrors(path) ?? NO_ERRORS;
2726
}
2827

2928
/**
@@ -48,7 +47,7 @@ export function getFieldsErrors<T>(
4847
): string[] {
4948
const errors: string[] = [];
5049
for (let i = 0; i < paths.length; i++) {
51-
const errs = ctx[FORM_ERRORS].get(paths[i]!);
50+
const errs = ctx[FORM_ERRORS].getFieldErrors(paths[i]!);
5251
if (errs) {
5352
for (let j = 0; j < errs.length; j++) {
5453
errors.push(errs[j]!);
@@ -74,14 +73,15 @@ export function getFieldsErrorsByPath<T>(
7473
/**
7574
* @command
7675
*/
77-
export function updateErrors<T>(ctx: FormState<T>, errors: ReadonlyArray<ValidationError>) {
76+
export function updateErrors<T>(
77+
ctx: FormState<T>,
78+
errors: ReadonlyArray<ValidationError>
79+
) {
7880
untrack(() => {
79-
internalAssignErrors(ctx[FORM_PATHS_TRIE_REF], ctx[FORM_ERRORS], errors);
81+
ctx[FORM_ERRORS].updateErrors(errors);
8082
});
8183
}
8284

83-
// NOTE: The `errors` map must contain non-empty error lists
84-
// for the `errors.size > 0` check to be useful.
8585
/**
8686
* @command
8787
*/
@@ -90,18 +90,7 @@ export function updateFieldErrors<T>(
9090
path: FieldPath,
9191
errors: Update<string[]>
9292
): boolean {
93-
return untrack(() => {
94-
if (typeof errors === "function") {
95-
const arr = ctx[FORM_ERRORS].get(path) ?? [];
96-
errors = errors(arr);
97-
}
98-
if (errors.length > 0) {
99-
ctx[FORM_ERRORS].set(path, errors);
100-
} else {
101-
ctx[FORM_ERRORS].delete(path);
102-
}
103-
return errors.length === 0;
104-
});
93+
return untrack(() => ctx[FORM_ERRORS].updateFieldErrors(path, errors));
10594
}
10695

10796
/**
@@ -125,14 +114,14 @@ export function updateFieldErrorsByPath<T>(
125114
* @query
126115
*/
127116
export function hasErrors<T>(ctx: FormState<T>) {
128-
return ctx[FORM_ERRORS].size > 0;
117+
return ctx[FORM_ERRORS].hasErrors();
129118
}
130119

131120
/**
132121
* @query
133122
*/
134123
export function getErrors<T>(
135124
ctx: FormState<T>
136-
): Iterable<[FieldPath, string[]]> {
125+
): Iterable<[FieldPath, FieldErrors]> {
137126
return ctx[FORM_ERRORS];
138127
}

packages/form/src/form/state/event-handlers.svelte.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ export function hasFieldStateByPath<T>(
8181
);
8282
}
8383

84+
85+
const NO_ERRORS: string[] = [];
86+
8487
export function makeEventHandlers<T>(
8588
ctx: FormState<T>,
8689
config: () => Config,
@@ -97,7 +100,7 @@ export function makeEventHandlers<T>(
97100
const initialPath = path;
98101
return () => {
99102
ctx[FORM_FIELDS_STATE_MAP].delete(initialPath);
100-
ctx[FORM_ERRORS].delete(initialPath);
103+
ctx[FORM_ERRORS].updateFieldErrors(initialPath, NO_ERRORS);
101104
};
102105
});
103106

packages/form/src/form/state/state.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
type UiSchemaRoot,
1313
} from "../ui-schema.js";
1414
import type {
15-
FormErrorsMap,
1615
FormSubmission,
1716
FieldsValidation,
1817
} from "../errors.js";
@@ -47,6 +46,7 @@ import {
4746
FORM_PATHS_TRIE_REF,
4847
FORM_ROOT_PATH,
4948
FORM_ID_PREFIX,
49+
FormErrors,
5050
} from "../internals.js";
5151
import type { FieldPath, Id } from "../id.js";
5252
import type { FieldState } from "../field-state.js";
@@ -68,7 +68,7 @@ export interface FormState<T> {
6868
readonly [FORM_ROOT_PATH]: FieldPath;
6969
readonly [FORM_ID_FROM_PATH]: (path: FieldPath) => Id;
7070
readonly [FORM_PATHS_TRIE_REF]: PathTrieRef<FieldPath>;
71-
readonly [FORM_ERRORS]: FormErrorsMap;
71+
readonly [FORM_ERRORS]: FormErrors;
7272
readonly [FORM_MARK_SCHEMA_CHANGE]: () => void;
7373
readonly [FORM_KEYED_ARRAYS]: KeyedArraysMap;
7474
readonly [FORM_FIELDS_VALIDATION_MODE]: number;

0 commit comments

Comments
 (0)