@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
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 }">
|
|
@@ -99,20 +99,67 @@ describe('Vue Workspace View Generator', () => {
|
|
|
99
99
|
expect(view).toContain(
|
|
100
100
|
"{ key: 'lastSaved', label: 'Last saved', sortable: true }",
|
|
101
101
|
);
|
|
102
|
-
|
|
102
|
+
// Goes through useApi's adapter in domain terms -- no query-param name and
|
|
103
|
+
// no response-envelope key appears in the view.
|
|
104
|
+
expect(view).toContain("await list('applications', {");
|
|
105
|
+
expect(view).toContain('pageSize: PAGE_SIZE');
|
|
106
|
+
expect(view).toContain('rows.value = result.rows');
|
|
107
|
+
expect(view).toContain('itemCount.value = result.total');
|
|
108
|
+
expect(view).not.toContain('apiFetch');
|
|
109
|
+
expect(view).not.toContain('URLSearchParams');
|
|
110
|
+
expect(view).not.toContain('data.results');
|
|
111
|
+
|
|
112
|
+
// Out-of-order responses: load() fires from page change, sort and the
|
|
113
|
+
// debounced search, so only the most recent call may apply its result.
|
|
114
|
+
expect(view).toContain('let loadSequence = 0');
|
|
115
|
+
expect(view).toContain('const sequence = ++loadSequence');
|
|
116
|
+
expect(view).toContain('if (sequence !== loadSequence) return;');
|
|
117
|
+
expect(view).toContain('if (sequence === loadSequence) loading.value = false;');
|
|
118
|
+
|
|
119
|
+
// The debounce timer must not outlive the component.
|
|
120
|
+
expect(view).toContain('onUnmounted(');
|
|
121
|
+
expect(view).toContain('clearTimeout(searchDebounce)');
|
|
122
|
+
|
|
123
|
+
// Dates come from the shared formatter, not a per-view copy.
|
|
124
|
+
expect(view).toContain('formatDateTime');
|
|
125
|
+
expect(view).not.toContain('function formatDate');
|
|
126
|
+
expect(view).not.toContain('toLocaleString');
|
|
127
|
+
expect(view).not.toContain('Intl.');
|
|
128
|
+
expect(view).not.toContain('function formatCurrency');
|
|
103
129
|
expect(view).toContain(
|
|
104
130
|
"<goa-badge type=\"information\" :content=\"String(row['status'] ?? '—')\" />",
|
|
105
131
|
);
|
|
106
|
-
expect(view).toContain("
|
|
132
|
+
expect(view).toContain("formatDateTime(row['lastSaved'])");
|
|
107
133
|
expect(view).toContain("formatCurrency(row['requestTotal'])");
|
|
108
134
|
// Uses the shared table shell, not hand-rolled loading/pagination markup.
|
|
109
|
-
|
|
135
|
+
// Read the import's contents rather than an exact line: formatFiles wraps a
|
|
136
|
+
// long import list and adds a trailing comma.
|
|
137
|
+
const goaImport =
|
|
138
|
+
view
|
|
139
|
+
.replace(/\s+/g, ' ')
|
|
140
|
+
.match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
|
|
141
|
+
for (const name of ['WorkspaceTable', 'formatCurrency', 'formatDateTime']) {
|
|
142
|
+
expect(goaImport).toContain(name);
|
|
143
|
+
}
|
|
110
144
|
expect(view).toContain('<WorkspaceTable');
|
|
111
145
|
// Filterable by default: a debounced search input.
|
|
112
146
|
expect(view).toContain('type="search"');
|
|
113
147
|
expect(view).toContain('searchDebounce');
|
|
114
148
|
}, 30000);
|
|
115
149
|
|
|
150
|
+
it('omits onUnmounted along with the debounce when --filterable=false', async () => {
|
|
151
|
+
// onUnmounted exists only to clear the search debounce, so importing it
|
|
152
|
+
// unconditionally would be an unused import and fail the generated lint.
|
|
153
|
+
await generator(host, { ...baseOptions, filterable: false });
|
|
154
|
+
const view = host
|
|
155
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
156
|
+
.toString();
|
|
157
|
+
expect(view).not.toContain('onUnmounted');
|
|
158
|
+
expect(view).not.toContain('searchDebounce');
|
|
159
|
+
// The out-of-order guard is not search-specific -- page and sort still race.
|
|
160
|
+
expect(view).toContain('let loadSequence = 0');
|
|
161
|
+
});
|
|
162
|
+
|
|
116
163
|
it('omits the search input and its wiring when --filterable=false', async () => {
|
|
117
164
|
await generator(host, { ...baseOptions, filterable: false });
|
|
118
165
|
const view = host
|