@zeno-lib/forms 0.0.1 → 0.2.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/package.json +23 -11
- package/src/addons/validation-spinner.tsx +3 -4
- package/src/create-form.tsx +59 -0
- package/src/create-zeno-form.tsx +531 -0
- package/src/fields/checkbox-field.tsx +15 -12
- package/src/fields/checkbox-group-field.tsx +160 -0
- package/src/fields/combobox-field.tsx +13 -14
- package/src/fields/date-picker-field.tsx +18 -15
- package/src/fields/index.ts +5 -0
- package/src/fields/input-field.tsx +12 -13
- package/src/fields/money-field.tsx +123 -0
- package/src/fields/multi-select-field.tsx +193 -0
- package/src/fields/number-field.tsx +1 -2
- package/src/fields/otp-field.tsx +15 -12
- package/src/fields/percentage-field.tsx +73 -0
- package/src/fields/radio-group-field.tsx +12 -13
- package/src/fields/reset-button.tsx +2 -3
- package/src/fields/select-field.tsx +12 -13
- package/src/fields/slider-field.tsx +11 -12
- package/src/fields/submit-button.tsx +3 -4
- package/src/fields/switch-field.tsx +15 -12
- package/src/fields/textarea-field.tsx +12 -13
- package/src/fields/year-field.tsx +56 -0
- package/src/form-dialog.test.tsx +161 -0
- package/src/form-dialog.tsx +253 -0
- package/src/form-element.tsx +51 -0
- package/src/formatted-number-fields.test.tsx +236 -0
- package/src/index.ts +27 -0
- package/src/lib/action-result.ts +61 -0
- package/src/lib/apply-validation-error.test.ts +16 -6
- package/src/lib/apply-validation-error.ts +38 -6
- package/src/lib/formatted-number.test.ts +157 -0
- package/src/lib/formatted-number.ts +307 -0
- package/src/lib/required-indicator.tsx +1 -1
- package/src/lib/schema-defaults.test.ts +1 -2
- package/src/lib/schema-required.test.ts +98 -6
- package/src/lib/schema-required.ts +126 -22
- package/src/lib/submit-action.test-d.ts +83 -0
- package/src/lib/submit-action.ts +178 -0
- package/src/lib/use-form-dialog.ts +184 -0
- package/src/lib/use-formatted-number.ts +235 -0
- package/src/lib/use-is-invalid.ts +4 -4
- package/src/lib/use-rebased-default-values.ts +42 -0
- package/src/lib/use-unsaved-changes-warning.ts +3 -3
- package/src/lib/validation-error.test.ts +1 -1
- package/src/lib/validation-logic.test.ts +1 -1
- package/src/lib/validation-modes.test-d.ts +1 -1
- package/src/lib/validation-modes.test.ts +12 -2
- package/src/lib/validation-modes.ts +3 -1
- package/src/multi-value-fields.test.tsx +116 -0
- package/src/submit-action.test.tsx +421 -0
- package/src/use-app-fields.test-d.ts +8 -25
- package/src/use-app-fields.test.tsx +8 -32
- package/src/use-form.cascade.test.tsx +4 -6
- package/src/use-form.focus-invalid.test.tsx +198 -0
- package/src/use-form.reset-defaults.test.tsx +125 -0
- package/src/use-form.test-d.ts +2 -3
- package/src/use-form.test.tsx +12 -14
- package/src/use-form.unsaved-warning.test.tsx +4 -13
- package/src/use-form.validation-modes.test.tsx +10 -8
- package/src/form.tsx +0 -105
- package/src/use-app-fields.tsx +0 -272
- package/src/use-form.tsx +0 -450
|
@@ -6,15 +6,29 @@
|
|
|
6
6
|
// issue with a `path` we can record. Fields with defaults or `.optional()` stay
|
|
7
7
|
// silent because the schema considers them satisfied.
|
|
8
8
|
//
|
|
9
|
+
// A missing parent hides its children (the schema stops at the missing key), so
|
|
10
|
+
// the probe descends: when an issue says it expected an object or an array
|
|
11
|
+
// (Zod and Valibot both put that on `issue.expected`), the next pass fills that
|
|
12
|
+
// path with `{}` / `[{}]` and validates again. Array rows are probed through
|
|
13
|
+
// index `0` and recorded index-agnostically, so `members[0].name` in the set
|
|
14
|
+
// matches every row's `members[3].name` via `toRequiredPathKey`.
|
|
15
|
+
//
|
|
16
|
+
// Paths use TanStack Form's field-name syntax: dots for object keys, brackets
|
|
17
|
+
// for array indices (`members[0].name`, not `members.0.name`).
|
|
18
|
+
//
|
|
9
19
|
// Limitations:
|
|
10
20
|
// - Async schemas are skipped (returns an empty set). Required-ness is a
|
|
11
21
|
// visual hint; the validator itself still runs at submit time.
|
|
12
|
-
// - Cross-field refinements that fail on
|
|
13
|
-
// intrinsically required. In practice this is the same heuristic
|
|
14
|
-
// would apply by eye.
|
|
22
|
+
// - Cross-field refinements that fail on the probe value may flag fields that
|
|
23
|
+
// aren't intrinsically required. In practice this is the same heuristic
|
|
24
|
+
// users would apply by eye.
|
|
25
|
+
// - Schemas whose issues carry no `expected` hint stop at the top level.
|
|
26
|
+
|
|
27
|
+
type PathPart = PropertyKey | { readonly key: PropertyKey }
|
|
15
28
|
|
|
16
29
|
type StandardIssue = {
|
|
17
|
-
readonly path?:
|
|
30
|
+
readonly path?: readonly PathPart[]
|
|
31
|
+
readonly expected?: unknown
|
|
18
32
|
}
|
|
19
33
|
|
|
20
34
|
type StandardSchemaLike = {
|
|
@@ -28,37 +42,127 @@ type StandardSchemaLike = {
|
|
|
28
42
|
}
|
|
29
43
|
}
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
45
|
+
const MAX_PROBE_DEPTH = 8
|
|
46
|
+
|
|
47
|
+
const INDEX_SEGMENT = /^\d+$/
|
|
48
|
+
|
|
49
|
+
// Matches `[3]` and `.3` index segments in a field name.
|
|
50
|
+
const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g
|
|
51
|
+
|
|
52
|
+
function pathKey(part: PathPart): PropertyKey {
|
|
34
53
|
if (typeof part === "object" && part !== null && "key" in part) {
|
|
35
|
-
return
|
|
54
|
+
return part.key
|
|
36
55
|
}
|
|
37
|
-
return
|
|
56
|
+
return part
|
|
38
57
|
}
|
|
39
58
|
|
|
40
|
-
function
|
|
41
|
-
|
|
59
|
+
function isIndex(key: PropertyKey): boolean {
|
|
60
|
+
return (
|
|
61
|
+
typeof key === "number" ||
|
|
62
|
+
(typeof key === "string" && INDEX_SEGMENT.test(key))
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Render issue path segments as a TanStack field name, with every array index
|
|
67
|
+
// normalised to `0` so one entry covers all rows.
|
|
68
|
+
function joinPath(keys: readonly PropertyKey[]): string {
|
|
69
|
+
let out = ""
|
|
70
|
+
for (const key of keys) {
|
|
71
|
+
if (isIndex(key)) {
|
|
72
|
+
out += "[0]"
|
|
73
|
+
} else {
|
|
74
|
+
out += out === "" ? String(key) : `.${String(key)}`
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalise a field name (`members[3].name` or `members.3.name`) to the key
|
|
82
|
+
* `getRequiredPaths` records (`members[0].name`).
|
|
83
|
+
*/
|
|
84
|
+
function toRequiredPathKey(name: string): string {
|
|
85
|
+
return name.replace(INDEX_IN_NAME, "[0]")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function probeContainer(expected: unknown): unknown {
|
|
89
|
+
if (typeof expected !== "string") {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
const kind = expected.toLowerCase()
|
|
93
|
+
if (kind === "object") {
|
|
94
|
+
return {}
|
|
95
|
+
}
|
|
96
|
+
if (kind === "array") {
|
|
97
|
+
return [{}]
|
|
98
|
+
}
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Write `value` at `keys` inside `root`, only where nothing is set yet.
|
|
103
|
+
function fillAt(
|
|
104
|
+
root: Record<PropertyKey, unknown>,
|
|
105
|
+
keys: readonly PropertyKey[],
|
|
106
|
+
value: unknown
|
|
107
|
+
): boolean {
|
|
108
|
+
let node: Record<PropertyKey, unknown> = root
|
|
109
|
+
for (const key of keys.slice(0, -1)) {
|
|
110
|
+
const next = node[key]
|
|
111
|
+
if (typeof next !== "object" || next === null) {
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
node = next as Record<PropertyKey, unknown>
|
|
115
|
+
}
|
|
116
|
+
const last = keys.at(-1) as PropertyKey
|
|
117
|
+
if (node[last] !== undefined) {
|
|
118
|
+
return false
|
|
119
|
+
}
|
|
120
|
+
node[last] = value
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateSync(
|
|
125
|
+
schema: StandardSchemaLike,
|
|
126
|
+
value: unknown
|
|
127
|
+
): readonly StandardIssue[] | undefined {
|
|
42
128
|
let result: ReturnType<StandardSchemaLike["~standard"]["validate"]>
|
|
43
129
|
try {
|
|
44
|
-
result = schema["~standard"].validate(
|
|
130
|
+
result = schema["~standard"].validate(value)
|
|
45
131
|
} catch {
|
|
46
|
-
return
|
|
132
|
+
return
|
|
47
133
|
}
|
|
48
134
|
if (result instanceof Promise) {
|
|
49
|
-
return
|
|
50
|
-
}
|
|
51
|
-
if (!("issues" in result && result.issues)) {
|
|
52
|
-
return required
|
|
135
|
+
return
|
|
53
136
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
137
|
+
return "issues" in result && result.issues ? result.issues : []
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
141
|
+
const required = new Set<string>()
|
|
142
|
+
const probe: Record<PropertyKey, unknown> = {}
|
|
143
|
+
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
|
|
144
|
+
const issues = validateSync(schema, probe)
|
|
145
|
+
if (!issues) {
|
|
146
|
+
return required
|
|
147
|
+
}
|
|
148
|
+
let descended = false
|
|
149
|
+
for (const issue of issues) {
|
|
150
|
+
const keys = issue.path?.map(pathKey) ?? []
|
|
151
|
+
if (keys.length === 0) {
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
required.add(joinPath(keys))
|
|
155
|
+
const container = probeContainer(issue.expected)
|
|
156
|
+
if (container !== undefined && fillAt(probe, keys, container)) {
|
|
157
|
+
descended = true
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!descended) {
|
|
161
|
+
break
|
|
58
162
|
}
|
|
59
163
|
}
|
|
60
164
|
return required
|
|
61
165
|
}
|
|
62
166
|
|
|
63
167
|
export type { StandardSchemaLike }
|
|
64
|
-
export { getRequiredPaths }
|
|
168
|
+
export { getRequiredPaths, toRequiredPathKey }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { expectTypeOf, test } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import { useForm } from "../create-form"
|
|
4
|
+
import type { ActionResult } from "./action-result"
|
|
5
|
+
import { submitAction } from "./submit-action"
|
|
6
|
+
|
|
7
|
+
const schema = z.object({
|
|
8
|
+
name: z.string(),
|
|
9
|
+
percentage: z.string().transform(Number),
|
|
10
|
+
})
|
|
11
|
+
type Values = z.input<typeof schema>
|
|
12
|
+
|
|
13
|
+
// What `defineFormAction` in `@zeno-lib/db/next` produces, restated here so
|
|
14
|
+
// the structural contract is pinned without depending on that package.
|
|
15
|
+
type ServerActionError = {
|
|
16
|
+
readonly fieldErrors: Record<string, string[]>
|
|
17
|
+
readonly formErrors: string[]
|
|
18
|
+
}
|
|
19
|
+
type ServerActionResult<T> =
|
|
20
|
+
| { readonly ok: true; readonly data: T }
|
|
21
|
+
| { readonly ok: false; readonly error: ServerActionError }
|
|
22
|
+
|
|
23
|
+
declare function saveShare(input: {
|
|
24
|
+
name: string
|
|
25
|
+
percentage: number
|
|
26
|
+
}): Promise<ServerActionResult<{ id: number }>>
|
|
27
|
+
declare function saveRaw(input: Values): Promise<ServerActionResult<Values>>
|
|
28
|
+
|
|
29
|
+
test("infers the data type from the action, with no explicit generics", () => {
|
|
30
|
+
useForm({
|
|
31
|
+
defaultValues: { name: "", percentage: "" },
|
|
32
|
+
onSubmit: async (submit) => {
|
|
33
|
+
const result = await submitAction(submit, saveShare, { schema })
|
|
34
|
+
expectTypeOf(result).toEqualTypeOf<ActionResult<{ id: number }>>()
|
|
35
|
+
if (result.ok) {
|
|
36
|
+
expectTypeOf(result.data).toEqualTypeOf<{ id: number }>()
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test("without a schema the action must accept the form values", () => {
|
|
43
|
+
useForm({
|
|
44
|
+
defaultValues: { name: "", percentage: "" },
|
|
45
|
+
onSubmit: (submit) => submitAction(submit, saveRaw, { reset: true }),
|
|
46
|
+
})
|
|
47
|
+
useForm({
|
|
48
|
+
defaultValues: { name: "", percentage: "" },
|
|
49
|
+
// @ts-expect-error: the action wants `percentage: number`; pass `schema` to transform.
|
|
50
|
+
onSubmit: (submit) => submitAction(submit, saveShare),
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("with a schema the action receives the schema output", () => {
|
|
55
|
+
useForm({
|
|
56
|
+
defaultValues: { name: "", percentage: "" },
|
|
57
|
+
onSubmit: (submit) =>
|
|
58
|
+
// @ts-expect-error: the schema outputs `percentage: number`, the action wants a string.
|
|
59
|
+
submitAction(submit, saveRaw, { schema }),
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("reset: true is only offered when the data has the form's shape", () => {
|
|
64
|
+
useForm({
|
|
65
|
+
defaultValues: { name: "", percentage: "" },
|
|
66
|
+
onSubmit: (submit) =>
|
|
67
|
+
// @ts-expect-error: `{ id: number }` is not the form's values.
|
|
68
|
+
submitAction(submit, saveShare, { reset: true, schema }),
|
|
69
|
+
})
|
|
70
|
+
useForm({
|
|
71
|
+
defaultValues: { name: "", percentage: "" },
|
|
72
|
+
onSubmit: (submit) =>
|
|
73
|
+
submitAction(submit, saveShare, { reset: "values", schema }),
|
|
74
|
+
})
|
|
75
|
+
useForm({
|
|
76
|
+
defaultValues: { name: "", percentage: "" },
|
|
77
|
+
onSubmit: (submit) =>
|
|
78
|
+
submitAction(submit, saveShare, {
|
|
79
|
+
reset: (data) => ({ name: String(data.id), percentage: "" }),
|
|
80
|
+
schema,
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { AnyFormApi } from "@tanstack/react-form"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
type ActionError,
|
|
5
|
+
type ActionIssue,
|
|
6
|
+
type ActionResult,
|
|
7
|
+
toActionError,
|
|
8
|
+
} from "./action-result"
|
|
9
|
+
import { applyValidationError } from "./apply-validation-error"
|
|
10
|
+
import { ValidationError } from "./validation-error"
|
|
11
|
+
|
|
12
|
+
// Write an action's failure onto the form. Field messages go through
|
|
13
|
+
// `applyValidationError`, so they live in `errorMap.onChange` and clear the
|
|
14
|
+
// moment the user edits that field (other fields keep theirs). A message
|
|
15
|
+
// keyed by a name no mounted field registered would be invisible yet still
|
|
16
|
+
// mark the form invalid, so it is folded into the form-level message instead,
|
|
17
|
+
// next to `formErrors` (one message per line).
|
|
18
|
+
//
|
|
19
|
+
// Why not `setErrorMap({ onServer: { form, fields } })`: the default
|
|
20
|
+
// `blur-then-change` logic never clears `onServer` on an edit, only on the
|
|
21
|
+
// next submit, and TanStack's `defaultValidationLogic` clears every field's
|
|
22
|
+
// `onServer` error on any single edit. Neither matches "clears when this field
|
|
23
|
+
// changes".
|
|
24
|
+
function applyActionError(formApi: FormApiLike, error: ActionError): void {
|
|
25
|
+
const fields: Record<string, readonly string[]> = {}
|
|
26
|
+
const formErrors = [...error.formErrors]
|
|
27
|
+
const fieldInfo = formApi.fieldInfo as Record<
|
|
28
|
+
string,
|
|
29
|
+
{ instance?: unknown } | undefined
|
|
30
|
+
>
|
|
31
|
+
for (const [name, messages] of Object.entries(error.fieldErrors)) {
|
|
32
|
+
if (messages.length === 0) {
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
if (fieldInfo[name]?.instance) {
|
|
36
|
+
fields[name] = messages
|
|
37
|
+
} else {
|
|
38
|
+
formErrors.push(...messages)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
applyValidationError(
|
|
42
|
+
formApi as AnyFormApi,
|
|
43
|
+
new ValidationError(
|
|
44
|
+
fields,
|
|
45
|
+
formErrors.length > 0 ? { formError: formErrors.join("\n") } : {}
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type SchemaResult<TOutput> =
|
|
51
|
+
| { readonly value: TOutput; readonly issues?: undefined }
|
|
52
|
+
| { readonly issues: readonly ActionIssue[] }
|
|
53
|
+
|
|
54
|
+
// The Standard Schema slice `submitAction` reads: `validate` (never throws)
|
|
55
|
+
// and the output type. Zod 4, Valibot and ArkType schemas all fit.
|
|
56
|
+
type SubmitActionSchema<TOutput> = {
|
|
57
|
+
readonly "~standard": {
|
|
58
|
+
readonly validate: (
|
|
59
|
+
value: unknown
|
|
60
|
+
) => SchemaResult<TOutput> | Promise<SchemaResult<TOutput>>
|
|
61
|
+
readonly types?:
|
|
62
|
+
| { readonly input: unknown; readonly output: TOutput }
|
|
63
|
+
| undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The members of a form API the helpers touch. Typed as a slice rather than
|
|
68
|
+
// `AnyFormApi` so a concrete `FormApi<Values, …, never>` (what `onSubmit`
|
|
69
|
+
// receives) is assignable without a cast.
|
|
70
|
+
type FormApiLike = Pick<
|
|
71
|
+
AnyFormApi,
|
|
72
|
+
| "fieldInfo"
|
|
73
|
+
| "getFieldMeta"
|
|
74
|
+
| "getFieldValue"
|
|
75
|
+
| "setErrorMap"
|
|
76
|
+
| "setFieldMeta"
|
|
77
|
+
| "store"
|
|
78
|
+
>
|
|
79
|
+
|
|
80
|
+
type SubmitProps<TValues> = {
|
|
81
|
+
readonly value: TValues
|
|
82
|
+
readonly formApi: FormApiLike & {
|
|
83
|
+
reset: (values?: TValues, opts?: { keepDefaultValues?: boolean }) => void
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type Action<TInput, TData> = (input: TInput) => Promise<ActionResult<TData>>
|
|
88
|
+
|
|
89
|
+
// `true` rebases the form on the returned data (only offered when the data
|
|
90
|
+
// has the form's shape), `"values"` on the values just submitted, and a
|
|
91
|
+
// function on whatever it maps the data to.
|
|
92
|
+
type ResetOption<TValues, TData> =
|
|
93
|
+
| "values"
|
|
94
|
+
| ((data: TData) => TValues)
|
|
95
|
+
| ([TData] extends [TValues] ? true : never)
|
|
96
|
+
|
|
97
|
+
type SubmitActionOptions<TValues, TData> = {
|
|
98
|
+
/**
|
|
99
|
+
* After a successful action, `formApi.reset(...)` so the saved values
|
|
100
|
+
* become the pristine baseline (clears `isDirty` and the unsaved-changes
|
|
101
|
+
* warning). Off by default.
|
|
102
|
+
*/
|
|
103
|
+
reset?: ResetOption<TValues, TData>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type SubmitActionSchemaOptions<TValues, TOutput, TData> = SubmitActionOptions<
|
|
107
|
+
TValues,
|
|
108
|
+
TData
|
|
109
|
+
> & {
|
|
110
|
+
/**
|
|
111
|
+
* Parsed on the client before the call: `onSubmit` receives the form's
|
|
112
|
+
* input values, and the action gets the schema's output. A failure is
|
|
113
|
+
* applied to the form and the action is never called.
|
|
114
|
+
*/
|
|
115
|
+
schema: SubmitActionSchema<TOutput>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Call a server action from `onSubmit` and route its result back into the
|
|
120
|
+
* form. Pass `onSubmit`'s own argument straight through:
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* onSubmit: (submit) => submitAction(submit, saveProfile, { reset: true, schema })
|
|
124
|
+
* ```
|
|
125
|
+
*
|
|
126
|
+
* On `{ ok: false }` the field errors land on their fields (array paths like
|
|
127
|
+
* `owners[0].percentage` included) and clear as each field is edited. On
|
|
128
|
+
* `{ ok: true }` the form is optionally reset. Either way the result is
|
|
129
|
+
* returned, so the caller can redirect or toast on `result.ok`. A thrown
|
|
130
|
+
* error (network, auth, a bug) propagates unchanged.
|
|
131
|
+
*/
|
|
132
|
+
function submitAction<TValues, TOutput, TData>(
|
|
133
|
+
props: SubmitProps<TValues>,
|
|
134
|
+
action: Action<TOutput, TData>,
|
|
135
|
+
options: SubmitActionSchemaOptions<TValues, TOutput, TData>
|
|
136
|
+
): Promise<ActionResult<TData>>
|
|
137
|
+
function submitAction<TValues, TData>(
|
|
138
|
+
props: SubmitProps<TValues>,
|
|
139
|
+
action: Action<NoInfer<TValues>, TData>,
|
|
140
|
+
options?: SubmitActionOptions<TValues, TData>
|
|
141
|
+
): Promise<ActionResult<TData>>
|
|
142
|
+
async function submitAction(
|
|
143
|
+
props: SubmitProps<unknown>,
|
|
144
|
+
action: Action<unknown, unknown>,
|
|
145
|
+
options: { reset?: unknown; schema?: SubmitActionSchema<unknown> } = {}
|
|
146
|
+
): Promise<ActionResult<unknown>> {
|
|
147
|
+
const { formApi, value } = props
|
|
148
|
+
let input: unknown = value
|
|
149
|
+
|
|
150
|
+
if (options.schema) {
|
|
151
|
+
const parsed = await options.schema["~standard"].validate(value)
|
|
152
|
+
if (parsed.issues) {
|
|
153
|
+
const error = toActionError(parsed.issues)
|
|
154
|
+
applyActionError(formApi, error)
|
|
155
|
+
return { error, ok: false }
|
|
156
|
+
}
|
|
157
|
+
input = parsed.value
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const result = await action(input)
|
|
161
|
+
if (!result.ok) {
|
|
162
|
+
applyActionError(formApi, result.error)
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const { reset } = options
|
|
167
|
+
if (reset === true) {
|
|
168
|
+
formApi.reset(result.data)
|
|
169
|
+
} else if (reset === "values") {
|
|
170
|
+
formApi.reset(value)
|
|
171
|
+
} else if (typeof reset === "function") {
|
|
172
|
+
formApi.reset((reset as (data: unknown) => unknown)(result.data))
|
|
173
|
+
}
|
|
174
|
+
return result
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type { FormApiLike, SubmitActionOptions, SubmitActionSchema }
|
|
178
|
+
export { applyActionError, submitAction }
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react"
|
|
4
|
+
|
|
5
|
+
type FormDialogOpenOptions<TValues> = {
|
|
6
|
+
/**
|
|
7
|
+
* Values to edit in this session. Omit to start from the hook's
|
|
8
|
+
* `defaultValues` (a blank "create" form).
|
|
9
|
+
*/
|
|
10
|
+
defaultValues?: TValues
|
|
11
|
+
/** Field `name` to focus once the dialog opens, e.g. `"email"`. */
|
|
12
|
+
focus?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type UseFormDialogOptions<TValues> = {
|
|
16
|
+
/** Values a session starts from when `open()` gets none. */
|
|
17
|
+
defaultValues?: TValues
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type FormDialogController<TValues> = {
|
|
21
|
+
/** Whether the dialog is open. */
|
|
22
|
+
isOpen: boolean
|
|
23
|
+
/** Open a new editing session, optionally with its own values and focus. */
|
|
24
|
+
open: (options?: FormDialogOpenOptions<TValues>) => void
|
|
25
|
+
/**
|
|
26
|
+
* Close without the unsaved-changes prompt. Close affordances inside the
|
|
27
|
+
* dialog (Cancel, ×, Escape, outside press) go through the guard instead.
|
|
28
|
+
*/
|
|
29
|
+
close: () => void
|
|
30
|
+
/**
|
|
31
|
+
* The current session's defaults. Pass them to `useForm({ defaultValues })`
|
|
32
|
+
* so the form's defaults follow the session (see the pitfall below).
|
|
33
|
+
*/
|
|
34
|
+
defaultValues: TValues | undefined
|
|
35
|
+
/** Field to focus when the current session opened. */
|
|
36
|
+
focus: string | undefined
|
|
37
|
+
/** Increments on every `open()`; lets the dialog reset once per session. */
|
|
38
|
+
session: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type SessionState<TValues> = {
|
|
42
|
+
isOpen: boolean
|
|
43
|
+
defaultValues: TValues | undefined
|
|
44
|
+
focus: string | undefined
|
|
45
|
+
session: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open/close state for a dialog-hosted form, one "session" per `open()`.
|
|
50
|
+
*
|
|
51
|
+
* Why the hook owns the defaults: TanStack Form ignores new `defaultValues`
|
|
52
|
+
* once the form is touched, and a `form.reset(values)` is undone on the next
|
|
53
|
+
* render when `useForm` still receives the old `defaultValues` (the form is
|
|
54
|
+
* untouched again after the reset, so TanStack re-applies its options).
|
|
55
|
+
* Passing `dialog.defaultValues` to `useForm` keeps the two in agreement;
|
|
56
|
+
* the `FormDialog` component then calls `form.reset(defaults)` at the start
|
|
57
|
+
* of every session.
|
|
58
|
+
*/
|
|
59
|
+
function useFormDialog<TValues>(
|
|
60
|
+
options: UseFormDialogOptions<TValues> = {}
|
|
61
|
+
): FormDialogController<TValues> {
|
|
62
|
+
const latestDefaults = useRef(options.defaultValues)
|
|
63
|
+
latestDefaults.current = options.defaultValues
|
|
64
|
+
|
|
65
|
+
const [state, setState] = useState<SessionState<TValues>>(() => ({
|
|
66
|
+
defaultValues: options.defaultValues,
|
|
67
|
+
focus: undefined,
|
|
68
|
+
isOpen: false,
|
|
69
|
+
session: 0,
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
const open = useCallback((openOptions?: FormDialogOpenOptions<TValues>) => {
|
|
73
|
+
setState((previous) => ({
|
|
74
|
+
defaultValues: openOptions?.defaultValues ?? latestDefaults.current,
|
|
75
|
+
focus: openOptions?.focus,
|
|
76
|
+
isOpen: true,
|
|
77
|
+
session: previous.session + 1,
|
|
78
|
+
}))
|
|
79
|
+
}, [])
|
|
80
|
+
|
|
81
|
+
const close = useCallback(() => {
|
|
82
|
+
setState((previous) =>
|
|
83
|
+
previous.isOpen ? { ...previous, isOpen: false } : previous
|
|
84
|
+
)
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
close,
|
|
89
|
+
defaultValues: state.defaultValues,
|
|
90
|
+
focus: state.focus,
|
|
91
|
+
isOpen: state.isOpen,
|
|
92
|
+
open,
|
|
93
|
+
session: state.session,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
type UseLeaveGuardOptions = {
|
|
98
|
+
/** Whether leaving now would lose changes. */
|
|
99
|
+
hasUnsavedChanges: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
type LeaveGuard = {
|
|
103
|
+
/**
|
|
104
|
+
* Run `proceed` now when there's nothing to lose, otherwise hold it until
|
|
105
|
+
* the user confirms. A newer request replaces a held one.
|
|
106
|
+
*/
|
|
107
|
+
requestLeave: (proceed: () => void) => void
|
|
108
|
+
/** Whether a leave is waiting on the user's answer (show your confirm UI). */
|
|
109
|
+
isConfirming: boolean
|
|
110
|
+
/** The user chose to discard: run the held leave. */
|
|
111
|
+
confirmLeave: () => void
|
|
112
|
+
/** The user chose to stay: drop the held leave. */
|
|
113
|
+
cancelLeave: () => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
117
|
+
function useLeaveGuard({
|
|
118
|
+
hasUnsavedChanges,
|
|
119
|
+
}: UseLeaveGuardOptions): LeaveGuard {
|
|
120
|
+
const pending = useRef<(() => void) | null>(null)
|
|
121
|
+
const [isConfirming, setIsConfirming] = useState(false)
|
|
122
|
+
|
|
123
|
+
const requestLeave = useCallback(
|
|
124
|
+
(proceed: () => void) => {
|
|
125
|
+
if (!hasUnsavedChanges) {
|
|
126
|
+
proceed()
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
pending.current = proceed
|
|
130
|
+
setIsConfirming(true)
|
|
131
|
+
},
|
|
132
|
+
[hasUnsavedChanges]
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const confirmLeave = useCallback(() => {
|
|
136
|
+
const proceed = pending.current
|
|
137
|
+
pending.current = null
|
|
138
|
+
setIsConfirming(false)
|
|
139
|
+
proceed?.()
|
|
140
|
+
}, [])
|
|
141
|
+
|
|
142
|
+
const cancelLeave = useCallback(() => {
|
|
143
|
+
pending.current = null
|
|
144
|
+
setIsConfirming(false)
|
|
145
|
+
}, [])
|
|
146
|
+
|
|
147
|
+
return { cancelLeave, confirmLeave, isConfirming, requestLeave }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const FOCUSABLE =
|
|
151
|
+
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
152
|
+
|
|
153
|
+
function attributeSelector(attribute: string, value: string): string {
|
|
154
|
+
return `[${attribute}="${value.replace(/["\\]/g, "\\$&")}"]`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Find the element to focus for field `name` inside `root`: the control with
|
|
159
|
+
* `id={name}` (what the shipped fields render), else the first focusable
|
|
160
|
+
* element inside the field root marked `data-field={name}`.
|
|
161
|
+
*/
|
|
162
|
+
function findFieldElement(
|
|
163
|
+
root: ParentNode | null | undefined,
|
|
164
|
+
name: string
|
|
165
|
+
): HTMLElement | null {
|
|
166
|
+
if (!root) {
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
const byId = root.querySelector<HTMLElement>(attributeSelector("id", name))
|
|
170
|
+
if (byId) {
|
|
171
|
+
return byId
|
|
172
|
+
}
|
|
173
|
+
const fieldRoot = root.querySelector(attributeSelector("data-field", name))
|
|
174
|
+
return fieldRoot?.querySelector<HTMLElement>(FOCUSABLE) ?? null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type {
|
|
178
|
+
FormDialogController,
|
|
179
|
+
FormDialogOpenOptions,
|
|
180
|
+
LeaveGuard,
|
|
181
|
+
UseFormDialogOptions,
|
|
182
|
+
UseLeaveGuardOptions,
|
|
183
|
+
}
|
|
184
|
+
export { findFieldElement, useFormDialog, useLeaveGuard }
|