alpidate
Version:
A model-based validation plugin for Alpine.js, inspired by Vuelidate.
1 lines • 6 kB
JavaScript
// alpidate.js
// Lightweight validation plugin for Alpine.js
// Author: H7Arash
/**
* Validate a single value against rules
*/
function validateValue(value, rules, data) {
const result = { $invalid: false };
rules.forEach((r) => {
const [rule, param] = r.split(':');
let error = false;
switch (rule) {
case 'required':
error = value === null || value === '' || value === undefined || (Array.isArray(value) && value.length === 0);
break;
case 'requiredIf':
const [field, expected] = param.split(',');
const actual = data[field];
const shouldBeRequired = ('' + actual) === expected;
if (shouldBeRequired)
error = value === null || value === '' || value === undefined;
break;
case 'array':
error = !Array.isArray(value);
break;
case 'min':
if (typeof value === 'string' || Array.isArray(value)) error = value.length < Number(param);
break;
case 'max':
if (typeof value === 'string' || Array.isArray(value)) error = value.length > Number(param);
break;
case 'numeric':
error = isNaN(value);
break;
case 'email':
error = !/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/.test(value);
break;
case 'regex':
try {
const regex = param ? new RegExp(param) : null;
error = regex ? !regex.test(value) : true;
} catch {
error = true;
}
break;
}
result[rule] = error;
if (error) result.$invalid = true;
});
return result;
}
/**
* Expand wildcard validations like address.*.name
*/
function expandWildcardValidations(data,validationsRules) {
const expanded = {};
for (let path in validationsRules) {
if (path.includes('*')) {
const parts = path.split('.');
let arrayKey = parts[0];
let arr = data[arrayKey] || [];
arr.forEach((_, idx) => {
const expandedPath = path.replace('*', idx);
expanded[expandedPath] = validationsRules[path];
});
} else {
expanded[path] = validationsRules[path];
}
}
return expanded;
}
/**
* Initialize watchers for each validation path
*/
function createValidationWatcher(data,validationsRules,key) {
const expandedValidations = expandWildcardValidations(data,validationsRules);
for (let model in expandedValidations) {
data[key].validate(model)
data.$watch(model, () => data[key].validate(model));
}
return data;
}
/**
* Main Alpine.js plugin
*/
export default function (Alpine) {
Alpine.magic('validation', () => (data,key = null) => {
if(!key){
key = '$v';
}
data[key] = {};
data[key].$touch = false;
data[key].$invalid = true;
const validationsRules = JSON.parse(JSON.stringify(data.validations));
data[key].validate = (modelName = null) => {
const expandedValidations = expandWildcardValidations(data,validationsRules);
const models = modelName ? [modelName] : Object.keys(expandedValidations);
if (!modelName) data[key].$touch = true;
models.forEach((model) => {
const rules = expandedValidations[model];
const chain = model.split('.');
let currentV = data[key];
let currentData = data;
chain.forEach((key, idx) => {
if (!isNaN(Number(key))) {
currentData = currentData[key];
return;
}
if (!currentV[key]) currentV[key] = { $invalid: false };
currentV = currentV[key];
currentData = currentData[key] ?? currentData[key];
});
// Validate main field
Object.assign(currentV, validateValue(currentData, rules, data));
// Handle arrays with wildcard
if (Array.isArray(currentData)) {
const wildcardKey = Object.keys(validationsRules).find(k => k.includes('*') && k.startsWith(chain[0]));
if (wildcardKey) {
currentV.each = currentData.map((item, idx) => {
const itemResult = {};
const subRules = Object.keys(validationsRules).filter(p => p.startsWith(chain[0] + '.*')).map(p => {
const fieldName = p.split('.').slice(-1)[0];
return { fieldName, rules: validationsRules[p] };
});
subRules.forEach(sr => {
itemResult[sr.fieldName] = validateValue(item[sr.fieldName], sr.rules, data);
});
return itemResult;
});
currentV.$invalid = currentV.each.some(e =>
Object.values(e).some(f => f.$invalid)
);
}
}
const parts = model.split('.');
if (parts.length > 1) {
let cursor = data[key];
for (let i = 0; i < parts.length - 1; i++) {
const segment = parts[i];
if (!cursor[segment]) continue;
const child = cursor[segment];
child.$invalid = Object.values(child)
.filter(v => typeof v === 'object' && v !== null)
.some(v => v.$invalid);
cursor = child;
}
}
});
};
data[key].reset = () => data[key].$touch = false;
data = createValidationWatcher(data,validationsRules,key);
// Watch $v for overall invalid status
data.$watch(key, () => {
data[key].$invalid = Object.keys(data[key]).some(k => {
if (k.startsWith('$')) return false;
const v = data[key][k];
if (v.$invalid) return true;
if (v.each) return v.each.some(e => Object.values(e).some(f => f.$invalid));
return false;
});
});
Object.entries(validationsRules).forEach(([field, rules]) => {
rules.forEach(rule => {
if (rule.startsWith('requiredIf:')) {
const onEffectModel = rule.split(':')[1].split(',')[0];
data.$watch(onEffectModel,() => {
data[key].validate(field);
});
}
});
});
});
};