@abgov/nx-adsp 13.18.0-beta.9 → 13.18.1

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Nx plugin for bootstrapping [ADSP](https://govalta.github.io/adsp-monorepo/) applications for the Government of Alberta.
4
4
 
5
- The plugin provides generators for Node/Express services, React and Angular frontends, .NET services, and fullstack solutions. When `@abgov/nx-oc` is also installed in the workspace, OpenShift deployment YAML is automatically included in the generated output.
5
+ The plugin provides generators for Node/Express services, React, Angular, and Vue 3 frontends, .NET services, and fullstack solutions. When `@abgov/nx-oc` is also installed in the workspace, OpenShift deployment YAML is automatically included in the generated output.
6
6
 
7
7
  ## Prerequisites
8
8
 
@@ -10,17 +10,18 @@ Install only the framework peers for the generators you use — they are declare
10
10
  as **optional** peer dependencies, so a workspace that only builds Vue apps
11
11
  doesn't need `@nx/react`, `@nx/angular`, etc.
12
12
 
13
- | Generator | Required peer dependency |
14
- | ------------------------------- | ---------------------------------------- |
15
- | `react-app` | `@nx/react` |
16
- | `angular-app` | `@nx/angular` |
17
- | `vue-app` | `@nx/vue` |
18
- | `dotnet-service` | `@nx-dotnet/core` |
19
- | `react-dotnet` | `@nx/react`, `@nx-dotnet/core` |
20
- | `express-service` | `@nx/express` |
21
- | `pevn`, `mevn` (full-stack) | `@nx/express`, `@nx/vue` |
22
- | `pern`, `pean` (full-stack) | `@nx/express`, `@nx/react`/`@nx/angular` |
23
- | `react-form`, `react-task-list` | existing React project in the workspace |
13
+ | Generator | Required peer dependency |
14
+ | ---------------------------------------------------------------------------- | ------------------------------------------- |
15
+ | `react-app` | `@nx/react` |
16
+ | `angular-app` | `@nx/angular` |
17
+ | `vue-app` | `@nx/vue` |
18
+ | `dotnet-service` | `@nx-dotnet/core` |
19
+ | `react-dotnet` | `@nx/react`, `@nx-dotnet/core` |
20
+ | `express-service` | `@nx/express` |
21
+ | `pevn`, `mevn` (full-stack) | `@nx/express`, `@nx/vue` |
22
+ | `pern`, `pean` (full-stack) | `@nx/express`, `@nx/react`/`@nx/angular` |
23
+ | `react-form`, `react-task-list` | existing React project in the workspace |
24
+ | `vue-detail-view`, `vue-workspace-view`, `vue-admin-crud`, `vue-intake-view` | existing `vue-app` project in the workspace |
24
25
 
25
26
  ## Installation
26
27
 
@@ -185,6 +186,30 @@ Accepts the same options as `react-app`.
185
186
 
186
187
  ---
187
188
 
189
+ ### `vue-app`
190
+
191
+ Creates a Vue 3 frontend application configured for ADSP, using GoA web components
192
+ (`@abgov/web-components`) and `@dsb-norge/vue-keycloak-js` for authentication. Requires
193
+ `@nx/vue`.
194
+
195
+ ```bash
196
+ npx nx g @abgov/nx-adsp:vue-app my-app --env dev --tenant my-tenant
197
+ ```
198
+
199
+ Accepts the same options as `react-app`, plus:
200
+
201
+ | Option | Required | Description |
202
+ | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
203
+ | `layout` | No | Top-level app shell: `header` (default) is a `goa-app-header` + hero banner + footer (public-facing); `internal` is a `goa-work-side-menu` shell with no header/banner/footer (staff-facing). Pair two runs against the same `--pairedProject` for a public+internal frontend pairing over one backend |
204
+
205
+ Every generated app provisions a shared `vue-components` library — `Goab*` `v-model` wrappers
206
+ over the design system plus reusable app-shell pattern components (`AppLayout`, `AppHeader`,
207
+ `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`) — see the generated app's own `AGENTS.md`
208
+ for the full contract. Four more generators retrofit common view shapes into an existing
209
+ `vue-app` project; see below.
210
+
211
+ ---
212
+
188
213
  ### `dotnet-service`
189
214
 
190
215
  Creates an ASP.NET Core backend service configured for ADSP. Requires `@nx-dotnet/core`.
@@ -241,6 +266,103 @@ Accepts the same options as `react-form`.
241
266
 
242
267
  ---
243
268
 
269
+ ### `vue-detail-view`
270
+
271
+ Adds a record-detail view (loading/error/loaded states, optional status badge, back button) to
272
+ an existing `vue-app` project, built on the shared `RecordDetailShell` pattern component.
273
+
274
+ ```bash
275
+ npx nx g @abgov/nx-adsp:vue-detail-view my-app --name=application-detail --resource=applications --route=/applications/:id --fields='[{"key":"status","label":"Status","type":"badge"}]'
276
+ ```
277
+
278
+ | Option | Required | Description |
279
+ | -------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
280
+ | `project` | Yes | The `vue-app` project to add the view to |
281
+ | `name` | Yes | View name, e.g. `application-detail` generates `src/views/ApplicationDetailView.vue` |
282
+ | `resource` | Yes | API resource path segment — the view fetches `/api/<resource>/:id` |
283
+ | `route` | Yes | Route path added to `router/index.ts`, e.g. `/applications/:id`. Must contain a `:id` param |
284
+ | `fields` | Yes | JSON array of fields rendered in the record's info list, in display order: `{ key, label, type?: "text"\|"date"\|"currency"\|"badge" }` — a JSON string, not a comma-separated CLI array (see note below) |
285
+ | `heading` | No | Page heading. Defaults to the view name, title-cased |
286
+ | `requiresAuth` | No | Whether the generated route requires authentication. Defaults to `true` |
287
+
288
+ ---
289
+
290
+ ### `vue-workspace-view`
291
+
292
+ Adds a staff-facing, paginated list view (a debounced search filter bar + sortable columns) to
293
+ an existing `vue-app` project, built on the shared `WorkspaceTable` pattern component.
294
+
295
+ ```bash
296
+ npx nx g @abgov/nx-adsp:vue-workspace-view my-app --name=applications --resource=applications --route=/applications --detailRoute=/applications --columns='[{"key":"status","label":"Status","type":"badge","sortable":true}]'
297
+ ```
298
+
299
+ | Option | Required | Description |
300
+ | -------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
301
+ | `project` | Yes | The `vue-app` project to add the view to |
302
+ | `name` | Yes | View name, e.g. `applications` generates `src/views/ApplicationsListView.vue` |
303
+ | `resource` | Yes | API resource path segment — fetches `/api/<resource>?page=&limit=&search=&sortBy=&sortDir=` |
304
+ | `route` | Yes | Route path added to `router/index.ts`, e.g. `/applications` |
305
+ | `columns` | Yes | JSON array of table columns, in display order: `{ key, label, type?: "text"\|"date"\|"currency"\|"badge", sortable? }` — a JSON string (see note below) |
306
+ | `detailRoute` | No | If set, each row gets a "View" action linking to `` `${detailRoute}/${row.id}` `` — typically a `vue-detail-view`'s route with the `:id` segment dropped |
307
+ | `filterable` | No | Whether to generate a debounced search input above the table. Defaults to `true` |
308
+ | `heading` | No | Page heading. Defaults to the view name, title-cased |
309
+ | `pageSize` | No | Rows per page. Defaults to `20` |
310
+ | `requiresAuth` | No | Whether the generated route requires authentication. Defaults to `true` |
311
+
312
+ ---
313
+
314
+ ### `vue-admin-crud`
315
+
316
+ Adds a simple admin CRUD screen pair (a `WorkspaceTable` list view with a Create action and
317
+ per-row Edit, plus a create/update Edit view) to an existing `vue-app` project — suited to small
318
+ lookup-table style admin screens, not large paginated workspaces (see `vue-workspace-view` for
319
+ that).
320
+
321
+ ```bash
322
+ npx nx g @abgov/nx-adsp:vue-admin-crud my-app --name=regions --resource=regions --route=/regions --fields='[{"key":"name","label":"Name"},{"key":"active","label":"Active","type":"checkbox"}]'
323
+ ```
324
+
325
+ | Option | Required | Description |
326
+ | --------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
327
+ | `project` | Yes | The `vue-app` project to add the views to |
328
+ | `name` | Yes | View name, e.g. `regions` generates `src/views/RegionsListView.vue` and `src/views/RegionsEditView.vue` |
329
+ | `resource` | Yes | API resource path segment — fetches `/api/<resource>` (list), `/api/<resource>/:id` (load one), `POST /api/<resource>` (create), `PUT /api/<resource>/:id` (update) |
330
+ | `route` | Yes | List route path added to `router/index.ts`, e.g. `/regions`. The edit/create route is added as `` `${route}/:id` `` (visiting `${route}/new` creates) |
331
+ | `fields` | Yes | JSON array of fields, in display/form order: `{ key, label, type?: "text"\|"checkbox", required? }` — a JSON string (see note below) |
332
+ | `heading` | No | List page heading. Defaults to the view name, title-cased |
333
+ | `singularLabel` | No | Singular label used in "Create <label>"/"Edit <label>" headings and buttons. Defaults to `--heading` (override for irregular plurals) |
334
+ | `requiresAuth` | No | Whether the generated routes require authentication. Defaults to `true` |
335
+
336
+ ---
337
+
338
+ ### `vue-intake-view`
339
+
340
+ Adds a route-per-step intake wizard (`Stepper` + `StepErrorSummary`, a required read-only
341
+ review step, and a confirmation page) to an existing `vue-app` project. Cross-step state is
342
+ server-persisted — each step PUTs/POSTs to `/api/<resource>/:id` and refetches on mount, so
343
+ there's no client-side draft caching. Every field is currently a plain text input.
344
+
345
+ ```bash
346
+ npx nx g @abgov/nx-adsp:vue-intake-view my-app --name=application --resource=applications --route=/applications --steps='[{"key":"personal-info","label":"Personal information","fields":[{"key":"fullName","label":"Full name"}]}]'
347
+ ```
348
+
349
+ | Option | Required | Description |
350
+ | -------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
351
+ | `project` | Yes | The `vue-app` project to add the views to |
352
+ | `name` | Yes | Base name for the generated views, e.g. `application` generates `<Step>StepView.vue` per step plus `ApplicationReviewView.vue`/`ApplicationConfirmationView.vue` |
353
+ | `resource` | Yes | API resource path segment. Each step fetches/saves `/api/<resource>/:id`; the review step's Submit posts `/api/<resource>/:id/submit` |
354
+ | `route` | Yes | Base route, e.g. `/applications`. Steps become `/applications/:id/<step-key>`, plus `/review` and `/confirmation`. Start a new intake at `/applications/new/<first-step-key>` |
355
+ | `steps` | Yes | JSON array of steps, in order: `{ key, label, fields: [{ key, label, required? }] }` — a JSON string (see note below) |
356
+ | `requiresAuth` | No | Whether the generated routes require authentication. Defaults to `true` |
357
+
358
+ ---
359
+
360
+ > **Note on JSON-string options (`fields`/`columns`/`steps`):** Nx's own CLI arg coercion only
361
+ > supports comma-separated primitive lists for `array`-typed schema options, not JSON — a JSON
362
+ > string is the only CLI syntax that survives it, so these options are declared as `string` and
363
+ > parsed internally. A plain array also works when the generator is invoked programmatically
364
+ > (e.g. from a script), bypassing CLI parsing entirely.
365
+
244
366
  ## Authentication
245
367
 
246
368
  Most generators call ADSP APIs during generation, which needs an ADSP access
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.18.0-beta.9",
3
+ "version": "13.18.1",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -34,11 +34,15 @@ executors read options from `project.json` and do not prompt.
34
34
  <% if (layout === 'internal') { %>
35
35
  | `src/App.vue` | Shell — `AppSideMenu` (side-menu shell, Sign in/out as an account item), `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout` |
36
36
  <% } else { %>
37
- | `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, hero banner, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
37
+ | `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
38
38
  <% } %>
39
39
  | `src/stores/session.ts` | Pinia store — `expired` flag behind `SessionExpiredBanner`, set from `main.ts`'s `onAuthRefreshError` hook |
40
40
  | `src/router/index.ts` | Routes — `/protected` guarded with `requiresAuth` meta |
41
+ <% if (layout === 'internal') { %>
41
42
  | `src/views/HomeView.vue` | Public page — calls public and private APIs |
43
+ <% } else { %>
44
+ | `src/views/HomeView.vue` | Public page — hero banner (page content, not app-shell — real GoA services show it once on the landing page, not on every route) + calls public and private APIs |
45
+ <% } %>
42
46
  | `src/views/ProtectedView.vue` | Authenticated page — shows user info from token |
43
47
  | `src/environments/environment.ts` | Access URL, realm, client ID — pre-set from ADSP tenant |
44
48
  | `vite.config.ts` | Vite config — `isCustomElement` marks `goa-*` as web components |
@@ -172,7 +176,7 @@ from **`<%= goaImportPath %>`**, not hand-rolled markup:
172
176
 
173
177
  | Component | Purpose |
174
178
  |---|---|
175
- | `AppSideMenu` | `goa-work-side-menu`. Takes `heading`/`primaryItems`/`secondaryItems`/`accountItems` props (each item: `{ label, to?, icon?, badge?, current? }`) and emits `itemClick` — it doesn't know about routing or auth, so `App.vue` computes `current`/handles the click itself. `App.vue` currently only populates `accountItems` (a single Sign in/out item); add real nav items to `primaryItems`/`secondaryItems` as routes are added. Also owns the skip-to-main-content landmark for this layout — `AppLayout` deliberately doesn't duplicate it. Also takes an optional `#topbar` slot — a slim row above the routed content, for something like a notification bell; only renders when given content, unused by default |
179
+ | `AppSideMenu` | `goa-work-side-menu`. Takes `heading`/`primaryItems`/`secondaryItems`/`accountItems` props (each item: `{ label, to?, icon?, badge?, current? }`) and emits `itemClick` — it doesn't know about routing or auth, so `App.vue` computes `current`/handles the click itself. `App.vue` currently only populates `accountItems` (a single Sign in/out item); add real nav items to `primaryItems`/`secondaryItems` as routes are added. **`icon` is effectively required** — `goa-work-side-menu-item` renders a blank item without it; use a GoA icon name (see [design.alberta.ca/components/icons](https://design.alberta.ca/components/icons)). Also owns the skip-to-main-content landmark for this layout — `AppLayout` deliberately doesn't duplicate it. Also takes an optional `#topbar` slot — a slim row above the routed content, for something like a notification bell; only renders when given content, unused by default |
176
180
  | `AppLayout` | The content gutter every view renders inside (see the key files table) |
177
181
  | `SessionExpiredBanner` | A `v-model:show` banner with `signIn`/`dismiss` emits. Bound to the `useSessionStore()` Pinia store (`src/stores/session.ts`), which `main.ts`'s `onAuthRefreshError` hook flips when the refresh token itself has expired |
178
182
 
@@ -230,20 +234,96 @@ mark what's meant to be edited.
230
234
  { path: '/queue', component: () => import('../views/QueueView.vue'), meta: { layout: 'wide' } }
231
235
  { path: '/apply', component: () => import('../views/ApplyView.vue'), meta: { layout: 'form' } }
232
236
  ```
233
- 3. Add a nav link to `src/App.vue` if needed
237
+ 3. **For the `internal` layout: add a nav link to `src/App.vue`.** The generated shell only
238
+ populates `accountItems`; primary navigation lives in `primaryItems` (or `secondaryItems`).
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`:
242
+
243
+ ```typescript
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
246
+ const primaryItems = computed(() => [
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
+ ])
250
+ ```
251
+
252
+ ```html
253
+ <!-- In <template>, add :primary-items to <AppSideMenu>
254
+ (@item-click="onItemClick" is already wired in the generated shell): -->
255
+ <AppSideMenu
256
+ heading="..."
257
+ :primary-items="primaryItems"
258
+ :account-items="accountItems"
259
+ @item-click="onItemClick"
260
+ >
261
+ ```
262
+
263
+ The `header` layout has no side menu — add route-level breadcrumbs or a secondary nav
264
+ directly inside the view component instead.
234
265
 
235
266
  ## Backend API calls (proxy setup)
236
267
 
237
- Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production:
268
+ Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production.
269
+
270
+ **Use `useApi()` for all API calls.** `apiFetch` automatically refreshes the token and adds
271
+ `Authorization: Bearer` when the user is authenticated — you never need to do it at each
272
+ call site.
238
273
 
239
274
  ```typescript
240
- // correct
241
- const res = await fetch('/api/v1/my-resource');
275
+ import { useApi } from '../composables/useApi' // adjust depth for nested components (../../…)
242
276
 
243
- // wrong bypasses proxy, won't work in production
244
- const res = await fetch('http://localhost:3333/my-service/v1/my-resource');
277
+ const { apiFetch } = useApi()
278
+
279
+ // ✓ Any route — adds the token when authenticated, skips it when not
280
+ const res = await apiFetch('/api/v1/my-resource')
281
+
282
+ // ✓ With a request body
283
+ const res = await apiFetch('/api/v1/my-resource', {
284
+ method: 'POST',
285
+ headers: { 'Content-Type': 'application/json' },
286
+ body: JSON.stringify(payload),
287
+ })
288
+
289
+ // ✗ Wrong path — bypasses proxy, won't work in production
290
+ const res = await fetch('http://localhost:3333/<%= pairedProject || 'my-service' %>/v1/my-resource')
291
+ ```
292
+
293
+ **Most routes require authentication.** The service mounts `/v1` with the anonymous passport
294
+ strategy, so a bare request without a token reaches the router — but business routes also
295
+ gate on `tenant` auth and will 401. `apiFetch` handles this automatically when signed in.
296
+
297
+ **React to auth state for calls that require the user to be signed in.** Keycloak settles
298
+ asynchronously; watch `kc.authenticated` rather than reading it once on mount:
299
+
300
+ ```typescript
301
+ import { watch } from 'vue'
302
+ import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
303
+ import { useApi } from '../composables/useApi'
304
+
305
+ const kc = useKeycloak()
306
+ const { apiFetch } = useApi()
307
+
308
+ watch(
309
+ () => kc.authenticated,
310
+ async (authenticated) => {
311
+ if (!authenticated) return
312
+ try {
313
+ const res = await apiFetch('/api/v1/my-resource')
314
+ // ...
315
+ } catch {
316
+ // apiFetch rejects on network error or if the refresh token expired;
317
+ // the SessionExpiredBanner handles the latter via onAuthRefreshError.
318
+ }
319
+ },
320
+ { immediate: true },
321
+ )
245
322
  ```
246
323
 
324
+ `apiFetch` adds the token only when `kc.authenticated` is true — the watch ensures
325
+ you call it only after auth has settled, not speculatively on mount.
326
+
247
327
  ## Testing
248
328
 
249
329
  Tests live alongside source files (`*.spec.ts` / `*.spec.vue`) and run with Vitest:
@@ -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"
@@ -86,7 +91,6 @@ function onAccountItemClick() {
86
91
  @sign-in="signInAgain"
87
92
  @dismiss="session.dismiss"
88
93
  />
89
- <goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
90
94
  <main id="main-content">
91
95
  <AppLayout :variant="contentWidth">
92
96
  <RouterView />
@@ -0,0 +1,21 @@
1
+ import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
2
+
3
+ export function useApi() {
4
+ // Keep the reactive instance — do NOT destructure: `authenticated` and `keycloak`
5
+ // are plain values inside a reactive object and only populate asynchronously once
6
+ // Keycloak settles. Reading `kc.authenticated` at call time (inside apiFetch)
7
+ // always gets the current value; a destructured snapshot would freeze at setup time.
8
+ const kc = useKeycloak()
9
+
10
+ async function apiFetch(url: string, init: RequestInit = {}): Promise<Response> {
11
+ if (kc.authenticated) {
12
+ await kc.keycloak?.updateToken(30)
13
+ const headers = new Headers(init.headers)
14
+ headers.set('Authorization', `Bearer ${kc.keycloak?.token}`)
15
+ return fetch(url, { ...init, headers })
16
+ }
17
+ return fetch(url, init)
18
+ }
19
+
20
+ return { apiFetch }
21
+ }
@@ -1,47 +1,50 @@
1
1
  <script setup lang="ts">
2
- import { ref, onMounted, watch } from 'vue';
3
- import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
2
+ import { ref, onMounted, watch } from 'vue'
3
+ import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
4
+ import { useApi } from '../composables/useApi'
4
5
 
5
- // Keep the reactive instance — do NOT destructure (see App.vue): a destructured
6
- // `authenticated`/`keycloak` freezes at its setup-time value, so the private
7
- // fetch below would never see the user sign in and the bearer token stays undefined.
8
- const kc = useKeycloak();
9
- const publicResource = ref('Not retrieved — is the backend service running?');
10
- const privateResource = ref('Not retrieved — sign in first.');
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
+ const kc = useKeycloak()
10
+ const { apiFetch } = useApi()
11
+ const publicResource = ref('Not retrieved — is the backend service running?')
12
+ const privateResource = ref('Not retrieved — sign in first.')
11
13
 
12
14
  onMounted(async () => {
13
15
  try {
14
- const res = await fetch('/api/v1/public');
15
- const data = await res.json();
16
- publicResource.value = data.message;
16
+ const res = await apiFetch('/api/v1/public')
17
+ const data = await res.json()
18
+ publicResource.value = data.message
17
19
  } catch {
18
- publicResource.value = 'Error loading public resource.';
20
+ publicResource.value = 'Error loading public resource.'
19
21
  }
20
- });
22
+ })
21
23
 
22
24
  // React to authentication (which settles asynchronously and may flip after mount,
23
25
  // e.g. on return from the login redirect) rather than reading it once on mount.
24
26
  watch(
25
27
  () => kc.authenticated,
26
28
  async (authenticated) => {
27
- if (!authenticated) return;
29
+ if (!authenticated) return
28
30
  try {
29
- await kc.keycloak?.updateToken(30);
30
- const res = await fetch('/api/v1/private', {
31
- headers: { Authorization: `Bearer ${kc.keycloak?.token}` },
32
- });
33
- const data = await res.json();
34
- privateResource.value = data.message;
31
+ const res = await apiFetch('/api/v1/private')
32
+ const data = await res.json()
33
+ privateResource.value = data.message
35
34
  } catch {
36
- privateResource.value = 'Error loading private resource.';
35
+ privateResource.value = 'Error loading private resource.'
37
36
  }
38
37
  },
39
- { immediate: true }
40
- );
38
+ { immediate: true },
39
+ )
41
40
  </script>
42
41
 
43
42
  <template>
44
43
  <section>
44
+ <% if (layout !== 'internal') { %>
45
+ <goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
46
+ <goa-spacer vspacing="l" />
47
+ <% } %>
45
48
  <h2>Welcome to <%= projectName %>!</h2>
46
49
  <p>Don't panic. Start editing the project to build your digital service.</p>
47
50
  <h3>A few things you might want to do next:</h3>
@@ -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 —
@@ -297,6 +297,23 @@ describe('Vue App Generator', () => {
297
297
  expect(app).toContain('kc.keycloak?.login()');
298
298
  }, 30000);
299
299
 
300
+ it('generates a useApi composable that handles token refresh and auth headers', async () => {
301
+ await generator(host, options);
302
+ expect(host.exists('apps/test/src/composables/useApi.ts')).toBeTruthy();
303
+ const useApi = host.read('apps/test/src/composables/useApi.ts').toString();
304
+ // Token refresh and auth header injection are encapsulated here, not at each call site.
305
+ expect(useApi).toContain('updateToken');
306
+ expect(useApi).toContain('Authorization');
307
+ expect(useApi).toContain('apiFetch');
308
+
309
+ // HomeView delegates to the composable — raw token wiring must not leak into views.
310
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
311
+ expect(homeView).toContain('useApi');
312
+ expect(homeView).toContain('apiFetch');
313
+ expect(homeView).not.toContain('updateToken');
314
+ expect(homeView).not.toContain('Authorization');
315
+ }, 30000);
316
+
300
317
  it('index.html is at the Vite entry root and its mount target matches main.ts', async () => {
301
318
  await generator(host, options);
302
319
  // Vite's entry is <projectRoot>/index.html, not src/index.html — a template
@@ -313,17 +330,50 @@ describe('Vue App Generator', () => {
313
330
 
314
331
  it('static assets live in public/ so Vite serves them at the referenced URLs', async () => {
315
332
  await generator(host, options);
316
- // App.vue's <goa-hero-banner backgroundurl="/assets/banner.jpg"> and
333
+ // HomeView's <goa-hero-banner backgroundurl="/assets/banner.jpg"> and
317
334
  // index.html's favicon.ico are absolute-URL string refs, so they must be in
318
335
  // the Vite publicDir (public/) — a src/assets file is not served at /assets.
319
- const appVue = host.read('apps/test/src/App.vue').toString();
320
- const bannerUrl = appVue.match(/backgroundurl="([^"]+)"/)?.[1];
336
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
337
+ const bannerUrl = homeView.match(/backgroundurl="([^"]+)"/)?.[1];
321
338
  expect(bannerUrl).toBe('/assets/banner.jpg');
322
339
  expect(host.exists('apps/test/public/assets/banner.jpg')).toBeTruthy();
323
340
  expect(host.exists('apps/test/src/assets/banner.jpg')).toBeFalsy();
324
341
  expect(host.exists('apps/test/public/favicon.ico')).toBeTruthy();
325
342
  }, 30000);
326
343
 
344
+ it('the hero banner is home-page content, not part of the persistent app shell', async () => {
345
+ // Real GovAlta-Pronghorn source (both the canonical template and an
346
+ // independently-evolved production app) puts goa-hero-banner only inside
347
+ // HomeView -- never in App.vue/AppLayout -- so it shows once on the
348
+ // landing page, not repeated on every interior route.
349
+ await generator(host, options);
350
+ const app = host.read('apps/test/src/App.vue').toString();
351
+ expect(app).not.toContain('goa-hero-banner');
352
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
353
+ expect(homeView).toContain('goa-hero-banner');
354
+ }, 30000);
355
+
356
+ it('--layout=internal App.vue uses a unified onItemClick that routes nav items and signs in/out for account items', async () => {
357
+ // AppSideMenu emits one itemClick for all slots. A dedicated onAccountItemClick breaks
358
+ // once primaryItems are added — primary nav clicks fire login()/logout() instead of routing.
359
+ // The generated handler must dispatch by item.to.
360
+ await generator(host, { ...options, layout: 'internal' });
361
+ const app = host.read('apps/test/src/App.vue').toString();
362
+ expect(app).toContain('onItemClick');
363
+ expect(app).toContain('router.push');
364
+ expect(app).not.toContain('onAccountItemClick');
365
+ // Routing branch: items with `to` navigate
366
+ expect(app).toContain('item.to');
367
+ // Auth branch: items without `to` fall through to sign-in/out
368
+ expect(app).toContain('kc.authenticated');
369
+ }, 30000);
370
+
371
+ it('--layout=internal has no hero banner anywhere, including on HomeView', async () => {
372
+ await generator(host, { ...options, layout: 'internal' });
373
+ const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
374
+ expect(homeView).not.toContain('goa-hero-banner');
375
+ }, 30000);
376
+
327
377
  it('vite.config.ts marks goa-* elements as custom elements', async () => {
328
378
  await generator(host, options);
329
379
  const viteConfig = host.read('apps/test/vite.config.ts').toString();