@abgov/nx-adsp 13.23.1 → 13.25.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/migrations.json +11 -0
- package/package.json +4 -1
- package/src/build-assets.spec.ts +74 -0
- 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__ +52 -0
- package/src/generators/vue-components/files/src/index.ts__tmpl__ +12 -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/vue-components.spec.ts__tmpl__ +11 -0
- package/src/generators/vue-components/vue-components.spec.ts +36 -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
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.d.ts +7 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.js +124 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.js.map +1 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.md +99 -0
- package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.spec.ts +209 -0
- package/src/migrations/add-migrate-advisory-lock/migrate.after.txt +63 -0
- package/src/migrations/add-migrate-advisory-lock/migrate.before.txt +41 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
formatCurrency,
|
|
4
|
+
formatDate,
|
|
5
|
+
formatDateTime,
|
|
6
|
+
formatNumber,
|
|
7
|
+
formatPercent,
|
|
8
|
+
} from './formatters';
|
|
9
|
+
|
|
10
|
+
describe('formatters', () => {
|
|
11
|
+
it('formats a date and a date-time from an ISO string', () => {
|
|
12
|
+
expect(formatDate('2026-08-28T14:05:00.000Z')).toContain('2026');
|
|
13
|
+
expect(formatDateTime('2026-08-28T14:05:00.000Z')).toMatch(/\d{1,2}:\d{2}/);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('accepts a Date, an epoch number, and an ISO string alike', () => {
|
|
17
|
+
const iso = '2026-08-28T00:00:00.000Z';
|
|
18
|
+
const expected = formatDate(iso);
|
|
19
|
+
expect(formatDate(new Date(iso))).toBe(expected);
|
|
20
|
+
expect(formatDate(new Date(iso).getTime())).toBe(expected);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// `unknown` input: views read fields off a record of unknown shape, so a
|
|
24
|
+
// non-date value must degrade rather than throw or need a cast.
|
|
25
|
+
it.each([null, undefined, '', 'not-a-date', {}, [], true, NaN])(
|
|
26
|
+
'returns the em dash for %p rather than throwing',
|
|
27
|
+
(value) => {
|
|
28
|
+
expect(formatDate(value)).toBe('—');
|
|
29
|
+
expect(formatDateTime(value)).toBe('—');
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
it('returns the em dash for an Invalid Date instance', () => {
|
|
34
|
+
expect(formatDate(new Date('nope'))).toBe('—');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('separates thousands', () => {
|
|
38
|
+
expect(formatNumber(12345)).toBe('12,345');
|
|
39
|
+
expect(formatNumber(0)).toBe('0');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([null, undefined, NaN, Infinity])(
|
|
43
|
+
'returns the em dash for a non-finite number (%p)',
|
|
44
|
+
(value) => {
|
|
45
|
+
expect(formatNumber(value as number | null | undefined)).toBe('—');
|
|
46
|
+
expect(formatPercent(value as number | null | undefined)).toBe('—');
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it('treats a percent value as already scaled 0-100, not a fraction', () => {
|
|
51
|
+
expect(formatPercent(99.5)).toBe('99.5%');
|
|
52
|
+
expect(formatPercent(100)).toBe('100%');
|
|
53
|
+
// 0.95 is nine-tenths of one percent here, NOT 95% -- the scaling contract
|
|
54
|
+
// is the whole reason this helper exists rather than a bare toFixed call.
|
|
55
|
+
expect(formatPercent(0.95)).toBe('1%');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('formats CAD currency', () => {
|
|
59
|
+
expect(formatCurrency(1234.5)).toContain('1,234.50');
|
|
60
|
+
expect(formatCurrency('1234.5')).toBe(formatCurrency(1234.5));
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each([null, undefined, ''])(
|
|
64
|
+
'returns the em dash for an absent currency value (%p)',
|
|
65
|
+
(value) => {
|
|
66
|
+
expect(formatCurrency(value)).toBe('—');
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
it('echoes an unparseable currency value rather than hiding it behind a dash', () => {
|
|
71
|
+
expect(formatCurrency('n/a')).toBe('n/a');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('honours the fractionDigits argument', () => {
|
|
75
|
+
expect(formatPercent(66.666, 2)).toBe('66.67%');
|
|
76
|
+
expect(formatPercent(66.666, 0)).toBe('67%');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -19,6 +19,17 @@ describe('vue-components', () => {
|
|
|
19
19
|
}
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
+
it('exports the shared value formatters', () => {
|
|
23
|
+
for (const name of [
|
|
24
|
+
'formatDate',
|
|
25
|
+
'formatDateTime',
|
|
26
|
+
'formatNumber',
|
|
27
|
+
'formatPercent',
|
|
28
|
+
]) {
|
|
29
|
+
expect(typeof lib[name as keyof typeof lib]).toBe('function');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
22
33
|
it('exports every app-shell pattern component', () => {
|
|
23
34
|
for (const name of [
|
|
24
35
|
'AppLayout',
|
|
@@ -57,6 +57,26 @@ 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
|
+
|
|
62
|
+
// Shared value formatters: every view renders a date/count the same way
|
|
63
|
+
// instead of inlining its own toLocaleString (which is what a real app did
|
|
64
|
+
// in three separate views before this existed).
|
|
65
|
+
expect(host.exists('libs/vue-components/src/lib/formatters.ts')).toBeTruthy();
|
|
66
|
+
expect(
|
|
67
|
+
host.exists('libs/vue-components/src/lib/formatters.spec.ts'),
|
|
68
|
+
).toBeTruthy();
|
|
69
|
+
const formatters = host
|
|
70
|
+
.read('libs/vue-components/src/lib/formatters.ts')
|
|
71
|
+
.toString();
|
|
72
|
+
for (const fn of [
|
|
73
|
+
'formatDate',
|
|
74
|
+
'formatDateTime',
|
|
75
|
+
'formatNumber',
|
|
76
|
+
'formatPercent',
|
|
77
|
+
]) {
|
|
78
|
+
expect(formatters).toContain(`export function ${fn}`);
|
|
79
|
+
}
|
|
60
80
|
|
|
61
81
|
// Ships a spec so the vitest test target isn't empty (vitest exits non-zero
|
|
62
82
|
// on "no test files found").
|
|
@@ -77,6 +97,22 @@ describe('Vue Components Generator', () => {
|
|
|
77
97
|
expect(agents).toContain('detail.value');
|
|
78
98
|
expect(agents).toContain('Wrapping a new component');
|
|
79
99
|
expect(agents).toContain('defineModel<boolean>');
|
|
100
|
+
|
|
101
|
+
// Catalogues the presentational goa-* elements that need no wrapper. Its
|
|
102
|
+
// absence is what drove a real app to hand-roll 254 inline styles on raw
|
|
103
|
+
// HTML standing in for elements that already shipped.
|
|
104
|
+
expect(agents).toContain('most need no wrapper');
|
|
105
|
+
for (const element of [
|
|
106
|
+
'goa-container',
|
|
107
|
+
'goa-block',
|
|
108
|
+
'goa-grid',
|
|
109
|
+
'goa-text',
|
|
110
|
+
'goa-table',
|
|
111
|
+
'goa-tabs',
|
|
112
|
+
]) {
|
|
113
|
+
expect(agents).toContain(element);
|
|
114
|
+
}
|
|
115
|
+
expect(agents).toContain("Don't wrap a presentational element");
|
|
80
116
|
}, 30000);
|
|
81
117
|
|
|
82
118
|
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
|
|
package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, computed,
|
|
2
|
+
import { ref, computed, watch } from 'vue';
|
|
3
3
|
import { useRoute, useRouter } from 'vue-router';
|
|
4
4
|
import { GoabCheckbox } from '<%= goaImportPath %>';
|
|
5
5
|
import { useApi } from '../composables/useApi';
|
|
6
6
|
|
|
7
7
|
const route = useRoute();
|
|
8
8
|
const router = useRouter();
|
|
9
|
-
const {
|
|
9
|
+
const { get, action } = useApi();
|
|
10
10
|
|
|
11
11
|
const idParam = computed(() => String(route.params.id ?? ''));
|
|
12
12
|
|
|
@@ -23,9 +23,7 @@ async function load() {
|
|
|
23
23
|
loading.value = true;
|
|
24
24
|
loadError.value = null;
|
|
25
25
|
try {
|
|
26
|
-
|
|
27
|
-
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
28
|
-
record.value = await res.json();
|
|
26
|
+
record.value = await get('<%= resource %>', idParam.value);
|
|
29
27
|
} catch (e) {
|
|
30
28
|
loadError.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
31
29
|
} finally {
|
|
@@ -33,7 +31,10 @@ async function load() {
|
|
|
33
31
|
}
|
|
34
32
|
}
|
|
35
33
|
|
|
36
|
-
onMounted
|
|
34
|
+
// Vue Router reuses this component when only the id param changes, so onMounted
|
|
35
|
+
// would never fire again and the previous record would stay on screen. An
|
|
36
|
+
// immediate watch covers first load and every later param change in one place.
|
|
37
|
+
watch(idParam, load, { immediate: true });
|
|
37
38
|
|
|
38
39
|
function editStep(key: string) {
|
|
39
40
|
router.push(`<%= route %>/${idParam.value}/${key}`);
|
|
@@ -44,10 +45,7 @@ async function onSubmit() {
|
|
|
44
45
|
submitting.value = true;
|
|
45
46
|
submitError.value = null;
|
|
46
47
|
try {
|
|
47
|
-
|
|
48
|
-
method: 'POST',
|
|
49
|
-
});
|
|
50
|
-
if (!res.ok) throw new Error(`Failed to submit (${res.status})`);
|
|
48
|
+
await action('<%= resource %>', idParam.value, 'submit');
|
|
51
49
|
router.push(`<%= route %>/${idParam.value}/confirmation`);
|
|
52
50
|
} catch (e) {
|
|
53
51
|
submitError.value = e instanceof Error ? e.message : 'Failed to submit.';
|
package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { reactive, ref, computed,
|
|
2
|
+
import { reactive, ref, computed, watch } from 'vue';
|
|
3
3
|
import { useRoute, useRouter } from 'vue-router';
|
|
4
4
|
import { Stepper, StepErrorSummary, GoabInput } from '<%= goaImportPath %>';
|
|
5
5
|
import { useApi } from '../composables/useApi';
|
|
@@ -12,7 +12,7 @@ const STEPS = [
|
|
|
12
12
|
|
|
13
13
|
const route = useRoute();
|
|
14
14
|
const router = useRouter();
|
|
15
|
-
const {
|
|
15
|
+
const { get, save } = useApi();
|
|
16
16
|
|
|
17
17
|
const idParam = computed(() => String(route.params.id ?? ''));
|
|
18
18
|
const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
|
|
@@ -50,9 +50,7 @@ async function load() {
|
|
|
50
50
|
loading.value = true;
|
|
51
51
|
loadError.value = null;
|
|
52
52
|
try {
|
|
53
|
-
const
|
|
54
|
-
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
55
|
-
const data = await res.json();
|
|
53
|
+
const data = await get('<%= resource %>', idParam.value);
|
|
56
54
|
completedSteps.value = Array.isArray(data.completedSteps) ? data.completedSteps : [];
|
|
57
55
|
<% stepFields.forEach(function (field) { -%>
|
|
58
56
|
if (data['<%- field.key %>'] !== undefined) form.<%- field.key %> = data['<%- field.key %>'];
|
|
@@ -64,7 +62,33 @@ async function load() {
|
|
|
64
62
|
}
|
|
65
63
|
}
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
// Field defaults come from the same EJS loop that seeds `form` above, so there
|
|
66
|
+
// is one source of truth for them. `form` is reactive, so it is reset per key
|
|
67
|
+
// rather than reassigned.
|
|
68
|
+
function resetForm() {
|
|
69
|
+
<% stepFields.forEach(function (field) { -%>
|
|
70
|
+
form.<%- field.key %> = '';
|
|
71
|
+
<% }); -%>
|
|
72
|
+
errors.value = [];
|
|
73
|
+
completedSteps.value = [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Vue Router reuses this component when only the id param changes, so onMounted
|
|
77
|
+
// would never fire again. The reset branch matters as much as the reload one: on
|
|
78
|
+
// an edit/<id> -> edit/new change load() returns early on isNew, which would
|
|
79
|
+
// leave the previous record's values sitting in a "create" form.
|
|
80
|
+
watch(
|
|
81
|
+
idParam,
|
|
82
|
+
() => {
|
|
83
|
+
if (isNew.value) {
|
|
84
|
+
resetForm();
|
|
85
|
+
loadError.value = null;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
void load();
|
|
89
|
+
},
|
|
90
|
+
{ immediate: true },
|
|
91
|
+
);
|
|
68
92
|
|
|
69
93
|
function validate(): boolean {
|
|
70
94
|
const found: { message: string; anchor?: string }[] = [];
|
|
@@ -98,17 +122,14 @@ async function onSaveAndContinue() {
|
|
|
98
122
|
...form,
|
|
99
123
|
completedSteps: [...new Set([...completedSteps.value, '<%- stepKey %>'])],
|
|
100
124
|
};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
},
|
|
125
|
+
// A create has to answer with the new record's id for the wizard to advance
|
|
126
|
+
// to the next step, so this one call site states the shape it needs.
|
|
127
|
+
const saved = await save<{ id: string | number }>(
|
|
128
|
+
'<%= resource %>',
|
|
129
|
+
isNew.value ? null : idParam.value,
|
|
130
|
+
body,
|
|
108
131
|
);
|
|
109
|
-
|
|
110
|
-
const saved = await res.json();
|
|
111
|
-
const nextId = isNew.value ? saved.id : idParam.value;
|
|
132
|
+
const nextId = isNew.value ? String(saved.id) : idParam.value;
|
|
112
133
|
router.push(`<%= route %>/${nextId}/<%- nextStepKey %>`);
|
|
113
134
|
} catch (e) {
|
|
114
135
|
saveError.value = e instanceof Error ? e.message : 'Failed to save.';
|
|
@@ -123,7 +123,10 @@ describe('Vue Intake View Generator', () => {
|
|
|
123
123
|
expect(review).toContain("record['fullName'] ?? '—'");
|
|
124
124
|
expect(review).toContain("record['email'] ?? '—'");
|
|
125
125
|
expect(review).toContain(':disabled="!declared || submitting || undefined"');
|
|
126
|
-
expect(review).toContain("
|
|
126
|
+
expect(review).toContain("await action('applications', idParam.value, 'submit')");
|
|
127
|
+
expect(review).not.toContain('apiFetch');
|
|
128
|
+
expect(review).toContain('watch(idParam, load, { immediate: true })');
|
|
129
|
+
expect(review).not.toContain('onMounted(');
|
|
127
130
|
expect(review).toContain('/applications/${idParam.value}/confirmation');
|
|
128
131
|
}, 30000);
|
|
129
132
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, onMounted } from 'vue';
|
|
3
|
-
import { WorkspaceTable } from '<%= goaImportPath %>';
|
|
2
|
+
import { ref, onMounted<% if (filterable) { %>, onUnmounted<% } %> } from 'vue';
|
|
3
|
+
import { WorkspaceTable, formatCurrency, formatDateTime } from '<%= goaImportPath %>';
|
|
4
4
|
import { useApi } from '../composables/useApi';
|
|
5
5
|
|
|
6
|
-
const {
|
|
6
|
+
const { list } = useApi();
|
|
7
7
|
|
|
8
8
|
const columns = [
|
|
9
9
|
<% columns.forEach(function (column) { -%>
|
|
@@ -27,31 +27,38 @@ let searchDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
|
27
27
|
|
|
28
28
|
const PAGE_SIZE = <%= pageSize %>;
|
|
29
29
|
|
|
30
|
+
// load() fires from page change, sort<% if (filterable) { %>, and the debounced search<% } %>, so two requests can
|
|
31
|
+
// overlap and resolve out of order — a slower earlier one would otherwise land
|
|
32
|
+
// last and overwrite the newer rows while the pagination control still showed
|
|
33
|
+
// the newer page. Only the most recent call is allowed to apply its result.
|
|
34
|
+
let loadSequence = 0;
|
|
35
|
+
|
|
30
36
|
async function load() {
|
|
37
|
+
const sequence = ++loadSequence;
|
|
31
38
|
loading.value = true;
|
|
32
39
|
error.value = null;
|
|
33
40
|
try {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
// Paging/sorting/filtering are stated in domain terms; useApi's adapter maps
|
|
42
|
+
// them onto whatever this backend actually expects (see its glue-layer block).
|
|
43
|
+
const result = await list('<%= resource %>', {
|
|
44
|
+
page: page.value,
|
|
45
|
+
pageSize: PAGE_SIZE,
|
|
38
46
|
<% if (filterable) { -%>
|
|
39
|
-
|
|
47
|
+
search: search.value || undefined,
|
|
40
48
|
<% } -%>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const data = await res.json();
|
|
48
|
-
// Accept either a bare array or a { results, total } page envelope.
|
|
49
|
-
rows.value = Array.isArray(data) ? data : (data.results ?? []);
|
|
50
|
-
itemCount.value = Array.isArray(data) ? data.length : (data.total ?? rows.value.length);
|
|
49
|
+
sortBy: sortBy.value,
|
|
50
|
+
sortDir: sortDir.value,
|
|
51
|
+
});
|
|
52
|
+
if (sequence !== loadSequence) return;
|
|
53
|
+
rows.value = result.rows;
|
|
54
|
+
itemCount.value = result.total;
|
|
51
55
|
} catch (e) {
|
|
56
|
+
if (sequence !== loadSequence) return;
|
|
52
57
|
error.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
53
58
|
} finally {
|
|
54
|
-
|
|
59
|
+
// A superseded request must not clear the flag — the newer one is still in
|
|
60
|
+
// flight, and the table would flash out of its loading state and back.
|
|
61
|
+
if (sequence === loadSequence) loading.value = false;
|
|
55
62
|
}
|
|
56
63
|
}
|
|
57
64
|
|
|
@@ -82,23 +89,15 @@ function onSearchInput(e: Event) {
|
|
|
82
89
|
void load();
|
|
83
90
|
}, 300);
|
|
84
91
|
}
|
|
92
|
+
|
|
93
|
+
// Without this, navigating away mid-keystroke lets the timer fire load() against
|
|
94
|
+
// a torn-down component.
|
|
95
|
+
onUnmounted(() => {
|
|
96
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
97
|
+
});
|
|
85
98
|
<% } -%>
|
|
86
99
|
|
|
87
|
-
function formatDate(value: unknown): string {
|
|
88
|
-
if (!value) return '—';
|
|
89
|
-
try {
|
|
90
|
-
return new Date(String(value)).toLocaleString('en-CA');
|
|
91
|
-
} catch {
|
|
92
|
-
return String(value);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
100
|
|
|
96
|
-
function formatCurrency(value: unknown): string {
|
|
97
|
-
if (value === undefined || value === null || value === '') return '—';
|
|
98
|
-
const n = Number(value);
|
|
99
|
-
if (Number.isNaN(n)) return String(value);
|
|
100
|
-
return new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD' }).format(n);
|
|
101
|
-
}
|
|
102
101
|
</script>
|
|
103
102
|
|
|
104
103
|
<template>
|
|
@@ -141,7 +140,7 @@ function formatCurrency(value: unknown): string {
|
|
|
141
140
|
</template>
|
|
142
141
|
<% } else if (column.type === 'date') { -%>
|
|
143
142
|
<template #cell-<%= column.key %>="{ row }">
|
|
144
|
-
{{
|
|
143
|
+
{{ formatDateTime(row['<%= column.key %>']) }}
|
|
145
144
|
</template>
|
|
146
145
|
<% } else if (column.type === 'currency') { -%>
|
|
147
146
|
<template #cell-<%= column.key %>="{ row }">
|