rn-dynamic-ui-render
Version:
A dynamic UI rendering engine for React Native
331 lines (291 loc) • 12.6 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
return normalized
.split(".")
.reduce((obj, key) => (obj ? obj[key] : ""), root);
} 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;
}
/**
* 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];
// Only process if it's a string pointing to a valid function
if (typeof handlerName === "string" && typeof handlerFn === "function") {
const alreadyAssigned = componentProps[key];
// Attach a unified handler function
componentProps[key] = (...args) => {
// 1. If another handler was already set (e.g. by the system), run it first
if (typeof alreadyAssigned === "function") {
alreadyAssigned(...args);
}
// 2. If passData is true, call with item/index instead of event args
if (props?.passData === true) {
handlerFn(handlers?.item, handlers?.index);
} else {
handlerFn(...args); // Pass the event/payload/etc.
}
};
}
});
}
/**
* Handles dynamic data binding for a component.
*
* Iterates over the keys of the `props` object, and if a key maps to a string
* that corresponds to a function in the `functions` object, or if the key
* has the `expression` property set to true, it will evaluate the expression
* and attach the result to the `componentProps` object.
*
* If a property has the `fixedLength` property set, it will also convert the
* value to a fixed length string.
*
* If a property has the `type` property set to `"dateTime"`, it will also
* format the value as a date string using the given `format` string.
*
* @param {Object} props - The properties containing expressions to evaluate.
* @param {Object} componentProps - The object to attach dynamic data to.
* @param {Object} functions - The object containing functions to evaluate.
*/
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) {
componentProps.source = {
uri: getPathValue(props.source.uri, functions),
};
}
if (props?.fixedLength) {
componentProps[key] = parseFloat(componentProps[key])?.toFixed(
props.fixedLength
);
}
if (props?.type == "dateTime") {
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) => {
if (
typeof props[key] === "string" &&
theme.colors[props[key]] &&
key.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) {
if (props?.visible) {
if (props?.visibleExpression) {
return getPathValue(props.visible, functions);
} else {
return functions[props.visible];
}
}
return true;
}