[READ-ONLY] Mirror of https://github.com/probablykasper/niceform. Convenient form validation and typing for SvelteKit
1import { fail, type RequestEvent } from '@sveltejs/kit'
2import {
3 setError,
4 superValidate,
5 type FormPathLeavesWithErrors,
6 type Infer,
7 type ValidationErrors,
8} from 'sveltekit-superforms'
9import { zod as zod_adapter } from 'sveltekit-superforms/adapters'
10
11export type FormInput = RequestEvent | Request | FormData | URLSearchParams | null | undefined
12export type ZodSchema = Parameters<typeof zod_adapter>[0]
13
14// For AddFormProps to get name attribute type validation, and to read errors
15export type PartialNiceForm = {
16 data?: Record<string, unknown>
17 errors?: Record<string, string[]>
18} | null
19
20export type AddFormProps<
21 // The unrelated props
22 T extends object,
23 // Partially defined form property. Allow null because SvelteKit's form prop is nullable
24 F extends PartialNiceForm | null,
25> =
26 // Props without form
27 | (Omit<T, 'form'> & {
28 form?: undefined
29 label?: string
30 })
31 // Props with form
32 | (Omit<T, 'form'> & {
33 // Allow null because SvelteKit's form prop is nullable
34 form: F
35 // Get the full combined `data` property of the form. This also gets around cases where a form action returns its own custom data, in which case `data` would not be defined
36 name: F extends { data: Record<string, unknown> } ? keyof F['data'] : never
37 label?: string
38 })
39
40let id_n = 0
41export function sequential_num() {
42 return id_n++
43}
44
45export type NiceForm<Z extends ZodSchema> = {
46 valid: boolean
47 data: Infer<Z>
48 errors: ValidationErrors<Infer<Z>>
49 message?: string
50 single_error_message?: string
51}
52type NiceFormServer<Z extends ZodSchema> = NiceForm<Z> & {
53 form: NiceForm<Z>
54 /** Adds the form as a property to the data object */
55 fail: <D extends Record<string, unknown> | string>(
56 status: number,
57 data?: D,
58 ) => D extends string ? NiceForm<Z> & { message: D } : NiceForm<Z> & D
59 success: <D extends Record<string, unknown> | string>(
60 data?: D,
61 ) => D extends string ? NiceForm<Z> & { message: D } : NiceForm<Z> & D
62 set_error: (
63 name: FormPathLeavesWithErrors<Infer<Z>>,
64 message: string,
65 options?: Parameters<typeof setError>[3],
66 ) => NiceForm<Z>
67}
68
69let error_map: Record<string, () => string> = {}
70
71export function set_error_map<K extends string>(map: Record<K, () => string>) {
72 error_map = map
73 return Object.fromEntries(Object.entries(map).map(([key]) => [key, key])) as Record<K, K>
74}
75
76async function zod<Z extends ZodSchema, D extends FormInput | undefined>(
77 schema: Z,
78 data?: D,
79): Promise<D extends object ? NiceFormServer<Z> : NiceForm<Z>> {
80 if (data) {
81 const sf_form = await superValidate(data, zod_adapter(schema), { strict: true )
82 let translated_single_error_message = ''
83 let single_error_message = ''
84 for (const [key, field_errors] of Object.entries(sf_form.errors)) {
85 for (let i = 0; i < field_errors.length; i++) {
86 const msg = field_errors[i]
87 if (error_map[msg]) {
88 field_errors[i] = error_map[msg]()
89 translated_single_error_message += `${key}: ${field_errors[i]}\n`
90 }
91 single_error_message += `${key}: ${field_errors[i]}\n`
92 }
93 }
94 const form: NiceForm<Z> = {
95 valid: sf_form.valid,
96 data: sf_form.data,
97 errors: sf_form.errors,
98 single_error_message: translated_single_error_message || single_error_message || undefined,
99 }
100 // eslint-disable-next-line @typescript-eslint/no-explicit-any
101 return <any>{
102 ...form,
103 form,
104 fail(status: number, data: Record<string, unknown> | string = {}) {
105 if (typeof data === 'string') {
106 return fail(status, { ...form, message: data })
107 }
108 return fail(status, { ...form, ...data })
109 },
110 success(data: Record<string, unknown> | string = {}) {
111 if (typeof data === 'string') {
112 return { ...form, message: data }
113 }
114 return { ...form, ...data }
115 },
116 set_error(
117 name: FormPathLeavesWithErrors<Infer<Z, 'zod'>>,
118 message: string,
119 options?: Parameters<typeof setError>[3],
120 ) {
121 const sf_form2 = setError(sf_form, name, message, options)
122 return fail(sf_form2.status, { ...form })
123 },
124 }
125 } else {
126 const sf_form = await superValidate(zod_adapter(schema), { strict: true })
127 // eslint-disable-next-line @typescript-eslint/no-explicit-any
128 return <any>{
129 valid: sf_form.valid,
130 data: sf_form.data,
131 errors: sf_form.errors,
132 }
133 }
134}
135
136export const nice_form = {
137 zod,
138}
139
140export function auto_snapshot() {
141 let container_el: HTMLElement | undefined
142 const selector = 'input[data-snapshot],textarea[data-snapshot],select[data-snapshot]'
143 return {
144 capture() {
145 if (!container_el?.querySelectorAll) {
146 throw new Error('auto_snapshot: container element not set')
147 }
148 const elements = container_el.querySelectorAll(selector)
149 const values: Record<string, string> = {}
150 for (const element of elements) {
151 if (
152 element instanceof HTMLInputElement ||
153 element instanceof HTMLSelectElement ||
154 element instanceof HTMLTextAreaElement
155 ) {
156 values[element.name] = element.value
157 }
158 }
159 return values
160 },
161 restore(value: Record<string, string>) {
162 if (!container_el?.querySelectorAll) {
163 throw new Error('auto_snapshot: container element not set')
164 }
165 const elements = container_el.querySelectorAll(selector)
166 for (const element of elements) {
167 if (
168 (element instanceof HTMLInputElement ||
169 element instanceof HTMLSelectElement ||
170 element instanceof HTMLTextAreaElement) &&
171 value[element.name]
172 ) {
173 element.value = value[element.name]
174 // Trigger Svelte state to updates
175 element.dispatchEvent(new Event('input'))
176 }
177 }
178 },
179 container(node: HTMLElement) {
180 container_el = node
181 },
182 }
183}