Migrate from 0.3 to 0.4
@antelopejs/interface-dms 0.4 and the DMS release that implements it change how a module declares tables, forms, settings pages and its frontend layer. This page lists every change a module author acts on, in the order you meet them when upgrading, each with the code before and after. The last section covers the options that only existed on the v2 preview branch (PR #119): skip it if your module only ran on a published 0.3.
Checklist
| Area | What to do | Section |
|---|---|---|
| Dependencies | Raise @antelopejs/interface-dms to 0.4; run @antelopejs/interface-data-api 0.2 and @antelopejs/dms-frontend 0.5 | Dependencies |
| Frontend layer | Add a dms.frontend.build.ts if your layer relies on auto-imports | Auto-imports |
| Custom write routes | Accept a partial body; treat null as "clear" | Partial edits |
| Table views | One writing table view per data controller; prefixed display ids; one-column tabs; action-level confirmations | Table views |
| Forms | saveMode / kind instead of showActions; declare kind: "action" on send forms | Forms |
| Settings | settings.user.members* / roles became settings.workspace.*; no explicit layout under settingsCategory | Settings pages |
| Notifications | Repeated identical sends within 10 s are stored once; dedupe: false opts out | Notifications |
| Frontend APIs | Replace the removed composables and components | Frontend APIs |
Dependencies
The DMS caps the interface below its next minor, so a module and the DMS move to 0.4 together. Raise the interface range in the module's package.json:
{
"dependencies": {
"@antelopejs/interface-dms": ">=0.4.0 <0.5.0"
}
}
The DMS requires two upstream releases, which a project raises with it:
@antelopejs/interface-data-api0.2.DefaultRoutes.Editwrites only the fields the body carries (below), a derived controller inherits its parent's@ModelReference(), and a field's validator decides the value stored (Column values are stored parsed). A module depending on it raises its range to>=0.2.0.@antelopejs/dms-frontend0.5, which needs@antelopejs/core1.13.2 or later; the DMS raises its own@antelopejs/corerange to>=1.13.2 <2to match. It declares auto-imports per module (below), takes the interface language from the session and draws icons in the server render. A frontend module declares it in itsengines:
{
"engines": {
"@antelopejs/dms-frontend": ">=0.5.0 <0.6.0"
}
}
A project on MongoDB also runs @antelopejs/mongodb 1.4.2 or later, which stores and matches a string starting with $ as the string itself.
Keep the ranges wide enough for every module of a project to resolve the same copy of @antelopejs/interface-dms (Architecture).
Auto-imports are declared by each module
Up to 0.3, the frontend build auto-imported every module's app/composables, app/utils, app/types, app/build/composables and app/build/types. With @antelopejs/dms-frontend 0.4, nothing is auto-imported from a module without that file, and app/build/** is never auto-imported. Add the file next to dms.frontend.ts and list the folders your layer exposes:
import { defineDmsFrontendBuild } from "#dms/frontend-build";
export default defineDmsFrontendBuild((build) => {
// a module split into layers lists "layers/*/app/composables" and so on
build.registerAutoImports(["app/composables", "app/utils", "app/types"]);
});
Code under app/build/ is private to its module: import it by path where you use it.
// Before: auto-imported from app/build/composables
const totals = useInvoiceTotals(rows);
// After
import { useInvoiceTotals } from "../build/composables/useInvoiceTotals";
const totals = useInvoiceTotals(rows);
@antelopejs/dms-frontend 0.5 puts a module's componentPrefix in front of every component it registers, and no longer drops a leading Dms when it compares names. Declare a prefix on your DmsFrontendModule, register the names without it, and make the backend send the full name; a backend that sent Table for the DMS's DmsTable sends DmsTable (or dms-table). See Component naming.
Edit bodies are partial and null clears a field
A form that loaded a record now sends only the fields the user changed, plus its submitDefaults. A field the user cleared arrives as null ([] for a list field); a field left out of the body is unchanged. DefaultRoutes.Edit from @antelopejs/interface-data-api 0.2, which TableViewRoutes.Edit wraps, writes this way.
A custom route a form submits to must merge the body into the stored row instead of replacing it:
// Before: the body held the whole record
@Put("settings")
async saveSettings(@JSONBody() body: ProjectSettings) {
await this.settings.replace(body);
}
// After: the body holds the changed fields; null clears one
@Put("settings")
async saveSettings(@JSONBody() body: Partial<ProjectSettings>) {
const current = await this.settings.get();
await this.settings.replace({ ...current, ...body });
}
A script calling an edit route now clears a field with null: leaving it out keeps the stored value.
Table views
One writing table view per data controller
A data controller has one writing TableView: a table view that enables any write action the controller offers (add, duplicate, edit, delete, archive, restore). A second writing table view over the same controller fails its page's registration with a WritingTableViewConflictError. Read-only table views still share the controller.
// Before: two pages writing through taskDataAPI
static table = TableView(taskDataAPI, { defaultDisplay: "kanban", kanban: { groupByField: "status" } });
// After, option 1: a read-only screen
static table = TableView(taskDataAPI, {
defaultDisplay: "kanban",
kanban: { groupByField: "status" },
rowActions: { add: false, duplicate: false, edit: false, delete: false }, // and no archiveMode
});
// After, option 2: a derived controller with routes of its own
static table = TableView(boardTaskDataAPI, { defaultDisplay: "kanban", kanban: { groupByField: "status" } });
The writing table view alone authorizes the write routes and supplies the row rules; reads stay open to every table view mounting the controller.
A tab filters on one column
TableViewTab.filters (an array) becomes filter (one filter). Several values of one column are joined with commas. A tab gives filter or to, never both: registration throws otherwise.
// Before
{ id: "problems", label: "Problems", filters: [{ accessorKey: "status", mode: "include", value: "bounced,failed" }] }
// After
{ id: "problems", label: "Problems", filter: { accessorKey: "status", mode: "include", value: "bounced,failed" } }
A tab that filters on several columns is a view: see Views.
Display ids are prefixed
table, kanban, cards and grouped are reserved. A module registers its display as <module>:<id>, and registerTableViewDisplay throws on a reserved id, an unprefixed id or an id another module already registered. Register it from a universal plugin, so the server render draws it.
// Before
registerTableViewDisplay({ id: "cards", label: "Plans", icon: "i-ph-cards", component: PlanCards });
// After
registerTableViewDisplay({ id: "saas:plan-cards", label: "Plans", icon: "i-ph-cards", component: PlanCards });
// Before
displays: [{ id: "cards", component: CustomComponent("PlanCards") }],
defaultDisplay: "cards",
// After
displays: [{ id: "saas:plan-cards", component: CustomComponent("PlanCards") }],
defaultDisplay: "saas:plan-cards",
card replaces the kanban card options
The kanban board and the built-in cards display draw their cards from one card option. kanban.cardFields and kanban.cardComponent are gone, and a custom card no longer receives its row as item: it reads row.
// Before
kanban: { groupByField: "status", cardFields: ["priority", "assignee"] },
// After
kanban: { groupByField: "status" },
card: { fields: ["priority", "assignee"] }, // or { component: CustomComponent("TaskCard") }
A card component receives the same props on the board and in the cards display: see Cards.
The details form page is details
formContainer.pages.view is renamed details, and every container takes the form titles (displayName, description), the drawer and the modal included.
// Before
formContainer: { type: "page", pages: { view: { urlSlug: ":id/overview" } } },
// After
formContainer: {
type: "page",
pages: { details: { urlSlug: ":id/overview", displayName: "$orders.details_title" } },
},
Confirmations belong to the action
The confirm of an api or exportJob target moves to the action that carries the target: a built-in row action, a custom row action or a custom button. It takes a ConfirmDialog, or { from } to let the server word it. confirmColor becomes color, and description is optional.
// Before
{
label: "Archive",
target: {
type: "api", url: "/api/projects/{_id}/archive", successMessage: "Archived",
confirm: { title: "Archive?", description: "It leaves the list.", confirmColor: "warning" },
},
}
// After
{
label: "Archive",
target: { type: "api", url: "/api/projects/{_id}/archive", successMessage: "Archived" },
confirm: { title: "$projects.archive_title", description: "$projects.archive_description", color: "warning" }, // "Archive {name}?"
}
A dialog can also collect values (fields) sent with the request: see Confirmations.
Behaviour
greater_than/less_thanfilters on a date column compare dates. On MongoDB they used to match every row or none.- A table keeps the column order a user picked, with the rest of its state.
- The selection of a table resets when its filters, search or tab change.
Forms
saveMode and kind replace showActions
A Form declares how it saves with saveMode ("bar" by default, "footer", "none", "instant") and what it edits with kind ("record" by default, or "action"). showActions, saveBar and cancellable are gone.
A form with no footer option now saves through the save bar and keeps its values. A form that does something each time it is sent — send a message, invite someone, run a job — declares kind: "action": it shows Reset and its submit button, and empties after a successful submit.
// Before
static send = Form({ submitUrl: "/api/mailing/send", submitLabel: "Send", fields: [/* … */] });
static readonly = Form({ fetchUrl: "/api/plan", showActions: false, fields: [/* … */] });
// After
static send = Form({ submitUrl: "/api/mailing/send", submitLabel: "Send", kind: "action", fields: [/* … */] });
static readonly = Form({ fetchUrl: "/api/plan", saveMode: "none", fields: [/* … */] });
See Saving.
A required checkbox must be ticked
A required boolean field drawn as a checkbox (BooleanType({ display: "checkbox" }), or inputComponent: FormComponents.InputCheckbox()) is an agreement: only ticking it fills it, in the form and on the server (z.literal(true)). A switch keeps false as a value. A setting drawn as a checkbox that may stay unticked must not be required.
// Before: a required checkbox accepted false
{ id: "newsletter", label: "Newsletter", type: new DefaultDataTypes.BooleanType(), required: true,
inputComponent: FormComponents.InputCheckbox() }
// After: only an agreement is required
{ id: "newsletter", label: "Newsletter", type: new DefaultDataTypes.BooleanType({ display: "checkbox" }) }
{ id: "terms", label: "I accept the terms", type: new DefaultDataTypes.BooleanType({ display: "checkbox" }), required: true }
Date ranges are { start, end }
DateType({ range: true }) validates [start, end] to { start, end }. Code reading a stored range by index reads it by name:
// Before
const [from, to] = row.period;
// After
const { start: from, end: to } = row.period;
Column values are stored parsed
A @Column validates a written value with its data type's schema, and the data API stores the value that schema parses rather than the raw JSON: a date sent as text is stored as a Date, a date range as { start, end } of dates, and an object value (an address, an image, a repeater row) keeps only the keys its schema declares. Code reading a stored date gets a date, not a string:
// Before: dates written through a table view were stored as text
const due = new Date(row.dueAt);
// After
const due = row.dueAt;
Durations
A time field refuses a duration it cannot read ("Enter a duration such as 1h30m or 01:30.") instead of saving 0.
Settings pages and permissions
Members, invitations and roles live under settings.workspace
The workspace pages moved from the account category to a workspace category, workspaceSettingsCategory (settings.workspace, /settings/workspace). Their permission ids, URLs and API routes follow:
| 0.3 | 0.4 |
|---|---|
settings.user.members | settings.workspace.members |
settings.user.members.invites | settings.workspace.members.invites |
settings.user.invites | settings.workspace.invites |
settings.user.roles | settings.workspace.roles |
/settings/user/members… | /settings/workspace/members… |
/settings/user/roles… | /settings/workspace/roles… |
// Before
@RegisterPageExtension("settings.user.members")
// After
@RegisterPageExtension("settings.workspace.members")
A module adding workspace settings uses the exported category instead of declaring its own settings.workspace:
import { Category, PageController, settingsCategory, workspaceSettingsCategory } from "@antelopejs/interface-dms/page";
// Before
const workspace = Category("workspace", { displayName: "Workspace", category: settingsCategory });
export class BillingPage extends PageController("billing", { displayName: "Billing", category: workspace }) {}
// After
export class BillingPage extends PageController("billing", {
displayName: "Billing",
category: workspaceSettingsCategory,
order: 4,
}) {}
The settings navigation lists each category's pages by order; see Settings pages.
Settings pages inherit SettingsLayout()
settingsCategory declares a default layout, SettingsLayout(), that every page under it inherits. A page passing a layout of its own no longer gets the settings frame: drop the argument.
// Before: the settings frame was found from the page id
export class BillingPage extends PageController("billing", { displayName: "Billing", category: settingsCategory }, FormPageLayout()) {}
// After
export class BillingPage extends PageController("billing", { displayName: "Billing", category: settingsCategory }) {}
Any category can declare a default layout the same way: Category("reports", { displayName: "Reports", module: "billing", layout: FormPageLayout() }) gives its pages FormPageLayout() unless they pass their own.
Account settings open to every member
The account category (settings.user: profile, security, notifications, language & region, appearance, shortcuts) is memberAccess: every signed-in member holds its pages, components and actions without a role. Roles can no longer withhold them, and the roles editor no longer lists them. A module opens its own per-user page the same way with memberAccess: true (Auth & permissions).
Note: who may grant what
Nothing to migrate, but worth knowing before you hand out roles: editing roles or members, and deleting a role with reassignTo, may grant any permission, * and owner status included: there is no escalation cap. Grant those permissions as you would administrator access. See Who can grant what.
Notifications
A send identical to one made to the same recipient within the last 10 seconds — same title, description, params, link, category, subject, tone and icon — is stored once. Pass { dedupe: false } when every send must arrive:
// Before: two identical sends stored two notifications
await notification.toUser(userId);
// After: opt out of the 10-second dedupe
await notification.toUser(userId, { dedupe: false });
A send with an idempotencyKey is stored once per key either way. See Sending notifications.
Frontend APIs
These APIs of the DMS layer were removed or changed, with no alias:
| 0.3 | 0.4 |
|---|---|
useNotifications().fetchUnreadCount() | fetchBellCounts() (refreshes unreadCount and unseenCount); the bell shows unseenCount |
useNotifications().markAllAsRead(), deleteAll() | The inbox is a table view on the notifications settings page, with its own toolbar buttons |
useConfirm() confirmColor | color; onConfirm(values) receives the dialog's field values |
useModulesListing().loadingError: string | ModulesListingError ({ kind: "forbidden" | "failed", status? }) |
DmsProfileLanguage, DmsProfileSessions, DmsProfileTwoFactor | Removed: the Language & region and Security settings pages |
Custom pages DmsSettings, DmsSettingsAppearance, DmsSettingsNotifications | Removed: the settings pages are backend block trees |
DmsRegister (onboarding step) | Requires a platform prop, emits back and registered, and no longer redirects |
useTable().resetFilters, useTableStates().resetFilters | Removed (build/ code, never public) |
<DmsBanner color> | tone |
readResponseWarning, resolveResponseToast, openDevReloadStream (auto-imported from utils/) | Private (build/): no replacement |
The components under app/build/ are no longer global, unless a DMS backend page names them: DmsActions, DmsContainer, DmsDynamicDrawer, DmsDynamicModal, DmsEmpty, DmsFiltersRow, DmsPageHeader, DmsPagination, DmsTable, DmsTabs and the rest of build/components. A module that named one in a backend tree or a template ships a component of its own instead.
// Before
const { fetchUnreadCount, unreadCount } = useNotifications();
await fetchUnreadCount();
// After
const { fetchBellCounts, unseenCount } = useNotifications();
await fetchBellCounts();
Account security
These flows changed for every user; update a client or an end-to-end test that drives them:
POST /settings/user/profileno longer changes the password or the sign-in email.POST /settings/user/security/passwordtakes{ currentPassword, password }and signs out every other device: it rotates the account's auth key, and the current device stays signed in through thesessionHandoffthe answer carries.DELETE /settings/user/security/other-sessionsdoes the same without a password change.POST /settings/user/security/emailsends a code to the new address and tells the current one;POST /settings/user/security/email/confirm { code }moves the account./settings/user/profile/two-factor/*moved to/settings/user/security/two-factor/*, andenable-totp/enable-emailtake{ currentPassword }.POST /api/auth/loginanswers 429 after 10 failed sign-ins per address, or 50 per client address, within 15 minutes.
If you built against the PR #119 preview
The options below only existed on the v2 preview branch, before 0.4 was released. A module written against it migrates them as follows. These behaviours changed on the way too:
- A role preview no longer blocks writes: an action taken while previewing runs with your own rights. The preview banner and the veils stay.
- The "New sign-in" notification names the country of the sign-in, never its address: its
ipparam is replaced bycountry. - A password change ignores
signOutOtherSessions: it always signs out every other device. resolveBulkRowIdsrefuses (403) a caller without the table'slistpermission for selectedidstoo, not only forallMatching.TableView.fromSourceruns the registration checks ofTableView()(pageSizerange, display ids,defaultDisplay, a tab with bothfilterandto) and checkscard.fieldsandexpandableagainst its columns; its route receives only the query parameters itscapabilitiesname.- A literal (non-
$) confirmation or empty-state text fills its{name}placeholders like a$key's translation. - A custom row action's
confirm.fromURL gets{id}, the row's id, like a built-in action's.
Table views
| Preview | 0.4 |
|---|---|
TableViewRoutes.PartialEdit (the row write an EditWith base called) | DefaultRoutes.Edit, partial from @antelopejs/interface-data-api 0.2 |
chrome: "minimal" / chrome: { … } | layout: "compact"; each control shows when it means something |
hiddenColumns: ["email"] | @Column({ isVisible: false }) on the column |
stickyHeader: true | maxHeight: "60vh" (a scroll area with a sticky header) |
formTexts: { new: { title } } | formContainer.pages.new.displayName |
@Column({ display: { type, options, label } }) | @Column({ display: new DefaultDisplays.IdentityDisplay({ … }) }); one label, the column name |
confirmFrom: "/…/confirm" | confirm: { from: "/…/confirm" } |
RowActionConfirmDescriptor, RowActionConfirmImpact | ConfirmDialog, ConfirmDialogImpact |
PageHeaderAction (to, external, quickAction, button) | CustomButton in DefaultLayout({ headerActions }), or placement: "header" on a table button |
CustomButton.hidden | Removed |
RelationType({ addTitle, addDescription }) | RelationType({ addForm: { displayName, description } }) |
expandable: { fields, component } | One of { fields } or { component } |
TableViewTab.badge: true | navBadge: true |
?tableView= query parameter | Ignored: the writing table view's rules and permission apply |
TableViewTab.permission: "settings.workspace.invites" | permissionId: "settings.workspace.invites"; permission names an action of the table |
successMessage on any RowActionConfig | On delete, archive and restore only |
// Preview
static members = TableView(memberAPI, {
chrome: "minimal",
hiddenColumns: ["email"],
stickyHeader: true,
formTexts: { edit: { title: "$members.edit" } },
rowActions: { delete: { confirmFrom: "/settings/workspace/members/{id}/removal-confirm" } },
});
// 0.4 (email gets isVisible: false on its @Column)
static members = TableView(memberAPI, {
layout: "compact",
maxHeight: "60vh",
formContainer: { type: "drawer", pages: { edit: { displayName: "$members.edit" } } },
rowActions: { delete: { confirm: { from: "/settings/workspace/members/{id}/removal-confirm" } } },
});
// Preview
@Column({ name: "Status", type: new StatusType(), display: { type: "pills", options: {}, label: "State" } })
// 0.4
@Column({ name: "State", type: new StatusType(), display: new DefaultDisplays.PillsDisplay({}) })
A module's own display class registers as @RegisterDisplay("<module>:<id>"): see Cell displays.
// Preview
DefaultLayout({ headerActions: [{ label: "Docs", to: "https://antelopejs.com/docs", external: true }] })
// 0.4
DefaultLayout({
headerActions: [
{ id: "docs", label: "Docs", target: { type: "external", url: "https://antelopejs.com/docs", newTab: true } },
],
})
Tones and blocks
| Preview | 0.4 |
|---|---|
Tone accent, ok | primary, success |
DISPLAY_TONES / DisplayTone, ActivityTone | TONES / Tone |
Identity display badge color | tone |
ActivityFeed({ framed, viewAllTo, viewAllLabel, emptyTitle, emptyDescription }) | card, actions: [{ label, to }], empty: { title, description } |
An ActivityFeed route answering an array | It answers { items } |
Meter({ framed, linkLabel, linkTo }) | card, actions |
Section({ bare: true }), SectionOptions, FieldRowOptions | card: false, SectionProps, FieldRowProps |
EmptyState({ framed }), EmptyState() | hatched; the title is required |
StatStrip, StatStripItem, StatStripProps, StatStripLayout, STAT_STRIP_LAYOUTS, block type StatStrip | StatGroup, StatGroupItem, StatGroupProps, StatGroupLayout, STAT_GROUP_LAYOUTS, block type StatGroup (base/stat-group) |
StatStripItem.href, KeyValueListItem.href | to |
emptyLabel on ActivityFeed, StatStrip, KeyValueList, NavCardGrid (TopListCard keeps it) | empty: { title, description? } |
NavCardItem.badge, <DmsNavCard badge> | tag |
EyebrowTone / MeterTone accent | primary |
// Preview
ActivityFeed({ fetchUrl: "/api/activity", framed: true, viewAllTo: "/activity", viewAllLabel: "View all", emptyTitle: "Nothing yet" })
// 0.4
ActivityFeed({ fetchUrl: "/api/activity", card: true, actions: [{ label: "View all", to: "/activity" }], empty: { title: "Nothing yet" } })
Forms, hooks and modules
| Preview | 0.4 |
|---|---|
saveBar: true | Default; drop it |
cancellable | kind ("record" offers Cancel where it can go back) |
resetOnSuccess | kind: "action" |
recordLabelKey | labelKey |
ModuleInfo.category | catalogCategory |
USER_DELETED payload { userId, email } | { userId, email, tenantIds, reason } |
useNotifications() inbox functions (inboxItems, fetchInbox, setInboxFilter, undoMarkAllAsRead…) | The inbox table view on the notifications settings page |
formatRelativeDate, usePermissionPreview, useNavBadges, useBlockItems, DmsTone, DmsStageCard, DmsPermissionVeil and the other files moved under build/ | Private: no replacement |
// Preview
Form({ submitUrl: "/api/invite", saveBar: false, resetOnSuccess: true, recordLabelKey: "email", fields })
// 0.4
Form({ submitUrl: "/api/invite", kind: "action", labelKey: "email", fields })