@memberjunction/react-runtime
Version:
Platform-agnostic React component runtime for MemberJunction. Provides core compilation, registry, and execution capabilities for React components in any JavaScript environment.
310 lines (263 loc) • 8.72 kB
text/typescript
/**
* @fileoverview Props builder utilities for React components.
* Provides utilities for constructing and validating component props.
* @module @memberjunction/react-runtime/runtime
*/
import { ComponentStyles } from '@memberjunction/interactive-component-types';
import { ComponentProps, ComponentCallbacks } from '../types';
import { Subject, debounceTime, Subscription } from 'rxjs';
/**
* Options for building component props
*/
export interface PropBuilderOptions {
/** Validate props before building */
validate?: boolean;
/** Merge with existing props */
merge?: boolean;
/** Transform data before passing to component */
transformData?: (data: any) => any;
/** Transform state before passing to component */
transformState?: (state: any) => any;
/** Debounce time for UpdateUserState callback in milliseconds */
debounceUpdateUserState?: number;
}
/**
* Builds component props from various sources
* @param data - Component data
* @param userState - User state object
* @param utilities - Utility functions
* @param callbacks - Component callbacks
* @param components - Child components
* @param styles - Component styles
* @param options - Builder options
* @returns Built component props
*/
export function buildComponentProps(
data: any = {},
userState: any = {},
utilities: any = {},
callbacks: ComponentCallbacks = {},
components: Record<string, any> = {},
styles?: ComponentStyles,
options: PropBuilderOptions = {},
onStateChanged?: (stateUpdate: Record<string, any>) => void
): ComponentProps {
const {
validate = true,
transformData,
transformState,
debounceUpdateUserState = 3000 // Default 3 seconds
} = options;
// Transform data if transformer provided
const transformedData = transformData ? transformData(data) : data;
const transformedState = transformState ? transformState(userState) : userState;
// Build props object
const props: ComponentProps = {
data: transformedData,
userState: transformedState,
utilities,
callbacks: normalizeCallbacks(callbacks, debounceUpdateUserState),
components,
styles: normalizeStyles(styles),
onStateChanged
};
// Validate if enabled
if (validate) {
validateComponentProps(props);
}
return props;
}
// Store subjects for debouncing per component instance
const updateUserStateSubjects = new WeakMap<Function, Subject<any>>();
// Store subscriptions for cleanup
const updateUserStateSubscriptions = new WeakMap<Function, Subscription>();
// Loop detection state
interface LoopDetectionState {
count: number;
lastUpdate: number;
lastState: any;
}
const loopDetectionStates = new WeakMap<Function, LoopDetectionState>();
// Deep equality check for objects
function deepEqual(obj1: any, obj2: any): boolean {
if (obj1 === obj2) return true;
if (!obj1 || !obj2) return false;
if (typeof obj1 !== 'object' || typeof obj2 !== 'object') return false;
const keys1 = Object.keys(obj1);
const keys2 = Object.keys(obj2);
if (keys1.length !== keys2.length) return false;
for (const key of keys1) {
if (!keys2.includes(key)) return false;
if (!deepEqual(obj1[key], obj2[key])) return false;
}
return true;
}
/**
* Normalizes component callbacks
* @param callbacks - Raw callbacks object
* @param debounceMs - Debounce time for UpdateUserState in milliseconds
* @returns Normalized callbacks
*/
export function normalizeCallbacks(callbacks: any, debounceMs: number = 3000): ComponentCallbacks {
const normalized: ComponentCallbacks = {};
// Ensure all callbacks are functions
if (callbacks.RefreshData && typeof callbacks.RefreshData === 'function') {
normalized.RefreshData = callbacks.RefreshData;
}
if (callbacks.OpenEntityRecord && typeof callbacks.OpenEntityRecord === 'function') {
normalized.OpenEntityRecord = callbacks.OpenEntityRecord;
}
return normalized;
}
/**
* Normalizes component styles
* @param styles - Raw styles object
* @returns Normalized styles
*/
export function normalizeStyles(styles?: any): any {
// Pass through the full styles object as-is
// This allows Skip components to access their full style structure
// including colors, typography, borders, etc.
return styles;
}
/**
* Validates component props
* @param props - Props to validate
* @throws Error if validation fails
*/
export function validateComponentProps(props: ComponentProps): void {
// Validate data
if (props.data === null || props.data === undefined) {
throw new Error('Component props.data cannot be null or undefined');
}
// Validate userState
if (props.userState === null) {
throw new Error('Component props.userState cannot be null');
}
// Validate utilities
if (props.utilities === null) {
throw new Error('Component props.utilities cannot be null');
}
// Validate callbacks
if (!props.callbacks || typeof props.callbacks !== 'object') {
throw new Error('Component props.callbacks must be an object');
}
// Validate callback functions
for (const [key, value] of Object.entries(props.callbacks)) {
if (value !== undefined && typeof value !== 'function') {
throw new Error(`Component callback "${key}" must be a function`);
}
}
}
/**
* Merges multiple prop objects
* @param propsList - Array of props to merge
* @returns Merged props
*/
export function mergeProps(...propsList: Partial<ComponentProps>[]): ComponentProps {
const merged: ComponentProps = {
data: {},
userState: {},
utilities: {},
callbacks: {},
components: {},
styles: {} as ComponentStyles
};
for (const props of propsList) {
if (props.data) {
merged.data = { ...merged.data, ...props.data };
}
if (props.userState) {
merged.userState = { ...merged.userState, ...props.userState };
}
if (props.utilities) {
merged.utilities = { ...merged.utilities, ...props.utilities };
}
if (props.callbacks) {
merged.callbacks = { ...merged.callbacks, ...props.callbacks };
}
if (props.components) {
merged.components = { ...merged.components, ...props.components };
}
if (props.styles) {
merged.styles = { ...merged.styles, ...props.styles };
}
}
return merged;
}
/**
* Creates a props transformer function
* @param transformations - Map of prop paths to transformer functions
* @returns Props transformer
*/
export function createPropsTransformer(
transformations: Record<string, (value: any) => any>
): (props: ComponentProps) => ComponentProps {
return (props: ComponentProps) => {
const transformed = { ...props };
for (const [path, transformer] of Object.entries(transformations)) {
const pathParts = path.split('.');
let current: any = transformed;
// Navigate to the parent of the target property
for (let i = 0; i < pathParts.length - 1; i++) {
if (!current[pathParts[i]]) {
current[pathParts[i]] = {};
}
current = current[pathParts[i]];
}
// Apply transformation
const lastPart = pathParts[pathParts.length - 1];
if (current[lastPart] !== undefined) {
current[lastPart] = transformer(current[lastPart]);
}
}
return transformed;
};
}
/**
* Creates a callback wrapper that adds logging
* @param callbacks - Original callbacks
* @param componentName - Component name for logging
* @returns Wrapped callbacks
*/
export function wrapCallbacksWithLogging(
callbacks: ComponentCallbacks,
componentName: string
): ComponentCallbacks {
const wrapped: ComponentCallbacks = {};
if (callbacks.RefreshData) {
wrapped.RefreshData = () => {
console.log(`[${componentName}] RefreshData called`);
callbacks.RefreshData!();
};
}
if (callbacks.OpenEntityRecord) {
wrapped.OpenEntityRecord = (entityName: string, key: any) => {
console.log(`[${componentName}] OpenEntityRecord called:`, { entityName, key });
callbacks.OpenEntityRecord!(entityName, key);
};
}
return wrapped;
}
/**
* Extracts props paths used by a component
* @param componentCode - Component source code
* @returns Array of prop paths
*/
export function extractPropPaths(componentCode: string): string[] {
const paths: string[] = [];
// Simple regex patterns to find prop access
const patterns = [
/props\.data\.(\w+)/g,
/props\.userState\.(\w+)/g,
/props\.utilities\.(\w+)/g,
/props\.callbacks\.(\w+)/g
];
for (const pattern of patterns) {
let match;
while ((match = pattern.exec(componentCode)) !== null) {
paths.push(match[0]);
}
}
return [...new Set(paths)]; // Remove duplicates
}