@abgov/nx-adsp 13.24.0 → 13.26.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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +40 -16
  3. package/src/generators/vue-admin-crud/files/src/views/__listViewFileName__.vue__tmpl__ +2 -6
  4. package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +23 -4
  5. package/src/generators/vue-app/files/AGENTS.md__tmpl__ +28 -0
  6. package/src/generators/vue-app/files/src/composables/useApi.spec.ts__tmpl__ +71 -0
  7. package/src/generators/vue-app/files/src/composables/useApi.ts__tmpl__ +154 -1
  8. package/src/generators/vue-app/vue-app.spec.ts +10 -0
  9. package/src/generators/vue-components/files/AGENTS.md__tmpl__ +55 -1
  10. package/src/generators/vue-components/files/src/index.ts__tmpl__ +14 -0
  11. package/src/generators/vue-components/files/src/lib/formatters.spec.ts__tmpl__ +78 -0
  12. package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +88 -0
  13. package/src/generators/vue-components/files/src/lib/patterns/FilterBar.spec.ts__tmpl__ +121 -0
  14. package/src/generators/vue-components/files/src/lib/patterns/FilterBar.vue__tmpl__ +158 -0
  15. package/src/generators/vue-components/files/src/lib/primitives/GoabDatePicker.spec.ts__tmpl__ +29 -0
  16. package/src/generators/vue-components/files/src/lib/primitives/GoabDatePicker.vue__tmpl__ +27 -0
  17. package/src/generators/vue-components/files/src/vue-components.spec.ts__tmpl__ +13 -0
  18. package/src/generators/vue-components/vue-components.spec.ts +80 -0
  19. package/src/generators/vue-detail-view/files/src/views/__viewFileName__.vue__tmpl__ +9 -22
  20. package/src/generators/vue-detail-view/vue-detail-view.spec.ts +21 -3
  21. package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +8 -10
  22. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +37 -16
  23. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +4 -1
  24. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +33 -34
  25. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +50 -3
@@ -0,0 +1,88 @@
1
+ // Shared value formatting for views across every Vue app in this workspace.
2
+ //
3
+ // Plain functions, not a `use*` composable: there is no reactive state and no
4
+ // lifecycle here, and in Vue `use*` signals both. Import them directly in an
5
+ // SFC's <script setup> and call them from the template.
6
+ //
7
+ // Every formatter returns an em dash for a null/undefined/unparseable value, so
8
+ // a view can render a partially-populated record without a guard per field.
9
+
10
+ const LOCALE = 'en-CA';
11
+ const EMPTY = '—';
12
+
13
+ const dateOnly = new Intl.DateTimeFormat(LOCALE, { dateStyle: 'short' });
14
+ const dateAndTime = new Intl.DateTimeFormat(LOCALE, {
15
+ dateStyle: 'short',
16
+ timeStyle: 'short',
17
+ });
18
+ const decimal = new Intl.NumberFormat(LOCALE);
19
+ const currency = new Intl.NumberFormat(LOCALE, {
20
+ style: 'currency',
21
+ currency: 'CAD',
22
+ });
23
+
24
+ // `unknown` rather than a date union: views read fields off a record whose shape
25
+ // the generator doesn't know, so every call site would otherwise need a cast.
26
+ // Anything that isn't a usable date becomes the em dash, same as null.
27
+ //
28
+ // Invalid dates are caught via getTime() rather than a try/catch: `new Date()`
29
+ // doesn't throw on unparseable input, it yields an Invalid Date whose getTime()
30
+ // is NaN, and Intl.format() on that would throw a RangeError.
31
+ function toValidDate(value: unknown): Date | null {
32
+ if (value === null || value === undefined || value === '') return null;
33
+ if (value instanceof Date) {
34
+ return Number.isNaN(value.getTime()) ? null : value;
35
+ }
36
+ if (typeof value !== 'string' && typeof value !== 'number') return null;
37
+ const date = new Date(value);
38
+ return Number.isNaN(date.getTime()) ? null : date;
39
+ }
40
+
41
+ /** Date without a time component, e.g. `2026-08-28` → `2026-08-28`. */
42
+ export function formatDate(value: unknown): string {
43
+ const date = toValidDate(value);
44
+ return date ? dateOnly.format(date) : EMPTY;
45
+ }
46
+
47
+ /** Date with a short time, for audit/activity timestamps. */
48
+ export function formatDateTime(value: unknown): string {
49
+ const date = toValidDate(value);
50
+ return date ? dateAndTime.format(date) : EMPTY;
51
+ }
52
+
53
+ /**
54
+ * CAD currency. Takes `unknown` for the same reason the date helpers do, and
55
+ * falls back to the raw value's string form rather than the em dash when it is
56
+ * present but unparseable — a visibly wrong amount is safer to notice than a
57
+ * dash that reads as "no data".
58
+ */
59
+ export function formatCurrency(value: unknown): string {
60
+ if (value === undefined || value === null || value === '') return EMPTY;
61
+ const amount = Number(value);
62
+ if (Number.isNaN(amount)) return String(value);
63
+ return currency.format(amount);
64
+ }
65
+
66
+ /** Thousands-separated integer/decimal, e.g. `12345` → `12,345`. */
67
+ export function formatNumber(value: number | null | undefined): string {
68
+ return typeof value === 'number' && Number.isFinite(value)
69
+ ? decimal.format(value)
70
+ : EMPTY;
71
+ }
72
+
73
+ /**
74
+ * Percentage from a value already scaled 0–100 — the form rate/percentage
75
+ * fields come back in from an ADSP service, not the 0–1 fraction
76
+ * `Intl.NumberFormat({ style: 'percent' })` expects. Pass 99.5, not 0.995.
77
+ */
78
+ export function formatPercent(
79
+ value: number | null | undefined,
80
+ fractionDigits = 1,
81
+ ): string {
82
+ if (typeof value !== 'number' || !Number.isFinite(value)) return EMPTY;
83
+ const rounded = new Intl.NumberFormat(LOCALE, {
84
+ minimumFractionDigits: 0,
85
+ maximumFractionDigits: fractionDigits,
86
+ }).format(value);
87
+ return `${rounded}%`;
88
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import FilterBar from './FilterBar.vue';
4
+
5
+ // FilterBar is presentational: values in via v-model, values out via
6
+ // update:modelValue, and nothing else. These tests pin that contract -- notably
7
+ // that it never reaches for the page, the router, or the network.
8
+ //
9
+ // These mount goa-* elements, so Vue logs "Failed to resolve component" unless
10
+ // this project's vite config sets isCustomElement (see AGENTS.md > Testing).
11
+ // The assertions pass either way -- the option only silences the warnings.
12
+ const filters = [
13
+ {
14
+ key: 'status',
15
+ label: 'Status',
16
+ type: 'dropdown' as const,
17
+ options: [
18
+ { value: 'active', label: 'Active' },
19
+ { value: 'closed', label: 'Closed' },
20
+ ],
21
+ },
22
+ { key: 'from', label: 'From', type: 'date' as const },
23
+ ];
24
+
25
+ function mountBar(modelValue: Record<string, string> = {}) {
26
+ return mount(FilterBar, {
27
+ props: { filters, modelValue },
28
+ global: { stubs: { GoabDropdown: true, GoabDatePicker: true, GoabButton: true } },
29
+ });
30
+ }
31
+
32
+ describe('FilterBar', () => {
33
+ it('renders one form item per filter', () => {
34
+ const wrapper = mountBar();
35
+ expect(wrapper.findAll('goa-form-item')).toHaveLength(2);
36
+ });
37
+
38
+ it('shows no chips and no clear-all when nothing is filtered', () => {
39
+ const wrapper = mountBar();
40
+ expect(wrapper.find('goa-filter-chip').exists()).toBe(false);
41
+ });
42
+
43
+ it('renders a chip per active filter, labelled with the option label not its value', () => {
44
+ const wrapper = mountBar({ status: 'active' });
45
+ const chips = wrapper.findAll('goa-filter-chip');
46
+ expect(chips).toHaveLength(1);
47
+ expect(chips[0].attributes('content')).toBe('Status: Active');
48
+ });
49
+
50
+ it('falls back to the raw value when no option matches (e.g. a date)', () => {
51
+ const wrapper = mountBar({ from: '2026-08-28' });
52
+ expect(wrapper.find('goa-filter-chip').attributes('content')).toBe(
53
+ 'From: 2026-08-28',
54
+ );
55
+ });
56
+
57
+ it('emits the whole values object, not a partial patch', async () => {
58
+ const wrapper = mountBar({ status: 'active' });
59
+ // Dismissing a chip clears that one key and keeps the rest of the object.
60
+ await wrapper.find('goa-filter-chip').trigger('_click');
61
+ const emitted = wrapper.emitted('update:modelValue');
62
+ expect(emitted?.[0][0]).toEqual({ status: '' });
63
+ });
64
+
65
+ it('collapses behind goa-details only when asked', () => {
66
+ expect(mountBar().find('goa-details').exists()).toBe(false);
67
+ const collapsible = mount(FilterBar, {
68
+ props: { filters, modelValue: {}, collapsible: true, heading: 'Filters' },
69
+ global: { stubs: { GoabDropdown: true, GoabDatePicker: true, GoabButton: true } },
70
+ });
71
+ expect(collapsible.find('goa-details').exists()).toBe(true);
72
+ });
73
+ });
74
+
75
+ describe('FilterBar date handling', () => {
76
+ it('serialises a picked Date to YYYY-MM-DD for the query string', async () => {
77
+ const wrapper = mount(FilterBar, {
78
+ props: { filters, modelValue: {} },
79
+ global: { stubs: { GoabDropdown: true, GoabButton: true } },
80
+ });
81
+ const picker = wrapper.findComponent({ name: 'GoabDatePicker' });
82
+ // Local midnight is what a date picker yields for a picked calendar date.
83
+ // A UTC instant here would make the assertion timezone-dependent.
84
+ await picker.vm.$emit('update:modelValue', new Date(2026, 7, 28));
85
+ expect(wrapper.emitted('update:modelValue')?.[0][0]).toEqual({
86
+ from: '2026-08-28',
87
+ });
88
+ });
89
+
90
+ it('clears the key when the date is cleared', async () => {
91
+ const wrapper = mount(FilterBar, {
92
+ props: { filters, modelValue: { from: '2026-08-28' } },
93
+ global: { stubs: { GoabDropdown: true, GoabButton: true } },
94
+ });
95
+ const picker = wrapper.findComponent({ name: 'GoabDatePicker' });
96
+ await picker.vm.$emit('update:modelValue', undefined);
97
+ expect(wrapper.emitted('update:modelValue')?.[0][0]).toEqual({ from: '' });
98
+ });
99
+ });
100
+
101
+ const dateOnlyFilters = [{ key: 'from', label: 'From', type: 'date' as const }];
102
+ const stubs = { GoabDropdown: true, GoabButton: true };
103
+
104
+ describe('date round-trip preserves the calendar date the user picked', () => {
105
+ it('serialises the picked calendar date, not its UTC date', async () => {
106
+ const wrapper = mount(FilterBar, { props: { filters: dateOnlyFilters, modelValue: {} }, global: { stubs } });
107
+ // What a picker yields for "28 Aug 2026": local midnight.
108
+ const localMidnight = new Date(2026, 7, 28, 0, 0, 0);
109
+ await wrapper.findComponent({ name: 'GoabDatePicker' }).vm.$emit('update:modelValue', localMidnight);
110
+ expect(wrapper.emitted('update:modelValue')?.[0][0]).toEqual({ from: '2026-08-28' });
111
+ });
112
+
113
+ it('parses a stored YYYY-MM-DD back to the same local calendar date', () => {
114
+ const wrapper = mount(FilterBar, {
115
+ props: { filters: dateOnlyFilters, modelValue: { from: '2026-08-28' } },
116
+ global: { stubs },
117
+ });
118
+ const back = wrapper.findComponent({ name: 'GoabDatePicker' }).props('modelValue') as Date;
119
+ expect([back.getFullYear(), back.getMonth() + 1, back.getDate()]).toEqual([2026, 8, 28]);
120
+ });
121
+ });
@@ -0,0 +1,158 @@
1
+ <script setup lang="ts">
2
+ // The presentation half of a filtered list: control layout, collapse, the
3
+ // active-filter chips, and clear-all. Deliberately NOT the query half — it
4
+ // emits a values object and nothing else, so it stays presentational per this
5
+ // library's rules (accept data via props, report via emit, no side effects).
6
+ //
7
+ // What that means for the consumer: resetting the page to 1, debouncing, and
8
+ // syncing to the URL all belong to the view, which owns `page` and the router.
9
+ // A component can't reach either without becoming stateful.
10
+ //
11
+ // Values are strings, query-ready, so a view can hand the object straight to
12
+ // useApi's `filters`. A `date` filter converts its Date to YYYY-MM-DD here,
13
+ // which is a format decision this component can make because it chose the
14
+ // control; anything richer belongs in the view.
15
+ //
16
+ // Both date conversions use local calendar parts, never toISOString() or
17
+ // `new Date(string)`. Those route through UTC, which shifts the date by a day on
18
+ // one side of midnight or the other: in Alberta (UTC-6/-7) `new Date('2026-08-28')`
19
+ // is 27 Aug 18:00 local, so a stored date would render as the day before.
20
+ import { computed } from 'vue';
21
+ import GoabDropdown from '../primitives/GoabDropdown.vue';
22
+ import GoabDatePicker from '../primitives/GoabDatePicker.vue';
23
+ import GoabButton from '../primitives/GoabButton.vue';
24
+
25
+ export interface FilterOption {
26
+ value: string;
27
+ label: string;
28
+ }
29
+
30
+ export interface FilterDescriptor {
31
+ key: string;
32
+ label: string;
33
+ type: 'dropdown' | 'date';
34
+ /** Dropdown only. May arrive after mount when fetched — it's a prop. */
35
+ options?: FilterOption[];
36
+ /** Dropdown only. Label for the "no filter" choice. */
37
+ anyLabel?: string;
38
+ }
39
+
40
+ const props = withDefaults(
41
+ defineProps<{
42
+ filters: FilterDescriptor[];
43
+ /** Collapse the controls behind a disclosure. Chips stay visible. */
44
+ collapsible?: boolean;
45
+ heading?: string;
46
+ }>(),
47
+ { collapsible: false, heading: 'Filters' },
48
+ );
49
+
50
+ // Record<key, value>; an empty string means "not filtering on this".
51
+ const model = defineModel<Record<string, string>>({ default: () => ({}) });
52
+
53
+ const active = computed(() =>
54
+ props.filters
55
+ .filter((filter) => !!model.value[filter.key])
56
+ .map((filter) => ({
57
+ ...filter,
58
+ value: model.value[filter.key],
59
+ display:
60
+ filter.options?.find((o) => o.value === model.value[filter.key])
61
+ ?.label ?? model.value[filter.key],
62
+ })),
63
+ );
64
+
65
+ // Parsed once per model change, not per render: handing goa-date-picker a fresh
66
+ // Date object every render would re-set its value on each tick.
67
+ const dateValues = computed(() =>
68
+ Object.fromEntries(
69
+ props.filters
70
+ .filter((filter) => filter.type === 'date')
71
+ .map((filter) => [
72
+ filter.key,
73
+ model.value[filter.key]
74
+ ? fromIsoDate(model.value[filter.key])
75
+ : undefined,
76
+ ]),
77
+ ),
78
+ );
79
+
80
+ // Local-calendar YYYY-MM-DD -- see the header note on why not toISOString().
81
+ function toIsoDate(date: Date): string {
82
+ const pad = (part: number) => String(part).padStart(2, '0');
83
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
84
+ }
85
+
86
+ function fromIsoDate(value: string): Date | undefined {
87
+ const [year, month, day] = value.split('-').map(Number);
88
+ return year && month && day ? new Date(year, month - 1, day) : undefined;
89
+ }
90
+
91
+ function set(key: string, value: string) {
92
+ model.value = { ...model.value, [key]: value };
93
+ }
94
+
95
+ function setDate(key: string, date: Date | undefined) {
96
+ set(key, date ? toIsoDate(date) : '');
97
+ }
98
+
99
+ function clearAll() {
100
+ model.value = Object.fromEntries(
101
+ props.filters.map((filter) => [filter.key, '']),
102
+ );
103
+ }
104
+ </script>
105
+
106
+ <template>
107
+ <div>
108
+ <component
109
+ :is="collapsible ? 'goa-details' : 'div'"
110
+ v-bind="collapsible ? { heading } : {}"
111
+ >
112
+ <goa-block gap="m" direction="row" alignment="end">
113
+ <goa-form-item
114
+ v-for="filter in filters"
115
+ :key="filter.key"
116
+ :label="filter.label"
117
+ >
118
+ <GoabDropdown
119
+ v-if="filter.type === 'dropdown'"
120
+ :model-value="model[filter.key] ?? ''"
121
+ :name="filter.key"
122
+ @update:model-value="(value: string) => set(filter.key, value)"
123
+ >
124
+ <goa-dropdown-item value="" :label="filter.anyLabel ?? 'Any'" />
125
+ <goa-dropdown-item
126
+ v-for="option in filter.options ?? []"
127
+ :key="option.value"
128
+ :value="option.value"
129
+ :label="option.label"
130
+ />
131
+ </GoabDropdown>
132
+ <GoabDatePicker
133
+ v-else
134
+ :model-value="dateValues[filter.key]"
135
+ :name="filter.key"
136
+ @update:model-value="(date: Date | undefined) => setDate(filter.key, date)"
137
+ />
138
+ </goa-form-item>
139
+ </goa-block>
140
+ </component>
141
+
142
+ <template v-if="active.length">
143
+ <goa-spacer vspacing="s" />
144
+ <goa-block gap="xs" direction="row" alignment="center">
145
+ <goa-filter-chip
146
+ v-for="filter in active"
147
+ :key="filter.key"
148
+ :content="`${filter.label}: ${filter.display}`"
149
+ :aria-label="`Remove ${filter.label} filter`"
150
+ @_click="set(filter.key, '')"
151
+ />
152
+ <GoabButton type="tertiary" size="compact" @click="clearAll">
153
+ Clear all
154
+ </GoabButton>
155
+ </goa-block>
156
+ </template>
157
+ </div>
158
+ </template>
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import GoabDatePicker from './GoabDatePicker.vue';
4
+
5
+ // Pins the event contract read out of the installed @abgov/web-components:
6
+ // goa-date-picker dispatches _change with { name, value: Date, valueStr: string }.
7
+ // Every other value wrapper here reads a string off detail.value; this one does
8
+ // not, so the contract is worth a test rather than a comment alone.
9
+ describe('GoabDatePicker', () => {
10
+ it('takes the Date off detail.value, not detail.valueStr', async () => {
11
+ const wrapper = mount(GoabDatePicker, { props: { modelValue: undefined } });
12
+ const date = new Date('2026-08-28T00:00:00.000Z');
13
+ await wrapper
14
+ .find('goa-date-picker')
15
+ .element.dispatchEvent(
16
+ new CustomEvent('_change', {
17
+ detail: { name: 'from', value: date, valueStr: '2026-08-28' },
18
+ }),
19
+ );
20
+ expect(wrapper.emitted('update:modelValue')?.[0][0]).toBeInstanceOf(Date);
21
+ expect(wrapper.emitted('update:modelValue')?.[0][0]).toEqual(date);
22
+ });
23
+
24
+ it('passes the model down as the element value', () => {
25
+ const date = new Date('2026-01-02T00:00:00.000Z');
26
+ const wrapper = mount(GoabDatePicker, { props: { modelValue: date } });
27
+ expect(wrapper.find('goa-date-picker').exists()).toBe(true);
28
+ });
29
+ });
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ // INTERIM WRAPPER — remove once GoA DS publishes @abgov/vue-components (see
3
+ // GoabInput.vue for the full rationale). Adds v-model to <goa-date-picker>.
4
+ //
5
+ // The model is a Date, not a string: goa-date-picker's _change detail is
6
+ // { name, value: Date, valueStr: string } — verified against the installed
7
+ // package, where the dispatch reads `value: j.date, valueStr: r`. That makes it
8
+ // the one element here whose detail.value isn't the string the other value
9
+ // wrappers read, so it does not use skeleton A verbatim.
10
+ //
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.
14
+ //
15
+ // Usage: <GoabDatePicker v-model="startDate" name="startDate" :max="new Date()" />
16
+ const model = defineModel<Date | undefined>();
17
+
18
+ function onChange(e: Event) {
19
+ model.value = (e as CustomEvent<{ value: Date }>).detail.value;
20
+ }
21
+ </script>
22
+
23
+ <template>
24
+ <goa-date-picker :value="model" @_change="onChange">
25
+ <slot />
26
+ </goa-date-picker>
27
+ </template>
@@ -14,11 +14,23 @@ describe('vue-components', () => {
14
14
  'GoabRadioGroup',
15
15
  'GoabButton',
16
16
  'GoabModal',
17
+ 'GoabDatePicker',
17
18
  ]) {
18
19
  expect(lib[name as keyof typeof lib]).toBeTruthy();
19
20
  }
20
21
  });
21
22
 
23
+ it('exports the shared value formatters', () => {
24
+ for (const name of [
25
+ 'formatDate',
26
+ 'formatDateTime',
27
+ 'formatNumber',
28
+ 'formatPercent',
29
+ ]) {
30
+ expect(typeof lib[name as keyof typeof lib]).toBe('function');
31
+ }
32
+ });
33
+
22
34
  it('exports every app-shell pattern component', () => {
23
35
  for (const name of [
24
36
  'AppLayout',
@@ -30,6 +42,7 @@ describe('vue-components', () => {
30
42
  'WorkspaceTable',
31
43
  'Stepper',
32
44
  'StepErrorSummary',
45
+ 'FilterBar',
33
46
  ]) {
34
47
  expect(lib[name as keyof typeof lib]).toBeTruthy();
35
48
  }
@@ -57,6 +57,70 @@ describe('Vue Components Generator', () => {
57
57
  expect(index).toContain('export { default as GoabInput }');
58
58
  expect(index).toContain('export { default as AppLayout }');
59
59
  expect(index).toContain('@abgov/vue-components'); // interim marker
60
+ expect(index).toContain("from './lib/formatters'");
61
+ expect(index).toContain('export { default as GoabDatePicker }');
62
+ expect(index).toContain('export { default as FilterBar }');
63
+
64
+ // GoabDatePicker is the one wrapper whose _change detail.value is a Date
65
+ // rather than a string -- verified against the installed web-components,
66
+ // where the dispatch reads `value: j.date, valueStr: r`.
67
+ const datePicker = host
68
+ .read('libs/vue-components/src/lib/primitives/GoabDatePicker.vue')
69
+ .toString();
70
+ expect(datePicker).toContain('defineModel<Date | undefined>()');
71
+ expect(datePicker).toContain('CustomEvent<{ value: Date }>');
72
+
73
+ // FilterBar stays presentational: it emits a query-ready values object and
74
+ // never touches the page, the router, or the network.
75
+ const filterBar = host
76
+ .read('libs/vue-components/src/lib/patterns/FilterBar.vue')
77
+ .toString();
78
+ expect(filterBar).toContain('goa-filter-chip');
79
+ expect(filterBar).toContain('goa-details');
80
+ // Precise API forms, not bare words -- 'page' appears in the component's own
81
+ // comment explaining that the *view* owns it.
82
+ for (const forbidden of [
83
+ 'useRouter',
84
+ 'useRoute',
85
+ 'apiFetch',
86
+ 'fetch(',
87
+ 'page.value',
88
+ ]) {
89
+ expect(filterBar).not.toContain(forbidden);
90
+ }
91
+ // Local calendar parts, not UTC: in Alberta new Date('2026-08-28') is
92
+ // 27 Aug 18:00 local, so a stored date would render as the day before.
93
+ expect(filterBar).toContain('function toIsoDate');
94
+ expect(filterBar).toContain('function fromIsoDate');
95
+ // The call form, not the bare name: the component's own comments explain why
96
+ // toISOString() is wrong, so the name legitimately appears in them.
97
+ expect(filterBar).not.toContain('date.toISOString()');
98
+
99
+ for (const spec of [
100
+ 'libs/vue-components/src/lib/patterns/FilterBar.spec.ts',
101
+ 'libs/vue-components/src/lib/primitives/GoabDatePicker.spec.ts',
102
+ ]) {
103
+ expect(host.exists(spec)).toBeTruthy();
104
+ }
105
+
106
+ // Shared value formatters: every view renders a date/count the same way
107
+ // instead of inlining its own toLocaleString (which is what a real app did
108
+ // in three separate views before this existed).
109
+ expect(host.exists('libs/vue-components/src/lib/formatters.ts')).toBeTruthy();
110
+ expect(
111
+ host.exists('libs/vue-components/src/lib/formatters.spec.ts'),
112
+ ).toBeTruthy();
113
+ const formatters = host
114
+ .read('libs/vue-components/src/lib/formatters.ts')
115
+ .toString();
116
+ for (const fn of [
117
+ 'formatDate',
118
+ 'formatDateTime',
119
+ 'formatNumber',
120
+ 'formatPercent',
121
+ ]) {
122
+ expect(formatters).toContain(`export function ${fn}`);
123
+ }
60
124
 
61
125
  // Ships a spec so the vitest test target isn't empty (vitest exits non-zero
62
126
  // on "no test files found").
@@ -77,6 +141,22 @@ describe('Vue Components Generator', () => {
77
141
  expect(agents).toContain('detail.value');
78
142
  expect(agents).toContain('Wrapping a new component');
79
143
  expect(agents).toContain('defineModel<boolean>');
144
+
145
+ // Catalogues the presentational goa-* elements that need no wrapper. Its
146
+ // absence is what drove a real app to hand-roll 254 inline styles on raw
147
+ // HTML standing in for elements that already shipped.
148
+ expect(agents).toContain('most need no wrapper');
149
+ for (const element of [
150
+ 'goa-container',
151
+ 'goa-block',
152
+ 'goa-grid',
153
+ 'goa-text',
154
+ 'goa-table',
155
+ 'goa-tabs',
156
+ ]) {
157
+ expect(agents).toContain(element);
158
+ }
159
+ expect(agents).toContain("Don't wrap a presentational element");
80
160
  }, 30000);
81
161
 
82
162
  it('AppSideMenu exposes an optional #topbar slot for header-action-style content', async () => {
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted } from 'vue';
2
+ import { ref, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
- import { RecordDetailShell } from '<%= goaImportPath %>';
4
+ import { RecordDetailShell, formatCurrency, formatDateTime } 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
@@ -12,15 +12,13 @@ const error = ref<string | null>(null);
12
12
 
13
13
  const route = useRoute();
14
14
  const router = useRouter();
15
- const { apiFetch } = useApi();
15
+ const { get } = useApi();
16
16
 
17
17
  async function load() {
18
18
  loading.value = true;
19
19
  error.value = null;
20
20
  try {
21
- const res = await apiFetch(`/api/<%= resource %>/${route.params.id}`);
22
- if (!res.ok) throw new Error(`Failed to load (${res.status})`);
23
- record.value = await res.json();
21
+ record.value = await get('<%= resource %>', String(route.params.id));
24
22
  } catch (e) {
25
23
  error.value = e instanceof Error ? e.message : 'Failed to load.';
26
24
  } finally {
@@ -28,27 +26,16 @@ async function load() {
28
26
  }
29
27
  }
30
28
 
31
- onMounted(load);
29
+ // Vue Router reuses this component when only the id param changes, so onMounted
30
+ // would never fire again and the previous record would stay on screen. An
31
+ // immediate watch covers first load and every later param change in one place.
32
+ watch(() => route.params.id, load, { immediate: true });
32
33
 
33
34
  function goBack() {
34
35
  router.back();
35
36
  }
36
37
 
37
- function formatDate(value: unknown): string {
38
- if (!value) return '—';
39
- try {
40
- return new Date(String(value)).toLocaleString('en-CA');
41
- } catch {
42
- return String(value);
43
- }
44
- }
45
38
 
46
- function formatCurrency(value: unknown): string {
47
- if (value === undefined || value === null || value === '') return '—';
48
- const n = Number(value);
49
- if (Number.isNaN(n)) return String(value);
50
- return new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD' }).format(n);
51
- }
52
39
  </script>
53
40
 
54
41
  <template>
@@ -67,7 +54,7 @@ function formatCurrency(value: unknown): string {
67
54
  <% if (field.type === 'badge') { -%>
68
55
  <goa-badge type="information" :content="String(record['<%= field.key %>'] ?? '—')" />
69
56
  <% } else if (field.type === 'date') { -%>
70
- {{ formatDate(record['<%= field.key %>']) }}
57
+ {{ formatDateTime(record['<%= field.key %>']) }}
71
58
  <% } else if (field.type === 'currency') { -%>
72
59
  {{ formatCurrency(record['<%= field.key %>']) }}
73
60
  <% } else { -%>
@@ -102,17 +102,35 @@ describe('Vue Detail View Generator', () => {
102
102
  .read('apps/test/src/views/ApplicationDetailView.vue')
103
103
  .toString();
104
104
  expect(view).toContain('heading="Application Detail"');
105
- expect(view).toContain("apiFetch(`/api/applications/${route.params.id}`)");
105
+ expect(view).toContain("await get('applications', String(route.params.id))");
106
+ expect(view).not.toContain('apiFetch');
107
+
108
+ // Vue Router reuses this component across an id-only change, so the fetch
109
+ // is driven by a watch rather than onMounted.
110
+ expect(view).toContain("watch(() => route.params.id, load, { immediate: true })");
111
+ expect(view).not.toContain('onMounted(');
112
+ expect(view).not.toContain('function formatCurrency');
113
+
114
+ expect(view).toContain('formatDateTime');
115
+ expect(view).not.toContain('function formatDate');
106
116
  expect(view).toContain(
107
117
  "<goa-badge type=\"information\" :content=\"String(record['status'] ?? '—')\" />",
108
118
  );
109
- expect(view).toContain("formatDate(record['lastSaved'])");
119
+ expect(view).toContain("formatDateTime(record['lastSaved'])");
110
120
  expect(view).toContain("formatCurrency(record['requestTotal'])");
111
121
  expect(view).toContain("record['serviceModel'] ?? '—'");
112
122
  expect(view).toContain('<dt>Status</dt>');
113
123
  expect(view).toContain('<dt>Service Model</dt>');
114
124
  // Uses the shared shell, not hand-rolled loading/error markup.
115
- expect(view).toContain("import { RecordDetailShell } from '@proj/vue-components';");
125
+ // Read the import's contents rather than an exact line: formatFiles wraps a
126
+ // long import list and adds a trailing comma.
127
+ const goaImport =
128
+ view
129
+ .replace(/\s+/g, ' ')
130
+ .match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
131
+ for (const name of ['RecordDetailShell', 'formatCurrency', 'formatDateTime']) {
132
+ expect(goaImport).toContain(name);
133
+ }
116
134
  expect(view).toContain('<RecordDetailShell');
117
135
  }, 30000);
118
136