App Loaders

From 3B Knowledge
Jump to navigation Jump to search

Loaders define what supporting records an app fetches — contacts, jobs, sites, certificates, working patterns, etc. Each loader is a Custom Metadata record holding a small JSON definition of a query. The query runs entirely in Apex; the browser only ever receives the results.

Two Custom Metadata Types exist, one per surface:

CMT Used by How records are matched to the app
b3s__Scheduling_Loader__mdt the Scheduler b3s__Object_Name__c must equal the context object API name (e.g. Account) or the sharing-group name the scheduler was opened with.
b3s__Component_Loader__mdt all component apps (Clock, Terminal, Timesheeting, Client Shift Manager, …) linked to a parent b3s__Component_Configuration__mdt; every loader under the app's configuration loads.

Both share the same JSON contract.

What changed in Version 10

Loaders are now defined only via the JSON field (b3s__JSON__c). The JSON format is declarative (no code) and is executed entirely server-side.

Do I have to do anything?

Yes. The legacy Javascript Code field (b3s__Javascript_Code__c) is no longer read — its content is ignored regardless of what it contains.

A loader record whose JSON field is empty (or invalid) now fails the app's boot metadata call with an error naming the record:

Loader <DeveloperName> has no b3s__JSON__c setup

Migrate every loader to the JSON field (or delete unused loader records) before upgrading.

How to migrate a loader

Old format (Javascript Code field)

{
    name: "contacts",
    objectName: "Contact",
    filterRecordsBy: function () {
        return "Id IN (SELECT b3o__Candidate__c FROM b3o__Placement__c WHERE b3o__Job__r.b3o__Client_Account__c = {0})"
    },
    groupRecordsBy: "Id",
    fieldToLoad: ['Name', 'Email'],
    filteringItems: function () {
        return [`'${contextRecordId}'`]
    }
}

New format (JSON field)

{
  "name": "contacts",
  "objectName": "Contact",
  "filterRecordsBy": "Id IN (SELECT b3o__Candidate__c FROM b3o__Placement__c WHERE b3o__Job__r.b3o__Client_Account__c = :contextId)",
  "groupRecordsBy": "Id",
  "fieldToLoad": ["Name", "Email"]
}

Key differences:

Old (JS) New (JSON)
filterRecordsBy is a function returning a string filterRecordsBy is a string (or an array of strings for multiple filters)
filteringItems supplies values for {0}, {1} Removed. Use named placeholders directly in the filter (see below)
'${contextRecordId}' / {0} :contextId

JSON field reference

Property Required Description
name Unique loader name used by the app to read the loaded records.
objectName API name of the object to query (e.g. Contact, b3s__Certificate_Requirement__c).
filterRecordsBy A SOQL WHERE-clause string, or an array of clause strings (each runs as a separate query; results are merged and de-duplicated by Id).
fieldToLoad Array of field API names to retrieve.
groupRecordsBy Field API name to group results by. Use * to combine multiple fields into one key (e.g. b3s__Account__c*b3s__Site__c).
label Friendly name used in error messages.

Available placeholders (merge fields)

Use these directly inside filterRecordsBy. They are automatically replaced and correctly quoted/escaped for SOQL:

Placeholder Resolves to
:contextId The record the app is opened on (e.g. the Account/Job record id)
:contactUserId The current contact/user id
:userId Same as :contactUserId
:contextRecord.<FieldApiName> A field value from the context record (e.g. :contextRecord.b3s__Region__c)
:contextUser.<FieldApiName> A field value from the context user record
:contextStart Start of the calendar range currently being viewed
:contextEnd End of the calendar range currently being viewed
:contextStart-Nd / :contextStart+Nd The window start shifted by N days, as a SOQL datetime (e.g. :contextStart-7d = 7 days before the window). Same forms work on :contextEnd. Use these to load records around, not just inside, the visible window.
:contextDate The context date

Examples

"filterRecordsBy": "Id = :contactUserId"
"filterRecordsBy": "b3s__Account__c = :contextId AND b3s__Active__c = true"
"filterRecordsBy": [
  "b3s__Account__c = :contextId",
  "b3s__Region__c = :contextRecord.b3s__Region__c"
]

Note: Do not wrap placeholders in quotes yourself — Id = :contactUserId is correct, not Id = ':contactUserId'. The system adds quotes automatically where needed.

Deployment notes for the admin

  • The new JSON field (b3s__JSON__c) must exist on both b3s__Scheduling_Loader__mdt and b3s__Component_Loader__mdt. This is included in the managed package upgrade — no manual field creation needed.
  • After editing a loader's JSON value (and clearing Javascript Code), users may need to hard-refresh the app to pick up the change, as loader metadata can be cached.
  • To validate a migration, open the app with the browser console visible: the deprecation warning should disappear, and the related data (e.g. contacts/certificates) should load as before.

1. JSON-only — the legacy JavaScript form is gone

A loader is defined only in the b3s__JSON__c field. The old b3s__Javascript_Code__c snippet form is no longer read at all — its filterRecordsBy functions, filteringItems, {0} positional tags and closure variables (contextRecordId, events, …) do not exist anymore.

⚠️ A loader record with an empty or invalid b3s__JSON__c is a boot error — the app's metadata call fails with Loader <DeveloperName> has no b3s__JSON__c setup (or a parse error). Migrate or delete every legacy record before upgrading.

2. How a loader executes (the footprint)

The key design point: the query definition never reaches the browser.

  1. Boot — the app calls DataLoadingService.getSupportingMetadata. For each matching loader the client receives only a descriptor: developerName, name, label, objectName, groupRecordsBy. filterRecordsBy, fieldToLoad and apexFilter stay server-side.
  2. Load — for each descriptor the client calls DataLoadingService.getLoaderData with the CMT DeveloperName plus its context (record id, user, calendar window). Apex re-reads the JSON from the CMT, merges the placeholders, and runs the query.
  3. Stream — results come back in Id-cursored pages (see Batching below) and land in a client-side loader state: { records: [...], grouped: { key: [...] } }, keyed by the loader's name. App configuration references loaders by that name.

Because the definition is read server-side by DeveloperName, the client cannot alter the query — it can only supply the context values, and even those are validated/escaped in Apex.

Access level. The permissions a loader runs with are derived from its owning app, a server-side fact:

  • Scheduling loaders always run as the logged-in user (USER_MODE — CRUD/FLS/sharing enforced) and never elevate for guests.
  • Component loaders classify by their parent configuration's b3s__Type__c. On portal apps, guests with a valid sessionKey (or a kiosk hostType) run elevated (SYSTEM_MODE, custom objects + Contact/Account only); everyone else runs USER_MODE.

Practical consequence: in the scheduler, a loader returns only records the running user can see — if two users see different row counts, check sharing before checking the loader.

3. JSON reference

{
    "name": "contacts",
    "label": "Workers",
    "objectName": "Contact",
    "fieldToLoad": ["Name", "Email", "MailingLatitude", "MailingLongitude"],
    "groupRecordsBy": "Id",
    "filterRecordsBy": "Id IN (SELECT b3s__Contact__c FROM b3s__Group_Assignment__c WHERE b3s__Group__c = :contextId)"
}
Property Required Description
name Unique loader name — this is what app configurations and components reference. (The CMT DeveloperName is what the server looks the record up by.)
objectName API name of the object to query.
filterRecordsBy ✅* SOQL WHERE-clause string, or an array of strings. Each array entry runs as a separate query; results are merged and de-duplicated by Id. *Optional when apexFilter is set.
fieldToLoad Array of field API names / relationship paths to retrieve. Id is always included. Every entry is identifier-validated server-side.
groupRecordsBy Field to group the results by on the client (grouped state). Combine fields with * for a composite key, e.g. b3s__Account__c*b3s__Site__c.
label Friendly name used in error messages / UI.
apexFilter Name of an Apex FilterPlugin class (e.g. "b3s.GroupShiftLoader") — see Apex filters below.

Notes:

  • A clause that contains its own LIMIT runs verbatim as a single page (no cursor pagination for that clause).
  • ORDER BY inside a clause is honored; the server injects Id ASC into it for stable paging.

4. Available variables (merge placeholders)

Use these inside filterRecordsBy. They are resolved in Apex, with values quoted and SOQL-escaped automatically — never add quotes yourself (Id = :contactUserId, not Id = ':contactUserId').

Placeholder Resolves to
:contextId Id of the record the app was opened on (Account, Job, sharing Group, …).
:contactUserId The acting contact's Id. For portal guests this is pinned to the sessionKey's contact server-side — a client cannot substitute someone else's.
:userId Alias of :contactUserId.
:contextRecord.<FieldApiName> A field value from the context record, resolved server-side and escaped by type (e.g. :contextRecord.b3s__Region__c). Relationship paths are supported.
:contextUser.<FieldApiName> A field value from the acting contact's record.
:contextStart Start of the date window being loaded (the calendar's visible range / the app's fetch window), as a SOQL datetime.
:contextEnd End of that window.
:contextDate The single context date some component apps send (YYYY-MM-DD). Validated against a strict date charset.

Resolution rules worth knowing:

  • An unresolvable placeholder (no context record, no user) becomes an empty-string literal — the clause safely matches nothing, it never turns into = null.
  • The legacy filteringItems function and positional {0} tags are not part of the JSON contract and are not honored.

5. Apex filters

When a WHERE clause can't express the filter (typically multi-level semi-joins, which SOQL rejects), a loader — or an app-definition schedulable — can name an Apex class instead:

{
    "name": "groupPatterns",
    "objectName": "b3s__Working_Pattern__c",
    "fieldToLoad": ["Name", "b3s__Start__c", "b3s__End__c"],
    "apexFilter": "b3s.GroupWorkingPatternLoader"
}

The class implements the packaged global interface FilterPlugin and returns the Set<Id> of records that pass; the server ANDs Id IN :ids into the query as a bind variable (no statement-length limit, pagination unchanged). Key rules:

  • apexFilter can stand alone (the Id set is the only filter) or combine with filterRecordsBy clauses.
  • The class name only ever comes from the CMT — never from the client.
  • Name resolution is namespace-aware: "b3s.GroupShiftLoader" works whether the class is packaged, local, or deployed unpackaged into a subscriber org; subscriber orgs can reference their own bare-named classes.
  • Plugins run in their own sharing context — a plugin meant for elevated portal guests must be without sharing, or it returns zero Ids.
  • Bundled plugins (GroupShiftLoader, GroupWorkingPatternLoader, GroupLocationLoader) ship unpackaged in unpackaged-once/classes/ and power the Group scheduling provider.

Full contract, context map, limits and testing guidance.

6. Batching & large data sets

Loaders are built to return very large sets (100k+ records):

  • The server caps each page at 45,000 records, ordered by Id.
  • The response carries hasMore / nextClauseIndex / nextLastId; the client loops until exhausted, running multiple filterRecordsBy clauses in sequence and de-duplicating by Id across clauses.
  • The object describe travels on the first page only; Date/Datetime/Time values arrive as epoch milliseconds and are normalized client-side.
  • Governor footprint: each page is one Apex request. An apexFilter plugin runs once per page request, so keep plugin queries selective.

7. Operational notes

  • Caching — loader metadata can be cached client-side; after editing a loader's JSON, hard-refresh the app to pick up the change.
  • Refreshing — apps can re-run a single loader (DataLoadingEngine.refreshAdditionalData(name)) and can splice in a freshly created record by Id (addRecordToLoader) even if it doesn't yet match the filter; the fetch uses the loader's server-side field list.
  • Errors — a failing loader surfaces a toast with the loader's label; boot-time JSON problems fail the metadata call with the offending DeveloperName in the message.
  • Fields must be loaded to be used — anything read by scheduler filters, matching rules or UI templates has to appear in fieldToLoad (or the app definition's field paths). A missing field is just undefined on the record, not an error.