@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.
- package/package.json +1 -1
- package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +40 -16
- package/src/generators/vue-admin-crud/files/src/views/__listViewFileName__.vue__tmpl__ +2 -6
- package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +23 -4
- package/src/generators/vue-app/files/AGENTS.md__tmpl__ +28 -0
- package/src/generators/vue-app/files/src/composables/useApi.spec.ts__tmpl__ +71 -0
- package/src/generators/vue-app/files/src/composables/useApi.ts__tmpl__ +154 -1
- package/src/generators/vue-app/vue-app.spec.ts +10 -0
- package/src/generators/vue-components/files/AGENTS.md__tmpl__ +55 -1
- package/src/generators/vue-components/files/src/index.ts__tmpl__ +14 -0
- package/src/generators/vue-components/files/src/lib/formatters.spec.ts__tmpl__ +78 -0
- package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +88 -0
- package/src/generators/vue-components/files/src/lib/patterns/FilterBar.spec.ts__tmpl__ +121 -0
- package/src/generators/vue-components/files/src/lib/patterns/FilterBar.vue__tmpl__ +158 -0
- package/src/generators/vue-components/files/src/lib/primitives/GoabDatePicker.spec.ts__tmpl__ +29 -0
- package/src/generators/vue-components/files/src/lib/primitives/GoabDatePicker.vue__tmpl__ +27 -0
- package/src/generators/vue-components/files/src/vue-components.spec.ts__tmpl__ +13 -0
- package/src/generators/vue-components/vue-components.spec.ts +80 -0
- package/src/generators/vue-detail-view/files/src/views/__viewFileName__.vue__tmpl__ +9 -22
- package/src/generators/vue-detail-view/vue-detail-view.spec.ts +21 -3
- package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +8 -10
- package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +37 -16
- package/src/generators/vue-intake-view/vue-intake-view.spec.ts +4 -1
- package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +33 -34
- 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,
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
{{
|
|
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("
|
|
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("
|
|
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
|
-
|
|
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
|
|