@abgov/nx-adsp 13.18.0 → 13.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.18.0",
3
+ "version": "13.18.2",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -3,10 +3,12 @@ import { reactive, ref, computed, onMounted } 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 %>';
6
+ import { useApi } from '../composables/useApi';
6
7
 
7
8
  const route = useRoute();
8
9
  const router = useRouter();
9
10
  const kc = useKeycloak();
11
+ const { apiFetch } = useApi();
10
12
 
11
13
  const idParam = computed(() => String(route.params.id ?? ''));
12
14
  const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
@@ -32,7 +34,7 @@ async function load() {
32
34
  loading.value = true;
33
35
  loadError.value = null;
34
36
  try {
35
- const res = await fetch(`/api/<%= resource %>/${idParam.value}`);
37
+ const res = await apiFetch(`/api/<%= resource %>/${idParam.value}`);
36
38
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
37
39
  const data = await res.json();
38
40
  <% fields.forEach(function (field) { -%>
@@ -67,7 +69,7 @@ async function onSubmit() {
67
69
  saving.value = true;
68
70
  saveError.value = null;
69
71
  try {
70
- const res = await fetch(
72
+ const res = await apiFetch(
71
73
  isNew.value ? '/api/<%= resource %>' : `/api/<%= resource %>/${idParam.value}`,
72
74
  {
73
75
  method: isNew.value ? 'POST' : 'PUT',
@@ -2,8 +2,10 @@
2
2
  import { ref, onMounted } from 'vue';
3
3
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
4
4
  import { WorkspaceTable } from '<%= goaImportPath %>';
5
+ import { useApi } from '../composables/useApi';
5
6
 
6
7
  const kc = useKeycloak();
8
+ const { apiFetch } = useApi();
7
9
 
8
10
  const columns = [
9
11
  <% fields.forEach(function (field) { -%>
@@ -21,7 +23,7 @@ async function load() {
21
23
  loading.value = true;
22
24
  error.value = null;
23
25
  try {
24
- const res = await fetch('/api/<%= resource %>');
26
+ const res = await apiFetch('/api/<%= resource %>');
25
27
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
26
28
  const data = await res.json();
27
29
  // Accept either a bare array or a { results } envelope.
@@ -61,7 +61,7 @@ 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("fetch('/api/regions')");
64
+ expect(view).toContain("apiFetch('/api/regions')");
65
65
  expect(view).toContain("import { WorkspaceTable } from '@proj/vue-components';");
66
66
  expect(view).toContain('<WorkspaceTable');
67
67
  // No pagination props bound -- this is the "reused without its pagination/
@@ -92,7 +92,7 @@ describe('Vue Admin CRUD Generator', () => {
92
92
  expect(view).not.toContain('errors.active');
93
93
  expect(view).toContain("method: isNew.value ? 'POST' : 'PUT'");
94
94
  expect(view).toContain("isNew.value ? '/api/regions'");
95
- expect(view).toContain('fetch(`/api/regions/${idParam.value}`');
95
+ expect(view).toContain('apiFetch(`/api/regions/${idParam.value}`');
96
96
  expect(view).toContain("router.push('/regions')");
97
97
  expect(view).toContain('Create Regions');
98
98
  expect(view).toContain('Edit Regions');
@@ -236,31 +236,22 @@ mark what's meant to be edited.
236
236
  ```
237
237
  3. **For the `internal` layout: add a nav link to `src/App.vue`.** The generated shell only
238
238
  populates `accountItems`; primary navigation lives in `primaryItems` (or `secondaryItems`).
239
- `AppSideMenu` is purely presentational it doesn't know about routing, so `App.vue` owns
240
- `current` detection and the `itemClick` handler:
239
+ `AppSideMenu` emits one `itemClick` for all slots the generated `onItemClick` already
240
+ dispatches by `item.to`: items with a `to` route, items without sign in/out. Add only
241
+ `primaryItems`:
241
242
 
242
243
  ```typescript
243
- // In <script setup>:
244
- import { computed } from 'vue';
245
- import { RouterView, useRoute, useRouter } from 'vue-router';
246
- // ...existing imports...
247
-
248
- const route = useRoute(); // already in the generated App.vue
249
- const router = useRouter();
250
-
251
- // Icon names come from the GoA icon set: design.alberta.ca/components/icons
244
+ // In <script setup> — route, router, and onItemClick are already in the generated App.vue:
245
+ // Icon names from the GoA icon set: design.alberta.ca/components/icons
252
246
  const primaryItems = computed(() => [
253
- { label: 'Home', to: '/', icon: 'home', current: route.path === '/' },
254
- { label: 'My Feature', to: '/my-feature', icon: 'list', current: route.path.startsWith('/my-feature') },
255
- ]);
256
-
257
- function onItemClick(item: { to?: string }) {
258
- if (item.to) router.push(item.to);
259
- }
247
+ { label: 'Home', to: '/', icon: 'home', current: route.path === '/' },
248
+ { label: 'My Feature', to: '/my-feature', icon: 'list', current: route.path.startsWith('/my-feature') },
249
+ ])
260
250
  ```
261
251
 
262
252
  ```html
263
- <!-- In <template>, add :primary-items and @item-click to <AppSideMenu>: -->
253
+ <!-- In <template>, add :primary-items to <AppSideMenu>
254
+ (@item-click="onItemClick" is already wired in the generated shell): -->
264
255
  <AppSideMenu
265
256
  heading="..."
266
257
  :primary-items="primaryItems"
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
4
- import { RouterView, useRoute } from 'vue-router';
4
+ import { RouterView, useRoute<% if (layout === 'internal') { %>, useRouter<% } %> } from 'vue-router';
5
5
  <% if (layout === 'internal') { %>
6
6
  import { AppLayout, AppSideMenu, SessionExpiredBanner } from '<%= goaImportPath %>';
7
7
  <% } else { %>
@@ -32,16 +32,21 @@ function signInAgain() {
32
32
  }
33
33
  <% if (layout === 'internal') { %>
34
34
 
35
- // AppSideMenu's account items are presentational -- it doesn't know about
36
- // Keycloak, it just renders whatever's passed and emits itemClick on click.
35
+ // AppSideMenu's items are presentational it doesn't know about Keycloak or
36
+ // routing; it just renders whatever's passed and emits itemClick on click.
37
37
  const accountItems = computed(() => [
38
38
  kc.authenticated
39
39
  ? { label: kc.fullName ? `Sign out (${kc.fullName})` : 'Sign out' }
40
40
  : { label: 'Sign in' },
41
41
  ]);
42
42
 
43
- function onAccountItemClick() {
44
- if (kc.authenticated) logout();
43
+ const router = useRouter();
44
+
45
+ // AppSideMenu emits one itemClick for ALL slots (primary, secondary, account).
46
+ // Items with `to` are nav items — route. Items without are account actions — sign in/out.
47
+ function onItemClick(item: { to?: string }) {
48
+ if (item.to) router.push(item.to);
49
+ else if (kc.authenticated) logout();
45
50
  else login();
46
51
  }
47
52
  <% } %>
@@ -52,7 +57,7 @@ function onAccountItemClick() {
52
57
  <AppSideMenu
53
58
  heading="<%= projectName %>"
54
59
  :account-items="accountItems"
55
- @item-click="onAccountItemClick"
60
+ @item-click="onItemClick"
56
61
  >
57
62
  <SessionExpiredBanner
58
63
  v-model:show="session.expired"
@@ -1,3 +1,4 @@
1
+ import { watch } from 'vue';
1
2
  import { createRouter, createWebHistory } from 'vue-router';
2
3
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
3
4
  import HomeView from '../views/HomeView.vue';
@@ -14,12 +15,23 @@ const router = createRouter({
14
15
  ],
15
16
  });
16
17
 
17
- router.beforeEach((to) => {
18
+ router.beforeEach(async (to) => {
18
19
  if (to.meta.requiresAuth) {
19
20
  // Read fields off the reactive instance (don't destructure) — consistent with
20
21
  // the views and safe if this ever moves out of the per-navigation callback.
21
22
  const kc = useKeycloak();
22
- if (!kc.ready) return true;
23
+ if (!kc.ready) {
24
+ // Keycloak init is async and may not have settled before the initial navigation
25
+ // fires (app.mount() triggers navigation synchronously). Block and wait rather
26
+ // than allowing through — a direct-URL load to a protected route would otherwise
27
+ // render the page unauthenticated with no login redirect.
28
+ await new Promise<void>((resolve) => {
29
+ const stop = watch(
30
+ () => kc.ready,
31
+ (ready) => { if (ready) { stop(); resolve() } },
32
+ )
33
+ })
34
+ }
23
35
  if (!kc.authenticated) {
24
36
  kc.keycloak?.login({ redirectUri: window.location.origin + to.fullPath });
25
37
  return false;
@@ -3,9 +3,9 @@ import { ref, onMounted, watch } from 'vue'
3
3
  import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
4
4
  import { useApi } from '../composables/useApi'
5
5
 
6
- // Keep the reactive instance — do NOT destructure (see App.vue): a destructured
7
- // `authenticated` freezes at its setup-time value, so the watch below would never
8
- // see the user sign in and apiFetch would always skip the token.
6
+ // Keep the useKeycloak() result as an object — do NOT destructure it (see App.vue):
7
+ // its fields are plain values in a reactive object and freeze at setup-time undefined
8
+ // when extracted. `apiFetch` from useApi() is a plain function and is fine to destructure.
9
9
  const kc = useKeycloak()
10
10
  const { apiFetch } = useApi()
11
11
  const publicResource = ref('Not retrieved — is the backend service running?')
@@ -206,7 +206,7 @@ describe('Vue App Generator', () => {
206
206
  expect(app).toContain('<AppSideMenu');
207
207
  expect(app).toContain('heading="test"');
208
208
  expect(app).toContain(':account-items="accountItems"');
209
- expect(app).toContain('@item-click="onAccountItemClick"');
209
+ expect(app).toContain('@item-click="onItemClick"');
210
210
  // The content gutter is shared regardless of shell choice.
211
211
  expect(app).toContain('<AppLayout');
212
212
  // App.vue itself must not add a second skip-to-main-content landmark —
@@ -314,6 +314,18 @@ describe('Vue App Generator', () => {
314
314
  expect(homeView).not.toContain('Authorization');
315
315
  }, 30000);
316
316
 
317
+ it('router guard waits for Keycloak readiness before making auth decisions', async () => {
318
+ await generator(host, options);
319
+ const router = host.read('apps/test/src/router/index.ts').toString();
320
+ // Returning `true` when !kc.ready lets unauthenticated direct-URL loads through
321
+ // to protected routes before Keycloak has finished init. The guard must block
322
+ // and wait (async watch) instead of allowing.
323
+ expect(router).toContain('async');
324
+ expect(router).toContain('kc.ready');
325
+ expect(router).not.toMatch(/if\s*\(!kc\.ready\)\s*return\s*true/);
326
+ expect(router).toContain('watch');
327
+ }, 30000);
328
+
317
329
  it('index.html is at the Vite entry root and its mount target matches main.ts', async () => {
318
330
  await generator(host, options);
319
331
  // Vite's entry is <projectRoot>/index.html, not src/index.html — a template
@@ -353,6 +365,21 @@ describe('Vue App Generator', () => {
353
365
  expect(homeView).toContain('goa-hero-banner');
354
366
  }, 30000);
355
367
 
368
+ it('--layout=internal App.vue uses a unified onItemClick that routes nav items and signs in/out for account items', async () => {
369
+ // AppSideMenu emits one itemClick for all slots. A dedicated onAccountItemClick breaks
370
+ // once primaryItems are added — primary nav clicks fire login()/logout() instead of routing.
371
+ // The generated handler must dispatch by item.to.
372
+ await generator(host, { ...options, layout: 'internal' });
373
+ const app = host.read('apps/test/src/App.vue').toString();
374
+ expect(app).toContain('onItemClick');
375
+ expect(app).toContain('router.push');
376
+ expect(app).not.toContain('onAccountItemClick');
377
+ // Routing branch: items with `to` navigate
378
+ expect(app).toContain('item.to');
379
+ // Auth branch: items without `to` fall through to sign-in/out
380
+ expect(app).toContain('kc.authenticated');
381
+ }, 30000);
382
+
356
383
  it('--layout=internal has no hero banner anywhere, including on HomeView', async () => {
357
384
  await generator(host, { ...options, layout: 'internal' });
358
385
  const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
@@ -61,6 +61,8 @@ const emit = defineEmits<{ itemClick: [item: AppSideMenuItem] }>();
61
61
  slot="secondary"
62
62
  :icon="item.icon"
63
63
  :label="item.label"
64
+ :url="item.to"
65
+ :current="item.current"
64
66
  :badge="item.badge"
65
67
  @click.prevent="emit('itemClick', item)"
66
68
  />
@@ -2,6 +2,7 @@
2
2
  import { ref, onMounted } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { RecordDetailShell } from '<%= goaImportPath %>';
5
+ import { useApi } from '../composables/useApi';
5
6
 
6
7
  // The fetched record's shape isn't known to this generator -- read fields
7
8
  // defensively rather than declaring (and likely getting wrong) a fake interface.
@@ -11,12 +12,13 @@ const error = ref<string | null>(null);
11
12
 
12
13
  const route = useRoute();
13
14
  const router = useRouter();
15
+ const { apiFetch } = useApi();
14
16
 
15
17
  async function load() {
16
18
  loading.value = true;
17
19
  error.value = null;
18
20
  try {
19
- const res = await fetch(`/api/<%= resource %>/${route.params.id}`);
21
+ const res = await apiFetch(`/api/<%= resource %>/${route.params.id}`);
20
22
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
21
23
  record.value = await res.json();
22
24
  } catch (e) {
@@ -97,7 +97,7 @@ describe('Vue Detail View Generator', () => {
97
97
  .read('apps/test/src/views/ApplicationDetailView.vue')
98
98
  .toString();
99
99
  expect(view).toContain('heading="Application Detail"');
100
- expect(view).toContain("fetch(`/api/applications/${route.params.id}`)");
100
+ expect(view).toContain("apiFetch(`/api/applications/${route.params.id}`)");
101
101
  expect(view).toContain(
102
102
  "<goa-badge type=\"information\" :content=\"String(record['status'] ?? '—')\" />",
103
103
  );
@@ -2,9 +2,11 @@
2
2
  import { ref, computed, onMounted } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { GoabCheckbox } from '<%= goaImportPath %>';
5
+ import { useApi } from '../composables/useApi';
5
6
 
6
7
  const route = useRoute();
7
8
  const router = useRouter();
9
+ const { apiFetch } = useApi();
8
10
 
9
11
  const idParam = computed(() => String(route.params.id ?? ''));
10
12
 
@@ -21,7 +23,7 @@ async function load() {
21
23
  loading.value = true;
22
24
  loadError.value = null;
23
25
  try {
24
- const res = await fetch(`/api/<%= resource %>/${idParam.value}`);
26
+ const res = await apiFetch(`/api/<%= resource %>/${idParam.value}`);
25
27
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
26
28
  record.value = await res.json();
27
29
  } catch (e) {
@@ -42,7 +44,7 @@ async function onSubmit() {
42
44
  submitting.value = true;
43
45
  submitError.value = null;
44
46
  try {
45
- const res = await fetch(`/api/<%= resource %>/${idParam.value}/submit`, {
47
+ const res = await apiFetch(`/api/<%= resource %>/${idParam.value}/submit`, {
46
48
  method: 'POST',
47
49
  });
48
50
  if (!res.ok) throw new Error(`Failed to submit (${res.status})`);
@@ -2,6 +2,7 @@
2
2
  import { reactive, ref, computed, onMounted } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { Stepper, StepErrorSummary, GoabInput } from '<%= goaImportPath %>';
5
+ import { useApi } from '../composables/useApi';
5
6
 
6
7
  const STEPS = [
7
8
  <% stepperSteps.forEach(function (step) { -%>
@@ -11,6 +12,7 @@ const STEPS = [
11
12
 
12
13
  const route = useRoute();
13
14
  const router = useRouter();
15
+ const { apiFetch } = useApi();
14
16
 
15
17
  const idParam = computed(() => String(route.params.id ?? ''));
16
18
  const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
@@ -48,7 +50,7 @@ async function load() {
48
50
  loading.value = true;
49
51
  loadError.value = null;
50
52
  try {
51
- const res = await fetch(`/api/<%= resource %>/${idParam.value}`);
53
+ const res = await apiFetch(`/api/<%= resource %>/${idParam.value}`);
52
54
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
53
55
  const data = await res.json();
54
56
  completedSteps.value = Array.isArray(data.completedSteps) ? data.completedSteps : [];
@@ -96,7 +98,7 @@ async function onSaveAndContinue() {
96
98
  ...form,
97
99
  completedSteps: [...new Set([...completedSteps.value, '<%- stepKey %>'])],
98
100
  };
99
- const res = await fetch(
101
+ const res = await apiFetch(
100
102
  isNew.value ? '/api/<%= resource %>' : `/api/<%= resource %>/${idParam.value}`,
101
103
  {
102
104
  method: isNew.value ? 'POST' : 'PUT',
@@ -123,7 +123,7 @@ 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("fetch(`/api/applications/${idParam.value}/submit`");
126
+ expect(review).toContain("apiFetch(`/api/applications/${idParam.value}/submit`");
127
127
  expect(review).toContain('/applications/${idParam.value}/confirmation');
128
128
  }, 30000);
129
129
 
@@ -1,6 +1,9 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, onMounted } from 'vue';
3
3
  import { WorkspaceTable } from '<%= goaImportPath %>';
4
+ import { useApi } from '../composables/useApi';
5
+
6
+ const { apiFetch } = useApi();
4
7
 
5
8
  const columns = [
6
9
  <% columns.forEach(function (column) { -%>
@@ -39,7 +42,7 @@ async function load() {
39
42
  params.set('sortBy', sortBy.value);
40
43
  params.set('sortDir', sortDir.value);
41
44
  }
42
- const res = await fetch(`/api/<%= resource %>?${params.toString()}`);
45
+ const res = await apiFetch(`/api/<%= resource %>?${params.toString()}`);
43
46
  if (!res.ok) throw new Error(`Failed to load (${res.status})`);
44
47
  const data = await res.json();
45
48
  // Accept either a bare array or a { results, total } page envelope.
@@ -99,7 +99,7 @@ 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('fetch(`/api/applications?${params.toString()}`)');
102
+ expect(view).toContain('apiFetch(`/api/applications?${params.toString()}`)');
103
103
  expect(view).toContain(
104
104
  "<goa-badge type=\"information\" :content=\"String(row['status'] ?? '—')\" />",
105
105
  );