@abgov/nx-adsp 13.18.0-beta.8 → 13.18.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.
- package/README.md +134 -12
- package/package.json +1 -1
- package/src/generators/vue-app/files/AGENTS.md__tmpl__ +97 -8
- package/src/generators/vue-app/files/src/App.vue__tmpl__ +0 -1
- package/src/generators/vue-app/files/src/composables/useApi.ts__tmpl__ +21 -0
- package/src/generators/vue-app/files/src/views/HomeView.vue__tmpl__ +25 -22
- package/src/generators/vue-app/vue-app.spec.ts +38 -3
- package/src/generators/vue-components/files/src/lib/patterns/AppSideMenu.vue__tmpl__ +23 -0
- package/src/generators/vue-components/vue-components.spec.ts +13 -0
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
|
|
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
|
|
14
|
-
|
|
|
15
|
-
| `react-app`
|
|
16
|
-
| `angular-app`
|
|
17
|
-
| `vue-app`
|
|
18
|
-
| `dotnet-service`
|
|
19
|
-
| `react-dotnet`
|
|
20
|
-
| `express-service`
|
|
21
|
-
| `pevn`, `mevn` (full-stack)
|
|
22
|
-
| `pern`, `pean` (full-stack)
|
|
23
|
-
| `react-form`, `react-task-list`
|
|
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
|
@@ -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`,
|
|
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 |
|
|
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,105 @@ 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.
|
|
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` is purely presentational — it doesn't know about routing, so `App.vue` owns
|
|
240
|
+
`current` detection and the `itemClick` handler:
|
|
241
|
+
|
|
242
|
+
```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
|
|
252
|
+
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
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```html
|
|
263
|
+
<!-- In <template>, add :primary-items and @item-click to <AppSideMenu>: -->
|
|
264
|
+
<AppSideMenu
|
|
265
|
+
heading="..."
|
|
266
|
+
:primary-items="primaryItems"
|
|
267
|
+
:account-items="accountItems"
|
|
268
|
+
@item-click="onItemClick"
|
|
269
|
+
>
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
The `header` layout has no side menu — add route-level breadcrumbs or a secondary nav
|
|
273
|
+
directly inside the view component instead.
|
|
234
274
|
|
|
235
275
|
## Backend API calls (proxy setup)
|
|
236
276
|
|
|
237
|
-
Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production
|
|
277
|
+
Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production.
|
|
278
|
+
|
|
279
|
+
**Use `useApi()` for all API calls.** `apiFetch` automatically refreshes the token and adds
|
|
280
|
+
`Authorization: Bearer` when the user is authenticated — you never need to do it at each
|
|
281
|
+
call site.
|
|
238
282
|
|
|
239
283
|
```typescript
|
|
240
|
-
//
|
|
241
|
-
const res = await fetch('/api/v1/my-resource');
|
|
284
|
+
import { useApi } from '../composables/useApi' // adjust depth for nested components (../../…)
|
|
242
285
|
|
|
243
|
-
|
|
244
|
-
|
|
286
|
+
const { apiFetch } = useApi()
|
|
287
|
+
|
|
288
|
+
// ✓ Any route — adds the token when authenticated, skips it when not
|
|
289
|
+
const res = await apiFetch('/api/v1/my-resource')
|
|
290
|
+
|
|
291
|
+
// ✓ With a request body
|
|
292
|
+
const res = await apiFetch('/api/v1/my-resource', {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: { 'Content-Type': 'application/json' },
|
|
295
|
+
body: JSON.stringify(payload),
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// ✗ Wrong path — bypasses proxy, won't work in production
|
|
299
|
+
const res = await fetch('http://localhost:3333/<%= pairedProject || 'my-service' %>/v1/my-resource')
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Most routes require authentication.** The service mounts `/v1` with the anonymous passport
|
|
303
|
+
strategy, so a bare request without a token reaches the router — but business routes also
|
|
304
|
+
gate on `tenant` auth and will 401. `apiFetch` handles this automatically when signed in.
|
|
305
|
+
|
|
306
|
+
**React to auth state for calls that require the user to be signed in.** Keycloak settles
|
|
307
|
+
asynchronously; watch `kc.authenticated` rather than reading it once on mount:
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
import { watch } from 'vue'
|
|
311
|
+
import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
|
|
312
|
+
import { useApi } from '../composables/useApi'
|
|
313
|
+
|
|
314
|
+
const kc = useKeycloak()
|
|
315
|
+
const { apiFetch } = useApi()
|
|
316
|
+
|
|
317
|
+
watch(
|
|
318
|
+
() => kc.authenticated,
|
|
319
|
+
async (authenticated) => {
|
|
320
|
+
if (!authenticated) return
|
|
321
|
+
try {
|
|
322
|
+
const res = await apiFetch('/api/v1/my-resource')
|
|
323
|
+
// ...
|
|
324
|
+
} catch {
|
|
325
|
+
// apiFetch rejects on network error or if the refresh token expired;
|
|
326
|
+
// the SessionExpiredBanner handles the latter via onAuthRefreshError.
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
{ immediate: true },
|
|
330
|
+
)
|
|
245
331
|
```
|
|
246
332
|
|
|
333
|
+
`apiFetch` adds the token only when `kc.authenticated` is true — the watch ensures
|
|
334
|
+
you call it only after auth has settled, not speculatively on mount.
|
|
335
|
+
|
|
247
336
|
## Testing
|
|
248
337
|
|
|
249
338
|
Tests live alongside source files (`*.spec.ts` / `*.spec.vue`) and run with Vitest:
|
|
@@ -86,7 +86,6 @@ function onAccountItemClick() {
|
|
|
86
86
|
@sign-in="signInAgain"
|
|
87
87
|
@dismiss="session.dismiss"
|
|
88
88
|
/>
|
|
89
|
-
<goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
|
|
90
89
|
<main id="main-content">
|
|
91
90
|
<AppLayout :variant="contentWidth">
|
|
92
91
|
<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
6
|
// Keep the reactive instance — do NOT destructure (see App.vue): a destructured
|
|
6
|
-
// `authenticated
|
|
7
|
-
//
|
|
8
|
-
const kc = useKeycloak()
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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.
|
|
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
|
|
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
|
|
30
|
-
const
|
|
31
|
-
|
|
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>
|
|
@@ -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,35 @@ 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
|
-
//
|
|
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
|
|
320
|
-
const bannerUrl =
|
|
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 has no hero banner anywhere, including on HomeView', async () => {
|
|
357
|
+
await generator(host, { ...options, layout: 'internal' });
|
|
358
|
+
const homeView = host.read('apps/test/src/views/HomeView.vue').toString();
|
|
359
|
+
expect(homeView).not.toContain('goa-hero-banner');
|
|
360
|
+
}, 30000);
|
|
361
|
+
|
|
327
362
|
it('vite.config.ts marks goa-* elements as custom elements', async () => {
|
|
328
363
|
await generator(host, options);
|
|
329
364
|
const viteConfig = host.read('apps/test/vite.config.ts').toString();
|
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { useSlots } from 'vue';
|
|
3
|
+
|
|
2
4
|
// The alternate top-level shell for staff-facing/internal apps (goa-work-side-menu),
|
|
3
5
|
// used instead of AppHeader/AppFooter -- not together with them. Presentational only:
|
|
4
6
|
// unlike the real GovAlta-Pronghorn source this is adapted from, active-route
|
|
5
7
|
// highlighting and navigation are the caller's job (pass `current` on each item,
|
|
6
8
|
// listen for `item-click`) so this component doesn't need vue-router as a dependency.
|
|
9
|
+
//
|
|
10
|
+
// The real reference source also has a slim in-content "topbar" row for
|
|
11
|
+
// module-registered header actions (e.g. a notification bell) -- there's no
|
|
12
|
+
// AppHeader/utilities slot in this layout to hold that kind of thing. Exposed
|
|
13
|
+
// here as a plain named slot (`#topbar`), not a module-registration system --
|
|
14
|
+
// that's app-specific plumbing, not something a shared, presentational
|
|
15
|
+
// component should own.
|
|
16
|
+
const slots = useSlots();
|
|
17
|
+
|
|
7
18
|
interface AppSideMenuItem {
|
|
8
19
|
label: string;
|
|
9
20
|
to?: string;
|
|
@@ -63,6 +74,9 @@ const emit = defineEmits<{ itemClick: [item: AppSideMenuItem] }>();
|
|
|
63
74
|
/>
|
|
64
75
|
</goa-work-side-menu>
|
|
65
76
|
<main id="main-content" class="app-side-menu-content">
|
|
77
|
+
<div v-if="slots.topbar" class="app-side-menu-topbar">
|
|
78
|
+
<slot name="topbar" />
|
|
79
|
+
</div>
|
|
66
80
|
<slot />
|
|
67
81
|
</main>
|
|
68
82
|
</div>
|
|
@@ -80,6 +94,15 @@ const emit = defineEmits<{ itemClick: [item: AppSideMenuItem] }>();
|
|
|
80
94
|
overflow-y: auto;
|
|
81
95
|
}
|
|
82
96
|
|
|
97
|
+
.app-side-menu-topbar {
|
|
98
|
+
display: flex;
|
|
99
|
+
justify-content: flex-end;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: var(--goa-space-s, 0.75rem);
|
|
102
|
+
padding: var(--goa-space-xs, 0.5rem) var(--goa-space-m, 1rem);
|
|
103
|
+
border-bottom: 1px solid var(--goa-color-greyscale-100, #f1f1f1);
|
|
104
|
+
}
|
|
105
|
+
|
|
83
106
|
.skip-link {
|
|
84
107
|
position: absolute;
|
|
85
108
|
left: -9999px;
|
|
@@ -76,6 +76,19 @@ describe('Vue Components Generator', () => {
|
|
|
76
76
|
expect(agents).toContain('defineModel<boolean>');
|
|
77
77
|
}, 30000);
|
|
78
78
|
|
|
79
|
+
it('AppSideMenu exposes an optional #topbar slot for header-action-style content', async () => {
|
|
80
|
+
await generator(host);
|
|
81
|
+
|
|
82
|
+
const sideMenu = host
|
|
83
|
+
.read('libs/vue-components/src/lib/patterns/AppSideMenu.vue')
|
|
84
|
+
.toString();
|
|
85
|
+
// Named slot, only rendered when actually given content -- no empty bar
|
|
86
|
+
// shows by default, matching the "unused by default" doc claim.
|
|
87
|
+
expect(sideMenu).toContain('<slot name="topbar" />');
|
|
88
|
+
expect(sideMenu).toContain('v-if="slots.topbar"');
|
|
89
|
+
expect(sideMenu).toContain('useSlots');
|
|
90
|
+
}, 30000);
|
|
91
|
+
|
|
79
92
|
it('disables vue/no-deprecated-slot-attribute in flat config too, not just .eslintrc.json', async () => {
|
|
80
93
|
// useFlatConfig() (from @nx/eslint) treats a root flat-config file's
|
|
81
94
|
// presence as authoritative, regardless of the installed ESLint version —
|