@abgov/nx-adsp 13.18.0-beta.6 → 13.18.0-beta.7

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.18.0-beta.6",
3
+ "version": "13.18.0-beta.7",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -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`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`) | **Permanent** |
10
+ | `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`, `Stepper`, `StepErrorSummary`) | **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,8 @@ 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`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`.
145
+ `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`,
146
+ `Stepper`, `StepErrorSummary`.
146
147
 
147
148
  - It's fine to compose `primitives/` wrappers inside a pattern component (e.g.
148
149
  `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';
@@ -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>
@@ -28,6 +28,8 @@ describe('vue-components', () => {
28
28
  'SessionExpiredBanner',
29
29
  'RecordDetailShell',
30
30
  'WorkspaceTable',
31
+ 'Stepper',
32
+ 'StepErrorSummary',
31
33
  ]) {
32
34
  expect(lib[name as keyof typeof lib]).toBeTruthy();
33
35
  }
@@ -45,6 +45,8 @@ describe('Vue Components Generator', () => {
45
45
  'SessionExpiredBanner',
46
46
  'RecordDetailShell',
47
47
  'WorkspaceTable',
48
+ 'Stepper',
49
+ 'StepErrorSummary',
48
50
  ]) {
49
51
  expect(host.exists(`${patterns}/${name}.vue`)).toBeTruthy();
50
52
  }
@@ -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>
@@ -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,3 @@
1
+ import { Tree } from '@nx/devkit';
2
+ import { Schema } from './schema';
3
+ export default function (host: Tree, options: Schema): Promise<void>;
@@ -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
+ });