@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.
Files changed (24) 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 +23 -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/src/App.vue__tmpl__ +16 -3
  9. package/src/generators/vue-app/vue-app.spec.ts +19 -4
  10. package/src/generators/vue-components/files/src/index.ts__tmpl__ +2 -0
  11. package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +23 -0
  12. package/src/generators/vue-components/files/src/lib/patterns/AppLayout.spec.ts__tmpl__ +32 -0
  13. package/src/generators/vue-components/files/src/lib/patterns/AppLayout.vue__tmpl__ +28 -21
  14. package/src/generators/vue-components/files/src/lib/patterns/FilterBar.vue__tmpl__ +3 -13
  15. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.spec.ts__tmpl__ +47 -0
  16. package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +42 -40
  17. package/src/generators/vue-components/vue-components.spec.ts +34 -2
  18. package/src/generators/vue-intake-view/files/shared/src/views/__reviewViewFileName__.vue__tmpl__ +7 -0
  19. package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +67 -3
  20. package/src/generators/vue-intake-view/schema.d.ts +3 -0
  21. package/src/generators/vue-intake-view/schema.json +8 -2
  22. package/src/generators/vue-intake-view/vue-intake-view.spec.ts +72 -0
  23. package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +5 -7
  24. package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +3 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abgov/nx-adsp",
3
- "version": "13.29.0",
3
+ "version": "13.31.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
  }
@@ -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: 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 });
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":"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"}
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,
@@ -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
- <main id="main-content">
110
+ </section>
111
+
112
+ <div id="main-content">
104
113
  <AppLayout :variant="contentWidth">
105
114
  <RouterView />
106
115
  </AppLayout>
107
- </main>
108
- <AppFooter />
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
- // Three named width variants, token-driven padding.
124
- expect(layout).toContain('form-content');
125
- expect(layout).toContain('wide-content');
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
- withDefaults(defineProps<{ variant?: 'page' | 'wide' | 'form' }>(), {
18
- variant: 'page',
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
- <div :class="`${variant}-content`">
24
- <slot />
25
- </div>
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
- .page-content,
30
- .wide-content,
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
- .page-content,
44
- .wide-content,
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
- ? 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
+ });
@@ -5,9 +5,13 @@
5
5
  // consuming view can render badges, formatted values, or action buttons per
6
6
  // column without this component knowing anything about the domain.
7
7
  //
8
- // No native GoA sortable-header component exists (checked -- there isn't
9
- // one), so a sortable column header is hand-rolled here using the standard
10
- // WAI-ARIA "sortable table header" pattern (aria-sort + a real button), not
8
+ // Sortable headers use the native goa-table-sort-header inside a goa-table with
9
+ // sort-mode="single": the element owns the aria-sort, the direction cycling and
10
+ // the visual affordance. An earlier version of this comment asserted no native
11
+ // sortable-header component existed -- it had shipped since 2.0.0. Check the
12
+ // installed package's registered elements rather than trusting a note like that.
13
+ //
14
+ // The superseded hand-rolled version used the standard
11
15
  // invented from scratch.
12
16
  import { useSlots } from 'vue';
13
17
  import GoabButton from '../primitives/GoabButton.vue';
@@ -44,19 +48,23 @@ withDefaults(
44
48
  const emit = defineEmits<{
45
49
  retry: [];
46
50
  pageChange: [page: number];
47
- sort: [key: string];
51
+ /**
52
+ * goa-table owns the direction cycling, so both parts arrive together and the
53
+ * consuming view no longer toggles a direction itself.
54
+ */
55
+ sort: [sortBy: string, sortDir: 'asc' | 'desc'];
48
56
  }>();
49
57
 
50
58
  const slots = useSlots();
51
59
 
52
- function ariaSort(
53
- column: WorkspaceTableColumn,
54
- sortBy?: string,
55
- sortDir?: 'asc' | 'desc',
56
- ): 'ascending' | 'descending' | 'none' | undefined {
57
- if (!column.sortable) return undefined;
58
- if (sortBy !== column.key) return 'none';
59
- return sortDir === 'desc' ? 'descending' : 'ascending';
60
+ // goa-table reports direction numerically -- 1 ascending, -1 descending, 0
61
+ // unsorted -- while useApi's ListQuery and every generated view speak
62
+ // 'asc' | 'desc'. Translating here keeps that difference in one place.
63
+ function onNativeSort(event: Event) {
64
+ const detail = (event as CustomEvent<{ sortBy: string; sortDir: number }>)
65
+ .detail;
66
+ if (!detail?.sortBy) return;
67
+ emit('sort', detail.sortBy, detail.sortDir < 0 ? 'desc' : 'asc');
60
68
  }
61
69
  </script>
62
70
 
@@ -77,25 +85,33 @@ function ariaSort(
77
85
  </goa-callout>
78
86
 
79
87
  <template v-else>
80
- <goa-table width="100%">
88
+ <!-- sort-mode="single" makes goa-table aggregate its sort headers and
89
+ emit one _sort event; the headers own their own aria-sort and
90
+ direction cycling, which this component used to hand-roll. -->
91
+ <!-- The V2 flag is set on both the table and its sort headers: each
92
+ defaults to version 1, and this workspace is on the V2 design system
93
+ (the same reason goa-app-header and goa-pagination carry it). Without
94
+ it the table and its headers render V1 styling inside a V2 app.
95
+ sort-mode="single" is goa-table's default but stated explicitly: it
96
+ decides whether the table emits _sort or _multisort, so the listener
97
+ below only makes sense alongside it. -->
98
+ <goa-table
99
+ width="100%"
100
+ version="2"
101
+ sort-mode="single"
102
+ @_sort="onNativeSort"
103
+ >
81
104
  <thead>
82
105
  <tr>
83
- <th
84
- v-for="column in columns"
85
- :key="column.key"
86
- :aria-sort="ariaSort(column, sortBy, sortDir)"
87
- >
88
- <button
106
+ <th v-for="column in columns" :key="column.key">
107
+ <goa-table-sort-header
89
108
  v-if="column.sortable"
90
- type="button"
91
- class="sort-button"
92
- @click="emit('sort', column.key)"
109
+ version="2"
110
+ :name="column.key"
111
+ :direction="column.key === sortBy ? sortDir : 'none'"
93
112
  >
94
113
  {{ column.label }}
95
- <span aria-hidden="true">{{
96
- sortBy === column.key ? (sortDir === 'desc' ? '▼' : '▲') : ''
97
- }}</span>
98
- </button>
114
+ </goa-table-sort-header>
99
115
  <template v-else>{{ column.label }}</template>
100
116
  </th>
101
117
  <th v-if="slots.actions" />
@@ -133,17 +149,3 @@ function ariaSort(
133
149
  </div>
134
150
  </template>
135
151
 
136
- <style scoped>
137
- .sort-button {
138
- background: none;
139
- border: none;
140
- padding: 0;
141
- font: inherit;
142
- font-weight: inherit;
143
- color: inherit;
144
- cursor: pointer;
145
- display: inline-flex;
146
- align-items: center;
147
- gap: var(--goa-space-2xs, 0.25rem);
148
- }
149
- </style>
@@ -81,6 +81,32 @@ describe('Vue Components Generator', () => {
81
81
  const table = host
82
82
  .read('libs/vue-components/src/lib/patterns/WorkspaceTable.vue')
83
83
  .toString();
84
+ // Sorting is the native element's job: goa-table-sort-header inside a
85
+ // goa-table with sort-mode="single". The hand-rolled button + manual
86
+ // aria-sort is gone, and the comment that claimed no native component
87
+ // existed is corrected.
88
+ expect(table).toContain('<goa-table-sort-header');
89
+ expect(table).toContain('sort-mode="single"');
90
+ // goa-table and goa-table-sort-header both default to V1 styling; this
91
+ // workspace is on V2, the same reason goa-app-header and goa-pagination
92
+ // carry version="2".
93
+ // Asserted per element rather than by counting: a count is hostage to the
94
+ // literal appearing in a comment, which is how this assertion first broke.
95
+ const compactTable = table.replace(/\s+/g, ' ');
96
+ expect(compactTable).toContain(
97
+ '<goa-table width="100%" version="2" sort-mode="single"',
98
+ );
99
+ expect(compactTable).toContain(
100
+ '<goa-table-sort-header v-if="column.sortable" version="2"',
101
+ );
102
+ expect(table).toContain('@_sort="onNativeSort"');
103
+ // Binding forms, not bare words -- the component's own comment explains that
104
+ // the element owns aria-sort, so the word legitimately appears in it.
105
+ expect(table).not.toContain(':aria-sort=');
106
+ expect(table).not.toContain('class="sort-button"');
107
+ // goa-table reports 1/-1/0; the rest of the stack speaks asc/desc.
108
+ expect(table).toContain("detail.sortDir < 0 ? 'desc' : 'asc'");
109
+
84
110
  for (const prop of [':pagenumber=', ':itemcount=', ':perpagecount=']) {
85
111
  expect(table).toContain(prop);
86
112
  }
@@ -129,8 +155,13 @@ describe('Vue Components Generator', () => {
129
155
  }
130
156
  // Local calendar parts, not UTC: in Alberta new Date('2026-08-28') is
131
157
  // 27 Aug 18:00 local, so a stored date would render as the day before.
132
- expect(filterBar).toContain('function toIsoDate');
133
- expect(filterBar).toContain('function fromIsoDate');
158
+ // The local-parts date conversion lives in formatters now, used by
159
+ // FilterBar and by every generated date field, so the rule has one home.
160
+ expect(filterBar).toContain('toIsoDateString');
161
+ expect(filterBar).toContain('fromIsoDateString');
162
+ expect(filterBar).not.toContain('function toIsoDate');
163
+ expect(formattersSrc).toContain('export function toIsoDateString');
164
+ expect(formattersSrc).toContain('export function fromIsoDateString');
134
165
  // The call form, not the bare name: the component's own comments explain why
135
166
  // toISOString() is wrong, so the name legitimately appears in them.
136
167
  expect(filterBar).not.toContain('date.toISOString()');
@@ -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
  }
@@ -2,6 +2,9 @@
2
2
  import { ref, computed, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
4
  import { GoabCheckbox } from '<%= goaImportPath %>';
5
+ <% if (steps.some((s) => s.fields.some((f) => f.type === 'date'))) { -%>
6
+ import { formatDate } from '<%= goaImportPath %>';
7
+ <% } -%>
5
8
  import { useApi } from '../composables/useApi';
6
9
 
7
10
  const route = useRoute();
@@ -79,7 +82,11 @@ async function onSubmit() {
79
82
  <dl>
80
83
  <% step.fields.forEach(function (field) { -%>
81
84
  <dt><%- field.label %></dt>
85
+ <% if (field.type === 'date') { -%>
86
+ <dd>{{ formatDate(record['<%- field.key %>']) }}</dd>
87
+ <% } else { -%>
82
88
  <dd>{{ record['<%- field.key %>'] ?? '—' }}</dd>
89
+ <% } -%>
83
90
  <% }); -%>
84
91
  </dl>
85
92
  </goa-container>
@@ -1,7 +1,24 @@
1
1
  <script setup lang="ts">
2
2
  import { reactive, ref, computed, watch } from 'vue';
3
3
  import { useRoute, useRouter } from 'vue-router';
4
- import { Stepper, StepErrorSummary, GoabInput } from '<%= goaImportPath %>';
4
+ import {
5
+ Stepper,
6
+ StepErrorSummary,
7
+ <% if (stepFields.some((f) => !f.type || f.type === 'text' || f.type === 'number')) { -%>
8
+ GoabInput,
9
+ <% } -%>
10
+ <% if (stepFields.some((f) => f.type === 'textarea')) { -%>
11
+ GoabTextarea,
12
+ <% } -%>
13
+ <% if (stepFields.some((f) => f.type === 'date')) { -%>
14
+ GoabDatePicker,
15
+ fromIsoDateString,
16
+ toIsoDateString,
17
+ <% } -%>
18
+ <% if (stepFields.some((f) => f.type === 'select')) { -%>
19
+ GoabDropdown,
20
+ <% } -%>
21
+ } from '<%= goaImportPath %>';
5
22
  import { useApi } from '../composables/useApi';
6
23
 
7
24
  const STEPS = [
@@ -19,7 +36,7 @@ const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
19
36
 
20
37
  const form = reactive({
21
38
  <% stepFields.forEach(function (field) { -%>
22
- <%- field.key %>: '',
39
+ <%- field.key %>: <%- field.type === 'date' ? 'undefined as Date | undefined' : "''" %>,
23
40
  <% }); -%>
24
41
  });
25
42
 
@@ -53,7 +70,15 @@ async function load() {
53
70
  const data = await get('<%= resource %>', idParam.value);
54
71
  completedSteps.value = Array.isArray(data.completedSteps) ? data.completedSteps : [];
55
72
  <% stepFields.forEach(function (field) { -%>
73
+ <% if (field.type === 'date') { -%>
74
+ if (data['<%- field.key %>'] !== undefined)
75
+ form.<%- field.key %> = fromIsoDateString(data['<%- field.key %>']);
76
+ <% } else if (field.type === 'number') { -%>
77
+ if (data['<%- field.key %>'] !== undefined)
78
+ form.<%- field.key %> = String(data['<%- field.key %>']);
79
+ <% } else { -%>
56
80
  if (data['<%- field.key %>'] !== undefined) form.<%- field.key %> = data['<%- field.key %>'];
81
+ <% } -%>
57
82
  <% }); -%>
58
83
  } catch (e) {
59
84
  loadError.value = e instanceof Error ? e.message : 'Failed to load.';
@@ -67,7 +92,7 @@ async function load() {
67
92
  // rather than reassigned.
68
93
  function resetForm() {
69
94
  <% stepFields.forEach(function (field) { -%>
70
- form.<%- field.key %> = '';
95
+ form.<%- field.key %> = <%- field.type === 'date' ? 'undefined' : "''" %>;
71
96
  <% }); -%>
72
97
  errors.value = [];
73
98
  completedSteps.value = [];
@@ -94,10 +119,26 @@ function validate(): boolean {
94
119
  const found: { message: string; anchor?: string }[] = [];
95
120
  <% stepFields.forEach(function (field) { -%>
96
121
  <% if (field.required !== false) { -%>
122
+ <% if (field.type === 'date') { -%>
123
+ if (!form.<%- field.key %>) {
124
+ found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
125
+ }
126
+ <% } else if (field.type === 'number') { -%>
127
+ if (form.<%- field.key %> === '') {
128
+ found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
129
+ } else if (!Number.isFinite(Number(form.<%- field.key %>))) {
130
+ found.push({ message: '<%- field.label %> must be a number.', anchor: '#field-<%- field.key %>' });
131
+ }
132
+ <% } else { -%>
97
133
  if (!form.<%- field.key %> || !form.<%- field.key %>.trim()) {
98
134
  found.push({ message: '<%- field.label %> is required.', anchor: '#field-<%- field.key %>' });
99
135
  }
100
136
  <% } -%>
137
+ <% } else if (field.type === 'number') { -%>
138
+ if (form.<%- field.key %> !== '' && !Number.isFinite(Number(form.<%- field.key %>))) {
139
+ found.push({ message: '<%- field.label %> must be a number.', anchor: '#field-<%- field.key %>' });
140
+ }
141
+ <% } -%>
101
142
  <% }); -%>
102
143
  errors.value = found;
103
144
  return found.length === 0;
@@ -118,8 +159,17 @@ async function onSaveAndContinue() {
118
159
  saving.value = true;
119
160
  saveError.value = null;
120
161
  try {
162
+ // The form model holds what each control needs (a Date for a picker, a
163
+ // string for a number input); the API wants wire types.
121
164
  const body = {
122
165
  ...form,
166
+ <% stepFields.forEach(function (field) { -%>
167
+ <% if (field.type === 'number') { -%>
168
+ <%- field.key %>: form.<%- field.key %> === '' ? null : Number(form.<%- field.key %>),
169
+ <% } else if (field.type === 'date') { -%>
170
+ <%- field.key %>: toIsoDateString(form.<%- field.key %>) || null,
171
+ <% } -%>
172
+ <% }); -%>
123
173
  completedSteps: [...new Set([...completedSteps.value, '<%- stepKey %>'])],
124
174
  };
125
175
  // A create has to answer with the new record's id for the wizard to advance
@@ -164,7 +214,21 @@ function goBack() {
164
214
 
165
215
  <% stepFields.forEach(function (field) { -%>
166
216
  <goa-form-item id="field-<%- field.key %>" label="<%- field.label %>"<% if (field.required !== false) { %> requirement="required"<% } %>>
217
+ <% if (field.type === 'textarea') { -%>
218
+ <GoabTextarea v-model="form.<%- field.key %>" name="<%- field.key %>" />
219
+ <% } else if (field.type === 'number') { -%>
220
+ <GoabInput v-model="form.<%- field.key %>" name="<%- field.key %>" type="number" />
221
+ <% } else if (field.type === 'date') { -%>
222
+ <GoabDatePicker v-model="form.<%- field.key %>" name="<%- field.key %>" />
223
+ <% } else if (field.type === 'select') { -%>
224
+ <GoabDropdown v-model="form.<%- field.key %>" name="<%- field.key %>">
225
+ <% (field.options || []).forEach(function (option) { -%>
226
+ <goa-dropdown-item value="<%- option.value %>" label="<%- option.label %>" />
227
+ <% }); -%>
228
+ </GoabDropdown>
229
+ <% } else { -%>
167
230
  <GoabInput v-model="form.<%- field.key %>" name="<%- field.key %>" type="text" />
231
+ <% } -%>
168
232
  </goa-form-item>
169
233
  <% }); -%>
170
234
 
@@ -1,6 +1,9 @@
1
1
  export interface IntakeViewField {
2
2
  key: string;
3
3
  label: string;
4
+ type?: 'text' | 'textarea' | 'number' | 'date' | 'select';
5
+ /** `select` only. Rendered as goa-dropdown-item children. */
6
+ options?: { value: string; label: string }[];
4
7
  required?: boolean;
5
8
  }
6
9
 
@@ -33,7 +33,7 @@
33
33
  },
34
34
  "steps": {
35
35
  "type": "string",
36
- "description": "JSON array of steps, in order -- e.g. '[{\"key\":\"personal-info\",\"label\":\"Personal information\",\"fields\":[{\"key\":\"fullName\",\"label\":\"Full name\"}]}]'. Each item: { key, label, fields: [{ key, label, required?: boolean (default true) }] }. A plain array is also accepted when this generator is invoked programmatically. Every field is currently a plain text input -- see the generated AGENTS.md note for other field types."
36
+ "description": "JSON array of steps, in order -- e.g. '[{\"key\":\"personal-info\",\"label\":\"Personal information\",\"fields\":[{\"key\":\"fullName\",\"label\":\"Full name\"},{\"key\":\"born\",\"label\":\"Date of birth\",\"type\":\"date\"}]}]'. Each item: { key, label, fields: [...] }. Each field: { key, label, type?: \"text\"|\"textarea\"|\"number\"|\"date\"|\"select\" (default \"text\"), options?: [{value,label}] (select only, required for it), required?: boolean (default true) }. A number field is submitted as a number and a date as YYYY-MM-DD built from local calendar parts, not UTC. A plain array is also accepted when this generator is invoked programmatically. Nx's CLI option coercion only supports comma-separated primitive lists for array-typed schema properties, not JSON -- a JSON string is the only CLI syntax that survives Nx's own arg parsing."
37
37
  },
38
38
  "requiresAuth": {
39
39
  "type": "boolean",
@@ -41,6 +41,12 @@
41
41
  "default": true
42
42
  }
43
43
  },
44
- "required": ["project", "name", "resource", "route", "steps"],
44
+ "required": [
45
+ "project",
46
+ "name",
47
+ "resource",
48
+ "route",
49
+ "steps"
50
+ ],
45
51
  "additionalProperties": false
46
52
  }
@@ -138,6 +138,78 @@ describe('Vue Intake View Generator', () => {
138
138
  expect(review).toContain('/applications/${idParam.value}/confirmation');
139
139
  }, 30000);
140
140
 
141
+ describe('field types', () => {
142
+ const typedSteps = JSON.stringify([
143
+ {
144
+ key: 'details',
145
+ label: 'Details',
146
+ fields: [
147
+ { key: 'name', label: 'Name' },
148
+ { key: 'story', label: 'Story', type: 'textarea', required: false },
149
+ { key: 'count', label: 'Count', type: 'number' },
150
+ { key: 'occurred', label: 'Occurred', type: 'date' },
151
+ {
152
+ key: 'species',
153
+ label: 'Species',
154
+ type: 'select',
155
+ options: [{ value: 'wolf', label: 'Wolf' }],
156
+ },
157
+ ],
158
+ },
159
+ { key: 'review-it', label: 'Review it', fields: [] },
160
+ ]);
161
+
162
+ it('renders the right control for each type', async () => {
163
+ await generator(host, { ...baseOptions, steps: typedSteps });
164
+ const step = host
165
+ .read('apps/test/src/views/DetailsStepView.vue')
166
+ .toString();
167
+ expect(step).toContain('<GoabTextarea v-model="form.story"');
168
+ expect(step).toContain('type="number"');
169
+ expect(step).toContain('<GoabDatePicker v-model="form.occurred"');
170
+ expect(step).toContain('<goa-dropdown-item value="wolf" label="Wolf" />');
171
+ });
172
+
173
+ it('coerces number and date on save', async () => {
174
+ await generator(host, { ...baseOptions, steps: typedSteps });
175
+ const step = host
176
+ .read('apps/test/src/views/DetailsStepView.vue')
177
+ .toString();
178
+ expect(step).toContain("count: form.count === '' ? null : Number(form.count)");
179
+ expect(step).toContain('occurred: toIsoDateString(form.occurred) || null');
180
+ expect(step).toContain("fromIsoDateString(data['occurred'])");
181
+ });
182
+
183
+ it('validates a number and a date without assuming a string', async () => {
184
+ await generator(host, { ...baseOptions, steps: typedSteps });
185
+ const step = host
186
+ .read('apps/test/src/views/DetailsStepView.vue')
187
+ .toString();
188
+ expect(step).toContain("if (form.count === '')");
189
+ expect(step).toContain('Count must be a number.');
190
+ expect(step).toContain('if (!form.occurred)');
191
+ });
192
+
193
+ it('formats a date field on the review view rather than printing it raw', async () => {
194
+ await generator(host, { ...baseOptions, steps: typedSteps });
195
+ const review = host
196
+ .read('apps/test/src/views/ApplicationReviewView.vue')
197
+ .toString();
198
+ expect(review).toContain("formatDate(record['occurred'])");
199
+ });
200
+
201
+ it('every field is still a text input when no types are given', async () => {
202
+ await generator(host, baseOptions);
203
+ const step = host
204
+ .read('apps/test/src/views/PersonalInfoStepView.vue')
205
+ .toString();
206
+ expect(step).toContain('type="text"');
207
+ for (const unused of ['GoabTextarea', 'GoabDatePicker', 'GoabDropdown']) {
208
+ expect(step).not.toContain(unused);
209
+ }
210
+ });
211
+ });
212
+
141
213
  it('generates a confirmation view showing the reference number', async () => {
142
214
  await generator(host, baseOptions);
143
215
  const confirmation = host
@@ -111,13 +111,11 @@ function onPageChange(newPage: number) {
111
111
  void load();
112
112
  }
113
113
 
114
- function onSort(key: string) {
115
- if (sortBy.value === key) {
116
- sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
117
- } else {
118
- sortBy.value = key;
119
- sortDir.value = 'asc';
120
- }
114
+ // goa-table-sort-header owns the direction cycling and reports both parts, so
115
+ // this no longer toggles anything -- it just records what the table decided.
116
+ function onSort(key: string, dir: 'asc' | 'desc') {
117
+ sortBy.value = key;
118
+ sortDir.value = dir;
121
119
  page.value = 1;
122
120
  void load();
123
121
  }
@@ -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');