# AGENTS.md — <%= projectName %>

Vue 3 frontend for the Alberta Digital Service Platform (ADSP).
Generated by `nx g @abgov/nx-adsp:vue-app`.
<% if (pairedProject) { %>
## Paired service

This app is paired with **`<%= pairedProject %>`** — the Express backend it talks to.
API calls use the `/api/` prefix which the Vite dev proxy rewrites to `/<%= pairedProject %>/` on port 3333.
When working on a feature that spans both projects, read `<%= pairedProjectRoot %>/AGENTS.md` for the service context.
<% } %>
## Running Nx commands (coding agents)

Run generators with `--no-interactive` **and** every required option supplied. With
`--no-interactive`, a missing required option errors instead of prompting, so an
interactive prompt never blocks your session (`CI=true` in the env does the same and
also skips the Nx Cloud prompt). This applies to `nx g` generators; `nx run <target>`
executors read options from `project.json` and do not prompt.

## Stack


- **UI**: Vue 3 (Composition API) + GoA design system (`@abgov/web-components` — `goa-*` custom elements)
- **Auth**: `@dsb-norge/vue-keycloak-js` (wraps `keycloak-js`)
- **State**: Pinia
- **Router**: Vue Router v4
- **Tests**: Vitest

## Key files

| File | Purpose |
|------|---------|
| `src/main.ts` | Entry — registers Pinia, Router, and Keycloak plugin (incl. `onAuthRefreshError`, which flags the session store as expired) |
<% if (layout === 'internal') { %>
| `src/App.vue` | Shell — `AppSideMenu` (side-menu shell, Sign in/out as an account item), `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout` |
<% } else { %>
| `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
<% } %>
| `src/stores/session.ts` | Pinia store — `expired` flag behind `SessionExpiredBanner`, set from `main.ts`'s `onAuthRefreshError` hook |
| `src/router/index.ts` | Routes — `/protected` guarded with `requiresAuth` meta |
<% if (layout === 'internal') { %>
| `src/views/HomeView.vue` | Public page — calls public and private APIs |
<% } else { %>
| `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 |
<% } %>
| `src/views/ProtectedView.vue` | Authenticated page — shows user info from token |
| `src/environments/environment.ts` | Access URL, realm, client ID — pre-set from ADSP tenant |
| `vite.config.ts` | Vite config — `isCustomElement` marks `goa-*` as web components |

## Auth pattern

> ⚠️ **Never destructure `useKeycloak()`.** Unlike most Vue composables (which
> return `ref`s that survive destructuring), this wrapper returns a
> `readonly(reactive({...}))` whose fields are **plain values** — `authenticated`
> is a `boolean`, `keycloak` is the instance-or-`undefined`, etc. Every field is
> `false`/`undefined` at setup time and only populates **asynchronously** once
> Keycloak's init settles. `const { keycloak } = useKeycloak()` therefore captures
> a permanent `undefined`, so `keycloak?.login()` becomes a silent no-op and
> `authenticated` never flips — and it *looks* fine because the initial render is
> correct. Always keep the object (`const kc = useKeycloak()`) and read `kc.field`
> at call/render time. This is the single most common way to break auth here.

```typescript
import { useKeycloak } from '@dsb-norge/vue-keycloak-js';

// useKeycloak() returns a readonly(reactive(...)) instance. Keep the object and
// read fields off it — do NOT destructure. Destructuring snapshots each field at
// setup time, so `kc.keycloak` would freeze at `undefined` (login() a no-op) and
// `kc.authenticated` would never flip to true. All fields populate asynchronously
// once Keycloak's init settles.
const kc = useKeycloak();

kc.authenticated    // boolean — true after sign-in
kc.fullName         // string | undefined — display name from token
kc.ready            // boolean — true once Keycloak init has settled
kc.token            // string | undefined — current access token

// Call Keycloak actions via the underlying keycloak-js instance:
kc.keycloak?.login()
kc.keycloak?.logout({ redirectUri: window.location.origin })

// Refresh token before an authenticated API call:
await kc.keycloak?.updateToken(30);
const bearer = kc.keycloak?.token;
```

To run work when auth settles (it flips after mount, e.g. on return from the
login redirect), `watch` it rather than reading once:

```typescript
import { watch } from 'vue';
watch(() => kc.authenticated, (authed) => { if (authed) loadPrivateData(); }, { immediate: true });
```

Route guard (in `router/index.ts`):

```typescript
router.beforeEach((to) => {
  if (to.meta.requiresAuth) {
    const kc = useKeycloak();
    if (!kc.ready) return true;    // let Keycloak init settle first
    if (!kc.authenticated) {
      kc.keycloak?.login({ redirectUri: window.location.origin + to.fullPath });
      return false;
    }
  }
  return true;
});
```

## GoA design system

**Find a component:** browse the gallery at
[design.alberta.ca/components](https://design.alberta.ca/components/) — press ⌘K to
search components and examples. Each component's page documents its properties,
events, and usage; treat it as the source of truth. Vue uses the framework-agnostic
`goa-*` web components (`@abgov/web-components`) directly — the `vite.config.ts`
`isCustomElement` option stops Vue from warning about them.

**Events**: GoA web components emit custom events with a `_` prefix (to avoid
clashing with native browser events); bind them with Vue's `@` syntax:

```html
<goa-button type="primary" @_click="handleSave">Save</goa-button>
<goa-input @_change="handleChange" />
<goa-dropdown @_change="handleSelect" />
<goa-checkbox @_change="handleCheck" />
```

Use `@_click` for buttons and `@_change` for inputs, dropdowns, and checkboxes.
Note `goa-*` elements do **not** support Vue's `v-model` — you must wire
`:value` + `@_change` by hand (the event carries the new value on
`$event.detail.value`, or `.detail.checked` for a checkbox).

### Prefer the `Goab*` wrappers for form controls

To avoid that friction (and the temptation to reach for a raw `<input>`, which
breaks the design system), the workspace has a shared **`<%= goaImportPath %>`**
library of thin wrappers that add real `v-model` support: `GoabInput`,
`GoabTextarea`, `GoabDropdown`, `GoabCheckbox`, `GoabRadioGroup`, `GoabButton`,
`GoabModal`.

```vue
<script setup lang="ts">
import { ref } from 'vue';
import { GoabInput, GoabButton } from '<%= goaImportPath %>';
const email = ref('');
</script>

<template>
  <GoabInput v-model="email" name="email" type="email" placeholder="you@alberta.ca" />
  <GoabButton type="primary" @click="save">Save</GoabButton>
</template>
```

Extra props/events fall through to the underlying `goa-*` element, so anything
the design system supports still works. For components without a wrapper, use the
`goa-*` element directly with the manual wiring above. The library is shared
across every Vue app in this workspace — fix or extend a wrapper once in
`<%= goaLibRoot %>` (or run `nx g @abgov/nx-adsp:vue-components` to restore it).

> **Interim:** these wrappers (`<%= goaLibRoot %>/src/lib/primitives/`) are a
> stopgap until GoA DS ships an official `@abgov/vue-components`. When it lands,
> delete `primitives/` and repoint imports at that package — the names/props are
> intended to match, so it's a scope swap. This does **not** apply to the
> `AppHeader`/`AppLayout`/`AppFooter`/`AppSideMenu`/`SessionExpiredBanner` shell
> components below — those live in the same library's `patterns/` folder and are
> permanent (app-shell composition has no equivalent in a design-system package).

### App shell components

This app was generated with **`--layout=<%= layout %>`**.
<% if (layout === 'internal') { %>
`App.vue` is a staff-facing, side-menu shell built from shared components imported
from **`<%= goaImportPath %>`**, not hand-rolled markup:

| Component | Purpose |
|---|---|
| `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` seeds `primaryItems` with Home and an `accountItems` Sign in/out item; the `vue-*-view` generators append their own entries, and you can add more by hand. **`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 |
| `AppLayout` | The content gutter every view renders inside (see the key files table) |
| `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 |

There's no `AppHeader`/`AppFooter`/hero banner in this layout — `AppSideMenu` is the
whole top-level shell, matching the real GoA "internal portal" pattern (no public
marketing chrome around a staff tool).
<% } else { %>
`App.vue` is built from four shared components imported from
**`<%= goaImportPath %>`**, not hand-rolled markup:

| Component | Purpose |
|---|---|
| `AppHeader` | `goa-microsite-header` + `goa-app-header`. Takes a `heading` prop; put account/sign-in actions in its `utilities` slot (a bare child with no slot is silently dropped by `goa-app-header`) |
| `AppLayout` | The content gutter every view renders inside (see the key files table). `App.vue` itself owns the skip-to-main-content landmark for this layout — `AppLayout` deliberately doesn't duplicate it |
| `AppFooter` | `goa-app-footer` with the standard GoA nav/meta links (Services/Contact/Terms of Use, Privacy/Disclaimer/Accessibility) |
| `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 |
<% } %>
These are shared across every Vue app in this workspace — fix or extend one once
in `<%= goaLibRoot %>/src/lib/patterns/`, the same way as the `Goab*` wrappers.

A second, **`--layout=<%= layout === 'internal' ? 'header' : 'internal' %>`** frontend
can be scaffolded against the same backend with `nx g @abgov/nx-adsp:vue-app <name>
--pairedProject <%= pairedProject || 'your-backend-project' %> --layout=<%= layout === 'internal' ? 'header' : 'internal' %>`
— a public-facing app and a staff-facing one sharing one API, without a separate
composite generator.

## Adding a view

**Check whether a generator already covers this view before hand-authoring one.** Each
retrofits into this project directly — file(s), route(s), and the shared component it's
built on, all in one step:

| If the view is... | Use | Built on |
|---|---|---|
| A single record's detail page (loading/error, optional status badge, back button) | `nx g @abgov/nx-adsp:vue-detail-view` | `RecordDetailShell` |
| A staff-facing, paginated/sortable/filterable list | `nx g @abgov/nx-adsp:vue-workspace-view` | `WorkspaceTable` |
| A simple admin list + create/update edit screen (no pagination) | `nx g @abgov/nx-adsp:vue-admin-crud` | `WorkspaceTable` |
| A multi-step submission flow (route-per-step, review, confirmation) | `nx g @abgov/nx-adsp:vue-intake-view` | `Stepper`, `StepErrorSummary` |

Run `nx g @abgov/nx-adsp:<generator> --help` for its full option list (each takes a
`--fields`/`--columns`/`--steps` JSON-string spec, not a CLI array flag — see the
generator's own schema description for why). Only hand-author a view when none of these
match, or once a generator's output needs customization it doesn't cover — its own comments
mark what's meant to be edited.

### Options that decide what a person sees

These are easy to omit and the result looks plausible until someone reads the screen, so
they are worth getting right on the first run:

| Pass | On | Or else |
|---|---|---|
| `options: [{ value, label }]` on a field/column | any generator, for any value stored as a code | the view renders the raw code — `northwest`, `cattle-mature` — including on the check-your-answers page, whose whole purpose is that the person can read back what they entered |
| `badgeMap: { approved: 'success', declined: 'emergency' }` | a `badge` column/field | every status renders the same neutral blue, so a declined claim reads as routine |
| `--referenceField=<key>` | `vue-intake-view` | the confirmation page falls back to the route id, presenting a storage id as "your reference number" (defaults to `reference`) |
| `--icon=<name>` | `vue-workspace-view`, `vue-admin-crud` | defaults (`list`, `settings`) are used; pick one that suits the screen |

The lookup itself is shared — `optionLabel` and `badgeType` in `<%= goaLibRoot %>/src/lib/formatters.ts`
— so a hand-authored view should use them too rather than re-deriving a mapping. See that
lib's own AGENTS.md for the full component catalog.

1. Create `src/views/MyFeatureView.vue` as a `<script setup>` SFC. **Don't add your
   own page margins/centering** — every view renders inside `AppLayout`'s gutter, so
   any top-level tag (`<div>`, `<section>`, …) is already centered and padded.
2. Add the route to `src/router/index.ts`:
   ```typescript
   { path: '/my-feature', component: () => import('../views/MyFeatureView.vue') }
   // For an authenticated route:
   { path: '/my-feature', component: () => import('../views/MyFeatureView.vue'), meta: { requiresAuth: true } }
   // For a wider (data table) or narrower (form) view, set the layout width:
   { path: '/queue', component: () => import('../views/QueueView.vue'), meta: { layout: 'wide' } }
   { path: '/apply', component: () => import('../views/ApplyView.vue'), meta: { layout: 'form' } }
   ```
3. **For the `internal` layout: check the side-menu nav.** `src/App.vue` has a plain
   `primaryItems` array — the app's own navigation list, seeded with Home. Each `vue-*-view`
   generator that adds a staff screen appends its entry automatically, so a generated route is
   navigable without you doing anything.

   It is a literal array, not derived from the router, on purpose: nav order, grouping and
   labels are decisions this app owns. Reorder, relabel, group into `secondaryItems`, or prune
   freely — the generators only ever append, and never touch an entry that already exists for
   that route.

   ```typescript
   const primaryItems = [
     { label: 'Home', to: '/', icon: 'home' },
     { label: 'Review queue', to: '/queue', icon: 'list' },   // added by vue-workspace-view
   ];
   ```

   **`icon`** is effectively required — `goa-work-side-menu-item` renders a blank item without
   one — so the generators supply it: `vue-workspace-view` defaults to `list` and
   `vue-admin-crud` to `settings`. Pass `--icon=<name>` to choose a better one for the screen
   (names: [design.alberta.ca/components/icons](https://design.alberta.ca/components/icons)).

   Still worth adding by hand: **`current`** for active highlighting, e.g.
   `current: route.path.startsWith('/queue')`, which needs the `route` already in scope.

   The `header` layout has no side menu, so it has no `primaryItems` array and the generators
   skip the step silently — use route-level breadcrumbs or a nav inside the view instead.

## Backend API calls (proxy setup)

Use relative `/api/` paths — they route through Vite's dev proxy and nginx in production.

**Use `useApi()` for all API calls.** `apiFetch` automatically refreshes the token and adds
`Authorization: Bearer` when the user is authenticated — you never need to do it at each
call site.

```typescript
import { useApi } from '../composables/useApi' // adjust depth for nested components (../../…)

const { apiFetch } = useApi()

// ✓ Any route — adds the token when authenticated, skips it when not
const res = await apiFetch('/api/v1/my-resource')

// ✓ With a request body
const res = await apiFetch('/api/v1/my-resource', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(payload),
})

// ✗ Wrong path — bypasses proxy, won't work in production
const res = await fetch('http://localhost:3333/<%= pairedProject || 'my-service' %>/v1/my-resource')
```

**For CRUD against a REST resource, prefer the domain-level helpers over `apiFetch`.**
`useApi()` also returns `list`/`get`/`save`/`action`, which state what you want rather than how
this API spells it:

```typescript
const { list, get, save, action } = useApi()

const { rows, total } = await list('my-resource', {
  page: 1, pageSize: 20, search: term, sortBy: 'name', sortDir: 'asc',
  filters: { status: 'active' },
})
const record = await get('my-resource', id)
await save('my-resource', null, payload)   // null id = create
await save('my-resource', id, payload)     // an id = update
await action('my-resource', id, 'submit')  // POST /:id/submit
```

These build their URL through `apiConvention.path()`, which resolves to
`/api/v1/<resource>`. `/api/` is the dev-proxy and nginx location; `v1` is the version the
paired `express-service` mounts its routers under (`/<service>/v1`), and the proxy rewrites
`/api/` to `/<service>/`. If your API is versioned differently — or not at all — change
`apiConvention` and nothing else: the views only ever call through that seam, so no view
needs editing.

In dev the rewrite lives in `vite.proxy.cjs`. That file is a CommonJS module rather than
JSON on purpose: Vite rewrites a proxied path with a `rewrite` function, and a JSON config
cannot hold a function. Add a route by appending a row to its `routes` array.

Every wire-level detail these hide — the base path, whether paging is `page`/`limit` or
`limit`/`offset`, whether rows arrive bare or under `results`/`entries`, whether an update is PUT
or PATCH — lives in the **API convention adapter** at the top of
`src/composables/useApi.ts`. That block is the glue layer between this app and its backend:
**edit it when your API differs, and never encode an API convention in a view.** Every
`vue-*-view` generator emits views that go through these helpers, so a view stays correct when the
backend's conventions aren't the default ones, and your mapping survives regenerating the view.

Use `apiFetch` directly for anything that isn't resource CRUD — a non-REST endpoint, a file
download, a custom collection path.

**Most routes require authentication.** The service mounts `/v1` with the anonymous passport
strategy, so a bare request without a token reaches the router — but business routes also
gate on `tenant` auth and will 401. `apiFetch` handles this automatically when signed in.

**React to auth state for calls that require the user to be signed in.** Keycloak settles
asynchronously; watch `kc.authenticated` rather than reading it once on mount:

```typescript
import { watch } from 'vue'
import { useKeycloak } from '@dsb-norge/vue-keycloak-js'
import { useApi } from '../composables/useApi'

const kc = useKeycloak()
const { apiFetch } = useApi()

watch(
  () => kc.authenticated,
  async (authenticated) => {
    if (!authenticated) return
    try {
      const res = await apiFetch('/api/v1/my-resource')
      // ...
    } catch {
      // apiFetch rejects on network error or if the refresh token expired;
      // the SessionExpiredBanner handles the latter via onAuthRefreshError.
    }
  },
  { immediate: true },
)
```

`apiFetch` adds the token only when `kc.authenticated` is true — the watch ensures
you call it only after auth has settled, not speculatively on mount.

## Testing

Tests live alongside source files (`*.spec.ts` / `*.spec.vue`) and run with Vitest:

```bash
nx test <%= projectName %>          # run all tests
nx test <%= projectName %> --watch  # watch mode
```

Component test example:

```typescript
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import MyComponent from './MyComponent.vue';

describe('MyComponent', () => {
  it('renders correctly', () => {
    const wrapper = mount(MyComponent, { props: { title: 'Hello' } });
    expect(wrapper.text()).toContain('Hello');
  });
});
```

### Accessibility check — expand its route list

The generated e2e project ships `src/a11y.spec.ts`: an axe-core check scoped to WCAG 2.1
A/AA, run as part of the normal `e2e` target. **It only covers the routes named in its own
`ROUTES` array, which ships as `['/']`.**

Add an entry whenever you add a route — whether a generator added it or you hand-edited the
router. Nothing does this for you: no generator writes to that file (it is left alone once it
exists so your edits survive), so a router that has grown past `/` while `ROUTES` has not is
a check that has quietly stopped covering the app. `/` is mostly app shell, so it exercises
almost none of the elements the rest of the app is built from.

Expect real failures when you expand it, and triage before fixing: some violations originate
inside `@abgov/web-components`' own shadow DOM and cannot be addressed from app code. Those
need an upstream fix or a documented exclusion, not a workaround in your view.

## OpenShift targets

```bash
nx run <%= projectName %>:sandbox           # build locally (podman) + push to GHCR + deploy
nx run <%= projectName %>:sandbox-teardown  # remove sandbox resources + delete the GHCR image
nx run <%= projectName %>:apply-envs        # apply manifests to all environments
nx run <%= projectName %>:teardown-dev      # remove from dev environment
```

## What NOT to change

- `src/main.ts` — Keycloak plugin is registered once here; do not call `new Keycloak()` elsewhere
- `environments/environment.ts` — access URL and realm are pre-configured for the ADSP tenant
- `vite.config.ts` — the `isCustomElement` predicate must stay to suppress Vue warnings for `goa-*` elements

## Sandbox deployment (local build)

**Deployment target: `sandbox`.** `nx run <%= projectName %>:sandbox` builds the image **locally with podman**, pushes it to GHCR, and deploys it to your namespace — no git push or CI wait. Run the generator once first to add the targets:

```bash
nx g @abgov/nx-oc:sandbox <%= projectName %> --sandboxProject <your-namespace>
```

That also writes **`.openshift/<%= projectName %>/SANDBOX.md`** — the full deploy runbook: prerequisites (`podman`, `oc` login, a `gh` account with **`write:packages`** as the *active* `gh` account), preflight failures and their fixes, `--skipBuild`/`--skipPush` to resume a partial deploy, a copy-paste manual-completion sequence, and troubleshooting (CPU quota, `CrashLoopBackOff`, registry auth, redirect URIs). Read it whenever a deploy misbehaves.
