@playfast/reform-forms 1.2.1 → 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/form.d.ts +12 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +85 -0
- package/dist/form.js.map +1 -0
- package/dist/formMake.d.ts +9 -0
- package/dist/formMake.d.ts.map +1 -0
- package/dist/formMake.js +55 -0
- package/dist/formMake.js.map +1 -0
- 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 +34 -0
- package/dist/formState.d.ts.map +1 -0
- package/dist/formState.js +137 -0
- package/dist/formState.js.map +1 -0
- package/dist/formTypes.d.ts +190 -0
- package/dist/formTypes.d.ts.map +1 -0
- package/dist/formTypes.js +2 -0
- package/dist/formTypes.js.map +1 -0
- package/dist/formView.d.ts +6 -0
- package/dist/formView.d.ts.map +1 -0
- package/dist/formView.js +136 -0
- package/dist/formView.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/path.d.ts +36 -0
- package/dist/path.d.ts.map +1 -0
- package/dist/path.js +113 -0
- package/dist/path.js.map +1 -0
- package/dist/validation.d.ts +12 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +20 -0
- package/dist/validation.js.map +1 -0
- package/package.json +17 -5
- 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
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",
|
|
@@ -21,15 +21,26 @@
|
|
|
21
21
|
"directory": "packages/forms"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
|
+
"dist",
|
|
24
25
|
"src",
|
|
25
26
|
"README.md"
|
|
26
27
|
],
|
|
27
28
|
"type": "module",
|
|
28
29
|
"sideEffects": false,
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
29
32
|
"exports": {
|
|
30
33
|
"./package.json": "./package.json",
|
|
31
|
-
".":
|
|
32
|
-
|
|
34
|
+
".": {
|
|
35
|
+
"playfast-src": "./src/index.ts",
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"default": "./dist/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./*": {
|
|
40
|
+
"playfast-src": "./src/*.ts",
|
|
41
|
+
"types": "./dist/*.d.ts",
|
|
42
|
+
"default": "./dist/*.js"
|
|
43
|
+
}
|
|
33
44
|
},
|
|
34
45
|
"publishConfig": {
|
|
35
46
|
"access": "public"
|
|
@@ -37,12 +48,13 @@
|
|
|
37
48
|
"scripts": {
|
|
38
49
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
39
50
|
"check": "tsc --noEmit",
|
|
40
|
-
"build": "tsc -p tsconfig.build.json",
|
|
51
|
+
"build": "rm -rf dist .tsbuildinfo && tsc -p tsconfig.build.json && bun ../../scripts/fix-esm-extensions.ts dist",
|
|
41
52
|
"test": "vitest run",
|
|
42
53
|
"test:watch": "vitest",
|
|
43
54
|
"coverage": "vitest run --coverage",
|
|
44
55
|
"lint": "oxlint src",
|
|
45
|
-
"lint:fix": "oxlint --fix src"
|
|
56
|
+
"lint:fix": "oxlint --fix src",
|
|
57
|
+
"prepack": "bun run build"
|
|
46
58
|
},
|
|
47
59
|
"peerDependencies": {
|
|
48
60
|
"@playfast/reform": "*",
|
|
@@ -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
|
+
})
|