Skip to content
Article Data UI, hooks and plugins
☀️ Light Clean and bright
📜 Sepia Warm and vintage
🌤️ Light Gray Subtle and neutral
🔵 Light Blue Calm and serene
🌙 Dark Easy on the eyes
🌑 Dark Gray Deep and modern
🔷 Dark Blue Professional and sleek
🌿 Forest Calm and natural

Data UI, hooks and plugins

Every CRUD surface in SepoDesk is one of two logic/UI pairs. You never instantiate them — AppDataManager does that — but you extend them constantly, and the two are not interchangeable.

You callLogicUIReads
renderApp(config)AppDataLogicAppDataUIdata-field
renderGroupedApp(config)AppGroupedDataLogicAppGroupedDataUIdata-key

The logic owns data, selection, paging and the network calls. The UI owns the toolbar, the render loop, modals and event binding. Hooks and plugins are how you get between them.

For the config keys themselves, see Building an app front end.

Where the two diverge

They look like a matched pair. They are not. Read this table before you copy a plugin or hook from one surface to another.

Flat (AppDataUI)Grouped (AppGroupedDataUI)
Template attributedata-fielddata-key
Master data_data + _filteredData_allFlatData + _groupedData
Row filter hookbefore.render.rows (array)before.render.groups (object)
Completion payload{ container, rows }{ container, data, totalItems }
Plugin parent methodscreateParentItem / updateParentItem / deleteParentItemcreate / update / delete
getSelection()Returns a copyReturns the live array
Select All scopeCurrent page onlyEvery currently rendered group
Batch update controlsPresentNot rendered at all
Search debounce250 msNone
Grouping vs pagingn/aRows are paged first, then grouped

That last row matters more than it looks. _rebuildGroupedStateFromMaster slices the flat array to the current page and only then buckets it, so a group spanning a page boundary appears on both pages, and the (N) count in a group header is the count on that page, not the group total.

uiSettings.batchUpdate does nothing in the grouped renderer. The field selector, value selector and Apply button are only built by AppDataUI. Setting the flag on a grouped surface silently gets you nothing.

Lifecycle

AppDataManager.createContainer() / createGroupedContainer() runs a fixed sequence. Knowing the order tells you what exists when your code runs.

new AppDataLogic(config)
new AppDataUI(logic)
  └─ _registerHooks(ui, config.hooks)      ← your hooks attach here
ui.init(config)
  ├─ _createToolbar()                       ← toolbar.panels.filter.build fires
  ├─ _createPaginationControls()            (grouped only)
  ├─ _createMainContainer()                 ← templates cached, throws if missing
  ├─ _bindEvents()
  ├─ _headerActions()
  ├─ _buildPluginRegistry(config.plugins)   ← your plugins attach here
  ├─ config.setup(ui)                       ← your escape hatch
  └─ logic.subscribe(() => this.render())
ui._initLoadOnStart()                       ← select options fetched in parallel
logic.fetchData()                           ← first paint

Two consequences:

  • toolbar.panels.filter.build fires once, during init, before any data exists. It is for arranging controls, not for reacting to rows.
  • config.setup(ui) receives the fully built UI. Anything you cannot express as a hook goes here — extra listeners, references you want to keep, monkey-patching a logic method.

The render pipeline

Both renderers walk the same shape. The hook points are marked.

render()
 ├─ guard: skip entirely if an .sp-gform-input has focus
 ├─ guard: skip if already rendering (_isRendering)
 ├─ clear main content
 ├─ ► before.render.rows / before.render.groups        (filter)
 │
 ├─ per row:
 │   ├─ clone template, attach clone.__rowData = row
 │   ├─ ► row.rendering                                 (action)
 │   ├─ _expandDynamicTemplate     — dynamicFields → blueprint clones
 │   ├─ applyBorderRadius          — first/last corner classes
 │   ├─ applyConditionalLogic      — data-if-null, data-class-if, …
 │   ├─ bindPermissions            — _can_* → lock or unlock controls
 │   ├─ rowHighlighter             — click-to-select highlight
 │   ├─ renderDynamicCards         (grouped only)
 │   ├─ renderDataFields           — data-field / data-key
 │   ├─ bindStandardActions        — edit, delete, extraActions, checkbox
 │   ├─ _compileRowActionsToMenu   — if [data-row-actions="true"]
 │   └─ applyRowFormulas
 │
 ├─ append fragment
 ├─ pagination + selection UI refresh
 ├─ footer formulas
 └─ ► after.render.complete                             (action)

The focus guard is easy to trip over. While an inline-edit input has focus, render() returns immediately. Calling logic.updateLocalRow() from a keystroke handler updates the data but does not repaint until blur. That is deliberate — re-rendering under the cursor would destroy the input — but it means "my hook didn't run" is often "render didn't run".


Hooks

Hooks are declared as one object on the surface config:

const container = await this.manager.renderGroupedApp({
  app: "SchoolManagerApp",
  // …
  hooks: {
    "before.render.groups": (grouped) => grouped,
    "row.rendering": async ({ element, data, index, groupName }) => {},
    "after.render.complete": async ({ container, data, totalItems }) => {},
    "toolbar.panels.filter.build": (panels) => panels,
  },
});

Filter or action?

AppDataManager._registerHooks decides by name, not by what you pass:

if (name.includes("before") || name.includes("filter")
 || name.includes("map")    || name.includes("build")) {
  ui.hooks.addFilter(name, cb, priority, options);
} else {
  ui.hooks.addAction(name, cb, priority, options);
}
  • A filter must return a value. Whatever it returns replaces the input.
  • An action returns nothing. Its return value is discarded.

Returning nothing from a filter hands undefined down the chain and the render loop then iterates nothing — a blank surface with no error. Always return.

Registration shapes

hooks: {
  // bare function → priority 10
  "row.rendering": ({ element, data }) => { /* … */ },

  // object → explicit priority; lower runs first
  "before.render.rows": {
    callback: (rows) => rows.filter(r => r.sp_status !== "archived"),
    priority: 5,
  },
}

Passing { callback } without priority sets the priority to undefined, not to 10 — the default only applies to the bare-function form. When you use the object form, always give a number.

Both filters and actions are awaited (applyFiltersAsync / doActionAsync), so async callbacks work everywhere.

The four hook points

before.render.rows — flat only

Receives the current page's rows as an array. Return an array.

hooks: {
  "before.render.rows": (rows) =>
    rows.map(r => ({ ...r, sp_display_name: `${r.sp_user_lastname}, ${r.sp_user_firstname}` })),
}

Use it for derived display columns, last-mile filtering and sorting. It runs on the page slice, not the whole dataset — filtering here changes what the page shows without changing the page count, so a filtered page can look short. Filter in the fetch method when you want the counts to agree.

before.render.groups — grouped only

Receives the grouped object, { groupName: [rows] }. Return the same shape.

hooks: {
  "before.render.groups": (grouped) => {
    const out = {};
    Object.keys(grouped).sort().forEach(k => { out[k] = grouped[k]; });
    return out;   // alphabetise the group headers
  },
}

Key order is render order — this is the only place to control it.

row.rendering — both

Fires per row after cloning, before any attribute processing. This is the main extension point.

hooks: {
  "row.rendering": async ({ element, data, index, groupName }) => {
    if (Number(data.sp_acquired_grade) < 50) {
      element.style.borderLeft = "solid 3px var(--action-delete)";
    }
    if (data.sp_assignment_due_date && new Date(data.sp_assignment_due_date) < new Date()) {
      element.querySelector('[data-key="sp_assignment_due_date"]')
        ?.classList.add("sp-txt-c-red-500");
    }
  },
}
FieldNotes
elementThe row's root element — the fragment's first child, not the clone wrapper
dataThe row object, including _can_* flags
indexPage index (flat) or index within the group (grouped)
groupNameGrouped only

Because it runs before renderDataFields and bindPermissions, anything you write into a data-field / data-key element is overwritten, and any class you add to an action button may be re-evaluated by the permission pass. Add decoration to wrappers; use after.render.complete when you need the final DOM.

clone.__rowData is set on every row, so you can recover the record from a DOM node later:

const row = e.target.closest(".row")?.__rowData;

after.render.complete — both

Fires once per render, after everything is in the DOM.

hooks: {
  // flat
  "after.render.complete": async ({ container, rows }) => {
    const total = rows.reduce((s, r) => s + Number(r.sp_credits_value || 0), 0);
    myTotalEl.textContent = total;
  },
  // grouped
  "after.render.complete": async ({ container, data, totalItems }) => {
    myCountEl.textContent = `${totalItems} records in ${Object.keys(data).length} groups`;
  },
}

The payload differs by renderer. Flat gives rows; grouped gives data (the grouped object) and totalItems. A hook written against one is silently undefined on the other.

Do not call render() from this hook. _isRendering will swallow the call, and if you defer it you get an infinite loop.

toolbar.panels.filter.build — both

Rearrange the toolbar before it is assembled.

hooks: {
  "toolbar.panels.filter.build": (panels) => {
    panels.left.push(myCustomButton);
    panels.right = panels.right.filter(el => el !== unwantedControl);
    return panels;   // must return { left, right }
  },
}

Default contents:

PanelFlatGrouped
leftSelect-all block, selection count, Create, plugin areasame
rightFilter field, filter value, [batch field/value/Apply], Refresh, Submit, DeleteFilter field, filter value, Refresh, Submit, Delete

All entries are already filter(Boolean)-ed, so disabled features are absent rather than null.


Plugins

A plugin adds entries to the toolbar's More menu that operate on the current row selection.

Anatomy

const gradeTransferPlugin = {
  appName: "SchoolManagerApp",     // which PHP app the child helpers call
  idField: "sp_transcript_uid",    // documentation only — nothing reads it
  featuresToAdd: {
    assign: {
      label: "Transfer Grades",
      icon: AppsIcons.get("share", "dark", 14),
      useModal: false,
      inputs: {},
      onApply: async (rows, values, api) => { /* … */ },
    },
  },
};

// then
plugins: [gradeTransferPlugin],
uiSettings: { pluginArea: true },

Each key under featuresToAdd becomes one menu item. Multiple plugins become multiple sections in the same menu.

KeyMeaning
labelMenu text. Falls back to text, then the feature key
iconSVG string, or "*"
useModalCollect inputs in a dialog before running onApply
inputsField definitions — object of name → config, or an array of names
onApply(rows, values, api)The work

plugin.idField is declared by convention throughout the codebase but is never read by the registry. It documents intent; it does not configure anything. Do not rely on it to identify rows — read the UID off the rows you are given.

Selection is mandatory

Every plugin item checks the selection first:

const selected = l._tempSelected;
if (selected.length === 0) return DomBuilder.showNotification("Select rows first", "warn");

There is no way to declare a plugin that runs without a selection. For a selection-free bulk action, put a button in contentHeader with a data-register-header-action instead.

The api bridge

onApply receives a bridge object. Its shape differs between the two renderers — this is the single most common porting mistake.

Shared by both

MethodEffect
api.logicThe logic instance
api.refresh()Re-fetch and repaint
api.getSelection()The selected rows
api.showModal(cfg)Open a form dialog on demand
api.addToChild(data, method)Call another app
api.addBulkToChild(data, method = "createBulk")Bulk-create in another app
api.updateChild(params, method = "update")Update in another app
api.deleteChild(where, method = "delete")Delete in another app

Parent CRUD — different names

Flat (AppDataUI)Grouped (AppGroupedDataUI)
api.createParentItem(data, customParams?)api.create(data, customParams?)
api.updateParentItem(id, newData, customParams?)api.update(id, newData, customParams?)
api.deleteParentItem(id, customParams?)api.delete(id, customParams?)

A plugin calling api.create(...) on a flat surface throws api.create is not a function. Write plugins for one renderer, or feature-detect:

const create = api.create || api.createParentItem;
await create(payload);

Two more differences worth knowing

  • getSelection() returns a copy on flat (Array.from) and the live array on grouped. Mutating grouped's return corrupts the selection state — copy it yourself before sorting or splicing.
  • The child helpers wrap their arguments differently. Grouped addToChild sends [data] and defaults to method "create"; flat sends data raw and defaults to "add". Grouped deleteChild wraps as [where]; flat does not. Check the parameter shape your PHP method expects.

A worked plugin

The grade-transfer plugin from the School app, annotated:

const gradeTransferPlugin = {
  appName: "SchoolManagerApp",
  featuresToAdd: {
    assign: {
      label: "Transfer Grades",
      icon: "*",
      useModal: false,
      inputs: {},
      onApply: async (rows, values, api) => {
        // 1. Map the selection into the backend's expected payload
        const gradePayload = rows.map((r) => ({
          sp_student_grade_uid: r.sp_student_grade_uid,
          sp_school_uid:        r.sp_school_uid,
          sp_program_uid:       r.sp_program_uid,
          sp_course_uid:        r.sp_course_uid,
          sp_student_uid:       r.sp_student_uid,
          sp_acquired_grade:    r.sp_acquired_grade,
          sp_school_year:       r.sp_school_year,
          sp_credits_value:     r.sp_credit_value,
          sp_grade_level:       r.sp_grade_level,
        }));

        // 2. One call, whole batch — not a loop of single calls
        const resp = await ApiConfig.fetchAppData(
          this.engine, ApiConfig.GET_DATA_URL,
          "SchoolManagerApp", "processGradeTransfer", [gradePayload],
        );

        // 3. Report, then refresh
        DomBuilder.showNotification(
          resp?.table?.message || resp?.message || "Complete",
          resp?.status === "success" ? "success" : "error",
        );
        await api.refresh();
      },
    },
  },
};

Three habits to copy: build the payload explicitly rather than sending whole rows (they carry _can_* and page metadata the backend will reject); send one batch request rather than looping; and always await api.refresh() so the grid reflects what the server did.

featuresToAdd: {
  reassign: {
    label: "Reassign Facilitator",
    useModal: true,
    inputs: {
      sp_course_facilitator: {
        type: "select|required",
        label: "New facilitator",
        fetch: {
          app: "SchoolManagerApp",
          method: "getScheduledClassTeachers",
          params: [school, program, category, level, year],
          valueKey: "value",
          labelKey: "label",
        },
      },
      sp_note: { type: "text", label: "Reason" },
    },
    onApply: async (rows, values, api) => {
      for (const row of rows) {
        await api.update(row.sp_course_schedule_uid, {
          sp_course_facilitator: values.sp_course_facilitator,
        });
      }
      await api.refresh();
    },
  },
}

How it works: before the dialog opens, every input carrying a fetch block is resolved in parallel, and the results are mapped through mapOptions into fieldCfg.options. Fields are then built with the same _createInputField used by the create/edit modals, so fieldTypes syntax applies — "select|required|Help text".

Two things to plan around:

  • fieldCfg.options is written back onto your config object. The plugin definition is mutated, so options are effectively cached for the life of the object. If the option list depends on the current selection, build the plugin fresh rather than hoisting it to a constant.
  • The modal always closes after onApply. Unlike createModal, returning false does not keep it open. Report failures with a notification, or use api.showModal() and drive createModal yourself when you need to hold the dialog open on error.

api.showModal

For a dialog outside the menu flow — from an extraActions handler, say:

api.showModal({
  title: "Set due date",
  fields: { sp_due: { type: "date", label: "Due date" } },
  onConfirm: async (values, selected, bridge) => { /* … */ },
});

Choosing an extension point

You want to…Use
Act on one row from a button in the rowextraActions + data-register-action
Act on many selected rowsA plugin
Act with no selection, from the headerheaderActions + data-register-header-action
Change what rows renderbefore.render.rows / before.render.groups
Decorate each row as it is builtrow.rendering
Read or total the finished DOMafter.render.complete
Add or remove toolbar controlstoolbar.panels.filter.build
Anything elseconfig.setup(ui)

Working with the logic instance

Hooks and plugins both get at logic. The useful surface:

State

FlatGroupedMeaning
_data_allFlatDataEverything loaded so far
_filteredData_groupedDataWhat is currently rendered
_selectedIds_selectedIdsSet of selected UIDs
_tempSelected_tempSelectedArray of selected row copies
_currentPage_currentPage1-based
selectedRowUidselectedGroupedUidClick-highlighted row
_remoteDatasetExhaustedNo more server pages

Methods

logic.fetchData()                     // full re-fetch
logic.nextPage() / prevPage()
logic.getPageData()                   // flat only
logic.search(term)
logic.filterData(field, value)        // grouped only
logic.setData(rows)                   // replace everything
logic.updateLocalRow(id, delta)       // optimistic update, no request
logic.removeRow(idField, value)       // flat only
logic.toggleSelection(row, checked)
logic.toggleSelectAll(checked)
logic.createItem(data, customParams?)
logic.updateItem(id, newData, customParams?)
logic.deleteItem(id, customParams?)
logic.deleteBulk(ids, customParams?)
logic.submitBatch(customParams?)
logic.emitSelection(row, id)          // fires "<eventPrefix>:selected" on ctx
logic.subscribe(cb)

updateLocalRow is the one to reach for during inline editing — it patches the master data, the visible copy and the selection buffer without a round trip.

emitSelection publishes to the parent context:

ctx.setPublicValue("row.selectedId", id);
ctx.setPublicValue("row.selectedRow", row);
ctx.dispatchEvent("row:selected", { id, row });

which is how one surface drives another inside the same window. eventPrefix defaults to "row" for both renderers.

Search behaviour

Client-side search deliberately skips columns that would produce noise: keys starting with _ (the permission flags), objects, and strings that begin with { or [ (serialised JSON). Pass searchConfig.columns to search an explicit list instead.

The grouped renderer additionally matches the group name, so typing a group title returns that whole group.


Known rough edges

Behaviours that will look like bugs in your code but are not.

Bulk delete never enables on grouped surfaces. AppGroupedDataUI creates the Delete button disabled: true and _updateToolbarSelectionUI never re-enables it. AppDataUI does enable it — but only at two or more selected rows, so single-row bulk delete is blocked there too. Until this is fixed, put bulk delete in a plugin.

this.dom is undefined on AppDataUI. The batch-submit button's handler calls this.dom.withLoading(...), but the flat UI stores its builder as this.builder. That handler throws. The grouped UI has it right.

ifThen results are cached per row for the life of the UI. _ruleCache is keyed on ${id}_rules and never invalidated, so a rule that depends on a value the user just changed keeps returning its first answer.

Local filters and pagination disagree on grouped surfaces. _applyLocalFilter rebuilds the view from a filtered array while _allFlatData stays whole, so the page count is computed from the unfiltered total.

Grouped search ungroups by page. search() regroups all of _allFlatData at once, while normal rendering groups only the current page — so results look structurally different from the unfiltered view.

Notification calls assume resp.table exists. Several paths read res.table.message without a guard. Return {status, message} from a PHP method and the success path throws. Use optional chaining in your own handlers: resp.table?.message || resp.message.