@playfast/reform-forms 1.3.0 → 1.4.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.
- package/dist/formReindex.d.ts +24 -0
- package/dist/formReindex.d.ts.map +1 -0
- package/dist/formReindex.js +42 -0
- package/dist/formReindex.js.map +1 -0
- package/dist/formState.d.ts.map +1 -1
- package/dist/formState.js +50 -9
- package/dist/formState.js.map +1 -1
- package/package.json +1 -1
- package/src/formReindex.ts +115 -0
- package/src/formState-array-keys.test.ts +179 -0
- package/src/formState-reindex.test.ts +198 -0
- package/src/formState.ts +73 -23
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Option } from 'effect';
|
|
2
|
+
import type { FormState } from './formTypes.js';
|
|
3
|
+
type IndexMap = (index: number) => Option.Option<number>;
|
|
4
|
+
interface ReindexMetadataInput<Values> {
|
|
5
|
+
readonly state: FormState<Values>;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly moveIndex: IndexMap;
|
|
8
|
+
}
|
|
9
|
+
export declare const reindexMetadata: <Values>({ state, path, moveIndex, }: ReindexMetadataInput<Values>) => Pick<FormState<Values>, "touched" | "errors"> & {
|
|
10
|
+
readonly nestedKeys: Readonly<Record<string, ReadonlyArray<string>>>;
|
|
11
|
+
};
|
|
12
|
+
export declare const removedIndexMap: (removed: number) => IndexMap;
|
|
13
|
+
interface MoveRange {
|
|
14
|
+
readonly from: number;
|
|
15
|
+
readonly to: number;
|
|
16
|
+
}
|
|
17
|
+
export declare const movedIndexMap: ({ from, to }: MoveRange) => IndexMap;
|
|
18
|
+
interface SwapPair {
|
|
19
|
+
readonly first: number;
|
|
20
|
+
readonly second: number;
|
|
21
|
+
}
|
|
22
|
+
export declare const swappedIndexMap: ({ first, second }: SwapPair) => IndexMap;
|
|
23
|
+
export {};
|
|
24
|
+
//# sourceMappingURL=formReindex.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formReindex.d.ts","sourceRoot":"","sources":["../src/formReindex.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAU,MAAM,QAAQ,CAAA;AAC9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAO5C,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;AAoDxD,UAAU,oBAAoB,CAAC,MAAM;IACnC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,SAAS,EAAE,QAAQ,CAAA;CAC7B;AAED,eAAO,MAAM,eAAe,GAAI,MAAM,EAAE,6BAIrC,oBAAoB,CAAC,MAAM,CAAC,KAAG,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,SAAS,GAAG,QAAQ,CAAC,GAAG;IAChF,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;CAKpE,CAAA;AAEF,eAAO,MAAM,eAAe,GACzB,SAAS,MAAM,KAAG,QAEmE,CAAA;AAExF,UAAU,SAAS;IACjB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAED,eAAO,MAAM,aAAa,GACvB,cAAc,SAAS,KAAG,QAS1B,CAAA;AAEH,UAAU,QAAQ;IAChB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAED,eAAO,MAAM,eAAe,GACzB,mBAAmB,QAAQ,KAAG,QAQ5B,CAAA"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Match, Option, Record } from 'effect';
|
|
2
|
+
// `members[2].tags` under path `members` → index 2, suffix `.tags`.
|
|
3
|
+
const indexedSegment = /^\[(\d+)\]/;
|
|
4
|
+
const reindexedKey = ({ key, path, moveIndex }) => {
|
|
5
|
+
const rest = key.slice(path.length);
|
|
6
|
+
return Option.match(Option.fromNullable(indexedSegment.exec(rest)), {
|
|
7
|
+
onNone: () => Option.some(key),
|
|
8
|
+
onSome: (found) => {
|
|
9
|
+
const index = Number(found[1]);
|
|
10
|
+
if (!Number.isInteger(index)) {
|
|
11
|
+
return Option.some(key);
|
|
12
|
+
}
|
|
13
|
+
const suffix = rest.slice(found[0].length);
|
|
14
|
+
return Option.map(moveIndex(index), (next) => `${path}[${next}]${suffix}`);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
const reindexPaths = ({ entries, path, moveIndex, }) => {
|
|
19
|
+
const prefix = `${path}[`;
|
|
20
|
+
const moved = ([key, entry]) => Option.match(reindexedKey({ key, path, moveIndex }), {
|
|
21
|
+
onNone: () => [],
|
|
22
|
+
onSome: (nextKey) => [[nextKey, entry]],
|
|
23
|
+
});
|
|
24
|
+
return Record.fromEntries(Record.toEntries(entries).flatMap((pair) => pair[0].startsWith(prefix) ? moved(pair) : [pair]));
|
|
25
|
+
};
|
|
26
|
+
export const reindexMetadata = ({ state, path, moveIndex, }) => ({
|
|
27
|
+
touched: reindexPaths({ entries: state.touched, path, moveIndex }),
|
|
28
|
+
errors: reindexPaths({ entries: state.errors, path, moveIndex }),
|
|
29
|
+
nestedKeys: reindexPaths({ entries: state.arrayKeys, path, moveIndex }),
|
|
30
|
+
});
|
|
31
|
+
export const removedIndexMap = (removed) => (index) => index === removed ? Option.none() : Option.some(index > removed ? index - 1 : index);
|
|
32
|
+
export const movedIndexMap = ({ from, to }) => (index) => {
|
|
33
|
+
if (index === from) {
|
|
34
|
+
return Option.some(to);
|
|
35
|
+
}
|
|
36
|
+
if (from < to) {
|
|
37
|
+
return Option.some(index > from && index <= to ? index - 1 : index);
|
|
38
|
+
}
|
|
39
|
+
return Option.some(index >= to && index < from ? index + 1 : index);
|
|
40
|
+
};
|
|
41
|
+
export const swappedIndexMap = ({ first, second }) => (index) => Option.some(Match.value(index).pipe(Match.when(first, () => second), Match.when(second, () => first), Match.orElse(() => index)));
|
|
42
|
+
//# sourceMappingURL=formReindex.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formReindex.js","sourceRoot":"","sources":["../src/formReindex.ts"],"names":[],"mappings":"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"}
|
package/dist/formState.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formState.d.ts","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"formState.d.ts","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAA;AAM5C,eAAO,MAAM,YAAY,GAAI,QAAQ,OAAO,EAAE,aAAS,KAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,CAmB7F,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,SAAS,MAAM,KAAG,SAAS,CAAC,MAAM,CAUrE,CAAA;AAEF,eAAO,MAAM,WAAW,GACtB,SAAS,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,EAC1C,MAAM,MAAM,KACX,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAmC,CAAA;AAEtE,UAAU,WAAW;IACnB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAA;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAOD,UAAU,SAAS,CAAC,MAAM;IACxB,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC,MAAM,CAAC,CAAA;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CACtB;AAED,UAAU,YAAY,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACtD,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAA;CACxB;AAED,UAAU,eAAe,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED,UAAU,aAAa,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CACpB;AAED,UAAU,aAAa,CAAC,MAAM,CAAE,SAAQ,SAAS,CAAC,MAAM,CAAC;IACvD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CACxB;AAQD,eAAO,MAAM,YAAY,GAAI,OAAO,WAAW,KAAG,aAAa,CAAC,OAAO,CAGtE,CAAA;AAyBD,eAAO,MAAM,SAAS,GAAI,MAAM,EAAE,OAAO,YAAY,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAM9E,CAAA;AAEF,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,OAAO,YAAY,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAalF,CAAA;AAED,eAAO,MAAM,YAAY,GAAI,MAAM,EAAE,OAAO,eAAe,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAyBrF,CAAA;AAED,eAAO,MAAM,UAAU,GAAI,MAAM,EAAE,OAAO,aAAa,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CA4BjF,CAAA;AAED,eAAO,MAAM,UAAU,GAAI,MAAM,EAAE,OAAO,aAAa,CAAC,MAAM,CAAC,KAAG,SAAS,CAAC,MAAM,CAqCjF,CAAA"}
|
package/dist/formState.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Function as Fn, Option, Record } from 'effect';
|
|
2
|
-
import { getNestedValue, moveAt, recalculateDirtyPaths, replaceAt, setNestedValue
|
|
2
|
+
import { getNestedValue, moveAt, recalculateDirtyPaths, replaceAt, setNestedValue } from './path.js';
|
|
3
|
+
import { movedIndexMap, reindexMetadata, removedIndexMap, swappedIndexMap } from './formReindex.js';
|
|
3
4
|
const arrayKeyCounter = { current: 0 };
|
|
4
5
|
const makeArrayKey = () => `form-item-${arrayKeyCounter.current++}`;
|
|
5
6
|
export const arrayKeysFor = (source, path = '') => {
|
|
@@ -42,16 +43,29 @@ const updateKeys = (state, path, transform) => {
|
|
|
42
43
|
const existing = state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey());
|
|
43
44
|
return { ...state.arrayKeys, [path]: transform(existing) };
|
|
44
45
|
};
|
|
45
|
-
|
|
46
|
+
// The subtree at `path` is being replaced, so every key describing a row inside it
|
|
47
|
+
// is about to describe nothing. Left behind, the next append folds over the stale
|
|
48
|
+
// array and hands a new row the identity of one the user discarded.
|
|
49
|
+
const withoutSubtree = (keys, path) => Record.fromEntries(Record.toEntries(keys).filter(([key]) => key !== path && !key.startsWith(`${path}[`) && !key.startsWith(`${path}.`)));
|
|
50
|
+
export const setAtPath = (input) => ({
|
|
51
|
+
...withValues(input.state, setNestedValue(input.state.values, input.path, input.value)),
|
|
52
|
+
arrayKeys: {
|
|
53
|
+
...withoutSubtree(input.state.arrayKeys, input.path),
|
|
54
|
+
...arrayKeysFor(input.value, input.path),
|
|
55
|
+
},
|
|
56
|
+
});
|
|
46
57
|
export const appendAtPath = (input) => {
|
|
47
58
|
const elements = currentArray({ source: input.state.values, path: input.path });
|
|
48
59
|
const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value]);
|
|
49
60
|
return {
|
|
50
61
|
...withValues(input.state, nextValues),
|
|
51
|
-
arrayKeys:
|
|
52
|
-
...keys,
|
|
53
|
-
|
|
54
|
-
|
|
62
|
+
arrayKeys: {
|
|
63
|
+
...updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
|
|
64
|
+
// Arrays nested inside the appended item need keys of their own — `initialState`
|
|
65
|
+
// seeds every array in the tree, and a nested list without them falls back to a
|
|
66
|
+
// positional key, so its rows change identity the first time one is touched.
|
|
67
|
+
...arrayKeysFor(input.value, `${input.path}[${elements.length}]`),
|
|
68
|
+
},
|
|
55
69
|
};
|
|
56
70
|
};
|
|
57
71
|
export const removeAtPath = (input) => {
|
|
@@ -60,9 +74,16 @@ export const removeAtPath = (input) => {
|
|
|
60
74
|
return input.state;
|
|
61
75
|
}
|
|
62
76
|
const nextValues = setNestedValue(input.state.values, input.path, elements.filter((_, position) => position !== input.index));
|
|
77
|
+
const moved = reindexMetadata({
|
|
78
|
+
state: input.state,
|
|
79
|
+
path: input.path,
|
|
80
|
+
moveIndex: removedIndexMap(input.index),
|
|
81
|
+
});
|
|
63
82
|
return {
|
|
64
83
|
...withValues(input.state, nextValues),
|
|
65
|
-
|
|
84
|
+
touched: moved.touched,
|
|
85
|
+
errors: moved.errors,
|
|
86
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }), input.path, (keys) => keys.filter((_, position) => position !== input.index)),
|
|
66
87
|
};
|
|
67
88
|
};
|
|
68
89
|
export const moveAtPath = (input) => {
|
|
@@ -72,9 +93,22 @@ export const moveAtPath = (input) => {
|
|
|
72
93
|
return input.state;
|
|
73
94
|
}
|
|
74
95
|
const nextValues = setNestedValue(input.state.values, input.path, next);
|
|
96
|
+
const moved = reindexMetadata({
|
|
97
|
+
state: input.state,
|
|
98
|
+
path: input.path,
|
|
99
|
+
// `moveAt` accepts `to === elements.length` as "move it to the end", but a move
|
|
100
|
+
// keeps the length, so the item lands one slot short of that. The metadata has to
|
|
101
|
+
// follow the value, not the argument.
|
|
102
|
+
moveIndex: movedIndexMap({
|
|
103
|
+
from: input.from,
|
|
104
|
+
to: Math.min(input.to, elements.length - 1),
|
|
105
|
+
}),
|
|
106
|
+
});
|
|
75
107
|
return {
|
|
76
108
|
...withValues(input.state, nextValues),
|
|
77
|
-
|
|
109
|
+
touched: moved.touched,
|
|
110
|
+
errors: moved.errors,
|
|
111
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }), input.path, (keys) => moveAt(keys, input.from, input.to)),
|
|
78
112
|
};
|
|
79
113
|
};
|
|
80
114
|
export const swapAtPath = (input) => {
|
|
@@ -88,9 +122,16 @@ export const swapAtPath = (input) => {
|
|
|
88
122
|
}
|
|
89
123
|
const swapped = replaceAt(replaceAt(elements, input.first, elements[input.second]), input.second, elements[input.first]);
|
|
90
124
|
const nextValues = setNestedValue(input.state.values, input.path, swapped);
|
|
125
|
+
const moved = reindexMetadata({
|
|
126
|
+
state: input.state,
|
|
127
|
+
path: input.path,
|
|
128
|
+
moveIndex: swappedIndexMap({ first: input.first, second: input.second }),
|
|
129
|
+
});
|
|
91
130
|
return {
|
|
92
131
|
...withValues(input.state, nextValues),
|
|
93
|
-
|
|
132
|
+
touched: moved.touched,
|
|
133
|
+
errors: moved.errors,
|
|
134
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }), input.path, (keys) => replaceAt(replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()), input.second, keys[input.first] ?? makeArrayKey())),
|
|
94
135
|
};
|
|
95
136
|
};
|
|
96
137
|
//# sourceMappingURL=formState.js.map
|
package/dist/formState.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"formState.js","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AACvD,OAAO,
|
|
1
|
+
{"version":3,"file":"formState.js","sourceRoot":"","sources":["../src/formState.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-forms",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Headless, schema-driven form state for reform — values, validation, field limitations, list operations, and submit flow, rendering nothing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Match, Option, Record } from 'effect'
|
|
2
|
+
import type { FormState } from './formTypes'
|
|
3
|
+
|
|
4
|
+
// Every per-field record — `touched`, `errors`, and the nested `arrayKeys` of a
|
|
5
|
+
// list inside an item — is addressed by a path that embeds the item's index.
|
|
6
|
+
// A structural edit therefore has to carry that metadata with the item, or the
|
|
7
|
+
// neighbour that slides into the vacated index inherits it: a removed row's
|
|
8
|
+
// touched flag, or a reordered row's validation error, landing on someone else.
|
|
9
|
+
type IndexMap = (index: number) => Option.Option<number>
|
|
10
|
+
|
|
11
|
+
interface ReindexInput {
|
|
12
|
+
readonly key: string
|
|
13
|
+
readonly path: string
|
|
14
|
+
readonly moveIndex: IndexMap
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// `members[2].tags` under path `members` → index 2, suffix `.tags`.
|
|
18
|
+
const indexedSegment = /^\[(\d+)\]/
|
|
19
|
+
|
|
20
|
+
const reindexedKey = ({ key, path, moveIndex }: ReindexInput): Option.Option<string> => {
|
|
21
|
+
const rest = key.slice(path.length)
|
|
22
|
+
return Option.match(Option.fromNullable(indexedSegment.exec(rest)), {
|
|
23
|
+
onNone: () => Option.some(key),
|
|
24
|
+
onSome: (found) => {
|
|
25
|
+
const index = Number(found[1])
|
|
26
|
+
if (!Number.isInteger(index)) {
|
|
27
|
+
return Option.some(key)
|
|
28
|
+
}
|
|
29
|
+
const suffix = rest.slice(found[0].length)
|
|
30
|
+
return Option.map(moveIndex(index), (next) => `${path}[${next}]${suffix}`)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ReindexPathsInput<V> {
|
|
36
|
+
readonly entries: Readonly<Record<string, V>>
|
|
37
|
+
readonly path: string
|
|
38
|
+
readonly moveIndex: IndexMap
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const reindexPaths = <V>({
|
|
42
|
+
entries,
|
|
43
|
+
path,
|
|
44
|
+
moveIndex,
|
|
45
|
+
}: ReindexPathsInput<V>): Readonly<Record<string, V>> => {
|
|
46
|
+
const prefix = `${path}[`
|
|
47
|
+
const moved = ([key, entry]: readonly [string, V]): ReadonlyArray<readonly [string, V]> =>
|
|
48
|
+
Option.match(reindexedKey({ key, path, moveIndex }), {
|
|
49
|
+
onNone: (): ReadonlyArray<readonly [string, V]> => [],
|
|
50
|
+
onSome: (nextKey) => [[nextKey, entry] as const],
|
|
51
|
+
})
|
|
52
|
+
return Record.fromEntries(
|
|
53
|
+
Record.toEntries(entries).flatMap((pair) =>
|
|
54
|
+
pair[0].startsWith(prefix) ? moved(pair) : [pair],
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// `arrayKeys[path]` itself is rewritten by `updateKeys`; only the entries nested
|
|
60
|
+
// under an item (`members[0].tags`) need moving, and those start with the prefix.
|
|
61
|
+
interface ReindexMetadataInput<Values> {
|
|
62
|
+
readonly state: FormState<Values>
|
|
63
|
+
readonly path: string
|
|
64
|
+
readonly moveIndex: IndexMap
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const reindexMetadata = <Values>({
|
|
68
|
+
state,
|
|
69
|
+
path,
|
|
70
|
+
moveIndex,
|
|
71
|
+
}: ReindexMetadataInput<Values>): Pick<FormState<Values>, 'touched' | 'errors'> & {
|
|
72
|
+
readonly nestedKeys: Readonly<Record<string, ReadonlyArray<string>>>
|
|
73
|
+
} => ({
|
|
74
|
+
touched: reindexPaths({ entries: state.touched, path, moveIndex }),
|
|
75
|
+
errors: reindexPaths({ entries: state.errors, path, moveIndex }),
|
|
76
|
+
nestedKeys: reindexPaths({ entries: state.arrayKeys, path, moveIndex }),
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
export const removedIndexMap =
|
|
80
|
+
(removed: number): IndexMap =>
|
|
81
|
+
(index) =>
|
|
82
|
+
index === removed ? Option.none() : Option.some(index > removed ? index - 1 : index)
|
|
83
|
+
|
|
84
|
+
interface MoveRange {
|
|
85
|
+
readonly from: number
|
|
86
|
+
readonly to: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const movedIndexMap =
|
|
90
|
+
({ from, to }: MoveRange): IndexMap =>
|
|
91
|
+
(index) => {
|
|
92
|
+
if (index === from) {
|
|
93
|
+
return Option.some(to)
|
|
94
|
+
}
|
|
95
|
+
if (from < to) {
|
|
96
|
+
return Option.some(index > from && index <= to ? index - 1 : index)
|
|
97
|
+
}
|
|
98
|
+
return Option.some(index >= to && index < from ? index + 1 : index)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface SwapPair {
|
|
102
|
+
readonly first: number
|
|
103
|
+
readonly second: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const swappedIndexMap =
|
|
107
|
+
({ first, second }: SwapPair): IndexMap =>
|
|
108
|
+
(index) =>
|
|
109
|
+
Option.some(
|
|
110
|
+
Match.value(index).pipe(
|
|
111
|
+
Match.when(first, () => second),
|
|
112
|
+
Match.when(second, () => first),
|
|
113
|
+
Match.orElse(() => index),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Function as Fn, Layer, Schema as S } from 'effect'
|
|
3
|
+
import { Engine } from '@playfast/reform'
|
|
4
|
+
import * as Form from './form'
|
|
5
|
+
import type { ArrayBinding, FieldBinding, FormView } from './formTypes'
|
|
6
|
+
|
|
7
|
+
// Same polling idiom as the neighbouring suites: never wait a fixed duration.
|
|
8
|
+
const waitUntil = <A, R>(
|
|
9
|
+
read: Effect.Effect<A, never, R>,
|
|
10
|
+
pred: (value: A) => boolean,
|
|
11
|
+
rounds = 100,
|
|
12
|
+
): Effect.Effect<A, never, R> =>
|
|
13
|
+
Effect.flatMap(read, (value) =>
|
|
14
|
+
pred(value) || rounds <= 0
|
|
15
|
+
? Effect.succeed(value)
|
|
16
|
+
: Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const RosterSchema = S.Struct({
|
|
20
|
+
members: S.Array(S.Struct({ name: S.String, tags: S.Array(S.String) })),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
type RosterValues = S.Schema.Encoded<typeof RosterSchema>
|
|
24
|
+
|
|
25
|
+
class AppendKeysForm extends Form.make('FormsHuntAppendKeysForm', { schema: RosterSchema }) {}
|
|
26
|
+
|
|
27
|
+
const rosterLayer = Form.live(AppendKeysForm, {
|
|
28
|
+
// `ada` comes from `initial`, so `initialState` seeds stable keys for her nested
|
|
29
|
+
// `tags` list. Every other member in these tests arrives through `append`.
|
|
30
|
+
initial: { members: [{ name: 'ada', tags: ['a1', 'a2'] }] },
|
|
31
|
+
}).pipe(Layer.provideMerge(Engine))
|
|
32
|
+
|
|
33
|
+
// A list nested inside an array item is addressed by an indexed runtime path
|
|
34
|
+
// (`members[1].tags`). `ArrayPath<Values>` cannot spell one, so every renderer
|
|
35
|
+
// reaches it the way `@playfast/reform-forms-react` does — through the erased
|
|
36
|
+
// `RuntimeFormView` shape whose `array` takes a plain string.
|
|
37
|
+
const nestedArray = (view: FormView<RosterValues>, path: string): ArrayBinding<string> => {
|
|
38
|
+
const runtimeArray: (candidate: string) => ArrayBinding<string> = Fn.unsafeCoerce(view.array)
|
|
39
|
+
return runtimeArray(path)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const nestedField = (
|
|
43
|
+
view: FormView<RosterValues>,
|
|
44
|
+
path: string,
|
|
45
|
+
): FieldBinding<ReadonlyArray<string>> => {
|
|
46
|
+
const runtimeField: (candidate: string) => FieldBinding<ReadonlyArray<string>> = Fn.unsafeCoerce(
|
|
47
|
+
view.field,
|
|
48
|
+
)
|
|
49
|
+
return runtimeField(path)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const keysOf = (view: FormView<RosterValues>, path: string): ReadonlyArray<string> =>
|
|
53
|
+
nestedArray(view, path).items.map((item) => item.key)
|
|
54
|
+
|
|
55
|
+
const tagCount = (values: RosterValues, index: number): number =>
|
|
56
|
+
values.members[index]?.tags.length ?? 0
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// `appendAtPath` mints one key for the row it adds to `arrayKeys[path]`, but it
|
|
60
|
+
// never derives keys for the arrays nested INSIDE the appended value the way
|
|
61
|
+
// `initialState` does via `arrayKeysFor`. Those nested lists then fall through
|
|
62
|
+
// `formView.array`'s positional fallback (`${path}-${index}`) until some later
|
|
63
|
+
// edit lazily mints real keys — at which point every existing row of that list
|
|
64
|
+
// changes identity and a renderer remounts rows the user never touched.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
it.scopedLive('a nested list inside an appended item keeps its row keys when a row is added', () =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const initial = yield* Form.view(AppendKeysForm)
|
|
70
|
+
initial.array('members').append({ name: 'zoe', tags: ['z1', 'z2'] })
|
|
71
|
+
const seeded = yield* waitUntil(
|
|
72
|
+
Form.view(AppendKeysForm),
|
|
73
|
+
(view) => view.values.members.length === 2,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const adaBefore = keysOf(seeded, 'members[0].tags')
|
|
77
|
+
const zoeBefore = keysOf(seeded, 'members[1].tags')
|
|
78
|
+
expect(adaBefore.length).toBe(2)
|
|
79
|
+
expect(zoeBefore.length).toBe(2)
|
|
80
|
+
|
|
81
|
+
nestedArray(seeded, 'members[0].tags').append('a3')
|
|
82
|
+
const adaGrown = yield* waitUntil(
|
|
83
|
+
Form.view(AppendKeysForm),
|
|
84
|
+
(view) => tagCount(view.values, 0) === 3,
|
|
85
|
+
)
|
|
86
|
+
nestedArray(adaGrown, 'members[1].tags').append('z3')
|
|
87
|
+
const grown = yield* waitUntil(
|
|
88
|
+
Form.view(AppendKeysForm),
|
|
89
|
+
(view) => tagCount(view.values, 1) === 3,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
// CONTROL — appending a row to ada's list leaves the two existing rows' keys
|
|
93
|
+
// untouched, which is what "stable item keys" means.
|
|
94
|
+
expect(keysOf(grown, 'members[0].tags').slice(0, 2)).toEqual(adaBefore)
|
|
95
|
+
// SUBJECT — the same operation on zoe's list must be just as stable.
|
|
96
|
+
expect(keysOf(grown, 'members[1].tags').slice(0, 2)).toEqual(zoeBefore)
|
|
97
|
+
|
|
98
|
+
// Root cause, stated directly: ada's rows carry minted keys, zoe's carry the
|
|
99
|
+
// positional `${path}-${index}` fallback because `append` seeded none.
|
|
100
|
+
expect(adaBefore.every((key) => key.startsWith('form-item-'))).toBe(true)
|
|
101
|
+
expect(zoeBefore.every((key) => key.startsWith('form-item-'))).toBe(true)
|
|
102
|
+
}).pipe(Effect.provide(rosterLayer)),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
it.scopedLive('a nested list inside an appended item keeps its row keys across a parent move', () =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const initial = yield* Form.view(AppendKeysForm)
|
|
108
|
+
initial.array('members').append({ name: 'zoe', tags: ['z1', 'z2'] })
|
|
109
|
+
const seeded = yield* waitUntil(
|
|
110
|
+
Form.view(AppendKeysForm),
|
|
111
|
+
(view) => view.values.members.length === 2,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const adaBefore = keysOf(seeded, 'members[0].tags')
|
|
115
|
+
const zoeBefore = keysOf(seeded, 'members[1].tags')
|
|
116
|
+
|
|
117
|
+
// Reorder the OUTER list; neither nested row was edited.
|
|
118
|
+
seeded.array('members').move(1, 0)
|
|
119
|
+
const moved = yield* waitUntil(
|
|
120
|
+
Form.view(AppendKeysForm),
|
|
121
|
+
(view) => view.values.members[0]?.name === 'zoe',
|
|
122
|
+
)
|
|
123
|
+
expect(moved.values.members.map((member) => member.name)).toEqual(['zoe', 'ada'])
|
|
124
|
+
|
|
125
|
+
// CONTROL — ada's nested keys travel with her to index 1 (reindexMetadata).
|
|
126
|
+
expect(keysOf(moved, 'members[1].tags')).toEqual(adaBefore)
|
|
127
|
+
// SUBJECT — zoe's nested rows must likewise keep the identity they had at
|
|
128
|
+
// index 1 now that she sits at index 0.
|
|
129
|
+
expect(keysOf(moved, 'members[0].tags')).toEqual(zoeBefore)
|
|
130
|
+
}).pipe(Effect.provide(rosterLayer)),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// `setAtPath` replaces the value at a path and never touches `arrayKeys`, so the
|
|
135
|
+
// keys describing the rows it just discarded stay behind. The next mutation folds
|
|
136
|
+
// over that stale array, and a freshly appended row inherits the identity of a row
|
|
137
|
+
// the user removed.
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
class SetKeysForm extends Form.make('FormsHuntSetKeysForm', { schema: RosterSchema }) {}
|
|
141
|
+
|
|
142
|
+
const setKeysLayer = Form.live(SetKeysForm, {
|
|
143
|
+
initial: { members: [{ name: 'ada', tags: ['a1', 'a2', 'a3'] }] },
|
|
144
|
+
}).pipe(Layer.provideMerge(Engine))
|
|
145
|
+
|
|
146
|
+
it.scopedLive('replacing an array wholesale does not leave a removed rowid behind', () =>
|
|
147
|
+
Effect.gen(function* () {
|
|
148
|
+
const initial = yield* Form.view(SetKeysForm)
|
|
149
|
+
const seeded = keysOf(initial, 'members[0].tags')
|
|
150
|
+
expect(seeded.length).toBe(3)
|
|
151
|
+
|
|
152
|
+
// CONTROL — appending to the list as seeded gives the new row an identity none
|
|
153
|
+
// of the existing rows holds.
|
|
154
|
+
nestedArray(initial, 'members[0].tags').append('a4')
|
|
155
|
+
const grown = yield* waitUntil(Form.view(SetKeysForm), (view) => tagCount(view.values, 0) === 4)
|
|
156
|
+
const grownKeys = keysOf(grown, 'members[0].tags')
|
|
157
|
+
expect(grownKeys.slice(0, 3)).toEqual(seeded)
|
|
158
|
+
expect(seeded).not.toContain(grownKeys[3])
|
|
159
|
+
|
|
160
|
+
// SUBJECT — replace the whole list with one row, then append. The three keys
|
|
161
|
+
// minted for the discarded rows are still in `arrayKeys`, so the appended row
|
|
162
|
+
// reads back the key of a row that no longer exists.
|
|
163
|
+
nestedField(grown, 'members[0].tags').set(['b1'])
|
|
164
|
+
const shrunk = yield* waitUntil(
|
|
165
|
+
Form.view(SetKeysForm),
|
|
166
|
+
(view) => tagCount(view.values, 0) === 1,
|
|
167
|
+
)
|
|
168
|
+
nestedArray(shrunk, 'members[0].tags').append('b2')
|
|
169
|
+
const regrown = yield* waitUntil(
|
|
170
|
+
Form.view(SetKeysForm),
|
|
171
|
+
(view) => tagCount(view.values, 0) === 2,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const regrownKeys = keysOf(regrown, 'members[0].tags')
|
|
175
|
+
expect(regrownKeys).toHaveLength(2)
|
|
176
|
+
expect(new Set(regrownKeys).size).toBe(2)
|
|
177
|
+
expect(seeded.slice(1)).not.toContain(regrownKeys[1])
|
|
178
|
+
}).pipe(Effect.provide(setKeysLayer)),
|
|
179
|
+
)
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Exit, Layer, Option, Schema as S } from 'effect'
|
|
3
|
+
import { Engine, Event } from '@playfast/reform'
|
|
4
|
+
import * as Form from './form'
|
|
5
|
+
import * as FormState from './formState'
|
|
6
|
+
import type { FormState as FormStateShape } from './formTypes'
|
|
7
|
+
import { expect as proofExpect } from '../../proof/src/assert'
|
|
8
|
+
|
|
9
|
+
// Same polling idiom as form.test.ts: never wait a fixed duration, yield until the
|
|
10
|
+
// condition holds.
|
|
11
|
+
const waitUntil = <A, R>(
|
|
12
|
+
read: Effect.Effect<A, never, R>,
|
|
13
|
+
pred: (value: A) => boolean,
|
|
14
|
+
rounds = 100,
|
|
15
|
+
): Effect.Effect<A, never, R> =>
|
|
16
|
+
Effect.flatMap(read, (value) =>
|
|
17
|
+
pred(value) || rounds <= 0
|
|
18
|
+
? Effect.succeed(value)
|
|
19
|
+
: Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const RosterSchema = S.Struct({
|
|
23
|
+
members: S.Array(S.Struct({ name: S.String, tags: S.Array(S.String) })),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
class RosterForm extends Form.make('HuntRosterForm', { schema: RosterSchema }) {}
|
|
27
|
+
|
|
28
|
+
const rosterLayer = Form.live(RosterForm, {
|
|
29
|
+
initial: {
|
|
30
|
+
members: [
|
|
31
|
+
{ name: 'ada', tags: ['ada-1', 'ada-2'] },
|
|
32
|
+
{ name: 'bob', tags: ['bob-1'] },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
validate: ({ values }) =>
|
|
36
|
+
values.members.flatMap((member, index) =>
|
|
37
|
+
member.name === 'ada' ? [Form.error(`members[${index}].name`, 'ada is blocked')] : [],
|
|
38
|
+
),
|
|
39
|
+
}).pipe(Layer.provideMerge(Engine))
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// forms: per-item state is addressed by index and never re-indexed when the
|
|
43
|
+
// array is spliced or reordered, so it lands on a different item.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
it.scopedLive('touched does not survive its item being removed from an array', () =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const view = yield* Form.view(RosterForm)
|
|
49
|
+
// Blur ADA's name only. Bob's name is never touched by anybody.
|
|
50
|
+
yield* Event.dispatch(RosterForm.events.blur, { path: 'members[0].name' })
|
|
51
|
+
yield* waitUntil(RosterForm.state, (state) => state.touched['members[0].name'] === true)
|
|
52
|
+
|
|
53
|
+
view.array('members').remove(0)
|
|
54
|
+
const after = yield* waitUntil(RosterForm.state, (state) => state.values.members.length === 1)
|
|
55
|
+
|
|
56
|
+
expect(after.values.members[0]?.name).toBe('bob')
|
|
57
|
+
// The only touched field belonged to the removed item, so nothing is touched.
|
|
58
|
+
// Today `members[0].name` is still flagged, and index 0 is now Bob — a row the
|
|
59
|
+
// user never visited renders as touched (and therefore as "show my errors").
|
|
60
|
+
expect(after.touched).toEqual({})
|
|
61
|
+
}).pipe(Effect.provide(rosterLayer)),
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
it.scopedLive('a field error follows its item across a reorder', () =>
|
|
65
|
+
Effect.gen(function* () {
|
|
66
|
+
const view = yield* Form.view(RosterForm)
|
|
67
|
+
view.validate()
|
|
68
|
+
const invalid = yield* waitUntil(RosterForm.state, (state) => state.validationCount > 0)
|
|
69
|
+
expect(invalid.errors).toEqual({ 'members[0].name': 'ada is blocked' })
|
|
70
|
+
|
|
71
|
+
// Move Ada (index 0) to the end; Bob becomes index 0.
|
|
72
|
+
view.array('members').move(0, 1)
|
|
73
|
+
const after = yield* waitUntil(
|
|
74
|
+
RosterForm.state,
|
|
75
|
+
(state) => state.values.members[0]?.name === 'bob',
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// Whatever the fix is — re-index the error onto Ada's new path, or drop stale
|
|
79
|
+
// errors on a structural change — Bob, who is now index 0, must not be wearing
|
|
80
|
+
// Ada's error message.
|
|
81
|
+
expect(after.errors['members[0].name']).toBe(undefined)
|
|
82
|
+
}).pipe(Effect.provide(rosterLayer)),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
it.scopedLive('stable array keys of a nested list follow their parent item', () =>
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const view = yield* Form.view(RosterForm)
|
|
88
|
+
const before = yield* RosterForm.state
|
|
89
|
+
const adaKey = before.arrayKeys['members']?.[0]
|
|
90
|
+
const bobKey = before.arrayKeys['members']?.[1]
|
|
91
|
+
const adaTagKeys = before.arrayKeys['members[0].tags']
|
|
92
|
+
const bobTagKeys = before.arrayKeys['members[1].tags']
|
|
93
|
+
expect(adaTagKeys?.length).toBe(2)
|
|
94
|
+
expect(bobTagKeys?.length).toBe(1)
|
|
95
|
+
|
|
96
|
+
view.array('members').move(0, 1)
|
|
97
|
+
const after = yield* waitUntil(
|
|
98
|
+
RosterForm.state,
|
|
99
|
+
(state) => state.values.members[0]?.name === 'bob',
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// The item's OWN key is re-keyed correctly by moveAtPath ...
|
|
103
|
+
expect(after.arrayKeys['members']).toEqual([bobKey, adaKey])
|
|
104
|
+
// ... but the keys of the list nested INSIDE each item are not touched, so the
|
|
105
|
+
// key list at `members[0].tags` still describes Ada while index 0 holds Bob.
|
|
106
|
+
// Independent of which fix is chosen (carry the keys along, or regenerate them),
|
|
107
|
+
// a stable-key list must hand out exactly one key per item.
|
|
108
|
+
expect(after.values.members[0]?.tags.length).toBe(1)
|
|
109
|
+
expect(after.arrayKeys['members[0].tags']?.length).toBe(1)
|
|
110
|
+
expect(after.arrayKeys['members[1].tags']?.length).toBe(2)
|
|
111
|
+
}).pipe(Effect.provide(rosterLayer)),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// proof: the harness compares values by a structural fingerprint string.
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
it('Proof.expect().toEqual() accepts a value that repeats a reference', () => {
|
|
119
|
+
const shared = { currency: 'USD' }
|
|
120
|
+
// `{ a: shared, b: shared }` is not cyclic — it is a DAG, and it is structurally
|
|
121
|
+
// equal to the literal on the right. The fingerprint's `seen` set is never
|
|
122
|
+
// unwound, so the second occurrence collapses to `[Circular]` and the assertion
|
|
123
|
+
// dies on two values that are equal.
|
|
124
|
+
const outcome = Effect.runSyncExit(
|
|
125
|
+
proofExpect({ a: shared, b: shared }).toEqual({
|
|
126
|
+
a: { currency: 'USD' },
|
|
127
|
+
b: { currency: 'USD' },
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
130
|
+
expect(Exit.isSuccess(outcome)).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('Proof.expect().toEqual() rejects a cyclic value against an acyclic one', () => {
|
|
134
|
+
const leaf = { currency: 'USD' }
|
|
135
|
+
const acyclic: Record<string, unknown> = { a: leaf, b: leaf }
|
|
136
|
+
const cyclic: Record<string, unknown> = { a: { currency: 'USD' } }
|
|
137
|
+
cyclic['b'] = cyclic
|
|
138
|
+
|
|
139
|
+
// Both fingerprint to `{a:{...},b:[Circular]}`, so the harness reports these two
|
|
140
|
+
// very different values as equal: an assertion that passes when it should fail.
|
|
141
|
+
const outcome = Effect.runSyncExit(proofExpect(acyclic).toEqual(cyclic))
|
|
142
|
+
expect(Exit.isFailure(outcome)).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('Proof.expect().toEqual() ignores key insertion order', () => {
|
|
146
|
+
// Props built in a different key order than the literal in the proof body are
|
|
147
|
+
// structurally identical, but the fingerprint serialises `Reflect.ownKeys` in
|
|
148
|
+
// insertion order, so the assertion dies.
|
|
149
|
+
const outcome = Effect.runSyncExit(
|
|
150
|
+
proofExpect({ total: 3, label: 'x' }).toEqual({ label: 'x', total: 3 }),
|
|
151
|
+
)
|
|
152
|
+
expect(Exit.isSuccess(outcome)).toBe(true)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// `moveAt` accepts `to === elements.length` — "move it to the end" — but a move keeps
|
|
156
|
+
// the array's length, so the item actually lands at `length - 1`. The index map has to
|
|
157
|
+
// agree with where the value went, or the row's `touched` flag and validation error are
|
|
158
|
+
// parked on an index that does not exist and the next `append` inherits them.
|
|
159
|
+
interface Member {
|
|
160
|
+
readonly name: string
|
|
161
|
+
readonly tags: ReadonlyArray<string>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
it('moving an item to the end carries its metadata to the index it landed on', () => {
|
|
165
|
+
const values = {
|
|
166
|
+
members: [
|
|
167
|
+
{ name: 'a', tags: [] },
|
|
168
|
+
{ name: 'b', tags: [] },
|
|
169
|
+
{ name: 'c', tags: [] },
|
|
170
|
+
],
|
|
171
|
+
} satisfies { readonly members: ReadonlyArray<Member> }
|
|
172
|
+
const state: FormStateShape<{ readonly members: ReadonlyArray<Member> }> = {
|
|
173
|
+
values,
|
|
174
|
+
initialValues: values,
|
|
175
|
+
touched: { 'members[0].name': true },
|
|
176
|
+
errors: { 'members[0].name': 'first is wrong' },
|
|
177
|
+
arrayKeys: {},
|
|
178
|
+
dirtyPaths: [],
|
|
179
|
+
submitCount: 0,
|
|
180
|
+
validationCount: 0,
|
|
181
|
+
lastSubmittedValues: Option.none(),
|
|
182
|
+
}
|
|
183
|
+
const names = (moved: FormStateShape<{ readonly members: ReadonlyArray<Member> }>) =>
|
|
184
|
+
moved.values.members.map((member: Member) => member.name)
|
|
185
|
+
|
|
186
|
+
// Control: the in-range form of the same move places the metadata correctly.
|
|
187
|
+
const inRange = FormState.moveAtPath({ state, path: 'members', from: 0, to: 2 })
|
|
188
|
+
expect(names(inRange)).toEqual(['b', 'c', 'a'])
|
|
189
|
+
expect(inRange.touched['members[2].name']).toBe(true)
|
|
190
|
+
expect(inRange.errors['members[2].name']).toBe('first is wrong')
|
|
191
|
+
|
|
192
|
+
// Subject: `to === length` is the same move, so the metadata must land in the same place.
|
|
193
|
+
const toEnd = FormState.moveAtPath({ state, path: 'members', from: 0, to: 3 })
|
|
194
|
+
expect(names(toEnd)).toEqual(['b', 'c', 'a'])
|
|
195
|
+
expect(toEnd.touched['members[2].name']).toBe(true)
|
|
196
|
+
expect(toEnd.errors['members[2].name']).toBe('first is wrong')
|
|
197
|
+
expect(toEnd.touched['members[3].name']).toBeUndefined()
|
|
198
|
+
})
|
package/src/formState.ts
CHANGED
|
@@ -1,12 +1,7 @@
|
|
|
1
1
|
import { Function as Fn, Option, Record } from 'effect'
|
|
2
|
-
import {
|
|
3
|
-
getNestedValue,
|
|
4
|
-
moveAt,
|
|
5
|
-
recalculateDirtyPaths,
|
|
6
|
-
replaceAt,
|
|
7
|
-
setNestedValue,
|
|
8
|
-
} from './path'
|
|
2
|
+
import { getNestedValue, moveAt, recalculateDirtyPaths, replaceAt, setNestedValue } from './path'
|
|
9
3
|
import type { FormState } from './formTypes'
|
|
4
|
+
import { movedIndexMap, reindexMetadata, removedIndexMap, swappedIndexMap } from './formReindex'
|
|
10
5
|
|
|
11
6
|
const arrayKeyCounter = { current: 0 }
|
|
12
7
|
const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
|
|
@@ -103,18 +98,39 @@ const updateKeys = (
|
|
|
103
98
|
return { ...state.arrayKeys, [path]: transform(existing) }
|
|
104
99
|
}
|
|
105
100
|
|
|
106
|
-
|
|
107
|
-
|
|
101
|
+
// The subtree at `path` is being replaced, so every key describing a row inside it
|
|
102
|
+
// is about to describe nothing. Left behind, the next append folds over the stale
|
|
103
|
+
// array and hands a new row the identity of one the user discarded.
|
|
104
|
+
const withoutSubtree = (
|
|
105
|
+
keys: Readonly<Record<string, ReadonlyArray<string>>>,
|
|
106
|
+
path: string,
|
|
107
|
+
): Record<string, ReadonlyArray<string>> =>
|
|
108
|
+
Record.fromEntries(
|
|
109
|
+
Record.toEntries(keys).filter(
|
|
110
|
+
([key]) => key !== path && !key.startsWith(`${path}[`) && !key.startsWith(`${path}.`),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
export const setAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => ({
|
|
115
|
+
...withValues(input.state, setNestedValue(input.state.values, input.path, input.value)),
|
|
116
|
+
arrayKeys: {
|
|
117
|
+
...withoutSubtree(input.state.arrayKeys, input.path),
|
|
118
|
+
...arrayKeysFor(input.value, input.path),
|
|
119
|
+
},
|
|
120
|
+
})
|
|
108
121
|
|
|
109
122
|
export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> => {
|
|
110
123
|
const elements = currentArray({ source: input.state.values, path: input.path })
|
|
111
124
|
const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
|
|
112
125
|
return {
|
|
113
126
|
...withValues(input.state, nextValues),
|
|
114
|
-
arrayKeys:
|
|
115
|
-
...keys,
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
arrayKeys: {
|
|
128
|
+
...updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [...keys, makeArrayKey()]),
|
|
129
|
+
// Arrays nested inside the appended item need keys of their own — `initialState`
|
|
130
|
+
// seeds every array in the tree, and a nested list without them falls back to a
|
|
131
|
+
// positional key, so its rows change identity the first time one is touched.
|
|
132
|
+
...arrayKeysFor(input.value, `${input.path}[${elements.length}]`),
|
|
133
|
+
},
|
|
118
134
|
}
|
|
119
135
|
}
|
|
120
136
|
|
|
@@ -128,10 +144,19 @@ export const removeAtPath = <Values>(input: RemovePathInput<Values>): FormState<
|
|
|
128
144
|
input.path,
|
|
129
145
|
elements.filter((_, position) => position !== input.index),
|
|
130
146
|
)
|
|
147
|
+
const moved = reindexMetadata({
|
|
148
|
+
state: input.state,
|
|
149
|
+
path: input.path,
|
|
150
|
+
moveIndex: removedIndexMap(input.index),
|
|
151
|
+
})
|
|
131
152
|
return {
|
|
132
153
|
...withValues(input.state, nextValues),
|
|
133
|
-
|
|
134
|
-
|
|
154
|
+
touched: moved.touched,
|
|
155
|
+
errors: moved.errors,
|
|
156
|
+
arrayKeys: updateKeys(
|
|
157
|
+
Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }),
|
|
158
|
+
input.path,
|
|
159
|
+
(keys) => keys.filter((_, position) => position !== input.index),
|
|
135
160
|
),
|
|
136
161
|
}
|
|
137
162
|
}
|
|
@@ -143,10 +168,25 @@ export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Valu
|
|
|
143
168
|
return input.state
|
|
144
169
|
}
|
|
145
170
|
const nextValues = setNestedValue(input.state.values, input.path, next)
|
|
171
|
+
const moved = reindexMetadata({
|
|
172
|
+
state: input.state,
|
|
173
|
+
path: input.path,
|
|
174
|
+
// `moveAt` accepts `to === elements.length` as "move it to the end", but a move
|
|
175
|
+
// keeps the length, so the item lands one slot short of that. The metadata has to
|
|
176
|
+
// follow the value, not the argument.
|
|
177
|
+
moveIndex: movedIndexMap({
|
|
178
|
+
from: input.from,
|
|
179
|
+
to: Math.min(input.to, elements.length - 1),
|
|
180
|
+
}),
|
|
181
|
+
})
|
|
146
182
|
return {
|
|
147
183
|
...withValues(input.state, nextValues),
|
|
148
|
-
|
|
149
|
-
|
|
184
|
+
touched: moved.touched,
|
|
185
|
+
errors: moved.errors,
|
|
186
|
+
arrayKeys: updateKeys(
|
|
187
|
+
Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }),
|
|
188
|
+
input.path,
|
|
189
|
+
(keys) => moveAt(keys, input.from, input.to),
|
|
150
190
|
),
|
|
151
191
|
}
|
|
152
192
|
}
|
|
@@ -168,14 +208,24 @@ export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Valu
|
|
|
168
208
|
elements[input.first],
|
|
169
209
|
)
|
|
170
210
|
const nextValues = setNestedValue(input.state.values, input.path, swapped)
|
|
211
|
+
const moved = reindexMetadata({
|
|
212
|
+
state: input.state,
|
|
213
|
+
path: input.path,
|
|
214
|
+
moveIndex: swappedIndexMap({ first: input.first, second: input.second }),
|
|
215
|
+
})
|
|
171
216
|
return {
|
|
172
217
|
...withValues(input.state, nextValues),
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
)
|
|
218
|
+
touched: moved.touched,
|
|
219
|
+
errors: moved.errors,
|
|
220
|
+
arrayKeys: updateKeys(
|
|
221
|
+
Fn.unsafeCoerce({ ...input.state, arrayKeys: moved.nestedKeys }),
|
|
222
|
+
input.path,
|
|
223
|
+
(keys) =>
|
|
224
|
+
replaceAt(
|
|
225
|
+
replaceAt(keys, input.first, keys[input.second] ?? makeArrayKey()),
|
|
226
|
+
input.second,
|
|
227
|
+
keys[input.first] ?? makeArrayKey(),
|
|
228
|
+
),
|
|
179
229
|
),
|
|
180
230
|
}
|
|
181
231
|
}
|