Skip to content

Modules (NXM)

Views and the view SDK

How an NXM module shows its records. Declarative views that nexxus draws for you, custom HTML views in a sandbox, and the view SDK that connects them to the platform.

For
Developers
Time
20 min

An NXM module has two ways to put something on screen. Most screens need no code at all: you describe a list or a record page in module.json, and nexxus draws it with the same components as its built-in modules. When a screen needs its own layout, such as a calendar, a cockpit with several blocks or a PDF with notes beside it, you write one HTML file. nexxus runs that file in a sandboxed frame and hands it a small JavaScript API, the view SDK (window.nx).

This page covers both. Start with the declarative views and reach for a custom view only where they stop being enough.

Declarative viewsCustom views
Written asJSON in module.json (type: list, record, related)One HTML file per view (type: "custom", html: "ui/<name>.html")
Drawn bynexxus itselfYour HTML, in a sandboxed frame
Looks likeThe rest of nexxus, automaticallynexxus when you use the UI kit and theme tokens
Data accessBuilt in: lists, filters, forms, related listsThrough window.nx, as the person looking at the view
Good forLists, record forms, lists of related recordsPages, cockpits, calendars, dialogs, PDF review

Both tiers act with the rights of the person on the page. A custom view never gets more access than the person who opened it, and every call it makes is checked again on the server.

  1. Your view

    One HTML file with inline script and style, running in a sandboxed frame.

  2. The view SDK

    window.nx sends each request over a private message channel.

  3. The host in nexxus

    Adds the module and the session, shows toasts, dialogs and the PDF viewer.

  4. The platform API

    Checks the rights of the viewing person and answers.

A custom view never sees a session, a tenant id or a user id. The host adds them, and the server checks every request.

These four types need no HTML. nexxus builds lists, forms, tabs and maps from them.

  • list: the list page of an entity at /x/<module>/<entity>. You choose columns (a field key, or { "field": "status", "display": "badge" }), filters and a default sort; "create": true shows the button for a new record. Filters and sorting work on indexed fields only. Without a list view, nexxus builds one from the titleField and the first four indexed fields. The list shows each record as a row in nexxus’ list style, and every column stays visible: the titleField becomes the heading, references and text fields go below it, the first person (core:user) with its label beside them. Choices, dates and numbers sit on the right, the first amount highlighted, and the badge column status (else the first badge column) as a chip. Under View options each person picks the density Normal or Compact and saves views of their own.
  • record: the Data tab of a record. sections group the fields, related adds lists of records that point to this one, and actions decides which actions the action bar offers.
  • related: a tab on another entity’s page that lists records pointing to it. On a core record that is the partner, project, invoice or opportunity page ("target": "core:partner", "core:project", "core:invoice" or "core:opportunity", slot record.tab).
  • map: the records of an entity on a map, together with their list, as a page of the module at /x/<module>/p/<view>. geoField names the geo field that places a record.

The full field reference for these views is on Package structure.

A record page of a package entity shows its tabs in this order:

  1. every record.detail view (custom views about this record),
  2. the Data tab (the record view, or all fields if there is none),
  3. every record.tab view of the module,
  4. the related lists.

Only views of the entity’s own package appear there: packages do not build on each other, so no other package adds tabs or dialogs to the page. Actions sit in the action bar at the bottom of the tab.

A package record page with a custom view as its first tab
Custom detail views come first, then Data, then the module's tabs, then related lists.

A custom view declares a slot. The slot decides where nexxus shows it and what the view gets as context.

SlotShown in the catalogue asWhere it appearstarget
pagePageA page of its own at /x/<module>/p/<view>. It fills the area and can be a menu entry.none
record.detailView on a recordThe first tabs of a record pagean entity of the package
record.tabTab on a recordA tab after Data; with a core target a tab on the partner, project, invoice or opportunity pagean entity, or core:partner, core:project, core:invoice, core:opportunity
record.actionDialog on a recordA dialog opened from the record’s action baran entity
dashboard.widgetDashboard tileA tile with size s, m or lnone
memberClient area pageA page of the customer area on the portal domain, at /konto/<view>, filling the windownone

nexxus shows the title above a page and a record view: the view’s label on a page, the record’s title on a record page. The view does not repeat it and starts with what it adds, such as a period, a count or filters. A tile and a dialog get their title bar from nexxus too.

A tile reaches the start page in two ways: a person adds it with Customize and Add widget, where the view’s label, icon and module name are listed, or applies a template of the package (see Start page templates). None appears by itself. When the module is deactivated or removed, a placed tile says Module not available and comes back with the module.

{ "key": "faellig", "type": "custom", "slot": "page", "html": "ui/faellig.html",
"label": { "de": "Fällige Rückgaben", "en": "Due returns" } }

Views and actions take an optional when. nexxus hides the view when the condition does not hold for the current record, and the server checks it again before an action runs.

"when": { "field": "status", "in": ["verfuegbar"] }
"when": { "field": "dokument", "set": true }

in holds when the field’s value is one of the listed values (for a list field, when any element is). set: true holds when the field has a value, set: false when it is empty.

Every custom view runs in an <iframe> with sandbox="allow-scripts allow-forms" and without same origin, under the same content policy as apps:

default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src data: blob:; font-src data:; media-src data: blob:; form-action 'none'; base-uri 'none'

The sandbox exists because a package’s code does not come from nexxus. Whatever the view does, it cannot reach the network, read the session or touch the page around it. In practice:

  • Inline only. Scripts, styles, fonts and images are inline or data:/blob: URLs. fetch, XHR, WebSockets and <script src> from the network are blocked.
  • No cookies and no localStorage. The frame has no origin. Use nx.storage for what a person should find again.
  • Forms work, submissions don’t. A <form> fires its submit event, but form-action 'none' blocks anything that would navigate. Call preventDefault() and handle the data yourself.
  • No popups and no downloads.
  • No icon font. The Material Symbols font of nexxus is not available in the frame. Use inline SVG.
  • Text as text. Records contain what people typed. Insert it with textContent, never with innerHTML.

The SDK and the UI kit are inserted before your own code, so window.nx exists when your first script runs. An HTML file may be up to 2 MB.

This page lists the devices that are on loan, from the first module tutorial:

<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>body { padding: 16px; }</style>
</head>
<body>
<div class="nx-list" id="list"></div>
<script>
(async () => {
const ctx = await nx.ready();
const {items} = await nx.records.search('geraet', {
filter: {status: ['verliehen']}, sort: 'rueckgabe_am', size: 50,
});
for (const geraet of items) {
const item = document.createElement('button');
item.className = 'nx-list__item';
item.textContent = geraet.title;
item.onclick = () => nx.nav.open({entity: 'geraet', id: geraet.id});
document.getElementById('list').append(item);
}
})();
</script>
</body>
</html>

await nx.ready() resolves once the host has connected. It returns the context of the view:

FieldMeaning
sdkProtocol version, currently 1
module, version, view, slotWhere the view runs
localede, en or pl, the person’s language
themelight or dark
user{id, name} of the person looking at the view
record{entity, id} on a record page of an entity of the view’s package, e.g. {entity: 'akte', id: 12}
target{type, id} on a core record, e.g. {type: 'core:project', id: 7}
paramsWhatever the opener handed over

nx.on(name, fn) subscribes to an event and returns a function that unsubscribes.

  • context: the context changed (another record, another language). The argument is the new context.
  • theme: light or dark changed, as {theme, tokens}. The SDK has already applied it. Listen only if you draw something yourself, such as a canvas or a chart.
  • record: the record your view is about was changed by nexxus outside your view, as {entity, id, version}. This happens after an action ran on it, after a dialog wrote it, after the Data tab was saved, or after another view changed it. For core records version is null. Your own writes do not come back as events.

The event says that something changed, not what. Read the record again:

const ctx = await nx.ready();
let shown = await nx.records.get(ctx.record.entity, ctx.record.id);
render(shown);
nx.on('record', async ({version}) => {
if (version !== null && version === shown.version) return;
shown = await nx.records.get(ctx.record.entity, ctx.record.id);
render(shown);
});

Changes that nexxus did not make in this browser tab, by another person or by a module service in the background, are not announced.

Every call returns a promise. A failed call rejects with an nx.NxError that carries code, message and, for validation errors, fieldErrors. When a module service refuses an action, code is SERVICE_REJECTED, serviceCode is the service’s own code (for example COMPOSE_STALE) and serviceStatus its HTTP status; check serviceCode, not the text, which is in the person’s language.

CallResult
nx.ready()The context (see above)
nx.on(name, fn)Subscribes to context, theme or record; returns an unsubscribe function
nx.records.search(entity, {q?, filter?, sort?, from?, size?}){items, total}. filter per field: a value, a list of values or {from, to}. sort: 'field', 'field,asc' or 'field,desc' (see below). size at most 200
nx.records.get(entity, id)The record
nx.records.create(entity, data)The new record
nx.records.update(entity, id, patch, {version?})The record. With version, a newer copy on the server fails with CONFLICT
nx.records.remove(entity, id)true
nx.lookup(target, {q?, ids?})[{id, label, sublabel}]. Targets: an entity of the module, 'core:partner', 'core:partnerContact', 'core:project', 'core:invoice', 'core:opportunity', 'core:user', 'core:dmsDocument', 'core:ismsMeasure'. Without q, the first 20
nx.invoke(action, {recordId?, payload?}){result, record?}. Runs an action of the module; the server checks its permission and its when. An action that sends e-mail asks the person first, see below
nx.actions.preview(action, recordId){recipient: {name, address}, subject, text, fileName}: what an action that sends e-mail would send for the record. Sends nothing
nx.nav.open(target)Opens {entity, id}, {list: entity}, {view} (a page of the module) or {core, id} with core one of 'partner', 'project', 'invoice', 'opportunity'. {view, id} with a record.action view opens its dialog for that record and resolves with what the dialog passed to nx.dialog.close(), or null when it was dismissed. {view, id} with a map view opens the map on that record (?focus=<id>)
nx.ui.toast(message, {kind?})Shows a toast; kind is success, error or info
nx.ui.confirm({title, message, confirmLabel?, danger?})true when confirmed. nexxus shows its own dialog
nx.dialog.close(result?)In record.action views only: closes the dialog and hands result to the opener
nx.storage.get(key)The stored value, per person and view
nx.storage.set(key, value)Stores a value in the person’s profile, so it follows them to other devices. set(key, null) removes it
nx.assistant.setContext({summary, records?})Tells the AI Assistant what the view shows. records is [{entity, id}]; an empty object withdraws it
nx.assistant.ask(prompt)Opens the assistant column and asks prompt as a new conversation
nx.t({de, en, pl})The text in the person’s language, else the English one, else the German one. pl is optional
nx.pdf.mount(element, {documentId, page?, highlights?})A handle for a PDF viewer over element (see below)
nx.images.url(documentId, {width?})A blob: URL of the document’s picture for <img src>. nexxus loads the file with the reading rights of the person looking at the view and takes only pictures up to 15 MB. With width, the width on screen in CSS pixels, a copy scaled to fit comes instead of the original, and the browser keeps it for ten minutes. The same id and width give the same URL, loaded once; at most four load at a time
nx.files.upload(file, {entity, field, name?}){id, documentId, name, type, size, url}. Stores a file from the view’s own input, a drop or the camera as a document of the person, for a field of entity that refers to documents. Write documentId into that field yourself; url shows the file at once. In record.detail, record.tab and page views, with the scope core:dmsDocument:create, see Photos and files from the view

Entities are those of the view’s own package, named by their key ('geraet'). A view reaches no other package: a name with a package key in front, such as 'curiaos.kanzlei:akte', fails with INVALID_PARAMS. Searches filter on indexed fields only.

sort takes an indexed field or one of id, createdAt (created) and updatedAt (last changed). Without a direction it sorts ascending. Without sort, the most recently created record comes first. Records without a value in the sort field come last, and among equal values the most recently created one comes first. nx.records.search('geraet', {sort: 'updatedAt,desc', size: 10}) gives the ten records changed last.

An action of type mail makes nexxus send an e-mail with a PDF on behalf of your organisation to the address of a contact. nexxus takes the address from the contact itself, never from your view, and asks the person before anything goes out.

When your view calls nx.invoke for such an action, nexxus opens its own dialog Send e-mail outside your frame. It shows the action’s confirm text, then To with the recipient’s name and address, Subject, the text of the e-mail and Attachment with the PDF’s file name, all as the server computed them. Only Send sends, and only what the dialog showed. Cancel, the close button or Escape reject your call with CANCELLED. The same dialog opens when someone clicks the action in the record’s action bar.

  • Don’t ask with nx.ui.confirm first. The person would confirm twice.
  • If the contact has no e-mail address, the dialog shows the server’s message and offers only Close. Your call then rejects with CANCELLED, and nx.actions.preview with NO_ADDRESS.
  • If the record or the recipient changed between the preview and the send, the call fails with CONFIRMATION_STALE. Call nx.invoke again for a fresh preview.
  • nx.actions.preview(action, recordId) returns the same preview, so your view can show it next to its button. It never contains the token that ties a send to the preview, so a view cannot send past the dialog.
const mail = await nx.actions.preview('entwurf-versenden', ctx.record.id);
showPreview(mail.recipient.name, mail.recipient.address, mail.subject, mail.text, mail.fileName);
try {
await nx.invoke('entwurf-versenden', {recordId: ctx.record.id});
} catch (e) {
if (e.code !== 'CANCELLED') nx.ui.toast(e.message, {kind: 'error'});
}

A view on a record page or on a page of its own can take photos and other files, for example on a tablet in the field. The view keeps its own <input type="file">. nexxus stores the file in Files as a document of the person and hands back its id, which the view writes into a field that refers to documents.

  1. Declare the scope core:dmsDocument:create under scopes. The administration agrees to it when installing the package (Create documents). Without it the call fails with CONSENT_REQUIRED.
  2. Give the entity a ref field with "target": "core:dmsDocument", with "multiple": true for several files and "accept": ["image/*"] when only pictures belong there.
  3. Call nx.files.upload(file, {entity, field}) and save the documentId it returns into the field.
// <input type="file" id="photo" accept="image/*" capture="environment">
const input = document.querySelector('#photo');
input.addEventListener('change', async () => {
const file = input.files[0];
if (!file) return;
try {
const photo = await nx.files.upload(file, {entity: 'component', field: 'photos'});
preview.src = photo.url;
const record = await nx.records.get('component', ctx.record.id);
const photos = [...(record.data.photos || []), photo.documentId];
await nx.records.update('component', record.id, {photos}, {version: record.version});
} catch (e) {
nx.ui.toast(e.message, {kind: 'error'});
}
});
  • What nexxus checks: the person may change records of the entity, the field refers to documents and its accept takes the file, the file is a document, picture, office file or e-mail, and it is at most 25 MB. A larger file is refused before it is sent (TOO_LARGE).
  • Where the file goes: into a folder with the package’s name in the person’s own files, made on the first upload. Nothing needs setting up, because everyone who may read a record that refers to the file may read the file too.
  • Optionally one folder for the team: an administrator creates a top folder in Files with the package’s name in the organisation’s language and shares it with the right to edit. The uploads of everyone who sees it and has no folder of that name of their own then land there. Shared for reading only, it refuses their uploads.
  • Who sees it: the person who uploaded it, everyone the folder is shared with, and everyone who may read a record of the package that refers to the file.
  • The upload alone changes no record. A file that never reaches a field stays in the person’s files.
  • HEIC: iPads and iPhones usually hand a file input a JPEG. A HEIC file is stored as it is, and browsers other than Safari do not show it.
  • Not available in widgets on the start page, in action dialogs (record.action) and on public forms: there the call fails with NOT_AVAILABLE.

Later the view shows a stored photo with nx.images.url(documentId).

CodeComes fromMeaning
VALIDATIONServerThe data was rejected; fieldErrors names the fields
FORBIDDENServerThe person lacks the permission
NOT_FOUNDServerThe record, action or view does not exist
MODULE_INACTIVEServerThe module is deactivated
CONFLICTServerSomeone changed the record since you read it
UNIQUEServerA unique value is already taken
NO_ADDRESSServerThe contact of an action that sends e-mail has no e-mail address
CONFIRMATION_STALEServerThe record or the recipient changed since the e-mail preview was shown, or the preview expired
CONSENT_REQUIREDServerThe package lacks the scope the call needs, such as core:dmsDocument:create for nx.files.upload
INVALID_PARAMSHostA parameter had the wrong shape; the message names it
UNKNOWN_METHODHostNo such SDK method
BUSYHostToo many calls at once, or a confirmation is already open
CANCELLEDHostThe person closed the dialog of an action that sends e-mail without sending
STORAGE_FULLHostMore than the storage limit for this view
NOT_IN_DIALOGHostnx.dialog.close() outside a record.action view
NO_USER_ACTIONHostThe call needs a click or key press in the view first
NOT_AVAILABLEHostThe call does not exist in this kind of view, such as nx.files.upload in a widget
NOT_AN_IMAGEHostnx.images.url: the document is no picture
TOO_LARGEHostnx.images.url: the picture is larger than 15 MB. nx.files.upload: the file is larger than 25 MB
LIMITHostA limit was hit, e.g. too many PDF viewers
NETWORKHostThe request did not reach the server
UNAUTHENTICATEDHostThe session has ended

Show error.message to the person. The server answers in German. For validation errors the message is general and fieldErrors holds the detail: a search sorted by a field that is not indexed fails with „Die Suche ist ungültig.“ (the search is invalid) and fieldErrors.sort „Nach ‘notiz’ lässt sich nicht sortieren: das Feld ist nicht indexed.“ (cannot sort by ‘notiz’: the field is not indexed)

The frame is transparent and follows the theme of nexxus: <html data-theme="light"> or "dark". On a record page there is no card behind it: your view stands directly on the page, like the sections of the Data tab, so its content belongs in cards (nx-card). The UI kit is a CSS file nexxus injects into every view. You may ignore it, but with it your view looks like nexxus in both themes and in the tenant’s brand colour.

VariableUse
--nx-text, --nx-text-muted, --nx-text-faintText, secondary text, hints
--nx-surface, --nx-surface-raised, --nx-surface-sunken, --nx-pageCards, menus, recessed areas, the page ground
--nx-line, --nx-line-softBorders and dividers
--nx-brand, --nx-brand-contrast, --nx-brand-inkBrand fill, text on it, brand colour as text
--nx-accent, --nx-success, --nx-warning, --nx-danger, --nx-infoSignal colours
--nx-radius, --nx-radius-sm, --nx-shadow, --nx-font, --nx-font-monoShape and type
ClassFor
nx-btn with nx-btn--primary, --danger, --quiet, --sm, --iconButtons
nx-badge with nx-badge--success, --warning, --danger, --info, --brandStatus labels
nx-card, nx-card__titleA panel
nx-field, nx-field__label, nx-field__hint, nx-field__error, nx-input, nx-select, nx-textareaForms; aria-invalid="true" marks an error
nx-list, nx-list__item (is-active)Lists; a <button class="nx-list__item"> is clickable
nx-rows, nx-entity-row (is-active), nx-metaRecords the way nexxus draws its lists; nx-list-compact on the list turns them into rows in one frame
nx-tableTables
nx-empty, nx-empty__icon, nx-empty__titleEmpty states
nx-title, nx-subtitle, nx-label, nx-muted, nx-faint, nx-mono, nx-numType
nx-stack, nx-row, nx-spacer, nx-dividerLayout helpers
nx-kbd, nx-progress (--nx-value), nx-spinnerSmall things
nx-pdf-slotThe region reserved for a PDF

A view can show a PDF from the files area without ever holding its bytes. You reserve an element, and nexxus lays its own PDF viewer over it and loads the document with the rights of the person looking at it.

const handle = nx.pdf.mount(document.getElementById('pdf'), {
documentId: 88, highlights: [{text: 'binnen zwei Wochen', page: 2}],
});
handle.on('select', ({page, text, rects}) => showNoteButton(page, text));
handle.on('page', page => updatePageLabel(page));
await handle.goTo(3);
const {total, index} = await handle.find('Zustellung');
await handle.setHighlights([{text: quote, page}]);
await handle.destroy();
  • Give the element a size, especially a height: <div id="pdf" class="nx-pdf-slot"></div>. The viewer follows the element when it moves, scrolls or resizes, and is cut where your scrolling containers cut the element.
  • Events: select ({page, text, rects}, with rects as fractions 0 to 1 of the page, origin top left; a cleared selection comes with empty text), page (the page in view), load ({pages}), error ({code}). handle.ready resolves when the viewer is mounted.
  • Highlights match regardless of case and whitespace, across line breaks, and with typographic quotes and dashes typed plainly. Without page, every occurrence is marked.
A custom view with the PDF viewer of nexxus and a notes pane
Clicking a note highlights its quote; nexxus loads the PDF with the reader's rights.
  • Height follows your content, up to 20 000 px. A page view fills the area below its top instead (at least 320 px). Leave <html> at its natural height unless you are a page.
  • Keys with Ctrl, Cmd or Alt, and Escape, go on to nexxus unless your handler calls preventDefault(). So Escape closes the dialog of a record.action view.
  • Dialogs (record.action): nexxus draws the dialog header with the view’s label, icon and a close button. Your view draws the body and its own buttons, and finishes with nx.dialog.close(result). Closing the dialog any other way means the action was not carried out. Another view of the module opens the dialog with nx.nav.open({view, id}).
  • Tiles (dashboard.widget): nexxus draws the card with icon, label and module name; the view draws only the content. Sizes on a wide screen: s a quarter of the row, m half, l the full row.
A record.action view as a dialog
nexxus draws the header, the view draws the content and the buttons.
WhatLimit
Calls in flight per view32
Toasts20 a minute, at least 400 ms apart
nx.storage per person and view15 000 characters as JSON; keys [A-Za-z0-9._-], at most 64 characters
Record payload per call256 KB as JSON
View height20 000 px
PDF viewers per view4, with up to 50 highlights of 2 000 characters
HTML file2 MB

Package modules bring no chat of their own. The AI Assistant of nexxus works on every package page, and a view can tell it what the view shows.

  • On list, record and page views, nexxus already tells the AI Assistant the module, the entity, the record and the view.
  • nx.assistant.setContext({summary, records}) adds what only your view knows, for example “3 devices overdue” with the records behind it. It appears as a chip in the assistant column and goes with every message while the view is on screen.
  • nx.assistant.ask(prompt) opens the assistant column with a new conversation. It works only right after a click or key press in your view, so a view cannot start a conversation on its own.
  • The example prompts of the manifest (assistant.prompts with a view key) show up as chips in the assistant column when that view is on screen.
The assistant column with prompts and context from a custom view
The chips come from the manifest's prompts; the context chip names the view.

How the assistant uses a module’s tools is described in Working with the AI Assistant and Package structure.

You rarely need this, but it helps when you read the browser’s message log.

  1. The SDK posts {type: 'nxm:ready', sdk} to the parent window.
  2. nexxus checks that the message comes from its own frame (event.source) and answers {type: 'nxm:connect'} with a MessagePort.
  3. Everything after that goes over the port: requests {t: 'req', id, method, params}, answers {t: 'res', id, ok, result} or {t: 'res', id, ok: false, error}, and events {t: 'evt', name, data} in both directions.

Events from nexxus to the view are init, context, theme, record and pdf.*; from the view to nexxus height, key and pdf.rect. The method names are records.search, records.get, records.create, records.update, records.remove, lookup, invoke, actions.preview, nav.open, ui.toast, ui.confirm, dialog.close, storage.get, storage.set, assistant.setContext, assistant.ask, images.get, pdf.mount, pdf.update, pdf.goTo, pdf.find and pdf.destroy.

  • No descriptor in the view. A view cannot read the module’s field and enum labels. Repeat the labels you show, with nx.t({de, en, pl}), and keep them in line with the manifest.
  • No rights in the view. A view cannot ask whether the person may run an action. Offer the button and show the server’s FORBIDDEN message when it comes back, or put the action in the record’s action bar, which nexxus shows only to people who may run it.
  • No icon font. Use inline SVG.

nexxus shows its own message in place of a view that cannot run:

MessageCause and fix
“This view could not be loaded.”The view could not be loaded, for example after a network error. Try again loads it again.
“You are not allowed to see this view.”The person lacks a permission of the module. Give them a role of the package, see Roles.
“This view does not exist (any more).”The view key is not in the installed version, for example after an update removed it.
“The module of this view is not active.”The package is deactivated. Activate it on its package page.

Inside your own view, the usual surprises are a fetch that the content policy blocks (use nx.records instead), a sort or filter on a field that is not indexed (VALIDATION), and nx.assistant.ask called without a click (NO_USER_ACTION).