8000 feat: initial draft implementation of the module · manchenkoff/nuxt-sanctum-precognition@a19eb4b · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Commit a19eb4b

Browse files
committed
feat: initial draft implementation of the module
1 parent f2ea537 commit a19eb4b

File tree

4 files changed

+359
-2
lines changed

4< D7AE !-- --> files changed

+359
-2
lines changed

src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { ModuleOptions } from './runtime/types'
22

33
export const defaultModuleOptions: ModuleOptions = {
4+
validateFiles: false,
5+
validationTimeout: 500,
46
logLevel: 3,
57
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import type { ModuleOptions } from '../types'
2+
import { useNuxtApp } from '#imports'
3+
4+
export const usePrecognitionConfig = (): ModuleOptions => {
5+
return useNuxtApp().$config.public.precognition as ModuleOptions
6+
}
Lines changed: 298 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,299 @@
1-
export const usePrecognitionForm = () => {
2-
return undefined
1+
import { computed, reactive, type Ref, ref, toRaw } from 'vue'
2+
import { cloneDeep, debounce, isEqual } from 'lodash'
3+
import type {
4+
Payload,
5+
PayloadData,
6+
PayloadErrors,
7+
PayloadKey,
8+
PrecognitionForm,
9+
RequestMethod,
10+
ResponseType,
11+
ValidationOptions,
12+
} from '../types'
13+
import { usePrecognitionConfig } from '../composables/usePrecognitionConfig'
14+
import { useSanctumClient } from '#imports'
15+
16+
const
17+
PRECOGNITION_HEADER = 'Precognition',
18+
PRECOGNITION_ONLY_HEADER = 'Precognition-Validate-Only',
19+
PRECOGNITION_SUCCESS_HEADER = 'Precognition-Success',
20+
CONTENT_TYPE_HEADER = 'Content-Type'
21+
22+
export const usePrecognitionForm = <T extends Payload>(
23+
method: RequestMethod,
24+
url: string,
25+
payload: T,
26+
): PrecognitionForm<T> => {
27+
const _originalPayload: T = cloneDeep(payload)
28+
const _originalPayloadKeys: PayloadKey<T>[] = Object.keys(_originalPayload)
29+
30+
const _payload = reactive<T>(payload)
31+
32+
const _validated = ref<PayloadKey<T>[]>([]) as Ref<PayloadKey<T>[]>
33+
const _touched = ref<PayloadKey<T>[]>([]) as Ref<PayloadKey<T>[]>
34+
35+
const _config = usePrecognitionConfig()
36+
const _client = useSanctumClient()
37+
38+
const isFile = (value: unknown): boolean => (typeof File !== 'undefined' && value instanceof File)
39+
|| value instanceof Blob
40+
|| (typeof FileList !== 'undefined' && value instanceof FileList && value.length > 0)
41+
42+
const hasFiles = (data: unknown): boolean => isFile(data)
43+
|| (typeof data === 'object' && data !== null && Object.values(data).some(value => hasFiles(value)))
44+
45+
const clearFiles = <T extends Payload>(data: T): T => {
46+
let newData = { ...data }
47+
48+
Object
49+
.keys(newData)
50+
.forEach((name) => {
51+
const value = newData[name]
52+
53+
if (value === null) {
54+
return
55+
}
56+
57+
// drop the file from the payload
58+
if (isFile(value)) {
59+
const { [name]: _, ...fields } = newData
60+
newData = fields as T
61+
62+
return
63+
}
64+
65+
// recursively clear files from nested arrays
66+
if (Array.isArray(value)) {
67+
// @ts-expect-error: assign property value on reactive object
68+
newData[name] = Object.values(clearFiles({ ...value }))
69+
70+
return
71+
}
72+
73+
// recursively clear files from nested objects
74+
if (typeof value === 'object') {
75+
// @ts-expect-error: assign property value on reactive object
76+
newData[name] = clearFiles(newData[name])
77+
78+
return
79+
}
80+
})
81+
82+
return newData
83+
}
84+
85+
async function process(params: { precognitive: boolean, fields: PayloadKey<T>[], options?: ValidationOptions } = { precognitive: false, fields: [], options: {} }): Promise<ResponseType> {
86+
let payload = form.data()
87+
88+
const headers = new Headers()
89+
const includeFiles = params.options?.validateFiles ?? _config.validateFiles
90+
91+
if (hasFiles(payload)) {
92+
if (includeFiles) {
93+
headers.set(CONTENT_TYPE_HEADER, 'multipart/form-data')
94+
}
95+
else {
96+
console.warn('Files were detected in the payload but will not be sent. '
97+
+ 'To include files, set `validateFiles` to `true` in the validation options or module config.')
98+
payload = clearFiles(payload)
99+
}
100+
}
101+
102+
if (params.precognitive) {
103+
headers.set(PRECOGNITION_HEADER, 'true')
104+
105+
if (params.fields.length > 0 && params.fields.length !== _originalPayloadKeys.length) {
106+
headers.set(PRECOGNITION_ONLY_HEADER, params.fields.join())
107+
}
108+
}
109+
110+
const response = await _client.raw(url, {
111+
method: method,
112+
...(
113+
['get', 'delete'].includes(method)
114+
? { query: payload } // GET, DELETE
115+
: { body: payload } // POST, PUT, PATCH
116+
),
117+
headers,
118+
ignoreResponseError: true,
119+
})
120+
121+
if (params.precognitive) {
122+
if (response.headers.get(PRECOGNITION_HEADER) !== 'true') {
123+
console.warn('Did not receive a Precognition response. Ensure you have the Precognition middleware in place for the route.')
124+
}
125+
126+
if (response.status === 204 && response.headers.get(PRECOGNITION_SUCCESS_HEADER) === 'true') {
127+
if (params.fields.length > 0) {
128+
const validatedNew = new Set(_validated.value)
129+
130+
params.fields.forEach((field) => {
131+
validatedNew.add(field)
132+
form.forgetError(field)
133+
})
134+
135+
_validated.value = [...validatedNew]
136+
}
137+
else {
138+
_validated.value = _originalPayloadKeys
139+
form.setErrors({})
140+
}
141+
}
142+
}
143+
144+
if (response.ok) {
145+
return Promise.resolve(response)
146+
}
147+
148+
if (response.status === 422) {
149+
form.setErrors(response._data.errors as PayloadErrors<T>)
150+
}
151+
152+
return Promise.reject(response)
153+
}
154+
155+
const form: PrecognitionForm<T> = {
156+
fields: _payload,
157+
errors: ref<PayloadErrors<T>>({}) as Ref<PayloadErrors<T>>,
158+
159+
processing: ref(false),
160+
validating: ref(false),
161+
hasErrors: computed(() => Object.keys(form.errors.value).length > 0),
162+
163+
touched: (name: PayloadKey<T>): boolean => _touched.value.includes(name),
164+
valid: (name: PayloadKey<T>): boolean => _validated.value.includes(name) && !form.invalid(name),
165+
invalid: (name: PayloadKey<T>): boolean => typeof form.errors.value[name] !== 'undefined',
166+
167+
data(): T {
168+
return toRaw(_payload) as T
169+
},
170+
171+
setData(data: PayloadData<T>): PrecognitionForm<T> {
172+
Object
173+
.keys(data)
174+
.forEach((key: PayloadKey<T>) => {
175+
// @ts-expect-error: assign property value on reactive object
176+
_payload[key] = data[key]
177+
})
178+
179+
return form
180+
},
181+
182+
touch(name: PayloadKey<T> | Array<PayloadKey<T>>): PrecognitionForm<T> {
183+
const inputs = Array.isArray(name) ? name : [name]
184+
const fields = [..._touched.value, ...inputs]
185+
186+
const newTouched = [...new Set(fields)]
187+
188+
const hasNewFields
189+
= newTouched.length !== _touched.value.length
190+
|| newTouched.every(x => _touched.value.includes(x))
191+
192+
if (hasNewFields) {
193+
_touched.value = newTouched
194+
}
195+
196+
return form
197+
},
198+
199+
reset(...keys: PayloadKey<T>[]): PrecognitionForm<T> {
200+
const resetField = (fieldName: string) => {
201+
// @ts-expect-error: assign property value on reactive object
202+
_payload[fieldName] = _originalPayload[fieldName]
203+
form.forgetError(fieldName)
204+
}
205+
206+
if (keys.length === 0) {
207+
_originalPayloadKeys.forEach(name => resetField(name))
208+
_touched.value = []
209+
_validated.value = []
210+
}
211+
212+
const newTouched = [..._touched.value]
213+
const newValidated = [..._validated.value]
214+
215+
keys.forEach((name) => {
216+
resetField(name)
217+
218+
if (newTouched.includes(name)) {
219+
newTouched.splice(newTouched.indexOf(name), 1)
220+
}
221+
222+
if (newValidated.includes(name)) {
223+
newValidated.splice(newValidated.indexOf(name), 1)
224+
}
225+
})
226+
227+
_touched.value = newTouched
228+
_validated.value = newValidated
229+
230+
return form
231+
},
232+
233+
setErrors(entries: PayloadErrors<T>): PrecognitionForm<T> {
234+
if (!isEqual(form.errors.value, entries)) {
235+
form.errors.value = entries
236+
}
237+
238+
return form
239+
},
240+
241+
forgetError(name: PayloadKey<T>): PrecognitionForm<T> {
242+
const {
243+
[name]: _,
244+
...newErrors
245+
} = form.errors.value
246+
247+
return form.setErrors(newErrors as PayloadErrors<T>)
248+
},
249+
250+
validate(name?: PayloadKey<T> | Array<PayloadKey<T>>, options?: ValidationOptions): PrecognitionForm<T> {
251+
if (typeof name === 'undefined') {
252+
name = []
253+
}
254+
255+
const fields = Array.isArray(name) ? name : [name]
256+
257+
form.validating.value = true
258+
259+
process({ precognitive: true, fields, options })
260+
.then((response: ResponseType) => {
261+
if (!options?.onSuccess) {
262+
return
263+
}
264+
265+
options.onSuccess(response)
266+
})
267+
.catch((response: ResponseType) => {
268+
if (response.status === 422) {
269+
if (!options?.onValidationError) {
270+
return
271+
}
272+
273+
options.onValidationError(response)
274+
return
275+
}
276+
277+
if (!options?.onError) {
278+
return
279+
}
280+
281+
options.onError(response)
282+
})
283+
.finally(() => form.validating.value = false)
284+
285+
return form
286+
},
287+
288+
async submit(): Promise<ResponseType> {
289+
form.processing.value = true
290+
291+
return await process()
292+
.finally(() => form.processing.value = false)
293+
},
294+
}
295+
296+
form.validate = debounce(form.validate, _config.validationTimeout) as typeof form.validate
297+
298+
return form
3299
}

src/runtime/types.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1+
import type { ComputedRef, Reactive, Ref } from 'vue'
2+
import type { FetchResponse } from 'ofetch'
3+
14
export interface ModuleOptions {
5+
/**
6+
* Whether to send files when validating the form on every request.
7+
* @default false
8+
*/
9+
validateFiles: boolean
10+
/**
11+
* The duration in milliseconds to wait before validating the form.
12+
* @default 500
13+
*/
14+
validationTimeout: number
215
/**
316
* The log level to use for the logger.
417
*
@@ -14,3 +27,43 @@ export interface ModuleOptions {
1427
*/
1528
logLevel: number
1629
}
30+
31+
export type Payload = Record<string, unknown>
32+
export type PayloadKey<T extends Payload> = keyof T & string
33+
export type PayloadData<T extends Payload> = Record<PayloadKey<T>, unknown>
34+
export type PayloadErrors<T extends Payload> = Partial<Record<PayloadKey<T>, string[]>>
35+
36+
export type RequestMethod = 'get' | 'post' | 'patch' | 'put' | 'delete'
37+
export type ResponseType = FetchResponse<unknown>
38+
39+
export type ValidationOptions = {
40+
validateFiles?: boolean
41+
onSuccess?: (response: ResponseType) => void
42+
onError?: (error: Error | ResponseType) => void
43+
onValidationError?: (response: ResponseType) => void
44+
}
45+
46+
export interface PrecognitionForm<T extends Payload> {
47+
fields: Reactive<T>
48+
errors: Ref<PayloadErrors<T>>
49+
50+
processing: Ref<boolean>
51+
validating: Ref<boolean>
52+
hasErrors: ComputedRef<boolean>
53+
54+
touched(name: PayloadKey<T>): boolean
55+
valid(name: PayloadKey<T>): boolean
56+
invalid(name: PayloadKey<T>): boolean
57+
58+
data(): T
59+
setData(data: PayloadData<T>): PrecognitionForm<T>
60+
61+
touch(name: PayloadKey<T> | Array<PayloadKey<T>>): PrecognitionForm<T>
62+
reset(...keys: PayloadKey<T>[]): PrecognitionForm<T>
63+
64+
setErrors(entries: PayloadErrors<T>): PrecognitionForm<T>
65+
forgetError(name: PayloadKey<T>): PrecognitionForm<T>
66+
67+
validate(name?: PayloadKey<T> | Array<PayloadKey<T>>, options?: ValidationOptions): PrecognitionForm<T>
68+
submit(): Promise<ResponseType>
69+
}

0 commit comments

Comments
 (0)
0