Skip to content

Form scripts (desk) ​

The file doctypes/<snake>/<snake>.form.ts, compiled by the server and loaded when the form opens.

ts
import { defineForm, ddcore } from "@ddcore/desk-sdk";
import type { Entry } from "../../.ddcore/types";

defineForm<Entry>("Entry", {
  setup(frm) { frm.setQuery("category", () => ({ filters: { kind: frm.doc.kind } })); },
  refresh(frm) {
    frm.setDfProperty("settlements", "cannotAddRows", true);
    if (frm.isNew) return;
    frm.addIndicator(__("Balance: {0}", [ddcore.format.currency(frm.doc.balance)]), "red");
    frm.addButton(__("Record a payment"), () => settle(frm), __("Actions"));
    frm.setInnerGroupAsPrimary(__("Actions"));
  },
  onChange: { kind(frm) { frm.setValue("category", null); } },
  validate(frm) { /* return false to stop the save */ },
  afterSave(frm) {},
  afterDelete(frm) { /* the form's Delete succeeded; frm.doc is what was deleted */ },
});

frm ​

doc, doctype, meta, isNew, isDirty, docstatus, perm, getValue, setValue(field | {..}, value), field(field), setDfProperty(field, prop, value) (hidden, readOnly, reqd, label, description, options, width, rows, cannotAddRows, cannotDeleteRows, gridSort, gridSortable, gridExport, gridSelect, gridFilters, gridSearch, gridIndex, hideLabel, reportFilters, and a Table's loading, see "A grid a script fills"), refreshField(field) (re-runs a Report field's report), setQuery(field, () => ({ filters })), toggleDisplay/toggleReqd/toggleEnable, addButton(label, fn, group), removeButton, setPrimaryAction(label, fn), setInnerGroupAsPrimary(group), addIndicator(label, colour), addChild(table, values), removeChild(table, idx), setRowValue(table, row | rowId, field | {..}, value), addFieldButton(field, { label, icon, onClick, key }), removeFieldButton(field, key?), getSelectedRows(field), clearSelection(field), addGridAction(field, { label, onClick, condition, primary, key }), removeGridAction(field, key?), trigger(field), save(), submit(), cancel(), reload(), discardChanges(), call(method, args, { reload }) → calls the controller's methods.<method> and reloads the doc, onRealtime(event, handler) → runs handler(payload) on each ddcore.publish(event, …) from the server while the form is open.

ts
// a thread that shows new messages as they arrive; the server publishes
// ddcore.publish("my_app.chat", { session: doc.id }, { doctype: "Chat Session", id: doc.id })
defineForm("Chat Session", {
  onload(frm) {
    frm.onRealtime("my_app.chat", (p) => { if (p.session === frm.doc.id && !frm.isDirty) frm.reload(); });
  },
});

isNew is always false on a Single: before its first save the form already holds the declared defaults, which are the settings in effect.

Tool Singles ​

A Single declared tool: true (see "DocType properties" in fieldtypes) is a screen, not a record. Its readers edit it, it is never dirty and it is never saved. The script fills its fields and rows and calls a service with what is on the form, and the primary button is the script's own. A Table declared gridSelect: true lets the reader tick the rows to act on, and the script reads them with frm.getSelectedRows (see "Actions on a grid's selected rows"):

ts
defineForm("Link Generator", {
  refresh(frm) {
    frm.addFieldButton("cpf", { icon: "search", label: __("Find Customer"), onClick: () => find(frm) });
    frm.setPrimaryAction(__("Generate Link"), async () => {
      const ids = frm.getSelectedRows("installments").map((r) => r.installment_id);
      const res = await ddcore.call("portal.services.desk.createLink", { customer: frm.doc.customer_code, ids });
      frm.setValue("link", res.url);
    });
  },
});

Use ddcore.call, never frm.call: a controller method sees the stored document, which for a tool is only its defaults, and reloads the form from it.

Field width and layout ​

A form is laid out by sizing its fields, not by splitting it into columns: a line is four slots (quarters) wide, and width?: "sm" | "md" | "lg" | "full" in metadata — or frm.setDfProperty(field, "width", value) at runtime — says how many of them the control takes.

widthslotsof the line
sm, md125%
lg250%
full4100%
  • Defaults by fieldtype:
    • sm: Date, Month, Time, Int, Percent.
    • md: Datetime, Float, Currency.
    • full: Text, Small Text, Text Editor, Markdown Editor, Code, JSON, Geolocation, Table, HTML.
    • lg: every other type (Data, Link, Select, Check, Attach, Password, etc.).
  • Line packing: Fields fill the line greedily, so [Status 1/2][Start 1/4][End 1/4] share one line. A field of s slots only starts at a multiple of s, so a half-line field never begins in the middle of a quarter: [1/4][1/2] renders as [1/4][empty 1/4][1/2], and a half-line field that no longer fits moves to the next line.
  • Dialogs pack onto a line of two slots, because a modal is too narrow for quarter-line controls: there sm/md is 50% and everything else fills the line.
  • Responsiveness: Below 800px, every cell collapses to 100% full width.

Unsaved changes ​

The desk keeps what the reader typed and did not save in the browser, one draft per user and record, for seven days. Leaving the form asks first — leave and keep the draft, stay, or Discard changes (Delete, or ⌫ on a Mac) and leave; coming back to the record puts the draft back and says so, and a record saved by someone else in the meantime asks whether to keep the draft. Discard changes in the form's menu — frm.discardChanges() — throws the draft away and goes back to the document as it was loaded, without asking the server for it again.

None of this needs anything from a form script. What it does mean is that refresh runs again after a draft is restored, on a doc that is already dirty.

Buttons on a field ​

addButton is an action on the document and belongs in the toolbar. An action on a single field — look this code up, pick one of the e-mails the query returned — reads right only next to the input it fills, and that is addFieldButton:

ts
frm.addFieldButton("email", {
  label: __("{0} e-mail(s) found", [emails.length]),
  onClick: () => pick("email", emails),
});
frm.addFieldButton("phone", { icon: "search", label: __("Look up"), onClick: () => lookup() });
frm.removeFieldButton("email");

Where the button goes depends on the field: beside the input on a field that has one; in the toolbar above the grid, to the right, on a Table or a Report field; under the content on an HTML field. That makes it the way to put an action next to a grid:

ts
// a Report field listing the course's classes, and the button that creates one
frm.addFieldButton("classes", {
  label: __("New Class"),
  onClick: () => ddcore.route("/app/Training%20Class/new?course=" + encodeURIComponent(frm.doc.id)),
});

A Report field has no grid until its document is saved, so its buttons appear only then.

The button is rendered by the desk, so:

  • the label is text, escaped by the desk — an app never writes HTML and never writes an esc();
  • icon (a name from the desk's icon set, listed under "Icons" in report-api) renders the button icon-only, with label as its tooltip and accessible name; without icon the label is the button's text;
  • the field's own hidden and dependsOn decide whether the button is on screen, and a field the reader cannot see carries no button;
  • key is the button's identity within the field. A second addFieldButton with the same key replaces it, which is what lets a label carrying a count be refreshed after each lookup; omit it and the field holds one button. removeFieldButton(field, key) removes that one, removeFieldButton(field) removes every button on the field.
  • an onClick that returns a promise keeps its button disabled, with a spinner in it, until the promise settles. A "Reload" button cannot be clicked twice, and its error is shown.

Field buttons are cleared by the same clearButtons() that empties the toolbar at the start of every refresh — declare in refresh whatever must survive a save or a reload.

A grid a script fills ​

A Tool Single often fills a Table from a whitelisted call that takes a while. While it runs, frm.setDfProperty(table, "loading", true) puts the grid in its loading state:

  • with no rows, a spinner and "Loading..." replace "No rows";
  • with rows (a reload), they stay on screen, dimmed and out of reach, until the new ones arrive;
  • either way, the grid's field buttons, its actions on the selected rows and "Add row" are off.

ddcore.call also runs the bar at the top of the desk while it is out. Set the flag back in finally:

ts
async function load(frm) {
  frm.setDfProperty("users", "loading", true);
  try {
    const res = await ddcore.call("app.services.users.listUsers", {});
    frm.doc.users = [];
    for (const r of res.rows) frm.addChild("users", r);
  } catch (e) {
    ddcore.ui.showError(e);
  } finally {
    frm.setDfProperty("users", "loading", false);
  }
}

defineForm("Users Tool", {
  onload(frm) { load(frm); }, // not awaited: the page opens at once, with the grid loading
  refresh(frm) {
    frm.addFieldButton("users", { icon: "refresh-cw", label: __("Reload"), onClick: () => load(frm) });
  },
});

An onload that awaits the call keeps the whole form on the desk's full-page spinner instead, until it settles.

Actions on a grid's selected rows ​

A Table or Report field declared gridSelect: true draws a checkbox per row and a "select all". The script reads what is ticked with frm.getSelectedRows(field), or puts actions on it in the grid's toolbar with frm.addGridAction, the form's counterpart of a list's actions:

ts
defineForm("TagOne Users", {
  refresh(frm) {
    frm.addGridAction("users", {
      label: __("Enable"),
      primary: true,
      condition: (row) => !row.enabled,
      onClick: async (rows) => {
        await ddcore.call("tagone.services.users.enable", { ids: rows.map((r) => r.user_id) });
        for (const r of rows) frm.setRowValue("users", r, "enabled", 1);
      },
    });
    frm.addGridAction("users", { label: __("Disable"), onClick: (rows) => disable(frm, rows) });
  },
});
  • Only the rows on screen count: a row ticked and then hidden by gridSearch or gridFilters is neither returned nor acted on, as it is not exported. The rows come in screen order (after gridSort or a click on a header), and they are the grid's own row objects: a Table's rows go to frm.setRowValue as they are, a Report field's are its report's rows.
  • The button reads label (n), where n counts the selected rows that pass condition (every selected row when there is none), and it hides while no row passes. Only those rows reach onClick. A condition that throws counts as false.
  • While onClick runs, the grid's action buttons are disabled; a rejection is shown to the user. Either way the selection is cleared when it settles. Nothing is reloaded: a script that changed the rows on the server updates them on screen itself (setRowValue, refreshField for a Report field).
  • key is the action's identity within the field, the label when there is none: a second addGridAction with the same key replaces it. removeGridAction(field, key) removes one, removeGridAction(field) removes them all. Like field buttons they are cleared at the start of every refresh, so declare them there.
  • Without gridSelect nothing can be ticked: getSelectedRows returns [] and no action shows. It also returns [] while the grid is not on screen (a hidden field, a Report field still loading). frm.clearSelection(field) unticks every row.
  • The actions show whether or not the grid is editable, and beside "Delete selected" on a Table that has it. The button is only a shortcut: the method it calls must check permissions itself.

Grids: row changes and cell clicks ​

onChange on a Table is keyed by the table's fieldname, not the child field, and fires for a change in any of its rows — an edit in the grid, the row dialog, or setRowValue:

ts
onChange: {
  attendance(frm, cdt, cdn, row, changed) {
    // cdt: the child DocType ("Training Class Attendance")
    // cdn: the row's id — undefined for a row not saved yet
    // row: the row itself, the object in frm.doc.attendance
    // changed: the child fields that changed — ["grade"] after an edit in its cell
    if (row) frm.setValue("present", frm.doc.attendance.filter((r) => r.in_class).length);
  },
},

Adding or removing a row fires it too, with no cdt, cdn, row or changed. An edit in a cell changes one field; the row dialog and setRowValue pass every field whose value they changed (possibly none, for a dialog applied as it was).

When what to do depends on which field of the row changed, give that field its own handler in grids.<table>.onChange.<child field>(frm, row). It runs for each changed field, before the table's onChange, so a total computed there sees the row as the field handlers left it:

ts
defineForm<TrainingClass>("Training Class", {
  grids: {
    attendance: {
      onChange: {
        in_class(frm, row) {
          if (!row.in_class) frm.setRowValue("attendance", row, { attended: 0, grade: null });
        },
        attended(frm, row) {
          if (row.attended && !row.in_class) frm.setRowValue("attendance", row, "in_class", 1);
        },
        grade(frm, row) {
          if (row.grade != null && !row.in_class) frm.setRowValue("attendance", row, { in_class: 1, attended: 1 });
        },
      },
    },
  },
});

A setRowValue inside a handler fires the handlers of the fields it sets, so set a field only when it must change — as above — or two handlers can undo each other forever. setRowValue leaves a field already holding the value alone and fires nothing for it.

frm.setRowValue(table, row, field, value) — or (table, row, { field: value, … }) — changes a row so that everything follows as if the reader had edited it: the grid shows the value, the form turns dirty, the handlers of the fields it changed run and the table's onChange fires once. row is the row object or its id; a row that is not in the table throws. Assigning to frm.doc.<table>[i].<field> directly also updates the grid, but fires no onChange.

grids.<table>.onCellClick.<child field> makes a cell act on a click. The cell renders as a button showing its usual value, and the handler receives the row clicked — the row itself, even when the grid is sorted or filtered:

ts
defineForm<TrainingClass>("Training Class", {
  grids: {
    attendance: {
      onCellClick: {
        employee(frm, row) {
          if (frm.docstatus === 0) frm.setRowValue("attendance", row, "in_class", row.in_class ? 0 : 1);
        },
      },
    },
  },
});

Only cells shown as text take the click: a read-only column, or every cell of a gridEditMode: "dialog" grid. An editable cell keeps its control. The whole cell takes the click, its padding and an empty value included, not only the text. The handler runs on a submitted or read-only form as well, so check before changing anything, as above. Handlers from several scripts for the same cell all run.

grids.<table>.onRowClick(frm, row) acts on a click anywhere on a row: the row shows a pointer, and a click on it runs the handler with the row clicked. A click that lands on something with a click of its own does not reach it: an editable cell's control, a link, a button, a cell with an onCellClick, the row's edit and remove buttons, a gridSelect checkbox. Neither does the end of a drag that selected text. Like onCellClick, it runs on a submitted or read-only form too. To tick a row from anywhere on it:

ts
defineForm<PortalLinkGenerator>("Portal Link Generator", {
  grids: {
    installments: {                       // every column read-only but the Check `selected`
      onRowClick(frm, row) {
        frm.setRowValue("installments", row, "selected", row.selected ? 0 : 1);
      },
    },
  },
});

A click on the selected checkbox itself ticks it through its control, and the handler does not run, so the two never undo each other. The row takes no keyboard focus: what onRowClick does should also be reachable from a control in the row (the Check above, a cell with onCellClick).

A Report field takes onCellClick too, keyed by the report's column fieldname. Every non-empty cell of that column becomes a button (a Link cell no longer navigates); an empty cell takes no click. onRowClick works on a Report field as well, with the row as the report returned it; a Link cell still navigates. The handler receives the row as the report returned it. A handler may be async, and an error it throws or rejects with is shown. frm.call(..., { reload: true }) or frm.refreshField(field) re-runs the report:

ts
defineForm<Charge>("Charge", {
  grids: {
    transactions: {                       // a Report field; its report returns an `action` column
      onCellClick: {
        async action(frm, row) {
          if (!(await ddcore.ui.confirm(__("{0} {1}?", [row.action, row.id])))) return;
          await frm.call("rowAction", { id: row.id }, { reload: true });
        },
      },
    },
  },
});

ddcore in the desk ​

  • ddcore.call("app.services.file.fn", args) — a whitelisted function; the bar at the top of the desk runs while it is out
  • ddcore.report(name, filters) — runs a defineReport: { meta, result: { columns, rows } }
  • ddcore.session — { user, fullName, roles, lang, impersonatedBy? }; impersonatedBy is the administrator while one is working as user. ddcore.impersonate(user) starts that (Admin or System Manager; the page reloads as the user) — see "Impersonation" in auth
  • ddcore.db.getValue/getList/count/getDoc/setValue/insert (asynchronous: await them). getList(doctype, { filters, fields, orderBy, limit, start }) returns the rows the user may read. Without limit it returns a page of 20, unlike the server's getList, which returns every row; limit: 0 returns every matching row, with no cap, and a negative limit is refused. A bulk action that acts on "all of them" passes limit: 0.
  • ddcore.ui.Dialog({ title, fields, values, primaryLabel, primaryAction(values, dlg), secondaryLabel, hideSecondary, dangerLabel, dangerAction(values, dlg), dangerShortcut, actions, onChange(field, values, dlg), size }) → dlg.show()/hide()/setValue/getValue/setHtml(htmlField, html)/setDfProperty(field, property, value). secondaryLabel names the button that closes the dialog without acting (Cancel when left out); hideSecondary: true leaves it out. dangerAction is for what loses something: its button is red, on the left.
  • actions: [{ label, icon?, action(values, dlg) }] adds non-destructive buttons between the close button and the primary one, in order. An action receives the dialog's values and the dialog; while one that returns a promise runs, every action button of the dialog is disabled, and its error is shown. Unlike primaryAction, an action checks no reqd field and has no key: Enter stays the primary's. None closes the dialog by itself, so call dlg.hide() when it is done:
    ts
    ddcore.ui.Dialog({
      title: __("Payment Link"),
      fields: [{ fieldname: "link", fieldtype: "Data", label: "Payment Link", readOnly: true }],
      values: { link: url },
      primaryLabel: __("Copy Link"),
      async primaryAction(_v, dlg) { await navigator.clipboard.writeText(url); dlg.hide(); },
      actions: [
        { label: __("Send by WhatsApp"), icon: "message-circle", action(_v, dlg) { dlg.hide(); sendDialog(url); } },
      ],
    }).show();
  • A dialog's fields are DocType fields plus one type that exists only there, File: a file picker whose value is the file itself, { name, size, type, base64 } (or null), handed to the script. Nothing is uploaded and no File document is created, which is what a credential needs; a file to keep is an Attach. options is the accept list (".pfx,.p12") and maxBytes the size cap, 5 MB when left out: a larger file is refused before it is read. A DocType cannot declare it.
    ts
    ddcore.ui.Dialog({
      title: __("Replace Certificate"),
      fields: [
        { fieldname: "file", fieldtype: "File", label: "Certificate (.pfx)", options: ".pfx,.p12", reqd: true },
        { fieldname: "password", fieldtype: "Password", label: "Password" },
      ],
      async primaryAction(values, dlg) {
        await frm.call("replaceCertificate", { pfx: values.file.base64, password: values.password });
        dlg.hide();
      },
    }).show();
    The server method checks it with ddcore.crypto.pfxInfo before storing it in the vault. See controller-api.
  • ddcore.ui.msgprint(msg, { title, indicator }), ddcore.ui.toast, ddcore.ui.confirm(msg, title?, { destructive? }) (with destructive: true the confirm button is red and "No" is the primary, so Enter keeps the data and Delete — ⌫ on a Mac — confirms), ddcore.ui.prompt(title, fields), ddcore.ui.showError(e)
  • ddcore.session → { user, fullName, roles, lang }, the signed-in user as the desk loaded it, and ddcore.hasRole(role). They mirror the server's ddcore.session and decide what the desk shows: a button for the roles the server will accept. They grant nothing, so the method the button calls still checks on the server.
    ts
    defineForm("Portal Payment", {
      refresh(frm) {
        if (ddcore.hasRole("Portal Manager")) frm.addButton(__("Resolve Manually"), () => frm.call("resolveManually"));
      },
    });
  • ddcore.format.currency/date/number/value/statusColor, ddcore.datetime.today/addMonths/addDays/dateDiff/monthDiff/monthStart/monthEnd
  • ddcore.search.global(txt, limit?) — the documents the global search palette lists. See search
  • ddcore.realtime.on(event, handler) → an off() function; ddcore.realtime.off(event, handler). Events sent with ddcore.publish (see controller-api), outside a form — in a form, frm.onRealtime drops the handler when the form closes
  • ddcore.route(path) — navigates the Desk to a path; ddcore.setRoute(...parts) — the same from path segments under the current workspace: the first is a name and goes in as its route name, the rest are encoded as they are. See "Navigation" below
  • __("text", [args]) — translation; the key is its English text. See i18n.

An app passes the short route and never needs to know which workspace a DocType lives in:

RouteOpens
/app/<DocType>the list (the form, for a Single)
/app/<DocType>/<id>the document
/app/<DocType>/newa new document

The Desk redirects each of them to /app/<Workspace>/<DocType>…, the workspace that owns the DocType, and the query string and the hash travel with the redirect.

A DocType, a workspace or a report goes into the path as its route name: the name without its spaces, case kept — Training Class is /app/TrainingClass, the report Open Tasks is /app/<Workspace>/report/OpenTasks. The name as it is (/app/Training%20Class) still opens and is redirected to the route name, so a link written before this rule keeps working. A record id is never changed: it goes in whole, percent-encoded. /api paths take the real name, spaces included.

  • Prefilling a new document — /app/<DocType>/new?field=value sets every query key that names a field of the DocType on the new document; any other key is ignored. A button on a Course that opens a Training Class for it:

    ts
    frm.addButton(__("New Class"), () =>
      ddcore.route(`/app/TrainingClass/new?course=${encodeURIComponent(frm.doc.id)}`));
  • Opening a filtered list — /app/<DocType>?field=value opens the list with that filter applied, the same query string the list writes as the user filters. It is what a workspace card's route: takes (see report-api).

Encode every value and every record id with encodeURIComponent.

Dates and times ​

ddcore.datetime works in civil dates ("YYYY-MM-DD") with exactly the semantics of ddcore.utils on the server: today() is the day in the site's timezone — the same day the server calls today, which is what makes due_date < today() agree on both sides — and addMonths clamps the day to the end of the target month: addMonths("2026-01-31", 1) === "2026-02-28".

dateDiff(a, b) is the whole days from b to a and monthDiff(a, b) the calendar months, the day ignored — dateDiff("2026-05-10", "2026-05-01") === 9, monthDiff("2026-02-01", "2026-01-31") === 1 — so an onChange can show what the controller's validate is about to compute:

ts
end_date(frm) {
  frm.setValue("total_days", ddcore.datetime.dateDiff(frm.doc.end_date, frm.doc.start_date) + 1);
}

Datetime fields, by contrast, are instants: they travel as ISO UTC and the control shows and accepts them in the site's timezone. Never slice the string (v.slice(0, 16)) to fill a datetime-local — that shows UTC as if it were local time; use $lib/datetime (toDatetimeLocal/fromDatetimeLocal).

Never format a date, a number or a currency by hand: ddcore.format.* derives the order, the separators and the symbol from the reader's language and the site's currency.

defineListView ​

The list's title comes from the DocType's label, with its description under it when there is one; the New button follows the reader's create permission and the DocType's allowCreate (see "DocType properties" in fieldtypes).

Adjusts a DocType's list from a global script (client/*.ts):

ts
import { defineListView, ddcore } from "@ddcore/desk-sdk";

defineListView("Entry", {
  columns: ["contract", "period", "amount", "balance"], // in place of the meta's inListView
  filters: { status: "Open" },          // initial filters (the URL's query string still wins)
  orderBy: "due_date asc",
  pageSize: 50,
  formatters: { amount: (v, row) => ddcore.format.currency(v) }, // the cell's text
  indicator: (row) => (row.balance > 0 ? { label: __("Open"), color: "red" } : { label: __("Settled"), color: "green" }),
  docstatusFilter: false,               // hides the "Document status" filter of a submittable DocType
  filtersCollapsed: false,              // opens the filter card by default (it starts hidden)
  modifiedColumn: false,                // hides the trailing "Modified" column
  idColumn: false,                      // hides (or `true` shows) the leading document-id column
  columnChooser: false,                 // hides the Columns menu, where each user hides and adds columns
  plainLinks: ["contract"],             // Link columns shown as the title in text, not as a link
  rangeFilters: ["due_date"],           // a Date or Datetime filtered by a from and a to day
});

Every key is optional. formatters returns text (not HTML); indicator replaces the default status column and may return null to show nothing on that row. docstatusFilter: false suits a submittable DocType whose status field already separates draft, submitted and cancelled — the docstatus filter would only repeat it.

The filter card (search, document status, the inStandardFilter fields, "Clear filters") sits behind a Filters button in the list's header and starts hidden, so a Calendar, Kanban or Gantt view keeps the screen. The button counts the filters in force — filters defaults and a day picked on the calendar included — so a hidden filter still shows. Each user's choice is kept per DocType in the browser; filtersCollapsed: false only changes where a user who has not chosen yet starts.

A Table or Table MultiSelect can be a standard filter too, when its child has exactly one Link (migrate refuses inStandardFilter on any other table): the card shows a Link to that Link's target, and the list keeps the documents with a row holding the value picked — the filter ["<Child>.<link>", "=", value]. The User list filters by role this way:

ts
{ fieldname: "roles", fieldtype: "Table", label: "Roles", options: "Has Role", inStandardFilter: true }

The search box matches, as a substring, the id, the titleField, the searchFields, the Links that are inListView or inStandardFilter, and every column on screen of a text type (Data, Email, the text and editor types, Select, Autocomplete, Barcode, Link, Dynamic Link) — so a column a user adds with the Columns menu is searchable too. A Link also matches its target's title; a Select matches its stored (English) value, not the translated label. Numbers, dates and checks are left to the filters.

Next to it, a Columns button (in the List view) lets each user hide a column — the id and Modified ones included — and add any other field they may read: not a layout field, a table, a hidden or computed field, a Password, Vault, Signature or Geolocation. The columns shown come first, in screen order, and a field checked joins them at the end; they are dragged into another order (or moved with Alt+Up/Down), the id staying first and Modified last. The choice is kept in the browser per user, tenant and DocType (tenants share one origin), a field the DocType no longer offers drops out of it, and Reset to default forgets it. So columns, idColumn and modifiedColumn are where a user starts, not what every user sees; columnChooser: false takes the menu away and the app's columns are all there is. The list's CSV export carries the id, always and first, then the columns on screen (see export).

Left out, idColumn follows the id. The list hides the column on its own when the id is a hash (idGeneration: { hash: true }, or no rule at all), unless the list has no other column, and when the title field is a column and is the id (idGeneration: { field } equal to titleField). idColumn: true brings a hash back; idColumn: false hides an id that means nothing to a reader for another reason, such as a code already shown in another column. With the column hidden, the row stays clickable and the title field's cell links to the document. To relabel the column instead of hiding it, set idLabel on the DocType (see fieldtypes).

A Link column links to the document it names, so a click on it leaves the row. When the rows are easily mistaken for what they link to — the classes of a course, each showing its course — plainLinks: ["course"] shows that column as the course's title in plain text, and a click on it opens the class like the rest of the row. Only Link and Dynamic Link columns are affected.

Most lists need no indicator at all: declare optionColors on the status field and the desk colours and translates it on its own. Reach for indicator only when the label is not a field value — and never key a colour on text a reader sees, since that changes with the language.

Badges and extra filter choices go together when a status has sub-states that are not a value of the field — say, open entries that are also overdue:

ts
defineListView<Entry>("Entry", {
  fields: ["overdue_count"],            // fetched though not a column
  badges: (row) => (row.overdue_count ? [{ label: __("Overdue"), color: "red" }] : []),
  filterOptions: {
    status: [{ value: "overdue", label: __("Overdue"), filters: [["status", "=", "Open"], ["overdue_count", ">=", 1]] }],
  },
});

badges are drawn after the status, in the same cell. filterOptions appends choices after a divider to that field's standard filter; choosing one sends its filters instead of an equality, and the URL carries its value (?status=overdue). Filters only reach columns, so a sub-state computed from other documents has to be stored on the document to be filterable.

Date ranges ​

A Date or Datetime standard filter picks one day. rangeFilters names the fields filtered by a period instead: each shows in the filter card — a standard filter or not — as two day pickers, <label> from and <label> to, and either may be left empty for an open-ended period:

ts
defineListView<Entry>("Entry", { rangeFilters: ["due_date", "posted_at"] });

The URL carries field=from..to (?due_date=2026-10-01..2026-10-31, ?due_date=2026-10-01.., ?due_date=..2026-10-31), and a bare day, as an older link or a day picked on the calendar writes it, still means that day alone. The bounds are inclusive: a Date compares the days themselves, a Datetime runs from the first day's first instant to the last day's last one, in the site's timezone. A name that is not a Date or Datetime field of the DocType is ignored.

Actions on the selected rows ​

actions adds buttons over the rows a user ticks in the List or Cards view. They show next to "Delete (n)" while at least one selected row applies:

ts
defineListView<MessageTemplate>("Message Template", {
  fields: ["approval_status"],          // what `condition` reads, when it is not a column
  actions: [{
    label: __("Submit for approval"),
    condition: (row) => row.approval_status !== "Submitted",
    async onClick(ids, list) {
      await ddcore.call("oblata.services.whatsapp_templates.submitTemplates", { ids });
      ddcore.ui.toast(__("{0} submitted", [ids.length]), { indicator: "green" });
    },
  }],
});

The button reads label (n), where n counts the selected rows that pass condition (every selected row when there is none), and it hides while no row passes. Only those rows reach onClick: their ids first, then list with rows (the loaded values, in list order), doctype and refresh(). A condition that throws counts as false. primary: true draws the button as primary.

While onClick runs, the list's action buttons are disabled; a rejection is shown to the user. Either way the selection is cleared and the list reloads, so onClick needs no refresh() of its own. The rows are the ones on screen, loaded with the list's columns and fields — fetch anything else by id. The button is only a shortcut: the method it calls must check permissions itself.

Actions on the whole list ​

toolbarActions adds buttons to the list toolbar that need no selection — "Pay out all", "Sync now". They show in every view, before the "Delete (n)" and Filters buttons:

ts
defineListView("Portal Store", {
  toolbarActions: [{
    label: __("Pay out all"),
    condition: () => ddcore.hasRole("Portal Manager"),
    async onClick(list) {
      const n = await ddcore.call("portal.services.payouts.payOutAll", { filters: list.filters });
      ddcore.ui.toast(__("{0} paid out", [n]), { indicator: "green" });
    },
  }],
});

onClick gets list with doctype, filters (the [field, op, value] triples the current view loads with, the search box left out) and refresh(). To act on every row those filters match, fetch them with limit: 0 — ddcore.db.getList(list.doctype, { filters: list.filters, fields: ["id"], limit: 0 }) — or pass filters to a server method that reads them itself; the default page of 20 would act on part of the rows. condition takes no row: it decides whether the button shows when the list renders, and one that throws hides it. primary: true draws the button as primary. While onClick runs the list's action buttons are disabled, a rejection is shown to the user, and the list reloads afterwards, keeping the selection. As with actions, the method it calls must check permissions itself.

Totals above the list ​

summary puts cards above the rows, like a report's summary — a count, or the sum of a field — over every row the filters match, not only the page on screen:

ts
defineListView("Ledger Entry", {
  summary: [
    { label: __("Total"), aggregate: "sum:amount" },
    { label: __("Receipts"), aggregate: "sum:amount", filters: { entry_type: "Receipt" }, indicator: "green" },
    { label: __("Fees"), aggregate: "sum:amount", filters: [["entry_type", "in", ["Platform Fee", "Card Fee"]]] },
    { label: __("Entries") },
  ],
});
  • aggregate is "count" (the default) or "sum:<fieldname>", as a workspace number card takes.
  • filters narrows one card on top of the list's filters and search: [field, op, value] triples, or { field: value } for equalities.
  • datatype formats the value (Currency, Int, Float, …); left out, a count is an Int and a sum takes the summed field's type. indicator (red, green, orange, blue) colours it.
  • label is shown as given: wrap it in __().

Each card is one aggregate query to the list API, sent with the rows and again whenever the list reloads (a filter, the search, a list_update). It runs as the reader, so it sees the rows the list shows: permissions, scopes and the tenant apply, and summing a field the reader may not read is refused. A card that fails shows a dash, and the list and the other cards load as usual. The cards show in every view but the Tree, over what that view loads — the month on screen in the Calendar, say.

Views: Tree, Calendar, Kanban, Gantt and Cards ​

Besides the table, a list can offer other views of the same filtered rows. A segmented switcher in the list header shows the views, and the choice is kept in the URL (?view=kanban) and per DocType in the browser. A view appears once it is configured: calendar needs its field, kanban its field, and gantt both startField and endField; list and cards are always available. tree needs nothing configured here but a DocType declared isTree, and comes first for one, so a hierarchy opens as a hierarchy (see trees); its tree option composes the node label and sets the order (tree: { title: "{acronym} - {title}", orderBy: "title asc" }). views sets the order, or a subset, and is filtered by the same rule — a view listed there but never configured is dropped rather than shown as a button that falls back to the table:

ts
defineListView<Task>("Task", {
  views: ["list", "calendar", "kanban", "gantt", "cards"], // default: list, then each configured view, then cards
  calendar: { field: "start_date", endField: "due_date", titleField: "title", colorField: "status" },
  kanban: { field: "priority", columns: ["High", "Medium", "Low"], titleField: "title", subtitleField: "project", colorField: "status" },
  gantt: { startField: "start_date", endField: "due_date", titleField: "title", colorField: "status", progressField: "percent_done" },
  card: { title: "title", subtitle: "project", dateField: "due_date", image: "cover" },
});
  • Calendar: clicking a day lists the records the grid draws on it (a Datetime field matches the whole day in the site's time zone): those whose field falls on the day and, with endField, those that started earlier and end on it or later. The day stays a filter of its own, ?calendar_day=YYYY-MM-DD, shown as a removable Day in the filter card. The + in a day's corner, shown on hover, opens a new record with the field set to that day. A calendar on creation or modified has the + only. With endField (a Date or Datetime), each record is drawn as a bar across every day from field to endField, and the bar wraps at the end of each week. A record keeps its row within the week, and it shows on the calendar from the first day to the last of the span, including one that started before the month shown. A record with no end (or an end before its start) takes its start day alone.

    calendar.newOptions points the day's + elsewhere — for a calendar that gathers other DocTypes' records into a read-only or virtual DocType, whose own + would never show. Each option names the doctype to create, its Date or Datetime field set to the day, an optional endField set to the same day (a Datetime one to its last minute) and a label (shown as given, so wrap it in __(); the DocType's label when omitted). An option shows only when the user may create its DocType; one left links straight to its form, several open a menu, and with none the + is hidden. The list's own DocType can be one of them.

    ts
    calendar: {
      field: "start_date", endField: "end_date",
      newOptions: [
        { label: __("Campaign"), doctype: "Campaign", field: "start_date", endField: "end_date" },
        { label: __("Diary"), doctype: "Marketing Log", field: "log_date" },
      ],
    },
  • Cards is the default on a phone.

    • title defaults to the DocType's titleField, then id. dateField defaults to the first visible Date or Datetime field.
    • image names an Attach Image (or Attach) field, and defaults to the DocType's imageField (see fieldtypes).
      • Each card then shows it beside the title as a square thumbnail, cropped to fill.
      • Thumbnails load lazily. A /private/files/… image follows the File's read access, like any other private file.
    • A card whose image is empty or fails to load shows an avatar instead:
      • it holds the title's initials, from the first letter of the first and last words ("MARIA DA SILVA" → MS, "Acme" → A);
      • its colour is derived from the title, so the same title always gets the same colour;
      • it also appears when the field is above the reader's permission level.
  • Calendar plots each row on the day of field (Date or Datetime) and loads the visible month.

  • Kanban makes a column of each value of a Select field.

    • Columns follow columns or the field's options, with their translated labels and optionColors.
    • A value outside those still gets a column, so no card is hidden, and rows with no value go to "(empty)".
    • titleField defaults to the DocType's titleField, then id; colorField defaults to field, and a card shows an indicator only when colorField names another field.
    • A field the DocType does not have — or one above the reader's permission level — renders "The Kanban field {0} is not a field of {1}". Nothing enforces Select: another fieldtype still renders, with the columns built from the loaded rows alone.
    • Dragging a card to another column saves the field at once, sending the row's modified, so a stale card is refused. The card moves back if the server refuses the save (permission, workflow, validation, or a concurrent change).
    • Cards are draggable only when the user may write the DocType and the field is not read-only (declared, or above the user's permission level); submitted documents never move.
    • Group by a field the user edits: a field a controller computes is usually readOnly, and its board is read-only.
  • Gantt draws a bar from startField to endField (Date or Datetime; creation works too).

    • Scales are Day, Week and Month, with previous, today and next navigation; clicking a bar opens the document. Week is the default and shows 12 weeks; Day shows 21 days, Month 12 months. The window starts one week — one month, at the Month scale — before the anchor's, and previous/next move 7 days, 4 weeks or 3 months.
    • Rows order by startField asc unless orderBy says otherwise. titleField defaults to the DocType's titleField, then id, and colorField to status.
    • The view loads only rows overlapping the window, a row without an end date is not drawn, and an end before the start is clamped to the start.
    • progressField (0–100) shades the bar. The view is read-only: bars are not dragged.

Colour. colorField usually names a Select, and the view paints each record with the indicator colour of its value: the field's optionColors, then the framework's canonical statuses, then a colour derived from the text. When colorField names a Color field, the record is painted with the colour it holds instead — a calendar entry and a Gantt bar take its hue (lightened behind the text, darkened for the text and the dot), and a Kanban card takes it as a coloured left edge rather than an indicator showing the hex. A record whose Color field is empty keeps the neutral grey. This is what lets a user pick any colour for a record (a campaign, a project) with the Color field's picker and see it on the boards.

Kanban, Gantt and Calendar load up to 500 rows matching the filters instead of a page. When more match, Kanban and Gantt say so and ask for narrower filters.

A DocType may have several form scripts: its owner's <snake>.form.ts, plus one per app extending it (extensions/<snake>.form.ts — see extending). Their handlers accumulate, in app load order; every refresh, validate and onChange runs. defineListView is the exception: one per DocType, and the last registration wins.

Global scripts (client/*.ts, listed under desk.include in ddcore.app.ts) run across the whole desk: masks, shortcuts, defineForm for several DocTypes. Date field inputs carry data-fieldname and data-fieldtype for masks applied by event delegation.

defineProfileSection ​

An app adds its own card to the profile page — /app/profile and /portal/profile — from a global script: desk.include for the desk, portal.include for the portal (see portal). A Website User loads only the portal's includes, so a card meant for them is registered there. Cards come after the Password card, in the order they were registered; several apps may each add theirs, and registering an id again replaces that card.

ts
// client/profile.ts, listed under desk.include in ddcore.app.ts
import { defineProfileSection, ddcore } from "@ddcore/desk-sdk";

defineProfileSection<{ strength: string }>({
  id: "tagone_password",
  title: __("TagOne password"),
  // null hides the card: this person does not sign in through TagOne
  load: () => ddcore.call("tagone.services.profile.myTagonePassword"),
  info: (d) => [{ label: __("Password strength at last sign-in"), value: d.strength }],
  description: __("Changes your password in TagOne."),
  fields: [
    { fieldname: "password", fieldtype: "Password", label: __("New password"), reqd: true },
    { fieldname: "confirm", fieldtype: "Password", label: __("Confirm the new password"), reqd: true },
  ],
  primaryLabel: __("Change TagOne password"),
  successMessage: __("TagOne password changed."),
  submit: (values) => {
    if (values.password !== values.confirm) throw new Error(__("The two passwords do not match"));
    return ddcore.call("tagone.services.profile.changeMyTagonePassword", { password: values.password });
  },
});
  • Every string is shown as given, so wrap each one in __().
  • load runs when the page opens. Its result goes to info and submit. null or undefined hides the card; a throw shows its message on the card instead of the info and fields. With no load, the card always shows.
  • fields are rendered as in a form. A reqd field left empty stops the submit with "Fill in {0}".
  • A throw from submit, or a rejected ddcore.call, shows its message on the card, as the Password card shows a wrong current password. A success clears the fields, shows successMessage as a toast when there is one, and runs load again.
  • A card with no fields and no submit shows only its info and description.
  • The core's Password card shows only when the person has a local password. getMyProfile answers hasPassword. Someone who signs in only through a credential provider or SSO has none, so a card like the one above is the only password card they see.
  • Includes are loaded once per page load, so a change to the script shows after the browser reloads.

MIT Licensed · ddcore (Data Driven Core) · Development documentation (main)