@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.
- package/package.json +1 -1
- package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +90 -4
- package/src/generators/vue-admin-crud/schema.d.ts +3 -1
- package/src/generators/vue-admin-crud/schema.json +8 -2
- package/src/generators/vue-admin-crud/vue-admin-crud.js +29 -1
- package/src/generators/vue-admin-crud/vue-admin-crud.js.map +1 -1
- package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +103 -1
- package/src/generators/vue-app/files/AGENTS.md__tmpl__ +20 -23
- package/src/generators/vue-app/files/src/App.vue__tmpl__ +9 -0
- package/src/generators/vue-app/vue-app.spec.ts +12 -0
- package/src/generators/vue-components/files/src/index.ts__tmpl__ +2 -0
- package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +23 -0
- package/src/generators/vue-components/files/src/lib/patterns/FilterBar.vue__tmpl__ +3 -13
- package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.spec.ts__tmpl__ +47 -0
- package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +42 -40
- package/src/generators/vue-components/vue-components.spec.ts +33 -2
- package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +7 -0
- package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +67 -3
- package/src/generators/vue-intake-view/schema.d.ts +3 -0
- package/src/generators/vue-intake-view/schema.json +8 -2
- package/src/generators/vue-intake-view/vue-intake-view.spec.ts +72 -0
- package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +5 -7
- package/src/generators/vue-workspace-view/vue-workspace-view.js +6 -0
- package/src/generators/vue-workspace-view/vue-workspace-view.js.map +1 -1
- package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +22 -0
- package/src/utils/vue-side-menu.d.ts +19 -0
- package/src/utils/vue-side-menu.js +41 -0
- package/src/utils/vue-side-menu.js.map +1 -0
- 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
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (sortBy
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
109
|
+
version="2"
|
|
110
|
+
:name="column.key"
|
|
111
|
+
:direction="column.key === sortBy ? sortDir : 'none'"
|
|
93
112
|
>
|
|
94
113
|
{{ column.label }}
|
|
95
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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()');
|
package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__
CHANGED
|
@@ -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>
|
package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__
CHANGED
|
@@ -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 {
|
|
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) }
|
|
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": [
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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":";;
|
|
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"}
|