|
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 |
3 | 299 | }
|
0 commit comments