feat: standardize admin form limits and guidance

This commit is contained in:
Tống Thành Đạt
2026-04-10 15:55:15 +07:00
parent 7ce5921fe0
commit 51c6303437
34 changed files with 1692 additions and 361 deletions

158
utils/lengthValidation.js Normal file
View File

@@ -0,0 +1,158 @@
const DEFAULT_LABEL = "Field";
const normalizePath = (path) => {
return String(path || "")
.replace(/\[(\d+)\]/g, ".$1")
.replace(/\[\*\]/g, ".*")
.replace(/\[\]/g, ".*")
.split(".")
.filter(Boolean);
};
const toLabel = (path) => {
if (!path) {
return DEFAULT_LABEL;
}
return String(path)
.replace(/\[\d+\]/g, "")
.replace(/\.\*/g, "")
.replace(/[._-]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/^./, (ch) => ch.toUpperCase());
};
const collectMatches = (value, segments, currentPath = [], results = []) => {
if (segments.length === 0) {
results.push({
path: currentPath.join("."),
value,
});
return results;
}
const [segment, ...rest] = segments;
if (segment === "*") {
if (!Array.isArray(value)) {
return results;
}
value.forEach((item, index) => {
collectMatches(item, rest, [...currentPath, String(index)], results);
});
return results;
}
if (value === null || value === undefined) {
return results;
}
if (typeof value !== "object") {
return results;
}
if (!Object.prototype.hasOwnProperty.call(value, segment)) {
return results;
}
return collectMatches(value[segment], rest, [...currentPath, segment], results);
};
const countWords = (value) => {
const normalized = String(value || "")
.replace(/\s+/g, " ")
.trim();
if (!normalized) {
return 0;
}
return normalized.split(" ").length;
};
const buildErrorMessage = (label, path, maxLength, maxWords) => {
const target = path ? `${label} (${path})` : label;
if (maxLength && maxWords) {
return `${target} must not exceed ${maxLength} characters or ${maxWords} words.`;
}
if (maxLength) {
return `${target} must not exceed ${maxLength} characters.`;
}
return `${target} must not exceed ${maxWords} words.`;
};
const validateLengthRules = (payload, rules = []) => {
const errors = [];
for (const rule of rules) {
const paths = Array.isArray(rule.paths)
? rule.paths
: [rule.path].filter(Boolean);
for (const path of paths) {
const segments = normalizePath(path);
const matches = collectMatches(payload, segments);
for (const match of matches) {
if (typeof match.value !== "string") {
continue;
}
const normalized = match.value.trim();
if (!normalized && rule.allowEmpty !== false) {
continue;
}
const actualLength = normalized.length;
const actualWords = countWords(normalized);
const maxLength = Number(rule.maxLength);
const maxWords = Number(rule.maxWords);
const exceedsLength = Number.isFinite(maxLength) && maxLength > 0 && actualLength > maxLength;
const exceedsWords = Number.isFinite(maxWords) && maxWords > 0 && actualWords > maxWords;
if (!exceedsLength && !exceedsWords) {
continue;
}
const label = rule.label || toLabel(path);
errors.push({
path: match.path,
label,
message: buildErrorMessage(label, match.path, exceedsLength ? maxLength : null, exceedsWords ? maxWords : null),
maxLength: Number.isFinite(maxLength) ? maxLength : undefined,
maxWords: Number.isFinite(maxWords) ? maxWords : undefined,
actualLength,
actualWords,
});
}
}
}
return {
valid: errors.length === 0,
errors,
};
};
const summarizeLengthErrors = (validation, limit = 1) => {
if (!validation || !Array.isArray(validation.errors) || validation.errors.length === 0) {
return "";
}
return validation.errors
.slice(0, limit)
.map((error) => error.message)
.join(" ");
};
module.exports = {
validateLengthRules,
summarizeLengthErrors,
toLabel,
};