diff --git a/package.json b/package.json index 0b000640..314e22be 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "dayjs": "^1.11.10", "dompurify": "^3.0.6", "eventemitter3": "^5.0.0", + "fast-json-patch": "^3.1.1", "fetch-ponyfill": "^7.1.0", "inputmask": "^5.0.9-beta.45", "json-logic-js": "^2.0.2", diff --git a/src/utils/formUtil.ts b/src/utils/formUtil.ts index f54c8e80..d468a6ab 100644 --- a/src/utils/formUtil.ts +++ b/src/utils/formUtil.ts @@ -1,5 +1,5 @@ -import { last, get, set, isEmpty, isNil, isObject, has } from "lodash"; - +import { last, get, set, isEmpty, isNil, isObject, has, isString, forOwn, round, chunk, pad, isPlainObject } from "lodash"; +import { compare, applyPatch } from 'fast-json-patch'; import { AsyncComponentDataCallback, CheckboxComponent, @@ -543,3 +543,486 @@ export function getComponentActualValue(compPath: string, data: any, row: any) { } return value; } + +/** + * Determine if a component is a layout component or not. + * + * @param {Object} component + * The component to check. + * + * @returns {Boolean} + * Whether or not the component is a layout component. + */ +export function isLayoutComponent(component: any) { + return Boolean( + (component.columns && Array.isArray(component.columns)) || + (component.rows && Array.isArray(component.rows)) || + (component.components && Array.isArray(component.components)) + ); +} + +/** + * Matches if a component matches the query. + * + * @param component + * @param query + * @return {boolean} + */ +export function matchComponent(component: any, query: any) { + if (isString(query)) { + return (component.key === query) || (component.path === query); + } + else { + let matches = false; + forOwn(query, (value, key) => { + matches = (get(component, key) === value); + if (!matches) { + return false; + } + }); + return matches; + } +} + +/** + * Get a component by its key + * + * @param {Object} components + * The components to iterate. + * @param {String|Object} key + * The key of the component to get, or a query of the component to search. + * + * @returns {Object} + * The component that matches the given key, or undefined if not found. + */ +export function getComponent(components: any, key: any, includeAll: any) { + let result; + eachComponent(components, (component: any, path: any) => { + if ((path === key) || (component.path === key)) { + result = component; + return true; + } + }, includeAll); + return result; +} + +/** + * Finds a component provided a query of properties of that component. + * + * @param components + * @param query + * @return {*} + */ +export function searchComponents(components: any, query: any) { + const results: any[] = []; + eachComponent(components, (component: any) => { + if (matchComponent(component, query)) { + results.push(component); + } + }, true); + return results; +} + + +/** + * Remove a component by path. + * + * @param components + * @param path + */ +export function removeComponent(components: any, path: string) { + // Using _.unset() leave a null value. Use Array splice instead. + // @ts-ignore + var index = path.pop(); + if (path.length !== 0) { + components = get(components, path); + } + components.splice(index, 1); +} + +/** + * Returns if this component has a conditional statement. + * + * @param component - The component JSON schema. + * + * @returns {boolean} - TRUE - This component has a conditional, FALSE - No conditional provided. + */ +export function hasCondition(component: any) { + return Boolean( + (component.customConditional) || + (component.conditional && ( + component.conditional.when || + component.conditional.json || + component.conditional.condition + )) + ); +} + +/** + * Extension of standard #parseFloat(value) function, that also clears input string. + * + * @param {any} value + * The value to parse. + * + * @returns {Number} + * Parsed value. + */ +export function parseFloatExt(value: any) { + return parseFloat(isString(value) + ? value.replace(/[^\de.+-]/gi, '') + : value); +} + +/** + * Formats provided value in way how Currency component uses it. + * + * @param {any} value + * The value to format. + * + * @returns {String} + * Value formatted for Currency component. + */ +export function formatAsCurrency(value: string) { + const parsedValue = parseFloatExt(value); + + if (isNaN(parsedValue)) { + return ''; + } + + const parts = round(parsedValue, 2) + .toString() + .split('.'); + parts[0] = chunk(Array.from(parts[0]).reverse(), 3) + .reverse() + .map((part) => part + .reverse() + .join('')) + .join(','); + parts[1] = pad(parts[1], 2, '0'); + return parts.join('.'); +} + +/** + * Escapes RegEx characters in provided String value. + * + * @param {String} value + * String for escaping RegEx characters. + * @returns {string} + * String with escaped RegEx characters. + */ +export function escapeRegExCharacters(value: string) { + return value.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&'); +} +/** + * Get the value for a component key, in the given submission. + * + * @param {Object} submission + * A submission object to search. + * @param {String} key + * A for components API key to search for. + */ +export function getValue(submission: any, key: string) { + const search = (data: any) => { + if (isPlainObject(data)) { + if (has(data, key)) { + return get(data, key); + } + + let value = null; + + forOwn(data, (prop) => { + const result = search(prop); + if (!isNil(result)) { + value = result; + return false; + } + }); + + return value; + } + else { + return null; + } + }; + + return search(submission.data); +} + +/** + * Iterate over all components in a form and get string values for translation. + * @param form + */ +export function getStrings(form: any) { + const properties = ['label', 'title', 'legend', 'tooltip', 'description', 'placeholder', 'prefix', 'suffix', 'errorLabel', 'content', 'html']; + const strings: any = []; + eachComponent(form.components, (component: any) => { + properties.forEach(property => { + if (component.hasOwnProperty(property) && component[property]) { + strings.push({ + key: component.key, + type: component.type, + property, + string: component[property] + }); + } + }); + if ((!component.dataSrc || component.dataSrc === 'values') && component.hasOwnProperty('values') && Array.isArray(component.values) && component.values.length) { + component.values.forEach((value: any, index: number) => { + strings.push({ + key: component.key, + property: `value[${index}].label`, + string: component.values[index].label + }); + }); + } + + // Hard coded values from Day component + if (component.type === 'day') { + [ + 'day', + 'month', + 'year', + 'Day', + 'Month', + 'Year', + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december' + ].forEach(string => { + strings.push({ + key: component.key, + property: 'day', + string, + }); + }); + + if (component.fields.day.placeholder) { + strings.push({ + key: component.key, + property: 'fields.day.placeholder', + string: component.fields.day.placeholder, + }); + } + + if (component.fields.month.placeholder) { + strings.push({ + key: component.key, + property: 'fields.month.placeholder', + string: component.fields.month.placeholder, + }); + } + + if (component.fields.year.placeholder) { + strings.push({ + key: component.key, + property: 'fields.year.placeholder', + string: component.fields.year.placeholder, + }); + } + } + + if (component.type === 'editgrid') { + const string = component.addAnother || 'Add Another'; + if (component.addAnother) { + strings.push({ + key: component.key, + property: 'addAnother', + string, + }); + } + } + + if (component.type === 'select') { + [ + 'loading...', + 'Type to search' + ].forEach(string => { + strings.push({ + key: component.key, + property: 'select', + string, + }); + }); + } + }, true); + + return strings; +} + +// ????????????????????????? +// questionable section + +export function generateFormChange(type: any, data: any) { + let change; + switch (type) { + case 'add': + change = { + op: 'add', + key: data.component.key, + container: data.parent.key, // Parent component + path: data.path, // Path to container within parent component. + index: data.index, // Index of component in parent container. + component: data.component + }; + break; + case 'edit': + change = { + op: 'edit', + key: data.originalComponent.key, + patches: compare(data.originalComponent, data.component) + }; + + // Don't save if nothing changed. + if (!change.patches.length) { + change = null; + } + break; + case 'remove': + change = { + op: 'remove', + key: data.component.key, + }; + break; + } + + return change; +} + +export function applyFormChanges(form: any, changes: any) { + const failed: any = []; + changes.forEach(function(change: any) { + var found = false; + switch (change.op) { + case 'add': + var newComponent = change.component; + + // Find the container to set the component in. + findComponent(form.components, change.container, null, function(parent: any) { + if (!change.container) { + parent = form; + } + + // A move will first run an add so remove any existing components with matching key before inserting. + findComponent(form.components, change.key, null, function(component: any, path: any) { + // If found, use the existing component. (If someone else edited it, the changes would be here) + newComponent = component; + removeComponent(form.components, path); + }); + + found = true; + var container = get(parent, change.path); + container.splice(change.index, 0, newComponent); + }); + break; + case 'remove': + findComponent(form.components, change.key, null, function(component: any, path: any) { + found = true; + const oldComponent = get(form.components, path); + if (oldComponent.key !== component.key) { + path.pop(); + } + removeComponent(form.components, path); + }); + break; + case 'edit': + findComponent(form.components, change.key, null, function(component: any, path: any) { + found = true; + try { + const oldComponent = get(form.components, path); + const newComponent = applyPatch(component, change.patches).newDocument; + + if (oldComponent.key !== newComponent.key) { + path.pop(); + } + + set(form.components, path, newComponent); + } + catch (err) { + failed.push(change); + } + }); + break; + case 'move': + break; + } + if (!found) { + failed.push(change); + } + }); + + return { + form, + failed + }; +} + + /** + * This function will find a component in a form and return the component AND THE PATH to the component in the form. + * Path to the component is stored as an array of nested components and their indexes.The Path is being filled recursively + * when you iterating through the nested structure. + * If the component is not found the callback won't be called and function won't return anything. + * + * @param components + * @param key + * @param fn + * @param path + * @returns {*} + */ +export function findComponent(components: any, key: any, path: any, fn: any) { + if (!components) return; + path = path || []; + + if (!key) { + return fn(components); + } + + components.forEach(function(component: any, index: any) { + var newPath = path.slice(); + // Add an index of the component it iterates through in nested structure + newPath.push(index); + if (!component) return; + + if (component.hasOwnProperty('columns') && Array.isArray(component.columns)) { + newPath.push('columns'); + component.columns.forEach(function(column: any, index: any) { + var colPath = newPath.slice(); + colPath.push(index); + colPath.push('components'); + findComponent(column.components, key, colPath, fn); + }); + } + + if (component.hasOwnProperty('rows') && Array.isArray(component.rows)) { + newPath.push('rows'); + component.rows.forEach(function(row: any, index: any) { + var rowPath = newPath.slice(); + rowPath.push(index); + row.forEach(function(column: any, index: any) { + var colPath = rowPath.slice(); + colPath.push(index); + colPath.push('components'); + findComponent(column.components, key, colPath, fn); + }); + }); + } + + if (component.hasOwnProperty('components') && Array.isArray(component.components)) { + newPath.push('components'); + findComponent(component.components, key, newPath, fn); + } + + if (component.key === key) { + //Final callback if the component is found + fn(component, newPath, components); + } + }); +} diff --git a/yarn.lock b/yarn.lock index 7e756de3..d9217b39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2028,6 +2028,11 @@ fast-glob@^3.2.9: merge2 "^1.3.0" micromatch "^4.0.4" +fast-json-patch@^3.1.1: + version "3.1.1" + resolved "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz#85064ea1b1ebf97a3f7ad01e23f9337e72c66947" + integrity sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ== + fast-json-stable-stringify@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"