This page documents the contract between the AL side (the hosting page) and the React side (the control add-in). Treat it as the source of truth when changing either layer.
How a control add-in works
- The AL page declares a
usercontrol(Name; "Control Add-in Name") field in its layout.
- The Control Add-in object (
controls/RosterGridHAC.al) declares the JS files to load, the event names it raises (calls from AL into JS), and the procedures it exposes (calls from JS into AL).
- BC injects an iframe sourcing the bundle’s HTML wrapper. The React app boots inside.
- The control’s
Scripts property points at /scripts/assets/js/base.js — the Vite output.
The event contract
Outbound (React → AL)
Every call is Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(eventName, [parameters]). The AL trigger with the matching name runs, reads data from BC tables, builds a JsonArray or JsonObject, and calls a control-add-in procedure (back into JS) with the result.
| Event name | Triggered by | Purpose |
|---|
ControlReady | scripts/startup.js once the iframe is ready | Tells AL the JS layer is up; AL then calls InitControls() |
GetShiftCodes | getShiftCodesAsync() | Asks AL for shift configurations marked Add to Roster = true |
GetRosterEntries | getRosterEntriesAsync(from, to) | Asks AL for roster entries within a date window |
SaveRosterEntry | saveRosterEntryAsync(empNo, date, shiftCode, entryNo?) | Insert or update an Employee Roster HAC row |
DeleteRosterEntry | deleteRosterEntryAsync(entryNo) | Delete the row by entry number |
GetResources | getAllResourcesAsync(from, to) | Return the BC Employee list |
Gantt-specific events (GetProjects, CheckIfUserCanEdit, NewTask, CreateTaskPlanningLines, etc.) are also declared and fire when that screen is re-mounted.
Inbound (AL → React)
The control add-in declares procedures, which BC compiles into JavaScript callbacks that the AL page calls with CurrPage.Control.<ProcedureName>(<Args>). The React app registers each as a window.<NAME_IN_UPPER_SNAKE> function in dataService.ts.
| Procedure | Called from AL | Purpose |
|---|
InitControls() | After ControlReady event | Signals JS to start fetching initial data |
GET_SHIFT_CODES(JsonArray) | GetShiftCodes trigger | Hands the shift list back to React |
GET_ROSTER_ENTRIES(JsonArray) | GetRosterEntries trigger | Hands the entries back |
SAVE_ROSTER_ENTRY(JsonObject) | SaveRosterEntry trigger | Returns the saved row (with the new entryNo on insert) |
DELETE_ROSTER_ENTRY() | DeleteRosterEntry trigger | Acknowledges deletion |
GET_RESOURCES(JsonArray) | GetResources trigger | Hands the resource/employee list back |
The startup handshake
If anything in steps 4-7 fails, the iframe stays blank. Debug by opening browser DevTools while inside BC, set a breakpoint in startup.js, and watch the console.
Working with the bridge in code
// react-roster-shift-board/src/services/dataService.ts
async function getDataFromBusinessCentralAsync<T>(
eventName: string,
callbackName: string,
parameter: unknown
): Promise<T> {
if (!isBC) {
// Dev mode: fetch the mock JSON
return fetch(`/api/${eventName}.json`).then(r => r.json());
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('BC timeout')), 120_000);
(window as any)[callbackName] = (result: T) => {
clearTimeout(timer);
resolve(result);
};
Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(eventName, [parameter]);
});
}
The 120-second timeout protects against AL never calling back.
Bundle layout (after npm run build)
scripts/
├── startup.js Boots the iframe, fires ControlReady
├── dev.html Used only during local development
└── assets/
├── js/
│ └── base.js The full React bundle (single file)
├── app.css Compiled styles
└── api/ Dev-mode JSON fixtures (used when isBC=false)
├── shift-codes.json
├── roster-entries.json
└── resources.json
The Control Add-in’s Scripts and StyleSheets properties name js/base.js and app.css verbatim. Never rename them.
Updating the contract
When you add a new feature that needs a new event or procedure:
- Add the event/procedure to
controls/RosterGridHAC.al.
- Implement the AL trigger on the hosting page (
Pag70003175).
- Add the matching service function in
dataService.ts.
- Rebuild the React bundle (
npm run build).
- Rebuild the AL
.app.
- Republish.
Skipping the React rebuild is the single most common cause of “feature works in dev but not in BC.”
Why no direct OData or fetch from React
Permission uniformity (AL triggers run under the BC user’s permission set), audit (every BC table write goes through the AL trigger and into BC’s change log), multi-environment support (the iframe inherits the BC session), and no CORS/auth issues — the iframe and the AL page share an origin within BC’s web shell.