rn-dynamic-ui-render
Version:
A dynamic UI rendering engine for React Native
407 lines (368 loc) • 16.1 kB
JavaScript
import moment from "moment";
/**
* Safely evaluate a dynamic condition coming from a JSON schema.
*
* @param {boolean|string} condition
* @param {Object} handlers Bag of data you want exposed.
* ├─ data {Object=} (reactive data / global store) (optional)
* ├─ helpers {Object=} (utility fns/constants) (optional)
* └─ …anything else you like (row, index, meta, etc.)
*
* @return {boolean}
*/
export function evaluateCondition(condition, handlers = {}) {
/* 0. Unpack special keys but keep the rest alive --------------------- */
const {
data: reactiveData = {},
helpers = {},
...scopedVars // EVERY other key goes here (row, index, form, etc.)
} = handlers;
/* 1. Literal booleans short‑circuit --------------------------------- */
if (typeof condition === "boolean") return condition;
/* 2. Evaluate string expressions ------------------------------------ */
if (typeof condition === "string") {
try {
/* 2.a Turn template placeholders into pure JS -------------------- */
const expression = resolvePlaceholders(
condition,
reactiveData,
scopedVars // pass EVERYTHING, not just a single "item"
);
/* 2.b Build execution context ------------------------------------ */
const context = {
...reactiveData, // top‑level access like user.name
...scopedVars, // row, index, etc.
...helpers, // dayjs, Math, custom utils…
};
/* eslint‑disable no-new-func */
const fn = new Function(
"context",
`"use strict";
return (function ({ ${Object.keys(context).join(",")} }) {
return (${expression});
})(context);
`
);
/* eslint‑enable no-new-func */
return Boolean(fn(context));
} catch (err) {
console.error("Error evaluating condition:", condition, err);
return false;
}
}
/* 3. Anything non‑boolean/non‑string is falsy ----------------------- */
return false;
}
//--------------------------------------------------------------
// ============== Helper functions =============================
//--------------------------------------------------------------
/**
* Replace {{ … }} and $…$ placeholders with live values.
*
* • {{ full JS expression }} → returns inner JS untouched
* • $path.to.value$ → resolved against ANY scoped handler key
* • Mixed strings get wrapped in quotes so they stay string literals
*/
function resolvePlaceholders(str, reactiveData, scopedVars) {
if (typeof str !== "string") return str;
const trimmed = str.trim();
// 1. Whole‑string JS expression: {{ … }}
const full = trimmed.match(/^{{\s*(.*?)\s*}}$/);
if (full) return full[1]; // inner code only
let out = str;
// 2. {{path.to.reactiveData}} –––> from reactiveData
out = out.replace(/{{(.*?)}}/g, (_, p) =>
getPathValue(p.trim(), reactiveData)
);
/* 3. $any.deep.path$ –––> from scopedVars -------------------------- */
out = out.replace(/\$(.*?)\$/g, (_, p) => getPathValue(p.trim(), scopedVars));
// 4. Mixed interpolation → treat as string literal in the final eval
return `"${out}"`;
}
/**
* Walk an object via dotted / bracket path safely.
* Works for reactiveData AND for arbitrary scoped handler keys.
*/
function getPathValue(path, root) {
if (!root || typeof path !== "string" || !path.trim()) return "";
try {
const normalized = path
.replace(/\['([^']+)'\]/g, ".$1") // foo['bar'] → foo.bar
.replace(/\"([^\"]+)\"/g, ".$1") // foo["bar"] → foo.bar
.replace(/\[(\d+)\]/g, ".$1") // arr[0] → arr.0
.replace(/^\./, ""); // leading dot
let result =
normalized
.split(".")
.reduce(
(obj, key) =>
obj
? typeof obj[key] == "number"
? obj[key].toString()
: obj[key]
: "",
root
) ||
root[path] ||
path;
return result;
} catch (e) {
console.error("Error while resolving path:", path, e);
return "";
}
}
/**
* Evaluates a style expression and returns the corresponding value.
*
* This function takes a style expression object and evaluates it based on
* the specified expression type. Currently, it supports ternary expressions
* where a condition is evaluated, and either a true or false value is returned
* based on the result of the condition.
*
* @param {Object} expr - The style expression object containing the expression type and values.
* @param {Object} data - The data used to evaluate the expression condition.
* @returns {*} - Returns the evaluated value based on the expression type, or the original expr if not an object or lacks an expression.
*/
function evaluateStyleExpression(expr, data) {
if (typeof expr !== "object" || !expr.expression) return expr;
if (expr.expression === "ternary") {
const conditionResult = evaluateCondition(expr.condition, data);
return conditionResult ? expr.trueValue : expr.falseValue;
}
return expr;
}
/**
* Evaluates a conditional expression and returns the corresponding value.
*
* This function takes an expression object and evaluates it based on the
* specified expression type. Currently, it supports ternary expressions where
* a condition is evaluated, and either a true or false value is returned based
* on the result of the condition.
*
* If the expression is not an object or lacks an expression, the original
* expression is returned.
*
* @param {Object} expr - The expression object containing the expression type and values.
* @param {Object} data - The data used to evaluate the expression condition.
* @returns {*} - Returns the evaluated value based on the expression type.
*/
function evaluateConditionalExpression(expr, data) {
if (typeof expr !== "object" || !expr.expression) return expr;
if (expr.expression === "ternary") {
const conditionResult = evaluateCondition(expr.condition, data);
return conditionResult
? getPathValue(expr.trueValue, data) || expr.trueValue
: getPathValue(expr.falseValue, data) || expr.falseValue;
}
return expr;
}
/**
* Attaches event handlers to component properties based on the provided props.
*
* Iterates over the keys of the `props` object, and if a key maps to a string
* that corresponds to a function in the `handlers` object, it attaches that
* function to the `componentProps` object. This allows for dynamic binding
* of event handlers to component properties.
*
* @param {Object} props - The properties containing handler names.
* @param {Object} handlers - The object containing handler functions.
* @param {Object} componentProps - The object to attach handlers to.
*/
export function handleInteractions(props, handlers, componentProps) {
Object.keys(props).forEach((key) => {
const handlerName = props[key];
const handlerFn = handlers[handlerName];
if (typeof handlerName === "string" && typeof handlerFn === "function") {
// Keep a stable handler reference
if (!componentProps[`__handler_${key}`]) {
componentProps[`__handler_${key}`] = (...args) => {
if (props?.passData === true) {
handlerFn(handlers?.item, handlers?.index);
} else if (props.passName == true) {
handlerFn(...args, props?.name);
} else {
handlerFn(...args);
}
};
}
componentProps[key] = componentProps[`__handler_${key}`];
}
});
}
/**
* Dynamically resolves values in a props object based on the provided functions.
* If a prop value is a string, it checks if the string corresponds to a function
* in the functions object. If it does, it assigns the function to the corresponding
* key in the componentProps object. If the string does not correspond to a
* function, it checks if the prop value has an expression property set to true.
* If it does, it evaluates the expression using the getPathValue util and assigns
* the result to the corresponding key in the componentProps object.
* If the prop value is an object with a uri property, it checks if the uri property
* is an object or a string. If it is an object, it evaluates the expression using
* the evaluateConditionalExpression util and assigns the result to the uri property
* of the componentProps.source object. If it is a string, it evaluates the expression
* using the getPathValue util and assigns the result to the uri property of the
* componentProps.source object.
* If the prop value is a boolean or number, it assigns the value to the corresponding key
* in the componentProps object.
* If the prop value has an expression property set to "ternary", it evaluates the
* expression using the evaluateConditionalExpression util and assigns the result to the
* corresponding key in the componentProps object.
* If the prop value has a fixedLength property, it formats the value using the
* toFixed method with the specified length and assigns the result to the corresponding key
* in the componentProps object.
* If the prop value has a type property set to "dateTime", it formats the value using
* the moment library with the specified format and assigns the result to the corresponding
* key in the componentProps object.
* @param {Object} props - The properties containing dynamic values.
* @param {Object} componentProps - The component props object to be modified.
* @param {Object} functions - The functions object containing handler functions.
*/
export function handleDynamicData(props, componentProps, functions) {
Object.keys(props).forEach((key) => {
if (typeof props[key] === "string" && functions[props[key]]) {
componentProps[key] = functions[props[key]];
} else if (typeof props[key] === "string" && props?.expression === true) {
componentProps[key] = getPathValue(props[key], functions);
} else if (props?.source?.uri && props?.expression === true) {
if (typeof props.source?.uri == "object") {
componentProps.source = {
uri: evaluateConditionalExpression(props.source.uri, functions),
};
} else {
componentProps.source = {
uri: getPathValue(props.source.uri, functions),
};
}
} else if (
functions[props[key]] != undefined &&
(typeof functions[props[key]] == "boolean" ||
typeof functions[props[key]] == "number")
) {
// Case: string references a boolean or number
componentProps[key] =
typeof functions[props[key]] == "number"
? functions[props[key]].toString()
: functions[props[key]];
}
if (props[key]?.expression == "ternary") {
componentProps[key] = evaluateConditionalExpression(
componentProps[key],
functions
);
}
if (props?.fixedLength) {
const num = Number(componentProps[key]);
if (!isNaN(num)) {
componentProps[key] = num.toFixed(props.fixedLength);
}
}
if (
props?.type == "dateTime" &&
(moment(componentProps[key], props.format, true).isValid() ||
moment(componentProps[key], moment.ISO_8601, true).isValid())
) {
componentProps[key] = moment(componentProps[key]).format(props.format);
}
});
}
/**
* Goes through all the props that end with "style" and applies them to the component.
* If the value is a string, it's assumed to be a key in the theme object.
* If the value is an object, it's assumed to be a reactive expression and is evaluated
* using the evaluateStyleExpression util.
* If the styleKey includes "color" and the finalValue is a string, it's assumed to be a
* key in the theme.colors object and is replaced with the corresponding value.
* If the styleKey includes "fontSize" and the finalValue is a string, it's assumed to be a
* key in the theme.font object and is replaced with the corresponding value.
* If the styleKey includes "fontWeight" and the finalValue is a string, it's assumed to be a
* key in the theme.fontWeight object and is replaced with the corresponding value.
* @param {Object} props - The component props
* @param {Object} componentProps - The component props object to be modified
* @param {Object} theme - The theme object
* @param {Object} functions - The functions object
*/
export function handleStyles(props, componentProps, theme = {}, functions) {
Object.entries(props).forEach(([key, value]) => {
if (key?.toLowerCase()?.endsWith("style")) {
if (Object.prototype.hasOwnProperty.call(theme, value)) {
componentProps[key] = theme[value];
} else {
if (!componentProps[key]) componentProps[key] = {};
Object.entries(value).forEach(([styleKey, styleValue]) => {
let finalValue = styleValue;
if (typeof styleValue === "object" && styleValue.expression) {
finalValue = evaluateStyleExpression(styleValue, functions);
}
if (
typeof finalValue === "string" &&
styleKey.toLowerCase().includes("color") &&
theme.colors?.[finalValue]
) {
finalValue = theme.colors[finalValue];
}
if (styleKey.includes("fontSize") && theme.font?.[finalValue]) {
finalValue = theme.font[finalValue];
}
if (
styleKey.includes("fontWeight") &&
theme.fontWeight?.[finalValue]
) {
finalValue = theme.fontWeight[finalValue];
}
componentProps[key] = {
...componentProps[key],
[styleKey]: finalValue,
};
});
}
}
});
}
/**
* Sets the color of a component based on the given properties and theme.
*
* For example, if a component has a property named `backgroundColor` with a value of
* `"primary"`, and the theme has a `colors` object with a `primary` property, then
* the component's `backgroundColor` property will be set to the theme's `primary` color.
*
* @param {Object} props - The properties object containing color settings.
* @param {Object} theme - The theme object containing color values.
* @param {Object} componentProps - The component props object where the color
* values will be set.
*/
export function handleColor(props, theme, componentProps) {
Object.keys(props).forEach((key, value) => {
if (
typeof props[key] === "string" &&
theme.colors[props[key]] &&
key.toLowerCase().endsWith("color")
) {
componentProps[key] = theme.colors[props[key]];
}
});
}
/**
* Determines the visibility of a component based on the given properties.
*
* @param {Object} props - The properties object containing visibility settings.
* @param {Object} functions - An object containing functions for evaluation.
* @returns {boolean} - Returns a boolean indicating whether the component should be visible.
* If `visible` is defined and `visibleExpression` is present, it evaluates
* the expression using `getPathValue`. Otherwise, it returns the result
* of a function mapped by `visible`. Defaults to `true` if `visible` is not defined.
*/
export function handleVisible(props, functions) {
let visible = true;
if (props?.visible != undefined) {
if (props?.visibleExpression) {
visible = getPathValue(props.visible, functions);
} else {
visible = functions[props.visible];
}
}
if (props?.visible?.expression == "ternary") {
visible = evaluateConditionalExpression(props.visible, functions);
}
return visible;
}