@voila.dev/effect-form 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/Field.d.ts +30 -0
  4. package/dist/Field.d.ts.map +1 -0
  5. package/dist/Field.js +86 -0
  6. package/dist/Field.js.map +1 -0
  7. package/dist/FieldState.d.ts +21 -0
  8. package/dist/FieldState.d.ts.map +1 -0
  9. package/dist/FieldState.js +2 -0
  10. package/dist/FieldState.js.map +1 -0
  11. package/dist/FormAtoms.d.ts +108 -0
  12. package/dist/FormAtoms.d.ts.map +1 -0
  13. package/dist/FormAtoms.js +620 -0
  14. package/dist/FormAtoms.js.map +1 -0
  15. package/dist/FormBuilder.d.ts +74 -0
  16. package/dist/FormBuilder.d.ts.map +1 -0
  17. package/dist/FormBuilder.js +86 -0
  18. package/dist/FormBuilder.js.map +1 -0
  19. package/dist/Mode.d.ts +34 -0
  20. package/dist/Mode.d.ts.map +1 -0
  21. package/dist/Mode.js +19 -0
  22. package/dist/Mode.js.map +1 -0
  23. package/dist/Path.d.ts +6 -0
  24. package/dist/Path.d.ts.map +1 -0
  25. package/dist/Path.js +69 -0
  26. package/dist/Path.js.map +1 -0
  27. package/dist/Validation.d.ts +11 -0
  28. package/dist/Validation.d.ts.map +1 -0
  29. package/dist/Validation.js +128 -0
  30. package/dist/Validation.js.map +1 -0
  31. package/dist/index.d.ts +8 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +8 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/internal/dirty.d.ts +3 -0
  36. package/dist/internal/dirty.d.ts.map +1 -0
  37. package/dist/internal/dirty.js +89 -0
  38. package/dist/internal/dirty.js.map +1 -0
  39. package/package.json +43 -0
  40. package/src/Field.ts +149 -0
  41. package/src/FieldState.ts +23 -0
  42. package/src/FormAtoms.ts +1202 -0
  43. package/src/FormBuilder.ts +235 -0
  44. package/src/Mode.ts +63 -0
  45. package/src/Path.ts +76 -0
  46. package/src/Validation.ts +167 -0
  47. package/src/index.ts +13 -0
  48. package/src/internal/dirty.ts +113 -0
@@ -0,0 +1,235 @@
1
+ import type * as Effect from "effect/Effect";
2
+ import type * as Option from "effect/Option";
3
+ import * as Predicate from "effect/Predicate";
4
+ import * as Schema from "effect/Schema";
5
+ import * as SchemaGetter from "effect/SchemaGetter";
6
+ import type * as AtomRegistry from "effect/unstable/reactivity/AtomRegistry";
7
+
8
+ import type {
9
+ AnyFieldDef,
10
+ ArrayFieldDef,
11
+ DecodedFromFields,
12
+ EncodedFromFields,
13
+ FieldDef,
14
+ FieldsRecord,
15
+ } from "./Field.ts";
16
+ import { isArrayFieldDef, isFieldDef, makeField } from "./Field.ts";
17
+
18
+ /**
19
+ * Flattens an intersection of field records into a single object type so that
20
+ * hover types and error messages display as `{ email: ...; password: ... }`
21
+ * instead of `{ email: ... } & { password: ... } & ...`.
22
+ *
23
+ * Note: `Types.Simplify` from effect is not used here because its
24
+ * `extends infer B ? B : never` indirection erases the `FieldsRecord`
25
+ * constraint in generic positions.
26
+ */
27
+ type Simplify<T> = { readonly [K in keyof T]: T[K] } & {};
28
+
29
+ export interface SubmittedValues<TFields extends FieldsRecord> {
30
+ readonly encoded: EncodedFromFields<TFields>;
31
+ readonly decoded: DecodedFromFields<TFields>;
32
+ }
33
+
34
+ export const FieldTypeId: unique symbol = Symbol.for(
35
+ "@voila.dev/effect-form/Field",
36
+ );
37
+
38
+ export type FieldTypeId = typeof FieldTypeId;
39
+
40
+ export interface FieldRef<S> {
41
+ readonly [FieldTypeId]: FieldTypeId;
42
+ readonly _S: S;
43
+ readonly key: string;
44
+ }
45
+
46
+ export const makeFieldRef = <S>(key: string): FieldRef<S> => ({
47
+ [FieldTypeId]: FieldTypeId,
48
+ _S: undefined as any,
49
+ key,
50
+ });
51
+
52
+ export const TypeId: unique symbol = Symbol.for("@voila.dev/effect-form/Form");
53
+
54
+ export type TypeId = typeof TypeId;
55
+
56
+ export interface FormState<TFields extends FieldsRecord> {
57
+ readonly values: EncodedFromFields<TFields>;
58
+ readonly initialValues: EncodedFromFields<TFields>;
59
+ readonly lastSubmittedValues: Option.Option<SubmittedValues<TFields>>;
60
+ readonly touched: { readonly [K in keyof TFields]: boolean };
61
+ readonly submitCount: number;
62
+ readonly validationCount: number;
63
+ readonly dirtyFields: ReadonlySet<string>;
64
+ }
65
+
66
+ interface SyncRefinement {
67
+ readonly _tag: "sync";
68
+ readonly fn: (values: unknown) => Schema.FilterOutput;
69
+ }
70
+
71
+ interface AsyncRefinement {
72
+ readonly _tag: "async";
73
+ readonly fn: (
74
+ values: unknown,
75
+ ) => Effect.Effect<undefined | boolean | Schema.FilterIssue, never, unknown>;
76
+ }
77
+
78
+ type Refinement = SyncRefinement | AsyncRefinement;
79
+
80
+ export interface FormBuilder<TFields extends FieldsRecord, R> {
81
+ readonly [TypeId]: TypeId;
82
+ readonly fields: TFields;
83
+ readonly refinements: ReadonlyArray<Refinement>;
84
+ readonly _R?: R;
85
+
86
+ addField<K extends string, S extends Schema.Top>(
87
+ this: FormBuilder<TFields, R>,
88
+ field: FieldDef<K, S>,
89
+ ): FormBuilder<
90
+ Simplify<TFields & { readonly [key in K]: FieldDef<K, S> }>,
91
+ R | Schema.Codec.DecodingServices<S>
92
+ >;
93
+
94
+ addField<K extends string, S extends Schema.Top>(
95
+ this: FormBuilder<TFields, R>,
96
+ field: ArrayFieldDef<K, S>,
97
+ ): FormBuilder<
98
+ Simplify<TFields & { readonly [key in K]: ArrayFieldDef<K, S> }>,
99
+ R | Schema.Codec.DecodingServices<S>
100
+ >;
101
+
102
+ addField<K extends string, S extends Schema.Top>(
103
+ this: FormBuilder<TFields, R>,
104
+ key: K,
105
+ schema: S,
106
+ ): FormBuilder<
107
+ Simplify<TFields & { readonly [key in K]: FieldDef<K, S> }>,
108
+ R | Schema.Codec.DecodingServices<S>
109
+ >;
110
+
111
+ merge<TFields2 extends FieldsRecord, R2>(
112
+ this: FormBuilder<TFields, R>,
113
+ other: FormBuilder<TFields2, R2>,
114
+ ): FormBuilder<Simplify<TFields & TFields2>, R | R2>;
115
+
116
+ refine(
117
+ this: FormBuilder<TFields, R>,
118
+ predicate: (values: DecodedFromFields<TFields>) => Schema.FilterOutput,
119
+ ): FormBuilder<TFields, R>;
120
+
121
+ refineEffect<RD>(
122
+ this: FormBuilder<TFields, R>,
123
+ predicate: (
124
+ values: DecodedFromFields<TFields>,
125
+ ) => Effect.Effect<Schema.FilterOutput, never, RD>,
126
+ ): FormBuilder<TFields, R | Exclude<RD, AtomRegistry.AtomRegistry>>;
127
+ }
128
+
129
+ const FormBuilderProto = {
130
+ [TypeId]: TypeId,
131
+ addField<TFields extends FieldsRecord, R>(
132
+ this: FormBuilder<TFields, R>,
133
+ keyOrField: string | AnyFieldDef,
134
+ schema?: Schema.Top,
135
+ ): FormBuilder<any, any> {
136
+ const field =
137
+ typeof keyOrField === "string"
138
+ ? makeField(keyOrField, schema!)
139
+ : keyOrField;
140
+ const newSelf = Object.create(FormBuilderProto);
141
+ newSelf.fields = { ...this.fields, [field.key]: field };
142
+ newSelf.refinements = this.refinements;
143
+ return newSelf;
144
+ },
145
+ merge<TFields extends FieldsRecord, R, TFields2 extends FieldsRecord, R2>(
146
+ this: FormBuilder<TFields, R>,
147
+ other: FormBuilder<TFields2, R2>,
148
+ ): FormBuilder<TFields & TFields2, R | R2> {
149
+ const newSelf = Object.create(FormBuilderProto);
150
+ newSelf.fields = { ...this.fields, ...other.fields };
151
+ newSelf.refinements = [...this.refinements, ...other.refinements];
152
+ return newSelf;
153
+ },
154
+ refine<TFields extends FieldsRecord, R>(
155
+ this: FormBuilder<TFields, R>,
156
+ predicate: (values: DecodedFromFields<TFields>) => Schema.FilterOutput,
157
+ ): FormBuilder<TFields, R> {
158
+ const newSelf = Object.create(FormBuilderProto);
159
+ newSelf.fields = this.fields;
160
+ newSelf.refinements = [
161
+ ...this.refinements,
162
+ {
163
+ _tag: "sync" as const,
164
+ fn: (values: unknown) =>
165
+ predicate(values as DecodedFromFields<TFields>),
166
+ },
167
+ ];
168
+ return newSelf;
169
+ },
170
+ refineEffect<TFields extends FieldsRecord, R, RD>(
171
+ this: FormBuilder<TFields, R>,
172
+ predicate: (
173
+ values: DecodedFromFields<TFields>,
174
+ ) => Effect.Effect<Schema.FilterOutput, never, RD>,
175
+ ): FormBuilder<TFields, R | Exclude<RD, AtomRegistry.AtomRegistry>> {
176
+ const newSelf = Object.create(FormBuilderProto);
177
+ newSelf.fields = this.fields;
178
+ newSelf.refinements = [
179
+ ...this.refinements,
180
+ {
181
+ _tag: "async" as const,
182
+ fn: (values: unknown) =>
183
+ predicate(values as DecodedFromFields<TFields>),
184
+ },
185
+ ];
186
+ return newSelf;
187
+ },
188
+ };
189
+
190
+ export const isFormBuilder = (u: unknown): u is FormBuilder<any, any> =>
191
+ Predicate.hasProperty(u, TypeId);
192
+
193
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
194
+ export const empty: FormBuilder<{}, never> = (() => {
195
+ const self = Object.create(FormBuilderProto);
196
+ self.fields = {};
197
+ self.refinements = [];
198
+ return self;
199
+ })();
200
+
201
+ export const buildSchema = <TFields extends FieldsRecord, R>(
202
+ self: FormBuilder<TFields, R>,
203
+ ): Schema.Codec<DecodedFromFields<TFields>, EncodedFromFields<TFields>, R> => {
204
+ const schemaFields: Record<string, Schema.Top> = {};
205
+ for (const [key, def] of Object.entries(self.fields)) {
206
+ if (isArrayFieldDef(def)) {
207
+ schemaFields[key] = Schema.Array(def.itemSchema);
208
+ } else if (isFieldDef(def)) {
209
+ schemaFields[key] = def.schema;
210
+ }
211
+ }
212
+
213
+ let schema: Schema.Codec<any, any, any, any> = Schema.Struct(schemaFields);
214
+
215
+ for (const refinement of self.refinements) {
216
+ if (refinement._tag === "sync") {
217
+ schema = schema.pipe(
218
+ Schema.check(Schema.makeFilter((input) => refinement.fn(input))),
219
+ );
220
+ } else {
221
+ schema = schema.pipe(
222
+ Schema.decode({
223
+ decode: SchemaGetter.checkEffect((input) => refinement.fn(input)),
224
+ encode: SchemaGetter.passthrough(),
225
+ }),
226
+ );
227
+ }
228
+ }
229
+
230
+ return schema as Schema.Codec<
231
+ DecodedFromFields<TFields>,
232
+ EncodedFromFields<TFields>,
233
+ R
234
+ >;
235
+ };
package/src/Mode.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type * as Duration from "effect/Duration";
2
+
3
+ export type FormMode =
4
+ | {
5
+ readonly validation?: "onSubmit";
6
+ readonly autoSubmit?: false;
7
+ readonly debounce?: never;
8
+ }
9
+ | {
10
+ readonly validation: "onBlur";
11
+ readonly autoSubmit?: boolean;
12
+ readonly debounce?: never;
13
+ }
14
+ | {
15
+ readonly validation: "onChange";
16
+ readonly debounce?: Duration.Input;
17
+ readonly autoSubmit?: boolean;
18
+ };
19
+
20
+ export type FormModeWithoutAutoSubmit =
21
+ | {
22
+ readonly validation?: "onSubmit";
23
+ readonly autoSubmit?: false;
24
+ readonly debounce?: never;
25
+ }
26
+ | {
27
+ readonly validation: "onBlur";
28
+ readonly autoSubmit?: false;
29
+ readonly debounce?: never;
30
+ }
31
+ | {
32
+ readonly validation: "onChange";
33
+ readonly debounce?: Duration.Input;
34
+ readonly autoSubmit?: false;
35
+ };
36
+
37
+ export interface ParsedMode {
38
+ readonly validation: "onSubmit" | "onBlur" | "onChange";
39
+ readonly debounce: Duration.Input | null;
40
+ readonly autoSubmit: boolean;
41
+ }
42
+
43
+ export const parse = (mode?: FormMode): ParsedMode => {
44
+ const validation = mode?.validation ?? "onSubmit";
45
+
46
+ if (validation === "onBlur") {
47
+ return {
48
+ validation: "onBlur",
49
+ debounce: null,
50
+ autoSubmit: mode?.autoSubmit === true,
51
+ };
52
+ }
53
+
54
+ if (validation === "onChange") {
55
+ return {
56
+ validation: "onChange",
57
+ debounce: mode?.debounce ?? null,
58
+ autoSubmit: mode?.autoSubmit === true,
59
+ };
60
+ }
61
+
62
+ return { validation: "onSubmit", debounce: null, autoSubmit: false };
63
+ };
package/src/Path.ts ADDED
@@ -0,0 +1,76 @@
1
+ const BRACKET_NOTATION_REGEX = /\[(\d+)\]/g;
2
+
3
+ export const schemaPathToFieldPath = (
4
+ path: ReadonlyArray<PropertyKey>,
5
+ ): string => {
6
+ if (path.length === 0) return "";
7
+
8
+ let result = String(path[0]);
9
+ for (let i = 1; i < path.length; i++) {
10
+ const segment = path[i];
11
+ if (typeof segment === "number") {
12
+ result += `[${segment}]`;
13
+ } else {
14
+ result += `.${String(segment)}`;
15
+ }
16
+ }
17
+ return result;
18
+ };
19
+
20
+ export const isPathUnderRoot = (path: string, rootPath: string): boolean =>
21
+ path === rootPath ||
22
+ path.startsWith(rootPath + ".") ||
23
+ path.startsWith(rootPath + "[");
24
+
25
+ export const isPathOrParentDirty = (
26
+ dirtyFields: ReadonlySet<string>,
27
+ path: string,
28
+ ): boolean => {
29
+ if (dirtyFields.has(path)) return true;
30
+
31
+ let parent = path;
32
+ while (true) {
33
+ const lastDot = parent.lastIndexOf(".");
34
+ const lastBracket = parent.lastIndexOf("[");
35
+ const splitIndex = Math.max(lastDot, lastBracket);
36
+
37
+ if (splitIndex === -1) break;
38
+
39
+ parent = parent.substring(0, splitIndex);
40
+ if (dirtyFields.has(parent)) return true;
41
+ }
42
+
43
+ return false;
44
+ };
45
+
46
+ export const getNestedValue = (obj: unknown, path: string): unknown => {
47
+ if (path === "") return obj;
48
+ const parts = path.replace(BRACKET_NOTATION_REGEX, ".$1").split(".");
49
+ let current: unknown = obj;
50
+ for (const part of parts) {
51
+ if (current == null || typeof current !== "object") return undefined;
52
+ current = (current as Record<string, unknown>)[part];
53
+ }
54
+ return current;
55
+ };
56
+
57
+ export const setNestedValue = <T>(obj: T, path: string, value: unknown): T => {
58
+ if (path === "") return value as T;
59
+ const parts = path.replace(BRACKET_NOTATION_REGEX, ".$1").split(".");
60
+ const result = { ...obj } as Record<string, unknown>;
61
+ const lastPart = parts[parts.length - 1];
62
+ if (lastPart === undefined) return result as T;
63
+
64
+ let current = result;
65
+ for (const part of parts.slice(0, -1)) {
66
+ if (Array.isArray(current[part])) {
67
+ current[part] = [...(current[part] as Array<unknown>)];
68
+ } else {
69
+ current[part] = { ...(current[part] as Record<string, unknown>) };
70
+ }
71
+ current = current[part] as Record<string, unknown>;
72
+ }
73
+
74
+ current[lastPart] = value;
75
+ return result as T;
76
+ };
@@ -0,0 +1,167 @@
1
+ import * as Option from "effect/Option";
2
+ import type * as Schema from "effect/Schema";
3
+ import * as SchemaIssue from "effect/SchemaIssue";
4
+ import { schemaPathToFieldPath } from "./Path.ts";
5
+
6
+ export type ErrorSource = "field" | "refinement";
7
+
8
+ export interface ErrorEntry {
9
+ readonly message: string;
10
+ readonly source: ErrorSource;
11
+ }
12
+
13
+ interface IssueSourceEntry {
14
+ readonly path: ReadonlyArray<PropertyKey>;
15
+ readonly source: ErrorSource;
16
+ readonly issue: SchemaIssue.Issue;
17
+ }
18
+
19
+ const standardFormatter = SchemaIssue.makeFormatterStandardSchemaV1();
20
+
21
+ const collectIssueSources = (
22
+ error: Schema.SchemaError,
23
+ ): ReadonlyArray<IssueSourceEntry> => {
24
+ const entries: Array<IssueSourceEntry> = [];
25
+
26
+ const walk = (
27
+ issue: SchemaIssue.Issue,
28
+ path: ReadonlyArray<PropertyKey>,
29
+ source: ErrorSource,
30
+ ): void => {
31
+ switch (issue._tag) {
32
+ case "Filter":
33
+ if (path.length === 0) {
34
+ walk(issue.issue, path, "refinement");
35
+ } else {
36
+ walk(issue.issue, path, source);
37
+ }
38
+ break;
39
+ case "Encoding":
40
+ if (path.length === 0) {
41
+ walk(issue.issue, path, "refinement");
42
+ } else {
43
+ walk(issue.issue, path, source);
44
+ }
45
+ break;
46
+ case "Pointer":
47
+ walk(issue.issue, [...path, ...issue.path], source);
48
+ break;
49
+ case "Composite":
50
+ for (const sub of issue.issues) {
51
+ walk(sub, path, source);
52
+ }
53
+ break;
54
+ case "AnyOf":
55
+ for (const sub of issue.issues) {
56
+ walk(sub, path, source);
57
+ }
58
+ break;
59
+ case "InvalidType":
60
+ case "InvalidValue":
61
+ case "MissingKey":
62
+ case "UnexpectedKey":
63
+ case "Forbidden":
64
+ case "OneOf":
65
+ entries.push({ path, source, issue });
66
+ break;
67
+ }
68
+ };
69
+
70
+ walk(error.issue, [], "field");
71
+ return entries;
72
+ };
73
+
74
+ const getIssueMessage = (issue: SchemaIssue.Issue): string | undefined => {
75
+ const formatted = standardFormatter(issue);
76
+ return formatted.issues[0]?.message;
77
+ };
78
+
79
+ export const extractFirstError = (
80
+ error: Schema.SchemaError,
81
+ ): Option.Option<string> => {
82
+ const formatted = standardFormatter(error.issue);
83
+ if (formatted.issues.length === 0) {
84
+ return Option.none();
85
+ }
86
+ const issue = formatted.issues[0];
87
+ return issue === undefined ? Option.none() : Option.some(issue.message);
88
+ };
89
+
90
+ const normalizePath = (
91
+ path: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }> | undefined,
92
+ ): ReadonlyArray<PropertyKey> => {
93
+ if (!path) return [];
94
+ return path.map((segment) =>
95
+ typeof segment === "object" && segment !== null && "key" in segment
96
+ ? segment.key
97
+ : (segment as PropertyKey),
98
+ );
99
+ };
100
+
101
+ export const routeErrors = (error: Schema.SchemaError): Map<string, string> => {
102
+ const result = new Map<string, string>();
103
+ const formatted = standardFormatter(error.issue);
104
+
105
+ for (const issue of formatted.issues) {
106
+ const fieldPath = schemaPathToFieldPath(normalizePath(issue.path));
107
+ if (fieldPath && !result.has(fieldPath)) {
108
+ result.set(fieldPath, issue.message);
109
+ }
110
+ }
111
+
112
+ return result;
113
+ };
114
+
115
+ export const routeErrorsWithSource = (
116
+ error: Schema.SchemaError,
117
+ ): Map<string, ErrorEntry> => {
118
+ const result = new Map<string, ErrorEntry>();
119
+ const formattedIssues = standardFormatter(error.issue).issues;
120
+ const issueSources = collectIssueSources(error);
121
+ const messageSources = new Map<string, ErrorSource>();
122
+ const refinementPaths = new Set<string>();
123
+
124
+ for (const entry of issueSources) {
125
+ const fieldPath = schemaPathToFieldPath(entry.path) ?? "";
126
+ const message = getIssueMessage(entry.issue);
127
+ if (message !== undefined) {
128
+ const messageKey = `${fieldPath}::${message}`;
129
+ const existing = messageSources.get(messageKey);
130
+ if (
131
+ !existing ||
132
+ (existing === "field" && entry.source === "refinement")
133
+ ) {
134
+ messageSources.set(messageKey, entry.source);
135
+ }
136
+ }
137
+ if (entry.source === "refinement") {
138
+ refinementPaths.add(fieldPath);
139
+ }
140
+ }
141
+
142
+ for (const issue of formattedIssues) {
143
+ const fieldPath = schemaPathToFieldPath(normalizePath(issue.path)) ?? "";
144
+ if (result.has(fieldPath)) continue;
145
+ const preferredSource: ErrorSource = refinementPaths.has(fieldPath)
146
+ ? "refinement"
147
+ : "field";
148
+ const messageKey = `${fieldPath}::${issue.message}`;
149
+ const issueSource = messageSources.get(messageKey) ?? "field";
150
+ if (preferredSource === "refinement" && issueSource !== "refinement") {
151
+ continue;
152
+ }
153
+ result.set(fieldPath, { message: issue.message, source: issueSource });
154
+ }
155
+
156
+ if (result.size < formattedIssues.length) {
157
+ for (const issue of formattedIssues) {
158
+ const fieldPath = schemaPathToFieldPath(normalizePath(issue.path)) ?? "";
159
+ if (result.has(fieldPath)) continue;
160
+ const messageKey = `${fieldPath}::${issue.message}`;
161
+ const issueSource = messageSources.get(messageKey) ?? "field";
162
+ result.set(fieldPath, { message: issue.message, source: issueSource });
163
+ }
164
+ }
165
+
166
+ return result;
167
+ };
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export * as Field from "./Field.ts";
2
+
3
+ export * as FieldState from "./FieldState.ts";
4
+
5
+ export * as FormAtoms from "./FormAtoms.ts";
6
+
7
+ export * as FormBuilder from "./FormBuilder.ts";
8
+
9
+ export * as Mode from "./Mode.ts";
10
+
11
+ export * as Path from "./Path.ts";
12
+
13
+ export * as Validation from "./Validation.ts";
@@ -0,0 +1,113 @@
1
+ import * as Equal from "effect/Equal";
2
+ import { getNestedValue, isPathUnderRoot } from "../Path.ts";
3
+
4
+ export const recalculateDirtyFieldsForArray = (
5
+ dirtyFields: ReadonlySet<string>,
6
+ initialValues: unknown,
7
+ arrayPath: string,
8
+ newItems: ReadonlyArray<unknown>,
9
+ ): ReadonlySet<string> => {
10
+ const initialItems = (getNestedValue(initialValues, arrayPath) ??
11
+ []) as ReadonlyArray<unknown>;
12
+
13
+ if (newItems === initialItems) {
14
+ return dirtyFields;
15
+ }
16
+
17
+ const nextDirty = new Set(
18
+ Array.from(dirtyFields).filter((path) => !isPathUnderRoot(path, arrayPath)),
19
+ );
20
+
21
+ const loopLength = Math.max(newItems.length, initialItems.length);
22
+ for (let i = 0; i < loopLength; i++) {
23
+ const itemPath = `${arrayPath}[${i}]`;
24
+ const newItem = newItems[i];
25
+ const initialItem = initialItems[i];
26
+
27
+ if (newItem === initialItem) continue;
28
+
29
+ if (!Equal.equals(newItem, initialItem)) {
30
+ nextDirty.add(itemPath);
31
+ }
32
+ }
33
+
34
+ if (newItems.length !== initialItems.length) {
35
+ nextDirty.add(arrayPath);
36
+ } else {
37
+ nextDirty.delete(arrayPath);
38
+ }
39
+
40
+ return nextDirty;
41
+ };
42
+
43
+ export const recalculateDirtySubtree = (
44
+ currentDirty: ReadonlySet<string>,
45
+ allInitial: unknown,
46
+ allValues: unknown,
47
+ rootPath: string = "",
48
+ ): ReadonlySet<string> => {
49
+ const targetValue = rootPath
50
+ ? getNestedValue(allValues, rootPath)
51
+ : allValues;
52
+ const targetInitial = rootPath
53
+ ? getNestedValue(allInitial, rootPath)
54
+ : allInitial;
55
+
56
+ if (targetValue === targetInitial) {
57
+ if (rootPath === "") {
58
+ return new Set();
59
+ }
60
+
61
+ let changed = false;
62
+ const nextDirty = new Set(currentDirty);
63
+ for (const path of currentDirty) {
64
+ if (isPathUnderRoot(path, rootPath)) {
65
+ nextDirty.delete(path);
66
+ changed = true;
67
+ }
68
+ }
69
+ return changed ? nextDirty : currentDirty;
70
+ }
71
+
72
+ const nextDirty = new Set(currentDirty);
73
+
74
+ if (rootPath === "") {
75
+ nextDirty.clear();
76
+ } else {
77
+ for (const path of nextDirty) {
78
+ if (isPathUnderRoot(path, rootPath)) {
79
+ nextDirty.delete(path);
80
+ }
81
+ }
82
+ }
83
+
84
+ const recurse = (current: unknown, initial: unknown, path: string): void => {
85
+ if (current === initial) return;
86
+
87
+ if (Array.isArray(current)) {
88
+ const initialArr = (initial ?? []) as ReadonlyArray<unknown>;
89
+ for (let i = 0; i < Math.max(current.length, initialArr.length); i++) {
90
+ recurse(current[i], initialArr[i], path ? `${path}[${i}]` : `[${i}]`);
91
+ }
92
+ } else if (current !== null && typeof current === "object") {
93
+ const initialObj = (initial ?? {}) as Record<string, unknown>;
94
+ for (const key in current as object) {
95
+ recurse(
96
+ (current as Record<string, unknown>)[key],
97
+ initialObj[key],
98
+ path ? `${path}.${key}` : key,
99
+ );
100
+ }
101
+ for (const key in initialObj) {
102
+ if (!(key in (current as object))) {
103
+ recurse(undefined, initialObj[key], path ? `${path}.${key}` : key);
104
+ }
105
+ }
106
+ } else {
107
+ if (!Equal.equals(current, initial) && path) nextDirty.add(path);
108
+ }
109
+ };
110
+
111
+ recurse(targetValue, targetInitial, rootPath);
112
+ return nextDirty;
113
+ };