@abgov/nx-adsp 13.26.0 → 13.27.1
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-components/files/AGENTS.md__tmpl__ +1 -1
- package/src/generators/vue-components/files/src/lib/formatters.spec.ts__tmpl__ +26 -0
- package/src/generators/vue-components/files/src/lib/formatters.ts__tmpl__ +34 -2
- package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.spec.ts__tmpl__ +33 -0
- package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +9 -3
- package/src/generators/vue-components/files/src/lib/primitives/GoabDatePicker.vue__tmpl__ +8 -2
- package/src/generators/vue-components/vue-components.spec.ts +22 -0
- package/src/generators/vue-detail-view/files/src/views/__viewFileName__.vue__tmpl__ +2 -2
- package/src/generators/vue-detail-view/vue-detail-view.spec.ts +5 -3
- package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +2 -2
- package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +5 -3
package/package.json
CHANGED
|
@@ -56,7 +56,7 @@ inline styles.
|
|
|
56
56
|
|---|---|---|
|
|
57
57
|
| `<div style="background:white;border:1px solid …;border-radius:…;padding:…">` | `<goa-container>` | The bordered, padded card box. `type`/`accent` variants |
|
|
58
58
|
| `<div style="display:flex;gap:…;align-items:…">` | `<goa-block>` | Flex row or stack, `gap` and `direction` props |
|
|
59
|
-
| `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(…))">` | `<goa-grid>` | Responsive auto-fit grid, `min-child-width`
|
|
59
|
+
| `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(…))">` | `<goa-grid>` | Responsive auto-fit grid, `minchildwidth` prop (all lowercase — `min-child-width` silently does nothing) |
|
|
60
60
|
| `<h2 style="font-size:…;font-weight:…;color:…">`, styled `span`/`p` | `<goa-text>` | Typography scale: `size`, `weight`, `color`, `mt`/`mb` |
|
|
61
61
|
| `<table>` with `<th style>`/`<td style>` | `<goa-table>` (+ `<goa-table-sort-header>`) | For any table that isn't a paginated list view — `WorkspaceTable` already wraps this for that case |
|
|
62
62
|
| Tab `<button>`s toggling `v-if` blocks | `<goa-tabs>` + `<goa-tab heading="…">` | Slot-based and self-managing: `initialtab` sets the first open tab, no `v-model` and no `@_change` |
|
|
@@ -13,6 +13,32 @@ describe('formatters', () => {
|
|
|
13
13
|
expect(formatDateTime('2026-08-28T14:05:00.000Z')).toMatch(/\d{1,2}:\d{2}/);
|
|
14
14
|
});
|
|
15
15
|
|
|
16
|
+
// The input every generated `type: 'date'` column and field actually carries.
|
|
17
|
+
// Its absence is why a UTC-parsing bug shipped: the suite covered ISO
|
|
18
|
+
// timestamps, Dates and epoch numbers, but never a bare calendar date.
|
|
19
|
+
it.each([
|
|
20
|
+
['2026-07-14'],
|
|
21
|
+
['2026-01-01'],
|
|
22
|
+
['2026-12-31'],
|
|
23
|
+
])('renders the bare calendar date %s unchanged, in any timezone', (value) => {
|
|
24
|
+
expect(formatDate(value)).toBe(value);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not invent a midnight for a date-only value', () => {
|
|
28
|
+
expect(formatDateTime('2026-07-14')).toBe('2026-07-14');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('still renders a time for a real instant', () => {
|
|
32
|
+
expect(formatDateTime('2026-07-14T14:05:00.000Z')).toMatch(/\d{1,2}:\d{2}/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it.each(['2026-13-45', '2026-02-30', '2026-00-10'])(
|
|
36
|
+
'rejects the out-of-range date %s rather than rolling it over',
|
|
37
|
+
(value) => {
|
|
38
|
+
expect(formatDate(value)).toBe('—');
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
16
42
|
it('accepts a Date, an epoch number, and an ISO string alike', () => {
|
|
17
43
|
const iso = '2026-08-28T00:00:00.000Z';
|
|
18
44
|
const expected = formatDate(iso);
|
|
@@ -21,6 +21,13 @@ const currency = new Intl.NumberFormat(LOCALE, {
|
|
|
21
21
|
currency: 'CAD',
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
// A bare YYYY-MM-DD names a calendar date, not an instant, and must be built
|
|
25
|
+
// from local parts. `new Date('2026-07-14')` parses as UTC midnight, which in
|
|
26
|
+
// Alberta (UTC-6/-7) is 13 July 18:00 local — so the naive path renders the day
|
|
27
|
+
// before, and on 1 January the year before. Every `type: 'date'` field the view
|
|
28
|
+
// generators emit arrives in exactly this form.
|
|
29
|
+
const DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
30
|
+
|
|
24
31
|
// `unknown` rather than a date union: views read fields off a record whose shape
|
|
25
32
|
// the generator doesn't know, so every call site would otherwise need a cast.
|
|
26
33
|
// Anything that isn't a usable date becomes the em dash, same as null.
|
|
@@ -34,6 +41,23 @@ function toValidDate(value: unknown): Date | null {
|
|
|
34
41
|
return Number.isNaN(value.getTime()) ? null : value;
|
|
35
42
|
}
|
|
36
43
|
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
|
44
|
+
|
|
45
|
+
if (typeof value === 'string') {
|
|
46
|
+
const parts = DATE_ONLY.exec(value);
|
|
47
|
+
if (parts) {
|
|
48
|
+
const [, year, month, day] = parts.map(Number);
|
|
49
|
+
const local = new Date(year, month - 1, day);
|
|
50
|
+
// Date's constructor rolls over out-of-range parts (month 13 becomes
|
|
51
|
+
// January of the next year) instead of failing, so round-trip the parts
|
|
52
|
+
// to reject '2026-13-45' and '2026-02-30' rather than render a wrong day.
|
|
53
|
+
return local.getFullYear() === year &&
|
|
54
|
+
local.getMonth() === month - 1 &&
|
|
55
|
+
local.getDate() === day
|
|
56
|
+
? local
|
|
57
|
+
: null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
const date = new Date(value);
|
|
38
62
|
return Number.isNaN(date.getTime()) ? null : date;
|
|
39
63
|
}
|
|
@@ -44,10 +68,18 @@ export function formatDate(value: unknown): string {
|
|
|
44
68
|
return date ? dateOnly.format(date) : EMPTY;
|
|
45
69
|
}
|
|
46
70
|
|
|
47
|
-
/**
|
|
71
|
+
/**
|
|
72
|
+
* Date with a short time, for audit/activity timestamps. A date-only value has
|
|
73
|
+
* no time to show, so it falls back to the date alone rather than inventing a
|
|
74
|
+
* midnight that was never in the data.
|
|
75
|
+
*/
|
|
48
76
|
export function formatDateTime(value: unknown): string {
|
|
49
77
|
const date = toValidDate(value);
|
|
50
|
-
|
|
78
|
+
if (!date) return EMPTY;
|
|
79
|
+
if (typeof value === 'string' && DATE_ONLY.test(value)) {
|
|
80
|
+
return dateOnly.format(date);
|
|
81
|
+
}
|
|
82
|
+
return dateAndTime.format(date);
|
|
51
83
|
}
|
|
52
84
|
|
|
53
85
|
/**
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import WorkspaceTable from './WorkspaceTable.vue';
|
|
4
|
+
|
|
5
|
+
// goa-pagination registers pagenumber/itemcount/perpagecount with no
|
|
6
|
+
// attribute: alias, so kebab-case bindings never arrive and the control
|
|
7
|
+
// renders "Page — of NaN".
|
|
8
|
+
describe('WorkspaceTable pagination wiring', () => {
|
|
9
|
+
const props = {
|
|
10
|
+
columns: [{ key: 'a', label: 'A' }],
|
|
11
|
+
rows: [{ a: 1 }],
|
|
12
|
+
loading: false,
|
|
13
|
+
error: null,
|
|
14
|
+
page: 2,
|
|
15
|
+
itemCount: 34,
|
|
16
|
+
perPageCount: 10,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
it('passes the lowercase attribute names the element registers', () => {
|
|
20
|
+
const el = mount(WorkspaceTable, { props }).find('goa-pagination');
|
|
21
|
+
expect(el.exists()).toBe(true);
|
|
22
|
+
expect(el.attributes('pagenumber')).toBe('2');
|
|
23
|
+
expect(el.attributes('itemcount')).toBe('34');
|
|
24
|
+
expect(el.attributes('perpagecount')).toBe('10');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('does not emit the kebab-case names that silently do nothing', () => {
|
|
28
|
+
const el = mount(WorkspaceTable, { props }).find('goa-pagination');
|
|
29
|
+
for (const wrong of ['page-number', 'item-count', 'per-page-count']) {
|
|
30
|
+
expect(el.attributes(wrong)).toBeUndefined();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -115,12 +115,18 @@ function ariaSort(
|
|
|
115
115
|
|
|
116
116
|
<goa-spacer vspacing="l" />
|
|
117
117
|
|
|
118
|
+
<!-- All-lowercase, not kebab-case: goa-pagination registers `pagenumber`,
|
|
119
|
+
`itemcount`, `perpagecount` with no `attribute:` alias, so kebab-case
|
|
120
|
+
bindings never reach it and the control renders "Page — of NaN".
|
|
121
|
+
(goa-work-side-menu's `userName` DOES declare attribute: "user-name",
|
|
122
|
+
which is why the design system looks inconsistent here -- check the
|
|
123
|
+
registered name per element rather than assuming a convention.) -->
|
|
118
124
|
<goa-pagination
|
|
119
125
|
v-if="page && itemCount && perPageCount && itemCount > perPageCount"
|
|
120
126
|
version="2"
|
|
121
|
-
:
|
|
122
|
-
:
|
|
123
|
-
:
|
|
127
|
+
:pagenumber="page"
|
|
128
|
+
:itemcount="itemCount"
|
|
129
|
+
:perpagecount="perPageCount"
|
|
124
130
|
@_change="(e) => emit('pageChange', (e as CustomEvent<{ page: number }>).detail?.page ?? 1)"
|
|
125
131
|
/>
|
|
126
132
|
</template>
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
// wrappers read, so it does not use skeleton A verbatim.
|
|
10
10
|
//
|
|
11
11
|
// Consumers that need a wire format (a query parameter, a JSON body) convert at
|
|
12
|
-
// that boundary
|
|
13
|
-
//
|
|
12
|
+
// that boundary, and must build YYYY-MM-DD from LOCAL calendar parts:
|
|
13
|
+
//
|
|
14
|
+
// `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
15
|
+
//
|
|
16
|
+
// NOT `toISOString().slice(0, 10)`. toISOString goes through UTC, and the UTC
|
|
17
|
+
// day of a local-midnight Date is the previous day anywhere west of Greenwich —
|
|
18
|
+
// in Alberta (UTC-6/-7) that files every date one day early. FilterBar's
|
|
19
|
+
// `toIsoDate` is the reference implementation.
|
|
14
20
|
//
|
|
15
21
|
// Usage: <GoabDatePicker v-model="startDate" name="startDate" :max="new Date()" />
|
|
16
22
|
const model = defineModel<Date | undefined>();
|
|
@@ -75,6 +75,27 @@ describe('Vue Components Generator', () => {
|
|
|
75
75
|
const filterBar = host
|
|
76
76
|
.read('libs/vue-components/src/lib/patterns/FilterBar.vue')
|
|
77
77
|
.toString();
|
|
78
|
+
// goa-pagination registers all-lowercase prop names with no attribute:
|
|
79
|
+
// alias, so kebab-case bindings silently never arrive and the control
|
|
80
|
+
// renders "Page — of NaN".
|
|
81
|
+
const table = host
|
|
82
|
+
.read('libs/vue-components/src/lib/patterns/WorkspaceTable.vue')
|
|
83
|
+
.toString();
|
|
84
|
+
for (const prop of [':pagenumber=', ':itemcount=', ':perpagecount=']) {
|
|
85
|
+
expect(table).toContain(prop);
|
|
86
|
+
}
|
|
87
|
+
for (const wrong of [':page-number=', ':item-count=', ':per-page-count=']) {
|
|
88
|
+
expect(table).not.toContain(wrong);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A bare YYYY-MM-DD is a calendar date, not an instant: parsing it through
|
|
92
|
+
// `new Date(string)` yields UTC midnight, which in Alberta is the day before.
|
|
93
|
+
const formattersSrc = host
|
|
94
|
+
.read('libs/vue-components/src/lib/formatters.ts')
|
|
95
|
+
.toString();
|
|
96
|
+
expect(formattersSrc).toContain('DATE_ONLY');
|
|
97
|
+
expect(formattersSrc).toContain('local.getFullYear() === year');
|
|
98
|
+
|
|
78
99
|
expect(filterBar).toContain('goa-filter-chip');
|
|
79
100
|
expect(filterBar).toContain('goa-details');
|
|
80
101
|
// Precise API forms, not bare words -- 'page' appears in the component's own
|
|
@@ -99,6 +120,7 @@ describe('Vue Components Generator', () => {
|
|
|
99
120
|
for (const spec of [
|
|
100
121
|
'libs/vue-components/src/lib/patterns/FilterBar.spec.ts',
|
|
101
122
|
'libs/vue-components/src/lib/primitives/GoabDatePicker.spec.ts',
|
|
123
|
+
'libs/vue-components/src/lib/patterns/WorkspaceTable.spec.ts',
|
|
102
124
|
]) {
|
|
103
125
|
expect(host.exists(spec)).toBeTruthy();
|
|
104
126
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, watch } from 'vue';
|
|
3
3
|
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
-
import { RecordDetailShell, formatCurrency,
|
|
4
|
+
import { RecordDetailShell, formatCurrency, formatDate } from '<%= goaImportPath %>';
|
|
5
5
|
import { useApi } from '../composables/useApi';
|
|
6
6
|
|
|
7
7
|
// The fetched record's shape isn't known to this generator -- read fields
|
|
@@ -54,7 +54,7 @@ function goBack() {
|
|
|
54
54
|
<% if (field.type === 'badge') { -%>
|
|
55
55
|
<goa-badge type="information" :content="String(record['<%= field.key %>'] ?? '—')" />
|
|
56
56
|
<% } else if (field.type === 'date') { -%>
|
|
57
|
-
{{
|
|
57
|
+
{{ formatDate(record['<%= field.key %>']) }}
|
|
58
58
|
<% } else if (field.type === 'currency') { -%>
|
|
59
59
|
{{ formatCurrency(record['<%= field.key %>']) }}
|
|
60
60
|
<% } else { -%>
|
|
@@ -111,12 +111,14 @@ describe('Vue Detail View Generator', () => {
|
|
|
111
111
|
expect(view).not.toContain('onMounted(');
|
|
112
112
|
expect(view).not.toContain('function formatCurrency');
|
|
113
113
|
|
|
114
|
-
expect(view).toContain('
|
|
114
|
+
expect(view).toContain('formatDate');
|
|
115
115
|
expect(view).not.toContain('function formatDate');
|
|
116
116
|
expect(view).toContain(
|
|
117
117
|
"<goa-badge type=\"information\" :content=\"String(record['status'] ?? '—')\" />",
|
|
118
118
|
);
|
|
119
|
-
|
|
119
|
+
// A type: 'date' column is a calendar date -- formatDate, not
|
|
120
|
+
// formatDateTime, which would show an invented midnight.
|
|
121
|
+
expect(view).toContain("formatDate(record['lastSaved'])");
|
|
120
122
|
expect(view).toContain("formatCurrency(record['requestTotal'])");
|
|
121
123
|
expect(view).toContain("record['serviceModel'] ?? '—'");
|
|
122
124
|
expect(view).toContain('<dt>Status</dt>');
|
|
@@ -128,7 +130,7 @@ describe('Vue Detail View Generator', () => {
|
|
|
128
130
|
view
|
|
129
131
|
.replace(/\s+/g, ' ')
|
|
130
132
|
.match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
|
|
131
|
-
for (const name of ['RecordDetailShell', 'formatCurrency', '
|
|
133
|
+
for (const name of ['RecordDetailShell', 'formatCurrency', 'formatDate']) {
|
|
132
134
|
expect(goaImport).toContain(name);
|
|
133
135
|
}
|
|
134
136
|
expect(view).toContain('<RecordDetailShell');
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, onMounted<% if (filterable) { %>, onUnmounted<% } %> } from 'vue';
|
|
3
|
-
import { WorkspaceTable, formatCurrency,
|
|
3
|
+
import { WorkspaceTable, formatCurrency, formatDate } from '<%= goaImportPath %>';
|
|
4
4
|
import { useApi } from '../composables/useApi';
|
|
5
5
|
|
|
6
6
|
const { list } = useApi();
|
|
@@ -140,7 +140,7 @@ onUnmounted(() => {
|
|
|
140
140
|
</template>
|
|
141
141
|
<% } else if (column.type === 'date') { -%>
|
|
142
142
|
<template #cell-<%= column.key %>="{ row }">
|
|
143
|
-
{{
|
|
143
|
+
{{ formatDate(row['<%= column.key %>']) }}
|
|
144
144
|
</template>
|
|
145
145
|
<% } else if (column.type === 'currency') { -%>
|
|
146
146
|
<template #cell-<%= column.key %>="{ row }">
|
|
@@ -121,7 +121,7 @@ describe('Vue Workspace View Generator', () => {
|
|
|
121
121
|
expect(view).toContain('clearTimeout(searchDebounce)');
|
|
122
122
|
|
|
123
123
|
// Dates come from the shared formatter, not a per-view copy.
|
|
124
|
-
expect(view).toContain('
|
|
124
|
+
expect(view).toContain('formatDate');
|
|
125
125
|
expect(view).not.toContain('function formatDate');
|
|
126
126
|
expect(view).not.toContain('toLocaleString');
|
|
127
127
|
expect(view).not.toContain('Intl.');
|
|
@@ -129,7 +129,9 @@ describe('Vue Workspace View Generator', () => {
|
|
|
129
129
|
expect(view).toContain(
|
|
130
130
|
"<goa-badge type=\"information\" :content=\"String(row['status'] ?? '—')\" />",
|
|
131
131
|
);
|
|
132
|
-
|
|
132
|
+
// A type: 'date' column is a calendar date -- formatDate, not
|
|
133
|
+
// formatDateTime, which would show an invented midnight.
|
|
134
|
+
expect(view).toContain("formatDate(row['lastSaved'])");
|
|
133
135
|
expect(view).toContain("formatCurrency(row['requestTotal'])");
|
|
134
136
|
// Uses the shared table shell, not hand-rolled loading/pagination markup.
|
|
135
137
|
// Read the import's contents rather than an exact line: formatFiles wraps a
|
|
@@ -138,7 +140,7 @@ describe('Vue Workspace View Generator', () => {
|
|
|
138
140
|
view
|
|
139
141
|
.replace(/\s+/g, ' ')
|
|
140
142
|
.match(/import \{[^}]*\} from '@proj\/vue-components';/)?.[0] ?? '';
|
|
141
|
-
for (const name of ['WorkspaceTable', 'formatCurrency', '
|
|
143
|
+
for (const name of ['WorkspaceTable', 'formatCurrency', 'formatDate']) {
|
|
142
144
|
expect(goaImport).toContain(name);
|
|
143
145
|
}
|
|
144
146
|
expect(view).toContain('<WorkspaceTable');
|