[data-reveal]{opacity:1!important;transform:none!important}
Migrate from 0.3 to 0.4
Building

Migrate from 0.3 to 0.4

What a module written for @antelopejs/interface-dms 0.3 changes to run on 0.4, with the code before and after.

@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

AreaWhat to doSection
DependenciesRaise @antelopejs/interface-dms to 0.4; run @antelopejs/interface-data-api 0.2 and @antelopejs/dms-frontend 0.5Dependencies
Frontend layerAdd a dms.frontend.build.ts if your layer relies on auto-importsAuto-imports
Custom write routesAccept a partial body; treat null as "clear"Partial edits
Table viewsOne writing table view per data controller; prefixed display ids; one-column tabs; action-level confirmationsTable views
FormssaveMode / kind instead of showActions; declare kind: "action" on send formsForms
Settingssettings.user.members* / roles became settings.workspace.*; no explicit layout under settingsCategorySettings pages
NotificationsRepeated identical sends within 10 s are stored once; dedupe: false opts outNotifications
Frontend APIsReplace the removed composables and componentsFrontend 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:

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-api 0.2. DefaultRoutes.Edit writes 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-frontend 0.5, which needs @antelopejs/core 1.13.2 or later; the DMS raises its own @antelopejs/core range to >=1.13.2 <2 to 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 its engines:
frontend-vue/package.json
{
  "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:

frontend-vue/dms.frontend.build.ts
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.

frontend-vue/app/components/InvoiceTable.vue
// 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:

src/data-api/projects.ts
// 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.

src/pages/board.ts
// 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.

frontend-vue/app/plugins/plan-cards-display.ts
// 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 });
src/pages/plans.ts
// 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_than filters 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.30.4
settings.user.memberssettings.workspace.members
settings.user.members.invitessettings.workspace.members.invites
settings.user.invitessettings.workspace.invites
settings.user.rolessettings.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:

src/pages/billing.ts
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.30.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() confirmColorcolor; onConfirm(values) receives the dialog's field values
useModulesListing().loadingError: stringModulesListingError ({ kind: "forbidden" | "failed", status? })
DmsProfileLanguage, DmsProfileSessions, DmsProfileTwoFactorRemoved: the Language & region and Security settings pages
Custom pages DmsSettings, DmsSettingsAppearance, DmsSettingsNotificationsRemoved: 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().resetFiltersRemoved (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/profile no longer changes the password or the sign-in email.
  • POST /settings/user/security/password takes { currentPassword, password } and signs out every other device: it rotates the account's auth key, and the current device stays signed in through the sessionHandoff the answer carries. DELETE /settings/user/security/other-sessions does the same without a password change.
  • POST /settings/user/security/email sends 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/*, and enable-totp / enable-email take { currentPassword }.
  • POST /api/auth/login answers 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 ip param is replaced by country.
  • A password change ignores signOutOtherSessions: it always signs out every other device.
  • resolveBulkRowIds refuses (403) a caller without the table's list permission for selected ids too, not only for allMatching.
  • TableView.fromSource runs the registration checks of TableView() (pageSize range, display ids, defaultDisplay, a tab with both filter and to) and checks card.fields and expandable against its columns; its route receives only the query parameters its capabilities name.
  • A literal (non-$) confirmation or empty-state text fills its {name} placeholders like a $ key's translation.
  • A custom row action's confirm.from URL gets {id}, the row's id, like a built-in action's.

Table views

Preview0.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: truemaxHeight: "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, RowActionConfirmImpactConfirmDialog, ConfirmDialogImpact
PageHeaderAction (to, external, quickAction, button)CustomButton in DefaultLayout({ headerActions }), or placement: "header" on a table button
CustomButton.hiddenRemoved
RelationType({ addTitle, addDescription })RelationType({ addForm: { displayName, description } })
expandable: { fields, component }One of { fields } or { component }
TableViewTab.badge: truenavBadge: true
?tableView= query parameterIgnored: 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 RowActionConfigOn 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

Preview0.4
Tone accent, okprimary, success
DISPLAY_TONES / DisplayTone, ActivityToneTONES / Tone
Identity display badge colortone
ActivityFeed({ framed, viewAllTo, viewAllLabel, emptyTitle, emptyDescription })card, actions: [{ label, to }], empty: { title, description }
An ActivityFeed route answering an arrayIt answers { items }
Meter({ framed, linkLabel, linkTo })card, actions
Section({ bare: true }), SectionOptions, FieldRowOptionscard: false, SectionProps, FieldRowProps
EmptyState({ framed }), EmptyState()hatched; the title is required
StatStrip, StatStripItem, StatStripProps, StatStripLayout, STAT_STRIP_LAYOUTS, block type StatStripStatGroup, StatGroupItem, StatGroupProps, StatGroupLayout, STAT_GROUP_LAYOUTS, block type StatGroup (base/stat-group)
StatStripItem.href, KeyValueListItem.hrefto
emptyLabel on ActivityFeed, StatStrip, KeyValueList, NavCardGrid (TopListCard keeps it)empty: { title, description? }
NavCardItem.badge, <DmsNavCard badge>tag
EyebrowTone / MeterTone accentprimary
// 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

Preview0.4
saveBar: trueDefault; drop it
cancellablekind ("record" offers Cancel where it can go back)
resetOnSuccesskind: "action"
recordLabelKeylabelKey
ModuleInfo.categorycatalogCategory
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 })