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 call | Logic | UI | Reads |
|---|---|---|---|
renderApp(config) | AppDataLogic | AppDataUI | data-field |
renderGroupedApp(config) | AppGroupedDataLogic | AppGroupedDataUI | data-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 attribute | data-field | data-key |
| Master data | _data + _filteredData | _allFlatData + _groupedData |
| Row filter hook | before.render.rows (array) | before.render.groups (object) |
| Completion payload | { container, rows } | { container, data, totalItems } |
| Plugin parent methods | createParentItem / updateParentItem / deleteParentItem | create / update / delete |
getSelection() | Returns a copy | Returns the live array |
| Select All scope | Current page only | Every currently rendered group |
| Batch update controls | Present | Not rendered at all |
| Search debounce | 250 ms | None |
| Grouping vs paging | n/a | Rows 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.batchUpdatedoes nothing in the grouped renderer. The field selector, value selector and Apply button are only built byAppDataUI. 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.buildfires once, duringinit, 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 }withoutprioritysets the priority toundefined, 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");
}
},
}
| Field | Notes |
|---|---|
element | The row's root element — the fragment's first child, not the clone wrapper |
data | The row object, including _can_* flags |
index | Page index (flat) or index within the group (grouped) |
groupName | Grouped 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 givesdata(the grouped object) andtotalItems. A hook written against one is silentlyundefinedon 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:
| Panel | Flat | Grouped |
|---|---|---|
left | Select-all block, selection count, Create, plugin area | same |
right | Filter field, filter value, [batch field/value/Apply], Refresh, Submit, Delete | Filter 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.
| Key | Meaning |
|---|---|
label | Menu text. Falls back to text, then the feature key |
icon | SVG string, or "*" |
useModal | Collect inputs in a dialog before running onApply |
inputs | Field definitions — object of name → config, or an array of names |
onApply(rows, values, api) | The work |
plugin.idFieldis 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
| Method | Effect |
|---|---|
api.logic | The 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
addToChildsends[data]and defaults to method"create"; flat sendsdataraw and defaults to"add". GroupeddeleteChildwraps 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.
Modal plugins
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.optionsis 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. UnlikecreateModal, returningfalsedoes not keep it open. Report failures with a notification, or useapi.showModal()and drivecreateModalyourself 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 row | extraActions + data-register-action |
| Act on many selected rows | A plugin |
| Act with no selection, from the header | headerActions + data-register-header-action |
| Change what rows render | before.render.rows / before.render.groups |
| Decorate each row as it is built | row.rendering |
| Read or total the finished DOM | after.render.complete |
| Add or remove toolbar controls | toolbar.panels.filter.build |
| Anything else | config.setup(ui) |
Working with the logic instance
Hooks and plugins both get at logic. The useful surface:
State
| Flat | Grouped | Meaning |
|---|---|---|
_data | _allFlatData | Everything loaded so far |
_filteredData | _groupedData | What is currently rendered |
_selectedIds | _selectedIds | Set of selected UIDs |
_tempSelected | _tempSelected | Array of selected row copies |
_currentPage | _currentPage | 1-based |
selectedRowUid | selectedGroupedUid | Click-highlighted row |
| — | _remoteDatasetExhausted | No 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.