@inubeapislive/render-control
v1.0.0
Published
iNube render control utilities for conditional and dynamic rendering
Readme
@inubeapislive/render-control
A modular, schema-driven form rendering engine for the iNube platform. Define your forms as JSON schemas and the engine handles rendering, validation, state management, visibility rules, and field lifecycle — all with zero business logic coupling.
Installation
npm install @inubeapislive/render-controlPeer Dependencies
npm install react react-dom @mui/material @emotion/react @emotion/styledRequired Companion Packages
npm install @inubeapislive/components @inubeapislive/validationsArchitecture Overview
The package is split into independent, tree-shakeable modules:
@inubeapislive/render-control
├── engine → RenderEngine component (the main entry point)
├── renderers → Built-in UI renderers (text, select, checkbox, etc.)
├── schema → JSON schema parser and validator
├── state → Immutable form state (reducer + React context + hooks)
├── validation → Validation pipeline (auto-resolves from @inubeapislive/validations)
├── visibility → Conditional visibility/required rule evaluator
├── path-resolver → Arbitrary-depth dot/bracket path traversal
├── adapters → Plugin registry for custom UI adapters
├── api-client → Generic HTTP client with URL placeholder substitution
└── controls → RenderWhen, RenderList, RenderSwitch utilitiesEach module can be imported independently via subpath exports:
import { get, set } from '@inubeapislive/render-control/path-resolver';
import { evaluateCondition } from '@inubeapislive/render-control/visibility';
import { formReducer } from '@inubeapislive/render-control/state';Quick Start
import { RenderEngine } from '@inubeapislive/render-control';
const schema = {
fields: [
{ path: 'name', type: 'text', label: 'Full Name', required: true },
{ path: 'email', type: 'text', label: 'Email', blurValidators: [{ name: 'isEmail' }] },
{ path: 'gender', type: 'radio', label: 'Gender', options: [
{ label: 'Male', value: 'male' },
{ label: 'Female', value: 'female' },
]},
{ path: 'premium', type: 'currency', label: 'Premium Amount' },
{ path: 'dob', type: 'date', label: 'Date of Birth', props: { maxDate: '2010-01-01' } },
],
};
function App() {
return (
<RenderEngine
schema={schema}
data={{ name: 'John Doe', gender: 'male' }}
onWarning={(path, type) => console.warn(`No renderer for ${type} at ${path}`)}
/>
);
}RenderEngine
The main orchestrating component. Accepts a schema, renders fields using the built-in renderers map, manages form state, evaluates visibility rules, and runs validation pipelines.
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| schema | FormSchema | — | JSON schema defining all form fields |
| data | FormState | {} | Initial form data (keyed by field path) |
| onValidateAll | (errors: {path, message}[]) => void | — | Called after validateAll with all validation errors |
| onWarning | (path: string, type: string) => void | — | Called when a field type has no registered renderer |
| customRenderers | Record<string, (props: RendererProps) => ReactElement> | — | Extend or override built-in renderers |
| customValidators | Record<string, (...args) => unknown> | — | Extend or override built-in validators |
How It Works
- Schema Parsing — Validates the schema structure and checks all
typevalues against known renderers - State Initialization — Wraps children in
FormProviderwith initial data - Visibility Resolution — For each field, evaluates
visibleandrequiredconditions against current state - Renderer Lookup — Finds the matching renderer from the built-in map (or
customRenderers) - Memoized Rendering — Each field is wrapped in
React.memokeyed on(value, error, visible, required)— only re-renders when these change - Validation — On blur, runs field-specific validators. On
validateAll, iterates all visible+required fields - Error Boundary — Each field has an isolated error boundary; a crashing renderer won't take down the whole form
Schema Change Handling
When the schema prop changes at runtime:
- Fields in both old and new schema retain their existing values
- Fields removed from the new schema are dropped from state
- New fields are initialized with
defaultValueor fromdata
Schema Format
FormSchema
interface FormSchema {
fields: FieldDescriptor[];
version?: string;
metadata?: Record<string, unknown>;
}FieldDescriptor
interface FieldDescriptor {
path: string; // Dot-notation path into form state (e.g., "applicant.name")
type: string; // Renderer type (text, select, radio, checkbox, etc.)
label?: string; // Display label
defaultValue?: unknown;// Initial value if not provided in data
required?: boolean | Condition; // Static or conditional required rule
visible?: boolean | Condition; // Static or conditional visibility rule
disabled?: boolean | Condition; // Static or conditional disabled rule
validators?: ValidatorRef[]; // Change-time validators
blurValidators?: ValidatorRef[]; // Blur-time validators
options?: OptionItem[]; // For select/radio/checkbox types
props?: Record<string, unknown>; // Extra props passed to the renderer
layout?: LayoutHints; // Grid layout hints (columns, order, group)
}Full Schema Example
{
"fields": [
{
"path": "applicant.firstName",
"type": "text",
"label": "First Name",
"required": true,
"validators": [
{ "name": "isAlphaSpace" },
{ "name": "isMinLength", "params": [2] },
{ "name": "isMaxLength", "params": [50] }
]
},
{
"path": "applicant.email",
"type": "text",
"label": "Email Address",
"blurValidators": [{ "name": "isEmail" }]
},
{
"path": "applicant.type",
"type": "select",
"label": "Applicant Type",
"options": [
{ "label": "Individual", "value": "individual" },
{ "label": "Corporate", "value": "corporate" }
]
},
{
"path": "company.name",
"type": "text",
"label": "Company Name",
"visible": {
"type": "equals",
"path": "applicant.type",
"value": "corporate"
},
"required": {
"type": "equals",
"path": "applicant.type",
"value": "corporate"
}
}
]
}Built-in Renderers
The engine includes 11 pre-built renderers that use @inubeapislive/components:
| Type | Renderer | Component Used | Description |
|------|----------|----------------|-------------|
| text | TextRenderer | TextField | Standard text input (supports multiline, placeholder, maxLength) |
| select | SelectRenderer | AutoComplete | Dropdown/autocomplete with options from schema |
| button | ButtonRenderer | Button | Action button (variant, color, onClick from props) |
| checkbox | CheckboxRenderer | Checkbox | Single or group checkbox (checkedVal/unCheckedVal or options) |
| radio | RadioRenderer | Radio | Radio group with options from schema |
| data-table | DataTableRenderer | DataTable | Read-only data table (columns/rows from props) |
| typography | TypographyRenderer | Typography | Display-only text (variant, color from props) |
| dialog | DialogRenderer | Dialog | Modal dialog (open state from value, title/content/actions from props) |
| tabs | TabsRenderer | Tabs | Tab navigation (tabs config from props) |
| date | DateRenderer | DatePicker | Native date picker (minDate/maxDate from props) |
| currency | CurrencyRenderer | CurrencyInput | Formatted currency input (symbol, decimals, locale from props) |
Adding Custom Renderers
import { RenderEngine } from '@inubeapislive/render-control';
import type { RendererProps } from '@inubeapislive/render-control/renderers';
function RatingRenderer(props: RendererProps) {
const { descriptor, value, onChange } = props;
return (
<div>
<label>{descriptor.label}</label>
{[1, 2, 3, 4, 5].map(star => (
<span
key={star}
onClick={() => onChange(star)}
style={{ cursor: 'pointer', color: (value as number) >= star ? 'gold' : 'gray' }}
>
★
</span>
))}
</div>
);
}
<RenderEngine
schema={schema}
customRenderers={{ rating: RatingRenderer }}
/>RendererProps Interface
Every renderer receives these standardized props:
interface RendererProps {
descriptor: FieldDescriptor; // Full field definition from schema
value: unknown; // Current value from form state
error: boolean; // Whether field has validation error
errorMessage: string; // Error message (empty if no error)
disabled: boolean; // Whether field is disabled
required: boolean; // Whether field is required
onChange: (value: unknown) => void; // Emit value change
onBlur: () => void; // Emit blur (triggers validation)
}Validation
Validators are resolved by name from @inubeapislive/validations at runtime. No manual registration needed.
How It Works
- When a field blurs, the engine runs
blurValidatorsin sequence - Each
ValidatorRefhas aname(maps to a function in@inubeapislive/validations) and optionalparams - The pipeline short-circuits on first failure, returning the error message
- Custom validators can be passed via
customValidatorsprop (take priority over built-in) - Unknown validator names are skipped with a console warning
ValidatorRef Format
interface ValidatorRef {
name: string; // e.g., "isEmail", "isMinLength", "IsRequired"
params?: unknown[]; // e.g., [5] for isMinLength(value, 5)
}Schema Examples
{
"path": "phone",
"type": "text",
"label": "Mobile Number",
"validators": [{ "name": "isNumeric" }],
"blurValidators": [
{ "name": "IsRequired" },
{ "name": "isMobileNumber" }
]
}Custom Validators
<RenderEngine
schema={schema}
customValidators={{
isUniquePolicyNo: async (value) => {
const exists = await checkPolicyExists(value as string);
return exists ? 'Policy number already exists' : true;
},
isMinAge: (value, minAge) => {
const age = calculateAge(value as string);
return age >= (minAge as number) ? true : `Must be at least ${minAge} years old`;
},
}}
/>Async Validators & Timeout
Async validators are awaited with a 5-second timeout. If a validator exceeds the timeout, it fails with "Validation timed out after 5000ms".
Direct Pipeline Usage
import { runValidationPipeline } from '@inubeapislive/render-control/validation';
const result = await runValidationPipeline(
'abc', // value to validate
[{ name: 'isNumeric' }], // validators
{ myCustomValidator: (v) => true }, // optional custom validators
{ timeout: 3000 } // optional timeout override
);
// result: "Value is not numeric" or trueState Management
FormProvider & Hooks
The engine uses a React Context-based state system with undo support.
import {
FormProvider,
useFormState,
useFormDispatch,
useFieldValue,
} from '@inubeapislive/render-control/state';Actions
| Action | Payload | Description |
|--------|---------|-------------|
| SET_FIELD | { path, value } | Immutably set a single field value |
| BULK_UPDATE | { changes: [{path, value}] } | Atomically apply multiple changes (one history entry) |
| UNDO | — | Revert to previous state (10-level history) |
| RESET | { data } | Replace entire state, clear history |
| SCHEMA_CHANGE | { newFields, data? } | Reconcile state with new schema |
useFieldValue (Selective Subscription)
import { useFieldValue } from '@inubeapislive/render-control/state';
function PremiumDisplay() {
// Only re-renders when this specific path changes
const premium = useFieldValue('policy.premium');
return <div>Premium: ₹{premium}</div>;
}Immutability & Undo
Every SET_FIELD creates a new state object (via the Path Resolver's immutable set). Previous states are stored in a bounded history stack (default 10 entries):
const dispatch = useFormDispatch();
dispatch({ type: 'SET_FIELD', path: 'name', value: 'John' });
dispatch({ type: 'SET_FIELD', path: 'name', value: 'Jane' });
dispatch({ type: 'UNDO' }); // state.name === 'John'Visibility & Conditional Rules
Fields can have visible, required, and disabled rules that evaluate against current form state.
Condition Types
// Simple equality
{ "type": "equals", "path": "applicant.type", "value": "corporate" }
// Array includes
{ "type": "includes", "path": "selectedAddons", "values": ["roadside", "engine"] }
// Compound AND
{ "type": "and", "conditions": [
{ "type": "equals", "path": "country", "value": "IN" },
{ "type": "equals", "path": "state", "value": "KA" }
]}
// Compound OR
{ "type": "or", "conditions": [
{ "type": "equals", "path": "type", "value": "health" },
{ "type": "equals", "path": "type", "value": "life" }
]}
// NOT
{ "type": "not", "condition": { "type": "equals", "path": "age", "value": 0 } }Nesting Depth
Conditions can be nested up to 5 levels deep. Beyond that, the evaluator returns false.
Direct Usage
import { evaluateCondition, resolveField } from '@inubeapislive/render-control/visibility';
const isVisible = evaluateCondition(
{ type: 'equals', path: 'country', value: 'IN' },
{ country: 'IN' }
); // true
const { visible, required } = resolveField(
{ visible: { type: 'equals', path: 'x', value: 1 }, required: true },
{ x: 1 }
); // { visible: true, required: true }Path Resolver
Replaces hardcoded nested object access with iterative traversal at any depth.
import { parsePath, get, set } from '@inubeapislive/render-control/path-resolver';
// Parse path into segments
parsePath('items[0].name'); // ['items', '0', 'name']
parsePath('applicant.personal.dob'); // ['applicant', 'personal', 'dob']
// Get nested value (returns undefined for missing paths, never throws)
get({ a: { b: { c: 42 } } }, 'a.b.c'); // 42
get({ items: [{ name: 'X' }] }, 'items[0].name'); // 'X'
get(null, 'any.path'); // undefined
// Immutably set (creates intermediates, never mutates original)
const obj = { user: { name: 'Alice' } };
const next = set(obj, 'user.name', 'Bob');
// next.user.name === 'Bob'
// obj.user.name === 'Alice' (unchanged)API Client
Portable HTTP utility for dynamic data fetching in forms (dropdown options, autocomplete, etc.).
import { createApiClient } from '@inubeapislive/render-control/api-client';
const client = createApiClient({
adapter: {
get: (url) => fetch(url).then(r => ({ data: r.json() })),
post: (url, body) => fetch(url, { method: 'POST', body: JSON.stringify(body) }).then(r => ({ data: r.json() })),
},
});
// URL placeholder substitution: {P1}, {P2}, etc.
const data = await client.get('/api/policies/{P1}/riders', ['POL-12345']);
// Calls: /api/policies/POL-12345/riders
// Missing params return error without making request
const result = await client.get('/api/{P1}/{P2}', ['only-one']);
// { status: 0, message: "Missing parameter for placeholder {P2}" }Controls (Backward-Compatible Utilities)
Three utility functions preserved from the legacy codebase for conditional rendering logic:
RenderWhen
import { RenderWhen } from '@inubeapislive/render-control/controls';
const result = RenderWhen({
condition: () => user.isAdmin,
content: <AdminPanel />,
fallback: <AccessDenied />,
});RenderList
import { RenderList } from '@inubeapislive/render-control/controls';
const visibleItems = RenderList({
items: [
{ key: '1', data: 'Item 1', visible: true },
{ key: '2', data: 'Item 2', visible: false },
{ key: '3', data: 'Item 3', visible: true },
],
filterHidden: true, // default
});
// Returns items 1 and 3 onlyRenderSwitch
import { RenderSwitch } from '@inubeapislive/render-control/controls';
const content = RenderSwitch({
value: policyType,
cases: [
{ match: 'health', content: <HealthForm /> },
{ match: 'motor', content: <MotorForm /> },
{ match: 'life', content: <LifeForm /> },
],
defaultContent: <GenericForm />,
});Adapter Registry (Public Utility)
The adapter registry is exported as a standalone utility. While the RenderEngine itself uses the static renderers map internally, you can use the registry for your own plugin patterns:
import { createAdapterRegistry } from '@inubeapislive/render-control/adapters';
const registry = createAdapterRegistry();
registry.register('custom-chart', MyChartAdapter);
registry.register('map-view', MapViewAdapter);
registry.get('custom-chart'); // MyChartAdapter
registry.has('map-view'); // true
registry.types(); // ['custom-chart', 'map-view']
// Throws if already registered — use replace() for intentional overwrites
registry.replace('custom-chart', MyBetterChartAdapter);Subpath Exports
Every module is independently importable for tree-shaking:
| Subpath | What's exported |
|---------|----------------|
| @inubeapislive/render-control | Everything (main barrel) |
| @inubeapislive/render-control/engine | RenderEngine, RenderEngineProps |
| @inubeapislive/render-control/renderers | renderers map, RendererProps |
| @inubeapislive/render-control/schema | parseSchema, printSchema, all types |
| @inubeapislive/render-control/state | FormProvider, useFormState, useFormDispatch, useFieldValue, formReducer |
| @inubeapislive/render-control/validation | runValidationPipeline |
| @inubeapislive/render-control/visibility | evaluateCondition, resolveField |
| @inubeapislive/render-control/path-resolver | parsePath, get, set |
| @inubeapislive/render-control/adapters | createAdapterRegistry |
| @inubeapislive/render-control/api-client | createApiClient |
| @inubeapislive/render-control/controls | RenderWhen, RenderList, RenderSwitch |
TypeScript
Full TypeScript coverage with strict mode. All types are exported:
import type {
FormSchema,
FieldDescriptor,
ValidatorRef,
OptionItem,
LayoutHints,
ParseResult,
SchemaValidationError,
FormState,
FormAction,
FormStore,
Condition,
SimpleCondition,
ArrayIncludesCondition,
CompoundCondition,
VisibilityResult,
RendererProps,
RenderEngineProps,
HttpAdapter,
ApiError,
ApiClientOptions,
AdapterProps,
ControlAdapter,
AdapterRegistry,
RenderCondition,
RenderItem,
SwitchCase,
} from '@inubeapislive/render-control';Build Output
The package ships as dual ESM/CJS with TypeScript declarations:
dist/
├── index.mjs / index.cjs / index.d.ts
├── engine.mjs / engine.cjs / engine.d.ts
├── renderers.mjs / renderers.cjs / renderers.d.ts
├── schema.mjs / schema.cjs / schema.d.ts
├── state.mjs / state.cjs / state.d.ts
├── validation.mjs / validation.cjs / validation.d.ts
├── visibility.mjs / visibility.cjs / visibility.d.ts
├── path-resolver.mjs / path-resolver.cjs / path-resolver.d.ts
├── adapters.mjs / adapters.cjs / adapters.d.ts
├── api-client.mjs / api-client.cjs / api-client.d.ts
└── controls.mjs / controls.cjs / controls.d.ts"sideEffects": false enables full tree-shaking — import only what you use.
Development
# Build
npm run build
# Run tests
npm run test
# Type check
npx tsc --noEmit