@antelopejs/dms 0.3.6 → 0.3.8

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 (43) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/implementations/dms/page.js +2 -2
  3. package/dist/implementations/dms/page.js.map +1 -1
  4. package/dist/test/antelope.test.js +16 -5
  5. package/dist/test/antelope.test.js.map +1 -1
  6. package/dist/test/api-endpoint/index.d.ts +5 -0
  7. package/dist/test/api-endpoint/index.js +8 -0
  8. package/dist/test/api-endpoint/index.js.map +1 -0
  9. package/dist/test/helpers/http.d.ts +1 -0
  10. package/dist/test/helpers/http.js +16 -4
  11. package/dist/test/helpers/http.js.map +1 -1
  12. package/dist/test/unit/implementations/dms/notify-menu-changed.test.js +21 -0
  13. package/dist/test/unit/implementations/dms/notify-menu-changed.test.js.map +1 -1
  14. package/dist/test/unit/interfaces/dms-base/table-view-form-page-urls.test.d.ts +1 -0
  15. package/dist/test/unit/interfaces/dms-base/table-view-form-page-urls.test.js +308 -0
  16. package/dist/test/unit/interfaces/dms-base/table-view-form-page-urls.test.js.map +1 -0
  17. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.d.ts +1 -0
  18. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js +241 -0
  19. package/dist/test/unit/interfaces/dms-base/table-view-form-redirect.test.js.map +1 -0
  20. package/dist/test/unit/interfaces/dms-base/table-view-route-param-filter-defaults.test.d.ts +1 -0
  21. package/dist/test/unit/interfaces/dms-base/table-view-route-param-filter-defaults.test.js +254 -0
  22. package/dist/test/unit/interfaces/dms-base/table-view-route-param-filter-defaults.test.js.map +1 -0
  23. package/frontend-vue/layers/dms-layout/app/composables/page/useSiteLayout.ts +49 -20
  24. package/frontend-vue/layers/dms-ui/app/build/components/table/Table.vue +6 -1
  25. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTable.ts +2 -0
  26. package/frontend-vue/layers/dms-ui/app/build/composables/table/useTableColumns.ts +13 -7
  27. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions.ts +49 -20
  28. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewConfig.ts +43 -5
  29. package/frontend-vue/layers/dms-ui/app/build/composables/table-view/useTableViewRowActions.ts +23 -29
  30. package/frontend-vue/layers/dms-ui/app/components/grid/Grid.vue +10 -1
  31. package/frontend-vue/layers/dms-ui/app/components/grid/GridRow.vue +16 -6
  32. package/frontend-vue/layers/dms-ui/app/components/grid/columns.ts +29 -0
  33. package/frontend-vue/layers/dms-ui/app/components/grid/constants.ts +1 -0
  34. package/frontend-vue/layers/dms-ui/app/components/table-view/TableView.vue +3 -0
  35. package/frontend-vue/layers/dms-ui/app/composables/form/useForm.ts +35 -25
  36. package/frontend-vue/layers/dms-ui/app/composables/table-view/types/config.ts +5 -1
  37. package/frontend-vue/pnpm-lock.yaml +4 -4
  38. package/frontend-vue/tests/form-page-url-fill.test.ts +63 -0
  39. package/frontend-vue/tests/form-redirect-placeholders.test.ts +69 -0
  40. package/frontend-vue/tests/grid-responsive-columns.test.ts +105 -0
  41. package/frontend-vue/tests/route-param-filter-defaults.test.ts +59 -0
  42. package/frontend-vue/tests/route-param-occurrences.test.ts +109 -0
  43. package/package.json +2 -2
@@ -1,4 +1,8 @@
1
- import type { FormContainer } from "./useTableViewConfig";
1
+ import {
2
+ fillFormPageUrl,
3
+ type FormContainer,
4
+ type FormPageUrls,
5
+ } from "./useTableViewConfig";
2
6
  import type { FormProps } from "../../../composables/form/types";
3
7
  import type { QueryParamFilters } from "../../../composables/table-view/types";
4
8
  import type {
@@ -38,6 +42,8 @@ interface TableRowActionsConfig {
38
42
  view?: ComponentInfo<FormProps>;
39
43
  };
40
44
  formContainer?: FormContainer;
45
+ formPages?: FormPageUrls;
46
+ routeParams?: Record<string, string>;
41
47
  componentId: string;
42
48
  pageId: string;
43
49
  queryParamFilters?: QueryParamFilters;
@@ -83,20 +89,6 @@ export const useTableRowActions = <T extends Data>(
83
89
  return Object.keys(forwarded).length > 0 ? forwarded : undefined;
84
90
  };
85
91
 
86
- const resolveFormPageUrl = (urlSlug: string): string => {
87
- if (urlSlug.startsWith("/")) return urlSlug;
88
- const baseSegments = route.path.replace(/\/$/, "").split("/");
89
- const parts = urlSlug.split("/");
90
- let popCount = 0;
91
- let partStart = 0;
92
- while (partStart < parts.length && parts[partStart] === "..") {
93
- popCount++;
94
- partStart++;
95
- }
96
- const base = baseSegments.slice(0, baseSegments.length - popCount);
97
- return [...base, ...parts.slice(partStart)].join("/");
98
- };
99
-
100
92
  const handleApiError = (error: unknown, defaultMessage: string) => {
101
93
  const err = error as Record<string, unknown>;
102
94
  const errData = err?.data as Record<string, unknown> | string | undefined;
@@ -335,10 +327,9 @@ export const useTableRowActions = <T extends Data>(
335
327
  };
336
328
 
337
329
  const openRowByIdImpl = (itemId: string, item?: T) => {
338
- if (getContainerType() === FormContainerType.page) {
339
- const viewSlug = config.formContainer?.pages?.view?.urlSlug || ":id/view";
340
- const viewUrl = resolveFormPageUrl(viewSlug).replace(":id", itemId);
341
- navigateDms(viewUrl);
330
+ const viewPageUrl = config.formPages?.view;
331
+ if (getContainerType() === FormContainerType.page && viewPageUrl) {
332
+ navigateDms(fillFormPageUrl(viewPageUrl, config.routeParams, itemId));
342
333
  return;
343
334
  }
344
335
 
@@ -355,12 +346,15 @@ export const useTableRowActions = <T extends Data>(
355
346
  const editRowImpl = (item: T, submitDefaults?: Record<string, unknown>) => {
356
347
  const itemId = getItemId(item, config.rowIdKey ?? DEFAULT_ROW_ID_KEY);
357
348
 
358
- if (getContainerType() === FormContainerType.page && itemId) {
359
- const editSlug = config.formContainer?.pages?.edit?.urlSlug || ":id/edit";
360
- const editUrl = resolveFormPageUrl(editSlug).replace(":id", itemId);
349
+ const editPageUrl = config.formPages?.edit;
350
+ if (
351
+ getContainerType() === FormContainerType.page &&
352
+ itemId &&
353
+ editPageUrl
354
+ ) {
361
355
  const forwardedQuery = buildForwardedQuery();
362
356
  navigateDms({
363
- path: editUrl,
357
+ path: fillFormPageUrl(editPageUrl, config.routeParams, itemId),
364
358
  ...(forwardedQuery ? { query: forwardedQuery } : {}),
365
359
  });
366
360
  return;
@@ -377,11 +371,11 @@ export const useTableRowActions = <T extends Data>(
377
371
  };
378
372
 
379
373
  const newRowImpl = (submitDefaults?: Record<string, unknown>) => {
380
- if (getContainerType() === FormContainerType.page) {
381
- const newSlug = config.formContainer?.pages?.new?.urlSlug || "new";
374
+ const newPageUrl = config.formPages?.new;
375
+ if (getContainerType() === FormContainerType.page && newPageUrl) {
382
376
  const forwardedQuery = buildForwardedQuery();
383
377
  navigateDms({
384
- path: resolveFormPageUrl(newSlug),
378
+ path: fillFormPageUrl(newPageUrl, config.routeParams),
385
379
  ...(forwardedQuery ? { query: forwardedQuery } : {}),
386
380
  });
387
381
  return;
@@ -396,10 +390,10 @@ export const useTableRowActions = <T extends Data>(
396
390
  };
397
391
 
398
392
  const duplicateRowImpl = (itemId: string) => {
399
- if (getContainerType() === FormContainerType.page) {
400
- const newSlug = config.formContainer?.pages?.new?.urlSlug || "new";
393
+ const newPageUrl = config.formPages?.new;
394
+ if (getContainerType() === FormContainerType.page && newPageUrl) {
401
395
  navigateDms({
402
- path: resolveFormPageUrl(newSlug),
396
+ path: fillFormPageUrl(newPageUrl, config.routeParams),
403
397
  query: { duplicate: itemId },
404
398
  });
405
399
  return;
@@ -1,13 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
3
3
  import { GRID_CONTEXT, type GridContext } from "./constants";
4
+ import { GRID_DEFAULT_MIN_COLUMN_WIDTH, gridColumnsTemplate } from "./columns";
4
5
 
5
6
  interface GridProps extends DefaultComponentProps {
6
7
  gap?: string;
8
+ minColumnWidth?: string;
7
9
  }
8
10
 
9
11
  const props = withDefaults(defineProps<GridProps>(), {
10
12
  gap: "1rem",
13
+ minColumnWidth: GRID_DEFAULT_MIN_COLUMN_WIDTH,
11
14
  });
12
15
 
13
16
  const rowColumnCounts = ref<number[]>([]);
@@ -18,6 +21,7 @@ const maxColumns = computed(() => {
18
21
  });
19
22
 
20
23
  const gapRef = computed(() => props.gap);
24
+ const minColumnWidthRef = computed(() => props.minColumnWidth);
21
25
 
22
26
  provide<GridContext>(GRID_CONTEXT, {
23
27
  registerRowColumnCount: (columnCount: number) => {
@@ -25,11 +29,16 @@ provide<GridContext>(GRID_CONTEXT, {
25
29
  },
26
30
  maxColumns,
27
31
  gap: gapRef,
32
+ minColumnWidth: minColumnWidthRef,
28
33
  });
29
34
 
30
35
  const gridStyle = computed(() => ({
31
36
  display: "grid",
32
- gridTemplateColumns: `repeat(${maxColumns.value}, 1fr)`,
37
+ gridTemplateColumns: gridColumnsTemplate(
38
+ maxColumns.value,
39
+ props.gap,
40
+ props.minColumnWidth,
41
+ ),
33
42
  gap: props.gap,
34
43
  width: "100%",
35
44
  }));
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { DefaultComponentProps } from "../../../../dms-core/app/types/component";
3
3
  import { GRID_CONTEXT, type GridContext } from "./constants";
4
+ import { GRID_DEFAULT_MIN_COLUMN_WIDTH, gridColumnsTemplate } from "./columns";
4
5
 
5
6
  interface GridRowProps extends DefaultComponentProps {}
6
7
 
8
+ const DEFAULT_GAP = "1rem";
9
+
7
10
  const props = defineProps<GridRowProps>();
8
11
 
9
12
  const gridContext = inject<GridContext>(GRID_CONTEXT);
@@ -13,12 +16,19 @@ if (gridContext) {
13
16
  gridContext.registerRowColumnCount(columnCount);
14
17
  }
15
18
 
16
- const rowStyle = computed(() => ({
17
- gridColumn: "1 / -1",
18
- display: "grid",
19
- gridTemplateColumns: `repeat(${gridContext?.maxColumns.value || 1}, 1fr)`,
20
- gap: gridContext?.gap.value ?? "1rem",
21
- }));
19
+ const rowStyle = computed(() => {
20
+ const gap = gridContext?.gap.value ?? DEFAULT_GAP;
21
+ return {
22
+ gridColumn: "1 / -1",
23
+ display: "grid",
24
+ gridTemplateColumns: gridColumnsTemplate(
25
+ gridContext?.maxColumns.value || 1,
26
+ gap,
27
+ gridContext?.minColumnWidth.value ?? GRID_DEFAULT_MIN_COLUMN_WIDTH,
28
+ ),
29
+ gap,
30
+ };
31
+ });
22
32
  </script>
23
33
 
24
34
  <template>
@@ -0,0 +1,29 @@
1
+ const SINGLE_COLUMN = 1;
2
+
3
+ /** Track floor below which a column stops being usable, as a CSS length. */
4
+ export const GRID_DEFAULT_MIN_COLUMN_WIDTH = "240px";
5
+
6
+ /**
7
+ * The column track template a `Grid` and every `GridRow` inside it share.
8
+ *
9
+ * `auto-fill` drops tracks as the container narrows, which is what makes the
10
+ * grid responsive without a breakpoint list. The track floor is the larger of
11
+ * `minColumnWidth` and the width one column would have at `maxColumns`: once
12
+ * the container is wide enough, that share exceeds `minColumnWidth` and leaves
13
+ * no room for an extra track, so `auto-fill` settles on exactly `maxColumns`
14
+ * and the wide layout stays what it was. The outer `min(100%, ...)` keeps a
15
+ * container narrower than `minColumnWidth` from overflowing it.
16
+ *
17
+ * Rows resolve this same template against the same width, so they keep lining
18
+ * up on one another — what `maxColumns` was introduced for — at every width
19
+ * rather than only at the widest.
20
+ */
21
+ export function gridColumnsTemplate(
22
+ maxColumns: number,
23
+ gap: string,
24
+ minColumnWidth: string,
25
+ ): string {
26
+ const columns = Math.max(maxColumns, SINGLE_COLUMN);
27
+ const columnShare = `(100% - ${columns - SINGLE_COLUMN} * ${gap}) / ${columns}`;
28
+ return `repeat(auto-fill, minmax(min(100%, max(${minColumnWidth}, ${columnShare})), 1fr))`;
29
+ }
@@ -4,4 +4,5 @@ export interface GridContext {
4
4
  registerRowColumnCount: (count: number) => void;
5
5
  maxColumns: ComputedRef<number>;
6
6
  gap: ComputedRef<string>;
7
+ minColumnWidth: ComputedRef<string>;
7
8
  }
@@ -88,6 +88,7 @@ const {
88
88
  customButtons,
89
89
  formComponents,
90
90
  formContainer,
91
+ formPages,
91
92
  componentId,
92
93
  pageId,
93
94
  defaultSort,
@@ -599,6 +600,8 @@ const {
599
600
  refreshCallback: refreshAll,
600
601
  formComponents,
601
602
  formContainer,
603
+ formPages,
604
+ routeParams,
602
605
  componentId: componentId!,
603
606
  pageId: pageId!,
604
607
  queryParamFilters,
@@ -96,18 +96,24 @@ function processFieldI18n(
96
96
  };
97
97
  }
98
98
 
99
- interface ReplaceUrlVariablesContext {
99
+ // `{{params.id}}` takes the bare name, which on a route repeating a placeholder
100
+ // is its last occurrence — the row id of a form page. The `:<n>` suffix reaches
101
+ // a specific occurrence, `{{params.id:1}}` being the id of the page carrying the
102
+ // table view (see extractRouteParams).
103
+ const PARAM_TOKEN = /\{\{params\.(\w+(?::\d+)?)\}\}/g;
104
+
105
+ export interface ReplaceUrlVariablesContext {
100
106
  routeParams?: Record<string, string>;
101
107
  routeQuery: Record<string, unknown>;
102
108
  response?: Record<string, unknown>;
103
109
  }
104
110
 
105
- function replaceUrlVariables(
111
+ export function replaceUrlVariables(
106
112
  url: string,
107
113
  context: ReplaceUrlVariablesContext,
108
114
  ): string {
109
115
  let processedUrl = url.replace(
110
- /\{\{params\.(\w+)\}\}/g,
116
+ PARAM_TOKEN,
111
117
  (match, key) => context.routeParams?.[key] || match,
112
118
  );
113
119
 
@@ -177,6 +183,30 @@ function collectSubmitData(
177
183
  };
178
184
  }
179
185
 
186
+ // `submitDefaults` string values may contain `{{query.X}}` / `{{params.X}}`
187
+ // tokens (e.g. a "new" form generated from `queryParamFilters`). Resolve them
188
+ // against the current route at submit time; drop entries whose tokens cannot
189
+ // be resolved so an unfiltered form doesn't submit a literal `{{...}}`.
190
+ export function resolveSubmitDefaults(
191
+ submitDefaults: Record<string, unknown> | undefined,
192
+ context: ReplaceUrlVariablesContext,
193
+ ): Record<string, unknown> | undefined {
194
+ if (!submitDefaults) return undefined;
195
+
196
+ const resolved: Record<string, unknown> = {};
197
+ for (const [key, value] of Object.entries(submitDefaults)) {
198
+ if (typeof value === "string" && value.includes("{{")) {
199
+ const replaced = replaceUrlVariables(value, context);
200
+ if (replaced.includes("{{")) continue;
201
+ resolved[key] = replaced;
202
+ } else {
203
+ resolved[key] = value;
204
+ }
205
+ }
206
+
207
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
208
+ }
209
+
180
210
  export function makeFieldSchemaRequired(schema: z.ZodTypeAny): z.ZodTypeAny {
181
211
  if (schema instanceof z.ZodOptional || schema instanceof z.ZodNullable) {
182
212
  return makeFieldSchemaRequired(schema.unwrap());
@@ -346,28 +376,8 @@ export const useForm = (props: FormProps) => {
346
376
  response,
347
377
  });
348
378
 
349
- // `submitDefaults` string values may contain `{{query.X}}` / `{{params.X}}`
350
- // tokens (e.g. a "new" form generated from `queryParamFilters`). Resolve them
351
- // against the current route at submit time; drop entries whose tokens cannot
352
- // be resolved so an unfiltered form doesn't submit a literal `{{...}}`.
353
- const effectiveSubmitDefaults = computed<Record<string, unknown> | undefined>(
354
- () => {
355
- if (!props.submitDefaults) return undefined;
356
-
357
- const context = buildUrlContext();
358
- const resolved: Record<string, unknown> = {};
359
- for (const [key, value] of Object.entries(props.submitDefaults)) {
360
- if (typeof value === "string" && value.includes("{{")) {
361
- const replaced = replaceUrlVariables(value, context);
362
- if (replaced.includes("{{")) continue;
363
- resolved[key] = replaced;
364
- } else {
365
- resolved[key] = value;
366
- }
367
- }
368
-
369
- return Object.keys(resolved).length > 0 ? resolved : undefined;
370
- },
379
+ const effectiveSubmitDefaults = computed(() =>
380
+ resolveSubmitDefaults(props.submitDefaults, buildUrlContext()),
371
381
  );
372
382
 
373
383
  const resolvedFetchUrl = computed(() => {
@@ -1,4 +1,7 @@
1
- import type { FormContainer } from "../../../build/composables/table-view/useTableViewConfig";
1
+ import type {
2
+ FormContainer,
3
+ FormPageUrls,
4
+ } from "../../../build/composables/table-view/useTableViewConfig";
2
5
  import type { TableViewColumn } from "./column";
3
6
  import type { TableViewDisplayCapabilities } from "./display";
4
7
  import type { CustomButton } from "./custom-button";
@@ -80,6 +83,7 @@ export interface TableViewConfig<T extends Data>
80
83
  view?: ComponentInfo<FormProps>;
81
84
  };
82
85
  formContainer?: FormContainer;
86
+ formPages?: FormPageUrls;
83
87
  componentId?: string;
84
88
  pageId?: string;
85
89
  defaultSort?: { field: string; desc?: boolean };
@@ -210,8 +210,8 @@ packages:
210
210
  '@antelopejs/core':
211
211
  optional: true
212
212
 
213
- '@antelopejs/interface-core@0.0.13':
214
- resolution: {integrity: sha512-zMVhW5OefmVNRLHvd3rPQTaShy+k9w6+cOhqOryyfxgVyRZ18nBlV8VSq9TuPkFIo5OGNAXFybOf54DRSnPVsw==}
213
+ '@antelopejs/interface-core@0.1.0':
214
+ resolution: {integrity: sha512-49mmTXREMNNEigWpmubwQD7fSrIIvmcTb9L8L79zQcDWTToud/GIap40vaKWmkbN1oGbjOB4ael/agqZ/ejYSg==}
215
215
 
216
216
  '@antfu/install-pkg@1.1.0':
217
217
  resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
@@ -4327,7 +4327,7 @@ snapshots:
4327
4327
 
4328
4328
  '@antelopejs/core@1.8.0(@types/node@24.8.1)':
4329
4329
  dependencies:
4330
- '@antelopejs/interface-core': 0.0.13
4330
+ '@antelopejs/interface-core': 0.1.0
4331
4331
  '@types/proper-lockfile': 4.1.4
4332
4332
  boxen: 8.0.1
4333
4333
  chalk: 4.1.2
@@ -4362,7 +4362,7 @@ snapshots:
4362
4362
  '@oxc-parser/binding-linux-x64-gnu': 0.95.0
4363
4363
  '@oxc-parser/binding-win32-x64-msvc': 0.95.0
4364
4364
 
4365
- '@antelopejs/interface-core@0.0.13':
4365
+ '@antelopejs/interface-core@0.1.0':
4366
4366
  dependencies:
4367
4367
  reflect-metadata: 0.2.2
4368
4368
 
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { fillFormPageUrl } from "../layers/dms-ui/app/build/composables/table-view/useTableViewConfig";
3
+
4
+ const ROW = "row-1";
5
+
6
+ describe("filling a serialized form page URL", () => {
7
+ it("substitutes the row id of a default slug", () => {
8
+ expect(
9
+ fillFormPageUrl("/settings/users/roles/table/:id/edit", {}, ROW),
10
+ ).toBe("/settings/users/roles/table/row-1/edit");
11
+ });
12
+
13
+ it("leaves a URL without placeholder alone", () => {
14
+ expect(fillFormPageUrl("/settings/users/roles/table/new", {})).toBe(
15
+ "/settings/users/roles/table/new",
16
+ );
17
+ });
18
+
19
+ it("substitutes the row id inside the query string of an absolute slug", () => {
20
+ expect(
21
+ fillFormPageUrl("/modules/automation/builder?selected=:id", {}, ROW),
22
+ ).toBe("/modules/automation/builder?selected=row-1");
23
+ });
24
+
25
+ // The page slug contributes its own `:id`, and the form slug appends another:
26
+ // the row takes the last, the route params fill the first.
27
+ it("tells the row id apart from the id of the carrying page", () => {
28
+ expect(
29
+ fillFormPageUrl("/modules/workspaces/:id/:id/view", { id: "ws-1" }, ROW),
30
+ ).toBe("/modules/workspaces/ws-1/row-1/view");
31
+ });
32
+
33
+ // What a table view nested under a `:id` detail page now resolves to: the
34
+ // key segment sits between the two placeholders and changes nothing.
35
+ it("tells the ids apart across the key segment of the table view", () => {
36
+ expect(
37
+ fillFormPageUrl(
38
+ "/modules/workspaces/:id/invoiceTable/:id/view",
39
+ { id: "ws-1" },
40
+ ROW,
41
+ ),
42
+ ).toBe("/modules/workspaces/ws-1/invoiceTable/row-1/view");
43
+ });
44
+
45
+ it("fills the params of the carrying page when the form takes no row id", () => {
46
+ expect(fillFormPageUrl("/modules/workspaces/:id/new", { id: "ws-1" })).toBe(
47
+ "/modules/workspaces/ws-1/new",
48
+ );
49
+ });
50
+
51
+ it("matches a route param on its whole name", () => {
52
+ expect(
53
+ fillFormPageUrl(
54
+ "/projects/:project/:projectRole/:id/view",
55
+ {
56
+ project: "p-1",
57
+ projectRole: "owner",
58
+ },
59
+ ROW,
60
+ ),
61
+ ).toBe("/projects/p-1/owner/row-1/view");
62
+ });
63
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractRouteParams } from "../layers/dms-layout/app/composables/page/useSiteLayout";
3
+ import { resolveFormSuccessTarget } from "../layers/dms-ui/app/build/composables/table-view/registerDefaultFunctions";
4
+
5
+ // The redirect a page-mode form of a table view registered below
6
+ // `/workspaces/:id` sends on submit, as the TableView factory generates it for
7
+ // each form page route.
8
+ const EDIT_PATTERN = "/workspaces/:id/invoiceTable/:id/edit";
9
+ const NEW_PATTERN = "/workspaces/:id/invoiceTable/new";
10
+ const EDIT_REDIRECT = { url: "/workspaces/{{params.id:1}}" };
11
+ const NEW_REDIRECT = { url: "/workspaces/{{params.id}}" };
12
+
13
+ const routeOf = (
14
+ pattern: string,
15
+ path: string,
16
+ query: Record<string, string> = {},
17
+ ) => ({ params: extractRouteParams(pattern, path)!, query });
18
+
19
+ describe("page-mode form redirect", () => {
20
+ it("sends the edit form back to the page, not to the row", () => {
21
+ expect(
22
+ resolveFormSuccessTarget(
23
+ EDIT_REDIRECT,
24
+ routeOf(EDIT_PATTERN, "/workspaces/ws-42/invoiceTable/inv-7/edit"),
25
+ ),
26
+ ).toEqual({ path: "/workspaces/ws-42" });
27
+ });
28
+
29
+ it("sends the new form back to the page", () => {
30
+ expect(
31
+ resolveFormSuccessTarget(
32
+ NEW_REDIRECT,
33
+ routeOf(NEW_PATTERN, "/workspaces/ws-42/invoiceTable/new"),
34
+ ),
35
+ ).toEqual({ path: "/workspaces/ws-42" });
36
+ });
37
+
38
+ it("carries back the declared query params only", () => {
39
+ expect(
40
+ resolveFormSuccessTarget(
41
+ { ...EDIT_REDIRECT, preserveQuery: ["status", "missing"] },
42
+ routeOf(EDIT_PATTERN, "/workspaces/ws-42/invoiceTable/inv-7/edit", {
43
+ status: "paid",
44
+ other: "x",
45
+ }),
46
+ ),
47
+ ).toEqual({ path: "/workspaces/ws-42", query: { status: "paid" } });
48
+ });
49
+
50
+ it("leaves the slug of a page without route parameter as is", () => {
51
+ const route = routeOf(
52
+ "/invoices/invoiceTable/:id/edit",
53
+ "/invoices/invoiceTable/inv-7/edit",
54
+ {
55
+ status: "paid",
56
+ },
57
+ );
58
+
59
+ expect(resolveFormSuccessTarget({ url: "/invoices" }, route)).toEqual({
60
+ path: "/invoices",
61
+ });
62
+ expect(
63
+ resolveFormSuccessTarget(
64
+ { url: "/invoices", preserveQuery: ["status"] },
65
+ route,
66
+ ),
67
+ ).toEqual({ path: "/invoices", query: { status: "paid" } });
68
+ });
69
+ });
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { describe, expect, it } from "vitest";
3
+ import { gridColumnsTemplate } from "../layers/dms-ui/app/components/grid/columns";
4
+
5
+ const GAP = "1rem";
6
+ const MIN = "240px";
7
+
8
+ const sourceOf = (file: string) =>
9
+ readFileSync(
10
+ new URL(`../layers/dms-ui/app/components/grid/${file}`, import.meta.url),
11
+ "utf8",
12
+ );
13
+
14
+ // Guards the exact-fit case: at the width where the track floor is precisely
15
+ // one Nth of the container, binary floats land a hair under the integer.
16
+ const FIT_EPSILON = 1e-6;
17
+
18
+ /**
19
+ * Resolves the template the way a browser would, in pixels, and reports how
20
+ * many tracks `auto-fill` ends up with for a given container width.
21
+ */
22
+ function resolveColumnCount(
23
+ template: string,
24
+ containerWidth: number,
25
+ gapPx: number,
26
+ ): number {
27
+ const match = template.match(
28
+ /repeat\(auto-fill, minmax\(min\(100%, max\((\d+)px, \(100% - (\d+) \* [^)]+\) \/ (\d+)\)\), 1fr\)\)/,
29
+ );
30
+ if (!match) throw new Error(`unexpected template: ${template}`);
31
+ const [, min, gapCount, maxColumns] = match;
32
+ const share =
33
+ (containerWidth - Number(gapCount) * gapPx) / Number(maxColumns);
34
+ const floor = Math.min(containerWidth, Math.max(Number(min), share));
35
+ if (floor <= 0) return Number(maxColumns);
36
+ return Math.max(
37
+ 1,
38
+ Math.floor((containerWidth + gapPx) / (floor + gapPx) + FIT_EPSILON),
39
+ );
40
+ }
41
+
42
+ describe("grid column template", () => {
43
+ it("keeps every column at the widest row's count once the container is wide", () => {
44
+ const template = gridColumnsTemplate(6, GAP, MIN);
45
+ expect(resolveColumnCount(template, 1600, 16)).toBe(6);
46
+ expect(resolveColumnCount(template, 2400, 16)).toBe(6);
47
+ });
48
+
49
+ it("drops columns rather than shrinking them below the minimum", () => {
50
+ const template = gridColumnsTemplate(6, GAP, MIN);
51
+ expect(resolveColumnCount(template, 976, 16)).toBe(3);
52
+ expect(resolveColumnCount(template, 657, 16)).toBe(2);
53
+ expect(resolveColumnCount(template, 328, 16)).toBe(1);
54
+ });
55
+
56
+ it("never asks for a track wider than the container", () => {
57
+ const template = gridColumnsTemplate(4, GAP, MIN);
58
+ expect(template).toContain("min(100%,");
59
+ expect(resolveColumnCount(template, 200, 16)).toBe(1);
60
+ });
61
+
62
+ it("gives rows of the same grid one shared template, so they stay aligned", () => {
63
+ expect(gridColumnsTemplate(4, GAP, MIN)).toBe(
64
+ gridColumnsTemplate(4, GAP, MIN),
65
+ );
66
+ expect(gridColumnsTemplate(4, GAP, MIN)).not.toBe(
67
+ gridColumnsTemplate(3, GAP, MIN),
68
+ );
69
+ });
70
+
71
+ it("treats a row-less grid as a single column", () => {
72
+ expect(gridColumnsTemplate(0, GAP, MIN)).toBe(
73
+ gridColumnsTemplate(1, GAP, MIN),
74
+ );
75
+ expect(resolveColumnCount(gridColumnsTemplate(1, GAP, MIN), 976, 16)).toBe(
76
+ 1,
77
+ );
78
+ });
79
+
80
+ it("subtracts the gaps the row actually renders", () => {
81
+ expect(gridColumnsTemplate(3, "2rem", MIN)).toContain("(100% - 2 * 2rem)");
82
+ expect(gridColumnsTemplate(1, GAP, MIN)).toContain("(100% - 0 * 1rem)");
83
+ });
84
+
85
+ it("honours a caller-supplied minimum", () => {
86
+ expect(gridColumnsTemplate(4, GAP, "120px")).toContain("120px");
87
+ expect(
88
+ resolveColumnCount(gridColumnsTemplate(4, GAP, "120px"), 657, 16),
89
+ ).toBe(4);
90
+ });
91
+ });
92
+
93
+ describe("grid components", () => {
94
+ it("drive both the container and its rows from the shared template", () => {
95
+ for (const file of ["Grid.vue", "GridRow.vue"]) {
96
+ const source = sourceOf(file);
97
+ expect(source).toContain("gridColumnsTemplate(");
98
+ expect(source).not.toMatch(/repeat\(\$\{/);
99
+ }
100
+ });
101
+
102
+ it("keeps rows spanning the full grid width", () => {
103
+ expect(sourceOf("GridRow.vue")).toContain('gridColumn: "1 / -1"');
104
+ });
105
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { extractRouteParams } from "../layers/dms-layout/app/composables/page/useSiteLayout";
3
+ import {
4
+ replaceUrlVariables,
5
+ resolveSubmitDefaults,
6
+ } from "../layers/dms-ui/app/composables/form/useForm";
7
+
8
+ // The forms of a table view filtered by `routeParamFilters: { id: { field:
9
+ // "_instance" } }`, registered below a page whose own slug is `:id`. The tokens
10
+ // are what the TableView factory generates for each form page route.
11
+ const EDIT_PATTERN = "/workspaces/:id/invoiceTable/:id/edit";
12
+ const NEW_PATTERN = "/workspaces/:id/invoiceTable/new";
13
+ const EDIT_DEFAULTS = { _instance: "{{params.id:1}}" };
14
+ const NEW_DEFAULTS = { _instance: "{{params.id}}" };
15
+ const EDIT_SUBMIT_URL = "/api/invoices/edit?id={{params.id}}";
16
+
17
+ const contextOf = (pattern: string, path: string) => ({
18
+ routeParams: extractRouteParams(pattern, path)!,
19
+ routeQuery: {},
20
+ });
21
+
22
+ describe("route param filter defaults on a nested form page", () => {
23
+ it("submits the page id in the filtered field, the row id in the URL", () => {
24
+ const context = contextOf(
25
+ EDIT_PATTERN,
26
+ "/workspaces/ws-42/invoiceTable/inv-7/edit",
27
+ );
28
+
29
+ expect(resolveSubmitDefaults(EDIT_DEFAULTS, context)).toEqual({
30
+ _instance: "ws-42",
31
+ });
32
+ expect(replaceUrlVariables(EDIT_SUBMIT_URL, context)).toBe(
33
+ "/api/invoices/edit?id=inv-7",
34
+ );
35
+ });
36
+
37
+ it("submits the page id from the new form, whose route has one :id", () => {
38
+ expect(
39
+ resolveSubmitDefaults(
40
+ NEW_DEFAULTS,
41
+ contextOf(NEW_PATTERN, "/workspaces/ws-42/invoiceTable/new"),
42
+ ),
43
+ ).toEqual({ _instance: "ws-42" });
44
+ });
45
+
46
+ // A form reached at an absolute URL does not carry the page's parameters:
47
+ // the default is dropped rather than submitted as a literal token.
48
+ it("drops a default no parameter of the route answers", () => {
49
+ const context = contextOf(
50
+ "/modules/automation/builder/:id",
51
+ "/modules/automation/builder/p-1",
52
+ );
53
+
54
+ expect(resolveSubmitDefaults(EDIT_DEFAULTS, context)).toBe(undefined);
55
+ expect(
56
+ resolveSubmitDefaults({ ...EDIT_DEFAULTS, status: "draft" }, context),
57
+ ).toEqual({ status: "draft" });
58
+ });
59
+ });