@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
@@ -1,12 +1,12 @@
1
1
  <script setup lang="ts">
2
- import { ref, computed, onMounted } from 'vue';
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 { apiFetch } = useApi();
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
- const res = await apiFetch(`/api/<%= resource %>/${idParam.value}`);
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(load);
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
- const res = await apiFetch(`/api/<%= resource %>/${idParam.value}/submit`, {
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.';
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { reactive, ref, computed, onMounted } from 'vue';
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 { apiFetch } = useApi();
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 res = await apiFetch(`/api/<%= resource %>/${idParam.value}`);
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
- onMounted(load);
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
- const res = await apiFetch(
102
- isNew.value ? '/api/<%= resource %>' : `/api/<%= resource %>/${idParam.value}`,
103
- {
104
- method: isNew.value ? 'POST' : 'PUT',
105
- headers: { 'Content-Type': 'application/json' },
106
- body: JSON.stringify(body),
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
- if (!res.ok) throw new Error(`Failed to save (${res.status})`);
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("apiFetch(`/api/applications/${idParam.value}/submit`");
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 { apiFetch } = useApi();
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
- const params = new URLSearchParams({
35
- page: String(page.value),
36
- limit: String(PAGE_SIZE),
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
- if (search.value) params.set('search', search.value);
47
+ search: search.value || undefined,
40
48
  <% } -%>
41
- if (sortBy.value) {
42
- params.set('sortBy', sortBy.value);
43
- params.set('sortDir', sortDir.value);
44
- }
45
- const res = await apiFetch(`/api/<%= resource %>?${params.toString()}`);
46
- if (!res.ok) throw new Error(`Failed to load (${res.status})`);
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
- loading.value = false;
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
- {{ formatDate(row['<%= column.key %>']) }}
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
- expect(view).toContain('apiFetch(`/api/applications?${params.toString()}`)');
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("formatDate(row['lastSaved'])");
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
- expect(view).toContain("import { WorkspaceTable } from '@proj/vue-components';");
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