# 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. It installs as @ngxviewbuilder/runtime (free) and @ngxviewbuilder/designer (the builder); the old unscoped ngx-view-builder package is no longer updated. The builder has no AI chat or model inside it: AI clients (Claude, ChatGPT, Cursor, agents) drive it from outside over MCP. 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, you are in it. Every connection starts **unpaired**. Before anything else: 1. Ask the user for their builder session key. It is shown in the builder settings under AI access (MCP) and looks like `NVB-XXXX-XXXX-XXXX`. Never guess one. 2. Call `nvb_pair` with the key exactly as the user gave it. Case, spaces and missing dashes do not matter. After that, `nvb_status` tells you where you stand: | Result | What it means | What to do | | --- | --- | --- | | `paired: true`, `builderConnected: true` | A builder tab is open and yours to drive | Drive it with commands. Do not hand back raw JSON for a person to paste. | | `paired: true`, `builderConnected: false` | The tab was closed, or is reloading | Ask the user to open or reload the builder, then retry. A reload keeps the pairing. | | `paired: false` | Nothing is paired yet, or the user pressed New key | Ask for the key (again) and call `nvb_pair`. | | An error saying no tab has that key | The key is wrong, or belongs to a tab that is gone | Ask the user to copy it again from the builder. A new tab has a new key. | | 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. | `callsRemaining` in the same answer is how many builder calls are left in the current window. Batch your changes; do not spend calls one element at a time. The API 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 a few round trips and remove almost every source of error. ```text nvb_pair 1. with the key the user gave you nvb_get_instructions 2. the contract as text nvb_describe_element_types 3. real element types and their real property keys nvb_get_tree 4. what already exists, the row indexes and element paths you need ``` When the user says "this field" or "the selected one", call `nvb_get_selection`: it returns the element they clicked in the designer (`{ name, path, type }`), or `null` when nothing is selected. `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. `nvb_help` returns the machine readable catalog: `help.commands` with a worked example per command, `help.guidelines` with the rules on their own, and `help.capabilities` with the optional features that 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. Group commands into batches by section rather than sending single calls: a change goes in one batch, a new view of more than about eight elements in one batch per section, all with the same `group`. 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 with `nvb_status` 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 (in the page itself, `available()` answers the same question). 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`. Elements inside a `dynamicPanel` or `objectPanel` only need a name that is unique within that panel, so two panels can each have a `city`. Address them by **path**, `billing.city`, which `getTree()` gives on every node. Every command that takes an element name also takes a path; a bare name that matches more than one element is an error that lists the paths to choose from. 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. Batches that share a `group` option (MCP: `group` on `nvb_execute`) fold into one undo step as long as the person edits nothing in between, so a view built section by section still undoes whole. `getAuditLog()` shows every call you made this session, which is worth reading back before you claim what you did. While you build, the canvas shows the person what you are doing (reading, checking, building with a count) and plays the elements a batch adds in one by one, top to bottom. ## 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 > NGX View Builder has **no AI chat and no AI model inside it**. The AI is your own client (Claude, ChatGPT, Cursor, Codex or an agent you run), which connects from outside over MCP and drives the builder while you watch the canvas. Nothing in this section describes a chat window in the builder, because there is none. 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. [Good practices](https://ngxviewbuilder.io/ai/good-practices): what makes a view look finished, element by element. Read before building or changing a view. 10. [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. 11. [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 | | [Good practices](https://ngxviewbuilder.io/ai/good-practices) | What makes a view look finished: captions and labels per element, buttons and contrast, layout, names, fields, logic | Always, before building or changing a view | | [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) 7. [Good practices](https://ngxviewbuilder.io/ai/good-practices) 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 - In a live builder session (MCP or the command API), the person can answer: ask a short question with options and your recommendation before building the part that depends on it. See [When to ask, and when to decide](https://ngxviewbuilder.io/ai/good-practices#when-to-ask-and-when-to-decide). - When no one can answer (a one-shot generation), decide based on the [Element selection map](https://ngxviewbuilder.io/ai/element-selection-map) and state the choice. - 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 eleven 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` | | `objectPanel` | `column.rows` | A `dynamicPanel` with exactly one entry: children are defined in its `template`, every descendant stores its value at `.`, the value is one object | | `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. ## Web pages: canvas render mode and page sections When the user asks for a web page rather than a form (a landing page, product page, event page, "a simple website"), set `settings.renderMode` to `"canvas"`: no page card, no pager or Submit bar, no outer spacing, full width, every page stacked into one long page. On phones, columns without `mobileWidth` stack. Build the page from `panel` elements with `sectionMode: true`, one per band, each in its own row of the page. Put the content (page titles, text, buttons, cards, fields) inside the sections; an element placed directly on a canvas page has no spacing around it. ```json { "schemaVersion": 1, "settings": { "language": "en", "renderMode": "canvas" }, "pages": [ { "name": "home", "rows": [ { "columns": [ { "elementRef": "hero", "rows": [ { "columns": [{ "elementRef": "heroTitle" }] }, { "columns": [{ "elementRef": "startButton" }, { "elementRef": "docsButton" }] } ] } ] }, { "columns": [ { "elementRef": "features", "rows": [ { "columns": [{ "elementRef": "featuresTitle" }] }, { "columns": [{ "elementRef": "featureForms" }, { "elementRef": "featureTables" }, { "elementRef": "featureCharts" }] } ] } ] } ] } ], "elements": { "home": { "name": "home", "label": "Home", "type": "page", "hideHeader": true }, "hero": { "name": "hero", "label": "", "type": "panel", "sectionMode": true, "sectionTheme": "dark", "sectionMinHeight": "480px", "backgroundImage": "https://example.com/hero.jpg", "backgroundOverlayColor": "rgba(2, 6, 23, 0.45)" }, "heroTitle": { "name": "heroTitle", "label": "", "type": "pageTitle", "level": "h1", "title": "Ship internal tools in days", "subtitle": "Forms, tables and dashboards, built visually." }, "startButton": { "name": "startButton", "label": "", "type": "button", "text": "Start free trial", "fitContent": true, "size": "large" }, "docsButton": { "name": "docsButton", "label": "", "type": "button", "text": "Read the docs", "fitContent": true, "size": "large", "variant": "outline", "tone": "neutral" }, "features": { "name": "features", "label": "", "type": "panel", "sectionMode": true }, "featuresTitle": { "name": "featuresTitle", "label": "", "type": "pageTitle", "level": "h2", "align": "center", "title": "Everything a back office needs" }, "featureForms": { "name": "featureForms", "label": "", "type": "messageCard", "variant": "info", "title": "Forms", "descriptionText": "Validation and conditions without code." }, "featureTables": { "name": "featureTables", "label": "", "type": "messageCard", "variant": "success", "title": "Tables", "descriptionText": "Sorting, filters and Excel export." }, "featureCharts": { "name": "featureCharts", "label": "", "type": "messageCard", "variant": "primary", "title": "Dashboards", "descriptionText": "Charts that read from the same data." } } } ``` Section properties: `sectionMode`, `sectionTheme` (`light`/`dark`), `sectionContentWidth`, `sectionPaddingY`, `sectionMinHeight`, `sectionVerticalAlign`, `backgroundImage`, `backgroundOverlayColor`, plus the panel's own `panelBackgroundColor`. A form inside a web page (a sign-up box) still goes in a section; its button carries the submit or data source action, since canvas has no Submit bar. ## 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 eleven 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", "textColor": "var(--nvb-color-neutral-000)", "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 eleven types accept children: `page`, `panel`, `objectPanel`, `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. - `objectPanel` stores one object: every element laid out inside it, at any depth, writes to `.` instead of ``. Define those children in the panel's `template` map, not in the root `elements`; their names only have to be unique inside that panel. Reference the values as `{address.city}` from outside, and as `{panel.city}` from a sibling inside the same panel. - `autocomplete` with a `searchDataSourceName` and no `searchValueKey` stores the whole selected record as an object. - `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 - "landing page", "web page", "website", "hero", "full width section" -> `settings.renderMode: "canvas"` with `panel` elements that have `sectionMode: true` (see [Layout model](https://ngxviewbuilder.io/ai/layout-model#web-pages-canvas-render-mode-and-page-sections)) - "dynamic table", "editable rows", "add row", "delete row" -> `dynamicTable` - "server-side table", "paging", "sorting", "filtering", "export", "row actions" -> `table` - "repeating block", "repeatable section" -> `dynamicPanel` - "fields saved as one object", "nested object", "address object" -> `objectPanel` - "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 | | Group of fields saved as one object | `objectPanel` | Values land at `.`; not repeatable | | 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` | | `object`, `nested object`, `group as one value`, `multiple text` | `objectPanel` | | `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? - `panel` vs `objectPanel`: should the fields stay separate top-level values, or arrive as one object under the group's name? - `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` | | `disallowAddRowsIf` | `dynamicPanel` / `dynamicTable`: disable the add button | `true` or `false` | | `disallowDeleteRowsIf` | `dynamicPanel` / `dynamicTable`: per row, remove that row's delete button | `true` or `false` | | `hideRowIf` | `dynamicTable`: per row, hide the row | `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. - For plain arithmetic across a few fields use `sum(a, b, ...)`, `min(a, b, ...)` and `max(a, b, ...)`. They take any number of values, skip empty ones and flatten arrays; `sum` of nothing is `0`, `min` and `max` of nothing are `null`. Use `sumArray` and friends for a field inside each entry of a dynamic panel. - Round with `roundNumber(value, digits)`, cut text with `substring(text, start, end?)` (0 based, `end` excluded), and get full years from a date with `age({birthDate})`. For an empty input `roundNumber` and `age` return `null` and `substring` returns `""`. ## 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" } ``` ```json { "name": "totalWithVat", "type": "number", "expression": "roundNumber(sum({net}, {shipping}) * 1.21, 2)" } ``` ## 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`) | | Stop adding rows to a dynamic panel or table in some cases | `disallowAddRowsIf` (`disallowAddRows: true` locks it always) | | Protect some rows from deletion | `disallowDeleteRowsIf`, e.g. `{row.status} == "approved"` | ## 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}` - in `hideRowIf`, `disallowAddRowsIf` and `disallowDeleteRowsIf`: `{row.field}` (also `{panel.field}` or `{item.field}`) for the row being judged, `{index}` for its position, `{rowCount}` for the number of rows, and any other `{field}` for the form. `disallowAddRowsIf` is judged once for the whole element, so it has `{rowCount}` and form fields but no row ### 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. - For a short list, give it `options` or a `dataSource` with `useFor: "option"`; it filters them as the user types. - For a list that lives on a server, set `searchDataSourceName` to a source whose URL uses `{query}`, plus `searchLabelKey` and, usually, `searchValueKey`. - Value shape: the option value, or the value key of the picked record. With a search source and no `searchValueKey`, the whole record object. ### `fileUpload` - Use for file uploads. - `storageMode`: `"base64"` (default, the file content goes into the value, no backend needed) or `"server"` (uploaded through `uploadDataSourceName`). Set `"server"` explicitly whenever you configure upload/download/delete sources. - Value shape, the same in both modes (an array when `multiple`): `{ name, size, type, lastModified, contentType }` plus `base64` or `fileKey`. In server mode the name, type, size and key are read from the response using `fileKeyField`/`fileNameField`/`fileTypeField`/`fileSizeField`. - With base64, set `maxFileSizeMb`: the value is about a third larger than the file. - 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: `text`, `variant`, `tone`, `textColor`, `events`, `disabled`. - The caption goes in `text`. A button has no field label: set `label` to `""`, otherwise a label row with the same words is drawn above the button. - A solid button on a dark fill needs light text. That covers the default `primary` tone, `success`, `info`, `risk`, and any dark custom `color`: set `textColor` to `"var(--nvb-color-neutral-000)"`. A host stylesheet can otherwise leave the caption dark on the dark fill. `outline` and `text` buttons, and the light `warning` tone, keep their default text color. ### `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. - To stop adding or deleting under a condition use `disallowAddRowsIf` / `disallowDeleteRowsIf` (JEXL), not a `disableIf` on the whole table. `disallowDeleteRowsIf` is judged per row: `{row.status} == "approved"` keeps approved rows and lets the rest be deleted. ### `table` - Use for displaying data from a datasource. - Supports `sorting`, `filtering`, `paging`, `rowActions`, and `export`, but start basic: columns, paging and quick search, with `showDetailedSearch` and `showExport` set to `false` (both default to `true`). Add detailed search, export, column settings, saved filters, selection, expandable rows or inline editing only when the user asks. - Row actions go in the three-dots menu, `rowActionsDisplayMode: "dropdown"`, unless the user explicitly asks for separate buttons, and the actions column shows no header text: `showActionsHeaderLabel: false` (it defaults to `true`). When the user does not say how a record opens, it opens from a **Preview** action in that menu, not from a row click. - 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. - `chartType`: `bar` and `horizontalBar` compare categories (horizontal for long names), `line` and `area` show change over time, `pie` and `donut` show parts of a whole with at most six slices (more fold into "Other"). - `showLegend` and `legendLayout` apply to `pie` and `donut` only; single series charts have no legend, so put what is plotted in `title`. - Click actions (`events` with `trigger: "click"`) receive the clicked point as `{item.label}`, `{item.value}` and `{index}`. ## 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. ### `objectPanel` - Use when several fields should be saved as one object: an address, a contact person, company details. - Children are laid out in `column.rows` like a `panel`'s, but defined in the panel's `template` map, like a `dynamicPanel`'s. Their names only have to be unique inside that panel. - Their values land at `.`. Value shape: one object. Refer to its fields as `{objectPanelName.fieldName}`, or `{panel.fieldName}` from inside the same panel. - No add or remove controls: it is always exactly one object. - If the user should add several such groups, use `dynamicPanel` instead. ### `dynamicPanel` - Use for repeating sets of blocks. - Value shape is typically: array of objects. - Suitable for addresses, family members, and education records. - `disallowAddRowsIf` and `disallowDeleteRowsIf` work the same as on `dynamicTable`: the add button is disabled while the first is `true`, and an entry where the second is `true` has no remove button. ### `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 ### `autocomplete` (search data source) For lists too large to load upfront, the element calls its own search source while the user types. This is separate from `dataSource`, which stays for static option lists. | Property | Type | Notes | |---|---|---| | `searchDataSourceName` | `string` | Name of a top-level data source. Its URL or body uses `{query}` for the typed text and `{limit}` for the result limit | | `searchItemsPath` | `string` | Path to the array in the response, e.g. `data.items`. Optional: `items`, `data`, `results`, `rows`, `content` (also under `data.`) are tried when empty | | `searchLabelKey` | `string` | Field shown in the dropdown, dot paths allowed (`country.name`). Empty tries `label`, `name`, `title`, `text` | | `searchValueKey` | `string` | Field stored as the value. **Empty stores the whole record** as an object | | `searchResultLimit` | `number` | Most records the source returns. An answer shorter than this is complete, so a longer query that extends the answered one is filtered locally instead of calling again. Empty calls on every change | | `debounceMs` | `number` | Wait after the last keystroke. Default `1000`, max `10000` | | `minSearchLength` | `number` | Characters before the first call. Default `3` | | `queryContextKey` | `string` | Extra name for the typed text besides `query` | | `maxSuggestions` | `number` | Most suggestions shown. Default `20` | | `forceSelection` | `boolean` | Clear text that matches no suggestion on blur. Turn it on when storing whole records | | `lazyLoad` | `boolean` | Legacy: search through `dataSource` instead. Ignored when `searchDataSourceName` is set | ```json { "name": "city", "type": "autocomplete", "label": "City", "searchDataSourceName": "citySearch", "searchLabelKey": "name", "searchResultLimit": 100, "minSearchLength": 2, "forceSelection": true } ``` with a source such as `{ "name": "citySearch", "type": "rest", "params": { "method": "GET", "url": "/api/cities?q={query}&limit={limit}" } }`. Value shape here: the whole record, e.g. `{ "id": 21, "name": "Riga", "country": { "code": "LV" } }`; with `"searchValueKey": "id"` it would be `21`. ### `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 | |---|---|---| | `label` | `string` | Set to `""` on a button: the caption belongs in `text`, and a label draws an extra label row above the button | | `text` | `string` | Button text (the caption) | | `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` | | `color` | `string` | Custom fill color (any CSS color or `var(--nvb-color-...)`) | | `textColor` | `string` | Caption color. On a solid button with a dark fill (`primary`, `success`, `info`, `risk`, or a dark `color`) set `"var(--nvb-color-neutral-000)"` | | `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 | | `storageMode` | `"base64"` \| `"server"` | `base64` (default) keeps each file's content in the value. `server` uploads through `uploadDataSourceName` and keeps the returned `fileKey`. A view saved without `storageMode` but with an upload source counts as `server` | | `uploadDataSourceName` / `downloadDataSourceName` / `deleteDataSourceName` | `string` | Names of REST data sources, used only when `storageMode` is `server` | | `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 (an array of these when `multiple`), keyed under the element's `name`, the same in both modes: ```json { "name": "logo.png", "size": 4507, "type": "image/png", "lastModified": 1790764181739, "contentType": "image/png", "base64": "iVBORw0KGgo..." } ``` With `storageMode: "base64"` the object carries `base64` (file content without the `data:` prefix). With `"server"` it carries `fileKey` instead. Base64 makes the value about a third larger than the file, so set `maxFileSizeMb` when using it. 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 | | `sectionMode` | `boolean` | Turns the panel into a page section: a full width band with no border, content centred at `sectionContentWidth`. The building block of web pages; pair it with `settings.renderMode: "canvas"` | | `sectionTheme` | `"light"` \| `"dark"` | `dark` switches every colour inside the section to the dark palette, so text, buttons and inputs on it stay readable | | `sectionContentWidth` | `string` | Max width of the content inside the band (default `1120px`) | | `sectionPaddingY` | `string` | Space above and below the content (default grows from 48px to 96px with the screen) | | `sectionMinHeight` | `string` | Minimum height, e.g. `"80vh"` for a hero band | | `sectionVerticalAlign` | `"start"` \| `"center"` \| `"end"` | Content position when `sectionMinHeight` makes the band taller than its content | | `backgroundImage` | `string` | Section background image URL (with `sectionMode`) | | `backgroundOverlayColor` | `string` | Colour over the background image so text stays readable, e.g. `rgba(0, 0, 0, 0.45)` | **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). ### `objectPanel` Same properties as `dynamicPanel`, without the row controls (`addRowButtonText`, `removeRowButtonText`, `emptyMessage`, `disallowAddRows`, `disallowDeleteRows`, `maxRows`, `confirmRowDeletion` and the confirm texts). It is a dynamic panel with exactly one entry: every element laid out under it, at any depth and through any plain layout containers, stores its value at `.`. Its children are defined in its `template`, so their names only have to be unique inside the panel. ```json "pages": [{ "name": "page1", "rows": [{ "columns": [{ "elementRef": "address", "rows": [{ "columns": [{ "elementRef": "city" }, { "elementRef": "street" }] }] }] }] }], "elements": { "address": { "name": "address", "type": "objectPanel", "label": "Address", "template": { "city": { "name": "city", "type": "text", "label": "City" }, "street": { "name": "street", "type": "text", "label": "Street" } } } } ``` Value shape: `{ "address": { "city": "...", "street": "..." } }`. Expressions and data source params reference `{address.city}`; inside the panel `{panel.city}` is the sibling. A nested `objectPanel` nests the object, also inside a `dynamicPanel` entry (`items[0].address.city`). Its own children go in its own `template`, not in the outer panel's: the outer template holds the inner panel, the inner template holds the inner fields. ### `dynamicPanel` Same as `panel` plus repeatable container behavior (`addRowButtonText`, `removeRowButtonText`, `emptyMessage`, `disallowAddRows`, `disallowDeleteRows`, `disallowAddRowsIf`, `disallowDeleteRowsIf`, `maxRows`, `confirmRowDeletion`, `hideHeader`). Value shape: array of objects. Children are attached the same way as for `panel`, via `column.rows`. `disallowAddRowsIf` and `disallowDeleteRowsIf` are JEXL conditions, the same pair as on `dynamicTable` below: while `disallowAddRowsIf` is `true` the add button is disabled, and `disallowDeleteRowsIf` is checked for every entry, so an entry where it is `true` has no remove button. Inside them `{row.field}` (or `{panel.field}`) reads the entry, `{index}` is its position, `{rowCount}` the number of entries, and any other `{field}` reads the form. ### `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` | | | `disallowAddRowsIf` | `string` | JEXL. While `true`, the add row button is disabled. Form fields and `{rowCount}`: `{rowCount} >= 5 \|\| {status} == "closed"` | | `disallowDeleteRowsIf` | `string` | JEXL, checked per row: a row where it is `true` has no delete button. `{row.field}`, `{index}`, `{rowCount}` and form fields: `{row.status} == "approved"` | | `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 | | `showQuickSearch` | `boolean` | The quick search box in the header | | `quickSearchOnType` | `boolean` | Default `false`: the quick search applies on the search button or Enter only. `true` searches while typing, after `searchDebounceMs` | | `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. `{field}` tokens show that field's current value, e.g. `"Order {orderNo}"` | | `settings.dialogDescription` | Subtitle. Takes `{field}` tokens like the title | | `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 | |---|---|---| | `title` | `string` | Heading | | `descriptionText` | `string` | Text under the title; typed line breaks are kept | | `variant` | `string` | Colour: `info` `primary` `success` `warn` `risk` `neutral` | | `appearance` | `"soft"` \| `"card"` \| `"accent"` | `soft` tinted banner (default), `card` white card with the icon in a tile above the title (feature lists, empty states), `accent` a coloured left rule and no box (hints inside forms) | | `actionsLayout` | `"stacked"` \| `"inline"` | Buttons under the text (default) or beside it when the card is wide | | `events` | `IElementActionConfig[]` | Every `trigger: "click"` action with a `label` becomes a small button. Keep to one or two. Without `buttonVariant`/`buttonTone` the first is filled in the message colour and the rest are outlined | | `icon` / `showIcon` | `string` / `boolean` | Leading icon; each variant has a default | | `dismissible` | `boolean` | Close button | | `initiallyVisible` | `boolean` | Start hidden (shown by an action) | | `variantMode` / `variantRules` / `variantExpression` | | Conditional colour | ### `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" \| "canvas"` | `canvas`: full width, no page card, no Prev/Next or Submit bar, no outer spacing, every page stacked into one long page. For web pages; build them from panels with `sectionMode`. Columns without `mobileWidth` stack on phones | | `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 | objectPanel | 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 | objectPanel | 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. ## 23. A button with a label row and a dark caption ### Mistake ```json { "type": "button", "name": "register", "label": "Register", "tone": "primary" } ``` The label is drawn as a field label above the button, so the page shows "Register" twice. The caption falls back to the label, and on a dark primary fill it can render dark on dark, depending on the host stylesheet. ### Correct ```json { "type": "button", "name": "register", "label": "", "text": "Register", "variant": "solid", "tone": "primary", "textColor": "var(--nvb-color-neutral-000)" } ``` ### Rule A button's caption lives in `text` and its `label` is `""`. Every solid button on a dark fill (`primary`, `success`, `info`, `risk`, or a dark custom `color`) gets `textColor: "var(--nvb-color-neutral-000)"`. ## 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. 14. Does every `button` keep its caption in `text` with `label: ""`, and does every solid button on a dark fill set `textColor: "var(--nvb-color-neutral-000)"`. --- ## AI: Good practices for building views Source: https://ngxviewbuilder.io/ai/good-practices # AI: Good practices for building views The other AI pages say what is **valid**. This page says what is **good**: the choices a careful designer makes so the view looks finished the first time the person sees it, and does not need a second round of fixes. Every rule here comes from a view that looked wrong or behaved wrong until it was followed. Read it before the first change. Over MCP, `nvb_execute` does not accept a batch until this page and the other required pages were read on the connection. ## How to work 1. **Look before you build.** Call `getTree()` (MCP: `nvb_get_tree`) first. Extend what is there; never rebuild a view the user only asked to change. 2. **Build in sections the person can watch.** A change to an existing view goes in one `execute()` call. A new view of more than about eight elements goes in a few batches, one per section from the top (header and first fields, each further group, then the buttons), each sent as soon as it is ready. The first section shows up right away and the canvas plays the new elements in one by one, instead of the whole form landing after a long wait. Give every batch of that build the same `group` option, for example `"build-contact-form"`, so the person still undoes the whole build in one step. Each batch is atomic on its own, so a failure never leaves a half applied section. 3. **Dry run large batches** with `dryRun: true`, fix what it reports, then send it for real. 4. **Check the result.** After a batch, read the returned tree (or `getTree()`) and confirm the elements are where you meant them: in the right container, in the right order, side by side where you wanted a row. 5. **Do not save.** Saving is the person's decision. Build, check, then stop and describe what you did. ## When to ask, and when to decide When you work in a live builder, the person is right there. A short question costs them a few seconds; a view built on a wrong guess costs them a round of fixes. So when you are genuinely unsure about something that changes what they get, ask before you build it. **Ask when** the choice changes how the view looks or behaves and the request does not settle it: - **Which control for a choice**: a `radio` list with every option visible, a compact `select`, a `selectButton` row, or `autocomplete` for a long list; one answer or several (`checkbox`, `multiSelect`). - **Which kind of table**: rows people edit (`dynamicTable`), data shown from a server with paging and search (`table`), or a repeating block of fields (`dynamicPanel`). - **One page or steps**: a single form with sections, or a multi-step flow. - **What is required**, and what happens on submit (a toast, a data source call, navigation). - **Where the data comes from**: fixed options, or a data source, and which one. - **Anything you would have to invent**: option lists, labels in another language, business rules, limits. **Decide yourself, and say what you chose,** when a good practice already answers it or the change is easy to undo: labels in plain words, side-by-side short fields, button tones, required messages, names, layout details. Asking about every detail is as unhelpful as guessing the important ones. **How to ask:** - One short message, before the batch that depends on it, with every open question together, not one at a time. - Offer concrete options and your recommendation: *"For Client type I would use a radio list, since there are only two options and both stay visible. Or should it be a dropdown?"* - Build everything that does not depend on the answer while you wait, if the person agrees, and leave the open parts out rather than filling them with a guess. - Once the person has answered, follow the answer, including in later requests of the same conversation. ## Captions and labels Every element sits in a wrapper that draws a **label row** above it whenever `label` is not empty. For input fields that row is the field's caption. For elements that show their own text (a button, a card, a picture) it is a second, redundant caption in bold, and it is the most common reason an AI-built view looks unfinished. **Rule: a visible word belongs to exactly one property, and it is the one the element is built to show.** | Element | Visible text goes in | `label` | | --- | --- | --- | | `text`, `number`, `textarea`, `select`, `radio`, `datepicker` and every other input | `label`, in plain words: "Company code" | the field caption | | `button` | `text` | `""` | | `badge` | `text` | `""` | | `statsCard` | `title` (the metric name) and `valueText` | `""` | | `messageCard` | `title` and `descriptionText` | `""` | | `pageTitle` | `title` and `subtitle` | `""` | | `image` | `alt` (always) and optionally `caption` | `""` | | `chart` | its `title` | `""` | | `divider`, `spacer`, `emptyBlock`, `customHtml`, `richTextViewer`, `video`, `iframe`, `progressBar`, `avatar`, `icon`, `breadcrumbs` | their own content | `""` | | `singleCheckbox` | the statement next to the box in `checkboxLabel`: "I agree to the terms" | `""`, or a question when the box answers it | | `toggleSwitch` | `label` for what it switches, `trueLabel` / `falseLabel` for the states | the caption | | `panel`, `dynamicPanel` | `label` is the panel heading | `""` when the panel only groups fields | | `page` | `label` is the page title | `""` hides the title | Since 0.10.6, `addElement` without a label already leaves these display elements without one, turns a button's `label` into its `text`, and gives a named field a readable label (`companyCode` becomes "Company code"). Older builders do not, so set the properties yourself either way. ```json { "op": "addElement", "type": "statsCard", "name": "revenue", "properties": { "label": "", "title": "Revenue this month", "valueText": "€48,200" } } ``` Other caption rules: - **Write labels for people**, in sentence case and the language of the view: "Delivery date", not `deliveryDate`, "DELIVERY DATE" or "Delivery Date". - **Never show a technical name.** A label equal to the element's `name` (`el3`, `customerEmail`) means the label was forgotten. - `placeholder` is an example of the answer ("name@company.com"), not a second label. `description` is for help that does not fit the label. Do not repeat the label in either. ## Buttons - The caption is `text`; `label` is `""`. A caption left in `label` is drawn twice: once as a field caption above the button, once on the button. - **One primary action per area**: `variant: "solid"`, `tone: "primary"`. Secondary actions next to it are `variant: "outline"` with `tone: "neutral"`. A destructive action is `tone: "risk"`. - **A dark fill needs light text.** A solid button with a dark tone (`primary`, `success`, `info`, `risk`) or a dark custom `color` gets `textColor: "var(--nvb-color-neutral-000)"`. Before 0.10.6 some host stylesheets left the caption dark on dark; setting it explicitly is correct on every version. A light fill (`warning`, or a light custom `color`) gets dark text, `"var(--nvb-color-neutral-900)"`. - **Button bars go in an `emptyBlock`**, not in a panel row: `contentDisplay: "grid"`, `gridTemplateColumns: "max-content max-content"`, a `contentJustify` and a `contentGap`. Flex does not line them up, because every row inside the block is full width. - A button does something. Give it `events` (a `submit`, a `validate`, a data source call, a toast), or leave it out. ```json { "type": "button", "name": "register", "label": "", "text": "Register", "variant": "solid", "tone": "primary", "textColor": "var(--nvb-color-neutral-000)", "events": [{ "trigger": "click", "type": "submit", "validateForm": true }] } ``` ## Color and contrast - Use the theme's tokens, `var(--nvb-color-primary-600)`, `var(--nvb-color-neutral-100)`, `var(--nvb-color-risk-500)` and so on, rather than hex values. The view then follows the host's theme and dark mode. - Text on a filled surface always contrasts with it: light text on a dark fill, dark text on a light fill. Check every place you set a background (`color` on a button, `panelBackgroundColor` on a block). - Use tones for meaning, not decoration: `success` for done, `warning` for attention, `risk` for errors and destructive actions, `info` for neutral notes. ## Layout - Follow the [layout model](https://ngxviewbuilder.io/ai/layout-model): children of a container live in the column that references it, never inside the element definition. - **Put related short fields side by side**: first and last name, city and postcode, start and end date. Two or three fields per row on desktop; long text fields get a row of their own. - **Elements in one row need no width.** Every element without a `width` grows to fill its share of the row: two elements take half each, three take a third each, and they stretch with the screen. Do not write `width: "50%"` or `"33%"` to get that. It adds nothing, and it breaks the row: `%` widths ignore the gap between columns, so two `50%` elements no longer fit side by side and the second one wraps to the next line. - **Only an uneven split gets a width**, and only on one element: for a 60/40 pair give the narrow one `width: "40%"` and leave the wide one without a width, so it takes the rest. ```json { "rows": [ { "columns": [{ "elementRef": "firstName" }, { "elementRef": "lastName" }] } ] } ``` `firstName` and `lastName` carry no `width`: each takes half the row. - **Give every side-by-side field `mobileWidth: "100%"`** so the row stacks on a phone. - **Align with `emptyBlock`** (grid, flex, gap, padding, background). Do not fake alignment with `customHtml`, spacer stacks or extra panels. - **Pages are steps, not sections.** A single form with sections is one page with panels or headings. Several pages only when the person should move through steps. - A dashboard or display page is not a form: set `showValidateButton: false` and `showSubmitButton: false` in `settings` so the Validate and Submit toolbar does not appear. ## Names - `name` is the data key the application receives. Make it meaningful camelCase: `companyCode`, `deliveryDate`, `invoiceLines`. Never leave `el1`, `el2`. - Names are stable. Renaming a field changes the data the host receives and breaks every expression that uses it; use `renameElement`, which updates the references, and only when asked. - Inside an `objectPanel` or `dynamicPanel`, child names only need to be unique inside that panel; address them by their path (`billing.city`). ## Fields - **Required means it**: set `required: true` together with a `requiredMessage` in plain words ("Enter your e-mail address"). A conditional requirement is `requireIf`, not a validator. - Check formats with validators (`email`, `pattern`, `minLength`, `min`, `max`) and give each a message that tells the person what to fix. - **Pick the choice control by the number of options**: two to four short options, `radio` or `selectButton` (all visible, one click); more, `select`; a long or server-backed list, `autocomplete`; several answers, `checkbox` or `multiSelect`; a yes/no, `singleCheckbox` or `toggleSwitch`. - Option `value`s are stable codes (`company`, `person`); option `label`s are what people read. - Pre-fill with `defaultValue`, not `value`. - Use the specific element for the data: `datepicker` for dates, `number` for amounts, `phoneInput` for phone numbers, `fileUpload` for files. A `text` field loses validation and formatting the specific one gives for free. ## Logic - Conditions reference fields by `name` in braces: `{clientType} == "company"`. - Logic that must react while the person is typing or choosing needs `logicExecutionMode: "onChange"` on the element that carries it; the default waits for blur. - A field hidden by `visibleIf` usually should not keep its value: set `resetOnHide`, or a `resetIf` with the same condition. - An `expression` computes a value from *other* fields. It never reads its own field. - Show or hide a whole group by putting `visibleIf` on its panel, not on every field inside it. ## Tables and repeating data ### A `table` starts basic - **Unless the person asked for more, a `table` is columns, paging and the quick search box, nothing else.** Do not improvise features. The element also turns on detailed search and export by default, so a basic table switches those two off: `showDetailedSearch: false`, `showExport: false`. Quick search (`showQuickSearch`) stays on. - **Add a feature only when it was asked for**: detailed search, export, column settings, saved filters, selection, expandable rows, inline editing, a header menu. If a feature seems useful but was not mentioned, suggest it in your summary instead of adding it. - **Row actions always go in the three-dots menu**: `rowActions` with `rowActionsDisplayMode: "dropdown"`. Use `iconButtons` or `buttons` only when the person explicitly asks for separate buttons in the row. - **The actions column has no header text**: `showActionsHeaderLabel: false`. It defaults to `true` and puts an "Actions" heading over the last column, which the three dots already explain. - **A `table` gets a full-width row of its own.** Inside a container narrower than 768px it switches to stacked mobile cards, so on a desktop page it must never end up looking like a phone. Do not put it beside another element, in a narrow column, a side panel or a small dialog, and do not give it a `width`. Leave `responsiveMode` on `"auto"` so it stacks only on a real phone; `"stack"` shows cards everywhere and is only for when the person asks for cards. - **When the person does not say how a record opens, it opens from the three-dots menu at the end of the row**: one **Preview** row action. Do not add navigation on a row click (`rowClickActions`, `rowClickOpensDetails`) on your own. When the person does say how (a click on the row, a dialog, a details panel), build it that way. ```json { "type": "table", "name": "orders", "label": "Orders", "showDetailedSearch": false, "showExport": false, "columnsConfig": [ { "key": "number", "label": "Order", "type": "text", "showInTable": true }, { "key": "customer", "label": "Customer", "type": "text", "showInTable": true }, { "key": "total", "label": "Total", "type": "number", "showInTable": true } ], "rowActionsDisplayMode": "dropdown", "showActionsHeaderLabel": false, "rowActions": [ { "label": "Preview", "icon": "visibility", "type": "navigate", "navigateTo": "/orders/{row.number}" } ] } ``` ### Choosing and filling - `dynamicTable` for rows the person edits, `table` for data shown from a data source, `dynamicPanel` for a repeating block of several fields per entry. - A status, badge, toggle or button in a `table` column is a hosted element (`type: "element"` with `elementType`), not HTML in a template. - Limit rows with `maxRows`, or conditionally with `disallowAddRowsIf`; protect some rows from deletion with `disallowDeleteRowsIf` (`{row.status} == "approved"`), rather than locking the whole table. - A `dynamicPanel` defines its children in its `template` map and lays them out in `column.rows`. ## Text and content - One language per view, the view's language, including placeholders, messages and button captions. - Examples and sample data use neutral, international names and places (Sam Carter, Harbor Foods, Amsterdam). - Short, specific wording: "Save order", not "Submit"; "Enter your e-mail address", not "Invalid input". ## Final checklist Before you report back, check that: 1. No label shows a technical name, and no display element (button, badge, card, image, divider, chart) has a label row. 2. Every button has its caption in `text`, `label: ""`, an action in `events`, and readable text on its fill. 3. Related short fields share rows without a `width` (no `50%`), and side-by-side fields stack on mobile. 4. Every `table` is basic unless more was asked for (quick search on, no detailed search, no export), its row actions sit in the three-dots menu with no header over that column, and when the person did not say how a record opens, it opens from a Preview action in that menu, not from a row click. It sits alone in a full-width row, so it shows as a table and not as mobile cards. 5. Every name is meaningful camelCase; no `el1` is left. 6. Required fields have a `requiredMessage`; validators have messages. 7. Live logic has `logicExecutionMode: "onChange"`, and hidden fields do not keep stale values. 8. Colors are theme tokens, and text contrasts with every fill. 9. Every choice you were unsure about was asked, not guessed, and the choices you made yourself are named in your summary. 10. Nothing was saved, and the person was told what changed. --- ## 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", "textColor": "var(--nvb-color-neutral-000)", "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", "textColor": "var(--nvb-color-neutral-000)", "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", "storageMode": "server", "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", "textColor": "var(--nvb-color-neutral-000)", "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", "textColor": "var(--nvb-color-neutral-000)", "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", "textColor": "var(--nvb-color-neutral-000)", "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, "disallowAddRowsIf": "{el2} >= 10000", "disallowDeleteRowsIf": "{row.column4} > 0 && {index} == 0", "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.*}`. - `disallowAddRowsIf` is judged for the whole table: no new lines once the invoice total reaches 10 000. `disallowDeleteRowsIf` is judged per row: the first line cannot be deleted once it has a total, the others can. - 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", "textColor": "var(--nvb-color-neutral-000)", "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, and each half is its own npm package: - **`@ngxviewbuilder/runtime`**: the engine that renders a saved view definition (JSON) to end users. Free forever, no license key. - **`@ngxviewbuilder/designer`**: the visual editor your creators use to design those views. Commercially licensed, and it depends on the runtime. Install one or both depending on what your app does. Most applications render views and never open the editor, so they only need the runtime. See [Installation](https://ngxviewbuilder.io/developers/installation) for the split, and [Architecture](https://ngxviewbuilder.io/developers/architecture#two-packages) for why the line falls where it does. You embed the components you need 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 | Package | Purpose | | --- | --- | --- | --- | | Designer | `` | designer | Full editing shell: canvas, sidebars, tabs, history | | Runtime | `` | runtime | Renders a view definition with live logic and data | | Unified | `` | runtime | Runtime-oriented host that can also take a `BuilderModel` | | Renderer | `` | runtime | Low-level renderer used by the unified wrapper | | Validator | `` | runtime | Headless validation for server-side scenarios | ## The flow ``` Creator designs in ← designer package │ (structureChanged) → IStructure JSON ▼ Your backend / storage │ [pageJson] ▼ renders to end users ← runtime package │ values, events, validation ▼ Your app (via outputs and NgxViewBuilderApiService) ``` The JSON in the middle is the whole contract between the two packages, which is why an app on either side of it can be deployed and licensed independently of the other. ## 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): which packages to install, 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. NGX View Builder has **no AI chat and no AI model inside it**. The AI is your own client (Claude, ChatGPT, Cursor, Codex or an agent you run), which connects from outside over MCP and drives the builder while you watch the canvas. 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()` | | false whenever the builder is not on screen | | `help()` | `nvb_help` | 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, each with its `path` | | `getSelection()` | `nvb_get_selection` | the element the person has selected, or `null` | | `getStructure()` | `nvb_get_structure` | the whole view JSON | | `getElement(name)` | `nvb_get_element` | one element body (a name or a path) | | `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 | Four more tools belong to the MCP server itself and never reach the builder: `nvb_pair`, `nvb_status` and `nvb_unpair`, which are about who may talk to which tab (the next section), and `nvb_read_docs`. `nvb_read_docs` hands the AI reference to the model page by page, so it works for clients that cannot open a URL. The server fetches it from [llms-authoring.txt](https://ngxviewbuilder.io/llms-authoring.txt), and the whole site from [llms-full.txt](https://ngxviewbuilder.io/llms-full.txt) when asked. Before the first change the model has to read the core pages: the command API, the generation contract, the layout model, the authoring rules, the element rules, the properties reference, the common mistakes and the [good practices](https://ngxviewbuilder.io/ai/good-practices). Until it has, `nvb_execute` answers with the list of pages still to read. That is about 33 000 tokens once per connection, and it is what keeps a model from inventing property names that are silently ignored. ## Connecting the builder The bridge connects only while `` is mounted, and only for a license key that is genuine and not expired: AI access comes with an active designer license. A runtime only host never opens one, no matter who calls what. Leaving the builder closes it again. There are two ways to open it. With nothing but `licenseKey` in the settings, the person presses **Connect** under Settings → AI access (MCP) and the builder dials our hosted server, `wss://mcp.ngxviewbuilder.io/bridge`. The hosted server asks the license console about the key before it pairs anything, and again every hour. A host that runs its own server gives the builder its address instead, and the builder connects on its own: ```ts readonly builderSettings: INgxViewBuilderBuilderSettings = { licenseKey: 'NVB-...', mcp: { url: 'wss://mcp.example.com/bridge', client: { app: 'Back office', page: 'Order form' }, }, }; ``` | Option | Description | | --- | --- | | `url` | Bridge endpoint of the MCP server. An `http(s)` URL is upgraded to `ws(s)`. Leave `mcp` out and the builder waits for the **Connect** button. | | `client` | App and page labels. The AI client sees them after pairing, which helps a person with several builder tabs open tell which one is being driven. | | `sessionKey` | A key your own backend issued. See [Keys from your backend](https://ngxviewbuilder.io/developers/ai-command-api#keys-from-your-backend). | `NgxViewBuilderMcpBridgeService` exposes the connection as signals, in case you want your own indicator somewhere in the host app: `status`, `sessionKey`, `clientUrl`, `pairedClients`, `isPaired`, `aiBusy`, `licenseProblem` and `lastError`. The builder's own header already shows an **AI connected** badge while a client is paired. `regenerateKey()` does what the **New key** button does. ## Pairing The MCP server does not let an AI client near a builder until a person says so. The handshake is short: 1. When the builder connects, it generates a session key such as `NVB-K990-RPNH-N2T7` and shows it in its settings under AI access (MCP), next to the server URL. 2. The person adds the server URL, `https:///mcp`, to their AI client once. The URL carries no key, so the same connector serves every builder tab. 3. The AI client connects. Until it is paired, every tool answers with an error telling the model to ask the user for the session key, and the server's MCP instructions say the same, so the model asks before it tries anything else. 4. The person pastes the key into the chat and the model calls `nvb_pair` with it. The builder shows a notice with the client's name, and from then on every tool call on that connection goes to that tab. A key is valid for 24 hours. When it runs out the builder generates a new one, and the server refuses the old one. A pairing belongs to the MCP session and points at the key, not at the socket. When the person reloads the builder, the tab comes back with the same key (it is kept in `sessionStorage`), the server tells it who is still paired, and the AI carries on without asking again. A new tab gets a new key. **New key** in the builder sends a revoke to the server, which drops every pairing to the old key on the spot, and the tab reconnects with a fresh one. Keys are drawn at random by the browser's cryptographic generator: twelve characters from a 32 letter alphabet, about 10¹⁸ possible keys. There is no date or counter in them on purpose, since anything predictable in a key would make it easier to guess. Two tabs drawing the same key is not something you will meet, and the server covers it anyway: a key that is in use in one browser cannot be taken over from another. The newcomer is refused and its builder simply picks a new key. A reload or a duplicated tab comes from the same browser and keeps its key as before. The key is forgiving to type: case, spaces and missing dashes are ignored, and `O`, `I` and `L` are read as `0`, `1` and `1`. It is 60 random bits, and a connection that tries ten wrong keys is refused for fifteen minutes, so guessing is not a realistic way in. `nvb_status` answers whether the connection is paired, whether the paired tab is open right now, and how many calls are left in the current window. `nvb_unpair` lets go of the tab. ### Keys from your backend If your backend already knows who should drive which view, it can hand out the key itself. Pass it as `mcp.sessionKey` and wire the AI client with an `x-nvb-session-key: ` header on `https:///mcp`. Such a connection starts out paired and nobody types anything. The server does not read the key from the URL, because a query string ends up in proxy logs and browser history. Make the key random, at least 128 bits, 16 to 128 characters of letters, digits and `. _ ~ -`. The builder hides **New key** in this case, because the backend gave that same key to the AI client and rotating it on one side only would break the pairing. ### The bridge keeps itself up If the socket drops, the builder reconnects on its own, backing off from one second to thirty. The key does not change, so neither does the pairing: once the server is back, the AI continues where it stopped. The status in the settings reads *MCP server unreachable, retrying…* in the meantime. ## The two guarantees **It runs only while the builder is on screen.** Three things must all hold: the host configured `mcp`, the builder component is mounted, and a person paired a key. 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. ::: ## What the MCP server keeps Nothing of your views or data. The server is a relay between the AI client and the builder tab: a tool call comes in from the AI client, goes down the socket to the builder, and the builder's answer goes back the same way. It does not read what passes through, it has no database, and it writes nothing to disk. Once a call is answered, the server has forgotten it. What it does hold, in memory only, is what it needs to route calls and enforce limits: - the open builder tabs and their pairing keys, and which AI connection is paired with which tab; - how many calls each tab made in the current hour; - how many browsers use each license right now (by license number), for the seat limit; - the license service's answer about a key, for ten minutes, so it is not asked on every connection. A restart clears all of it. The server's log records only connection events: a tab opened or closed, a license refused or full, with the license number and a shortened key such as `NVB-…TWN5`. Structures, data, commands and answers never appear in it. Your conversation with the AI stays between you and your AI provider. The server only sees the tool calls the AI makes, while they are in transit. If even that should not leave your network, run the server yourself, as described below, and point the builder at it. ## Running the MCP server The server is the `ngx-view-builder-mcp` package. It is a router and nothing more: it holds the builder sockets, pairs AI connections to them, and forwards each call. What a command means is decided in the builder, so a new element type or command in the library needs no new server release. ```bash npx ngx-view-builder-mcp ``` It listens on port 3200 by default, with the MCP endpoint at `/mcp` and the builder socket at `/bridge`. | Variable | Default | What it does | | --- | --- | --- | | `NVB_MCP_PORT` | `3200` | Port. | | `NVB_MCP_ALLOWED_ORIGINS` | empty | Comma separated origins allowed to open `/bridge`. Empty means any, which is fine on your own machine and nowhere else. | | `NVB_MCP_MAX_CALLS_PER_SESSION` | `600` | Builder tool calls per tab per window. The builder shows a notice when a client runs out. | | `NVB_MCP_SESSION_LIMIT_WINDOW_MS` | `3600000` | Length of that window. | | `NVB_MCP_CHARACTER_LIMIT` | `60000` | Longest tool answer before it is cut, with a note telling the model to ask for a narrower slice. | | `NVB_MCP_CLIENT_IDLE_TIMEOUT_MS` | `43200000` | An MCP session nobody used for this long is closed, pairing included. | | `NVB_MCP_KEY_TTL_MS` | `86400000` | How long a pairing key is valid. | | `NVB_LICENSE_CHECK_URL` | empty | Where to ask whether a license key is active. Empty turns the license check off, so a self hosted server pairs any builder. | | `NVB_LICENSE_CHECK_TOKEN` | empty | Shared secret sent with that request as `x-internal-token`. | | `NVB_LICENSE_CACHE_TTL_MS` | `600000` | How long one answer about a key is reused before asking again. | | `NVB_MCP_RECHECK_INTERVAL_MS` | `3600000` | How often the licenses of connected tabs are checked again. A tab whose license stopped qualifying is disconnected. | | `NVB_MCP_LICENSE_EXEMPT_ORIGINS` | empty | Comma separated origins that pair without a license, such as a public demo. | | `NVB_MCP_DOCS_URL` | `https://ngxviewbuilder.io/llms-authoring.txt` | The AI reference `nvb_read_docs` serves. | | `NVB_MCP_REQUIRE_DOCS` | `on` | `off` lets `nvb_execute` work before the docs were read, for a server with no internet access. | | `NVB_MCP_SEAT_IDLE_MS` | `1800000` | When a license has every seat taken, a seat nobody used for this long goes to the browser that is waiting. | | `NVB_MCP_DEMO_MAX_TABS` | `30` | Tabs from exempt origins that may be connected at once, all of them together. | | `NVB_MCP_DEMO_MAX_CALLS_PER_SESSION` | `150` | Calls per window for a tab from an exempt origin. | | `NVB_MCP_DEMO_KEY_TTL_MS` | `7200000` | How long a pairing key from an exempt origin is valid. | | `NVB_MCP_DEMO_MAX_TABS_PER_IP` | `5` | Tabs from exempt origins one address may hold, so one network cannot take the whole demo pool. | | `NVB_MCP_MAX_CONNECTIONS` | `5000` | Open MCP sessions in total. When it is reached, a new session pushes out the unpaired one idle the longest, never a working pairing. | | `NVB_MCP_UNPAIRED_IDLE_TIMEOUT_MS` | `900000` | An MCP session that never paired is closed after this long without a request. | | `NVB_MCP_MAX_SOCKETS` | `10000` | Builder tabs connected in total. | | `NVB_MCP_MAX_SOCKETS_PER_IP` | `500` | Builder tabs connected from one address. An office behind one address fits easily. | | `NVB_MCP_HELLO_TIMEOUT_MS` | `15000` | A tab that does not introduce itself by then is closed. | | `NVB_MCP_TRUST_PROXY` | `loopback, linklocal, uniquelocal` | Which proxies may name the client address in `X-Forwarded-For` (Express `trust proxy`). The default takes it only from a proxy on the same machine or a private network. | | `NVB_MCP_ALLOWED_HOSTS` | empty | `Host` values `/mcp` answers to, comma separated. Set it to turn on DNS rebinding protection. | | `NVB_MCP_MAX_CONNECTIONS_PER_IP` | `0` (off) | Open MCP sessions per address. Leave it off when AI clients are hosted (claude.ai, ChatGPT): they reach the server from their provider's addresses, shared by everyone. | | `NVB_MCP_NEW_SESSIONS_PER_IP_PER_MINUTE` | `0` (off) | New MCP sessions per address per minute. Same caveat. | | `NVB_MCP_MAX_FAILED_PAIRS_PER_IP` | `0` (off) | Wrong pairing keys per address per 15 minutes. Same caveat. | The license and seat settings only matter when `NVB_LICENSE_CHECK_URL` is set. Seats are counted per license and per browser, never per address, so twenty people behind one office address with their own licenses each get their own seat. ### Seats A license lets as many browsers use AI access at the same time as it has seats. Several tabs in one browser count once, because the builder tells the server which browser it runs in. When every seat is taken, the next browser is turned away with a short message in the AI access group and tries again every minute, so it gets in as soon as a colleague disconnects. A seat also frees up when a laptop goes to sleep or loses its network, within about a minute. If everyone is connected but someone has not used the AI for half an hour, their seat goes to the person waiting; pressing **Connect** brings them back with their pairing intact. Nobody is disconnected for being idle while a seat is still free. Pairings live in memory. Restarting the server means pairing again, which the builder handles by itself and the AI client handles by asking for the key. ## Try it on the public demo AI access works on [demo.ngxviewbuilder.io/builder](https://demo.ngxviewbuilder.io/builder) without a license of your own. Press **Connect** under Settings → AI access (MCP), add `https://mcp.ngxviewbuilder.io/mcp` to your AI client, ask it to build something in the builder, and give it the session key when it asks. The demo shares its AI access between everyone trying it, so it is kept small: up to 30 people at a time, 150 calls per hour each, and a key that lasts two hours. If it is busy, the builder says so and tries again every minute. 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, right after pairing. `nvb_pair` says so in its answer. `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_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`, `objectPanel`, `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. `objectPanel` is laid out like `panel` but also changes where its children's values live: everything placed under it, at any depth, stores its value at `.`. An `addElement` or `insertJson` whose `parent` is an `objectPanel` or `dynamicPanel` defines the new elements in that panel's `template`, so a name only has to be free inside the panel: a `billing` and a `shipping` panel can each get a `city`. The data paths you read back from `getData()` and write in expressions are `billing.city` and `shipping.city`. Because two panels can hold the same name, commands also accept a **path** wherever they take an element name: `billing.city` is the `city` inside `billing`, and nesting goes deeper the same way (`order.address.city`). `getTree()` gives every node its `path`, so read it there rather than building one by hand. `getElement`, `updateElement`, `deleteElement`, `moveElement`, `renameElement` and `duplicateElement` all take one. A plain name still works when it is unique. Moving an element into or out of a panel moves its definition with it and fails with a clear error if the target already has an element of that name. `getSelection()` answers with the element the person clicked in the designer (`{ name, path, type }`), so an agent can act on "this field" without asking which one is meant. ```js await api.execute([ { op: 'addElement', type: 'objectPanel', name: 'address', properties: { label: 'Address' } }, { op: 'addElement', type: 'text', name: 'city', parent: 'address', properties: { label: 'City' } }, { op: 'addElement', type: 'text', name: 'street', parent: 'address', row: 0 }, ]); // getData() -> { address: { city: ..., street: ... } } ``` 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 the one service your application talks to. Inject it anywhere, in a page component, a resolver, a service of your own, and use it to read and change the view on screen, react to what happens in it, and plug your own functions, validators, elements and icons in. ```ts import { inject } from '@angular/core'; import { NgxViewBuilderApiService } from '@ngxviewbuilder/runtime'; export class ClientPage { private readonly api = inject(NgxViewBuilderApiService); async save(): Promise { const result = await this.api.validateData(); if (result.isValid) { await this.clients.save(this.api.getData()); } } } ``` An application that hosts the builder imports the same service from `@ngxviewbuilder/designer`. It is the same class. ## How a call reaches the view You always inject the root service. What it talks to depends on what is on screen: | On screen | Calls about the view (values, structure, elements, validation) go to | Registrations (functions, validators, elements, icons, theme) go to | | --- | --- | --- | | `` | that runtime view | the root and every runtime view, now and later | | several runtime views | the one created last | the root and every runtime view | | `` | the scope | the root and every scope | | `` | the view being edited (the builder owns it) | the root, the builder and its Preview | | nothing yet | the root itself | the root; views created later pick them up | Two things follow from that table. You can register everything at startup, before any view exists, and every view gets it. And you never need a reference to a particular runtime, because the root forwards. When several views render at once, every event still reaches the root and carries `elementName` and `elementDataPath`, which is how you tell them apart. ::: tip Waiting for a view Values and elements exist once the view has rendered. Subscribe to `onAfterRender`, or call the API from a button or an event handler, rather than straight from your constructor. ::: ## Reading this reference Each page groups the methods of one area. Every method shows its signature, what it does, an example, and what it returns. Types are written in TypeScript; `IStructure` is the view JSON described in [Structure JSON](https://ngxviewbuilder.io/developers/structure-json). | Page | What it covers | | --- | --- | | [Structure & settings](https://ngxviewbuilder.io/developers/api/structure) | Read, replace and patch the view definition and its settings | | [Data & values](https://ngxviewbuilder.io/developers/api/data) | Form data, single values, server errors, the dirty flag | | [Elements](https://ngxviewbuilder.io/developers/api/elements) | Finding elements, reading and writing their properties, the element handle, rows of repeaters, element defaults | | [Validation & lifecycle](https://ngxviewbuilder.io/developers/api/validation) | Validating the view or any structure, toasts, render and completion notifications | | [Data sources & variables](https://ngxviewbuilder.io/developers/api/data-sources) | Reloading sources, host sources, websocket authorization, runtime variables and external context | | [Language & theme](https://ngxviewbuilder.io/developers/api/language-theme) | Language, locale, translations, UI dictionaries, theme modes, tokens and custom CSS | | [Extensions](https://ngxviewbuilder.io/developers/api/extensions) | Expression functions, validator types, custom elements and properties, icons, table cells | | [Builder, templates & tables](https://ngxviewbuilder.io/developers/api/builder) | Template library, element library, table settings and filters, builder tabs | Events have their own page: [Events reference](https://ngxviewbuilder.io/developers/events). ## Every method at a glance | Area | Methods | | --- | --- | | Structure | [`getStructure`](https://ngxviewbuilder.io/developers/api/structure#getstructure) · [`getStructureModel`](https://ngxviewbuilder.io/developers/api/structure#getstructuremodel) · [`setStructure`](https://ngxviewbuilder.io/developers/api/structure#setstructure) · [`updateStructure`](https://ngxviewbuilder.io/developers/api/structure#updatestructure) · [`notifyStructureChanged`](https://ngxviewbuilder.io/developers/api/structure#notifystructurechanged) · [`captureStructureSource`](https://ngxviewbuilder.io/developers/api/structure#capturestructuresource-clearcapturedstructuresource) · [`clearCapturedStructureSource`](https://ngxviewbuilder.io/developers/api/structure#capturestructuresource-clearcapturedstructuresource) | | Settings | [`getSettings`](https://ngxviewbuilder.io/developers/api/structure#getsettings) · [`getSetting`](https://ngxviewbuilder.io/developers/api/structure#getsetting) · [`setSetting`](https://ngxviewbuilder.io/developers/api/structure#setsetting) · [`patchSettings`](https://ngxviewbuilder.io/developers/api/structure#patchsettings) · [`updateSettings`](https://ngxviewbuilder.io/developers/api/structure#updatesettings) · [`replaceSettings`](https://ngxviewbuilder.io/developers/api/structure#replacesettings) | | Data | [`getData`](https://ngxviewbuilder.io/developers/api/data#getdata) · [`setData`](https://ngxviewbuilder.io/developers/api/data#setdata) · [`setElementsData`](https://ngxviewbuilder.io/developers/api/data#setelementsdata) · [`setValue`](https://ngxviewbuilder.io/developers/api/data#setvalue) · [`setElementValue`](https://ngxviewbuilder.io/developers/api/data#setelementvalue) · [`getElementValue`](https://ngxviewbuilder.io/developers/api/data#getelementvalue) · [`clearValue`](https://ngxviewbuilder.io/developers/api/data#clearvalue) · [`clearValues`](https://ngxviewbuilder.io/developers/api/data#clearvalues) · [`clearElementValue`](https://ngxviewbuilder.io/developers/api/data#clearelementvalue) · [`resetElementValue`](https://ngxviewbuilder.io/developers/api/data#resetelementvalue) · [`clearData`](https://ngxviewbuilder.io/developers/api/data#cleardata) · [`isDirty`](https://ngxviewbuilder.io/developers/api/data#isdirty) · [`markPristine`](https://ngxviewbuilder.io/developers/api/data#markpristine) · [`setElementErrors`](https://ngxviewbuilder.io/developers/api/data#setelementerrors) · [`clearElementErrors`](https://ngxviewbuilder.io/developers/api/data#clearelementerrors) · [`getElementErrors`](https://ngxviewbuilder.io/developers/api/data#getelementerrors) | | Element lookup | [`getElement`](https://ngxviewbuilder.io/developers/api/elements#getelement) · [`getElementModel`](https://ngxviewbuilder.io/developers/api/elements#getelementmodel) · [`getElementLookup`](https://ngxviewbuilder.io/developers/api/elements#getelementlookup) · [`getElementByDataPath`](https://ngxviewbuilder.io/developers/api/elements#getelementbydatapath) · [`getElementBySchemaPath`](https://ngxviewbuilder.io/developers/api/elements#getelementbyschemapath) · [`getRenderedElements`](https://ngxviewbuilder.io/developers/api/elements#getrenderedelements) · [`getElementName` and friends](https://ngxviewbuilder.io/developers/api/elements#single-lookup-fields) · [`describeElement`](https://ngxviewbuilder.io/developers/api/elements#describeelement) | | DOM | [`getElementDom`](https://ngxviewbuilder.io/developers/api/elements#getelementdom) · [`getElementDomById`](https://ngxviewbuilder.io/developers/api/elements#getelementdombyid) · [`getElementDomByTestId`](https://ngxviewbuilder.io/developers/api/elements#getelementdombytestid) · [`queryRenderDom`](https://ngxviewbuilder.io/developers/api/elements#queryrenderdom) · [`queryRenderDomAll`](https://ngxviewbuilder.io/developers/api/elements#queryrenderdomall) · [`getRenderRoot`](https://ngxviewbuilder.io/developers/api/elements#getrenderroot-registerrenderroot) · [`registerRenderRoot`](https://ngxviewbuilder.io/developers/api/elements#getrenderroot-registerrenderroot) | | Properties | [`getElementProperty`](https://ngxviewbuilder.io/developers/api/elements#getelementproperty) · [`setElementProperty`](https://ngxviewbuilder.io/developers/api/elements#setelementproperty) · [`setElementProperties`](https://ngxviewbuilder.io/developers/api/elements#setelementproperties) · [`removeElementProperty`](https://ngxviewbuilder.io/developers/api/elements#removeelementproperty-removeelementproperties) · [`removeElementProperties`](https://ngxviewbuilder.io/developers/api/elements#removeelementproperty-removeelementproperties) · [`resetElementProperty`](https://ngxviewbuilder.io/developers/api/elements#resetelementproperty-resetelementproperties) · [`resetElementProperties`](https://ngxviewbuilder.io/developers/api/elements#resetelementproperty-resetelementproperties) · [`updateElement`](https://ngxviewbuilder.io/developers/api/elements#updateelement) · [`refreshElement`](https://ngxviewbuilder.io/developers/api/elements#refreshelement) | | Element handle | [`element`](https://ngxviewbuilder.io/developers/api/elements#element-getelementapi) · [`getElementApi`](https://ngxviewbuilder.io/developers/api/elements#element-getelementapi) · [rows](https://ngxviewbuilder.io/developers/api/elements#rows-of-a-dynamic-table-or-panel) · [columns](https://ngxviewbuilder.io/developers/api/elements#columns-of-a-repeater) · [cells](https://ngxviewbuilder.io/developers/api/elements#cells) | | Element defaults | [`setElementTypeDefault`](https://ngxviewbuilder.io/developers/api/elements#setelementtypedefault-setelementtypedefaults) · [`setElementTypeDefaults`](https://ngxviewbuilder.io/developers/api/elements#setelementtypedefault-setelementtypedefaults) · [`setElementDefaults`](https://ngxviewbuilder.io/developers/api/elements#setelementdefaults-defaults) · [`defaults`](https://ngxviewbuilder.io/developers/api/elements#setelementdefaults-defaults) · [`getElementDefaults`](https://ngxviewbuilder.io/developers/api/elements#getelementdefaults-clearelementdefaults) · [`clearElementDefaults`](https://ngxviewbuilder.io/developers/api/elements#getelementdefaults-clearelementdefaults) · [`getElementTypeBuiltInDefaults`](https://ngxviewbuilder.io/developers/api/elements#getelementtypebuiltindefaults) | | Validation | [`validateData`](https://ngxviewbuilder.io/developers/api/validation#validatedata) · [`showToast`](https://ngxviewbuilder.io/developers/api/validation#showtoast) · [`notifyValidationResult`](https://ngxviewbuilder.io/developers/api/validation#notifyvalidationresult) · [`notifyComplete`](https://ngxviewbuilder.io/developers/api/validation#notifycomplete) · [`notifySaveRequested`](https://ngxviewbuilder.io/developers/api/validation#notifysaverequested) | | Lifecycle | [render notifications](https://ngxviewbuilder.io/developers/api/validation#notifybeforerender-notifyrender-notifyafterrender) · [`notifyTabChanged`](https://ngxviewbuilder.io/developers/api/validation#notifytabchanged) · [`notifyCurrentPageChanged`](https://ngxviewbuilder.io/developers/api/validation#notifycurrentpagechanged) · [`notifyDialogClosed`](https://ngxviewbuilder.io/developers/api/validation#notifydialogclosed) · [element interaction notifications](https://ngxviewbuilder.io/developers/api/validation#element-interaction-notifications) | | Data sources | [`reloadDataSource`](https://ngxviewbuilder.io/developers/api/data-sources#reloaddatasource) · [`reloadElementDataSource`](https://ngxviewbuilder.io/developers/api/data-sources#reloadelementdatasource) · [`reloadElementsByDataSource`](https://ngxviewbuilder.io/developers/api/data-sources#reloadelementsbydatasource) · [`setDefaultDataSources`](https://ngxviewbuilder.io/developers/api/data-sources#setdefaultdatasources) · [`setDataSourceTypeSettings`](https://ngxviewbuilder.io/developers/api/data-sources#setdatasourcetypesettings-getdatasourcetypesettings) · [`getDataSourceTypeSettings`](https://ngxviewbuilder.io/developers/api/data-sources#setdatasourcetypesettings-getdatasourcetypesettings) · [`setWebsocketAuthorizer`](https://ngxviewbuilder.io/developers/api/data-sources#setwebsocketauthorizer) | | Variables | [`setRuntimeVariableContext`](https://ngxviewbuilder.io/developers/api/data-sources#setruntimevariablecontext) · [`getRuntimeVariableContext`](https://ngxviewbuilder.io/developers/api/data-sources#getruntimevariablecontext-clearruntimevariablecontext) · [`clearRuntimeVariableContext`](https://ngxviewbuilder.io/developers/api/data-sources#getruntimevariablecontext-clearruntimevariablecontext) · [`setRuntimeVariableDefinitions`](https://ngxviewbuilder.io/developers/api/data-sources#setruntimevariabledefinitions) · [`getRuntimeVariableDefinitions`](https://ngxviewbuilder.io/developers/api/data-sources#getruntimevariabledefinitions) · [`configureRuntimeVariables`](https://ngxviewbuilder.io/developers/api/data-sources#configureruntimevariables) | | Language | [`getLanguage`](https://ngxviewbuilder.io/developers/api/language-theme#getlanguage-setlanguage) · [`setLanguage`](https://ngxviewbuilder.io/developers/api/language-theme#getlanguage-setlanguage) · [`resolveHostLanguage`](https://ngxviewbuilder.io/developers/api/language-theme#resolvehostlanguage-applyhostlanguage) · [`applyHostLanguage`](https://ngxviewbuilder.io/developers/api/language-theme#resolvehostlanguage-applyhostlanguage) · [`startLanguageSync`](https://ngxviewbuilder.io/developers/api/language-theme#startlanguagesync) · [`setApplicationLocale`](https://ngxviewbuilder.io/developers/api/language-theme#setapplicationlocale-getapplicationlocale) · [`getApplicationLocale`](https://ngxviewbuilder.io/developers/api/language-theme#setapplicationlocale-getapplicationlocale) · [`setLocale`](https://ngxviewbuilder.io/developers/api/language-theme#setlocale) · [`setContentTranslations`](https://ngxviewbuilder.io/developers/api/language-theme#setcontenttranslations) · [`setUiDictionaries`](https://ngxviewbuilder.io/developers/api/language-theme#setuidictionaries-setuitranslations) · [`setUiTranslations`](https://ngxviewbuilder.io/developers/api/language-theme#setuidictionaries-setuitranslations) · [`translateUi`](https://ngxviewbuilder.io/developers/api/language-theme#translateui) · [`setPropertyHints`](https://ngxviewbuilder.io/developers/api/language-theme#setpropertyhints-setpropertytypehints) · [`setPropertyTypeHints`](https://ngxviewbuilder.io/developers/api/language-theme#setpropertyhints-setpropertytypehints) | | Theme | [`setThemeMode`](https://ngxviewbuilder.io/developers/api/language-theme#setthememode-getthememode) · [`getThemeMode`](https://ngxviewbuilder.io/developers/api/language-theme#setthememode-getthememode) · [`setTheme`](https://ngxviewbuilder.io/developers/api/language-theme#settheme) · [`setCustomTheme`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomtheme-getcustomtheme) · [`getCustomTheme`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomtheme-getcustomtheme) · [`setCssVariables`](https://ngxviewbuilder.io/developers/api/language-theme#setcssvariables-getcssvariables) · [`getCssVariables`](https://ngxviewbuilder.io/developers/api/language-theme#setcssvariables-getcssvariables) · [`setCustomCss`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomcss-getcustomcss) · [`getCustomCss`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomcss-getcustomcss) · [`setCustomCssUrls`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomcssurls-getcustomcssurls) · [`getCustomCssUrls`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomcssurls-getcustomcssurls) | | Extensions | [`registerExtensions`](https://ngxviewbuilder.io/developers/api/extensions#registerextensions-registerextensionsasync) · [`registerExtensionsAsync`](https://ngxviewbuilder.io/developers/api/extensions#registerextensions-registerextensionsasync) · [expression functions](https://ngxviewbuilder.io/developers/api/extensions#registerexpressionfunction-registerexpressionfunctions) · [validator types](https://ngxviewbuilder.io/developers/api/extensions#registervalidatortype-registervalidatortypes) · [`getRegisteredValidatorTypes`](https://ngxviewbuilder.io/developers/api/extensions#getregisteredvalidatortypes-clearregisteredvalidatortypes) · [`clearRegisteredValidatorTypes`](https://ngxviewbuilder.io/developers/api/extensions#getregisteredvalidatortypes-clearregisteredvalidatortypes) · [custom elements](https://ngxviewbuilder.io/developers/api/extensions#registercustomelement-registercustomelements) · [`registerElementGroup`](https://ngxviewbuilder.io/developers/api/extensions#registerelementgroup) · [element properties](https://ngxviewbuilder.io/developers/api/extensions#element-properties) · [SVG icons](https://ngxviewbuilder.io/developers/api/extensions#svg-icons) · [table cell types](https://ngxviewbuilder.io/developers/api/extensions#table-cell-element-types) · [`getTableHeaderCenterExtensions`](https://ngxviewbuilder.io/developers/api/extensions#gettableheadercenterextensions) | | Templates | [`getTemplates`](https://ngxviewbuilder.io/developers/api/builder#gettemplates) · [`loadTemplates`](https://ngxviewbuilder.io/developers/api/builder#loadtemplates-settemplates) · [`saveTemplate`](https://ngxviewbuilder.io/developers/api/builder#savetemplate-upserttemplate) · [`deleteTemplate`](https://ngxviewbuilder.io/developers/api/builder#deletetemplate-removetemplate) · [`setTemplateActionMap`](https://ngxviewbuilder.io/developers/api/builder#settemplateactionmap-registertemplateactiondatasource) · [`registerTemplateActionDataSource`](https://ngxviewbuilder.io/developers/api/builder#settemplateactionmap-registertemplateactiondatasource) | | Element library | [`loadSidebarGroups`](https://ngxviewbuilder.io/developers/api/builder#loadsidebargroups) · [`saveSidebarGroup`](https://ngxviewbuilder.io/developers/api/builder#savesidebargroup-upsertsidebargroup) · [`deleteSidebarGroup`](https://ngxviewbuilder.io/developers/api/builder#deletesidebargroup-removesidebargroup) | | Tables | [`setTableSettings`](https://ngxviewbuilder.io/developers/api/builder#settablesettings-gettablesettings) · [`getTableSettings`](https://ngxviewbuilder.io/developers/api/builder#settablesettings-gettablesettings) · [`setTableFilters`](https://ngxviewbuilder.io/developers/api/builder#settablefilters-gettablefilters) · [`getTableFilters`](https://ngxviewbuilder.io/developers/api/builder#settablefilters-gettablefilters) · [`setTableSavedFilters`](https://ngxviewbuilder.io/developers/api/builder#settablesavedfilters-gettablesavedfilters) · [`getTableSavedFilters`](https://ngxviewbuilder.io/developers/api/builder#settablesavedfilters-gettablesavedfilters) · [`request*` methods](https://ngxviewbuilder.io/developers/api/builder#asking-the-host-for-table-state) | | Builder | [`switchToTab`](https://ngxviewbuilder.io/developers/api/builder#switchtotab-requesttabchange) · [`requestTabChange`](https://ngxviewbuilder.io/developers/api/builder#switchtotab-requesttabchange) · [`isBuilderAttached`](https://ngxviewbuilder.io/developers/api/builder#isbuilderattached-getbuilderadapter-attachbuilderadapter) · [`getBuilderAdapter`](https://ngxviewbuilder.io/developers/api/builder#isbuilderattached-getbuilderadapter-attachbuilderadapter) · [`attachBuilderAdapter`](https://ngxviewbuilder.io/developers/api/builder#isbuilderattached-getbuilderadapter-attachbuilderadapter) · [`setAiAssistantConfig`](https://ngxviewbuilder.io/developers/api/builder#setaiassistantconfig-getaiassistantconfig) · [`getAiAssistantConfig`](https://ngxviewbuilder.io/developers/api/builder#setaiassistantconfig-getaiassistantconfig) | --- ## API: builder, templates & tables Source: https://ngxviewbuilder.io/developers/api/builder # Builder, templates & tables | Method | Returns | In short | | --- | --- | --- | | [`getTemplates()`](https://ngxviewbuilder.io/developers/api/builder#gettemplates) | `ITemplateDefinition[]` | The template library | | [`loadTemplates` / `setTemplates`](https://ngxviewbuilder.io/developers/api/builder#loadtemplates-settemplates) | `void` | Hand stored templates in | | [`saveTemplate` / `upsertTemplate`](https://ngxviewbuilder.io/developers/api/builder#savetemplate-upserttemplate) | `void` | Add or change one | | [`deleteTemplate` / `removeTemplate`](https://ngxviewbuilder.io/developers/api/builder#deletetemplate-removetemplate) | `void` | Remove one | | [`setTemplateActionMap` / `registerTemplateActionDataSource`](https://ngxviewbuilder.io/developers/api/builder#settemplateactionmap-registertemplateactiondatasource) | `void` | Wire template actions | | [`loadSidebarGroups`](https://ngxviewbuilder.io/developers/api/builder#loadsidebargroups) | `void` | Hand stored library items in | | [`saveSidebarGroup` / `upsertSidebarGroup`](https://ngxviewbuilder.io/developers/api/builder#savesidebargroup-upsertsidebargroup) | `void` | Add or change one | | [`deleteSidebarGroup` / `removeSidebarGroup`](https://ngxviewbuilder.io/developers/api/builder#deletesidebargroup-removesidebargroup) | `void` | Remove one | | [`setTableSettings` / `getTableSettings`](https://ngxviewbuilder.io/developers/api/builder#settablesettings-gettablesettings) | `void` / settings | Column layout per table | | [`setTableFilters` / `getTableFilters`](https://ngxviewbuilder.io/developers/api/builder#settablefilters-gettablefilters) | `void` / filters | Active filters | | [`setTableSavedFilters` / `getTableSavedFilters`](https://ngxviewbuilder.io/developers/api/builder#settablesavedfilters-gettablesavedfilters) | `void` / list | Saved filter sets | | [`request*` methods](https://ngxviewbuilder.io/developers/api/builder#asking-the-host-for-table-state) | `void` | Ask the host for stored table state | | [`switchToTab` / `requestTabChange`](https://ngxviewbuilder.io/developers/api/builder#switchtotab-requesttabchange) | `void` | Builder navigation | | [`isBuilderAttached`, `getBuilderAdapter`, `attachBuilderAdapter`](https://ngxviewbuilder.io/developers/api/builder#isbuilderattached-getbuilderadapter-attachbuilderadapter) | | Is a builder on screen? | | [`setAiAssistantConfig` / `getAiAssistantConfig`](https://ngxviewbuilder.io/developers/api/builder#setaiassistantconfig-getaiassistantconfig) | `void` / config | AI assistant backend | ## Templates Templates are reusable HTML snippets creators manage in the Templates tab and use in tables, lists and cards ([Templates](https://ngxviewbuilder.io/creators/templates)). The library is yours to store: the builder tells you when a creator saves or deletes one (`(templateSaved)`, `(templateDeleted)`), and you hand the stored ones back on startup. A saved view only carries the templates its elements use. ```ts // startup api.loadTemplates(await firstValueFrom(http.get('/api/templates'))); // persistence api.onTemplateSaved.add(({ template, previousCode }) => http.put(`/api/templates/${template.code}`, template).subscribe()); api.onTemplateDeleted.add(({ code }) => http.delete(`/api/templates/${code}`).subscribe()); ``` A template is: ```ts { name: 'clientCard', // the code elements refer to; `code` is accepted as well title: 'Client card', content: '
{{name}}
', // `html` is accepted as well css: '.card { padding: 8px; }', requestBodyJson: '', extraJsonSettings: '', } ``` ### `getTemplates()` ```ts getTemplates(): ITemplateDefinition[] ``` The whole library: in the builder every template of the Templates tab, in a runtime every template of the view. ```ts api.getTemplates().map((t) => t.name); // ['clientCard', 'orderRow'] ``` ### `loadTemplates()` / `setTemplates()` ```ts loadTemplates(templates: INgxViewBuilderTemplateInput[], replace = true): void setTemplates(templates: INgxViewBuilderTemplateInput[], replace = true): void ``` Replaces the library, or merges by name with `replace = false`. Fires `onTemplatesLoaded`; the Templates tab shows them at once. ### `saveTemplate()` / `upsertTemplate()` ```ts saveTemplate(template: INgxViewBuilderTemplateInput, previousCode?: string): void upsertTemplate(template: INgxViewBuilderTemplateInput): void ``` Adds or replaces one template; `previousCode` renames. Other templates and the view stay as they are. Fires `onTemplateSaved`. ```ts api.saveTemplate({ code: 'clientCard', content: '{{name}}' }); api.saveTemplate({ code: 'customerCard', content: '{{name}}' }, 'clientCard'); // rename ``` ### `deleteTemplate()` / `removeTemplate()` ```ts deleteTemplate(code: string): void removeTemplate(name: string): void ``` Fires `onTemplateDeleted`. ### `setTemplateActionMap()` / `registerTemplateActionDataSource()` ```ts setTemplateActionMap(map: Record): void registerTemplateActionDataSource(functionName: string, dataSourceName: string): void ``` A template can call a function on click, `(click)="loadClient(row.id)"`. These map such function names to data sources: the click runs the source with the arguments as its parameters. `setTemplateActionMap` replaces the whole map, `registerTemplateActionDataSource` adds one entry. ```ts api.setTemplateActionMap({ loadClient: 'getClientById', archive: 'archiveClient' }); api.registerTemplateActionDataSource('loadOrders', 'getOrdersByClient'); ``` ## The element library Creators save configured elements and whole sections into the builder's library (*Copies*, from an element's right click menu) and drag them into other views. Like templates, the library is yours to store. ```ts api.loadSidebarGroups(await firstValueFrom(http.get('/api/builder-library'))); api.onSidebarGroupSaved.add(({ group }) => http.put(`/api/builder-library/${group.id}`, group).subscribe()); api.onSidebarGroupDeleted.add(({ code }) => http.delete(`/api/builder-library/${code}`).subscribe()); ``` An item carries the element and, for containers, the layout below it: ```ts { id: 'b7c1e0f2', // `code` is accepted as well label: 'Address block', type: 'panel', icon: 'dashboard', // optional, the type's icon by default element: { name: 'address', type: 'panel', label: 'Address' }, segmentRows: [...], // rows inside the element segmentElements: { street: {...}, city: {...} }, createdAt: '2026-10-08T09:30:00.000Z', } ``` ### `loadSidebarGroups()` ```ts loadSidebarGroups(groups: INgxViewBuilderSidebarGroupInput[], replace = true): void setSidebarGroups(groups, replace = true): void setSidebarTemplates(templates, replace = true): void ``` Replaces the library, or adds to it with `replace = false`. The three names do the same. Fires `onSidebarGroupsLoaded`. ### `saveSidebarGroup()` / `upsertSidebarGroup()` ```ts saveSidebarGroup(group: INgxViewBuilderSidebarGroupInput, previousCode?: string): void upsertSidebarGroup(group: INgxViewBuilderSidebarGroupInput): void ``` Adds or replaces one item. Only `element` is required; a missing `id`, `label` or `type` is filled in. Fires `onSidebarGroupSaved`. ```ts api.saveSidebarGroup({ id: 'vatField', label: 'VAT code', element: { name: 'vatCode', type: 'text', label: 'VAT code', placeholder: 'LT100000000000' }, }); ``` ### `deleteSidebarGroup()` / `removeSidebarGroup()` ```ts deleteSidebarGroup(code: string): void removeSidebarGroup(code: string): void ``` Fires `onSidebarGroupDeleted`. ## Tables Tables let people hide and reorder columns, filter and save filter sets. Store those per user and table and hand them back, so the table opens the way the person left it. Identify a table by its element name. ### `setTableSettings()` / `getTableSettings()` ```ts setTableSettings(identifier: string, settings: INgxViewBuilderTableColumnSetting[], options?: { tableName?: string; elementName?: string; source?: 'host' | 'localStorage' | 'dataSource' | 'user' }): void getTableSettings(identifier: string): INgxViewBuilderTableColumnSetting[] | null ``` ```ts api.setTableSettings('clients', [ { key: 'name', visible: true, order: 0, width: '240px' }, { key: 'email', visible: true, order: 1 }, { key: 'createdAt', visible: false, order: 2 }, ], { source: 'host' }); ``` The table applies it at once. Fires `onTableSettingsChanged` and `onTableSettingsApplied`; when the person changes columns, `onTableSettingsChanged` comes with `source: 'user'`. ### `setTableFilters()` / `getTableFilters()` ```ts setTableFilters(identifier: string, filters: unknown[], options?: { tableName?: string; elementName?: string; source?: 'host' | 'user' | 'savedFilter'; savedFilter?: INgxViewBuilderTableSavedFilter | null }): void getTableFilters(identifier: string): unknown[] | null ``` Applies detailed search filters. Each filter names a column, a condition and a value: ```ts api.setTableFilters('clients', [ { key: 'status', operator: '=', value: 'active', type: 'text' }, { key: 'name', operator: '%-%', value: 'har', type: 'text' }, { key: 'createdAt', operator: '>=', value: '2026-01-01', type: 'date' }, ]); ``` | `operator` | Meaning | | --- | --- | | `%-%` / `!%-%` | contains / does not contain | | `%-` / `-%` | starts with / ends with | | `=` / `!=` | equals / not equals | | `>` `>=` `<` `<=` | number and date comparisons | Fires `onTableFiltersChanged` and `onTableFiltersApplied`. ### `setTableSavedFilters()` / `getTableSavedFilters()` ```ts setTableSavedFilters(identifier: string, filters: INgxViewBuilderTableSavedFilter[], options?): void getTableSavedFilters(identifier: string): INgxViewBuilderTableSavedFilter[] | null ``` ```ts api.setTableSavedFilters('clients', [ { id: 'f1', name: 'Active in Vilnius', filters: [ { key: 'status', operator: '=', value: 'active', type: 'text' }, { key: 'city', operator: '=', value: 'Vilnius', type: 'text' }, ] }, ]); ``` ### Asking the host for table state ```ts requestTableSettings(tableName: string, elementName: string, currentSettings: INgxViewBuilderTableColumnSetting[]): void requestTableSettingsSave(tableName: string, elementName: string, settings: INgxViewBuilderTableColumnSetting[]): void requestTableSavedFilters(tableName: string, elementName: string, currentFilters: INgxViewBuilderTableSavedFilter[]): void requestTableSavedFilterSave(tableName: string, elementName: string, filter: INgxViewBuilderTableSavedFilter, currentFilters: INgxViewBuilderTableSavedFilter[]): void ``` A table whose settings or saved filters live with the host calls these; each fires the matching `onTable...Requested` event. Answer with the setters above: ```ts api.onTableSettingsRequested.add(async ({ tableName }) => { const stored = await prefs.get(`table:${tableName}`); if (stored) api.setTableSettings(tableName, stored, { source: 'host' }); }); api.onTableSettingsSaveRequested.add(({ tableName, settings }) => prefs.set(`table:${tableName}`, settings)); ``` ## The builder ### `switchToTab()` / `requestTabChange()` ```ts switchToTab(tab: 'editor' | 'preview' | 'jsonEditor' | 'formSettings' | 'translations' | 'variables' | string): void requestTabChange(tab: string): void ``` `switchToTab` changes the builder's tab; a plugin tab is addressed by its code (`'templates'`). `requestTabChange` only fires `onTabChangeRequested`, for hosts that wrap the navigation themselves. Without a builder on screen both do nothing visible. ```ts api.switchToTab('preview'); ``` ### `isBuilderAttached()` / `getBuilderAdapter()` / `attachBuilderAdapter()` ```ts isBuilderAttached(): boolean getBuilderAdapter(): INgxViewBuilderBuilderAdapter | null attachBuilderAdapter(adapter: INgxViewBuilderBuilderAdapter | null): void ``` `isBuilderAttached()` tells shared code whether it runs next to the builder or a plain runtime. The adapter is how the builder connects itself; hosts do not attach one. ```ts if (api.isBuilderAttached()) { // designer page: the root owns the edited view } ``` ### `setAiAssistantConfig()` / `getAiAssistantConfig()` ```ts setAiAssistantConfig(config: { backendUrl?: string | null } | null): void getAiAssistantConfig(): { backendUrl?: string | null } ``` Fires `onAiAssistantConfigChanged`. For connecting an AI client to the builder, see [AI command API](https://ngxviewbuilder.io/developers/ai-command-api). --- ## API: data sources & variables Source: https://ngxviewbuilder.io/developers/api/data-sources # Data sources & variables | Method | Returns | In short | | --- | --- | --- | | [`reloadDataSource(element, source?, context?)`](https://ngxviewbuilder.io/developers/api/data-sources#reloaddatasource) | `Promise` | Reload one element's source | | [`reloadElementDataSource(element, context?)`](https://ngxviewbuilder.io/developers/api/data-sources#reloadelementdatasource) | `Promise` | Same, the element's own source | | [`reloadElementsByDataSource(source, context?)`](https://ngxviewbuilder.io/developers/api/data-sources#reloadelementsbydatasource) | `Promise` | Every element using a source | | [`setDefaultDataSources(sources, overwrite?)`](https://ngxviewbuilder.io/developers/api/data-sources#setdefaultdatasources) | `void` | Add host sources to every view | | [`setDataSourceTypeSettings` / `getDataSourceTypeSettings`](https://ngxviewbuilder.io/developers/api/data-sources#setdatasourcetypesettings-getdatasourcetypesettings) | `void` / settings | Switch optional source types on | | [`setWebsocketAuthorizer(fn)`](https://ngxviewbuilder.io/developers/api/data-sources#setwebsocketauthorizer) | `void` | Put credentials on websocket connections | | [`setRuntimeVariableContext(values, merge?)`](https://ngxviewbuilder.io/developers/api/data-sources#setruntimevariablecontext) | `void` | The `{__external.*}` context | | [`getRuntimeVariableContext` / `clearRuntimeVariableContext`](https://ngxviewbuilder.io/developers/api/data-sources#getruntimevariablecontext-clearruntimevariablecontext) | object / `void` | Read or clear it | | [`setRuntimeVariableDefinitions(defs, replace?)`](https://ngxviewbuilder.io/developers/api/data-sources#setruntimevariabledefinitions) | `void` | Define `{__variables.*}` | | [`getRuntimeVariableDefinitions()`](https://ngxviewbuilder.io/developers/api/data-sources#getruntimevariabledefinitions) | `IRuntimeVariableDefinition[]` | Read them | | [`configureRuntimeVariables(config)`](https://ngxviewbuilder.io/developers/api/data-sources#configureruntimevariables) | `void` | Both in one call | ## Data sources Requests go through your Angular `HttpClient`, so interceptors apply. Every load fires [`onDataSourceLoading`, `onDataSourceLoaded` or `onDataSourceLoadFailed`](https://ngxviewbuilder.io/developers/events#data-sources). ### `reloadDataSource()` ```ts reloadDataSource(elementNameOrPath: string, dataSourceName?: string, runtimeContext?: Record): Promise ``` Loads the source bound to an element again (or another source, for that element) and returns the result. `runtimeContext` fills placeholders the element's own parameters do not. ```ts await api.reloadDataSource('country'); // [{ code: 'LT', name: 'Lithuania' }, { code: 'LV', name: 'Latvia' }, { code: 'EE', name: 'Estonia' }] await api.reloadDataSource('clientCard', 'loadClient', { id: 42 }); // { id: 42, name: 'Harbor Foods', ... } with url /api/clients/{id} ``` Resolves to `null` when the element or source does not exist. A failed request fires `onDataSourceLoadFailed` with the error, so one global handler can report it. ### `reloadElementDataSource()` ```ts reloadElementDataSource(elementNameOrPath: string, runtimeContext?: Record): Promise ``` The element's own source, no name needed. ### `reloadElementsByDataSource()` ```ts reloadElementsByDataSource(dataSourceName: string, runtimeContext?: Record): Promise ``` Reloads every element bound to the source, one result per element. Handy after a save: everything that shows clients refreshes. ```ts await http.post('/api/clients', client); await api.reloadElementsByDataSource('loadClients'); ``` ### `setDefaultDataSources()` ```ts setDefaultDataSources(dataSources: IDataSource[], overwriteExisting = false): void ``` Adds sources your application owns to the view, so creators can bind elements to them without defining them. They are remembered: a view loaded or a runtime created later gets them too. A view's own source of the same name wins unless you pass `overwriteExisting`. ```ts api.setDefaultDataSources([ { name: 'countries', title: 'Countries', type: 'rest', params: { url: '/api/countries', method: 'GET' } }, { name: 'currentUser', type: 'rest', params: { url: '/api/me', method: 'GET' } }, ]); api.getStructure()?.dataSources?.map((s) => s.name); // ['loadClients', 'countries', 'currentUser'] ``` `runtimeSettings.defaultDataSources` on the components does the same declaratively. ### `setDataSourceTypeSettings()` / `getDataSourceTypeSettings()` ```ts setDataSourceTypeSettings(settings: { enableWebsocket?: boolean }): void getDataSourceTypeSettings(): { enableWebsocket?: boolean } ``` Optional source types creators may pick in the builder. Fires `onDataSourceTypeSettingsChanged`. ```ts api.setDataSourceTypeSettings({ enableWebsocket: true }); api.getDataSourceTypeSettings(); // { enableWebsocket: true } ``` ### `setWebsocketAuthorizer()` ```ts setWebsocketAuthorizer(authorizer: (context: { sourceName: string; url: string; protocols: string[] }) => { url?: string; protocols?: string[] } | null | Promise<{ url?: string; protocols?: string[] } | null>): void ``` Runs before every websocket connection and reconnection; return a changed url or subprotocol list to carry credentials, or `null` to leave it. ```ts api.setWebsocketAuthorizer(async ({ url }) => { const token = await auth.getToken(); return { url: `${url}?token=${encodeURIComponent(token)}` }; }); ``` See [WebSocket security](https://ngxviewbuilder.io/developers/data-sources#security-authorizing-the-connection). ## Runtime variables Expressions read host context as `{__external.name}` and variables as `{__variables.name}`. The [Runtime variables](https://ngxviewbuilder.io/developers/runtime-variables) page explains the variable types. ### `setRuntimeVariableContext()` ```ts setRuntimeVariableContext(values: Record, merge = true): void ``` The external context. Set it once at startup and every view sees it; change it later and expressions that read it recalculate. ```ts api.setRuntimeVariableContext({ userId: 'u-42', userRole: 'admin', tenant: 'acme' }); // in the view: disableIf: {__external.userRole} != "admin" ``` ### `getRuntimeVariableContext()` / `clearRuntimeVariableContext()` ```ts getRuntimeVariableContext(): Record clearRuntimeVariableContext(): void ``` ```ts api.getRuntimeVariableContext(); // { userId: 'u-42', userRole: 'admin', tenant: 'acme' } ``` ### `setRuntimeVariableDefinitions()` ```ts setRuntimeVariableDefinitions(definitions: IRuntimeVariableDefinition[], replace = true): void ``` Writes the view's variable definitions (the Variables tab) and resolves them. With `replace = false` they are added to the view's own, a definition with the same name replacing the old one. ```ts api.setRuntimeVariableDefinitions([ { name: 'clientId', sourceType: 'route', source: 'id', fallbackValue: '' }, { name: 'isEdit', sourceType: 'expression', expression: 'notEmpty({__variables.clientId})' }, { name: 'apiBase', sourceType: 'constant', constantValue: '/api/v2' }, ], false); api.getData().__variables; // { route: {...}, clientId: '42', isEdit: true, apiBase: '/api/v2', ... } ``` ### `getRuntimeVariableDefinitions()` ```ts getRuntimeVariableDefinitions(): IRuntimeVariableDefinition[] ``` ```ts api.getRuntimeVariableDefinitions().map((d) => d.name); // ['clientId', 'isEdit', 'apiBase'] ``` ### `configureRuntimeVariables()` ```ts configureRuntimeVariables(config: { mappings?: IRuntimeVariableDefinition[]; external?: Record; mergeExternal?: boolean }): void ``` Definitions and context in one call. ```ts api.configureRuntimeVariables({ mappings: [{ name: 'clientId', sourceType: 'route', source: 'id' }], external: { userName: auth.userName }, }); ``` Values an action or `setValue()` writes into `__variables` stay through navigation, including a change of the query string; they belong to the session until the view is closed. --- ## API: data & values Source: https://ngxviewbuilder.io/developers/api/data # Data & values The data of a view is one object: every element stores its value under its `name` (or its data path inside panels and repeaters). These methods read and write that object, one value or all of it, and keep track of whether the person changed anything. | Method | Returns | In short | | --- | --- | --- | | [`getData()`](https://ngxviewbuilder.io/developers/api/data#getdata) | `Record` | The whole data object | | [`setData(data)`](https://ngxviewbuilder.io/developers/api/data#setdata) | `void` | Replace all data | | [`setElementsData(data)`](https://ngxviewbuilder.io/developers/api/data#setelementsdata) | `Promise` | Merge several values | | [`setValue(path, value)`](https://ngxviewbuilder.io/developers/api/data#setvalue) | `Promise` | Set one value like a person would | | [`setElementValue(path, value)`](https://ngxviewbuilder.io/developers/api/data#setelementvalue) | `Promise` | Same as `setValue` | | [`getElementValue(path)`](https://ngxviewbuilder.io/developers/api/data#getelementvalue) | `unknown` | Read one value | | [`clearValue(path)`](https://ngxviewbuilder.io/developers/api/data#clearvalue) | `boolean` | Remove one key from the data | | [`clearValues(paths)`](https://ngxviewbuilder.io/developers/api/data#clearvalues) | `number` | Remove several keys | | [`clearElementValue(nameOrPath)`](https://ngxviewbuilder.io/developers/api/data#clearelementvalue) | `boolean` | Empty one field | | [`resetElementValue(nameOrPath)`](https://ngxviewbuilder.io/developers/api/data#resetelementvalue) | `Promise` | Back to the default value | | [`clearData()`](https://ngxviewbuilder.io/developers/api/data#cleardata) | `boolean` | Empty the whole form | | [`isDirty()`](https://ngxviewbuilder.io/developers/api/data#isdirty) | `boolean` | Did anything change since load? | | [`markPristine()`](https://ngxviewbuilder.io/developers/api/data#markpristine) | `void` | Treat the current data as saved | | [`setElementErrors(nameOrPath, errors)`](https://ngxviewbuilder.io/developers/api/data#setelementerrors) | `boolean` | Show server errors on a field | | [`clearElementErrors(nameOrPath)`](https://ngxviewbuilder.io/developers/api/data#clearelementerrors) | `boolean` | Remove them | | [`getElementErrors(nameOrPath)`](https://ngxviewbuilder.io/developers/api/data#getelementerrors) | `string[]` | Read them | ## The data object ### `getData()` ```ts getData(): Record ``` A copy of the current data. Keys starting with `__` belong to the library (route, external context, variables); leave them out when you save. ```ts const data = api.getData(); // { // firstName: 'Ada', // clientType: 'company', // address: { city: 'Vilnius', street: 'Gedimino pr. 1' }, // orders: [{ sku: 'A-100', amount: 2 }], // __variables: { route: {...}, picked: 'V21' }, // __external: { userRole: 'admin' } // } const toSave = Object.fromEntries(Object.entries(data).filter(([key]) => !key.startsWith('__'))); ``` The runtime component also has `getDataSnapshot()`, which returns the same object. ### `setData()` ```ts setData(data: Record): void ``` Replaces all data, typically with a record loaded for editing. Fields without a key in it become empty. The result counts as the loaded state, so `isDirty()` stays `false`. ```ts const client = await firstValueFrom(http.get(`/api/clients/${id}`)); api.setData(client); ``` ### `setElementsData()` ```ts setElementsData(data: Record): Promise ``` Merges values into the current data and leaves every other field alone. Each key goes through [`setValue`](https://ngxviewbuilder.io/developers/api/data#setvalue), so expressions, logic and change events run for it. ```ts await api.setElementsData({ city: 'Kaunas', zip: 'LT-44280' }); api.getData(); // firstName, email and the rest unchanged; city and zip updated ``` ## One value ### `setValue()` ```ts setValue(dataPath: string, value: unknown): Promise ``` Sets one value the way a person typing would: the field shows it, expressions that depend on it recalculate, `visibleIf` and friends re-run, `onValueChanged` fires. Paths reach into panels and repeaters. ```ts await api.setValue('clientType', 'company'); // the company panel appears await api.setValue('address.city', 'Vilnius'); // object panel field await api.setValue('orders[0].amount', 3); // first row of a repeater await api.setValue('orders', [{ sku: 'A', amount: 1 }, { sku: 'B', amount: 2 }]); // all rows ``` The promise resolves once the value is written; dependent expressions finish a moment later. ### `setElementValue()` ```ts setElementValue(elementDataPath: string, value: unknown): Promise ``` Another name for `setValue`, for code that reads better with it. ### `getElementValue()` ```ts getElementValue(elementDataPath: string): unknown ``` ```ts api.getElementValue('total'); // 30 api.getElementValue('orders[1].sku'); // 'B' api.getElementValue('unknown'); // undefined ``` ### `clearValue()` ```ts clearValue(dataPath: string, options?: { clearErrors?: boolean }): boolean ``` Removes the key from the data object (it is gone from `getData()`, not set to `null`) and empties the field on screen. Errors shown on it are cleared too unless you pass `clearErrors: false`. Returns `false` when the key was not there. ```ts api.clearValue('companyCode'); // true ``` ### `clearValues()` ```ts clearValues(dataPaths: readonly string[], options?: { clearErrors?: boolean }): number ``` Several at once; returns how many keys were removed. ```ts api.clearValues(['companyCode', 'vatCode', 'notThere']); // 2 ``` ### `clearElementValue()` ```ts clearElementValue(elementNameOrPath: string, options?: { clearErrors?: boolean }): boolean ``` Like `clearValue`, addressed by element name. ### `resetElementValue()` ```ts resetElementValue(elementNameOrPath: string): Promise ``` Puts the element back on its **Default value**, or empties it when it has none. ```ts await api.setValue('quantity', 7); await api.resetElementValue('quantity'); // true, quantity is 1 again (its default) ``` ### `clearData()` ```ts clearData(): boolean ``` Empties every field. The library's own keys stay. ## Unsaved changes ### `isDirty()` ```ts isDirty(): boolean ``` `true` once the data differs from what was loaded or last marked as saved. Default values, expressions and load events that fill the form when it opens are part of the loaded state, so a view that was only opened is not dirty. ```ts canDeactivate(): boolean { return !this.api.isDirty() || confirm('Leave without saving?'); } ``` ### `markPristine()` ```ts markPristine(): void ``` Takes the current data as the new saved state, typically right after your save succeeded. A successful Submit does this on its own. ```ts await firstValueFrom(http.put(`/api/clients/${id}`, api.getData())); api.markPristine(); api.isDirty(); // false ``` ## Server errors Errors you attach show under the field exactly like validator messages, and count in validation until you clear them. ### `setElementErrors()` ```ts setElementErrors(nameOrPath: string, errors: string | readonly string[]): boolean ``` ```ts const res = await firstValueFrom(http.post('/api/clients', api.getData()), { defaultValue: null }); if (res?.errors?.email) { api.setElementErrors('email', ['This e-mail is already registered']); } ``` Returns `false` when no such element exists. ### `clearElementErrors()` ```ts clearElementErrors(nameOrPath: string): boolean ``` ### `getElementErrors()` ```ts getElementErrors(nameOrPath: string): string[] ``` ```ts api.getElementErrors('email'); // ['This e-mail is already registered'] ``` The [element handle](https://ngxviewbuilder.io/developers/api/elements#element-getelementapi) has the same three as `setErrors`, `clearErrors` and `getErrors`. --- ## API: elements Source: https://ngxviewbuilder.io/developers/api/elements # Elements Every element of a view can be found by name, data path or schema path, and every property the builder shows can be read and written from code. A write shows on screen at once: change `label`, `required`, `disabled`, `readOnly` or `hidden` and the field updates, whatever its type. The change also lands in [`getStructure()`](https://ngxviewbuilder.io/developers/api/structure#getstructure). All methods below accept a **name** (`email`), a **data path** (`address.city`, `orders[0].sku`) or a **schema path**. | Method | Returns | In short | | --- | --- | --- | | [`getElementModel(nameOrPath)`](https://ngxviewbuilder.io/developers/api/elements#getelementmodel) | `ElementBaseModel \| null` | The live model | | [`getElement(nameOrPath)`](https://ngxviewbuilder.io/developers/api/elements#getelement) | `unknown` | Same, untyped | | [`getElementLookup(nameOrPath)`](https://ngxviewbuilder.io/developers/api/elements#getelementlookup) | `INgxViewBuilderElementLookupResult \| null` | Model, paths, ids and DOM in one record | | [`getElementByDataPath(path)` / `getElementBySchemaPath(path)`](https://ngxviewbuilder.io/developers/api/elements#getelementbydatapath) | model or `null` | Lookup by one kind of path | | [`getRenderedElements()`](https://ngxviewbuilder.io/developers/api/elements#getrenderedelements) | lookup records | Everything currently on screen | | [single lookup fields](https://ngxviewbuilder.io/developers/api/elements#single-lookup-fields) | `string \| null` | Name, paths, type, id, test id | | [`describeElement(nameOrPath)`](https://ngxviewbuilder.io/developers/api/elements#describeelement) | `Record \| null` | Readable dump of the set properties | | [`getElementDom(nameOrPath)` and friends](https://ngxviewbuilder.io/developers/api/elements#getelementdom) | `HTMLElement \| null` | DOM nodes | | [`getElementProperty` / `setElementProperty`](https://ngxviewbuilder.io/developers/api/elements#getelementproperty) | `unknown` / `boolean` | Read or write one property | | [`setElementProperties`](https://ngxviewbuilder.io/developers/api/elements#setelementproperties) | `boolean` | Write several | | [`removeElementProperty(ies)`](https://ngxviewbuilder.io/developers/api/elements#removeelementproperty-removeelementproperties) | `boolean` | Delete keys | | [`resetElementProperty(ies)`](https://ngxviewbuilder.io/developers/api/elements#resetelementproperty-resetelementproperties) | `boolean` | Back to the type's built-in value | | [`updateElement` / `refreshElement`](https://ngxviewbuilder.io/developers/api/elements#updateelement) | `boolean` | Change in a callback, force a redraw | | [`element(nameOrPath)`](https://ngxviewbuilder.io/developers/api/elements#element-getelementapi) | `INgxViewBuilderElementApi` | A handle with all of the above plus rows and columns | | [element defaults](https://ngxviewbuilder.io/developers/api/elements#element-defaults) | | Defaults for every element of a type | ## Finding elements ### `getElementModel()` ```ts getElementModel(nameOrPath: string): ElementBaseModel | null ``` The live model the element renders from. ```ts const email = api.getElementModel('email'); email?.type; // 'text' email?.required; // true email?.value; // 'ada@example.com' ``` ### `getElement()` ```ts getElement(nameOrPath: string): unknown ``` The same model, typed as `unknown` for code that does not import model classes. ### `getElementLookup()` ```ts getElementLookup(nameOrPath: string): INgxViewBuilderElementLookupResult | null ``` Everything about one element in one record. ```ts api.getElementLookup('email'); // { // key: 'email', // elementName: 'email', // elementDataPath: 'email', // elementSchemaPath: 'email', // elementType: 'text', // elementId: 'nvb-text-input-email', // elementTestId: 'nvb-element-email', // element: TextInputModel, // the live model // host: TextInputModel, // elementDom: HTMLElement, // the element's wrapper // dom: HTMLElement // } ``` `elementId` is the id of the element's control (the input of a text field), which [`getElementDomById`](https://ngxviewbuilder.io/developers/api/elements#getelementdombyid) finds again. ### `getElementByDataPath()` ```ts getElementByDataPath(dataPath: string): ElementBaseModel | null getElementBySchemaPath(schemaPath: string): ElementBaseModel | null ``` For code that knows which kind of path it holds, for example the `elementDataPath` of an event. ```ts api.onElementValueChanged.add((e) => { const model = api.getElementByDataPath(e.elementDataPath); }); ``` ### `getRenderedElements()` ```ts getRenderedElements(): INgxViewBuilderElementLookupResult[] ``` A lookup record for every element currently on screen, in page order. ```ts api.getRenderedElements().map((e) => e.elementName); // ['page1', 'firstName', 'email', 'clientType', ...] ``` ### Single lookup fields ```ts getElementName(nameOrPath: string): string | null getElementDataPath(nameOrPath: string): string | null getElementSchemaPath(nameOrPath: string): string | null getElementType(nameOrPath: string): string | null getElementId(nameOrPath: string): string | null getElementTestId(nameOrPath: string): string | null ``` ```ts api.getElementType('email'); // 'text' api.getElementTestId('email'); // 'nvb-element-email' api.getElementDataPath('city'); // 'address.city' inside an object panel ``` ### `describeElement()` ```ts describeElement(nameOrPath: string, options?: { depth?: number; includeRuntimeFields?: boolean; includeInternalFields?: boolean }): Record | null ``` Only the properties that hold a value, readable in a console log. ```ts console.log(api.describeElement('email')); // { name: 'email', type: 'text', label: 'Email', dataPath: 'email', required: true, validators: [...] } ``` ## DOM A runtime renders inside a shadow root, so `document.querySelector` does not see into it. These do. ### `getElementDom()` ```ts getElementDom(nameOrPath: string): HTMLElement | null ``` The element's wrapper (`data-testid="nvb-element-"`). ### `getElementDomById()` ```ts getElementDomById(id: string): HTMLElement | null ``` ```ts api.getElementDomById(api.getElementId('email')!)?.focus(); ``` ### `getElementDomByTestId()` ```ts getElementDomByTestId(testId: string): HTMLElement | null ``` ```ts api.getElementDomByTestId('nvb-element-email')?.scrollIntoView({ behavior: 'smooth' }); ``` ### `queryRenderDom()` ```ts queryRenderDom(selector: string): HTMLElement | null queryRenderDomAll(selector: string): HTMLElement[] ``` `querySelector` and `querySelectorAll` inside the view. ```ts api.queryRenderDomAll('[data-testid^="nvb-element-"]').length; // 24 ``` ### `getRenderRoot()` / `registerRenderRoot()` ```ts getRenderRoot(): HTMLElement | ShadowRoot | null registerRenderRoot(root: HTMLElement | ShadowRoot | null): void ``` The node the view renders into. The runtime registers it; you only need `registerRenderRoot` when you render a view somewhere yourself. ## Properties Property keys are the ones in the view JSON and in the builder's property panel: `label`, `placeholder`, `description`, `labelTooltip`, `required`, `disabled`, `readOnly`, `hidden`, `width`, `options`, `text` (buttons), `icon`, `validators`, `visibleIf`, and every property of the [element pages](https://ngxviewbuilder.io/creators/elements/) or one you [registered](https://ngxviewbuilder.io/developers/api/extensions#element-properties). Nested keys use dots: `dataSource.name`, `options[0].label`. ### `getElementProperty()` ```ts getElementProperty(nameOrPath: string, propertyKey: string): unknown ``` ```ts api.getElementProperty('email', 'label'); // 'Email' api.getElementProperty('country', 'dataSource.name'); // 'loadCountries' api.getElementProperty('status', 'options[0].label'); // 'Open' ``` ### `setElementProperty()` ```ts setElementProperty(nameOrPath: string, propertyKey: string, value: unknown): boolean ``` Writes one property; the element redraws. Returns `false` when the element does not exist. ```ts api.setElementProperty('email', 'required', true); // asterisk appears, validation requires it api.setElementProperty('email', 'disabled', true); // the input is disabled api.setElementProperty('notes', 'hidden', true); // the field disappears api.setElementProperty('save', 'text', 'Save draft'); // button caption api.setElementProperty('country', 'options', [ { label: 'Lithuania', value: 'LT' }, { label: 'Latvia', value: 'LV' }, ]); api.setElementProperty('discount', 'width', '120px'); ``` Fires [`onElementPropertyChanging` and `onElementPropertyChanged`](https://ngxviewbuilder.io/developers/events#values-data). ### `setElementProperties()` ```ts setElementProperties(nameOrPath: string, props: Record): boolean ``` ```ts api.setElementProperties('email', { label: 'Work e-mail', required: true, placeholder: 'name@company.com' }); ``` ### `removeElementProperty()` / `removeElementProperties()` ```ts removeElementProperty(nameOrPath: string, propertyKey: string): boolean removeElementProperties(nameOrPath: string, propertyKeys: readonly string[]): boolean ``` Deletes the keys; the property becomes `undefined`. ```ts api.removeElementProperties('email', ['placeholder', 'description']); ``` ### `resetElementProperty()` / `resetElementProperties()` ```ts resetElementProperty(nameOrPath: string, propertyKey: string): boolean resetElementProperties(nameOrPath: string, propertyKeys?: readonly string[]): boolean ``` Back to the element type's built-in value (`false` for `required`, `disabled`, `readOnly`, `hidden`). Without keys, every built-in property is reset. ```ts api.resetElementProperties('email', ['disabled', 'readOnly']); ``` ### `updateElement()` ```ts updateElement(nameOrPath: string, updater: (element: ElementBaseModel) => void): boolean ``` Change the live model in a callback; whatever you changed is redrawn and saved into the structure. ```ts api.updateElement('summary', (el) => { el.label = 'Order summary'; el['description'] = `Updated ${new Date().toLocaleTimeString()}`; }); ``` ### `refreshElement()` ```ts refreshElement(nameOrPath: string): boolean ``` Redraws the element from its model, for when you wrote to `getElementModel()` directly. ## The element handle ### `element()` / `getElementApi()` ```ts element(nameOrPath: string): INgxViewBuilderElementApi getElementApi(nameOrPath: string): INgxViewBuilderElementApi ``` A handle for one element with everything above as methods, plus subscriptions and row control. It finds the element again on every call, so it keeps working after re-renders and after rows are added or removed. ```ts const email = api.element('email'); email.exists; // true email.type; // 'text' email.dataPath; // 'email' email.setLabel('Work e-mail'); email.setRequired(); // true by default; setRequired(false) undoes it email.setDisabled(false); email.setReadOnly(true); email.setHidden(false); email.getValue(); // 'ada@example.com' await email.setValue('ada@company.com'); await email.reset(); // back to its default value email.clearValue(); email.setErrors('Already registered'); email.getErrors(); // ['Already registered'] email.clearErrors(); email.describe(); // { name: 'email', type: 'text', ... } ``` | Member | Returns | | --- | --- | | `name`, `dataPath`, `type`, `model`, `exists` | read only fields | | `getProperty`, `setProperty`, `setProperties`, `removeProperty`, `removeProperties`, `resetProperty`, `resetProperties` | as the methods above | | `setLabel`, `setRequired`, `setDisabled`, `setReadOnly`, `setHidden`, `setOptions` | `boolean` | | `update(updater)`, `refresh()` | `boolean` | | `getValue()`, `setValue(value)`, `clearValue()`, `reset()` | value, `Promise`, `boolean`, `Promise` | | `setErrors`, `clearErrors`, `getErrors` | `boolean`, `boolean`, `string[]` | | `onValueChanged(cb)`, `onValueChanging(cb)`, `onPropertyChanged(cb)` | a function that unsubscribes | | `rows`, `column(name)`, `cell(row, column)` | see below | | `describe()` | `Record \| null` | Subscriptions only fire for this element: ```ts const off = api.element('country').onValueChanged((e) => { // e = { name: 'country', dataPath: 'country', value: 'LT', oldValue: null, trigger: 'change', // rowIndex: null, indexPath: [], rowPath: [], element, api } void api.reloadDataSource('city'); }); // later off(); ``` Pass `{ includeChildren: true }` on a panel or repeater to hear its children too; `rowIndex` and `rowPath` then say which row changed. ### Rows of a dynamic table or panel `element('orders').rows` controls the rows of a `dynamicTable` or `dynamicPanel`. Every change shows on screen and fires the same events as the add and delete buttons. ```ts const rows = api.element('orders').rows; rows.supported; // true for dynamicTable / dynamicPanel rows.count(); // 2 rows.get(); // [{ sku: 'A', amount: 1 }, { sku: 'B', amount: 2 }] await rows.add(); // 2, new row seeded with the columns' default values await rows.add({ sku: 'C', amount: 5 }); // 3 await rows.insert(0, { sku: 'FIRST' }); // 0 await rows.move(0, 3); // true await rows.remove(1); // true await rows.set([{ sku: 'ONLY' }]); // true, replaces all rows await rows.clear(); // true const row = rows.at(0); // one row row.index; // 0 row.get(); // { sku: 'ONLY' } await row.patch({ amount: 3 }); // merge await row.set({ sku: 'X', amount: 1 }); // replace row.cell('sku').setDisabled(true); // one cell await row.remove(); rows.all(); // a handle per row ``` | Member | Returns | | --- | --- | | `supported` | `boolean` | | `count()` / `get()` | `number` / `Record[]` | | `add(values?)` / `insert(index, values?)` | `Promise`, the new row's index (`-1` on failure) | | `remove(index)` / `move(from, to)` / `set(rows)` / `clear()` | `Promise` | | `at(index)` / `all()` | row handles: `index`, `get()`, `set()`, `patch()`, `remove()`, `cell(column)` | ### Columns of a repeater ```ts column(columnName: string): INgxViewBuilderElementColumnApi ``` A column is the definition every row is built from. Writing to it changes the definition **and** every row already on screen, which a plain `setElementProperty` on one cell cannot do. ```ts const sku = api.element('orders').column('sku'); sku.exists; // true sku.setLabel('Article no.'); // header text sku.setDisabled(true); // every row's sku field sku.setRequired(); sku.setProperty('placeholder', 'A-000'); sku.cells(); // a handle per row's sku field sku.column('lines'); // nested repeater inside a column ``` ### Cells ```ts cell(rowIndex: number, columnName: string): INgxViewBuilderElementApi ``` One field in one row, as a full element handle. ```ts await api.element('orders').cell(0, 'amount').setValue(10); api.element('orders').cell(1, 'sku').setErrors('Unknown article'); ``` ## Element defaults Defaults decide what a newly created element of a type looks like, in the builder and from `setStructure`. With `applyToExisting` (on by default) they also reach elements already in the view, but only where the element still has the built-in value, so nothing an author set is overwritten. ### `setElementTypeDefault()` / `setElementTypeDefaults()` ```ts setElementTypeDefault(type: string, propertyKey: string, value: unknown, options?: { merge?: boolean; applyToExisting?: boolean }): boolean setElementTypeDefaults(type: string, patch: Record, options?): boolean ``` ```ts api.setElementTypeDefault('text', 'autocomplete', 'off'); api.setElementTypeDefaults('number', { visualFormatMinFractionDigits: 2, visualFormatMaxFractionDigits: 2 }); ``` ### `setElementDefaults()` / `defaults()` ```ts setElementDefaults(defaults: INgxViewBuilderElementDefaults | null, options?): boolean defaults(defaults?: INgxViewBuilderElementDefaults | null, options?): INgxViewBuilderElementDefaults ``` Several types at once; `*` stands for every type. `defaults()` without arguments returns what is registered, with arguments it sets and returns the result. ```ts api.setElementDefaults({ '*': { logicExecutionMode: 'onChange' }, textarea: { rows: 6 }, select: { placeholder: 'Choose one' }, }); ``` ### `getElementDefaults()` / `clearElementDefaults()` ```ts getElementDefaults(type?: string): INgxViewBuilderElementDefaults | Record clearElementDefaults(type?: string): boolean ``` ```ts api.getElementDefaults('textarea'); // { rows: 6 } api.getElementDefaults(); // { '*': {...}, textarea: {...}, select: {...} } api.clearElementDefaults('select'); // true ``` ### `getElementTypeBuiltInDefaults()` ```ts getElementTypeBuiltInDefaults(type: string): Record ``` The values a fresh element of the type has before any default you registered. ```ts api.getElementTypeBuiltInDefaults('text'); // { showMaxLengthCounter: true, hidden: false, disabled: false, readOnly: false, required: false } ``` Changing defaults fires [`onElementDefaultsChanged`](https://ngxviewbuilder.io/developers/events#appearance-configuration). --- ## API: extensions Source: https://ngxviewbuilder.io/developers/api/extensions # Extensions Everything here can also go into [`provideNgxViewBuilderExtensions`](https://ngxviewbuilder.io/developers/extensions) at bootstrap, which is usually the better place. Use these methods when what you register is only known later, for example after loading a tenant's configuration. Registrations reach every view: the builder, its Preview, every `` and every ``, including the ones created after the call. | Method | Returns | In short | | --- | --- | --- | | [`registerExtensions` / `registerExtensionsAsync`](https://ngxviewbuilder.io/developers/api/extensions#registerextensions-registerextensionsasync) | `void` / `Promise` | A whole extensions config | | [`registerExpressionFunction(s)`](https://ngxviewbuilder.io/developers/api/extensions#registerexpressionfunction-registerexpressionfunctions) | `void` | Functions for expressions | | [`registerValidatorType(s)`](https://ngxviewbuilder.io/developers/api/extensions#registervalidatortype-registervalidatortypes) | `void` | Named validator rules | | [`getRegisteredValidatorTypes` / `clearRegisteredValidatorTypes`](https://ngxviewbuilder.io/developers/api/extensions#getregisteredvalidatortypes-clearregisteredvalidatortypes) | descriptors / `void` | List or drop them | | [`registerCustomElement(s)`](https://ngxviewbuilder.io/developers/api/extensions#registercustomelement-registercustomelements) | `void` | Your own element types | | [`registerElementGroup`](https://ngxviewbuilder.io/developers/api/extensions#registerelementgroup) | `void` | A group in the builder's library | | [element properties](https://ngxviewbuilder.io/developers/api/extensions#element-properties) | `void` | Extra or changed properties in the panel | | [SVG icons](https://ngxviewbuilder.io/developers/api/extensions#svg-icons) | names / results | Icons by name | | [table cell types](https://ngxviewbuilder.io/developers/api/extensions#table-cell-element-types) | `void` / list | Elements offered in table cells | | [`getTableHeaderCenterExtensions`](https://ngxviewbuilder.io/developers/api/extensions#gettableheadercenterextensions) | definitions | Elements allowed in a table header | ### `registerExtensions()` / `registerExtensionsAsync()` ```ts registerExtensions(config: INgxViewBuilderExtensionsConfig): void registerExtensionsAsync(config: INgxViewBuilderExtensionsConfig): Promise ``` The same object `provideNgxViewBuilderExtensions` takes. The async variant waits for icon directories to load. ```ts const config = await firstValueFrom(http.get('/api/tenant/builder-config')); await api.registerExtensionsAsync({ expressionFunctions: [{ name: 'vat', handler: (net: unknown) => Number(net || 0) * config.vatRate }], runtimeVariableContext: { tenant: config.code }, svgIconDirectory: { basePath: '/assets/icons', names: config.icons, prefix: 'acme' }, }); ``` ### `registerExpressionFunction()` / `registerExpressionFunctions()` ```ts registerExpressionFunction(fn: IJexlFunctionRegistration): void registerExpressionFunctions(fns: IJexlFunctionRegistration[]): void ``` ```ts api.registerExpressionFunction({ name: 'workdaysBetween', args: ['from', 'to'], description: 'Working days between two dates', example: 'workdaysBetween({start}, {end})', handler: (from: unknown, to: unknown) => countWorkdays(String(from ?? ''), String(to ?? '')), }); // in the view: expression: workdaysBetween({start}, {end}) ``` Expressions using it recalculate when their fields change. See [Custom functions](https://ngxviewbuilder.io/developers/custom-functions). ### `registerValidatorType()` / `registerValidatorTypes()` ```ts registerValidatorType(definition: INgxViewBuilderValidatorTypeRegistration): void registerValidatorTypes(definitions: INgxViewBuilderValidatorTypeRegistration[]): void ``` A named rule creators pick from the validator Type list. `isValid` returns `true` for a valid value. ```ts api.registerValidatorType({ type: 'ltPersonalCode', label: 'LT personal code', defaultMessage: 'Invalid personal code', appliesTo: ['text'], isValid: ({ value }) => /^[3-6]\d{10}$/.test(String(value ?? '')), }); ``` The rule runs on blur, on Validate, on Submit and in `validateData()`. See [Custom validator types](https://ngxviewbuilder.io/developers/custom-validators). ### `getRegisteredValidatorTypes()` / `clearRegisteredValidatorTypes()` ```ts getRegisteredValidatorTypes(): INgxViewBuilderValidatorTypeDescriptor[] clearRegisteredValidatorTypes(): void ``` ```ts api.getRegisteredValidatorTypes(); // [{ type: 'ltpersonalcode', label: 'LT personal code', defaultMessage: 'Invalid personal code', // hasValue: false, valueLabel: '', valuePlaceholder: '', appliesTo: ['text'], ... }] ``` Type names are stored lower case. ### `registerCustomElement()` / `registerCustomElements()` ```ts registerCustomElement(definition: INgxViewBuilderCustomElementDefinition): void registerCustomElements(definitions: INgxViewBuilderCustomElementDefinition[]): void ``` ```ts api.registerCustomElement({ type: 'ratingWidget', label: 'Rating', icon: 'star', groupCode: 'custom', component: RatingWidgetComponent, model: RatingWidgetModel, allowInTableCell: true, properties: { max: { label: 'Stars', type: 'number', category: 'general' }, }, }); ``` The model class is used everywhere the type appears, in runtime views and scopes too, so its own fields survive loading a view. See [Custom elements](https://ngxviewbuilder.io/developers/custom-elements). ### `registerElementGroup()` ```ts registerElementGroup(group: { code: string; label: string; icon?: string; order?: number }): void ``` ```ts api.registerElementGroup({ code: 'acme', label: 'ACME widgets', icon: 'acmeLogo', order: 10 }); ``` ### Element properties ```ts registerElementProperties(type: string, properties: Record, merge = true): void registerElementPropertiesMap(map: Record>, merge = true): void registerGlobalElementProperties(properties: Record, merge = true): void ``` Add properties to the builder's panel, or change built-in ones, for one type, several types, or every type. Values are saved on the element and readable with [`getElementProperty`](https://ngxviewbuilder.io/developers/api/elements#getelementproperty). ```ts api.registerGlobalElementProperties({ trackingId: { label: 'Tracking ID', type: 'text', category: 'general' }, }); api.registerElementProperties('button', { analyticsEvent: { label: 'Analytics event', type: 'text', category: 'general' }, }); api.getElementProperty('save', 'analyticsEvent'); // 'checkout_submit' ``` See [Custom properties](https://ngxviewbuilder.io/developers/custom-properties) for editor types and fields. ### SVG icons ```ts registerSvgIcon(name: string, svgMarkup: string, overwrite = true): boolean registerSvgIcons(icons: Record, overwrite = true): string[] registerSvgIconDirectory(config: { basePath: string; names: string[]; extension?: string; prefix?: string; overwrite?: boolean }): Promise<{ loaded: string[]; failed: string[] }> registerSvgIconDirectories(configs: INgxViewBuilderSvgIconDirectoryConfig[]): Promise<{ loaded: string[]; failed: string[] }[]> clearRegisteredSvgIcons(): void ``` ```ts api.registerSvgIcon('acmeLogo', '...'); // true api.registerSvgIcons({ invoice: '...', shipment: '...' }); // ['invoice', 'shipment'] await api.registerSvgIconDirectory({ basePath: '/assets/icons', names: ['truck', 'box', 'missing'], prefix: 'acme' }); // { loaded: ['acmeTruck', 'acmeBox'], failed: ['missing'] } ``` A registered name works in every icon property at once, for example a button's `icon`. See [Custom SVG icons](https://ngxviewbuilder.io/developers/icons). ### Table cell element types ```ts registerTableCellElementType(type: string | { type: string; label?: string; order?: number }): void registerTableCellElementTypes(types: Array): void removeTableCellElementType(type: string): void getTableCellElementTypes(): ITableCellElementType[] ``` The elements creators can pick for a table column of type *Element (control)*. ```ts api.registerTableCellElementTypes(['autocomplete', 'listBox']); api.registerTableCellElementType({ type: 'ratingWidget', label: 'Rating', order: 2500 }); api.removeTableCellElementType('fileUpload'); api.getTableCellElementTypes().map((t) => t.type); // ['text', 'number', 'select', ..., 'autocomplete', 'listBox', 'ratingWidget'] ``` ### `getTableHeaderCenterExtensions()` ```ts getTableHeaderCenterExtensions(): INgxViewBuilderCustomElementDefinition[] ``` Custom elements registered with `allowInTableHeader: true`. --- ## API: language & theme Source: https://ngxviewbuilder.io/developers/api/language-theme # Language & theme | Method | Returns | In short | | --- | --- | --- | | [`getLanguage` / `setLanguage`](https://ngxviewbuilder.io/developers/api/language-theme#getlanguage-setlanguage) | `string` / `void` | The active language | | [`resolveHostLanguage` / `applyHostLanguage`](https://ngxviewbuilder.io/developers/api/language-theme#resolvehostlanguage-applyhostlanguage) | `{ language, locale }` | Pick the language from the page or the browser | | [`startLanguageSync(options)`](https://ngxviewbuilder.io/developers/api/language-theme#startlanguagesync) | `{ refresh, stop }` | Keep following the host's language | | [`setApplicationLocale` / `getApplicationLocale`](https://ngxviewbuilder.io/developers/api/language-theme#setapplicationlocale-getapplicationlocale) | `void` / `string` | Number and date formatting | | [`setLocale(locale)`](https://ngxviewbuilder.io/developers/api/language-theme#setlocale) | `void` | Locale in the formatter and in the view's settings | | [`setContentTranslations(translations, merge?)`](https://ngxviewbuilder.io/developers/api/language-theme#setcontenttranslations) | `void` | Translate labels, options, messages | | [`setUiDictionaries` / `setUiTranslations`](https://ngxviewbuilder.io/developers/api/language-theme#setuidictionaries-setuitranslations) | `void` | Translate the library's own texts | | [`translateUi(key, fallback?)`](https://ngxviewbuilder.io/developers/api/language-theme#translateui) | `string` | Read a UI text | | [`setPropertyHints` / `setPropertyTypeHints`](https://ngxviewbuilder.io/developers/api/language-theme#setpropertyhints-setpropertytypehints) | `void` | Help texts in the builder's property panel | | [`setThemeMode` / `getThemeMode`](https://ngxviewbuilder.io/developers/api/language-theme#setthememode-getthememode) | `void` / mode | Light or dark | | [`setTheme(modeOrTokens)`](https://ngxviewbuilder.io/developers/api/language-theme#settheme) | `void` | A mode or a token map | | [`setCustomTheme` / `getCustomTheme`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomtheme-getcustomtheme) | `void` / theme | Tokens per mode | | [`setCssVariables` / `getCssVariables`](https://ngxviewbuilder.io/developers/api/language-theme#setcssvariables-getcssvariables) | `void` / map | Token overrides | | [`setCustomCss` / `getCustomCss`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomcss-getcustomcss) | `void` / `string` | Extra CSS inside the view | | [`setCustomCssUrls` / `getCustomCssUrls`](https://ngxviewbuilder.io/developers/api/language-theme#setcustomcssurls-getcustomcssurls) | `void` / `string[]` | Extra stylesheets | ## Language Two kinds of text are translated separately: the view's own texts (labels, options, messages a creator wrote) come from its `localization`, and the library's texts (Submit, Add row, validator messages) come from the UI dictionaries. See [Translations](https://ngxviewbuilder.io/creators/translations) and [UI translations](https://ngxviewbuilder.io/developers/ui-translations). ### `getLanguage()` / `setLanguage()` ```ts getLanguage(): string setLanguage(language: string): void ``` Switches both at once and redraws the view. A language the view does not list yet is added to its localization. Fires `onLanguageChanged`. ```ts api.setLanguage('lt'); api.getLanguage(); // 'lt' ``` A bound `[language]` input on the component sets the language when it changes; a later `setLanguage()` call stays in effect until the input changes again. ### `resolveHostLanguage()` / `applyHostLanguage()` ```ts resolveHostLanguage(options?: INgxViewBuilderLanguageResolveOptions): { language: string; locale: string } applyHostLanguage(options?: INgxViewBuilderLanguageResolveOptions): { language: string; locale: string } ``` Picks the best language from `hostLanguageCode`, `hostLanguageResolver`, `` or the browser, limited to `supportedLanguages`, falling back to `fallbackLanguage`. `applyHostLanguage` also applies it (and the locale, unless `applyLocale: false`). ```ts document.documentElement.lang = 'lt-LT'; api.resolveHostLanguage({ supportedLanguages: ['en', 'lt'], fallbackLanguage: 'en' }); // { language: 'lt', locale: 'lt-LT' } ``` ### `startLanguageSync()` ```ts startLanguageSync(options?: INgxViewBuilderLanguageSyncOptions): { refresh: () => string; stop: () => void } ``` Keeps the view on the host's language: it watches `` (`watchDocumentLanguage`, on by default) and the browser's `languagechange` (`watchSystemLanguage`). `onResolved` tells you what was picked. ```ts const sync = api.startLanguageSync({ supportedLanguages: ['en', 'lt', 'de'], fallbackLanguage: 'en', hostLanguageResolver: () => this.i18n.currentLang, onResolved: ({ language }) => console.log('view language', language), }); this.i18n.langChanged.subscribe(() => sync.refresh()); // in ngOnDestroy sync.stop(); ``` ### `setApplicationLocale()` / `getApplicationLocale()` ```ts setApplicationLocale(locale: string): void getApplicationLocale(): string ``` The locale for number and date formatting, independent of the language. Until set, formatting follows the view's `settings.locale`, then the browser. ```ts api.setApplicationLocale('de-DE'); // 1.234,56 api.getApplicationLocale(); // 'de-DE' ``` ### `setLocale()` ```ts setLocale(locale: string): void ``` Sets the application locale and writes it into the view's settings, so it is saved with the view. ### `setContentTranslations()` ```ts setContentTranslations(translations: Record>, merge = true): void ``` Adds translations of the view's own texts, keyed by language and translation key, the same format the Translations tab exports. Labels redraw at once when that language is active. ```ts api.setContentTranslations({ lt: { 'elements.firstName.label': 'Vardas', 'elements.email.label': 'El. paštas' }, de: { 'elements.firstName.label': 'Vorname' }, }); ``` ### `setUiDictionaries()` / `setUiTranslations()` ```ts setUiDictionaries(dictionaries: Record>, merge = true): void setUiTranslations(dictionaries: Record>, merge = true): void ``` The library's own texts, by language. The two names do the same. Start from the exported `EN_UI_DICTIONARY` to see every key. ```ts api.setUiDictionaries({ lt: { 'preview.submit': 'Pateikti', 'preview.validate': 'Tikrinti' } }); ``` ### `translateUi()` ```ts translateUi(key: string, fallback?: string): string ``` ```ts api.translateUi('preview.submit'); // 'Submit' api.translateUi('host.save', 'Save'); // 'Save' when the key is not in any dictionary ``` ### `setPropertyHints()` / `setPropertyTypeHints()` ```ts setPropertyHints(hints: Record): void setPropertyTypeHints(hints: Record): void ``` Your own help texts in the builder's property panel, per property key or per editor type. ```ts api.setPropertyHints({ name: 'Becomes the column name in our database, use camelCase.' }); api.setPropertyTypeHints({ sourceMapper: 'Ask the data team which endpoint to use.' }); ``` ## Theme All visuals come from `--nvb-*` design tokens; see [Theming](https://ngxviewbuilder.io/developers/theming). Theme calls made before a view exists apply to every view created later. ### `setThemeMode()` / `getThemeMode()` ```ts setThemeMode(theme: 'light' | 'dark'): void getThemeMode(): 'light' | 'dark' | null ``` Fires `onThemeModeChanged`. ```ts api.setThemeMode('dark'); api.getThemeMode(); // 'dark' ``` ### `setTheme()` ```ts setTheme(theme: 'light' | 'dark' | Record): void ``` A mode, or a map of tokens applied on top of the active mode: one call for either. ```ts api.setTheme('dark'); api.setTheme({ '--nvb-color-primary-500': 'oklch(64% 0.19 145)' }); ``` ### `setCustomTheme()` / `getCustomTheme()` ```ts setCustomTheme(theme: { shared?: Record; light?: Record; dark?: Record; mergeWithDefaults?: boolean } | null): void getCustomTheme(): INgxViewBuilderCustomThemeDefinition | null ``` Tokens per mode; the right set is applied whenever the mode changes. Fires `onCustomThemeChanged`. ```ts api.setCustomTheme({ shared: { '--nvb-font-family-base': "'Inter', sans-serif" }, light: { '--nvb-color-primary-500': 'oklch(64% 0.19 145)' }, dark: { '--nvb-color-primary-500': 'oklch(72% 0.17 145)' }, mergeWithDefaults: true, }); ``` ### `setCssVariables()` / `getCssVariables()` ```ts setCssVariables(cssVariables: Record): void getCssVariables(): Record ``` Token overrides; names without `--` get it added. Fires `onCssVariablesChanged`. ```ts api.setCssVariables({ '--nvb-color-primary-500': 'rgb(14, 116, 144)', 'nvb-radius-md': '10px' }); api.getCssVariables(); // { '--nvb-color-primary-500': 'rgb(14, 116, 144)', '--nvb-radius-md': '10px' } ``` ### `setCustomCss()` / `getCustomCss()` ```ts setCustomCss(cssText: string | null): void getCustomCss(): string ``` CSS injected inside the view's shadow root, where your page's stylesheets cannot reach. Element components carry their own scoped styles, so write selectors at least as specific as theirs: target the stable `data-testid` hooks together with a class, or use `!important` for a one off. See [Custom CSS](https://ngxviewbuilder.io/developers/custom-css). ```ts api.setCustomCss(` [data-testid="nvb-element-total"] .nvb-field__label { font-weight: 700; } .invoice-total { font-size: 18px !important; } `); ``` ### `setCustomCssUrls()` / `getCustomCssUrls()` ```ts setCustomCssUrls(urls: string[] | null): void getCustomCssUrls(): string[] ``` Stylesheets loaded into the view, for overrides you version with your app. ```ts api.setCustomCssUrls(['/assets/builder-overrides.css']); ``` --- ## API: structure & settings Source: https://ngxviewbuilder.io/developers/api/structure # Structure & settings The structure is the view itself: pages, elements, data sources, settings. These methods read it, swap it for another one, or change parts of it while the view stays on screen. The JSON format is described in [Structure JSON](https://ngxviewbuilder.io/developers/structure-json). | Method | Returns | In short | | --- | --- | --- | | [`getStructure()`](https://ngxviewbuilder.io/developers/api/structure#getstructure) | `IStructure \| null` | The view as plain JSON, ready to save | | [`getStructureModel()`](https://ngxviewbuilder.io/developers/api/structure#getstructuremodel) | `IStructure \| null` | The live models the view renders from | | [`setStructure(structure)`](https://ngxviewbuilder.io/developers/api/structure#setstructure) | `void` | Replace the whole view | | [`updateStructure(updater)`](https://ngxviewbuilder.io/developers/api/structure#updatestructure) | `boolean` | Change the view in a callback | | [`notifyStructureChanged()`](https://ngxviewbuilder.io/developers/api/structure#notifystructurechanged) | `void` | Fire `onStructureChanged` yourself | | [`getSettings()`](https://ngxviewbuilder.io/developers/api/structure#getsettings) | `ISettings \| null` | All view-wide settings | | [`getSetting(key)`](https://ngxviewbuilder.io/developers/api/structure#getsetting) | `unknown` | One setting, nested paths allowed | | [`setSetting(key, value)`](https://ngxviewbuilder.io/developers/api/structure#setsetting) | `boolean` | Change one setting | | [`patchSettings(patch)`](https://ngxviewbuilder.io/developers/api/structure#patchsettings) | `boolean` | Change several settings | | [`updateSettings(updater)`](https://ngxviewbuilder.io/developers/api/structure#updatesettings) | `boolean` | Change settings in a callback | | [`replaceSettings(settings)`](https://ngxviewbuilder.io/developers/api/structure#replacesettings) | `boolean` | Swap the settings object | | [`captureStructureSource()` / `clearCapturedStructureSource()`](https://ngxviewbuilder.io/developers/api/structure#capturestructuresource-clearcapturedstructuresource) | `void` | Low level: the copy a runtime renders from | Every change made here shows on screen straight away, lands in `getStructure()` and fires [`onStructureChanged`](https://ngxviewbuilder.io/developers/events#structure-navigation). In the builder it also becomes an undo step. ## Reading ### `getStructure()` ```ts getStructure(): IStructure | null ``` The view as plain JSON. This is what you save: no class instances, no runtime state, nothing that does not survive `JSON.stringify`. Properties you changed through the API are included. ```ts const structure = api.getStructure(); await http.put(`/api/views/${id}`, structure); ``` Returns: ```json { "schemaVersion": 1, "settings": { "language": "en", "showSubmitButton": true }, "pages": [{ "name": "page1", "rows": [{ "columns": [{ "elementRef": "firstName" }] }] }], "elements": { "page1": { "name": "page1", "type": "page", "label": "Page 1" }, "firstName": { "name": "firstName", "type": "text", "label": "First name", "required": true } }, "dataSources": [] } ``` `null` while no view exists. In the builder, the template library is not part of it: an exported structure carries only the templates its elements use (see [templates](https://ngxviewbuilder.io/developers/api/builder#templates)). ### `getStructureModel()` ```ts getStructureModel(): IStructure | null ``` The live models instead of JSON: every element is an instance of its model class (`TextInputModel`, `SelectInputModel` and so on) with the state the view is rendering right now, such as values and hidden flags set by logic. Use it to inspect, not to save. ```ts const model = api.getStructureModel(); model?.elements['firstName'].hidden; // false ``` To change an element, prefer [`setElementProperty`](https://ngxviewbuilder.io/developers/api/elements#setelementproperty): writing to a live model directly does not refresh the screen. ## Replacing and changing the view ### `setStructure()` ```ts setStructure(structure: IStructure): void ``` Replaces the whole view. The structure is migrated to the current schema first, its runtime variables are applied and the view renders again. In a runtime, render listeners get `onBeforeRender`, `onRender` and `onAfterRender` like for a new `[pageJson]`. ```ts const next = await firstValueFrom(http.get(`/api/views/${id}`)); api.setStructure(next); ``` Form data is kept: values whose element still exists show up in the new view. In the builder the current template library is kept as well when the incoming structure does not define those templates. ### `updateStructure()` ```ts updateStructure(updater: (structure: IStructure) => void): boolean ``` Hands you the structure as JSON; change it in place and the view is updated. Returns `false` when there is no view. ```ts api.updateStructure((s) => { s.elements['email'].label = 'Work e-mail'; s.elements['email'].required = true; s.pages[0].rows.push({ columns: [{ elementRef: 'notes' }] }); s.elements['notes'] = { name: 'notes', type: 'textarea', label: 'Notes' }; }); // true ``` Use it for changes to the layout (pages, rows, new elements). For one property of one element, [`setElementProperty`](https://ngxviewbuilder.io/developers/api/elements#setelementproperty) is lighter: it does not rebuild anything. ### `notifyStructureChanged()` ```ts notifyStructureChanged(): void ``` Fires `onStructureChanged` (and the `(structureChanged)` output) on the next tick with the current structure, even when nothing changed. The methods on this page do it for you; call it after you changed a live model by hand. ## Settings Settings are the view-wide options from the builder's Form settings dialog: language, submit and validate buttons, navigation, render mode, custom CSS, variables. Keys accept dot paths. ### `getSettings()` ```ts getSettings(): ISettings | null ``` ```ts api.getSettings(); // { language: 'en', locale: 'lt-LT', showSubmitButton: true, renderMode: 'page', variables: [...] } ``` ### `getSetting()` ```ts getSetting(key: string): unknown ``` ```ts api.getSetting('language'); // 'en' api.getSetting('header.title'); // 'Client onboarding' api.getSetting('missing.key'); // undefined ``` ### `setSetting()` ```ts setSetting(key: string, value: unknown): boolean ``` Changes one setting and updates the view. Returns `true` (also when the value was already equal), `false` without a view. ```ts api.setSetting('showSubmitButton', false); // the Submit button disappears api.setSetting('header.title', 'New client'); ``` ### `patchSettings()` ```ts patchSettings(patch: Record): boolean ``` Several settings in one go; keys may be dot paths. ```ts api.patchSettings({ showValidateButton: true, 'header.title': 'New client', pageNavigationMode: 'stepper', }); ``` ### `updateSettings()` ```ts updateSettings(updater: (settings: ISettings) => void): boolean ``` ```ts api.updateSettings((settings) => { settings.locale = 'de-DE'; settings.customCss = '.invoice-total { font-weight: 700; }'; }); ``` ### `replaceSettings()` ```ts replaceSettings(settings: ISettings | null | undefined): boolean ``` Swaps the whole settings object. Anything you leave out is gone, so start from `getSettings()`: ```ts api.replaceSettings({ ...api.getSettings(), renderMode: 'dialog', dialogTitle: 'Order {orderNo}' }); ``` ## Low level ### `captureStructureSource()` / `clearCapturedStructureSource()` ```ts captureStructureSource(structure: IStructure | null | undefined): void clearCapturedStructureSource(): void ``` A runtime keeps a plain copy of the structure it rendered; `getStructure()` returns it and the methods above write into it. `` and `setStructure()` capture it for you, so hosts rarely touch these. Clearing makes `getStructure()` serialize the live models instead. --- ## API: validation & lifecycle Source: https://ngxviewbuilder.io/developers/api/validation # Validation & lifecycle | Method | Returns | In short | | --- | --- | --- | | [`validateData()`](https://ngxviewbuilder.io/developers/api/validation#validatedata) | `Promise` | Validate the view on screen, or any structure headlessly | | [`showToast(payload)`](https://ngxviewbuilder.io/developers/api/validation#showtoast) | `void` | Show a toast inside the view | | [`notifyValidationResult(isValid, issues)`](https://ngxviewbuilder.io/developers/api/validation#notifyvalidationresult) | `void` | Fire `onValidated` | | [`notifyComplete(isValid, data, issues)`](https://ngxviewbuilder.io/developers/api/validation#notifycomplete) | `void` | Fire `onComplete` | | [`notifySaveRequested(source?)`](https://ngxviewbuilder.io/developers/api/validation#notifysaverequested) | `void` | Fire `onSaveRequested` | | [render notifications](https://ngxviewbuilder.io/developers/api/validation#notifybeforerender-notifyrender-notifyafterrender) | `void` | Fire the render phases | | [`notifyTabChanged`](https://ngxviewbuilder.io/developers/api/validation#notifytabchanged) / [`notifyCurrentPageChanged`](https://ngxviewbuilder.io/developers/api/validation#notifycurrentpagechanged) / [`notifyDialogClosed`](https://ngxviewbuilder.io/developers/api/validation#notifydialogclosed) | `void` | Fire navigation events | | [element interaction notifications](https://ngxviewbuilder.io/developers/api/validation#element-interaction-notifications) | `void` | Fire repeater, upload, tab and accordion events | ## Validating ### `validateData()` ```ts validateData(options?: INgxViewBuilderValidateDataOptions): Promise validateData(structure: IStructure | string, data: Record | string | null, options?): Promise ``` Two ways to call it. **The view on screen.** Without a structure, it validates the view the person is filling in, exactly like the built-in Validate button: every rule runs, messages appear under the fields, and `onValidating` / `onValidated` fire. ```ts const result = await api.validateData(); if (!result.isValid) { console.table(result.issues); return; } await save(api.getData()); ``` **Any structure, headlessly.** With a structure and data, it builds a throwaway copy of the view, applies the data, runs expressions and validators, and returns the result. The view on screen is not touched, so you can check stored records, a submission on its way to the server, or a structure that is not rendered at all. ```ts const structure = await firstValueFrom(http.get('/api/views/client')); const result = await api.validateData(structure, { firstName: '', email: 'not-an-email' }); ``` Both return: ```json { "isValid": false, "issues": [ { "elementName": "firstName", "dataPath": "firstName", "errors": ["This field is required."] }, { "elementName": "email", "dataPath": "email", "errors": ["Enter a valid email address"] } ] } ``` Options: | Option | Default | What it does | | --- | --- | --- | | `emitValidationEvent` | `true` | Fire `onValidated` with the result. Pass `false` for a silent check. | | `includeHidden` | `false` | Report issues of hidden elements too. | | `includeDisabled` | `false` | Report issues of disabled and read-only elements too. | Validator types you [registered](https://ngxviewbuilder.io/developers/api/extensions#registervalidatortype-registervalidatortypes) and expression functions take part in both modes. For validating on a server, see [Headless validation](https://ngxviewbuilder.io/developers/validator). ## Toasts ### `showToast()` ```ts showToast(payload: { title?: string; message?: string; variant?: 'success' | 'info' | 'warning' | 'error'; position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'; autoHide?: boolean; autoHideMs?: number; showIcon?: boolean; icon?: string; }): void ``` The same toast the view's own *Toast* action shows, styled with its tokens. ```ts api.showToast({ title: 'Saved', message: 'Client HF-20418 was updated.', variant: 'success' }); api.showToast({ title: 'Offline', variant: 'warning', position: 'bottom-center', autoHide: false }); ``` ## Notifications These fire the events and the automation triggers behind them. The runtime and the builder call them at the right moments; call them yourself when your own code does the equivalent, for example a Save button outside the view. ### `notifyValidationResult()` ```ts notifyValidationResult(isValid: boolean, issues: INgxViewBuilderValidationIssue[]): void ``` Fires `onValidating`, `onValidated` and the `validated` trigger. ### `notifyComplete()` ```ts notifyComplete(isValid: boolean, data: Record, issues: INgxViewBuilderValidationIssue[]): void ``` Fires `onComplete` and the `completed` trigger, what the Submit button does after validating. ```ts const result = await api.validateData(); api.notifyComplete(result.isValid, api.getData(), result.issues); ``` ### `notifySaveRequested()` ```ts notifySaveRequested(source: 'header' | 'api' = 'api'): void ``` Fires `onSaveRequested` with `{ source, timestamp }`, the event the builder's Save button sends. ### `notifyBeforeRender()` / `notifyRender()` / `notifyAfterRender()` ```ts notifyBeforeRender(tab: NgxViewBuilderBuilderTabCode): void notifyRender(tab: NgxViewBuilderBuilderTabCode): void notifyAfterRender(tab: NgxViewBuilderBuilderTabCode): void ``` Fire `onBeforeRender`, `onRender` (with one `onElementRender` per element) and `onAfterRender` (with `onElementAfterRender`). Hosts rarely need them; a reload through [`setStructure`](https://ngxviewbuilder.io/developers/api/structure#setstructure) fires them already. ### `notifyTabChanged()` ```ts notifyTabChanged(tab: NgxViewBuilderBuilderTabCode): void ``` Fires `onTabChanged` with `{ tab }`. ### `notifyCurrentPageChanged()` ```ts notifyCurrentPageChanged(pageIndex: number, pageName: string | null): void ``` Fires `onCurrentPageChanged` and the `pageChanged` trigger. ### `notifyDialogClosed()` ```ts notifyDialogClosed(pageIndex: number, pageName: string | null, dialogTitle?: string | null, reason: 'close-button' | 'api' = 'close-button'): void ``` Fires `onDialogClosed` and the `dialogClosed` trigger, for a view rendered as a dialog. ### Element interaction notifications ```ts notifyDynamicTableRowAdded(element, rowIndex, row?): void notifyDynamicTableRowRemoved(element, rowIndex, row?): void notifyDynamicPanelItemAdded(element, rowIndex, row?): void notifyDynamicPanelItemRemoved(element, rowIndex, row?): void notifyFileUploadFilesAdded(element, files, addedFiles): void notifyFileUploadFileRemoved(element, files, removedFile, removedIndex): void notifyElementTabChanged(element, previousTabValue, tabValue, tabIndex): void notifyAccordionItemToggled(element, itemIndex, itemValue, expanded): void ``` The elements call these when a person adds a row, uploads a file, switches a tab or opens an accordion section, and each fires the [event of the same name](https://ngxviewbuilder.io/developers/events#element-interactions). `element` is the element's model; get it with [`getElementModel`](https://ngxviewbuilder.io/developers/api/elements#getelementmodel). When you change rows from code, use the [rows handle](https://ngxviewbuilder.io/developers/api/elements#rows-of-a-dynamic-table-or-panel), which fires them for you. --- ## Architecture Source: https://ngxviewbuilder.io/developers/architecture # Architecture ## Two packages The library is published as two npm packages that split it along the line between designing a view and running one. ``` @ngxviewbuilder/runtime free, no license key ├─ 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/ ├─ runtime/ rendering helpers, lazy element loading └─ shared/ models, the 55 elements, services, providers, expressions @ngxviewbuilder/designer commercial license ├─ ngx-view-builder-designer/ designer shell (tabs, sidebars, history) └─ builder/ drag & drop, property editing, registries, datasets, license service ``` Roughly 72% of the code sits in the runtime, including every element and the whole service layer: expressions, data sources, validation, i18n, rules and process. The designer is the remaining 28%, and it is all editor UI. Dependencies point one way only. The designer imports the runtime; the runtime knows nothing about the designer and never loads it. That was already true inside the old single package, which is what made the split a packaging change rather than a rewrite. Optional plugins are sibling npm packages (`@ngxviewbuilder/plugin-*`) that register builder tabs and feature packs through the extensions API. They sit on top of the designer, since a tab needs an editor to appear in. The core never auto-loads them. ### Why the packages are split Both halves used to ship as one `ngx-view-builder` package. That put two costs on applications that only render views, which is most of them: - **Code they never run.** The editor was installed and resolved even when no user could ever open it. - **A license they do not need.** The runtime is free forever and needs no key, but a single package meant a single `LICENSE.md`, and it read as commercial software. Procurement reviews the package, not your intentions. - **Dependencies they never resolve.** The code editor brings CodeMirror and Prettier, roughly 15 MB of packages. They belong to the designer and are installed only with it. Splitting them also moved `license.service.ts` where it belongs. It used to live in `core/shared`, which is the half that is free forever, so the free runtime carried the code that enforced payment for the other half. It now lives in the designer. The two packages are versioned in lockstep and the designer's peer dependency pins the exact runtime version, so they cannot drift apart. ### One runtime instance, not two The designer re-exports the runtime's public surface so a host that embeds the editor has a single import site. This is a re-export, never a bundled copy: the runtime stays an external package that both resolve to. That distinction matters more than it looks. Services like `EventService` and `NgxViewBuilderApiService` are root-provided singletons. If the runtime were duplicated into the designer's bundle, Angular would see two distinct classes and create two instances, and the editor would quietly stop hearing the runtime's events. The published builds are checked for this: the designer's bundle imports the runtime rather than inlining it. ### Public and internal surface The runtime exports two tiers. The documented surface is what host applications build against and follows semver. Below it, a section marked `Internal API` exports the components, registries and services the designer needs to do its job. Those are reachable, but they change without a major version, and code that imports them is choosing to track the designer's release cycle. ## 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. ## Designer vs. runtime boundary Designer-side code (property sidebars, drag & drop, datasets) is never needed to *render* a view. If your end-user app only displays views, it installs `@ngxviewbuilder/runtime` alone and pays no editor cost at all: not in the bundle, not in the dependency tree, and not in licensing. Lazy element rendering and preloading are tunable on top of that. The boundary is worth keeping in mind when you split your own application, too. A common shape is one internal admin app that installs both packages and hosts the editor, and one or more customer-facing apps that install the runtime only and render the JSON the first one produced. --- ## Embedding the builder Source: https://ngxviewbuilder.io/developers/builder-integration # Embedding the builder ::: tip Renamed in 0.5.1 The component is `` / `NgxViewBuilderDesigner`, matching the package it ships in. The former `` selector and `NgxViewBuilderBuilder` class name still work as aliases, so existing templates keep rendering, but new code should use the names below. ::: ## Minimal setup ```ts import { Component } from '@angular/core'; import { BuilderModel, IStructure, NgxViewBuilderDesigner } from '@ngxviewbuilder/designer'; @Component({ selector: 'app-builder-page', imports: [NgxViewBuilderDesigner], 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. The view does not have to be there when the builder starts. When it comes from your backend, call `setJson()` once it arrives, or hand the builder a new `BuilderModel`; the canvas loads it and undo starts from it: ```ts ngOnInit(): void { this.http.get('/api/views/order-form', { responseType: 'text' }) .subscribe((json) => this.builderModel.setJson(json)); } ``` ## 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 mcp: { url: 'wss://mcp.example.com/bridge' }, // let an AI client drive the builder licenseKey: 'NVB-...', }; ``` `mcp` is optional. With it, the builder connects to an MCP server while it is on screen and shows a session key under Settings → AI access (MCP), which a person gives to their AI client to let it edit the view. See [AI command API](https://ngxviewbuilder.io/developers/ai-command-api#connecting-the-builder). ## 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. - 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 '@ngxviewbuilder/runtime'; 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 '@ngxviewbuilder/runtime'; 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 '@ngxviewbuilder/runtime'; 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 '@ngxviewbuilder/runtime'; 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 '@ngxviewbuilder/runtime'; 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 "@ngxviewbuilder/runtime"; 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, give the source the **POST** method in the Data sources tab and turn on **Send the table's paging, sorting and filters**. The switch shows up on a POST source as soon as a table or list grid loads from it, and names the tables that do. The view stores this as the method `TABLE-POST`, which is also what you write when you author the JSON yourself, and views saved with it keep working. 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) contributes one entry once the user applies a search with the magnifier button or Enter; `paramName` is the **Quick search param name** property (default `quickSearch`). Typing alone sends nothing, unless the table has *Search while typing* (`quickSearchOnType`) on. - **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. ## Autocomplete search requests An `autocomplete` with a **Search datasource** (`searchDataSourceName`) calls that source while the user types. It is an ordinary REST source; the only thing specific to it is what the runtime puts at your disposal when it fills the URL and body: | Placeholder | Value | | --- | --- | | `{query}` | The text the user typed, trimmed. Also available as `{term}`, `{search}` and `{filterValue}`, and under the element's *Query context key* if one is set. | | `{limit}` | The element's **Server result limit**, or **Max suggestions** when the limit is empty. Also `{take}`, `{rowLimit}`, `{recordCount}`. | | `{value}` | The field's current value. | Any other `{path}` resolves against the form data as usual, so a search can be narrowed by another field, e.g. `/api/cities?q={query}&country={country}`. ```text GET /api/cities?q=vil&limit=100 ``` **The response** can be any JSON that contains an array of records: the array itself, or an object with the array at a path you set in *Items path*. When *Items path* is empty the runtime tries `items`, `data.items`, `data.rows`, `data.records`, `data.results`, `data.content`, `records`, `rows`, `results`, `content`, `result` and `data`, in that order. The records need no particular shape. The creator picks which field is the label and which the value, and can store the whole record. **When the call is made.** Not on every keystroke. The runtime waits for the *Min search length*, then for the *Debounce* (1000 ms by default) after the last keystroke, and drops the answer to any request a newer one has replaced. It skips the call altogether when the new text only extends a query whose answer came back with fewer records than the result limit, because such an answer already held every match; the list is narrowed in the browser instead. So return **at most `limit` records** and never pad the answer: a full page is how the runtime knows there may be more. ## File upload requests `fileUpload` has a **Save files as** setting (`storageMode`): - **Base64 in the form data** (`base64`, the default): no request is made. Each file is read in the browser and its content goes into the field's value as `base64`. - **Upload to server** (`server`): the file is sent to the **Upload data source** and the value keeps the `fileKey` your endpoint returns. The **Download** and **Delete** data sources are used for preview, download and removal. Either way the value has the same shape, so your backend reads one format: ```json { "name": "resume.pdf", "size": 245678, "type": "application/pdf", "lastModified": 1790764181739, "contentType": "application/pdf", "fileKey": "9f2c1e6a-..." } ``` In base64 mode `base64` takes the place of `fileKey`. Views saved before this setting existed keep uploading if they have an upload data source. The rest of this section describes the server mode. ### 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 field's value is not the response itself: it is the standard object shown above, with the name, size and type taken from the response (falling back to the picked file) and the key stored as `fileKey`. An array of them is stored when **Multiple** is on. ### 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 [Data sources 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. --- ## E2E testing Source: https://ngxviewbuilder.io/developers/e2e-testing # E2E testing Every interactive control NGX View Builder renders has a `data-testid`: inputs, select triggers and their options, date pickers, table menus and dialogs, rich text toolbars, dynamic panel rows, and in the builder every property editor and canvas action. You never have to reach for CSS classes or text in a test. The ids are built from the element's **data path**, so they stay the same when you restyle a view, change a label or translate it. Two fields of the same type never share an id, which matters because Playwright's locators are strict: if `getByTestId()` matches two nodes, the test fails instead of picking one. ## How an id is built ``` nvb--- ``` | Piece | What it is | Example | | --- | --- | --- | | `nvb-` | the element that owns the control | `nvb-select` | | `` | which control inside it | `trigger`, `search`, `option` | | `` | where the value lives in the data, written as one token | `country`, `orders-0-status` | The data path is the element's `dataPath`, or its `name` when it has none. Dots, brackets and any other characters that are not letters, digits or `_` become a single dash, and the case is kept, so `orders[0].status` becomes `orders-0-status` and `billing.city` becomes `billing-city`. Nothing is shortened. A field called `firstName` and a select called `country` render, among others: ``` nvb-element-firstName the element's wrapper nvb-label-firstName its label nvb-text-input-firstName the input itself nvb-select-trigger-country the select's button nvb-select-search-country its search box nvb-country-option-LT the option whose value is LT nvb-button-trigger-save a button named save ``` Option ids end with the option's **value**, not its label, so they do not change when the labels are translated. A value made only of symbols is spelled out: `%` gives `...-option-percent`, `+` gives `...-option-plus`. ## Repeaters Inside a dynamic panel or a dynamic table the data path already carries the row, so the ids do too. For a dynamic panel called `orders` with a text field `sku` in it: ``` nvb-dynamic-panel-row-orders-0 the first row nvb-text-input-orders-0-sku the sku field in the first row nvb-text-input-orders-1-sku the same field in the second row nvb-dynamic-panel-delete-row-orders-1 delete button of the second row ``` Fields in an object panel work the same way through their path: `address.city` gives `nvb-text-input-address-city`. ## Playwright `data-testid` is Playwright's default test id attribute, so `getByTestId()` works without any configuration. ```ts import { test, expect } from '@playwright/test'; test('new order', async ({ page }) => { await page.goto('/orders/new'); await page.getByTestId('nvb-text-input-firstName').fill('Ada'); // A select: open it, then pick the option by its value. await page.getByTestId('nvb-select-trigger-country').click(); await page.getByTestId('nvb-country-option-LT').click(); // A repeater row. await page.getByTestId('nvb-text-input-orders-0-sku').fill('A-100'); await page.getByTestId('nvb-button-trigger-save').click(); await expect(page.getByTestId('nvb-element-firstName')).toBeVisible(); }); ``` With Cypress, use the attribute selector: `cy.get('[data-testid="nvb-text-input-firstName"]')`. The fastest way to find an id is the browser's element inspector: select the control and read its `data-testid`. Every id you see there is stable as long as the element keeps its name. ## Testing a page with the builder in it The builder's own controls use the `builder-` prefix (and `sidebar-` for the element library), followed by the area and, where there are several, the element or property they belong to: ``` sidebar-element-search the library search box sidebar-element-text the Text input tile in the library builder-canvas the canvas builder-column-firstName the firstName element on the canvas builder-element-duplicate-firstName its Duplicate action builder-element-delete-firstName its Delete action builder-row-firstName the row that starts with firstName property-row-label the Label row in the properties panel property-search-input the property search box header-settings the Form settings button builder-ai-status what a connected AI client is doing ``` Library tiles add an element on double click or Enter, and dragging works like any HTML5 drag and drop. A property's control is easiest to reach through its row, for example `[data-testid="property-row-label"] input`. `builder-row-…` named after the row's first element, `header-settings`, the spelled-out ids for symbol option values, and test ids on every dropdown in the properties panel come with 0.12.1. In 0.12.0 rows carry a random id that changes on every load. ## Your own elements A [custom element](https://ngxviewbuilder.io/developers/custom-elements) gets the same scheme from `createElementTestId()`: ```ts import { createElementTestId } from '@ngxviewbuilder/runtime'; export class RatingElement { readonly model = input.required(); protected readonly tid = createElementTestId(this.model, 'nvb-rating', 'rating'); } ``` ```html @for (star of stars; track star) { } ``` `tid('star', 3)` on an element named `score` gives `nvb-rating-star-score-3`. The extra arguments are for parts that repeat inside one element, like the stars here or the options of a list. `toTestIdToken()` is exported too, for building a token from your own values. ## Upgrading from 0.11 or earlier Before 0.12.0 a data path kept its dots and brackets (`nvb-text-input-orders[0].sku`), option ids were lower case and cut at 50 characters, and dynamic panel rows were not namespaced by the panel. Selectors written against those ids need the new form shown above. --- ## Events reference Source: https://ngxviewbuilder.io/developers/events # Events reference All events live on `NgxViewBuilderApiService`. They are small emitters of the library's own, not RxJS: ```ts const dispose = api.onValueChanged.add((e) => { /* ... */ }); // returns a function that detaches const sub = api.onValueChanged.subscribe((e) => { /* ... */ }); // { unsubscribe(), closed } api.onValueChanged.once((e) => { /* ... */ }); // first time only api.onValueChanged.remove(handler); ``` Detach in `ngOnDestroy`. A runtime view, a scope and the builder's Preview each fire on their own instance, and every event is forwarded to the root service you inject, so the same subscription works on any page. When several views are on screen, `elementName` and `elementDataPath` in the payload tell them apart. For one element only, the [element handle](https://ngxviewbuilder.io/developers/api/elements#element-getelementapi) has its own `onValueChanged`, `onValueChanging` and `onPropertyChanged`. ## Values & data | Event | Fires when | Payload | | --- | --- | --- | | `onValueChanging` / `onValueChanged` | a value is about to change / changed, by a person, an expression, an action or `setValue()` | `{ dataPath, newValue, oldValue, sender, trigger }` | | `onElementValueChanging` / `onElementValueChanged` | the same, with the element resolved | `{ elementDataPath, newValue, oldValue, trigger, element, host, api }` | | `onElementPropertyChanging` / `onElementPropertyChanged` | a property (label, hidden, disabled...) is written | `{ propertyKey, newValue, oldValue, elementName, elementDataPath, elementType, element, elementDom }` | `trigger` is `'input'`, `'change'` or `'blur'` for a person, `'programmatic'` for code and expressions, `'load'` while the view opens. `api` is the [element handle](https://ngxviewbuilder.io/developers/api/elements#element-getelementapi). ```ts api.onElementValueChanged.add(({ elementDataPath, newValue, trigger }) => { if (elementDataPath === 'country' && trigger !== 'programmatic') { void api.reloadDataSource('city'); } }); ``` ## Validation & completion | Event | Fires when | Payload | | --- | --- | --- | | `onValidating` / `onValidated` | validation runs: Validate, Submit, a validate action, `validateData()` | `{ isValid, issues }` | | `onComplete` | Submit (the button or a submit action) | `{ isValid, data, issues }` | | `onSaveRequested` | the builder's Save, Ctrl+S, or `notifySaveRequested()` | `{ source: 'header' \| 'api', timestamp }` | `issues` is a list of `{ elementName, dataPath, errors: string[] }`. ```ts api.onComplete.add(async ({ isValid, data }) => { if (!isValid) return; await firstValueFrom(http.post('/api/applications', data)); api.markPristine(); }); ``` ## Structure & navigation | Event | Fires when | Payload | | --- | --- | --- | | `onStructureChanged` | the view definition changed (builder edit, API write, `setStructure`) | `{ structure }` | | `onCurrentPageChanged` | another page is shown | `{ pageIndex, pageName }` | | `onDialogClosed` | a view rendered as a dialog closed | `{ pageIndex, pageName, dialogTitle, reason: 'close-button' \| 'api', timestamp }` | | `onTabChanged` / `onTabChangeRequested` | the builder's tab changed / was asked to change | `{ tab }` | | `onLanguageChanged` | the language switched | `{ language }` | ## Rendering | Event | Fires when | Payload | | --- | --- | --- | | `onBeforeRender` / `onRender` / `onAfterRender` | a view starts rendering / rendered its models / is in the DOM | `{ tab, root, phase }` | | `onElementRender` / `onElementAfterRender` | once per element in each of those phases | `{ tab, root, phase, elementName, elementDataPath, elementType, elementTestId, element, elementDom, index, total }` | They fire on the first render, on a new `[pageJson]` and on [`setStructure()`](https://ngxviewbuilder.io/developers/api/structure#setstructure). Use them for DOM level integrations: tooltips, analytics attributes, measuring. ```ts api.onElementAfterRender.add(({ elementName, elementDom }) => { elementDom?.setAttribute('data-analytics-id', `form.${elementName}`); }); ``` ## Element interactions | Event | Fires when | Payload, besides `elementName`, `elementDataPath`, `element`, `timestamp` | | --- | --- | --- | | `onDynamicTableRowAdded` / `onDynamicTableRowRemoved` | a row of a `dynamicTable` was added / removed | `rowIndex, row, rows, total` | | `onDynamicPanelItemAdded` / `onDynamicPanelItemRemoved` | an entry of a `dynamicPanel` was added / removed | `rowIndex, row, rows, total` | | `onFileUploadFilesAdded` / `onFileUploadFileRemoved` | files were attached / one removed | `files, addedFiles` / `files, removedFile, removedIndex` | | `onElementTabChanged` | a `tabs` element switched tab | `previousTabValue, tabValue, tabIndex` | | `onAccordionItemToggled` | an accordion section opened or closed | `itemIndex, itemValue, expanded` | The buttons, the [rows handle](https://ngxviewbuilder.io/developers/api/elements#rows-of-a-dynamic-table-or-panel) and `setValue()` on a repeater all fire them. ```ts api.onDynamicTableRowAdded.add(({ elementName, total }) => { if (elementName === 'familyMembers' && total >= 5) { api.showToast({ title: 'Limit', message: 'At most 5 family members', variant: 'warning' }); } }); ``` ## Data sources Every payload has `sourceName`, `sourceType`, `contextElementName`, `contextElementDataPath`, `fromCache` and `runtimeContext`. | Event | Fires when | Payload adds | | --- | --- | --- | | `onDataSourceLoading` | a request starts | `startedAt` | | `onDataSourceLoaded` | it succeeded | `startedAt, finishedAt, durationMs, result` | | `onDataSourceLoadFailed` | it failed | `startedAt, finishedAt, durationMs, error, errorMessage` | | `onDataSourceReloaded` | an element or the API asked for a reload | `timestamp, result` | One handler covers spinners and error toasts for every view: ```ts let pending = 0; api.onDataSourceLoading.add(() => this.loading.set(++pending > 0)); api.onDataSourceLoaded.add(() => this.loading.set(--pending > 0)); api.onDataSourceLoadFailed.add(({ sourceName, errorMessage }) => { this.loading.set(--pending > 0); api.showToast({ title: `Could not load ${sourceName}`, message: errorMessage, variant: 'error' }); }); ``` ## Appearance & configuration | Event | Fires when | Payload | | --- | --- | --- | | `onThemeModeChanged` | light or dark was set | `{ theme }` | | `onCustomThemeChanged` | a custom theme was set | `{ theme }` | | `onCssVariablesChanged` | tokens were overridden | `{ cssVariables }` | | `onCustomCssChanged` | custom CSS changed | `{ cssText }` | | `onCustomCssUrlsChanged` | stylesheet urls changed | `{ urls }` | | `onDataSourceTypeSettingsChanged` | optional source types were switched | `{ settings }` | | `onElementDefaultsChanged` | element defaults were registered or cleared | `{ defaults, changedTypes }` | | `onAiAssistantConfigChanged` | the AI assistant config was set | `{ config }` | ## Templates & sidebar library These are the persistence hooks of the [template library and the element library](https://ngxviewbuilder.io/developers/api/builder): store what they hand you, load it back on startup. | Event | Fires when | Payload | | --- | --- | --- | | `onTemplateSaved` | a template was saved in the Templates tab or with `saveTemplate()` | `{ template, previousCode, timestamp }` | | `onTemplateDeleted` | a template was deleted | `{ code, timestamp }` | | `onTemplatesLoaded` | `loadTemplates()` ran | `{ templates, replace, timestamp }` | | `onSidebarGroupSaved` | an element was saved to the library (right click, *Copy to library*) or with `saveSidebarGroup()` | `{ group, previousCode, timestamp }` | | `onSidebarTemplateSaved` | the same save from the builder, with the item as `item` | `{ item, timestamp }` | | `onSidebarGroupDeleted` | a library item was deleted | `{ code, timestamp }` | | `onSidebarGroupsLoaded` | `loadSidebarGroups()` ran | `{ groups, replace, timestamp }` | The components expose the same as outputs: `(templateSaved)`, `(templateDeleted)`, `(templatesLoaded)`, `(sidebarGroupSaved)`, `(sidebarGroupDeleted)`, `(sidebarGroupsLoaded)`. ## Tables | Event | Fires when | Payload | | --- | --- | --- | | `onTableSettingsChanged` / `onTableSettingsApplied` | columns were changed by a person or applied from the host | `{ tableName, elementName, settings, source }` | | `onTableSettingsRequested` | a table asks for stored settings | `{ tableName, elementName, currentSettings }` | | `onTableSettingsSaveRequested` | a table asks the host to store settings | `{ tableName, elementName, settings }` | | `onTableFiltersChanged` / `onTableFiltersApplied` | filters changed / were applied | `{ tableName, elementName, filters, source, savedFilter }` | | `onTableSavedFiltersChanged` | the saved filter list changed | `{ tableName, elementName, filters, source }` | | `onTableSavedFiltersRequested` | a table asks for stored filter sets | `{ tableName, elementName, currentFilters }` | | `onTableSavedFilterSaveRequested` | a person saved a filter set | `{ tableName, elementName, filter, currentFilters }` | ## Automation Triggers and rules defined in a view's `settings.triggers` and `settings.rules`, or registered through [extensions](https://ngxviewbuilder.io/developers/extensions), run in every runtime view and report here. | Event | Fires when | Payload | | --- | --- | --- | | `onTriggerHandling` / `onTriggerHandled` | a trigger matched its event / finished | `{ eventName, triggerId, trigger, startedAt, actionCount }` plus `finishedAt, durationMs, executed, reason` | | `onRuleEvaluating` / `onRuleEvaluated` | a rule is evaluated / finished | `{ runMode, ruleId, rule, startedAt, actionCount, otherwiseActionCount }` plus `executedBranch: 'actions' \| 'otherwise' \| 'skipped'` | A `valueChanged` trigger reacts to values a person changes. ## Example: a full save pipeline ```ts private readonly disposers: Array<() => void> = []; ngOnInit(): void { this.disposers.push( this.api.onComplete.add(async ({ isValid, data }) => { if (!isValid) return; await firstValueFrom(this.http.post('/api/forms', data)); this.api.markPristine(); this.api.showToast({ title: 'Saved', variant: 'success' }); }), this.api.onDataSourceLoadFailed.add((e) => this.logger.error('Data source failed', e.sourceName, e.errorMessage)), ); } ngOnDestroy(): void { this.disposers.forEach((dispose) => dispose()); } ``` --- ## 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 '@ngxviewbuilder/runtime'; 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; the designer additionally needs `@angular/forms`) - Node.js 22.22+ (or 24.15+ / 26+) The library works with zoneless change detection and standalone components. ## The two packages NGX View Builder ships as two npm packages, and which ones you install depends on what your application does. | Package | You need it when | License | | --- | --- | --- | | `@ngxviewbuilder/runtime` | Your app renders saved views | Free forever, no key | | `@ngxviewbuilder/designer` | Your app also hosts the visual editor | Commercial | The runtime stands on its own. The designer does not: it declares the runtime as a peer dependency pinned to the exact same version, because both halves are built from one source tree and released together. **Rendering views only**, which is the common case for the applications your end users open: ```bash npm install @ngxviewbuilder/runtime ``` **Hosting the editor as well.** Installing the designer pulls in the runtime, so name both and npm keeps the versions honest: ```bash npm install @ngxviewbuilder/runtime @ngxviewbuilder/designer ``` The optional Templates plugin is a third package, version-locked to the designer: ```bash npm install @ngxviewbuilder/plugin-templates ``` ### Why it is split Up to and including 0.4.1 both halves lived in one `ngx-view-builder` package. An application that only rendered views still installed the whole visual editor, roughly 28% of the code it would never execute, under a commercial license it did not need. Splitting the package means a runtime-only application now installs neither the editor code nor the license terms that come with it. ## 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 ], }; ``` ### Build warning about jexl The expression engine, `jexl`, is published as CommonJS, so the Angular build prints "Module 'jexl' used by ... is not ESM" a few times. It works as it is. To keep the build output clean, allow it in `angular.json`, under the build target's `options`: ```json "allowedCommonJsDependencies": ["jexl"] ``` ## Stylesheet Each component carries its own scoped styles, but the design tokens and the shared element rules live in one global stylesheet. It ships with the runtime and covers both packages, so the path is the same whether or not you installed the designer. Import it once: ```css @import '@ngxviewbuilder/runtime/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, which leaves you free to keep your own `.field` or `--color-neutral-300` meaning whatever they mean in your application. ## Initialization Runtime services can be pre-warmed either with a provider: ```ts import { provideNgxViewBuilderRuntime } from '@ngxviewbuilder/runtime'; providers: [ provideNgxViewBuilderRuntime({ preloadRuntimeServices: true }), ] ``` …or imperatively in a component: ```ts import { ForgeInitializerService } from '@ngxviewbuilder/runtime'; 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 '@ngxviewbuilder/plugin-templates'; providers: [ provideNgxViewBuilderTemplates(), ] ``` Each registered plugin adds its tab to the builder automatically. See [Using plugins](https://ngxviewbuilder.io/developers/plugins). Plugins extend the editor, so they depend on `@ngxviewbuilder/designer` rather than on the runtime. A runtime-only application has nothing to register them into. ## Which package to import from Both packages export from their root, and the designer re-exports the entire runtime surface. That gives you a simple rule: - **Runtime-only app**: import everything from `@ngxviewbuilder/runtime`. - **App that hosts the editor**: import everything from `@ngxviewbuilder/designer`. Structures, models and the API service all come through it, so you keep one import site instead of two. Nothing is duplicated in your bundle, because the runtime remains a separate package that both resolve to. Import only from package roots. Deep imports into `src/lib/...` are internal and break between releases. The runtime also exports a block of symbols marked `Internal API` in its `public-api.ts`. Those exist so the designer can reach into the runtime; they carry no stability guarantee and change without a major version. Build against the documented surface. --- ## Licensing Source: https://ngxviewbuilder.io/developers/licensing # Licensing > NGX View Builder is in public beta, so licenses aren't for sale yet. They go on sale with the 1.0.0 release. 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. Since 0.5.0 that split is enforced by packaging rather than by promise. `@ngxviewbuilder/runtime` and `@ngxviewbuilder/designer` are separate npm packages with separate license files: | Package | `LICENSE.md` | | --- | --- | | `@ngxviewbuilder/runtime` | Proprietary but free, perpetual, no key. Commercial and client work allowed. You may not resell it on its own, build a competing product from it, or build a visual editor on top of it. | | `@ngxviewbuilder/designer` | The commercial agreement described on this page. | | `@ngxviewbuilder/plugin-templates` | Follows the designer. | An application that only renders views therefore installs no commercially licensed code at all, which is the answer to the question procurement usually asks first. Writing the view JSON by hand, or generating it with an AI agent, is explicitly allowed by the runtime license: what you cannot do is ship your own editor UI on top of it. ## 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 The key is read by the designer. There is nowhere to put one in a runtime-only application, and nothing there would check it. Pass it 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. The check itself lives in the designer package, so a runtime-only application does not even ship the code that draws a watermark. ## AI access Letting an AI assistant work in the builder over MCP comes with the license, there is nothing extra to buy. It is also the one part that already needs a key before 1.0.0. The builder only opens the connection for a key that is genuine and not expired, whether it goes to our hosted server through the **Connect** button or to [a server you run yourself](https://ngxviewbuilder.io/developers/ai-command-api#running-the-mcp-server). Without such a key the AI access (MCP) group in the settings says what is missing. Our hosted server also checks the key with the license service before it pairs anything, and again every hour. It lets as many browsers use AI access at the same time as the license has seats (see [Seats](https://ngxviewbuilder.io/developers/ai-command-api#seats)). The server only relays requests between the AI client and the builder and stores none of your views or data ([what it keeps](https://ngxviewbuilder.io/developers/ai-command-api#what-the-mcp-server-keeps)). ## License terms The designer is **commercial software**, distributed under the NGX View Builder Commercial License Agreement, and the full text ships as `LICENSE.md` inside the published npm package (also shown on the npm package page). The runtime package ships its own, free license text instead. 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 keep their own licenses: the designer installs CodeMirror, Prettier and Lodash under MIT, the runtime installs Jexl and Lodash under MIT, and both install tslib under 0BSD. ## 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: **[info@heydelabs.com](mailto:info@heydelabs.com)**. --- ## 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 '@ngxviewbuilder/designer'; 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 (`@ngxviewbuilder/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 `@ngxviewbuilder/designer` (never bundle it). A plugin adds a builder tab, so the designer is the package it extends; the runtime surface reaches you through the designer's re-exports. - [ ] 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 `@ngxviewbuilder/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 @ngxviewbuilder/plugin-templates ``` ```ts import { provideNgxViewBuilderTemplates } from '@ngxviewbuilder/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 `