# NGX View Builder > NGX View Builder is a visual builder for complete Angular views: forms, dashboards, data tables, and guided flows are designed in a drag-and-drop builder, stored as JSON, and rendered natively in an Angular app. This file contains the full text of every documentation page, concatenated for AI agents and tools that cannot crawl links. See https://ngxviewbuilder.io/llms.txt for a linked index instead. --- ## Live builder command API Source: https://ngxviewbuilder.io/ai/command-api # Live builder command API Everything else in this section teaches you to **write** structure JSON. This page is about the other mode: when a builder is open in a browser you can reach, you can **read and edit the live view directly**, one JSON command at a time, and see the result immediately. ## Detect the mode first The live mode arrives as an MCP server. If your client has the `nvb_*` tools, call `nvb_status`. | Result | What it means | What to do | | --- | --- | --- | | `paired` and `builder_attached` are `true` | A builder is open and the API is armed | Drive it with commands. Do not hand back raw JSON for a person to paste. | | `builder_attached` is `false` | The tab is connected but the builder is not on screen | Ask the user to open the builder. | | `browser_connected` is `false` | The paired tab was closed or lost its socket | Ask the user to reopen the builder and re-pair. | | An error mentioning a pair code | Nothing is paired yet | Ask the user for the code shown in the builder settings, under AI access (MCP). | | No `nvb_*` tools at all | Runtime only host, or no MCP server configured | Fall back to authoring structure JSON as the rest of this section describes. | The bridge is open only while the builder component is mounted. It closes when the user leaves the builder, so re-check before a long sequence. ## Bootstrap sequence Run these before writing anything. They cost one round trip and remove almost every source of error. ```text nvb_status 1. is the door open nvb_get_instructions 2. the contract as text, plus the command catalog nvb_describe_element_types 3. real element types and their real property keys nvb_get_tree 4. what already exists, and the row indexes you need ``` `nvb_get_instructions` is the fastest way in: it states what the API is, the working order, the rules, the capabilities this builder has, and the templates already in the library. It returns `help()` in the same call, so `help.guidelines` carries the rules on their own and `help.capabilities` tells you which optional features are present. `help()` is authoritative and versioned. If it disagrees with this page, follow `help()`. **Never guess a property name.** `describeElementTypes(type)` returns the exact keys the builder accepts for that type. Guessing produces a warning and a property the runtime ignores, which is worse than an error because it looks like it worked. ## The one write door ```js const result = await api.execute(commandOrArray, options); ``` Over MCP that is `nvb_execute`, with the array under `commands` and the options as flat arguments (`dry_run`, `return_tree`, `return_structure`). The snippets below show the command shapes, which are the same either way. Hard rules: 1. **An array is atomic.** If one command fails, none are applied. Prefer one batch over a sequence of single calls, so a partial build cannot happen. 2. **Use `{ dryRun: true }` first** for anything large or destructive. It runs every check and reports `changed` without touching the view. 3. **Read `warnings`, not just `errors`.** `ok: true` with warnings means something landed that the builder does not recognise. 4. **Act on `hint`.** Errors carry a concrete correction. One retry using the hint should succeed; if it does not, stop and ask rather than looping. 5. **There is no save.** You can build and edit freely, but a person commits the view. Do not claim you saved anything, and do not try to reach a save through a trigger or an element event: automation actions have no submit or save type, and an element `submit` still needs a human click. 6. **Re-check `available()` after anything slow.** The API disarms the moment the user leaves the builder, and every command then fails with `apiClosed` while every read returns null. That is expected, not a fault. ### Result shape ```json { "ok": true, "version": 1, "dryRun": false, "applied": 3, "changed": ["contact", "email", "phone"], "errors": [], "warnings": ["[2] addElement: 'select' has no registered property 'placeholder'."] } ``` ### Error codes | Code | Meaning | Recovery | | --- | --- | --- | | `apiClosed` | The builder is not on screen | Ask the user to open it | | `unknownType` | Element type does not exist | Use the `hint`, or call `describeElementTypes()` | | `unknownElement` | No element with that name | Use the `hint`, or call `getTree()` | | `notAContainer` | The parent cannot hold children | Pick a container from the hint list | | `missingTab` | Tab style container needs a section | Pass `tab` | | `nameTaken` | Name already used | Pick another, or use `updateElement` | | `invalidName` | Name is not a valid identifier | Letters, digits, underscores, starting with a letter | | `cycle` | The target sits inside the element being moved | Pick a different parent | | `capabilityMissing` | The command needs a feature pack this builder does not have | Build without it, per the hint | | `unknownTemplate` | No template with that name | Call `describeTemplates()`, or create it with `upsertTemplate` | ## Layout is the part that goes wrong A view is rows, and each row holds one or more columns. **A new element gets its own row by default, so elements stack.** To place two fields side by side, name the existing `row`. ```js await api.execute([ { op: 'addElement', type: 'text', name: 'firstName', parent: 'contact', properties: { label: 'First name' } }, { op: 'addElement', type: 'text', name: 'lastName', parent: 'contact', row: 0, properties: { label: 'Last name' } }, ]); ``` Row indexes come from `getTree()` or from `addRow`. Target fields are `parent`, `page`, `tab`, `index`, `row`, `column`. See [Layout model](https://ngxviewbuilder.io/ai/layout-model) for how the same tree looks as raw JSON. **Do not set a percentage `width` on fields you want side by side.** Columns already share the row, and a fixed width fights it. Use `mobileWidth: '100%'` to make a pair stack on phones. For a whole section at once, `insertJson` takes the elements and the nested layout together, which is usually one command instead of five: ```js await api.execute({ op: 'insertJson', root: 'contact', elements: { contact: { type: 'panel', label: 'Contact' }, firstName: { type: 'text', label: 'First name', required: true }, lastName: { type: 'text', label: 'Last name', required: true }, }, rows: [{ columns: [{ elementRef: 'firstName' }, { elementRef: 'lastName' }] }], }); ``` ## What you can change Mutations: `addElement`, `insertJson`, `upsertTemplate`, `useTemplate`, `updateElement`, `deleteElement`, `moveElement`, `renameElement`, `duplicateElement`, `addRow`, `deleteRow`, `addPage`, `deletePage`, `renamePage`, `updatePage`, `setSettings`, `setHeader`, `upsertDataSource`, `deleteDataSource`, `upsertVariable`, `deleteVariable`, `upsertTrigger`, `deleteTrigger`, `upsertRule`, `deleteRule`, `upsertFragment`, `deleteFragment`, `setProcess`, `setLocalization`, `replaceStructure`. Actions: `switchTab`, `focusElement`, `setData`, `validate`, `setLanguage`, `setUiTranslations`, `setTheme`, `saveTemplate`, `deleteTemplate`, `saveSidebarGroup`, `deleteSidebarGroup`, `setTableSettings`, `setTableFilters`, `setRuntimeVariableContext`, `reloadDataSource`, `undo`, `redo`. Mutations are applied to a draft in order and committed together. Actions run afterwards, in order. Call `help()` for the parameters of each. ## Reuse before you invent Two habits save most of the rework. **Pick the element type that already does the job.** Call `describeElementTypes()` and choose from it. `customHtml` and hand written template markup carry no options, no validation and no events, so anything assembled that way has to be rebuilt later. The case that comes up most is a table column showing a status or a badge: that is a hosted element, `type: 'element'` with `elementType: 'badge'`, not markup. **Look in the template library before creating a template.** When `help().capabilities` includes `templates`, the view has one: ```js api.describeTemplates(); // { available, hosts, templates: [{ name, slots, hasCss, ... }] } ``` If the user names a card and that name is already in the library, reuse it. Create a missing one with `upsertTemplate` and bind it with `useTemplate`, which resolves the right reference property for the element type on its own: ```js await api.execute([ { op: 'upsertTemplate', name: 'person card', content: '
{{row[0]}} {{row[1]}}
' }, { op: 'useTemplate', name: 'peopleGrid', template: 'person card', fieldMap: { '0': 'fullName', '1': 'email' } }, ]); ``` Both need the `templates` capability and fail with `capabilityMissing` without it, because the reference properties are hidden then and the binding would render nothing. ## Checking your own work ```js await api.execute([ { op: 'switchTab', tab: 'preview' }, { op: 'setData', data: { age: 25 } }, { op: 'validate' }, ]); ``` `setData` pushes values through the same pipeline as typing, so `visibleIf`, `requireIf` and computed expressions re-evaluate. `validate` returns the issues. **Do not use `validate` to test whether a section is visible.** It runs on a fresh copy and does not apply container visibility to children, so a field inside a hidden panel is still reported as required. Read the preview instead. Note that a mutation rebuilds the view, which clears working data. Set data after your last structural change, not before. ## Your changes are one undo step Each `execute()` call is bracketed with a history checkpoint, so a whole batch collapses into a single undo for the user. `getAuditLog()` shows every call you made this session, which is worth reading back before you claim what you did. ## What gets checked Element types, property names, layout targets, name collisions and cycles are checked. So are the properties whose value is structured: `options` entries need a `value`, `validators` need a known `type` (and `custom` also needs a `condition`), `events` need a known action `type`, and nested elements in `columns` or `template` need a known type and a name. Everything else in an element body passes through untouched. That is why warnings matter: a misspelled property still lands and the runtime ignores it. ## Rules that still apply Everything in [JSON authoring rules](https://ngxviewbuilder.io/ai/json-authoring-rules), [Element rules](https://ngxviewbuilder.io/ai/element-rules) and [Properties reference](https://ngxviewbuilder.io/ai/properties-reference) applies unchanged. The command API changes how the JSON gets in, not what valid JSON looks like. --- ## AI reference Source: https://ngxviewbuilder.io/ai/ # AI reference This section is the **single indexed knowledge base for AI agents** that generate or modify NGX View Builder structure JSON. It is written for machine consumption first: an agent driving the builder over [MCP](https://ngxviewbuilder.io/developers/ai-command-api) loads slices of these pages into its prompts, and any other LLM setup (ChatGPT, Claude, a custom pipeline) can use the same pages as system-prompt material. Everything here has one goal: the agent must behave as a **strict NGX View Builder JSON author**, using real elements, documented properties and valid references, never as a generic frontend generator. ## Reading order An agent (or a person building prompts) should consume the pages in this order: 0. [Live builder command API](https://ngxviewbuilder.io/ai/command-api): check this first. If a builder is open and reachable, you edit it directly with JSON commands instead of handing back structure JSON. The authoring rules below still apply either way. 1. [Generation contract](https://ngxviewbuilder.io/ai/generation-contract): the core rules of engagement and output modes. 2. [Layout model](https://ngxviewbuilder.io/ai/layout-model): how `pages`, `rows`, `columns` and `elementRef` relate to the flat `elements` map. **Non-negotiable prerequisite for writing any structure JSON.** 3. [JSON authoring rules](https://ngxviewbuilder.io/ai/json-authoring-rules): the structure skeleton and hard rules. 4. [Element selection map](https://ngxviewbuilder.io/ai/element-selection-map): mapping user intent to the right element type. 5. [Logic & expression properties](https://ngxviewbuilder.io/ai/logic-and-expressions): `visibleIf`, `expression`, and friends. 6. [Element rules & value shapes](https://ngxviewbuilder.io/ai/element-rules): per-element expectations. 7. [Canonical properties reference](https://ngxviewbuilder.io/ai/properties-reference): the authoritative property list. 8. [Common mistakes](https://ngxviewbuilder.io/ai/common-mistakes): anti-patterns to avoid. 9. [Verified examples](https://ngxviewbuilder.io/ai/examples): complete, source-checked JSON for every element family, the full `table` feature set, dynamic tables and panels, data sources, variables and expressions. 10. [Legacy form migration](https://ngxviewbuilder.io/ai/legacy-form-migration): only when converting forms from a legacy form-builder JSON format. ## Index | Page | What it answers | Load when | | --- | --- | --- | | [Live builder command API](https://ngxviewbuilder.io/ai/command-api) | Driving an open builder directly: detection, bootstrap sequence, the `execute` contract, error recovery, row targeting | First, whenever the `nvb_*` MCP tools are available | | [Generation contract](https://ngxviewbuilder.io/ai/generation-contract) | How the agent must behave; output modes; prompt templates | Always | | [Layout model](https://ngxviewbuilder.io/ai/layout-model) | The layout tree: `pages` → `rows` → `columns` → `elementRef`, container nesting, `tabRows`, where widths live, full worked example | Always, before any JSON is written | | [JSON authoring rules](https://ngxviewbuilder.io/ai/json-authoring-rules) | Skeleton, `pages`/`elements` rules, naming, layout, value shapes | Always | | [Canonical properties reference](https://ngxviewbuilder.io/ai/properties-reference) | Every supported property per element type, settings, data sources | Always | | [Element rules & value shapes](https://ngxviewbuilder.io/ai/element-rules) | Per-element usage rules and value shapes | Always | | [Common mistakes](https://ngxviewbuilder.io/ai/common-mistakes) | Known anti-patterns with corrections | Always; especially in review mode | | [Verified examples](https://ngxviewbuilder.io/ai/examples) | Complete working JSON: layout, all element families, `table` end to end, `dynamicTable`, `dynamicPanel`, data sources, variables, expressions, actions | When building anything non-trivial; always for `table` | | [Element selection map](https://ngxviewbuilder.io/ai/element-selection-map) | Which element type fits the user's intent | When element choice is ambiguous | | [Logic & expression properties](https://ngxviewbuilder.io/ai/logic-and-expressions) | Expression fields, syntax rules, correct/incorrect examples | When the request involves logic | | [Legacy form migration](https://ngxviewbuilder.io/ai/legacy-form-migration) | Element/property/expression mapping from a legacy form-builder JSON format | When converting legacy form JSON | | [API service reference](https://ngxviewbuilder.io/developers/api-service), [Events reference](https://ngxviewbuilder.io/developers/events) | What every host API method / event does, its parameters, payloads and return values | When the user asks a development or integration question | Every JSON block on those pages is machine-checked against the library source by `npm run validate:ai-json` in this repo: element types against the element registry, property names against the builder property datasets, sub-objects against their TypeScript interfaces. If a property appears in these docs, it exists. ## Using this with ChatGPT, Claude or Gemini Three single-file bundles are published, in increasing size: | File | Contains | Use it when | | --- | --- | --- | | [`/llms.txt`](https://ngxviewbuilder.io/llms.txt) | a linked index, a few KB | the model can fetch URLs itself | | [`/llms-authoring.txt`](https://ngxviewbuilder.io/llms-authoring.txt) | **every page in this AI section**, roughly 55k tokens | the task is writing or fixing structure JSON. This is the one to paste into a chat | | [`/llms-full.txt`](https://ngxviewbuilder.io/llms-full.txt) | the whole site, roughly 135k tokens | the question also covers embedding, the host API, events, theming or plugins | `llms-authoring.txt` is deliberately the smaller bundle: it drops the creator guides, pricing and host-integration pages, which are noise when the only deliverable is JSON. Paste it once at the start of a session, then describe the view you want. A caveat worth stating plainly: a pasted file is reference material, not a guarantee. Models still skim long context. The two habits that matter most are asking for the layout tree to be stated in words before the JSON, and running the result back through the [pre-return checklist](https://ngxviewbuilder.io/ai/json-authoring-rules#pre-return-checklist). The agent does more than write JSON. It also **consults developers**: what an API method means, what it returns, how to wire an event, how to embed the builder or runtime. For those answers the retrieval map has a dedicated `developer` source group pointing at the developer documentation. The agent also draws on the human documentation ([element pages](https://ngxviewbuilder.io/creators/elements/), [expressions](https://ngxviewbuilder.io/creators/expressions), [conditional logic](https://ngxviewbuilder.io/creators/conditional-logic), [validation](https://ngxviewbuilder.io/creators/validation), [events & actions](https://ngxviewbuilder.io/creators/events-actions), [data sources](https://ngxviewbuilder.io/creators/data-sources) and [variables](https://ngxviewbuilder.io/creators/variables)) and on library source files: interfaces, enums and property datasets, which are always the final source of truth. ## The machine index: `retrieval-map.json` [`retrieval-map.json`](https://ngxviewbuilder.io/ai/retrieval-map.json) (next to this page) is the machine-readable index. It maps request profiles and element types to the exact files the agent should load, with per-file character budgets: - **`roots`**: where the docs and the library live, relative to the workspace root (the folder that contains all `ngx-view-builder*` projects). - **`universal`**: sources injected into every prompt. - **`types`**: per element type, which docs page, interface, and property dataset to load. - **`selection`, `review`, `legacyForms`, `logic`, `validators`, `events`, `actions`, `data`**: sources added when the request profile matches. - **`groupFallback`**: group-level docs used when no concrete element type was resolved. Paths use two prefixes: `docs:` (resolved against `roots.docs`, i.e. this documentation) and `lib:` (resolved against `roots.library`, i.e. the library source). An agent loads this file at startup, so when documentation moves, updating this map is the only change needed. --- ## AI: Generation contract Source: https://ngxviewbuilder.io/ai/generation-contract # AI: Generation contract This page defines the rules of engagement for `ChatGPT`, `Claude`, and other agents that generate or fix NGX View Builder structures, or consult developers about the library. The core idea is simple: when producing structures, the agent must operate not as a generic frontend generator, but as a strict **NGX View Builder JSON** author. ## Core contract - Generate only NGX View Builder JSON if the user explicitly asked to generate or modify a form or view. - Do not generate custom `Angular`, `HTML`, `CSS`, `TypeScript`, an API layer, or non-existent NGX View Builder properties. - Use only real built-in elements and documented fields. - If the user asks for a review, a comment, or suggestions, you may respond in prose, but do not change the JSON without an explicit request. - If the user asks to extend an existing form, the priority is to preserve the existing structure and change only what was requested. - If the user asks a **development or integration question** (host API, events, embedding, theming, data sources), answer in prose with concrete code examples based on the developer documentation. Do not invent methods or events that are not documented. ## What the agent should read first 1. [Layout model](https://ngxviewbuilder.io/ai/layout-model) 2. [JSON authoring rules](https://ngxviewbuilder.io/ai/json-authoring-rules) 3. [Element selection map](https://ngxviewbuilder.io/ai/element-selection-map) 4. [Logic and expression properties](https://ngxviewbuilder.io/ai/logic-and-expressions) 5. [Element rules and value shapes](https://ngxviewbuilder.io/ai/element-rules) 6. [Common mistakes](https://ngxviewbuilder.io/ai/common-mistakes) After that, consult the general reference pages: - [Structure JSON overview](https://ngxviewbuilder.io/developers/structure-json) - [Layout & containers](https://ngxviewbuilder.io/creators/layout) - [Element catalog](https://ngxviewbuilder.io/creators/elements/) - [Expressions](https://ngxviewbuilder.io/creators/expressions) - [Canonical properties reference](https://ngxviewbuilder.io/ai/properties-reference) For developer-consulting answers, the sources of truth are: - [API service reference](https://ngxviewbuilder.io/developers/api-service): every host API method, its parameters and return values - [Events reference](https://ngxviewbuilder.io/developers/events): every event the builder and runtime emit, with payloads - [Embedding the builder](https://ngxviewbuilder.io/developers/builder-integration) and [Rendering views](https://ngxviewbuilder.io/developers/runtime-integration) - [Data source integration](https://ngxviewbuilder.io/developers/data-sources) and [Runtime variables](https://ngxviewbuilder.io/developers/runtime-variables) ## Mandatory rules - `pages` describes layout. `elements` describes configuration. Neither ever does the other's job. - `elements` is a **flat** map where the key matches `element.name`. It never nests. - `pages[*].rows[*].columns[*].elementRef` is a string that must point to an existing `elements` entry. - Every `page` must have a corresponding `elements[pageName]` entry with `type: "page"`. - **A container element never contains its children.** The children of a `panel`, `dynamicPanel`, `dialog` or `emptyBlock` are attached to the column that references it, in `column.rows`; for `tabs`, `tabsPro`, `accordion`, `splitter` and `progressFlow`, in `column.tabRows`. Writing `rows` (or `children`, `items`, `fields`) inside an element definition produces an empty container and orphaned fields, with no error anywhere. - A row object has only `columns`. A column object has only `elementRef` plus `rows` / `tabRows`. Widths and other properties on a column are silently dropped; they belong on the element. - Columns with no width split their row evenly. Do not add `"width": "50%"` to get halves. - If there is interdependency logic between fields, use `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, `resetIf`, `expression`, and, when needed, `logicExecutionMode: "onChange"`. - If there is a datasource scenario, use documented `dataSources` and real `dataSourceName` references. - Do not use self-reference expressions, e.g. `"{el2} == 'x' ? 'y' : {el2}"`. - Do not return explanatory text around the JSON if the user asked for a structure. ## Recommended system prompt template ```text You are a strict NGX View Builder JSON generator and integration consultant. Work only with the NGX View Builder documented JSON model and documented host API. Do not use custom Angular, custom components, or invented properties. If the user asks to generate or modify a form, return only valid JSON. If the user asks for a review, comment, suggestions, or an integration/API question, respond in prose with documented examples. The structure has two independent halves: - "pages" is a layout tree: pages -> rows -> columns, each column carrying an "elementRef" STRING. - "elements" is a FLAT map of definitions, keyed by element name. It never nests. Container elements (page, panel, dynamicPanel, dialog, emptyBlock, tabs, tabsPro, accordion, splitter, progressFlow) do NOT contain their children. The children are attached to the COLUMN that references the container, in column.rows (or column.tabRows for the tab-like ones). Writing "rows"/"children"/"items" inside an element definition renders an empty container and drops the fields silently. A row object has only "columns". A column object has only "elementRef" plus "rows"/"tabRows". Widths (width, tabletWidth, mobileWidth, fitContent) go on the ELEMENT, and only when the split must be uneven: columns with no width already split their row evenly. A page is a step or screen. Titled sections on one screen are panel elements inside a single page, never separate pages. Before generating: 1. Decide which NGX View Builder elements best match the task. 2. List the visual rows top to bottom and count the fields per row before writing JSON. 3. Verify the required value shape and mandatory properties. 4. Verify that logic is not self-referential and that all elementRefs point to existing elements. 5. Verify that no element definition contains a rows/children/items array and that no column carries anything besides elementRef/rows/tabRows. 6. If extending an existing form, do not modify unrelated parts. ``` ## Recommended user prompt format Instead of a vague "create a form", give the agent these signals: - goal: what the form should allow the user to do - data: which fields are needed - logic: which fields hide, compute, fill, or validate others - data sources: what is loaded from an API or another datasource - update mode: new form or extension of an existing one Example: ```text Extend an existing NGX View Builder form. Required: - if first name is John, last name is automatically set to Doe - date of birth cannot be in the future - add a dynamicTable for family members with add row and delete row - use only NGX View Builder JSON, no comments ``` ## What to do if the user's request is ambiguous - If a term is ambiguous, decide based on the [Element selection map](https://ngxviewbuilder.io/ai/element-selection-map). - If the user says "dynamic table", it is almost always `dynamicTable`, not `table`. - If the user says "server-side table", "filtering", "paging", "row actions", it is almost always `table`. - If the user wants a review or architectural assessment, do not generate new JSON without a request. ## Output modes ### 1. Generation mode - Return only JSON. - No markdown. - No explanation before or after the JSON. ### 2. Review mode - You may provide a brief analysis. - If you suggest fixes, clearly separate comments from the proposed JSON. ### 3. Extension mode - Preserve the entire existing form. - Change only what is needed. - Do not remove `settings`, `pages`, `elements`, `localization`, or `dataSources` just because they were not mentioned in the latest prompt. ### 4. Consulting mode - The user asks how something works: a host API method, an event, embedding, theming, a data source, an expression function. - Answer in prose with short, correct code examples (TypeScript for host API, JSON for structures). - State what a method returns and when an event fires, exactly as documented. If the documentation does not describe it, say so instead of guessing. - Do not generate a whole structure unless asked. --- ## AI: Layout model (pages, rows, columns, elementRef) Source: https://ngxviewbuilder.io/ai/layout-model # AI: Layout model (pages, rows, columns, elementRef) This is the page that stops the single most damaging class of generated-JSON errors: getting the layout tree wrong. Read it before writing any structure JSON. If anything on this page contradicts your intuition about form builders, this page wins. ## The one rule that explains everything NGX View Builder splits a view into **two independent halves**: | Half | What it is | What it contains | |---|---|---| | `pages` | The **layout tree**. Position only. | Nested `rows` and `columns`. Each column carries one `elementRef` string. | | `elements` | A **flat dictionary** of element definitions. Configuration only. | One entry per element, keyed by its `name`. No nesting, no children, no position. | `elementRef` is the only bridge between them. It is a **string key into `elements`**, nothing else. ``` pages[0] elements └─ rows[0] ┌──────────────────────────────────┐ └─ columns[0] │ "firstName": { type: "text", ... }│ └─ elementRef: "firstName" ────►│ │ │ "lastName": { type: "text", ... }│ └─ columns[1] │ │ └─ elementRef: "lastName" ────►│ "page1": { type: "page", ... } │ └──────────────────────────────────┘ ``` Two consequences that agents get wrong constantly: 1. **Structure lives only in `pages`.** An element definition never contains its children. 2. **Configuration lives only in `elements`.** A column never contains element properties. ## Exact object shapes These are the real TypeScript interfaces (`core/shared/interfaces/structure.interface.ts`). Keys not listed here are ignored by the renderer. ```typescript interface IStructure { schemaVersion?: number; settings: ISettings; header?: IHeader; pages: IPage[]; elements: { [name: string]: IBaseElement }; dataSources?: IDataSource[]; localization?: ILocalization; } interface IPage { name: string; // must match an elements[name] entry with type "page" rows: IRow[]; status?: 'default' | 'readonly'; disabled?: boolean; readOnly?: boolean; } interface IRow { columns: IColumn[]; // this is the ONLY key on a row } interface IColumn { elementRef: string; // required, points into elements rows?: IRow[]; // children of a container element tabRows?: Record; // children of tabs/accordion/splitter/progressFlow fragmentRef?: string; // platform fragments only fragmentBindings?: Record; fragmentMode?: 'append' | 'replace'; } ``` **A row object has exactly one key: `columns`.** **A column object has `elementRef` plus, when the element is a container, `rows` or `tabRows`. Nothing else.** Anything you are tempted to write on a column (`width`, `mobileWidth`, `label`, `type`, `span`, `size`, `flex`, `colSpan`) does not exist there. It is silently dropped. ## How a row renders - A `row` is a **horizontal band**. Its `columns` sit side by side, left to right. - Vertical order comes from the order of `rows` in the array. There is no `order` property. - **A column with no width takes an equal share of the row.** The renderer applies `flex: 1 1 0` when the element has no `width` (`core/shared/utils/responsive-width.ts`). So a 50/50 pair is just two columns in one row, with no widths at all: ```json { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ``` Three equal fields: three columns. One full-width field: one column. **Do not write `"width": "50%"` to get halves.** Equal split is the default, and writing widths on columns does nothing anyway. ## Where widths belong Width is a property of the **element**, in the `elements` map: | Property | Meaning | |---|---| | `width` | Desktop width, e.g. `"100%"`, `"320px"`, `"calc(50% - 8px)"` | | `tabletWidth` | Overrides `width` on tablet | | `mobileWidth` | Overrides `width` and `tabletWidth` on mobile | | `fitContent` | Shrink to content instead of filling | A bare number is treated as pixels. A percentage, `calc()`, `min()`, `max()` or `clamp()` is treated as relative and pinned exactly; a fixed size is allowed to shrink (`min(width, 100%)`). Only set these when you need something **other** than an equal split, for example a narrow field beside a wide one: ```json { "postCode": { "name": "postCode", "label": "Post code", "type": "text", "width": "160px" }, "street": { "name": "street", "label": "Street", "type": "text" } } ``` Mobile stacking happens by giving the element `"mobileWidth": "100%"`, again on the element, never on the column. ## Nesting: the mistake that breaks everything Container elements do **not** hold their children. The **column that references the container** holds them, in its own `rows` array. ### Wrong (children inside the element definition) ```json { "pages": [ { "name": "page1", "rows": [{ "columns": [{ "elementRef": "panelGeneral" }] }] } ], "elements": { "panelGeneral": { "name": "panelGeneral", "type": "panel", "label": "General", "rows": [ { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ] } } } ``` The panel renders **empty**. `IBaseElement` has no `rows`, so the array is ignored, and `firstName` / `lastName` are never placed anywhere. ### Right (children inside the column) ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "panelGeneral", "rows": [ { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "type": "page", "label": "Page 1" }, "panelGeneral": { "name": "panelGeneral", "type": "panel", "label": "General" }, "firstName": { "name": "firstName", "type": "text", "label": "First name" }, "lastName": { "name": "lastName", "type": "text", "label": "Last name" } } } ``` Nesting is recursive: a nested column may itself carry `rows`, to any depth. Note that `parentName` exists on `IBaseElement` but is **not** how you build the tree. Never use it to declare parentage; the layout tree is the only source of structure. ## Which element types accept children Exactly these ten types are containers (`runtime-preview.ts`, `isContainerElement`): | Type | Children go in | Notes | |---|---|---| | `page` | `page.rows` | The page itself; its rows are the top level | | `panel` | `column.rows` | The normal grouping box, with a title from `label` | | `dynamicPanel` | `column.rows` | Repeatable group; value is an array of objects | | `dialog` | `column.rows` | An in-view modal element | | `emptyBlock` | `column.rows` | Unstyled layout box with flex/grid controls | | `tabs` | `column.tabRows` | Keyed by tab value | | `tabsPro` | `column.tabRows` | Keyed by item value | | `accordion` | `column.tabRows` | Keyed by item value | | `splitter` | `column.tabRows` | Keyed by panel value | | `progressFlow` | `column.tabRows` | Keyed by step value | **Every other type is a leaf.** `text`, `select`, `table`, `dynamicTable`, `messageCard`, `statsCard`, `richText` and the rest never receive `rows`. The two tables are the exception that proves the rule: their cells are declared on the element, not in the layout tree, and the two do not use the same property. `table` uses `columnsConfig`, and each entry is a column keyed by `key`. `dynamicTable` uses `columns`, and each entry is a full element definition keyed by `name`, because every cell is an editable control. Neither uses `column.rows`. ### `tabRows` keys For tab-like containers, `tabRows` is an object keyed by the **`value` of each declared tab / item / panel / step** (falling back to `id`, then `tab1`, `tab2`, ...). The keys must match the container's own list property: | Type | List property | Key source | |---|---|---| | `tabs` | `tabs` | `tabs[i].value` | | `tabsPro` | `items` | `items[i].value` | | `accordion` | `items` | `items[i].value` | | `splitter` | `panels` | `panels[i].value` | | `progressFlow` | `items` | `items[i].value` | ```json { "columns": [ { "elementRef": "personTabs", "tabRows": { "general": [{ "columns": [{ "elementRef": "firstName" }] }], "contact": [{ "columns": [{ "elementRef": "email" }] }] } } ] } ``` with ```json { "personTabs": { "name": "personTabs", "type": "tabs", "label": "Person", "tabs": [ { "value": "general", "label": "General" }, { "value": "contact", "label": "Contact" } ] } } ``` ## Pages are not sections A `page` is a **step or screen**, not a visual section. Multiple entries in `pages` produce a multi-step view, navigated by the built-in pager or stepper (`settings.pageNavigationMode`). To draw titled sections stacked on one screen, use **one page containing several `panel` elements**. Do not create one page per section. Every page needs its twin in `elements`: ```json { "pages": [{ "name": "personPage", "rows": [] }], "elements": { "personPage": { "name": "personPage", "label": "Person", "type": "page", "hideHeader": true } } } ``` Page element properties: `name`, `label`, `description`, `type`, `visibleIf`, `disableIf`, `readonlyIf`, `hideHeader`, `removeBackgraund` (spelled exactly like that), `pageBackgroundColor`, `pagePadding`, `mobilePadding`. Nothing else. ## Building a layout from a screenshot or description Follow this order every time. Do not start writing JSON at step 1. 1. **Read the visual top to bottom.** List every field, in order, with its label. 2. **Find the section headings.** Each heading becomes one `panel` element whose `label` is that heading. 3. **Slice each section into horizontal bands.** Fields on the same visual line belong to the same `row`. A field on its own line is its own `row` with one column. 4. **Count the columns per band.** Two fields side by side is two columns. Three is three. Equal split is free; only reach for widths when the visual is clearly uneven. 5. **Write `elements` first.** Every panel, every field, the page. Flat, keyed by `name`, each with `name`, `label`, `type`. 6. **Write `pages` second.** Panels at the top level; each panel's column carries the section's `rows`. 7. **Verify** with the checklist below. Counting rows and columns before touching JSON is what keeps the output faithful to the picture. ## Verification checklist Run all of these mentally before returning JSON. 1. Every `elementRef` string resolves to a key in `elements`. 2. Every key in `elements` appears exactly once as some `elementRef` (or is a `page`). 3. Every entry of `pages` has a matching `elements[name]` with `type: "page"`. 4. No object in `elements` has a `rows`, `columns` or `children` key. The two exceptions are `table.columnsConfig` and `dynamicTable.columns`. 5. No column object has any key other than `elementRef`, `rows`, `tabRows`, `fragmentRef`, `fragmentBindings`, `fragmentMode`. 6. No row object has any key other than `columns`. 7. `rows` on a column appears only when the referenced element is one of the ten container types. 8. `tabRows` keys match the container's declared tab/item/panel/step values. 9. Widths, if present at all, are on elements, not columns. 10. `name` values are unique across the whole structure. ## Full worked example A dialog with four titled sections, mixed one-, two- and three-field rows, conditional document fields, and a footer button pair. Every property used below exists in [the properties reference](https://ngxviewbuilder.io/ai/properties-reference). ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "lt-LT", "width": "640px", "widthUnit": "px", "renderMode": "dialog", "dialogTitle": "Add a new person", "dialogWidth": "640px", "dialogShowCloseButton": true, "showSubmitButton": false }, "pages": [ { "name": "personPage", "rows": [ { "columns": [ { "elementRef": "panelGeneral", "rows": [ { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] }, { "columns": [{ "elementRef": "personalCode" }, { "elementRef": "birthDate" }] }, { "columns": [{ "elementRef": "citizenship" }, { "elementRef": "gender" }] }, { "columns": [{ "elementRef": "isStudent" }] } ] } ] }, { "columns": [ { "elementRef": "panelContact", "rows": [ { "columns": [{ "elementRef": "email" }] }, { "columns": [{ "elementRef": "phone" }] }, { "columns": [{ "elementRef": "declaredAddress" }] }, { "columns": [{ "elementRef": "country" }] }, { "columns": [{ "elementRef": "locality" }, { "elementRef": "street" }] }, { "columns": [ { "elementRef": "houseNumber" }, { "elementRef": "flatNumber" }, { "elementRef": "postCode" } ] } ] } ] }, { "columns": [ { "elementRef": "panelBank", "rows": [ { "columns": [{ "elementRef": "bankAccount" }] }, { "columns": [{ "elementRef": "bankName" }] }, { "columns": [{ "elementRef": "bankSwift" }] }, { "columns": [{ "elementRef": "bankAddress" }] } ] } ] }, { "columns": [ { "elementRef": "panelDocument", "rows": [ { "columns": [{ "elementRef": "hasDocument" }] }, { "columns": [{ "elementRef": "documentType" }] }, { "columns": [ { "elementRef": "documentNumber" }, { "elementRef": "documentValidUntil" } ] } ] } ] }, { "columns": [ { "elementRef": "panelActions", "rows": [ { "columns": [{ "elementRef": "cancelButton" }, { "elementRef": "saveButton" }] } ] } ] } ] } ], "elements": { "personPage": { "name": "personPage", "label": "Add a new person", "type": "page", "hideHeader": true }, "panelGeneral": { "name": "panelGeneral", "label": "Bendri asmens duomenys", "type": "panel", "showBorder": false, "panelPadding": "0px", "titleUnderline": true }, "firstName": { "name": "firstName", "label": "Vardas", "type": "text", "required": true, "requiredMessage": "Enter the first name" }, "lastName": { "name": "lastName", "label": "Last name", "type": "text", "required": true, "requiredMessage": "Enter the last name" }, "personalCode": { "name": "personalCode", "label": "Asmens kodas", "type": "text", "maskType": "digits", "maxlength": 11, "required": true, "requiredMessage": "Enter the personal code" }, "birthDate": { "name": "birthDate", "label": "Gimimo data", "type": "datepicker", "placeholder": "Pasirinkti", "pickerMode": "date", "format": "YYYY-MM-DD", "required": true }, "citizenship": { "name": "citizenship", "label": "Citizenship", "type": "select", "showSearch": true, "strictOptions": true, "required": true, "options": [ { "value": "LT", "label": "Lietuva" }, { "value": "LV", "label": "Latvija" }, { "value": "EE", "label": "Estija" } ] }, "gender": { "name": "gender", "label": "Lytis", "type": "select", "strictOptions": true, "required": true, "options": [ { "value": "M", "label": "Vyras" }, { "value": "F", "label": "Moteris" } ] }, "isStudent": { "name": "isStudent", "label": "", "type": "singleCheckbox", "checkboxLabel": "Person qualifies as a student", "defaultValue": false }, "panelContact": { "name": "panelContact", "label": "Kontaktiniai duomenys", "type": "panel", "showBorder": false, "panelPadding": "0px", "titleUnderline": true }, "email": { "name": "email", "label": "Email", "type": "text", "inputMode": "email", "required": true, "validators": [ { "type": "email", "message": "Invalid email format" } ] }, "phone": { "name": "phone", "label": "Tel. Nr.", "type": "phoneInput", "defaultCountryCode": "LT" }, "declaredAddress": { "name": "declaredAddress", "label": "Deklaruotos gyvenamosios vietos adresas", "type": "text", "readOnly": true, "expression": "{street} + ' ' + {houseNumber} + ', ' + {locality}", "logicExecutionMode": "onChange" }, "country": { "name": "country", "label": "Country", "type": "select", "showSearch": true, "strictOptions": true, "options": [ { "value": "LT", "label": "Lietuva" }, { "value": "LV", "label": "Latvija" }, { "value": "EE", "label": "Estija" } ] }, "locality": { "name": "locality", "label": "Locality", "type": "text" }, "street": { "name": "street", "label": "Street", "type": "text" }, "houseNumber": { "name": "houseNumber", "label": "Namo numeris", "type": "text", "mobileWidth": "100%" }, "flatNumber": { "name": "flatNumber", "label": "Buto numeris", "type": "text", "mobileWidth": "100%" }, "postCode": { "name": "postCode", "label": "Post code", "type": "text", "mobileWidth": "100%" }, "panelBank": { "name": "panelBank", "label": "Bank account details", "type": "panel", "showBorder": false, "panelPadding": "0px", "titleUnderline": true }, "bankAccount": { "name": "bankAccount", "label": "Bank account number", "type": "text" }, "bankName": { "name": "bankName", "label": "Banko pavadinimas", "type": "text" }, "bankSwift": { "name": "bankSwift", "label": "Banko SWIFT (BIC) kodas", "type": "text", "minlength": 8, "maxlength": 11 }, "bankAddress": { "name": "bankAddress", "label": "Banko adresas", "type": "text" }, "panelDocument": { "name": "panelDocument", "label": "Asmens dokumento duomenys", "type": "panel", "showBorder": false, "panelPadding": "0px", "titleUnderline": true }, "hasDocument": { "name": "hasDocument", "label": "Dokumentas", "type": "radio", "showInline": true, "defaultValue": "Y", "required": true, "options": [ { "value": "Y", "label": "Taip" }, { "value": "N", "label": "Ne" } ] }, "documentType": { "name": "documentType", "label": "Dokumento tipas", "type": "select", "strictOptions": true, "visibleIf": "{hasDocument} == 'Y'", "requireIf": "{hasDocument} == 'Y'", "logicExecutionMode": "onChange", "options": [ { "value": "PASSPORT", "label": "Pasas" }, { "value": "ID_CARD", "label": "ID card" } ] }, "documentNumber": { "name": "documentNumber", "label": "Dokumento numeris", "type": "text", "visibleIf": "{hasDocument} == 'Y'", "requireIf": "{hasDocument} == 'Y'", "logicExecutionMode": "onChange" }, "documentValidUntil": { "name": "documentValidUntil", "label": "Dok. galioja iki", "type": "datepicker", "placeholder": "Pasirinkti", "pickerMode": "date", "format": "YYYY-MM-DD", "visibleIf": "{hasDocument} == 'Y'", "logicExecutionMode": "onChange" }, "panelActions": { "name": "panelActions", "label": "", "type": "panel", "showBorder": false, "panelPadding": "0px", "contentJustify": "flex-end", "contentGap": "8px" }, "cancelButton": { "name": "cancelButton", "label": "", "type": "button", "text": "Cancel", "variant": "outline", "tone": "neutral", "fitContent": true }, "saveButton": { "name": "saveButton", "label": "", "type": "button", "text": "Save", "variant": "solid", "tone": "primary", "fitContent": true, "events": [{ "trigger": "click", "type": "submit", "validateForm": true }] } }, "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` Read that example structurally, not as a template to copy verbatim. The shape is what matters: four panels at the top level of `pages`, each panel's column carrying the section's rows, and every field defined once, flat, in `elements`. ## Quick reference card ``` pages[] → { name, rows[] } rows[] → { columns[] } (only key: columns) columns[] → { elementRef, rows?, tabRows? } (no widths, no props) rows[] → recursion, containers only elements{} → flat map, key === element.name every element → { name, label, type, ...properties } containers → hold NO children widths → width / tabletWidth / mobileWidth / fitContent, here only ``` Related: [JSON authoring rules](https://ngxviewbuilder.io/ai/json-authoring-rules), [Common mistakes](https://ngxviewbuilder.io/ai/common-mistakes), [Canonical properties reference](https://ngxviewbuilder.io/ai/properties-reference). --- ## AI: JSON authoring rules Source: https://ngxviewbuilder.io/ai/json-authoring-rules # AI: JSON authoring rules This page defines how an agent must construct NGX View Builder JSON. ::: warning Read the layout model first A structure has two independent halves: `pages` is a **layout tree of positions**, `elements` is a **flat dictionary of definitions**, and `elementRef` is the only bridge between them. Containers do not hold their children; the column that references a container holds them in its own `rows`. Widths belong to elements, never to columns. The complete rules, with the exact object shapes and a full worked example, are in [Layout model](https://ngxviewbuilder.io/ai/layout-model). Do not write layout JSON before reading it. ::: ## Minimal skeleton ```json { "settings": { "language": "en", "locale": "en-US", "renderMode": "page" }, "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "field1" } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page" }, "field1": { "name": "field1", "label": "Field 1", "type": "text" } }, "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` ## Element type name rules **Type strings are case-sensitive. Copy them exactly.** Never invent casing variants. | Correct | Wrong (do not use) | |---|---| | `"datepicker"` | `"datePicker"`, `"DatePicker"`, `"date-picker"` | | `"singleCheckbox"` | `"SingleCheckbox"`, `"single_checkbox"` | | `"dynamicTable"` | `"DynamicTable"`, `"dynamic-table"` | | `"dynamicPanel"` | `"DynamicPanel"` | | `"multiSelect"` | `"MultiSelect"`, `"multi-select"` | | `"toggleSwitch"` | `"ToggleSwitch"`, `"toggle-switch"` | | `"richText"` | `"richtext"`, `"RichText"` | | `"fileUpload"` | `"fileupload"`, `"FileUpload"` | | `"phoneInput"` | `"PhoneInput"`, `"phone-input"` | | `"listGrid"` | `"ListGrid"`, `"list-grid"` | The canonical full list is in [Common mistakes, entry 14](https://ngxviewbuilder.io/ai/common-mistakes#_14-wrong-element-type-casing). ## Structure rules ### `settings` - Keep only settings that are actually used. - If the user does not request a complex modal/dialog mode, `language`, `locale`, and `renderMode` are sufficient. - Do not invent unnecessary `dialog*`, `stepper*`, or `pageNavigation*` properties if they are not in use. ### `pages` - `pages` describes layout only. - Every page must have a `name` and a `rows` array. Those are its only structural keys. - A row object has exactly one key: `columns`. - A column object has `elementRef`, plus `rows` or `tabRows` when it references a container. Nothing else. `width`, `mobileWidth`, `span`, `label`, `type` on a column are silently dropped. - `rows[*].columns[*].elementRef` is a **string key** into the `elements` map, not an object. - Do not embed full element objects inside `pages`. - A `page` is a step or screen. Titled sections on one screen are `panel` elements inside a single page, not separate pages. ### `elements` - `elements` is an object, not an array, and it is **flat**. It never nests. - The key must match `element.name`. - Every element must have at least `name`, `label`, and `type`. - If `pages[*].name = "pageCustomer"`, there must be an `elements.pageCustomer` entry with `type: "page"`. - **No element definition lists its children.** `rows`, `columns`, `children`, `items`, `fields` on a `panel` / `dynamicPanel` / `tabs` / `dialog` do not exist and are ignored. The exception is the two tables, whose cells are not layout: `table` declares them in `columnsConfig` keyed by `key`, and `dynamicTable` declares them in `columns` keyed by `name`. ### `localization` - For a single-language form, the minimum is: - `defaultLanguage` - `languages` - For multi-language forms, a `texts` section may also be present. ### `dataSources` - Create only when the user genuinely needs external data or actions. - `dataSourceName` references in elements must point to a real datasource. - If the form has no integration scenarios, it is better to omit `dataSources`. ## Name and reference rules - All `name` values must be unique throughout the structure. - `elementRef` must point to an existing `elements` entry. - If an element is a container with inner fields, those child elements must also have unique `name` values. - Do not reuse the same `name` for multiple different elements. ## What the agent must do when extending an existing form - Preserve existing `settings`, `pages`, `elements`, `localization`, and `dataSources` unless the user explicitly asks to rebuild everything from scratch. - Modify only the related sections. - When adding a new element, you must: - add it to `elements` - insert the `elementRef` in the appropriate `page/row/column` - When adding logic between fields, verify that both fields already exist. ## Layout rules - A single `row` is a horizontal band; its `columns` sit side by side. Vertical order is the order of `rows`. - **A column with no width takes an equal share of the row.** For a two-column layout, one `row` with two `columns` and no widths is sufficient. Never write `"width": "50%"` to get halves. - Widths (`width`, `tabletWidth`, `mobileWidth`, `fitContent`) are **element** properties, set in the `elements` map, and only when the split must be uneven. - Exactly ten types accept children: `page`, `panel`, `dynamicPanel`, `dialog`, `emptyBlock` (via `column.rows`) and `tabs`, `tabsPro`, `accordion`, `splitter`, `progressFlow` (via `column.tabRows`, keyed by each tab/item/panel/step `value`). Every other type is a leaf. - Container inner layouts must remain in the NGX View Builder model, not via custom HTML. - `parentName` is not how parentage is declared. The layout tree is. Worked examples and the exact interfaces: [Layout model](https://ngxviewbuilder.io/ai/layout-model). ## Value shape rules - `text`, `textarea`, `richText` typically store a `string`. - `number`, `slider` store a `number` or `string` depending on `valueStorageType`. - `singleCheckbox`, `toggleSwitch`, `toggleButton` typically store a `boolean`. - `checkbox`, `multiSelect` typically store an array. - `select`, `radio`, `autocomplete` typically store a single value. - `dateRange` must return an object with `dateFrom` and `dateTo`. - `dynamicPanel` and `dynamicTable` typically store an array of objects. - `numberStepper` stores a `number`; `timePicker` stores a time string. - `signaturePad` stores signature image data keyed under the element's `name`. - `listBox`, `selectButton` store a single value, or an array when multi-select/`multiple` is enabled. - `progressBar` stores a `number`. ## Logic rules - Use `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, `resetIf` for boolean conditions. - Use `expression` to compute and write a value. - When logic depends on changes in another field, typically add `logicExecutionMode: "onChange"`. - Do not use self-reference `expression`. ## Validator field rules Validators are separate sub-objects in the `validators` array. They have their own field names that differ from element-level logic fields: | Element-level (correct placement) | Validator-level (correct placement) | |---|---| | `element.visibleIf` | `validator.condition` | | `element.requireIf` | `validator.applyIf` | | `element.expression` | _(does not exist on validators)_ | Never put `visibleIf`, `requireIf`, `readonlyIf`, `disableIf`, `expression`, or `resetIf` inside a validator object. Never use the field name `expression` on a validator; the correct field is `condition`. **Polarity:** `condition` is the *failing* check. The validator error is shown while `condition` evaluates to `true`. Use `applyIf` to run a validator only in certain cases (it runs while `applyIf` is truthy). Correct: ```json { "name": "age", "type": "number", "visibleIf": "{skipAge} != true", "validators": [ { "type": "min", "value": 0, "message": "Cannot be negative" }, { "type": "max", "value": 120, "message": "Invalid age", "applyIf": "{country} == 'LT'" } ] } ``` ## What must not be generated - Custom `Angular` components. - Non-existent NGX View Builder properties. - Full element objects embedded in `pages`. - Pseudo-code instead of real JSON. - Empty wrappers or meta-properties with no real purpose. ## Pre-return checklist 1. Do all `elementRef` values point to an existing element. 2. Does every `page` have a corresponding `elements[pageName]` entry. 3. Do logic fields return the correct type. 4. Are there no self-reference expressions. 5. Is the correct element chosen for the task. 6. Are there no unnecessary properties added. 7. Are all `type` strings exact-cased (e.g. `datepicker`, not `datePicker`)? 8. Do validators use `condition` (error when `true`) and `applyIf`, rather than `expression`, `visibleIf`, or other element-level field names? 9. Is every container's content attached to the referencing **column**, and does no element definition contain a `rows` / `children` / `items` array? 10. Do all column objects carry only `elementRef` (plus `rows` / `tabRows` where applicable), with no widths or other properties? --- ## AI: Element selection map Source: https://ngxviewbuilder.io/ai/element-selection-map # AI: Element selection map This page helps the agent decide which NGX View Builder element to use based on the user's intent. ## Quick reference - "dynamic table", "editable rows", "add row", "delete row" -> `dynamicTable` - "server-side table", "paging", "sorting", "filtering", "export", "row actions" -> `table` - "repeating block", "repeatable section" -> `dynamicPanel` - "single choice from a list" -> `select` or `radio` - "search in a large list" -> `autocomplete` - "multiple choices" -> `multiSelect` or `checkbox` - "yes/no toggle" -> `singleCheckbox`, `toggleSwitch`, or `toggleButton` - "handwritten signature" -> `signaturePad` - "time of day" -> `timePicker` - "quantity with +/- buttons" -> `numberStepper` ## Text and input elements | User intent | Choose | When not to choose | | --- | --- | --- | | A single short text value | `text` | Do not use for long descriptions | | Longer comment or notes | `textarea` | Do not use if rich content is needed | | Formatted content | `richText` | Do not use for plain single-line text | | Number | `number` | Do not use if it is only a text code | | Sliding numeric value | `slider` | Do not use for precise financial amounts | | Phone number | `phoneInput` | Do not use plain `text` if country-code UX is required | | Large searchable list | `autocomplete` | Do not use for small static lists | | File upload | `fileUpload` | Do not use for plain link text | | Triggering an action | `button` | Do not use for data input | | Code or JSON fragment | `textarea` | There is no `code` element type | | Small bounded quantity with +/- buttons | `numberStepper` | Do not use for large or precise ranges; prefer `number` | | Handwritten signature capture | `signaturePad` | Do not use for typed text | ## Choice elements | User intent | Choose | When not to choose | | --- | --- | --- | | Single choice from several options | `select` | If all options must always be visible, prefer `radio` | | Single choice with all options visible | `radio` | Do not use if there are very many options | | Multiple choices from a list | `multiSelect` | Do not use for a yes/no scenario | | Multiple choices as a checkbox group | `checkbox` | Do not use for a single boolean field | | Single boolean checkbox | `singleCheckbox` | Do not use if a button-style switch is desired | | Boolean toggle switch | `toggleSwitch` | Do not use if checkbox semantics are required | | Boolean button-style toggle | `toggleButton` | Do not use if a standard checkbox is needed | | Date selection | `datepicker` | Do not use for a date range | | Date range | `dateRange` | Do not use for a single date | | Time of day | `timePicker` | Combine with `datepicker` if both are needed | | Simple dropdown menu | `button` with `menuActions` | There is no `dropdown` element type; `select` is the closed choice control | | Always-visible scrollable selection list | `listBox` | Do not use if a compact closed control is preferred; use `select` | | Connected button row for 2-4 short options | `selectButton` | Do not use for long option lists; prefer `radio` or `select` | ## Tables and repeating data | User intent | Choose | Note | | --- | --- | --- | | User must add, delete, and edit rows inline | `dynamicTable` | This is not `table` | | Repeat an entire block with multiple fields | `dynamicPanel` | Better than `dynamicTable` if it is not a table | | Server-side table with paging, sorting, filtering, row actions | `table` | This is the main data grid | | Simple list or card grid display | `listGrid` | Not for an editable table | | Chart | `chart` | Not for a table or repeating input | ## Containers and UI structure | User intent | Choose | Note | | --- | --- | --- | | Simple block with inner fields | `panel` | Most common container | | Content divided into tabs | `tabs` or `tabsPro` | Do not use if multiple pages are sufficient | | Collapsible sections | `accordion` | Good for longer forms | | Step flow or progress | `progressFlow` | Not for a plain panel layout | | Modal-style content | `dialog` | Not as the primary page-level form container | | Two-zone layout with a divider | `splitter` | Only when two separate regions are needed | | Empty placeholder block for a drop zone | `emptyBlock` | Internal layout helper | | Visual separator | `divider` | Do not use for data storage | | Spacing | `spacer` | Do not use as a structural container | ## Display and media elements | User intent | Choose | Note | | --- | --- | --- | | Static HTML template | `customHtml` or `htmlSnippet` | Only when template HTML is truly needed | | Rich text viewer | `richTextViewer` | Not for editing | | Image | `image` | Not for file upload | | Video | `video` | Media viewing only | | External page or content | `iframe` | Only if the host allows it | | Avatar | `avatar` | Not for general images | | Icon | `icon` | Small UI symbol | | Navigation path | `breadcrumbs` | Navigation context | | Page heading | `pageTitle` | Hero or page top | | Badge | `badge` | Short status or label | | Informational card | `messageCard` | Message, warning, info | | Statistics card | `statsCard` | KPI or summary | | Toast notification | `toast` | Temporary notifications | | Completion percentage | `progressBar` | Not for step/page navigation; use `progressFlow` | ## Internal or system types The agent should not normally generate these directly as user form elements: - `row` - `column` - `visibility` - `choices` - `validators` - `dataSourceArguments` They belong to the internal model, property editing, or builder infrastructure. ## Keyword map | Prompt phrase | Choose | | --- | --- | | `dynamic table`, `editable rows`, `add row`, `delete row` | `dynamicTable` | | `repeating block`, `repeatable section`, `repeatable group` | `dynamicPanel` | | `data table`, `server-side table`, `paging`, `sorting`, `filtering` | `table` | | `search in list`, `searchable select`, `autocomplete` | `autocomplete` | | `multiple checkboxes`, `select multiple` | `checkbox` or `multiSelect` | | `yes/no`, `toggle`, `switch` | `singleCheckbox`, `toggleSwitch`, or `toggleButton` | ## If you are unsure between two options - `dynamicTable` vs `table`: does the user edit rows inline, or work with a datasource data grid? - `dynamicPanel` vs `dynamicTable`: does the data look more like a form/block or like a row-based table? - `select` vs `autocomplete`: are there few options, or many that require search? - `singleCheckbox` vs `toggleSwitch`: does checkbox semantics matter, or is a switch style preferred? --- ## AI: Logic and expression properties Source: https://ngxviewbuilder.io/ai/logic-and-expressions # AI: Logic and expression properties This page defines which expression fields the agent may use and which rules it must follow. ## Primary expression properties | Property | When to use | Must return | | --- | --- | --- | | `visibleIf` | Show an element (native field; return `false` to hide) | `true` or `false` | | `disableIf` | Make an element inactive | `true` or `false` | | `requireIf` | Make an element required | `true` or `false` | | `readonlyIf` | Make an element read-only | `true` or `false` | | `resetIf` | Clear an element under a condition | `true` or `false` | | `expression` | Compute and write a value | `string`, `number`, `boolean`, `array`, or `object` | | `validators[].applyIf` | Enable a validator conditionally | `true` or `false` | | `validators[].condition` | Failing check, error shown while `true` | `true` or `false` | ## Other expression usage points The agent must know that expression logic does not live only in the main fields. ### Data source parameters The field is called `params`, never `paramMap`: - an element's `dataSource.params[*].value`, shaped `[{ "name": ..., "value": ... }]` - an action's `params[*].value`, the same shape - a `table`'s own lazy-load request params, which are different: `[{ "paramName": ..., "paramValue": ... }]` - `{placeholder}` tokens inside a REST source's `params.url` and `params.body` These can use expression fragments such as: ``` {personCode} {row.id} {__variables.route.mode} ``` ### Table columns `table.columnsConfig[*]` can use: - `visibleIf` - `controlActiveIf` - `controlEnabledIf` Inside a column of `type: "element"`, the hosted element's own texts, templates, and event params resolve against the row it renders: `{row.field}`, a bare `{field}` for a sibling of the same row, `{index}`, and `{value}`. The same tokens work in a `dynamicTable` cell and a `dynamicPanel` row. ### Event and action logic Expressions are often used in actions in: - `params[*].value` - `condition`, which gates whether the action runs at all (per row for a table row action) - `setValueValue` when `setValueMode` is `template` or `expression` ## When to use `logicExecutionMode` If there is a dependency between fields, especially when `expression` is used, you typically need: ```json { "logicExecutionMode": "onChange" } ``` This is especially important when: - one field fills another - one field changes another's required state - one field hides or disables another ## Core writing rules - Use only NGX View Builder expression syntax, not full `JavaScript`. - Read field values via `{fieldName}` or `{object.field}`. - Use quotes for string values. - Do not add `toNumber(...)` by reflex. Number elements already hold numbers, so `{price} * {quantity}` works as written. Reach for it only when the value genuinely arrives as text: a `text` element, a data source field, a select whose values are strings, or a number element with `valueStorageType: "string"`. - Use `isEmpty(...)` and `notEmpty(...)` for empty checks. - Use `contains`, `containsAny`, `containsAll`, and `len` for arrays. ## Correct examples ### Auto-fill ```json { "name": "lastName", "type": "text", "expression": "{firstName} == 'John' ? 'Doe' : ''", "logicExecutionMode": "onChange" } ``` ### Conditional required ```json { "name": "companyCode", "type": "text", "requireIf": "{personType} == 'company'", "logicExecutionMode": "onChange" } ``` ### Show/hide ```json { "name": "vatCode", "type": "text", "visibleIf": "{country} == 'LT'" } ``` ### Calculation ```json { "name": "totalAmount", "type": "number", "expression": "{price} * {quantity}", "logicExecutionMode": "onChange" } ``` ## Bad examples ### Self-reference expression Incorrect: ```json { "name": "el2", "expression": "{el1} == 'John' ? 'Doe' : {el2}" } ``` Why it is wrong: - the element references itself - this leads to unstable or non-functioning logic Correct: ```json { "name": "el2", "expression": "{el1} == 'John' ? 'Doe' : ''", "logicExecutionMode": "onChange" } ``` ### Wrong type for a boolean property Incorrect: ```json { "visibleIf": "'yes'" } ``` `visibleIf` must return `true` or `false`, not a string. ### Reference to a non-existent field Incorrect: ```json { "disableIf": "{customerType} == 'vip'" } ``` if `customerType` does not exist in the form. ## Expression property map by need | Need | Property | | --- | --- | | Show/hide a field | `visibleIf` (return `false` to hide) | | Show but prevent editing | `disableIf` or `readonlyIf` | | Make required only in certain cases | `requireIf` | | Reset value when condition changes | `resetIf` | | Automatically compute a value | `expression` | | Enable a validator conditionally | `validators[].applyIf` | | Flag a custom validation error | `validators[].condition` (error while `true`) | ## Working with arrays and element metadata Conditions inside collection functions are written unquoted and evaluated once per entry. Entry fields are referenced bare, everything else in the view stays reachable through `{...}`: ```text countInArray({tasks}, status == "OPEN") filterArray({users}, role == {__variables.requiredRole}) findInArray({products}, id == {selectedId}) existsInArray({items}, status == "ACTIVE") sumArray({orderItems}, price) joinInArray(mapArray(filterArray({tasks}, status == "OPEN"), title), "", ", ") ``` Also available: `avgArray`, `getFirst(source, condition?)`, `getLast(source, condition?)`. `countInArray` still accepts a plain field selector, so older structures keep working. For element metadata rather than values: | Call | Returns | | --- | --- | | `getValue({select1})` | stored value, e.g. `OPEN` | | `getLabel({select1})` | displayed option label, e.g. `Open issue` | | `getLabel("select1", "CLOSED")` | label of a specific value | | `getElementProperty("el1", "label")` | any configured property, nested keys allowed | | `getProp({el1}, "placeholder")` | alias of `getElementProperty` | | `translate({row.status})` | value translated through `localization.texts[]` | | `currentLanguage()` | active language code | In `getLabel`, `getValue`, and `getProp` the token names the element, it is not replaced by that element's value. ## Writing values from an expression | Call | Does | | --- | --- | | `setValue({variable1}, value)` | writes into a variable or an element data path | | `sumValue({variable1}, value)` | adds a number to the current value, returns the new total | | `pushValue({variable1}, value)` | appends to the target's array | | `flattenArray(source)` | flattens nested arrays into one level | `setVar`, `sumVar`, and `pushVar` are the same calls with the target always read as a variable name. `addValue` is an alias of `sumValue`. Shorthand, where the leftmost token is the target and the right side reads normally: ```text {variable1} = {row.column3} + 40 {variable1} += {row.column3} ``` Rules for the agent: - The first argument names the target, do not replace it with a value. - `setValue` is safe in a recalculated expression, writing an unchanged value does nothing. - `sumValue` and `pushValue` add again on every run, so put them in an action, never in `visibleIf`, `disableIf`, or an `expression` property. - For a table inside a dynamic panel prefer one aggregate over accumulation: `setValue({variable1}, sumArray({el1}, el5[].column3))`. - A published column total is read as `{el4.column1-total}`, always in braces. ## Contexts the agent may encounter ### General form context - `{fieldName}` - `{nested.field}` - `{__variables.*}` ### Dynamic blocks and tables - `{row.field}` inside a `table` cell, a `dynamicTable` row, a row action or a column template - `{panel.field}` inside a `dynamicPanel` entry - `{item.field}` for the candidate option in `filterOptionsBy` - `{index}` and `{value}` inside a cell - a published `dynamicTable` column total, `{el1.column4-total}` ### A table's own live state A `table` publishes its state so other elements can read it. `__table..*` is that table, bare `__table.*` is the last one touched: `rows` `allRows` `rowCount` `totalRecords` `page` `size` `sortField` `sortDirection` `quickSearch` `detailedFilters` `request` `selectedRows` `selectedKeys` `selectedItems` `selectedCount` `selectedRow` `selectedRowKey` `selectedRowIndex` `activeRow` `activeRowKey` ```text {__table.el2.selectedCount} > 0 notEmpty({__table.el2.selectedRowKey}) {__table.el2.selectedRow}.column2 ``` This is what drives selection-aware buttons, counters and master/detail screens. Full examples: [Verified examples, 6.5 and 6.6](https://ngxviewbuilder.io/ai/examples#_6-5-the-table-s-live-state-table). In such cases, do not guess. If `table` or `dynamicPanel` is used, consult: - [Expressions](https://ngxviewbuilder.io/creators/expressions) - [Tables & lists](https://ngxviewbuilder.io/creators/elements/tables) ## When to avoid `expression` - When the user only wants a static default text. - When `defaultValue` is sufficient. - When you only need to show or hide a section, not compute new data. ## Final checklist 1. Does the condition reference existing fields. 2. Do boolean properties return a boolean value. 3. Does `expression` not return the wrong type. 4. Is there no self-reference. 5. Is `logicExecutionMode: "onChange"` added where needed. --- ## AI: Element rules and value shapes Source: https://ngxviewbuilder.io/ai/element-rules # AI: Element rules and value shapes This page is an AI-oriented summary of the primary NGX View Builder elements. For the full property list always consult: - [Element catalog](https://ngxviewbuilder.io/creators/elements/) - [Canonical properties reference](https://ngxviewbuilder.io/ai/properties-reference) ## General rules for all elements - Every user-facing element must have `name`, `label`, and `type`. - `name` must be unique. - If an element participates in logic, its `name` must be stable and meaningful. - Do not use properties that are not found in the documentation or catalog. ## Input elements ### `text` - Use for short text. - Typical value shape: `string`. - Common properties: `placeholder`, `defaultValue`, `required`, `expression`. ### `textarea` - Use for longer text, comments, and descriptions. - Value shape: `string`. - Do not use if formatted rich content is needed. ### `number` - Use for numbers, amounts, and quantities. - Value shape: `number` or `string`, depending on `valueStorageType`. - Do not wrap it in `toNumber(...)` in calculations. A `number` element already stores a number, so `{price} * {quantity}` is correct and `toNumber({price}) * toNumber({quantity})` only adds noise. The exception is `valueStorageType: "string"`, which really does store text. ### `slider` - Use for ranges or bounded numeric values with a slider. - Value shape: `number` or `string`. - Do not use for precise manual financial input. ### `numberStepper` - Use for small bounded quantities with increment/decrement buttons (passengers, rooms, items). - Value shape: `number`. - Use `min`/`max` to bound it. Do not use for large or precise numeric ranges; prefer `number`. ### `signaturePad` - Use to capture a handwritten signature (mouse or touch). - Value shape: signature image data (per `exportFormat`, e.g. `png`/`jpeg`), keyed under the element's `name`. - Do not use as a substitute for typed text input. ### `phoneInput` - Use for phone numbers with country code. - Value shape is often an object with country and number parts. - Do not use plain `text` if a standard phone UX is required. ### `autocomplete` - Use for a large list of options with search. - Value shape depends on the option mapping. - Often requires a datasource or options mapping. ### `fileUpload` - Use for file uploads. - Value shape: the upload endpoint's response object, stored verbatim (or an array when `multiple`), never file bytes/base64. Depends on `fileKeyField`/`fileNameField`/`fileTypeField`/`fileSizeField`. - Do not treat it as a plain text URL field. - Consulting mode: for the exact upload/download/delete request and response contract, see [Properties reference](https://ngxviewbuilder.io/ai/properties-reference#fileupload) and [File upload requests](https://ngxviewbuilder.io/developers/data-sources#file-upload-requests). ### `button` - Use to trigger an action. - Not intended for form value storage. - Common properties: `variant`, `events`, `actions`, `disabled`. ### `richText` / `richTextViewer` - `richText` edits formatted HTML; `richTextViewer` only renders it. - Value shape is an HTML `string`. - There is no `code` element type. For a code or JSON fragment use `textarea`, or `customHtml` / `htmlSnippet` for read-only markup. ## Choice elements ### `select` - Single choice from a list. - Value shape: single value. - Works with static or datasource options. ### `multiSelect` - Multiple choices from a list. - Value shape: array. - Suitable when there are many options and a compact UI is needed. ### `radio` - Single choice when all options must be visible at once. - Value shape: single value. ### `checkbox` - Multiple choices as a checkbox group. - Value shape: array. - Do not use for a single boolean field. ### `singleCheckbox` - Single boolean checkbox. - Value shape: `boolean`. ### `toggleSwitch` - Single boolean switch. - Value shape: `boolean`. - Good for active/inactive scenarios. ### `toggleButton` - Boolean or small-choice button-style control. - Value shape is typically `boolean`. ### `datepicker` - **Exact type string: `"datepicker"`** (all lowercase, never `datePicker`, `DatePicker`, or `date-picker`). - Single date. - Value shape is typically a date string or documented date format. - Do not use for a date range. Canonical example: ```json { "name": "birthDate", "type": "datepicker", "label": "Date of Birth", "placeholder": "Select date", "required": true, "validators": [ { "type": "maxDate", "value": "today", "message": "Cannot be in the future" } ] } ``` ### `dateRange` - Date range. - Value shape: ```json { "dateFrom": "2026-03-01", "dateTo": "2026-03-09" } ``` - Do not use for a single date. ### `timePicker` - Selects a time of day. - Value shape is typically a time string. - Combine with `datepicker` when both a date and a time are needed, or use a `text` element with a date-time input mask for single-field entry. ### `dropdown` does not exist - There is no `dropdown` element type. `select` is the closed dropdown control. - For an action menu use `button` with `menuActions`, or `table.rowActions` with `rowActionsDisplayMode: "dropdown"`. ### `listBox` - Always-visible scrollable selection list, single or multiple via `selectionMode`. - Value shape: single value (`selectionMode: "single"`) or array (`selectionMode: "multiple"`). - Use when choosing is the primary task on screen; otherwise prefer `select`. ### `selectButton` - A row of connected buttons, a visual alternative to `radio` for 2-4 short options. - Value shape: single value, or array when `multiple` is `true`. - Do not use for long option lists; prefer `radio` or `select`. ## Table and repeating data elements ### `dynamicTable` - Use for editable rows. - The user must be able to add, delete, and edit rows. - Value shape is typically: array of objects. - This is not a server-side data grid. ### `table` - Use for displaying data from a datasource. - Supports `sorting`, `filtering`, `paging`, `rowActions`, and `export`. - Requires `columnsConfig` and often datasource properties. - Use `key`, not `name`, for `columnsConfig[*]` columns. - A column either prints text (`type: "text"`) or hosts a real element (`type: "element"` plus `elementType` and `element`). Use the element form whenever the cell needs a control, a badge, a button, or an event. - Server-side paging/search (`lazyLoad: true` + a `TABLE-POST` datasource method) has its own request/response contract, so do not invent one. Consulting mode: see [Properties reference](https://ngxviewbuilder.io/ai/properties-reference#table) and [Table: server-side paging & filtering](https://ngxviewbuilder.io/developers/data-sources#table-server-side-paging-filtering-table-post). - If `key` is empty or absent, the column will not be rendered at runtime. - This is not an editable `dynamicTable`. ### `listGrid` - Use for displaying a list or card grid. - Do not use if full table functionality is needed. ### `chart` - Use for graphical visualization. - Do not use as a data input element. ## Container and layout elements ### `page` - Every `pages[*]` entry must have a corresponding `elements[pageName]` entry with `type: "page"`. - `page` itself is the top-level form page. ### `panel` - The most common container for grouping fields. - Suitable for sections with an inner layout. ### `dynamicPanel` - Use for repeating sets of blocks. - Value shape is typically: array of objects. - Suitable for addresses, family members, and education records. ### `tabs` and `tabsPro` - Use to divide content into tabs. - Suitable when multiple clearly separated areas are needed on a single page. ### `accordion` - Use for collapsible sections. - Suitable for longer forms. ### `progressFlow` - Use to display steps or progress. - Not for primary data input. ### `dialog` - Use for modal content. - Not every form needs `dialog*` settings. ### `splitter` - Use for a two-zone layout with a divider. ### `emptyBlock` - Internal helper placeholder for layout scenarios. ### `row` and `column` - These are internal layout concepts. - The agent typically does not generate them as top-level elements in the `elements` map. ## Display and media elements ### `richText` - Formatted content or a longer rich editor scenario. - Value shape: `string`. ### `richTextViewer` - For rich text viewing. - Not for editing. ### `customHtml` - Use only when template HTML is genuinely needed, based on documented NGX View Builder capability. - Do not use as an excuse to generate custom Angular. ### `htmlSnippet` - Use for short HTML fragments. ### `image` - For displaying images. ### `video` - For displaying video. ### `iframe` - For embedding external content. - Suitable only if the host allows it. ### `avatar` - For small user or object representations. ### `icon` - For a UI icon. ### `divider` - For visual separation. ### `spacer` - For spacing. ### `breadcrumbs` - For navigation path. ### `pageTitle` - For page or section headings. ### `badge` - For a short label or status. ### `messageCard` - For an informational, success, or warning card. ### `statsCard` - For KPI or summary blocks. ### `toast` - For temporary notifications. - Often tied to actions, not permanent layout. ### `progressBar` - Completion indicator; value 0-100 (or a custom `max`) from `expression`, `defaultValue`, or a bound datasource. - Value shape: `number`. - Not for step/page navigation; use `progressFlow` for that. ## Validators Validators belong in the `validators` array on any element. The exact interface is: ```typescript interface IValidator { type: string; // validator type (required) value?: string | number; // threshold (minLength, maxLength, min, max, minDate, maxDate, etc.) message?: string; // error text shown to user condition?: string; // expression, the FAILING check; the error is shown while it evaluates to true applyIf?: string; // expression, validator only runs while this evaluates to true } ``` **Field names that do NOT exist on `IValidator`: `expression`, `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, `resetIf`.** Those are element-level logic fields. Never place them inside a validator object. When writing a custom `condition`, express the failing state, for example: ```json { "type": "custom", "condition": "dateDiffDays({startDate}, {endDate}) < 1", "message": "End date must be after start date" } ``` Correct usage: ```json { "name": "firstName", "type": "text", "label": "First Name", "requireIf": "{step} == 'personal'", "validators": [ { "type": "minLength", "value": 2, "message": "At least 2 characters required" }, { "type": "maxLength", "value": 64, "message": "Must not exceed 64 characters", "applyIf": "{firstName} != ''" } ] } ``` Wrong, do not do this: ```json { "validators": [ { "type": "minLength", "expression": "{firstName}.length >= 2", "visibleIf": "{skip} == true" } ] } ``` ## Internal and system types Do not normally generate these as user form elements unless working with builder infrastructure: - `visibility` - `choices` - `validators` - `dataSourceArguments` ## Per-element AI checklist Before generating an element, the agent must answer: 1. What is the purpose of this element. 2. What must the value shape be. 3. Is a datasource needed. 4. Are logic properties needed. 5. Does the user want input, display, repetition, or a data grid. --- ## AI: Canonical properties reference Source: https://ngxviewbuilder.io/ai/properties-reference # AI: Canonical properties reference This is the authoritative property reference for AI JSON generation. Only properties listed here are supported. Do not invent or guess properties not found in this document. --- ## Base properties (all elements) Every element must have these three fields: ```json { "name": "fieldName", "label": "Field Label", "type": "text" } ``` Additional base properties available on all element types: | Property | Type | Description | |---|---|---| | `description` | `string` | Subtitle or help text shown below the label | | `hidden` | `boolean` | Statically hides the element | | `width` | `string` | Width (e.g. `"100%"`, `"300px"`) | | `tabletWidth` | `string` | Width on tablet | | `mobileWidth` | `string` | Width on mobile | | `dependsOn` | `string[]` | Limit expression recalculation to these field names | | `logicExecutionMode` | `"onBlur" \| "onChange" \| "onInput"` | When logic/expression is re-evaluated | | `validationExecutionMode` | `"onBlur" \| "onChange" \| "onInput"` | When validators are re-evaluated | | `status` | `"default" \| "readonly"` | Interaction status override | | `labelTooltip` | `string` | Tooltip shown on label hover | | `fitContent` | `boolean` | Width fits content | --- ## Logic fields (element level only, never inside validators) These fields accept JEXL expressions using `{fieldName}` placeholder syntax. | Field | Return type | Behavior | |---|---|---| | `visibleIf` | `boolean` | Element visible when `true` | | `disableIf` | `boolean` | Element disabled when `true` | | `requireIf` | `boolean` | Element required when `true` | | `readonlyIf` | `boolean` | Element readonly when `true` | | `resetIf` | `boolean` | Element value cleared when `true` | | `expression` | `any` | Result is set as element value; also for `setElementProperty` calls | **All logic fields must contain valid JEXL expressions only.** **There is no `hideIf`, and no `readOnlyIf` with a capital O.** These six keys are the entire list and they are matched exactly. Anything else is an ordinary property as far as the runtime is concerned: it is stored, it is never evaluated, and no dependency is registered for it. What you get is a form that looks correct in the JSON and does nothing at all, with no error anywhere to point at. Write the visible condition rather than the hidden one, because `visibleIf` states when the element **is** shown, so a hide condition has to be inverted, not renamed. ### Execution mode | Field | Default | Behavior | |---|---|---| | `logicExecutionMode` | `onBlur` | When the logic fields above are re-evaluated | | `validationExecutionMode` | `onBlur` | When validators are re-evaluated | Both fall back to `onBlur` when the property is missing, so logic only re-runs after the field loses focus. To anyone typing in the form that reads as broken logic, so set `onChange` on every element whose logic or validation has to follow the typing, unless another mode was asked for. --- ## Static state fields | Field | Type | Description | |---|---|---| | `required` | `boolean` | Always required | | `disabled` | `boolean` | Always disabled | | `readOnly` | `boolean` | Always read-only | --- ## Validators `validators` is an array of `IValidator` objects. Exact interface: ```typescript interface IValidator { type?: string; // validator type (required) message?: string; // error message shown to user value?: string | number; // threshold (minLength, maxLength, min, max, minDate, maxDate) condition?: string; // JEXL, the FAILING check; error shown while this evaluates to true applyIf?: string; // JEXL, validator runs only when this is truthy } ``` **Forbidden inside validators: `expression`, `visibleIf`, `requireIf`, `disableIf`, `readonlyIf`, `resetIf`.** ```json { "validators": [ { "type": "minLength", "value": 3, "message": "At least 3 characters" }, { "type": "maxLength", "value": 100, "message": "Too long", "applyIf": "notEmpty({name})" }, { "type": "min", "value": 0, "message": "Cannot be negative" }, { "type": "max", "value": 999, "message": "Too large" }, { "type": "minDate", "value": "today", "message": "Cannot be in the past" }, { "type": "maxDate", "value": "today", "message": "Cannot be in the future" } ] } ``` --- ## Events / Actions `events` is an array of `IElementActionConfig` objects. Key fields: | Field | Type | Values / Notes | |---|---|---| | `trigger` | `string` | `click` `submit` `input` `change` `blur` `focus` `beforeLoad` `onLoad` `afterLoad` `always`. Prefer a named trigger; `always` skips trigger filtering entirely and is only for host elements whose events are not gestures (a router outlet's `activate`/`deactivate`), so on a normal element it fires on load and on every gesture. | | `type` | `string` | `navigate` `dataSource` `setValue` `setOptions` `reloadElements` `toast` `dialog` `validate` `submit` | | `label` | `string` | Button label (when rendered as action button) | | `icon` | `string` | Icon name | | `condition` | `string` | JEXL, action runs only when truthy | | `validateForm` | `boolean` | Validate before executing | | `confirmEnabled` | `boolean` | Show confirmation dialog before action | | `confirmTitle` | `string` | Confirm dialog title | | `confirmMessage` | `string` | Confirm dialog body | **By action type:** `navigate`: `navigateTo` (URL/route), `openInNewTab` (boolean) `dataSource`: `dataSourceName`, `responseMode` (`none` `download` `setValue`), `responseDataPath`, `responseTargetElement`, `reloadCurrentElementAfterSuccess`, `reloadOnReturnElementNames` `toast`: `toastTitle`, `toastMessage`, `toastVariant` (`error` `warning` `info` `success`), `toastPosition` (`top-left` `top-center` `top-right` `bottom-left` `bottom-center` `bottom-right`), `toastAutoHide`, `toastAutoHideMs` `dialog`: `dialogName`, `dialogOperation` (`open` `close` `toggle`) `setValue`: `setValueTargetPath`, `setValueMode` (`contextPath` `template` `json`), `setValueValue` `setOptions`: `setOptionsTargetElement`, `setOptionsMode` (`contextPath` `template` `json`), `setOptionsValue` `reloadElements`: `reloadElementNames` (comma-separated) `validate`: no extra fields. Validates the whole form and shows field errors, same as the built-in Validate toolbar button `submit`: no extra fields. Validates the form, then fires `onComplete` with `{ isValid, data, issues }`, same as the built-in Submit toolbar button Button appearance for action: `buttonVariant` (`filled` `outlined` `text`), `buttonTone` (`primary` `risk` `neutral`), `hideText`, `useCustomButtonStyle` --- ## Per-type additional properties ### `text` | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | | | `minlength` | `number` | | | `maxlength` | `number` | | | `showMaxLengthCounter` | `boolean` | Show counter when maxlength is set | | `pattern` | `string` | Regex pattern | | `maskType` | `string` | Exactly: `none` `phoneIntl` `date` `time` `dateTime` `digits` `custom`. No other mask names exist | | `maskPattern` | `string` | Custom mask (when maskType is `custom`) | | `inputMode` | `string` | `text` `search` `email` `tel` `url` `numeric` `decimal` | | `spellcheck` | `boolean` | | | `autocomplete` | `string` | Browser autocomplete hint | | `requiredMessage` | `string` | Custom required error message | ### `textarea` Same as `text` plus: | Property | Type | Notes | |---|---|---| | `rows` | `number` | Visible row count | ### `number` | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | | | `min` | `number` | Minimum value | | `max` | `number` | Maximum value | | `step` | `number` | Increment step | | `inputMode` | `string` | `numeric` `decimal` | | `valueStorageType` | `string` | `number` (default) or `string` | | `visualFormatEnabled` | `boolean` | Enable visual number formatting | | `visualFormatLocale` | `string` | Locale for formatting (e.g. `"lt-LT"`) | | `visualFormatUseGrouping` | `boolean` | Thousands separator | | `visualFormatMinFractionDigits` | `number` | | | `visualFormatMaxFractionDigits` | `number` | | | `requiredMessage` | `string` | | ### `datepicker` **Exact type string: `"datepicker"` (all lowercase)** | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | | | `pickerMode` | `string` | `date` (default) or `datetime` | | `includeSeconds` | `boolean` | For datetime mode | | `format` | `string` | Output format. Exactly one of `""` (locale) `YYYY-MM-DD` `DD/MM/YYYY` `MM/DD/YYYY` `DD.MM.YYYY` `YYYY/MM/DD` `custom`. Lowercase Angular-style patterns such as `yyyy-MM-dd` are **not** valid | | `customFormat` | `string` | The pattern used when `format` is `custom` | | `minValue` | `string` | Min selectable date (JEXL or static) | | `maxValue` | `string` | Max selectable date (JEXL or static) | | `requiredMessage` | `string` | | ### `dateRange` | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | | | `format` | `string` | | | `minValue` | `string` | | | `maxValue` | `string` | | | `requiredMessage` | `string` | | Value shape: `{ "dateFrom": "YYYY-MM-DD", "dateTo": "YYYY-MM-DD" }` ### `select` / `multiSelect` / `radio` / `checkbox` / `autocomplete` | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | Real on `autocomplete`, `listBox`, `selectButton`. On `select` / `multiSelect` it sets **only the search box hint** inside the open dropdown, and only when `showSearch` is on; the closed-state "Select..." text comes from the UI translation `select.placeholder` / `multiSelect.placeholder`, not from this property | | `options` | `IOption[]` | Static options: `[{ "value": "x", "label": "X" }]` | | `dataSource` | `IElementDataSource` | Dynamic options from datasource | | `showSearch` | `boolean` | Search box in dropdown (select) | | `strictOptions` | `boolean` | Value must be from options list | | `filterIfEqual` | `string` | Filter options when datasource field equals value | | `filterIfNotEqual` | `string` | Filter options when datasource field not equals value | | `optionTemplate` | `string` | Inline HTML template for each option | | `optionTemplateName` | `string` | Template name reference | | `showInline` | `boolean` | Lay the choices out in a row (`radio`, `checkbox`) | | `requiredMessage` | `string` | | Value shapes: - `select` / `radio` / `autocomplete`: single value - `multiSelect` / `checkbox`: array ### `singleCheckbox` / `toggleSwitch` / `toggleButton` Value shape: `boolean` | Property | Type | Notes | |---|---|---| | `checkboxLabel` | `string` | Text rendered **next to the box** (`singleCheckbox`). `label` is the field label above it; for a bare checkbox row set `label` to `""` and put the wording here | | `checkedValue` | `any` | Value when checked (`toggleButton` only) | | `uncheckedValue` | `any` | Value when unchecked (`toggleButton` only) | `singleCheckbox` has no `checkedValue` / `uncheckedValue`. It always stores `true` / `false`. ### `phoneInput` | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | | | `defaultCountryCode` | `string` | ISO code of the preselected country: `LT` `LV` `EE` `PL` `DE` `SE` `NO` `DK` `FI` `GB` `IE` `US` | | `allowedCountryCodes` | `string[]` | Restrict the country dropdown to these ISO codes | | `maxlength` | `number` | | | `autocomplete` | `string` | | | `requiredMessage` | `string` | | `phoneInput` has its own country dropdown. It has **no** `maskType`, `maskPattern` or `inputMode`, and the dial code is not set via `defaultValue`. ### `button` | Property | Type | Notes | |---|---|---| | `text` | `string` | Button text | | `icon` | `string` | Icon name | | `iconPosition` | `string` | `left` `right` `top` `bottom` | | `iconOnly` | `boolean` | Show icon only | | `variant` | `string` | `solid` `outline` `text` | | `tone` | `string` | `primary` `neutral` `success` `info` `warning` `risk` | | `size` | `string` | `small` `normal` `large` | | `loading` | `boolean` | Show loading spinner | | `badge` | `string` | Badge text | | `events` | `IElementActionConfig[]` | Primary click actions | | `menuActions` | `IElementActionConfig[]` | Dropdown menu actions | ### `fileUpload` | Property | Type | Notes | |---|---|---| | `accept` | `string` | Accepted MIME types or extensions, e.g. `.pdf,image/*` | | `multiple` | `boolean` | Allow multiple files | | `maxFiles` | `number` | Max number of attached files (default `5`) | | `maxFileSizeMb` | `number` | Max file size **in megabytes**, not bytes | | `uploadDataSourceName` / `downloadDataSourceName` / `deleteDataSourceName` | `string` | Names of REST data sources; empty `uploadDataSourceName` means files never leave the browser (local metadata only) | | `uploadFormFieldName` | `string` | `multipart/form-data` field name for the file (default `file`). Upload always sends single-field `FormData`, never JSON/base64 | | `fileKeyField` / `fileNameField` / `fileTypeField` / `fileSizeField` | `string` | Field names read from the server's response object (defaults `key`, `name`, `contentType`, `size`; the older `fil_key`/`fil_name`/`fil_content_type`/`fil_size` names are still recognized as fallback aliases) | Value shape: the upload response object stored verbatim (or an array of them when `multiple`), keyed under the element's `name`, never file bytes/base64. Full request/response wire contract: [File upload requests](https://ngxviewbuilder.io/developers/data-sources#file-upload-requests). ### `page` The complete list. A `page` element accepts nothing else. | Property | Type | Notes | |---|---|---| | `name` | `string` | Must match the `pages[*].name` entry | | `label` | `string` | Page / step title | | `description` | `string` | Subtitle | | `type` | `"page"` | Required | | `hideHeader` | `boolean` | Hide the page title block | | `removeBackgraund` | `boolean` | Drop the page background. Spelled exactly like this, the typo is part of the schema | | `pageBackgroundColor` | `string` | | | `pagePadding` | `string` | | | `mobilePadding` | `string` | | | `visibleIf` / `disableIf` / `readonlyIf` | `string` | JEXL | A page's children are **not** listed here. They live in `pages[*].rows`; see [Layout model](https://ngxviewbuilder.io/ai/layout-model). ### `panel` | Property | Type | Notes | |---|---|---| | `headerTemplate` | `string` | Inline HTML for panel header | | `headerTemplateName` | `string` | Template name reference | | `maxHeight` | `string` | Max panel height | | `panelPadding` | `string` | Internal padding | | `mobilePadding` | `string` | Mobile padding | | `panelRadius` | `string` | Border radius | | `panelShadow` | `string` | Box shadow CSS | | `showBorder` | `boolean` | Show panel border | | `panelBorderWidth` | `string` | Border width (when showBorder) | | `panelBorderColor` | `string` | Border color (when showBorder) | | `panelBackgroundColor` | `string` | Background color | | `titleUnderline` | `boolean` | Show underline below title | | `contentJustify` | `string` | Flexbox justify: `flex-start` `center` `flex-end` `space-between` `space-around` `space-evenly` | | `contentAlign` | `string` | Flexbox align: `flex-start` `center` `flex-end` `stretch` | | `contentGap` | `string` | Gap between child elements | | `titleUnderlineColor` | `string` | Underline color (when `titleUnderline`) | | `titleUnderlineWidth` | `string` | Underline thickness (when `titleUnderline`) | | `resetChildrenOnHide` | `boolean` | Clear descendant field values when the panel becomes hidden | **A panel never lists its children.** There is no `rows`, `columns`, `children`, `elements` or `items` property on `panel`. Child fields are attached in the layout tree, on the column that references the panel. See [Layout model](https://ngxviewbuilder.io/ai/layout-model#nesting-the-mistake-that-breaks-everything). ### `dynamicPanel` Same as `panel` plus repeatable container behavior (`addRowButtonText`, `removeRowButtonText`, `emptyMessage`, `disallowAddRows`, `disallowDeleteRows`, `maxRows`, `confirmRowDeletion`, `hideHeader`). Value shape: array of objects. Children are attached the same way as for `panel`, via `column.rows`. ### `dynamicTable` | Property | Type | Notes | |---|---|---| | `emptyMessage` | `string` | Message when no rows | | `addRowButtonText` | `string` | Add row button label | | `itemsPath` | `string` | Data path for pre-populating rows | | `disallowAddRows` | `boolean` | | | `disallowDeleteRows` | `boolean` | | | `maxRows` | `number` | | | `confirmRowDeletion` | `boolean` | | | `confirmDeleteTitle` | `string` | | | `confirmDeleteMessage` | `string` | | | `confirmDeleteConfirmLabel` | `string` | | | `confirmDeleteCancelLabel` | `string` | | | `hideRowIf` | `string` | JEXL, hides individual rows | | `columns` | `IDynamicTableColumn[]` | Column definitions | | `dataSource` | `IElementDataSource` | Pre-populate from datasource | Column-level totals: | Property | Type | Notes | |---|---|---| | `columns[*].useTotals` | `boolean` | Sum in the footer row, also published to data | | `columns[*].totalToData` | `boolean` | Publish the sum without a footer row | A published sum lands in the data as `.-total`, for example `el4.column1-total`, and is read in expressions as `{el4.column1-total}`. Inside a dynamic panel the key carries the entry index: `el1[0].el5.column3-total`. Value shape: array of objects. ### `table` `columnsConfig` entries use `key` (NOT `name`). Minimum column: ```json { "key": "firstName", "label": "First Name", "showInTable": true } ``` Column type values the builder offers: `text`, `date`, `dateTime`, `number`, and `element`. The renderer still understands `boolean` `html` `status` `toggleSwitch` `singleCheckbox` so older structures keep working, but do not author new columns with them; use `element` with the matching `elementType` instead. `date`, `dateTime`, and `number` format the raw value in place, no template or cell element needed: ```json { "key": "created", "label": "Created", "type": "dateTime", "dateIncludeSeconds": true, "showInTable": true } ``` | Property | Type | Notes | |---|---|---| | `formatLocale` | `string` | `lt-LT`, `en-US`. Empty follows the view locale | | `dateFormatPattern` | `string` | `yyyy-MM-dd HH:mm:ss`. Tokens `yyyy yy MM dd HH hh mm ss SSS a`. Wins over the locale format | | `dateIncludeSeconds` | `boolean` | `dateTime` only | | `numberMinFractionDigits` | `number` | Decimal places, minimum | | `numberMaxFractionDigits` | `number` | Decimal places, maximum | | `numberUseGrouping` | `boolean` | Thousands separator, defaults to `true` | A column of `type: "element"` renders a real element in every row: ```json { "key": "status", "label": "Status", "type": "element", "showInTable": true, "elementType": "select", "element": { "options": [{ "label": "New", "value": "new" }, { "label": "Done", "value": "done" }], "events": [ { "trigger": "change", "type": "dataSource", "dataSourceName": "saveStatus", "params": [ { "name": "id", "value": "{row.id}" }, { "name": "status", "value": "{value}" } ] } ] } } ``` `elementType` is the element type id (`select`, `button`, `badge`, `datepicker`, `progressBar`, `customHtml`, `richTextViewer`, and so on). Use `richTextViewer` when the row field already holds HTML and only needs rendering. `element` is that element's own configuration, the same keys it would have as a page element. Inside it, `{row.*}`, a bare sibling field name, `{index}`, and `{value}` all resolve against the row the cell belongs to. | Property | Type | Notes | |---|---|---| | `columnsConfig` | `ITableColumnConfig[]` | Column definitions, use `key` not `name` | | `columnsConfig[*].elementType` | `string` | Element type rendered in the cells, when `type` is `element` | | `columnsConfig[*].element` | `object` | Configuration of that hosted element (options, dataSource, events, validators) | | `dataSource` | `IElementDataSource` | The table's source, for both client-side and server-side tables. With `lazyLoad: false` it must return **all** rows in one response; with `lazyLoad: true` it must accept the `TABLE-POST` paging/filter contract below | | `tableDataSourceName` | `string` | Legacy server-side binding, still read for older structures. New structures put the source in `dataSource` | | `lazyLoad` | `boolean` | `true` = server-side paging/sort/search per request; `false` = load everything once | | `tableItemsPath` / `tableTotalPath` | `string` | Response paths for rows / total count, both optional. Common shapes (`items`/`data`/`results`/`rows`, `total`/`totalCount`/`totalRecords`/`count`/`cnt`) are auto-detected | | `pageSize` | `number` | Default page size | | `rowActions` | `ITableRowActionConfig[]` | Row action buttons. These go through the generic action/data-source pipeline, not this contract | **Do not use `ITableDataSourceConfig`.** It exists in the type definitions but is dead code, never wired to anything; the real per-table binding is the flat `tableDataSourceName`/`tableItemsPath`/`tableTotalPath` trio above. When `lazyLoad: true` and the data source's `method` is the literal string `TABLE-POST` (sent over the wire as a real `POST`), NGX View Builder auto-merges the current page/sort/search state into the request body: ```json { "pagingParams": { "cnt": null, "orderClause": "lastName DESC", "pageSize": 25, "skipRows": 50, "totalCountUsed": false }, "params": [ ["tenantId", "42"] ], "extendedParams": [ { "paramName": "quickSearch", "paramValue": { "condition": "%-%", "value": "acme" } }, { "paramName": "status", "paramValue": { "condition": "=", "value": "active" } } ] } ``` `condition` values: `%-%` contains, `!%-%` not contains, `%-` starts with, `-%` ends with, `=`/`!=` equals/not equals, `>`/`>=`/`<`/`<=` for numbers and dates. Full contract, response shape, row-action/inline-edit context, and export behavior: [Table: server-side paging & filtering](https://ngxviewbuilder.io/developers/data-sources#table-server-side-paging-filtering-table-post). ### `tabs` / `tabsPro` Contains named tab sections. Child rows assigned via `column.tabRows`. ### `accordion` Collapsible sections with `label` per panel. ### `dialog` Modal element. Configure via `settings.dialog*` fields: | Setting | Notes | |---|---| | `settings.renderMode` | `"dialog"` | | `settings.dialogTitle` | Dialog title | | `settings.dialogDescription` | Subtitle | | `settings.dialogWidth` | Width | | `settings.dialogShowCloseButton` | boolean | | `settings.dialogPadding` | CSS padding | | `settings.dialogMaxWidth` | Max width | | `settings.dialogMaxHeight` | Max height | ### `richText` / `richTextViewer` Value shape: `string` (HTML). ### `messageCard` | Property | Type | Notes | |---|---|---| | `tone` | `string` | `primary` `success` `warning` `risk` `neutral` `info` | | `icon` | `string` | Icon name | | `text` | `string` | Card message text | ### `statsCard` | Property | Type | Notes | |---|---|---| | `value` | `string` | Main numeric/text value | | `trend` | `string` | Trend direction | | `trendValue` | `string` | Trend amount | ### `badge` | Property | Type | Notes | |---|---|---| | `text` | `string` | Badge content | | `tone` | `string` | `primary` `success` `warning` `risk` `neutral` `info` | ### `image` | Property | Type | Notes | |---|---|---| | `src` | `string` | Image URL or JEXL expression | | `alt` | `string` | Alt text | | `objectFit` | `string` | CSS object-fit | ### `icon` | Property | Type | Notes | |---|---|---| | `icon` | `string` | Icon name | | `size` | `string` | Icon size | | `tone` | `string` | Color tone | ### `divider` No additional properties beyond base. ### `spacer` | Property | Type | Notes | |---|---|---| | `height` | `string` | Spacer height | ### `pageTitle` | Property | Type | Notes | |---|---|---| | `title` | `string` | Heading text | | `subtitle` | `string` | Subheading text | ### `breadcrumbs` | Property | Type | Notes | |---|---|---| | `items` | `array` | Breadcrumb items | ### `iframe` | Property | Type | Notes | |---|---|---| | `src` | `string` | Source URL | | `height` | `string` | Frame height | | `scrolling` | `boolean` | | ### `slider` | Property | Type | Notes | |---|---|---| | `min` | `number` | | | `max` | `number` | | | `step` | `number` | | | `valueStorageType` | `string` | `number` or `string` | ### `numberStepper` | Property | Type | Notes | |---|---|---| | `step` | `number` | Increment/decrement step | | `min` | `number` | Min value | | `max` | `number` | Max value | Value shape: `number`. ### `timePicker` | Property | Type | Notes | |---|---|---| | `placeholder` | `string` | | | `minuteStep` | `number` | Minute increment shown in the picker | Value shape: time string. ### `signaturePad` | Property | Type | Notes | |---|---|---| | `canvasHeight` | `number` | Canvas height in px | | `clearLabel` | `string` | Clear button label | | `strokeColor` / `strokeWidth` / `backgroundColor` | `string` / `number` / `string` | Drawing style | | `exportFormat` | `string` | `png` or `jpeg` | Value shape: signature image data (per `exportFormat`), keyed under the element's `name`. ### `listBox` | Property | Type | Notes | |---|---|---| | `selectionMode` | `string` | `single` or `multiple` | | `dataSource` | `IElementDataSource` | Options from a datasource | | `options` | `array` | Static options | | `maxHeight` | `number` | List height in px | Value shape: single value (`selectionMode: "single"`) or array (`selectionMode: "multiple"`). ### `selectButton` | Property | Type | Notes | |---|---|---| | `multiple` | `boolean` | Allow selecting more than one option | | `allowEmpty` | `boolean` | Allow deselecting to no value | | `variant` | `string` | `buttons` or `segmented` | | `orientation` | `string` | `horizontal` or `vertical` | | `size` | `string` | `small` `normal` `large` | | `tone` | `string` | `primary` `neutral` `success` `info` `warning` `risk` | Value shape: single value, or array when `multiple` is `true`. ### `progressBar` | Property | Type | Notes | |---|---|---| | `min` / `max` | `number` | Value bounds | | `expression` | `string` | Computes the current value | | `dataSource` / `progressDataPath` / `progressValuePath` / `progressMaxPath` | - | Drive the value from a datasource | | `displayType` | `string` | `linear` or `circular` | | `valueLabelMode` | `string` | `percent` `value` `fraction` | Value shape: `number` (0 to `max`, default 100). --- ## `ISettings` fields Only include settings actually needed by the form. | Field | Type | Notes | |---|---|---| | `language` | `string` | Required. E.g. `"en"`, `"de"` | | `locale` | `string` | E.g. `"en-US"`, `"lt-LT"` | | `width` | `string` | Form width | | `widthUnit` | `"px" \| "%"` | | | `renderMode` | `"page" \| "dialog"` | | | `theme` | `"light" \| "dark"` | | | `pageNavigationMode` | `"default" \| "stepper"` | | | `allowStepWithoutValidation` | `boolean` | | | `pageNavigationPosition` | `"top" \| "bottom" \| "both" \| "none"` | | | `stepperPosition` | `"top" \| "bottom" \| "both" \| "none"` | | | `actionButtonsPosition` | `"top" \| "bottom" \| "both" \| "none"` | | | `showSubmitButton` | `boolean` | | | `showValidateButton` | `boolean` | | | `showValidationIssuesModal` | `boolean` | | | `elementSpacing` | `string` | Gap between rows | | `customCss` | `string` | Raw CSS injected | | `lazyElementRendering` | `boolean` | Lazy-render off-screen elements | --- ## `IDataSource` fields (top-level dataSources array) `{ name, title, type, params }`. `type` is one of `rest` `local` `route` `websocket`, and `params` is a plain object whose keys depend on that type. ```json { "dataSources": [ { "name": "ds1", "title": "List", "type": "rest", "params": { "url": "/api/items", "method": "GET" } }, { "name": "ds2", "title": "Save", "type": "rest", "params": { "url": "/api/items/{id}", "method": "PUT", "body": { "name": "{el1}" } } }, { "name": "ds3", "title": "Paged table", "type": "rest", "params": { "url": "/api/items/search", "method": "TABLE-POST" } }, { "name": "ds4", "title": "Static list", "type": "local", "params": { "localMode": "json", "dataJson": "[{\"id\":1,\"name\":\"A\"}]" } }, { "name": "ds5", "title": "From form data", "type": "local", "params": { "localMode": "dataPath", "dataPath": "el9.items" } }, { "name": "ds6", "title": "Route data", "type": "route", "params": { "routeDataKey": "record", "routeDataPath": "data" } }, { "name": "ds7", "title": "Live feed", "type": "websocket", "params": { "url": "wss://host/feed", "protocols": "", "message": "{}", "messagePath": "payload", "messageMode": "pushValuesInArray" } } ] } ``` | `type` | `params` keys | |---|---| | `rest` | `url` (required), `method` (`GET` `POST` `PUT` `PATCH` `DELETE`, or `TABLE-POST` for a lazy `table`), `body` (alias `payload`) | | `local` | `localMode` (`json` or `dataPath`), then `dataJson` (a JSON **string**) and `dataFunction`, or `dataPath` | | `route` | `routeDataKey`, `routeDataPath` | | `websocket` | `url`, `protocols`, `message`, `messagePath`, `messageMode` (`replaceCurrent` or `pushValuesInArray`) | URL and body templates use **single** braces: `{el1}`, `{row.id}`, `{__variables.tenantId}`. --- ## `IElementDataSource` (element-level dataSource field) ```json { "dataSource": { "name": "loadCities", "useFor": "option", "optionValue": "id", "optionLabel": "name", "refreshPaths": ["countryField"], "params": [{ "name": "country", "value": "{countryField}" }] } } ``` | Field | Type | Notes | |---|---|---| | `name` | `string` | References a top-level dataSource name | | `useFor` | `"option" \| "value"` | Whether datasource feeds options or element value | | `optionValue` | `string` | Property path for option value | | `optionLabel` | `string` | Property path for option label | | `filterOptionsBy` | `string` | JEXL expression using `item.*` to filter options | | `params` | `IDataSourceParamMapping[]` | `[{ "name": "key", "value": "{field}" }]` | | `refreshOnChange` | `boolean` | Re-fetch when any dependency changes | | `refreshPaths` | `string[]` | Re-fetch only when these fields change | --- ## Canonical type string list ``` text | textarea | number | slider | phoneInput | fileUpload | button | numberStepper | signaturePad select | multiSelect | radio | checkbox | singleCheckbox | toggleSwitch | toggleButton | autocomplete | selectButton | listBox datepicker | dateRange | timePicker panel | dynamicPanel | tabs | tabsPro | accordion | dialog | splitter | progressFlow | emptyBlock dynamicTable | table | listGrid | chart richText | richTextViewer | customHtml | htmlSnippet | image | video | iframe | avatar | icon | routerOutlet divider | spacer | breadcrumbs | pageTitle | badge | messageCard | statsCard | toast | progressBar page ``` Type strings are exact and case-sensitive. Never alter capitalization. --- ## AI: Common mistakes and anti-patterns Source: https://ngxviewbuilder.io/ai/common-mistakes # AI: Common mistakes and anti-patterns This page exists so that the agent does not keep repeating the same mistakes when generating NGX View Builder JSON. ## 1. Wrong element selection ### Mistake The user asks for a "dynamic table" and the agent generates `table`. ### What to do correctly - Use `dynamicTable` for editable rows with `add row` and `delete row`. - Use `table` for server-side data grid scenarios. ## 2. Self-reference expression ### Mistake ```json { "name": "lastName", "expression": "{firstName} == 'John' ? 'Doe' : {lastName}" } ``` ### Problem - the element references itself - the logic may not work or may behave unpredictably ### Correct ```json { "name": "lastName", "expression": "{firstName} == 'John' ? 'Doe' : ''", "logicExecutionMode": "onChange" } ``` ## 3. Mixing `pages` and `elements` ### Mistake - the agent places a full element object directly in `pages.rows.columns` ### Correct - `pages` holds only layout - `columns[*].elementRef` points to `elements` ## 4. Missing `page` entry in the `elements` map ### Mistake Present: ```json { "pages": [{ "name": "page1", "rows": [] }] } ``` but missing: ```json { "elements": { "page1": { "name": "page1", "type": "page", "label": "Page 1" } } } ``` ## 5. Generating non-existent properties ### Mistake - the agent invents custom properties that NGX View Builder does not have ### Rule - if you cannot find a property in the documentation or catalog, do not use it ## 6. Too much metadata ### Mistake - unnecessary `settings` or extra wrappers are added ### Rule - keep only properties that are actually used - if a feature is not active, it is better to omit its child properties ## 7. Wrong value shape Typical mistakes: - `dateRange` returns a string instead of `{ dateFrom, dateTo }` - `multiSelect` returns a single value instead of an array - `singleCheckbox` returns a string instead of `boolean` - `dynamicPanel` or `dynamicTable` does not return an array ## 8. Logic without `onChange` when recalculation is needed ### Symptom - the form renders, but the interdependency logic does not work as expected ### Why - the execution mode is missing, and the default is `onBlur` With `onBlur` the logic only re-runs once the field loses focus, so someone typing in the form sees nothing happen and reports the logic as broken. ### Rule - if one field's value must immediately affect another field, set `logicExecutionMode: "onChange"` - if validation has to follow the typing, set `validationExecutionMode: "onChange"` as well ## 8b. `hideIf` and other logic keys that do not exist ### Mistake ```json { "name": "companyCode", "hideIf": "{customerType} != 'company'" } ``` ### Problem There is no `hideIf`. The logic keys are exactly `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, `resetIf` and `expression`, and they are matched exactly, so `readOnlyIf` with a capital O misses too. This is the worst kind of mistake to debug, because nothing complains. The property is stored, the JSON reviews as correct, no dependency is ever registered for it, and the field simply stays visible forever. Deleting the element and adding the same rule by hand in the builder makes it work, which sends everyone hunting for a bug that is not there. ### Correct `visibleIf` states when the element **is** shown, so invert the condition instead of renaming the key. ```json { "name": "companyCode", "visibleIf": "{customerType} == 'company'", "logicExecutionMode": "onChange" } ``` ## 9. Overly aggressive rewrite of an existing form ### Mistake - the user asks to add one field, and the agent rebuilds the entire form from scratch ### Rule - in extension mode, change only what is relevant to the task - do not remove `dataSources`, `localization`, `settings`, or other sections without reason ## 10. Broken datasource references ### Mistake - an element references `dataSourceName` but no such datasource exists - a datasource exists but the item path or value/label mapping is wrong ### Rule - every datasource reference must point to a real, documented object ## 11. Using `name` instead of `key` in `table` columns ### Mistake ```json { "type": "table", "columnsConfig": [ { "name": "firstName", "label": "First Name", "type": "text" } ] } ``` ### Problem - `table` renders columns by `columnsConfig[*].key` - if only `name` is present, the column may not be rendered at all ### Correct ```json { "type": "table", "columnsConfig": [ { "key": "firstName", "label": "First Name", "type": "text", "showInTable": true } ] } ``` ## 12. Custom frontend instead of NGX View Builder ### Mistake - the agent starts suggesting `Angular` components, custom templates, or an API layer, even though an NGX View Builder form was requested ### Rule - the first choice is always the built-in NGX View Builder JSON model ## 13. Prose instead of JSON ### Mistake - the user asks to generate a form, and the agent writes an explanation followed by JSON ### Rule - in generation mode, return only JSON ## 14. Wrong element type casing ### Mistake ```json { "type": "datePicker" } { "type": "SingleCheckbox" } { "type": "DynamicTable" } { "type": "richtext" } { "type": "fileupload" } ``` ### Rule Type strings are **exact lowercase camelCase**. The canonical list: ``` text | textarea | number | slider | phoneInput | fileUpload | button | numberStepper | signaturePad select | multiSelect | radio | checkbox | singleCheckbox | toggleSwitch | toggleButton | autocomplete | selectButton | listBox datepicker | dateRange | timePicker panel | dynamicPanel | tabs | tabsPro | accordion | dialog | splitter | progressFlow | emptyBlock dynamicTable | table | listGrid | chart richText | richTextViewer | customHtml | htmlSnippet | image | video | iframe | avatar | icon | routerOutlet divider | spacer | breadcrumbs | pageTitle | badge | messageCard | statsCard | toast | progressBar page ``` Never invent capitalization variants. Copy from the list above. ## 15. Wrong validator field names ### Mistake ```json { "validators": [ { "type": "minLength", "expression": "{firstName}.length > 0", "message": "Required" } ] } ``` ### Problem - `expression` does not exist on `IValidator` - `visibleIf` does not exist on `IValidator` ### Correct validator schema ```json { "validators": [ { "type": "minLength", "value": 3, "message": "Minimum 3 characters", "applyIf": "{otherField} != ''" }, { "type": "custom", "condition": "dateDiffDays({startDate}, {endDate}) < 1", "message": "End date must be after start date" } ] } ``` Exact fields allowed on a validator: - `type`: validator type string (required) - `value`: threshold value when applicable (`number | string`) - `message`: error message shown to user - `condition`: JEXL expression, the *failing* check. The validator error is shown while it evaluates to `true` - `applyIf`: JEXL expression, the validator runs only when this is truthy **Never use `expression`, `visibleIf`, `disableIf`, or any other logic field on a validator object.** ## 16. Using logic field names that belong to elements on validators ### Mistake Copying `visibleIf`, `requireIf`, `readonlyIf`, `disableIf`, `expression`, `resetIf` from element-level logic into a validator object. Those fields do not exist on `IValidator`. ### Rule Element-level logic (`visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, `resetIf`, `expression`) belongs directly on the element, not inside the `validators` array. ```json { "name": "birthDate", "type": "datepicker", "requireIf": "{needsBirthDate} == true", "validators": [ { "type": "maxDate", "value": "today", "message": "Cannot be in the future" } ] } ``` ## 17. Wrapping logic fields in a `logic` object ### Mistake ```json { "name": "totalAmount", "type": "number", "logic": { "expression": "{price} * {qty}", "logicExecutionMode": "onChange" } } ``` ### Problem - There is no `logic` key in the NGX View Builder element schema. - The renderer ignores the `logic` wrapper, so the expression never runs. ### Correct Logic fields are **direct element-level properties**, never nested: ```json { "name": "totalAmount", "type": "number", "expression": "{price} * {qty}", "logicExecutionMode": "onChange" } ``` The same rule applies to `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, and `resetIf`. ## 18. Putting a container's children inside the element definition This is the single most damaging generation error. The panel renders, and it is **empty**. ### Mistake ```json { "elements": { "panelGeneral": { "name": "panelGeneral", "type": "panel", "label": "General", "rows": [ { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ] } } } ``` ### Problem - `IBaseElement` has no `rows` property. The array is ignored. - `firstName` and `lastName` are never placed in the layout tree, so they never render. - The same applies to invented keys such as `children`, `elements`, `items`, `content`, `fields`. ### Correct Children belong to the **column that references the container**, in `pages`: ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "panelGeneral", "rows": [ { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "type": "page", "label": "Page 1" }, "panelGeneral": { "name": "panelGeneral", "type": "panel", "label": "General" }, "firstName": { "name": "firstName", "type": "text", "label": "First name" }, "lastName": { "name": "lastName", "type": "text", "label": "Last name" } } } ``` `elements` is a **flat map**. It never nests. Full rules: [Layout model](https://ngxviewbuilder.io/ai/layout-model). ## 19. Widths written on columns ### Mistake ```json { "columns": [ { "elementRef": "firstName", "width": "50%", "mobileWidth": "100%" }, { "elementRef": "lastName", "width": "50%", "mobileWidth": "100%" } ] } ``` ### Problem - `IColumn` has exactly these keys: `elementRef`, `rows`, `tabRows`, `fragmentRef`, `fragmentBindings`, `fragmentMode`. Everything else is dropped. - The width was never applied, and the JSON is now noisier and harder to review. - Worse, it hides the real intent: two columns in one row **already** split evenly (`flex: 1 1 0`). ### Correct For an even split, write nothing: ```json { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ``` For an uneven split, put `width` / `tabletWidth` / `mobileWidth` on the **element**: ```json { "postCode": { "name": "postCode", "label": "Post code", "type": "text", "width": "160px" } } ``` ## 20. Using `parentName` to declare structure ### Mistake ```json { "name": "firstName", "type": "text", "parentName": "panelGeneral" } ``` ### Problem `parentName` exists on `IBaseElement`, but it is runtime bookkeeping, not the structure declaration. Setting it does not place the element anywhere. ### Rule The layout tree in `pages` is the **only** source of parentage. ## 21. One page per visual section ### Mistake A screenshot shows four titled sections, and the agent emits four entries in `pages`. ### Problem A `page` is a **step or screen**, navigated by the pager or stepper. Four pages means a four-step wizard, not four sections stacked on one screen. ### Correct One page, four `panel` elements at the top level of its `rows`, each panel's `label` being the section heading. ## 22. Invented properties that look plausible Every one of these has been generated by an agent and none of them exists: | Invented | What is actually there | |---|---| | `panel.rows`, `panel.children`, `panel.items` | children go in `column.rows` | | `column.width`, `column.mobileWidth`, `column.span` | width goes on the element | | `singleCheckbox.checkedValue` / `uncheckedValue` | `singleCheckbox` stores `true`/`false`; the side text is `checkboxLabel` | | `phoneInput.maskType`, `phoneInput.inputMode`, `phoneInput.defaultValue` | `defaultCountryCode` (ISO code such as `"LT"`), `allowedCountryCodes` | | `text.maskType: "personalCodeLt"` / `"phoneLt"` | masks are exactly `none` `phoneIntl` `date` `time` `dateTime` `digits` `custom` | | `datepicker.format: "yyyy-MM-dd"` | uppercase tokens only: `YYYY-MM-DD`, `DD.MM.YYYY`, ... | | `element.logic { ... }` wrapper | logic fields sit directly on the element | | `validator.expression` | `validator.condition` | | `"type": "code"`, `"type": "dropdown"` | neither element type exists; use `textarea` and `select` | | `select.placeholder` for the closed-state text | that text is the UI translation `select.placeholder`; the property only sets the dropdown's search hint | | `dataSources[*].paramMap` | the field is `params` | | `"params": [["id", "{row.id}"]]` | `"params": [{ "name": "id", "value": "{row.id}" }]` | | `table.params: [{ "name", "value" }]` | table request params are `[{ "paramName", "paramValue" }]` | | `table.columnsConfig[*].name` | `key` | | `dynamicTable.columns[*].key` | `name` | ### Rule If a property is not in [the properties reference](https://ngxviewbuilder.io/ai/properties-reference), it does not exist. Omitting a nicety is always better than inventing a property: an invented property is silently ignored, so the form ships subtly broken with no error anywhere. ## Final anti-pattern checklist Before returning a response, the agent must verify: 1. Is the correct element chosen. 2. Are `pages` and `elements` not mixed up. 3. Do all `elementRef` values point to valid entries. 4. Is there no self-reference expression. 5. Is the value shape correct. 6. Are there no invented properties. 7. Do `table.columnsConfig[*]` entries use `key`, and `dynamicTable.columns[*]` entries use `name`. The two tables do not share a column shape. 8. Is the existing form context preserved. 9. Are logic fields (`expression`, `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, `resetIf`, `logicExecutionMode`) placed directly on the element, not inside a `logic` wrapper, and spelled exactly. No `hideIf`, no `readOnlyIf`. 9b. Does every element carrying logic or typing dependent validation set `logicExecutionMode` or `validationExecutionMode` to `onChange`, since both default to `onBlur`. 10. Does any object in `elements` contain `rows`, `columns`, `children` or `items` as a way of holding child elements. It must not. 11. Does any column object carry a key other than `elementRef`, `rows`, `tabRows`, `fragmentRef`, `fragmentBindings`, `fragmentMode`. 12. Are widths on elements rather than columns, and absent entirely wherever an even split is wanted. 13. Is the number of `pages` equal to the number of real steps, not the number of visual sections. --- ## AI: Verified example structures Source: https://ngxviewbuilder.io/ai/examples # AI: Verified example structures Every JSON block on this page is checked against the library source: element type strings against the element registry, property names against the builder property datasets, sub-object fields against the TypeScript interfaces. Copy the **shapes** from here; invent nothing that is not shown or listed in [the properties reference](https://ngxviewbuilder.io/ai/properties-reference). Names are deliberately generic (`el1`, `column1`, `ds1`) so the structure is what stands out, not the naming. ## How to read these examples 1. Layout is always `pages` → `rows` → `columns` → `elementRef`. Configuration is always the flat `elements` map. See [Layout model](https://ngxviewbuilder.io/ai/layout-model) first. 2. Anything a container holds is attached to the **column** that references it, never inside the element. 3. A property that does not appear on this page or in the properties reference does not exist. An invented property is dropped silently, so the view ships subtly broken with no error. --- ## 1. Skeleton and layout grammar One page, four bands: full width, even halves, uneven thirds, and a mobile-stacking trio. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "900px", "widthUnit": "px", "renderMode": "page", "elementSpacing": "12px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }] }, { "columns": [{ "elementRef": "el2" }, { "elementRef": "el3" }] }, { "columns": [{ "elementRef": "el4" }, { "elementRef": "el5" }] }, { "columns": [ { "elementRef": "el6" }, { "elementRef": "el7" }, { "elementRef": "el8" } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "Full width", "type": "text" }, "el2": { "name": "el2", "label": "Half", "type": "text" }, "el3": { "name": "el3", "label": "Half", "type": "text" }, "el4": { "name": "el4", "label": "Narrow", "type": "text", "width": "160px" }, "el5": { "name": "el5", "label": "Takes the rest", "type": "text" }, "el6": { "name": "el6", "label": "Third", "type": "text", "mobileWidth": "100%" }, "el7": { "name": "el7", "label": "Third", "type": "text", "mobileWidth": "100%" }, "el8": { "name": "el8", "label": "Third", "type": "text", "mobileWidth": "100%" } }, "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` What to take from it: - `el2` / `el3` split 50/50 with **no width property at all**. Columns with no width get `flex: 1 1 0`. - `el4` is fixed at `160px`, so `el5` absorbs the remainder. Width is on the element, never on the column. - `mobileWidth: "100%"` is what makes a row stack on phones. --- ## 2. Containers ### 2.1 Panels as titled sections ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "panel1", "rows": [ { "columns": [{ "elementRef": "el1" }, { "elementRef": "el2" }] }, { "columns": [{ "elementRef": "el3" }] } ] } ] }, { "columns": [ { "elementRef": "panel2", "rows": [{ "columns": [{ "elementRef": "el4" }, { "elementRef": "el5" }] }] } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page" }, "panel1": { "name": "panel1", "label": "Section one", "type": "panel", "showBorder": false, "panelPadding": "0px", "titleUnderline": true }, "panel2": { "name": "panel2", "label": "Section two", "type": "panel", "showBorder": true, "panelBorderWidth": "1px", "panelRadius": "8px", "panelPadding": "16px", "contentGap": "12px" }, "el1": { "name": "el1", "label": "Field 1", "type": "text" }, "el2": { "name": "el2", "label": "Field 2", "type": "text" }, "el3": { "name": "el3", "label": "Field 3", "type": "textarea", "rows": 3 }, "el4": { "name": "el4", "label": "Field 4", "type": "text" }, "el5": { "name": "el5", "label": "Field 5", "type": "text" } } } ``` ### 2.2 Tabs (`tabRows`, keyed by tab value) ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1", "tabRows": { "tab1": [{ "columns": [{ "elementRef": "el2" }, { "elementRef": "el3" }] }], "tab2": [{ "columns": [{ "elementRef": "el4" }] }] } } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page" }, "el1": { "name": "el1", "label": "Details", "type": "tabs", "tabsPosition": "top", "tabsVariant": "underline", "fullWidthTabs": false, "tabs": [ { "value": "tab1", "label": "General" }, { "value": "tab2", "label": "Address" } ] }, "el2": { "name": "el2", "label": "First name", "type": "text" }, "el3": { "name": "el3", "label": "Last name", "type": "text" }, "el4": { "name": "el4", "label": "Street", "type": "text" } } } ``` `tabsPro` is the same element with the same properties; its list property is `items` instead of `tabs`. ### 2.3 Accordion and splitter Both key their `tabRows` by the `value` of each entry in their own list property (`items` for accordion, `panels` for splitter). ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1", "tabRows": { "item1": [{ "columns": [{ "elementRef": "el3" }] }], "item2": [{ "columns": [{ "elementRef": "el4" }] }] } } ] }, { "columns": [ { "elementRef": "el2", "tabRows": { "left": [{ "columns": [{ "elementRef": "el5" }] }], "right": [{ "columns": [{ "elementRef": "el6" }] }] } } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page" }, "el1": { "name": "el1", "label": "Sections", "type": "accordion", "allowMultiple": false, "openFirst": true, "items": [ { "value": "item1", "label": "Contact" }, { "value": "item2", "label": "Billing" } ] }, "el2": { "name": "el2", "label": "Workspace", "type": "splitter", "orientation": "horizontal", "allowResize": true, "gutterSize": "6px", "minPanelSize": "160px", "showPanelHeaders": true, "panels": [ { "value": "left", "label": "Filters" }, { "value": "right", "label": "Results" } ] }, "el3": { "name": "el3", "label": "Email", "type": "text" }, "el4": { "name": "el4", "label": "IBAN", "type": "text" }, "el5": { "name": "el5", "label": "Search", "type": "text" }, "el6": { "name": "el6", "label": "Notes", "type": "textarea" } } } ``` ### 2.4 `emptyBlock` as a CSS grid `emptyBlock` is the unstyled layout box. Use it when a `panel` would add unwanted chrome, or when you need real grid/flex control. ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1", "rows": [ { "columns": [ { "elementRef": "el2" }, { "elementRef": "el3" }, { "elementRef": "el4" } ] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page" }, "el1": { "name": "el1", "type": "emptyBlock", "contentDisplay": "grid", "gridTemplateColumns": "repeat(3, minmax(0, 1fr))", "contentGap": "16px", "panelPadding": "16px", "panelBackgroundColor": "#f6f8fa", "panelRadius": "10px" }, "el2": { "name": "el2", "label": "Open", "type": "statsCard", "valueText": "128", "variant": "info" }, "el3": { "name": "el3", "label": "Closed", "type": "statsCard", "valueText": "47", "variant": "success" }, "el4": { "name": "el4", "label": "Overdue", "type": "statsCard", "valueText": "9", "variant": "risk" } } } ``` ### 2.5 In-view `dialog` element The `dialog` **element** is a modal that lives inside a page, opened by an action or `openIf`. It is not the same as `settings.renderMode: "dialog"`, which renders the whole view as a modal. ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }] }, { "columns": [ { "elementRef": "el2", "rows": [ { "columns": [{ "elementRef": "el3" }] }, { "columns": [{ "elementRef": "el4" }] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Page 1", "type": "page" }, "el1": { "name": "el1", "label": "", "type": "button", "text": "Edit record", "variant": "solid", "tone": "primary", "fitContent": true, "events": [ { "trigger": "click", "type": "dialog", "dialogName": "el2", "dialogOperation": "open" } ] }, "el2": { "name": "el2", "label": "Edit record", "type": "dialog", "showTriggerButton": false, "showCloseButton": true, "closeOnBackdrop": true, "dialogWidth": "560px" }, "el3": { "name": "el3", "label": "Title", "type": "text", "required": true }, "el4": { "name": "el4", "label": "", "type": "button", "text": "Save", "variant": "solid", "tone": "primary", "fitContent": true, "events": [ { "trigger": "click", "type": "dataSource", "dataSourceName": "ds1", "validateForm": true, "showToastAfter": true, "toastTitle": "Saved", "toastVariant": "success" }, { "trigger": "click", "type": "dialog", "dialogName": "el2", "dialogOperation": "close" } ] } } } ``` --- ## 3. Input elements Every property below is real. Notice what each element does **not** have: `phoneInput` has no mask, `singleCheckbox` has no `checkedValue`, `datepicker` formats use uppercase tokens. ```json { "elements": { "el1": { "name": "el1", "label": "Text", "type": "text", "placeholder": "Type here", "minlength": 2, "maxlength": 60, "showMaxLengthCounter": true, "inputMode": "text", "spellcheck": false, "autocomplete": "off", "required": true, "requiredMessage": "This field is required" }, "el2": { "name": "el2", "label": "Masked text", "type": "text", "maskType": "custom", "maskPattern": "AA-0000", "placeholder": "AB-1234" }, "el3": { "name": "el3", "label": "Textarea", "type": "textarea", "rows": 4, "maxlength": 500, "showMaxLengthCounter": true }, "el4": { "name": "el4", "label": "Number", "type": "number", "min": 0, "max": 1000000, "step": 0.01, "inputMode": "decimal", "valueStorageType": "number", "visualFormatEnabled": true, "visualFormatLocale": "lt-LT", "visualFormatUseGrouping": true, "visualFormatMinFractionDigits": 2, "visualFormatMaxFractionDigits": 2 }, "el5": { "name": "el5", "label": "Slider", "type": "slider", "min": 0, "max": 100, "step": 5, "showValueLabel": true, "defaultValue": 20 }, "el6": { "name": "el6", "label": "Stepper", "type": "numberStepper", "min": 1, "max": 20, "step": 1, "defaultValue": 1 }, "el7": { "name": "el7", "label": "Phone", "type": "phoneInput", "defaultCountryCode": "LT", "allowedCountryCodes": ["LT", "LV", "EE"], "placeholder": "600 00000" }, "el8": { "name": "el8", "label": "Date", "type": "datepicker", "placeholder": "Pick a date", "pickerMode": "date", "format": "YYYY-MM-DD", "minValue": "today", "validators": [{ "type": "maxDate", "value": "today", "message": "Cannot be in the future" }] }, "el9": { "name": "el9", "label": "Date and time", "type": "datepicker", "pickerMode": "datetime", "includeSeconds": false, "format": "YYYY-MM-DD HH:mm" }, "el10": { "name": "el10", "label": "Date range", "type": "dateRange", "format": "YYYY-MM-DD", "placeholder": "From - to" }, "el11": { "name": "el11", "label": "Time", "type": "timePicker", "minuteStep": 15 }, "el12": { "name": "el12", "label": "Attachments", "type": "fileUpload", "accept": ".pdf,image/*", "multiple": true, "maxFiles": 5, "maxFileSizeMb": 10, "showPreview": true, "dropzoneText": "Drop files here", "uploadDataSourceName": "ds1", "downloadDataSourceName": "ds2", "deleteDataSourceName": "ds3", "uploadFormFieldName": "file", "fileKeyField": "key", "fileNameField": "name", "fileTypeField": "contentType", "fileSizeField": "size" }, "el13": { "name": "el13", "label": "Signature", "type": "signaturePad", "canvasHeight": "180px", "strokeColor": "#111827", "strokeWidth": 2, "backgroundColor": "#ffffff", "exportFormat": "png", "clearLabel": "Clear" }, "el14": { "name": "el14", "label": "Rich text", "type": "richText", "toolbarTools": ["bold", "italic", "underline", "bulletList", "link"] } } } ``` Value shapes: `text` / `textarea` / `richText` store a string, `number` / `slider` / `numberStepper` a number, `dateRange` an object `{ dateFrom, dateTo }`, `fileUpload` the upload response object (or an array of them), `timePicker` a time string. --- ## 4. Choice elements ```json { "elements": { "el1": { "name": "el1", "label": "Select", "type": "select", "showSearch": true, "strictOptions": true, "defaultValue": "a", "options": [ { "value": "a", "label": "Option A" }, { "value": "b", "label": "Option B" }, { "value": "c", "label": "Option C" } ] }, "el2": { "name": "el2", "label": "Multi select", "type": "multiSelect", "showSearch": true, "strictOptions": true, "options": [ { "value": "a", "label": "Option A" }, { "value": "b", "label": "Option B" } ] }, "el3": { "name": "el3", "label": "Radio", "type": "radio", "showInline": true, "defaultValue": "y", "options": [ { "value": "y", "label": "Yes" }, { "value": "n", "label": "No" } ] }, "el4": { "name": "el4", "label": "Checkbox group", "type": "checkbox", "showInline": false, "options": [ { "value": "a", "label": "Option A" }, { "value": "b", "label": "Option B" } ] }, "el5": { "name": "el5", "label": "", "type": "singleCheckbox", "checkboxLabel": "I accept the terms", "defaultValue": false, "required": true, "requiredMessage": "You must accept the terms" }, "el6": { "name": "el6", "label": "Toggle", "type": "toggleSwitch", "trueLabel": "On", "falseLabel": "Off", "trueValue": true, "falseValue": false, "defaultValue": false }, "el7": { "name": "el7", "label": "Segmented", "type": "selectButton", "orientation": "horizontal", "multiple": false, "allowEmpty": false, "variant": "outline", "size": "normal", "showSelectedIcon": true, "options": [ { "value": "day", "label": "Day" }, { "value": "week", "label": "Week" }, { "value": "month", "label": "Month" } ] }, "el8": { "name": "el8", "label": "List box", "type": "listBox", "selectionMode": "multiple", "maxHeight": "220px", "options": [ { "value": "a", "label": "Option A" }, { "value": "b", "label": "Option B" }, { "value": "c", "label": "Option C" } ] }, "el9": { "name": "el9", "label": "Autocomplete", "type": "autocomplete", "placeholder": "Start typing", "minSearchLength": 2, "debounceMs": 300, "maxSuggestions": 20, "forceSelection": true, "lazyLoad": true, "queryContextKey": "query", "dataSource": { "name": "ds1", "useFor": "option", "optionValue": "id", "optionLabel": "name" } } } } ``` Value shapes: `select` / `radio` / `selectButton` / `autocomplete` store a single value; `multiSelect` / `checkbox` store an array; `listBox` stores a single value or an array depending on `selectionMode`; `singleCheckbox` / `toggleSwitch` store a boolean. --- ## 5. Content and feedback elements ```json { "elements": { "el1": { "name": "el1", "label": "", "type": "pageTitle", "title": "Customer records", "subtitle": "Everything the support desk needs", "level": 2, "align": "left", "showDivider": true }, "el2": { "name": "el2", "label": "", "type": "messageCard", "title": "Heads up", "descriptionText": "Changes are applied immediately.", "variant": "info", "showIcon": true, "dismissible": true, "initiallyVisible": true }, "el3": { "name": "el3", "label": "", "type": "statsCard", "title": "Open tickets", "valueText": "128", "variant": "info", "icon": "inbox", "trendDirection": "up", "trendText": "+12 this week" }, "el4": { "name": "el4", "label": "", "type": "badge", "text": "Beta", "variant": "warning", "pill": true, "size": "small" }, "el5": { "name": "el5", "label": "", "type": "progressBar", "min": 0, "max": 100, "displayType": "bar", "variant": "success", "showValueLabel": true, "valueLabelMode": "percent" }, "el6": { "name": "el6", "label": "", "type": "divider", "orientation": "horizontal", "thickness": "1px", "spacing": "16px" }, "el7": { "name": "el7", "label": "", "type": "spacer", "axis": "vertical", "size": "24px" }, "el8": { "name": "el8", "label": "", "type": "image", "src": "/assets/cover.png", "alt": "Cover", "fit": "cover", "aspectRatio": "16/9", "radius": "8px", "showPreview": true }, "el9": { "name": "el9", "label": "", "type": "customHtml", "htmlTemplate": "

Reference: {el1}

" }, "el10": { "name": "el10", "label": "", "type": "richTextViewer", "htmlSourcePath": "el11" }, "el11": { "name": "el11", "label": "", "type": "chart", "chartType": "bar", "chartHeight": "280px", "showLegend": true, "legendLayout": "horizontal", "labelKey": "label", "valueKey": "value", "chartDataPath": "stats.byMonth", "dataSource": { "name": "ds1", "useFor": "value" } } } } ``` --- ## 6. `table`: client-side data grid This is the shape to reach for when the endpoint returns every row in one response. The table sorts, searches, filters, paginates and exports entirely in the browser. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1180px", "widthUnit": "px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }] }, { "columns": [{ "elementRef": "el3" }] }, { "columns": [{ "elementRef": "el2" }] } ] } ], "elements": { "page1": { "name": "page1", "label": "Customers", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "", "type": "pageTitle", "title": "Customers", "level": 2 }, "el2": { "name": "el2", "label": "All customers", "type": "table", "dataSource": { "name": "ds1", "useFor": "value", "refreshOnChange": true, "refreshPaths": ["el3"] }, "tableItemsPath": "data.items", "lazyLoad": false, "pageSize": 25, "pageSizeOptions": "10,25,50,100", "paginator": true, "paginatorStyle": "default", "paginatorVariant": "full", "paginatorAlign": "end", "paginatorMaxButtons": 7, "showPageSizeSelector": true, "tableStyle": "striped", "stickyHeader": true, "rowHover": true, "responsiveMode": "auto", "flatHeaderSurface": false, "flatFooterSurface": false, "emptyMessage": "No customers found", "loadingLabel": "Loading...", "showHeaderControls": true, "showQuickSearch": true, "quickSearchPlaceholder": "Search customers", "quickSearchCondition": "%-%", "quickSearchCaseMode": "caseInsensitiveLatin", "searchDebounceMs": 300, "showDetailedSearch": true, "detailedSearchCaseMode": "caseInsensitiveLatin", "showExport": true, "exportFileName": "customers", "exportUseColumnPicker": true, "showColumnSettings": true, "columnSettingsMode": "localStorage", "columnSettingsStorageKey": "customersTableColumns", "columnSettingsDialogTitle": "Visible columns", "orderClause": "column2 ASC", "orderDirection": "asc", "columnsConfig": [ { "key": "column1", "label": "ID", "type": "number", "sortable": true, "width": "90px", "align": "right", "numberUseGrouping": false, "showInTable": true, "showInDetails": true }, { "key": "column2", "label": "Name", "type": "text", "sortable": true, "showInTable": true, "filterControlType": "text" }, { "key": "column3", "label": "Email", "type": "text", "sortable": true, "showInTable": true, "mobileLabel": "Mail" }, { "key": "column4", "label": "Balance", "type": "number", "sortable": true, "align": "right", "formatLocale": "lt-LT", "numberMinFractionDigits": 2, "numberMaxFractionDigits": 2, "numberUseGrouping": true, "showInTable": true, "filterControlType": "number" }, { "key": "column5", "label": "Created", "type": "dateTime", "sortable": true, "dateFormatPattern": "yyyy-MM-dd HH:mm", "dateIncludeSeconds": false, "showInTable": true, "filterControlType": "date" }, { "key": "column6", "label": "Country", "type": "text", "showInTable": true, "filterControlType": "select", "filterOptionsSourceType": "static", "filterOptions": [ { "value": "LT", "label": "Lithuania" }, { "value": "LV", "label": "Latvia" } ], "filterOptionValueKey": "value", "filterOptionLabelKey": "label" }, { "key": "column7", "label": "Internal note", "type": "text", "showInTable": false, "showInDetails": true, "detailLabel": "Note (internal)" } ] }, "el3": { "name": "el3", "label": "Segment", "type": "select", "options": [] } }, "dataSources": [ { "name": "ds1", "title": "Customers", "type": "rest", "params": { "url": "/api/customers", "method": "GET" } } ], "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` Points that decide whether this works: - **`columnsConfig[*].key`, never `name`.** A column with only `name` does not render. - `type` for a column is one of `text` `date` `dateTime` `number` `element`. The renderer still understands the legacy `boolean` `html` `status` `toggleSwitch` `singleCheckbox` so old views keep working, but author new columns with `element` instead. - `tableItemsPath` is only needed when the rows are not the response root and not under `items` / `data` / `results` / `rows`. - `refreshPaths` on the element `dataSource` makes the table reload when another field changes. - `showInTable: false` + `showInDetails: true` keeps a field out of the grid but available in the details panel. ### 6.1 Row, header and selection actions ```json { "el2": { "name": "el2", "label": "All customers", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "columnsConfig": [ { "key": "column1", "label": "ID", "type": "number", "showInTable": true }, { "key": "column2", "label": "Name", "type": "text", "showInTable": true } ], "enableSelection": true, "selectionKey": "column1", "selectLabel": "Select", "selectAllLabel": "Select all", "selectionActionsLabel": "With selected", "autoSelectCondition": "row.column3 == 'active'", "selectionActions": [ { "label": "Archive selected", "type": "dataSource", "dataSourceName": "ds2", "buttonTone": "neutral", "confirmEnabled": true, "confirmTitle": "Archive", "confirmMessage": "Archive every selected record?", "reloadCurrentElementAfterSuccess": true } ], "headerActionsPosition": "end", "headerActionsDisplayMode": "buttons", "headerActions": [ { "label": "New customer", "icon": "add", "type": "navigate", "navigateTo": "/customers/new", "buttonVariant": "filled", "buttonTone": "primary" }, { "label": "Reload", "icon": "refresh", "type": "reloadElements", "reloadElementNames": ["el2"], "buttonVariant": "text" } ], "showActionsHeaderLabel": true, "actionsLabel": "Actions", "rowActionsDisplayMode": "dropdown", "rowActionsDropdownButtonStyle": "borderless", "rowActionVisibilityPath": "column4", "rowActions": [ { "label": "Open", "icon": "open_in_new", "type": "navigate", "navigateTo": "/customers/{row.column1}" }, { "label": "Deactivate", "icon": "block", "type": "dataSource", "dataSourceName": "ds3", "condition": "row.column3 == 'active'", "reloadCurrentElementAfterSuccess": true, "showToastAfter": true, "toastTitle": "Customer deactivated", "toastVariant": "success", "toastPosition": "bottom-right", "toastAutoHide": true, "toastAutoHideMs": 4000 }, { "label": "Delete", "icon": "delete", "type": "dataSource", "dataSourceName": "ds4", "buttonTone": "risk", "confirmEnabled": true, "confirmTitle": "Delete customer", "confirmMessage": "This cannot be undone. Continue?", "confirmConfirmLabel": "Delete", "confirmCancelLabel": "Cancel", "reloadCurrentElementAfterSuccess": true } ], "rowClickActions": [ { "type": "navigate", "navigateTo": "/customers/{row.column1}" } ] }, "dataSources": [ { "name": "ds1", "title": "Customers", "type": "rest", "params": { "url": "/api/customers", "method": "GET" } }, { "name": "ds2", "title": "Archive", "type": "rest", "params": { "url": "/api/customers/archive", "method": "POST", "body": { "ids": "{selectedKeys}" } } }, { "name": "ds3", "title": "Deactivate", "type": "rest", "params": { "url": "/api/customers/{row.column1}", "method": "PUT", "body": { "active": false } } }, { "name": "ds4", "title": "Delete", "type": "rest", "params": { "url": "/api/customers/{row.column1}", "method": "DELETE" } } ] } ``` Context available to actions: | Action kind | Sees | | --- | --- | | `rowActions[*]`, `rowClickActions[*]` | `{row.*}` for the clicked row | | `selectionActions[*]` | `{selectedRows}`, `{selectedKeys}`, `{rows}`, `{items}` | | `headerActions[*]` | the surrounding form context, no row | | inline edit save | `{row.*}` plus `{changedValues}` (only the columns that changed) | `rowActions[*].condition` is evaluated per row against `row.*`. `rowActionVisibilityPath` names a row field that hides the whole action column for rows where it is falsy. ### 6.2 Element columns, status badges and templates A column of `type: "element"` renders a real builder element in every cell. `element` holds that element's own configuration, with the same property names it would have as a page element. ```json { "el2": { "name": "el2", "label": "Orders", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "columnsConfig": [ { "key": "column1", "label": "Order", "type": "text", "showInTable": true, "template": "#{row.column1}
{row.column2}" }, { "key": "column3", "label": "State", "type": "status", "statusShowIcon": true, "showInTable": true, "statusRules": [ { "condition": "value == 'new'", "label": "New", "tone": "info", "icon": "fiber_new", "rounded": true }, { "condition": "value == 'paid'", "label": "Paid", "tone": "success", "icon": "check_circle", "rounded": true }, { "condition": "value == 'late'", "label": "Late", "tone": "risk", "icon": "error", "rounded": true } ] }, { "key": "column4", "label": "Assignee", "type": "element", "elementType": "select", "showInTable": true, "controlEnabledIf": "row.column3 != 'paid'", "element": { "placeholder": "Unassigned", "strictOptions": true, "options": [ { "value": "u1", "label": "Anna" }, { "value": "u2", "label": "Ben" } ], "events": [ { "trigger": "change", "type": "dataSource", "dataSourceName": "ds2", "params": [ { "name": "id", "value": "{row.column1}" }, { "name": "assignee", "value": "{value}" } ] } ] } }, { "key": "column5", "label": "Progress", "type": "element", "elementType": "progressBar", "showInTable": true, "element": { "min": 0, "max": 100, "showValueLabel": true, "displayType": "bar" } }, { "key": "column6", "label": "Description", "type": "element", "elementType": "richTextViewer", "showInTable": false, "showInDetails": true, "element": {} }, { "key": "column7", "label": "", "type": "text", "showInTable": true, "cellActionsEnabled": true, "cellActionsDisplayMode": "iconButtons", "cellActions": [ { "label": "Copy", "icon": "content_copy", "type": "setValue", "setValueTargetPath": "el3", "setValueMode": "template", "setValueValue": "{row.column2}" } ] } ] } } ``` Rules for these: - **`statusRules[*].condition` reads the cell value as `value`**, not as the column key. `status == 'paid'` never matches; `value == 'paid'` does. `row.otherColumn` also works. - `statusRules[*].tone` is one of `neutral` `success` `warning` `risk` `primary` `danger` `info`. - Inside `template` and inside a hosted `element`, the row is reachable as `{row.*}`, a bare sibling key, `{index}` and `{value}`. - `visibleIf`, `controlEnabledIf` and `controlActiveIf` on a column are evaluated per row. - Action `params` are always `[{ "name": ..., "value": ... }]`. An array of pairs is dropped silently. ### 6.3 Server-side paging: `lazyLoad` and `TABLE-POST` With `lazyLoad: true` and the data source method set to the literal string `TABLE-POST`, the table merges paging, sorting and filter state into the request body for you. ```json { "elements": { "el2": { "name": "el2", "label": "Customers", "type": "table", "lazyLoad": true, "dataSource": { "name": "ds1", "useFor": "value" }, "tableItemsPath": "data.items", "tableTotalPath": "data.total", "params": [ { "paramName": "tenantId", "paramValue": "{__variables.tenantId}" }, { "paramName": "segment", "paramValue": "{el3}" } ], "pageSize": 50, "pageSizeOptions": "25,50,100", "orderClause": "column2 ASC", "showQuickSearch": true, "quickSearchParamName": "quickSearch", "quickSearchCondition": "%-%", "showDetailedSearch": true, "showExport": true, "exportAllPageSize": 10000, "virtualScroll": true, "virtualScrollRowHeight": 44, "virtualScrollViewportHeight": "600px", "virtualScrollBuffer": 10, "columnsConfig": [ { "key": "column1", "label": "ID", "type": "number", "sortable": true, "showInTable": true }, { "key": "column2", "label": "Name", "type": "text", "sortable": true, "showInTable": true, "filterControlType": "text" }, { "key": "column3", "label": "Active", "type": "element", "elementType": "badge", "showInTable": true, "filterControlType": "boolean", "filterToggleTrueValue": "true", "filterToggleFalseValue": "false", "element": {} } ] } }, "dataSources": [ { "name": "ds1", "title": "Customers (paged)", "type": "rest", "params": { "url": "/api/customers/search", "method": "TABLE-POST" } } ] } ``` The body your endpoint receives: ```json { "pagingParams": { "cnt": null, "orderClause": "column2 ASC", "pageSize": 50, "skipRows": 100, "totalCountUsed": false }, "params": [["tenantId", "42"], ["segment", "vip"]], "extendedParams": [ { "paramName": "quickSearch", "paramValue": { "condition": "%-%", "value": "acme", "upperLower": "caseInsensitiveLatin" } }, { "paramName": "column2", "paramValue": { "condition": "%-", "value": "Ac" } }, { "paramName": "column3", "paramValue": { "condition": "=", "value": true } } ] } ``` - On the wire `params` becomes `[name, value]` tuples, but **you author it as `[{ "paramName": ..., "paramValue": ... }]`** on the element. Up to five rows. - `condition` vocabulary: `%-%` contains, `!%-%` does not contain, `%-` starts with, `-%` ends with, `=`, `!=`, `>`, `>=`, `<`, `<=`. - Response: rows come from `tableItemsPath`, or `items` / `data` / `results` / `rows`. Total comes from `tableTotalPath`, or `total` / `totalCount` / `totalRecords` / `count` / `cnt` / `paging.*`. Without a recognizable total the pager looks stuck. - Export "all" re-issues the same request with `pagingParams.pageSize` set to `exportAllPageSize`, then builds the file in the browser. ### 6.4 Inline edit, expandable rows, details panel, saved filters ```json { "elements": { "el2": { "name": "el2", "label": "Orders", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "enableInlineEdit": true, "inlineEditStartMode": "doubleClick", "inlineEditSaveDataSourceName": "ds2", "inlineEditCancelOnReload": true, "expandableRows": true, "expandedDisplayMode": "inline", "showExpandChevron": true, "alwaysShowExpandChevron": false, "expandLabel": "Show items", "collapseLabel": "Hide items", "expandedEmptyMessage": "This order has no lines", "expandedDataSourceName": "ds3", "expandedDataSourceItemsPath": "data.lines", "expandedColumnsConfig": [ { "key": "column10", "label": "Product", "type": "text", "showInTable": true }, { "key": "column11", "label": "Qty", "type": "number", "align": "right", "showInTable": true }, { "key": "column12", "label": "Price", "type": "number", "align": "right", "numberMinFractionDigits": 2, "showInTable": true } ], "expandedRowActions": [ { "label": "Remove line", "icon": "delete", "type": "dataSource", "dataSourceName": "ds4", "buttonTone": "risk" } ], "rowClickOpensDetails": true, "detailsPanelTitle": "Order details", "enableSavedFilters": true, "savedFiltersMode": "dataSource", "savedFiltersDataSourceName": "ds5", "savedFiltersItemsPath": "data.items", "saveFilterDataSourceName": "ds6", "deleteFilterDataSourceName": "ds7", "savedFilterIdKey": "id", "savedFilterNameKey": "name", "savedFilterCodeKey": "code", "savedFilterDescriptionKey": "description", "savedFilterPayloadKey": "payload", "columnsConfig": [ { "key": "column1", "label": "Order", "type": "text", "showInTable": true }, { "key": "column2", "label": "Status", "type": "text", "showInTable": true, "editable": true, "editorType": "select", "editorOptions": [ { "value": "new", "label": "New" }, { "value": "paid", "label": "Paid" } ], "editorOptionValueKey": "value", "editorOptionLabelKey": "label" }, { "key": "column3", "label": "Note", "type": "text", "showInTable": true, "editable": true, "editorType": "textarea", "editorPlaceholder": "Internal note" } ] } } } ``` The inline-edit save data source receives `{row.*}` (the whole edited row) **and** `{changedValues}`, a flat object holding only the columns that actually changed, which is what a PATCH endpoint wants. ### 6.5 The table's live state: `__table.*` Every `table` continuously publishes its own state into the form data, so **any other element can read it without knowing how the table is fed**. This is the mechanism behind selection-driven toolbars, counters, master/detail screens and conditional buttons. Two roots exist. `__table..*` is that specific table. Bare `__table.*` is the last table the user touched, useful when there is only one. | Path | Holds | | --- | --- | | `__table..rows` | rows currently visible (the active page, after filtering) | | `__table..allRows` | every loaded row | | `__table..rowCount` | number of visible rows | | `__table..totalRecords` | total according to the server (lazy) or the loaded set | | `__table..page` | current page, 1-based | | `__table..size` | current page size | | `__table..sortField` | sorted column key, or `""` | | `__table..sortDirection` | `asc` or `desc` | | `__table..quickSearch` | current quick-search term | | `__table..detailedFilters` | active per-column filters (lazy) | | `__table..request` | the exact request payload last sent (lazy) | | `__table..selectedRows` | array of checkbox-selected row objects | | `__table..selectedKeys` | array of their `selectionKey` values | | `__table..selectedItems` | the same selection rendered through `selectionItemTemplate` | | `__table..selectedCount` | how many are selected | | `__table..selectedRow` | the single row the user is working on: first selected, else expanded/active, else last clicked | | `__table..selectedRowKey` | its key, or `null` | | `__table..selectedRowIndex` | its index, or `-1` | | `__table..activeRow` / `.activeRowKey` | the expanded / detail row | These are read in braces like any other path: `{__table.el2.selectedCount}`. ### 6.6 Selection-driven toolbar A counter, a bulk button that only enables with a selection, and a panel that mirrors the row being worked on. Nothing here is wired by hand; everything reads `__table.*`. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1180px", "widthUnit": "px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }, { "elementRef": "el2" }, { "elementRef": "el3" }] }, { "columns": [{ "elementRef": "el4" }] }, { "columns": [ { "elementRef": "el5", "rows": [ { "columns": [{ "elementRef": "el6" }, { "elementRef": "el7" }] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Orders", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "", "type": "badge", "text": "Selected: {__table.el4.selectedCount}", "variant": "info", "pill": true, "visibleIf": "{__table.el4.selectedCount} > 0" }, "el2": { "name": "el2", "label": "", "type": "button", "text": "Approve selected", "variant": "solid", "tone": "primary", "fitContent": true, "disableIf": "{__table.el4.selectedCount} == 0", "events": [ { "trigger": "click", "type": "dataSource", "dataSourceName": "ds2", "confirmEnabled": true, "confirmTitle": "Approve", "confirmMessage": "Approve every selected order?", "reloadElementNames": ["el4"], "showToastAfter": true, "toastTitle": "Approved", "toastVariant": "success" } ] }, "el3": { "name": "el3", "label": "", "type": "button", "text": "Clear search", "variant": "text", "tone": "neutral", "fitContent": true, "visibleIf": "notEmpty({__table.el4.quickSearch})", "events": [{ "trigger": "click", "type": "reloadElements", "reloadElementNames": ["el4"] }] }, "el4": { "name": "el4", "label": "Orders", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "enableSelection": true, "selectionKey": "column1", "selectionItemTemplate": "#{row.column1} ({row.column2})", "showQuickSearch": true, "pageSize": 25, "columnsConfig": [ { "key": "column1", "label": "Order", "type": "text", "showInTable": true, "sortable": true }, { "key": "column2", "label": "Customer", "type": "text", "showInTable": true }, { "key": "column3", "label": "Total", "type": "number", "align": "right", "numberMinFractionDigits": 2, "showInTable": true, "sortable": true } ] }, "el5": { "name": "el5", "label": "Current order", "type": "panel", "titleUnderline": true, "panelPadding": "0px", "showBorder": false, "visibleIf": "notEmpty({__table.el4.selectedRowKey})" }, "el6": { "name": "el6", "label": "Order", "type": "text", "readOnly": true, "expression": "{__table.el4.selectedRow}.column1", "logicExecutionMode": "onChange" }, "el7": { "name": "el7", "label": "Customer", "type": "text", "readOnly": true, "expression": "{__table.el4.selectedRow}.column2", "logicExecutionMode": "onChange" } }, "dataSources": [ { "name": "ds1", "title": "Orders", "type": "rest", "params": { "url": "/api/orders", "method": "GET" } }, { "name": "ds2", "title": "Approve selected", "type": "rest", "params": { "url": "/api/orders/approve", "method": "POST", "body": { "ids": "{__table.el4.selectedKeys}" } } } ], "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` `selectionKey` names the row field used as the identity of a selection; it defaults to `id`. `selectionItemTemplate` shapes each entry of `selectedItems`, which is what you send when the endpoint wants labels rather than raw rows. ### 6.7 Master and detail: one table drives another The detail table reloads whenever the master's selected row changes, because its data source declares that dependency in `refreshPaths`. ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }] }, { "columns": [{ "elementRef": "el2" }] } ] } ], "elements": { "page1": { "name": "page1", "label": "Orders", "type": "page" }, "el1": { "name": "el1", "label": "Orders", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "rowHover": true, "columnsConfig": [ { "key": "column1", "label": "Order", "type": "text", "showInTable": true }, { "key": "column2", "label": "Customer", "type": "text", "showInTable": true } ] }, "el2": { "name": "el2", "label": "Lines of the selected order", "type": "table", "emptyMessage": "Pick an order above", "visibleIf": "notEmpty({__table.el1.selectedRowKey})", "dataSource": { "name": "ds2", "useFor": "value", "params": [{ "name": "orderId", "value": "{__table.el1.selectedRowKey}" }], "refreshOnChange": true, "refreshPaths": ["__table.el1.selectedRowKey"] }, "columnsConfig": [ { "key": "column10", "label": "Product", "type": "text", "showInTable": true }, { "key": "column11", "label": "Qty", "type": "number", "align": "right", "showInTable": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Orders", "type": "rest", "params": { "url": "/api/orders", "method": "GET" } }, { "name": "ds2", "title": "Order lines", "type": "rest", "params": { "url": "/api/orders/{orderId}/lines", "method": "GET" } } ] } ``` Clicking a row is enough: `selectedRow` falls back to the last clicked row when there is no selection column. ### 6.8 A filter toolbar above the table Real fields drive the request. `params` decides what is sent, and each field change re-issues it. ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1", "rows": [ { "columns": [ { "elementRef": "el2" }, { "elementRef": "el3" }, { "elementRef": "el4" } ] } ] } ] }, { "columns": [{ "elementRef": "el5" }] } ] } ], "elements": { "page1": { "name": "page1", "label": "Reports", "type": "page" }, "el1": { "name": "el1", "label": "Filters", "type": "panel", "showBorder": true, "panelRadius": "8px", "panelPadding": "12px", "contentGap": "12px" }, "el2": { "name": "el2", "label": "Status", "type": "select", "strictOptions": true, "defaultValue": "", "options": [ { "value": "", "label": "All" }, { "value": "open", "label": "Open" }, { "value": "closed", "label": "Closed" } ] }, "el3": { "name": "el3", "label": "Period", "type": "dateRange", "format": "YYYY-MM-DD" }, "el4": { "name": "el4", "label": "Owner", "type": "text" }, "el5": { "name": "el5", "label": "Results", "type": "table", "lazyLoad": true, "dataSource": { "name": "ds1", "useFor": "value" }, "tableItemsPath": "data.items", "tableTotalPath": "data.total", "params": [ { "paramName": "status", "paramValue": "{el2}" }, { "paramName": "from", "paramValue": "{el3.dateFrom}" }, { "paramName": "to", "paramValue": "{el3.dateTo}" }, { "paramName": "owner", "paramValue": "{el4}" } ], "showQuickSearch": true, "pageSize": 50, "columnsConfig": [ { "key": "column1", "label": "Ref", "type": "text", "showInTable": true, "sortable": true }, { "key": "column2", "label": "Opened", "type": "date", "showInTable": true, "sortable": true }, { "key": "column3", "label": "Owner", "type": "text", "showInTable": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Report search", "type": "rest", "params": { "url": "/api/reports/search", "method": "TABLE-POST" } } ] } ``` A `dateRange` value is an object, so its parts are `{el3.dateFrom}` and `{el3.dateTo}`. ### 6.9 Column filter options loaded from a data source Per-column filters do not have to be hardcoded. A column can pull its own option list. ```json { "elements": { "el1": { "name": "el1", "label": "Tickets", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "showDetailedSearch": true, "columnsConfig": [ { "key": "column1", "label": "Ref", "type": "text", "showInTable": true }, { "key": "column2", "label": "Assignee", "type": "text", "showInTable": true, "filterControlType": "multiSelect", "filterOptionsSourceType": "datasource", "filterOptionsDataSourceName": "ds2", "filterOptionsDataSourceItemsPath": "data.items", "filterOptionValueKey": "id", "filterOptionLabelKey": "name", "filterMultiValueDelimiter": "," }, { "key": "column3", "label": "Archived", "type": "text", "showInTable": true, "filterControlType": "toggle", "filterToggleTrueValue": "true", "filterToggleFalseValue": "false" }, { "key": "column4", "label": "Priority", "type": "text", "showInTable": true, "filterControlType": "select", "filterOptionsSourceType": "static", "filterOptionValueKey": "value", "filterOptionLabelKey": "label", "filterOptions": [ { "value": "low", "label": "Low" }, { "value": "high", "label": "High" } ] } ] } }, "dataSources": [ { "name": "ds1", "title": "Tickets", "type": "rest", "params": { "url": "/api/tickets", "method": "GET" } }, { "name": "ds2", "title": "Users", "type": "rest", "params": { "url": "/api/users", "method": "GET" } } ] } ``` ### 6.10 Header dropdown Beside the header buttons a table can host one dropdown, either with static options or fed by a data source, whose selection runs actions. ```json { "elements": { "el1": { "name": "el1", "label": "Tickets", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "showHeaderControls": true, "headerActionsPosition": "end", "headerMenuEnabled": true, "headerMenuPlaceholder": "Switch queue", "headerMenuDataSourceName": "ds2", "headerMenuItemsPath": "data.items", "headerMenuLabelKey": "name", "headerMenuValueKey": "id", "headerMenuActions": [ { "trigger": "change", "type": "reloadElements", "reloadElementNames": ["el1"] } ], "columnsConfig": [ { "key": "column1", "label": "Ref", "type": "text", "showInTable": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Tickets", "type": "rest", "params": { "url": "/api/tickets", "method": "GET" } }, { "name": "ds2", "title": "Queues", "type": "rest", "params": { "url": "/api/queues", "method": "GET" } } ] } ``` For static options instead of a source, drop `headerMenuDataSourceName` / `headerMenuItemsPath` / `headerMenuLabelKey` / `headerMenuValueKey` and set `headerMenuOptions: [{ "value": "a", "label": "A" }]`. ### 6.11 Templates and mobile behaviour `headerTemplate` fills the centre of the header, `rowTemplate` replaces the whole row, `expandedTemplate` renders the expanded area. Row-level templates see `{row.*}`; all of them can read `{__table..*}`. ```json { "elements": { "el1": { "name": "el1", "label": "Tickets", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "headerTemplate": "Showing {__table.el1.rowCount} of {__table.el1.totalRecords}", "headerCenterElementsPosition": "center", "responsiveMode": "stack", "stickyHeader": true, "tableStyle": "grid", "expandableRows": true, "expandedDisplayMode": "inline", "expandedTemplate": "
{row.column2}

{row.column3}

", "columnsConfig": [ { "key": "column1", "label": "Ticket", "type": "text", "showInTable": true, "mobileLabel": "Ref", "template": "#{row.column1}" }, { "key": "column2", "label": "Subject", "type": "text", "showInTable": true }, { "key": "column3", "label": "Body", "type": "text", "showInTable": false, "showInDetails": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Tickets", "type": "rest", "params": { "url": "/api/tickets", "method": "GET" } } ] } ``` `responsiveMode: "stack"` turns each row into a stacked card on narrow screens, where `mobileLabel` becomes the visible field label. ### 6.12 Column settings stored on the server `columnSettingsMode: "localStorage"` needs only `columnSettingsStorageKey`. Per-user layouts kept server-side use the data-source mode instead. ```json { "elements": { "el1": { "name": "el1", "label": "Tickets", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "showColumnSettings": true, "columnSettingsDialogTitle": "Columns", "columnSettingsMode": "dataSource", "columnSettingsDataSourceName": "ds2", "columnSettingsDataSourcePath": "data.layout", "saveColumnSettingsDataSourceName": "ds3", "resetColumnSettingsDataSourceName": "ds4", "columnsConfig": [ { "key": "column1", "label": "Ref", "type": "text", "showInTable": true }, { "key": "column2", "label": "Subject", "type": "text", "showInTable": true }, { "key": "column3", "label": "Owner", "type": "text", "showInTable": false } ] } }, "dataSources": [ { "name": "ds1", "title": "Tickets", "type": "rest", "params": { "url": "/api/tickets", "method": "GET" } }, { "name": "ds2", "title": "Load layout", "type": "rest", "params": { "url": "/api/me/table-layout", "method": "GET" } }, { "name": "ds3", "title": "Save layout", "type": "rest", "params": { "url": "/api/me/table-layout", "method": "PUT" } }, { "name": "ds4", "title": "Reset layout", "type": "rest", "params": { "url": "/api/me/table-layout", "method": "DELETE" } } ] } ``` ### 6.13 Full CRUD loop: table, dialog editor, reload The pattern most admin screens need, end to end. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1180px", "widthUnit": "px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }] }, { "columns": [{ "elementRef": "el2" }] }, { "columns": [ { "elementRef": "el3", "rows": [ { "columns": [{ "elementRef": "el4" }, { "elementRef": "el5" }] }, { "columns": [{ "elementRef": "el6" }] }, { "columns": [ { "elementRef": "el7", "rows": [ { "columns": [{ "elementRef": "el8" }, { "elementRef": "el9" }] } ] } ] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Customers", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "", "type": "button", "text": "New customer", "icon": "add", "variant": "solid", "tone": "primary", "fitContent": true, "events": [ { "trigger": "click", "type": "setValue", "setValueTargetPath": "el4", "setValueMode": "template", "setValueValue": "" }, { "trigger": "click", "type": "setValue", "setValueTargetPath": "el5", "setValueMode": "template", "setValueValue": "" }, { "trigger": "click", "type": "setValue", "setValueTargetPath": "el6", "setValueMode": "template", "setValueValue": "" }, { "trigger": "click", "type": "dialog", "dialogName": "el3", "dialogOperation": "open" } ] }, "el2": { "name": "el2", "label": "Customers", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "selectionKey": "column1", "pageSize": 25, "showQuickSearch": true, "rowActionsDisplayMode": "iconButtons", "columnsConfig": [ { "key": "column1", "label": "ID", "type": "number", "showInTable": true, "width": "80px" }, { "key": "column2", "label": "Name", "type": "text", "showInTable": true, "sortable": true }, { "key": "column3", "label": "Email", "type": "text", "showInTable": true } ], "rowActions": [ { "label": "Edit", "icon": "edit", "type": "setValue", "setValueTargetPath": "el4", "setValueMode": "template", "setValueValue": "{row.column1}" }, { "label": "Edit", "icon": "edit", "hideText": true, "type": "setValue", "setValueTargetPath": "el5", "setValueMode": "template", "setValueValue": "{row.column2}" }, { "label": "Edit", "icon": "edit", "hideText": true, "type": "setValue", "setValueTargetPath": "el6", "setValueMode": "template", "setValueValue": "{row.column3}" }, { "label": "Edit", "icon": "edit", "hideText": true, "type": "dialog", "dialogName": "el3", "dialogOperation": "open" }, { "label": "Delete", "icon": "delete", "type": "dataSource", "dataSourceName": "ds3", "buttonTone": "risk", "confirmEnabled": true, "confirmTitle": "Delete customer", "confirmMessage": "This cannot be undone.", "reloadCurrentElementAfterSuccess": true } ] }, "el3": { "name": "el3", "label": "Customer", "type": "dialog", "showTriggerButton": false, "showCloseButton": true, "dialogWidth": "520px" }, "el4": { "name": "el4", "label": "ID", "type": "text", "readOnly": true }, "el5": { "name": "el5", "label": "Name", "type": "text", "required": true }, "el6": { "name": "el6", "label": "Email", "type": "text", "inputMode": "email", "required": true }, "el7": { "name": "el7", "label": "", "type": "panel", "showBorder": false, "panelPadding": "0px", "contentJustify": "flex-end", "contentGap": "8px" }, "el8": { "name": "el8", "label": "", "type": "button", "text": "Cancel", "variant": "outline", "tone": "neutral", "fitContent": true, "events": [{ "trigger": "click", "type": "dialog", "dialogName": "el3", "dialogOperation": "close" }] }, "el9": { "name": "el9", "label": "", "type": "button", "text": "Save", "variant": "solid", "tone": "primary", "fitContent": true, "events": [ { "trigger": "click", "type": "dataSource", "dataSourceName": "ds2", "validateForm": true, "reloadElementNames": ["el2"], "showToastAfter": true, "toastTitle": "Saved", "toastVariant": "success" }, { "trigger": "click", "type": "dialog", "dialogName": "el3", "dialogOperation": "close" } ] } }, "dataSources": [ { "name": "ds1", "title": "Customers", "type": "rest", "params": { "url": "/api/customers", "method": "GET" } }, { "name": "ds2", "title": "Save customer", "type": "rest", "params": { "url": "/api/customers/{el4}", "method": "PUT", "body": { "name": "{el5}", "email": "{el6}" } } }, { "name": "ds3", "title": "Delete customer", "type": "rest", "params": { "url": "/api/customers/{row.column1}", "method": "DELETE" } } ], "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` One action carries one `type`, so filling three fields and opening the dialog is four actions on the same row-action button. They run in order, and only the first needs a visible label; give the rest `hideText: true` so the toolbar stays clean. ### 6.14 Dashboard: one table feeding KPI cards and a chart The table is the single source of data; the cards and the chart read its published state. No second request is issued: a `local` source in `dataPath` mode simply points at `__table.el3.allRows`. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1180px", "widthUnit": "px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1" }, { "elementRef": "el2" } ] }, { "columns": [{ "elementRef": "el5" }] }, { "columns": [{ "elementRef": "el4" }] }, { "columns": [{ "elementRef": "el3" }] } ] } ], "elements": { "page1": { "name": "page1", "label": "Sales", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "", "type": "statsCard", "title": "Orders", "valueText": "{__table.el3.totalRecords}", "variant": "info", "icon": "receipt_long" }, "el2": { "name": "el2", "label": "", "type": "statsCard", "title": "Revenue", "valueText": "{el5}", "variant": "success", "icon": "payments" }, "el4": { "name": "el4", "label": "Revenue by month", "type": "chart", "chartType": "bar", "chartHeight": "260px", "showLegend": false, "labelKey": "column2", "valueKey": "column3", "dataSource": { "name": "ds2", "useFor": "value", "refreshOnChange": true, "refreshPaths": ["__table.el3.allRows"] } }, "el3": { "name": "el3", "label": "Orders", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "pageSize": 25, "showQuickSearch": true, "columnsConfig": [ { "key": "column1", "label": "Order", "type": "text", "showInTable": true }, { "key": "column2", "label": "Month", "type": "text", "showInTable": true }, { "key": "column3", "label": "Total", "type": "number", "align": "right", "numberMinFractionDigits": 2, "showInTable": true } ] }, "el5": { "name": "el5", "label": "Revenue", "type": "number", "hidden": true, "readOnly": true, "expression": "sumArray({__table.el3.allRows}, column3)", "logicExecutionMode": "onChange" } }, "dataSources": [ { "name": "ds1", "title": "Orders", "type": "rest", "params": { "url": "/api/orders", "method": "GET" } }, { "name": "ds2", "title": "Orders already loaded by the table", "type": "local", "params": { "localMode": "dataPath", "dataPath": "__table.el3.allRows" } } ], "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` `statsCard.title`, `valueText` and `trendText`, and `badge.text`, all interpolate `{...}` tokens, so a card can display live table state directly. A hidden `number` element is the usual place to park an aggregate that several other elements want to reuse. **`hidden: true` still needs a place in the layout tree.** `hidden` only suppresses the rendering; an element that is absent from `pages` is never instantiated at all, so its `expression` never runs and everything reading it stays empty. Give the helper element its own row and hide it there. ### 6.15 Aggregates under a table `allRows` is the whole loaded set, `rows` is only what the current page shows after filtering. Pick deliberately: a "total" that changes when the user pages is almost always a bug. ```json { "elements": { "el1": { "name": "el1", "label": "Invoices", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "columnsConfig": [ { "key": "column1", "label": "Invoice", "type": "text", "showInTable": true }, { "key": "column2", "label": "Status", "type": "text", "showInTable": true }, { "key": "column3", "label": "Amount", "type": "number", "align": "right", "showInTable": true } ] }, "el2": { "name": "el2", "label": "Rows loaded", "type": "number", "readOnly": true, "expression": "len({__table.el1.allRows})", "logicExecutionMode": "onChange" }, "el3": { "name": "el3", "label": "Total amount", "type": "number", "readOnly": true, "expression": "sumArray({__table.el1.allRows}, column3)", "logicExecutionMode": "onChange" }, "el4": { "name": "el4", "label": "Average amount", "type": "number", "readOnly": true, "expression": "avgArray({__table.el1.allRows}, column3)", "logicExecutionMode": "onChange" }, "el5": { "name": "el5", "label": "Unpaid", "type": "number", "readOnly": true, "expression": "countInArray({__table.el1.allRows}, column2 == \"unpaid\")", "logicExecutionMode": "onChange" }, "el6": { "name": "el6", "label": "Unpaid total", "type": "number", "readOnly": true, "expression": "sumArray(filterArray({__table.el1.allRows}, column2 == \"unpaid\"), column3)", "logicExecutionMode": "onChange" }, "el7": { "name": "el7", "label": "Largest invoice", "type": "text", "readOnly": true, "expression": "getFirst(filterArray({__table.el1.allRows}, column3 > 1000), column1)", "logicExecutionMode": "onChange" } }, "dataSources": [ { "name": "ds1", "title": "Invoices", "type": "rest", "params": { "url": "/api/invoices", "method": "GET" } } ] } ``` Inside a collection function the row fields are written **bare and unquoted** (`column2 == "unpaid"`); everything outside the collection keeps its braces. ### 6.16 Details side panel instead of an inline expand The same expand machinery renders in a drawer when `expandedDisplayMode` is `sidePanel`. `rowClickOpensDetails` means the whole row opens it, so no chevron column is needed. ```json { "elements": { "el1": { "name": "el1", "label": "Applications", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "expandableRows": true, "expandedDisplayMode": "sidePanel", "detailsPanelTitle": "Application details", "rowClickOpensDetails": true, "showExpandChevron": false, "rowHover": true, "rowExpandActions": [ { "type": "dataSource", "dataSourceName": "ds2", "params": [{ "name": "id", "value": "{row.column1}" }] } ], "columnsConfig": [ { "key": "column1", "label": "Ref", "type": "text", "showInTable": true }, { "key": "column2", "label": "Applicant", "type": "text", "showInTable": true }, { "key": "column3", "label": "Submitted", "type": "date", "showInTable": true }, { "key": "column4", "label": "Motivation", "type": "text", "showInTable": false, "showInDetails": true, "detailLabel": "Why they applied" }, { "key": "column5", "label": "Reviewer notes", "type": "text", "showInTable": false, "showInDetails": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Applications", "type": "rest", "params": { "url": "/api/applications", "method": "GET" } }, { "name": "ds2", "title": "Mark as viewed", "type": "rest", "params": { "url": "/api/applications/{id}/viewed", "method": "POST" } } ] } ``` `rowExpandActions` fire when a row is expanded, which is where "mark as read" or "load the heavy part now" belongs. Columns with `showInTable: false` and `showInDetails: true` are exactly what fills the panel. ### 6.17 Row action that downloads a file `responseMode: "download"` turns the response into a file instead of data. ```json { "elements": { "el1": { "name": "el1", "label": "Invoices", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "rowActionsDisplayMode": "iconButtons", "columnsConfig": [ { "key": "column1", "label": "Invoice", "type": "text", "showInTable": true }, { "key": "column2", "label": "Issued", "type": "date", "showInTable": true } ], "rowActions": [ { "label": "Download PDF", "icon": "download", "type": "dataSource", "dataSourceName": "ds2", "responseMode": "download", "responseFileName": "invoice-{row.column1}.pdf" }, { "label": "Email a copy", "icon": "mail", "type": "dataSource", "dataSourceName": "ds3", "condition": "notEmpty({row.column3})", "showToastAfter": true, "toastTitle": "Sent", "toastVariant": "success" } ] } }, "dataSources": [ { "name": "ds1", "title": "Invoices", "type": "rest", "params": { "url": "/api/invoices", "method": "GET" } }, { "name": "ds2", "title": "Invoice PDF", "type": "rest", "params": { "url": "/api/invoices/{row.column1}/pdf", "method": "GET" } }, { "name": "ds3", "title": "Email invoice", "type": "rest", "params": { "url": "/api/invoices/{row.column1}/email", "method": "POST" } } ] } ``` CSV/Excel/PDF export of the grid itself is different: it is `showExport` and happens entirely in the browser, with no endpoint to implement. ### 6.18 Export configuration ```json { "elements": { "el1": { "name": "el1", "label": "Customers", "type": "table", "lazyLoad": true, "dataSource": { "name": "ds1", "useFor": "value" }, "tableItemsPath": "data.items", "tableTotalPath": "data.total", "pageSize": 50, "showExport": true, "exportFileName": "customers-2026", "exportUseColumnPicker": true, "exportAllPageSize": 20000, "columnsConfig": [ { "key": "column1", "label": "ID", "type": "number", "showInTable": true }, { "key": "column2", "label": "Name", "type": "text", "showInTable": true }, { "key": "column3", "label": "Created", "type": "dateTime", "dateFormatPattern": "yyyy-MM-dd", "showInTable": true }, { "key": "column4", "label": "Internal score", "type": "number", "showInTable": false, "showInDetails": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Customers", "type": "rest", "params": { "url": "/api/customers/search", "method": "TABLE-POST" } } ] } ``` With `exportUseColumnPicker` the user chooses which columns land in the file. "Export all" re-issues the same table request with `pagingParams.pageSize` set to `exportAllPageSize`, so that number is a real memory decision, not decoration. ### 6.19 A live table over a websocket ```json { "elements": { "el1": { "name": "el1", "label": "Live events", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "pageSize": 50, "stickyHeader": true, "virtualScroll": true, "virtualScrollRowHeight": 40, "virtualScrollViewportHeight": "520px", "orderClause": "column1 DESC", "columnsConfig": [ { "key": "column1", "label": "Time", "type": "dateTime", "dateIncludeSeconds": true, "showInTable": true, "sortable": true }, { "key": "column2", "label": "Level", "type": "status", "showInTable": true, "statusShowIcon": true, "statusRules": [ { "condition": "value == 'error'", "label": "Error", "tone": "risk", "icon": "error", "rounded": true }, { "condition": "value == 'warn'", "label": "Warning", "tone": "warning", "icon": "warning", "rounded": true }, { "condition": "value == 'info'", "label": "Info", "tone": "info", "rounded": true } ] }, { "key": "column3", "label": "Message", "type": "text", "showInTable": true } ] } }, "dataSources": [ { "name": "ds1", "title": "Event stream", "type": "websocket", "params": { "url": "wss://example.test/events", "protocols": "", "message": "{\"subscribe\":\"events\"}", "messagePath": "payload", "messageMode": "pushValuesInArray" } } ] } ``` `messageMode: "pushValuesInArray"` appends each message to the accumulated array, which is what a log-style table wants; `replaceCurrent` swaps the whole set instead. The buffer is capped at 500 entries, so a view left open overnight cannot grow without end. `virtualScroll` keeps a long list cheap to render. ### 6.20 Tables nested in containers A table is a leaf element, so it sits in a column like any other. Inside `tabs` that column lives in `tabRows`; inside a `dynamicPanel` each entry renders its own copy of the table. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1100px", "widthUnit": "px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1", "tabRows": { "active": [{ "columns": [{ "elementRef": "el2" }] }], "archived": [{ "columns": [{ "elementRef": "el3" }] }] } } ] }, { "columns": [ { "elementRef": "el4", "rows": [ { "columns": [{ "elementRef": "el5" }] }, { "columns": [{ "elementRef": "el6" }] } ] } ] } ] } ], "elements": { "page1": { "name": "page1", "label": "Projects", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "Projects", "type": "tabs", "tabsPosition": "top", "tabs": [ { "value": "active", "label": "Active" }, { "value": "archived", "label": "Archived" } ] }, "el2": { "name": "el2", "label": "Active projects", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "columnsConfig": [ { "key": "column1", "label": "Project", "type": "text", "showInTable": true }, { "key": "column2", "label": "Owner", "type": "text", "showInTable": true } ] }, "el3": { "name": "el3", "label": "Archived projects", "type": "table", "dataSource": { "name": "ds2", "useFor": "value" }, "columnsConfig": [ { "key": "column1", "label": "Project", "type": "text", "showInTable": true }, { "key": "column3", "label": "Archived", "type": "date", "showInTable": true } ] }, "el4": { "name": "el4", "label": "Teams", "type": "dynamicPanel", "addRowButtonText": "Add team", "panelPadding": "16px", "panelBorderWidth": "1px", "panelRadius": "8px" }, "el5": { "name": "el5", "label": "Team name", "type": "text", "required": true }, "el6": { "name": "el6", "label": "Members", "type": "dynamicTable", "addRowButtonText": "Add member", "columns": [ { "name": "column1", "label": "Name", "type": "text" }, { "name": "column2", "label": "Role", "type": "select", "options": [ { "value": "dev", "label": "Developer" }, { "value": "qa", "label": "QA" } ] } ] } }, "dataSources": [ { "name": "ds1", "title": "Active", "type": "rest", "params": { "url": "/api/projects?state=active", "method": "GET" } }, { "name": "ds2", "title": "Archived", "type": "rest", "params": { "url": "/api/projects?state=archived", "method": "GET" } } ], "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` Note which repeating element goes where: a **grid of editable cells** inside a repeating block is `dynamicTable`; a **read-only view of server data** would be `table`. A `table` inside a `dynamicPanel` publishes its state under one shared `__table.` key, so prefer `dynamicTable` when each entry needs its own independent state. ### 6.21 A localized table Column labels, action labels and messages all go through `localization.texts` when a `translate()` call or a translated key is used. The simplest route is to author the labels in the default language and let the host supply the rest. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1100px", "widthUnit": "px" }, "pages": [{ "name": "page1", "rows": [{ "columns": [{ "elementRef": "el1" }] }] }], "elements": { "page1": { "name": "page1", "label": "Orders", "type": "page", "hideHeader": true }, "el1": { "name": "el1", "label": "Orders", "type": "table", "dataSource": { "name": "ds1", "useFor": "value" }, "emptyMessage": "No records found", "loadingLabel": "Kraunama...", "quickSearchPlaceholder": "Search", "actionsLabel": "Veiksmai", "showQuickSearch": true, "columnsConfig": [ { "key": "column1", "label": "Numeris", "type": "text", "showInTable": true }, { "key": "column2", "label": "Suma", "type": "number", "align": "right", "formatLocale": "lt-LT", "numberMinFractionDigits": 2, "numberUseGrouping": true, "showInTable": true }, { "key": "column3", "label": "Status", "type": "element", "elementType": "customHtml", "showInTable": true, "element": { "htmlTemplate": "{translate({row.column3})}" } } ] } }, "dataSources": [ { "name": "ds1", "title": "Orders", "type": "rest", "params": { "url": "/api/orders", "method": "GET" } } ], "localization": { "defaultLanguage": "en", "languages": ["en", "de"], "texts": { "de": { "new": "Neu", "paid": "Bezahlt", "late": "Überfällig" }, "en": { "new": "New", "paid": "Paid", "late": "Late" } } } } ``` `translate(value)` looks the raw value up in `localization.texts[]`, which is how a raw server code such as `paid` becomes a readable label without a lookup table in the view. ### 6.22 Choosing the right table | The screen needs | Use | | --- | --- | | Users type into every row, add and delete rows | `dynamicTable` | | A read-only or lightly interactive grid over server data | `table` | | Server owns paging, sorting and filtering | `table` with `lazyLoad: true` and `TABLE-POST` | | Cards instead of rows | `listGrid` | | A repeating block of fields rather than a grid of cells | `dynamicPanel` | `table` never uses `column.rows`; its cells are `columnsConfig`, keyed by `key`. `dynamicTable` never uses `columnsConfig`; its cells are `columns`, keyed by `name`. ### 6.23 `table` property map Grouped so nothing has to be guessed. | Group | Properties | | --- | --- | | Data | `dataSource` `tableItemsPath` `tableTotalPath` `lazyLoad` `params` `orderClause` `orderDirection` | | Columns | `columnsConfig` `showColumnSettings` `columnSettingsMode` `columnSettingsStorageKey` `columnSettingsDialogTitle` `columnSettingsDataSourceName` `columnSettingsDataSourcePath` `saveColumnSettingsDataSourceName` `resetColumnSettingsDataSourceName` | | Paging | `pageSize` `pageSizeOptions` `paginator` `paginatorStyle` `paginatorVariant` `paginatorAlign` `paginatorMaxButtons` `showPageSizeSelector` `prevLabel` `nextLabel` | | Search & filters | `showQuickSearch` `quickSearchPlaceholder` `quickSearchParamName` `quickSearchCondition` `quickSearchCaseMode` `searchDebounceMs` `showDetailedSearch` `detailedSearchCaseMode` `enableSavedFilters` `savedFiltersMode` `savedFiltersDataSourceName` `savedFiltersItemsPath` `saveFilterDataSourceName` `deleteFilterDataSourceName` `savedFilterIdKey` `savedFilterNameKey` `savedFilterCodeKey` `savedFilterDescriptionKey` `savedFilterPayloadKey` | | Actions | `rowActions` `rowActionsDisplayMode` `rowActionsDropdownButtonStyle` `rowActionVisibilityPath` `rowClickActions` `rowExpandActions` `headerActions` `headerActionsPosition` `headerActionsDisplayMode` `headerMenuEnabled` `headerMenuActions` `headerMenuOptions` `headerMenuPlaceholder` `headerMenuDataSourceName` `headerMenuDataSourceItemsPath` `headerMenuItemsPath` `headerMenuLabelKey` `headerMenuValueKey` `actionsLabel` `showActionsHeaderLabel` | | Selection | `enableSelection` `selectionKey` `selectionActions` `selectionActionsLabel` `selectionItemTemplate` `selectLabel` `selectAllLabel` `selectRowLabel` `autoSelectCondition` | | Inline edit | `enableInlineEdit` `inlineEditStartMode` `inlineEditSaveDataSourceName` `inlineEditCancelOnReload` | | Expand & details | `expandableRows` `expandedDisplayMode` `expandedColumnsConfig` `expandedDataSourceName` `expandedDataSourceItemsPath` `expandedItemsPath` `expandedTemplate` `expandedEmptyMessage` `expandedRowActions` `showExpandChevron` `alwaysShowExpandChevron` `expandLabel` `collapseLabel` `rowClickOpensDetails` `detailsPanelTitle` | | Export | `showExport` `exportFileName` `exportUseColumnPicker` `exportAllPageSize` | | Appearance | `tableStyle` `stickyHeader` `rowHover` `responsiveMode` `flatHeaderSurface` `flatFooterSurface` `showHeaderControls` `headerCenterElementsPosition` `headerTemplate` `rowTemplate` `rowTemplateName` `emptyMessage` `loadingLabel` `width` `tabletWidth` `mobileWidth` `hidden` `visibleIf` | | Virtual scroll | `virtualScroll` `virtualScrollRowHeight` `virtualScrollViewportHeight` `virtualScrollBuffer` | Column (`columnsConfig[*]`) properties: | Group | Properties | | --- | --- | | Identity | `key` (required) `label` `showHeaderLabel` `mobileLabel` `type` `showInTable` `showInDetails` `detailLabel` `sortable` `width` `align` `visibleIf` | | Formatting | `formatLocale` `dateFormatPattern` `dateIncludeSeconds` `numberMinFractionDigits` `numberMaxFractionDigits` `numberUseGrouping` | | Element cells | `elementType` `element` `controlActiveIf` `controlEnabledIf` | | Status cells | `statusRules` `statusShowIcon` | | Templates | `template` `templateName` `templateFieldMap` | | Filters | `filterControlType` `filterOptionsSourceType` `filterOptions` `filterOptionsJson` `filterOptionsPath` `filterOptionsDataSourceName` `filterOptionsDataSourceItemsPath` `filterOptionLabelKey` `filterOptionValueKey` `filterMultiValueDelimiter` `filterToggleTrueValue` `filterToggleFalseValue` | | Inline edit | `editable` `editorType` `editorPlaceholder` `editorOptions` `editorOptionLabelKey` `editorOptionValueKey` | | Cell actions | `cellActionsEnabled` `cellActions` `cellActionsDisplayMode` `cellActionsPlaceholder` | Enumerations, exact values: | Property | Values | | --- | --- | | `columnsConfig[*].type` | `text` `date` `dateTime` `number` `element` (legacy, still rendered: `boolean` `html` `status` `toggleSwitch` `singleCheckbox`) | | `columnsConfig[*].align` | `left` `center` `right` | | `columnsConfig[*].filterControlType` | `auto` `text` `number` `date` `dateTime` `boolean` `select` `multiSelect` `toggle` | | `columnsConfig[*].editorType` | `auto` `text` `number` `date` `textarea` `checkbox` `toggle` `select` | | `columnsConfig[*].filterOptionsSourceType` | `static` `datasource` | | `statusRules[*].tone` | `neutral` `success` `warning` `risk` `primary` `danger` `info` | | `tableStyle` | `default` `grid` `striped` | | `responsiveMode` | `auto` `stack` | | `rowActionsDisplayMode` | `dropdown` `iconButtons` `buttons` | | `rowActionsDropdownButtonStyle` | `borderless` `default` | | `headerActionsDisplayMode` | `buttons` `dropdown` | | `headerActionsPosition` | `start` `center` `end` | | `headerCenterElementsPosition` | `left` `center` `right` | | `paginatorStyle` | `default` `compact` `segmented` | | `paginatorVariant` | `full` `pager` `numbers` `simple` | | `paginatorAlign` | `start` `center` `end` | | `quickSearchCondition` | `%-%` `!%-%` `%-` `-%` `=` `!=` | | `quickSearchCaseMode`, `detailedSearchCaseMode` | `caseInsensitiveLatin` `caseSensitive` `uppercase` `lowercase` | | `columnSettingsMode` | `none` `localStorage` `dataSource` `host` | | `savedFiltersMode` | `localStorage` `dataSource` `host` | | `expandedDisplayMode` | `sidePanel` `inline` | | `inlineEditStartMode` | `action` `doubleClick` | | `orderDirection` | `asc` `desc` | --- ## 7. `dynamicTable`: editable repeating rows `dynamicTable` is the editable grid: users add and delete rows and type into cells. Its cells are declared in its own `columns` array, where **each entry is a full element definition**. ```json { "pages": [ { "name": "page1", "rows": [ { "columns": [{ "elementRef": "el1" }] }, { "columns": [{ "elementRef": "el2" }] } ] } ], "elements": { "page1": { "name": "page1", "label": "Invoice", "type": "page" }, "el1": { "name": "el1", "label": "Invoice lines", "type": "dynamicTable", "addRowButtonText": "Add line", "emptyMessage": "No lines yet", "maxRows": 50, "disallowAddRows": false, "disallowDeleteRows": false, "confirmRowDeletion": true, "confirmDeleteTitle": "Remove line", "confirmDeleteMessage": "Remove this invoice line?", "confirmDeleteConfirmLabel": "Remove", "confirmDeleteCancelLabel": "Keep", "hideRowIf": "{row.column5} == true", "columns": [ { "name": "column1", "label": "Product", "type": "select", "strictOptions": true, "options": [ { "value": "p1", "label": "Widget" }, { "value": "p2", "label": "Gadget" } ] }, { "name": "column2", "label": "Qty", "type": "number", "min": 1, "step": 1, "defaultValue": 1, "valueStorageType": "number" }, { "name": "column3", "label": "Unit price", "type": "number", "step": 0.01, "valueStorageType": "number", "visualFormatEnabled": true, "visualFormatMinFractionDigits": 2, "visualFormatMaxFractionDigits": 2 }, { "name": "column4", "label": "Line total", "type": "number", "readOnly": true, "valueStorageType": "number", "expression": "{row.column2} * {row.column3}", "logicExecutionMode": "onChange", "useTotals": true, "totalUseGrouping": true, "totalFractionDigits": 2, "totalLocale": "lt-LT" }, { "name": "column5", "label": "Cancelled", "type": "singleCheckbox", "checkboxLabel": "Cancelled", "defaultValue": false } ] }, "el2": { "name": "el2", "label": "Invoice total", "type": "number", "readOnly": true, "expression": "{el1.column4-total}", "logicExecutionMode": "onChange" } } } ``` The rules that make this work: - **Inside a row, other cells of the same row are `{row.columnX}`.** A bare `{column3}` reads a top-level form field with that name, not the cell. - `useTotals: true` publishes a footer sum. That sum is readable elsewhere as `{el1.column4-total}`: the table name, a dot, the column name, `-total`. `totalToData: true` publishes the sum without drawing the footer row. - `hideRowIf` is evaluated per row, also against `{row.*}`. - Column entries are element definitions, so any leaf element type and its own properties are valid there (`select` with `options`, `datepicker` with `format`, `text` with `maskType`, and so on). Value shape: an array of objects, one per row, keyed by column `name`. --- ## 8. `dynamicPanel`: repeating groups, with nesting `dynamicPanel` repeats a whole layout block. Its children live in the layout tree exactly like a `panel`'s, in `column.rows`. Each rendered entry gets its own copy. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "1000px", "widthUnit": "px" }, "pages": [ { "name": "page1", "rows": [ { "columns": [ { "elementRef": "el1", "rows": [ { "columns": [{ "elementRef": "el2" }, { "elementRef": "el3" }] }, { "columns": [{ "elementRef": "el4" }] }, { "columns": [{ "elementRef": "el5" }] } ] } ] }, { "columns": [{ "elementRef": "el6" }] }, { "columns": [{ "elementRef": "el7" }] } ] } ], "elements": { "page1": { "name": "page1", "label": "Projects", "type": "page" }, "el1": { "name": "el1", "label": "Projects", "type": "dynamicPanel", "addRowButtonText": "Add project", "removeRowButtonText": "Remove project", "emptyMessage": "No projects yet", "maxRows": 10, "confirmRowDeletion": true, "confirmDeleteTitle": "Remove project", "confirmDeleteMessage": "Remove this project and all of its tasks?", "panelPadding": "16px", "panelRadius": "8px", "panelBorderWidth": "1px", "contentGap": "12px", "resetChildrenOnHide": true }, "el2": { "name": "el2", "label": "Project name", "type": "text", "required": true }, "el3": { "name": "el3", "label": "Status", "type": "select", "strictOptions": true, "options": [ { "value": "open", "label": "Open" }, { "value": "done", "label": "Done" } ] }, "el4": { "name": "el4", "label": "Tasks", "type": "dynamicTable", "addRowButtonText": "Add task", "columns": [ { "name": "column1", "label": "Task", "type": "text" }, { "name": "column2", "label": "Hours", "type": "number", "step": 0.5, "valueStorageType": "number", "useTotals": true, "totalFractionDigits": 1 }, { "name": "column3", "label": "Rate", "type": "number", "step": 0.01, "valueStorageType": "number" }, { "name": "column4", "label": "Cost", "type": "number", "readOnly": true, "valueStorageType": "number", "expression": "{row.column2} * {row.column3}", "logicExecutionMode": "onChange", "useTotals": true, "totalFractionDigits": 2 } ] }, "el5": { "name": "el5", "label": "Project cost", "type": "number", "readOnly": true, "expression": "{panel.el4.column4-total}", "logicExecutionMode": "onChange" }, "el6": { "name": "el6", "label": "Total cost of all projects", "type": "number", "readOnly": true, "expression": "sumArray({el1}, el5)", "logicExecutionMode": "onChange" }, "el7": { "name": "el7", "label": "Open projects", "type": "number", "readOnly": true, "expression": "countInArray({el1}, el3 == \"open\")", "logicExecutionMode": "onChange" } }, "localization": { "defaultLanguage": "en", "languages": ["en"] } } ``` Context rules for nested structures, in one place: | Where you are | How you reach a value | | --- | --- | | Top level of the form | `{el1}` | | Inside a `dynamicTable` row | `{row.column2}` for a sibling cell | | Inside a `dynamicPanel` entry | `{panel.el3}` for a sibling field of the same entry | | A `dynamicTable` column total | `{el1.column4-total}` at top level | | A total inside a panel entry | `{panel.el4.column4-total}` | | A specific panel entry from outside | `{el1[0].el4.column4-total}` | | Across all entries of a panel | `sumArray({el1}, el5)`, `countInArray({el1}, el3 == "open")` | | A runtime variable | `{__variables.tenantId}` | | A table's own live state | `{__table.el2.selectedRows}`, `{__table.el2.totalRecords}` (see 6.5) | | Host-supplied external context | `{__external.userName}` | Inside collection functions the entry fields are written **bare and unquoted** (`el3 == "open"`), while anything outside the collection still uses braces (`role == {__variables.requiredRole}`). Prefer one aggregate over repeated accumulation. `setValue({var1}, sumArray({el1}, el5[].column3))` is safe on every recalculation; `sumValue` adds again on every run and belongs in an action, never in an `expression`. --- ## 9. Data sources and runtime variables ### 9.1 Every source type `dataSources[*]` is `{ name, title, type, params }`, where `params` differs per `type`. `type` is `rest`, `local`, `route` or `websocket`. ```json { "dataSources": [ { "name": "ds1", "title": "Customers (GET)", "type": "rest", "params": { "url": "/api/customers", "method": "GET" } }, { "name": "ds2", "title": "Save customer (POST)", "type": "rest", "params": { "url": "/api/customers/{id}", "method": "POST", "body": { "name": "{el1}", "segment": "{el2}", "tenantId": "{__variables.tenantId}" } } }, { "name": "ds3", "title": "Server-side table", "type": "rest", "params": { "url": "/api/customers/search", "method": "TABLE-POST" } }, { "name": "ds4", "title": "Static options", "type": "local", "params": { "localMode": "json", "dataJson": "[{\"id\":\"LT\",\"name\":\"Lithuania\"},{\"id\":\"LV\",\"name\":\"Latvia\"}]" } }, { "name": "ds5", "title": "Options already in the form data", "type": "local", "params": { "localMode": "dataPath", "dataPath": "el9.items" } }, { "name": "ds6", "title": "Resolved route data", "type": "route", "params": { "routeDataKey": "customer", "routeDataPath": "data" } }, { "name": "ds7", "title": "Live feed", "type": "websocket", "params": { "url": "wss://example.test/feed", "protocols": "", "message": "{\"subscribe\":\"orders\"}", "messagePath": "payload", "messageMode": "pushValuesInArray" } } ] } ``` REST URL and body templates use **single** braces: `{el1}`, `{row.id}`, `{__variables.tenantId}`. Method is one of `GET` `POST` `PUT` `PATCH` `DELETE`, plus the special `TABLE-POST` for lazy tables. ### 9.2 Element-level `dataSource` and cascading selects The element-level `dataSource` object is `IElementDataSource`: `name`, `useFor`, `optionValue`, `optionLabel`, `filterOptionsBy`, `params`, `refreshOnChange`, `refreshPaths`. Nothing else. ```json { "elements": { "el1": { "name": "el1", "label": "Country", "type": "select", "showSearch": true, "strictOptions": true, "dataSource": { "name": "ds1", "useFor": "option", "optionValue": "id", "optionLabel": "name" } }, "el2": { "name": "el2", "label": "City", "type": "select", "strictOptions": true, "disableIf": "isEmpty({el1})", "resetIf": "isEmpty({el1})", "logicExecutionMode": "onChange", "dataSource": { "name": "ds2", "useFor": "option", "optionValue": "id", "optionLabel": "name", "params": [{ "name": "country", "value": "{el1}" }], "refreshOnChange": true, "refreshPaths": ["el1"] } }, "el3": { "name": "el3", "label": "Warehouse", "type": "select", "strictOptions": true, "dataSource": { "name": "ds3", "useFor": "option", "optionValue": "id", "optionLabel": "title", "filterOptionsBy": "item.cityId == {el2} && item.active == true", "refreshPaths": ["el2"] } }, "el4": { "name": "el4", "label": "Loaded name", "type": "text", "expression": "{__variables.recordData}.name", "logicExecutionMode": "onChange" } }, "dataSources": [ { "name": "ds1", "title": "Countries", "type": "rest", "params": { "url": "/api/countries", "method": "GET" } }, { "name": "ds2", "title": "Cities", "type": "rest", "params": { "url": "/api/countries/{country}/cities", "method": "GET" } }, { "name": "ds3", "title": "Warehouses", "type": "rest", "params": { "url": "/api/warehouses", "method": "GET" } }, { "name": "ds4", "title": "Record", "type": "rest", "params": { "url": "/api/records/{__variables.recordId}", "method": "GET" } } ] } ``` - `useFor: "option"` feeds the option list; `useFor: "value"` feeds the element's value. - `params[*].value` resolves form fields, `{row.*}` and `{__variables.*}`; the resolved values also fill `{placeholder}` tokens in the source URL. - `filterOptionsBy` filters the fetched list client-side. The candidate option is `item.*`; form fields stay in braces. ### 9.3 Runtime variables ```json { "settings": { "language": "en", "variables": [ { "name": "recordId", "title": "Record id", "sourceType": "route", "source": "id" }, { "name": "tenantId", "title": "Tenant", "sourceType": "external", "source": "tenantId", "fallbackValue": "" }, { "name": "apiBase", "title": "API base", "sourceType": "constant", "constantValue": "/api/v2" }, { "name": "isEdit", "title": "Edit mode", "sourceType": "expression", "expression": "notEmpty({__variables.recordId})", "refreshPaths": [] }, { "name": "settingsData", "title": "Settings", "sourceType": "dataSource", "source": "ds1", "fallbackValue": {}, "includeInDataJson": false }, { "name": "draftName", "title": "Draft name", "sourceType": "manual", "targetPath": "el1", "includeInDataJson": true } ] } } ``` `sourceType` is exactly one of `route` `external` `constant` `expression` `dataSource` `manual`. Read a variable anywhere as `{__variables.name}`, never as `{name}`. A `dataSource` variable needs an explicit `source`. --- ## 10. Expression cookbook Every logic field is a **direct property of the element**. There is no `logic` wrapper. All of them take JEXL. ```json { "elements": { "el1": { "name": "el1", "label": "Price", "type": "number", "valueStorageType": "number" }, "el2": { "name": "el2", "label": "Quantity", "type": "number", "valueStorageType": "number" }, "el3": { "name": "el3", "label": "Total", "type": "number", "readOnly": true, "expression": "{el1} * {el2}", "logicExecutionMode": "onChange", "dependsOn": ["el1", "el2"] }, "el4": { "name": "el4", "label": "Customer type", "type": "radio", "showInline": true, "defaultValue": "person", "options": [ { "value": "person", "label": "Person" }, { "value": "company", "label": "Company" } ] }, "el5": { "name": "el5", "label": "Company code", "type": "text", "visibleIf": "{el4} == 'company'", "requireIf": "{el4} == 'company'", "resetIf": "{el4} != 'company'", "logicExecutionMode": "onChange" }, "el6": { "name": "el6", "label": "Discount %", "type": "number", "disableIf": "{el3} < 100", "readonlyIf": "{__variables.isEdit} != true", "logicExecutionMode": "onChange", "validators": [ { "type": "min", "value": 0, "message": "Cannot be negative" }, { "type": "max", "value": 50, "message": "50% is the maximum", "applyIf": "{el4} == 'person'" }, { "type": "custom", "condition": "{el6} > 0 && isEmpty({el7})", "message": "Give a reason for the discount" } ] }, "el7": { "name": "el7", "label": "Discount reason", "type": "textarea", "rows": 2 }, "el8": { "name": "el8", "label": "Starts", "type": "datepicker", "format": "YYYY-MM-DD" }, "el9": { "name": "el9", "label": "Ends", "type": "datepicker", "format": "YYYY-MM-DD", "minValue": "{el8}", "validators": [ { "type": "custom", "condition": "notEmpty({el8}) && notEmpty({el9}) && dateDiffDays({el8}, {el9}) < 1", "message": "End date must be after the start date" } ] }, "el10": { "name": "el10", "label": "Summary", "type": "text", "readOnly": true, "expression": "getLabel({el4}) + ': ' + {el3} + ' EUR'", "logicExecutionMode": "onChange" }, "el11": { "name": "el11", "label": "Tags", "type": "multiSelect", "options": [ { "value": "a", "label": "A" }, { "value": "b", "label": "B" } ] }, "el12": { "name": "el12", "label": "Has tag A", "type": "singleCheckbox", "checkboxLabel": "Tagged A", "readOnly": true, "expression": "contains({el11}, 'a')", "logicExecutionMode": "onChange" } } } ``` Operators JEXL supports: `&& || ! == != > >= < <= + - * / // % ^ in`, the ternary `a ? b : c`, and the Elvis `a ?: b`. It does **not** support `??`, `?.`, arrow functions or any other JavaScript-only syntax. Built-in functions: `isEmpty` `notEmpty` `toNumber` `inRange` `len` `contains` `containsAny` `containsAll` `inArray` `collectValuesFrom` `sumInArray` `avgInArray` `minInArray` `maxInArray` `countInArray` `firstInArray` `lastInArray` `joinInArray` `filterArray` `mapArray` `findInArray` `existsInArray` `sumArray` `avgArray` `getFirst` `getLast` `flattenArray` `today` `now` `date` `day` `month` `year` `weekDay` `weekDayIndex` `isWeekend` `addDays` `dateDiffDays` `startOfWeek` `endOfWeek` `getVal` `getValue` `getLabel` `getElementProperty` `getProp` `translate` `currentLanguage` `setValue` `sumValue` `pushValue` `setVar` `sumVar` `pushVar` `setElementProperty` `runDataSource` `dbg`. Validator objects accept exactly `type`, `value`, `message`, `condition`, `applyIf`. `condition` is the **failing** check: the error shows while it is `true`. --- ## 11. Actions and events `events` is an array of `IElementActionConfig`. Same shape for `button.events`, `table.rowActions`, `table.headerActions`, `table.selectionActions`, cell actions and dialog footer actions. ```json { "elements": { "el1": { "name": "el1", "label": "", "type": "button", "text": "Save", "variant": "solid", "tone": "primary", "icon": "save", "iconPosition": "left", "size": "normal", "fitContent": true, "events": [ { "trigger": "click", "type": "dataSource", "dataSourceName": "ds1", "validateForm": true, "confirmEnabled": true, "confirmTitle": "Save changes", "confirmMessage": "Write these changes to the server?", "confirmConfirmLabel": "Save", "confirmCancelLabel": "Cancel", "params": [ { "name": "id", "value": "{__variables.recordId}" }, { "name": "name", "value": "{el3}" } ], "responseMode": "setValue", "responseDataPath": "data.id", "responseTargetPath": "el4", "reloadOnReturnElementNames": ["el5"], "showToastAfter": true, "toastTitle": "Saved", "toastMessage": "The record was stored", "toastVariant": "success", "toastPosition": "bottom-right", "toastAutoHide": true, "toastAutoHideMs": 3000 } ], "menuActions": [ { "label": "Save and close", "type": "submit", "validateForm": true }, { "label": "Discard", "type": "navigate", "navigateTo": "/records" } ] }, "el2": { "name": "el2", "label": "", "type": "button", "text": "Download report", "variant": "outline", "tone": "neutral", "fitContent": true, "events": [ { "trigger": "click", "type": "dataSource", "dataSourceName": "ds2", "responseMode": "download", "responseFileName": "report.pdf" } ] }, "el3": { "name": "el3", "label": "Name", "type": "text" }, "el4": { "name": "el4", "label": "Saved id", "type": "text", "readOnly": true }, "el5": { "name": "el5", "label": "History", "type": "table", "columnsConfig": [] }, "el6": { "name": "el6", "label": "Segment", "type": "select", "options": [ { "value": "a", "label": "A" }, { "value": "b", "label": "B" } ], "events": [ { "trigger": "change", "type": "setOptions", "setOptionsTargetElement": "el7", "setOptionsMode": "contextPath", "setOptionsValue": "lookup.subSegments" }, { "trigger": "change", "type": "reloadElements", "reloadElementNames": ["el5"], "condition": "notEmpty({el6})" } ] }, "el7": { "name": "el7", "label": "Sub-segment", "type": "select", "options": [] } } } ``` Action fields by `type`: | `type` | Required / typical fields | | --- | --- | | `navigate` | `navigateTo`, `openInNewTab`, `beforeNavigateDataSourceName` | | `dataSource` | `dataSourceName`, `params`, `responseMode` (`none` `download` `setValue`), `responseDataPath`, `responseTargetElement`, `responseTargetPath`, `responseFileName`, `reloadCurrentElementAfterSuccess`, `reloadOnReturnElementNames`, `reloadDataSourceNames` | | `setValue` | `setValueTargetPath`, `setValueMode` (`contextPath` `template` `json` `expression`), `setValueValue` | | `setOptions` | `setOptionsTargetElement`, `setOptionsMode`, `setOptionsValue` | | `reloadElements` | `reloadElementNames` | | `toast` | `toastTitle`, `toastMessage`, `toastVariant`, `toastPosition`, `toastAutoHide`, `toastAutoHideMs`, `toastShowIcon`, `toastIcon` | | `dialog` | `dialogName`, `dialogOperation` (`open` `close` `toggle`) | | `sendMessage` | `dataSourceName` (a `websocket` source), `messagePayload` | | `validate` | none; validates the whole form | | `submit` | none; validates, then fires `onComplete` with `{ isValid, data, issues }` | Common to all: `trigger` (`click` `submit` `input` `change` `blur` `focus` `beforeLoad` `onLoad` `afterLoad` `always`), `label`, `icon`, `hideText`, `condition`, `validateForm`, `debounceMs`, `confirmEnabled`, `confirmOnDirty`, `confirmTitle`, `confirmMessage`, `confirmConfirmLabel`, `confirmCancelLabel`, `buttonVariant` (`filled` `outlined` `text`), `buttonTone` (`primary` `risk` `neutral`), `useCustomButtonStyle`, `showToastAfter` and the `toast*` fields. Note the two different button vocabularies: a `button` **element** uses `variant` (`solid` `outline` `text`) and `tone` (`primary` `neutral` `success` `info` `warning` `risk`); an **action** rendered as a button uses `buttonVariant` (`filled` `outlined` `text`) and `buttonTone` (`primary` `risk` `neutral`). --- ## 12. Multi-page stepper Several entries in `pages` become steps. Each still needs its twin in `elements`. ```json { "schemaVersion": 1, "settings": { "language": "en", "locale": "en-US", "width": "760px", "widthUnit": "px", "pageNavigationMode": "stepper", "stepperPosition": "top", "pageNavigationPosition": "bottom", "allowStepWithoutValidation": false, "actionButtonsPosition": "bottom", "showSubmitButton": true, "showValidateButton": false, "showValidationIssuesModal": true }, "pages": [ { "name": "page1", "rows": [{ "columns": [{ "elementRef": "el1" }, { "elementRef": "el2" }] }] }, { "name": "page2", "rows": [{ "columns": [{ "elementRef": "el3" }] }] }, { "name": "page3", "rows": [{ "columns": [{ "elementRef": "el4" }] }] } ], "elements": { "page1": { "name": "page1", "label": "Applicant", "type": "page" }, "page2": { "name": "page2", "label": "Address", "type": "page" }, "page3": { "name": "page3", "label": "Review", "type": "page", "visibleIf": "notEmpty({el1}) && notEmpty({el2})" }, "el1": { "name": "el1", "label": "First name", "type": "text", "required": true }, "el2": { "name": "el2", "label": "Last name", "type": "text", "required": true }, "el3": { "name": "el3", "label": "Street", "type": "text", "required": true }, "el4": { "name": "el4", "label": "", "type": "customHtml", "htmlTemplate": "

Submitting as {el1} {el2}, {el3}

" } }, "localization": { "defaultLanguage": "en", "languages": ["en", "de"], "texts": { "en": { "submitButtonText": "Submit" }, "de": { "submitButtonText": "Absenden" } } } } ``` --- ## 13. Pre-return checklist for any of these 1. Every `elementRef` resolves; every `elements` key equals its `name`. 2. Every `pages[*].name` has an `elements` twin with `type: "page"`. 3. No element definition holds children (`rows`, `columns`, `children`, `items`); the exception is `dynamicTable.columns` and `table.columnsConfig`, which are cell definitions, not layout. 4. No column object holds anything besides `elementRef`, `rows`, `tabRows`. 5. `table` columns use `key`. `dynamicTable` columns use `name`. 6. `statusRules[*].condition` uses `value`; `rowActions[*].condition` uses `row.*`. 7. Action and element-datasource `params` are `[{ "name", "value" }]`; table request params are `[{ "paramName", "paramValue" }]`. 8. Runtime variables are read as `{__variables.x}`. 9. Logic fields sit directly on the element; validators use `condition` / `applyIf`. 10. Every type string matches the canonical list exactly, casing included. --- ## AI: Legacy form migration Source: https://ngxviewbuilder.io/ai/legacy-form-migration # AI: Legacy form migration This page is intended for the AI agent when a user asks to convert a form defined in a legacy form-builder JSON format to NGX View Builder JSON. ## Core rule - Do not copy the source JSON 1:1. - First recognize the semantics of the source form. - Then map them to the closest supported NGX View Builder structure. ## Element map | Source JSON | NGX View Builder | | --- | --- | | `text` | `text` or `number` / `phoneInput` depending on `inputType` | | `comment` | `textarea` | | `radiogroup` | `radio` | | `dropdown` | `select` | | `dropdown_list` | `autocomplete` | | `tagbox` | `multiSelect` | | `checkbox` | `checkbox` or `singleCheckbox` | | `boolean` | `singleCheckbox` | | `date` / `date_picker` | `datepicker` | | `file_uploader` / `file_list` | `fileUpload` | | `html` | `customHtml` | | `message_box` | `messageCard` | | `panel` | `panel` | | `paneldynamic` | `dynamicPanel` | | `matrixdynamic` / `matrixdropdown` | `dynamicTable` | | `dialog` | `dialog` | ## Property map - `visibleIf` maps directly to `visibleIf`: same field name, same polarity, no inversion needed. - `enableIf` is typically mapped to `disableIf`, but the logic must be inverted. - `requiredIf` maps to `requireIf`. - `resetValueIf` maps to `resetIf`. - `setValueExpression` typically maps to `expression`. - `defaultValue` stays as `defaultValue` if it is a static value. - `defaultValueExpression` can become `defaultValue` if it is a literal, or `expression` if it is a computed value. - `choices` maps to `options`. - `validators` maps to `validators`. ## Expression translation rules In the final NGX View Builder JSON, use NGX View Builder / `JEXL`-style syntax. ### Operators - `and` -> `&&` - `or` -> `||` - `<>` -> `!=` - `=` in comparisons -> `==` - `iif(cond, a, b)` -> `cond ? a : b` ### Empty values - `x notempty` -> `notEmpty(x)` - `x empty` -> `isEmpty(x)` ### Collections - `anyof` -> `containsAny(left, values)` - `allof` -> `containsAll(left, values)` - `contains` -> `contains(left, value)` - `{field.length}` -> `len({field})` ### Dynamic contexts - `{panelIndex}` -> `panel.index` - `{rowIndex}` -> `row.index` - `{parentIndex}` -> `parentIndex` - `{panel.field}` -> `panel.field` - `{row.field}` -> `row.field` ## Important safeguards - Do not use source-library property names in the final NGX View Builder JSON if NGX View Builder does not have that property. - Do not use `and`, `or`, `notempty`, `empty`, `<>`, or `iif(...)` in final NGX View Builder expression strings. - Do not use self-reference expressions. A field must not reference itself in a fallback branch. - If `expression`, `visibleIf`, `disableIf`, `requireIf`, `readonlyIf`, or `resetIf` is added, `logicExecutionMode: "onChange"` is typically required. ## When a 1:1 mapping is not possible - If a source-library feature has no direct NGX View Builder equivalent, produce the closest safe result. - In that case, briefly explain in a `warnings` section what was simplified or lost. --- ## Angular form & view builder for developers Source: https://ngxviewbuilder.io/developers/ # Introduction for developers NGX View Builder is an Angular library with two halves: - **Builder**: the visual editor your creators use to design views. - **Runtime**: the engine that renders a saved view definition (JSON) to end users. You embed one or both as standalone components, persist the JSON wherever you like, and control everything else (data, theming, custom elements, plugins) through providers and a typed API service. ## The five components | Component | Selector | Purpose | | --- | --- | --- | | Builder | `` | Full editing shell: canvas, sidebars, tabs, history | | Runtime | `` | Renders a view definition with live logic and data | | Unified | `` | Runtime-oriented host that can also take a `BuilderModel` | | Renderer | `` | Low-level renderer used by the unified wrapper | | Validator | `` | Headless validation for server-side scenarios | ## The flow ``` Creator designs in │ (structureChanged) → IStructure JSON ▼ Your backend / storage │ [pageJson] ▼ renders to end users │ values, events, validation ▼ Your app (via outputs and NgxViewBuilderApiService) ``` ## What you can extend | Extension | How | | --- | --- | | Custom elements (component + model + properties) | [Custom elements](https://ngxviewbuilder.io/developers/custom-elements) | | Expression functions | [Custom functions](https://ngxviewbuilder.io/developers/custom-functions) | | Extra/changed element properties | [Custom properties](https://ngxviewbuilder.io/developers/custom-properties) | | SVG icons | [Icons](https://ngxviewbuilder.io/developers/icons) | | Theme tokens, light/dark palettes | [Theming](https://ngxviewbuilder.io/developers/theming) | | Builder UI language | [UI translations](https://ngxviewbuilder.io/developers/ui-translations) | | Whole builder tabs | [Plugins](https://ngxviewbuilder.io/developers/plugin-development) | ## Reading order 1. [Installation](https://ngxviewbuilder.io/developers/installation): package, providers, initialization. 2. [Embedding the builder](https://ngxviewbuilder.io/developers/builder-integration) and [Rendering views](https://ngxviewbuilder.io/developers/runtime-integration). 3. [API service overview](https://ngxviewbuilder.io/developers/api-service): the programmatic surface. 4. [Extensions overview](https://ngxviewbuilder.io/developers/extensions): one config object for everything custom. --- ## AI command API Source: https://ngxviewbuilder.io/developers/ai-command-api # AI command API The AI command API is a design time surface that lets an agent inspect and edit the view a user is currently designing. Everything crosses the boundary as plain JSON, so nothing Angular shaped leaks out and the whole contract survives a trip through a websocket. An agent reaches it over MCP. The browser dials out to the MCP server and the server forwards each method call back down that socket, so the user's machine never has to accept an inbound connection. It exposes one write door and a handful of read methods, one MCP tool each: | Method | MCP tool | What it does | | --- | --- | --- | | `available()` | part of `nvb_status` | false whenever the builder is not on screen | | `help()` | `nvb_get_instructions` | the full command catalog, with schemas | | `getSystemInstructions()` | `nvb_get_instructions` | the contract as one prompt ready block | | `describeElementTypes(type?)` | `nvb_describe_element_types` | element types and their properties | | `describeTemplates(name?)` | `nvb_describe_templates` | the template library, and what can host a template | | `getTree()` | `nvb_get_tree` | compact outline of pages and elements | | `getStructure()` | `nvb_get_structure` | the whole view JSON | | `getElement(name)` | `nvb_get_element` | one element body | | `getElementProperty(name, key)` | `nvb_get_element_property` | one property value | | `getData()` | `nvb_get_data` | current working data | | `getAuditLog()` | `nvb_get_audit_log` | every batch applied this session | | `execute(commands, options?)` | `nvb_execute` | the only way to change anything | ## When it exists The bridge connects only while `` is mounted, and only when the host opted in. A runtime only host never opens one, no matter who calls what. Leaving the builder closes it again. ```ts import { bootstrapApplication } from '@angular/platform-browser'; import { provideNgxViewBuilderRuntime, provideNgxViewBuilderMcp } from 'ngx-view-builder'; bootstrapApplication(AppComponent, { providers: [ provideNgxViewBuilderRuntime(), provideNgxViewBuilderMcp({ url: 'wss://mcp.ngx-view-builder.io/bridge' }), ], }); ``` | Option | Description | | --- | --- | | `enabled` | Default `true`. Set `false` to keep the provider registered but leave the API closed. | | `url` | Bridge endpoint of the MCP server. An `http(s)` URL is upgraded to `ws(s)`. | | `autoConnect` | Default `true`. Set `false` to connect by hand through `NgxViewBuilderMcpBridgeService`. | | `client` | App and page labels, shown in `nvb_status` so a user with several tabs open can tell which one an agent is attached to. | | `sessionKey` | A key your own backend issued, string or resolver. Supply it and the pair code disappears: your backend already knows the key, so it can hand the same one to whatever AI client it wires up. | | `auth` | Token your MCP server's authorization service understands. Forwarded untouched, never interpreted. | | `metadata` | Anything your authorization service should see: tenant, user, plan hint. Echoed back in `nvb_status`. | Without a `sessionKey`, the builder shows a pair code in its settings, under AI access (MCP), and an agent reaches the view only after a person has typed that code into their MCP client. With one, your backend has already decided who may connect, so no code is shown and nobody types anything. Either way the session lasts only as long as that tab stays open, and the panel says so once a client is attached. The MCP server itself holds no user table. It asks whatever authorization service you configure and does what it is told, which is what lets you run it next to your own product and drive it from your own backend. See its README for the auth webhook contract. ## The two guarantees **It runs only while the builder is on screen.** Three things must all hold: the host registered the provider, the builder component is mounted, and a person paired a code. Leaving the builder disarms the API and drops the socket. Disarming is what matters: closing the socket alone would leave the surface open to anything that had captured it. A disarmed API refuses every command with `apiClosed` and every read returns `null` or empty, so a captured reference is worth nothing on a runtime page. **It cannot save.** There is no save, publish or submit command, and none of the mutation commands reach the host's save path. `saveTemplate` and `saveSidebarGroup` write reusable builder library items, not the view. Automation an agent writes cannot commit the view either. Triggers and rules run a deliberately narrower action set than element events: `navigate`, `dataSource`, `toast`, `dialog`, `setValue`, `setElementProperty`, `transitionProcess`, `setProcessState` and `refreshRuntimeVariables`. There is no submit and no save among them, so an agent cannot author a rule that saves on load. An element `events` entry may use `submit`, but that still needs a person to click the element. ::: warning This is a design time tool The worst a runaway agent can do is leave unsaved edits in an open tab, which a reload undoes. Keep it out of production builds, and let a person press Save. A trigger bound to `onLoad` does run by itself when the view is rebuilt, so an agent can cause a data source call or a navigation without a click. That is a side effect, not a commit, but it is worth knowing when reviewing what an agent wrote. ::: ## Try it on the public demo The bridge is armed on [demo.ngxviewbuilder.io/builder](https://demo.ngxviewbuilder.io/builder). Open the builder settings, copy the pair code from AI access (MCP), point your MCP client at `https://mcp.ngx-view-builder.io/mcp?code=NVB-XXXX-XXXX`, and watch it build. Nothing there can be saved to anything of yours: the demo keeps its view in your own browser storage, and the API has no save command in the first place. Reloading the page restores the demo view. ## Start with getSystemInstructions() `getSystemInstructions()` returns the whole contract as one block of text: what the API is, the working order, the rules worth following, which capabilities this builder has, and which templates are on hand. It is meant to go straight into a system prompt. It exists because of a failure that has nothing to do with the schema. An agent told to "build this form through the AI API" often answers by describing the JSON it would send, or hands it over for someone to paste, because nothing in its context said the API is live and reachable right now. The instructions say that in the first line. Call `nvb_get_instructions` first in a session. It returns both the prompt ready text and the machine readable `help()` object in one round trip. `help()` is the machine readable version of the same thing: the command catalog with parameters and a worked example per command, the tab codes, behaviour notes, and three fields worth reading on their own. | Field | What it carries | | --- | --- | | `notes` | How to call the API: batching, dry runs, idempotent names. | | `guidelines` | How to decide what to build. Kept apart from `notes` so a host can put these in a prompt without the call mechanics. | | `capabilities` | What the registered feature packs contribute, such as `templates`. | | `featurePacks` | The packs themselves, by id and title. | `describeElementTypes()` is the other one to call before writing anything. It returns every registered type, whether it can hold children, and the real property keys for each. Without it a model guesses property names and every command comes back with an error. ```json // nvb_get_instructions -> result.help { "commands": [ /* 47 entries */ ], "capabilities": ["templates"] } // nvb_describe_element_types { "type": "select" } { "properties": ["options", "showSearch", "required", "..."] } ``` ## Build from element types first The guidelines exist because an agent with a blank canvas reaches for markup far too early. Markup is the expensive answer: a `customHtml` element or a hand written template carries no options, no validation and no events, so everything built that way has to be rebuilt by hand later. The case that comes up most is a status column. A table column that shows a badge, a status, a toggle or a checkbox is a hosted element, not markup: ```js await api.execute({ op: 'updateElement', name: 'ordersTable', properties: { columnsConfig: [ { key: 'status', label: 'Status', type: 'element', elementType: 'badge' }, ], }, }); ``` The command layer reports both of these as warnings rather than errors, since an agent that was explicitly asked for custom markup is right to carry on. Read the warnings anyway: - adding a `customHtml` element - a column that sets `templateName` while its `type` is not `element` ## execute() `execute()` takes one command or an array. An array is atomic: commands are folded into a draft copy of the structure, and if any of them fails, none of them are applied. Over MCP this is `nvb_execute`, with the array under `commands` and the options as flat arguments (`dry_run`, `return_tree`, `return_structure`). The snippets on this page show the command shapes, which are the same either way. ```js const result = await api.execute([ { op: 'addElement', type: 'panel', name: 'contact', properties: { label: 'Contact' } }, { op: 'addElement', type: 'text', name: 'email', parent: 'contact', properties: { label: 'Email', required: true } }, ]); ``` | Option | Description | | --- | --- | | `dryRun` | Runs every check and reports what would change, then throws the draft away. | | `returnStructure` | Includes the resulting view JSON in the result. | | `returnTree` | Includes the resulting outline in the result. | Commands come in two kinds. **Mutations** change the view and are applied to the draft in order. **Actions** do something to the running builder instead, and they run after the draft is committed, in the order they appear. `help()` labels each command with its `kind`. ### The result Errors are data, never exceptions, because a thrown error does not survive a trip through a bridge. ```json { "ok": false, "version": 1, "dryRun": false, "applied": 0, "changed": [], "errors": [ { "index": 1, "op": "addElement", "code": "unknownType", "message": "Unknown element type 'txt'.", "hint": "Did you mean 'text'? Call describeElementTypes() for the full list." } ], "warnings": [] } ``` The `hint` field matters more than it looks. It is what lets an agent correct itself in one retry instead of looping. Unknown property keys are reported as warnings rather than errors, because an element body is an open record and custom fields are legitimate. A misspelled property still lands, so read the warnings. ## Layout: rows and columns This is the part worth understanding, because it decides whether fields stack or sit side by side. A view is a list of rows, and each row holds one or more columns. By default a new element gets a row of its own, so elements stack. Naming an existing `row` in the target puts the element into that row instead, next to whatever is already there. ```js await api.execute([ { op: 'addElement', type: 'text', name: 'firstName', parent: 'contact', properties: { label: 'First name' } }, // same row, so the two sit side by side { op: 'addElement', type: 'text', name: 'lastName', parent: 'contact', row: 0, properties: { label: 'Last name' } }, // explicit position inside that row { op: 'addElement', type: 'text', name: 'title', parent: 'contact', row: 0, column: 0 }, ]); ``` Every placement command shares the same target fields: | Field | Description | | --- | --- | | `parent` | Name of a container element. Leave it out to place at page level. | | `page` | Page name. Defaults to the first page. | | `tab` | Required when the parent is a tab style container such as `tabs` or `accordion`. | | `index` | Position of the new row. | | `row` | Put the element into this existing row instead of creating one. | | `column` | Position inside `row`. Appends when left out. | Containers that hold children directly are `panel`, `dialog`, `splitter`, `dynamicPanel`, `emptyBlock`, `messageCard`, `statsCard` and `listGrid`. Containers that hold children per section, and therefore need a `tab`, are `tabs`, `tabsPro`, `accordion` and `progressFlow`. Targeting anything else returns a `notAContainer` error with the list. Do not set a percentage `width` on fields you want side by side. Columns in a row already share the space, and a fixed width fights that. Use `mobileWidth: '100%'` when you want a pair to stack on narrow screens. ## Templates When the [templates plugin](https://ngxviewbuilder.io/developers/plugin-templates) is registered, the view carries a template library and several element types can render a template instead of their own markup. The API surfaces the library so an agent can reuse what is already there rather than inventing a second version of the same card. ```js api.describeTemplates(); // { // available: true, // capability: 'templates', // hosts: [ // { type: 'listGrid', property: 'cardTemplateName', fieldMap: 'cardTemplateFieldMap' }, // { type: 'customHtml', property: 'htmlTemplateName', fieldMap: 'htmlTemplateFieldMap' }, // ], // templates: [ // { name: 'person card', slots: ['0', '1'], hasCss: true, contentPreview: '
...' }, // ], // } ``` `available` is the gate. It is false when the plugin is not registered, and then the reference properties stay hidden in the builder, so writing one would bind a template nothing renders. Both template commands refuse with `capabilityMissing` in that case rather than writing a property that goes nowhere. `hosts` is read from the property catalog rather than hard coded, so an element type a host registers with its own template property shows up there too. Two commands go with it: | Command | What it does | | --- | --- | | `upsertTemplate` | Creates or replaces a template by name. It lands in the draft, so a `useTemplate` later in the same batch can already reference it. | | `useTemplate` | Points an element at a template and maps its slots. | `useTemplate` resolves the reference property itself. A `listGrid` keeps it in `cardTemplateName` and a `customHtml` in `htmlTemplateName`, and expecting a caller to know that mapping is how bindings end up on the wrong key. ```js await api.execute([ { op: 'upsertTemplate', name: 'person card', content: '
{{row[0]}}{{row[1]}}
', css: '.person { display: grid; gap: 4px; }', }, { op: 'addElement', type: 'listGrid', name: 'peopleGrid', properties: { label: 'People' } }, { op: 'useTemplate', name: 'peopleGrid', template: 'person card', fieldMap: { '0': 'fullName', '1': 'email' }, }, ]); ``` The order matters and the batch is atomic, so either the template and its binding both land or neither does. A few details worth knowing: - Slots are positional. Markup addresses its fields as `row[0]`, `item[1]` and so on, and the field map binds each slot to a data path. Do not bake values into the markup. - Ask for a template by name before creating one. If `describeTemplates('person card')` returns it, reuse it. - `column` binds one table column instead of the element itself, matched by `key`, and it comes with the warning above about hosted elements being the better answer. - Passing an empty `template` clears the binding and its field map. - Templates live in the view JSON, so saving the view persists them. Committing a batch also fires the template saved event, which is what a host mirroring the library into its own storage listens for. ## Mutation commands | Command | What it does | | --- | --- | | `addElement` | Creates an element and places it. Pass `name` to make the command idempotent. | | `upsertTemplate` | Creates or replaces a library template. Needs the `templates` capability. | | `useTemplate` | Binds a library template to an element or a table column. Needs the `templates` capability. | | `insertJson` | Inserts a whole subtree: elements keyed by name, which one is the root, and the layout under it. Renames colliding names unless `rename: false`. | | `updateElement` | Patches an element body. `merge: false` replaces it instead. | | `deleteElement` | Removes an element, its layout slot, and everything nested under it. | | `moveElement` | Moves an element with its children to another parent, row or position. | | `renameElement` | Renames an element and rewrites every reference to it, including expressions. | | `duplicateElement` | Copies an element with its subtree. | | `addRow`, `deleteRow` | Adds an empty row, or removes one. A non empty row needs `force: true`. | | `addPage`, `deletePage`, `renamePage`, `updatePage` | Page level operations. A page carries an element entry of the same name, and these keep the two in step. | | `setSettings`, `setHeader` | Patches view settings and the view header. | | `upsertDataSource`, `deleteDataSource` | Data sources by name. | | `upsertVariable`, `deleteVariable` | Runtime variables in `settings.variables`. | | `upsertTrigger`, `deleteTrigger` | Triggers in `settings.triggers`. | | `upsertRule`, `deleteRule` | Rules in `settings.rules`. | | `upsertFragment`, `deleteFragment` | Fragments in `settings.fragments`. | | `setProcess` | The process definition, or `null` to remove it. | | `setLocalization` | Content translations for the view. | | `replaceStructure` | Replaces the whole view JSON. | ## Action commands | Command | What it does | | --- | --- | | `switchTab` | Moves the builder to another tab, preview included. | | `focusElement` | Selects an element so its properties open in the sidebar. | | `setData` | Sets working data. Values that belong to real elements go through the value pipeline, so expressions and conditions re-evaluate. | | `validate` | Validates the current view against data and returns the issues. | | `setLanguage` | Switches the active language and re-applies content translations. | | `setUiTranslations` | Overrides built in control text such as select placeholders, per language. | | `setTheme` | Theme mode, CSS variables, custom CSS, stylesheet urls, custom theme. | | `saveTemplate`, `deleteTemplate` | Templates in the Templates tab. | | `saveSidebarGroup`, `deleteSidebarGroup` | Reusable groups in the builder sidebar library. | | `setTableSettings`, `setTableFilters` | Table column settings and filters. | | `setRuntimeVariableContext` | External values that runtime variables can map from. | | `reloadDataSource` | Re-runs a data source. | | `undo`, `redo` | Steps the builder history. Warns when there is nothing to step to. | ## Two languages in one view The structure itself holds the default language. Every other language lives in `localization.texts`, keyed by structure path. ```js await api.execute([ { op: 'setLocalization', defaultLanguage: 'en', languages: ['en', 'de'], texts: { de: { 'header.label': 'Neue Person hinzufügen', 'elements.firstName.label': 'Vorname', 'elements.gender.options[0].label': 'Männlich', }, }, }, { op: 'setUiTranslations', dictionaries: { de: { 'select.placeholder': 'Auswählen' } }, }, { op: 'setLanguage', language: 'de' }, ]); ``` `setLocalization` covers the text you authored. `setUiTranslations` covers the control text the library ships, such as select placeholders and table labels. ::: warning Supply a full UI dictionary The library ships an English UI dictionary only. Some call sites pass the translation key as their own fallback, which means a language with a partial dictionary can render raw keys instead of falling back to English. Provide the keys you need for any language you switch to. ::: ## Undo and audit Each `execute()` call is bracketed with a history checkpoint, so a batch of twenty commands collapses into a single undo step no matter how many elements it touched. A person can revert an agent's whole change with one Ctrl+Z, and the `undo` and `redo` commands step the same history. `getAuditLog()` returns every `execute()` call this session with its ops, outcome, applied count and the names it changed. The log is capped at 200 entries. ## What is checked, and what is not The command layer checks the structure it can see: element types against the registry, property names against the property catalog, layout targets, name collisions, and cycles. It also checks the inside of the properties whose value is itself structured: | Property | Checked | | --- | --- | | `options` | Must be an array, and every entry needs a `value`. | | `validators` | Every entry needs a known `type`, and `custom` also needs a `condition`. | | `events` | Every entry needs a known action `type`. | | `columns`, `template` | Nested elements are checked for a known type, a name, and valid property names. | Everything else in an element body passes through untouched, because the body is an open record and custom fields are legitimate. Unknown keys are reported as warnings, so read them. ## Known limits `validate` runs on a freshly built copy of the structure and does not apply container visibility to children. A field inside a hidden panel is still reported as required, so do not use validation results to test whether a section is visible. Read the preview instead. The exporter drops empty objects and arrays, so a command that writes `{ steps: [] }` leaves nothing behind. Write real content. A structural change rebuilds the view and clears working data, so set data after your last mutation, not before. --- ## API service reference Source: https://ngxviewbuilder.io/developers/api-service # API service reference `NgxViewBuilderApiService` is a root-provided façade. Inject it anywhere in the host app to read, mutate, and observe the active view. This page lists **every public method**; events are listed separately in the [Events reference](https://ngxviewbuilder.io/developers/events). ```ts import { inject } from '@angular/core'; import { NgxViewBuilderApiService } from 'ngx-view-builder'; private api = inject(NgxViewBuilderApiService); ``` ::: tip Scoped instances When several runtime views render at once (e.g. a view inside a dialog), each gets a scoped child API. Data calls on the root service (`setData`, `getData`, `setValue`, table settings/filters) automatically delegate to the most recently created scoped child, so host code usually just talks to the root instance. ::: ## Structure & settings | Method | Description | | --- | --- | | `getStructure(): IStructure \| null` | Returns the current view definition as plain JSON (the source snapshot if one was captured, otherwise serialized from the live model). | | `getStructureModel(): IStructure \| null` | Returns the **live structure model** (signal-based class instances) instead of plain JSON. | | `setStructure(structure: IStructure): void` | Replaces the whole view: rebuilds the model, syncs the builder, applies runtime-variable definitions, and re-runs init rules. | | `updateStructure(updater: (s: IStructure) => void): boolean` | Mutates the structure in place and notifies/rebuilds. Changes flow into Undo history and `structureChanged`. | | `getSettings(): IStructure['settings'] \| null` | Copy of the view-wide settings object. | | `getSetting(key: string): unknown` | One setting by key; supports nested paths (`'header.title'`). | | `setSetting(key: string, value: unknown): boolean` | Sets one setting (nested paths supported). No-op when the value is already equal. | | `patchSettings(patch: Record): boolean` | Sets several settings at once; keys may be nested paths. | | `replaceSettings(settings): boolean` | Replaces the settings object wholesale. | | `updateSettings(updater: (settings) => void): boolean` | Mutates settings via callback. | | `notifyStructureChanged(): void` | Emits `onStructureChanged` manually (rarely needed, since mutation methods call it for you). | | `captureStructureSource(structure): void` | Stores a structure snapshot used as the mutable source for `updateStructure`/`setSetting`. Called automatically by `setStructure`. | | `clearCapturedStructureSource(): void` | Drops the captured snapshot. | | `markPristine(): void` | Resets the dirty flag (e.g. right after saving). | | `isDirty(): boolean` | Whether form data changed since load / last `markPristine()`. | ```ts api.updateStructure((s) => { s.settings.header = { ...s.settings.header, title: 'Client onboarding' }; }); api.markPristine(); ``` ## Data & values | Method | Description | | --- | --- | | `getData(): Record` | Current form data snapshot. | | `setData(data): void` | Replaces the form data. | | `setElementsData(data): void` | Merges several values into the current data (partial update). | | `setValue(dataPath, value): Promise` | Sets one value by data path (`'addresses[0].city'`), running the same change pipeline as user input (expressions, dependencies, events). | | `setElementValue(elementDataPath, value): Promise` | Alias of `setValue` targeted at an element's data path. | | `getElementValue(elementDataPath): unknown` | Reads a value by element data path. | | `setElementErrors(nameOrPath, errors: string[]): boolean` | Attaches external error messages to an element (e.g. server-side validation). | | `clearElementErrors(nameOrPath): boolean` | Removes external errors from an element. | | `getElementErrors(nameOrPath): string[]` | Current external errors of an element. | ```ts await api.setValue('client.country', 'LT'); api.setElementErrors('email', ['This e-mail is already registered']); ``` ## Element lookup & manipulation All lookup methods accept an element **name, data path, or schema path**. | Method | Description | | --- | --- | | `getElement(nameOrPath): unknown` | Raw element model (untyped). | | `getElementModel(nameOrPath): ElementBaseModel \| null` | Typed element model. | | `getElementByDataPath(dataPath)` / `getElementBySchemaPath(schemaPath)` | Lookup by a specific path kind. | | `getElementLookup(nameOrPath): INgxViewBuilderElementLookupResult \| null` | Full lookup record: name, paths, type, id, testId, model, and DOM node. | | `getRenderedElements(): INgxViewBuilderElementLookupResult[]` | Lookup records for everything currently rendered. | | `getElementName(nameOrPath)` / `getElementDataPath(...)` / `getElementSchemaPath(...)` / `getElementType(...)` / `getElementId(...)` / `getElementTestId(...)` | Convenience accessors for individual lookup fields. | | `getElementProperty(nameOrPath, propertyKey): unknown` | Reads one element property (`'hidden'`, `'label'`, …). | | `setElementProperty(nameOrPath, propertyKey, value): boolean` | Sets one element property and re-renders. | | `setElementProperties(nameOrPath, props: Record): boolean` | Sets several properties at once. | | `updateElement(nameOrPath, updater: (el) => void): boolean` | Mutates the element model via callback. | | `refreshElement(nameOrPath): boolean` | Forces the element to re-render. | ```ts api.setElementProperty('email', 'disabled', true); api.updateElement('summaryPanel', (el: any) => { el.title = 'Order summary'; }); ``` ## Render root & DOM access | Method | Description | | --- | --- | | `registerRenderRoot(root: HTMLElement \| ShadowRoot \| null): void` | Registers the root node views render into (the runtime component does this for you). Also hosts toast styles. | | `getRenderRoot(): HTMLElement \| ShadowRoot \| null` | The registered render root. | | `getElementDom(nameOrPath): HTMLElement \| null` | DOM node of a rendered element. | | `getElementDomById(id)` / `getElementDomByTestId(testId)` | DOM lookup by element id / test id. | | `queryRenderDom(selector): HTMLElement \| null` | `querySelector` scoped to the render root. | | `queryRenderDomAll(selector): HTMLElement[]` | `querySelectorAll` scoped to the render root. | ## Validation, completion & lifecycle | Method | Description | | --- | --- | | `validateData(formJson, dataJson, options?): Promise` | **Headless validation**: builds the structure, applies data, evaluates expressions and validators, and returns `{ isValid, issues[] }`, with no UI needed. See [Headless validation](https://ngxviewbuilder.io/developers/validator). | | `showToast(payload: INgxViewBuilderToastPayload): void` | Shows a runtime toast (`title`, `message`, `variant`, `position`, `autoHideMs`, `icon`…). | | `notifyValidationResult(isValid, issues): void` | Emits `onValidated`/`onValidating` and fires the `validated` platform trigger. Called by the runtime after UI validation. | | `notifyComplete(isValid, data, issues): void` | Emits `onComplete` and the `completed` trigger. Called on Submit. | | `notifySaveRequested(source?): void` | Emits `onSaveRequested` (`source`: `'header' \| 'api'`), a programmatic "Save" click. | | `notifyBeforeRender(tab)` / `notifyRender(tab)` / `notifyAfterRender(tab)` | Emit the render-phase events and matching `beforeLoad`/`onLoad`/`afterLoad` triggers. The builder/runtime call these; hosts rarely need to. | | `notifyTabChanged(tab): void` | Announces that the builder tab changed (emits `onTabChanged`). | | `notifyCurrentPageChanged(pageIndex, pageName): void` | Announces a page switch (emits `onCurrentPageChanged` + `pageChanged` trigger). | | `notifyDialogClosed(pageIndex, pageName, dialogTitle?, reason?): void` | Announces a dialog-mode close (emits `onDialogClosed` + `dialogClosed` trigger). | ```ts const result = await api.validateData(structureJson, dataJson); if (!result.isValid) console.table(result.issues); ``` ## Data sources | Method | Description | | --- | --- | | `reloadDataSource(elementNameOrPath, dataSourceName?, runtimeContext?): Promise` | Reloads the data source bound to an element (or a named source), with optional extra runtime context for placeholders. | | `reloadElementDataSource(elementNameOrPath, runtimeContext?): Promise` | Shorthand: reload whatever source the element uses. | | `reloadElementsByDataSource(dataSourceName, runtimeContext?): Promise` | Reloads **every element** consuming the named source (falls back to the raw source when nothing consumes it). | | `setDefaultDataSources(dataSources: IDataSource[], overwriteExisting?): void` | Injects host-defined sources into the current structure so every view can use them (by name; existing names kept unless `overwriteExisting`). | | `setDataSourceTypeSettings(settings): void` | Enables optional source types in the builder UI: `{ enableWebsocket }`. | | `getDataSourceTypeSettings(): INgxViewBuilderDataSourceTypeSettings` | Current source-type switches. | | `setWebsocketAuthorizer(authorizer): void` | Puts this runtime instance's websocket connections under host control: rewrite the url or the subprotocols to carry credentials. Runs before every connection and reconnection, and may return a promise. See [WebSocket security](https://ngxviewbuilder.io/developers/data-sources#security-authorizing-the-connection). | ```ts await api.reloadElementsByDataSource('loadClients'); api.setDefaultDataSources([{ name: 'countries', type: 'rest', url: '/api/countries' }]); ``` ## Runtime variables | Method | Description | | --- | --- | | `setRuntimeVariableDefinitions(definitions, replace = true): void` | Writes the variable definitions into `structure.settings` and (re)binds them. | | `getRuntimeVariableDefinitions(): IRuntimeVariableDefinition[]` | Current definitions. | | `setRuntimeVariableContext(values, merge = true): void` | Sets the **external context** available as `{__external.*}` in expressions. | | `getRuntimeVariableContext(): Record` | Current external context. | | `clearRuntimeVariableContext(): void` | Clears the external context. | | `configureRuntimeVariables({ mappings?, external?, mergeExternal? }): void` | One-call setup: definitions + external context. | Details: [Runtime variables](https://ngxviewbuilder.io/developers/runtime-variables). ## Language, locale & translations | Method | Description | | --- | --- | | `getLanguage(): string` | Active content/UI language. | | `setLanguage(language): void` | Switches the language (UI dictionaries + structure `settings.language`; registers the language in localization if new). | | `resolveHostLanguage(options?): INgxViewBuilderResolvedLanguage` | Resolves the best `{ language, locale }` from document/system language against `supportedLanguages` with a `fallbackLanguage`. | | `applyHostLanguage(options?): INgxViewBuilderResolvedLanguage` | `resolveHostLanguage` + applies it (`setLanguage`, and `setApplicationLocale` unless `applyLocale: false`). | | `startLanguageSync(options?): INgxViewBuilderLanguageSyncHandle` | Keeps the view in sync with the host page language: watches `` and the browser `languagechange` event. Returns `{ refresh, stop }`. | | `setApplicationLocale(locale): void` | Overrides the locale used for number/date formatting (e.g. `'de-DE'`), independent of the UI language. Until called, formatting falls back to the structure's `settings.language`, then the visitor's browser locale. | | `getApplicationLocale(): string` | The explicitly-set application locale, or `''` if `setApplicationLocale` was never called (formatting is still active via the fallback chain above). | | `setLocale(locale): void` | Convenience: sets the locale in both the application locale service and structure settings. | | `setContentTranslations(translations, merge = true): void` | Adds content translations (`{ lt: { 'First name': 'Vardas' } }`) into the structure's localization. | | `setUiDictionaries(dictionaries, merge = true): void` | Overrides builder/runtime UI dictionaries (see [UI translations](https://ngxviewbuilder.io/developers/ui-translations)). | | `setUiTranslations(dictionaries, merge = true): void` | Alias of `setUiDictionaries`. | | `translateUi(key, fallback?): string` | Translates a UI dictionary key. | | `setPropertyHints(hints): void` | Overrides the help texts shown next to builder properties. | | `setPropertyTypeHints(hints): void` | Overrides the help texts for property *types* (text, options, validators…). | ```ts const handle = api.startLanguageSync({ supportedLanguages: ['en', 'de'], fallbackLanguage: 'en', }); // later: handle.stop(); ``` ## Theme & CSS | Method | Description | | --- | --- | | `setThemeMode(theme: 'light' \| 'dark'): void` | Switches light/dark mode. | | `getThemeMode(): 'light' \| 'dark' \| null` | Current mode (null until first set). | | `setTheme(theme): void` | Accepts either a mode string (`'dark'`) or a CSS-variable map, one call for both cases. | | `setCustomTheme(theme): void` | Registers a custom theme: `{ mergeWithDefaults?, shared?, light?, dark? }` variable maps. Re-applies variables for the active mode. | | `getCustomTheme(): INgxViewBuilderCustomThemeDefinition \| null` | Current custom theme. | | `setCssVariables(cssVariables): void` | Sets raw CSS custom properties (names are auto-prefixed with `--`). | | `getCssVariables(): Record` | Currently applied variables. | | `setCustomCss(cssText): void` | Injects a raw CSS string into the render scope. | | `getCustomCss(): string` | Current custom CSS. | | `setCustomCssUrls(urls): void` | Loads external stylesheets into the render scope. | | `getCustomCssUrls(): string[]` | Current stylesheet URLs. | Details: [Theming & design tokens](https://ngxviewbuilder.io/developers/theming), [Custom CSS](https://ngxviewbuilder.io/developers/custom-css). ## Extensions & registration Everything from [`provideNgxViewBuilderExtensions`](https://ngxviewbuilder.io/developers/extensions) is also callable imperatively: | Method | Description | | --- | --- | | `registerExtensions(config): void` | Registers a whole extensions config (icons, dictionaries, elements, functions, properties…) at runtime. | | `registerExtensionsAsync(config): Promise` | Same, but awaits async icon directories. | | `registerElementGroup(group): void` | Adds a custom group to the elements sidebar. | | `registerCustomElement(definition): void` | Registers one [custom element](https://ngxviewbuilder.io/developers/custom-elements). | | `registerCustomElements(definitions): void` | Registers several custom elements. | | `registerElementProperties(type, properties, merge = true): void` | Adds/overrides builder properties for one element type ([custom properties](https://ngxviewbuilder.io/developers/custom-properties)). | | `registerElementPropertiesMap(map, merge = true): void` | Properties for several types at once. | | `registerGlobalElementProperties(properties, merge = true): void` | Properties added to **every** element type. | | `registerExpressionFunction(fn)` / `registerExpressionFunctions(fns)` | Adds [custom expression functions](https://ngxviewbuilder.io/developers/custom-functions) to the expression language. | | `registerSvgIcon(name, svgMarkup, overwrite = true): boolean` | Registers one inline [SVG icon](https://ngxviewbuilder.io/developers/icons). | | `registerSvgIcons(icons, overwrite = true): string[]` | Registers a map of icons; returns the registered names. | | `clearRegisteredSvgIcons(): void` | Removes all registered SVG icons. | | `registerSvgIconDirectory(config): Promise<{ loaded, failed }>` | Loads a directory of `.svg` files by URL manifest. | | `registerSvgIconDirectories(configs): Promise<...[]>` | Several directories at once. | | `getTableHeaderCenterExtensions(): INgxViewBuilderCustomElementDefinition[]` | Custom elements flagged `allowInTableHeader` (usable in table header bars). | | `registerTableCellElementType(type): void` | Adds an element type to the **Cell element** picker of a table column ([cell elements](https://ngxviewbuilder.io/creators/elements/tables#cell-elements)). Takes a type id or `{ type, label, order }`. Elements registered with `allowInTableCell: true` are added for you. | | `registerTableCellElementTypes(types): void` | Several at once. | | `removeTableCellElementType(type): void` | Takes one back out of the picker. | | `getTableCellElementTypes(): ITableCellElementType[]` | Built-in cell element types plus everything registered by the host. | | `attachBuilderAdapter(adapter): void` | Internal bridge between this service and the builder UI. The builder component attaches itself; hosts don't call this. | ## Templates & sidebar library | Method | Description | | --- | --- | | `getTemplates(): ITemplateDefinition[]` | Templates of the current structure. | | `setTemplates(templates, replace = true)` / `loadTemplates(...)` | Loads host-persisted templates into the builder (both names do the same; `loadTemplates` reads more naturally on startup). | | `upsertTemplate(template)` / `saveTemplate(template, previousCode?)` | Adds or updates one template (`saveTemplate` also handles renames via `previousCode`). | | `removeTemplate(name)` / `deleteTemplate(code)` | Deletes a template. | | `setTemplateActionMap(map): void` | Maps template `action('...')` names to action handlers. | | `registerTemplateActionDataSource(functionName, dataSourceName): void` | Binds a template function call (e.g. `loadData(id)`) to a data source. | | `setSidebarTemplates(templates, replace = true)` / `setSidebarGroups(groups, replace = true)` / `loadSidebarGroups(groups, replace = true)` | Loads saved element-library items (the reusable elements/groups panel in the sidebar). | | `saveSidebarGroup(group, previousCode?)` / `upsertSidebarGroup(group)` | Adds or updates a sidebar library item. | | `deleteSidebarGroup(code)` / `removeSidebarGroup(code)` | Deletes a sidebar library item. | Persistence pattern: listen to `onTemplateSaved` / `onSidebarGroupSaved` etc., store items wherever you want, and call `loadTemplates` / `loadSidebarGroups` on startup. ## Tables | Method | Description | | --- | --- | | `setTableSettings(identifier, settings, options?): void` | Applies column settings (visibility/order/width) to a table. `options`: `tableName`, `elementName`, `source` (`'host' \| 'localStorage' \| 'dataSource' \| 'user'`). | | `getTableSettings(identifier)` | Stored settings for a table. | | `requestTableSettings(tableName, elementName, currentSettings): void` | Fires `onTableSettingsRequested` so the host can supply persisted settings. | | `requestTableSettingsSave(tableName, elementName, settings): void` | Fires `onTableSettingsSaveRequested` so the host can persist settings. | | `setTableFilters(identifier, filters, options?): void` | Applies active detailed-search filters. `options.source`: `'host' \| 'user' \| 'savedFilter'`; may carry the applied `savedFilter`. | | `getTableFilters(identifier)` | Stored active filters. | | `setTableSavedFilters(identifier, filters, options?): void` | Supplies the saved-filter list for a table. | | `getTableSavedFilters(identifier)` | Stored saved filters. | | `requestTableSavedFilters(tableName, elementName, currentFilters): void` | Fires `onTableSavedFiltersRequested` (host should respond with `setTableSavedFilters`). | | `requestTableSavedFilterSave(tableName, elementName, filter, currentFilters): void` | Fires `onTableSavedFilterSaveRequested` so the host can persist one saved filter. | Use together with the table events to persist per-user column layouts and saved filters. ## Builder UI control | Method | Description | | --- | --- | | `switchToTab(tab): void` | Switches the builder to a tab (`'editor'`, `'preview'`, `'jsonEditor'`, `'formSettings'`, `'translations'`, `'variables'`, or a plugin tab code). | | `requestTabChange(tab): void` | Emits `onTabChangeRequested`. The builder decides whether to honour it (useful when the host wraps tab navigation). | --- ## Architecture Source: https://ngxviewbuilder.io/developers/architecture # Architecture ## Package layout ``` ngx-view-builder ├─ ngx-view-builder-builder/ builder shell (tabs, sidebars, history) ├─ ngx-view-builder-runtime/ runtime shell (rendering + runtime services) ├─ ngx-view-builder/ unified host component ├─ ngx-view-builder-renderer/ low-level renderer ├─ ngx-view-builder-validator/ headless validation component └─ core/ ├─ builder/ builder-only: drag & drop, property editing, registries, datasets ├─ runtime/ runtime-only: rendering helpers, lazy element loading └─ shared/ both sides: models, elements, services, providers, expressions ``` Optional plugins are sibling npm packages (`ngx-view-builder-plugin-*`) that register builder tabs and feature packs through the extensions API. The core never auto-loads them. ## Key concepts ### Structure (`IStructure`) The single JSON document that describes a view: `settings`, `pages` (layout as rows/columns), `elements` (flat map of element configs keyed by name), `dataSources`, and `localization`. The builder edits it; the runtime executes it. Reference: [Structure JSON](https://ngxviewbuilder.io/developers/structure-json). ### Element model Every element type has a model class extending `ElementBaseModel` (name, label, type, widths, logic fields, value) and an Angular component that renders it. Built-ins live in `core/shared/elements`; you add your own via [custom elements](https://ngxviewbuilder.io/developers/custom-elements). ### Data Runtime values live in a data object keyed by element name (nested for repeaters: `addresses[0].city`). Reads and writes flow through the data service, which fires value-change events that drive expression re-evaluation. ### Expressions Logic strings (`visibleIf`, `expression`, …) are evaluated with [JEXL](https://github.com/TomFrost/Jexl). The expression service tracks dependencies between elements and re-evaluates dependents in topological order when a value changes. `{tokens}` compile to `getVal()` lookups; `row.`/`panel.` prefixes resolve against the element's position in repeaters. ### Host API `NgxViewBuilderApiService` (root-provided) is the façade the host app uses: get/set structure and data, look up elements, react to 59 typed events, register extensions at runtime, control theming and language. Reference: [API service](https://ngxviewbuilder.io/developers/api-service). ## Render pipeline 1. Structure JSON is parsed into element models (`flatModelMap`). 2. Runtime variables resolve (route, external, constants, data sources). 3. Default values apply; expressions evaluate in dependency order. 4. Pages render rows → columns → element components (optionally lazily). 5. User input → data service → dependent expressions → validation → events. ## Builder vs. runtime boundary Builder-side code (property sidebars, drag & drop, datasets) is never needed to *render* a view. If your end-user app only displays views, you ship the runtime component and pay no builder cost at runtime (lazy element rendering and preloading are tunable). --- ## Embedding the builder Source: https://ngxviewbuilder.io/developers/builder-integration # Embedding the builder ## Minimal setup ```ts import { Component } from '@angular/core'; import { BuilderModel, IStructure, NgxViewBuilderBuilder } from 'ngx-view-builder'; @Component({ selector: 'app-builder-page', imports: [NgxViewBuilderBuilder], template: ` `, }) export class BuilderPageComponent { builderModel = new BuilderModel(); ngOnInit(): void { const saved = localStorage.getItem('my-view'); if (saved) { this.builderModel.setJson(saved); // takes a JSON string } } onStructureChanged(structure: IStructure): void { localStorage.setItem('my-view', JSON.stringify(structure)); // autosave } onSave(): void { // user clicked Save in the builder header } } ``` `BuilderModel` wraps the structure: `setJson(jsonString)`, `getJson(): IStructure`, plus `setDataJson`/`getDataJson` for preview data. An empty model starts with one blank page. ## Inputs | Input | Type | Purpose | | --- | --- | --- | | `model` | `BuilderModel` | The structure being edited | | `language` | `string` | Builder UI + content language | | `theme` | `'light' \| 'dark'` | Color scheme | | `runtimeSettings` | `INgxViewBuilderBuilderSettings` | The big config object (below) | | `headerActions` | `INgxViewBuilderHeaderAction[]` | Extra header buttons | | `hideSaveAction` | `boolean` | Hide the built-in Save | | `hideHeaderTabs` | `boolean` | Chrome-less embedding | | `activeTab` | tab code | Preselect a tab | | `uiDictionaries` | `UiDictionaries` | Builder UI translations | | `contentTranslations` | `Record>` | Content translations | | `cssVariables` | `Record` | Token overrides | | `viewportSettings` | `{ mobile?, tablet?, desktop? }` | Preview breakpoint widths | | `externalConfig` / `externalConfigUrl` | config object / URL | Load host config as an object or JSON file | ## `runtimeSettings` highlights ```ts readonly builderSettings: INgxViewBuilderBuilderSettings = { theme: 'light', language: 'en', builderPageDisplayMode: 'single', // canvas shows one page at a time dataSources: { enableWebsocket: true }, defaultDataSources: [...], // sources every view gets runtimeVariableContext: { userRole: 'admin' }, propertyHints: { name: 'Unique data key' }, // extra hint texts in the sidebar licenseKey: 'NVB-...', }; ``` ## Outputs | Output | Fires when | | --- | --- | | `structureChanged` | any edit changes the structure (autosave hook) | | `saveRequested` | Save is clicked | | `valueChanged` | a preview value changes | | `tabChanged` / `languageChanged` | navigation events | | `templateSaved` / `templateDeleted` / `templatesLoaded` | template library persistence (host-side storage) | | `sidebarGroupSaved` / `sidebarGroupDeleted` / `sidebarGroupsLoaded` | element-library persistence | | `headerActionRequested` | one of your `headerActions` was clicked | ## Custom header actions ```ts readonly headerActions: INgxViewBuilderHeaderAction[] = [ { id: 'save', label: 'Save', icon: 'save', tone: 'primary', onClick: () => this.saveToBackend() }, { id: 'publish', label: 'Publish', onClick: () => this.publish() }, ]; ``` ## Persisting templates and library items When creators save templates or sidebar library groups, the builder emits events instead of assuming storage. Listen, persist wherever you like, and feed items back on startup: ```ts ngAfterViewInit(): void { queueMicrotask(() => { this.api.loadTemplates(this.templatesFromBackend); this.api.loadSidebarGroups(this.groupsFromBackend); }); } ``` (`api` is [`NgxViewBuilderApiService`](https://ngxviewbuilder.io/developers/api-service).) --- ## Custom CSS Source: https://ngxviewbuilder.io/developers/custom-css # Custom CSS Tokens ([Theming](https://ngxviewbuilder.io/developers/theming)) restyle the system consistently. Custom CSS is the escape hatch for anything more specific. ## Three injection points | Where | Scope | Set by | | --- | --- | --- | | **Form settings → Custom CSS** | one view | creator (stored in `settings.customCss`) | | `api.setCustomCss(css)` | current session | host | | `api.setCustomCssUrls([url])` | current session, external files | host | ```ts api.setCustomCss(` .nvb-panel { border-radius: 12px; } .invoice-total { font-weight: 700; font-size: 18px; } `); api.setCustomCssUrls(['/assets/builder-overrides.css']); ``` Declarative equivalents exist on `runtimeSettings` (`customCss`, `customCssUrls`) and in the structure itself. ## Targeting elements - Every class the library renders starts with `nvb-`, so `nvb-field`, `nvb-panel`, `nvb-dropdown`. Anything without that prefix in a view belongs to you or to a template. Views written before 0.4.0 used unprefixed names and need updating. - Custom CSS is injected without a cascade layer, so it beats the library's own rules without needing `!important`. - Elements render with stable identity hooks, so prefer targeting by the element's id/test-id attributes or by classes you add in templates, rather than by internal DOM structure. - Template HTML ([template library](https://ngxviewbuilder.io/creators/templates)) carries its own scoped CSS, so for card layouts prefer a template's CSS over global custom CSS. - Custom-HTML elements can include class names that your custom CSS styles. ## Sanitisation Injected CSS is passed through markup security processing. Keep it to plain rules; imports of arbitrary origins should go through `customCssUrls` where the host controls the list. ## Recommended layering 1. **Tokens** for colors, fonts, radii → whole-system consistency. 2. **Template CSS** for repeatable card/option layouts. 3. **View Custom CSS** for one-off tweaks a creator owns. 4. **Host CSS URLs** for org-wide overrides versioned with the app. Track changes via `onCustomCssChanged` / `onCustomCssUrlsChanged`. --- ## Custom elements Source: https://ngxviewbuilder.io/developers/custom-elements # Custom elements A custom element is three parts: 1. a **model** class extending `ElementBaseModel` holding the element's data/config fields, 2. a **component** that renders it (receives the model as an input), 3. a **registration** describing type, label, icon, sidebar group, and editable properties. The library's own demo (`projects/test-app/src/app/demo/`) contains this exact example. ## 1. Model ```ts import { ElementBaseModel } from 'ngx-view-builder'; export class NoteElementModel extends ElementBaseModel { text = ''; variant: 'neutral' | 'info' | 'success' | 'warn' = 'warn'; showEditButton = true; constructor(data?: Partial) { super(); this.type = 'noteCard' as any; this.label = 'Note card'; this.width = '100%'; if (data) Object.assign(this, data); } } ``` Fields you declare here are persisted in the structure JSON and editable through the properties you register in step 3. ## 2. Component ```ts import { ChangeDetectionStrategy, Component, computed, inject, input, signal } from '@angular/core'; import { NgxViewBuilderApiService, TemplateEngineService } from 'ngx-view-builder'; import { NoteElementModel } from './note-element.model'; @Component({ selector: 'app-note-element', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: `
`, }) export class NoteElementComponent { model = input.required(); // the library passes the model in private templates = inject(TemplateEngineService); private api = inject(NgxViewBuilderApiService); private version = signal(0); readonly renderedText = computed(() => { this.version(); return this.templates.renderTemplate(this.model().text ?? '', { element: this.model(), }); }); ngOnInit(): void { // re-render when form values change so {{placeholders}} stay live this.disposer = this.api.onValueChanged.add(() => this.version.update((v) => v + 1)); } ngOnDestroy(): void { this.disposer?.(); } private disposer?: () => void; } ``` Useful services inside element components: | Service | For | | --- | --- | | `TemplateEngineService.renderTemplate(tpl, ctx)` | {{placeholder}} rendering | | `ElementActionService.runActions(model, model.events, 'click', ctx)` | firing the element's configured actions | | `NgxViewBuilderApiService` | values, events, everything | | `EventService.afterPropertyChanged` | react to property edits in the builder | For value-producing elements, write through the data service path (`api.setValue(model.dataPath, v)`) so expressions and validation react. Extending the input base classes in `core/shared/base-elements` gives you label/description/error chrome for free. ## 3. Registration ```ts import { provideNgxViewBuilderExtensions } from 'ngx-view-builder'; import { NoteElementComponent } from './note-element.component'; import { NoteElementModel } from './note-element.model'; provideNgxViewBuilderExtensions({ elementGroups: [{ code: 'custom', label: 'Custom', icon: 'extension', order: 90 }], elements: [{ type: 'noteCard', // unique type id (stored in JSON) label: 'Note card', // sidebar caption icon: 'noteIcon', // registered SVG icon name groupCode: 'custom', // which sidebar group component: NoteElementComponent, model: NoteElementModel, properties: { name: { label: 'Name', type: 'text', category: 'general' }, label: { label: 'Label', type: 'text', category: 'general' }, text: { label: 'Text', type: 'textarea', category: 'general', hint: 'Supports {{firstName}}-style placeholders.' }, variant: { label: 'Variant', type: 'select', category: 'general', choices: [ { label: 'Info', value: 'info' }, { label: 'Success', value: 'success' }, { label: 'Warn', value: 'warn' }, ] }, events: { label: 'Events', type: 'eventActions', category: 'events' }, width: { label: 'Width', type: 'size', category: 'design' }, mobileWidth: { label: 'Mobile width', type: 'size', category: 'design' }, }, }], }); ``` `properties` drives the properties sidebar; each key maps a model field to an editor. Available editor `type`s and categories: [Custom properties](https://ngxviewbuilder.io/developers/custom-properties). Optional registration fields: `aliases` (alternative type ids), `order` (position in group), `allowInTableHeader`, `allowInTableCell`. ## Using a custom element in a table cell A table column whose type is `Element (control)` renders a real element in every row ([Cell elements](https://ngxviewbuilder.io/creators/elements/tables#cell-elements)). Add `allowInTableCell: true` and your element joins the built-in ones in that column's **Cell element** picker: ```ts provideNgxViewBuilderExtensions({ elements: [{ type: 'ratingWidget', label: 'Rating', groupCode: 'custom', allowInTableCell: true, component: RatingWidgetComponent, model: RatingWidgetModel, properties: { /* … */ }, }], }); ``` The same list is editable at runtime through the [API service](https://ngxviewbuilder.io/developers/api-service#extensions-registration), which is handy when the component is registered somewhere else, or when you want a built-in type back that the picker does not offer by default: ```ts api.registerTableCellElementType({ type: 'ratingWidget', label: 'Rating', order: 2500 }); api.registerTableCellElementTypes(['autocomplete', 'listBox']); api.removeTableCellElementType('fileUpload'); api.getTableCellElementTypes(); // built-ins plus everything you registered ``` ### Reading the row Nothing extra is needed for values and events: the library gives the cell model its row data path, mirrors the table's disabled/read-only state onto it, and merges the row into the runtime context of every action your element runs, so `{row.id}` and `{index}` resolve in the action editor. If your component renders its own text template, resolve it against the row explicitly: ```ts import { buildElementRowRuntimeContext, resolveElementRowScope } from 'ngx-view-builder'; const context = buildElementRowRuntimeContext(this.model(), (path) => this.dataService.getValue(path), ); // context.row, context.index, and every field of the row addressable by name ``` `resolveElementRowScope()` returns the raw scope (`rowPath`, `rowIndex`, `rowValue`, sibling names) when you need the pieces rather than a ready context. Both work for table cells, dynamic-table rows, and dynamic-panel rows, so writing against them once covers every repeater. ## Behaviour you get for free Once registered, the element supports everything generic: drag & drop, responsive widths, `visibleIf`/`disableIf` logic, events (if you expose an `events` property and run them), translations of its text properties, and JSON round-tripping. ## Checklist - [ ] `type` is unique and stable (it lives in saved JSON forever). - [ ] Model defaults are sensible, so the element looks right when first dropped. - [ ] Component is `OnPush` and reacts to value/property change events. - [ ] Colors use the library's CSS variables so light/dark themes work ([Theming](https://ngxviewbuilder.io/developers/theming)). - [ ] `properties` covers every model field creators should edit. --- ## Custom expression functions Source: https://ngxviewbuilder.io/developers/custom-functions # Custom expression functions Creators write expressions like `isEmpty({email})`. You can add your own functions so they can write `vatAmount({netPrice})` or `isWorkday({date})`. ## Registration ```ts import { provideNgxViewBuilderExtensions, IJexlFunctionRegistration } from 'ngx-view-builder'; const functions: IJexlFunctionRegistration[] = [ { name: 'vatAmount', args: ['net', 'rate(optional)'], description: 'Net price + VAT (default 21%)', example: 'vatAmount({netPrice})', handler: (net: unknown, rate?: unknown) => Number(net || 0) * (1 + (Number(rate) || 0.21)), }, { name: 'formatIban', args: ['iban'], description: 'Groups an IBAN into blocks of 4', example: 'formatIban({accountNumber})', handler: (iban: unknown) => String(iban ?? '').replace(/\s+/g, '').replace(/(.{4})/g, '$1 ').trim(), }, ]; providers: [ provideNgxViewBuilderExtensions({ expressionFunctions: functions }), ] ``` Or at runtime: `api.registerExpressionFunction(fn)` / `api.registerExpressionFunctions(fns)`. ## Registration fields | Field | Purpose | | --- | --- | | `name` | The identifier used in expressions (re-registering a name replaces it) | | `handler` | The implementation. May return a value or a `Promise` | | `args` | Argument names, shown in the expression editor's help | | `description` / `example` | Shown in the editor's function list. Write them well; creators rely on this | Custom functions are flagged `isCustom` and appear alongside the [built-ins](https://ngxviewbuilder.io/creators/functions) in the editor's help panel. ## Async functions Handlers may be async (the built-in `runDataSource` is). Note that *synchronous* evaluation contexts (e.g. inline default-value evaluation) skip expressions containing async functions, so keep functions used in `defaultValue` synchronous. ## Guidelines - **Be forgiving with inputs.** Creators will pass empty strings and `null`, so coerce (`Number(x) || 0`, `String(x ?? '')`) instead of throwing. A thrown error silently fails the whole expression. - **Return simple types** (string, number, boolean, array, plain object) that JSON-serialise cleanly. - **Keep them pure** where possible. Functions with side effects (like `setElementProperty`) are powerful but make views harder to reason about. - **Namespace by convention** if you register many: `acmeVat()`, `acmeRound()`. Function names are global. --- ## Custom properties Source: https://ngxviewbuilder.io/developers/custom-properties # Custom properties Property definitions drive the properties sidebar. You can add new properties (persisted into the structure JSON automatically) or override built-in ones. ## Add to every element ```ts provideNgxViewBuilderExtensions({ globalProperties: { trackingId: { label: 'Tracking ID', type: 'text', category: 'general', hint: 'Analytics identifier rendered as data-tracking-id.', }, }, }); ``` ## Add or override per element type ```ts provideNgxViewBuilderExtensions({ elementProperties: { button: { // element type analyticsEvent: { // new property label: 'Analytics event', type: 'text', category: 'general', }, label: { hidden: true }, // hide a built-in property }, }, }); ``` Runtime equivalents: `api.registerElementProperties(type, props)`, `api.registerGlobalElementProperties(props)`, `api.registerElementPropertiesMap(map)`. ## Property definition fields | Field | Purpose | | --- | --- | | `label` | Caption in the sidebar | | `type` | Which editor renders it (table below) | | `category` | Sidebar section: `general`, `texts`, `dataSet`, `options`, `columns`, `restrictions`, `validators`, `logic`, `events`, `design` | | `order` | Position within the category | | `hint` | Help text under the editor | | `choices` | Options for `select`-style editors (`{ label, value }[]`) | | `hiddenIf` | Expression over sibling properties that hides the editor conditionally (e.g. `!maskType \|\| maskType.value != "custom"`) | | `applyConditions` | Other property keys to re-evaluate when this one changes | | `section` | Optional sub-grouping inside a category | | `component` | A custom Angular editor component (fully custom editors) | | `loadChoices` | Async choice loader `(properties) => Promise` | ## Editor types The `type` string picks one of the built-in attribute editors: | Type | Editor | | --- | --- | | `text` / `textarea` / `number` | plain inputs | | `checkbox` | boolean toggle | | `select` | dropdown (uses `choices`) | | `color` | color picker | | `size` | size with units (px/%/…) | | `padding` | four-sided padding editor | | `options` | option-list editor (label/value rows) | | `validators` | validators rule editor | | `eventActions` | events & actions editor | | `sourceMapper` | data source binding editor | | `variantRules` | conditional variant rules | | `columns` / `tableColumns` | column schema editors | | `htmlCode` | code editor | | `file` | file picker | | `checklist`, `logic`, `templateName`, `sourceName`, and others | specialised editors used by built-in elements | For anything unique, supply your own `component`. It receives the property and element context and writes the value back like any built-in editor. ## How values reach the element A property with key `analyticsEvent` is stored on the element JSON as `"analyticsEvent": ...` and appears on the model at runtime (`model().analyticsEvent` in your component, `getElementProperty(name, 'analyticsEvent')` from the host). ## Property hints To only *clarify* existing properties (no structural change), set hint texts from the host: ```ts api.setPropertyHints({ name: 'Unique key, becomes the data field name.' }); api.setPropertyTypeHints({ sourceMapper: 'Binds this element to a data source.' }); ``` --- ## Custom validator types Source: https://ngxviewbuilder.io/developers/custom-validators # Custom validator types The built-in validator types (`required`, `minLength`, `pattern`, `email`, …) are deliberately generic. Anything country-specific a personal code, a tax ID, a phone number, a bank account differs per market, so those rules live in _your_ application, not in the library. A registered type appears in the builder's **Type** list next to the built-ins, and from then on a creator picks _LT personal code_ the same way they pick _email_. No regular expression, no expression language. ## Registration ```ts import { provideNgxViewBuilderExtensions, INgxViewBuilderValidatorTypeRegistration, } from "ngx-view-builder"; const validatorTypes: INgxViewBuilderValidatorTypeRegistration[] = [ { type: "ltPersonalCode", label: "LT personal code", defaultMessage: "Invalid personal code", appliesTo: ["text"], isValid: ({ value }) => isValidLtPersonalCode(String(value ?? "")), }, { type: "minWords", label: "Minimum words", defaultMessage: "Please write at least {value} words", hasValue: true, valueLabel: "Words", valuePlaceholder: "20", appliesTo: ["textarea"], isValid: ({ value, validatorValue }) => String(value ?? "") .trim() .split(/\s+/) .filter(Boolean).length >= Number(validatorValue || 0), }, ]; providers: [provideNgxViewBuilderExtensions({ validatorTypes })]; ``` Or at runtime: `api.registerValidatorType(definition)` / `api.registerValidatorTypes(definitions)`. Register early in an `APP_INITIALIZER` or the host component's `ngOnInit` so the types exist before a view is rendered. ## Registration fields | Field | Purpose | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `type` | The identifier stored in the view JSON. Lower-cased on save, so `ltPersonalCode` and `ltpersonalcode` are the same rule. Built-in names cannot be overridden | | `isValid` | The check. **Returns `true` when the value is valid** the opposite polarity of a `custom` rule's condition. May return a `Promise` | | `label` | Text shown in the Type list (defaults to `type`) | | `defaultMessage` | Pre-filled into the rule's _Error message_ field when a creator picks the type, and used at runtime when the message is left empty | | `hasValue` | Set `true` when the rule needs a setting, which shows the **Value** input (like `minLength`) | | `valueLabel` / `valuePlaceholder` | Label and placeholder for that Value input | | `appliesTo` | Element types the rule is offered for, e.g. `['text', 'textarea']`. Omit to offer it everywhere | | `validateEmpty` | By default an empty value passes (matching `email` and `pattern`) so the rule stays optional; set `true` to check empty values too | ::: warning Polarity `isValid` returns **true = valid**. A `custom` rule's _Condition_ field is the other way round it describes the **error** and fires while it is true. The names match the behaviour in both cases; do not copy a condition expression into an `isValid` handler unchanged. ::: ## What `isValid` receives ```ts isValid: ({ value, validatorValue, validator, element, data }) => boolean | Promise; ``` | Field | Contents | | ---------------- | -------------------------------------------------------------------------- | | `value` | The current value of the field being validated | | `validatorValue` | The rule's **Value** setting (present when `hasValue` is on) | | `validator` | The whole rule object (`type`, `message`, `value`, `condition`, `applyIf`) | | `element` | The element model being validated | | `data` | The full runtime data object, for rules that depend on other fields | Everything else keeps working as usual: the creator can still set a per-rule error message, translate it, and guard the rule with **Apply if**. ## Async rules `isValid` may return a `Promise`, which makes server-side checks possible (verifying a VAT number against a registry, for example): ```ts { type: 'vatNumber', label: 'VAT number', defaultMessage: 'This VAT number was not found', isValid: async ({ value }) => { const response = await fetch(`/api/vat/${encodeURIComponent(String(value ?? ''))}`); return response.ok; }, } ``` If you do this, set the element's **Validation execution mode** to `onBlur` (the default) rather than `onInput`, or every keystroke becomes a request. ## Portability The rule _definition_ lives in your application; the view JSON only stores the name: ```json { "type": "ltPersonalCode", "message": "Invalid personal code" } ``` That keeps views portable, but it also means a view using `ltPersonalCode` needs that type registered wherever it is rendered. In an application where it is not registered the rule is **skipped** (the field validates as if the rule were absent) and a warning is logged to the console once per unknown type. The builder still shows the rule and marks it `(not registered)` instead of silently rewriting it, so opening a view in another app never destroys the rule. If several applications share views, register the shared types from one small library rather than copy-pasting the handlers. ## Guidelines - **Be forgiving with input.** Creators and users will hand you `null`, `''`, and stray whitespace. Coerce (`String(value ?? '').trim()`) instead of throwing a handler that throws is treated as invalid and logs an error. - **Keep the message useful.** `Company code must be 9 digits` beats `Invalid value`. See [good error messages](https://ngxviewbuilder.io/creators/validation#good-error-messages). - **Namespace country rules** by prefix (`ltPersonalCode`, `lvPersonalCode`) so the Type list stays readable as it grows. - **Do not re-implement the built-ins.** Length, range, pattern, and email are already there; a custom type is for logic those cannot express. ## Related - [Validation](https://ngxviewbuilder.io/creators/validation) how creators use validators, including `custom` conditions and _Apply if_ - [Custom expression functions](https://ngxviewbuilder.io/developers/custom-functions) when the rule is better expressed as an expression creators can reuse - [Headless validation](https://ngxviewbuilder.io/developers/validator) running the same rules outside the UI --- ## Data source integration Source: https://ngxviewbuilder.io/developers/data-sources # Data source integration Creators configure data sources inside views ([creator guide](https://ngxviewbuilder.io/creators/data-sources)). This page covers what the host application controls. ## Requests go through Angular HttpClient REST sources use the host's `HttpClient`, so your existing **interceptors apply automatically**: auth tokens, tenant headers, error mapping, logging. That's the recommended way to secure view-issued requests. ```ts // app.config.ts provideHttpClient(withInterceptors([authInterceptor, apiPrefixInterceptor])), ``` An `apiPrefix` interceptor also lets creators use short relative URLs (`/clients`) while the host decides the actual host per environment. ## Table: server-side paging & filtering (`TABLE-POST`) This only applies to a **`table`** element with **Lazy load** on (creator-side property, `tableDataSourceName`). With Lazy load off, the table's data source is a plain source that must return every row in one response. The table paginates/sorts/filters in the browser and none of the contract below applies. With Lazy load on, set the source's **Method** to the literal string **`TABLE-POST`** in the DataSources tab. NGX View Builder sends a real `POST` over the wire, but first merges the current page/sort/search state into the request body automatically, so you don't write this merging logic yourself. - If the source's **Request body** is left empty, the body sent to your endpoint is exactly the object below. - If you *do* configure a body template (e.g. `{"tenantId":"{el1}"}`), your fields are kept and `pagingParams`/`params`/`extendedParams` are added on top. ### Request body shape ```json { "pagingParams": { "cnt": null, "orderClause": "lastName DESC", "pageSize": 25, "skipRows": 50, "totalCountUsed": false }, "params": [ ["tenantId", "42"] ], "extendedParams": [ { "paramName": "quickSearch", "paramValue": { "condition": "%-%", "value": "acme", "upperLower": "caseInsensitiveLatin" } }, { "paramName": "status", "paramValue": { "condition": "=", "value": "active" } }, { "paramName": "createdAt", "paramValue": { "condition": ">=", "value": "2026-01-01" } } ] } ``` - **`pagingParams`**: `pageSize` and `skipRows` (offset, not page number) drive the page window; `orderClause` is a ready-to-use string like `"lastName DESC"` (column key + `ASC`/`DESC`, taken from whichever column is actively sorted, or the **Order clause** property otherwise). `cnt` and `totalCountUsed` are always sent as `null`/`false`, because the frontend never fills them in; your endpoint owns computing the total. - **`params`**: an array of `[paramName, value]` **tuples** (not `{name, value}` objects), one per row configured in the element's **Request params** property (up to 5). Values may resolve `{path}`/`{{expression}}` tokens from the form before sending. - **`extendedParams`**: one entry per active search/filter, each `{ paramName, paramValue: { condition, value, upperLower? } }`: - The **quick search** box (if enabled) always contributes one entry; `paramName` is the **Quick search param name** property (default `quickSearch`). - **Detailed search** contributes one entry per column that has an active filter; `paramName` is that column's `key`. `value` is already typed for you: a real `boolean`/`number` for boolean/number columns, an array of strings for multi-select filters, otherwise a string. **Search/filter `condition` values** (same vocabulary for quick search and every per-column filter): | Value | Meaning (text columns) | Meaning (number/date columns) | | --- | --- | --- | | `%-%` | contains | - | | `!%-%` | does not contain | - | | `%-` | starts with | - | | `-%` | ends with | - | | `=` | equals | equals | | `!=` | not equals | not equals | | `>` | - | greater than / after | | `>=` | - | on/after | | `<` | - | less than / before | | `<=` | - | on/before | ### Response shape your endpoint must return No wrapper is mandatory; the table tries several common shapes automatically before giving up: ```json { "items": [ { "id": 101, "lastName": "Smith", "status": "active" } ], "total": 187 } ``` - **Rows**: the whole response if it's already an array; otherwise the **Items path** property if set (e.g. `data.items`); otherwise the first of `items`, `data`, `results`, `rows` that is itself an array. - **Total** (only read when Lazy load is on): the **Total path** property if set (e.g. `meta.total`); otherwise the first present of `total`, `totalCount`, `totalRecords`, `count`, `cnt`, `paging.total`, `paging.totalCount`, `paging.totalRecords`, `paging.count`, `paging.cnt`. **Total path is optional**: leave it empty, use any of those key names, and it's picked up automatically. If nothing matches, the total silently falls back to the current page's row count (pagination will look "stuck", so set one of the recognized keys, or Total path, to avoid that). So `{ "data": [...], "totalCount": 187 }` or `{ "results": [...], "paging": { "total": 187 } }` work with zero extra configuration. ### Row actions, inline edit, and export are not part of this contract - **Row/selection/header actions** and **inline edit save** go through the same generic [action → data source](https://ngxviewbuilder.io/creators/events-actions) mechanism as any button; there is no table-specific request shape for them. What reaches your endpoint is whatever URL/body you configured on that action's data source, populated from the action's own runtime context: a row action sees `row`, `selectedRows`, `selectedKeys`; a bulk selection action sees `rows`/`items` for every selected record; inline edit save sees `row` (the full edited row) **and** `changedValues`, a flat `{ "status": "active" }`-style object containing only the columns that actually changed, handy for a PATCH-style endpoint. - **CSV/Excel/PDF export happens entirely in the browser.** There is no server-side export endpoint to implement; "export all" re-issues the same table request with a much larger `pagingParams.pageSize` (the **Export all page size** property, default 10000) to fetch every matching row, then builds the file client-side. ## File upload requests `fileUpload` can bind up to three independent data sources (**Upload data source**, **Download data source**, **Delete data source**), each a normal REST source. If **Upload data source** is left empty, files never leave the browser: only local metadata (`name`/`size`/`type`/`lastModified`) is kept as the field's value, which is a common foot-gun for a field that otherwise looks "filled in". ### Upload Always sent as raw `multipart/form-data` with exactly **one** field, never JSON, never base64: ``` POST {upload data source URL} Content-Type: multipart/form-data; boundary=... ------WebKitFormBoundary... Content-Disposition: form-data; name="file"; filename="resume.pdf" Content-Type: application/pdf ------WebKitFormBoundary...-- ``` - The field name is the **Upload form field name** property (default `file`). - Any **Request body** template configured on the upload source is **ignored**; only `{placeholder}` tokens in the **URL** are resolved (against `value`, `file`, `fileName`, `fileType`, `fileSize`, plus the usual form context). - With **Max files** > 1, each file is uploaded as its **own sequential request**. There is no multi-file batch endpoint and no chunked or resumable upload. - There is no upload-progress percentage available, only a pending/ready/error state per file. Your endpoint should respond with an object describing the stored file: ```json { "key": "9f2c1e6a-...", "name": "resume.pdf", "size": 245678, "contentType": "application/pdf" } ``` Field names are configurable via the **File key / name / size / type field** properties (defaults shown above: `key`, `name`, `size`, `contentType`); if your backend already uses different keys (including the older `fil_key`/`fil_name`/`fil_size`/`fil_content_type` convention from earlier versions), the parser also recognizes common aliases automatically (`fileKey`/`filKey`/`fil_key`/`id`/`fileId`/`uuid` for the key, `fileName`/`fil_name` for the name, `mimeType`/`fil_content_type` for the type, `fil_size` for the size). The response can also come wrapped as `{ "data": {...} } `/`{ "items": [...] }`/etc., and the first record found is used. **The entire response object is stored verbatim as the field's value** (or an array of responses when **Max files** > 1). There is never a file-bytes/base64 field in the form's own JSON result, only whatever metadata your endpoint returned. ### Download Defaults to `GET`. If the source has no configured **Request body**, the fallback request body is `{ "": "" }` (only sent for non-`GET` methods). Your response can be **either**: - a raw binary stream (any `Content-Type` other than JSON, or an `attachment` `Content-Disposition`), or - a small JSON envelope such as `{ "url": "https://cdn.example.com/files/abc123.pdf" }` or `{ "content": "", "contentType": "application/pdf" }`. Both are handled without extra configuration. ### Delete Defaults to `DELETE`. If no **Request body** template is configured, the fallback body sends the file key under every common alias at once, so it matches almost any backend field naming without extra setup: ```json { "key": "9f2c1e6a-...", "fileKey": "9f2c1e6a-...", "filKey": "9f2c1e6a-...", "fil_key": "9f2c1e6a-...", "id": "9f2c1e6a-...", "fileId": "9f2c1e6a-...", "uuid": "9f2c1e6a-..." } ``` Any 2xx response counts as success; the response body itself is ignored. ## Default data sources Give every view a base catalogue of sources so creators don't retype URLs: ```ts this.api.setDefaultDataSources([ { name: 'loadCountries', title: 'Countries', type: 'rest', params: { url: '/api/countries', method: 'GET' } }, { name: 'saveForm', title: 'Save form', type: 'rest', params: { url: '/api/forms', method: 'POST' } }, ]); ``` Or via `runtimeSettings.defaultDataSources` / `dataSourceDefinitions`. ## Observability Subscribe to the data source events for global loading states and error handling: ```ts api.onDataSourceLoading.add(({ sourceName }) => this.spinner.show(sourceName)); api.onDataSourceLoaded.add(({ sourceName, durationMs }) => this.spinner.hide(sourceName)); api.onDataSourceLoadFailed.add(({ sourceName, errorMessage }) => { this.spinner.hide(sourceName); this.api.showToast({ title: 'Load failed', message: errorMessage, variant: 'error' }); }); ``` ## Programmatic reloads ```ts await api.reloadDataSource('loadClients'); // by source name await api.reloadElementDataSource('clientsTable'); // one element's binding await api.reloadElementsByDataSource('loadClients');// every element using it ``` Call these after host-side mutations so tables and dropdowns reflect new data. ## Caching & skipped requests - Responses are cached per request signature (URL + method + payload); identical concurrent requests are coalesced. Force fresh data with the reload APIs. - A request whose URL/body still contains an **unresolved `{placeholder}`** is skipped and yields empty data. That's by design (dependent dropdowns before their parent has a value). ## WebSocket sources A `websocket` source is a live connection rather than a request. The runtime opens it the first time anything on the page uses that source, keeps it open, and pushes every message into the bound elements and variables. Creators configure it in the [DataSources tab](https://ngxviewbuilder.io/creators/data-sources#websocket); this section is about what the host controls. Enable the type in the builder UI first, otherwise creators cannot pick it: ```ts api.setDataSourceTypeSettings({ enableWebsocket: true }); ``` The runtime is not gated by that flag. A stored view containing a websocket source connects even if the builder never offered the type. ### Your server side Plain WebSocket, the kind `new WebSocket(url)` speaks. Not STOMP, not SockJS. Text frames that contain JSON are parsed automatically, anything else is delivered as-is. ### One connection per endpoint Connections are shared by url, subprotocols, and handshake message. Two sources pointing at the same endpoint with different `messagePath` values use one socket, and so do two elements reading the same source. The socket closes when the last subscriber goes away. This matters for capacity planning: the number of connections your server sees is the number of distinct endpoints your views use, not the number of bound elements. ### Reconnecting A closed or failed connection is retried automatically with a growing delay of 1s, 2s, 5s, 10s, then 30s for as long as it takes. The delay resets after a successful connection. Subscribers stay attached throughout, so a view recovers on its own after a deploy or a network blip, with no page refresh. Each attempt is authorized again, which is what makes token refresh possible. ### Connection status The runtime publishes each source's state under `__socket.`, which creators can bind to and expressions can read: ``` {__socket.liveFeed.connected} // boolean {__socket.liveFeed.lastMessageAt} // epoch milliseconds {__socket.liveFeed.reconnects} // how many times it came back {__socket.liveFeed.url} // endpoint actually connected to ``` Use it to show an offline banner. A frozen live view that still looks live is the failure mode worth designing against. ### Security: authorizing the connection REST sources go through Angular `HttpClient`, so your interceptors add tokens automatically. **A browser `WebSocket` cannot send headers**, which means nothing you installed for HTTP applies to it. That leaves two ways to authenticate, and both are the host's decision: a query parameter on the url, or a subprotocol entry. Register an authorizer once, at app level, and every runtime instance uses it: ```ts // app.config.ts provideNgxViewBuilderProjectLayer({ id: 'websocket-security', authorizeWebsocket: async ({ sourceName, url, protocols }) => { const token = await auth.getFreshToken(); return { url: `${url}${url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}` }; }, }); ``` The hook runs before every connection and before every reconnection, and it may return a promise, so an expired token can be refreshed before the socket is opened again. Return `{ url }`, `{ protocols }`, both, or nothing at all to connect exactly as configured. Subprotocols are the alternative when you would rather keep tokens out of urls: ```ts authorizeWebsocket: async ({ protocols }) => ({ protocols: [...protocols, `auth.${await auth.getFreshToken()}`], }); ``` Your server reads it from the `Sec-WebSocket-Protocol` header during the handshake and must echo back one of the offered values. ::: warning A token in a url is a token in a log Query strings end up in access logs, proxy logs, and referrer headers. Prefer short-lived tokens issued for this purpose, validate them at the handshake, and reject unauthorized handshakes with a 401 before the connection is established. ::: `NgxViewBuilderApiService.setWebsocketAuthorizer()` sets the same hook on a single runtime instance, which is useful when one embedded view needs different credentials than the rest of the app. An authorizer set that way wins over the project layer for that instance. ### Proxies and timeouts Sockets die for reasons that have nothing to do with your code. Load balancers and reverse proxies need WebSocket upgrade enabled on the route, and their idle timeout decides how long a quiet connection survives. A server-side heartbeat keeps long-lived views connected instead of relying on the reconnect loop. ## Mock data in development Use an interceptor to fake endpoints while the backend is in flight. The demo app's `demo-data.interceptor.ts` in `projects/test-app` is a working example that pattern-matches URLs and returns canned JSON. --- ## Events reference Source: https://ngxviewbuilder.io/developers/events # Events reference All events live on `NgxViewBuilderApiService` and share a lightweight emitter API: ```ts const sub = api.onValueChanged.subscribe((e) => { ... }); // {unsubscribe()} const dispose = api.onValueChanged.add(handler); // returns disposer api.onValueChanged.once(handler); api.onValueChanged.remove(handler); ``` These are library emitters (not RxJS): `subscribe` returns `{ unsubscribe, closed }`. Always detach in `ngOnDestroy`. ::: tip Events work the same in the runtime and renderer `` and `` run on an isolated internal API instance, but every event they fire is automatically forwarded to the root `NgxViewBuilderApiService` your application injects, so the subscriptions below work identically on builder and runtime pages. (If you embed several runtimes on one page, use the payload's `elementName`/`elementDataPath` to tell them apart, or bridge instances yourself with the exported `bridgeNgxViewBuilderApiEvents(source, target)`.) ::: ## Values & data | Event | Fires | | --- | --- | | `onValueChanging` / `onValueChanged` | a field value is about to change / changed (`dataPath`, `newValue`, `oldValue`, `sender`, `trigger`) | | `onElementValueChanging` / `onElementValueChanged` | same, enriched with element lookup + an `api` handle for the element | | `onElementPropertyChanging` / `onElementPropertyChanged` | an element property (label, hidden, disabled…) changes | ```ts api.onElementValueChanged.add(({ elementDataPath, newValue, api: el }) => { if (elementDataPath === 'country') { void el.reloadDataSource('loadCities'); } }); ``` ## Validation & completion | Event | Fires | | --- | --- | | `onValidating` / `onValidated` | validation runs / finishes (`isValid`, `issues[]`) | | `onComplete` | the form completes (Submit): `{ isValid, data, issues }` | | `onSaveRequested` | Save is requested from header or API | ## Structure & navigation | Event | Fires | | --- | --- | | `onStructureChanged` | the structure was edited | | `onCurrentPageChanged` | the visible page changed | | `onDialogClosed` | a dialog-mode view closed (`reason: 'close-button' \| 'api'`) | | `onTabChanged` / `onTabChangeRequested` | builder tab navigation | | `onLanguageChanged` | active language switched | ## Rendering | Event | Fires | | --- | --- | | `onBeforeRender` / `onRender` / `onAfterRender` | view render phases (with render root) | | `onElementRender` / `onElementAfterRender` | per-element render, with model + DOM handles | Use these for DOM-level integrations (tooltips, analytics attributes, measuring). ## Element interactions | Event | Fires | | --- | --- | | `onDynamicTableRowAdded` / `onDynamicTableRowRemoved` | a `dynamicTable` row was added / removed (`elementName`, `rowIndex`, `row`, `rows`, `total`) | | `onDynamicPanelItemAdded` / `onDynamicPanelItemRemoved` | a `dynamicPanel` item was added / removed (same payload shape) | | `onFileUploadFilesAdded` / `onFileUploadFileRemoved` | files were attached / a file was removed on a `fileUpload` (`files`, `addedFiles` / `removedFile`, `removedIndex`) | | `onElementTabChanged` | the active tab of a `tabs` / `tabsPro` element changed (`previousTabValue`, `tabValue`, `tabIndex`) | | `onAccordionItemToggled` | an `accordion` section expanded or collapsed (`itemIndex`, `itemValue`, `expanded`) | All payloads carry `elementName`, `elementDataPath`, the element model, and a `timestamp`, so one subscription can serve many elements: ```ts api.onDynamicTableRowAdded.add(({ elementName, rowIndex, total }) => { if (elementName === 'familyMembers' && total >= 5) { api.showToast({ title: 'Limit', message: 'Max 5 members', variant: 'warning' }); } }); api.onDynamicPanelItemRemoved.add(({ elementDataPath, row }) => { this.audit.log('panel item removed', elementDataPath, row); }); ``` ## Data sources | Event | Fires | | --- | --- | | `onDataSourceLoading` | request started | | `onDataSourceLoaded` | success (`result`, `durationMs`, `fromCache`) | | `onDataSourceLoadFailed` | failure (`error`, `errorMessage`) | | `onDataSourceReloaded` | explicit reload happened | Perfect for global spinners and error toasts: ```ts api.onDataSourceLoadFailed.add(({ sourceName, errorMessage }) => api.showToast({ title: sourceName, message: errorMessage, variant: 'error' })); ``` ## Appearance & configuration | Event | Fires | | --- | --- | | `onThemeModeChanged` / `onCustomThemeChanged` | theme switches | | `onCssVariablesChanged` / `onCustomCssChanged` / `onCustomCssUrlsChanged` | style updates | ## Templates & sidebar library | Event | Fires | | --- | --- | | `onTemplateSaved` / `onTemplateDeleted` / `onTemplatesLoaded` | template persistence hooks | | `onSidebarTemplateSaved`, `onSidebarGroupSaved` / `onSidebarGroupDeleted` / `onSidebarGroupsLoaded` | element-library hooks | ## Tables | Event | Fires | | --- | --- | | `onTableSettingsChanged` / `Applied` / `Requested` / `SaveRequested` | column layout lifecycle | | `onTableFiltersChanged` / `Applied` | active filters | | `onTableSavedFiltersChanged` / `Requested`, `onTableSavedFilterSaveRequested` | saved filter sets | ## Automation (plugins) | Event | Fires | | --- | --- | | `onTriggerHandling` / `onTriggerHandled` | platform triggers execute | | `onRuleEvaluating` / `onRuleEvaluated` | rules engine runs (`executedBranch`) | ## Example: full save pipeline ```ts ngOnInit(): void { this.disposers.push( this.api.onComplete.add(async ({ isValid, data }) => { if (!isValid) return; const saved = await firstValueFrom(this.http.post('/api/forms', data)); this.api.showToast({ title: 'Saved', variant: 'success' }); }), this.api.onDataSourceLoadFailed.add((e) => this.logger.error('DS failed', e.sourceName, e.errorMessage)), ); } ngOnDestroy(): void { this.disposers.forEach((d) => d()); } ``` --- ## Extensions overview Source: https://ngxviewbuilder.io/developers/extensions # Extensions overview All customisation funnels through a single config type, `INgxViewBuilderExtensionsConfig`, registered either at bootstrap or at runtime. ## Registering **At bootstrap (recommended):** ```ts import { provideNgxViewBuilderExtensions } from 'ngx-view-builder'; providers: [ provideNgxViewBuilderExtensions({ elements: [...], expressionFunctions: [...], svgIcons: {...}, }), ] ``` The provider also accepts a factory (sync or async), which helps when the config comes from an API: ```ts provideNgxViewBuilderExtensions(async () => { const cfg = await fetch('/api/builder-config').then((r) => r.json()); return { runtimeVariableContext: cfg.context, svgIcons: cfg.icons }; }), ``` **At runtime:** ```ts api.registerExtensions(config); await api.registerExtensionsAsync(config); // resolves icon directories etc. ``` Multiple `provideNgxViewBuilderExtensions(...)` calls compose; plugins use the same mechanism. ## What the config can contain | Key | Registers | Details | | --- | --- | --- | | `elements` | custom element types | [Custom elements](https://ngxviewbuilder.io/developers/custom-elements) | | `elementGroups` | new sidebar groups | id, label, icon, order | | `expressionFunctions` | JEXL functions | [Custom functions](https://ngxviewbuilder.io/developers/custom-functions) | | `globalProperties` | properties added to *every* element | [Custom properties](https://ngxviewbuilder.io/developers/custom-properties) | | `elementProperties` | per-type property overrides/additions | [Custom properties](https://ngxviewbuilder.io/developers/custom-properties) | | `svgIcons` / `svgIconDirectory` / `svgIconDirectories` | icons by name / from asset folders | [Icons](https://ngxviewbuilder.io/developers/icons) | | `builderTabs` | extra builder tabs | [Building a plugin](https://ngxviewbuilder.io/developers/plugin-development) | | `featurePacks` | bundles of tabs + capabilities | [Building a plugin](https://ngxviewbuilder.io/developers/plugin-development) | | `runtimeVariables` / `runtimeVariableContext` | variables for expressions | [Runtime variables](https://ngxviewbuilder.io/developers/runtime-variables) | | `uiDictionaries` | builder UI translations | [UI translations](https://ngxviewbuilder.io/developers/ui-translations) | | `triggers` / `rules` / `process` / `fragments` | automation definitions | consumed by the automation plugins | ## Example: a project preset ```ts provideNgxViewBuilderExtensions({ elementGroups: [{ code: 'acme', label: 'ACME widgets', icon: 'acmeLogo', order: 10 }], elements: [acmeCardElement, acmeRatingElement], expressionFunctions: [ { name: 'vatAmount', args: ['net'], description: 'Adds 21% VAT', example: 'vatAmount({netPrice})', handler: (net: unknown) => Number(net || 0) * 1.21 }, ], globalProperties: { trackingId: { label: 'Tracking ID', type: 'text', category: 'general' }, }, svgIcons: { acmeLogo: '...' }, runtimeVariableContext: { brand: 'acme' }, }); ``` Everything a creator then sees (the ACME group, the elements, the `vatAmount()` function, the extra property) comes from this one object. --- ## Custom SVG icons Source: https://ngxviewbuilder.io/developers/icons # Custom SVG icons Icons are referenced by **name** everywhere (element `icon` properties, buttons, sidebar groups, header actions). Register your own names once; creators then use them like built-ins. ## Inline registration ```ts provideNgxViewBuilderExtensions({ svgIcons: { acmeLogo: ``, rocket: ``, }, }); ``` Runtime: `api.registerSvgIcon(name, svg)` / `api.registerSvgIcons(map)` (both accept `overwrite`). ## Loading from asset folders ```ts provideNgxViewBuilderExtensions({ svgIconDirectory: { basePath: '/assets/icons', names: ['invoice', 'shipment', 'warehouse'], extension: 'svg', // default prefix: 'acme', // registered as acmeInvoice, acmeShipment, … }, }); ``` Multiple folders via `svgIconDirectories: [...]`. Runtime loading (`await api.registerSvgIconDirectory(cfg)`) resolves fetches and returns `{ loaded, failed }`. ## Using icons - **Creators**: type the icon name into any `Icon` property (button icon, Icon element, sidebar group…). - **In your components**: the exported `NvbIcon` component renders any registered icon: ```html ``` ## SVG guidelines - Use `viewBox` and no fixed `width`/`height`; the host sizes the icon. - Use `currentColor` for strokes/fills so icons follow text color and theme. - Keep markup minimal; icons are inlined into the DOM. - Registered SVG is sanitised, so keep icons to plain vector markup (no scripts or foreignObject). --- ## Installation Source: https://ngxviewbuilder.io/developers/installation # Installation ## Requirements - Angular 22+ (`@angular/common`, `@angular/core`, `@angular/cdk` as peer dependencies) - Node.js 22.22+ (or 24.15+ / 26+) The library works with zoneless change detection and standalone components. ## Install ```bash npm install ngx-view-builder ``` The optional Templates plugin is a separate package, version-locked to the core: ```bash npm install ngx-view-builder-plugin-templates ``` ## Minimal app config ```ts // app.config.ts import { ApplicationConfig } from '@angular/core'; import { provideHttpClient } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ provideHttpClient(), // required for REST data sources provideRouter(routes), // required for route variables & navigate actions ], }; ``` ## Stylesheet Each component carries its own scoped styles, but the design tokens and the shared element rules live in one global stylesheet. Import it once: ```css @import 'ngx-view-builder/styles/index.css'; ``` Leaving it out does not blank the UI, which is what makes it easy to miss. The components still render and still have their own layout, they simply lose every token: inputs come out around 26px instead of 40px, labels fall back to plain black, and surfaces turn transparent. ### Layer order Everything in the stylesheet lives in `@layer reset, tokens, components, fpUtilities, elements`. Cascade layers only help if the host takes part in them, and two rules decide whether it does. Unlayered CSS beats layered CSS whatever the specificity. A host that writes its styles outside any layer, which is the default for Tailwind v3 and for most hand written stylesheets, will override every rule the library ships. A plain CSS `@import` is always hoisted to the top of the bundle, so the layer order cannot be fixed by moving the import line around. Declare the order yourself, in a stylesheet loaded before everything else: ```css /* src/layers.css, with Tailwind as the example host */ @layer theme, base, reset, tokens, components, fpUtilities, elements, utilities; ``` ```json "styles": ["src/layers.css", "src/styles.scss"] ``` Our layers now sit after the host's base and reset, and the host's utilities still win over element styles, which is usually what you want. ### Name collisions Every class and every custom property the library ships is prefixed with `nvb-`, so `nvb-field`, `nvb-dropdown`, `--nvb-color-neutral-300`. Nothing in the package answers to a generic name any more, and a host is free to keep its own `.field` or `--color-neutral-300` meaning whatever it likes. Before 0.4.0 those names were shared, which broke both directions at once. ## Initialization Runtime services can be pre-warmed either with a provider: ```ts import { provideNgxViewBuilderRuntime } from 'ngx-view-builder'; providers: [ provideNgxViewBuilderRuntime({ preloadRuntimeServices: true }), ] ``` …or imperatively in a component: ```ts import { ForgeInitializerService } from 'ngx-view-builder'; constructor() { inject(ForgeInitializerService).load({ preloadRuntimeServices: true }); } ``` Options: | Option | What it does | | --- | --- | | `preloadRuntimeServices` | Instantiates structure/expression/validator services upfront | | `exposeHeadlessValidationApi` | Publishes a global validation API (for headless/E2E use) | | `headlessValidationApiKey` | The global key it registers under | ## Registering plugins Installing a plugin package does nothing by itself. Register its provider: ```ts import { provideNgxViewBuilderTemplates } from 'ngx-view-builder-plugin-templates'; providers: [ provideNgxViewBuilderTemplates(), ] ``` Each registered plugin adds its tab to the builder automatically. See [Using plugins](https://ngxviewbuilder.io/developers/plugins). ## Import rule Import only from package roots (`ngx-view-builder`, `ngx-view-builder-plugin-*`). Deep imports into `src/lib/...` are internal and break between releases. --- ## Licensing Source: https://ngxviewbuilder.io/developers/licensing # Licensing > NGX View Builder is in public beta, so licenses aren't for sale yet. See [Pricing](https://ngxviewbuilder.io/pricing) for current status. Everything below describes the license model you'll buy into at launch. **The runtime is free forever and never requires a license key, at 1.0.0 or any version after.** Everything on this page describes the commercial license for the builder only. ## Is this open source? **No.** NGX View Builder is commercial software, not open source (not MIT, Apache, GPL, or any OSI-approved license). Installing it from npm gives you the right to use it under the terms below. It does not grant a license to fork, redistribute, or build a competing product from it. If you've used commercial component libraries before, the model will feel familiar: install from the public npm registry, evaluate for free with a watermark, unlock full use with a paid key. **Attempting to remove, disable, or circumvent the license check (the watermark, the signature validation, or the server check) is a breach of the license agreement**, regardless of how it's done (patching the package, monkey-patching at runtime, stripping the check at build time, etc.). See [Restrictions](https://ngxviewbuilder.io/developers/licensing#license-terms) below. ## Supplying the key Pass the license key through `runtimeSettings`: ```ts readonly builderSettings: INgxViewBuilderBuilderSettings = { licenseKey: 'NVB-eyJjdXN0b21lcklkIjo...', }; ``` ```html ``` ## How validation works 1. **Local check**: the key embeds `customerId`, `plan`, and `expiresAt`; version coverage is checked immediately (see below). 2. **Offline signature check**: keys are ECDSA P-256 signed; the builder verifies the signature against an embedded public key (WebCrypto). Tampered or self-made keys are treated as invalid. No network required. 3. **Server check**: the builder additionally calls the official license service (fixed, non-configurable URL) with `{ key, domain }`. The result is cached in localStorage for 7 days; network failures fall back silently to the local checks, so air-gapped environments keep working. A revoked key always shows the watermark. ## Version coverage (perpetual fallback) Every published build embeds its release date. A license covers **all versions released before its `expiresAt`**, permanently: - While the license is active, every update is covered. - After the license expires, the versions you already use **keep working forever, without a watermark**. - Versions released *after* the license expired run in degraded mode (watermark) until the license is renewed, so installing the latest package from npm without an active license does not grant access to new features. ## What users see | State | UI | | --- | --- | | Valid for this version | Nothing | | **No key supplied** | A diagonal “NO LICENSE” watermark across the builder canvas + a banner | | Version not covered (released after license expiry) | The canvas watermark + a banner + a reminder modal (at most once per 24 h) | | Invalid / tampered / unparseable / revoked | The canvas watermark + an invalid-license banner + modal (at most once per 24 h) | **The runtime never shows any license UI.** Rendering views in production is unaffected; license messaging is builder-facing only. ## License terms NGX View Builder is **commercial software**, distributed under the NGX View Builder Commercial License Agreement, and the full text ships as `LICENSE.md` inside every published npm package (also shown on the npm package page). In short: a paid key covers all versions released during your license term perpetually; **client work is included and either side can hold the license**: an agency's key covers the applications it builds for its clients, or the end customer buys the key and it covers contractors developing their application (operating a delivered application never needs a key); evaluation without a key is free but watermarked; redistributing the library on its own or circumventing license enforcement is prohibited. The open-source dependencies the package installs (CodeMirror, Jexl, Prettier and Lodash under MIT; tslib under 0BSD) keep their own licenses. ## Notes - Keep the key out of client repositories where possible; inject it from environment configuration. - The `domain` sent to the validation server is `window.location.hostname`; issue keys per environment if you validate domains. Licensing questions: **[support@ngxviewbuilder.io](mailto:support@ngxviewbuilder.io)**. --- ## Building a plugin Source: https://ngxviewbuilder.io/developers/plugin-development # Building a plugin A plugin is just a package (or app module) that calls `provideNgxViewBuilderExtensions()` with a **feature pack**: an id, optional capabilities, and one or more builder tabs backed by Angular components. ## Anatomy: the real Templates plugin ```ts import { EnvironmentProviders } from '@angular/core'; import { provideNgxViewBuilderExtensions } from 'ngx-view-builder'; import { MyStudio } from './my-studio/my-studio'; export const MY_FEATURE_PACK_ID = 'acme-audit'; export function provideAcmeAuditStudio(): EnvironmentProviders { return provideNgxViewBuilderExtensions({ featurePacks: [ { id: MY_FEATURE_PACK_ID, title: 'Audit studio', capabilities: ['acme.audit'], // optional gating keys builderTabs: [ { id: 'acmeAudit', // tab code label: 'tab.audit', // UI-dictionary key (or plain text) component: MyStudio, // any standalone component order: 900, // position among tabs }, ], }, ], }); } ``` That's the whole registration. The official Templates plugin (`ngx-view-builder-plugin-templates`) is exactly this thin. ## The tab component Your component renders inside the builder shell and talks to the same services as everything else: ```ts @Component({ standalone: true, template: `...` }) export class MyStudio { private api = inject(NgxViewBuilderApiService); structure = computed(() => this.api.getStructure()); addAuditNote(): void { this.api.updateStructure((s) => { (s.settings as any).auditNotes = [...(s.settings as any).auditNotes ?? [], { at: Date.now() }]; }); } } ``` Useful building blocks: - `api.getStructure()` / `api.updateStructure(fn)`: read/write the view (updates flow to Undo history and `structureChanged`). - `api.onStructureChanged.add(...)`: refresh when the creator edits elsewhere. - Custom data can live in `structure.settings` under your own key, and unknown settings keys are preserved by the core. - `api.translateUi(key, fallback)` + `uiDictionaries` in your extensions config: localise your tab. - Tab labels starting with `tab.` resolve through UI dictionaries automatically. ## Capabilities `capabilities` are opaque strings other code can require. Property definitions support `requiredFeaturePacks` / `requiredCapabilities`: a property (or other gated UI) shows only when a matching pack is registered. Use this to make core-side features light up when your plugin is installed. ## Packaging checklist - [ ] Ship as an Angular library with a single `provideX()` entry point. - [ ] Peer-depend on `ngx-view-builder` (never bundle it). - [ ] Version-lock releases to the core version you built against. - [ ] Keep tab `id` stable, since hosts may persist the active tab. - [ ] Style with the design tokens so themes work ([Theming](https://ngxviewbuilder.io/developers/theming)). --- ## Templates plugin reference Source: https://ngxviewbuilder.io/developers/plugin-templates # Templates plugin reference `ngx-view-builder-plugin-templates` adds the **Templates** tab: a library of reusable HTML/CSS snippets that List grid card templates, Select option templates, row templates, and header templates reference by name. The [creator guide](https://ngxviewbuilder.io/creators/templates) covers the tab from a form-builder's point of view; this page is the developer reference the exact template syntax, how the plugin decides where templates are read from, and (the part hosts always ask about) how to wire template Save/Delete to your own backend and database. ## Install & register ```bash npm install ngx-view-builder-plugin-templates ``` ```ts import { provideNgxViewBuilderTemplates } from 'ngx-view-builder-plugin-templates'; providers: [ provideNgxViewBuilderTemplates() ], ``` That's the whole plugin: one feature pack (`ngx-view-builder-templates`), one capability (`templates`), one builder tab (tab code `templates`). See [Using plugins](https://ngxviewbuilder.io/developers/plugins) for the general install/registration mechanism. ## Template syntax A template's **HTML content** is rendered by a small purpose-built engine, not the real Angular compiler it accepts an Angular-flavoured syntax (so it looks familiar to anyone who knows Angular templates) but only understands what's documented below. ### Interpolation {{ expression }} ```html {{ item.name }} Row {{ index + 1 }} of {{ $count }} ``` The expression runs against the template's runtime context: `item` / `row` / `value` (the bound record all three names point at the same object), `index`, every field in the current form data, and (inside `@for`) the loop variables below. Output is HTML-escaped automatically. ### Legacy single-brace tokens `{path}` `{status.code}` also works as a shorthand for a plain data-path lookup (no operators allowed). Prefer {{ }} for anything that isn't a bare path {{ }} supports real expressions, `{ }` doesn't. ### Translations `[[ key ]]` and `t()` / `tr()` ```html

[[ card.title | Card title ]]

``` - `[[ key | fallback ]]` looks the key up in the view's `localization.texts` for the active language (falling back to the default language, then the fallback text, then the raw key). See [Translations](https://ngxviewbuilder.io/creators/translations) for how `localization` gets populated. - `t('key')` / `tr('key', 'fallback')` do the same lookup from inside any expression position {{ }}, `@if (...)`, `@switch (...)`, click handlers. - `[[ someField ]]` (no literal key) evaluates `someField` as an expression first if the data has a value there, _that value_ becomes the translation key, so `[[ status ]]` looks up whatever `status` currently holds (e.g. `"ACTIVE"`). Use a path that resolves to nothing when you want a literal key instead of a dynamic one. ### Control flow `@if` / `@for` / `@switch` / `@let` ```html @if (item.status == "Active") { Active } @else if (item.status == "Pending") { Pending } @else { {{ item.status }} } @for (line of item.lines; track $index) {
  • {{ line.label }} {{ line.qty }}
  • } @empty {
  • No lines
  • } @switch (item.tier) { @case ('gold') { Gold } @case ('silver') { Silver } @default { Standard } } @let total = sumInArray(item.lines, 'qty'); Total: {{ total }} ``` Loop variables inside `@for`: `$index`, `$count`, `$first`, `$last`, `$even`, `$odd`, plus any `let x = expr` aliases declared in the loop header. Conditions and iterable expressions use the same expression language as **Visible if** / logic properties elsewhere in the builder see [Expressions basics](https://ngxviewbuilder.io/creators/expressions) evaluated against the runtime context above. `===` / `!==` are accepted and treated as `==` / `!=`; `?.` is treated as `.`. There's no real Angular pipe system, no `*ngIf` / `*ngFor`, no arbitrary JavaScript only the constructs on this page. ### Conditional classes `[class.name]="expr"` ```html
    {{ item.label }}
    ``` Each matching class is added to the element's `class` attribute when its expression is truthy; the binding itself is stripped from the rendered output. ### Click actions `(click)="..."` ```html ``` `(click)` is rewritten to a `data-nvb-event-click` attribute at render time and intercepted by the engine directly (no Angular event binding involved). See [Wiring template actions](https://ngxviewbuilder.io/developers/plugin-templates#wiring-template-actions-to-your-backend) below for what the call inside the quotes can do. ### Icons `` ```html ``` Renders the same inline SVG set used by the rest of the builder (see [Custom SVG icons](https://ngxviewbuilder.io/developers/icons)); the `name` / `icon` / `data-icon` attribute or the tag's own text content picks the icon. Legacy `check_circle` markup is upgraded automatically too. ### CSS the template's own `