@abgov/nx-adsp 13.18.0-beta.6 → 13.18.0-beta.8
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/generators.json +5 -0
- package/package.json +1 -1
- package/src/generators/vue-app/files/AGENTS.md__tmpl__ +20 -3
- package/src/generators/vue-app/files/src/App.vue__tmpl__ +25 -3
- package/src/generators/vue-app/vue-app.spec.ts +14 -2
- package/src/generators/vue-components/files/AGENTS.md__tmpl__ +23 -5
- package/src/generators/vue-components/files/src/index.ts__tmpl__ +2 -0
- package/src/generators/vue-components/files/src/lib/patterns/AppLayout.vue__tmpl__ +9 -22
- package/src/generators/vue-components/files/src/lib/patterns/StepErrorSummary.vue__tmpl__ +26 -0
- package/src/generators/vue-components/files/src/lib/patterns/Stepper.vue__tmpl__ +35 -0
- package/src/generators/vue-components/files/src/vue-components.spec.ts__tmpl__ +2 -0
- package/src/generators/vue-components/vue-components.spec.ts +2 -0
- package/src/generators/vue-intake-view/files/shared/src/views/__confirmationViewFileName__.vue__tmpl__ +20 -0
- package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +121 -0
- package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +162 -0
- package/src/generators/vue-intake-view/schema.d.ts +33 -0
- package/src/generators/vue-intake-view/schema.json +46 -0
- package/src/generators/vue-intake-view/vue-intake-view.d.ts +3 -0
- package/src/generators/vue-intake-view/vue-intake-view.js +85 -0
- package/src/generators/vue-intake-view/vue-intake-view.js.map +1 -0
- package/src/generators/vue-intake-view/vue-intake-view.spec.ts +194 -0
package/generators.json
CHANGED
|
@@ -75,6 +75,11 @@
|
|
|
75
75
|
"schema": "./src/generators/vue-admin-crud/schema.json",
|
|
76
76
|
"description": "Generator that adds a simple admin CRUD screen pair (WorkspaceTable list + create/update Edit view) to an existing vue-app project."
|
|
77
77
|
},
|
|
78
|
+
"vue-intake-view": {
|
|
79
|
+
"factory": "./src/generators/vue-intake-view/vue-intake-view",
|
|
80
|
+
"schema": "./src/generators/vue-intake-view/schema.json",
|
|
81
|
+
"description": "Generator that adds a route-per-step intake wizard (Stepper + a required review/confirmation flow, from a --steps spec) to an existing vue-app project."
|
|
82
|
+
},
|
|
78
83
|
"mean": {
|
|
79
84
|
"factory": "./src/generators/mean/mean",
|
|
80
85
|
"schema": "./src/generators/mean/schema.json",
|
package/package.json
CHANGED
|
@@ -172,8 +172,8 @@ from **`<%= goaImportPath %>`**, not hand-rolled markup:
|
|
|
172
172
|
|
|
173
173
|
| Component | Purpose |
|
|
174
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)
|
|
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 |
|
|
176
|
+
| `AppLayout` | The content gutter every view renders inside (see the key files table) |
|
|
177
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
178
|
|
|
179
179
|
There's no `AppHeader`/`AppFooter`/hero banner in this layout — `AppSideMenu` is the
|
|
@@ -186,7 +186,7 @@ marketing chrome around a staff tool).
|
|
|
186
186
|
| Component | Purpose |
|
|
187
187
|
|---|---|
|
|
188
188
|
| `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`) |
|
|
189
|
-
| `AppLayout` | The content gutter every view renders inside (see the key files table).
|
|
189
|
+
| `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 |
|
|
190
190
|
| `AppFooter` | `goa-app-footer` with the standard GoA nav/meta links (Services/Contact/Terms of Use, Privacy/Disclaimer/Accessibility) |
|
|
191
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 |
|
|
192
192
|
<% } %>
|
|
@@ -201,6 +201,23 @@ composite generator.
|
|
|
201
201
|
|
|
202
202
|
## Adding a view
|
|
203
203
|
|
|
204
|
+
**Check whether a generator already covers this view before hand-authoring one.** Each
|
|
205
|
+
retrofits into this project directly — file(s), route(s), and the shared component it's
|
|
206
|
+
built on, all in one step:
|
|
207
|
+
|
|
208
|
+
| If the view is... | Use | Built on |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| A single record's detail page (loading/error, optional status badge, back button) | `nx g @abgov/nx-adsp:vue-detail-view` | `RecordDetailShell` |
|
|
211
|
+
| A staff-facing, paginated/sortable/filterable list | `nx g @abgov/nx-adsp:vue-workspace-view` | `WorkspaceTable` |
|
|
212
|
+
| A simple admin list + create/update edit screen (no pagination) | `nx g @abgov/nx-adsp:vue-admin-crud` | `WorkspaceTable` |
|
|
213
|
+
| A multi-step submission flow (route-per-step, review, confirmation) | `nx g @abgov/nx-adsp:vue-intake-view` | `Stepper`, `StepErrorSummary` |
|
|
214
|
+
|
|
215
|
+
Run `nx g @abgov/nx-adsp:<generator> --help` for its full option list (each takes a
|
|
216
|
+
`--fields`/`--columns`/`--steps` JSON-string spec, not a CLI array flag — see the
|
|
217
|
+
generator's own schema description for why). Only hand-author a view when none of these
|
|
218
|
+
match, or once a generator's output needs customization it doesn't cover — its own comments
|
|
219
|
+
mark what's meant to be edited.
|
|
220
|
+
|
|
204
221
|
1. Create `src/views/MyFeatureView.vue` as a `<script setup>` SFC. **Don't add your
|
|
205
222
|
own page margins/centering** — every view renders inside `AppLayout`'s gutter, so
|
|
206
223
|
any top-level tag (`<div>`, `<section>`, …) is already centered and padded.
|
|
@@ -64,6 +64,7 @@ function onAccountItemClick() {
|
|
|
64
64
|
</AppLayout>
|
|
65
65
|
</AppSideMenu>
|
|
66
66
|
<% } else { %>
|
|
67
|
+
<a class="skip-link" href="#main-content">Skip to main content</a>
|
|
67
68
|
<AppHeader heading="<%= projectName %>">
|
|
68
69
|
<template #utilities>
|
|
69
70
|
<goa-button-group alignment="end">
|
|
@@ -86,13 +87,34 @@ function onAccountItemClick() {
|
|
|
86
87
|
@dismiss="session.dismiss"
|
|
87
88
|
/>
|
|
88
89
|
<goa-hero-banner heading="<%= projectName %>" backgroundurl="/assets/banner.jpg" />
|
|
89
|
-
<
|
|
90
|
-
<
|
|
91
|
-
|
|
90
|
+
<main id="main-content">
|
|
91
|
+
<AppLayout :variant="contentWidth">
|
|
92
|
+
<RouterView />
|
|
93
|
+
</AppLayout>
|
|
94
|
+
</main>
|
|
92
95
|
<AppFooter />
|
|
93
96
|
<% } %>
|
|
94
97
|
</template>
|
|
95
98
|
|
|
96
99
|
<style>
|
|
97
100
|
body { margin: 0; }
|
|
101
|
+
<% if (layout !== 'internal') { %>
|
|
102
|
+
/* Visually hidden until focused (e.g. via Tab from the top of the page) — lets
|
|
103
|
+
keyboard/screen-reader users jump past the header straight to the content.
|
|
104
|
+
The internal/side-menu shell has its own scoped copy of this; this layout
|
|
105
|
+
has no equivalent shell component, so App.vue owns it directly here. */
|
|
106
|
+
.skip-link {
|
|
107
|
+
position: absolute;
|
|
108
|
+
left: -9999px;
|
|
109
|
+
top: 0;
|
|
110
|
+
z-index: 100;
|
|
111
|
+
padding: var(--goa-space-s, 0.5rem) var(--goa-space-m, 1rem);
|
|
112
|
+
background: var(--goa-color-interactive-default, #0070c4);
|
|
113
|
+
color: #fff;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.skip-link:focus {
|
|
117
|
+
left: var(--goa-space-m, 1rem);
|
|
118
|
+
}
|
|
119
|
+
<% } %>
|
|
98
120
|
</style>
|
|
@@ -124,14 +124,21 @@ describe('Vue App Generator', () => {
|
|
|
124
124
|
expect(layout).toContain('form-content');
|
|
125
125
|
expect(layout).toContain('wide-content');
|
|
126
126
|
expect(layout).toContain('--goa-space');
|
|
127
|
-
//
|
|
128
|
-
|
|
127
|
+
// AppLayout must NOT own the skip-to-main-content landmark itself: it nests
|
|
128
|
+
// inside AppSideMenu for --layout=internal, which already provides one, and
|
|
129
|
+
// a second <main id="main-content"> would duplicate the landmark/id.
|
|
130
|
+
expect(layout).not.toContain('id="main-content"');
|
|
131
|
+
expect(layout).not.toContain('skip-link');
|
|
129
132
|
|
|
130
133
|
// App.vue uses AppLayout and no longer relies on the `main > section` gutter
|
|
131
134
|
// (which silently failed when a view's top-level tag wasn't <section>).
|
|
135
|
+
// For --layout=header (the default), App.vue itself owns the skip-link
|
|
136
|
+
// landmark instead, since there's no shell component to hold it.
|
|
132
137
|
const app = host.read('apps/test/src/App.vue').toString();
|
|
133
138
|
expect(app).toContain('AppLayout');
|
|
134
139
|
expect(app).not.toContain('main > section');
|
|
140
|
+
expect(app).toContain('href="#main-content"');
|
|
141
|
+
expect(app).toContain('id="main-content"');
|
|
135
142
|
});
|
|
136
143
|
|
|
137
144
|
it('provisions the shared GoA wrapper library and points the app at it', async () => {
|
|
@@ -202,6 +209,11 @@ describe('Vue App Generator', () => {
|
|
|
202
209
|
expect(app).toContain('@item-click="onAccountItemClick"');
|
|
203
210
|
// The content gutter is shared regardless of shell choice.
|
|
204
211
|
expect(app).toContain('<AppLayout');
|
|
212
|
+
// App.vue itself must not add a second skip-to-main-content landmark —
|
|
213
|
+
// AppSideMenu (imported, not inlined) already owns the one and only
|
|
214
|
+
// #main-content for this layout.
|
|
215
|
+
expect(app).not.toContain('id="main-content"');
|
|
216
|
+
expect(app).not.toContain('skip-link');
|
|
205
217
|
|
|
206
218
|
const agents = host.read('apps/test/AGENTS.md').toString();
|
|
207
219
|
expect(agents).toContain('--layout=internal');
|
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
# AGENTS.md — vue-components (GoA Design System Vue wrappers + app shell)
|
|
2
2
|
|
|
3
|
-
A shared, workspace-local library
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
A shared, workspace-local library — every Vue app in this workspace imports from
|
|
4
|
+
it instead of carrying its own copy. Generated by `@abgov/nx-adsp:vue-components`,
|
|
5
|
+
invoked automatically by `vue-app` and by every `vue-*-view` generator.
|
|
6
6
|
|
|
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`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`) | **Permanent** |
|
|
10
|
+
| `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`, `Stepper`, `StepErrorSummary`) | **Permanent** |
|
|
11
|
+
|
|
12
|
+
**Not every pattern component is present in every app.** `AppLayout`, `AppHeader`,
|
|
13
|
+
`AppFooter`, `AppSideMenu`, and `SessionExpiredBanner` are base app-shell — `vue-app`
|
|
14
|
+
provisions and wires all five into `App.vue` for every generated app, unconditionally.
|
|
15
|
+
The rest are on-demand: they only exist in a given app's `src/lib/patterns/` if that
|
|
16
|
+
app has run the matching view generator at least once.
|
|
17
|
+
|
|
18
|
+
| Component | Provisioned by |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `RecordDetailShell` | `vue-detail-view` |
|
|
21
|
+
| `WorkspaceTable` | `vue-workspace-view`, `vue-admin-crud` |
|
|
22
|
+
| `Stepper`, `StepErrorSummary` | `vue-intake-view` |
|
|
23
|
+
|
|
24
|
+
Don't assume one of these on-demand components exists in a given app — check whether
|
|
25
|
+
its generator has actually been run (e.g. `nx list @abgov/nx-adsp` won't tell you;
|
|
26
|
+
look for the component file itself, or the view file that would import it) before
|
|
27
|
+
building on it.
|
|
11
28
|
|
|
12
29
|
> **⚠️ `primitives/` is interim — do not invest in it as permanent.** It exists
|
|
13
30
|
> only because GoA DS has not yet published an official Vue wrapper package. When
|
|
@@ -142,7 +159,8 @@ detail); just leave it to fall through from the caller.
|
|
|
142
159
|
|
|
143
160
|
A pattern component is app-shell composition — layout, header/footer chrome,
|
|
144
161
|
banners — not a single-element wrapper. Existing examples: `AppLayout`,
|
|
145
|
-
`AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable
|
|
162
|
+
`AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`,
|
|
163
|
+
`Stepper`, `StepErrorSummary`.
|
|
146
164
|
|
|
147
165
|
- It's fine to compose `primitives/` wrappers inside a pattern component (e.g.
|
|
148
166
|
`SessionExpiredBanner` uses `GoabButton`) — import them with a relative path
|
|
@@ -24,3 +24,5 @@ export { default as AppSideMenu } from './lib/patterns/AppSideMenu.vue';
|
|
|
24
24
|
export { default as SessionExpiredBanner } from './lib/patterns/SessionExpiredBanner.vue';
|
|
25
25
|
export { default as RecordDetailShell } from './lib/patterns/RecordDetailShell.vue';
|
|
26
26
|
export { default as WorkspaceTable } from './lib/patterns/WorkspaceTable.vue';
|
|
27
|
+
export { default as Stepper } from './lib/patterns/Stepper.vue';
|
|
28
|
+
export { default as StepErrorSummary } from './lib/patterns/StepErrorSummary.vue';
|
|
@@ -8,37 +8,24 @@
|
|
|
8
8
|
// meta: { layout: 'form' } // single-column forms → 640px
|
|
9
9
|
// (default) // general content → 1000px
|
|
10
10
|
// meta: { layout: 'wide' } // data-heavy pages / tables → 1200px
|
|
11
|
+
//
|
|
12
|
+
// Deliberately not a landmark/skip-target itself: this component nests inside
|
|
13
|
+
// AppSideMenu for --layout=internal, which already provides one, and a second
|
|
14
|
+
// copy would duplicate the anchor screen readers/keyboard nav jump to. The
|
|
15
|
+
// top-level shell (App.vue for --layout=header, AppSideMenu for --layout=internal)
|
|
16
|
+
// owns that landmark instead.
|
|
11
17
|
withDefaults(defineProps<{ variant?: 'page' | 'wide' | 'form' }>(), {
|
|
12
18
|
variant: 'page',
|
|
13
19
|
});
|
|
14
20
|
</script>
|
|
15
21
|
|
|
16
22
|
<template>
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
<slot />
|
|
21
|
-
</div>
|
|
22
|
-
</main>
|
|
23
|
+
<div :class="`${variant}-content`">
|
|
24
|
+
<slot />
|
|
25
|
+
</div>
|
|
23
26
|
</template>
|
|
24
27
|
|
|
25
28
|
<style scoped>
|
|
26
|
-
/* Visually hidden until focused (e.g. via Tab from the top of the page) — lets
|
|
27
|
-
keyboard/screen-reader users jump past the header straight to the content. */
|
|
28
|
-
.skip-link {
|
|
29
|
-
position: absolute;
|
|
30
|
-
left: -9999px;
|
|
31
|
-
top: 0;
|
|
32
|
-
z-index: 100;
|
|
33
|
-
padding: var(--goa-space-s, 0.5rem) var(--goa-space-m, 1rem);
|
|
34
|
-
background: var(--goa-color-interactive-default, #0070c4);
|
|
35
|
-
color: #fff;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
.skip-link:focus {
|
|
39
|
-
left: var(--goa-space-m, 1rem);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
29
|
.page-content,
|
|
43
30
|
.wide-content,
|
|
44
31
|
.form-content {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Hand-rolled, not a wrapper -- checked the real, currently-installed
|
|
3
|
+
// @abgov/web-components (grepped the compiled index.js for "error-summary"):
|
|
4
|
+
// no such element exists, only CSS tokens for error *styling* on other
|
|
5
|
+
// components (input, checkbox, radio, ...). Built from goa-callout instead,
|
|
6
|
+
// matching the standard error-summary a11y pattern (a list of links jumping to
|
|
7
|
+
// each invalid field) rather than inventing something with no real precedent.
|
|
8
|
+
export interface StepError {
|
|
9
|
+
message: string;
|
|
10
|
+
/** e.g. '#field-email' -- an id on the invalid field's goa-form-item. */
|
|
11
|
+
anchor?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
defineProps<{ errors: StepError[] }>();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<goa-callout v-if="errors.length" type="emergency" heading="There is a problem">
|
|
19
|
+
<ul>
|
|
20
|
+
<li v-for="(error, index) in errors" :key="index">
|
|
21
|
+
<a v-if="error.anchor" :href="error.anchor">{{ error.message }}</a>
|
|
22
|
+
<span v-else>{{ error.message }}</span>
|
|
23
|
+
</li>
|
|
24
|
+
</ul>
|
|
25
|
+
</goa-callout>
|
|
26
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Wraps the real, native goa-form-stepper/goa-form-step pair (confirmed via
|
|
3
|
+
// design.alberta.ca/components/form-stepper -- there wasn't one when the spec
|
|
4
|
+
// this is based on was written, hence "nobody used a native GoA stepper"; that
|
|
5
|
+
// gap has since closed, so this wraps the real thing rather than hand-rolling
|
|
6
|
+
// custom step icons/connectors like older reference implementations did).
|
|
7
|
+
//
|
|
8
|
+
// goa-form-step's own status enum is only complete/incomplete/not-started --
|
|
9
|
+
// there's no "current" value, so the caller decides what "current" looks like
|
|
10
|
+
// (typically: incomplete). Its docs also say "Don't use FormStepper for
|
|
11
|
+
// non-sequential navigation" -- this component just re-emits every step's
|
|
12
|
+
// click as `select`; which clicks are actually honoured (e.g. only completed
|
|
13
|
+
// steps, plus a review step) is the consuming view's job, not this one's.
|
|
14
|
+
export interface StepperStep {
|
|
15
|
+
key: string;
|
|
16
|
+
label: string;
|
|
17
|
+
status: 'complete' | 'incomplete' | 'not-started';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = defineProps<{ steps: StepperStep[] }>();
|
|
21
|
+
const emit = defineEmits<{ select: [key: string] }>();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<template>
|
|
25
|
+
<goa-form-stepper>
|
|
26
|
+
<goa-form-step
|
|
27
|
+
v-for="(step, index) in props.steps"
|
|
28
|
+
:key="step.key"
|
|
29
|
+
:text="step.label"
|
|
30
|
+
:status="step.status"
|
|
31
|
+
:last="index === props.steps.length - 1"
|
|
32
|
+
@_click="emit('select', step.key)"
|
|
33
|
+
/>
|
|
34
|
+
</goa-form-stepper>
|
|
35
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useRoute } from 'vue-router';
|
|
3
|
+
|
|
4
|
+
const route = useRoute();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div>
|
|
9
|
+
<goa-callout type="success" heading="Submitted">
|
|
10
|
+
<p>
|
|
11
|
+
Your reference number is <strong>{{ route.params.id }}</strong>. Keep this for your
|
|
12
|
+
records.
|
|
13
|
+
</p>
|
|
14
|
+
</goa-callout>
|
|
15
|
+
|
|
16
|
+
<goa-spacer vspacing="m" />
|
|
17
|
+
|
|
18
|
+
<p>You'll be notified of any updates. No further action is needed right now.</p>
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, onMounted } from 'vue';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import { GoabCheckbox } from '<%= goaImportPath %>';
|
|
5
|
+
|
|
6
|
+
const route = useRoute();
|
|
7
|
+
const router = useRouter();
|
|
8
|
+
|
|
9
|
+
const idParam = computed(() => String(route.params.id ?? ''));
|
|
10
|
+
|
|
11
|
+
// The fetched record's shape isn't known to this generator -- read fields
|
|
12
|
+
// defensively rather than declaring (and likely getting wrong) a fake interface.
|
|
13
|
+
const record = ref<Record<string, unknown> | null>(null);
|
|
14
|
+
const loading = ref(true);
|
|
15
|
+
const loadError = ref<string | null>(null);
|
|
16
|
+
const declared = ref(false);
|
|
17
|
+
const submitting = ref(false);
|
|
18
|
+
const submitError = ref<string | null>(null);
|
|
19
|
+
|
|
20
|
+
async function load() {
|
|
21
|
+
loading.value = true;
|
|
22
|
+
loadError.value = null;
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`/api/<%= resource %>/${idParam.value}`);
|
|
25
|
+
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
26
|
+
record.value = await res.json();
|
|
27
|
+
} catch (e) {
|
|
28
|
+
loadError.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
29
|
+
} finally {
|
|
30
|
+
loading.value = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onMounted(load);
|
|
35
|
+
|
|
36
|
+
function editStep(key: string) {
|
|
37
|
+
router.push(`<%= route %>/${idParam.value}/${key}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function onSubmit() {
|
|
41
|
+
if (!declared.value) return;
|
|
42
|
+
submitting.value = true;
|
|
43
|
+
submitError.value = null;
|
|
44
|
+
try {
|
|
45
|
+
const res = await fetch(`/api/<%= resource %>/${idParam.value}/submit`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) throw new Error(`Failed to submit (${res.status})`);
|
|
49
|
+
router.push(`<%= route %>/${idParam.value}/confirmation`);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
submitError.value = e instanceof Error ? e.message : 'Failed to submit.';
|
|
52
|
+
} finally {
|
|
53
|
+
submitting.value = false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<div>
|
|
60
|
+
<h1>Review your <%= baseName %></h1>
|
|
61
|
+
|
|
62
|
+
<div v-if="loading" aria-label="Loading">
|
|
63
|
+
<goa-skeleton type="text" size="3" />
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<goa-callout v-else-if="loadError" type="emergency" heading="Unable to load">
|
|
67
|
+
<p>{{ loadError }}</p>
|
|
68
|
+
</goa-callout>
|
|
69
|
+
|
|
70
|
+
<template v-else-if="record">
|
|
71
|
+
<% steps.forEach(function (step) { -%>
|
|
72
|
+
<goa-container accent="thin">
|
|
73
|
+
<div class="review-section-header">
|
|
74
|
+
<h2><%- step.label %></h2>
|
|
75
|
+
<goa-button type="tertiary" size="compact" @_click="editStep('<%- step.key %>')">
|
|
76
|
+
Edit
|
|
77
|
+
</goa-button>
|
|
78
|
+
</div>
|
|
79
|
+
<dl>
|
|
80
|
+
<% step.fields.forEach(function (field) { -%>
|
|
81
|
+
<dt><%- field.label %></dt>
|
|
82
|
+
<dd>{{ record['<%- field.key %>'] ?? '—' }}</dd>
|
|
83
|
+
<% }); -%>
|
|
84
|
+
</dl>
|
|
85
|
+
</goa-container>
|
|
86
|
+
<goa-spacer vspacing="m" />
|
|
87
|
+
<% }); -%>
|
|
88
|
+
|
|
89
|
+
<GoabCheckbox
|
|
90
|
+
v-model="declared"
|
|
91
|
+
name="declaration"
|
|
92
|
+
text="I confirm the information above is accurate and complete."
|
|
93
|
+
/>
|
|
94
|
+
|
|
95
|
+
<goa-callout v-if="submitError" type="emergency" heading="Submit failed">
|
|
96
|
+
<p>{{ submitError }}</p>
|
|
97
|
+
</goa-callout>
|
|
98
|
+
|
|
99
|
+
<goa-spacer vspacing="l" />
|
|
100
|
+
|
|
101
|
+
<goa-button-group gap="relaxed">
|
|
102
|
+
<goa-button
|
|
103
|
+
type="primary"
|
|
104
|
+
:disabled="!declared || submitting || undefined"
|
|
105
|
+
@_click="onSubmit"
|
|
106
|
+
>
|
|
107
|
+
Submit
|
|
108
|
+
</goa-button>
|
|
109
|
+
</goa-button-group>
|
|
110
|
+
</template>
|
|
111
|
+
</div>
|
|
112
|
+
</template>
|
|
113
|
+
|
|
114
|
+
<style scoped>
|
|
115
|
+
.review-section-header {
|
|
116
|
+
display: flex;
|
|
117
|
+
justify-content: space-between;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: var(--goa-space-m);
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { reactive, ref, computed, onMounted } from 'vue';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import { Stepper, StepErrorSummary, GoabInput } from '<%= goaImportPath %>';
|
|
5
|
+
|
|
6
|
+
const STEPS = [
|
|
7
|
+
<% stepperSteps.forEach(function (step) { -%>
|
|
8
|
+
{ key: '<%- step.key %>', label: '<%- step.label %>' },
|
|
9
|
+
<% }); -%>
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const route = useRoute();
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
|
|
15
|
+
const idParam = computed(() => String(route.params.id ?? ''));
|
|
16
|
+
const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
|
|
17
|
+
|
|
18
|
+
const form = reactive({
|
|
19
|
+
<% stepFields.forEach(function (field) { -%>
|
|
20
|
+
<%- field.key %>: '',
|
|
21
|
+
<% }); -%>
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const errors = ref<{ message: string; anchor?: string }[]>([]);
|
|
25
|
+
const completedSteps = ref<string[]>([]);
|
|
26
|
+
const loading = ref(!isNew.value);
|
|
27
|
+
const saving = ref(false);
|
|
28
|
+
const loadError = ref<string | null>(null);
|
|
29
|
+
const saveError = ref<string | null>(null);
|
|
30
|
+
|
|
31
|
+
// completedSteps drives the stepper's status per step. This assumes the API
|
|
32
|
+
// persists and returns a `completedSteps: string[]` field on the resource --
|
|
33
|
+
// document that contract if your backend doesn't have it yet.
|
|
34
|
+
const stepperSteps = computed(() =>
|
|
35
|
+
STEPS.map((step) => ({
|
|
36
|
+
key: step.key,
|
|
37
|
+
label: step.label,
|
|
38
|
+
status: completedSteps.value.includes(step.key)
|
|
39
|
+
? ('complete' as const)
|
|
40
|
+
: step.key === '<%- stepKey %>'
|
|
41
|
+
? ('incomplete' as const)
|
|
42
|
+
: ('not-started' as const),
|
|
43
|
+
})),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
async function load() {
|
|
47
|
+
if (isNew.value) return;
|
|
48
|
+
loading.value = true;
|
|
49
|
+
loadError.value = null;
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`/api/<%= resource %>/${idParam.value}`);
|
|
52
|
+
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
completedSteps.value = Array.isArray(data.completedSteps) ? data.completedSteps : [];
|
|
55
|
+
<% stepFields.forEach(function (field) { -%>
|
|
56
|
+
if (data['<%- field.key %>'] !== undefined) form.<%- field.key %> = data['<%- field.key %>'];
|
|
57
|
+
<% }); -%>
|
|
58
|
+
} catch (e) {
|
|
59
|
+
loadError.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
60
|
+
} finally {
|
|
61
|
+
loading.value = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onMounted(load);
|
|
66
|
+
|
|
67
|
+
function validate(): boolean {
|
|
68
|
+
const found: { message: string; anchor?: string }[] = [];
|
|
69
|
+
<% stepFields.forEach(function (field) { -%>
|
|
70
|
+
<% if (field.required !== false) { -%>
|
|
71
|
+
if (!form.<%- field.key %> || !form.<%- field.key %>.trim()) {
|
|
72
|
+
found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
|
|
73
|
+
}
|
|
74
|
+
<% } -%>
|
|
75
|
+
<% }); -%>
|
|
76
|
+
errors.value = found;
|
|
77
|
+
return found.length === 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// goa-form-stepper's own docs say not to use it for non-sequential navigation
|
|
81
|
+
// -- only already-complete steps and the review step are honoured here, not
|
|
82
|
+
// an arbitrary future step.
|
|
83
|
+
function onStepperSelect(key: string) {
|
|
84
|
+
if (key === '<%- stepKey %>') return;
|
|
85
|
+
if (completedSteps.value.includes(key) || key === 'review') {
|
|
86
|
+
router.push(`<%= route %>/${idParam.value}/${key}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function onSaveAndContinue() {
|
|
91
|
+
if (!validate()) return;
|
|
92
|
+
saving.value = true;
|
|
93
|
+
saveError.value = null;
|
|
94
|
+
try {
|
|
95
|
+
const body = {
|
|
96
|
+
...form,
|
|
97
|
+
completedSteps: [...new Set([...completedSteps.value, '<%- stepKey %>'])],
|
|
98
|
+
};
|
|
99
|
+
const res = await fetch(
|
|
100
|
+
isNew.value ? '/api/<%= resource %>' : `/api/<%= resource %>/${idParam.value}`,
|
|
101
|
+
{
|
|
102
|
+
method: isNew.value ? 'POST' : 'PUT',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify(body),
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
if (!res.ok) throw new Error(`Failed to save (${res.status})`);
|
|
108
|
+
const saved = await res.json();
|
|
109
|
+
const nextId = isNew.value ? saved.id : idParam.value;
|
|
110
|
+
router.push(`<%= route %>/${nextId}/<%- nextStepKey %>`);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
saveError.value = e instanceof Error ? e.message : 'Failed to save.';
|
|
113
|
+
} finally {
|
|
114
|
+
saving.value = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function goBack() {
|
|
119
|
+
router.back();
|
|
120
|
+
}
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<template>
|
|
124
|
+
<div>
|
|
125
|
+
<Stepper :steps="stepperSteps" @select="onStepperSelect" />
|
|
126
|
+
|
|
127
|
+
<goa-spacer vspacing="l" />
|
|
128
|
+
|
|
129
|
+
<h1><%- stepLabel %></h1>
|
|
130
|
+
|
|
131
|
+
<div v-if="loading" aria-label="Loading">
|
|
132
|
+
<goa-skeleton type="text" size="3" />
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<goa-callout v-else-if="loadError" type="emergency" heading="Unable to load">
|
|
136
|
+
<p>{{ loadError }}</p>
|
|
137
|
+
</goa-callout>
|
|
138
|
+
|
|
139
|
+
<template v-else>
|
|
140
|
+
<StepErrorSummary :errors="errors" />
|
|
141
|
+
|
|
142
|
+
<% stepFields.forEach(function (field) { -%>
|
|
143
|
+
<goa-form-item id="field-<%- field.key %>" label="<%- field.label %>"<% if (field.required !== false) { %> requirement="required"<% } %>>
|
|
144
|
+
<GoabInput v-model="form.<%- field.key %>" name="<%- field.key %>" type="text" />
|
|
145
|
+
</goa-form-item>
|
|
146
|
+
<% }); -%>
|
|
147
|
+
|
|
148
|
+
<goa-callout v-if="saveError" type="emergency" heading="Save failed">
|
|
149
|
+
<p>{{ saveError }}</p>
|
|
150
|
+
</goa-callout>
|
|
151
|
+
|
|
152
|
+
<goa-spacer vspacing="l" />
|
|
153
|
+
|
|
154
|
+
<goa-button-group gap="relaxed">
|
|
155
|
+
<goa-button type="primary" :disabled="saving || undefined" @_click="onSaveAndContinue">
|
|
156
|
+
Save and continue
|
|
157
|
+
</goa-button>
|
|
158
|
+
<goa-button type="secondary" @_click="goBack">Back</goa-button>
|
|
159
|
+
</goa-button-group>
|
|
160
|
+
</template>
|
|
161
|
+
</div>
|
|
162
|
+
</template>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface IntakeViewField {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
required?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface IntakeViewStep {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
fields: IntakeViewField[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Schema {
|
|
14
|
+
project: string;
|
|
15
|
+
name: string;
|
|
16
|
+
resource: string;
|
|
17
|
+
route: string;
|
|
18
|
+
/**
|
|
19
|
+
* JSON string on the real CLI (Nx's array-typed CLI coercion only supports
|
|
20
|
+
* comma-separated primitives, not JSON). A real array is also accepted for
|
|
21
|
+
* programmatic callers (e.g. tests).
|
|
22
|
+
*/
|
|
23
|
+
steps: string | IntakeViewStep[];
|
|
24
|
+
requiresAuth?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NormalizedSchema extends Omit<Schema, 'steps'> {
|
|
28
|
+
projectRoot: string;
|
|
29
|
+
steps: IntakeViewStep[];
|
|
30
|
+
requiresAuth: boolean;
|
|
31
|
+
/** PascalCase base name, e.g. "Application" for --name application. */
|
|
32
|
+
baseName: string;
|
|
33
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"id": "NxAdspVueIntakeView",
|
|
4
|
+
"title": "Vue Multi-Step Intake View",
|
|
5
|
+
"description": "Generates a route-per-step intake wizard (Stepper + StepErrorSummary, a required read-only review step, and a confirmation page) into an existing vue-app project. Cross-step state is server-persisted -- each step PUTs/POSTs to /api/<resource>/:id and refetches on mount; there's no client-side draft caching.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"project": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "The vue-app project to add the views to.",
|
|
11
|
+
"$default": {
|
|
12
|
+
"$source": "argv",
|
|
13
|
+
"index": 0
|
|
14
|
+
},
|
|
15
|
+
"x-prompt": "Which project should the intake view be added to?"
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "Base name for the generated views, e.g. 'application' generates <Step>StepView.vue per step plus ApplicationReviewView.vue and ApplicationConfirmationView.vue.",
|
|
20
|
+
"$default": {
|
|
21
|
+
"$source": "argv",
|
|
22
|
+
"index": 1
|
|
23
|
+
},
|
|
24
|
+
"x-prompt": "What should the intake flow be called?"
|
|
25
|
+
},
|
|
26
|
+
"resource": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "API resource path segment. Each step fetches/saves /api/<resource>/:id; the review step's Submit posts /api/<resource>/:id/submit."
|
|
29
|
+
},
|
|
30
|
+
"route": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Base route, e.g. /applications. Steps become /applications/:id/<step-key>, plus /applications/:id/review and /applications/:id/confirmation. Start a new intake at /applications/new/<first-step-key>."
|
|
33
|
+
},
|
|
34
|
+
"steps": {
|
|
35
|
+
"type": "string",
|
|
36
|
+
"description": "JSON array of steps, in order -- e.g. '[{\"key\":\"personal-info\",\"label\":\"Personal information\",\"fields\":[{\"key\":\"fullName\",\"label\":\"Full name\"}]}]'. Each item: { key, label, fields: [{ key, label, required?: boolean (default true) }] }. A plain array is also accepted when this generator is invoked programmatically. Every field is currently a plain text input -- see the generated AGENTS.md note for other field types."
|
|
37
|
+
},
|
|
38
|
+
"requiresAuth": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "Whether the generated routes require authentication.",
|
|
41
|
+
"default": true
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"required": ["project", "name", "resource", "route", "steps"],
|
|
45
|
+
"additionalProperties": false
|
|
46
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = default_1;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const devkit_1 = require("@nx/devkit");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const vue_router_1 = require("../../utils/vue-router");
|
|
8
|
+
const vue_components_1 = require("../vue-components/vue-components");
|
|
9
|
+
// Nx's own CLI option coercion (coerceTypesInOptions in nx/src/utils/params)
|
|
10
|
+
// only knows how to split an array-typed option on commas -- it has no JSON
|
|
11
|
+
// support, so a real `"type": "array"` schema for --steps silently mangles a
|
|
12
|
+
// JSON array into garbage fragments when invoked from the actual CLI (only
|
|
13
|
+
// programmatic callers, like this generator's own unit tests, ever pass a
|
|
14
|
+
// real array). --steps is `"type": "string"` in schema.json specifically so
|
|
15
|
+
// Nx leaves it alone, and this generator parses the JSON itself.
|
|
16
|
+
function parseSteps(steps) {
|
|
17
|
+
const parsed = typeof steps === 'string' ? JSON.parse(steps) : steps;
|
|
18
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
19
|
+
throw new Error('--steps must be a non-empty JSON array of { key, label, fields } objects.');
|
|
20
|
+
}
|
|
21
|
+
const keys = new Set();
|
|
22
|
+
for (const step of parsed) {
|
|
23
|
+
if (keys.has(step.key)) {
|
|
24
|
+
throw new Error(`--steps has a duplicate step key: "${step.key}".`);
|
|
25
|
+
}
|
|
26
|
+
keys.add(step.key);
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
function normalizeOptions(host, options) {
|
|
31
|
+
var _a;
|
|
32
|
+
const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
|
|
33
|
+
return Object.assign(Object.assign({}, options), { projectRoot, steps: parseSteps(options.steps), requiresAuth: (_a = options.requiresAuth) !== null && _a !== void 0 ? _a : true, baseName: (0, devkit_1.names)(options.name).className });
|
|
34
|
+
}
|
|
35
|
+
// PascalCase view name for a step, e.g. "personal-info" -> "PersonalInfoStepView".
|
|
36
|
+
function stepViewFileName(stepKey) {
|
|
37
|
+
return `${(0, devkit_1.names)(stepKey).className}StepView`;
|
|
38
|
+
}
|
|
39
|
+
function default_1(host, options) {
|
|
40
|
+
return tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
41
|
+
const normalizedOptions = normalizeOptions(host, options);
|
|
42
|
+
const { projectRoot, steps, baseName } = normalizedOptions;
|
|
43
|
+
// Idempotent -- ensures Stepper/StepErrorSummary exist even in a project
|
|
44
|
+
// scaffolded before vue-components carried them.
|
|
45
|
+
yield (0, vue_components_1.default)(host);
|
|
46
|
+
const goaImportPath = (0, vue_components_1.vueComponentsImportPath)(host);
|
|
47
|
+
const reviewViewFileName = `${baseName}ReviewView`;
|
|
48
|
+
const confirmationViewFileName = `${baseName}ConfirmationView`;
|
|
49
|
+
const stepperSteps = steps.map((step) => ({
|
|
50
|
+
key: step.key,
|
|
51
|
+
label: step.label,
|
|
52
|
+
}));
|
|
53
|
+
steps.forEach((step, index) => {
|
|
54
|
+
const nextStepKey = index === steps.length - 1 ? 'review' : steps[index + 1].key;
|
|
55
|
+
(0, devkit_1.generateFiles)(host, path.join(__dirname, 'files/steps'), projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath, stepViewFileName: stepViewFileName(step.key), stepKey: step.key, stepLabel: step.label, stepFields: step.fields, stepperSteps,
|
|
56
|
+
nextStepKey, tmpl: '' }));
|
|
57
|
+
});
|
|
58
|
+
(0, devkit_1.generateFiles)(host, path.join(__dirname, 'files/shared'), projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath,
|
|
59
|
+
reviewViewFileName,
|
|
60
|
+
confirmationViewFileName, tmpl: '' }));
|
|
61
|
+
// Registered in reverse so the final router file lists steps in forward
|
|
62
|
+
// order (each insertVueRoute call adds right after `routes: [`, pushing
|
|
63
|
+
// earlier insertions down).
|
|
64
|
+
(0, vue_router_1.insertVueRoute)(host, projectRoot, options.project, {
|
|
65
|
+
path: `${normalizedOptions.route}/:id/confirmation`,
|
|
66
|
+
componentImportPath: `../views/${confirmationViewFileName}.vue`,
|
|
67
|
+
requiresAuth: normalizedOptions.requiresAuth,
|
|
68
|
+
});
|
|
69
|
+
(0, vue_router_1.insertVueRoute)(host, projectRoot, options.project, {
|
|
70
|
+
path: `${normalizedOptions.route}/:id/review`,
|
|
71
|
+
componentImportPath: `../views/${reviewViewFileName}.vue`,
|
|
72
|
+
requiresAuth: normalizedOptions.requiresAuth,
|
|
73
|
+
});
|
|
74
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
75
|
+
const step = steps[i];
|
|
76
|
+
(0, vue_router_1.insertVueRoute)(host, projectRoot, options.project, {
|
|
77
|
+
path: `${normalizedOptions.route}/:id/${step.key}`,
|
|
78
|
+
componentImportPath: `../views/${stepViewFileName(step.key)}.vue`,
|
|
79
|
+
requiresAuth: normalizedOptions.requiresAuth,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
yield (0, devkit_1.formatFiles)(host);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
//# sourceMappingURL=vue-intake-view.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vue-intake-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-intake-view/vue-intake-view.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addProjectConfiguration,
|
|
3
|
+
readProjectConfiguration,
|
|
4
|
+
Tree,
|
|
5
|
+
} from '@nx/devkit';
|
|
6
|
+
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
|
7
|
+
import generator from './vue-intake-view';
|
|
8
|
+
import { Schema } from './schema';
|
|
9
|
+
|
|
10
|
+
// Mirrors the shape vue-app's own template generates -- vue-intake-view retrofits
|
|
11
|
+
// into this file, so the fixture must match what it actually looks for.
|
|
12
|
+
const ROUTER_FIXTURE = `import { createRouter, createWebHistory } from 'vue-router';
|
|
13
|
+
import HomeView from '../views/HomeView.vue';
|
|
14
|
+
|
|
15
|
+
const router = createRouter({
|
|
16
|
+
history: createWebHistory(import.meta.env.BASE_URL),
|
|
17
|
+
routes: [
|
|
18
|
+
{ path: '/', component: HomeView },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default router;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
describe('Vue Intake View Generator', () => {
|
|
26
|
+
let host: Tree;
|
|
27
|
+
const baseOptions: Schema = {
|
|
28
|
+
project: 'test',
|
|
29
|
+
name: 'application',
|
|
30
|
+
resource: 'applications',
|
|
31
|
+
route: '/applications',
|
|
32
|
+
steps: [
|
|
33
|
+
{
|
|
34
|
+
key: 'personal-info',
|
|
35
|
+
label: 'Personal information',
|
|
36
|
+
fields: [{ key: 'fullName', label: 'Full name' }],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: 'contact-info',
|
|
40
|
+
label: 'Contact information',
|
|
41
|
+
fields: [
|
|
42
|
+
{ key: 'email', label: 'Email' },
|
|
43
|
+
{ key: 'phone', label: 'Phone', required: false },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
|
51
|
+
addProjectConfiguration(host, 'test', { root: 'apps/test' });
|
|
52
|
+
host.write('apps/test/src/router/index.ts', ROUTER_FIXTURE);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws when --project does not exist', async () => {
|
|
56
|
+
await expect(
|
|
57
|
+
generator(host, { ...baseOptions, project: 'no-such-app' }),
|
|
58
|
+
).rejects.toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("throws a clear error when the project isn't a vue-app (no router/index.ts)", async () => {
|
|
62
|
+
addProjectConfiguration(host, 'not-vue', { root: 'apps/not-vue' });
|
|
63
|
+
await expect(
|
|
64
|
+
generator(host, { ...baseOptions, project: 'not-vue' }),
|
|
65
|
+
).rejects.toThrow(/router\/index\.ts/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('throws a clear error on a duplicate step key', async () => {
|
|
69
|
+
await expect(
|
|
70
|
+
generator(host, {
|
|
71
|
+
...baseOptions,
|
|
72
|
+
steps: [
|
|
73
|
+
{ key: 'a', label: 'A', fields: [] },
|
|
74
|
+
{ key: 'a', label: 'A again', fields: [] },
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
).rejects.toThrow(/duplicate step key/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('throws a clear error when --steps parses to an empty array', async () => {
|
|
81
|
+
await expect(
|
|
82
|
+
generator(host, { ...baseOptions, steps: '[]' }),
|
|
83
|
+
).rejects.toThrow(/non-empty/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('generates one view per step, using the shared Stepper/StepErrorSummary and the real goa-form-stepper status enum', async () => {
|
|
87
|
+
await generator(host, baseOptions);
|
|
88
|
+
|
|
89
|
+
const step1 = host
|
|
90
|
+
.read('apps/test/src/views/PersonalInfoStepView.vue')
|
|
91
|
+
.toString();
|
|
92
|
+
expect(step1).toContain(
|
|
93
|
+
"import { Stepper, StepErrorSummary, GoabInput } from '@proj/vue-components';",
|
|
94
|
+
);
|
|
95
|
+
expect(step1).toContain("{ key: 'personal-info', label: 'Personal information' }");
|
|
96
|
+
expect(step1).toContain("{ key: 'contact-info', label: 'Contact information' }");
|
|
97
|
+
// Own step is 'incomplete' when active and not yet completed, per the real
|
|
98
|
+
// goa-form-step status enum (complete/incomplete/not-started -- no "current").
|
|
99
|
+
expect(step1).toContain("step.key === 'personal-info'");
|
|
100
|
+
expect(step1).toContain("('incomplete' as const)");
|
|
101
|
+
expect(step1).toContain("('not-started' as const)");
|
|
102
|
+
// Moves to the next step's key on save.
|
|
103
|
+
expect(step1).toContain('/applications/${nextId}/contact-info');
|
|
104
|
+
|
|
105
|
+
const step2 = host
|
|
106
|
+
.read('apps/test/src/views/ContactInfoStepView.vue')
|
|
107
|
+
.toString();
|
|
108
|
+
// Last step in the list moves to review, not another step.
|
|
109
|
+
expect(step2).toContain('/applications/${nextId}/review');
|
|
110
|
+
// A required field gets a validation block + requirement="required"; an
|
|
111
|
+
// explicitly non-required one gets neither.
|
|
112
|
+
expect(step2).toContain("found.push({ message: 'Email is required.'");
|
|
113
|
+
expect(step2).toContain('requirement="required"');
|
|
114
|
+
expect(step2).not.toContain('Phone is required');
|
|
115
|
+
}, 30000);
|
|
116
|
+
|
|
117
|
+
it('generates a review view listing every step with an Edit link, and a declaration gate on Submit', async () => {
|
|
118
|
+
await generator(host, baseOptions);
|
|
119
|
+
|
|
120
|
+
const review = host.read('apps/test/src/views/ApplicationReviewView.vue').toString();
|
|
121
|
+
expect(review).toContain("editStep('personal-info')");
|
|
122
|
+
expect(review).toContain("editStep('contact-info')");
|
|
123
|
+
expect(review).toContain("record['fullName'] ?? '—'");
|
|
124
|
+
expect(review).toContain("record['email'] ?? '—'");
|
|
125
|
+
expect(review).toContain(':disabled="!declared || submitting || undefined"');
|
|
126
|
+
expect(review).toContain("fetch(`/api/applications/${idParam.value}/submit`");
|
|
127
|
+
expect(review).toContain('/applications/${idParam.value}/confirmation');
|
|
128
|
+
}, 30000);
|
|
129
|
+
|
|
130
|
+
it('generates a confirmation view showing the reference number', async () => {
|
|
131
|
+
await generator(host, baseOptions);
|
|
132
|
+
const confirmation = host
|
|
133
|
+
.read('apps/test/src/views/ApplicationConfirmationView.vue')
|
|
134
|
+
.toString();
|
|
135
|
+
expect(confirmation).toContain('{{ route.params.id }}');
|
|
136
|
+
}, 30000);
|
|
137
|
+
|
|
138
|
+
it('inserts a route for every step plus review and confirmation, requiring auth by default', async () => {
|
|
139
|
+
await generator(host, baseOptions);
|
|
140
|
+
|
|
141
|
+
const routerTs = host.read('apps/test/src/router/index.ts').toString();
|
|
142
|
+
expect(routerTs).toContain("path: '/applications/:id/personal-info'");
|
|
143
|
+
expect(routerTs).toContain("path: '/applications/:id/contact-info'");
|
|
144
|
+
expect(routerTs).toContain("path: '/applications/:id/review'");
|
|
145
|
+
expect(routerTs).toContain("path: '/applications/:id/confirmation'");
|
|
146
|
+
expect(routerTs).toContain(
|
|
147
|
+
"component: () => import('../views/PersonalInfoStepView.vue')",
|
|
148
|
+
);
|
|
149
|
+
expect(routerTs).toContain(
|
|
150
|
+
"component: () => import('../views/ApplicationReviewView.vue')",
|
|
151
|
+
);
|
|
152
|
+
expect(routerTs).toContain(
|
|
153
|
+
"component: () => import('../views/ApplicationConfirmationView.vue')",
|
|
154
|
+
);
|
|
155
|
+
expect(
|
|
156
|
+
routerTs.split('meta: { requiresAuth: true }').length - 1,
|
|
157
|
+
).toBe(4);
|
|
158
|
+
// The existing route is untouched, not replaced.
|
|
159
|
+
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
160
|
+
}, 30000);
|
|
161
|
+
|
|
162
|
+
it('omits the requiresAuth meta on every generated route when --requiresAuth=false', async () => {
|
|
163
|
+
await generator(host, { ...baseOptions, requiresAuth: false });
|
|
164
|
+
const routerTs = host.read('apps/test/src/router/index.ts').toString();
|
|
165
|
+
expect(routerTs).not.toContain('requiresAuth');
|
|
166
|
+
}, 30000);
|
|
167
|
+
|
|
168
|
+
it('accepts --steps as a JSON string, the form the real CLI produces', async () => {
|
|
169
|
+
await generator(host, {
|
|
170
|
+
...baseOptions,
|
|
171
|
+
steps: JSON.stringify(baseOptions.steps),
|
|
172
|
+
});
|
|
173
|
+
expect(
|
|
174
|
+
host.exists('apps/test/src/views/PersonalInfoStepView.vue'),
|
|
175
|
+
).toBeTruthy();
|
|
176
|
+
}, 30000);
|
|
177
|
+
|
|
178
|
+
it('ensures the shared Stepper and StepErrorSummary pattern components exist', async () => {
|
|
179
|
+
await generator(host, baseOptions);
|
|
180
|
+
expect(
|
|
181
|
+
host.exists('libs/vue-components/src/lib/patterns/Stepper.vue'),
|
|
182
|
+
).toBeTruthy();
|
|
183
|
+
expect(
|
|
184
|
+
host.exists('libs/vue-components/src/lib/patterns/StepErrorSummary.vue'),
|
|
185
|
+
).toBeTruthy();
|
|
186
|
+
}, 30000);
|
|
187
|
+
|
|
188
|
+
it('does not touch the target project configuration', async () => {
|
|
189
|
+
const before = readProjectConfiguration(host, 'test');
|
|
190
|
+
await generator(host, baseOptions);
|
|
191
|
+
const after = readProjectConfiguration(host, 'test');
|
|
192
|
+
expect(after).toEqual(before);
|
|
193
|
+
}, 30000);
|
|
194
|
+
});
|