@abgov/nx-adsp 13.18.0-beta.2 → 13.18.0-beta.3
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 +1 -1
- package/src/generators/vue-app/files/AGENTS.md__tmpl__ +29 -4
- package/src/generators/vue-app/files/src/App.spec.ts__tmpl__ +26 -1
- package/src/generators/vue-app/files/src/App.vue__tmpl__ +38 -2
- package/src/generators/vue-app/schema.d.ts +2 -0
- package/src/generators/vue-app/schema.json +6 -0
- package/src/generators/vue-app/vue-app.js +2 -1
- package/src/generators/vue-app/vue-app.js.map +1 -1
- package/src/generators/vue-app/vue-app.spec.ts +30 -0
- package/src/generators/vue-components/files/AGENTS.md__tmpl__ +2 -2
- package/src/generators/vue-components/files/src/index.ts__tmpl__ +1 -0
- package/src/generators/vue-components/files/src/lib/patterns/AppSideMenu.vue__tmpl__ +96 -0
- package/src/generators/vue-components/files/src/vue-components.spec.ts__tmpl__ +1 -0
- package/src/generators/vue-components/vue-components.spec.ts +1 -0
package/package.json
CHANGED
|
@@ -31,7 +31,11 @@ executors read options from `project.json` and do not prompt.
|
|
|
31
31
|
| File | Purpose |
|
|
32
32
|
|------|---------|
|
|
33
33
|
| `src/main.ts` | Entry — registers Pinia, Router, and Keycloak plugin (incl. `onAuthRefreshError`, which flags the session store as expired) |
|
|
34
|
+
<% if (layout === 'internal') { %>
|
|
35
|
+
| `src/App.vue` | Shell — `AppSideMenu` (side-menu shell, Sign in/out as an account item), `SessionExpiredBanner`, `<RouterView>` wrapped in `AppLayout` |
|
|
36
|
+
<% } else { %>
|
|
34
37
|
| `src/App.vue` | Shell — `AppHeader` with sign-in/out in its `utilities` slot, `SessionExpiredBanner`, hero banner, `<RouterView>` wrapped in `AppLayout`, `AppFooter` |
|
|
38
|
+
<% } %>
|
|
35
39
|
| `src/stores/session.ts` | Pinia store — `expired` flag behind `SessionExpiredBanner`, set from `main.ts`'s `onAuthRefreshError` hook |
|
|
36
40
|
| `src/router/index.ts` | Routes — `/protected` guarded with `requiresAuth` meta |
|
|
37
41
|
| `src/views/HomeView.vue` | Public page — calls public and private APIs |
|
|
@@ -155,12 +159,27 @@ across every Vue app in this workspace — fix or extend a wrapper once in
|
|
|
155
159
|
> stopgap until GoA DS ships an official `@abgov/vue-components`. When it lands,
|
|
156
160
|
> delete `primitives/` and repoint imports at that package — the names/props are
|
|
157
161
|
> intended to match, so it's a scope swap. This does **not** apply to the
|
|
158
|
-
> `AppHeader`/`AppLayout`/`AppFooter`/`SessionExpiredBanner` shell
|
|
159
|
-
> below — those live in the same library's `patterns/` folder and are
|
|
160
|
-
> (app-shell composition has no equivalent in a design-system package).
|
|
162
|
+
> `AppHeader`/`AppLayout`/`AppFooter`/`AppSideMenu`/`SessionExpiredBanner` shell
|
|
163
|
+
> components below — those live in the same library's `patterns/` folder and are
|
|
164
|
+
> permanent (app-shell composition has no equivalent in a design-system package).
|
|
161
165
|
|
|
162
166
|
### App shell components
|
|
163
167
|
|
|
168
|
+
This app was generated with **`--layout=<%= layout %>`**.
|
|
169
|
+
<% if (layout === 'internal') { %>
|
|
170
|
+
`App.vue` is a staff-facing, side-menu shell built from shared components imported
|
|
171
|
+
from **`<%= goaImportPath %>`**, not hand-rolled markup:
|
|
172
|
+
|
|
173
|
+
| Component | Purpose |
|
|
174
|
+
|---|---|
|
|
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 |
|
|
176
|
+
| `AppLayout` | The content gutter every view renders inside (see the key files table). Also renders the skip-to-main-content link |
|
|
177
|
+
| `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
|
+
|
|
179
|
+
There's no `AppHeader`/`AppFooter`/hero banner in this layout — `AppSideMenu` is the
|
|
180
|
+
whole top-level shell, matching the real GoA "internal portal" pattern (no public
|
|
181
|
+
marketing chrome around a staff tool).
|
|
182
|
+
<% } else { %>
|
|
164
183
|
`App.vue` is built from four shared components imported from
|
|
165
184
|
**`<%= goaImportPath %>`**, not hand-rolled markup:
|
|
166
185
|
|
|
@@ -170,10 +189,16 @@ across every Vue app in this workspace — fix or extend a wrapper once in
|
|
|
170
189
|
| `AppLayout` | The content gutter every view renders inside (see the key files table). Also renders the skip-to-main-content link |
|
|
171
190
|
| `AppFooter` | `goa-app-footer` with the standard GoA nav/meta links (Services/Contact/Terms of Use, Privacy/Disclaimer/Accessibility) |
|
|
172
191
|
| `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 |
|
|
173
|
-
|
|
192
|
+
<% } %>
|
|
174
193
|
These are shared across every Vue app in this workspace — fix or extend one once
|
|
175
194
|
in `libs/vue-components/src/lib/patterns/`, the same way as the `Goab*` wrappers.
|
|
176
195
|
|
|
196
|
+
A second, **`--layout=<%= layout === 'internal' ? 'header' : 'internal' %>`** frontend
|
|
197
|
+
can be scaffolded against the same backend with `nx g @abgov/nx-adsp:vue-app <name>
|
|
198
|
+
--pairedProject <%= pairedProject || 'your-backend-project' %> --layout=<%= layout === 'internal' ? 'header' : 'internal' %>`
|
|
199
|
+
— a public-facing app and a staff-facing one sharing one API, without a separate
|
|
200
|
+
composite generator.
|
|
201
|
+
|
|
177
202
|
## Adding a view
|
|
178
203
|
|
|
179
204
|
1. Create `src/views/MyFeatureView.vue` as a `<script setup>` SFC. **Don't add your
|
|
@@ -34,7 +34,32 @@ const router = createRouter({
|
|
|
34
34
|
history: createMemoryHistory(),
|
|
35
35
|
routes: [{ path: '/', component: { template: '<div />' } }],
|
|
36
36
|
});
|
|
37
|
+
<% if (layout === 'internal') { %>
|
|
38
|
+
describe('App shell (internal / side-menu)', () => {
|
|
39
|
+
it('shows a Sign in account item when unauthenticated', () => {
|
|
40
|
+
const wrapper = mount(App, {
|
|
41
|
+
global: { plugins: [router, createPinia()], stubs: { RouterView: true } },
|
|
42
|
+
});
|
|
43
|
+
const item = wrapper.find('goa-work-side-menu-item[label="Sign in"]');
|
|
44
|
+
expect(item.exists()).toBe(true);
|
|
45
|
+
});
|
|
37
46
|
|
|
47
|
+
it('clicking the account item calls keycloak.login (guards against lost reactivity)', async () => {
|
|
48
|
+
// Same bug class as the header shell: destructuring useKeycloak() would
|
|
49
|
+
// freeze `keycloak` at undefined, so login() would be a permanent no-op.
|
|
50
|
+
const kc = useKeycloak();
|
|
51
|
+
const wrapper = mount(App, {
|
|
52
|
+
global: { plugins: [router, createPinia()], stubs: { RouterView: true } },
|
|
53
|
+
});
|
|
54
|
+
// AppSideMenu binds @click.prevent directly on goa-work-side-menu-item (not
|
|
55
|
+
// an underscore-prefixed event, unlike the goa-* form elements) -- trigger
|
|
56
|
+
// a plain 'click'.
|
|
57
|
+
const item = wrapper.find('goa-work-side-menu-item[label="Sign in"]');
|
|
58
|
+
await item.trigger('click');
|
|
59
|
+
expect(kc.keycloak?.login).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
<% } else { %>
|
|
38
63
|
describe('App shell header', () => {
|
|
39
64
|
it('shows Sign in when unauthenticated', () => {
|
|
40
65
|
const wrapper = mount(App, {
|
|
@@ -57,7 +82,7 @@ describe('App shell header', () => {
|
|
|
57
82
|
expect(kc.keycloak?.login).toHaveBeenCalled();
|
|
58
83
|
});
|
|
59
84
|
});
|
|
60
|
-
|
|
85
|
+
<% } %>
|
|
61
86
|
describe('App shell session-expired banner', () => {
|
|
62
87
|
it('is hidden until the session store flags an expired session', async () => {
|
|
63
88
|
const pinia = createPinia();
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
import { computed } from 'vue';
|
|
3
3
|
import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
|
|
4
4
|
import { RouterView, useRoute } from 'vue-router';
|
|
5
|
+
<% if (layout === 'internal') { %>
|
|
6
|
+
import { AppLayout, AppSideMenu, SessionExpiredBanner } from '<%= goaImportPath %>';
|
|
7
|
+
<% } else { %>
|
|
5
8
|
import { AppHeader, AppLayout, AppFooter, SessionExpiredBanner } from '<%= goaImportPath %>';
|
|
9
|
+
<% } %>
|
|
6
10
|
import { useSessionStore } from './stores/session';
|
|
7
11
|
|
|
8
12
|
// Keep the reactive instance — do NOT destructure. useKeycloak() returns a
|
|
@@ -15,7 +19,7 @@ const session = useSessionStore();
|
|
|
15
19
|
// Content width per view, from route meta (default 'page'). Views opt into a
|
|
16
20
|
// different width with `meta: { layout: 'wide' | 'form' }` in the router.
|
|
17
21
|
const route = useRoute();
|
|
18
|
-
const
|
|
22
|
+
const contentWidth = computed(
|
|
19
23
|
() => (route.meta.layout as 'page' | 'wide' | 'form' | undefined) ?? 'page'
|
|
20
24
|
);
|
|
21
25
|
|
|
@@ -26,9 +30,40 @@ function signInAgain() {
|
|
|
26
30
|
session.dismiss();
|
|
27
31
|
kc.keycloak?.login();
|
|
28
32
|
}
|
|
33
|
+
<% if (layout === 'internal') { %>
|
|
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.
|
|
37
|
+
const accountItems = computed(() => [
|
|
38
|
+
kc.authenticated
|
|
39
|
+
? { label: kc.fullName ? `Sign out (${kc.fullName})` : 'Sign out' }
|
|
40
|
+
: { label: 'Sign in' },
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function onAccountItemClick() {
|
|
44
|
+
if (kc.authenticated) logout();
|
|
45
|
+
else login();
|
|
46
|
+
}
|
|
47
|
+
<% } %>
|
|
29
48
|
</script>
|
|
30
49
|
|
|
31
50
|
<template>
|
|
51
|
+
<% if (layout === 'internal') { %>
|
|
52
|
+
<AppSideMenu
|
|
53
|
+
heading="<%= projectName %>"
|
|
54
|
+
:account-items="accountItems"
|
|
55
|
+
@item-click="onAccountItemClick"
|
|
56
|
+
>
|
|
57
|
+
<SessionExpiredBanner
|
|
58
|
+
v-model:show="session.expired"
|
|
59
|
+
@sign-in="signInAgain"
|
|
60
|
+
@dismiss="session.dismiss"
|
|
61
|
+
/>
|
|
62
|
+
<AppLayout :variant="contentWidth">
|
|
63
|
+
<RouterView />
|
|
64
|
+
</AppLayout>
|
|
65
|
+
</AppSideMenu>
|
|
66
|
+
<% } else { %>
|
|
32
67
|
<AppHeader heading="<%= projectName %>">
|
|
33
68
|
<template #utilities>
|
|
34
69
|
<goa-button-group alignment="end">
|
|
@@ -51,10 +86,11 @@ function signInAgain() {
|
|
|
51
86
|
@dismiss="session.dismiss"
|
|
52
87
|
/>
|
|
53
88
|
<goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
|
|
54
|
-
<AppLayout :variant="
|
|
89
|
+
<AppLayout :variant="contentWidth">
|
|
55
90
|
<RouterView />
|
|
56
91
|
</AppLayout>
|
|
57
92
|
<AppFooter />
|
|
93
|
+
<% } %>
|
|
58
94
|
</template>
|
|
59
95
|
|
|
60
96
|
<style>
|
|
@@ -13,6 +13,8 @@ export interface Schema {
|
|
|
13
13
|
skipAgent?: boolean;
|
|
14
14
|
/** Name of an existing backend service project to derive the nginx/dev-server proxy and the adsp:proxy-service: sandbox tag from. */
|
|
15
15
|
pairedProject?: string;
|
|
16
|
+
/** Top-level app shell: 'header' (default, public-facing) or 'internal' (staff-facing side menu, no header/banner/footer). */
|
|
17
|
+
layout?: 'header' | 'internal';
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface NormalizedSchema extends Schema {
|
|
@@ -83,6 +83,12 @@
|
|
|
83
83
|
"type": "boolean",
|
|
84
84
|
"description": "Skip the consultAgent interaction and generate base scaffolding only.",
|
|
85
85
|
"default": false
|
|
86
|
+
},
|
|
87
|
+
"layout": {
|
|
88
|
+
"type": "string",
|
|
89
|
+
"description": "Top-level app shell: 'header' 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). Both wrap views in the same AppLayout content gutter. Pair two vue-app runs against the same --pairedProject (once each) for a public+internal frontend pairing over one backend.",
|
|
90
|
+
"enum": ["header", "internal"],
|
|
91
|
+
"default": "header"
|
|
86
92
|
}
|
|
87
93
|
},
|
|
88
94
|
"required": ["name", "env"],
|
|
@@ -14,6 +14,7 @@ const paired_project_1 = require("../../utils/paired-project");
|
|
|
14
14
|
const vue_components_1 = require("../vue-components/vue-components");
|
|
15
15
|
function normalizeOptions(host, options) {
|
|
16
16
|
return tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
17
|
+
var _a;
|
|
17
18
|
const projectName = (0, devkit_1.names)(options.name).fileName;
|
|
18
19
|
const projectRoot = `${(0, devkit_1.getWorkspaceLayout)(host).appsDir}/${projectName}`;
|
|
19
20
|
const openshiftDirectory = `.openshift/${projectName}`;
|
|
@@ -36,7 +37,7 @@ function normalizeOptions(host, options) {
|
|
|
36
37
|
projectRoot,
|
|
37
38
|
openshiftDirectory,
|
|
38
39
|
adsp,
|
|
39
|
-
nginxProxies, pairedProjectTag: paired === null || paired === void 0 ? void 0 : paired.tag });
|
|
40
|
+
nginxProxies, pairedProjectTag: paired === null || paired === void 0 ? void 0 : paired.tag, layout: (_a = options.layout) !== null && _a !== void 0 ? _a : 'header' });
|
|
40
41
|
});
|
|
41
42
|
}
|
|
42
43
|
function addFiles(host, options) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vue-app.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-app/vue-app.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"vue-app.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-app/vue-app.ts"],"names":[],"mappings":";;AAuHA,4BA+MC;;AAtUD,wCAIsB;AACtB,6CAA6E;AAC7E,+DAIoC;AACpC,+DAA4D;AAC5D,iDAK6B;AAC7B,uCAAyC;AACzC,uCAYoB;AACpB,6BAA6B;AAC7B,+DAAuE;AACvE,qEAE0C;AAG1C,SAAe,gBAAgB,CAC7B,IAAU,EACV,OAAe;;;QAEf,MAAM,WAAW,GAAG,IAAA,cAAK,EAAC,OAAO,CAAC,IAAI,CAAC,CAAC,QAAQ,CAAC;QACjD,MAAM,WAAW,GAAG,GAAG,IAAA,2BAAkB,EAAC,IAAI,CAAC,CAAC,OAAO,IAAI,WAAW,EAAE,CAAC;QACzE,MAAM,kBAAkB,GAAG,cAAc,WAAW,EAAE,CAAC;QACvD,MAAM,IAAI,GAAG,MAAM,IAAA,4BAAoB,EAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QACvD,MAAM,eAAe,GAAG,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC;YAClD,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC;YACpB,CAAC,CAAC,OAAO,CAAC,KAAK;gBACb,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC;gBACjB,CAAC,CAAC,EAAE,CAAC;QACT,MAAM,MAAM,GAAG,IAAA,0CAAyB,EAAC,IAAI,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;QACtE,IACE,MAAM;YACN,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,KAAK,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,EACjE,CAAC;YACD,MAAM,IAAI,KAAK,CACb,gDAAgD,MAAM,CAAC,KAAK,CAAC,QAAQ,iBAAiB;gBACpF,4EAA4E,CAC/E,CAAC;QACJ,CAAC;QACD,MAAM,YAAY,GAAG,MAAM;YACzB,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,GAAG,eAAe,CAAC;YACpC,CAAC,CAAC,eAAe,CAAC;QACpB,uCACK,OAAO,KACV,WAAW;YACX,WAAW;YACX,kBAAkB;YAClB,IAAI;YACJ,YAAY,EACZ,gBAAgB,EAAE,MAAM,aAAN,MAAM,uBAAN,MAAM,CAAE,GAAG,EAC7B,MAAM,EAAE,MAAA,OAAO,CAAC,MAAM,mCAAI,QAAQ,IAClC;IACJ,CAAC;CAAA;AAED,SAAS,QAAQ,CAAC,IAAU,EAAE,OAAyB;;IACrD,MAAM,eAAe,iDAChB,OAAO,GACP,OAAO,CAAC,IAAI,KACf,cAAc,EAAE,IAAA,uBAAc,EAAC,OAAO,CAAC,WAAW,CAAC,EACnD,aAAa,EAAE,MAAA,OAAO,CAAC,aAAa,mCAAI,IAAI;QAC5C,6EAA6E;QAC7E,aAAa,EAAE,IAAA,wCAAuB,EAAC,IAAI,CAAC,EAC5C,IAAI,EAAE,EAAE,GACT,CAAC;IACF,IAAA,sBAAa,EACX,IAAI,EACJ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAC7B,OAAO,CAAC,WAAW,EACnB,eAAe,CAChB,CAAC;IAEF,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,GAAG,CAAC,CAAC;IACrD,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC,MAAM,CAC9C,CAAC,SAAS,EAAE,UAAU,EAAE,EAAE;YACxB,MAAM,WAAW,GAAG,IAAI,GAAG,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAClD,MAAM,KAAK,GAAG;gBACZ,MAAM,EAAE,GAAG,WAAW,CAAC,QAAQ,cAAc,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE;gBAC7F,MAAM,EAAE,WAAW,CAAC,QAAQ,KAAK,QAAQ;gBACzC,YAAY,EAAE,KAAK;gBACnB,WAAW,EAAE,EAAE;aAChB,CAAC;YACF,IAAI,WAAW,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;gBACpC,KAAK,CAAC,WAAW,GAAG;oBAClB,CAAC,IAAI,UAAU,CAAC,QAAQ,EAAE,CAAC,EAAE,WAAW,CAAC,QAAQ;iBAClD,CAAC;YACJ,CAAC;YACD,uCAAY,SAAS,KAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,KAAK,IAAG;QACxD,CAAC,EACD,EAAE,CACH,CAAC;QACF,IAAA,kBAAS,EAAC,IAAI,EAAE,GAAG,OAAO,CAAC,WAAW,kBAAkB,EAAE,YAAY,CAAC,CAAC;IAC1E,CAAC;IACD,OAAO,YAAY,CAAC;AACtB,CAAC;AAED,mBAA+B,IAAU,EAAE,OAAe;;;QACxD,4EAA4E;QAC5E,2EAA2E;QAC3E,MAAM,EAAE,oBAAoB,EAAE,OAAO,EAAE,GAAG,MAAM,qCAAO,SAAS,GAAE,KAAK,CACrE,GAAG,EAAE;YACH,MAAM,IAAI,KAAK,CACb,mGAAmG,CACpG,CAAC;QACJ,CAAC,CACF,CAAC;QAEF,MAAM,iBAAiB,GAAG,MAAM,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAEhE,MAAM,OAAO,CAAC,IAAI,EAAE;YAClB,IAAI,EAAE,OAAO,CAAC,IAAI;YAClB,KAAK,EAAE,KAAK;YACZ,UAAU,EAAE,IAAI;YAChB,MAAM,EAAE,QAAQ;YAChB,cAAc,EAAE,QAAQ;YACxB,aAAa,EAAE,YAAY;YAC3B,OAAO,EAAE,IAAI;YACb,SAAS,EAAE,iBAAiB,CAAC,WAAW;SACzC,CAAC,CAAC;QAEH,6EAA6E;QAC7E,0EAA0E;QAC1E,IAAA,kCAAwB,EAAC,IAAI,EAAE,GAAG,iBAAiB,CAAC,WAAW,MAAM,CAAC,CAAC;QACvE,IAAA,kCAAwB,EAAC,IAAI,EAAE,GAAG,iBAAiB,CAAC,WAAW,MAAM,CAAC,CAAC;QAEvE,8EAA8E;QAC9E,+EAA+E;QAC/E,wEAAwE;QACxE,6EAA6E;QAC7E,+EAA+E;QAC/E,6EAA6E;QAC7E,mCAAmC;QACnC,MAAM,IAAA,wBAAsB,EAAC,IAAI,CAAC,CAAC;QAEnC,IAAA,qCAA4B,EAC1B,IAAI,EACJ;YACE,sBAAsB,EAAE,QAAQ;YAChC,uBAAuB,EAAE,OAAO;YAChC,wEAAwE;YACxE,iEAAiE;YACjE,4BAA4B,EAAE,QAAQ;YACtC,KAAK,EAAE,QAAQ;YACf,YAAY,EAAE,QAAQ;SACvB,EACD;YACE,sBAAsB,EAAE,SAAS;YACjC,wBAAwB,EAAE,QAAQ;YAClC,0BAA0B,EAAE,QAAQ;SACrC,CACF,CAAC;QAEF,gFAAgF;QAChF,0EAA0E;QAC1E,0EAA0E;QAC1E,4EAA4E;QAC5E,wEAAwE;QACxE,4EAA4E;QAC5E,KAAK,MAAM,CAAC,IAAI;YACd,aAAa;YACb,iBAAiB;YACjB,qBAAqB;YACrB,uBAAuB;YACvB,+BAA+B;YAC/B,qEAAqE;YACrE,+DAA+D;YAC/D,8BAA8B;YAC9B,yBAAyB;YACzB,iBAAiB;SAClB,EAAE,CAAC;YACF,IAAI,IAAI,CAAC,MAAM,CAAC,GAAG,iBAAiB,CAAC,WAAW,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC;gBACzD,IAAI,CAAC,MAAM,CAAC,GAAG,iBAAiB,CAAC,WAAW,IAAI,CAAC,EAAE,CAAC,CAAC;YACvD,CAAC;QACH,CAAC;QAED,MAAM,UAAU,GAAG,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC,CAAC;QAErD,IAAA,+BAAqB,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,CAAC,CAAC;QAC3D,2EAA2E;QAC3E,yEAAyE;QACzE,kBAAkB;QAClB,MAAM,IAAA,cAAa,EAAC,IAAI,CAAC,CAAC;QAE1B,MAAM,MAAM,GAAG,IAAA,iCAAwB,EAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QAE5D,4EAA4E;QAC5E,IAAI,UAAU,KAAI,MAAA,MAAM,CAAC,OAAO,CAAC,KAAK,0CAAE,OAAO,CAAA,EAAE,CAAC;YAChD,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,mCACvB,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,KAC/B,WAAW,EAAE,GAAG,iBAAiB,CAAC,WAAW,kBAAkB,GAChE,CAAC;QACJ,CAAC;QAED,yCAAyC;QACzC,wEAAwE;QACxE,4EAA4E;QAC5E,yEAAyE;QACzE,oCAAoC;QACpC,IAAI,MAAA,MAAM,CAAC,OAAO,CAAC,KAAK,0CAAE,OAAO,EAAE,CAAC;YAClC,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,mCACvB,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,KAC/B,UAAU,EAAE,QAAQ,iBAAiB,CAAC,WAAW,EAAE,GACpD,CAAC;QACJ,CAAC;QAED,oFAAoF;QACpF,iEAAiE;QACjE,IAAI,iBAAiB,CAAC,gBAAgB,EAAE,CAAC;YACvC,MAAM,CAAC,IAAI,GAAG,CAAC,GAAG,CAAC,MAAA,MAAM,CAAC,IAAI,mCAAI,EAAE,CAAC,EAAE,iBAAiB,CAAC,gBAAgB,CAAC,CAAC;QAC7E,CAAC;QAED,MAAM,CAAC,IAAI,GAAG;YACZ,GAAG,CAAC,MAAA,MAAM,CAAC,IAAI,mCAAI,EAAE,CAAC;YACtB,GAAG,IAAA,uBAAe,EAChB,iBAAiB,CAAC,GAAG,EACrB,iBAAiB,CAAC,IAAI,CAAC,MAAM,CAC9B,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,WAAC,OAAA,CAAC,CAAC,MAAA,MAAM,CAAC,IAAI,mCAAI,EAAE,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAA,EAAA,CAAC;SACtD,CAAC;QAEF,IAAA,mCAA0B,EAAC,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;QAEvD,IAAA,0BAAgB,EAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QACrC,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;QAExB,IAAI,iBAAiB,CAAC,IAAI,EAAE,CAAC;YAC3B,MAAM,WAAW,GACf,MAAA,iBAAiB,CAAC,IAAI,CAAC,WAAW,mCAAI,OAAO,CAAC,WAAW,CAAC;YAC5D,MAAM,QAAQ,GAAG,WAAW,iBAAiB,CAAC,IAAI,CAAC,MAAM,IAAI,iBAAiB,CAAC,WAAW,EAAE,CAAC;YAC7F,MAAM,IAAA,mCAAkB,EACtB,iBAAiB,CAAC,IAAI,CAAC,gBAAgB,EACvC,iBAAiB,CAAC,IAAI,CAAC,WAAW,EAClC,QAAQ,EACR,WAAW,CACZ,CAAC;YACF,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;gBAC5B,MAAM,IAAA,qCAAoB,EACxB,iBAAiB,CAAC,IAAI,CAAC,gBAAgB,EACvC,iBAAiB,CAAC,IAAI,CAAC,WAAW,EAClC,QAAQ,EACR,OAAO,CAAC,eAAe,EACvB,WAAW,CACZ,CAAC;gBACF,MAAM,IAAA,sCAAqB,EACzB,iBAAiB,CAAC,IAAI,CAAC,gBAAgB,EACvC,iBAAiB,CAAC,IAAI,CAAC,WAAW,EAClC,QAAQ,EACR,OAAO,CAAC,eAAe,EACvB,cAAc,EACd,WAAW,CACZ,CAAC;YACJ,CAAC;QACH,CAAC;QAED,IAAI,iBAAiB,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,CAAC;YACjD,MAAM,WAAW,GACf,MAAA,iBAAiB,CAAC,IAAI,CAAC,WAAW,mCAAI,OAAO,CAAC,WAAW,CAAC;YAC5D,MAAM,MAAM,GACV,MAAA,MAAA,IAAI,CAAC,IAAI,CAAC,GAAG,iBAAiB,CAAC,WAAW,cAAc,CAAC,0CAAE,QAAQ,EAAE,mCACrE,EAAE,CAAC;YACL,MAAM,MAAM,GACV,MAAA,MAAA,IAAI,CAAC,IAAI,CAAC,GAAG,iBAAiB,CAAC,WAAW,cAAc,CAAC,0CAAE,QAAQ,EAAE,mCACrE,EAAE,CAAC;YACL,MAAM,QAAQ,GACZ,MAAA,MAAA,IAAI;iBACD,IAAI,CAAC,GAAG,iBAAiB,CAAC,WAAW,sBAAsB,CAAC,0CAC3D,QAAQ,EAAE,mCAAI,EAAE,CAAC;YACvB,MAAM,aAAa,GACjB,MAAA,MAAA,IAAI;iBACD,IAAI,CACH,GAAG,iBAAiB,CAAC,WAAW,kCAAkC,CACnE,0CACC,QAAQ,EAAE,mCAAI,EAAE,CAAC;YACvB,MAAM,IAAA,kCAA0B,EAC9B,MAAM,IAAA,oBAAY,EAChB,iBAAiB,CAAC,IAAI,CAAC,mBAAmB,EAC1C,WAAW,EACX;gBACE,WAAW,EAAE,iBAAiB,CAAC,WAAW;gBAC1C,WAAW,EAAE,SAAS;gBACtB,MAAM,EAAE,iBAAiB,CAAC,IAAI,CAAC,MAAM;gBACrC,aAAa,EAAE,+BAAc;gBAC7B,aAAa,EAAE;oBACb,aAAa,EAAE,MAAM;oBACrB,aAAa,EAAE,MAAM;oBACrB,qBAAqB,EAAE,QAAQ;oBAC/B,iCAAiC,EAAE,aAAa;iBACjD;aACF,EACD,IAAI,EACJ,iBAAiB,CAAC,WAAW,CAC9B,CACF,CAAC;QACJ,CAAC;QAED,MAAM,IAAA,2BAAmB,EAAC,IAAI,kCACzB,iBAAiB,KACpB,OAAO,EAAE,UAAU,EACnB,OAAO,EAAE,iBAAiB,CAAC,WAAW,IACtC,CAAC;QAEH,OAAO,GAAG,EAAE;YACV,IAAA,4BAAmB,EAAC,IAAI,CAAC,CAAC;QAC5B,CAAC,CAAC;IACJ,CAAC;CAAA"}
|
|
@@ -172,6 +172,7 @@ describe('Vue App Generator', () => {
|
|
|
172
172
|
'AppLayout',
|
|
173
173
|
'AppHeader',
|
|
174
174
|
'AppFooter',
|
|
175
|
+
'AppSideMenu',
|
|
175
176
|
'SessionExpiredBanner',
|
|
176
177
|
]) {
|
|
177
178
|
expect(host.exists(`${patterns}/${name}.vue`)).toBeTruthy();
|
|
@@ -185,6 +186,35 @@ describe('Vue App Generator', () => {
|
|
|
185
186
|
expect(app).toContain('<AppFooter />');
|
|
186
187
|
}, 30000);
|
|
187
188
|
|
|
189
|
+
it('--layout=internal generates an AppSideMenu shell instead of AppHeader/AppFooter', async () => {
|
|
190
|
+
await generator(host, { ...options, layout: 'internal' });
|
|
191
|
+
|
|
192
|
+
const app = host.read('apps/test/src/App.vue').toString();
|
|
193
|
+
expect(app).toContain(
|
|
194
|
+
"import { AppLayout, AppSideMenu, SessionExpiredBanner } from '@proj/vue-components';",
|
|
195
|
+
);
|
|
196
|
+
expect(app).not.toContain('AppHeader');
|
|
197
|
+
expect(app).not.toContain('AppFooter');
|
|
198
|
+
expect(app).not.toContain('goa-hero-banner');
|
|
199
|
+
expect(app).toContain('<AppSideMenu');
|
|
200
|
+
expect(app).toContain('heading="test"');
|
|
201
|
+
expect(app).toContain(':account-items="accountItems"');
|
|
202
|
+
expect(app).toContain('@item-click="onAccountItemClick"');
|
|
203
|
+
// The content gutter is shared regardless of shell choice.
|
|
204
|
+
expect(app).toContain('<AppLayout');
|
|
205
|
+
|
|
206
|
+
const agents = host.read('apps/test/AGENTS.md').toString();
|
|
207
|
+
expect(agents).toContain('--layout=internal');
|
|
208
|
+
expect(agents).toContain('AppSideMenu');
|
|
209
|
+
}, 30000);
|
|
210
|
+
|
|
211
|
+
it('defaults --layout to header when omitted', async () => {
|
|
212
|
+
await generator(host, options);
|
|
213
|
+
const app = host.read('apps/test/src/App.vue').toString();
|
|
214
|
+
expect(app).toContain('AppHeader');
|
|
215
|
+
expect(app).not.toContain('AppSideMenu');
|
|
216
|
+
}, 30000);
|
|
217
|
+
|
|
188
218
|
it('wires SessionExpiredBanner to a session store fed by onAuthRefreshError (not onAuthLogout)', async () => {
|
|
189
219
|
await generator(host, options);
|
|
190
220
|
|
|
@@ -7,7 +7,7 @@ app in this workspace imports both instead of carrying its own copy. Generated b
|
|
|
7
7
|
| Folder | Contains | Lifespan |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `src/lib/primitives/` | Thin `v-model`/idiomatic-event wrappers over individual `goa-*` elements (`GoabInput`, `GoabButton`, …) | **Interim** — see below |
|
|
10
|
-
| `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `SessionExpiredBanner`) | **Permanent** |
|
|
10
|
+
| `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`) | **Permanent** |
|
|
11
11
|
|
|
12
12
|
> **⚠️ `primitives/` is interim — do not invest in it as permanent.** It exists
|
|
13
13
|
> only because GoA DS has not yet published an official Vue wrapper package. When
|
|
@@ -142,7 +142,7 @@ detail); just leave it to fall through from the caller.
|
|
|
142
142
|
|
|
143
143
|
A pattern component is app-shell composition — layout, header/footer chrome,
|
|
144
144
|
banners — not a single-element wrapper. Existing examples: `AppLayout`,
|
|
145
|
-
`AppHeader`, `AppFooter`, `SessionExpiredBanner`.
|
|
145
|
+
`AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`.
|
|
146
146
|
|
|
147
147
|
- It's fine to compose `primitives/` wrappers inside a pattern component (e.g.
|
|
148
148
|
`SessionExpiredBanner` uses `GoabButton`) — import them with a relative path
|
|
@@ -20,4 +20,5 @@ export { default as GoabModal } from './lib/primitives/GoabModal.vue';
|
|
|
20
20
|
export { default as AppLayout } from './lib/patterns/AppLayout.vue';
|
|
21
21
|
export { default as AppHeader } from './lib/patterns/AppHeader.vue';
|
|
22
22
|
export { default as AppFooter } from './lib/patterns/AppFooter.vue';
|
|
23
|
+
export { default as AppSideMenu } from './lib/patterns/AppSideMenu.vue';
|
|
23
24
|
export { default as SessionExpiredBanner } from './lib/patterns/SessionExpiredBanner.vue';
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The alternate top-level shell for staff-facing/internal apps (goa-work-side-menu),
|
|
3
|
+
// used instead of AppHeader/AppFooter -- not together with them. Presentational only:
|
|
4
|
+
// unlike the real GovAlta-Pronghorn source this is adapted from, active-route
|
|
5
|
+
// highlighting and navigation are the caller's job (pass `current` on each item,
|
|
6
|
+
// listen for `item-click`) so this component doesn't need vue-router as a dependency.
|
|
7
|
+
interface AppSideMenuItem {
|
|
8
|
+
label: string;
|
|
9
|
+
to?: string;
|
|
10
|
+
icon?: string;
|
|
11
|
+
badge?: string;
|
|
12
|
+
current?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
withDefaults(
|
|
16
|
+
defineProps<{
|
|
17
|
+
heading?: string;
|
|
18
|
+
userName?: string;
|
|
19
|
+
primaryItems?: AppSideMenuItem[];
|
|
20
|
+
secondaryItems?: AppSideMenuItem[];
|
|
21
|
+
accountItems?: AppSideMenuItem[];
|
|
22
|
+
}>(),
|
|
23
|
+
{
|
|
24
|
+
primaryItems: () => [],
|
|
25
|
+
secondaryItems: () => [],
|
|
26
|
+
accountItems: () => [],
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const emit = defineEmits<{ itemClick: [item: AppSideMenuItem] }>();
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<template>
|
|
34
|
+
<div class="app-side-menu">
|
|
35
|
+
<a class="skip-link" href="#main-content">Skip to main content</a>
|
|
36
|
+
<goa-work-side-menu :heading="heading" :user-name="userName">
|
|
37
|
+
<goa-work-side-menu-item
|
|
38
|
+
v-for="item in primaryItems"
|
|
39
|
+
:key="item.label"
|
|
40
|
+
slot="primary"
|
|
41
|
+
:icon="item.icon"
|
|
42
|
+
:label="item.label"
|
|
43
|
+
:url="item.to"
|
|
44
|
+
:current="item.current"
|
|
45
|
+
@click.prevent="emit('itemClick', item)"
|
|
46
|
+
/>
|
|
47
|
+
<goa-work-side-menu-item
|
|
48
|
+
v-for="item in secondaryItems"
|
|
49
|
+
:key="item.label"
|
|
50
|
+
slot="secondary"
|
|
51
|
+
:icon="item.icon"
|
|
52
|
+
:label="item.label"
|
|
53
|
+
:badge="item.badge"
|
|
54
|
+
@click.prevent="emit('itemClick', item)"
|
|
55
|
+
/>
|
|
56
|
+
<goa-work-side-menu-item
|
|
57
|
+
v-for="item in accountItems"
|
|
58
|
+
:key="item.label"
|
|
59
|
+
slot="account"
|
|
60
|
+
:label="item.label"
|
|
61
|
+
:url="item.to"
|
|
62
|
+
@click.prevent="emit('itemClick', item)"
|
|
63
|
+
/>
|
|
64
|
+
</goa-work-side-menu>
|
|
65
|
+
<main id="main-content" class="app-side-menu-content">
|
|
66
|
+
<slot />
|
|
67
|
+
</main>
|
|
68
|
+
</div>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<style scoped>
|
|
72
|
+
.app-side-menu {
|
|
73
|
+
min-height: 100vh;
|
|
74
|
+
display: flex;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.app-side-menu-content {
|
|
78
|
+
flex: 1;
|
|
79
|
+
min-width: 0;
|
|
80
|
+
overflow-y: auto;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.skip-link {
|
|
84
|
+
position: absolute;
|
|
85
|
+
left: -9999px;
|
|
86
|
+
top: 0;
|
|
87
|
+
z-index: 100;
|
|
88
|
+
padding: var(--goa-space-s, 0.5rem) var(--goa-space-m, 1rem);
|
|
89
|
+
background: var(--goa-color-interactive-default, #0070c4);
|
|
90
|
+
color: #fff;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.skip-link:focus {
|
|
94
|
+
left: var(--goa-space-m, 1rem);
|
|
95
|
+
}
|
|
96
|
+
</style>
|