@antelopejs/dms 0.7.19 → 0.7.21

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 (26) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.d.ts +1 -0
  3. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js +27 -0
  4. package/dist/test/unit/interfaces/dms-base/table-view-row-menu-label.test.js.map +1 -0
  5. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js +11 -0
  6. package/dist/test/unit/interfaces/dms-base/table-view-source-reorder.test.js.map +1 -1
  7. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +13 -3
  8. package/frontend-vue/layers/dms-ui/app/build/components/table-view/CardsDisplay.vue +1 -1
  9. package/frontend-vue/layers/dms-ui/app/build/composables/actions/rowNavigation.ts +14 -0
  10. package/frontend-vue/layers/dms-ui/app/build/composables/actions/useActionTargets.ts +70 -8
  11. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableRecordLink.ts +93 -0
  12. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableUrlTab.ts +42 -12
  13. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +3 -2
  14. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +3 -0
  15. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/recordLink.ts +18 -16
  16. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/urlFilters.ts +18 -0
  17. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/utils/views.ts +29 -0
  18. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +43 -54
  19. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +2 -0
  20. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/display.ts +6 -0
  21. package/frontend-vue/tests/row-side-panel.test.ts +93 -2
  22. package/frontend-vue/tests/table-view-bulk-drawer.test.ts +117 -0
  23. package/frontend-vue/tests/table-view-cards-reread.test.ts +77 -0
  24. package/frontend-vue/tests/table-view-record-link.test.ts +207 -0
  25. package/frontend-vue/tests/table-view-url-tab.test.ts +84 -2
  26. package/package.json +2 -2
@@ -0,0 +1,207 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * A table with a `deepLink` action opens the row `?record=` names on arrival
4
+ * and on each visit that keeps the page mounted, including a link to a row
5
+ * closed meanwhile: the table removes the key in place, so the route still
6
+ * holds the same value and only the visit tells the link was followed again.
7
+ */
8
+ import { readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
+ import type { CustomRowAction } from "../layers/dms-ui/app/types/row-action";
13
+ import type { DeepLinkedRow } from "../layers/dms-ui/app/build/composables/table-view/utils/recordLink";
14
+ import {
15
+ clearWrittenRecord,
16
+ writeRecordId,
17
+ } from "../layers/dms-ui/app/build/composables/table-view/utils/recordLink";
18
+ import {
19
+ currentQuery,
20
+ withoutQueryParam,
21
+ } from "../layers/dms-ui/app/build/composables/table-view/utils/urlFilters";
22
+
23
+ type NavigateListener = (event: { detail: { page: { url: string } } }) => void;
24
+ const navigateListeners = new Set<NavigateListener>();
25
+
26
+ vi.mock("@inertiajs/vue3", () => ({
27
+ router: {
28
+ on: (type: string, listener: NavigateListener) => {
29
+ if (type !== "navigate") return () => {};
30
+ navigateListeners.add(listener);
31
+ return () => navigateListeners.delete(listener);
32
+ },
33
+ },
34
+ }));
35
+
36
+ const { useTableRecordLink } = await import(
37
+ "../layers/dms-ui/app/build/composables/table-view/useTableRecordLink"
38
+ );
39
+
40
+ interface Row {
41
+ id: string;
42
+ }
43
+
44
+ const ROWS: Row[] = [{ id: "r1" }, { id: "r2" }];
45
+ const SCOPE = { tableId: "sends", isSoleTableView: true };
46
+ const DRAWER = {
47
+ id: "details",
48
+ deepLink: true,
49
+ target: { type: "drawer" },
50
+ } as unknown as CustomRowAction;
51
+
52
+ const mounted: Array<{ unmount: () => void }> = [];
53
+
54
+ const flush = () => new Promise<void>((done) => setTimeout(done));
55
+
56
+ /** A visit of the page: Inertia pushes its URL, then announces it. */
57
+ async function visit(route: { query: Record<string, unknown> }, url: string) {
58
+ window.history.pushState({}, "", url);
59
+ route.query = Object.fromEntries(new URL(url, "http://x").searchParams);
60
+ await nextTick();
61
+ navigateListeners.forEach((listener) =>
62
+ listener({ detail: { page: { url } } }),
63
+ );
64
+ await flush();
65
+ }
66
+
67
+ function mountTable(url: string) {
68
+ window.history.replaceState({}, "", url);
69
+ const route = reactive({
70
+ query: Object.fromEntries(new URL(url, "http://x").searchParams) as Record<
71
+ string,
72
+ unknown
73
+ >,
74
+ });
75
+ const opened: string[] = [];
76
+ const notify = vi.fn();
77
+ let link: { shown: (id?: string) => void } | undefined;
78
+ const app = createApp(
79
+ defineComponent({
80
+ setup() {
81
+ link = useTableRecordLink<Row>({
82
+ route,
83
+ urlScope: SCOPE,
84
+ action: DRAWER,
85
+ resolve: async (id): Promise<DeepLinkedRow<Row>> => {
86
+ const row = ROWS.find((candidate) => candidate.id === id);
87
+ return row ? { row, isMissing: false } : { isMissing: true };
88
+ },
89
+ // The drawer writes the row it opens, as the action's targets do.
90
+ open: (row) => {
91
+ opened.push(row.id);
92
+ writeRecordId(SCOPE, row.id);
93
+ link?.shown(row.id);
94
+ },
95
+ showMissing: () => {},
96
+ notify,
97
+ });
98
+ return () => h("div");
99
+ },
100
+ }),
101
+ );
102
+ app.mount(document.createElement("div"));
103
+ mounted.push(app);
104
+ // Closing the drawer removes the row from the URL in place.
105
+ const close = () => {
106
+ writeRecordId(SCOPE);
107
+ link?.shown(undefined);
108
+ };
109
+ return { route, opened, notify, close };
110
+ }
111
+
112
+ beforeEach(() => navigateListeners.clear());
113
+
114
+ afterEach(() => {
115
+ mounted.splice(0).forEach((app) => app.unmount());
116
+ window.history.replaceState({}, "", "/");
117
+ });
118
+
119
+ describe("a record link on a mounted table", () => {
120
+ it("opens the row a link names on arrival, once", async () => {
121
+ const { route, opened } = mountTable("/sends?record=r1");
122
+ await flush();
123
+ // The visit that mounted the table is announced after it mounted.
124
+ navigateListeners.forEach((listener) =>
125
+ listener({ detail: { page: { url: "/sends?record=r1" } } }),
126
+ );
127
+ await flush();
128
+ expect(opened).toEqual(["r1"]);
129
+ expect(route.query.record).toBe("r1");
130
+ });
131
+
132
+ it("opens the row a visit to the same page names", async () => {
133
+ const { route, opened } = mountTable("/sends?record=r1");
134
+ await flush();
135
+ await visit(route, "/sends?record=r2");
136
+ expect(opened).toEqual(["r1", "r2"]);
137
+ expect(window.location.search).toBe("?record=r2");
138
+ });
139
+
140
+ it("opens the same row again after its drawer closed", async () => {
141
+ const { route, opened, close } = mountTable("/sends?record=r1");
142
+ await flush();
143
+ // A link to the row shown opens nothing more.
144
+ await visit(route, "/sends?record=r1");
145
+ expect(opened).toEqual(["r1"]);
146
+
147
+ close();
148
+ expect(window.location.search).toBe("");
149
+ await visit(route, "/sends?record=r1");
150
+ expect(opened).toEqual(["r1", "r1"]);
151
+ });
152
+
153
+ it("tells the user a link names no row and removes the key", async () => {
154
+ const { route, opened, notify } = mountTable("/sends?tab=all&record=gone");
155
+ await flush();
156
+ expect(opened).toEqual([]);
157
+ expect(notify).toHaveBeenCalledWith("notify");
158
+ expect(window.location.search).toBe("?tab=all");
159
+
160
+ // Following the same link again tells it again.
161
+ await visit(route, "/sends?tab=all&record=gone");
162
+ expect(notify).toHaveBeenCalledTimes(2);
163
+ });
164
+
165
+ it("stops following visits once unmounted", async () => {
166
+ const { route, opened } = mountTable("/sends");
167
+ mounted.splice(0).forEach((app) => app.unmount());
168
+ await visit(route, "/sends?record=r2");
169
+ expect(opened).toEqual([]);
170
+ });
171
+ });
172
+
173
+ describe("the URL a closed drawer leaves", () => {
174
+ it("removes only the row this drawer wrote, and says so", () => {
175
+ window.history.replaceState({}, "", "/sends");
176
+ const written = writeRecordId(SCOPE, "r1");
177
+ writeRecordId(SCOPE, "r2");
178
+ expect(clearWrittenRecord(written)).toBe(false);
179
+ expect(window.location.search).toBe("?record=r2");
180
+ expect(clearWrittenRecord(writeRecordId(SCOPE, "r2"))).toBe(true);
181
+ expect(window.location.search).toBe("");
182
+ });
183
+
184
+ it("rebuilds a removed filter's visit from the URL the browser shows", () => {
185
+ window.history.replaceState({}, "", "/sends?email=a%40b.test&tab=all");
186
+ // The route still holds the row of the drawer closed in place.
187
+ const routeQuery = { email: "a@b.test", tab: "all", record: "r1" };
188
+ expect(withoutQueryParam(currentQuery(routeQuery), "email")).toEqual({
189
+ tab: "all",
190
+ });
191
+ });
192
+ });
193
+
194
+ it("wires the record link and the filter chips into the table view", () => {
195
+ const source = readFileSync(
196
+ resolve(
197
+ __dirname,
198
+ "../layers/dms-ui/app/components/table-view/TableView.vue",
199
+ ),
200
+ "utf8",
201
+ );
202
+ expect(source).toContain("useTableRecordLink<T>({");
203
+ expect(source).toContain("onRecordShown: (id) => recordLink?.shown(id),");
204
+ expect(source).toContain(
205
+ "withoutQueryParam(currentQuery(route.query), param)",
206
+ );
207
+ });
@@ -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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms",
3
- "version": "0.7.19",
3
+ "version": "0.7.21",
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.5.13 <0.6.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",