@plone/volto
Version:
Volto
378 lines (347 loc) • 10.6 kB
JSX
import cloneDeepWith from 'lodash/cloneDeepWith';
import flatten from 'lodash/flatten';
import isEqual from 'lodash/isEqual';
import isObject from 'lodash/isObject';
import transform from 'lodash/transform';
import React from 'react';
import { matchPath } from 'react-router';
import config from '@plone/volto/registry';
/**
* Deep diff between two object, using lodash
* @param {Object} object Object compared
* @param {Object} base Object to compare with
* @return {Object} Return a new object who represent the diff
*/
export function difference(object, base) {
return transform(object, (result, value, key) => {
if (!isEqual(value, base[key])) {
result[key] =
isObject(value) && isObject(base[key])
? difference(value, base[key])
: value;
}
});
}
/**
* Throw an error if the wrapped function returns undefined
*
* @param {Function} func
*/
export const safeWrapper = (func) => (config) => {
const res = func(config);
if (typeof res === 'undefined') {
throw new Error(`Configuration function doesn't return config, ${func}`);
}
return res;
};
/**
* A helper to pipe a configuration object through configuration loaders
*
* @param {Array} configMethods A list of configuration methods
* @param {Object} config The Volto singleton config object
*/
export function applyConfig(configMethods, config) {
return configMethods.reduce((acc, apply) => safeWrapper(apply)(acc), config);
}
/**
* A HOC factory that propagates the status of asyncConnected requests back to
* the main server process, to allow properly expressing an error status as
* HTTP status code
*
* @param {} code HTTP return code
*/
export function withServerErrorCode(code) {
return (WrappedComponent) => (props) => {
if (props.staticContext && Object.keys(props.staticContext).length === 0) {
const { staticContext } = props;
staticContext.error_code = code;
staticContext.error = props.error;
}
return <WrappedComponent {...props} />;
};
}
// See https://en.wikipedia.org/wiki/Web_colors#Extended_colors
const safeColors = [
'Black',
'Blue',
'BlueViolet',
'Brown',
'Crimson',
'DarkBlue',
'DarkCyan',
'DarkGreen',
'DarkMagenta',
'DarkOliveGreen',
'DarkOrchid',
'DarkRed',
'DarkSlateBlue',
'DarkSlateGray',
'DarkViolet',
'DeepPink',
'DimGray',
'DodgerBlue',
'Firebrick',
'ForestGreen',
'Fuchsia',
'Green',
'IndianRed',
'Indigo',
'Magenta',
'Maroon',
'MediumBlue',
'MediumSlateBlue',
'MediumVioletRed',
'MidnightBlue',
'Navy',
'Olive',
'OliveDrab',
'OrangeRed',
'Purple',
'Red',
'RoyalBlue',
'SaddleBrown',
'SeaGreen',
'Sienna',
'SlateBlue',
'SlateGray',
'SteelBlue',
'Teal',
];
const namedColors = {};
/**
* Will generate initials from string
* @param {string} name
* @param {integer} count
* @returns {string} only one letter if received only one name
*/
export const getInitials = (title, limit) => {
const text = title
.split(' ')
.map((n) => (n[0] ? n[0].toUpperCase() : ''))
.join('');
if (limit) {
return text.substring(0, limit);
}
return text;
};
/**
* Will generate a random color hex
* Will also remmember the color for each userId
* @param {string} userId
*/
export const getColor = (name) => {
const namedColor = namedColors[name]
? namedColors[name]
: safeColors.length > 0
? safeColors.pop()
: `#${Math.floor(Math.random() * 16777215).toString(16)}`;
if (!namedColors[name]) {
namedColors[name] = namedColor;
}
return namedColor;
};
/**
* Fixes TimeZones issues on moment date objects
* Parses a DateTime and sets correct moment locale
* @param {string} locale Current locale
* @param {string} value DateTime string
* @param {string} format Date format of choice
* @returns {Object|string} Moment object or string if format is set
*/
export const parseDateTime = (locale, value, format, moment) => {
// Used to set a server timezone or UTC as default
moment.updateLocale(locale, moment.localeData(locale)._config); // copy locale to moment-timezone
let datetime = null;
if (value) {
// check if datetime has timezone, otherwise assumes it's UTC
datetime =
!value.match(/T/) || value.match(/T(.)*(-|\+|Z)/g)
? // Since we assume UTC everywhere, then transform to local (momentjs default)
moment(value)
: // This might happen in old Plone versions dates
moment(`${value}Z`);
}
if (format && datetime) {
return datetime.format(format);
}
return datetime;
};
/**
* Converts a language code like pt-br to the format `pt_BR` (`lang_region`)
* Useful for passing from Plone's i18n lang names to Xnix locale names
* eg. LC_MESSAGES/lang_region.po filenames. Also used in the I18N_LANGUAGE cookie.
* @param {string} language Language to be converted
* @returns {string} Language converted
*/
export const toGettextLang = (language) => {
if (language.includes('-')) {
let normalizedLang = language.split('-');
normalizedLang = `${normalizedLang[0]}_${normalizedLang[1].toUpperCase()}`;
return normalizedLang;
}
return language;
};
export const normalizeLanguageName = toGettextLang;
/**
* Converts a language code like pt-br or pt_BR to the format `pt-BR`.
* `react-intl` only supports this syntax. We also use it for the locales
* in the volto Redux store.
* @param {string} language Language to be converted
* @returns {string} Language converted
*/
export const toReactIntlLang = (language) => {
if (language.includes('_') || language.includes('-')) {
let langCode = language.split(/[-_]/);
langCode = `${langCode[0]}-${langCode[1].toUpperCase()}`;
return langCode;
}
return language;
};
export const toLangUnderscoreRegion = toReactIntlLang; // old name for backwards-compat
/**
* Converts a language code like pt_BR or pt-BR to the format `pt-br`.
* This format is used on the backend and in volto config settings.
* @param {string} language Language to be converted
* @returns {string} Language converted
*/
export const toBackendLang = (language) => {
return toReactIntlLang(language).toLowerCase();
};
/**
* Lookup if a given expander is set in apiExpanders for the given path and action type
* @param {string} expander The id literal of the expander eg. `navigation`
* @param {string} path The path (no URL) to check if the expander has effect
* @param {string} type The Redux action type
* @returns {boolean} Return if the expander is present for the path and the type given
*/
export const hasApiExpander = (expander, path = '', type = 'GET_CONTENT') => {
return flatten(
config.settings.apiExpanders
.filter((expand) => matchPath(path, expand.match) && expand[type])
.map((expand) => expand[type]),
).includes(expander);
};
/**
* Insert element into array at a give index
* @param {Array} array Array with data
* @param {*} element Element to be inserted
* @param {number} index Index of item to be inserted at
* @returns {Array} Array with inserted element
*/
export const insertInArray = (array, element, index) => [
...array.slice(0, index),
element,
...array.slice(index),
];
/**
* Replace element in array at a give index
* @param {Array} array Array with data
* @param {*} element Element to be replaced
* @param {number} index Index of item to be replaced at
* @returns {Array} Array with replaced element
*/
export const replaceItemOfArray = (array, index, value) =>
Object.assign([...array], { [index]: value });
/**
* Remove item from array at given index
* @param {Array} array Array with data
* @param {number} index Index of item to be removed
* @returns {Array} Array without deleted element
*/
export const removeFromArray = (array, index) => {
let newArray = array.slice();
newArray.splice(index, 1);
return newArray;
};
/**
* Moves an item from origin to target inside an array in an immutable way
* @param {Array} array Array with data
* @param {number} origin Index of item to be moved from
* @param {number} target Index of item to be moved to
* @returns {Array} Resultant array
*/
export const reorderArray = (array, origin, target) => {
const result = Array.from(array);
const [removed] = result.splice(origin, 1);
result.splice(target, 0, removed);
return result;
};
/**
* Normalize (unicode) string to a normalized plain ascii string
* @method normalizeString
* @param {string} str The string to be normalized
* @returns {string} Normalized plain ascii string
*/
export function normalizeString(str) {
return str.normalize('NFD').replace(/\p{Diacritic}/gu, '');
}
/**
* Slugify a string: remove whitespaces, special chars and replace with _
* @param {string} string String to be slugified
* @param {Array} slugs Array with slugs already taken
* @returns {string} Slugified string
*/
export const slugify = (string, slugs = []) => {
let slug = string
.toLowerCase()
.replace(/[\s-]+/g, '_')
.replace(/[^\w]+/g, '');
let i = 1;
if (slugs.includes(slug)) {
while (slugs.includes(`${slug}_${i}`)) {
i++;
}
slug = `${slug}_${i}`;
}
return slug;
};
/**
* cloneDeep an object with support for JSX nodes on it
* Somehow, in a browser it fails with a "Illegal invocation" error
* but in node (Jest test) it doesn't. This does the trick.
* @param {object} object object to be cloned
* @returns {object} deep cloned object
*/
export const cloneDeepSchema = (object) => {
return cloneDeepWith(object, (value) => {
if (React.isValidElement(value)) {
// If a JSX valid element, just return it, do not try to deep clone it
return value;
}
});
};
/**
* Creates an array given a range of numbers
* @param {number} start start number from
* @param {number} stop stop number at
* @param {number} step step every each number in the sequence
* @returns {array} The result, eg. [0, 1, 2, 3, 4]
*/
export const arrayRange = (start, stop, step) =>
Array.from(
{ length: (stop - start) / step + 1 },
(value, index) => start + index * step,
);
/**
* Given an event target element returns if it's an interactive element
* of the one in the list.
* @param {node} element event.target element type
* @returns {boolean} If it's an interactive element of the list
*/
export function isInteractiveElement(
element,
interactiveElements = [
'button',
'input',
'textarea',
'select',
'option',
'svg',
'path',
],
) {
if (interactiveElements.includes(element.tagName.toLowerCase())) {
return true;
}
return false;
}