@abgov/nx-adsp 13.29.0 → 13.31.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 +23 -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/src/App.vue__tmpl__ +16 -3
- package/src/generators/vue-app/vue-app.spec.ts +19 -4
- 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/AppLayout.spec.ts__tmpl__ +32 -0
- package/src/generators/vue-components/files/src/lib/patterns/AppLayout.vue__tmpl__ +28 -21
- 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 +34 -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.spec.ts +3 -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
|
}
|
|
@@ -21,6 +21,24 @@ function parseFields(fields) {
|
|
|
21
21
|
}
|
|
22
22
|
return parsed;
|
|
23
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
|
+
}
|
|
24
42
|
function normalizeOptions(host, options) {
|
|
25
43
|
var _a, _b, _c;
|
|
26
44
|
const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
|
|
@@ -28,7 +46,11 @@ function normalizeOptions(host, options) {
|
|
|
28
46
|
// className is PascalCase (e.g. "Regions") -- space it out for a readable
|
|
29
47
|
// default heading ("Regions").
|
|
30
48
|
const heading = (_a = options.heading) !== null && _a !== void 0 ? _a : className.replace(/([A-Z])/g, ' $1').trim();
|
|
31
|
-
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 });
|
|
32
54
|
}
|
|
33
55
|
function default_1(host, options) {
|
|
34
56
|
return tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
@@ -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":";;AA+EA,4BAyCC;;AAxHD,uCAMoB;AACpB,6BAA6B;AAC7B,uDAAwD;AACxD,6DAA+D;AAC/D,qEAE0C;AAG1C,6EAA6E;AAC7E,4EAA4E;AAC5E,8EAA8E;AAC9E,2EAA2E;AAC3E,0EAA0E;AAC1E,6EAA6E;AAC7E,iEAAiE;AACjE,SAAS,WAAW,CAAC,MAAwB;IAC3C,MAAM,MAAM,GAAG,OAAO,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC;IACxE,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CACb,sFAAsF,CACvF,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,WAAW,GAAG;IAClB,MAAM;IACN,UAAU;IACV,QAAQ;IACR,MAAM;IACN,QAAQ;IACR,UAAU;CACF,CAAC;AAEX,SAAS,gBAAgB,CAAC,MAA2D;IACnF,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,IAAI,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAa,CAAC,EAAE,CAAC;YAC7D,MAAM,IAAI,KAAK,CACb,kCAAkC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,KAAK,CAAC,IAAI,UAAU,KAAK,CAAC,GAAG,IAAI,CACpG,CAAC;QACJ,CAAC;QACD,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;YAC7D,MAAM,IAAI,KAAK,CACb,wDAAwD,KAAK,CAAC,GAAG,6CAA6C,CAC/G,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAS,gBAAgB,CAAC,IAAU,EAAE,OAAe;;IACnD,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,GAAG,IAAA,iCAAwB,EAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC;IAE9E,MAAM,SAAS,GAAG,IAAA,cAAK,EAAC,OAAO,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC;IAChD,0EAA0E;IAC1E,+BAA+B;IAC/B,MAAM,OAAO,GAAG,MAAA,OAAO,CAAC,OAAO,mCAAI,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,CAAC;IAC/E,uCACK,OAAO,KACV,WAAW,EACX,MAAM,EAAE,CAAC,GAAG,EAAE;YACZ,MAAM,MAAM,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;YAC3C,gBAAgB,CAAC,MAAM,CAAC,CAAC;YACzB,OAAO,MAAM,CAAC;QAChB,CAAC,CAAC,EAAE,EACJ,gBAAgB,EAAE,GAAG,SAAS,UAAU,EACxC,gBAAgB,EAAE,GAAG,SAAS,UAAU,EACxC,OAAO,EACP,aAAa,EAAE,MAAA,OAAO,CAAC,aAAa,mCAAI,OAAO,EAC/C,YAAY,EAAE,MAAA,OAAO,CAAC,YAAY,mCAAI,IAAI,IAC1C;AACJ,CAAC;AAED,mBAA+B,IAAU,EAAE,OAAe;;QACxD,MAAM,iBAAiB,GAAG,gBAAgB,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAE1D,2EAA2E;QAC3E,oCAAoC;QACpC,MAAM,IAAA,wBAAsB,EAAC,IAAI,CAAC,CAAC;QAEnC,IAAA,sBAAa,EACX,IAAI,EACJ,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,EAC7B,iBAAiB,CAAC,WAAW,kCAExB,iBAAiB,KACpB,aAAa,EAAE,IAAA,wCAAuB,EAAC,IAAI,CAAC,EAC5C,IAAI,EAAE,EAAE,IAEX,CAAC;QAEF,yEAAyE;QACzE,sEAAsE;QACtE,uEAAuE;QACvE,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,GAAG,iBAAiB,CAAC,KAAK,MAAM;YACtC,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,gBAAgB,MAAM;YACzE,YAAY,EAAE,iBAAiB,CAAC,YAAY;YAC5C,MAAM,EAAE,MAAM;SACf,CAAC,CAAC;QACH,IAAA,2BAAc,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE,iBAAiB,CAAC,OAAO,EAAE;YAC7E,IAAI,EAAE,iBAAiB,CAAC,KAAK;YAC7B,mBAAmB,EAAE,YAAY,iBAAiB,CAAC,gBAAgB,MAAM;YACzE,YAAY,EAAE,iBAAiB,CAAC,YAAY;YAC5C,MAAM,EAAE,MAAM;SACf,CAAC,CAAC;QAEH,qEAAqE;QACrE,IAAA,kCAAkB,EAAC,IAAI,EAAE,iBAAiB,CAAC,WAAW,EAAE;YACtD,KAAK,EAAE,iBAAiB,CAAC,OAAO;YAChC,EAAE,EAAE,iBAAiB,CAAC,KAAK;SAC5B,CAAC,CAAC;QAEH,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
|
|
@@ -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,
|
|
@@ -78,6 +78,13 @@ function onItemClick(item: { to?: string }) {
|
|
|
78
78
|
</AppLayout>
|
|
79
79
|
</AppSideMenu>
|
|
80
80
|
<% } else { %>
|
|
81
|
+
<!-- goa-one-column-layout owns the page's flex column, the sticky footer and
|
|
82
|
+
the <main> landmark. That <main> lives in the element's shadow DOM, so the
|
|
83
|
+
skip link targets an id'd wrapper in the slotted (light DOM) content
|
|
84
|
+
instead -- the wrapper is a plain div, so there is still exactly one main
|
|
85
|
+
landmark on the page. -->
|
|
86
|
+
<goa-one-column-layout>
|
|
87
|
+
<section slot="header">
|
|
81
88
|
<a class="skip-link" href="#main-content">Skip to main content</a>
|
|
82
89
|
<AppHeader heading="<%= projectName %>">
|
|
83
90
|
<template #utilities>
|
|
@@ -100,12 +107,18 @@ function onItemClick(item: { to?: string }) {
|
|
|
100
107
|
@sign-in="signInAgain"
|
|
101
108
|
@dismiss="session.dismiss"
|
|
102
109
|
/>
|
|
103
|
-
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<div id="main-content">
|
|
104
113
|
<AppLayout :variant="contentWidth">
|
|
105
114
|
<RouterView />
|
|
106
115
|
</AppLayout>
|
|
107
|
-
</
|
|
108
|
-
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<section slot="footer">
|
|
119
|
+
<AppFooter />
|
|
120
|
+
</section>
|
|
121
|
+
</goa-one-column-layout>
|
|
109
122
|
<% } %>
|
|
110
123
|
</template>
|
|
111
124
|
|
|
@@ -120,10 +120,17 @@ describe('Vue App Generator', () => {
|
|
|
120
120
|
const layoutPath = 'libs/vue-components/src/lib/patterns/AppLayout.vue';
|
|
121
121
|
expect(host.exists(layoutPath)).toBeTruthy();
|
|
122
122
|
const layout = host.read(layoutPath).toString();
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// The gutter is goa-page-block's job now: it supplies the centering, the
|
|
124
|
+
// max-width and a responsive horizontal gutter. The named variants stay the
|
|
125
|
+
// public API, mapped to the widths the element takes.
|
|
126
|
+
expect(layout).toContain('<goa-page-block');
|
|
127
|
+
expect(layout).toContain("form: '640px'");
|
|
128
|
+
expect(layout).toContain("page: '1000px'");
|
|
129
|
+
expect(layout).toContain("wide: '1200px'");
|
|
130
|
+
// Vertical padding is not part of goa-page-block, so this component keeps it
|
|
131
|
+
// -- the same thing GoA's own public-form reference does.
|
|
126
132
|
expect(layout).toContain('--goa-space');
|
|
133
|
+
expect(layout).toContain('padding-block');
|
|
127
134
|
// AppLayout must NOT own the skip-to-main-content landmark itself: it nests
|
|
128
135
|
// inside AppSideMenu for --layout=internal, which already provides one, and
|
|
129
136
|
// a second <main id="main-content"> would duplicate the landmark/id.
|
|
@@ -137,8 +144,16 @@ describe('Vue App Generator', () => {
|
|
|
137
144
|
const app = host.read('apps/test/src/App.vue').toString();
|
|
138
145
|
expect(app).toContain('AppLayout');
|
|
139
146
|
expect(app).not.toContain('main > section');
|
|
147
|
+
// The public shell is goa-one-column-layout, which owns the page's flex
|
|
148
|
+
// column, sticky footer and <main> landmark. That <main> is in its shadow
|
|
149
|
+
// DOM, so the skip link targets an id'd wrapper in the slotted content --
|
|
150
|
+
// a plain div, so the page still has exactly one main landmark.
|
|
151
|
+
expect(app).toContain('<goa-one-column-layout>');
|
|
152
|
+
expect(app).toContain('<section slot="header">');
|
|
153
|
+
expect(app).toContain('<section slot="footer">');
|
|
140
154
|
expect(app).toContain('href="#main-content"');
|
|
141
|
-
expect(app).toContain('id="main-content"');
|
|
155
|
+
expect(app).toContain('<div id="main-content">');
|
|
156
|
+
expect(app).not.toContain('<main id="main-content">');
|
|
142
157
|
});
|
|
143
158
|
|
|
144
159
|
it('provisions the shared GoA wrapper library and points the app at it', async () => {
|
|
@@ -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);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import AppLayout from './AppLayout.vue';
|
|
4
|
+
|
|
5
|
+
// Note on how width is asserted: in this test environment goa-page-block is not
|
|
6
|
+
// a defined custom element, so Vue falls back to setting the width as an
|
|
7
|
+
// ATTRIBUTE. In a real browser the element defines the property, so Vue sets it
|
|
8
|
+
// as a property and getAttribute('width') returns null -- verified against a
|
|
9
|
+
// running app, where the element then applies --max-width correctly. These tests
|
|
10
|
+
// therefore guard the variant -> width mapping, which is the part that can
|
|
11
|
+
// regress; they are not evidence about attribute-vs-property binding.
|
|
12
|
+
describe('AppLayout', () => {
|
|
13
|
+
const widthFor = (variant?: 'page' | 'wide' | 'form') =>
|
|
14
|
+
mount(AppLayout, { props: variant ? { variant } : {} })
|
|
15
|
+
.find('goa-page-block')
|
|
16
|
+
.attributes('width');
|
|
17
|
+
|
|
18
|
+
it('delegates the content gutter to goa-page-block', () => {
|
|
19
|
+
expect(mount(AppLayout).find('goa-page-block').exists()).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('maps each variant to the width the element expects', () => {
|
|
23
|
+
expect(widthFor('form')).toBe('640px');
|
|
24
|
+
expect(widthFor()).toBe('1000px');
|
|
25
|
+
expect(widthFor('wide')).toBe('1200px');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('renders slotted content inside the block', () => {
|
|
29
|
+
const w = mount(AppLayout, { slots: { default: '<p>hello</p>' } });
|
|
30
|
+
expect(w.find('goa-page-block').html()).toContain('<p>hello</p>');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -14,36 +14,43 @@
|
|
|
14
14
|
// copy would duplicate the anchor screen readers/keyboard nav jump to. The
|
|
15
15
|
// top-level shell (App.vue for --layout=header, AppSideMenu for --layout=internal)
|
|
16
16
|
// owns that landmark instead.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
import { computed } from 'vue';
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(
|
|
20
|
+
defineProps<{ variant?: 'page' | 'wide' | 'form' }>(),
|
|
21
|
+
{ variant: 'page' },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// goa-page-block takes a CSS dimension (or "full"); the variant names stay the
|
|
25
|
+
// public API so a view says what kind of page it is, not how wide it should be.
|
|
26
|
+
const WIDTHS = {
|
|
27
|
+
form: '640px',
|
|
28
|
+
page: '1000px',
|
|
29
|
+
wide: '1200px',
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
const maxWidth = computed(() => WIDTHS[props.variant]);
|
|
20
33
|
</script>
|
|
21
34
|
|
|
22
35
|
<template>
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
<goa-page-block :width="maxWidth">
|
|
37
|
+
<!-- goa-page-block supplies the centering, the max-width and a responsive
|
|
38
|
+
HORIZONTAL gutter, but no vertical padding -- GoA's own public-form
|
|
39
|
+
reference does the same thing, wrapping the block's children to add it. -->
|
|
40
|
+
<div class="app-layout-inset">
|
|
41
|
+
<slot />
|
|
42
|
+
</div>
|
|
43
|
+
</goa-page-block>
|
|
26
44
|
</template>
|
|
27
45
|
|
|
28
46
|
<style scoped>
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
.form-content {
|
|
32
|
-
margin-inline: auto;
|
|
33
|
-
box-sizing: border-box;
|
|
34
|
-
/* Design-token gutter; fallbacks apply only if tokens aren't loaded. */
|
|
35
|
-
padding: var(--goa-space-xl, 2.5rem) var(--goa-space-l, 1.5rem);
|
|
47
|
+
.app-layout-inset {
|
|
48
|
+
padding-block: var(--goa-space-xl, 2.5rem);
|
|
36
49
|
}
|
|
37
50
|
|
|
38
|
-
.form-content { max-width: 640px; }
|
|
39
|
-
.page-content { max-width: 1000px; }
|
|
40
|
-
.wide-content { max-width: 1200px; }
|
|
41
|
-
|
|
42
51
|
@media (max-width: 623.98px) {
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
.form-content {
|
|
46
|
-
padding: var(--goa-space-m, 1rem);
|
|
52
|
+
.app-layout-inset {
|
|
53
|
+
padding-block: var(--goa-space-m, 1rem);
|
|
47
54
|
}
|
|
48
55
|
}
|
|
49
56
|
</style>
|
|
@@ -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
|
+
});
|
|
@@ -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()');
|
|
@@ -140,6 +171,7 @@ describe('Vue Components Generator', () => {
|
|
|
140
171
|
'libs/vue-components/src/lib/primitives/GoabDatePicker.spec.ts',
|
|
141
172
|
'libs/vue-components/src/lib/patterns/WorkspaceTable.spec.ts',
|
|
142
173
|
'libs/vue-components/src/lib/patterns/Stepper.spec.ts',
|
|
174
|
+
'libs/vue-components/src/lib/patterns/AppLayout.spec.ts',
|
|
143
175
|
]) {
|
|
144
176
|
expect(host.exists(spec)).toBeTruthy();
|
|
145
177
|
}
|
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
|
}
|
|
@@ -112,6 +112,9 @@ describe('Vue Workspace View Generator', () => {
|
|
|
112
112
|
expect(view).toContain("await list('applications', {");
|
|
113
113
|
expect(view).toContain('pageSize: PAGE_SIZE');
|
|
114
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'");
|
|
115
118
|
expect(view).toContain('itemCount.value = result.total');
|
|
116
119
|
expect(view).not.toContain('apiFetch');
|
|
117
120
|
expect(view).not.toContain('URLSearchParams');
|