@antelopejs/interface-dms 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (237) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/auth/index.d.ts +3 -3
  3. package/dist/auth/index.js +11 -9
  4. package/dist/auth/index.js.map +1 -1
  5. package/dist/auth/internal/jwt-auth-decorator.d.ts +20 -0
  6. package/dist/auth/internal/jwt-auth-decorator.js +51 -0
  7. package/dist/auth/internal/jwt-auth-decorator.js.map +1 -0
  8. package/dist/auth/internal/request-authenticators.d.ts +12 -0
  9. package/dist/auth/internal/request-authenticators.js +22 -0
  10. package/dist/auth/internal/request-authenticators.js.map +1 -1
  11. package/dist/base/activity-feed.d.ts +6 -6
  12. package/dist/base/block-registry/types.d.ts +16 -0
  13. package/dist/base/block-types.d.ts +1 -0
  14. package/dist/base/block-types.js +1 -0
  15. package/dist/base/block-types.js.map +1 -1
  16. package/dist/base/chart-card.d.ts +15 -0
  17. package/dist/base/chart-card.js +4 -0
  18. package/dist/base/chart-card.js.map +1 -1
  19. package/dist/base/chart-schemas.d.ts +26 -26
  20. package/dist/base/code-block.d.ts +3 -3
  21. package/dist/base/data-types/compare-types.js +19 -13
  22. package/dist/base/data-types/compare-types.js.map +1 -1
  23. package/dist/base/data-types/core.d.ts +11 -0
  24. package/dist/base/data-types/core.js +14 -1
  25. package/dist/base/data-types/core.js.map +1 -1
  26. package/dist/base/data-types/default-types.d.ts +112 -2
  27. package/dist/base/data-types/default-types.js +90 -19
  28. package/dist/base/data-types/default-types.js.map +1 -1
  29. package/dist/base/data-types/field-types.d.ts +10 -4
  30. package/dist/base/data-types/field-types.js +12 -2
  31. package/dist/base/data-types/field-types.js.map +1 -1
  32. package/dist/base/data-types/internal/compare-types.d.ts +3 -0
  33. package/dist/base/data-types/internal/compare-types.js +7 -0
  34. package/dist/base/data-types/internal/compare-types.js.map +1 -1
  35. package/dist/base/empty-state.d.ts +4 -4
  36. package/dist/base/form-block-schema.d.ts +59 -3
  37. package/dist/base/form-block-schema.js +12 -2
  38. package/dist/base/form-block-schema.js.map +1 -1
  39. package/dist/base/form-schema.d.ts +46 -1
  40. package/dist/base/form-schema.js.map +1 -1
  41. package/dist/base/form-types.d.ts +56 -13
  42. package/dist/base/form-types.js +12 -4
  43. package/dist/base/form-types.js.map +1 -1
  44. package/dist/base/guard.d.ts +6 -6
  45. package/dist/base/helpers/archive-mode-helpers.js +6 -2
  46. package/dist/base/helpers/archive-mode-helpers.js.map +1 -1
  47. package/dist/base/index.d.ts +1 -0
  48. package/dist/base/index.js +1 -0
  49. package/dist/base/index.js.map +1 -1
  50. package/dist/base/internal/form-schema.js +2 -0
  51. package/dist/base/internal/form-schema.js.map +1 -1
  52. package/dist/base/internal/layouts.d.ts +4 -0
  53. package/dist/base/key-value-list.d.ts +12 -9
  54. package/dist/base/key-value-list.js +1 -1
  55. package/dist/base/key-value-list.js.map +1 -1
  56. package/dist/base/kpi-card.d.ts +39 -5
  57. package/dist/base/kpi-card.js +18 -6
  58. package/dist/base/kpi-card.js.map +1 -1
  59. package/dist/base/layouts.d.ts +24 -0
  60. package/dist/base/layouts.js.map +1 -1
  61. package/dist/base/live-toggle.d.ts +11 -0
  62. package/dist/base/live-toggle.js +8 -1
  63. package/dist/base/live-toggle.js.map +1 -1
  64. package/dist/base/page-toolbar.d.ts +158 -0
  65. package/dist/base/page-toolbar.js +198 -0
  66. package/dist/base/page-toolbar.js.map +1 -0
  67. package/dist/base/period-selector.d.ts +9 -2
  68. package/dist/base/period-selector.js.map +1 -1
  69. package/dist/base/placeholder.d.ts +2 -2
  70. package/dist/base/section.d.ts +2 -2
  71. package/dist/base/stack.d.ts +2 -2
  72. package/dist/base/stat-group.d.ts +18 -6
  73. package/dist/base/stat-group.js +9 -0
  74. package/dist/base/stat-group.js.map +1 -1
  75. package/dist/base/table-view/column-display.d.ts +9 -5
  76. package/dist/base/table-view/column-display.js +1 -1
  77. package/dist/base/table-view/column-display.js.map +1 -1
  78. package/dist/base/table-view/data-functions.d.ts +26 -7
  79. package/dist/base/table-view/data-functions.js +10 -6
  80. package/dist/base/table-view/data-functions.js.map +1 -1
  81. package/dist/base/table-view/factory.js +24 -13
  82. package/dist/base/table-view/factory.js.map +1 -1
  83. package/dist/base/table-view/internal/auth.d.ts +2 -0
  84. package/dist/base/table-view/internal/auth.js +3 -1
  85. package/dist/base/table-view/internal/auth.js.map +1 -1
  86. package/dist/base/table-view/internal/factory-helpers.d.ts +5 -0
  87. package/dist/base/table-view/internal/factory-helpers.js +55 -11
  88. package/dist/base/table-view/internal/factory-helpers.js.map +1 -1
  89. package/dist/base/table-view/internal/guards.js +19 -2
  90. package/dist/base/table-view/internal/guards.js.map +1 -1
  91. package/dist/base/table-view/internal/realtime.js +17 -0
  92. package/dist/base/table-view/internal/realtime.js.map +1 -1
  93. package/dist/base/table-view/internal/resource-form.js +1 -0
  94. package/dist/base/table-view/internal/resource-form.js.map +1 -1
  95. package/dist/base/table-view/internal/row-actions-schema.d.ts +296 -0
  96. package/dist/base/table-view/internal/row-actions-schema.js +62 -0
  97. package/dist/base/table-view/internal/row-actions-schema.js.map +1 -0
  98. package/dist/base/table-view/internal/validation.d.ts +15 -3
  99. package/dist/base/table-view/internal/validation.js +70 -2
  100. package/dist/base/table-view/internal/validation.js.map +1 -1
  101. package/dist/base/table-view/meta.d.ts +8 -0
  102. package/dist/base/table-view/meta.js +25 -3
  103. package/dist/base/table-view/meta.js.map +1 -1
  104. package/dist/base/table-view/options.d.ts +90 -16
  105. package/dist/base/table-view/options.js.map +1 -1
  106. package/dist/base/table-view/realtime.d.ts +8 -0
  107. package/dist/base/table-view/realtime.js.map +1 -1
  108. package/dist/base/table-view/routes.js +5 -2
  109. package/dist/base/table-view/routes.js.map +1 -1
  110. package/dist/base/table-view/schema.d.ts +36 -30
  111. package/dist/base/table-view/schema.js +11 -57
  112. package/dist/base/table-view/schema.js.map +1 -1
  113. package/dist/base/table-view/source.d.ts +1 -1
  114. package/dist/base/table-view/source.js +4 -1
  115. package/dist/base/table-view/source.js.map +1 -1
  116. package/dist/base/table-view.d.ts +2 -2
  117. package/dist/base/table-view.js.map +1 -1
  118. package/dist/base/top-list-card.d.ts +5 -5
  119. package/dist/base/types/action-target.d.ts +77 -20
  120. package/dist/base/types/confirm-dialog.d.ts +5 -1
  121. package/dist/base/types/custom-button.d.ts +5 -3
  122. package/dist/base/types/field-config.d.ts +4 -4
  123. package/dist/base/types/guards.d.ts +10 -0
  124. package/dist/base/types/row-action.d.ts +6 -1
  125. package/dist/base/types/size.d.ts +3 -1
  126. package/dist/client-ip.d.ts +6 -2
  127. package/dist/client-ip.js +4 -2
  128. package/dist/client-ip.js.map +1 -1
  129. package/dist/component.d.ts +8 -0
  130. package/dist/component.js.map +1 -1
  131. package/dist/data-controllers/members.d.ts +2 -5
  132. package/dist/data-controllers/members.js +38 -9
  133. package/dist/data-controllers/members.js.map +1 -1
  134. package/dist/db/internal/tenantLifecycle.model.d.ts +4 -0
  135. package/dist/db/internal/tenantLifecycle.model.js +26 -0
  136. package/dist/db/internal/tenantLifecycle.model.js.map +1 -1
  137. package/dist/db/internal/tenantLifecycle.table.d.ts +1 -0
  138. package/dist/db/internal/tenantLifecycle.table.js +4 -0
  139. package/dist/db/internal/tenantLifecycle.table.js.map +1 -1
  140. package/dist/guards.js +2 -3
  141. package/dist/guards.js.map +1 -1
  142. package/dist/hooks.d.ts +47 -0
  143. package/dist/hooks.js +6 -0
  144. package/dist/hooks.js.map +1 -1
  145. package/dist/index.d.ts +3 -3
  146. package/dist/index.js +3 -2
  147. package/dist/index.js.map +1 -1
  148. package/dist/internal/invite-membership.d.ts +9 -0
  149. package/dist/internal/invite-membership.js +25 -0
  150. package/dist/internal/invite-membership.js.map +1 -1
  151. package/dist/internal/member-changes.d.ts +24 -0
  152. package/dist/internal/member-changes.js +66 -0
  153. package/dist/internal/member-changes.js.map +1 -0
  154. package/dist/internal/tenant-ownership.d.ts +4 -1
  155. package/dist/internal/tenant-ownership.js +4 -1
  156. package/dist/internal/tenant-ownership.js.map +1 -1
  157. package/dist/invite-extensions.d.ts +0 -9
  158. package/dist/invite-extensions.js +1 -36
  159. package/dist/invite-extensions.js.map +1 -1
  160. package/dist/invite-resolution.js +6 -2
  161. package/dist/invite-resolution.js.map +1 -1
  162. package/dist/invites.js +36 -22
  163. package/dist/invites.js.map +1 -1
  164. package/dist/layout-banners.d.ts +40 -4
  165. package/dist/layout-banners.js +25 -3
  166. package/dist/layout-banners.js.map +1 -1
  167. package/dist/notifications/internal.d.ts +2 -2
  168. package/dist/notifications/internal.js.map +1 -1
  169. package/dist/notifications/sendable.d.ts +3 -2
  170. package/dist/notifications/sendable.js +2 -1
  171. package/dist/notifications/sendable.js.map +1 -1
  172. package/dist/notifications/types.d.ts +9 -0
  173. package/dist/page/controllers.d.ts +44 -0
  174. package/dist/page/controllers.js +14 -1
  175. package/dist/page/controllers.js.map +1 -1
  176. package/dist/page/internal/layout-filter.d.ts +2 -1
  177. package/dist/page/internal/layout-filter.js +3 -1
  178. package/dist/page/internal/layout-filter.js.map +1 -1
  179. package/dist/page/internal/metadata-helpers.d.ts +7 -0
  180. package/dist/page/internal/metadata-helpers.js +13 -2
  181. package/dist/page/internal/metadata-helpers.js.map +1 -1
  182. package/dist/page/internal/page-registration.d.ts +2 -0
  183. package/dist/page/internal/page-registration.js.map +1 -1
  184. package/dist/page/layout-filter.js +31 -10
  185. package/dist/page/layout-filter.js.map +1 -1
  186. package/dist/page/metadata.js +29 -24
  187. package/dist/page/metadata.js.map +1 -1
  188. package/dist/page/types.d.ts +39 -4
  189. package/dist/page/types.js.map +1 -1
  190. package/dist/page.d.ts +1 -1
  191. package/dist/page.js +2 -1
  192. package/dist/page.js.map +1 -1
  193. package/dist/permissions-resolver.d.ts +26 -3
  194. package/dist/permissions-resolver.js +2 -1
  195. package/dist/permissions-resolver.js.map +1 -1
  196. package/dist/permissions.d.ts +7 -3
  197. package/dist/permissions.js +8 -6
  198. package/dist/permissions.js.map +1 -1
  199. package/dist/realtime.d.ts +24 -1
  200. package/dist/realtime.js +7 -1
  201. package/dist/realtime.js.map +1 -1
  202. package/dist/request-tenant.d.ts +17 -13
  203. package/dist/request-tenant.js +54 -42
  204. package/dist/request-tenant.js.map +1 -1
  205. package/dist/tenant-access.d.ts +15 -0
  206. package/dist/tenant-access.js +11 -1
  207. package/dist/tenant-access.js.map +1 -1
  208. package/dist/tenant-lifecycle.d.ts +27 -0
  209. package/dist/tenant-lifecycle.js +35 -0
  210. package/dist/tenant-lifecycle.js.map +1 -1
  211. package/dist/tenant-members.d.ts +26 -0
  212. package/dist/tenant-members.js +29 -0
  213. package/dist/tenant-members.js.map +1 -0
  214. package/dist/tenant-ownership.d.ts +4 -1
  215. package/dist/tenant-ownership.js +15 -3
  216. package/dist/tenant-ownership.js.map +1 -1
  217. package/dist/tenant-scoped-model.d.ts +2 -1
  218. package/dist/tenant-scoped-model.js +4 -3
  219. package/dist/tenant-scoped-model.js.map +1 -1
  220. package/docs/1.core/10.layout-banners.md +57 -2
  221. package/docs/1.core/2.pages-and-modules.md +20 -11
  222. package/docs/1.core/4.permissions.md +34 -1
  223. package/docs/1.core/5.tenancy.md +51 -5
  224. package/docs/1.core/7.hooks.md +13 -0
  225. package/docs/1.core/8.realtime.md +24 -3
  226. package/docs/2.auth/2.authentication.md +3 -3
  227. package/docs/3.base/1.introduction.md +1 -0
  228. package/docs/3.base/10.display-blocks.md +20 -2
  229. package/docs/3.base/2.types.md +7 -5
  230. package/docs/3.base/3.forms.md +21 -6
  231. package/docs/3.base/4.tables.md +14 -9
  232. package/docs/3.base/5.charts-and-widgets.md +8 -4
  233. package/docs/3.base/6.layout.md +38 -1
  234. package/docs/3.base/8.data-types.md +14 -7
  235. package/docs/4.notifications/3.sending.md +4 -3
  236. package/docs/5.html-render/1.introduction.md +5 -7
  237. package/package.json +3 -7
@@ -45,6 +45,8 @@ RegisterPermission("reports.export", {
45
45
  | `dependencies` | `string[]` | Permission ids this one needs to be of any use. The roles editor grants them with it (and takes it away with them), and a role is saved with them and the ids they sit under. `HasPermission` requires them, like the ids above: a registered dependency the set lacks makes the permission count for nothing. An unregistered one counts as held. |
46
46
  | `defaultGranted` | `boolean` | Treat the permission as held by every caller, without a role grant. |
47
47
 
48
+ A table view's actions declare their `dependencies` themselves: `edit` and `export` need `view`, and every action only reached from the rows — `delete`, `archive`, `viewArchived`, `export`, and `add`/`edit`/`view` when their form opens over the table — needs `list`; `restore` needs `viewArchived`. A form opening as its own page and `select` need no `list`.
49
+
48
50
  `RegisterPermission(id, permission)` takes the id twice — once as the registry key, once inside the record — so keep them equal.
49
51
 
50
52
  `UnregisterPermission(id)` drops a permission from the grantable tree again. It exists for registrations undone while the process keeps running — a page extension whose module stopped, for instance. A permission left behind after its surface is gone stays offered in the roles editor and stays grantable, so anything that registers a permission conditionally should unregister it on the same condition.
@@ -174,11 +176,42 @@ type PermissionsResolverFn = (
174
176
  userId: string,
175
177
  tenantId: string,
176
178
  currentPermissions: Set<string>,
179
+ context: PermissionsResolverContext,
177
180
  ) => Promise<Set<string>> | Set<string>;
181
+
182
+ interface PermissionsResolverContext {
183
+ /** Set when the call previews a role rather than computing a member's set. */
184
+ preview?: PermissionsResolverPreview;
185
+ }
186
+
187
+ interface PermissionsResolverPreview {
188
+ /** The previewed role's permissions, before any resolver reshaped them. */
189
+ rolePermissions: ReadonlySet<string>;
190
+ }
178
191
  ```
179
192
 
180
193
  `PermissionsResolverInfo` carries `id`, `resolver` and `order`.
181
194
 
195
+ ### Role previews
196
+
197
+ The roles editor's **Preview as role** runs the resolvers too, over the previewed role's set, and tells them so with `context.preview`. The `userId` of such a call is the person previewing. A resolver that grants or withholds by the user's standing (an owner handed every permission of the plan, a screen kept for owners) must check the flag, and shape the set as for a member holding only that role:
198
+
199
+ ```typescript
200
+ RegisterPermissionsResolver({
201
+ id: "my_app.plan-limits",
202
+ order: 100,
203
+ resolver: async (userId, tenantId, currentPermissions, context) => {
204
+ const allowed = await loadPlanPermissions(tenantId);
205
+ if (!context.preview && (await isWorkspaceOwner(userId, tenantId))) {
206
+ return allowed;
207
+ }
208
+ return new Set([...currentPermissions].filter((p) => allowed.has(p)));
209
+ },
210
+ });
211
+ ```
212
+
213
+ The DMS always passes `context`: `{}` outside a preview.
214
+
182
215
  Semantics the interface guarantees:
183
216
 
184
217
  - Resolvers run in ascending `order`, each seeing the previous one's result; equal `order` runs in registration order, which is module load order.
@@ -187,7 +220,7 @@ Semantics the interface guarantees:
187
220
  - The registration is removed when the registering module unloads; there is no unregister call.
188
221
  - Register in `construct()` so the resolver is active before the API serves requests.
189
222
 
190
- `ApplyPermissionsResolvers(userId, tenantId, basePermissions)` runs the pipeline and resolves to the effective set. Prefer `GetEffectiveUserPermissions`, which computes the base set for you and calls it.
223
+ `ApplyPermissionsResolvers(userId, tenantId, basePermissions, context?)` runs the pipeline, handing `context` (`{}` when omitted) to every resolver, and resolves to the effective set. Prefer `GetEffectiveUserPermissions`, which computes the base set for you and calls it.
191
224
 
192
225
  A resolver shapes a permission set. It cannot deny a surface that consults no permission set, and it cannot revoke a `defaultGranted` permission, which `HasPermission` grants after the resolvers ran. To cut a tenant off from the product entirely, register a tenant access gate instead ([Tenancy](/docs/interfaces/core/tenancy)).
193
226
 
@@ -127,7 +127,7 @@ Table `roles` in `dms-tenant` — the tenant's roles.
127
127
 
128
128
  ## Resolve the Request's Tenant
129
129
 
130
- `getRequestTenantId(ctx)` reads the `tenantId` claim of the request's bearer token and returns `DEFAULT_TENANT_ID` when there is no token or no claim.
130
+ `getRequestTenantId(ctx)` returns the tenant of the request's **verified** credential, bound by the DMS auth decorators (`AuthUser`, `AuthRawUser`, `IfAuthUser` with a valid token, `AuthOwnerOnly`, `AuthUserWithPermission`, the auth a `PageController` installs), the tenant guards or `authenticateRequestUser`. Without a verified credential (anonymous request, invalid or forged token) it returns `DEFAULT_TENANT_ID`; a token is never read for its tenant before its signature, user and session are checked.
131
131
 
132
132
  ```typescript
133
133
  import {
@@ -148,11 +148,11 @@ export class WorkspaceController extends Controller("/api/workspace") {
148
148
  }
149
149
  ```
150
150
 
151
- The returned id is **decoded, not verified**: the function does not check the token's signature. It is safe behind a decorator that already authenticated the request — the guards from [Permissions](/docs/interfaces/core/permissions), `AuthUser` from `@antelopejs/interface-dms/auth`, or the auth a `PageController` installs. Do not authorize on the id in a route that no such decorator gates.
151
+ Call it in the handler, after the route's auth decorator ran. A parameter or property provider that may run before it uses `resolveRequestTenantId(ctx)` (async), which verifies a bearer token itself and binds its tenant; `TenantScopedModel` does. Resolving a tenant is still not authorization: check membership and permissions for the operation.
152
152
 
153
153
  ## Scope a Model to the Request's Tenant
154
154
 
155
- `TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass, getRequestTenantId(ctx))`.
155
+ `TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass, await resolveRequestTenantId(ctx))`: the tenant of the verified credential, `DEFAULT_TENANT_ID` without one.
156
156
 
157
157
  ```typescript
158
158
  import { Controller, Get } from "@antelopejs/interface-api";
@@ -234,6 +234,7 @@ Two functions evaluate the gates:
234
234
  | -------- | -------- |
235
235
  | `CheckTenantAccess(userId, tenantId)` | Resolves to the first denial, or `{ allowed: true }`. |
236
236
  | `AssertTenantAccess(userId, tenantId)` | Calls `CheckTenantAccess` and throws a `403` whose body is the denying gate's `code`. |
237
+ | `EvaluateTenantAccess(userId, tenantId)` | Server-side only. Resolves to every denial as `{ gateId, code }[]`, in gate order, `[]` when none; a gate that throws rejects the whole evaluation. |
237
238
 
238
239
  ```typescript
239
240
  import {
@@ -251,6 +252,8 @@ await AssertTenantAccess(user._id, tenantId); // or let it throw
251
252
 
252
253
  `AuthTenantOwner` and `AuthTenantMember` call `AssertTenantAccess` unless the route opts out with `bypassTenantAccessGate` ([Permissions](/docs/interfaces/core/permissions)).
253
254
 
255
+ `EvaluateTenantAccess` exists for modules that must know whether a denial other than their own is in place, for example before lifting a hold: identify gates by `gateId`, never by `code`, which two gates may share. Its result is an observation, not an authorization receipt or an atomic snapshot of external state; revalidate before acting on it.
256
+
254
257
  Use a gate to deny a tenant outright; use a permissions resolver to shape what its users may do.
255
258
 
256
259
  ## Memberships and Ownership
@@ -276,7 +279,7 @@ await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
276
279
  | `isTenantOwner` | `boolean` | Ownership flag after the write. |
277
280
  | `invitedBy` | `string \| null` | Recorded on insert. Ignored when the membership row already exists. |
278
281
 
279
- When the user is not yet a member, `applyTenantOwnership` fires `Hook.MEMBER_BEING_ADDED` before the write and `Hook.MEMBER_ADDED` after it. For an existing member it updates `roleIds` and `isTenantOwner` and fires neither ([Hooks](/docs/interfaces/core/hooks)).
282
+ `applyTenantOwnership` goes through [`changeTenantMembers`](#changetenantmembers), which fires `Hook.MEMBER_BEING_CHANGED` before any write, so a module can veto it. When the user is not yet a member, it also fires `Hook.MEMBER_BEING_ADDED` before the write and `Hook.MEMBER_ADDED` after it. For an existing member it updates `roleIds` and `isTenantOwner` and fires neither of those two ([Hooks](/docs/interfaces/core/hooks)).
280
283
 
281
284
  Two helpers keep the legacy platform-owner flag on the user row aligned with tenant ownership:
282
285
 
@@ -310,12 +313,55 @@ The registration is removed when the registering module unloads, and the mode st
310
313
 
311
314
  Membership and invite producers coordinate with tenant deletion through `@antelopejs/interface-dms/tenant-lifecycle`. This is a write-lifecycle contract, not an authorization check or a transaction: authorize the caller separately.
312
315
 
316
+ ### `changeTenantMembers`
317
+
318
+ Every insert, update or delete of a `TenantMemberModel` or `UserInviteModel` row goes through one entry point, `changeTenantMembers` from `@antelopejs/interface-dms/tenant-members`. The DMS uses it for every membership and invitation write it makes: adding a member, changing a member's roles or ownership (from the members table or the owner route), removing a member, moving the holders of a deleted role, leaving each tenant when an account is deleted, and creating, editing, resending, cancelling, expiring and accepting invitations. A module writing these rows itself must use it too; there is no other supported write path, and a raw `GetModel(...).insert/update/delete` on them skips both the barrier and the policy hook.
319
+
320
+ ```typescript
321
+ changeTenantMembers<T>(
322
+ tenantId: string,
323
+ plan: () => Promise<{ changes: MemberChange[]; write: () => Promise<T> }>,
324
+ ): Promise<T>
325
+ ```
326
+
327
+ It runs, in order:
328
+
329
+ 1. `runTenantLifecycleOperation(tenantId, ...)`: refused at once, before anything is read, while a [barrier](#pausing-admission) is held or once admission is closed;
330
+ 2. `plan()`, inside the admission, which reads the current rows and describes the change;
331
+ 3. `Hook.MEMBER_BEING_CHANGED` once per `MemberChange`, with `{ tenantId, ...change }`. A throwing handler refuses the write, replays of an invitation acceptance included;
332
+ 4. `write()`, then the admission is released as `runTenantLifecycleOperation` describes.
333
+
334
+ A `MemberChange` has a `kind`:
335
+
336
+ | `kind` | Fields | When |
337
+ | ------ | ------ | ---- |
338
+ | `added` | `userId`, `isTenantOwner`, `roleIds`, `deliveryId?` | A user joins: direct add, or an invitation accepted (`deliveryId` set) |
339
+ | `removed` | `userId`, `isTenantOwner` | A member is removed, or leaves by deleting their account |
340
+ | `roles` | `userId`, `roleIds`, `previousRoleIds` | A member's roles change, including when a deleted role is reassigned |
341
+ | `ownership` | `userId`, `isTenantOwner`, `previousIsTenantOwner` | A member is given or loses the owner role |
342
+
343
+ One edit setting both roles and ownership announces two changes. A write that changes no membership (an invitation created, edited or retired) passes `changes: []`: it is still admitted, and invitation creation keeps its own `Hook.INVITE_BEING_CREATED`. Do not call `changeTenantMembers` from inside another `changeTenantMembers` or `runTenantLifecycleOperation` callback.
344
+
345
+ Deleting an account makes one call per tenant the user belongs to, in turn. A tenant holding a barrier refuses its call, which stops the deletion there: the account and its remaining memberships stay, and the user can retry once the barrier is released.
346
+
313
347
  ### `runTenantLifecycleOperation`
314
348
 
315
- `runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T>` admits one invocation before calling `work`, then returns its result after releasing that invocation's admission. Closed admission rejects without running `work`. Built-in membership and invite entry points already participate; custom producers must use this entry point and await every effect, with no detached writer.
349
+ `runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T>` admits one invocation before calling `work`, then returns its result after releasing that invocation's admission. Closed admission rejects without running `work`. Membership and invitation writes use it through `changeTenantMembers`; call it directly only for other work that must coordinate with tenant deletion, and await every effect, with no detached writer.
316
350
 
317
351
  A successful callback releases its admission. A deliberate HTTP 4xx refusal also attempts release and rethrows the original refusal; other failures retain admission because their effects may be indeterminate. Retrying is a new invocation and never retires the failed one's identity. There is no automatic expiry or reconciliation API.
318
352
 
353
+ ### Pausing Admission
354
+
355
+ A module that must validate a tenant's inventory with no membership writer in flight, such as a plan downgrade, pauses admission instead of closing it:
356
+
357
+ | Function | Behavior |
358
+ | -------- | -------- |
359
+ | `observeTenantLifecycle(tenantId)` | Resolves to `{ revision, barrierId, activeAttemptIds, closed }`. Never retires an attempt. |
360
+ | `pauseTenantLifecycleAdmission(tenantId, barrierId, expectedRevision)` | Publishes the barrier with one compare-and-set on `expectedRevision`. Resolves `false` when the revision moved, a barrier is held or admission is closed. |
361
+ | `resumeTenantLifecycleAdmission(tenantId, barrierId)` | Releases this barrier; with no barrier held, it invalidates the revision so a delayed `pause` fails. Resolves `false` for another owner's barrier or closed admission. |
362
+
363
+ While a barrier is held, `runTenantLifecycleOperation` rejects new work without running it, with an `HTTPResult` 409 (`Tenant lifecycle admission is paused`): a deliberate refusal with nothing written and no admission retained, which the caller may retry once the barrier is released. Admitted work keeps running. Observe the revision before checking your own business intent, pause with that revision, recheck the intent, then wait for an observation with no active attempt before validating. A `false` from `pause` needs a new observation, not a blind retry. Use a unique `barrierId` per phase, and do not observe or pause from inside a `runTenantLifecycleOperation` callback: the caller would wait for itself.
364
+
319
365
  ### `closeTenantLifecycleAdmission`
320
366
 
321
367
  `closeTenantLifecycleAdmission(tenantId: string): Promise<void>` permanently closes admission, then checks that no admitted operation remains. It rejects if work is active or indeterminate, or if the database cannot confirm the transition. Rejection can leave admission closed: it never authorizes deletion or reopening.
@@ -76,6 +76,7 @@ UnregisterHook(Hook.MEMBER_ADDED, onMemberAdded);
76
76
  | `Hook.INVITE_CREATED` | `"invite:created"` | `InviteCreatedHookPayload` | `undefined` |
77
77
  | `Hook.INVITE_DELETED` | `"invite:deleted"` | `InviteDeletedHookPayload` | `undefined` |
78
78
  | `Hook.MEMBER_BEING_ADDED` | `"member:being-added"` | `MemberHookPayload` | `undefined` |
79
+ | `Hook.MEMBER_BEING_CHANGED` | `"member:being-changed"` | `MemberChangeHookPayload` | `undefined` — fired by `changeTenantMembers` under tenant lifecycle admission before every membership write (`kind`: `added`, `removed`, `roles`, `ownership`); throwing vetoes the write |
79
80
  | `Hook.MEMBER_ADDED` | `"member:added"` | `MemberHookPayload` | `undefined` |
80
81
  | `Hook.MEMBER_REMOVED` | `"member:removed"` | `MemberRemovedHookPayload` | `undefined` |
81
82
  | `Hook.USER_REGISTERED` | `"user:registered"` | `UserRegisteredHookPayload` | `undefined` |
@@ -140,6 +141,18 @@ interface UserDeletedHookPayload {
140
141
 
141
142
  The shapes are not uniform, and the difference between the addition and removal payloads is deliberate: `MEMBER_BEING_ADDED` and `MEMBER_ADDED` describe one member with `userId`, while `MEMBER_REMOVED` carries `userIds`, because a removal can cover several members at once. Let the handler's inferred parameter type guide you.
142
143
 
144
+ `MemberChangeHookPayload` is `{ tenantId } & MemberChange`, a union discriminated by `kind`:
145
+
146
+ ```typescript
147
+ type MemberChange =
148
+ | { kind: "added"; userId: string; isTenantOwner: boolean; roleIds: string[]; deliveryId?: string }
149
+ | { kind: "removed"; userId: string; isTenantOwner: boolean }
150
+ | { kind: "roles"; userId: string; roleIds: string[]; previousRoleIds: string[] }
151
+ | { kind: "ownership"; userId: string; isTenantOwner: boolean; previousIsTenantOwner: boolean };
152
+ ```
153
+
154
+ Read `kind` first and return early for the kinds your policy does not limit. An invitation acceptance is an `added` change carrying its `deliveryId`, vetoed before the acceptance is persisted, including on replay: a `deliveryId` is not a policy exemption. See [`changeTenantMembers`](/docs/interfaces/core/tenancy#changetenantmembers) for which writes announce which kind.
155
+
143
156
  `UserDeletedHookPayload.tenantIds` lists the workspaces the user was a member of, all left before the deletion; delete the per-user rows your module keeps in them. `reason` is `"self"` today, the user deleting their own account.
144
157
 
145
158
  `InviteCreatedHookPayload` carries the invite `token`. It is a signup credential: keep it inside the handler rather than forwarding it to a wider surface.
@@ -17,7 +17,7 @@ Subscription is derived, not requested by the publisher:
17
17
 
18
18
  1. A page — or a component on it — calls `RegisterPageTopic(pageId, topic)` at registration time. `pageId` is the page's `fullId` ([Pages and Modules](/docs/interfaces/core/pages-and-modules)), not its slug.
19
19
  2. A client opens a realtime session and reports which page it is on. The backend checks that the user may access that page, then subscribes the session to every topic registered for that page id, and drops the topics that are no longer wanted.
20
- 3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic`.
20
+ 3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic` — with a `tenantId`, only to those of that tenant (see [Tenant-scoped messages](#tenant-scoped-messages)).
21
21
 
22
22
  So a publisher never addresses a page or a user. It addresses a topic, and the page-topic registry decides who is listening.
23
23
 
@@ -86,7 +86,7 @@ PublishMessage(
86
86
  | --------- | ----------- |
87
87
  | `topic` | The topic to publish on. Subscribers are the sessions on a page that registered this topic. |
88
88
  | `type` | The event type carried with the message, for subscribers that distinguish several kinds of event on one topic. |
89
- | `options` | Optional payload and actor. |
89
+ | `options` | Optional payload, actor and tenant. |
90
90
 
91
91
  ### `PublishMessageOptions`
92
92
 
@@ -94,12 +94,32 @@ PublishMessage(
94
94
  | ------ | ---- | ----------- |
95
95
  | `payload` | `Record<string, unknown>` | Data carried with the event. |
96
96
  | `actorId` | `string` | Identifies who caused the event, so a client can ignore an event it triggered itself. |
97
+ | `tenantId` | `string` | The tenant the event is about: only the sessions of that tenant receive it. Omitted, the message is global and reaches every tenant. |
97
98
 
98
99
  `PublishMessage` resolves once the message has been handed to the broker. It does not report how many sessions received it, and publishing on a topic no page registered is not an error — it simply reaches nobody.
99
100
 
101
+ ### Tenant-scoped messages
102
+
103
+ Page topics are shared by every tenant: a page registers the same topic whichever workspace views it. A message published without a `tenantId` therefore reaches the sessions of **every** workspace on that page — each one refetches, and a paused live page counts the message in its "N new" banner. Publish a workspace's data with the tenant it belongs to:
104
+
105
+ ```typescript
106
+ import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
107
+
108
+ await PublishMessage(CAMPAIGN_TOPIC, "campaign.sent", {
109
+ payload: { count: sent.length },
110
+ tenantId: getRequestTenantId(ctx), // or the tenant a job runs for
111
+ });
112
+ ```
113
+
114
+ A session is bound to the tenant of the request that opened it, and receives the messages of that tenant plus the global ones. When the browser switches workspace (or account), the client opens a new session in the new one, and the server ends a session whose page requests come from a workspace or account other than its own, so the previous workspace's messages stop with it. A session on the connection a browser's tabs share is ended alone: the other tabs' sessions on it stay. Keep messages without a tenant for what is the same for every workspace: a platform-wide setting, a catalogue shared by all tenants.
115
+
116
+ A session on a platform page (a page of a module, which only the platform owner opens) receives the messages of **every** tenant on that page's topics, whichever workspace the owner has open: such a page lists the data of all of them.
117
+
118
+ The table views of the [Base](/docs/interfaces/base/tables#realtime) domain publish their row changes and presence for the writer's tenant, or for every tenant when their rows live in the shared schema. Messages to one user (notifications) and the menu invalidations already name their audience in the topic.
119
+
100
120
  ## Subscribe from the Backend
101
121
 
102
- `SubscribeMessage` receives every message published on a topic, whichever instance published it: the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
122
+ `SubscribeMessage` receives every message published on a topic, whichever instance and whichever tenant published it (a handler serving one tenant filters on `message.tenantId` itself): the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
103
123
 
104
124
  ```typescript
105
125
  import { SubscribeMessage } from "@antelopejs/interface-dms/realtime";
@@ -132,6 +152,7 @@ type UnsubscribeMessage = () => void;
132
152
  | `type` | `string` | Event type given to `PublishMessage`. |
133
153
  | `payload` | `Record<string, unknown>` | Data carried with the event, when the publisher sent any. |
134
154
  | `actorId` | `string` | Who caused the event, when the publisher named an actor. |
155
+ | `tenantId` | `string` | The tenant the message was published for; absent on a global message. |
135
156
  | `ts` | `number` | Publication timestamp. |
136
157
 
137
158
  Paired with `PublishMessage`, this turns the DMS's broker into one a module can use for realtime channels of its own — its own endpoint subscribes here and the DMS carries the messages — instead of bringing a second broker to do the same job.
@@ -7,7 +7,7 @@ description: "Authentication turns an incoming HTTP request into a `User` row."
7
7
 
8
8
  ## Overview
9
9
 
10
- Authentication turns an incoming HTTP request into a `User` row. The interface exposes one plain function, `authenticateRequestUser`, and four decorators built with `CreateAuthDecorator` from `@antelopejs/interface-auth`: `AuthRawUser`, `AuthUser`, `IfAuthUser` and `AuthOwnerOnly`.
10
+ Authentication turns an incoming HTTP request into a `User` row. The interface exposes one plain function, `authenticateRequestUser`, and four decorators with the `CreateAuthDecorator` contract of `@antelopejs/interface-auth`: `AuthRawUser`, `AuthUser`, `IfAuthUser` and `AuthOwnerOnly`. Each of them, and `authenticateRequestUser`, binds the verified user and tenant to the request, which [`getRequestTenantId`](/docs/interfaces/core/tenancy#resolve-the-requests-tenant) then answers.
11
11
 
12
12
  All of them read the bearer token from the request's `Authorization` header (`Authorization: Bearer <token>`) and run the same two steps:
13
13
 
@@ -113,7 +113,7 @@ export class MaintenanceController extends Controller("/api/maintenance") {
113
113
 
114
114
  ## Gate Every Route on a Controller
115
115
 
116
- `AuthRawUser`, `AuthUser` and `IfAuthUser` are built with `CreateAuthDecorator`, so they apply to a parameter, to a property, or to the controller class itself. A property provider is a computed property of the controller, resolved on every request the controller handles whether or not the handler reads it — so a decorated property gates the whole controller and gives the handlers a typed `this.user`.
116
+ `AuthRawUser`, `AuthUser` and `IfAuthUser` follow the `CreateAuthDecorator` contract, so they apply to a parameter, to a property, or to the controller class itself. A property provider is a computed property of the controller, resolved on every request the controller handles whether or not the handler reads it — so a decorated property gates the whole controller and gives the handlers a typed `this.user`.
117
117
 
118
118
  ```typescript
119
119
  import { Controller, Get, JSONBody, Post } from "@antelopejs/interface-api";
@@ -206,7 +206,7 @@ export const AuthBetaTester = MakeParameterAndPropertyDecorator(
206
206
 
207
207
  This is how the DMS builds its tenant guards: `AuthTenantOwner` and `AuthTenantMember` in `@antelopejs/interface-dms/guards` both start from `authenticateRequestUser`, then resolve the request's tenant and check the caller's membership on top of it.
208
208
 
209
- A provider is also the way to add a requirement without losing the built-in one. The `CreateAuthDecorator` contract lets a call site pass a replacement validator — `@AuthUser(callback)` — but that callback replaces the whole validation step, signature check included, and its return type is constrained to `boolean | User`. This interface exports no token-verification primitive, so a replacement validator cannot redo the signature check it removes.
209
+ A provider is also the way to add a requirement without losing the built-in one. The `CreateAuthDecorator` contract lets a call site pass a replacement validator — `@AuthUser(callback)` — but that callback replaces the whole validation step, signature check included, and its return type is constrained to `boolean | User`; such a call binds no tenant to the request. This interface exports no token-verification primitive, so a replacement validator cannot redo the signature check it removes.
210
210
 
211
211
  ## Decorator Reference
212
212
 
@@ -30,6 +30,7 @@ The DMS documentation carries task-shaped chapters on the same components, organ
30
30
  | `top-list-card` | `TopListCard` | yes | [Charts and Widgets](/docs/interfaces/base/charts-and-widgets) |
31
31
  | `period-selector` | `PeriodSelector` | yes | [Charts and Widgets](/docs/interfaces/base/charts-and-widgets) |
32
32
  | `live-toggle` | `LiveToggle` | yes | [Display Blocks](/docs/interfaces/base/display-blocks#live-toggle) |
33
+ | `page-toolbar` | `PageToolbar`, `pageToolbar` | yes | [Display Blocks](/docs/interfaces/base/display-blocks#page-toolbar) |
33
34
  | `grid` | `Grid`, `GridRow` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
34
35
  | `stack` | `HStack`, `VStack`, `Spacer` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
35
36
  | `tab` | `Tab` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
@@ -72,7 +72,7 @@ A block that can drop its frame does so with `card?: boolean` (`KeyValueList`, `
72
72
 
73
73
  | Props | Own options |
74
74
  | ------------------- | --------------------------------------------------------- |
75
- | `StatGroupProps` | `items`, `layout` (`StatGroupLayout`), `columns`, `label` |
75
+ | `StatGroupProps` | `items`, `layout` (`StatGroupLayout`), `columns`, `columnsFrom` (`StatGroupColumnsFrom`: `sm` / `md` / `lg` / `xl`, the group width from which all `columns` sit side by side), `label` |
76
76
  | `KeyValueListProps` | `items`, `title`, `card`, `dense`, `columns`, `currency` |
77
77
  | `NavCardGridProps` | `items`, `title`, `description`, `columns` |
78
78
 
@@ -127,10 +127,28 @@ Card({ title: "Connectors", count: 4 })
127
127
 
128
128
  ## Live Toggle
129
129
 
130
- `LiveToggle(options?)` (subpath `live-toggle`, block type `LiveToggle`, component `dms-live-toggle-block`, group `data`) is the Live / Pause switch of a page declared with `live` (`PageLiveOptions` from `@antelopejs/interface-dms/page`). `LiveToggleProps` extends `BaseComponentProps`: `align` (`LIVE_TOGGLE_ALIGNS`: `left`, `center`, `right`; default `right`) `showUpdatedAt` (default `true`), the time since the page last read its data, counted in the browser, `variant` (`LIVE_TOGGLE_VARIANTS`: `segmented`, `pill`; default `segmented`) and `missedLabel`, an i18n key or text given `{count}` naming the missed events while paused. The state belongs to the page, not to the block; on a page without `live` the block renders nothing.
130
+ `LiveToggle(options?)` (subpath `live-toggle`, block type `LiveToggle`, component `dms-live-toggle-block`, group `data`) is the Live / Pause switch of a page declared with `live` (`PageLiveOptions` from `@antelopejs/interface-dms/page`). `LiveToggleProps` extends `BaseComponentProps`: `align` (`LIVE_TOGGLE_ALIGNS`: `left`, `center`, `right`; default `right`) `showUpdatedAt` (default `true`), the time since the page last read its data, counted in the browser, `variant` (`LIVE_TOGGLE_VARIANTS`: `segmented`, `pill`; default `segmented`), `size` (`LIVE_TOGGLE_SIZES`: `xs`, `sm`, `md`; default the holding `PageToolbar`'s, else `sm`) and `missedLabel`, an i18n key or text given `{count}` naming the missed events while paused. The state belongs to the page, not to the block; on a page without `live` the block renders nothing.
131
131
 
132
132
  ```typescript
133
133
  import { LiveToggle } from "@antelopejs/interface-dms/base/live-toggle";
134
134
 
135
135
  static toolbar = HStack().child("spacer", Spacer()).child("live", LiveToggle());
136
136
  ```
137
+
138
+ ## Page Toolbar
139
+
140
+ `PageToolbar(options?)` (subpath `page-toolbar`, block type `PageToolbar`, component `dms-page-toolbar-block`, group `data`, a container) is the context bar of a page with a period or live data, first in its body. `PageToolbarProps` extends `BaseComponentProps`: `size` (`PAGE_TOOLBAR_SIZES`: `xs`, `sm`, `md`; default `sm`), the size of the `LiveToggle` and `PeriodSelector` it holds unless they set one, and `showUpdatedAt` and `refresh` (both default `true`), the "updated N s ago" label and the refresh button it shows where no `LiveToggle` is on screen — a page without `live` — counted from the page's last read. Its children render in one order whatever order they were added in: the other blocks, a spacer, the updated label or the `LiveToggle`, then the `PeriodSelector`.
141
+
142
+ `pageToolbar(options?)` builds one: `PageToolbarOptions` extends `PageToolbarProps` with `leading` (`Record<string, Component>`, the blocks on the left by child id), `live` (`LiveToggleProps | false`, child `live`, over `PAGE_TOOLBAR_LIVE_DEFAULTS`) and `period` (`PeriodSelectorProps | false`, child `period`, over `PAGE_TOOLBAR_PERIOD_DEFAULTS`: segmented `last-hour` / `last-24h` / `last-7-days` / `last-30-days`, `defaultPreset: "last-24h"`, `rangeLabel: "relative"`, right aligned). Unless `period` sets `comparisonControl`, it is `"menu"` when the period offers more than one comparison (`none` included; the selector's default list when `comparisons` is unset), on `defaultComparison` or else the first one besides `none`, and `"hidden"` with a single comparison or none.
143
+
144
+ ```typescript
145
+ import { pageToolbar } from "@antelopejs/interface-dms/base/page-toolbar";
146
+
147
+ static toolbar = pageToolbar({
148
+ leading: { scope: scopeChip },
149
+ live: { missedLabel: "$api.live.new_requests" },
150
+ period: { id: "api", rememberKey: "api", comparisons: ["previous-period"] },
151
+ });
152
+ ```
153
+
154
+ A module's Vue component may also draw the toolbar itself, around controls of its own, as `<DmsPageToolbarBlock>` holding a `<DmsLiveToggleBlock>`: outside the page's block tree, neither needs a `componentId` or `pageId`.
@@ -90,7 +90,7 @@ import {
90
90
 
91
91
  `Size` maps readable names onto the scale tokens: `tiny` → `"xs"`, `small` → `"sm"`, `medium` → `"md"`, `large` → `"lg"`, `huge` → `"xl"`. `SizeValue` widens it the same way `ColorValue` widens `Color`.
92
92
 
93
- `ModalSize` is a separate, wider union used only for modal action targets: `"sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl"`.
93
+ `ModalSize` is a separate, wider union for modal and drawer action targets: `"sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "full"`: a modal's width, and the width of a side drawer given that size, `full` the whole window.
94
94
 
95
95
  ## Buttons
96
96
 
@@ -145,9 +145,9 @@ import type { ActionTarget } from "@antelopejs/interface-dms/base/types/action-t
145
145
 
146
146
  | `type` | Fields |
147
147
  | --- | --- |
148
- | `"drawer"` | `component: Component`, `title?`, `description?`, `direction?: DrawerDirection` |
148
+ | `"drawer"` | `component: Component`, `title?`, `description?`, `direction?: DrawerDirection`, plus `DrawerTargetOptions`: `size?: ModalSize`, `rowHeader?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` |
149
149
  | `"modal"` | `component: Component`, `size?: ModalSize`, `title?`, `description?` |
150
- | `"sidePanel"` | `component: Component`, plus `SidePanelTargetOptions`: `title?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` |
150
+ | `"sidePanel"` | `component: Component`, plus `SidePanelTargetOptions`: `title?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` — for global, page-independent panels; a detail panel is a `drawer` |
151
151
  | `"page"` | `url: string`: the page URL's `{{params.X}}` / `{{query.X}}` (a query pair without a value left out), then on a row action the row's `{{row.field}}`; see [Template tokens](/docs/building/pages-and-components#template-tokens) |
152
152
  | `"external"` | `url: string`, `newTab?: boolean`; the same tokens as `page` |
153
153
  | `"download"` | `url: string`, `filename?: string` (`DownloadActionTarget`); the same tokens as `page`, the file name taking them as written. Fetched with the user's session and saved under `filename`, else the server's `Content-Disposition` name, else the URL's last segment; a failure is toasted |
@@ -155,9 +155,11 @@ import type { ActionTarget } from "@antelopejs/interface-dms/base/types/action-t
155
155
  | `"api"` | `url: string`, `method?: "GET" \| "POST" \| "PUT" \| "DELETE" \| "PATCH"`, `body?`, `copy?`, `successMessage: string`; the same tokens as `page` |
156
156
  | `"exportJob"` | `url: string`, `method?: "GET" \| "POST"`, `statusUrl?`, `downloadUrl?`, `labels?`; the same tokens as `page`, plus `{{job.id}}` in `statusUrl` and `downloadUrl` |
157
157
 
158
- `SidePanelTargetOptions` sets the header the DMS draws above a `sidePanel` target's component: `title` (the action's label by default; `$`: an i18n key), `rowTitle`, the title while a row is open, an i18n key or a literal receiving the row's fields (`"{method} {path}"`), `positionLabel`, an i18n key receiving `{ index }` (from 1) and `{ total }` for the row's position among the rows the table shows (the DMS's "{index} of {total}" by default), and `deepLink`, which keeps the open row in the URL like the action's own `deepLink` — on by default for a side panel, `false` opting out (the action's own `deepLink` wins when set), and `notFound` (`SidePanelNotFoundTexts`: `title?`, `description?`), the state the panel draws with a close button when the row a link names answers 404. The panel is non-modal and docked on the right, one at a time with a module's side panels.
158
+ `SidePanelTargetOptions` sets the header the DMS draws above a `sidePanel` target's component: `title` (the action's label by default; `$`: an i18n key), `rowTitle`, the title while a row is open, an i18n key or a literal receiving the row's fields (`"{method} {path}"`), `positionLabel`, an i18n key receiving `{ index }` (from 1) and `{ total }` for the row's position among all the rows the table lists, its count rather than the page loaded (the DMS's "{index} of {total}" by default), and `deepLink`, which keeps the open row in the URL like the action's own `deepLink` — on by default for a side panel, `false` opting out (the action's own `deepLink` wins when set), and `notFound` (`SidePanelNotFoundTexts`: `title?`, `description?`), the state the panel draws with a close button when the row a link names answers 404. The panel is non-modal and docked on the right, one at a time with a module's side panels. It is meant for global, page-independent panels (an assistant dock): a detail or record panel opens in a `drawer`.
159
159
 
160
- `DrawerDirection` is `"top" | "right" | "bottom" | "left"`, the edge the drawer slides in from: `bottom` by default, while `left` and `right` open a side sheet 440px wide (a side panel's default width) spanning the window's height.
160
+ `DrawerTargetOptions` gives a `drawer` target opened on a row the same options, so a detail panel moves from `sidePanel` to `drawer` without losing any: `rowTitle`, `positionLabel` and `notFound` as above; `rowHeader`, the DMS drawing the row header (the title naming the row, its position, previous and next buttons next to the close button) — on when `rowTitle` or `positionLabel` is set, `false` leaving the drawer's own title, `true` drawing it with the defaults; `deepLink`, off by default for a drawer (the action's own `deepLink` wins when set); and `size` (`ModalSize`), the width a `left` / `right` drawer opens at from `lg`, a modal's width at that size. J and K step through the rows in place whatever the header. A drawer shows its not-found state only once its target sets `notFound`; otherwise a deep link to a missing row is a toast, as before.
161
+
162
+ `DrawerDirection` is `"top" | "right" | "bottom" | "left"`, the edge the drawer slides in from: `bottom` by default, while `left` and `right` open a side sheet spanning the window's height, 600px wide from `lg` without a `size` (capped to the window) and 440px below it. From `lg` the user resizes every drawer by its inner edge (width or height), and the drawer opens again at the size it was left at.
161
163
 
162
164
  A target carries no confirmation: the action does (`CustomButton.confirm`, `CustomRowAction.confirm`, `RowActionConfig.confirm`), with one type, `ActionConfirm = ConfirmDialog | ConfirmDialogFrom` (see below). The `labels` object on `exportJob` accepts `title`, `exporting`, `downloading`, `successTitle`, `successMessage`, `errorTitle` and `retry`, all optional strings.
163
165
 
@@ -89,12 +89,12 @@ export class NotesPage extends PageController(
89
89
  | `redirectOnSuccess` | `string` | Path to navigate to after a successful submit. Accepts `{{params.X}}` and `{{query.X}}` tokens like `submitUrl`, plus `{{response.X}}` which resolves against the JSON body of the submit response. |
90
90
  | `submitDefaults` | `Record<string, unknown>` | Values merged into the submit payload even when the field is absent from the form. String values accept `{{params.X}}` and `{{query.X}}` tokens resolved against the form's route at submit time; entries whose tokens do not resolve are dropped. |
91
91
  | `saveMode` | `FormSaveMode` | How the form offers to save. `"bar"` (default): a sticky bar shown while there are unsaved changes, naming them, with Discard and Save; on a page it floats over the form, taking no space while hidden. `"footer"`: buttons in the form's footer. `"none"`: no buttons. `"instant"`: each change saves on its own, the changed field alone sent to `submitUrl` (so the endpoint must accept a partial body), a pick at once and a text once typing pauses; a refused save is put back to the last confirmed value, with a Retry; an `"action"` form ignores it. In a drawer or modal, `"bar"` and `"footer"` both use the container's footer. |
92
- | `kind` | `FormKind` | What the form edits. `"record"` (default): keeps its values once saved, and offers Cancel while clean only when it has somewhere to go back to (the drawer or modal it sits in, or `backTo` on a page). `"action"`: a send, invite or run form; its `submitLabel` always shows in its footer, ready, so a pre-filled form sends its defaults as they are (a form with no field to fill in included); Reset joins it once a value changes (in a drawer or a modal, Cancel before that), and it empties after a successful submit (each field back to its `defaultValue`, or what `fetchUrl` loaded). |
92
+ | `kind` | `FormKind` | What the form edits. `"record"` (default): keeps its values once saved, and offers Cancel while clean only when it has somewhere to go back to (the drawer or modal it sits in, or `backTo` on a page). `"create"`: a form making a new record (a table view's add form and its duplicate, a relation's add form, set by `TableView` and `RelationType`); its submit, "Create" by default, shows from the start and it lists no changes. Declared, never inferred from `submitUrlMethod`. `"action"`: a send, invite or run form; its `submitLabel` always shows in its footer, ready, so a pre-filled form sends its defaults as they are (a form with no field to fill in included); Reset joins it once a value changes (in a drawer or a modal, Cancel before that), and it empties after a successful submit (each field back to its `defaultValue`, or what `fetchUrl` loaded). |
93
93
  | `backTo` | `string` | Where Cancel leads a `"record"` form on a page; without it the form offers no Cancel there. `TableView` sets it on its form pages, to the list. |
94
94
  | `labelKey` | `string` | Field of the loaded record that names it: on a page, the breadcrumb ends with its value once the form has loaded. `TableView` sets it on its edit and details pages from its own `labelKey`. Also in the page editor's Form schema, as an advanced option. |
95
95
  | `requiresRecord` | `boolean` | The form is about the record `fetchUrl` loads: while a route value that URL names is missing (a page reached without its `?id=`), it shows "No entry selected" instead of its fields, and cannot be saved. `ResourceForm` sets it in `edit` and `view`. |
96
96
 
97
- A form on a page that sets neither `saveMode` nor `kind` saves with the save bar, as a record: declare `kind: "action"` on a form that sends something new each time. `FORM_SAVE_MODES` and `FORM_KINDS` list the accepted values.
97
+ A form on a page that sets neither `saveMode` nor `kind` saves with the save bar, as a record: declare `kind: "create"` on a form that makes a new record and `kind: "action"` on a form that sends something new each time. `FORM_SAVE_MODES` and `FORM_KINDS` list the accepted values.
98
98
 
99
99
  An `"action"` form resets after a successful submit, a `"record"` keeps its values; `labelKey` names the field labelling the record.
100
100
 
@@ -112,8 +112,10 @@ A `FormField` describes one input.
112
112
  | `description` | `string` | Help text. |
113
113
  | `inputComponent` | `ComponentInfoSerialized` | Overrides the input component the data type would provide. |
114
114
  | `disabled` | `boolean` | Render the input non-editable. |
115
+ | `showEmptyOnCreate` | `boolean` | On a `"create"` form, keep this read-only field while it holds nothing; left out, an empty read-only field is hidden there. |
115
116
  | `required` | `boolean` | Whether a value must be supplied. Drives the schema, see below. |
116
117
  | `defaultValue` | `DefaultValue` | Initial value. |
118
+ | `offersDefault` | `boolean` | `defaultValue` is the module's default for a setting: while the field holds something else, it shows "Module default: X" with "Use default", which puts it back. Off unless set, so a record form never presents a column's initial value as a default. |
117
119
  | `localized` | `boolean` | The value is a per-locale record rather than a single value. |
118
120
 
119
121
  `FormFieldSerialized` replaces `type` with the data type's registered id string and `inputComponent` with a mandatory `component: ComponentInfoSerialized` — the resolved input component, taken from `inputComponent` when set and from `type.inputComponent()` otherwise.
@@ -275,6 +277,8 @@ const priority = FormComponents.InputSelect({
275
277
  | `File(options?)` | `FileOptions` |
276
278
  | `Image(options?)` | `ImageOptions` |
277
279
 
280
+ `InputWidth` is `"compact" | "full"`: how wide a single-line text, number or percentage input sits in its row. `"compact"` sizes it for a short value (11rem) from the `sm` breakpoint up, while a phone keeps the full row; `"full"` fills the row. Left out, the input is compact when its bounds keep the value short -- `min` / `max` within 6 digits, `maxLength` of 16 or less -- and fills the row otherwise.
281
+
278
282
  The option interfaces live in the same namespace. The ones with more than a placeholder:
279
283
 
280
284
  | Interface | Fields |
@@ -282,9 +286,9 @@ The option interfaces live in the same namespace. The ones with more than a plac
282
286
  | `SelectOption` | `label: string`, `value: string \| number`, `disabled?`, `icon?`, `iconColor?`, `textColor?` |
283
287
  | `SelectOptions` | `items: SelectOption[]`, `placeholder?`, `multiple?`, `deselectable?` |
284
288
  | `RadioGroupOptions` | `items: SelectOption[]`, `orientation?: AxeOrientation` |
285
- | `InputTextOptions` | `placeholder?`, `maxLength?`, `minLength?`, `showCount?` |
289
+ | `InputTextOptions` | `placeholder?`, `maxLength?`, `minLength?`, `showCount?`, `width?: InputWidth` |
286
290
  | `TextareaOptions` | `placeholder?`, `rows?`, `maxLength?`, `showCount?` |
287
- | `InputNumberOptions` | `min?`, `max?`, `step?`, `placeholder?`, `suffix?`, `clamp?` (`InputNumber` only: `false` keeps a value outside `min` / `max` as typed and reports it under the field instead of clamping it on blur) |
291
+ | `InputNumberOptions` | `min?`, `max?`, `step?`, `placeholder?`, `suffix?`, `clamp?` (`InputNumber` only: `false` keeps a value outside `min` / `max` as typed and reports it under the field instead of clamping it on blur), `width?: InputWidth` |
288
292
  | `InputTimeOptions` | `min?`, `max?`, `placeholder?` |
289
293
  | `InputPhoneOptions` | `placeholder?`, `requiredPrefix?` |
290
294
  | `InputPasswordOptions` | `placeholder?`, `minLength?`, `confirmPassword?`, `confirmPlaceholder?` |
@@ -316,14 +320,16 @@ The option interfaces live in the same namespace. The ones with more than a plac
316
320
  | `FormFunctions.SET_FIELD_DISABLED` | `"DmsComponent.Form.SetFieldDisabled"` |
317
321
  | `FormFunctions.SET_FIELD_HIDDEN` | `"DmsComponent.Form.SetFieldHidden"` |
318
322
  | `FormFunctions.SET_FIELD_REQUIRED` | `"DmsComponent.Form.SetFieldRequired"` |
323
+ | `FormFunctions.SET_FIELD_VALUE` | `"DmsComponent.Form.SetFieldValue"` |
319
324
 
320
- Both functions register their parameter shape in `WatchFunctionParamMap`, so `.watch()` type-checks the `params` object:
325
+ Each function registers their parameter shape in `WatchFunctionParamMap`, so `.watch()` type-checks the `params` object:
321
326
 
322
327
  | Function | Params |
323
328
  | --- | --- |
324
329
  | `SET_FIELD_DISABLED` | `{ targetField: string; setDisabled: boolean }` |
325
330
  | `SET_FIELD_HIDDEN` | `{ targetField: string; setHidden: boolean }` |
326
331
  | `SET_FIELD_REQUIRED` | `{ targetField: string; setRequired: boolean }` |
332
+ | `SET_FIELD_VALUE` | `SetFieldValueParams`: `{ targetField: string; value?: unknown; valueMap?: Record<string, unknown> }` |
327
333
 
328
334
  ```typescript
329
335
  import { Form, FormEvents, FormFunctions } from "@antelopejs/interface-dms/base/form";
@@ -340,7 +346,16 @@ const form = Form({
340
346
  });
341
347
  ```
342
348
 
343
- `FIELD_CHANGE` carries `{ fieldId, value }` and fires once per field with the values the form starts from (its defaults, or the record it loaded), then on each change: the watch above, which names no condition, hides `vatNumber` as soon as the form opens. Add a `value` condition to `onParam` for a watch that applies only when the field holds that value, whether it opened with it or the user picked it.
349
+ `FIELD_CHANGE` carries `{ fieldId, value, initial }` and fires once per field with the values the form starts from (its defaults, or the record it loaded), then on each change: the watch above, which names no condition, hides `vatNumber` as soon as the form opens. Add a `value` condition to `onParam` for a watch that applies only when the field holds that value, whether it opened with it or the user picked it. `initial` is `true` while the field still holds the value it started from and never held another.
350
+
351
+ `SET_FIELD_VALUE` writes a field from another one's change: `value` writes a fixed value, `valueMap` the entry named by the value of the field the event names, and a value the map has no entry for writes nothing. It skips the `initial` events, so a record opens with what it stored and a field nobody edited is never submitted as changed; a field it writes on a change counts as changed, like one typed in. A field it writes fires `FIELD_CHANGE` in turn, so two watches writing each other loop.
352
+
353
+ ```typescript
354
+ form.watch(FormEvents.FIELD_CHANGE, FormFunctions.SET_FIELD_VALUE, {
355
+ params: { targetField: "probability", valueMap: { won: 100, lost: 0 } },
356
+ onParam: { key: "fieldId", value: "stage" },
357
+ });
358
+ ```
344
359
 
345
360
  `.watch()` records the watch on the component itself; `.watchOn(childId, …)` records it on a child declared with `.child()`. Both come from `ComponentBuilder` in `@antelopejs/interface-dms/component`.
346
361
 
@@ -102,7 +102,7 @@ export class roleDataAPI extends DataController(
102
102
  | --- | --- |
103
103
  | `Get` | Read one row. |
104
104
  | `List` | Paginated list with search, filters and sorting. |
105
- | `Select` | List with the `select` pluck mode, used by relation pickers. |
105
+ | `Select` | List with the `select` pluck mode, used by relation pickers. `?ids=a&ids=b` (with `idKey` when the value is another field) answers those rows alone, as `List` does: a picker and a filter chip name the ids they hold this way. |
106
106
  | `Count` | Row count for the current query. |
107
107
  | `CountBatch` | One count per query, in a single `POST /count/batch` (tab, view and group counters). |
108
108
  | `Summary` | `GET /summary?ids=…`: the footer summaries a table view declared, over every row the request's filters list. |
@@ -139,7 +139,7 @@ These are not the plain data-api defaults. Every route asserts tenant access and
139
139
  | `size` | `number` | Default width of the grid column in px (150 when omitted). |
140
140
  | `display` | `ColumnDisplay` | A display class instance (`new DefaultDisplays.IdentityDisplay({ … })`, or a module's own `@RegisterDisplay("<module>:<id>")` class) the grid cells (and expanded-row fields) render with, its options typed by the class. `type` keeps driving forms, filters, validation and exports; the formatter also receives the row. Serialized as `{ type, options? }`. |
141
141
 
142
- A read-only field whose `readonlyBehavior` resolves to `hidden` for a mode is dropped from that mode's form; a field whose data type has no input component is dropped from every form.
142
+ A field is read-only in a form when its `@Access` for the action that form runs is `AccessMode.ReadOnly`: the per-action override (`@Access(AccessMode.ReadWrite, { new: AccessMode.ReadOnly })`, keyed `new`, `edit` and `get` for the view form) where it declares one, else its default access — the access the data API applies to the value it writes. Such a field is disabled in that form, or dropped from it when its `readonlyBehavior` resolves to `hidden` for the mode; a field whose data type has no input component is dropped from every form.
143
143
 
144
144
  ### Column Groups
145
145
 
@@ -230,7 +230,7 @@ Typing `TableViewOptions` by `InstanceType<T>` is what makes row-action rules fi
230
230
  | `layout` | `TableViewLayout` | `"full"` (default): the dashboard grid; `"compact"`: the list of a settings page (tabs, open search, quick filters, the footer's page size picker; no caption, refresh, ⋯ menu nor column menus). Either way, search shows only with `@Searchable` fields, filters only with a `filterable` column, the caption only when `caption` is set. |
231
231
  | `searchPlaceholder` | `string` | Placeholder of the search field, also its accessible name. |
232
232
  | `searchShortcut` | `string` | Key opening and focusing the search, in Nuxt UI `defineShortcuts` notation (`"/"`, `"shift_f"`): `"shift_f"` (⇧F) by default. It does not fire while the user types in a field. |
233
- | `quickFilters` | `TableViewQuickFilter[]` | `{ field, label?, icon?, allLabel?, mode? }` dropdown filters over a filterable column's values, `mode` a `TableViewQuickFilterMode`. The picked value is the column's own filter: kept with the table state, counted, cleared by "Clear filters"; in the `compact` layout the button reads "Role: Admin" with a clear button. Unknown or non-filterable columns throw. |
233
+ | `quickFilters` | `TableViewQuickFilter[]` | `{ field, label?, icon?, allLabel?, mode? }` dropdown filters over a filterable column's values, `mode` a `TableViewQuickFilterMode`. The picked value is the column's own filter: kept with the table state, counted, cleared by "Clear filters"; in the `compact` layout the button reads "Role: Admin" with a clear button. Unknown or non-filterable columns throw. The column needs values to list (a select with `items`, a boolean, a status, a relation, or an input declaring `items` or a `searchUrl`): on any other — a free-text string, say — the toolbar leaves it out and registration logs a warning. |
234
234
  | `pageSize` | `number` | Rows per page while the user picked none (default 10), 1 to 50: registration throws above. A size other than 10, 25 and 50 joins them in the table's page size picker. |
235
235
  | `pagination` | `TableViewPaginationMode` | `"pages"` (default): a pager; `"loadMore"`: a "Load more" button; `"infinite"`: the next page loads as the footer scrolls into view. The last two ask for every page loaded so far, from the first row, so a refresh re-reads them. |
236
236
  | `footer` | `TableViewFooterOptions` | `{ countLabel?, hint?, summary?, legend? }`: the footer's row count (`$` key pluralized on `{ count }`) and hint; `summary` figures (`TableViewFooterSummary`: `label`, `field?`, `op: "sum" \| "count"`, `where?` row rule, `format?` `TableViewSummaryFormat`) computed by the `Summary` route over every listed row; `legend`, a `SelectType` column. Serialized as `TableViewFooterSerialized`: each summary gets an `id`, its `where` stays server-side (`TableViewMeta.footerSummary(id)`). A sum without `field`, an unknown column or a legend that is no select column throws. |
@@ -241,8 +241,10 @@ Typing `TableViewOptions` by `InstanceType<T>` is what makes row-action rules fi
241
241
  | `rowActions` | `TableViewRowActionOptions<T>` | Built-in and custom row actions. |
242
242
  | `columns` | `TableViewColumnsOptions` | The controller's columns as this table view shows them: `hidden` lists the ones it opens hidden, as `isVisible: false` does on the `@Column`, for this table view only (they stay in the column menu, filterable and in the forms). A name the controller has no column for throws at registration. |
243
243
  | `customButtons` | `CustomButton[]` | Toolbar buttons; see [Shared Types](/docs/interfaces/base/types). |
244
+ | `forms` | `Partial<Record<"new" \| "edit" \| "view", TableViewFormSetup>>` | A callback per generated form, `(form: FormBuilder) => void`, receiving its builder before the table view embeds it: what it declares (`.watch()`, a field shown, hidden or required as another changes) holds in a drawer, a modal or a form page alike. |
244
245
  | `formContainer` | `FormContainer` | Where the `new` / `edit` / `view` forms open. |
245
246
  | `archiveMode` | `boolean` | Replace delete with archive/restore. Requires `@ArchiveField`. |
247
+ | `archivedCountLabel` | `string` | How the footer counts the archived rows: an i18n key with `$` receiving `{ count }` and pluralized on it, or a text with `{count}`. Defaults to "Archived: {count}", which agrees with no noun. |
246
248
  | `defaultFilters` | `Array<{ accessorKey: string; value?: string; mode: string }>` | Filters applied when the table opens. |
247
249
  | `defaultSort` | `{ field: string; desc?: boolean }` | Sort applied when the viewer has no stored preference, and the one a cleared sort goes back to. |
248
250
  | `pinnedRows` | `RowActionRule<T>` | Rows listed first whatever the sort, in the sort's order among themselves: within the list the route answers (a source without `paginate` answers every row, so its pinned rows open the first page; a paging route's open their page). The `grouped` display pins nothing; registration throws beside `reorder`. |
@@ -252,14 +254,15 @@ Typing `TableViewOptions` by `InstanceType<T>` is what makes row-action rules fi
252
254
  | `tabs` | `TableViewTab[]` | Tabs above the table, each applying a hidden filter on one column (`filter` optional). A tab may link to another page instead (`to`: page controller or path, `countFrom`: data controller or location for its counter); a tab with id `"all"` replaces the implicit one; `permission` (an action of the table) or `permissionId` (an absolute id) gates it; `navBadge` (`true`, or `{ tone }`) publishes its counter as the page's nav badge. Serialized as `TableViewTabSerialized` (`to` resolved per request, `toPage`). |
253
255
  | `realtime` | `boolean` | Set to `false` to disable realtime broadcast and presence for this resource. Realtime is on by default. |
254
256
  | `realtimeTopic` | `string \| string[]` | Topics the backend publishes on when rows change outside the table's routes: the table reads its rows again on each event. Registered against the page from `onCreated`; also offered by `TableView.fromSource`. |
255
- | `liveHold` | `boolean \| TableViewLiveHoldOptions` | On a page declared with `live`, keeps the rows while the page is paused and shows a "N new · Show" strip; Show reads them once without leaving the pause. `{ whileRowOpen?, whileScrolled?, whileSearching?, whileSorted? }` (all default `false`) also holds the table alone, the page staying live, while a row is open in the side panel, its first row is scrolled out of view, the user's search narrows the rows or they are sorted otherwise than by `defaultSort`; the strip then names the reason ("Table on hold · search active"). `missedLabel` words the strip's count, given `{count}` ("12 new requests"); while the page is paused the strip adds the page's `setMissedDetail` breakdown. A `TableView.fromSource` route receives `liveHeldAt` (ISO 8601) on every request made while held. Also offered by `TableView.fromSource`. |
257
+ | `liveHold` | `boolean \| TableViewLiveHoldOptions` | On a page declared with `live`, keeps the rows while the page is paused, and in Direct while they are on screen or scrolled past (a new row at the top would push them down), and shows a "N new · Show" pill over the rows, which moves nothing, counting the rows its tab, search and filters match now beyond those it holds (asked of its count route), not the events; Show reads them once without leaving the pause. `{ whileRowOpen?, whileScrolled?, whileSearching?, whileSorted? }` (all default `false`) also holds the table alone, the page staying live, while a row is open in the side panel, its first row is scrolled out of view, the user's search narrows the rows or they are sorted otherwise than by `defaultSort`; the strip then names the reason ("Table on hold · search active"). `missedLabel` words the strip's count, given `{count}` ("12 new requests"); while the page is paused the strip adds the page's `setMissedDetail` breakdown. A `TableView.fromSource` route receives `liveHeldAt` (ISO 8601) on every request made while held. Also offered by `TableView.fromSource`. |
256
258
  | `reorder` | `TableViewReorderOptions` | `{ field }`: rows ordered by hand with a drag handle, on the grid and on the `cards` display. `field` must be a `@Sortable()`, writable `NumberType` column, and the edit action on: registration throws otherwise. The rows are listed sorted on it, and a move saves only the rows whose value changed. |
257
259
  | `kanban` | `KanbanOptions` | Enable the kanban display. |
258
260
  | `grouped` | `GroupedOptions` | `{ groupByField, by?: "value" \| "day" \| "week", collapsible?, count? }`: offer the `grouped` display, the grid's columns under a header row per group. `groupByField` must be a `@Sortable()` column, a `DateType` one to group by day or week. |
259
261
  | `views` | `TableViewViewsOptions` | `{ items: TableViewView[], layout?: "tabs" \| "strip" \| "menu", defaultView?, userViews? }`. A `TableViewView` is a `TableViewViewState` (`filters`, `search`, `sort`, `columns: { visible?, hidden?, order? }`, `display`, `density`) with `id`, `label`, `icon`, `tone`, `dot`, `count`, `permission` (an action of the table by name, or an `Action`) and `permissionId` (an absolute permission id, which wins); a view the caller fails it for is left out when the layout is served. Filter values may hold `{{user.id}}`, `{{now}}` and `{{now±Nd}}`, resolved by the server. Serialized as `TableViewViewsSerialized` (no `permission` nor `permissionId`). |
260
- | `card` | `TableViewCardOptions` | The card the `kanban` and `cards` displays draw: `fields` it shows, or a `component` drawing it whole. |
262
+ | `card` | `TableViewCardOptions` | The card the `kanban` and `cards` displays draw: `fields` it shows, or a `component` drawing it whole; `minWidth` (a CSS length, `"230px"` by default) is the narrowest a card of the `cards` grid gets. |
261
263
  | `displays` | `TableViewDisplayOption[]` | The displays offered, in order: a built-in id (`"table"`, `"cards"`) without a component, or a module display named `<module>:<id>`; registration throws otherwise. Left out, the built-in `table` alone; listed, exactly these, so the grid is kept by listing `{ id: "table" }`. |
262
264
  | `defaultDisplay` | `string` | Display shown when the viewer has no stored preference. Defaults to `"table"`. |
265
+ | `mobileDisplay` | `string` | Display a phone (narrower than 768px) opens on when the viewer has no stored preference, e.g. `"cards"`. Registration throws when it is not among the displays offered. |
263
266
  | `guards` | `TableViewGuards<T>` | Server-side guards run before mutating actions. |
264
267
  | `bypassTenantAccessGate` | `boolean` | Skip the tenant access gate on every data route of this table view. |
265
268
 
@@ -269,7 +272,7 @@ Typing `TableViewOptions` by `InstanceType<T>` is what makes row-action rules fi
269
272
  TableView.fromSource(options: TableViewSourceOptions): ComponentBuilder<TableViewOptionsSerialized>;
270
273
  ```
271
274
 
272
- A read-only table view with no data controller (`tableViewFromSource`). `TableViewSourceOptions` takes `columns` (a record of `TableViewSourceColumn`, a `@Column`'s options plus `sortable`), `fetchUrl` (its `{{params.X}}` / `{{query.X}}` tokens are filled from the page URL, percent-encoded; the table and its tab counters list again when they change and request nothing while one has no value), `fetchRowUrl?` (a route answering one row by id, `{{row.id}}` its percent-encoded id, with the same tokens: a deep link to a row the table does not list opens through it; serialized on `TableViewSourceSerialized`), `rowIdKey?`, `periodScope?` (the id of a `PeriodSelector`: every request carries its `from`, `to`, `preset` and `comparison`, the table lists again when the period changes, and nothing is requested until the selector is on the page), `capabilities?` (`TableViewSourceCapabilities`: `search`, `sort`, `paginate`, `filter`), `queryParamFilters?` (URL query parameters sent to the route as hidden `filter_<field>` filters), custom row actions and buttons only, and the presentation options of a table view (`caption`, `layout`, `pagination`, `tabs`, `quickFilters`, `displays`, `card`, `emptyStates`, `footer` texts and legend…). The route answers `{ results, total }` and is sent the part of the list query its capabilities name, nothing else; the browser does the rest over the rows it answered, except that a paging route's rows are neither searched nor sorted by the browser. Registration runs the checks of `TableView()` that apply (page size, display ids, tab targets) and throws when tabs, quick filters or `queryParamFilters` lack `capabilities.filter`, or when a key they, `labelKey`, `card.fields` or `expandable` name is not a column, or when `expandable` sets `lazyLoad` (a source has no `get` route), or when `fetchRowUrl` lacks `{{row.id}}`. There is no export, realtime nor footer summary.
275
+ A read-only table view with no data controller (`tableViewFromSource`). `TableViewSourceOptions` takes `columns` (a record of `TableViewSourceColumn`, a `@Column`'s options plus `sortable`), `fetchUrl` (its `{{params.X}}` / `{{query.X}}` tokens are filled from the page URL, percent-encoded; the table and its tab counters list again when they change and request nothing while one has no value), `fetchRowUrl?` (a route answering one row by id, `{{row.id}}` its percent-encoded id, with the same tokens: a deep link to a row the table does not list opens through it; serialized on `TableViewSourceSerialized`), `rowIdKey?`, `periodScope?` (the id of a `PeriodSelector`: every request carries its `from`, `to`, `preset` and `comparison`, the table lists again when the period changes, and nothing is requested until the selector is on the page), `capabilities?` (`TableViewSourceCapabilities`: `search`, `sort`, `paginate`, `filter`), `queryParamFilters?` (URL query parameters sent to the route as hidden `filter_<field>` filters), custom row actions and buttons only, and the presentation options of a table view (`caption`, `layout`, `pagination`, `tabs`, `quickFilters`, `displays`, `card`, `emptyStates`, `footer` texts and legend…). The route answers `{ results, total }` and is sent the part of the list query its capabilities name, nothing else; the browser does the rest over the rows it answered, except that a paging route's rows are neither searched nor sorted by the browser. Registration runs the checks of `TableView()` that apply (page size, display ids, tab targets) and throws when tabs, quick filters or `queryParamFilters` lack `capabilities.filter`, or when a key they, `labelKey`, `card.fields` or `expandable` name is not a column, or when `expandable` sets `lazyLoad` (a source has no `get` route), or when `fetchRowUrl` lacks `{{row.id}}`; it warns about a quick filter on a column with no values to pick. There is no export, realtime nor footer summary.
273
276
 
274
277
  ### Reaching a Table Behind a Tenant Gate
275
278
 
@@ -358,7 +361,7 @@ A `new` or `edit` `ResourceForm` block over the controller submits to the same r
358
361
 
359
362
  `RowActionConfig` (built-in actions) also takes `label`, `icon` and `confirm` (an `ActionConfirm`: a `ConfirmDialog`, or `{ from }`, a URL answering one for the row — on delete, archive and restore, for a single row); `BulkRowActionConfig` (delete, archive, restore) adds `successMessage`. `CustomRowAction` also takes `confirm`, `permission` (`ButtonPermission`: an action of the table by name, or an `Action`), `permissionId` (an absolute permission id, which wins over `permission`), `color`, `variant`, `showLabel`, `bulk` (`true` or `{ allMatching: true }`: offered on the selection bar, see below) and `deepLink` (a drawer, modal or side panel it opens is kept in the URL as `?record=<id>` — by default for a side panel, which `false` opts out of —, `?<tableId>.record=<id>` on a page with several table views). An action the caller fails the permission for is stripped per request like a custom button; it is only hidden, so the route its target calls must check the permission itself.
360
363
 
361
- A bulk custom action sends the selected ids as a repeated `ids` query parameter, or `allMatching=true` with the table's filters, search and archive view after "Select all N matching". `resolveBulkRowIds(this, ctx, user)`, called from a route of the table's data controller, answers the ids the action covers: the selected ones, or every matching row read like the list reads them, refusing more than `MAX_BULK_MATCHING_ROWS` (413). Either way the caller needs the table's `list` permission (403 otherwise). `BULK_ALL_MATCHING_KEY` is the query flag. The `api` action target also takes `body` (the confirmation's field values merged in) and `copy` (response field copied to the clipboard). `CustomButton` takes `placement` (`"toolbar"`, the default, or `"header"`: drawn in the page header, which the server adds it to); `rowActions.add` takes it too (`AddRowActionConfig`).
364
+ A bulk custom action sends the selected ids as a repeated `ids` query parameter, or `allMatching=true` with the table's filters, search and archive view after "Select all N matching". `resolveBulkRowIds(this, ctx, user)`, called from a route of the table's data controller — or `resolveBulkRowIds({ model, tableView }, ctx, user)` (`BulkRowSource`: the model the calling route is given, and the table's `TableViewMeta`) from any other route, a page's included — answers the ids the action covers: the selected ones, or every matching row read like the list reads them, refusing more than `MAX_BULK_MATCHING_ROWS` (413). Either way the caller needs the table's `list` permission (403 otherwise). `BULK_ALL_MATCHING_KEY` is the query flag. The `api` action target also takes `body` (the confirmation's field values merged in) and `copy` (response field copied to the clipboard). `CustomButton` takes `placement` (`"toolbar"`, the default, or `"header"`: drawn in the page header, which the server adds it to); `rowActions.add` takes it too (`AddRowActionConfig`).
362
365
 
363
366
  When several table views share a data controller, each read action of its routes is guarded by the permission ids of every mounted table view, each write action by the writing table view's (`TableViewMeta.actionPermissionIds(action)`; any one suffices). `TableViewMeta.componentBuilders` lists them, `writingComponentBuilders` the ones that write.
364
367
 
@@ -442,7 +445,7 @@ static table = TableView(invoiceDataAPI, {
442
445
 
443
446
  `KanbanOptionsSerialized` is `KanbanOptions` plus the serialized `card` (`TableViewCardOptionsSerialized`).
444
447
 
445
- `card` (`TableViewCardOptions`: `{ fields?: string[]; component?: Component }`) is the card the `kanban` and `cards` displays draw for each row; it joins the options of both display entries. The component receives the same props in both: `row`, `rowId`, `columns`, `labelKey`, `actions`, `selected`, `select(value?)`, `open()`, on the kanban `groupValue`, and on the `cards` display of a table ordered by hand `reorder` (`{ enabled, dragging, handle }`, the card's move handle).
448
+ `card` (`TableViewCardOptions`: `{ fields?: string[]; component?: Component; minWidth?: string }`) is the card the `kanban` and `cards` displays draw for each row; it joins the options of both display entries. The component receives the same props in both: `row`, `rowId`, `columns`, `labelKey`, `actions`, `selected`, `select(value?)`, `open()`, on the kanban `groupValue`, and on the `cards` display of a table ordered by hand `reorder` (`{ enabled, dragging, handle }`, the card's move handle).
446
449
 
447
450
  ## Generated Forms
448
451
 
@@ -516,7 +519,9 @@ tableViewRowTopic("/api/orders"); // "tableview:row:/api/orders"
516
519
  tableViewPresenceTopic("/api/orders"); // "tableview:presence:/api/orders"
517
520
  ```
518
521
 
519
- The topics are registered with `RegisterPageTopic` from the table view's `onCreated`, so they belong to the module whose page it is, as described in [Realtime](/docs/interfaces/core/realtime). A module that publishes on a table's row topic itself — a background job updating rows, for instance — builds the name with `tableViewRowTopic` rather than spelling out the prefix.
522
+ The topics are registered with `RegisterPageTopic` from the table view's `onCreated`, so they belong to the module whose page it is, as described in [Realtime](/docs/interfaces/core/realtime). A module that publishes on a table's row topic itself — a background job updating rows, for instance — builds the name with `tableViewRowTopic` rather than spelling out the prefix, and passes the tenant of the rows as `tenantId`.
523
+
524
+ The topics are the same for every tenant, but what is published on them is not: the routes publish each change, and each presence, for the tenant the request was made in, so the sessions of the other tenants neither refetch nor see who has a row open. The DMS decides who else receives them, with no option on the table: a table whose rows live in the schema every tenant shares (`CORE_SCHEMA_NAME`) publishes to every tenant, and a session on a platform page (a module's page) receives the changes of every tenant, as such a page lists the data of all of them. `RealtimeMutationContext` and `RealtimePresenceContext` carry the tenant as `tenantId`, absent on a table of the shared schema.
520
525
 
521
526
  Two functions let other modules observe those mutations:
522
527