@antelopejs/dms 0.7.20 → 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 (172) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/implementations/dms-auth/account-emails.d.ts +4 -0
  3. package/dist/implementations/dms-auth/account-emails.js +62 -0
  4. package/dist/implementations/dms-auth/account-emails.js.map +1 -0
  5. package/dist/implementations/dms-auth/index.d.ts +1 -3
  6. package/dist/implementations/dms-auth/index.js +15 -61
  7. package/dist/implementations/dms-auth/index.js.map +1 -1
  8. package/dist/pages/settings/users/email-change.js +3 -4
  9. package/dist/pages/settings/users/email-change.js.map +1 -1
  10. package/dist/pages/settings/users/invites.js +1 -1
  11. package/dist/pages/settings/users/invites.js.map +1 -1
  12. package/dist/pages/settings/users/members.js +1 -1
  13. package/dist/pages/settings/users/members.js.map +1 -1
  14. package/dist/pages/settings/users/notifications.js +1 -0
  15. package/dist/pages/settings/users/notifications.js.map +1 -1
  16. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.d.ts +1 -0
  17. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js +34 -0
  18. package/dist/test/unit/implementations/dms-html-render/built-in-emails.test.js.map +1 -0
  19. package/dist/test/unit/interfaces/dms/hook-handler-results.test.d.ts +1 -0
  20. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js +49 -0
  21. package/dist/test/unit/interfaces/dms/hook-handler-results.test.js.map +1 -0
  22. package/dist/test/unit/interfaces/dms/page-topics.test.js +17 -0
  23. package/dist/test/unit/interfaces/dms/page-topics.test.js.map +1 -1
  24. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js +2 -2
  25. package/dist/test/unit/interfaces/dms-base/confirm-dialog-secondary-action.test.js.map +1 -1
  26. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.d.ts +1 -0
  27. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js +29 -0
  28. package/dist/test/unit/interfaces/dms-base/query-response-delta.test.js.map +1 -0
  29. package/dist/test/unit/interfaces/dms-base/resource-form.test.js +5 -0
  30. package/dist/test/unit/interfaces/dms-base/resource-form.test.js.map +1 -1
  31. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js +12 -2
  32. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js.map +1 -1
  33. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js +29 -4
  34. package/dist/test/unit/interfaces/dms-base/table-view-reduced-chrome.test.js.map +1 -1
  35. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.d.ts +1 -0
  36. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js +27 -0
  37. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js.map +1 -0
  38. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +18 -4
  39. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  40. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js +8 -5
  41. package/dist/test/unit/interfaces/dms-base/table-view-views.test.js.map +1 -1
  42. package/dist/test/unit/utils/email-subjects.test.d.ts +1 -0
  43. package/dist/test/unit/utils/email-subjects.test.js +27 -0
  44. package/dist/test/unit/utils/email-subjects.test.js.map +1 -0
  45. package/dist/utils/admin-invite-email.js +2 -4
  46. package/dist/utils/admin-invite-email.js.map +1 -1
  47. package/dist/utils/email-change-mail.d.ts +1 -1
  48. package/dist/utils/email-change-mail.js +11 -6
  49. package/dist/utils/email-change-mail.js.map +1 -1
  50. package/dist/utils/email-subjects.d.ts +3 -0
  51. package/dist/utils/email-subjects.js +32 -0
  52. package/dist/utils/email-subjects.js.map +1 -0
  53. package/dist/utils/export-jobs.js +15 -10
  54. package/dist/utils/export-jobs.js.map +1 -1
  55. package/frontend-vue/layers/dms-core/app/utils/url-interpolation.ts +29 -11
  56. package/frontend-vue/layers/dms-core/shared/types/app-config.ts +17 -0
  57. package/frontend-vue/layers/dms-layout/app/app.config.ts +5 -0
  58. package/frontend-vue/layers/dms-layout/app/build/components/layout/DashboardHeader.vue +18 -2
  59. package/frontend-vue/layers/dms-layout/app/build/components/layout/Footer.vue +16 -1
  60. package/frontend-vue/layers/dms-layout/app/build/composables/layout/useHeaderActionControls.ts +14 -0
  61. package/frontend-vue/layers/dms-layout/app/components/layout/HStack.vue +16 -0
  62. package/frontend-vue/layers/dms-layout/app/components/layout/VStack.vue +2 -0
  63. package/frontend-vue/layers/dms-layout/app/composables/useFooterLinks.ts +11 -1
  64. package/frontend-vue/layers/dms-layout/app/custom-pages/settings/roles.vue +1 -1
  65. package/frontend-vue/layers/dms-layout/app/emails/EmailAdminInvite.vue +1 -1
  66. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeNotice.vue +13 -7
  67. package/frontend-vue/layers/dms-layout/app/emails/EmailChangeVerification.vue +18 -7
  68. package/frontend-vue/layers/dms-layout/app/emails/EmailExportReady.vue +23 -12
  69. package/frontend-vue/layers/dms-layout/app/emails/EmailResetPassword.vue +18 -7
  70. package/frontend-vue/layers/dms-layout/app/emails/EmailTwoFactor.vue +23 -4
  71. package/frontend-vue/layers/dms-layout/app/emails/EmailUserValidation.vue +18 -6
  72. package/frontend-vue/layers/dms-layout/app/emails/components/EmailOTP.vue +8 -2
  73. package/frontend-vue/layers/dms-layout/i18n/locales/layout-en-GB.json +40 -0
  74. package/frontend-vue/layers/dms-layout/i18n/locales/layout-fr-FR.json +40 -0
  75. package/frontend-vue/layers/dms-ui/app/build/components/form/FormFieldControl.vue +2 -1
  76. package/frontend-vue/layers/dms-ui/app/build/components/form/FormLoadError.vue +11 -3
  77. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +13 -3
  78. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +1 -1
  79. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +14 -0
  80. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +90 -43
  81. package/frontend-vue/layers/dms-ui/app/build/composables/chart/apexTheme.ts +59 -2
  82. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/confirmDialogTexts.ts +27 -4
  83. package/frontend-vue/layers/dms-ui/app/build/composables/confirm/useActionConfirm.ts +10 -2
  84. package/frontend-vue/layers/dms-ui/app/build/composables/data-types/registerDefaults.ts +0 -5
  85. package/frontend-vue/layers/dms-ui/app/build/composables/form/formEntryOrder.ts +23 -0
  86. package/frontend-vue/layers/dms-ui/app/build/composables/form/formFooter.ts +19 -15
  87. package/frontend-vue/layers/dms-ui/app/build/composables/form/formRecordLoad.ts +26 -2
  88. package/frontend-vue/layers/dms-ui/app/build/composables/page/pageUrlValues.ts +41 -0
  89. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +2 -2
  90. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRows.ts +3 -5
  91. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +42 -12
  92. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +7 -11
  93. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -3
  94. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +10 -11
  95. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/views.ts +29 -0
  96. package/frontend-vue/layers/dms-ui/app/build/utils/link.ts +7 -3
  97. package/frontend-vue/layers/dms-ui/app/build/utils/urlVariables.ts +32 -9
  98. package/frontend-vue/layers/dms-ui/app/components/activity-feed/ActivityFeed.vue +8 -1
  99. package/frontend-vue/layers/dms-ui/app/components/banner/Banner.vue +9 -4
  100. package/frontend-vue/layers/dms-ui/app/components/blocks/BannerBlock.vue +5 -11
  101. package/frontend-vue/layers/dms-ui/app/components/blocks/NavCardGridBlock.vue +2 -1
  102. package/frontend-vue/layers/dms-ui/app/components/chart/ApexChartHost.vue +32 -1
  103. package/frontend-vue/layers/dms-ui/app/components/check-list/CheckList.vue +1 -1
  104. package/frontend-vue/layers/dms-ui/app/components/form/Form.vue +12 -2
  105. package/frontend-vue/layers/dms-ui/app/components/form/components/Cascader.vue +7 -3
  106. package/frontend-vue/layers/dms-ui/app/components/form/components/File.vue +7 -0
  107. package/frontend-vue/layers/dms-ui/app/components/form/components/InputAddress.vue +4 -1
  108. package/frontend-vue/layers/dms-ui/app/components/form/components/InputCode.vue +4 -2
  109. package/frontend-vue/layers/dms-ui/app/components/form/components/InputSecret.vue +3 -2
  110. package/frontend-vue/layers/dms-ui/app/components/form/components/Relation.vue +4 -1
  111. package/frontend-vue/layers/dms-ui/app/components/grid/columns.ts +15 -3
  112. package/frontend-vue/layers/dms-ui/app/components/key-value-list/KeyValueList.vue +2 -1
  113. package/frontend-vue/layers/dms-ui/app/components/list-row/ListRow.vue +9 -2
  114. package/frontend-vue/layers/dms-ui/app/components/save-bar/InstantSaveBadge.vue +13 -5
  115. package/frontend-vue/layers/dms-ui/app/components/save-bar/SaveBar.vue +1 -1
  116. package/frontend-vue/layers/dms-ui/app/components/stat-group/StatGroup.vue +3 -2
  117. package/frontend-vue/layers/dms-ui/app/components/table-view/CascaderPath.vue +53 -31
  118. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +14 -6
  119. package/frontend-vue/layers/dms-ui/app/composables/chart/apexPlotOptions.ts +15 -4
  120. package/frontend-vue/layers/dms-ui/app/composables/chart/apexYRange.ts +123 -0
  121. package/frontend-vue/layers/dms-ui/app/composables/chart/useApexChart.ts +49 -49
  122. package/frontend-vue/layers/dms-ui/app/composables/chart/useChartTheme.ts +20 -1
  123. package/frontend-vue/layers/dms-ui/app/composables/confirm/types.ts +1 -1
  124. package/frontend-vue/layers/dms-ui/app/composables/form/types/props.ts +2 -0
  125. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +8 -6
  126. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/action-target.ts +1 -1
  127. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +3 -3
  128. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +6 -0
  129. package/frontend-vue/layers/dms-ui/app/utils/bannerToneIcons.ts +13 -0
  130. package/frontend-vue/layers/dms-ui/app/utils/cascader.ts +26 -0
  131. package/frontend-vue/layers/dms-ui/i18n/locales/ui-en-GB.json +4 -0
  132. package/frontend-vue/layers/dms-ui/i18n/locales/ui-fr-FR.json +4 -0
  133. package/frontend-vue/tests/activity-feed-fill-height.test.ts +7 -0
  134. package/frontend-vue/tests/banner-tone-icon.test.ts +55 -0
  135. package/frontend-vue/tests/builtin-emails.test.ts +141 -0
  136. package/frontend-vue/tests/cascader.test.ts +27 -0
  137. package/frontend-vue/tests/chart-auto-y-range.test.ts +25 -9
  138. package/frontend-vue/tests/chart-colors.test.ts +8 -0
  139. package/frontend-vue/tests/chart-resize-after-animation.test.ts +100 -0
  140. package/frontend-vue/tests/chart-type-defaults.test.ts +128 -0
  141. package/frontend-vue/tests/check-list-marker.test.ts +58 -0
  142. package/frontend-vue/tests/confirm-from.test.ts +1 -1
  143. package/frontend-vue/tests/confirm-secondary-action.test.ts +2 -2
  144. package/frontend-vue/tests/context-links.test.ts +2 -2
  145. package/frontend-vue/tests/custom-action-confirm-from.test.ts +2 -2
  146. package/frontend-vue/tests/download-action-target.test.ts +2 -2
  147. package/frontend-vue/tests/file-drop-prompt.test.ts +72 -0
  148. package/frontend-vue/tests/footer-config.test.ts +135 -0
  149. package/frontend-vue/tests/form-actions.test.ts +7 -0
  150. package/frontend-vue/tests/form-entry-order.test.ts +45 -0
  151. package/frontend-vue/tests/form-footer-kind.test.ts +11 -6
  152. package/frontend-vue/tests/form-load-error.test.ts +38 -1
  153. package/frontend-vue/tests/grid-responsive-columns.test.ts +9 -0
  154. package/frontend-vue/tests/header-actions.test.ts +55 -9
  155. package/frontend-vue/tests/instant-save-badge.test.ts +11 -4
  156. package/frontend-vue/tests/list-row-truncate.test.ts +46 -0
  157. package/frontend-vue/tests/roles-name-errors.test.ts +2 -3
  158. package/frontend-vue/tests/row-side-panel.test.ts +93 -2
  159. package/frontend-vue/tests/save-bar.test.ts +15 -3
  160. package/frontend-vue/tests/save-status-error.test.ts +2 -2
  161. package/frontend-vue/tests/table-row-action-errors.test.ts +5 -5
  162. package/frontend-vue/tests/table-row-hover.test.ts +2 -2
  163. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +114 -0
  164. package/frontend-vue/tests/table-view-cards-reread.test.ts +77 -0
  165. package/frontend-vue/tests/table-view-sources.test.ts +1 -1
  166. package/frontend-vue/tests/table-view-url-tab.test.ts +84 -2
  167. package/frontend-vue/tests/template-tokens.test.ts +235 -0
  168. package/frontend-vue/tests/url-interpolation.test.ts +6 -6
  169. package/package.json +2 -2
  170. package/skills/dms/SKILL.md +2 -1
  171. package/skills/dms-pages/REFERENCE.md +2 -2
  172. package/skills/dms-project/SKILL.md +0 -1
@@ -313,6 +313,120 @@ describe("action targets on a selection and on a listed row", () => {
313
313
  expect(drawer!.patches).toHaveLength(1);
314
314
  });
315
315
 
316
+ it("shows another row in a row's drawer by id, read when unlisted", async () => {
317
+ window.history.replaceState(null, "", "/runs");
318
+ const rows = [{ _id: "r1" }, { _id: "r2" }];
319
+ const older = { _id: "r9" };
320
+ const getRow = vi.fn(async (id: string) =>
321
+ id === "r9" ? older : undefined,
322
+ );
323
+ const targets = mountTargets({
324
+ rowNavigation: { rows: () => rows, rowIdKey: "_id", getRow },
325
+ recordScope: { tableId: "runs", isSoleTableView: true },
326
+ });
327
+ targets.handleCustomRowAction(
328
+ {
329
+ label: "Details",
330
+ deepLink: true,
331
+ target: { type: "drawer", component: { componentName: "RunDrawer" } },
332
+ },
333
+ rows[0],
334
+ );
335
+ await vi.waitFor(() => expect(drawers).toHaveLength(1));
336
+ const [drawer] = drawers;
337
+ const options = drawer!.options.componentOptions as Record<string, unknown>;
338
+ const showRow = options.showRow as (row: object | string) => Promise<void>;
339
+
340
+ await showRow("r9");
341
+ expect(drawer!.patches[0]).toMatchObject({ componentKey: "r9" });
342
+ const patched = drawer!.patches[0]!.componentOptions as Record<
343
+ string,
344
+ unknown
345
+ >;
346
+ expect(patched.rowData).toBe(older);
347
+ expect(patched.navigation).toBeUndefined();
348
+ expect(window.location.search).toBe("?record=r9");
349
+
350
+ await showRow("r2");
351
+ expect(getRow).toHaveBeenCalledTimes(1);
352
+ expect(drawer!.patches[1]).toMatchObject({ componentKey: "r2" });
353
+ expect(window.location.search).toBe("?record=r2");
354
+
355
+ await expect(showRow("r404")).rejects.toThrow("r404");
356
+ expect(drawer!.patches).toHaveLength(2);
357
+ drawer!.settle();
358
+ });
359
+
360
+ const deferredRows = () => {
361
+ const pending = new Map<string, (row: object) => void>();
362
+ const getRow = vi.fn(
363
+ (id: string) =>
364
+ new Promise<object | undefined>((resolve) => pending.set(id, resolve)),
365
+ );
366
+ return { getRow, resolve: (id: string) => pending.get(id)!({ _id: id }) };
367
+ };
368
+
369
+ const openDeepLinkedDrawer = (getRow: (id: string) => Promise<unknown>) => {
370
+ window.history.replaceState(null, "", "/runs");
371
+ const rows = [{ _id: "r1" }];
372
+ const targets = mountTargets({
373
+ rowNavigation: { rows: () => rows, rowIdKey: "_id", getRow },
374
+ recordScope: { tableId: "runs", isSoleTableView: true },
375
+ onRecordShown: vi.fn(),
376
+ });
377
+ targets.handleCustomRowAction(
378
+ {
379
+ label: "Details",
380
+ deepLink: true,
381
+ target: { type: "drawer", component: { componentName: "RunDrawer" } },
382
+ },
383
+ rows[0],
384
+ );
385
+ return targets;
386
+ };
387
+
388
+ const showRowOf = (drawer: (typeof drawers)[number]) =>
389
+ (drawer.options.componentOptions as Record<string, unknown>).showRow as (
390
+ row: object | string,
391
+ ) => Promise<void>;
392
+
393
+ it("drops a row read that resolves after its drawer closed", async () => {
394
+ const rows = deferredRows();
395
+ openDeepLinkedDrawer(rows.getRow);
396
+ await vi.waitFor(() => expect(drawers).toHaveLength(1));
397
+ const [drawer] = drawers;
398
+ const shown = showRowOf(drawer!)("r9");
399
+
400
+ drawer!.settle();
401
+ await vi.waitFor(() => expect(window.location.search).toBe(""));
402
+ rows.resolve("r9");
403
+ await shown;
404
+
405
+ expect(window.location.search).toBe("");
406
+ expect(drawer!.patches).toHaveLength(0);
407
+ expect(drawers).toHaveLength(1);
408
+ });
409
+
410
+ it("applies only the latest of two rows read at once", async () => {
411
+ const rows = deferredRows();
412
+ openDeepLinkedDrawer(rows.getRow);
413
+ await vi.waitFor(() => expect(drawers).toHaveLength(1));
414
+ const [drawer] = drawers;
415
+ const showRow = showRowOf(drawer!);
416
+ const first = showRow("r8");
417
+ const second = showRow("r9");
418
+
419
+ rows.resolve("r9");
420
+ await second;
421
+ rows.resolve("r8");
422
+ await first;
423
+
424
+ expect(drawer!.patches).toHaveLength(1);
425
+ expect(drawer!.patches[0]).toMatchObject({ componentKey: "r9" });
426
+ expect(window.location.search).toBe("?record=r9");
427
+ drawer!.settle();
428
+ });
429
+
316
430
  it("hands a bulk drawer the selection instead of a row", async () => {
317
431
  const targets = mountTargets();
318
432
  targets.handleBulkCustomAction(
@@ -0,0 +1,77 @@
1
+ // @vitest-environment jsdom
2
+ import { computed, createApp, h, reactive, ref, type App } from "vue";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import CardsDisplay from "../layers/dms-ui/app/build/components/table-view/CardsDisplay.vue";
5
+ import type { TableViewDisplayContext } from "../layers/dms-ui/app/composables/table-view/types/display";
6
+
7
+ vi.mock("../layers/dms-ui/app/build/components/table/Pagination.vue", () => ({
8
+ default: { render: () => null },
9
+ }));
10
+ vi.mock("../layers/dms-ui/app/build/components/table/Empty.vue", () => ({
11
+ default: { render: () => h("p", { "data-test": "empty" }) },
12
+ }));
13
+ vi.mock(
14
+ "../layers/dms-ui/app/build/components/table-view/CardGridSkeleton.vue",
15
+ () => ({ default: { render: () => h("p", { "data-test": "skeleton" }) } }),
16
+ );
17
+
18
+ interface ReadState {
19
+ loading: boolean;
20
+ firstPageLoading: boolean;
21
+ }
22
+
23
+ function createContext(state: ReadState) {
24
+ return reactive({
25
+ items: [],
26
+ columns: [],
27
+ ...state,
28
+ rowIdKey: "_id",
29
+ labelKey: "name",
30
+ options: {},
31
+ pagination: { pageIndex: 0, pageSize: 10, total: 0 },
32
+ selection: { isSelected: () => false, toggle: () => {} },
33
+ actions: { canAdd: false, open: () => {} },
34
+ }) as unknown as TableViewDisplayContext;
35
+ }
36
+
37
+ describe("the cards display of an empty list", () => {
38
+ let app: App | undefined;
39
+ let container: HTMLElement;
40
+
41
+ const mount = (context: TableViewDisplayContext) => {
42
+ app = createApp({ render: () => h(CardsDisplay, { context }) });
43
+ container = document.createElement("div");
44
+ document.body.append(container);
45
+ app.mount(container);
46
+ };
47
+ const shown = (test: string) =>
48
+ container.querySelector(`[data-test="${test}"]`) !== null;
49
+
50
+ beforeEach(() => {
51
+ vi.stubGlobal("computed", computed);
52
+ vi.stubGlobal("h", h);
53
+ vi.stubGlobal("useI18n", () => ({ locale: ref("en"), t: (t: string) => t }));
54
+ vi.stubGlobal("useTranslation", () => ({ processI18n: (t: string) => t }));
55
+ vi.stubGlobal("useDataTypes", () => ({ getDataType: () => undefined }));
56
+ vi.stubGlobal("resolveDmsComponent", () => undefined);
57
+ });
58
+
59
+ afterEach(() => {
60
+ app?.unmount();
61
+ app = undefined;
62
+ container.remove();
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ it("draws the skeleton until the first answer arrives", () => {
67
+ mount(createContext({ loading: true, firstPageLoading: true }));
68
+ expect(shown("skeleton")).toBe(true);
69
+ expect(shown("empty")).toBe(false);
70
+ });
71
+
72
+ it("keeps the empty state on screen while the list is read again", () => {
73
+ mount(createContext({ loading: true, firstPageLoading: false }));
74
+ expect(shown("empty")).toBe(true);
75
+ expect(shown("skeleton")).toBe(false);
76
+ });
77
+ });
@@ -182,7 +182,7 @@ describe("source tables", () => {
182
182
 
183
183
  describe("reading one row (fetchRowUrl)", () => {
184
184
  const FETCH_URL = "/api/ws/{{params.ws}}/requests";
185
- const ROW_URL = "/api/ws/{{params.ws}}/requests/{id}";
185
+ const ROW_URL = "/api/ws/{{params.ws}}/requests/{{row.id}}";
186
186
 
187
187
  function rowsWith(
188
188
  api: (url: string) => Promise<unknown>,
@@ -3,18 +3,32 @@
3
3
  * A table view opens the tab its URL names (`?<tableId>.tab=`, or `?tab=` on
4
4
  * a page with a single table view), on arrival and afterwards: a link or a
5
5
  * `router.replace` naming another tab switches to it in place, without
6
- * remounting the page.
6
+ * remounting the page. A tab the user opens is written back to the URL in
7
+ * place, under the same key.
7
8
  */
8
9
  import { readFileSync } from "node:fs";
9
10
  import { resolve } from "node:path";
10
11
  import { createApp, defineComponent, h, nextTick, reactive, ref } from "vue";
11
- import { afterEach, describe, expect, it } from "vitest";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
13
  import {
13
14
  readTableUrlTab,
14
15
  useTableUrlTab,
15
16
  } from "../layers/dms-ui/app/build/composables/table-view/useTableUrlTab";
16
17
  import type { TableUrlScope } from "../layers/dms-ui/app/build/composables/table-view/utils/views";
17
18
 
19
+ type NavigateListener = (event: { detail: { page: { url: string } } }) => void;
20
+ const navigateListeners = new Set<NavigateListener>();
21
+
22
+ vi.mock("@inertiajs/vue3", () => ({
23
+ router: {
24
+ on: (type: string, listener: NavigateListener) => {
25
+ if (type !== "navigate") return () => {};
26
+ navigateListeners.add(listener);
27
+ return () => navigateListeners.delete(listener);
28
+ },
29
+ },
30
+ }));
31
+
18
32
  const OPENABLE_TABS = new Set(["all", "active", "archived"]);
19
33
  const mounted: Array<{ unmount: () => void }> = [];
20
34
 
@@ -39,10 +53,23 @@ function mountTable(scope: TableUrlScope, query: Record<string, string>) {
39
53
  return { route, activeTabId };
40
54
  }
41
55
 
56
+ beforeEach(() => {
57
+ window.history.replaceState(null, "", "/runs");
58
+ });
59
+
42
60
  afterEach(() => {
43
61
  mounted.splice(0).forEach((app) => app.unmount());
44
62
  });
45
63
 
64
+ /** A visit of the page already shown: Inertia announces its URL. */
65
+ async function visit(url: string) {
66
+ window.history.pushState(null, "", url);
67
+ navigateListeners.forEach((listener) =>
68
+ listener({ detail: { page: { url } } }),
69
+ );
70
+ await nextTick();
71
+ }
72
+
46
73
  describe("the tab a URL names", () => {
47
74
  it("opens on arrival", () => {
48
75
  const { activeTabId } = mountTable(
@@ -102,6 +129,61 @@ describe("the tab a URL names", () => {
102
129
  });
103
130
  });
104
131
 
132
+ describe("the tab the user opens", () => {
133
+ it("is written to the short key in place on a single table view", async () => {
134
+ window.history.replaceState(null, "", "/runs?page=2");
135
+ const { activeTabId } = mountTable(
136
+ { tableId: "runs", isSoleTableView: true },
137
+ {},
138
+ );
139
+ const entries = window.history.length;
140
+ activeTabId.value = "archived";
141
+ await nextTick();
142
+ expect(window.location.search).toBe("?page=2&tab=archived");
143
+ expect(window.history.length).toBe(entries);
144
+ activeTabId.value = "all";
145
+ await nextTick();
146
+ expect(window.location.search).toBe("?page=2&tab=all");
147
+ });
148
+
149
+ it("is written to the table's own key on a page with several tables", async () => {
150
+ window.history.replaceState(null, "", "/runs?jobs.tab=late");
151
+ const { activeTabId } = mountTable({ tableId: "runs" }, {});
152
+ activeTabId.value = "active";
153
+ await nextTick();
154
+ expect(window.location.search).toBe("?jobs.tab=late&runs.tab=active");
155
+ });
156
+
157
+ it("leaves the URL alone for a tab it cannot open in place", async () => {
158
+ const { activeTabId } = mountTable({ tableId: "runs" }, {});
159
+ activeTabId.value = "docs";
160
+ await nextTick();
161
+ expect(window.location.search).toBe("");
162
+ });
163
+
164
+ it("gives way to a link back to the tab the page was opened on", async () => {
165
+ window.history.replaceState(null, "", "/runs?runs.tab=active");
166
+ const { route, activeTabId } = mountTable(
167
+ { tableId: "runs" },
168
+ { "runs.tab": "active" },
169
+ );
170
+ activeTabId.value = "archived";
171
+ await nextTick();
172
+ expect(window.location.search).toBe("?runs.tab=archived");
173
+ // The route still holds the arrival's tab: only the visit tells.
174
+ await visit("/runs?runs.tab=active");
175
+ expect(route.query["runs.tab"]).toBe("active");
176
+ expect(activeTabId.value).toBe("active");
177
+ });
178
+
179
+ it("stops following visits once the table is gone", async () => {
180
+ const { activeTabId } = mountTable({ tableId: "runs" }, {});
181
+ mounted.splice(0).forEach((app) => app.unmount());
182
+ await visit("/runs?runs.tab=archived");
183
+ expect(activeTabId.value).toBe("all");
184
+ });
185
+ });
186
+
105
187
  it("wires the URL tab into the table view", () => {
106
188
  const source = readFileSync(
107
189
  resolve(
@@ -0,0 +1,235 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Every option naming a value with a token writes it `{{<scope>.<key>}}`, and
4
+ * every URL or link option fills the page's `{{params.X}}` / `{{query.X}}`
5
+ * the same way: an `api` or `exportJob` target as a `page` one, a
6
+ * confirmation's `from`, a block's links, a form's `backTo`.
7
+ */
8
+ import { createApp, defineComponent, h, reactive, type App } from "vue";
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
10
+ import { useActionTargets } from "../layers/dms-ui/app/build/composables/actions/useActionTargets";
11
+ import {
12
+ interpolateRowText,
13
+ interpolateUrl,
14
+ } from "../layers/dms-core/app/utils/url-interpolation";
15
+ import { HttpMethod } from "../layers/dms-core/app/types/http";
16
+ import {
17
+ replaceUrlVariables,
18
+ resolveLinkVariables,
19
+ } from "../layers/dms-ui/app/build/utils/urlVariables";
20
+ import {
21
+ pageLink,
22
+ pageRequestUrl,
23
+ } from "../layers/dms-ui/app/build/composables/page/pageUrlValues";
24
+ import { buttonLinkProps } from "../layers/dms-ui/app/build/utils/link";
25
+ import { resolveConfirmDialog } from "../layers/dms-ui/app/build/composables/confirm/confirmDialogTexts";
26
+ import type { CustomButton } from "../layers/dms-ui/app/composables/table-view/types/custom-button";
27
+ import type { CustomRowAction } from "../layers/dms-ui/app/types/row-action";
28
+
29
+ const route = reactive<{ path: string; query: Record<string, unknown> }>({
30
+ path: "/modules/shop/stores/s1",
31
+ query: {},
32
+ });
33
+ const api = vi.fn();
34
+ const runJob = vi.fn();
35
+ let app: App | undefined;
36
+
37
+ function mountTargets() {
38
+ let targets: ReturnType<typeof useActionTargets> | undefined;
39
+ app = createApp(
40
+ defineComponent({
41
+ setup() {
42
+ targets = useActionTargets({
43
+ api: api as never,
44
+ pageId: "store-page",
45
+ componentId: "orders",
46
+ handleApiError: vi.fn(),
47
+ });
48
+ return () => h("div");
49
+ },
50
+ }),
51
+ );
52
+ app.mount(document.createElement("div"));
53
+ return targets!;
54
+ }
55
+
56
+ beforeEach(() => {
57
+ route.query = { store: "north", period: "7d" };
58
+ api.mockReset().mockResolvedValue(undefined);
59
+ runJob.mockReset().mockResolvedValue(undefined);
60
+ vi.stubGlobal("useToast", () => ({ add: vi.fn() }));
61
+ vi.stubGlobal("useTranslation", () => ({
62
+ processI18n: (text: string) => text,
63
+ processApiMessage: (text: string) => text,
64
+ }));
65
+ vi.stubGlobal("useI18n", () => ({ t: (key: string) => key }));
66
+ vi.stubGlobal("useModal", () => ({ open: vi.fn() }));
67
+ vi.stubGlobal("useDrawer", () => ({ open: vi.fn() }));
68
+ vi.stubGlobal("useExportJob", () => ({ runJob }));
69
+ vi.stubGlobal("useConfirm", () => ({ confirm: vi.fn(async () => true) }));
70
+ vi.stubGlobal("useAuthFetch", () => ({
71
+ $authFetch: Object.assign(vi.fn(), { raw: vi.fn() }),
72
+ }));
73
+ vi.stubGlobal("interpolateUrl", interpolateUrl);
74
+ vi.stubGlobal("HttpMethod", HttpMethod);
75
+ vi.stubGlobal("useDmsRoute", () => route);
76
+ vi.stubGlobal("useSiteLayout", () => ({
77
+ findMatchingRoute: () => ({ params: { id: "s1" } }),
78
+ }));
79
+ });
80
+
81
+ afterEach(() => {
82
+ app?.unmount();
83
+ app = undefined;
84
+ vi.restoreAllMocks();
85
+ vi.unstubAllGlobals();
86
+ });
87
+
88
+ describe("token syntax", () => {
89
+ const context = {
90
+ routeParams: { id: "s 1" },
91
+ routeQuery: { store: "north" },
92
+ response: { data: { id: "o-9" } },
93
+ };
94
+
95
+ it("allows spaces inside the braces, as the server's filter tokens do", () => {
96
+ expect(
97
+ replaceUrlVariables(
98
+ "/s/{{ params.id }}?store={{ query.store }}",
99
+ context,
100
+ ),
101
+ ).toBe("/s/s%201?store=north");
102
+ });
103
+
104
+ it("reaches into a response with a dotted path", () => {
105
+ expect(replaceUrlVariables("/orders/{{response.data.id}}", context)).toBe(
106
+ "/orders/o-9",
107
+ );
108
+ });
109
+
110
+ it("resolves a redirect's response and page tokens, dropping an empty pair", () => {
111
+ expect(
112
+ resolveLinkVariables(
113
+ "/orders/{{response.data.id}}?store={{query.store}}&tab={{query.tab}}",
114
+ context,
115
+ ),
116
+ ).toBe("/orders/o-9?store=north");
117
+ });
118
+
119
+ it("names a row field with {{row.X}}, a dotted path reaching into it", () => {
120
+ const row = { _id: "a 1", customer: { name: "Ada" } };
121
+ expect(interpolateUrl("/o/{{row._id}}/{{ row.customer.name }}", row)).toBe(
122
+ "/o/a%201/Ada",
123
+ );
124
+ expect(interpolateRowText("{{row._id}}.csv", row)).toBe("a 1.csv");
125
+ });
126
+
127
+ it("does not take a single-brace name for a row field", () => {
128
+ expect(interpolateUrl("/o/{_id}", { _id: "a" })).toBe("/o/{_id}");
129
+ });
130
+
131
+ it("leaves another scope's token whole when filling a row", () => {
132
+ expect(
133
+ interpolateUrl("/s/{{params.id}}/o/{{row._id}}", { _id: "a" }),
134
+ ).toBe("/s/{{params.id}}/o/a");
135
+ });
136
+ });
137
+
138
+ describe("page tokens in links and requested routes", () => {
139
+ it("fills a block link, leaving out a pair the page has no value for", () => {
140
+ expect(pageLink("/stores/{{params.id}}?store={{query.store}}")).toBe(
141
+ "/stores/s1?store=north",
142
+ );
143
+ expect(pageLink("/orders?tab={{query.tab}}")).toBe("/orders");
144
+ expect(buttonLinkProps("/stores/{{params.id}}/orders")).toEqual({
145
+ to: "/stores/s1/orders",
146
+ });
147
+ });
148
+
149
+ it("does not request a route while one of its tokens has no value", () => {
150
+ expect(pageRequestUrl("/api/stores/{{params.id}}/rotate")).toBe(
151
+ "/api/stores/s1/rotate",
152
+ );
153
+ expect(pageRequestUrl("/api/x?tab={{query.tab}}")).toBeUndefined();
154
+ });
155
+
156
+ it("fills a confirmation's secondary target with the page, then the row", async () => {
157
+ const runTarget = vi.fn();
158
+ const dialog = resolveConfirmDialog(
159
+ {
160
+ title: "Remove",
161
+ secondaryAction: {
162
+ label: "Download",
163
+ target: {
164
+ type: "download",
165
+ url: "/api/stores/{{params.id}}/orders/{{row._id}}",
166
+ filename: "{{query.store}}-{{row.name}}.json",
167
+ },
168
+ },
169
+ },
170
+ { _id: "o1", name: "order" },
171
+ (key) => key,
172
+ runTarget,
173
+ );
174
+ await dialog.secondaryAction!.run();
175
+ expect(runTarget).toHaveBeenCalledWith({
176
+ type: "download",
177
+ url: "/api/stores/s1/orders/o1",
178
+ filename: "north-order.json",
179
+ });
180
+ });
181
+ });
182
+
183
+ describe("an action target's URL", () => {
184
+ it("fills an api target on a row with the page's values, then the row's", async () => {
185
+ const action: CustomRowAction = {
186
+ label: "Refund",
187
+ target: {
188
+ type: "api",
189
+ url: "/api/stores/{{params.id}}/orders/{{row._id}}/refund?store={{query.store}}",
190
+ successMessage: "Refunded",
191
+ },
192
+ };
193
+ mountTargets().handleCustomRowAction(action, { _id: "o 1" });
194
+ await vi.waitFor(() => expect(api).toHaveBeenCalled());
195
+ expect(api.mock.calls[0]![0]).toBe(
196
+ "/api/stores/s1/orders/o%201/refund?store=north",
197
+ );
198
+ });
199
+
200
+ it("fills an api target on a toolbar button, without a row", async () => {
201
+ const button: CustomButton = {
202
+ id: "sync",
203
+ label: "Sync",
204
+ target: {
205
+ type: "api",
206
+ url: "/api/stores/{{params.id}}/sync?period={{query.period}}",
207
+ successMessage: "Synced",
208
+ },
209
+ };
210
+ mountTargets().handleCustomButton(button);
211
+ await vi.waitFor(() => expect(api).toHaveBeenCalled());
212
+ expect(api.mock.calls[0]![0]).toBe("/api/stores/s1/sync?period=7d");
213
+ });
214
+
215
+ it("fills an export job's start, status and download URLs", async () => {
216
+ const button: CustomButton = {
217
+ id: "export",
218
+ label: "Export",
219
+ target: {
220
+ type: "exportJob",
221
+ url: "/api/stores/{{params.id}}/export?store={{query.store}}",
222
+ statusUrl: "/api/stores/{{params.id}}/export/{{job.id}}",
223
+ downloadUrl: "/api/stores/{{params.id}}/export/{{ job.id }}/file",
224
+ },
225
+ };
226
+ mountTargets().handleCustomButton(button);
227
+ await vi.waitFor(() => expect(runJob).toHaveBeenCalled());
228
+ const job = runJob.mock.calls[0]![0];
229
+ expect(job.startUrl).toBe("/api/stores/s1/export?store=north");
230
+ expect(job.statusUrl({ jobId: "j1" })).toBe("/api/stores/s1/export/j1");
231
+ expect(job.downloadUrl({ jobId: "j1" })).toBe(
232
+ "/api/stores/s1/export/j1/file",
233
+ );
234
+ });
235
+ });
@@ -4,25 +4,25 @@ import { interpolateUrl } from "../layers/dms-core/app/utils/url-interpolation";
4
4
  describe("interpolateUrl", () => {
5
5
  it("fills row fields into the URL, encoded", () => {
6
6
  expect(
7
- interpolateUrl("/items/{_id}/tag/{name}", { _id: "a1", name: "a b/c" }),
7
+ interpolateUrl("/items/{{row._id}}/tag/{{row.name}}", { _id: "a1", name: "a b/c" }),
8
8
  ).toBe("/items/a1/tag/a%20b%2Fc");
9
9
  });
10
10
 
11
11
  it("keeps the colon of a tenant-scoped id readable", () => {
12
12
  expect(
13
- interpolateUrl("/members/{_id}/owner", { _id: "default:29fd-11" }),
13
+ interpolateUrl("/members/{{row._id}}/owner", { _id: "default:29fd-11" }),
14
14
  ).toBe("/members/default:29fd-11/owner");
15
15
  });
16
16
 
17
17
  it("leaves a missing field empty", () => {
18
- expect(interpolateUrl("/items/{missing}", {})).toBe("/items/");
18
+ expect(interpolateUrl("/items/{{row.missing}}", {})).toBe("/items/");
19
19
  });
20
20
 
21
21
  it("keeps web and mail links", () => {
22
- expect(interpolateUrl("https://acme.dev/{slug}", { slug: "a" })).toBe(
22
+ expect(interpolateUrl("https://acme.dev/{{row.slug}}", { slug: "a" })).toBe(
23
23
  "https://acme.dev/a",
24
24
  );
25
- expect(interpolateUrl("mailto:{email}", { email: "a@b.c" })).toBe(
25
+ expect(interpolateUrl("mailto:{{row.email}}", { email: "a@b.c" })).toBe(
26
26
  "mailto:a%40b.c",
27
27
  );
28
28
  });
@@ -34,7 +34,7 @@ describe("interpolateUrl", () => {
34
34
  "data:text/html,x",
35
35
  "vbscript:x",
36
36
  ]) {
37
- expect(interpolateUrl("{site}", { site })).toBe("");
37
+ expect(interpolateUrl("{{row.site}}", { site })).toBe("");
38
38
  }
39
39
  });
40
40
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.7.20",
3
+ "version": "0.8.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/AntelopeJS/dms.git"
@@ -37,7 +37,7 @@
37
37
  "@antelopejs/interface-data-api": ">=0.2.0 <1.0.0",
38
38
  "@antelopejs/interface-database": ">=0.1.8 <1.0.0",
39
39
  "@antelopejs/interface-database-decorators": ">=0.1.7 <1.0.0",
40
- "@antelopejs/interface-dms": ">=0.5.12 <0.6.0",
40
+ "@antelopejs/interface-dms": ">=0.6.0 <0.7.0",
41
41
  "@antelopejs/interface-email": ">=0.0.5 <1.0.0",
42
42
  "@antelopejs/interface-file-storage": ">=0.1.3 <1.0.0",
43
43
  "archiver": "^7.0.1",
@@ -33,7 +33,8 @@ For the framework underneath (the core, `ajs` CLI, interfaces as versioned contr
33
33
  frontend renders it. **Components fetch their own data** via URLs (`fetchUrl`, `submitUrl`, …) —
34
34
  routes often declared `@Get`/`@Post` on the same page class; the backend stays the source of truth.
35
35
  A URL names the record of its page with `{{params.id}}` / `{{query.X}}` (filled from the page URL, percent-encoded;
36
- nothing is fetched while one is unresolved), and `.watch(FormEvents.SUBMIT_SUCCESS,
36
+ nothing is fetched while one is unresolved) — every token is `{{<scope>.<key>}}`: `params`, `query`, `row` (an
37
+ action's row), `response` (a form's redirect), and the same page tokens work in every URL and link option, and `.watch(FormEvents.SUBMIT_SUCCESS,
37
38
  BlockFunctions.REFRESH_PAGE)` makes the page's data blocks (table views included) re-fetch after a form saves;
38
39
  `periodScope` binds a card, chart, list block, `Meter` or `TableView.fromSource` to a `PeriodSelector`
39
40
  (which keeps its period in the page URL, `?<id>.period=`), and
@@ -54,10 +54,10 @@ own drawer in a `CustomComponent`.
54
54
  To read rows one after the other next to the table (a request log), use a `sidePanel`
55
55
  target instead: a non-modal panel docked on the right whose header the DMS draws (title,
56
56
  `rowTitle` naming the row from its fields, `positionLabel` "x of y", previous / next, J / K, close), `?record=` kept by default (`deepLink: false` opts out), a "not found" state on a 404 (`notFound: { title, description }`) and the table's
57
- `rowClick: "single"` (a `TableView.fromSource` table reads a deep-linked row beyond its first page through `fetchRowUrl: "/api/x/{id}"`) — never bridge the table's DOM to build one —
57
+ `rowClick: "single"` (a `TableView.fromSource` table reads a deep-linked row beyond its first page through `fetchRowUrl: "/api/x/{{row.id}}"`) — never bridge the table's DOM to build one —
58
58
  `docs/04.components/06.tables/8.row-drawers.md#side-panel`.
59
59
  A button downloading a file the user's session may read (`/api/...`) takes a `download`
60
- target, `{ url, filename? }`, with the same `{field}` / `{{query.X}}` tokens — never a Vue
60
+ target, `{ url, filename? }`, with the same `{{row.field}}` / `{{query.X}}` tokens — never a Vue
61
61
  header button or an `api` target for it — `docs/07.interfaces/3.base/2.types.md`.
62
62
 
63
63
  A row that must stay on top of a table under any sort (a base language, a default
@@ -82,7 +82,6 @@ export function construct(config: Config): void {
82
82
  registerAllPermissions(); // RegisterPermission(...) — see dms-auth
83
83
  RegisterHook(Hook.DATABASE_INITIALIZED, async () => {
84
84
  await initDatabase();
85
- return undefined;
86
85
  });
87
86
  }
88
87
  export async function start(): Promise<void> {