@abgov/nx-adsp 13.27.0 → 13.28.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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/generators/vue-admin-crud/vue-admin-crud.js +2 -0
  3. package/src/generators/vue-admin-crud/vue-admin-crud.js.map +1 -1
  4. package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +1 -1
  5. package/src/generators/vue-components/files/AGENTS.md__tmpl__ +1 -1
  6. package/src/generators/vue-components/files/src/lib/formatters.spec.ts__tmpl__ +26 -0
  7. package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +34 -2
  8. package/src/generators/vue-components/files/src/lib/patterns/Stepper.spec.ts__tmpl__ +42 -0
  9. package/src/generators/vue-components/files/src/lib/patterns/Stepper.vue__tmpl__ +18 -2
  10. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.spec.ts__tmpl__ +33 -0
  11. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +9 -3
  12. package/src/generators/vue-components/files/src/lib/primitives/GoabDatePicker.vue__tmpl__ +8 -2
  13. package/src/generators/vue-components/vue-components.spec.ts +31 -0
  14. package/src/generators/vue-detail-view/files/src/views/__viewFileName__.vue__tmpl__ +2 -2
  15. package/src/generators/vue-detail-view/vue-detail-view.spec.ts +7 -3
  16. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +1 -1
  17. package/src/generators/vue-intake-view/vue-intake-view.js +7 -1
  18. package/src/generators/vue-intake-view/vue-intake-view.js.map +1 -1
  19. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +9 -1
  20. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +54 -2
  21. package/src/generators/vue-workspace-view/schema.d.ts +15 -1
  22. package/src/generators/vue-workspace-view/schema.json +11 -1
  23. package/src/generators/vue-workspace-view/vue-workspace-view.js +18 -1
  24. package/src/generators/vue-workspace-view/vue-workspace-view.js.map +1 -1
  25. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +73 -4
  26. package/src/utils/vue-router.d.ts +7 -0
  27. package/src/utils/vue-router.js +6 -2
  28. package/src/utils/vue-router.js.map +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.27.0",
3
+ "version": "13.28.0",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -43,11 +43,13 @@ function default_1(host, options) {
43
43
  path: `${normalizedOptions.route}/:id`,
44
44
  componentImportPath: `../views/${normalizedOptions.editViewFileName}.vue`,
45
45
  requiresAuth: normalizedOptions.requiresAuth,
46
+ layout: 'form',
46
47
  });
47
48
  (0, vue_router_1.insertVueRoute)(host, normalizedOptions.projectRoot, normalizedOptions.project, {
48
49
  path: normalizedOptions.route,
49
50
  componentImportPath: `../views/${normalizedOptions.listViewFileName}.vue`,
50
51
  requiresAuth: normalizedOptions.requiresAuth,
52
+ layout: 'wide',
51
53
  });
52
54
  yield (0, devkit_1.formatFiles)(host);
53
55
  });
@@ -1 +1 @@
1
- {"version":3,"file":"vue-admin-crud.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-admin-crud/vue-admin-crud.ts"],"names":[],"mappings":";;AAkDA,4BAiCC;;AAnFD,uCAMoB;AACpB,6BAA6B;AAC7B,uDAAwD;AACxD,qEAE0C;AAG1C,6EAA6E;AAC7E,4EAA4E;AAC5E,8EAA8E;AAC9E,2EAA2E;AAC3E,0EAA0E;AAC1E,6EAA6E;AAC7E,iEAAiE;AACjE,SAAS,WAAW,CAAC,MAAwB;IAC3C,MAAM,MAAM,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACxE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CACb,sFAAsF,CACvF,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAU,EAAE,OAAe;;IACnD,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,IAAA,iCAAwB,EAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IAE9E,MAAM,SAAS,GAAG,IAAA,cAAK,EAAC,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC;IAChD,0EAA0E;IAC1E,+BAA+B;IAC/B,MAAM,OAAO,GAAG,MAAA,OAAO,CAAC,OAAO,mCAAI,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;IAC/E,uCACK,OAAO,KACV,WAAW,EACX,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,EACnC,gBAAgB,EAAE,GAAG,SAAS,UAAU,EACxC,gBAAgB,EAAE,GAAG,SAAS,UAAU,EACxC,OAAO,EACP,aAAa,EAAE,MAAA,OAAO,CAAC,aAAa,mCAAI,OAAO,EAC/C,YAAY,EAAE,MAAA,OAAO,CAAC,YAAY,mCAAI,IAAI,IAC1C;AACJ,CAAC;AAED,mBAA+B,IAAU,EAAE,OAAe;;QACxD,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAE1D,2EAA2E;QAC3E,oCAAoC;QACpC,MAAM,IAAA,wBAAsB,EAAC,IAAI,CAAC,CAAC;QAEnC,IAAA,sBAAa,EACX,IAAI,EACJ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAC7B,iBAAiB,CAAC,WAAW,kCAExB,iBAAiB,KACpB,aAAa,EAAE,IAAA,wCAAuB,EAAC,IAAI,CAAC,EAC5C,IAAI,EAAE,EAAE,IAEX,CAAC;QAEF,yEAAyE;QACzE,sEAAsE;QACtE,uEAAuE;QACvE,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,GAAG,iBAAiB,CAAC,KAAK,MAAM;YACtC,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,gBAAgB,MAAM;YACzE,YAAY,EAAE,iBAAiB,CAAC,YAAY;SAC7C,CAAC,CAAC;QACH,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,iBAAiB,CAAC,KAAK;YAC7B,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,gBAAgB,MAAM;YACzE,YAAY,EAAE,iBAAiB,CAAC,YAAY;SAC7C,CAAC,CAAC;QAEH,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
1
+ {"version":3,"file":"vue-admin-crud.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-admin-crud/vue-admin-crud.ts"],"names":[],"mappings":";;AAkDA,4BAmCC;;AArFD,uCAMoB;AACpB,6BAA6B;AAC7B,uDAAwD;AACxD,qEAE0C;AAG1C,6EAA6E;AAC7E,4EAA4E;AAC5E,8EAA8E;AAC9E,2EAA2E;AAC3E,0EAA0E;AAC1E,6EAA6E;AAC7E,iEAAiE;AACjE,SAAS,WAAW,CAAC,MAAwB;IAC3C,MAAM,MAAM,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACxE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CACb,sFAAsF,CACvF,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAU,EAAE,OAAe;;IACnD,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,IAAA,iCAAwB,EAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IAE9E,MAAM,SAAS,GAAG,IAAA,cAAK,EAAC,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC;IAChD,0EAA0E;IAC1E,+BAA+B;IAC/B,MAAM,OAAO,GAAG,MAAA,OAAO,CAAC,OAAO,mCAAI,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;IAC/E,uCACK,OAAO,KACV,WAAW,EACX,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,EACnC,gBAAgB,EAAE,GAAG,SAAS,UAAU,EACxC,gBAAgB,EAAE,GAAG,SAAS,UAAU,EACxC,OAAO,EACP,aAAa,EAAE,MAAA,OAAO,CAAC,aAAa,mCAAI,OAAO,EAC/C,YAAY,EAAE,MAAA,OAAO,CAAC,YAAY,mCAAI,IAAI,IAC1C;AACJ,CAAC;AAED,mBAA+B,IAAU,EAAE,OAAe;;QACxD,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAE1D,2EAA2E;QAC3E,oCAAoC;QACpC,MAAM,IAAA,wBAAsB,EAAC,IAAI,CAAC,CAAC;QAEnC,IAAA,sBAAa,EACX,IAAI,EACJ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAC7B,iBAAiB,CAAC,WAAW,kCAExB,iBAAiB,KACpB,aAAa,EAAE,IAAA,wCAAuB,EAAC,IAAI,CAAC,EAC5C,IAAI,EAAE,EAAE,IAEX,CAAC;QAEF,yEAAyE;QACzE,sEAAsE;QACtE,uEAAuE;QACvE,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,GAAG,iBAAiB,CAAC,KAAK,MAAM;YACtC,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,gBAAgB,MAAM;YACzE,YAAY,EAAE,iBAAiB,CAAC,YAAY;YAC5C,MAAM,EAAE,MAAM;SACf,CAAC,CAAC;QACH,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,iBAAiB,CAAC,KAAK;YAC7B,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,gBAAgB,MAAM;YACzE,YAAY,EAAE,iBAAiB,CAAC,YAAY;YAC5C,MAAM,EAAE,MAAM;SACf,CAAC,CAAC;QAEH,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
@@ -177,7 +177,7 @@ describe('Vue Admin CRUD Generator', () => {
177
177
  "component: () => import('../views/RegionsEditView.vue')",
178
178
  );
179
179
  expect(
180
- routerTs.split('meta: { requiresAuth: true }').length - 1,
180
+ routerTs.split('requiresAuth: true').length - 1,
181
181
  ).toBe(2);
182
182
  // The existing route is untouched, not replaced.
183
183
  expect(routerTs).toContain("{ path: '/', component: HomeView }");
@@ -56,7 +56,7 @@ inline styles.
56
56
  |---|---|---|
57
57
  | `<div style="background:white;border:1px solid …;border-radius:…;padding:…">` | `<goa-container>` | The bordered, padded card box. `type`/`accent` variants |
58
58
  | `<div style="display:flex;gap:…;align-items:…">` | `<goa-block>` | Flex row or stack, `gap` and `direction` props |
59
- | `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(…))">` | `<goa-grid>` | Responsive auto-fit grid, `min-child-width` prop |
59
+ | `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(…))">` | `<goa-grid>` | Responsive auto-fit grid, `minchildwidth` prop (all lowercase — `min-child-width` silently does nothing) |
60
60
  | `<h2 style="font-size:…;font-weight:…;color:…">`, styled `span`/`p` | `<goa-text>` | Typography scale: `size`, `weight`, `color`, `mt`/`mb` |
61
61
  | `<table>` with `<th style>`/`<td style>` | `<goa-table>` (+ `<goa-table-sort-header>`) | For any table that isn't a paginated list view — `WorkspaceTable` already wraps this for that case |
62
62
  | Tab `<button>`s toggling `v-if` blocks | `<goa-tabs>` + `<goa-tab heading="…">` | Slot-based and self-managing: `initialtab` sets the first open tab, no `v-model` and no `@_change` |
@@ -13,6 +13,32 @@ describe('formatters', () => {
13
13
  expect(formatDateTime('2026-08-28T14:05:00.000Z')).toMatch(/\d{1,2}:\d{2}/);
14
14
  });
15
15
 
16
+ // The input every generated `type: 'date'` column and field actually carries.
17
+ // Its absence is why a UTC-parsing bug shipped: the suite covered ISO
18
+ // timestamps, Dates and epoch numbers, but never a bare calendar date.
19
+ it.each([
20
+ ['2026-07-14'],
21
+ ['2026-01-01'],
22
+ ['2026-12-31'],
23
+ ])('renders the bare calendar date %s unchanged, in any timezone', (value) => {
24
+ expect(formatDate(value)).toBe(value);
25
+ });
26
+
27
+ it('does not invent a midnight for a date-only value', () => {
28
+ expect(formatDateTime('2026-07-14')).toBe('2026-07-14');
29
+ });
30
+
31
+ it('still renders a time for a real instant', () => {
32
+ expect(formatDateTime('2026-07-14T14:05:00.000Z')).toMatch(/\d{1,2}:\d{2}/);
33
+ });
34
+
35
+ it.each(['2026-13-45', '2026-02-30', '2026-00-10'])(
36
+ 'rejects the out-of-range date %s rather than rolling it over',
37
+ (value) => {
38
+ expect(formatDate(value)).toBe('—');
39
+ },
40
+ );
41
+
16
42
  it('accepts a Date, an epoch number, and an ISO string alike', () => {
17
43
  const iso = '2026-08-28T00:00:00.000Z';
18
44
  const expected = formatDate(iso);
@@ -21,6 +21,13 @@ const currency = new Intl.NumberFormat(LOCALE, {
21
21
  currency: 'CAD',
22
22
  });
23
23
 
24
+ // A bare YYYY-MM-DD names a calendar date, not an instant, and must be built
25
+ // from local parts. `new Date('2026-07-14')` parses as UTC midnight, which in
26
+ // Alberta (UTC-6/-7) is 13 July 18:00 local — so the naive path renders the day
27
+ // before, and on 1 January the year before. Every `type: 'date'` field the view
28
+ // generators emit arrives in exactly this form.
29
+ const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
30
+
24
31
  // `unknown` rather than a date union: views read fields off a record whose shape
25
32
  // the generator doesn't know, so every call site would otherwise need a cast.
26
33
  // Anything that isn't a usable date becomes the em dash, same as null.
@@ -34,6 +41,23 @@ function toValidDate(value: unknown): Date | null {
34
41
  return Number.isNaN(value.getTime()) ? null : value;
35
42
  }
36
43
  if (typeof value !== 'string' && typeof value !== 'number') return null;
44
+
45
+ if (typeof value === 'string') {
46
+ const parts = DATE_ONLY.exec(value);
47
+ if (parts) {
48
+ const [, year, month, day] = parts.map(Number);
49
+ const local = new Date(year, month - 1, day);
50
+ // Date's constructor rolls over out-of-range parts (month 13 becomes
51
+ // January of the next year) instead of failing, so round-trip the parts
52
+ // to reject '2026-13-45' and '2026-02-30' rather than render a wrong day.
53
+ return local.getFullYear() === year &&
54
+ local.getMonth() === month - 1 &&
55
+ local.getDate() === day
56
+ ? local
57
+ : null;
58
+ }
59
+ }
60
+
37
61
  const date = new Date(value);
38
62
  return Number.isNaN(date.getTime()) ? null : date;
39
63
  }
@@ -44,10 +68,18 @@ export function formatDate(value: unknown): string {
44
68
  return date ? dateOnly.format(date) : EMPTY;
45
69
  }
46
70
 
47
- /** Date with a short time, for audit/activity timestamps. */
71
+ /**
72
+ * Date with a short time, for audit/activity timestamps. A date-only value has
73
+ * no time to show, so it falls back to the date alone rather than inventing a
74
+ * midnight that was never in the data.
75
+ */
48
76
  export function formatDateTime(value: unknown): string {
49
77
  const date = toValidDate(value);
50
- return date ? dateAndTime.format(date) : EMPTY;
78
+ if (!date) return EMPTY;
79
+ if (typeof value === 'string' && DATE_ONLY.test(value)) {
80
+ return dateOnly.format(date);
81
+ }
82
+ return dateAndTime.format(date);
51
83
  }
52
84
 
53
85
  /**
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import Stepper from './Stepper.vue';
4
+
5
+ // goa-form-stepper's own `step` prop (1-based) drives the progress bar and the
6
+ // current-step emphasis; goa-form-step's status enum has no "current" value.
7
+ // Without it the element defaults to 1 and every page of a wizard shows step one
8
+ // as current.
9
+ const steps = [
10
+ { key: 'a', label: 'A', status: 'complete' as const },
11
+ { key: 'b', label: 'B', status: 'incomplete' as const },
12
+ { key: 'c', label: 'C', status: 'not-started' as const },
13
+ ];
14
+
15
+ const stepAttr = (step?: number) =>
16
+ mount(Stepper, { props: step === undefined ? { steps } : { steps, step } })
17
+ .find('goa-form-stepper')
18
+ .attributes('step');
19
+
20
+ describe('Stepper', () => {
21
+ it('forwards the current step to goa-form-stepper', () => {
22
+ expect(stepAttr(2)).toBe('2');
23
+ expect(stepAttr(3)).toBe('3');
24
+ });
25
+
26
+ it('defaults to the first step', () => {
27
+ expect(stepAttr()).toBe('1');
28
+ });
29
+
30
+ it('clamps out of range rather than handing the element a bad progress value', () => {
31
+ // goa-form-stepper sets a <progress> value from this; out of range yields a
32
+ // non-finite value that throws in the browser.
33
+ expect(stepAttr(0)).toBe('1');
34
+ expect(stepAttr(-5)).toBe('1');
35
+ expect(stepAttr(99)).toBe('3');
36
+ });
37
+
38
+ it('clamps to 1 when there are no steps', () => {
39
+ const el = mount(Stepper, { props: { steps: [], step: 4 } }).find('goa-form-stepper');
40
+ expect(el.attributes('step')).toBe('1');
41
+ });
42
+ });
@@ -1,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import { computed } from 'vue';
2
3
  // Wraps the real, native goa-form-stepper/goa-form-step pair (confirmed via
3
4
  // design.alberta.ca/components/form-stepper -- there wasn't one when the spec
4
5
  // this is based on was written, hence "nobody used a native GoA stepper"; that
@@ -17,12 +18,27 @@ export interface StepperStep {
17
18
  status: 'complete' | 'incomplete' | 'not-started';
18
19
  }
19
20
 
20
- const props = defineProps<{ steps: StepperStep[] }>();
21
+ // `step` is goa-form-stepper's OWN prop (1-based) and is what drives the
22
+ // progress bar and the current-step emphasis. Without it the element defaults
23
+ // to 1, so every page of a wizard renders step one as current and the internal
24
+ // <progress> sits at zero. goa-form-step's status enum has no "current" value —
25
+ // that state lives on the parent, which is easy to miss when reading only the
26
+ // child's API.
27
+ const props = withDefaults(
28
+ defineProps<{ steps: StepperStep[]; step?: number }>(),
29
+ { step: 1 },
30
+ );
31
+
32
+ // Clamped: the element sets a <progress> value from this, and an out-of-range
33
+ // number yields a non-finite value that throws in the browser.
34
+ const currentStep = computed(() =>
35
+ Math.min(Math.max(props.step, 1), Math.max(props.steps.length, 1)),
36
+ );
21
37
  const emit = defineEmits<{ select: [key: string] }>();
22
38
  </script>
23
39
 
24
40
  <template>
25
- <goa-form-stepper>
41
+ <goa-form-stepper :step="currentStep">
26
42
  <goa-form-step
27
43
  v-for="(step, index) in props.steps"
28
44
  :key="step.key"
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import WorkspaceTable from './WorkspaceTable.vue';
4
+
5
+ // goa-pagination registers pagenumber/itemcount/perpagecount with no
6
+ // attribute: alias, so kebab-case bindings never arrive and the control
7
+ // renders "Page — of NaN".
8
+ describe('WorkspaceTable pagination wiring', () => {
9
+ const props = {
10
+ columns: [{ key: 'a', label: 'A' }],
11
+ rows: [{ a: 1 }],
12
+ loading: false,
13
+ error: null,
14
+ page: 2,
15
+ itemCount: 34,
16
+ perPageCount: 10,
17
+ };
18
+
19
+ it('passes the lowercase attribute names the element registers', () => {
20
+ const el = mount(WorkspaceTable, { props }).find('goa-pagination');
21
+ expect(el.exists()).toBe(true);
22
+ expect(el.attributes('pagenumber')).toBe('2');
23
+ expect(el.attributes('itemcount')).toBe('34');
24
+ expect(el.attributes('perpagecount')).toBe('10');
25
+ });
26
+
27
+ it('does not emit the kebab-case names that silently do nothing', () => {
28
+ const el = mount(WorkspaceTable, { props }).find('goa-pagination');
29
+ for (const wrong of ['page-number', 'item-count', 'per-page-count']) {
30
+ expect(el.attributes(wrong)).toBeUndefined();
31
+ }
32
+ });
33
+ });
@@ -115,12 +115,18 @@ function ariaSort(
115
115
 
116
116
  <goa-spacer vspacing="l" />
117
117
 
118
+ <!-- All-lowercase, not kebab-case: goa-pagination registers `pagenumber`,
119
+ `itemcount`, `perpagecount` with no `attribute:` alias, so kebab-case
120
+ bindings never reach it and the control renders "Page — of NaN".
121
+ (goa-work-side-menu's `userName` DOES declare attribute: "user-name",
122
+ which is why the design system looks inconsistent here -- check the
123
+ registered name per element rather than assuming a convention.) -->
118
124
  <goa-pagination
119
125
  v-if="page && itemCount && perPageCount && itemCount > perPageCount"
120
126
  version="2"
121
- :page-number="page"
122
- :item-count="itemCount"
123
- :per-page-count="perPageCount"
127
+ :pagenumber="page"
128
+ :itemcount="itemCount"
129
+ :perpagecount="perPageCount"
124
130
  @_change="(e) => emit('pageChange', (e as CustomEvent<{ page: number }>).detail?.page ?? 1)"
125
131
  />
126
132
  </template>
@@ -9,8 +9,14 @@
9
9
  // wrappers read, so it does not use skeleton A verbatim.
10
10
  //
11
11
  // Consumers that need a wire format (a query parameter, a JSON body) convert at
12
- // that boundary `date?.toISOString().slice(0, 10)` for YYYY-MM-DD rather
13
- // than this wrapper guessing which format is wanted.
12
+ // that boundary, and must build YYYY-MM-DD from LOCAL calendar parts:
13
+ //
14
+ // `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
15
+ //
16
+ // NOT `toISOString().slice(0, 10)`. toISOString goes through UTC, and the UTC
17
+ // day of a local-midnight Date is the previous day anywhere west of Greenwich —
18
+ // in Alberta (UTC-6/-7) that files every date one day early. FilterBar's
19
+ // `toIsoDate` is the reference implementation.
14
20
  //
15
21
  // Usage: <GoabDatePicker v-model="startDate" name="startDate" :max="new Date()" />
16
22
  const model = defineModel<Date | undefined>();
@@ -75,6 +75,35 @@ describe('Vue Components Generator', () => {
75
75
  const filterBar = host
76
76
  .read('libs/vue-components/src/lib/patterns/FilterBar.vue')
77
77
  .toString();
78
+ // goa-pagination registers all-lowercase prop names with no attribute:
79
+ // alias, so kebab-case bindings silently never arrive and the control
80
+ // renders "Page — of NaN".
81
+ const table = host
82
+ .read('libs/vue-components/src/lib/patterns/WorkspaceTable.vue')
83
+ .toString();
84
+ for (const prop of [':pagenumber=', ':itemcount=', ':perpagecount=']) {
85
+ expect(table).toContain(prop);
86
+ }
87
+ for (const wrong of [':page-number=', ':item-count=', ':per-page-count=']) {
88
+ expect(table).not.toContain(wrong);
89
+ }
90
+
91
+ // A bare YYYY-MM-DD is a calendar date, not an instant: parsing it through
92
+ // `new Date(string)` yields UTC midnight, which in Alberta is the day before.
93
+ const formattersSrc = host
94
+ .read('libs/vue-components/src/lib/formatters.ts')
95
+ .toString();
96
+ // goa-form-stepper's `step` prop drives the progress bar; without it every
97
+ // page of a wizard renders step one as current.
98
+ const stepper = host
99
+ .read('libs/vue-components/src/lib/patterns/Stepper.vue')
100
+ .toString();
101
+ expect(stepper).toContain(':step="currentStep"');
102
+ expect(stepper).toContain('Math.min(Math.max(props.step, 1)');
103
+
104
+ expect(formattersSrc).toContain('DATE_ONLY');
105
+ expect(formattersSrc).toContain('local.getFullYear() === year');
106
+
78
107
  expect(filterBar).toContain('goa-filter-chip');
79
108
  expect(filterBar).toContain('goa-details');
80
109
  // Precise API forms, not bare words -- 'page' appears in the component's own
@@ -99,6 +128,8 @@ describe('Vue Components Generator', () => {
99
128
  for (const spec of [
100
129
  'libs/vue-components/src/lib/patterns/FilterBar.spec.ts',
101
130
  'libs/vue-components/src/lib/primitives/GoabDatePicker.spec.ts',
131
+ 'libs/vue-components/src/lib/patterns/WorkspaceTable.spec.ts',
132
+ 'libs/vue-components/src/lib/patterns/Stepper.spec.ts',
102
133
  ]) {
103
134
  expect(host.exists(spec)).toBeTruthy();
104
135
  }
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
- import { RecordDetailShell, formatCurrency, formatDateTime } from '<%= goaImportPath %>';
4
+ import { RecordDetailShell, formatCurrency, formatDate } from '<%= goaImportPath %>';
5
5
  import { useApi } from '../composables/useApi';
6
6
 
7
7
  // The fetched record's shape isn't known to this generator -- read fields
@@ -54,7 +54,7 @@ function goBack() {
54
54
  <% if (field.type === 'badge') { -%>
55
55
  <goa-badge type="information" :content="String(record['<%= field.key %>'] ?? '—')" />
56
56
  <% } else if (field.type === 'date') { -%>
57
- {{ formatDateTime(record['<%= field.key %>']) }}
57
+ {{ formatDate(record['<%= field.key %>']) }}
58
58
  <% } else if (field.type === 'currency') { -%>
59
59
  {{ formatCurrency(record['<%= field.key %>']) }}
60
60
  <% } else { -%>
@@ -111,12 +111,14 @@ describe('Vue Detail View Generator', () => {
111
111
  expect(view).not.toContain('onMounted(');
112
112
  expect(view).not.toContain('function formatCurrency');
113
113
 
114
- expect(view).toContain('formatDateTime');
114
+ expect(view).toContain('formatDate');
115
115
  expect(view).not.toContain('function formatDate');
116
116
  expect(view).toContain(
117
117
  "<goa-badge type=\"information\" :content=\"String(record['status'] ?? '—')\" />",
118
118
  );
119
- expect(view).toContain("formatDateTime(record['lastSaved'])");
119
+ // A type: 'date' column is a calendar date -- formatDate, not
120
+ // formatDateTime, which would show an invented midnight.
121
+ expect(view).toContain("formatDate(record['lastSaved'])");
120
122
  expect(view).toContain("formatCurrency(record['requestTotal'])");
121
123
  expect(view).toContain("record['serviceModel'] ?? '—'");
122
124
  expect(view).toContain('<dt>Status</dt>');
@@ -128,7 +130,7 @@ describe('Vue Detail View Generator', () => {
128
130
  view
129
131
  .replace(/\s+/g, ' ')
130
132
  .match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
131
- for (const name of ['RecordDetailShell', 'formatCurrency', 'formatDateTime']) {
133
+ for (const name of ['RecordDetailShell', 'formatCurrency', 'formatDate']) {
132
134
  expect(goaImport).toContain(name);
133
135
  }
134
136
  expect(view).toContain('<RecordDetailShell');
@@ -151,6 +153,8 @@ describe('Vue Detail View Generator', () => {
151
153
  "component: () => import('../views/ApplicationDetailView.vue')",
152
154
  );
153
155
  expect(routerTs).toContain('meta: { requiresAuth: true }');
156
+ // A record info list is neither a table nor a form -- default width.
157
+ expect(routerTs).not.toContain('layout:');
154
158
  // The existing route is untouched, not replaced.
155
159
  expect(routerTs).toContain("{ path: '/', component: HomeView }");
156
160
  }, 30000);
@@ -145,7 +145,7 @@ function goBack() {
145
145
 
146
146
  <template>
147
147
  <div>
148
- <Stepper :steps="stepperSteps" @select="onStepperSelect" />
148
+ <Stepper :steps="stepperSteps" :step="<%= stepNumber %>" @select="onStepperSelect" />
149
149
 
150
150
  <goa-spacer vspacing="l" />
151
151
 
@@ -53,7 +53,11 @@ function default_1(host, options) {
53
53
  steps.forEach((step, index) => {
54
54
  const nextStepKey = index === steps.length - 1 ? 'review' : steps[index + 1].key;
55
55
  (0, devkit_1.generateFiles)(host, path.join(__dirname, 'files/steps'), projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath, stepViewFileName: stepViewFileName(step.key), stepKey: step.key, stepLabel: step.label, stepFields: step.fields, stepperSteps,
56
- nextStepKey, tmpl: '' }));
56
+ nextStepKey,
57
+ // 1-based, for goa-form-stepper's own `step` prop. The generator knows
58
+ // which step it is emitting; the view would otherwise have to re-derive
59
+ // it from the route.
60
+ stepNumber: index + 1, tmpl: '' }));
57
61
  });
58
62
  (0, devkit_1.generateFiles)(host, path.join(__dirname, 'files/shared'), projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath,
59
63
  reviewViewFileName,
@@ -70,6 +74,7 @@ function default_1(host, options) {
70
74
  path: `${normalizedOptions.route}/:id/review`,
71
75
  componentImportPath: `../views/${reviewViewFileName}.vue`,
72
76
  requiresAuth: normalizedOptions.requiresAuth,
77
+ layout: 'form',
73
78
  });
74
79
  for (let i = steps.length - 1; i >= 0; i--) {
75
80
  const step = steps[i];
@@ -77,6 +82,7 @@ function default_1(host, options) {
77
82
  path: `${normalizedOptions.route}/:id/${step.key}`,
78
83
  componentImportPath: `../views/${stepViewFileName(step.key)}.vue`,
79
84
  requiresAuth: normalizedOptions.requiresAuth,
85
+ layout: 'form',
80
86
  });
81
87
  }
82
88
  yield (0, devkit_1.formatFiles)(host);
@@ -1 +1 @@
1
- {"version":3,"file":"vue-intake-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-intake-view/vue-intake-view.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"vue-intake-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-intake-view/vue-intake-view.ts"],"names":[],"mappings":"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"}
@@ -107,6 +107,14 @@ describe('Vue Intake View Generator', () => {
107
107
  .toString();
108
108
  // Last step in the list moves to review, not another step.
109
109
  expect(step2).toContain('/applications/${nextId}/review');
110
+ // Each step tells the Stepper which step it is (1-based) -- the generator
111
+ // knows, so the view doesn't re-derive it from the route. Without it every
112
+ // page renders step one as current.
113
+ expect(step1).toContain(':step="1"');
114
+ expect(step2).toContain(':step="2"');
115
+ // Single-column forms ask for the narrow variant.
116
+ const routerForm = host.read('apps/test/src/router/index.ts').toString();
117
+ expect(routerForm).toContain("layout: 'form'");
110
118
  // A required field gets a validation block + requirement="required"; an
111
119
  // explicitly non-required one gets neither.
112
120
  expect(step2).toContain("found.push({ message: 'Email is required.'");
@@ -156,7 +164,7 @@ describe('Vue Intake View Generator', () => {
156
164
  "component: () => import('../views/ApplicationConfirmationView.vue')",
157
165
  );
158
166
  expect(
159
- routerTs.split('meta: { requiresAuth: true }').length - 1,
167
+ routerTs.split('requiresAuth: true').length - 1,
160
168
  ).toBe(4);
161
169
  // The existing route is untouched, not replaced.
162
170
  expect(routerTs).toContain("{ path: '/', component: HomeView }");
@@ -1,6 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, onMounted<% if (filterable) { %>, onUnmounted<% } %> } from 'vue';
3
- import { WorkspaceTable, formatCurrency, formatDateTime } from '<%= goaImportPath %>';
3
+ import {
4
+ WorkspaceTable,
5
+ <% if (filters.length) { -%>
6
+ FilterBar,
7
+ <% } -%>
8
+ formatCurrency,
9
+ formatDate,
10
+ } from '<%= goaImportPath %>';
4
11
  import { useApi } from '../composables/useApi';
5
12
 
6
13
  const { list } = useApi();
@@ -25,6 +32,38 @@ const search = ref('');
25
32
  let searchDebounce: ReturnType<typeof setTimeout> | undefined;
26
33
  <% } -%>
27
34
 
35
+ <% if (filters.length) { -%>
36
+ // A ref, not a const: a dropdown's options can be fetched (load them in `load`
37
+ // or a second call and assign into the matching descriptor) without changing
38
+ // FilterBar's contract.
39
+ const filterDescriptors = ref([
40
+ <% filters.forEach(function (filter) { -%>
41
+ {
42
+ key: '<%- filter.key %>',
43
+ label: '<%- filter.label %>',
44
+ type: '<%- filter.type %>' as const,
45
+ <% if (filter.type === 'dropdown') { -%>
46
+ options: <%- JSON.stringify(filter.options ?? []) %>,
47
+ <% if (filter.anyLabel) { -%>
48
+ anyLabel: '<%- filter.anyLabel %>',
49
+ <% } -%>
50
+ <% } -%>
51
+ },
52
+ <% }); -%>
53
+ ]);
54
+
55
+ // Query-ready values, keyed by filter. FilterBar owns the controls and the
56
+ // chips; the page reset and the reload below belong here because `page` is this
57
+ // view's state.
58
+ const filterValues = ref<Record<string, string>>({});
59
+
60
+ function onFiltersChange(next: Record<string, string>) {
61
+ filterValues.value = next;
62
+ page.value = 1;
63
+ void load();
64
+ }
65
+ <% } -%>
66
+
28
67
  const PAGE_SIZE = <%= pageSize %>;
29
68
 
30
69
  // load() fires from page change, sort<% if (filterable) { %>, and the debounced search<% } %>, so two requests can
@@ -48,6 +87,9 @@ async function load() {
48
87
  <% } -%>
49
88
  sortBy: sortBy.value,
50
89
  sortDir: sortDir.value,
90
+ <% if (filters.length) { -%>
91
+ filters: filterValues.value,
92
+ <% } -%>
51
93
  });
52
94
  if (sequence !== loadSequence) return;
53
95
  rows.value = result.rows;
@@ -118,6 +160,16 @@ onUnmounted(() => {
118
160
 
119
161
  <goa-spacer vspacing="l" />
120
162
  <% } -%>
163
+ <% if (filters.length) { -%>
164
+
165
+ <FilterBar
166
+ :filters="filterDescriptors"
167
+ :model-value="filterValues"
168
+ @update:model-value="onFiltersChange"
169
+ />
170
+
171
+ <goa-spacer vspacing="l" />
172
+ <% } -%>
121
173
 
122
174
  <WorkspaceTable
123
175
  :columns="columns"
@@ -140,7 +192,7 @@ onUnmounted(() => {
140
192
  </template>
141
193
  <% } else if (column.type === 'date') { -%>
142
194
  <template #cell-<%= column.key %>="{ row }">
143
- {{ formatDateTime(row['<%= column.key %>']) }}
195
+ {{ formatDate(row['<%= column.key %>']) }}
144
196
  </template>
145
197
  <% } else if (column.type === 'currency') { -%>
146
198
  <template #cell-<%= column.key %>="{ row }">
@@ -5,6 +5,16 @@ export interface WorkspaceViewColumn {
5
5
  sortable?: boolean;
6
6
  }
7
7
 
8
+ export interface WorkspaceViewFilter {
9
+ key: string;
10
+ label: string;
11
+ type: 'dropdown' | 'date';
12
+ /** Dropdown only. Omit and populate `filterDescriptors` at runtime to fetch them. */
13
+ options?: { value: string; label: string }[];
14
+ /** Dropdown only. Label for the "no filter" choice. Defaults to "Any". */
15
+ anyLabel?: string;
16
+ }
17
+
8
18
  export interface Schema {
9
19
  project: string;
10
20
  name: string;
@@ -16,6 +26,8 @@ export interface Schema {
16
26
  * also accepted for programmatic callers (e.g. tests).
17
27
  */
18
28
  columns: string | WorkspaceViewColumn[];
29
+ /** Same JSON-string-on-the-CLI reasoning as `columns`. */
30
+ filters?: string | WorkspaceViewFilter[];
19
31
  detailRoute?: string;
20
32
  heading?: string;
21
33
  pageSize?: number;
@@ -23,11 +35,13 @@ export interface Schema {
23
35
  requiresAuth?: boolean;
24
36
  }
25
37
 
26
- export interface NormalizedSchema extends Omit<Schema, 'columns'> {
38
+ export interface NormalizedSchema extends Omit<Schema, 'columns' | 'filters'> {
27
39
  projectRoot: string;
28
40
  /** PascalCase view name with a "ListView" suffix, e.g. ApplicationsListView. */
29
41
  viewFileName: string;
30
42
  columns: WorkspaceViewColumn[];
43
+ /** Empty when --filters wasn't given, so the template can skip the FilterBar. */
44
+ filters: WorkspaceViewFilter[];
31
45
  heading: string;
32
46
  pageSize: number;
33
47
  filterable: boolean;
@@ -35,6 +35,10 @@
35
35
  "type": "string",
36
36
  "description": "JSON array of table columns, in display order -- e.g. '[{\"key\":\"status\",\"label\":\"Status\",\"type\":\"badge\",\"sortable\":true}]'. Each item: { key, label, type?: \"text\"|\"date\"|\"currency\"|\"badge\" (default \"text\"), sortable?: boolean }. A plain array is also accepted when this generator is invoked programmatically. Nx's CLI option coercion only supports comma-separated primitive lists for array-typed schema properties, not JSON -- a JSON string is the only CLI syntax that actually survives Nx's own arg parsing for a structured list like this."
37
37
  },
38
+ "filters": {
39
+ "type": "string",
40
+ "description": "JSON array of filter controls rendered above the table by FilterBar, beyond the --filterable search box -- e.g. '[{\"key\":\"status\",\"label\":\"Status\",\"type\":\"dropdown\",\"options\":[{\"value\":\"open\",\"label\":\"Open\"}]},{\"key\":\"from\",\"label\":\"From\",\"type\":\"date\"}]'. Each item: { key, label, type: \"dropdown\"|\"date\", options?: [{value,label}], anyLabel? }. Values are sent as query parameters alongside search/page/sort, so the API decides what each key means. Omit for no filter bar. Same JSON-string-on-the-CLI reasoning as --columns."
41
+ },
38
42
  "detailRoute": {
39
43
  "type": "string",
40
44
  "description": "If set, each row gets a \"View\" action linking to `${detailRoute}/${row.id}` -- typically a vue-detail-view's route with the :id segment dropped. Omit to leave the actions column for manual customization."
@@ -59,6 +63,12 @@
59
63
  "default": true
60
64
  }
61
65
  },
62
- "required": ["project", "name", "resource", "route", "columns"],
66
+ "required": [
67
+ "project",
68
+ "name",
69
+ "resource",
70
+ "route",
71
+ "columns"
72
+ ],
63
73
  "additionalProperties": false
64
74
  }
@@ -20,11 +20,26 @@ function parseColumns(columns) {
20
20
  }
21
21
  return parsed;
22
22
  }
23
+ // Same JSON-string-on-the-CLI reasoning as parseColumns above.
24
+ function parseFilters(filters) {
25
+ if (filters === undefined || filters === '')
26
+ return [];
27
+ const parsed = typeof filters === 'string' ? JSON.parse(filters) : filters;
28
+ if (!Array.isArray(parsed)) {
29
+ throw new Error('--filters must be a JSON array of { key, label, type: "dropdown"|"date", options?, anyLabel? } objects.');
30
+ }
31
+ for (const filter of parsed) {
32
+ if (filter.type !== 'dropdown' && filter.type !== 'date') {
33
+ throw new Error(`--filters[].type must be "dropdown" or "date"; got "${filter.type}" for "${filter.key}".`);
34
+ }
35
+ }
36
+ return parsed;
37
+ }
23
38
  function normalizeOptions(host, options) {
24
39
  var _a, _b, _c, _d, _e;
25
40
  const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
26
41
  const className = (0, devkit_1.names)(options.name).className;
27
- return Object.assign(Object.assign({}, options), { projectRoot, columns: parseColumns(options.columns), viewFileName: `${className}ListView`,
42
+ return Object.assign(Object.assign({}, options), { projectRoot, columns: parseColumns(options.columns), filters: parseFilters(options.filters), viewFileName: `${className}ListView`,
28
43
  // className is PascalCase (e.g. "Applications") -- space it out for a
29
44
  // readable default heading ("Applications").
30
45
  heading: (_a = options.heading) !== null && _a !== void 0 ? _a : className.replace(/([A-Z])/g, ' $1').trim(), pageSize: (_b = options.pageSize) !== null && _b !== void 0 ? _b : 20, filterable: (_c = options.filterable) !== null && _c !== void 0 ? _c : true, requiresAuth: (_d = options.requiresAuth) !== null && _d !== void 0 ? _d : true,
@@ -44,6 +59,8 @@ function default_1(host, options) {
44
59
  path: normalizedOptions.route,
45
60
  componentImportPath: `../views/${normalizedOptions.viewFileName}.vue`,
46
61
  requiresAuth: normalizedOptions.requiresAuth,
62
+ // A multi-column table at the 1000px default wraps every cell.
63
+ layout: 'wide',
47
64
  });
48
65
  yield (0, devkit_1.formatFiles)(host);
49
66
  });
@@ -1 +1 @@
1
- {"version":3,"file":"vue-workspace-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-workspace-view/vue-workspace-view.ts"],"names":[],"mappings":";;AAqDA,4BAyBC;;AA9ED,uCAMoB;AACpB,6BAA6B;AAC7B,uDAAwD;AACxD,qEAE0C;AAG1C,6EAA6E;AAC7E,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,0EAA0E;AAC1E,8EAA8E;AAC9E,iEAAiE;AACjE,SAAS,YAAY,CAAC,OAA0B;IAC9C,MAAM,MAAM,GAAG,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAC3E,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CACb,uFAAuF,CACxF,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAU,EAAE,OAAe;;IACnD,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,IAAA,iCAAwB,EAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IAE9E,MAAM,SAAS,GAAG,IAAA,cAAK,EAAC,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC;IAChD,uCACK,OAAO,KACV,WAAW,EACX,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,OAAO,CAAC,EACtC,YAAY,EAAE,GAAG,SAAS,UAAU;QACpC,sEAAsE;QACtE,6CAA6C;QAC7C,OAAO,EAAE,MAAA,OAAO,CAAC,OAAO,mCAAI,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,EACvE,QAAQ,EAAE,MAAA,OAAO,CAAC,QAAQ,mCAAI,EAAE,EAChC,UAAU,EAAE,MAAA,OAAO,CAAC,UAAU,mCAAI,IAAI,EACtC,YAAY,EAAE,MAAA,OAAO,CAAC,YAAY,mCAAI,IAAI;QAC1C,6EAA6E;QAC7E,2EAA2E;QAC3E,4EAA4E;QAC5E,WAAW,EAAE,MAAA,OAAO,CAAC,WAAW,mCAAI,IAAI,IACxC;AACJ,CAAC;AAED,mBAA+B,IAAU,EAAE,OAAe;;QACxD,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAE1D,2EAA2E;QAC3E,oCAAoC;QACpC,MAAM,IAAA,wBAAsB,EAAC,IAAI,CAAC,CAAC;QAEnC,IAAA,sBAAa,EACX,IAAI,EACJ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAC7B,iBAAiB,CAAC,WAAW,kCAExB,iBAAiB,KACpB,aAAa,EAAE,IAAA,wCAAuB,EAAC,IAAI,CAAC,EAC5C,IAAI,EAAE,EAAE,IAEX,CAAC;QAEF,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,iBAAiB,CAAC,KAAK;YAC7B,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,YAAY,MAAM;YACrE,YAAY,EAAE,iBAAiB,CAAC,YAAY;SAC7C,CAAC,CAAC;QAEH,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
1
+ {"version":3,"file":"vue-workspace-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-workspace-view/vue-workspace-view.ts"],"names":[],"mappings":"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"}
@@ -121,7 +121,7 @@ describe('Vue Workspace View Generator', () => {
121
121
  expect(view).toContain('clearTimeout(searchDebounce)');
122
122
 
123
123
  // Dates come from the shared formatter, not a per-view copy.
124
- expect(view).toContain('formatDateTime');
124
+ expect(view).toContain('formatDate');
125
125
  expect(view).not.toContain('function formatDate');
126
126
  expect(view).not.toContain('toLocaleString');
127
127
  expect(view).not.toContain('Intl.');
@@ -129,7 +129,9 @@ describe('Vue Workspace View Generator', () => {
129
129
  expect(view).toContain(
130
130
  "<goa-badge type=\"information\" :content=\"String(row['status'] ?? '—')\" />",
131
131
  );
132
- expect(view).toContain("formatDateTime(row['lastSaved'])");
132
+ // A type: 'date' column is a calendar date -- formatDate, not
133
+ // formatDateTime, which would show an invented midnight.
134
+ expect(view).toContain("formatDate(row['lastSaved'])");
133
135
  expect(view).toContain("formatCurrency(row['requestTotal'])");
134
136
  // Uses the shared table shell, not hand-rolled loading/pagination markup.
135
137
  // Read the import's contents rather than an exact line: formatFiles wraps a
@@ -138,7 +140,7 @@ describe('Vue Workspace View Generator', () => {
138
140
  view
139
141
  .replace(/\s+/g, ' ')
140
142
  .match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
141
- for (const name of ['WorkspaceTable', 'formatCurrency', 'formatDateTime']) {
143
+ for (const name of ['WorkspaceTable', 'formatCurrency', 'formatDate']) {
142
144
  expect(goaImport).toContain(name);
143
145
  }
144
146
  expect(view).toContain('<WorkspaceTable');
@@ -147,6 +149,71 @@ describe('Vue Workspace View Generator', () => {
147
149
  expect(view).toContain('searchDebounce');
148
150
  }, 30000);
149
151
 
152
+ describe('--filters', () => {
153
+ const filters = JSON.stringify([
154
+ {
155
+ key: 'status',
156
+ label: 'Status',
157
+ type: 'dropdown',
158
+ options: [{ value: 'open', label: 'Open' }],
159
+ anyLabel: 'All statuses',
160
+ },
161
+ { key: 'from', label: 'From', type: 'date' },
162
+ ]);
163
+
164
+ it('wires the shared FilterBar and sends the values as query filters', async () => {
165
+ await generator(host, { ...baseOptions, filters });
166
+ const view = host
167
+ .read('apps/test/src/views/ApplicationsListView.vue')
168
+ .toString();
169
+
170
+ expect(view).toContain('FilterBar');
171
+ expect(view).toContain(':filters="filterDescriptors"');
172
+ expect(view).toContain('@update:model-value="onFiltersChange"');
173
+ // The values object goes straight through useApi's adapter.
174
+ expect(view).toContain('filters: filterValues.value');
175
+ // The view owns the page reset, not FilterBar -- it owns `page`.
176
+ expect(view).toContain('page.value = 1');
177
+ // Descriptors are a ref so fetched dropdown options can be assigned in.
178
+ expect(view).toContain('const filterDescriptors = ref([');
179
+ expect(view).toContain("anyLabel: 'All statuses'");
180
+ expect(view).toContain("type: 'date' as const");
181
+ });
182
+
183
+ it('leaves output byte-identical when --filters is omitted', async () => {
184
+ await generator(host, baseOptions);
185
+ const without = host
186
+ .read('apps/test/src/views/ApplicationsListView.vue')
187
+ .toString();
188
+ expect(without).not.toContain('FilterBar');
189
+ expect(without).not.toContain('filterValues');
190
+ expect(without).not.toContain('onFiltersChange');
191
+ });
192
+
193
+ it('rejects a filter type it cannot render, naming the offender', async () => {
194
+ await expect(
195
+ generator(host, {
196
+ ...baseOptions,
197
+ filters: JSON.stringify([
198
+ { key: 'x', label: 'X', type: 'checkbox' },
199
+ ]),
200
+ }),
201
+ ).rejects.toThrow('--filters[].type must be "dropdown" or "date"; got "checkbox" for "x".');
202
+ });
203
+
204
+ it('accepts a real array, the form a programmatic caller passes', async () => {
205
+ await generator(host, {
206
+ ...baseOptions,
207
+ filters: [{ key: 'status', label: 'Status', type: 'dropdown' }],
208
+ });
209
+ const view = host
210
+ .read('apps/test/src/views/ApplicationsListView.vue')
211
+ .toString();
212
+ expect(view).toContain('FilterBar');
213
+ expect(view).toContain('options: []');
214
+ });
215
+ });
216
+
150
217
  it('omits onUnmounted along with the debounce when --filterable=false', async () => {
151
218
  // onUnmounted exists only to clear the search debounce, so importing it
152
219
  // unconditionally would be an unused import and fail the generated lint.
@@ -204,7 +271,9 @@ describe('Vue Workspace View Generator', () => {
204
271
  expect(routerTs).toContain(
205
272
  "component: () => import('../views/ApplicationsListView.vue')",
206
273
  );
207
- expect(routerTs).toContain('meta: { requiresAuth: true }');
274
+ expect(routerTs).toContain('requiresAuth: true');
275
+ // A data table asks for the wide variant -- the generator knows it emitted one.
276
+ expect(routerTs).toContain("layout: 'wide'");
208
277
  expect(routerTs).toContain("{ path: '/', component: HomeView }");
209
278
  }, 30000);
210
279
 
@@ -4,5 +4,12 @@ export interface VueRouteSpec {
4
4
  /** Relative import path passed to () => import(...), e.g. '../views/FooView.vue'. */
5
5
  componentImportPath: string;
6
6
  requiresAuth?: boolean;
7
+ /**
8
+ * Content width for the view, read by App.vue's `contentWidth` and passed to
9
+ * AppLayout. Omit for the 1000px default; a generator emitting a data table
10
+ * should say 'wide' and one emitting a single-column form 'form', since it
11
+ * already knows which it produced.
12
+ */
13
+ layout?: 'wide' | 'form';
7
14
  }
8
15
  export declare function insertVueRoute(host: Tree, projectRoot: string, project: string, route: VueRouteSpec): void;
@@ -16,8 +16,12 @@ function insertVueRoute(host, projectRoot, project, route) {
16
16
  if (markerIndex === -1) {
17
17
  throw new Error(`Could not find "${marker}" in ${routerPath} -- has its shape changed since @abgov/nx-adsp:vue-app generated it?`);
18
18
  }
19
- const metaLine = route.requiresAuth
20
- ? `\n meta: { requiresAuth: true },`
19
+ const metaEntries = [
20
+ ...(route.requiresAuth ? ['requiresAuth: true'] : []),
21
+ ...(route.layout ? [`layout: '${route.layout}'`] : []),
22
+ ];
23
+ const metaLine = metaEntries.length
24
+ ? `\n meta: { ${metaEntries.join(', ')} },`
21
25
  : '';
22
26
  const routeSnippet = `{\n` +
23
27
  ` path: '${route.path}',\n` +
@@ -1 +1 @@
1
- {"version":3,"file":"vue-router.js","sourceRoot":"","sources":["../../../../../packages/nx-adsp/src/utils/vue-router.ts"],"names":[],"mappings":";;AAYA,wCAqCC;AAxCD,6EAA6E;AAC7E,wEAAwE;AACxE,6EAA6E;AAC7E,SAAgB,cAAc,CAC5B,IAAU,EACV,WAAmB,EACnB,OAAe,EACf,KAAmB;;IAEnB,MAAM,UAAU,GAAG,GAAG,WAAW,sBAAsB,CAAC;IACxD,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,0CAAE,QAAQ,EAAE,CAAC;IAClD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,kBAAkB,UAAU,WAAW,OAAO,0DAA0D,CACzG,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,WAAW,CAAC;IAC3B,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,IAAI,WAAW,KAAK,CAAC,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,mBAAmB,MAAM,QAAQ,UAAU,sEAAsE,CAClH,CAAC;IACJ,CAAC;IAED,MAAM,QAAQ,GAAG,KAAK,CAAC,YAAY;QACjC,CAAC,CAAC,uCAAuC;QACzC,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,YAAY,GAChB,KAAK;QACL,gBAAgB,KAAK,CAAC,IAAI,MAAM;QAChC,kCAAkC,KAAK,CAAC,mBAAmB,MAAM,QAAQ,IAAI;QAC7E,QAAQ,CAAC;IAEX,MAAM,QAAQ,GAAG,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC;IAC7C,MAAM,OAAO,GACX,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC;QAC1B,SAAS,YAAY,EAAE;QACvB,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC1B,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAClC,CAAC"}
1
+ {"version":3,"file":"vue-router.js","sourceRoot":"","sources":["../../../../../packages/nx-adsp/src/utils/vue-router.ts"],"names":[],"mappings":";;AAmBA,wCAyCC;AA5CD,6EAA6E;AAC7E,wEAAwE;AACxE,6EAA6E;AAC7E,SAAgB,cAAc,CAC5B,IAAU,EACV,WAAmB,EACnB,OAAe,EACf,KAAmB;;IAEnB,MAAM,UAAU,GAAG,GAAG,WAAW,sBAAsB,CAAC;IACxD,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,0CAAE,QAAQ,EAAE,CAAC;IAClD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,kBAAkB,UAAU,WAAW,OAAO,0DAA0D,CACzG,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,WAAW,CAAC;IAC3B,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,IAAI,WAAW,KAAK,CAAC,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,mBAAmB,MAAM,QAAQ,UAAU,sEAAsE,CAClH,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG;QAClB,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KACvD,CAAC;IACF,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM;QACjC,CAAC,CAAC,mBAAmB,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;QAChD,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,YAAY,GAChB,KAAK;QACL,gBAAgB,KAAK,CAAC,IAAI,MAAM;QAChC,kCAAkC,KAAK,CAAC,mBAAmB,MAAM,QAAQ,IAAI;QAC7E,QAAQ,CAAC;IAEX,MAAM,QAAQ,GAAG,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC;IAC7C,MAAM,OAAO,GACX,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC;QAC1B,SAAS,YAAY,EAAE;QACvB,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC1B,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAClC,CAAC"}