/** @module helpers */
/**
* Deeply clone an object.
* @param {object} target - object to merge things into
* @param {object} source - data source
* @returns {object} - cloned object
* @example const result = mergeDeep({ a: 1 }, { b: 2 });
* // result === { a: 1, b: 2 }
* @example const result = mergeDeep({ a: 1 }, { a: 2 });
* // result === { a: 2 }
*/
export function mergeDeep(target, source) {
if (typeof target !== 'object') {
target = {};
}
if (Array.isArray(target) && !Array.isArray(source)) {
target = {};
}
Object.keys(source).forEach(key => {
if (source[key] === null) {
target[key] = null;
} else if (Array.isArray(source[key])) {
target[key] = source[key].slice();
} else if (typeof source[key] === 'object') {
target[key] = mergeDeep(target[key], source[key]);
} else {
target[key] = source[key];
}
});
return target;
}
/**
* Check if a value is an object
* @param {any} objValue
* @returns {boolean} - true if object, false if not
* @example const result = isObject({ a: 1 });
* // result === true
* @example const result = isObject(1);
* // result === false
* @example const result = isObject(null);
* // result === false
* @example const result = isObject(undefined);
* // result === false
* @example const result = isObject('a');
* // result === false
* @example const result = isObject([1, 2, 3]);
* // result === false
* @example const result = isObject(new Date());
* // result === false
* @example const result = isObject(new Map());
* // result === false
*/
export function isObject(objValue) {
const type = typeof objValue;
const notNull = !!objValue;
return (notNull && type === 'object' && objValue.constructor === Object) ? true : false;
}
/**
* Helper method to check if a value is null, undefined, or ''
* @example
* isEmpty('') // true
* isEmpty(null) // true
* isEmpty(undefined) // true
* isEmpty(0) // false
* @function isEmpty
* @param {string | number} value
* @returns {boolean} - true if empty, false if not
*/
export const isEmpty = (value) => {
if (Array.isArray(value) && value.length === 0) {
return true;
}
if (value === '' || value === null || value === undefined) {
return true;
}
return false;
};
/**
* Given an object and a path, return the value at that path. If the path is not found, return undefined.
* @example
* const obj = { a: { b: { c: 1 } } };
* const result = objectReducer(obj, 'a.b.c');
* // result === 1
*
* @param {object} obj - object to search
* @param {string} path - path to value
* @param {string} separator - separator for path (default: '.')
* @returns {any} value at path
*/
export function objectReducer(obj, path, separator = '.') {
return path.split(separator).reduce((r, k) => r?.[k], obj);
}
/**
* Cases, we don't need no stinking cases.
* @example
* const result = caseless('a', 'A');
* // result === 0
* @example
* const result = caseless('a', 'b');
* // result === -1
* @example
* const result = caseless('b', 'a');
* // result === 1
* @example
* const result = caseless('a', '');
* // result === -1
* @param {*} valueA
* @param {*} valueB
* @returns {number} - 0 if equal, -1 if valueA < valueB, 1 if valueA > valueB
*/
export function caseless(valueA, valueB) {
if (valueA === valueB) {
return 0;
}
// These two conditionals will put empty string, null, or undefined as the last items in a sort.
if (valueA === null || valueA === undefined || valueA === '') {
return 1;
}
if (valueB === null || valueB === undefined || valueB === '') {
return -1;
}
return (valueA)?.toLowerCase().localeCompare((valueB)?.toLowerCase());
}
/**
* Used to correctly handle sorting floats
* @example const result = floatCompare('1.1', '1.2');
* // result === -1
* @example const result = floatCompare('1.2', '1.1');
* // result === 1
* @example const result = floatCompare('1.1', '1.1');
* // result === 0
* @param valueA
* @param valueB
* @returns {number} - 0 if equal, -1 if valueA < valueB, 1 if valueA > valueB
*/
export function floatCompare(valueA, valueB) {
const nA = parseFloat(valueA);
const nB = parseFloat(valueB);
if (valueA === valueB) {
return 0;
}
// These two conditionals will put empty string, null, or undefined as the first items in a sort.
if (Number.isNaN(nA)) {
return -1;
}
if (Number.isNaN(nB)) {
return 1;
}
return (nA > nB) ? 1 : -1;
}
/**
* Sorts array of objects by a given prop and returns a copy
* @example const result = sortOn([{ label: 'b' }, { label: 'a' }], 'label');
* // result === [{ label: 'a' }, { label: 'b' }]
* @param items
* @param prop
* @returns {Array} - sorted array
*/
export function sortOn(items, prop = 'label', isNumber = false) {
if (!items || items.length < 2) {
return items && items.length ? [...items] : [];
}
if (!Array.isArray(items)) {
return [items];
}
if (isNumber) {
return [...items].sort((a, b) => floatCompare(a?.[prop], b?.[prop]));
}
return [...items].sort((a, b) => caseless(a?.[prop], b?.[prop]));
}
/**
* Create a zoom option object
* @param {*} label
* @param {*} value
* @param {*} extent
* @returns {object} - zoom option object
*/
export function createZoomOption(label, value, extent) {
return { label, value, extent };
}
/**
* Create a zoom option object from a zoomable item
* @param {object} item
* @param {object} item.fields - fields object
* @param {object} item.fields.extent - extent object
* @param {string} labelProp
* @param {string} valueProp
* @returns {object} - zoom option object
*/
export function zoomableOption(item, labelProp = 'label', valueProp = 'id') {
return createZoomOption(item[labelProp], item[valueProp], item.fields?.extent);
}
/**
* Get a list of zoom options from a list of zoomable items
* @param {Object[]} zoomables - list of zoomable items
* @param {string} labelProp - property to use for label
* @param {string} valueProp - property to use for value
* @returns {Object[]}
*/
export function zoomablesOptions(zoomables, labelProp = 'label', valueProp = 'id') {
return sortOn(zoomables, 'label').map((item) => zoomableOption(item, labelProp, valueProp));
}
/**
* Returns possible choices for a given section and model
* @param {Object} layout - layout object
* @param {string} sectionName - name of the section
* @param {string} modelName - name of the model
* @returns {Object[]} - list of possible choices or an empty array
*/
export const getSectionChoices = (layout, sectionName, modelName) => {
if (!layout || !layout.sections) {
return [];
}
const section = layout.sections.find(s => s.name === sectionName);
if (!section?.layout) {
return [];
}
const type = section.layout.find(l => l.model.name === modelName);
const choices = type?.possibleChoices ? type?.possibleChoices.map(item => ({
label: item.name,
id: item.id,
})) : [];
return choices;
};
/**
* Simple layout process method to convert the layout object into a format that the layout builder can use.
* If you are using GenericForm you should be using the useFormLayout and parseFormLayout methods instead.
* @param {object} layout - The layout object to process
* @return {object} - The processed layout object
*/
export function processLayout(layout) {
if (!layout || !layout.sections) {
return layout;
}
if (layout.type === 1) {
console.warn('You are processing a form layout with the processLayout method. You should be using the useFormLayout and parseFormLayout methods instead.');
}
const sections = layout.sections.map((section) => {
const fields = section.layout.map(getStructure);
// This is a small hack to make the layout work with the old layout builder.
// If this layout structure predates the new layout types we need to remove the "fields." prefix from the path.
if (layout.type === undefined || layout.type === null || layout.type === 2) {
// If this a grid layout we need to remove the "fields." prefix from the path.
fields.forEach((field) => {
field.path = field.path.replace('fields.', '');
});
}
return { name: section.name, fields: fields };
});
return sections;
}
/**
* Process a layout object
* @param {object} layout
* @returns {object} - The processed layout object
*/
export function processGenericLayout(layout) {
if (!layout || !layout.sections || layout.isGeneric) {
return layout;
}
const sections = layout.sections.map((section) => {
const fields = section.layout.map(getStructure);
return { name: section.name, fields: fields };
});
const layoutTypes = {
1: 'Form',
2: 'Grid',
};
const data = {};
if (layout.data) {
data.source = layout.data;
}
if (layout.grid) {
data.gridConfig = layout.grid;
}
let newLayout = {
sections: sections,
title: layout.name,
id: layout.id,
type: layoutTypes[layout.type] || 'Unknown Layout Type: ' + layout.type,
editable: layout.editable,
data,
isGeneric: true,
};
return newLayout;
}
function getStructure(field) {
const model = field.model || {};
const name = model.name || `unknown${model.id}`;
const { label, linkFormat } = field;
const required = !!field.required;
const readOnly = !!field.readOnly;
const disabled = false;
const hidden = !!field.hidden;
const dynField = {
hidden,
required,
disabled,
type: field.type,
path: field.path,
isArrayData: !!model.multiple,
isStringId: !!model.isStringId,
render: {
type: field.type,
label,
name,
hidden,
required,
disabled,
readOnly,
linkFormat,
},
source: model
};
if (field.possibleChoices) {
const choices = field?.possibleChoices ? field?.possibleChoices.map(item => ({
label: item.name,
id: item.id,
source: item,
})) : [];
dynField.render.choices = choices;
}
if (field.width) {
dynField.width = field.width;
}
if (field.flex) {
dynField.flex = field.flex;
}
if (field.nullValue) {
dynField.nullValue = field.nullValue;
}
return dynField;
}
export const functionOrDefault = (f, fdefault) => {
if (typeof f === 'function') {
return f;
}
return fdefault;
};
export const hasPermission = (permission, acl) => {
return acl?.includes(permission) || false;
};
export const hasAllPermissions = (permissions, acl) => {
for (let perm of permissions) {
if (!acl?.includes(perm)) {
return false;
}
}
return true;
};
export const hasAnyPermissions = (permissions, acl) => {
for (let perm of permissions)
if (acl.includes(perm))
return true;
return false;
};
/**
* Number with zero padding
* @param {number} num Number to pad
* @param {number} size amount of padding
* @returns {string} The padded number
*/
export function zeroPad(num, size = 3) {
return num.toString().padStart(size, '0');
}
/**
* Format a phone number
* @param {string} phoneNumberString
* @returns {string} The formatted phone number
* @example formatPhoneNumber('1234567890') => '(123) 456-7890'
*/
export const formatPhoneNumber = (phoneNumberString) => {
var cleaned = ('' + phoneNumberString).replace(/\D/g, '');
var match = cleaned.match(/^(\d{3})(\d{3})(\d{4})$/);
if (match) {
return '(' + match[1] + ') ' + match[2] + '-' + match[3];
}
return null;
};
export function dateStringNormalizer(dateString) {
if (!dateString) {
return null;
}
return dateString.replace(/-/g, '/').replace(/T.+/, '');
}
/**
* Return a date string from either an ag grid cell object or string
* @param {string | object} inc - The date string or ag grid cell object
* @returns {string} The date as a string
*/
export function dateFormatter(inc) {
if (inc instanceof Date) {
return dateToString(inc);
}
if ((!inc || (typeof inc === 'object' && !inc.value))) {
return '';
}
const normalized = dateStringNormalizer(typeof inc === 'object' ? inc.value : inc);
const date = new Date(normalized);
return dateToString(date);
}
/**
* Convert a date to a string with zero padding
* @param {Date} date
* @returns {string} The date as a string
*/
export function dateToString(date) {
if (!date) {
return '';
}
return `${zeroPad(date.getMonth() + 1, 2)}/${zeroPad(date.getDate(), 2)}/${date.getFullYear()}`;
}
/**
* Get the color for the header text
* @param {object} theme
* @returns {string} The color for the header text
*/
export function headerColor (theme) {
return theme.palette.text.header;
}
/**
* Get the color for the special text
* @param {object} theme
* @returns {string} The color for the special text
*/
export function specialColor (theme) {
return theme.palette.text.special;
}
/**
* Useful when you want/need to share an RGBA color in more than one place with different opacity levels.
* If you want to show a color on a map with opacity 0.6 but want the legend value to use the same color
* but with opacity 1.0.
* @param {string} color an rgba color
* @param {number} opacity the opacity to convert to
* @returns {string} An rgba color with it's opacity modified
*/
export function modifyColorOpacity(color, opacity) {
const opac = opacity || 1;
if (!color || color === undefined)
return color;
const cleanedColor = color.replace('rgba(', '').replace(')', '');
const colorArray = cleanedColor.split(',');
const newColor = `rgba(${colorArray[0]}, ${colorArray[1]}, ${colorArray[2]}, ${opac})`;
return newColor;
}
/**
* Method to capitalize the first letter of a string
* @example capitalizeFirstLetter('hello') => 'Hello'
* @example capitalizeFirstLetter('Hello') => 'Hello'
* @param {string} string
* @returns {string} The capitalized string
*/
export function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* Return a currency string from either an ag grid cell object or string
* @example currencyFormatter(1234.56) => '$1,234.56'
* @param {object|string} inc - ag grid cell params
* @returns {string} a currency string
*/
export function currencyFormatter(inc) {
if (!inc || (typeof inc === 'object' && !inc.value)) {
return '';
}
const numOS = typeof inc === 'object' ? inc.value : inc;
const num = typeof numOS === 'string' ? parseFloat(numOS) : numOS;
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
}
/**
* Return a currency string from either an ag grid cell object or string
* @param {object|string} inc - ag grid cell params
* @param {number} decimalPlaces - number of decimal places to round to
* @returns {string}
*/
export function floatFormatter(inc, decimalPlaces) {
if (!inc || (typeof inc === 'object' && !inc.value)) {
return '';
}
const numOS = typeof inc === 'object' ? inc.value : inc;
const num = typeof numOS === 'string' ? parseFloat(numOS) : numOS;
return new Intl.NumberFormat('en-US', { style: 'decimal', currency: 'USD' }).format(parseFloat(num.toFixed(decimalPlaces)));
}
/**
* Convert an array of objects into a string using prop
* @param {Array} value - Array to process
* @param {string} prop - Display property (also used for sorting)
* @param {string} delim - String to place between each value
* @returns {array}
*/
export function arrayToDisplay(value, prop = 'name', delim = ', ') {
return value ? sortOn(value, prop).map((reg) => reg[prop]).join(delim) : null;
}
/**
* Convert an object or array of objects to urls. Intended for use with DetailList/DetailContent component
* @param {Object | Array} value - thing or things to turn into urls
* @param {string} slug - url slug
* @param {boolean} sameTab - open in same tab
* @param {string} displayProp - display property for sorthing and link text
* @param {string} idProp - id to use with slug to generate url
* @returns {object}
*/
export function arrayToUrls(value, slug, skipIds = [], sameTab = true, displayProp = 'name', idProp = 'id') {
if (!value) {
return null;
}
// Wrap non-array in array, sort, map, ???, profit
return sortOn(Array.isArray(value) ? value : [value], displayProp).map((prop) => {
const iID = prop[idProp];
const createUrl = !(skipIds.length && skipIds.includes(iID));
return {
sameTab,
name: prop[displayProp] || iID.toString(),
url: createUrl ? `#/${slug}/${iID}` : null,
};
});
}
/**
* Convert array of objects to a string for a given key and separator
* @param items
* @param {string} key
* @param {string} separator
* @returns {string}
*/
export function objectsToString(items, key = 'name', separator = ', ') {
if (!items || !Array.isArray(items) || items.length === 0) {
return '';
}
return items.map((item) => item[key]).join(separator) || '';
}
/**
* Replaces placeholders in a link format string with values from a data node object.
* @param {string} linkFormat - The link format string with placeholders wrapped in curly braces.
* @param {Object} dataNode - The data node object containing values to replace the placeholders.
* @returns {string} The link format string with placeholders replaced with values from the data node object.
*/
export function convertToLinkFormat(linkFormat, dataNode) {
let link = linkFormat;
// Find all the properties in the linkFormat that are wrapped in curly braces
const regex = /(?<=\{)(.*?)(?=\})/g;
let matches = link.match(regex);
// For each match, replace the match with the value from the row
// Example linkFormat: /admin/streams/{streamID}/edit/{id}
// Example row: {id: 1, streamID: 2, name: 'Test'}
// Example result: /admin/streams/2/edit/1
if (matches.length > 0) {
matches.forEach((match) => {
link = link.replace(`{${match}}`, dataNode[match]);
});
}
return link;
}
Source