454 lines
15 KiB
JavaScript
454 lines
15 KiB
JavaScript
import { debounce, isEqual, get, set, merge } from 'lodash-es';
|
|
import { client, isFile } from './client.js';
|
|
import { isAxiosError, isCancel, mergeConfig } from 'axios';
|
|
/**
|
|
* Expand a wildcard path to concrete paths using the given data.
|
|
*
|
|
* Examples:
|
|
* - 'users.*' with {users: [{name: 'A'}, {name: 'B'}]} => ['users.0', 'users.1']
|
|
* - 'users.*.name' with {users: [{name: 'A'}, {name: 'B'}]} => ['users.0.name', 'users.1.name']
|
|
* - 'author.*' with {author: {name: 'John', bio: 'Dev'}} => ['author.name', 'author.bio']
|
|
*/
|
|
export const expandWildcardPaths = (pattern, data) => {
|
|
if (!pattern.includes('*')) {
|
|
return [pattern];
|
|
}
|
|
const parts = pattern.split('.');
|
|
let paths = [''];
|
|
for (const part of parts) {
|
|
if (part === '*') {
|
|
const expanded = [];
|
|
for (const path of paths) {
|
|
const value = path ? get(data, path) : data;
|
|
if (Array.isArray(value)) {
|
|
// Expand array indices...
|
|
for (let index = 0; index < value.length; index++) {
|
|
expanded.push(path ? `${path}.${index}` : String(index));
|
|
}
|
|
}
|
|
else if (value !== null && typeof value === 'object') {
|
|
// Expand object keys...
|
|
for (const key of Object.keys(value)) {
|
|
expanded.push(path ? `${path}.${key}` : key);
|
|
}
|
|
}
|
|
// If value is null, undefined, or primitive, wildcard matches nothing.
|
|
// e.g., 'users.*' with {users: null} => []
|
|
}
|
|
paths = expanded;
|
|
}
|
|
else {
|
|
// Append the literal part to all current paths
|
|
paths = paths.map((path) => path ? `${path}.${part}` : part);
|
|
}
|
|
}
|
|
return paths;
|
|
};
|
|
/**
|
|
* Determine if a key matches the given pattern.
|
|
*/
|
|
const keyMatchesPattern = (key, pattern) => {
|
|
if (!pattern.includes('*')) {
|
|
return key === pattern;
|
|
}
|
|
const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '[^.]+') + '$');
|
|
return regex.test(key);
|
|
};
|
|
/**
|
|
* Omit entries from an object whose keys match the given patterns.
|
|
*/
|
|
const omitByPattern = (obj, patterns) => {
|
|
return Object.fromEntries(Object.entries(obj).filter(([key]) => {
|
|
return !patterns.some((pattern) => keyMatchesPattern(key, pattern));
|
|
}));
|
|
};
|
|
export const createValidator = (callback, initialData = {}) => {
|
|
/**
|
|
* Event listener state.
|
|
*/
|
|
const listeners = {
|
|
errorsChanged: [],
|
|
touchedChanged: [],
|
|
validatingChanged: [],
|
|
validatedChanged: [],
|
|
};
|
|
/**
|
|
* Validate files state.
|
|
*/
|
|
let validateFiles = false;
|
|
/**
|
|
* Processing validation state.
|
|
*/
|
|
let validating = false;
|
|
/**
|
|
* Set the validating inputs.
|
|
*
|
|
* Returns an array of listeners that should be invoked once all state
|
|
* changes have taken place.
|
|
*/
|
|
const setValidating = (value) => {
|
|
if (value !== validating) {
|
|
validating = value;
|
|
return listeners.validatingChanged;
|
|
}
|
|
return [];
|
|
};
|
|
/**
|
|
* Inputs that have been validated.
|
|
*/
|
|
let validated = [];
|
|
/**
|
|
* Set the validated inputs.
|
|
*
|
|
* Returns an array of listeners that should be invoked once all state
|
|
* changes have taken place.
|
|
*/
|
|
const setValidated = (value) => {
|
|
const uniqueNames = [...new Set(value)];
|
|
if (validated.length !== uniqueNames.length || !uniqueNames.every((name) => validated.includes(name))) {
|
|
validated = uniqueNames;
|
|
return listeners.validatedChanged;
|
|
}
|
|
return [];
|
|
};
|
|
/**
|
|
* Valid validation state.
|
|
*/
|
|
const valid = () => validated.filter((name) => typeof errors[name] === 'undefined');
|
|
/**
|
|
* Touched input state.
|
|
*/
|
|
let touched = [];
|
|
/**
|
|
* Set the touched inputs.
|
|
*
|
|
* Returns an array of listeners that should be invoked once all state
|
|
* changes have taken place.
|
|
*/
|
|
const setTouched = (value) => {
|
|
const uniqueNames = [...new Set(value)];
|
|
if (touched.length !== uniqueNames.length || !uniqueNames.every((name) => touched.includes(name))) {
|
|
touched = uniqueNames;
|
|
return listeners.touchedChanged;
|
|
}
|
|
return [];
|
|
};
|
|
/**
|
|
* Validation errors state.
|
|
*/
|
|
let errors = {};
|
|
/**
|
|
* Set the input errors.
|
|
*
|
|
* Returns an array of listeners that should be invoked once all state
|
|
* changes have taken place.
|
|
*/
|
|
const setErrors = (value) => {
|
|
const prepared = toValidationErrors(value);
|
|
if (!isEqual(errors, prepared)) {
|
|
errors = prepared;
|
|
return listeners.errorsChanged;
|
|
}
|
|
return [];
|
|
};
|
|
/**
|
|
* Forget the given input's errors.
|
|
*
|
|
* Returns an array of listeners that should be invoked once all state
|
|
* changes have taken place.
|
|
*/
|
|
const forgetError = (name) => {
|
|
const newErrors = { ...errors };
|
|
delete newErrors[resolveName(name)];
|
|
return setErrors(newErrors);
|
|
};
|
|
/**
|
|
* Has errors state.
|
|
*/
|
|
const hasErrors = () => Object.keys(errors).length > 0;
|
|
/**
|
|
* Debouncing timeout state.
|
|
*/
|
|
let debounceTimeoutDuration = 1500;
|
|
const setDebounceTimeout = (value) => {
|
|
debounceTimeoutDuration = value;
|
|
validator.cancel();
|
|
validator = createValidator();
|
|
};
|
|
/**
|
|
* The old data.
|
|
*/
|
|
let oldData = initialData;
|
|
/**
|
|
* The data currently being validated.
|
|
*/
|
|
let validatingData = null;
|
|
/**
|
|
* The old touched.
|
|
*/
|
|
let oldTouched = [];
|
|
/**
|
|
* The touched currently being validated.
|
|
*/
|
|
let validatingTouched = null;
|
|
/**
|
|
* Create a debounced validation callback.
|
|
*/
|
|
const createValidator = () => debounce((instanceConfig) => {
|
|
callback({
|
|
get: (url, data = {}, globalConfig = {}) => client.get(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
|
|
post: (url, data = {}, globalConfig = {}) => client.post(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
|
|
patch: (url, data = {}, globalConfig = {}) => client.patch(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
|
|
put: (url, data = {}, globalConfig = {}) => client.put(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
|
|
delete: (url, data = {}, globalConfig = {}) => client.delete(url, parseData(data), resolveConfig(globalConfig, instanceConfig, data)),
|
|
}).catch((error) => {
|
|
// Precognition can often cancel in-flight requests. Instead of
|
|
// throwing an exception for this expected behaviour, we silently
|
|
// discard cancelled request errors to not flood the console with
|
|
// expected errors.
|
|
if (isCancel(error)) {
|
|
return null;
|
|
}
|
|
// Unlike other status codes, 422 responses are expected and
|
|
// regularly occur with Precognition requests. We silently ignore
|
|
// these so we do not flood the console with expected errors. If
|
|
// needed, they can be intercepted by the `onValidationError`
|
|
// config option instead.
|
|
if (isAxiosError(error) && error.response?.status === 422) {
|
|
return null;
|
|
}
|
|
return Promise.reject(error);
|
|
});
|
|
}, debounceTimeoutDuration, { leading: true, trailing: true });
|
|
/**
|
|
* Validator state.
|
|
*/
|
|
let validator = createValidator();
|
|
/**
|
|
* Resolve the configuration.
|
|
*/
|
|
const resolveConfig = (globalConfig, instanceConfig, data = {}) => {
|
|
const config = {
|
|
...globalConfig,
|
|
...instanceConfig,
|
|
};
|
|
const only = Array.from(config.only ?? config.validate ?? touched);
|
|
return {
|
|
...instanceConfig,
|
|
// Axios has special rules for merging global and local config. We
|
|
// use their merge function here to make sure things like headers
|
|
// merge in an expected way.
|
|
...mergeConfig(globalConfig, instanceConfig),
|
|
only,
|
|
timeout: config.timeout ?? 5000,
|
|
onValidationError: (response, axiosError) => {
|
|
[
|
|
...setValidated([...validated, ...only]),
|
|
...setErrors(merge(omitByPattern({ ...errors }, only), response.data.errors)),
|
|
].forEach((listener) => listener());
|
|
return config.onValidationError
|
|
? config.onValidationError(response, axiosError)
|
|
: Promise.reject(axiosError);
|
|
},
|
|
onSuccess: (response) => {
|
|
setValidated([...validated, ...only]).forEach((listener) => listener());
|
|
return config.onSuccess
|
|
? config.onSuccess(response)
|
|
: response;
|
|
},
|
|
onPrecognitionSuccess: (response) => {
|
|
[
|
|
...setValidated([...validated, ...only]),
|
|
...setErrors(omitByPattern({ ...errors }, only)),
|
|
].forEach((listener) => listener());
|
|
return config.onPrecognitionSuccess
|
|
? config.onPrecognitionSuccess(response)
|
|
: response;
|
|
},
|
|
onBefore: () => {
|
|
// Wildcards are expanded to concrete paths using the current
|
|
// form data so that each field is individually tracked.
|
|
const hasWildcards = touched.some((name) => name.includes('*'));
|
|
const expandedTouched = hasWildcards
|
|
? [...new Set(touched.flatMap((name) => expandWildcardPaths(name, data)))]
|
|
: touched;
|
|
if (config.onBeforeValidation && config.onBeforeValidation({ data, touched: expandedTouched }, { data: oldData, touched: oldTouched }) === false) {
|
|
return false;
|
|
}
|
|
const beforeResult = (config.onBefore || (() => true))();
|
|
if (beforeResult === false) {
|
|
return false;
|
|
}
|
|
if (hasWildcards) {
|
|
setTouched(expandedTouched).forEach((listener) => listener());
|
|
}
|
|
validatingTouched = touched;
|
|
validatingData = data;
|
|
return true;
|
|
},
|
|
onStart: () => {
|
|
setValidating(true).forEach((listener) => listener());
|
|
(config.onStart ?? (() => null))();
|
|
},
|
|
onFinish: () => {
|
|
setValidating(false).forEach((listener) => listener());
|
|
oldTouched = validatingTouched;
|
|
oldData = validatingData;
|
|
validatingTouched = validatingData = null;
|
|
(config.onFinish ?? (() => null))();
|
|
},
|
|
};
|
|
};
|
|
/**
|
|
* Validate the given input.
|
|
*/
|
|
const validate = (name, value, config) => {
|
|
if (typeof name === 'undefined') {
|
|
const only = Array.from(config?.only ?? config?.validate ?? []);
|
|
setTouched([...touched, ...only]).forEach((listener) => listener());
|
|
validator(config ?? {});
|
|
return;
|
|
}
|
|
if (isFile(value) && !validateFiles) {
|
|
console.warn('Precognition file validation is not active. Call the "validateFiles" function on your form to enable it.');
|
|
return;
|
|
}
|
|
name = resolveName(name);
|
|
if (name.includes('*') || get(oldData, name) !== value) {
|
|
setTouched([name, ...touched]).forEach((listener) => listener());
|
|
validator(config ?? {});
|
|
}
|
|
};
|
|
/**
|
|
* Parse the validated data.
|
|
*/
|
|
const parseData = (data) => validateFiles === false
|
|
? forgetFiles(data)
|
|
: data;
|
|
/**
|
|
* The form validator instance.
|
|
*/
|
|
const form = {
|
|
touched: () => touched,
|
|
validate(name, value, config) {
|
|
if (typeof name === 'object' && !('target' in name)) {
|
|
config = name;
|
|
name = value = undefined;
|
|
}
|
|
validate(name, value, config);
|
|
return form;
|
|
},
|
|
touch(input) {
|
|
const inputs = Array.isArray(input)
|
|
? input
|
|
: [resolveName(input)];
|
|
setTouched([...touched, ...inputs]).forEach((listener) => listener());
|
|
return form;
|
|
},
|
|
validating: () => validating,
|
|
valid,
|
|
errors: () => errors,
|
|
hasErrors,
|
|
setErrors(value) {
|
|
setErrors(value).forEach((listener) => listener());
|
|
return form;
|
|
},
|
|
forgetError(name) {
|
|
forgetError(name).forEach((listener) => listener());
|
|
return form;
|
|
},
|
|
defaults(data) {
|
|
initialData = data;
|
|
oldData = data;
|
|
return form;
|
|
},
|
|
reset(...names) {
|
|
if (names.length === 0) {
|
|
setTouched([]).forEach((listener) => listener());
|
|
}
|
|
else {
|
|
const newTouched = [...touched];
|
|
names.forEach((name) => {
|
|
if (newTouched.includes(name)) {
|
|
newTouched.splice(newTouched.indexOf(name), 1);
|
|
}
|
|
set(oldData, name, get(initialData, name));
|
|
});
|
|
setTouched(newTouched).forEach((listener) => listener());
|
|
}
|
|
return form;
|
|
},
|
|
setTimeout(value) {
|
|
setDebounceTimeout(value);
|
|
return form;
|
|
},
|
|
on(event, callback) {
|
|
listeners[event].push(callback);
|
|
return form;
|
|
},
|
|
validateFiles() {
|
|
validateFiles = true;
|
|
return form;
|
|
},
|
|
withoutFileValidation() {
|
|
validateFiles = false;
|
|
return form;
|
|
},
|
|
};
|
|
return form;
|
|
};
|
|
/**
|
|
* Normalise the validation errors as Inertia formatted errors.
|
|
*/
|
|
export const toSimpleValidationErrors = (errors) => {
|
|
return Object.keys(errors).reduce((carry, key) => ({
|
|
...carry,
|
|
[key]: Array.isArray(errors[key])
|
|
? errors[key][0]
|
|
: errors[key],
|
|
}), {});
|
|
};
|
|
/**
|
|
* Normalise the validation errors as Laravel formatted errors.
|
|
*/
|
|
export const toValidationErrors = (errors) => {
|
|
return Object.keys(errors).reduce((carry, key) => ({
|
|
...carry,
|
|
[key]: typeof errors[key] === 'string' ? [errors[key]] : errors[key],
|
|
}), {});
|
|
};
|
|
/**
|
|
* Resolve the input's "name" attribute.
|
|
*/
|
|
export const resolveName = (name) => {
|
|
return typeof name !== 'string'
|
|
? name.target.name
|
|
: name;
|
|
};
|
|
/**
|
|
* Forget any files from the payload.
|
|
*/
|
|
const forgetFiles = (data) => {
|
|
const newData = { ...data };
|
|
Object.keys(newData).forEach((name) => {
|
|
const value = newData[name];
|
|
if (value === null) {
|
|
return;
|
|
}
|
|
if (isFile(value)) {
|
|
delete newData[name];
|
|
return;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
newData[name] = Object.values(forgetFiles({ ...value }));
|
|
return;
|
|
}
|
|
if (typeof value === 'object') {
|
|
// @ts-expect-error
|
|
newData[name] = forgetFiles(newData[name]);
|
|
return;
|
|
}
|
|
});
|
|
return newData;
|
|
};
|