@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
package/package.json
CHANGED
|
@@ -2,7 +2,27 @@
|
|
|
2
2
|
import { reactive, ref, computed, watch, onUnmounted } from 'vue';
|
|
3
3
|
import { useRoute, useRouter } from 'vue-router';
|
|
4
4
|
import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
<% if (fields.some((f) => !f.type || f.type === 'text' || f.type === 'number')) { -%>
|
|
7
|
+
GoabInput,
|
|
8
|
+
<% } -%>
|
|
9
|
+
<% if (fields.some((f) => f.type === 'textarea')) { -%>
|
|
10
|
+
GoabTextarea,
|
|
11
|
+
<% } -%>
|
|
12
|
+
<% if (fields.some((f) => f.type === 'date')) { -%>
|
|
13
|
+
GoabDatePicker,
|
|
14
|
+
<% } -%>
|
|
15
|
+
<% if (fields.some((f) => f.type === 'select')) { -%>
|
|
16
|
+
GoabDropdown,
|
|
17
|
+
<% } -%>
|
|
18
|
+
<% if (fields.some((f) => f.type === 'checkbox')) { -%>
|
|
19
|
+
GoabCheckbox,
|
|
20
|
+
<% } -%>
|
|
21
|
+
<% if (fields.some((f) => f.type === 'date')) { -%>
|
|
22
|
+
fromIsoDateString,
|
|
23
|
+
toIsoDateString,
|
|
24
|
+
<% } -%>
|
|
25
|
+
} from '<%= goaImportPath %>';
|
|
6
26
|
import { useApi } from '../composables/useApi';
|
|
7
27
|
|
|
8
28
|
const route = useRoute();
|
|
@@ -15,7 +35,7 @@ const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
|
|
|
15
35
|
|
|
16
36
|
const form = reactive({
|
|
17
37
|
<% fields.forEach(function (field) { -%>
|
|
18
|
-
<%- field.key %>: <%- field.type === 'checkbox' ? 'false' : "''" %>,
|
|
38
|
+
<%- field.key %>: <%- field.type === 'checkbox' ? 'false' : field.type === 'date' ? 'undefined as Date | undefined' : "''" %>,
|
|
19
39
|
<% }); -%>
|
|
20
40
|
});
|
|
21
41
|
|
|
@@ -37,7 +57,15 @@ async function load() {
|
|
|
37
57
|
try {
|
|
38
58
|
const data = await get('<%= resource %>', idParam.value);
|
|
39
59
|
<% fields.forEach(function (field) { -%>
|
|
60
|
+
<% if (field.type === 'date') { -%>
|
|
61
|
+
if (data['<%= field.key %>'] !== undefined)
|
|
62
|
+
form.<%= field.key %> = fromIsoDateString(data['<%= field.key %>']);
|
|
63
|
+
<% } else if (field.type === 'number') { -%>
|
|
64
|
+
if (data['<%= field.key %>'] !== undefined)
|
|
65
|
+
form.<%= field.key %> = String(data['<%= field.key %>']);
|
|
66
|
+
<% } else { -%>
|
|
40
67
|
if (data['<%= field.key %>'] !== undefined) form.<%= field.key %> = data['<%= field.key %>'];
|
|
68
|
+
<% } -%>
|
|
41
69
|
<% }); -%>
|
|
42
70
|
} catch (e) {
|
|
43
71
|
loadError.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
@@ -77,6 +105,24 @@ function validate(): boolean {
|
|
|
77
105
|
let valid = true;
|
|
78
106
|
<% fields.forEach(function (field) { -%>
|
|
79
107
|
<% if (field.type !== 'checkbox' && field.required !== false) { -%>
|
|
108
|
+
<% if (field.type === 'date') { -%>
|
|
109
|
+
if (!form.<%= field.key %>) {
|
|
110
|
+
errors.<%= field.key %> = '<%= field.label %> is required.';
|
|
111
|
+
valid = false;
|
|
112
|
+
} else {
|
|
113
|
+
errors.<%= field.key %> = undefined;
|
|
114
|
+
}
|
|
115
|
+
<% } else if (field.type === 'number') { -%>
|
|
116
|
+
if (form.<%= field.key %> === '') {
|
|
117
|
+
errors.<%= field.key %> = '<%= field.label %> is required.';
|
|
118
|
+
valid = false;
|
|
119
|
+
} else if (!Number.isFinite(Number(form.<%= field.key %>))) {
|
|
120
|
+
errors.<%= field.key %> = '<%= field.label %> must be a number.';
|
|
121
|
+
valid = false;
|
|
122
|
+
} else {
|
|
123
|
+
errors.<%= field.key %> = undefined;
|
|
124
|
+
}
|
|
125
|
+
<% } else { -%>
|
|
80
126
|
if (!form.<%= field.key %> || !form.<%= field.key %>.trim()) {
|
|
81
127
|
errors.<%= field.key %> = '<%= field.label %> is required.';
|
|
82
128
|
valid = false;
|
|
@@ -84,17 +130,41 @@ function validate(): boolean {
|
|
|
84
130
|
errors.<%= field.key %> = undefined;
|
|
85
131
|
}
|
|
86
132
|
<% } -%>
|
|
133
|
+
<% } else if (field.type === 'number') { -%>
|
|
134
|
+
if (form.<%= field.key %> !== '' && !Number.isFinite(Number(form.<%= field.key %>))) {
|
|
135
|
+
errors.<%= field.key %> = '<%= field.label %> must be a number.';
|
|
136
|
+
valid = false;
|
|
137
|
+
} else {
|
|
138
|
+
errors.<%= field.key %> = undefined;
|
|
139
|
+
}
|
|
140
|
+
<% } -%>
|
|
87
141
|
<% }); -%>
|
|
88
142
|
return valid;
|
|
89
143
|
}
|
|
90
144
|
|
|
145
|
+
// The form model holds what each CONTROL needs (a Date for a picker, a string
|
|
146
|
+
// for a number input); the API wants wire types. Converting here keeps that
|
|
147
|
+
// difference in one place instead of at every call site.
|
|
148
|
+
function payload() {
|
|
149
|
+
return {
|
|
150
|
+
...form,
|
|
151
|
+
<% fields.forEach(function (field) { -%>
|
|
152
|
+
<% if (field.type === 'number') { -%>
|
|
153
|
+
<%- field.key %>: form.<%- field.key %> === '' ? null : Number(form.<%- field.key %>),
|
|
154
|
+
<% } else if (field.type === 'date') { -%>
|
|
155
|
+
<%- field.key %>: toIsoDateString(form.<%- field.key %>) || null,
|
|
156
|
+
<% } -%>
|
|
157
|
+
<% }); -%>
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
91
161
|
async function onSubmit() {
|
|
92
162
|
if (!validate()) return;
|
|
93
163
|
saving.value = true;
|
|
94
164
|
saveError.value = null;
|
|
95
165
|
try {
|
|
96
166
|
// Create vs. update — which verb and path that means is the adapter's call.
|
|
97
|
-
await save('<%= resource %>', isNew.value ? null : idParam.value,
|
|
167
|
+
await save('<%= resource %>', isNew.value ? null : idParam.value, payload());
|
|
98
168
|
successMessage.value = isNew.value ? '<%= singularLabel %> created.' : '<%= singularLabel %> saved.';
|
|
99
169
|
redirectTimer = setTimeout(() => router.push('<%= route %>'), 600);
|
|
100
170
|
} catch (e) {
|
|
@@ -135,12 +205,28 @@ onUnmounted(() => {
|
|
|
135
205
|
<form v-else @submit.prevent="onSubmit">
|
|
136
206
|
<% fields.forEach(function (field) { -%>
|
|
137
207
|
<% if (field.type === 'checkbox') { -%>
|
|
138
|
-
|
|
208
|
+
<!-- The checkbox carries its own label via `text`; goa-form-item must not
|
|
209
|
+
repeat it, or it renders twice. -->
|
|
210
|
+
<goa-form-item>
|
|
139
211
|
<GoabCheckbox v-model="form.<%= field.key %>" name="<%= field.key %>" text="<%= field.label %>" />
|
|
140
212
|
</goa-form-item>
|
|
141
213
|
<% } else { -%>
|
|
142
214
|
<goa-form-item label="<%= field.label %>"<% if (field.required !== false) { %> requirement="required"<% } %> :error="errors.<%= field.key %>">
|
|
215
|
+
<% if (field.type === 'textarea') { -%>
|
|
216
|
+
<GoabTextarea v-model="form.<%= field.key %>" name="<%= field.key %>" />
|
|
217
|
+
<% } else if (field.type === 'number') { -%>
|
|
218
|
+
<GoabInput v-model="form.<%= field.key %>" name="<%= field.key %>" type="number" />
|
|
219
|
+
<% } else if (field.type === 'date') { -%>
|
|
220
|
+
<GoabDatePicker v-model="form.<%= field.key %>" name="<%= field.key %>" />
|
|
221
|
+
<% } else if (field.type === 'select') { -%>
|
|
222
|
+
<GoabDropdown v-model="form.<%= field.key %>" name="<%= field.key %>">
|
|
223
|
+
<% (field.options || []).forEach(function (option) { -%>
|
|
224
|
+
<goa-dropdown-item value="<%- option.value %>" label="<%- option.label %>" />
|
|
225
|
+
<% }); -%>
|
|
226
|
+
</GoabDropdown>
|
|
227
|
+
<% } else { -%>
|
|
143
228
|
<GoabInput v-model="form.<%= field.key %>" name="<%= field.key %>" type="text" />
|
|
229
|
+
<% } -%>
|
|
144
230
|
</goa-form-item>
|
|
145
231
|
<% } -%>
|
|
146
232
|
<% }); -%>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
export interface AdminCrudField {
|
|
2
2
|
key: string;
|
|
3
3
|
label: string;
|
|
4
|
-
type?: 'text' | 'checkbox';
|
|
4
|
+
type?: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'checkbox';
|
|
5
|
+
/** `select` only. Rendered as goa-dropdown-item children. */
|
|
6
|
+
options?: { value: string; label: string }[];
|
|
5
7
|
required?: boolean;
|
|
6
8
|
}
|
|
7
9
|
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"fields": {
|
|
35
35
|
"type": "string",
|
|
36
|
-
"description": "JSON array of fields, in display/form order -- e.g. '[{\"key\":\"name\",\"label\":\"Name\"},{\"key\":\"
|
|
36
|
+
"description": "JSON array of fields, in display/form order -- e.g. '[{\"key\":\"name\",\"label\":\"Name\"},{\"key\":\"quota\",\"label\":\"Quota\",\"type\":\"number\"},{\"key\":\"region\",\"label\":\"Region\",\"type\":\"select\",\"options\":[{\"value\":\"north\",\"label\":\"North\"}]}]'. Each item: { key, label, type?: \"text\"|\"textarea\"|\"number\"|\"date\"|\"select\"|\"checkbox\" (default \"text\"), options?: [{value,label}] (select only), required?: boolean }. A number field is submitted as a number and a date as YYYY-MM-DD built from local calendar parts. A plain array is also accepted when 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
|
"heading": {
|
|
39
39
|
"type": "string",
|
|
@@ -49,6 +49,12 @@
|
|
|
49
49
|
"default": true
|
|
50
50
|
}
|
|
51
51
|
},
|
|
52
|
-
"required": [
|
|
52
|
+
"required": [
|
|
53
|
+
"project",
|
|
54
|
+
"name",
|
|
55
|
+
"resource",
|
|
56
|
+
"route",
|
|
57
|
+
"fields"
|
|
58
|
+
],
|
|
53
59
|
"additionalProperties": false
|
|
54
60
|
}
|
|
@@ -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
|
|
@@ -20,6 +21,24 @@ function parseFields(fields) {
|
|
|
20
21
|
}
|
|
21
22
|
return parsed;
|
|
22
23
|
}
|
|
24
|
+
const FIELD_TYPES = [
|
|
25
|
+
'text',
|
|
26
|
+
'textarea',
|
|
27
|
+
'number',
|
|
28
|
+
'date',
|
|
29
|
+
'select',
|
|
30
|
+
'checkbox',
|
|
31
|
+
];
|
|
32
|
+
function assertFieldTypes(fields) {
|
|
33
|
+
for (const field of fields) {
|
|
34
|
+
if (field.type && !FIELD_TYPES.includes(field.type)) {
|
|
35
|
+
throw new Error(`--fields[].type must be one of ${FIELD_TYPES.join(', ')}; got "${field.type}" for "${field.key}".`);
|
|
36
|
+
}
|
|
37
|
+
if (field.type === 'select' && !Array.isArray(field.options)) {
|
|
38
|
+
throw new Error(`--fields[].options is required for the select field "${field.key}" -- the generator cannot know its choices.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
23
42
|
function normalizeOptions(host, options) {
|
|
24
43
|
var _a, _b, _c;
|
|
25
44
|
const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
|
|
@@ -27,7 +46,11 @@ function normalizeOptions(host, options) {
|
|
|
27
46
|
// className is PascalCase (e.g. "Regions") -- space it out for a readable
|
|
28
47
|
// default heading ("Regions").
|
|
29
48
|
const heading = (_a = options.heading) !== null && _a !== void 0 ? _a : className.replace(/([A-Z])/g, ' $1').trim();
|
|
30
|
-
return Object.assign(Object.assign({}, options), { projectRoot, fields:
|
|
49
|
+
return Object.assign(Object.assign({}, options), { projectRoot, fields: (() => {
|
|
50
|
+
const fields = parseFields(options.fields);
|
|
51
|
+
assertFieldTypes(fields);
|
|
52
|
+
return fields;
|
|
53
|
+
})(), listViewFileName: `${className}ListView`, editViewFileName: `${className}EditView`, heading, singularLabel: (_b = options.singularLabel) !== null && _b !== void 0 ? _b : heading, requiresAuth: (_c = options.requiresAuth) !== null && _c !== void 0 ? _c : true });
|
|
31
54
|
}
|
|
32
55
|
function default_1(host, options) {
|
|
33
56
|
return tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
@@ -51,6 +74,11 @@ function default_1(host, options) {
|
|
|
51
74
|
requiresAuth: normalizedOptions.requiresAuth,
|
|
52
75
|
layout: 'wide',
|
|
53
76
|
});
|
|
77
|
+
// The list is the nav destination; the edit form is reached from it.
|
|
78
|
+
(0, vue_side_menu_1.insertSideMenuItem)(host, normalizedOptions.projectRoot, {
|
|
79
|
+
label: normalizedOptions.heading,
|
|
80
|
+
to: normalizedOptions.route,
|
|
81
|
+
});
|
|
54
82
|
yield (0, devkit_1.formatFiles)(host);
|
|
55
83
|
});
|
|
56
84
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vue-admin-crud.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-admin-crud/vue-admin-crud.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"vue-admin-crud.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-admin-crud/vue-admin-crud.ts"],"names":[],"mappings":"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"}
|
|
@@ -94,7 +94,11 @@ describe('Vue Admin CRUD Generator', () => {
|
|
|
94
94
|
expect(view).not.toContain('errors.active');
|
|
95
95
|
// Create vs. update is expressed as a null id; which verb and path that
|
|
96
96
|
// becomes is decided by useApi's adapter.
|
|
97
|
-
|
|
97
|
+
// payload(), not the raw form: the model holds what the controls need, the
|
|
98
|
+
// API wants wire types.
|
|
99
|
+
expect(view).toContain(
|
|
100
|
+
"await save('regions', isNew.value ? null : idParam.value, payload())",
|
|
101
|
+
);
|
|
98
102
|
expect(view).toContain("await get('regions', idParam.value)");
|
|
99
103
|
expect(view).not.toContain('apiFetch');
|
|
100
104
|
expect(view).not.toContain("'PUT'");
|
|
@@ -117,6 +121,104 @@ describe('Vue Admin CRUD Generator', () => {
|
|
|
117
121
|
expect(view).toContain('Edit Regions');
|
|
118
122
|
}, 30000);
|
|
119
123
|
|
|
124
|
+
describe('field types', () => {
|
|
125
|
+
const allTypes = JSON.stringify([
|
|
126
|
+
{ key: 'name', label: 'Name' },
|
|
127
|
+
{ key: 'notes', label: 'Notes', type: 'textarea', required: false },
|
|
128
|
+
{ key: 'quota', label: 'Quota', type: 'number' },
|
|
129
|
+
{ key: 'effective', label: 'Effective', type: 'date' },
|
|
130
|
+
{
|
|
131
|
+
key: 'region',
|
|
132
|
+
label: 'Region',
|
|
133
|
+
type: 'select',
|
|
134
|
+
options: [
|
|
135
|
+
{ value: 'north', label: 'North' },
|
|
136
|
+
{ value: 'south', label: 'South' },
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
{ key: 'active', label: 'Active', type: 'checkbox' },
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
it('renders the right control for each type', async () => {
|
|
143
|
+
await generator(host, { ...baseOptions, fields: allTypes });
|
|
144
|
+
const view = host
|
|
145
|
+
.read('apps/test/src/views/RegionsEditView.vue')
|
|
146
|
+
.toString();
|
|
147
|
+
expect(view).toContain('<GoabInput v-model="form.name"');
|
|
148
|
+
expect(view).toContain('<GoabTextarea v-model="form.notes"');
|
|
149
|
+
expect(view).toContain('type="number"');
|
|
150
|
+
expect(view).toContain('<GoabDatePicker v-model="form.effective"');
|
|
151
|
+
expect(view).toContain('<GoabDropdown v-model="form.region"');
|
|
152
|
+
expect(view).toContain('<goa-dropdown-item value="north" label="North" />');
|
|
153
|
+
expect(view).toContain('<GoabCheckbox v-model="form.active"');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('imports only the wrappers the field set actually uses', async () => {
|
|
157
|
+
await generator(host, {
|
|
158
|
+
...baseOptions,
|
|
159
|
+
fields: JSON.stringify([{ key: 'name', label: 'Name' }]),
|
|
160
|
+
});
|
|
161
|
+
const view = host
|
|
162
|
+
.read('apps/test/src/views/RegionsEditView.vue')
|
|
163
|
+
.toString();
|
|
164
|
+
expect(view).toContain('GoabInput');
|
|
165
|
+
for (const unused of ['GoabTextarea', 'GoabDatePicker', 'GoabDropdown']) {
|
|
166
|
+
expect(view).not.toContain(unused);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('submits a number as a number and a date as a local-calendar string', async () => {
|
|
171
|
+
await generator(host, { ...baseOptions, fields: allTypes });
|
|
172
|
+
const view = host
|
|
173
|
+
.read('apps/test/src/views/RegionsEditView.vue')
|
|
174
|
+
.toString();
|
|
175
|
+
expect(view).toContain("quota: form.quota === '' ? null : Number(form.quota)");
|
|
176
|
+
expect(view).toContain('effective: toIsoDateString(form.effective) || null');
|
|
177
|
+
// A stored YYYY-MM-DD comes back as a Date the picker can redisplay.
|
|
178
|
+
expect(view).toContain("fromIsoDateString(data['effective'])");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('validates by type, not by assuming every value is a string', async () => {
|
|
182
|
+
await generator(host, { ...baseOptions, fields: allTypes });
|
|
183
|
+
const view = host
|
|
184
|
+
.read('apps/test/src/views/RegionsEditView.vue')
|
|
185
|
+
.toString();
|
|
186
|
+
// A number field's required check cannot be !value.trim().
|
|
187
|
+
expect(view).toContain("if (form.quota === '')");
|
|
188
|
+
expect(view).toContain('Quota must be a number.');
|
|
189
|
+
// A date field holds a Date, so truthiness is the check.
|
|
190
|
+
expect(view).toContain('if (!form.effective)');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('does not label a checkbox twice', async () => {
|
|
194
|
+
await generator(host, { ...baseOptions, fields: allTypes });
|
|
195
|
+
const view = host
|
|
196
|
+
.read('apps/test/src/views/RegionsEditView.vue')
|
|
197
|
+
.toString();
|
|
198
|
+
// goa-form-item must not repeat the label the checkbox already carries.
|
|
199
|
+
expect(view).not.toContain('<goa-form-item label="Active"');
|
|
200
|
+
expect(view).toContain('text="Active"');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('rejects a type it cannot render', async () => {
|
|
204
|
+
await expect(
|
|
205
|
+
generator(host, {
|
|
206
|
+
...baseOptions,
|
|
207
|
+
fields: JSON.stringify([{ key: 'x', label: 'X', type: 'colour' }]),
|
|
208
|
+
}),
|
|
209
|
+
).rejects.toThrow('got "colour" for "x"');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('requires options for a select, since it cannot know the choices', async () => {
|
|
213
|
+
await expect(
|
|
214
|
+
generator(host, {
|
|
215
|
+
...baseOptions,
|
|
216
|
+
fields: JSON.stringify([{ key: 'r', label: 'R', type: 'select' }]),
|
|
217
|
+
}),
|
|
218
|
+
).rejects.toThrow(/options is required for the select field "r"/);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
120
222
|
it('uses --singularLabel over --heading for Create/Edit headings when both are set', async () => {
|
|
121
223
|
await generator(host, {
|
|
122
224
|
...baseOptions,
|
|
@@ -234,34 +234,31 @@ mark what's meant to be edited.
|
|
|
234
234
|
{ path: '/queue', component: () => import('../views/QueueView.vue'), meta: { layout: 'wide' } }
|
|
235
235
|
{ path: '/apply', component: () => import('../views/ApplyView.vue'), meta: { layout: 'form' } }
|
|
236
236
|
```
|
|
237
|
-
3. **For the `internal` layout:
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
3. **For the `internal` layout: check the side-menu nav.** `src/App.vue` has a plain
|
|
238
|
+
`primaryItems` array — the app's own navigation list, seeded with Home. Each `vue-*-view`
|
|
239
|
+
generator that adds a staff screen appends its entry automatically, so a generated route is
|
|
240
|
+
navigable without you doing anything.
|
|
241
|
+
|
|
242
|
+
It is a literal array, not derived from the router, on purpose: nav order, grouping and
|
|
243
|
+
labels are decisions this app owns. Reorder, relabel, group into `secondaryItems`, or prune
|
|
244
|
+
freely — the generators only ever append, and never touch an entry that already exists for
|
|
245
|
+
that route.
|
|
242
246
|
|
|
243
247
|
```typescript
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
{ label: 'My Feature', to: '/my-feature', icon: 'list', current: route.path.startsWith('/my-feature') },
|
|
249
|
-
])
|
|
248
|
+
const primaryItems = [
|
|
249
|
+
{ label: 'Home', to: '/', icon: 'home' },
|
|
250
|
+
{ label: 'Review queue', to: '/queue' }, // added by vue-workspace-view
|
|
251
|
+
];
|
|
250
252
|
```
|
|
251
253
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
:primary-items="primaryItems"
|
|
258
|
-
:account-items="accountItems"
|
|
259
|
-
@item-click="onItemClick"
|
|
260
|
-
>
|
|
261
|
-
```
|
|
254
|
+
Two things worth adding by hand: **`icon`** is effectively required —
|
|
255
|
+
`goa-work-side-menu-item` renders a blank item without one, and the generators can't guess it
|
|
256
|
+
(icon names: [design.alberta.ca/components/icons](https://design.alberta.ca/components/icons)).
|
|
257
|
+
And **`current`** for active highlighting, e.g. `current: route.path.startsWith('/queue')`,
|
|
258
|
+
which needs the `route` already in scope.
|
|
262
259
|
|
|
263
|
-
The `header` layout has no side menu
|
|
264
|
-
|
|
260
|
+
The `header` layout has no side menu, so it has no `primaryItems` array and the generators
|
|
261
|
+
skip the step silently — use route-level breadcrumbs or a nav inside the view instead.
|
|
265
262
|
|
|
266
263
|
## Backend API calls (proxy setup)
|
|
267
264
|
|
|
@@ -32,6 +32,14 @@ function signInAgain() {
|
|
|
32
32
|
}
|
|
33
33
|
<% if (layout === 'internal') { %>
|
|
34
34
|
|
|
35
|
+
// The app's main navigation. Every `vue-*-view` generator that adds a staff
|
|
36
|
+
// screen appends an entry here, and it is yours to reorder, relabel, group or
|
|
37
|
+
// prune -- a generated app is a starting point, not a fixed shell. Items are
|
|
38
|
+
// `{ label, to }`; add `icon` for a leading icon or `badge` for a count.
|
|
39
|
+
const primaryItems = [
|
|
40
|
+
{ label: 'Home', to: '/' },
|
|
41
|
+
];
|
|
42
|
+
|
|
35
43
|
// AppSideMenu's items are presentational — it doesn't know about Keycloak or
|
|
36
44
|
// routing; it just renders whatever's passed and emits itemClick on click.
|
|
37
45
|
const accountItems = computed(() => [
|
|
@@ -56,6 +64,7 @@ function onItemClick(item: { to?: string }) {
|
|
|
56
64
|
<% if (layout === 'internal') { %>
|
|
57
65
|
<AppSideMenu
|
|
58
66
|
heading="<%= projectName %>"
|
|
67
|
+
:primary-items="primaryItems"
|
|
59
68
|
:account-items="accountItems"
|
|
60
69
|
@item-click="onItemClick"
|
|
61
70
|
>
|
|
@@ -193,6 +193,12 @@ describe('Vue App Generator', () => {
|
|
|
193
193
|
expect(app).toContain('<AppFooter />');
|
|
194
194
|
}, 30000);
|
|
195
195
|
|
|
196
|
+
it('the header layout has no side-menu nav array (there is no side menu)', async () => {
|
|
197
|
+
await generator(host, options);
|
|
198
|
+
const app = host.read('apps/test/src/App.vue').toString();
|
|
199
|
+
expect(app).not.toContain('primaryItems');
|
|
200
|
+
});
|
|
201
|
+
|
|
196
202
|
it('--layout=internal generates an AppSideMenu shell instead of AppHeader/AppFooter', async () => {
|
|
197
203
|
await generator(host, { ...options, layout: 'internal' });
|
|
198
204
|
|
|
@@ -206,6 +212,12 @@ describe('Vue App Generator', () => {
|
|
|
206
212
|
expect(app).toContain('<AppSideMenu');
|
|
207
213
|
expect(app).toContain('heading="test"');
|
|
208
214
|
expect(app).toContain(':account-items="accountItems"');
|
|
215
|
+
// A plain, editable nav array -- the app's own list, seeded with Home and
|
|
216
|
+
// appended to by each staff view generator. Not derived from the router:
|
|
217
|
+
// nav order, grouping and labels are the owning team's decisions.
|
|
218
|
+
expect(app).toContain(':primary-items="primaryItems"');
|
|
219
|
+
expect(app).toContain('const primaryItems = [');
|
|
220
|
+
expect(app).toContain("{ label: 'Home', to: '/' },");
|
|
209
221
|
expect(app).toContain('@item-click="onItemClick"');
|
|
210
222
|
// The content gutter is shared regardless of shell choice.
|
|
211
223
|
expect(app).toContain('<AppLayout');
|
|
@@ -34,6 +34,8 @@ export { default as StepErrorSummary } from './lib/patterns/StepErrorSummary.vue
|
|
|
34
34
|
export { default as FilterBar } from './lib/patterns/FilterBar.vue';
|
|
35
35
|
|
|
36
36
|
export {
|
|
37
|
+
fromIsoDateString,
|
|
38
|
+
toIsoDateString,
|
|
37
39
|
formatCurrency,
|
|
38
40
|
formatDate,
|
|
39
41
|
formatDateTime,
|
|
@@ -62,6 +62,29 @@ function toValidDate(value: unknown): Date | null {
|
|
|
62
62
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A Date as local-calendar YYYY-MM-DD, for the wire -- a query parameter, a JSON
|
|
67
|
+
* body. NOT display: use formatDate for that.
|
|
68
|
+
*
|
|
69
|
+
* Built from local parts, never toISOString(): that routes through UTC, and the
|
|
70
|
+
* UTC day of a local-midnight Date is the previous day anywhere west of
|
|
71
|
+
* Greenwich. In Alberta (UTC-6/-7) it files every date one day early.
|
|
72
|
+
*/
|
|
73
|
+
export function toIsoDateString(value: unknown): string {
|
|
74
|
+
const date = toValidDate(value);
|
|
75
|
+
if (!date) return '';
|
|
76
|
+
const pad = (part: number) => String(part).padStart(2, '0');
|
|
77
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The inverse: a stored YYYY-MM-DD back to a Date at local midnight, so the
|
|
82
|
+
* calendar date a user picked is the calendar date a picker redisplays.
|
|
83
|
+
*/
|
|
84
|
+
export function fromIsoDateString(value: unknown): Date | undefined {
|
|
85
|
+
return toValidDate(value) ?? undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
/** Date without a time component, e.g. `2026-08-28` → `2026-08-28`. */
|
|
66
89
|
export function formatDate(value: unknown): string {
|
|
67
90
|
const date = toValidDate(value);
|
|
@@ -21,6 +21,7 @@ import { computed } from 'vue';
|
|
|
21
21
|
import GoabDropdown from '../primitives/GoabDropdown.vue';
|
|
22
22
|
import GoabDatePicker from '../primitives/GoabDatePicker.vue';
|
|
23
23
|
import GoabButton from '../primitives/GoabButton.vue';
|
|
24
|
+
import { fromIsoDateString, toIsoDateString } from '../formatters';
|
|
24
25
|
|
|
25
26
|
export interface FilterOption {
|
|
26
27
|
value: string;
|
|
@@ -71,29 +72,18 @@ const dateValues = computed(() =>
|
|
|
71
72
|
.map((filter) => [
|
|
72
73
|
filter.key,
|
|
73
74
|
model.value[filter.key]
|
|
74
|
-
?
|
|
75
|
+
? fromIsoDateString(model.value[filter.key])
|
|
75
76
|
: undefined,
|
|
76
77
|
]),
|
|
77
78
|
),
|
|
78
79
|
);
|
|
79
80
|
|
|
80
|
-
// Local-calendar YYYY-MM-DD -- see the header note on why not toISOString().
|
|
81
|
-
function toIsoDate(date: Date): string {
|
|
82
|
-
const pad = (part: number) => String(part).padStart(2, '0');
|
|
83
|
-
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function fromIsoDate(value: string): Date | undefined {
|
|
87
|
-
const [year, month, day] = value.split('-').map(Number);
|
|
88
|
-
return year && month && day ? new Date(year, month - 1, day) : undefined;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
81
|
function set(key: string, value: string) {
|
|
92
82
|
model.value = { ...model.value, [key]: value };
|
|
93
83
|
}
|
|
94
84
|
|
|
95
85
|
function setDate(key: string, date: Date | undefined) {
|
|
96
|
-
set(key,
|
|
86
|
+
set(key, toIsoDateString(date));
|
|
97
87
|
}
|
|
98
88
|
|
|
99
89
|
function clearAll() {
|
|
@@ -31,3 +31,50 @@ describe('WorkspaceTable pagination wiring', () => {
|
|
|
31
31
|
}
|
|
32
32
|
});
|
|
33
33
|
});
|
|
34
|
+
|
|
35
|
+
const sortProps = {
|
|
36
|
+
columns: [
|
|
37
|
+
{ key: 'name', label: 'Name', sortable: true },
|
|
38
|
+
{ key: 'plain', label: 'Plain' },
|
|
39
|
+
],
|
|
40
|
+
rows: [{ name: 'a', plain: 'b' }],
|
|
41
|
+
loading: false,
|
|
42
|
+
error: null,
|
|
43
|
+
sortBy: 'name',
|
|
44
|
+
sortDir: 'desc' as const,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe('WorkspaceTable native sorting', () => {
|
|
48
|
+
it('renders goa-table-sort-header only for sortable columns', () => {
|
|
49
|
+
const w = mount(WorkspaceTable, { props: sortProps });
|
|
50
|
+
expect(w.findAll('goa-table-sort-header')).toHaveLength(1);
|
|
51
|
+
expect(w.find('goa-table-sort-header').attributes('name')).toBe('name');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('tells the header the current direction, and none for other columns', () => {
|
|
55
|
+
const w = mount(WorkspaceTable, { props: sortProps });
|
|
56
|
+
expect(w.find('goa-table-sort-header').attributes('direction')).toBe('desc');
|
|
57
|
+
const other = mount(WorkspaceTable, { props: { ...sortProps, sortBy: 'plain' } });
|
|
58
|
+
expect(other.find('goa-table-sort-header').attributes('direction')).toBe('none');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('enables single sort mode on the table', () => {
|
|
62
|
+
expect(mount(WorkspaceTable, { props: sortProps }).find('goa-table').attributes('sort-mode')).toBe('single');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("translates goa-table's numeric sortDir to asc/desc", async () => {
|
|
66
|
+
const w = mount(WorkspaceTable, { props: sortProps });
|
|
67
|
+
const table = w.find('goa-table').element;
|
|
68
|
+
table.dispatchEvent(new CustomEvent('_sort', { detail: { sortBy: 'name', sortDir: -1 } }));
|
|
69
|
+
table.dispatchEvent(new CustomEvent('_sort', { detail: { sortBy: 'name', sortDir: 1 } }));
|
|
70
|
+
expect(w.emitted('sort')).toEqual([['name', 'desc'], ['name', 'asc']]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('ignores an unsorted (0) event with no column', async () => {
|
|
74
|
+
const w = mount(WorkspaceTable, { props: sortProps });
|
|
75
|
+
w.find('goa-table').element.dispatchEvent(
|
|
76
|
+
new CustomEvent('_sort', { detail: { sortBy: '', sortDir: 0 } }),
|
|
77
|
+
);
|
|
78
|
+
expect(w.emitted('sort')).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
});
|