@abgov/nx-adsp 13.28.1 → 13.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +90 -4
  3. package/src/generators/vue-admin-crud/schema.d.ts +3 -1
  4. package/src/generators/vue-admin-crud/schema.json +8 -2
  5. package/src/generators/vue-admin-crud/vue-admin-crud.js +29 -1
  6. package/src/generators/vue-admin-crud/vue-admin-crud.js.map +1 -1
  7. package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +103 -1
  8. package/src/generators/vue-app/files/AGENTS.md__tmpl__ +20 -23
  9. package/src/generators/vue-app/files/src/App.vue__tmpl__ +9 -0
  10. package/src/generators/vue-app/vue-app.spec.ts +12 -0
  11. package/src/generators/vue-components/files/src/index.ts__tmpl__ +2 -0
  12. package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +23 -0
  13. package/src/generators/vue-components/files/src/lib/patterns/FilterBar.vue__tmpl__ +3 -13
  14. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.spec.ts__tmpl__ +47 -0
  15. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +42 -40
  16. package/src/generators/vue-components/vue-components.spec.ts +33 -2
  17. package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +7 -0
  18. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +67 -3
  19. package/src/generators/vue-intake-view/schema.d.ts +3 -0
  20. package/src/generators/vue-intake-view/schema.json +8 -2
  21. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +72 -0
  22. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +5 -7
  23. package/src/generators/vue-workspace-view/vue-workspace-view.js +6 -0
  24. package/src/generators/vue-workspace-view/vue-workspace-view.js.map +1 -1
  25. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +22 -0
  26. package/src/utils/vue-side-menu.d.ts +19 -0
  27. package/src/utils/vue-side-menu.js +41 -0
  28. package/src/utils/vue-side-menu.js.map +1 -0
  29. package/src/utils/vue-side-menu.spec.ts +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.28.1",
3
+ "version": "13.30.0",
4
4
  "license": "Apache-2.0",
5
5
  "main": "src/index.js",
6
6
  "description": "Government of Alberta - Nx plugin for ADSP apps.",
@@ -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 { GoabInput, GoabCheckbox } from '<%= goaImportPath %>';
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, form);
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
- <goa-form-item label="<%= field.label %>">
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\":\"active\",\"label\":\"Active\",\"type\":\"checkbox\"}]'. Each item: { key, label, type?: \"text\"|\"checkbox\" (default \"text\"), required?: boolean (default true for \"text\", ignored for \"checkbox\") }. A plain array is also accepted when this generator is invoked programmatically."
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": ["project", "name", "resource", "route", "fields"],
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: parseFields(options.fields), 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 });
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":";;AAkDA,4BAmCC;;AArFD,uCAMoB;AACpB,6BAA6B;AAC7B,uDAAwD;AACxD,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,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,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,EACnC,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,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
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
- expect(view).toContain("await save('regions', isNew.value ? null : idParam.value, form)");
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: add a nav link to `src/App.vue`.** The generated shell only
238
- populates `accountItems`; primary navigation lives in `primaryItems` (or `secondaryItems`).
239
- `AppSideMenu` emits one `itemClick` for all slots the generated `onItemClick` already
240
- dispatches by `item.to`: items with a `to` route, items without sign in/out. Add only
241
- `primaryItems`:
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
- // In <script setup> — route, router, and onItemClick are already in the generated App.vue:
245
- // Icon names from the GoA icon set: design.alberta.ca/components/icons
246
- const primaryItems = computed(() => [
247
- { label: 'Home', to: '/', icon: 'home', current: route.path === '/' },
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
- ```html
253
- <!-- In <template>, add :primary-items to <AppSideMenu>
254
- (@item-click="onItemClick" is already wired in the generated shell): -->
255
- <AppSideMenu
256
- heading="..."
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 add route-level breadcrumbs or a secondary nav
264
- directly inside the view component instead.
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
- ? fromIsoDate(model.value[filter.key])
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, date ? toIsoDate(date) : '');
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
+ });