UNPKG

@react-query-builder-express/core

Version:
1,359 lines (1,288 loc) 52.9 kB
import omit from "lodash/omit"; import Immutable from "immutable"; import { getFieldConfig, getOperatorConfig, getFieldWidgetConfig, getFuncConfig, getFieldSrc, extendConfig} from "./configUtils"; import { getOperatorsForField, getWidgetForFieldOp, whatRulePropertiesAreCompleted, selectTypes, getValueSourcesForFieldOp, } from "../utils/ruleUtils"; import {getOpCardinality, getFirstDefined, deepEqual} from "../utils/stuff"; import {getItemInListValues} from "../utils/listValues"; import {defaultOperatorOptions} from "../utils/defaultUtils"; import {fixPathsInTree, getItemByPath, getFlatTree} from "../utils/treeUtils"; import {setFuncDefaultArg} from "../utils/funcUtils"; import {queryString} from "../export/queryString"; import * as constants from "../i18n/validation/constains"; import { translateValidation } from "../i18n"; export { translateValidation }; const typeOf = (v) => { if (typeof v === "object" && v !== null && Array.isArray(v)) return "array"; else return (typeof v); }; const isTypeOf = (v, type) => { if (typeOf(v) === type) return true; if (type === "number" && !isNaN(v)) return true; //can be casted return false; }; // tip: If showErrorMessage is false, this function will always return true export const isValidTree = (tree, config) => { return getTreeBadFields(tree, config).length === 0; }; // tip: If showErrorMessage is false, this function will always return [] // tip: If LHS is func, func key will be used to put in array of bad fields (naive) export const getTreeBadFields = (tree, config) => { const {showErrorMessage} = config.settings; let badFields = []; function _processNode (item, path, lev) { const id = item.get("id"); const children = item.get("children1"); const valueError = item.getIn(["properties", "valueError"]); const fieldError = item.getIn(["properties", "fieldError"]); const field = item.getIn(["properties", "field"]); const fieldStr = field?.get?.("func") ?? field; const hasValueError = valueError?.size > 0 && valueError.filter(v => v != null).size > 0; const isBad = hasValueError || !!fieldError; if (isBad && showErrorMessage) { // for showErrorMessage=false valueError/fieldError is used to hold last error, but actual value is always valid badFields.push(fieldStr); } if (children) { children.map((child, _childId) => { if (child) { _processNode(child, path.concat(id), lev + 1); } }); } } if (tree) _processNode(tree, [], 0); return Array.from(new Set(badFields)); }; // @deprecated export const checkTree = (tree, config) => { const extendedConfig = extendConfig(config, undefined, true); const options = { removeEmptyGroups: config.settings.removeEmptyGroupsOnLoad, removeIncompleteRules: config.settings.removeIncompleteRulesOnLoad, removeEmptyRules: config.settings.removeEmptyRulesOnLoad, forceFix: false, }; const {fixedTree, allErrors, isSanitized} = _validateTree( tree, null, extendedConfig, extendedConfig, options ); if (isSanitized && allErrors.length) { console.warn("Tree check errors: ", allErrors); } return fixedTree; }; /** * @param {Immutable.Map} tree * @param {SanitizeOptions} options * @returns {ValidationItemErrors[]} */ export const validateTree = (tree, config, options = {}) => { const extendedConfig = extendConfig(config, undefined, true); const finalOptions = { ...options, // disbale sanitize options, just validate removeEmptyGroups: false, removeEmptyRules: false, removeIncompleteRules: false, forceFix: false, }; const {allErrors} = _validateTree( tree, null, extendedConfig, extendedConfig, finalOptions ); return allErrors; }; /** * @param {Immutable.Map} tree * @param {SanitizeOptions} options * @returns {{ * fixedTree: Immutable.Map, * fixedErrors: ValidationItemErrors[], * nonFixedErrors: ValidationItemErrors[], * allErrors: ValidationItemErrors[], * }} */ export const sanitizeTree = (tree, config, options = {}) => { const extendedConfig = extendConfig(config, undefined, true); const finalOptions = { // defaults removeEmptyGroups: true, removeEmptyRules: true, removeIncompleteRules: true, forceFix: false, ...options, }; const {fixedTree, fixedErrors, nonFixedErrors, allErrors} = _validateTree( tree, null, extendedConfig, extendedConfig, finalOptions ); return {fixedTree, fixedErrors, nonFixedErrors, allErrors}; }; // tip: Should be used only internally in createValidationMemo() export const validateAndFixTree = (newTree, _oldTree, newConfig, oldConfig, removeEmptyGroups, removeEmptyRules, removeIncompleteRules) => { if (removeEmptyGroups === undefined) { removeEmptyGroups = newConfig.settings.removeEmptyGroupsOnLoad; } if (removeEmptyRules === undefined) { removeEmptyRules = newConfig.settings.removeEmptyRulesOnLoad; } if (removeIncompleteRules === undefined) { removeIncompleteRules = newConfig.settings.removeIncompleteRulesOnLoad; } const options = { // sanitize options removeEmptyGroups, removeEmptyRules, removeIncompleteRules, forceFix: false, }; let {fixedTree, allErrors, fixedErrors, nonFixedErrors, isSanitized} = _validateTree( newTree, _oldTree, newConfig, oldConfig, options ); if (isSanitized && fixedErrors.length) { console.warn("Fixed tree errors: ", fixedErrors); } // if (nonFixedErrors.length) { // console.info("Tree validation errors: ", nonFixedErrors); // } fixedTree = fixPathsInTree(fixedTree); return fixedTree; }; /** * @param {Immutable.Map} tree * @param {SanitizeOptions} options * @typedef {{ * removeEmptyGroups?: boolean, * removeEmptyRules?: boolean, * removeIncompleteRules?: boolean, * forceFix?: boolean, * translateErrors?: boolean, * includeStringifiedItems?: boolean, * stringifyFixedItems?: boolean, * stringifyItemsUserFriendly?: boolean, * includeItemsPositions?: boolean, * }} SanitizeOptions * @typedef {{ * path: string[], * errors: { * key: string, args?: object | null, str?: string, * side?: "lhs"|"rhs"|"op", delta?: number, fixed?: boolean, fixedTo?: any, fixedFrom?: any, * }[], * itemStr?: string, * itemPosition?: { * caseNo: number | null, globalNoByType: number, indexPath: number[], globalLeafNo?: number, globalGroupNo?: number, * isDeleted: boolean, index: number, type: "rule"|"group"|"rule_group" * }, * itemPositionStr?: string, * }} ValidationItemErrors * @returns {{ * fixedTree: Immutable.Map, * allErrors: ValidationItemErrors[], * fixedErrors: ValidationItemErrors[], * nonFixedErrors: ValidationItemErrors[], * isSanitized: boolean * }} */ export const _validateTree = ( tree, _oldTree, config, oldConfig, options ) => { if (!tree) { return { fixedTree: tree, allErrors: [], fixedErrors: [], nonFixedErrors: [], isSanitized: false, }; } const { // sanitize options removeEmptyGroups, removeEmptyRules, removeIncompleteRules, forceFix, // translation options translateErrors = true, includeStringifiedItems = true, stringifyFixedItems = false, stringifyItemsUserFriendly = true, includeItemsPositions = true, } = options || {}; const c = { config, oldConfig, removeEmptyGroups, removeEmptyRules, removeIncompleteRules, forceFix, }; const meta = { errors: {}, }; const fixedTree = validateItem(tree, [], null, meta, c); const isSanitized = meta.sanitized; // build allErrors const allErrors = []; let flatItems, oldFlatItems; if (includeItemsPositions) { flatItems = getFlatTree(fixedTree).items; } for (const id in meta.errors) { let { path, errors } = meta.errors[id]; if (translateErrors) { errors = errors.map(e => { return { ...e, str: translateValidation(e), }; }); } let errorItem = { path, errors }; if (includeStringifiedItems) { const item = getItemByPath(stringifyFixedItems ? fixedTree : tree, path); const isRoot = path.length === 1; if (!isRoot && item.get("type") !== "group") { const isDebugMode = true; const isForDisplay = stringifyItemsUserFriendly; const itemStr = queryString(item, config, isForDisplay, isDebugMode); errorItem.itemStr = itemStr; } } if (includeItemsPositions) { let flatItem = flatItems[id]; const isDeleted = !flatItem; if (isDeleted) { // get positions from old tree if (!oldFlatItems) { oldFlatItems = getFlatTree(tree).items; } flatItem = oldFlatItems[id]; } if (flatItem) { // build position object const itemPosition = { ...flatItem.position, index: flatItem.index, type: flatItem.type, isDeleted, }; errorItem.itemPosition = itemPosition; // build position string const trKey = !flatItem.index ? constants.ITEM_POSITION_ROOT : constants.ITEM_POSITION+"__"+flatItem.type+(isDeleted ? "__deleted" : ""); const trArgs = { ...itemPosition }; if (stringifyItemsUserFriendly) { // convert indexes from 0-based to 1-based (user friendly) for (const k of ["caseNo", "globalLeafNo", "globalGroupNo", "globalNoByType"]) { if (trArgs[k] != undefined) { trArgs[k] = trArgs[k] + 1; } } trArgs.indexPath = itemPosition.indexPath?.map(ind => ind+1); } errorItem.itemPositionStr = translateValidation(trKey, trArgs); if (flatItem.index) { // don't extend for root if (flatItem.caseId && flatItem.type !== "case_group") { errorItem.itemPositionStr = translateValidation(constants.ITEM_POSITION_IN_CASE, { ...trArgs, str: errorItem.itemPositionStr }); } if (flatItem.type !== "case_group") { errorItem.itemPositionStr = translateValidation(constants.ITEM_POSITION_WITH_INDEX_PATH, { ...trArgs, str: errorItem.itemPositionStr }); } } } } allErrors.push(errorItem); } // split allErrors to fixedErrors and nonFixedErrors let fixedErrors = []; let nonFixedErrors = []; for (const itemErrors of allErrors) { const fixedItemErrors = itemErrors.errors.filter(e => !!e.fixed); let nonFixedItemErrors = itemErrors.errors.filter(e => !e.fixed && e.key !== "EMPTY_QUERY"); if (fixedItemErrors.length) { fixedErrors.push({ ...itemErrors, errors: fixedItemErrors, }); } if (nonFixedItemErrors.length) { nonFixedErrors.push({ ...itemErrors, errors: nonFixedItemErrors, }); } } return { fixedTree, allErrors, fixedErrors, nonFixedErrors, isSanitized }; }; function _addError(meta, item, path, err) { const id = item.get("id"); if (!meta.errors[id]) { meta.errors[id] = { path: [...path, id], errors: [], }; } meta.errors[id].errors.push(err); } function _setErrorsAsFixed(meta, item) { const id = item.get("id"); if (meta.errors[id]) { meta.errors[id].errors.map(e => { e.fixed = true; }); } } function validateItem (item, path, itemId, meta, c) { const type = item?.get("type"); if ((type === "group" || type === "rule_group" || type == "case_group" || type == "switch_group")) { return validateGroup(item, path, itemId, meta, c); } else if (type === "rule") { return validateRule(item, path, itemId, meta, c); } else { return item; } } function validateGroup (item, path, itemId, meta, c) { const {removeEmptyGroups, removeIncompleteRules, forceFix, config} = c; const {showErrorMessage} = config.settings; const canFix = !showErrorMessage || forceFix; let id = item.get("id"); let children = item.get("children1"); const isRoot = !path.length; const oldChildren = children; const type = item.get("type"); const properties = item.get("properties"); const field = properties?.get("field"); const mode = properties?.get("mode"); const operator = properties?.get?.("operator"); const isGroupExt = type === "rule_group" && mode === "array"; const isCase = type === "case_group"; const isDefaultCase = isCase && children == undefined; const cardinality = operator ? config.operators[operator]?.cardinality ?? 1 : undefined; // tip: for group operators some/none/all children ARE required, for group operator count children are NOT required // tip: default case should contain only value const childrenAreRequired = isCase ? !isDefaultCase : (isGroupExt ? cardinality == 0 : removeEmptyGroups); const canHaveValue = isGroupExt || isCase; if (!id && itemId) { id = itemId; item = item.set("id", id); meta.sanitized = true; } if (canHaveValue) { item = validateRule(item, path, itemId, meta, c); } //validate children let submeta = { errors: {} }; children = children ?.map( (currentChild, childId) => validateItem(currentChild, path.concat(id), childId, submeta, c) ); const nonEmptyChildren = children?.filter((currentChild) => (currentChild != undefined)); if (removeEmptyGroups) { children = nonEmptyChildren; } let sanitized = submeta.sanitized || (oldChildren?.size != children?.size); const isEmptyChildren = !nonEmptyChildren?.size; let canDrop = removeEmptyGroups && !isRoot; if (isGroupExt && field) { // to remove rule-group like "SOME cars" (with SOME/ALL/NONE, but without filters), we need to rely on removeIncompleteRules canDrop = removeIncompleteRules; } if (isEmptyChildren && childrenAreRequired) { _addError(meta, item, path, { key: isRoot ? constants.EMPTY_QUERY : isCase ? constants.EMPTY_CASE : isGroupExt ? constants.EMPTY_RULE_GROUP : constants.EMPTY_GROUP, args: { field }, fixed: canDrop, }); if (canDrop) { _setErrorsAsFixed(meta, item); item = undefined; } } if (sanitized) meta.sanitized = true; if (sanitized && item) item = item.set("children1", children); meta.errors = { ...meta.errors, ...(submeta?.errors || {}), }; return item; } /** * @param {Immutable.Map} item * @returns {Immutable.Map} */ function validateRule (item, path, itemId, meta, c) { const {removeIncompleteRules, removeEmptyRules, forceFix, config, oldConfig} = c; const {showErrorMessage} = config.settings; const canFix = !showErrorMessage || forceFix; const origItem = item; let id = item.get("id"); const type = item.get("type"); const isCase = type === "case_group"; let properties = item.get("properties"); if (!properties) { if (isCase) { properties = new Immutable.Map(); } else { const err = { key: constants.INCOMPLETE_RULE, args: {}, fixed: removeIncompleteRules || removeEmptyRules }; _addError(meta, item, path, err); return undefined; } } let field = properties.get("field") || null; if (isCase) { field = "!case_value"; } let fieldSrc = properties.get("fieldSrc") || null; let operator = properties.get("operator") || null; let operatorOptions = properties.get("operatorOptions"); let valueSrc = properties.get("valueSrc"); let value = properties.get("value"); let valueError = properties.get("valueError"); let fieldError = properties.get("fieldError"); const serializeRule = () => { return { field: field?.toJS?.() || field, fieldSrc, operator, operatorOptions: operatorOptions ? operatorOptions.toJS() : {}, valueSrc: valueSrc ? valueSrc.toJS() : null, value: value ? value.toJS() : null, valueError: valueError ? valueError.toJS() : null, fieldError: fieldError ? fieldError : null, }; }; const oldSerialized = serializeRule(); //const _wasValid = field && operator && value && !value.includes(undefined); if (!id && itemId) { id = itemId; item = item.set("id", id); meta.sanitized = true; } //validate field const fieldDefinition = field ? getFieldConfig(config, field) : null; if (field && !fieldDefinition) { _addError(meta, item, path, { key: constants.NO_CONFIG_FOR_FIELD, args: { field }, side: "lhs", fixed: removeIncompleteRules || removeEmptyRules, }); field = null; } if (field == null && !isCase) { properties = [ "operator", "operatorOptions", "valueSrc", "value", "valueError", "fieldError", "field" ].reduce((map, key) => map.delete(key), properties); operator = null; } if (!fieldSrc && field && !isCase) { fieldSrc = getFieldSrc(field); properties = properties.set("fieldSrc", fieldSrc); } //validate operator // Backward compatibility: obsolete operator range_between if (operator === "range_between" || operator === "range_not_between") { operator = operator === "range_between" ? "between" : "not_between"; // _addError(meta, item, path, { // type: "fix", // key: constants.FIXED_OPERATOR, // args: { from: properties.get("operator"), to: operator, field } // }); properties = properties.set("operator", operator); } const operatorDefinition = operator ? getOperatorConfig(config, operator, field) : null; if (operator && !operatorDefinition) { _addError(meta, item, path, { key: constants.NO_CONFIG_FOR_OPERATOR, args: { operator }, side: "op", fixed: removeIncompleteRules || removeEmptyRules, }); operator = null; } const availOps = field ? getOperatorsForField(config, field) : []; if (field && !isCase) { if (!availOps?.length) { _addError(meta, item, path, { key: constants.UNSUPPORTED_FIELD_TYPE, args: { field }, side: "lhs", fixed: removeIncompleteRules || removeEmptyRules, }); operator = null; } else if (operator && availOps.indexOf(operator) == -1) { if (operator === "is_empty" || operator === "is_not_empty") { // Backward compatibility: is_empty #494 operator = operator === "is_empty" ? "is_null" : "is_not_null"; // _addError(meta, item, path, { // type: "fix", // key: constants.FIXED_OPERATOR, // args: { from: properties.get("operator"), to: operator, field } // }); properties = properties.set("operator", operator); } else { _addError(meta, item, path, { key: constants.UNSUPPORTED_OPERATOR_FOR_FIELD, args: { operator, field }, side: "lhs", fixed: removeIncompleteRules || removeEmptyRules, }); operator = null; } } } if (operator == null && !isCase) { // do not unset operator ? properties = [ "operatorOptions", "valueSrc", "value", "valueError" ].reduce((map, key) => map.delete(key), properties); } //validate operator options operatorOptions = properties.get("operatorOptions"); //const _operatorCardinality = operator ? getOpCardinality(operatorDefinition) : null; if (!operator || operatorOptions && !operatorDefinition.options) { operatorOptions = null; properties = properties.delete("operatorOptions"); } else if (operator && !operatorOptions && operatorDefinition.options) { operatorOptions = defaultOperatorOptions(config, operator, field); properties = properties.set("operatorOptions", operatorOptions); } //validate values valueSrc = properties.get("valueSrc"); value = properties.get("value"); const isEndValue = true; let { newValue, newValueSrc, newValueError, validationErrors, newFieldError, fixedField, } = getNewValueForFieldOp(config, oldConfig, properties, field, operator, null, canFix, isEndValue); value = newValue; valueSrc = newValueSrc; valueError = newValueError; fieldError = newFieldError; field = fixedField; properties = properties.set("field", field); properties = properties.set("value", value); properties = properties.set("valueSrc", valueSrc); if (showErrorMessage) { properties = properties .set("valueError", valueError) .set("fieldError", fieldError); } else { properties = properties .delete("valueError") .delete("fieldError"); } const newSerialized = serializeRule(); const hasBeenSanitized = !deepEqual(oldSerialized, newSerialized); const compl = whatRulePropertiesAreCompleted(properties.toObject(), config); const isCompleted = isCase ? compl.parts.value : compl.score >= 3; if (hasBeenSanitized) { meta.sanitized = true; item = item.set("properties", properties); } validationErrors?.map(e => _addError(meta, item, path, e) ); if (!isCompleted) { if (isCase) { // todo } else { let shoudlRemoveRule = !compl.score ? removeEmptyRules : removeIncompleteRules; // if (shoudlRemoveRule && showErrorMessage) { // // try to be not so rude about removing incomplete rule with functions // const complLite = whatRulePropertiesAreCompleted(properties.toObject(), config, true); // const isCompletedLite = complLite.score >= 3; // if (isCompletedLite) { // shoudlRemoveRule = false; // } // } let incError = { key: constants.INCOMPLETE_RULE, args: {} }; if (!compl.parts.field) { incError.key = constants.INCOMPLETE_LHS; incError.side = "lhs"; } else if(!compl.parts.value) { incError.key = constants.INCOMPLETE_RHS; incError.side = "rhs"; if ( newSerialized.valueSrc?.[0] && newSerialized.valueSrc?.[0] != oldSerialized.valueSrc?.[0] && newSerialized.value?.[0] != undefined ) { // eg. operator `starts_with` supports only valueSrc "value" incError.key = constants.INVALID_VALUE_SRC; incError.args = { valueSrcs: newSerialized.valueSrc }; } } incError.fixed = shoudlRemoveRule; _addError(meta, item, path, incError); if (shoudlRemoveRule) { _setErrorsAsFixed(meta, item); item = undefined; } } } return item; } /** * * @param {bool} canFix true is useful for func values to remove bad args * @param {bool} isEndValue false if value is in process of editing by user * @return {array} [fixedValue, allErrors] - if `allErrors` is empty and `canFix` == true, `fixedValue` can differ from value if was fixed. * `allErrors` is an array of {key, args, fixed, fixedFrom, fixedTo} * If `args` === null, `key` should not be translated */ export const validateValue = ( config, leftField, field, operator, value, valueType, valueSrc, asyncListValues, canFix = false, isEndValue = false, canDropArgs = false ) => { let allErrors = []; let fixedValue = value; if (value != null) { if (valueSrc === "field") { [fixedValue, allErrors] = validateFieldValue(leftField, field, value, valueSrc, valueType, asyncListValues, config, operator, canFix, isEndValue); } else if (valueSrc === "func") { [fixedValue, allErrors] = validateFuncValue(leftField, field, value, valueSrc, valueType, asyncListValues, config, operator, canFix, isEndValue, canDropArgs); } else if (valueSrc === "value" || !valueSrc) { [fixedValue, allErrors] = validateNormalValue(field, value, valueSrc, valueType, asyncListValues, config, operator, canFix, isEndValue); } let fixedAllErrors = !allErrors?.find(e => !e.fixed); const shouldCallValidateFn = !!field // `validateValue` should not be available for valueSrc === "func" or "field" && !["field", "func"].includes(valueSrc) // eg. if value was > max, and fixed to max, but max value can not satisfy validateValue() in config && (!allErrors?.length || fixedAllErrors); if (shouldCallValidateFn) { //todo: why not just take fieldSettings from fieldConfig, why need to use getFieldWidgetConfig() ?? // const fieldConfig = getFieldConfig(config, field); // const fieldSettings = fieldConfig?.fieldSettings; const w = getWidgetForFieldOp(config, field, operator, valueSrc); const operatorDefinition = operator ? getOperatorConfig(config, operator, field) : null; const fieldWidgetDefinition = getFieldWidgetConfig(config, field, operator, w, valueSrc, { forExport: true }); const rightFieldDefinition = (valueSrc === "field" ? getFieldConfig(config, value) : null); const fieldSettings = fieldWidgetDefinition; // widget definition merged with fieldSettings const fn = fieldWidgetDefinition.validateValue; if (typeof fn === "function") { const args = [ fixedValue, fieldSettings, operator, operatorDefinition ]; if (valueSrc === "field") args.push(rightFieldDefinition); const validResult = fn.call(config.ctx, ...args); if (typeof validResult === "object" && validResult !== null && !Array.isArray(validResult)) { let newError; if (validResult.error?.key) { newError = {...validResult.error}; } else { // Note: `null` means it's not translated string! newError = {key: validResult.error, args: null}; } if (validResult.fixedValue !== undefined && canFix) { newError.fixed = true; newError.fixedFrom = fixedValue; newError.fixedTo = validResult.fixedValue; fixedValue = validResult.fixedValue; } allErrors.push(newError); } else if (typeof validResult === "boolean") { if (validResult == false) { allErrors.push({key: constants.INVALID_VALUE, args: {}}); } } else if (typeof validResult === "string") { allErrors.push({key: validResult, args: null}); } } } // if can't fix value, use defaultValue fixedAllErrors = !allErrors?.find(e => !e.fixed); if (allErrors?.length && !fixedAllErrors && canFix) { const fieldConfig = getFieldConfig(config, field); const fieldSettings = fieldConfig?.fieldSettings; const defaultValue = fieldSettings?.defaultValue; if (defaultValue !== undefined) { const lastError = allErrors[allErrors.length - 1]; lastError.fixed = true; lastError.fixedFrom = fixedValue; lastError.fixedTo = defaultValue; fixedValue = defaultValue; // mark all errors as fixed allErrors.map(e => { e.fixed = true; }); } } } return [fixedValue, allErrors]; }; /** * */ const validateValueInList = (value, listValues, canFix, isEndValue, removeInvalidMultiSelectValuesOnLoad) => { const values = Immutable.List.isList(value) ? value.toJS() : (value instanceof Array ? [...value] : undefined); let fixedValue = value; let allErrors = []; if (values) { const [goodValues, badValues] = values.reduce(([goodVals, badVals], val) => { const vv = getItemInListValues(listValues, val); if (vv == undefined) { return [goodVals, [...badVals, val]]; } else { return [[...goodVals, vv.value], badVals]; } }, [[], []]); const needFix = badValues.length > 0; // always remove bad values at tree validation as user can't unselect them (except AntDesign widget) canFix = canFix || removeInvalidMultiSelectValuesOnLoad === true; fixedValue = canFix && needFix ? goodValues : value; if (badValues.length) { const fixed = canFix && needFix; allErrors.push({ key: constants.BAD_MULTISELECT_VALUES, args: { badValues, count: badValues.length }, fixed, fixedFrom: fixed ? values : undefined, fixedTo: fixed ? fixedValue : undefined, }); } return [fixedValue, allErrors]; } else { const vv = getItemInListValues(listValues, value); if (vv == undefined) { fixedValue = canFix ? null : value; allErrors.push({ key: constants.BAD_SELECT_VALUE, args: { value }, fixed: canFix, fixedFrom: canFix ? value : undefined, fixedTo: canFix ? fixedValue : undefined, }); } else { fixedValue = vv.value; } return [fixedValue, allErrors]; } }; /** * */ const validateNormalValue = (field, value, valueSrc, valueType, asyncListValues, config, operator = null, canFix = false, isEndValue = false) => { let allErrors = []; let fixedValue = value; if (field) { const fieldConfig = getFieldConfig(config, field); const w = getWidgetForFieldOp(config, field, operator, valueSrc); const wConfig = config.widgets[w]; const wType = wConfig?.type; const jsType = wConfig?.jsType; const fieldSettings = fieldConfig?.fieldSettings; const listValues = fieldSettings?.treeValues || fieldSettings?.listValues; const isAsyncListValues = !!fieldSettings?.asyncFetch; // todo: for select/multiselect value can be string or number const canSkipTypeCheck = listValues || isAsyncListValues; // validate type if (valueType && wType && valueType != wType) { allErrors.push({ key: constants.INCORRECT_VALUE_TYPE, args: { wType, valueType } }); return [value, allErrors]; } if (jsType && !isTypeOf(value, jsType) && !canSkipTypeCheck) { allErrors.push({ key: constants.INCORRECT_VALUE_JS_TYPE, args: { jsType, valueTypeof: typeOf(value) } }); return [value, allErrors]; } if (fieldSettings) { // validate against list of values const realListValues = asyncListValues || listValues; // tip: "case_value" is deprecated, don't apply validation based on listValues if (realListValues && !fieldSettings.allowCustomValues && w !== "case_value") { [fixedValue, allErrors] = validateValueInList( value, realListValues, canFix, isEndValue, config.settings.removeInvalidMultiSelectValuesOnLoad ); } // validate length if (fieldSettings.maxLength > 0 && value != null && String(value).length > fieldSettings.maxLength) { fixedValue = canFix ? String(value).substring(0, fieldSettings.maxLength) : value; allErrors.push({ key: constants.VALUE_LENGTH_CONSTRAINT_FAIL, args: { value, length: String(value).length, fieldSettings }, fixed: canFix, fixedFrom: canFix ? value : undefined, fixedTo: canFix ? fixedValue : undefined, }); } // validate min/max const minMaxContext = fieldSettings.min != undefined && fieldSettings.max != undefined ? constants._CONTEXT_MIN_MAX : undefined; if (fieldSettings.min != null && value < fieldSettings.min) { fixedValue = canFix ? fieldSettings.min : value; allErrors.push({ key: constants.VALUE_MIN_CONSTRAINT_FAIL, args: { value, fieldSettings, context: minMaxContext }, fixed: canFix, fixedFrom: canFix ? value : undefined, fixedTo: canFix ? fixedValue : undefined, }); } if (fieldSettings.max != null && value > fieldSettings.max) { fixedValue = canFix ? fieldSettings.max : value; allErrors.push({ key: constants.VALUE_MAX_CONSTRAINT_FAIL, args: { value, fieldSettings, context: minMaxContext }, fixed: canFix, fixedFrom: canFix ? value : undefined, fixedTo: canFix ? fixedValue : undefined, }); } } } return [fixedValue, allErrors]; }; /** * */ const validateFieldValue = (leftField, field, value, _valueSrc, valueType, asyncListValues, config, operator = null, canFix = false, isEndValue = false) => { const allErrors = []; const {fieldSeparator, canCompareFieldWithField} = config.settings; const isFuncArg = typeof field == "object" && field?._isFuncArg; const leftFieldStr = Array.isArray(leftField) ? leftField.join(fieldSeparator) : leftField; const leftFieldConfig = getFieldConfig(config, leftField); const rightFieldStr = Array.isArray(value) ? value.join(fieldSeparator) : value; const rightFieldConfig = getFieldConfig(config, value); if (!rightFieldConfig) { allErrors.push({ key: constants.NO_CONFIG_FOR_FIELD_VALUE, args: { field: rightFieldStr } }); return [value, allErrors]; } if (leftField && rightFieldStr === leftFieldStr && !isFuncArg) { allErrors.push({ key: constants.CANT_COMPARE_FIELD_WITH_ITSELF, args: { field: leftFieldStr } }); return [value, allErrors]; } if (valueType && valueType != rightFieldConfig.type) { allErrors.push({ key: constants.INCORRECT_FIELD_TYPE, args: { field: rightFieldStr, type: rightFieldConfig.type, expected: valueType } }); return [value, allErrors]; } if (leftField && !isFuncArg && canCompareFieldWithField) { const canUse = canCompareFieldWithField( leftFieldStr, leftFieldConfig, rightFieldStr, rightFieldConfig, operator ); if (!canUse) { allErrors.push({ key: constants.CANT_COMPARE_FIELD_WITH_FIELD, args: { leftField: leftFieldStr, rightField: rightFieldStr } }); return [value, allErrors]; } } return [value]; }; /** * @param {bool} canDropArgs true only if user sets new func key */ const validateFuncValue = ( leftField, field, value, _valueSrc, valueType, asyncListValues, config, operator = null, canFix = false, isEndValue = false, canDropArgs = false ) => { let fixedValue = value; const allErrors = []; if (!value) { // empty value return [value]; } const funcKey = value.get?.("func"); if (!funcKey) { // it's not a function value return [value]; } const fieldDef = getFieldConfig(config, field); if (fieldDef?.funcs && !fieldDef.funcs.includes(funcKey)) { allErrors.push({ key: constants.UNSUPPORTED_FUNCTION_FOR_FIELD, args: { funcKey, field } }); return [value, allErrors]; } const funcConfig = getFuncConfig(config, funcKey); if (!funcConfig) { allErrors.push({ key: constants.NO_CONFIG_FOR_FUNCTION, args: { funcKey } }); return [value, allErrors]; } const funcName = funcConfig.label ?? funcKey; if (valueType && funcConfig.returnType != valueType) { allErrors.push({ key: constants.INCORRECT_FUNCTION_RETURN_TYPE, args: { funcKey, funcName, returnType: funcConfig.returnType, valueType } }); return [value, allErrors]; } //tip: Exception for canDropArgs (true only if changing func) - can fix/drop args to fit new func validations canFix = canFix || canDropArgs; for (const argKey in funcConfig.args) { const argConfig = funcConfig.args[argKey]; const args = fixedValue.get("args"); const argVal = args ? args.get(argKey) : undefined; const argDef = getFieldConfig(config, argConfig); const argName = argDef?.label ?? argKey; const argValue = argVal ? argVal.get("value") : undefined; const argValueSrc = argVal ? argVal.get("valueSrc") : undefined; if (argValue !== undefined) { const [fixedArgVal, argErrors] = validateValue( config, leftField, argDef, operator, argValue, argConfig.type, argValueSrc, asyncListValues, canFix, isEndValue, canDropArgs, ); const isValid = !argErrors?.length; const willFix = canFix && fixedArgVal !== argValue; //const willFixAllErrors = !isValid && willFix && !allErrors?.find(e => !e.fixed); //tip: reset to default ONLY if isEndValue==true const canDropOrReset = canFix && !isValid && !willFix && (isEndValue || canDropArgs); if (willFix) { fixedValue = fixedValue.setIn(["args", argKey, "value"], fixedArgVal); } if (canDropOrReset) { // reset to default fixedValue = fixedValue.deleteIn(["args", argKey]); fixedValue = setFuncDefaultArg(config, fixedValue, funcConfig, argKey); } if (!isValid) { const firstError = argErrors.find(e => !e.fixed && !e.ignore) ?? argErrors.find(e => !e.fixed) ?? argErrors[0]; const fixed = willFix || canDropOrReset; const ignore = argErrors.filter(e => !e.ignore).length === 0; if (firstError) { const argValidationError = translateValidation(firstError); allErrors.push({ key: constants.INVALID_FUNC_ARG_VALUE, args: { funcKey, funcName, argKey, argName, argValidationError, // more meta argErrors, }, ignore, fixed, fixedFrom: fixed ? argValue : undefined, fixedTo: fixed ? (willFix ? fixedArgVal : argConfig.defaultValue) : undefined, }); } } } else if (!argConfig.isOptional && (isEndValue || canDropArgs)) { const canReset = canFix && argConfig.defaultValue !== undefined && (isEndValue || canDropArgs); const canAddError = isEndValue; //tip: Exception for canDropArgs (true only if changing func) - don't show error about required args if (canAddError) { allErrors.push({ key: constants.REQUIRED_FUNCTION_ARG, args: { funcKey, funcName, argKey, argName }, fixed: canReset, fixedTo: canReset ? argConfig.defaultValue : undefined, ignore: !canReset, // tip: don't show error message in UI about missing arg after validation API call }); } if (canReset) { // set default fixedValue = fixedValue.deleteIn(["args", argKey]); fixedValue = setFuncDefaultArg(config, fixedValue, funcConfig, argKey); } } } return [fixedValue, allErrors]; }; /** * */ export const validateRange = (config, field, operator, values, valueSrcs) => { const operatorConfig = getOperatorConfig(config, operator, field); const operatorCardinality = operator ? getOpCardinality(operatorConfig) : null; const valueSrcsArr = (valueSrcs.toJS ? valueSrcs.toJS() : valueSrcs); const valuesArr = (values.toJS ? values.toJS() : values); const areValueSrcsPureValues = valueSrcsArr.filter(vs => vs == "value" || vs == null).length == operatorCardinality; let rangeError; if (operatorConfig?.validateValues && areValueSrcsPureValues) { const valueSrc = valueSrcsArr[0]; const w = getWidgetForFieldOp(config, field, operator, valueSrc); const fieldWidgetDefinition = getFieldWidgetConfig(config, field, operator, w, valueSrc); const jsValues = fieldWidgetDefinition?.toJS ? valuesArr.map(v => { let jsVal = fieldWidgetDefinition.toJS.call(config.ctx, v, fieldWidgetDefinition); if (jsVal instanceof Date) { jsVal = jsVal.getTime(); } return jsVal; }) : valuesArr; const validResult = operatorConfig.validateValues(jsValues); if (typeof validResult === "boolean") { if (validResult == false) { rangeError = { key: constants.INVALID_RANGE, args: { jsValues, values: valuesArr, } }; } } } return rangeError; }; /** * @param {Immutable.Map} current * @param {string} changedProp * @param {boolean} canFix (default: false) true - eg. set value to max if it > max or revert or drop * @param {boolean} isEndValue (default: false) true - if value is in process of editing by user * @param {boolean} canDropArgs (default: false) * @return {{canReuseValue, newValue, newValueSrc, newValueType, fixedField, operatorCardinality, newValueError, newFieldError, validationErrors}} */ export const getNewValueForFieldOp = function ( config, oldConfig = null, current, newField, newOperator, changedProp = null, canFix = false, isEndValue = false, canDropArgs = false ) { //const isValidatingTree = (changedProp === null); if (!oldConfig) oldConfig = config; const { keepInputOnChangeFieldSrc, convertableWidgets, clearValueOnChangeField, clearValueOnChangeOp, } = config.settings; const isCase = newField == "!case_value"; let currentField = current.get("field"); if (!currentField && isCase) { currentField = newField; } const currentFieldType = current.get("fieldType"); const currentFieldSrc = current.get("fieldSrc"); const currentOperator = current.get("operator"); const currentValue = current.get("value"); const currentValueSrc = current.get("valueSrc", new Immutable.List()); const currentValueType = current.get("valueType", new Immutable.List()); const currentValueError = current.get("valueError", new Immutable.List()); const asyncListValues = current.get("asyncListValues"); const isOkWithoutOperator = isCase; const currentOperatorConfig = getOperatorConfig(oldConfig, currentOperator); const newOperatorConfig = getOperatorConfig(config, newOperator, newField); const currentOperatorCardinality = isCase ? 1 : currentOperator ? getOpCardinality(currentOperatorConfig) : null; const operatorCardinality = isCase ? 1 : newOperator ? getOpCardinality(newOperatorConfig) : null; const currentFieldConfig = getFieldConfig(oldConfig, currentField); const newFieldConfig = getFieldConfig(config, newField); const isOkWithoutField = !currentField && currentFieldType && keepInputOnChangeFieldSrc; const currentType = currentFieldConfig?.type || currentFieldType; const newType = newFieldConfig?.type || !newField && isOkWithoutField && currentType; const currentListValuesType = currentFieldConfig?.listValuesType; const newListValuesType = newFieldConfig?.listValuesType; const currentFieldSimpleValue = currentField?.get?.("func") || currentField; const newFieldSimpleValue = newField?.get?.("func") || newField; const hasFieldChanged = newFieldSimpleValue != currentFieldSimpleValue; let validationErrors = []; let canReuseValue = (currentField || isOkWithoutField) && (currentOperator && newOperator || isOkWithoutOperator) && currentValue != undefined; if ( !(currentType && newType && currentType == newType) || changedProp === "field" && hasFieldChanged && clearValueOnChangeField || changedProp === "operator" && clearValueOnChangeOp ) { canReuseValue = false; } if (hasFieldChanged && selectTypes.includes(newType)) { if (newListValuesType && newListValuesType === currentListValuesType) { // ok } else { // different fields of select types has different listValues canReuseValue = false; } } if (!isOkWithoutOperator && (!currentValue?.size && operatorCardinality || currentValue?.size && !operatorCardinality)) { canReuseValue = false; } // validate func LHS let newFieldError; if (currentFieldSrc === "func" && newField) { const [fixedField, fieldErrors] = validateValue( config, null, null, newOperator, newField, newType, currentFieldSrc, asyncListValues, canFix, isEndValue, canDropArgs ); const isValid = !fieldErrors?.length; const willFix = fixedField !== newField; const willFixAllErrors = !isValid && willFix && !fieldErrors.find(e => !e.fixed); const willRevert = canFix && !isValid && !willFixAllErrors && !!changedProp && newField !== currentField; const willDrop = false; //canFix && !isValid && !willFixAllErrors && !willRevert && !changedProp; if (willDrop) { newField = null; } else if (willRevert) { newField = currentField; } else if (willFix) { newField = fixedField; } if (!isValid) { const showError = !isValid && !willFixAllErrors && !willDrop && !willRevert; const firstError = fieldErrors.find(e => !e.fixed && !e.ignore); if (showError && firstError) { newFieldError = translateValidation(firstError); } // tip: even if we don't show errors, but revert LHS, put the reason of revert fieldErrors.map(e => validationErrors.push({ side: "lhs", ...e, fixed: e.fixed || willRevert || willDrop, })); } } // compare old & new widgets for (let i = 0 ; i < operatorCardinality ; i++) { const vs = currentValueSrc.get(i) || null; const currentWidget = getWidgetForFieldOp(oldConfig, currentField, currentOperator, vs); const newWidget = getWidgetForFieldOp(config, newField, newOperator, vs); // need to also check value widgets if we changed operator and current value source was 'field' // cause for select type op '=' requires single value and op 'in' requires array value const currentValueWidget = vs === "value" ? currentWidget : getWidgetForFieldOp(oldConfig, currentField, currentOperator, "value"); const newValueWidget = vs === "value" ? newWidget : getWidgetForFieldOp(config, newField, newOperator, "value"); const canReuseWidget = newValueWidget == currentValueWidget || (convertableWidgets[currentValueWidget] || []).includes(newValueWidget) || !currentValueWidget && isOkWithoutField; if (!canReuseWidget) { canReuseValue = false; } } if (currentOperator != newOperator && [currentOperator, newOperator].includes("proximity")) { canReuseValue = false; } const firstValueSrc = currentValueSrc.first(); const firstWidgetConfig = getFieldWidgetConfig(config, newField, newOperator, null, firstValueSrc); let valueSources = getValueSourcesForFieldOp(config, newField, newOperator, null); if (!newField && isOkWithoutField) { valueSources = Object.keys(config.settings.valueSourcesInfo); } const defaultValueSrc = valueSources[0]; let defaultValueType; if (operatorCardinality === 1 && firstWidgetConfig && firstWidgetConfig.type !== undefined) { defaultValueType = firstWidgetConfig.type; } else if (operatorCardinality === 1 && newFieldConfig && newFieldConfig.type !== undefined) { defaultValueType = newFieldConfig.type === "!group" ? "number" : newFieldConfig.type; } // changed operator from '==' to 'between' let canExtendValueToRange = canReuseValue && changedProp === "operator" && currentOperatorCardinality === 1 && operatorCardinality === 2; let valueFixes = []; let valueSrcFixes = []; let valueTypeFixes = []; let valueErrors = Array.from({length: operatorCardinality}, () => null); if (canReuseValue) { for (let i = 0 ; i < operatorCardinality ; i++) { let v = currentValue.get(i); let vType = currentValueType.get(i) || null; let vSrc = currentValueSrc.get(i) || null; if (canExtendValueToRange && i === 1) { v = valueFixes[0] !== undefined ? valueFixes[0] : currentValue.get(0); valueFixes[i] = v; vType = currentValueType.get(0) || null; vSrc = currentValueSrc.get(0) || null; } const isValidSrc = vSrc ? (valueSources.find(v => v == vSrc) !== undefined) : true; const [fixedValue, allErrors] = validateValue( config, newField, newField, newOperator, v, vType, vSrc, asyncListValues, canFix, isEndValue, canDropArgs ); const isValid = !allErrors?.length; // Allow bad value with error message // But not on field change - in that case just drop bad value that can't be reused // ? Maybe we should also drop bad value on op change? // For bad multiselect value we have both error message + fixed value. // If we show error message, it will gone on next tree validation const willFix = fixedValue !== v; const willFixAllErrors = !isValid && willFix && !allErrors?.find(e => !e.fixed); const allErrorsHandled = !allErrors?.find(e => !e.fixed && !e.ignore); // tip: is value src is invalid, drop ANYWAY // tip: Edge case in demo: // Given "login = LOWER(?)", change config to not show errors -> "LOWER(?)" will be dropped // We don't want to drop func completely, so need to add `allErrorsAheHandled` or `vSrc !== "func"` // todo: `hasFieldChanged` is not needed ? const willDrop = !isValidSrc || canFix && !isValid && !willFixAllErrors && (!allErrorsHandled || hasFieldChanged); if (!isValid) { // tip: even if we don't show errors, but drop bad values, put the reason of removal allErrors?.map(e => validationErrors.push({ side: "rhs", delta: i, ...e, fixed: e.fixed || willDrop, })); } if (willDrop) { valueFixes[i] = null; if (i === 0) { delete valueFixes[1]; } } const showError = !isValid && !willFix; const firstError = allErrors?.find(e => !e.fixed && !e.ignore); if (showError && firstError) { valueErrors[i] = translateValidation(firstError); } if (willFix) { valueFixes[i] = fixedValue; } if (canExtendValueToRange && i === 0 && !isValid && !willFix) { // don't extend bad value to range canExtendValueToRange = false; } if (canExtendValueToRange && i === 0 && ["func", "field"].includes(vSrc)) { // don't extend func/field value, only primitive value canExtendValueToRange = false; } } } // if can't reuse, get defaultValue if (!canReuseValue) { for (let i = 0 ; i < operatorCardinality ; i++) { if (operatorCardinality === 1) { // tip: default range values (for cardinality > 1) are not supported yet, todo const dv = getFirstDefined([ newFieldConfig?.defaultValue, newFieldConfig?.fieldSettings?.defaultValue, firstWidgetConfig?.defaultValue ]); valueFixes[i] = dv; if (dv?.func) { valueSrcFixes[i] = "func"; //tip: defaultValue of src "field"