import Registry from "../Registry"; import messages from "../../utils/messages"; export const errorBuilder = { /** * Occurrs when attribute is not provided at all */ required(modelName, field) { return { modelName, field, error: "ERR_REQUIRED", }; }, /** * Occurrs when tag is not in our Registry */ unknownTag(modelName, field, value) { return { modelName, field, value, error: "ERR_UNKNOWN_TAG", }; }, /** * Occurrs when tag is not on the tree */ tagNotFound(modelName, field, value) { return { modelName, field, value, error: "ERR_TAG_NOT_FOUND", }; }, /** * Occurrs when referenced tag cannot be controlled by particular control tag */ tagUnsupported(modelName, field, value, validType) { return { modelName, field, value, validType, error: "ERR_TAG_UNSUPPORTED", }; }, /** * Occurrs when tag has not expected parent tag at any level */ parentTagUnexpected(modelName, field, value, validType) { return { modelName, field, value, validType, error: "ERR_PARENT_TAG_UNEXPECTED", }; }, /** * Occurrs when attribute value has wrong type */ badAttributeValueType(modelName, field, value, validType) { return { modelName, field, value, validType, error: "ERR_BAD_TYPE", }; }, internalError(error) { return { error: "ERR_INTERNAL", value: String(error).substr(0, 1000), field: String(error.code), modelName: "", }; }, generalError(error) { return { error: "ERR_GENERAL", value: String(error).substr(0, 1000), field: String(error.code), modelName: "", }; }, loadingError(error, url, attrWithUrl, message = messages.ERR_LOADING_HTTP) { console.log("ERR", error, error.code); return { error: "ERR_GENERAL", value: message({ attr: attrWithUrl, error: String(error), url }), field: attrWithUrl, modelName: "", }; }, }; /** * Transforms MST `describe()` to a human-readable value * @param {import("mobx-state-tree").IType} type * @param {boolean} withNullType */ const getTypeDescription = (type, withNullType = true) => { const description = type .describe() .match(/([a-z0-9?|]+)/gi) .join("") .split("|"); // Remove optional null if (withNullType === false) { const index = description.indexOf("null?"); if (index >= 0) description.splice(index, 1); } return description; }; /** * Flatten config tree for faster iterations and searches * @param {object} tree * @param {string | null;;} parent * @param {string[]} parentParentTypes * @param {object[]} result * @returns {object[]} */ const flattenTree = (tree, parent = null, parentParentTypes = ["view"], result) => { if (!tree.children) return []; const children = tree.type === "pagedview" ? tree.children.slice(0, 1) : tree.children; for (const child of children) { /* Create a child without children and assign id of the parent for quick mathcing */ const parentTypes = [...parentParentTypes, ...(parent?.type ? [parent?.type] : [])]; const flatChild = { ...child, parent: parent?.id ?? null, parentTypes }; delete flatChild.children; result.push(flatChild); if (Array.isArray(child.children)) { flattenTree(child, child, parentTypes, result); } } return result; }; /** * Validates presence and format of the name attribute * @param {Object} child * @param {Object} model */ const validateNameTag = (child, model) => { const { name } = model.properties; // HyperText can be used for mark-up, without name, so name is optional type there if (name && !name.optionalValues && child.name === undefined) { return errorBuilder.required(model.name, "name"); } return null; }; /** * Validates toName attribute * Checks that connected tag is existing tag, it present in the tree * and can be controlled by current Object Tag * @param {Object} element * @param {Object} model * @param {Object[]} flatTree */ const validateToNameTag = (element, model, flatTree) => { const { controlledTags } = model.properties; if (!element.toname) return null; const names = element.toname.split(","); // for pairwise for (const name of names) { // Find referenced tag in the tree const controlledTag = flatTree.find((item) => item.name === name); if (controlledTag === undefined) { return errorBuilder.tagNotFound(model.name, "toname", name); } if (controlledTags && controlledTags.validate(controlledTag.tagName).length) { return errorBuilder.tagUnsupported(model.name, "toname", controlledTag.tagName, controlledTags); } } return null; }; /** * Validates parent of tag * Checks that parent tag has the right type * @param {Object} element * @param {Object} model * @param {Object[]} flatTree */ const validateParentTag = (element, model) => { const parentTypes = model.properties.parentTypes?.value; if ( !parentTypes || element.parentTypes.find((elementParentType) => parentTypes.find((type) => elementParentType === type.toLowerCase()), ) ) { return null; } return errorBuilder.parentTagUnexpected(model.name, "parent", element.tagName, model.properties.parentTypes); }; /** * Validates if visual tags have name attribute * @param {Object} element */ const validateVisualTags = (element) => { const visualTags = ["Collapse", "Filter", "Header", "Style", "View"]; const { tagName } = element; if (visualTags.includes(tagName) && element.name) { return errorBuilder.generalError(`Attribute name is not allowed for tag ${tagName}.`); } return null; }; /** * Validate other tag attributes other than name and toName * @param {Object} child * @param {import("mobx-state-tree").IModelType} model * @param {string[]} fieldsToSkip */ const validateAttributes = (child, model, fieldsToSkip) => { const result = []; const properties = Object.keys(model.properties); for (const key of properties) { if (!{}.hasOwnProperty.call(child, key)) continue; if (fieldsToSkip.includes(key)) continue; const value = child[key]; const modelProperty = model.properties[key.toLowerCase()]; const mstValidationResult = modelProperty.validate(value, modelProperty); if (mstValidationResult.length === 0) continue; result.push(errorBuilder.badAttributeValueType(model.name, key, value, modelProperty)); } return result; }; /** * Validate perRegion restrictions * @param {Object} child */ const validatePerRegion = (child) => { const validationResult = []; // PerItem and PerRegion are incompatible but PerRegion is more prioritized mode if (child.perregion && child.peritem) { validationResult.push( errorBuilder.generalError( "Attribute perItem is incompatible with attribute perRegion. " + "They define two different modes. However perRegion works fine even with multi-item mode of object tags.", ), ); } return validationResult; }; /** * Convert MST type to a human-readable string * @param {import("mobx-state-tree").IType} type */ const humanizeTypeName = (type) => { return type ? getTypeDescription(type, false) : null; }; export class ConfigValidator { /** * Validate node attributes and compatibility with other nodes * @param {*} node */ static validate(root) { const flatTree = []; flattenTree(root, null, [], flatTree); const propertiesToSkip = ["id", "children", "name", "toname", "controlledTags", "parentTypes"]; const validationResult = []; for (const child of flatTree) { try { const model = Registry.getModelByTag(child.type); // Validate name attribute const nameValidation = validateNameTag(child, model); if (nameValidation !== null) validationResult.push(nameValidation); // Validate toName attribute const toNameValidation = validateToNameTag(child, model, flatTree); if (toNameValidation !== null) validationResult.push(toNameValidation); // Validate by parentUnexpected parent tag const parentValidation = validateParentTag(child, model); if (parentValidation !== null) validationResult.push(parentValidation); validationResult.push(...validatePerRegion(child)); validationResult.push(...validateAttributes(child, model, propertiesToSkip)); } catch (e) { validationResult.push(errorBuilder.unknownTag(child.type, child.name, child.type)); } } if (validationResult.length) { return validationResult.map((error) => ({ ...error, validType: humanizeTypeName(error.validType), })); } return []; } }