@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.
Files changed (30) hide show
  1. package/migrations.json +11 -0
  2. package/package.json +4 -1
  3. package/src/build-assets.spec.ts +74 -0
  4. package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +40 -16
  5. package/src/generators/vue-admin-crud/files/src/views/__listViewFileName__.vue__tmpl__ +2 -6
  6. package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +23 -4
  7. package/src/generators/vue-app/files/AGENTS.md__tmpl__ +28 -0
  8. package/src/generators/vue-app/files/src/composables/useApi.spec.ts__tmpl__ +71 -0
  9. package/src/generators/vue-app/files/src/composables/useApi.ts__tmpl__ +154 -1
  10. package/src/generators/vue-app/vue-app.spec.ts +10 -0
  11. package/src/generators/vue-components/files/AGENTS.md__tmpl__ +52 -0
  12. package/src/generators/vue-components/files/src/index.ts__tmpl__ +12 -0
  13. package/src/generators/vue-components/files/src/lib/formatters.spec.ts__tmpl__ +78 -0
  14. package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +88 -0
  15. package/src/generators/vue-components/files/src/vue-components.spec.ts__tmpl__ +11 -0
  16. package/src/generators/vue-components/vue-components.spec.ts +36 -0
  17. package/src/generators/vue-detail-view/files/src/views/__viewFileName__.vue__tmpl__ +9 -22
  18. package/src/generators/vue-detail-view/vue-detail-view.spec.ts +21 -3
  19. package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +8 -10
  20. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +37 -16
  21. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +4 -1
  22. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +33 -34
  23. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +50 -3
  24. package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.d.ts +7 -0
  25. package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.js +124 -0
  26. package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.js.map +1 -0
  27. package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.md +99 -0
  28. package/src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.spec.ts +209 -0
  29. package/src/migrations/add-migrate-advisory-lock/migrate.after.txt +63 -0
  30. package/src/migrations/add-migrate-advisory-lock/migrate.before.txt +41 -0
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "generators": {
4
+ "add-migrate-advisory-lock": {
5
+ "version": "13.24.0",
6
+ "description": "Wrap express-service's generated src/migrate.ts drizzle migrate() call in a Postgres advisory lock, so concurrent init containers serialize instead of racing.",
7
+ "implementation": "./src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock",
8
+ "prompt": "./src/migrations/add-migrate-advisory-lock/add-migrate-advisory-lock.md"
9
+ }
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.23.1",
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.",
@@ -41,6 +41,9 @@
41
41
  "socket.io-client": "^4.8.3"
42
42
  },
43
43
  "generators": "./generators.json",
44
+ "nx-migrations": {
45
+ "migrations": "./migrations.json"
46
+ },
44
47
  "scripts": {},
45
48
  "types": "./src/index.d.ts",
46
49
  "type": "commonjs"
@@ -59,4 +59,78 @@ describe('build assets packaging', () => {
59
59
 
60
60
  expect(unmatched).toEqual([]);
61
61
  });
62
+
63
+ // The same boundary one level up. A migration is only reachable if
64
+ // package.json declares the registry and project.json ships it — and the
65
+ // migration's own unit tests resolve everything from the source tree, so they
66
+ // pass either way. Nothing else catches a migration that publishes inert.
67
+ it('ships the migrations registry declared in package.json', () => {
68
+ const pkg = JSON.parse(
69
+ fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf-8'),
70
+ );
71
+ const registry: string | undefined = pkg['nx-migrations']?.migrations;
72
+ expect(registry).toBeDefined();
73
+
74
+ const registryPath = path.join(projectRoot, registry as string);
75
+ expect(fs.existsSync(registryPath)).toBe(true);
76
+
77
+ const project = JSON.parse(
78
+ fs.readFileSync(path.join(projectRoot, 'project.json'), 'utf-8'),
79
+ );
80
+ const assets: unknown[] = project.targets.build.options.assets ?? [];
81
+ const rootGlobs = assets
82
+ .filter(
83
+ (a): a is { input: string; glob: string } =>
84
+ typeof a === 'object' &&
85
+ a !== null &&
86
+ 'input' in a &&
87
+ path.resolve(repoRoot, (a as { input: string }).input) ===
88
+ path.resolve(projectRoot),
89
+ )
90
+ .map((a) => a.glob);
91
+
92
+ const rel = path.relative(projectRoot, registryPath);
93
+ expect(rootGlobs.some((glob) => minimatch(rel, glob, { dot: true }))).toBe(
94
+ true,
95
+ );
96
+ });
97
+
98
+ // Every file a migration names must resolve, or `nx migrate` fails at run
99
+ // time in the consumer's workspace rather than here. `prompt` is the markdown
100
+ // handed to the paired AI step; Nx resolves it relative to migrations.json
101
+ // (not through package exports), and requires at least one of implementation,
102
+ // factory, or prompt per entry.
103
+ it('points every migration at files that exist', () => {
104
+ const registry = JSON.parse(
105
+ fs.readFileSync(path.join(projectRoot, 'migrations.json'), 'utf-8'),
106
+ );
107
+ const entries: [
108
+ string,
109
+ { implementation?: string; factory?: string; prompt?: string },
110
+ ][] = Object.entries(registry.generators ?? {});
111
+ expect(entries.length).toBeGreaterThan(0);
112
+
113
+ const problems: string[] = [];
114
+ for (const [name, entry] of entries) {
115
+ if (!entry.implementation && !entry.factory && !entry.prompt) {
116
+ problems.push(`${name}: needs implementation, factory, or prompt`);
117
+ }
118
+ if (
119
+ entry.implementation &&
120
+ !fs.existsSync(path.join(projectRoot, `${entry.implementation}.ts`))
121
+ ) {
122
+ problems.push(`${name}: implementation not found`);
123
+ }
124
+ // Referenced verbatim, extension included — unlike implementation, which
125
+ // Nx resolves without one.
126
+ if (
127
+ entry.prompt &&
128
+ !fs.existsSync(path.join(projectRoot, entry.prompt))
129
+ ) {
130
+ problems.push(`${name}: prompt not found`);
131
+ }
132
+ }
133
+
134
+ expect(problems).toEqual([]);
135
+ });
62
136
  });
@@ -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';