@abgov/nx-adsp 13.24.0 → 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.
Files changed (21) 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__ +52 -0
  10. package/src/generators/vue-components/files/src/index.ts__tmpl__ +12 -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/vue-components.spec.ts__tmpl__ +11 -0
  14. package/src/generators/vue-components/vue-components.spec.ts +36 -0
  15. package/src/generators/vue-detail-view/files/src/views/__viewFileName__.vue__tmpl__ +9 -22
  16. package/src/generators/vue-detail-view/vue-detail-view.spec.ts +21 -3
  17. package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +8 -10
  18. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +37 -16
  19. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +4 -1
  20. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +33 -34
  21. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +50 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.24.0",
3
+ "version": "13.25.0",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { reactive, ref, computed, onMounted } from 'vue';
2
+ import { reactive, ref, computed, watch, onUnmounted } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
5
5
  import { GoabInput, GoabCheckbox } from '<%= goaImportPath %>';
@@ -8,7 +8,7 @@ import { useApi } from '../composables/useApi';
8
8
  const route = useRoute();
9
9
  const router = useRouter();
10
10
  const kc = useKeycloak();
11
- const { apiFetch } = useApi();
11
+ const { get, save } = useApi();
12
12
 
13
13
  const idParam = computed(() => String(route.params.id ?? ''));
14
14
  const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
@@ -28,15 +28,14 @@ const saving = ref(false);
28
28
  const loadError = ref<string | null>(null);
29
29
  const saveError = ref<string | null>(null);
30
30
  const successMessage = ref<string | null>(null);
31
+ let redirectTimer: ReturnType<typeof setTimeout> | undefined;
31
32
 
32
33
  async function load() {
33
34
  if (isNew.value) return;
34
35
  loading.value = true;
35
36
  loadError.value = null;
36
37
  try {
37
- const res = await apiFetch(`/api/<%= resource %>/${idParam.value}`);
38
- if (!res.ok) throw new Error(`Failed to load (${res.status})`);
39
- const data = await res.json();
38
+ const data = await get('<%= resource %>', idParam.value);
40
39
  <% fields.forEach(function (field) { -%>
41
40
  if (data['<%= field.key %>'] !== undefined) form.<%= field.key %> = data['<%= field.key %>'];
42
41
  <% }); -%>
@@ -47,7 +46,32 @@ async function load() {
47
46
  }
48
47
  }
49
48
 
50
- onMounted(load);
49
+ // Field defaults come from the same EJS loop that seeds `form` above, so there
50
+ // is one source of truth for them. `form` is reactive, so it is reset per key
51
+ // rather than reassigned.
52
+ function resetForm() {
53
+ <% fields.forEach(function (field) { -%>
54
+ form.<%- field.key %> = <%- field.type === 'checkbox' ? 'false' : "''" %>;
55
+ <% }); -%>
56
+ for (const key of Object.keys(errors)) errors[key] = undefined;
57
+ }
58
+
59
+ // Vue Router reuses this component when only the id param changes, so onMounted
60
+ // would never fire again. The reset branch matters as much as the reload one: on
61
+ // an edit/<id> -> edit/new change load() returns early on isNew, which would
62
+ // leave the previous record's values sitting in a "create" form.
63
+ watch(
64
+ idParam,
65
+ () => {
66
+ if (isNew.value) {
67
+ resetForm();
68
+ loadError.value = null;
69
+ return;
70
+ }
71
+ void load();
72
+ },
73
+ { immediate: true },
74
+ );
51
75
 
52
76
  function validate(): boolean {
53
77
  let valid = true;
@@ -69,17 +93,10 @@ async function onSubmit() {
69
93
  saving.value = true;
70
94
  saveError.value = null;
71
95
  try {
72
- const res = await apiFetch(
73
- isNew.value ? '/api/<%= resource %>' : `/api/<%= resource %>/${idParam.value}`,
74
- {
75
- method: isNew.value ? 'POST' : 'PUT',
76
- headers: { 'Content-Type': 'application/json' },
77
- body: JSON.stringify(form),
78
- },
79
- );
80
- if (!res.ok) throw new Error(`Failed to save (${res.status})`);
96
+ // Create vs. update — which verb and path that means is the adapter's call.
97
+ await save('<%= resource %>', isNew.value ? null : idParam.value, form);
81
98
  successMessage.value = isNew.value ? '<%= singularLabel %> created.' : '<%= singularLabel %> saved.';
82
- setTimeout(() => router.push('<%= route %>'), 600);
99
+ redirectTimer = setTimeout(() => router.push('<%= route %>'), 600);
83
100
  } catch (e) {
84
101
  saveError.value = e instanceof Error ? e.message : 'Failed to save.';
85
102
  } finally {
@@ -90,6 +107,13 @@ async function onSubmit() {
90
107
  function goBack() {
91
108
  router.push('<%= route %>');
92
109
  }
110
+
111
+ // The success message holds for 600ms before redirecting. If the user navigates
112
+ // away inside that window the timer would fire anyway and yank them back to the
113
+ // list from wherever they went.
114
+ onUnmounted(() => {
115
+ if (redirectTimer) clearTimeout(redirectTimer);
116
+ });
93
117
  </script>
94
118
 
95
119
  <template>
@@ -5,7 +5,7 @@ import { WorkspaceTable } from '<%= goaImportPath %>';
5
5
  import { useApi } from '../composables/useApi';
6
6
 
7
7
  const kc = useKeycloak();
8
- const { apiFetch } = useApi();
8
+ const { list } = useApi();
9
9
 
10
10
  const columns = [
11
11
  <% fields.forEach(function (field) { -%>
@@ -23,11 +23,7 @@ async function load() {
23
23
  loading.value = true;
24
24
  error.value = null;
25
25
  try {
26
- const res = await apiFetch('/api/<%= resource %>');
27
- if (!res.ok) throw new Error(`Failed to load (${res.status})`);
28
- const data = await res.json();
29
- // Accept either a bare array or a { results } envelope.
30
- rows.value = Array.isArray(data) ? data : (data.results ?? []);
26
+ rows.value = (await list('<%= resource %>')).rows;
31
27
  } catch (e) {
32
28
  error.value = e instanceof Error ? e.message : 'Failed to load.';
33
29
  } finally {
@@ -61,7 +61,9 @@ describe('Vue Admin CRUD Generator', () => {
61
61
  .read('apps/test/src/views/RegionsListView.vue')
62
62
  .toString();
63
63
  expect(view).toContain('<h1>Regions</h1>');
64
- expect(view).toContain("apiFetch('/api/regions')");
64
+ expect(view).toContain("await list('regions')");
65
+ // The envelope shape is the adapter's business, not the view's.
66
+ expect(view).not.toContain('data.results');
65
67
  expect(view).toContain("import { WorkspaceTable } from '@proj/vue-components';");
66
68
  expect(view).toContain('<WorkspaceTable');
67
69
  // No pagination props bound -- this is the "reused without its pagination/
@@ -90,9 +92,26 @@ describe('Vue Admin CRUD Generator', () => {
90
92
  expect(view).toContain("errors.name = 'Name is required.';");
91
93
  // Checkbox has no required-validation block.
92
94
  expect(view).not.toContain('errors.active');
93
- expect(view).toContain("method: isNew.value ? 'POST' : 'PUT'");
94
- expect(view).toContain("isNew.value ? '/api/regions'");
95
- expect(view).toContain('apiFetch(`/api/regions/${idParam.value}`');
95
+ // Create vs. update is expressed as a null id; which verb and path that
96
+ // becomes is decided by useApi's adapter.
97
+ expect(view).toContain("await save('regions', isNew.value ? null : idParam.value, form)");
98
+ expect(view).toContain("await get('regions', idParam.value)");
99
+ expect(view).not.toContain('apiFetch');
100
+ expect(view).not.toContain("'PUT'");
101
+
102
+ // Vue Router reuses this component across an id-only change. The reset
103
+ // branch is the point: edit/<id> -> edit/new must not leave the previous
104
+ // record's values in a create form.
105
+ expect(view).toContain('watch(');
106
+ expect(view).toContain('{ immediate: true }');
107
+ expect(view).toContain('function resetForm()');
108
+ expect(view).toContain('resetForm();');
109
+ expect(view).not.toContain('onMounted(');
110
+
111
+ // The 600ms success redirect must not fire after the user navigates away.
112
+ expect(view).toContain('redirectTimer = setTimeout(');
113
+ expect(view).toContain('clearTimeout(redirectTimer)');
114
+ expect(view).toContain('onUnmounted(');
96
115
  expect(view).toContain("router.push('/regions')");
97
116
  expect(view).toContain('Create Regions');
98
117
  expect(view).toContain('Edit Regions');
@@ -290,6 +290,34 @@ const res = await apiFetch('/api/v1/my-resource', {
290
290
  const res = await fetch('http://localhost:3333/<%= pairedProject || 'my-service' %>/v1/my-resource')
291
291
  ```
292
292
 
293
+ **For CRUD against a REST resource, prefer the domain-level helpers over `apiFetch`.**
294
+ `useApi()` also returns `list`/`get`/`save`/`action`, which state what you want rather than how
295
+ this API spells it:
296
+
297
+ ```typescript
298
+ const { list, get, save, action } = useApi()
299
+
300
+ const { rows, total } = await list('my-resource', {
301
+ page: 1, pageSize: 20, search: term, sortBy: 'name', sortDir: 'asc',
302
+ filters: { status: 'active' },
303
+ })
304
+ const record = await get('my-resource', id)
305
+ await save('my-resource', null, payload) // null id = create
306
+ await save('my-resource', id, payload) // an id = update
307
+ await action('my-resource', id, 'submit') // POST /:id/submit
308
+ ```
309
+
310
+ Every wire-level detail these hide — the base path, whether paging is `page`/`limit` or
311
+ `limit`/`offset`, whether rows arrive bare or under `results`/`entries`, whether an update is PUT
312
+ or PATCH — lives in the **API convention adapter** at the top of
313
+ `src/composables/useApi.ts`. That block is the glue layer between this app and its backend:
314
+ **edit it when your API differs, and never encode an API convention in a view.** Every
315
+ `vue-*-view` generator emits views that go through these helpers, so a view stays correct when the
316
+ backend's conventions aren't the default ones, and your mapping survives regenerating the view.
317
+
318
+ Use `apiFetch` directly for anything that isn't resource CRUD — a non-REST endpoint, a file
319
+ download, a custom collection path.
320
+
293
321
  **Most routes require authentication.** The service mounts `/v1` with the anonymous passport
294
322
  strategy, so a bare request without a token reaches the router — but business routes also
295
323
  gate on `tenant` auth and will 401. `apiFetch` handles this automatically when signed in.
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { apiConvention } from './useApi'
3
+
4
+ // The API convention adapter is this app's glue layer to its backend, so it is
5
+ // the piece most likely to be edited by hand. These tests pin the default
6
+ // mapping; if you change the adapter to match a different API, change them with
7
+ // it -- a failure here means a generated view's assumptions moved.
8
+ describe('apiConvention', () => {
9
+ it('builds collection, record, and action paths', () => {
10
+ expect(apiConvention.path('widgets')).toBe('/api/widgets')
11
+ expect(apiConvention.path('widgets', 7)).toBe('/api/widgets/7')
12
+ expect(apiConvention.path('widgets', 7, 'submit')).toBe('/api/widgets/7/submit')
13
+ })
14
+
15
+ it('maps a domain page/pageSize onto this API page/limit parameters', () => {
16
+ const params = apiConvention.query({ page: 3, pageSize: 20 })
17
+ expect(params.get('page')).toBe('3')
18
+ expect(params.get('limit')).toBe('20')
19
+ })
20
+
21
+ it('omits paging entirely when the view did not ask for a page', () => {
22
+ expect(apiConvention.query({}).toString()).toBe('')
23
+ })
24
+
25
+ it('passes search, sort, and extra filters through, dropping empty values', () => {
26
+ const params = apiConvention.query({
27
+ search: 'abc',
28
+ sortBy: 'name',
29
+ filters: { status: 'active', region: undefined, owner: '' },
30
+ })
31
+ expect(params.get('search')).toBe('abc')
32
+ expect(params.get('sortBy')).toBe('name')
33
+ expect(params.get('sortDir')).toBe('asc') // defaulted
34
+ expect(params.get('status')).toBe('active')
35
+ expect(params.has('region')).toBe(false)
36
+ expect(params.has('owner')).toBe(false)
37
+ })
38
+
39
+ it('reads a bare array response', () => {
40
+ expect(apiConvention.result([{ id: 1 }, { id: 2 }])).toEqual({
41
+ rows: [{ id: 1 }, { id: 2 }],
42
+ total: 2,
43
+ })
44
+ })
45
+
46
+ it('reads a { results, total } envelope, and falls back to row count', () => {
47
+ expect(apiConvention.result({ results: [{ id: 1 }], total: 57 })).toEqual({
48
+ rows: [{ id: 1 }],
49
+ total: 57,
50
+ })
51
+ expect(apiConvention.result({ results: [{ id: 1 }] })).toEqual({
52
+ rows: [{ id: 1 }],
53
+ total: 1,
54
+ })
55
+ })
56
+
57
+ it('yields no rows rather than throwing on an unrecognised envelope', () => {
58
+ // A silently empty table is the failure this adapter exists to make fixable
59
+ // in one place: if your API nests rows under another key, map it above.
60
+ expect(apiConvention.result({ entries: [{ id: 1 }], total: 9 })).toEqual({
61
+ rows: [],
62
+ total: 9,
63
+ })
64
+ expect(apiConvention.result(null)).toEqual({ rows: [], total: 0 })
65
+ })
66
+
67
+ it('creates with POST and updates with PUT', () => {
68
+ expect(apiConvention.writeMethod(true)).toBe('POST')
69
+ expect(apiConvention.writeMethod(false)).toBe('PUT')
70
+ })
71
+ })
@@ -1,5 +1,83 @@
1
1
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
2
2
 
3
+ // ---------------------------------------------------------------------------
4
+ // API convention adapter — THE GLUE LAYER. Edit this block, not the views.
5
+ //
6
+ // Generated views speak in domain terms only: page, pageSize, search, sort,
7
+ // filters, rows, total. Every wire-level detail — the base path, what the paging
8
+ // parameters are called, which key the rows arrive under, which verb updates a
9
+ // record — lives here and nowhere else. A backend that pages by limit/offset,
10
+ // wraps rows under `entries`, or updates with PATCH is a change to this block
11
+ // alone, with no view touched and nothing to redo when a view is regenerated.
12
+ //
13
+ // This is deliberately a small amount of indirection: the alternative, which
14
+ // this replaced, inlined `page`/`limit` and `data.results` into every generated
15
+ // view, so a backend using different names silently produced an empty table
16
+ // under a pagination control reporting the right total.
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface ListQuery {
20
+ page?: number
21
+ pageSize?: number
22
+ search?: string
23
+ sortBy?: string
24
+ sortDir?: 'asc' | 'desc'
25
+ /** Extra key/value filters. Empty values are dropped. */
26
+ filters?: Record<string, string | undefined>
27
+ }
28
+
29
+ export interface ListResult<T = Record<string, unknown>> {
30
+ rows: T[]
31
+ total: number
32
+ }
33
+
34
+ export const apiConvention = {
35
+ /** Path to a collection, one record, or a named action on a record. */
36
+ path(resource: string, id?: string | number, action?: string): string {
37
+ const base = `/api/${resource}`
38
+ if (id === undefined || id === null) return base
39
+ return action ? `${base}/${id}/${action}` : `${base}/${id}`
40
+ },
41
+
42
+ /** Domain list query -> this API's query string. */
43
+ query(query: ListQuery): URLSearchParams {
44
+ const params = new URLSearchParams()
45
+ if (query.page !== undefined && query.pageSize !== undefined) {
46
+ params.set('page', String(query.page))
47
+ params.set('limit', String(query.pageSize))
48
+ // For a limit/offset API, replace the two lines above with:
49
+ // params.set('limit', String(query.pageSize))
50
+ // params.set('offset', String((query.page - 1) * query.pageSize))
51
+ }
52
+ if (query.search) params.set('search', query.search)
53
+ if (query.sortBy) {
54
+ params.set('sortBy', query.sortBy)
55
+ params.set('sortDir', query.sortDir ?? 'asc')
56
+ }
57
+ for (const [key, value] of Object.entries(query.filters ?? {})) {
58
+ if (value) params.set(key, value)
59
+ }
60
+ return params
61
+ },
62
+
63
+ /**
64
+ * This API's list response -> { rows, total }. Handles a bare array and a
65
+ * `{ results, total }` envelope; if yours nests rows under a different key
66
+ * (e.g. `entries`), read it here.
67
+ */
68
+ result(data: unknown): ListResult {
69
+ if (Array.isArray(data)) return { rows: data, total: data.length }
70
+ const envelope = (data ?? {}) as Record<string, unknown>
71
+ const rows = (envelope.results ?? []) as Record<string, unknown>[]
72
+ return { rows, total: (envelope.total as number) ?? rows.length }
73
+ },
74
+
75
+ /** Create vs. update verb. Updates with PATCH instead? Change it here. */
76
+ writeMethod(isNew: boolean): 'POST' | 'PUT' | 'PATCH' {
77
+ return isNew ? 'POST' : 'PUT'
78
+ },
79
+ }
80
+
3
81
  export function useApi() {
4
82
  // Keep the reactive instance — do NOT destructure: `authenticated` and `keycloak`
5
83
  // are plain values inside a reactive object and only populate asynchronously once
@@ -17,5 +95,80 @@ export function useApi() {
17
95
  return fetch(url, init)
18
96
  }
19
97
 
20
- return { apiFetch }
98
+ // `verb` only shapes the thrown message ("Failed to load (404)"), so a view's
99
+ // catch block can surface it unchanged.
100
+ async function request<T>(
101
+ url: string,
102
+ init: RequestInit,
103
+ verb: string,
104
+ ): Promise<T> {
105
+ const res = await apiFetch(url, init)
106
+ if (!res.ok) throw new Error(`Failed to ${verb} (${res.status})`)
107
+ // A write may legitimately answer 204, or 200 with an empty body — parsing
108
+ // that as JSON would throw on a request that actually succeeded.
109
+ if (res.status === 204) return undefined as T
110
+ const body = await res.text()
111
+ return (body ? JSON.parse(body) : undefined) as T
112
+ }
113
+
114
+ function list<T = Record<string, unknown>>(
115
+ resource: string,
116
+ query: ListQuery = {},
117
+ ): Promise<ListResult<T>> {
118
+ const params = apiConvention.query(query).toString()
119
+ const url = apiConvention.path(resource) + (params ? `?${params}` : '')
120
+ return request<unknown>(url, {}, 'load').then(
121
+ (data) => apiConvention.result(data) as ListResult<T>,
122
+ )
123
+ }
124
+
125
+ function get<T = Record<string, unknown>>(
126
+ resource: string,
127
+ id: string | number,
128
+ ): Promise<T> {
129
+ return request<T>(apiConvention.path(resource, id), {}, 'load')
130
+ }
131
+
132
+ function save<T = Record<string, unknown>>(
133
+ resource: string,
134
+ id: string | number | null,
135
+ body: unknown,
136
+ ): Promise<T> {
137
+ const isNew = id === null || id === undefined
138
+ return request<T>(
139
+ isNew
140
+ ? apiConvention.path(resource)
141
+ : apiConvention.path(resource, id),
142
+ {
143
+ method: apiConvention.writeMethod(isNew),
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(body),
146
+ },
147
+ 'save',
148
+ )
149
+ }
150
+
151
+ /** A named action on one record, e.g. action('applications', id, 'submit'). */
152
+ function action<T = Record<string, unknown>>(
153
+ resource: string,
154
+ id: string | number,
155
+ name: string,
156
+ body?: unknown,
157
+ ): Promise<T> {
158
+ return request<T>(
159
+ apiConvention.path(resource, id, name),
160
+ {
161
+ method: 'POST',
162
+ ...(body === undefined
163
+ ? {}
164
+ : {
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify(body),
167
+ }),
168
+ },
169
+ name,
170
+ )
171
+ }
172
+
173
+ return { apiFetch, list, get, save, action }
21
174
  }
@@ -295,6 +295,16 @@ describe('Vue App Generator', () => {
295
295
  expect(useApi).toContain('updateToken');
296
296
  expect(useApi).toContain('Authorization');
297
297
  expect(useApi).toContain('apiFetch');
298
+ // The glue layer: views state paging/sorting in domain terms and this block
299
+ // maps them to the wire. Its absence is what let query-param names and
300
+ // response-envelope keys get inlined into every generated view.
301
+ expect(useApi).toContain('apiConvention');
302
+ expect(useApi).toContain('THE GLUE LAYER');
303
+ for (const method of ['function list', 'function get', 'function save', 'function action']) {
304
+ expect(useApi).toContain(method);
305
+ }
306
+ // The documented escape hatch for a limit/offset backend.
307
+ expect(useApi).toContain("params.set('offset'");
298
308
 
299
309
  // HomeView delegates to the composable — raw token wiring must not leak into views.
300
310
  const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
@@ -8,6 +8,7 @@ invoked automatically by `vue-app` and by every `vue-*-view` generator.
8
8
  |---|---|---|
9
9
  | `src/lib/primitives/` | Thin `v-model`/idiomatic-event wrappers over individual `goa-*` elements (`GoabInput`, `GoabButton`, …) | **Interim** — see below |
10
10
  | `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`, `Stepper`, `StepErrorSummary`) | **Permanent** |
11
+ | `src/lib/formatters.ts` | Shared value formatting for views (`formatDate`, `formatDateTime`, `formatNumber`, `formatPercent`) | **Permanent** |
11
12
 
12
13
  **Not every pattern component is present in every app.** `AppLayout`, `AppHeader`,
13
14
  `AppFooter`, `AppSideMenu`, and `SessionExpiredBanner` are base app-shell — `vue-app`
@@ -38,6 +39,50 @@ building on it.
38
39
  > package — a design system ships primitives, not your app's shell. Nothing in
39
40
  > `patterns/` is deleted when `primitives/` is.
40
41
 
42
+ ## Use the design system's own elements — most need no wrapper
43
+
44
+ **Read this before writing any markup.** GoA's design system ships ~94 custom elements. Only a
45
+ handful need a Vue wrapper (the form inputs — see the next section for why); the rest are
46
+ presentational, have nothing to bind, and are used **bare** in any Vue SFC. `vite.config.mts`'s
47
+ `isCustomElement` already recognises `goa-*`, so they work with no import and no registration.
48
+
49
+ If you find yourself writing `style="…"` for layout, spacing, or typography, stop — the element you
50
+ want almost certainly exists. Measured on a real ADSP app that shipped without this catalog: 254 of
51
+ its 257 inline styles were on raw HTML (`div`, `th`, `td`, `span`, `h2`) standing in for the elements
52
+ below, while only 3 were on `goa-*` elements. Every view its generators fully covered had zero
53
+ inline styles.
54
+
55
+ | Instead of hand-rolling | Use | Notes |
56
+ |---|---|---|
57
+ | `<div style="background:white;border:1px solid …;border-radius:…;padding:…">` | `<goa-container>` | The bordered, padded card box. `type`/`accent` variants |
58
+ | `<div style="display:flex;gap:…;align-items:…">` | `<goa-block>` | Flex row or stack, `gap` and `direction` props |
59
+ | `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(…))">` | `<goa-grid>` | Responsive auto-fit grid, `min-child-width` prop |
60
+ | `<h2 style="font-size:…;font-weight:…;color:…">`, styled `span`/`p` | `<goa-text>` | Typography scale: `size`, `weight`, `color`, `mt`/`mb` |
61
+ | `<table>` with `<th style>`/`<td style>` | `<goa-table>` (+ `<goa-table-sort-header>`) | For any table that isn't a paginated list view — `WorkspaceTable` already wraps this for that case |
62
+ | Tab `<button>`s toggling `v-if` blocks | `<goa-tabs>` + `<goa-tab heading="…">` | Slot-based and self-managing: `initialtab` sets the first open tab, no `v-model` and no `@_change` |
63
+ | A margin-only `<div>` or `<br>` | `<goa-spacer vspacing="m">` | |
64
+ | A styled status pill | `<goa-badge type="…">` | |
65
+ | A hand-built alert/notice box | `<goa-callout type="…" heading="…">` | |
66
+ | A loading `<div>` or spinner markup | `<goa-skeleton type="…">` or `<goa-spinner>` | Skeletons match the shape they replace (`card`, `text`, `table`) |
67
+ | A `<label>` + error `<span>` around an input | `<goa-form-item label="…" error="…">` | Wrap the `Goab*` input in this, don't restyle it |
68
+ | A row of buttons with flex styling | `<goa-button-group alignment="…">` | |
69
+
70
+ Also available bare and worth knowing before hand-rolling: `goa-accordion`, `goa-details`,
71
+ `goa-divider`, `goa-chip`, `goa-filter-chip`, `goa-icon`, `goa-icon-button`, `goa-link`,
72
+ `goa-notification`, `goa-popover`, `goa-tooltip`, `goa-date-picker`, `goa-file-upload-input`,
73
+ `goa-file-upload-card`, `goa-circular-progress`, `goa-linear-progress`, `goa-hero-banner`,
74
+ `goa-page-block`. For the full set, list the registered element names from the installed package:
75
+
76
+ ```bash
77
+ grep -rhoE 'customElements\.define\("goa-[a-z0-9-]+"' node_modules/@abgov/web-components \
78
+ | grep -oE 'goa-[a-z0-9-]+' | sort -u
79
+ ```
80
+
81
+ For values rather than layout, use `formatters.ts` (`formatDate`, `formatDateTime`, `formatNumber`,
82
+ `formatPercent`) rather than an inline `toLocaleString` — every view should render a date or a count
83
+ the same way, and each formatter already returns an em dash for a null/unparseable value so a
84
+ partially-populated record needs no per-field guard.
85
+
41
86
  ## What these wrappers do (and why they're needed)
42
87
 
43
88
  `goa-*` are framework-agnostic custom elements (built with Svelte). Three quirks
@@ -177,6 +222,13 @@ banners — not a single-element wrapper. Existing examples: `AppLayout`,
177
222
 
178
223
  ## Don't
179
224
 
225
+ - **Don't write inline `style` for layout, spacing, or typography** in a component here or in a
226
+ view that consumes it. Use the elements in the catalog above. An inline style is a signal either
227
+ that the right element wasn't found, or that a pattern component is missing — both worth raising
228
+ rather than working around.
229
+ - **Don't wrap a presentational element.** `primitives/` exists only to add `v-model` over elements
230
+ whose `_`-prefixed event Vue can't bind. `goa-container`, `goa-block`, `goa-grid`, `goa-text`,
231
+ `goa-table`, and `goa-tabs` have no model to bind — a wrapper would add a layer for nothing.
180
232
  - **Don't add new `goa-*` element wrappers to `patterns/`, or new composite
181
233
  components to `primitives/`.** Single-element wrappers belong in
182
234
  `primitives/` (interim, deleted on the `@abgov/vue-components` swap);
@@ -9,6 +9,10 @@
9
9
  // PERMANENT: these have no equivalent in an official design-system package
10
10
  // (that's app-shell composition, not a design-system primitive) and are not
11
11
  // deleted when primitives/ is.
12
+ //
13
+ // formatters.ts — shared value formatting for views. PERMANENT, and not a
14
+ // component: presentational goa-* elements need no wrapper, but every view
15
+ // still has to render a date or a count the same way as every other view.
12
16
  export { default as GoabInput } from './lib/primitives/GoabInput.vue';
13
17
  export { default as GoabTextarea } from './lib/primitives/GoabTextarea.vue';
14
18
  export { default as GoabDropdown } from './lib/primitives/GoabDropdown.vue';
@@ -26,3 +30,11 @@ export { default as RecordDetailShell } from './lib/patterns/RecordDetailShell.v
26
30
  export { default as WorkspaceTable } from './lib/patterns/WorkspaceTable.vue';
27
31
  export { default as Stepper } from './lib/patterns/Stepper.vue';
28
32
  export { default as StepErrorSummary } from './lib/patterns/StepErrorSummary.vue';
33
+
34
+ export {
35
+ formatCurrency,
36
+ formatDate,
37
+ formatDateTime,
38
+ formatNumber,
39
+ formatPercent,
40
+ } from './lib/formatters';
@@ -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, onMounted } from 'vue';
2
+ import { ref, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
- import { RecordDetailShell } from '<%= goaImportPath %>';
4
+ import { RecordDetailShell, formatCurrency, formatDateTime } from '<%= goaImportPath %>';
5
5
  import { useApi } from '../composables/useApi';
6
6
 
7
7
  // The fetched record's shape isn't known to this generator -- read fields
@@ -12,15 +12,13 @@ const error = ref<string | null>(null);
12
12
 
13
13
  const route = useRoute();
14
14
  const router = useRouter();
15
- const { apiFetch } = useApi();
15
+ const { get } = useApi();
16
16
 
17
17
  async function load() {
18
18
  loading.value = true;
19
19
  error.value = null;
20
20
  try {
21
- const res = await apiFetch(`/api/<%= resource %>/${route.params.id}`);
22
- if (!res.ok) throw new Error(`Failed to load (${res.status})`);
23
- record.value = await res.json();
21
+ record.value = await get('<%= resource %>', String(route.params.id));
24
22
  } catch (e) {
25
23
  error.value = e instanceof Error ? e.message : 'Failed to load.';
26
24
  } finally {
@@ -28,27 +26,16 @@ async function load() {
28
26
  }
29
27
  }
30
28
 
31
- onMounted(load);
29
+ // Vue Router reuses this component when only the id param changes, so onMounted
30
+ // would never fire again and the previous record would stay on screen. An
31
+ // immediate watch covers first load and every later param change in one place.
32
+ watch(() => route.params.id, load, { immediate: true });
32
33
 
33
34
  function goBack() {
34
35
  router.back();
35
36
  }
36
37
 
37
- function formatDate(value: unknown): string {
38
- if (!value) return '—';
39
- try {
40
- return new Date(String(value)).toLocaleString('en-CA');
41
- } catch {
42
- return String(value);
43
- }
44
- }
45
38
 
46
- function formatCurrency(value: unknown): string {
47
- if (value === undefined || value === null || value === '') return '—';
48
- const n = Number(value);
49
- if (Number.isNaN(n)) return String(value);
50
- return new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD' }).format(n);
51
- }
52
39
  </script>
53
40
 
54
41
  <template>
@@ -67,7 +54,7 @@ function formatCurrency(value: unknown): string {
67
54
  <% if (field.type === 'badge') { -%>
68
55
  <goa-badge type="information" :content="String(record['<%= field.key %>'] ?? '—')" />
69
56
  <% } else if (field.type === 'date') { -%>
70
- {{ formatDate(record['<%= field.key %>']) }}
57
+ {{ formatDateTime(record['<%= field.key %>']) }}
71
58
  <% } else if (field.type === 'currency') { -%>
72
59
  {{ formatCurrency(record['<%= field.key %>']) }}
73
60
  <% } else { -%>
@@ -102,17 +102,35 @@ describe('Vue Detail View Generator', () => {
102
102
  .read('apps/test/src/views/ApplicationDetailView.vue')
103
103
  .toString();
104
104
  expect(view).toContain('heading="Application Detail"');
105
- expect(view).toContain("apiFetch(`/api/applications/${route.params.id}`)");
105
+ expect(view).toContain("await get('applications', String(route.params.id))");
106
+ expect(view).not.toContain('apiFetch');
107
+
108
+ // Vue Router reuses this component across an id-only change, so the fetch
109
+ // is driven by a watch rather than onMounted.
110
+ expect(view).toContain("watch(() => route.params.id, load, { immediate: true })");
111
+ expect(view).not.toContain('onMounted(');
112
+ expect(view).not.toContain('function formatCurrency');
113
+
114
+ expect(view).toContain('formatDateTime');
115
+ expect(view).not.toContain('function formatDate');
106
116
  expect(view).toContain(
107
117
  "<goa-badge type=\"information\" :content=\"String(record['status'] ?? '—')\" />",
108
118
  );
109
- expect(view).toContain("formatDate(record['lastSaved'])");
119
+ expect(view).toContain("formatDateTime(record['lastSaved'])");
110
120
  expect(view).toContain("formatCurrency(record['requestTotal'])");
111
121
  expect(view).toContain("record['serviceModel'] ?? '—'");
112
122
  expect(view).toContain('<dt>Status</dt>');
113
123
  expect(view).toContain('<dt>Service Model</dt>');
114
124
  // Uses the shared shell, not hand-rolled loading/error markup.
115
- expect(view).toContain("import { RecordDetailShell } from '@proj/vue-components';");
125
+ // Read the import's contents rather than an exact line: formatFiles wraps a
126
+ // long import list and adds a trailing comma.
127
+ const goaImport =
128
+ view
129
+ .replace(/\s+/g, ' ')
130
+ .match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
131
+ for (const name of ['RecordDetailShell', 'formatCurrency', 'formatDateTime']) {
132
+ expect(goaImport).toContain(name);
133
+ }
116
134
  expect(view).toContain('<RecordDetailShell');
117
135
  }, 30000);
118
136
 
@@ -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