laravel-precognition
Version:
Laravel Precognition.
348 lines (347 loc) • 11 kB
JavaScript
import { debounce, isEqual, get, set, omit, merge } from 'lodash-es';
import { client, isFile } from './client.js';
import { isAxiosError } from 'axios';
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(() => {
callback({
get: (url, data = {}, config = {}) => client.get(url, parseData(data), resolveConfig(config, data)),
post: (url, data = {}, config = {}) => client.post(url, parseData(data), resolveConfig(config, data)),
patch: (url, data = {}, config = {}) => client.patch(url, parseData(data), resolveConfig(config, data)),
put: (url, data = {}, config = {}) => client.put(url, parseData(data), resolveConfig(config, data)),
delete: (url, data = {}, config = {}) => client.delete(url, parseData(data), resolveConfig(config, data)),
})
.catch(error => isAxiosError(error) ? null : Promise.reject(error));
}, debounceTimeoutDuration, { leading: true, trailing: true });
/**
* Validator state.
*/
let validator = createValidator();
/**
* Resolve the configuration.
*/
const resolveConfig = (config, data = {}) => {
const validate = Array.from(config.validate ?? touched);
return {
...config,
validate,
timeout: config.timeout ?? 5000,
onValidationError: (response, axiosError) => {
[
...setValidated([...validated, ...validate]),
...setErrors(merge(omit({ ...errors }, validate), response.data.errors)),
].forEach(listener => listener());
return config.onValidationError
? config.onValidationError(response, axiosError)
: Promise.reject(axiosError);
},
onSuccess: () => {
setValidated([...validated, ...validate]).forEach(listener => listener());
},
onPrecognitionSuccess: (response) => {
[
...setValidated([...validated, ...validate]),
...setErrors(omit({ ...errors }, validate)),
].forEach(listener => listener());
return config.onPrecognitionSuccess
? config.onPrecognitionSuccess(response)
: response;
},
onBefore: () => {
const beforeValidationResult = (config.onBeforeValidation ?? ((previous, next) => {
return !isEqual(previous, next);
}))({ data, touched }, { data: oldData, touched: oldTouched });
if (beforeValidationResult === false) {
return false;
}
const beforeResult = (config.onBefore || (() => true))();
if (beforeResult === false) {
return false;
}
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) => {
if (typeof name === 'undefined') {
validator();
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 (get(oldData, name) !== value) {
setTouched([name, ...touched]).forEach(listener => listener());
}
if (touched.length === 0) {
return;
}
validator();
};
/**
* Parse the validated data.
*/
const parseData = (data) => validateFiles === false
? forgetFiles(data)
: data;
/**
* The form validator instance.
*/
const form = {
touched: () => touched,
validate(input, value) {
validate(input, value);
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;
},
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;
},
};
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] = value.filter((value) => !isFile(value));
return;
}
if (typeof value === 'object') {
// @ts-expect-error
newData[name] = forgetFiles(newData[name]);
return;
}
});
return newData;
};