@abgov/nx-adsp 13.28.1 → 13.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +90 -4
  3. package/src/generators/vue-admin-crud/schema.d.ts +3 -1
  4. package/src/generators/vue-admin-crud/schema.json +8 -2
  5. package/src/generators/vue-admin-crud/vue-admin-crud.js +29 -1
  6. package/src/generators/vue-admin-crud/vue-admin-crud.js.map +1 -1
  7. package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +103 -1
  8. package/src/generators/vue-app/files/AGENTS.md__tmpl__ +20 -23
  9. package/src/generators/vue-app/files/src/App.vue__tmpl__ +9 -0
  10. package/src/generators/vue-app/vue-app.spec.ts +12 -0
  11. package/src/generators/vue-components/files/src/index.ts__tmpl__ +2 -0
  12. package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +23 -0
  13. package/src/generators/vue-components/files/src/lib/patterns/FilterBar.vue__tmpl__ +3 -13
  14. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.spec.ts__tmpl__ +47 -0
  15. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +42 -40
  16. package/src/generators/vue-components/vue-components.spec.ts +33 -2
  17. package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +7 -0
  18. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +67 -3
  19. package/src/generators/vue-intake-view/schema.d.ts +3 -0
  20. package/src/generators/vue-intake-view/schema.json +8 -2
  21. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +72 -0
  22. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +5 -7
  23. package/src/generators/vue-workspace-view/vue-workspace-view.js +6 -0
  24. package/src/generators/vue-workspace-view/vue-workspace-view.js.map +1 -1
  25. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +22 -0
  26. package/src/utils/vue-side-menu.d.ts +19 -0
  27. package/src/utils/vue-side-menu.js +41 -0
  28. package/src/utils/vue-side-menu.js.map +1 -0
  29. package/src/utils/vue-side-menu.spec.ts +71 -0
@@ -5,9 +5,13 @@
5
5
  // consuming view can render badges, formatted values, or action buttons per
6
6
  // column without this component knowing anything about the domain.
7
7
  //
8
- // No native GoA sortable-header component exists (checked -- there isn't
9
- // one), so a sortable column header is hand-rolled here using the standard
10
- // WAI-ARIA "sortable table header" pattern (aria-sort + a real button), not
8
+ // Sortable headers use the native goa-table-sort-header inside a goa-table with
9
+ // sort-mode="single": the element owns the aria-sort, the direction cycling and
10
+ // the visual affordance. An earlier version of this comment asserted no native
11
+ // sortable-header component existed -- it had shipped since 2.0.0. Check the
12
+ // installed package's registered elements rather than trusting a note like that.
13
+ //
14
+ // The superseded hand-rolled version used the standard
11
15
  // invented from scratch.
12
16
  import { useSlots } from 'vue';
13
17
  import GoabButton from '../primitives/GoabButton.vue';
@@ -44,19 +48,23 @@ withDefaults(
44
48
  const emit = defineEmits<{
45
49
  retry: [];
46
50
  pageChange: [page: number];
47
- sort: [key: string];
51
+ /**
52
+ * goa-table owns the direction cycling, so both parts arrive together and the
53
+ * consuming view no longer toggles a direction itself.
54
+ */
55
+ sort: [sortBy: string, sortDir: 'asc' | 'desc'];
48
56
  }>();
49
57
 
50
58
  const slots = useSlots();
51
59
 
52
- function ariaSort(
53
- column: WorkspaceTableColumn,
54
- sortBy?: string,
55
- sortDir?: 'asc' | 'desc',
56
- ): 'ascending' | 'descending' | 'none' | undefined {
57
- if (!column.sortable) return undefined;
58
- if (sortBy !== column.key) return 'none';
59
- return sortDir === 'desc' ? 'descending' : 'ascending';
60
+ // goa-table reports direction numerically -- 1 ascending, -1 descending, 0
61
+ // unsorted -- while useApi's ListQuery and every generated view speak
62
+ // 'asc' | 'desc'. Translating here keeps that difference in one place.
63
+ function onNativeSort(event: Event) {
64
+ const detail = (event as CustomEvent<{ sortBy: string; sortDir: number }>)
65
+ .detail;
66
+ if (!detail?.sortBy) return;
67
+ emit('sort', detail.sortBy, detail.sortDir < 0 ? 'desc' : 'asc');
60
68
  }
61
69
  </script>
62
70
 
@@ -77,25 +85,33 @@ function ariaSort(
77
85
  </goa-callout>
78
86
 
79
87
  <template v-else>
80
- <goa-table width="100%">
88
+ <!-- sort-mode="single" makes goa-table aggregate its sort headers and
89
+ emit one _sort event; the headers own their own aria-sort and
90
+ direction cycling, which this component used to hand-roll. -->
91
+ <!-- The V2 flag is set on both the table and its sort headers: each
92
+ defaults to version 1, and this workspace is on the V2 design system
93
+ (the same reason goa-app-header and goa-pagination carry it). Without
94
+ it the table and its headers render V1 styling inside a V2 app.
95
+ sort-mode="single" is goa-table's default but stated explicitly: it
96
+ decides whether the table emits _sort or _multisort, so the listener
97
+ below only makes sense alongside it. -->
98
+ <goa-table
99
+ width="100%"
100
+ version="2"
101
+ sort-mode="single"
102
+ @_sort="onNativeSort"
103
+ >
81
104
  <thead>
82
105
  <tr>
83
- <th
84
- v-for="column in columns"
85
- :key="column.key"
86
- :aria-sort="ariaSort(column, sortBy, sortDir)"
87
- >
88
- <button
106
+ <th v-for="column in columns" :key="column.key">
107
+ <goa-table-sort-header
89
108
  v-if="column.sortable"
90
- type="button"
91
- class="sort-button"
92
- @click="emit('sort', column.key)"
109
+ version="2"
110
+ :name="column.key"
111
+ :direction="column.key === sortBy ? sortDir : 'none'"
93
112
  >
94
113
  {{ column.label }}
95
- <span aria-hidden="true">{{
96
- sortBy === column.key ? (sortDir === 'desc' ? '▼' : '▲') : ''
97
- }}</span>
98
- </button>
114
+ </goa-table-sort-header>
99
115
  <template v-else>{{ column.label }}</template>
100
116
  </th>
101
117
  <th v-if="slots.actions" />
@@ -133,17 +149,3 @@ function ariaSort(
133
149
  </div>
134
150
  </template>
135
151
 
136
- <style scoped>
137
- .sort-button {
138
- background: none;
139
- border: none;
140
- padding: 0;
141
- font: inherit;
142
- font-weight: inherit;
143
- color: inherit;
144
- cursor: pointer;
145
- display: inline-flex;
146
- align-items: center;
147
- gap: var(--goa-space-2xs, 0.25rem);
148
- }
149
- </style>
@@ -81,6 +81,32 @@ describe('Vue Components Generator', () => {
81
81
  const table = host
82
82
  .read('libs/vue-components/src/lib/patterns/WorkspaceTable.vue')
83
83
  .toString();
84
+ // Sorting is the native element's job: goa-table-sort-header inside a
85
+ // goa-table with sort-mode="single". The hand-rolled button + manual
86
+ // aria-sort is gone, and the comment that claimed no native component
87
+ // existed is corrected.
88
+ expect(table).toContain('<goa-table-sort-header');
89
+ expect(table).toContain('sort-mode="single"');
90
+ // goa-table and goa-table-sort-header both default to V1 styling; this
91
+ // workspace is on V2, the same reason goa-app-header and goa-pagination
92
+ // carry version="2".
93
+ // Asserted per element rather than by counting: a count is hostage to the
94
+ // literal appearing in a comment, which is how this assertion first broke.
95
+ const compactTable = table.replace(/\s+/g, ' ');
96
+ expect(compactTable).toContain(
97
+ '<goa-table width="100%" version="2" sort-mode="single"',
98
+ );
99
+ expect(compactTable).toContain(
100
+ '<goa-table-sort-header v-if="column.sortable" version="2"',
101
+ );
102
+ expect(table).toContain('@_sort="onNativeSort"');
103
+ // Binding forms, not bare words -- the component's own comment explains that
104
+ // the element owns aria-sort, so the word legitimately appears in it.
105
+ expect(table).not.toContain(':aria-sort=');
106
+ expect(table).not.toContain('class="sort-button"');
107
+ // goa-table reports 1/-1/0; the rest of the stack speaks asc/desc.
108
+ expect(table).toContain("detail.sortDir < 0 ? 'desc' : 'asc'");
109
+
84
110
  for (const prop of [':pagenumber=', ':itemcount=', ':perpagecount=']) {
85
111
  expect(table).toContain(prop);
86
112
  }
@@ -129,8 +155,13 @@ describe('Vue Components Generator', () => {
129
155
  }
130
156
  // Local calendar parts, not UTC: in Alberta new Date('2026-08-28') is
131
157
  // 27 Aug 18:00 local, so a stored date would render as the day before.
132
- expect(filterBar).toContain('function toIsoDate');
133
- expect(filterBar).toContain('function fromIsoDate');
158
+ // The local-parts date conversion lives in formatters now, used by
159
+ // FilterBar and by every generated date field, so the rule has one home.
160
+ expect(filterBar).toContain('toIsoDateString');
161
+ expect(filterBar).toContain('fromIsoDateString');
162
+ expect(filterBar).not.toContain('function toIsoDate');
163
+ expect(formattersSrc).toContain('export function toIsoDateString');
164
+ expect(formattersSrc).toContain('export function fromIsoDateString');
134
165
  // The call form, not the bare name: the component's own comments explain why
135
166
  // toISOString() is wrong, so the name legitimately appears in them.
136
167
  expect(filterBar).not.toContain('date.toISOString()');
@@ -2,6 +2,9 @@
2
2
  import { ref, computed, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { GoabCheckbox } from '<%= goaImportPath %>';
5
+ <% if (steps.some((s) => s.fields.some((f) => f.type === 'date'))) { -%>
6
+ import { formatDate } from '<%= goaImportPath %>';
7
+ <% } -%>
5
8
  import { useApi } from '../composables/useApi';
6
9
 
7
10
  const route = useRoute();
@@ -79,7 +82,11 @@ async function onSubmit() {
79
82
  <dl>
80
83
  <% step.fields.forEach(function (field) { -%>
81
84
  <dt><%- field.label %></dt>
85
+ <% if (field.type === 'date') { -%>
86
+ <dd>{{ formatDate(record['<%- field.key %>']) }}</dd>
87
+ <% } else { -%>
82
88
  <dd>{{ record['<%- field.key %>'] ?? '—' }}</dd>
89
+ <% } -%>
83
90
  <% }); -%>
84
91
  </dl>
85
92
  </goa-container>
@@ -1,7 +1,24 @@
1
1
  <script setup lang="ts">
2
2
  import { reactive, ref, computed, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
- import { Stepper, StepErrorSummary, GoabInput } from '<%= goaImportPath %>';
4
+ import {
5
+ Stepper,
6
+ StepErrorSummary,
7
+ <% if (stepFields.some((f) => !f.type || f.type === 'text' || f.type === 'number')) { -%>
8
+ GoabInput,
9
+ <% } -%>
10
+ <% if (stepFields.some((f) => f.type === 'textarea')) { -%>
11
+ GoabTextarea,
12
+ <% } -%>
13
+ <% if (stepFields.some((f) => f.type === 'date')) { -%>
14
+ GoabDatePicker,
15
+ fromIsoDateString,
16
+ toIsoDateString,
17
+ <% } -%>
18
+ <% if (stepFields.some((f) => f.type === 'select')) { -%>
19
+ GoabDropdown,
20
+ <% } -%>
21
+ } from '<%= goaImportPath %>';
5
22
  import { useApi } from '../composables/useApi';
6
23
 
7
24
  const STEPS = [
@@ -19,7 +36,7 @@ const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
19
36
 
20
37
  const form = reactive({
21
38
  <% stepFields.forEach(function (field) { -%>
22
- <%- field.key %>: '',
39
+ <%- field.key %>: <%- field.type === 'date' ? 'undefined as Date | undefined' : "''" %>,
23
40
  <% }); -%>
24
41
  });
25
42
 
@@ -53,7 +70,15 @@ async function load() {
53
70
  const data = await get('<%= resource %>', idParam.value);
54
71
  completedSteps.value = Array.isArray(data.completedSteps) ? data.completedSteps : [];
55
72
  <% stepFields.forEach(function (field) { -%>
73
+ <% if (field.type === 'date') { -%>
74
+ if (data['<%- field.key %>'] !== undefined)
75
+ form.<%- field.key %> = fromIsoDateString(data['<%- field.key %>']);
76
+ <% } else if (field.type === 'number') { -%>
77
+ if (data['<%- field.key %>'] !== undefined)
78
+ form.<%- field.key %> = String(data['<%- field.key %>']);
79
+ <% } else { -%>
56
80
  if (data['<%- field.key %>'] !== undefined) form.<%- field.key %> = data['<%- field.key %>'];
81
+ <% } -%>
57
82
  <% }); -%>
58
83
  } catch (e) {
59
84
  loadError.value = e instanceof Error ? e.message : 'Failed to load.';
@@ -67,7 +92,7 @@ async function load() {
67
92
  // rather than reassigned.
68
93
  function resetForm() {
69
94
  <% stepFields.forEach(function (field) { -%>
70
- form.<%- field.key %> = '';
95
+ form.<%- field.key %> = <%- field.type === 'date' ? 'undefined' : "''" %>;
71
96
  <% }); -%>
72
97
  errors.value = [];
73
98
  completedSteps.value = [];
@@ -94,10 +119,26 @@ function validate(): boolean {
94
119
  const found: { message: string; anchor?: string }[] = [];
95
120
  <% stepFields.forEach(function (field) { -%>
96
121
  <% if (field.required !== false) { -%>
122
+ <% if (field.type === 'date') { -%>
123
+ if (!form.<%- field.key %>) {
124
+ found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
125
+ }
126
+ <% } else if (field.type === 'number') { -%>
127
+ if (form.<%- field.key %> === '') {
128
+ found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
129
+ } else if (!Number.isFinite(Number(form.<%- field.key %>))) {
130
+ found.push({ message: '<%- field.label %> must be a number.', anchor: '#field-<%- field.key %>' });
131
+ }
132
+ <% } else { -%>
97
133
  if (!form.<%- field.key %> || !form.<%- field.key %>.trim()) {
98
134
  found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
99
135
  }
100
136
  <% } -%>
137
+ <% } else if (field.type === 'number') { -%>
138
+ if (form.<%- field.key %> !== '' && !Number.isFinite(Number(form.<%- field.key %>))) {
139
+ found.push({ message: '<%- field.label %> must be a number.', anchor: '#field-<%- field.key %>' });
140
+ }
141
+ <% } -%>
101
142
  <% }); -%>
102
143
  errors.value = found;
103
144
  return found.length === 0;
@@ -118,8 +159,17 @@ async function onSaveAndContinue() {
118
159
  saving.value = true;
119
160
  saveError.value = null;
120
161
  try {
162
+ // The form model holds what each control needs (a Date for a picker, a
163
+ // string for a number input); the API wants wire types.
121
164
  const body = {
122
165
  ...form,
166
+ <% stepFields.forEach(function (field) { -%>
167
+ <% if (field.type === 'number') { -%>
168
+ <%- field.key %>: form.<%- field.key %> === '' ? null : Number(form.<%- field.key %>),
169
+ <% } else if (field.type === 'date') { -%>
170
+ <%- field.key %>: toIsoDateString(form.<%- field.key %>) || null,
171
+ <% } -%>
172
+ <% }); -%>
123
173
  completedSteps: [...new Set([...completedSteps.value, '<%- stepKey %>'])],
124
174
  };
125
175
  // A create has to answer with the new record's id for the wizard to advance
@@ -164,7 +214,21 @@ function goBack() {
164
214
 
165
215
  <% stepFields.forEach(function (field) { -%>
166
216
  <goa-form-item id="field-<%- field.key %>" label="<%- field.label %>"<% if (field.required !== false) { %> requirement="required"<% } %>>
217
+ <% if (field.type === 'textarea') { -%>
218
+ <GoabTextarea v-model="form.<%- field.key %>" name="<%- field.key %>" />
219
+ <% } else if (field.type === 'number') { -%>
220
+ <GoabInput v-model="form.<%- field.key %>" name="<%- field.key %>" type="number" />
221
+ <% } else if (field.type === 'date') { -%>
222
+ <GoabDatePicker v-model="form.<%- field.key %>" name="<%- field.key %>" />
223
+ <% } else if (field.type === 'select') { -%>
224
+ <GoabDropdown v-model="form.<%- field.key %>" name="<%- field.key %>">
225
+ <% (field.options || []).forEach(function (option) { -%>
226
+ <goa-dropdown-item value="<%- option.value %>" label="<%- option.label %>" />
227
+ <% }); -%>
228
+ </GoabDropdown>
229
+ <% } else { -%>
167
230
  <GoabInput v-model="form.<%- field.key %>" name="<%- field.key %>" type="text" />
231
+ <% } -%>
168
232
  </goa-form-item>
169
233
  <% }); -%>
170
234
 
@@ -1,6 +1,9 @@
1
1
  export interface IntakeViewField {
2
2
  key: string;
3
3
  label: string;
4
+ type?: 'text' | 'textarea' | 'number' | 'date' | 'select';
5
+ /** `select` only. Rendered as goa-dropdown-item children. */
6
+ options?: { value: string; label: string }[];
4
7
  required?: boolean;
5
8
  }
6
9
 
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "steps": {
35
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."
36
+ "description": "JSON array of steps, in order -- e.g. '[{\"key\":\"personal-info\",\"label\":\"Personal information\",\"fields\":[{\"key\":\"fullName\",\"label\":\"Full name\"},{\"key\":\"born\",\"label\":\"Date of birth\",\"type\":\"date\"}]}]'. Each item: { key, label, fields: [...] }. Each field: { key, label, type?: \"text\"|\"textarea\"|\"number\"|\"date\"|\"select\" (default \"text\"), options?: [{value,label}] (select only, required for it), required?: boolean (default true) }. A number field is submitted as a number and a date as YYYY-MM-DD built from local calendar parts, not UTC. A plain array is also accepted when this generator is invoked programmatically. Nx's CLI option coercion only supports comma-separated primitive lists for array-typed schema properties, not JSON -- a JSON string is the only CLI syntax that survives Nx's own arg parsing."
37
37
  },
38
38
  "requiresAuth": {
39
39
  "type": "boolean",
@@ -41,6 +41,12 @@
41
41
  "default": true
42
42
  }
43
43
  },
44
- "required": ["project", "name", "resource", "route", "steps"],
44
+ "required": [
45
+ "project",
46
+ "name",
47
+ "resource",
48
+ "route",
49
+ "steps"
50
+ ],
45
51
  "additionalProperties": false
46
52
  }
@@ -138,6 +138,78 @@ describe('Vue Intake View Generator', () => {
138
138
  expect(review).toContain('/applications/${idParam.value}/confirmation');
139
139
  }, 30000);
140
140
 
141
+ describe('field types', () => {
142
+ const typedSteps = JSON.stringify([
143
+ {
144
+ key: 'details',
145
+ label: 'Details',
146
+ fields: [
147
+ { key: 'name', label: 'Name' },
148
+ { key: 'story', label: 'Story', type: 'textarea', required: false },
149
+ { key: 'count', label: 'Count', type: 'number' },
150
+ { key: 'occurred', label: 'Occurred', type: 'date' },
151
+ {
152
+ key: 'species',
153
+ label: 'Species',
154
+ type: 'select',
155
+ options: [{ value: 'wolf', label: 'Wolf' }],
156
+ },
157
+ ],
158
+ },
159
+ { key: 'review-it', label: 'Review it', fields: [] },
160
+ ]);
161
+
162
+ it('renders the right control for each type', async () => {
163
+ await generator(host, { ...baseOptions, steps: typedSteps });
164
+ const step = host
165
+ .read('apps/test/src/views/DetailsStepView.vue')
166
+ .toString();
167
+ expect(step).toContain('<GoabTextarea v-model="form.story"');
168
+ expect(step).toContain('type="number"');
169
+ expect(step).toContain('<GoabDatePicker v-model="form.occurred"');
170
+ expect(step).toContain('<goa-dropdown-item value="wolf" label="Wolf" />');
171
+ });
172
+
173
+ it('coerces number and date on save', async () => {
174
+ await generator(host, { ...baseOptions, steps: typedSteps });
175
+ const step = host
176
+ .read('apps/test/src/views/DetailsStepView.vue')
177
+ .toString();
178
+ expect(step).toContain("count: form.count === '' ? null : Number(form.count)");
179
+ expect(step).toContain('occurred: toIsoDateString(form.occurred) || null');
180
+ expect(step).toContain("fromIsoDateString(data['occurred'])");
181
+ });
182
+
183
+ it('validates a number and a date without assuming a string', async () => {
184
+ await generator(host, { ...baseOptions, steps: typedSteps });
185
+ const step = host
186
+ .read('apps/test/src/views/DetailsStepView.vue')
187
+ .toString();
188
+ expect(step).toContain("if (form.count === '')");
189
+ expect(step).toContain('Count must be a number.');
190
+ expect(step).toContain('if (!form.occurred)');
191
+ });
192
+
193
+ it('formats a date field on the review view rather than printing it raw', async () => {
194
+ await generator(host, { ...baseOptions, steps: typedSteps });
195
+ const review = host
196
+ .read('apps/test/src/views/ApplicationReviewView.vue')
197
+ .toString();
198
+ expect(review).toContain("formatDate(record['occurred'])");
199
+ });
200
+
201
+ it('every field is still a text input when no types are given', async () => {
202
+ await generator(host, baseOptions);
203
+ const step = host
204
+ .read('apps/test/src/views/PersonalInfoStepView.vue')
205
+ .toString();
206
+ expect(step).toContain('type="text"');
207
+ for (const unused of ['GoabTextarea', 'GoabDatePicker', 'GoabDropdown']) {
208
+ expect(step).not.toContain(unused);
209
+ }
210
+ });
211
+ });
212
+
141
213
  it('generates a confirmation view showing the reference number', async () => {
142
214
  await generator(host, baseOptions);
143
215
  const confirmation = host
@@ -111,13 +111,11 @@ function onPageChange(newPage: number) {
111
111
  void load();
112
112
  }
113
113
 
114
- function onSort(key: string) {
115
- if (sortBy.value === key) {
116
- sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
117
- } else {
118
- sortBy.value = key;
119
- sortDir.value = 'asc';
120
- }
114
+ // goa-table-sort-header owns the direction cycling and reports both parts, so
115
+ // this no longer toggles anything -- it just records what the table decided.
116
+ function onSort(key: string, dir: 'asc' | 'desc') {
117
+ sortBy.value = key;
118
+ sortDir.value = dir;
121
119
  page.value = 1;
122
120
  void load();
123
121
  }
@@ -5,6 +5,7 @@ const tslib_1 = require("tslib");
5
5
  const devkit_1 = require("@nx/devkit");
6
6
  const path = require("path");
7
7
  const vue_router_1 = require("../../utils/vue-router");
8
+ const vue_side_menu_1 = require("../../utils/vue-side-menu");
8
9
  const vue_components_1 = require("../vue-components/vue-components");
9
10
  // Nx's own CLI option coercion (coerceTypesInOptions in nx/src/utils/params)
10
11
  // only knows how to split an array-typed option on commas -- it has no JSON
@@ -62,6 +63,11 @@ function default_1(host, options) {
62
63
  // A multi-column table at the 1000px default wraps every cell.
63
64
  layout: 'wide',
64
65
  });
66
+ // No-op on a header-layout app, which has no side menu.
67
+ (0, vue_side_menu_1.insertSideMenuItem)(host, normalizedOptions.projectRoot, {
68
+ label: normalizedOptions.heading,
69
+ to: normalizedOptions.route,
70
+ });
65
71
  yield (0, devkit_1.formatFiles)(host);
66
72
  });
67
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"vue-workspace-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-workspace-view/vue-workspace-view.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"vue-workspace-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-workspace-view/vue-workspace-view.ts"],"names":[],"mappings":"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"}
@@ -9,6 +9,13 @@ import { Schema } from './schema';
9
9
 
10
10
  // Mirrors the shape vue-app's own template generates -- vue-workspace-view
11
11
  // retrofits into this file, so the fixture must match what it actually looks for.
12
+ const APP_VUE_INTERNAL_FIXTURE = `<script setup lang="ts">
13
+ const primaryItems = [
14
+ { label: 'Home', to: '/' },
15
+ ];
16
+ </script>
17
+ `;
18
+
12
19
  const ROUTER_FIXTURE = `import { createRouter, createWebHistory } from 'vue-router';
13
20
  import HomeView from '../views/HomeView.vue';
14
21
 
@@ -41,6 +48,7 @@ describe('Vue Workspace View Generator', () => {
41
48
  host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
42
49
  addProjectConfiguration(host, 'test', { root: 'apps/test' });
43
50
  host.write('apps/test/src/router/index.ts', ROUTER_FIXTURE);
51
+ host.write('apps/test/src/App.vue', APP_VUE_INTERNAL_FIXTURE);
44
52
  });
45
53
 
46
54
  it('throws when --project does not exist', async () => {
@@ -104,6 +112,9 @@ describe('Vue Workspace View Generator', () => {
104
112
  expect(view).toContain("await list('applications', {");
105
113
  expect(view).toContain('pageSize: PAGE_SIZE');
106
114
  expect(view).toContain('rows.value = result.rows');
115
+ // The element decides the direction, so the view records rather than toggles.
116
+ expect(view).toContain("function onSort(key: string, dir: 'asc' | 'desc')");
117
+ expect(view).not.toContain("sortDir.value === 'asc' ? 'desc' : 'asc'");
107
118
  expect(view).toContain('itemCount.value = result.total');
108
119
  expect(view).not.toContain('apiFetch');
109
120
  expect(view).not.toContain('URLSearchParams');
@@ -263,6 +274,13 @@ describe('Vue Workspace View Generator', () => {
263
274
  expect(view).toContain(':per-page-count="50"');
264
275
  }, 30000);
265
276
 
277
+ it('adds nothing to the side menu when the app has no App.vue', async () => {
278
+ host.delete('apps/test/src/App.vue');
279
+ // A header-layout app has no side menu; adding a staff view to one must not
280
+ // fail, it simply has no nav to join.
281
+ await expect(generator(host, baseOptions)).resolves.not.toThrow();
282
+ });
283
+
266
284
  it('inserts the route into router/index.ts, requiring auth by default', async () => {
267
285
  await generator(host, baseOptions);
268
286
 
@@ -274,6 +292,10 @@ describe('Vue Workspace View Generator', () => {
274
292
  expect(routerTs).toContain('requiresAuth: true');
275
293
  // A data table asks for the wide variant -- the generator knows it emitted one.
276
294
  expect(routerTs).toContain("layout: 'wide'");
295
+ // A staff queue is a nav destination, so the generator adds it to the
296
+ // app's side-menu list rather than leaving the route reachable only by URL.
297
+ const appVue = host.read('apps/test/src/App.vue').toString();
298
+ expect(appVue).toContain("to: '/applications'");
277
299
  expect(routerTs).toContain("{ path: '/', component: HomeView }");
278
300
  }, 30000);
279
301
 
@@ -0,0 +1,19 @@
1
+ import { Tree } from '@nx/devkit';
2
+ export interface SideMenuItemSpec {
3
+ /** Nav label shown in the side menu, e.g. 'Review queue'. */
4
+ label: string;
5
+ /** Route path the item navigates to, e.g. '/queue'. */
6
+ to: string;
7
+ }
8
+ /**
9
+ * Adds a nav item to a generated app's side menu.
10
+ *
11
+ * A no-op when the app has no `primaryItems` array. That is the signal the app
12
+ * was generated with `--layout=header` (a public app has no side menu at all),
13
+ * so adding a staff view to it should not fail or warn -- there is simply no nav
14
+ * to add to.
15
+ *
16
+ * Idempotent: re-running a generator, or two generators claiming the same path,
17
+ * leaves one entry.
18
+ */
19
+ export declare function insertSideMenuItem(host: Tree, projectRoot: string, item: SideMenuItemSpec): void;
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.insertSideMenuItem = insertSideMenuItem;
4
+ // Deterministic text insertion into vue-app's `primaryItems` array, mirroring
5
+ // insertVueRoute's approach for the same reason: the template's shape is fixed,
6
+ // so a targeted anchor beats pulling in an AST library for one array entry.
7
+ //
8
+ // The array is a plain literal in App.vue rather than something derived from the
9
+ // router, deliberately: a generated app is an opinionated starting point, and
10
+ // nav order, grouping and labels are decisions the owning team should be able to
11
+ // read and change in one obvious place -- not behaviour resolved at runtime by
12
+ // the shell that every app then has to work around.
13
+ const ANCHOR = 'const primaryItems = [';
14
+ /**
15
+ * Adds a nav item to a generated app's side menu.
16
+ *
17
+ * A no-op when the app has no `primaryItems` array. That is the signal the app
18
+ * was generated with `--layout=header` (a public app has no side menu at all),
19
+ * so adding a staff view to it should not fail or warn -- there is simply no nav
20
+ * to add to.
21
+ *
22
+ * Idempotent: re-running a generator, or two generators claiming the same path,
23
+ * leaves one entry.
24
+ */
25
+ function insertSideMenuItem(host, projectRoot, item) {
26
+ var _a;
27
+ const appPath = `${projectRoot}/src/App.vue`;
28
+ const content = (_a = host.read(appPath)) === null || _a === void 0 ? void 0 : _a.toString();
29
+ if (content === undefined)
30
+ return;
31
+ const anchorIndex = content.indexOf(ANCHOR);
32
+ if (anchorIndex === -1)
33
+ return;
34
+ if (content.includes(`to: '${item.to}'`))
35
+ return;
36
+ const insertAt = anchorIndex + ANCHOR.length;
37
+ host.write(appPath, content.slice(0, insertAt) +
38
+ `\n { label: '${item.label}', to: '${item.to}' },` +
39
+ content.slice(insertAt));
40
+ }
41
+ //# sourceMappingURL=vue-side-menu.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vue-side-menu.js","sourceRoot":"","sources":["../../../../../packages/nx-adsp/src/utils/vue-side-menu.ts"],"names":[],"mappings":";;AA+BA,gDAqBC;AA3CD,8EAA8E;AAC9E,gFAAgF;AAChF,4EAA4E;AAC5E,EAAE;AACF,iFAAiF;AACjF,8EAA8E;AAC9E,iFAAiF;AACjF,+EAA+E;AAC/E,oDAAoD;AACpD,MAAM,MAAM,GAAG,wBAAwB,CAAC;AAExC;;;;;;;;;;GAUG;AACH,SAAgB,kBAAkB,CAChC,IAAU,EACV,WAAmB,EACnB,IAAsB;;IAEtB,MAAM,OAAO,GAAG,GAAG,WAAW,cAAc,CAAC;IAC7C,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,0CAAE,QAAQ,EAAE,CAAC;IAC/C,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO;IAElC,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,IAAI,WAAW,KAAK,CAAC,CAAC;QAAE,OAAO;IAE/B,IAAI,OAAO,CAAC,QAAQ,CAAC,QAAQ,IAAI,CAAC,EAAE,GAAG,CAAC;QAAE,OAAO;IAEjD,MAAM,QAAQ,GAAG,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC;IAC7C,IAAI,CAAC,KAAK,CACR,OAAO,EACP,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC;QACxB,iBAAiB,IAAI,CAAC,KAAK,WAAW,IAAI,CAAC,EAAE,MAAM;QACnD,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAC1B,CAAC;AACJ,CAAC"}