@abgov/nx-adsp 13.27.0 → 13.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/generators/vue-admin-crud/vue-admin-crud.js +2 -0
- package/src/generators/vue-admin-crud/vue-admin-crud.js.map +1 -1
- package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +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/Stepper.spec.ts__tmpl__ +42 -0
- package/src/generators/vue-components/files/src/lib/patterns/Stepper.vue__tmpl__ +18 -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 +31 -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 +7 -3
- package/src/generators/vue-intake-view/files/steps/src/views/__stepViewFileName__.vue__tmpl__ +1 -1
- package/src/generators/vue-intake-view/vue-intake-view.js +7 -1
- package/src/generators/vue-intake-view/vue-intake-view.js.map +1 -1
- package/src/generators/vue-intake-view/vue-intake-view.spec.ts +9 -1
- package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +54 -2
- package/src/generators/vue-workspace-view/schema.d.ts +15 -1
- package/src/generators/vue-workspace-view/schema.json +11 -1
- package/src/generators/vue-workspace-view/vue-workspace-view.js +18 -1
- package/src/generators/vue-workspace-view/vue-workspace-view.js.map +1 -1
- package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +73 -4
- package/src/utils/vue-router.d.ts +7 -0
- package/src/utils/vue-router.js +6 -2
- package/src/utils/vue-router.js.map +1 -1
package/package.json
CHANGED
|
@@ -43,11 +43,13 @@ function default_1(host, options) {
|
|
|
43
43
|
path: `${normalizedOptions.route}/:id`,
|
|
44
44
|
componentImportPath: `../views/${normalizedOptions.editViewFileName}.vue`,
|
|
45
45
|
requiresAuth: normalizedOptions.requiresAuth,
|
|
46
|
+
layout: 'form',
|
|
46
47
|
});
|
|
47
48
|
(0, vue_router_1.insertVueRoute)(host, normalizedOptions.projectRoot, normalizedOptions.project, {
|
|
48
49
|
path: normalizedOptions.route,
|
|
49
50
|
componentImportPath: `../views/${normalizedOptions.listViewFileName}.vue`,
|
|
50
51
|
requiresAuth: normalizedOptions.requiresAuth,
|
|
52
|
+
layout: 'wide',
|
|
51
53
|
});
|
|
52
54
|
yield (0, devkit_1.formatFiles)(host);
|
|
53
55
|
});
|
|
@@ -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,
|
|
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"}
|
|
@@ -177,7 +177,7 @@ describe('Vue Admin CRUD Generator', () => {
|
|
|
177
177
|
"component: () => import('../views/RegionsEditView.vue')",
|
|
178
178
|
);
|
|
179
179
|
expect(
|
|
180
|
-
routerTs.split('
|
|
180
|
+
routerTs.split('requiresAuth: true').length - 1,
|
|
181
181
|
).toBe(2);
|
|
182
182
|
// The existing route is untouched, not replaced.
|
|
183
183
|
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
@@ -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,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import Stepper from './Stepper.vue';
|
|
4
|
+
|
|
5
|
+
// goa-form-stepper's own `step` prop (1-based) drives the progress bar and the
|
|
6
|
+
// current-step emphasis; goa-form-step's status enum has no "current" value.
|
|
7
|
+
// Without it the element defaults to 1 and every page of a wizard shows step one
|
|
8
|
+
// as current.
|
|
9
|
+
const steps = [
|
|
10
|
+
{ key: 'a', label: 'A', status: 'complete' as const },
|
|
11
|
+
{ key: 'b', label: 'B', status: 'incomplete' as const },
|
|
12
|
+
{ key: 'c', label: 'C', status: 'not-started' as const },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const stepAttr = (step?: number) =>
|
|
16
|
+
mount(Stepper, { props: step === undefined ? { steps } : { steps, step } })
|
|
17
|
+
.find('goa-form-stepper')
|
|
18
|
+
.attributes('step');
|
|
19
|
+
|
|
20
|
+
describe('Stepper', () => {
|
|
21
|
+
it('forwards the current step to goa-form-stepper', () => {
|
|
22
|
+
expect(stepAttr(2)).toBe('2');
|
|
23
|
+
expect(stepAttr(3)).toBe('3');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('defaults to the first step', () => {
|
|
27
|
+
expect(stepAttr()).toBe('1');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('clamps out of range rather than handing the element a bad progress value', () => {
|
|
31
|
+
// goa-form-stepper sets a <progress> value from this; out of range yields a
|
|
32
|
+
// non-finite value that throws in the browser.
|
|
33
|
+
expect(stepAttr(0)).toBe('1');
|
|
34
|
+
expect(stepAttr(-5)).toBe('1');
|
|
35
|
+
expect(stepAttr(99)).toBe('3');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('clamps to 1 when there are no steps', () => {
|
|
39
|
+
const el = mount(Stepper, { props: { steps: [], step: 4 } }).find('goa-form-stepper');
|
|
40
|
+
expect(el.attributes('step')).toBe('1');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
2
3
|
// Wraps the real, native goa-form-stepper/goa-form-step pair (confirmed via
|
|
3
4
|
// design.alberta.ca/components/form-stepper -- there wasn't one when the spec
|
|
4
5
|
// this is based on was written, hence "nobody used a native GoA stepper"; that
|
|
@@ -17,12 +18,27 @@ export interface StepperStep {
|
|
|
17
18
|
status: 'complete' | 'incomplete' | 'not-started';
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
// `step` is goa-form-stepper's OWN prop (1-based) and is what drives the
|
|
22
|
+
// progress bar and the current-step emphasis. Without it the element defaults
|
|
23
|
+
// to 1, so every page of a wizard renders step one as current and the internal
|
|
24
|
+
// <progress> sits at zero. goa-form-step's status enum has no "current" value —
|
|
25
|
+
// that state lives on the parent, which is easy to miss when reading only the
|
|
26
|
+
// child's API.
|
|
27
|
+
const props = withDefaults(
|
|
28
|
+
defineProps<{ steps: StepperStep[]; step?: number }>(),
|
|
29
|
+
{ step: 1 },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// Clamped: the element sets a <progress> value from this, and an out-of-range
|
|
33
|
+
// number yields a non-finite value that throws in the browser.
|
|
34
|
+
const currentStep = computed(() =>
|
|
35
|
+
Math.min(Math.max(props.step, 1), Math.max(props.steps.length, 1)),
|
|
36
|
+
);
|
|
21
37
|
const emit = defineEmits<{ select: [key: string] }>();
|
|
22
38
|
</script>
|
|
23
39
|
|
|
24
40
|
<template>
|
|
25
|
-
<goa-form-stepper>
|
|
41
|
+
<goa-form-stepper :step="currentStep">
|
|
26
42
|
<goa-form-step
|
|
27
43
|
v-for="(step, index) in props.steps"
|
|
28
44
|
:key="step.key"
|
|
@@ -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,35 @@ 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
|
+
// goa-form-stepper's `step` prop drives the progress bar; without it every
|
|
97
|
+
// page of a wizard renders step one as current.
|
|
98
|
+
const stepper = host
|
|
99
|
+
.read('libs/vue-components/src/lib/patterns/Stepper.vue')
|
|
100
|
+
.toString();
|
|
101
|
+
expect(stepper).toContain(':step="currentStep"');
|
|
102
|
+
expect(stepper).toContain('Math.min(Math.max(props.step, 1)');
|
|
103
|
+
|
|
104
|
+
expect(formattersSrc).toContain('DATE_ONLY');
|
|
105
|
+
expect(formattersSrc).toContain('local.getFullYear() === year');
|
|
106
|
+
|
|
78
107
|
expect(filterBar).toContain('goa-filter-chip');
|
|
79
108
|
expect(filterBar).toContain('goa-details');
|
|
80
109
|
// Precise API forms, not bare words -- 'page' appears in the component's own
|
|
@@ -99,6 +128,8 @@ describe('Vue Components Generator', () => {
|
|
|
99
128
|
for (const spec of [
|
|
100
129
|
'libs/vue-components/src/lib/patterns/FilterBar.spec.ts',
|
|
101
130
|
'libs/vue-components/src/lib/primitives/GoabDatePicker.spec.ts',
|
|
131
|
+
'libs/vue-components/src/lib/patterns/WorkspaceTable.spec.ts',
|
|
132
|
+
'libs/vue-components/src/lib/patterns/Stepper.spec.ts',
|
|
102
133
|
]) {
|
|
103
134
|
expect(host.exists(spec)).toBeTruthy();
|
|
104
135
|
}
|
|
@@ -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');
|
|
@@ -151,6 +153,8 @@ describe('Vue Detail View Generator', () => {
|
|
|
151
153
|
"component: () => import('../views/ApplicationDetailView.vue')",
|
|
152
154
|
);
|
|
153
155
|
expect(routerTs).toContain('meta: { requiresAuth: true }');
|
|
156
|
+
// A record info list is neither a table nor a form -- default width.
|
|
157
|
+
expect(routerTs).not.toContain('layout:');
|
|
154
158
|
// The existing route is untouched, not replaced.
|
|
155
159
|
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
156
160
|
}, 30000);
|
|
@@ -53,7 +53,11 @@ function default_1(host, options) {
|
|
|
53
53
|
steps.forEach((step, index) => {
|
|
54
54
|
const nextStepKey = index === steps.length - 1 ? 'review' : steps[index + 1].key;
|
|
55
55
|
(0, devkit_1.generateFiles)(host, path.join(__dirname, 'files/steps'), projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath, stepViewFileName: stepViewFileName(step.key), stepKey: step.key, stepLabel: step.label, stepFields: step.fields, stepperSteps,
|
|
56
|
-
nextStepKey,
|
|
56
|
+
nextStepKey,
|
|
57
|
+
// 1-based, for goa-form-stepper's own `step` prop. The generator knows
|
|
58
|
+
// which step it is emitting; the view would otherwise have to re-derive
|
|
59
|
+
// it from the route.
|
|
60
|
+
stepNumber: index + 1, tmpl: '' }));
|
|
57
61
|
});
|
|
58
62
|
(0, devkit_1.generateFiles)(host, path.join(__dirname, 'files/shared'), projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath,
|
|
59
63
|
reviewViewFileName,
|
|
@@ -70,6 +74,7 @@ function default_1(host, options) {
|
|
|
70
74
|
path: `${normalizedOptions.route}/:id/review`,
|
|
71
75
|
componentImportPath: `../views/${reviewViewFileName}.vue`,
|
|
72
76
|
requiresAuth: normalizedOptions.requiresAuth,
|
|
77
|
+
layout: 'form',
|
|
73
78
|
});
|
|
74
79
|
for (let i = steps.length - 1; i >= 0; i--) {
|
|
75
80
|
const step = steps[i];
|
|
@@ -77,6 +82,7 @@ function default_1(host, options) {
|
|
|
77
82
|
path: `${normalizedOptions.route}/:id/${step.key}`,
|
|
78
83
|
componentImportPath: `../views/${stepViewFileName(step.key)}.vue`,
|
|
79
84
|
requiresAuth: normalizedOptions.requiresAuth,
|
|
85
|
+
layout: 'form',
|
|
80
86
|
});
|
|
81
87
|
}
|
|
82
88
|
yield (0, devkit_1.formatFiles)(host);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vue-intake-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-intake-view/vue-intake-view.ts"],"names":[],"mappings":";;AAsDA,
|
|
1
|
+
{"version":3,"file":"vue-intake-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-intake-view/vue-intake-view.ts"],"names":[],"mappings":"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"}
|
|
@@ -107,6 +107,14 @@ describe('Vue Intake View Generator', () => {
|
|
|
107
107
|
.toString();
|
|
108
108
|
// Last step in the list moves to review, not another step.
|
|
109
109
|
expect(step2).toContain('/applications/${nextId}/review');
|
|
110
|
+
// Each step tells the Stepper which step it is (1-based) -- the generator
|
|
111
|
+
// knows, so the view doesn't re-derive it from the route. Without it every
|
|
112
|
+
// page renders step one as current.
|
|
113
|
+
expect(step1).toContain(':step="1"');
|
|
114
|
+
expect(step2).toContain(':step="2"');
|
|
115
|
+
// Single-column forms ask for the narrow variant.
|
|
116
|
+
const routerForm = host.read('apps/test/src/router/index.ts').toString();
|
|
117
|
+
expect(routerForm).toContain("layout: 'form'");
|
|
110
118
|
// A required field gets a validation block + requirement="required"; an
|
|
111
119
|
// explicitly non-required one gets neither.
|
|
112
120
|
expect(step2).toContain("found.push({ message: 'Email is required.'");
|
|
@@ -156,7 +164,7 @@ describe('Vue Intake View Generator', () => {
|
|
|
156
164
|
"component: () => import('../views/ApplicationConfirmationView.vue')",
|
|
157
165
|
);
|
|
158
166
|
expect(
|
|
159
|
-
routerTs.split('
|
|
167
|
+
routerTs.split('requiresAuth: true').length - 1,
|
|
160
168
|
).toBe(4);
|
|
161
169
|
// The existing route is untouched, not replaced.
|
|
162
170
|
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { ref, onMounted<% if (filterable) { %>, onUnmounted<% } %> } from 'vue';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
WorkspaceTable,
|
|
5
|
+
<% if (filters.length) { -%>
|
|
6
|
+
FilterBar,
|
|
7
|
+
<% } -%>
|
|
8
|
+
formatCurrency,
|
|
9
|
+
formatDate,
|
|
10
|
+
} from '<%= goaImportPath %>';
|
|
4
11
|
import { useApi } from '../composables/useApi';
|
|
5
12
|
|
|
6
13
|
const { list } = useApi();
|
|
@@ -25,6 +32,38 @@ const search = ref('');
|
|
|
25
32
|
let searchDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
26
33
|
<% } -%>
|
|
27
34
|
|
|
35
|
+
<% if (filters.length) { -%>
|
|
36
|
+
// A ref, not a const: a dropdown's options can be fetched (load them in `load`
|
|
37
|
+
// or a second call and assign into the matching descriptor) without changing
|
|
38
|
+
// FilterBar's contract.
|
|
39
|
+
const filterDescriptors = ref([
|
|
40
|
+
<% filters.forEach(function (filter) { -%>
|
|
41
|
+
{
|
|
42
|
+
key: '<%- filter.key %>',
|
|
43
|
+
label: '<%- filter.label %>',
|
|
44
|
+
type: '<%- filter.type %>' as const,
|
|
45
|
+
<% if (filter.type === 'dropdown') { -%>
|
|
46
|
+
options: <%- JSON.stringify(filter.options ?? []) %>,
|
|
47
|
+
<% if (filter.anyLabel) { -%>
|
|
48
|
+
anyLabel: '<%- filter.anyLabel %>',
|
|
49
|
+
<% } -%>
|
|
50
|
+
<% } -%>
|
|
51
|
+
},
|
|
52
|
+
<% }); -%>
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// Query-ready values, keyed by filter. FilterBar owns the controls and the
|
|
56
|
+
// chips; the page reset and the reload below belong here because `page` is this
|
|
57
|
+
// view's state.
|
|
58
|
+
const filterValues = ref<Record<string, string>>({});
|
|
59
|
+
|
|
60
|
+
function onFiltersChange(next: Record<string, string>) {
|
|
61
|
+
filterValues.value = next;
|
|
62
|
+
page.value = 1;
|
|
63
|
+
void load();
|
|
64
|
+
}
|
|
65
|
+
<% } -%>
|
|
66
|
+
|
|
28
67
|
const PAGE_SIZE = <%= pageSize %>;
|
|
29
68
|
|
|
30
69
|
// load() fires from page change, sort<% if (filterable) { %>, and the debounced search<% } %>, so two requests can
|
|
@@ -48,6 +87,9 @@ async function load() {
|
|
|
48
87
|
<% } -%>
|
|
49
88
|
sortBy: sortBy.value,
|
|
50
89
|
sortDir: sortDir.value,
|
|
90
|
+
<% if (filters.length) { -%>
|
|
91
|
+
filters: filterValues.value,
|
|
92
|
+
<% } -%>
|
|
51
93
|
});
|
|
52
94
|
if (sequence !== loadSequence) return;
|
|
53
95
|
rows.value = result.rows;
|
|
@@ -118,6 +160,16 @@ onUnmounted(() => {
|
|
|
118
160
|
|
|
119
161
|
<goa-spacer vspacing="l" />
|
|
120
162
|
<% } -%>
|
|
163
|
+
<% if (filters.length) { -%>
|
|
164
|
+
|
|
165
|
+
<FilterBar
|
|
166
|
+
:filters="filterDescriptors"
|
|
167
|
+
:model-value="filterValues"
|
|
168
|
+
@update:model-value="onFiltersChange"
|
|
169
|
+
/>
|
|
170
|
+
|
|
171
|
+
<goa-spacer vspacing="l" />
|
|
172
|
+
<% } -%>
|
|
121
173
|
|
|
122
174
|
<WorkspaceTable
|
|
123
175
|
:columns="columns"
|
|
@@ -140,7 +192,7 @@ onUnmounted(() => {
|
|
|
140
192
|
</template>
|
|
141
193
|
<% } else if (column.type === 'date') { -%>
|
|
142
194
|
<template #cell-<%= column.key %>="{ row }">
|
|
143
|
-
{{
|
|
195
|
+
{{ formatDate(row['<%= column.key %>']) }}
|
|
144
196
|
</template>
|
|
145
197
|
<% } else if (column.type === 'currency') { -%>
|
|
146
198
|
<template #cell-<%= column.key %>="{ row }">
|
|
@@ -5,6 +5,16 @@ export interface WorkspaceViewColumn {
|
|
|
5
5
|
sortable?: boolean;
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
+
export interface WorkspaceViewFilter {
|
|
9
|
+
key: string;
|
|
10
|
+
label: string;
|
|
11
|
+
type: 'dropdown' | 'date';
|
|
12
|
+
/** Dropdown only. Omit and populate `filterDescriptors` at runtime to fetch them. */
|
|
13
|
+
options?: { value: string; label: string }[];
|
|
14
|
+
/** Dropdown only. Label for the "no filter" choice. Defaults to "Any". */
|
|
15
|
+
anyLabel?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
8
18
|
export interface Schema {
|
|
9
19
|
project: string;
|
|
10
20
|
name: string;
|
|
@@ -16,6 +26,8 @@ export interface Schema {
|
|
|
16
26
|
* also accepted for programmatic callers (e.g. tests).
|
|
17
27
|
*/
|
|
18
28
|
columns: string | WorkspaceViewColumn[];
|
|
29
|
+
/** Same JSON-string-on-the-CLI reasoning as `columns`. */
|
|
30
|
+
filters?: string | WorkspaceViewFilter[];
|
|
19
31
|
detailRoute?: string;
|
|
20
32
|
heading?: string;
|
|
21
33
|
pageSize?: number;
|
|
@@ -23,11 +35,13 @@ export interface Schema {
|
|
|
23
35
|
requiresAuth?: boolean;
|
|
24
36
|
}
|
|
25
37
|
|
|
26
|
-
export interface NormalizedSchema extends Omit<Schema, 'columns'> {
|
|
38
|
+
export interface NormalizedSchema extends Omit<Schema, 'columns' | 'filters'> {
|
|
27
39
|
projectRoot: string;
|
|
28
40
|
/** PascalCase view name with a "ListView" suffix, e.g. ApplicationsListView. */
|
|
29
41
|
viewFileName: string;
|
|
30
42
|
columns: WorkspaceViewColumn[];
|
|
43
|
+
/** Empty when --filters wasn't given, so the template can skip the FilterBar. */
|
|
44
|
+
filters: WorkspaceViewFilter[];
|
|
31
45
|
heading: string;
|
|
32
46
|
pageSize: number;
|
|
33
47
|
filterable: boolean;
|
|
@@ -35,6 +35,10 @@
|
|
|
35
35
|
"type": "string",
|
|
36
36
|
"description": "JSON array of table columns, in display order -- e.g. '[{\"key\":\"status\",\"label\":\"Status\",\"type\":\"badge\",\"sortable\":true}]'. Each item: { key, label, type?: \"text\"|\"date\"|\"currency\"|\"badge\" (default \"text\"), sortable?: boolean }. 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 actually survives Nx's own arg parsing for a structured list like this."
|
|
37
37
|
},
|
|
38
|
+
"filters": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "JSON array of filter controls rendered above the table by FilterBar, beyond the --filterable search box -- e.g. '[{\"key\":\"status\",\"label\":\"Status\",\"type\":\"dropdown\",\"options\":[{\"value\":\"open\",\"label\":\"Open\"}]},{\"key\":\"from\",\"label\":\"From\",\"type\":\"date\"}]'. Each item: { key, label, type: \"dropdown\"|\"date\", options?: [{value,label}], anyLabel? }. Values are sent as query parameters alongside search/page/sort, so the API decides what each key means. Omit for no filter bar. Same JSON-string-on-the-CLI reasoning as --columns."
|
|
41
|
+
},
|
|
38
42
|
"detailRoute": {
|
|
39
43
|
"type": "string",
|
|
40
44
|
"description": "If set, each row gets a \"View\" action linking to `${detailRoute}/${row.id}` -- typically a vue-detail-view's route with the :id segment dropped. Omit to leave the actions column for manual customization."
|
|
@@ -59,6 +63,12 @@
|
|
|
59
63
|
"default": true
|
|
60
64
|
}
|
|
61
65
|
},
|
|
62
|
-
"required": [
|
|
66
|
+
"required": [
|
|
67
|
+
"project",
|
|
68
|
+
"name",
|
|
69
|
+
"resource",
|
|
70
|
+
"route",
|
|
71
|
+
"columns"
|
|
72
|
+
],
|
|
63
73
|
"additionalProperties": false
|
|
64
74
|
}
|
|
@@ -20,11 +20,26 @@ function parseColumns(columns) {
|
|
|
20
20
|
}
|
|
21
21
|
return parsed;
|
|
22
22
|
}
|
|
23
|
+
// Same JSON-string-on-the-CLI reasoning as parseColumns above.
|
|
24
|
+
function parseFilters(filters) {
|
|
25
|
+
if (filters === undefined || filters === '')
|
|
26
|
+
return [];
|
|
27
|
+
const parsed = typeof filters === 'string' ? JSON.parse(filters) : filters;
|
|
28
|
+
if (!Array.isArray(parsed)) {
|
|
29
|
+
throw new Error('--filters must be a JSON array of { key, label, type: "dropdown"|"date", options?, anyLabel? } objects.');
|
|
30
|
+
}
|
|
31
|
+
for (const filter of parsed) {
|
|
32
|
+
if (filter.type !== 'dropdown' && filter.type !== 'date') {
|
|
33
|
+
throw new Error(`--filters[].type must be "dropdown" or "date"; got "${filter.type}" for "${filter.key}".`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
23
38
|
function normalizeOptions(host, options) {
|
|
24
39
|
var _a, _b, _c, _d, _e;
|
|
25
40
|
const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
|
|
26
41
|
const className = (0, devkit_1.names)(options.name).className;
|
|
27
|
-
return Object.assign(Object.assign({}, options), { projectRoot, columns: parseColumns(options.columns), viewFileName: `${className}ListView`,
|
|
42
|
+
return Object.assign(Object.assign({}, options), { projectRoot, columns: parseColumns(options.columns), filters: parseFilters(options.filters), viewFileName: `${className}ListView`,
|
|
28
43
|
// className is PascalCase (e.g. "Applications") -- space it out for a
|
|
29
44
|
// readable default heading ("Applications").
|
|
30
45
|
heading: (_a = options.heading) !== null && _a !== void 0 ? _a : className.replace(/([A-Z])/g, ' $1').trim(), pageSize: (_b = options.pageSize) !== null && _b !== void 0 ? _b : 20, filterable: (_c = options.filterable) !== null && _c !== void 0 ? _c : true, requiresAuth: (_d = options.requiresAuth) !== null && _d !== void 0 ? _d : true,
|
|
@@ -44,6 +59,8 @@ function default_1(host, options) {
|
|
|
44
59
|
path: normalizedOptions.route,
|
|
45
60
|
componentImportPath: `../views/${normalizedOptions.viewFileName}.vue`,
|
|
46
61
|
requiresAuth: normalizedOptions.requiresAuth,
|
|
62
|
+
// A multi-column table at the 1000px default wraps every cell.
|
|
63
|
+
layout: 'wide',
|
|
47
64
|
});
|
|
48
65
|
yield (0, devkit_1.formatFiles)(host);
|
|
49
66
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vue-workspace-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-workspace-view/vue-workspace-view.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"vue-workspace-view.js","sourceRoot":"","sources":["../../../../../../packages/nx-adsp/src/generators/vue-workspace-view/vue-workspace-view.ts"],"names":[],"mappings":"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"}
|
|
@@ -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');
|
|
@@ -147,6 +149,71 @@ describe('Vue Workspace View Generator', () => {
|
|
|
147
149
|
expect(view).toContain('searchDebounce');
|
|
148
150
|
}, 30000);
|
|
149
151
|
|
|
152
|
+
describe('--filters', () => {
|
|
153
|
+
const filters = JSON.stringify([
|
|
154
|
+
{
|
|
155
|
+
key: 'status',
|
|
156
|
+
label: 'Status',
|
|
157
|
+
type: 'dropdown',
|
|
158
|
+
options: [{ value: 'open', label: 'Open' }],
|
|
159
|
+
anyLabel: 'All statuses',
|
|
160
|
+
},
|
|
161
|
+
{ key: 'from', label: 'From', type: 'date' },
|
|
162
|
+
]);
|
|
163
|
+
|
|
164
|
+
it('wires the shared FilterBar and sends the values as query filters', async () => {
|
|
165
|
+
await generator(host, { ...baseOptions, filters });
|
|
166
|
+
const view = host
|
|
167
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
168
|
+
.toString();
|
|
169
|
+
|
|
170
|
+
expect(view).toContain('FilterBar');
|
|
171
|
+
expect(view).toContain(':filters="filterDescriptors"');
|
|
172
|
+
expect(view).toContain('@update:model-value="onFiltersChange"');
|
|
173
|
+
// The values object goes straight through useApi's adapter.
|
|
174
|
+
expect(view).toContain('filters: filterValues.value');
|
|
175
|
+
// The view owns the page reset, not FilterBar -- it owns `page`.
|
|
176
|
+
expect(view).toContain('page.value = 1');
|
|
177
|
+
// Descriptors are a ref so fetched dropdown options can be assigned in.
|
|
178
|
+
expect(view).toContain('const filterDescriptors = ref([');
|
|
179
|
+
expect(view).toContain("anyLabel: 'All statuses'");
|
|
180
|
+
expect(view).toContain("type: 'date' as const");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('leaves output byte-identical when --filters is omitted', async () => {
|
|
184
|
+
await generator(host, baseOptions);
|
|
185
|
+
const without = host
|
|
186
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
187
|
+
.toString();
|
|
188
|
+
expect(without).not.toContain('FilterBar');
|
|
189
|
+
expect(without).not.toContain('filterValues');
|
|
190
|
+
expect(without).not.toContain('onFiltersChange');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('rejects a filter type it cannot render, naming the offender', async () => {
|
|
194
|
+
await expect(
|
|
195
|
+
generator(host, {
|
|
196
|
+
...baseOptions,
|
|
197
|
+
filters: JSON.stringify([
|
|
198
|
+
{ key: 'x', label: 'X', type: 'checkbox' },
|
|
199
|
+
]),
|
|
200
|
+
}),
|
|
201
|
+
).rejects.toThrow('--filters[].type must be "dropdown" or "date"; got "checkbox" for "x".');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('accepts a real array, the form a programmatic caller passes', async () => {
|
|
205
|
+
await generator(host, {
|
|
206
|
+
...baseOptions,
|
|
207
|
+
filters: [{ key: 'status', label: 'Status', type: 'dropdown' }],
|
|
208
|
+
});
|
|
209
|
+
const view = host
|
|
210
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
211
|
+
.toString();
|
|
212
|
+
expect(view).toContain('FilterBar');
|
|
213
|
+
expect(view).toContain('options: []');
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
150
217
|
it('omits onUnmounted along with the debounce when --filterable=false', async () => {
|
|
151
218
|
// onUnmounted exists only to clear the search debounce, so importing it
|
|
152
219
|
// unconditionally would be an unused import and fail the generated lint.
|
|
@@ -204,7 +271,9 @@ describe('Vue Workspace View Generator', () => {
|
|
|
204
271
|
expect(routerTs).toContain(
|
|
205
272
|
"component: () => import('../views/ApplicationsListView.vue')",
|
|
206
273
|
);
|
|
207
|
-
expect(routerTs).toContain('
|
|
274
|
+
expect(routerTs).toContain('requiresAuth: true');
|
|
275
|
+
// A data table asks for the wide variant -- the generator knows it emitted one.
|
|
276
|
+
expect(routerTs).toContain("layout: 'wide'");
|
|
208
277
|
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
209
278
|
}, 30000);
|
|
210
279
|
|
|
@@ -4,5 +4,12 @@ export interface VueRouteSpec {
|
|
|
4
4
|
/** Relative import path passed to () => import(...), e.g. '../views/FooView.vue'. */
|
|
5
5
|
componentImportPath: string;
|
|
6
6
|
requiresAuth?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Content width for the view, read by App.vue's `contentWidth` and passed to
|
|
9
|
+
* AppLayout. Omit for the 1000px default; a generator emitting a data table
|
|
10
|
+
* should say 'wide' and one emitting a single-column form 'form', since it
|
|
11
|
+
* already knows which it produced.
|
|
12
|
+
*/
|
|
13
|
+
layout?: 'wide' | 'form';
|
|
7
14
|
}
|
|
8
15
|
export declare function insertVueRoute(host: Tree, projectRoot: string, project: string, route: VueRouteSpec): void;
|
package/src/utils/vue-router.js
CHANGED
|
@@ -16,8 +16,12 @@ function insertVueRoute(host, projectRoot, project, route) {
|
|
|
16
16
|
if (markerIndex === -1) {
|
|
17
17
|
throw new Error(`Could not find "${marker}" in ${routerPath} -- has its shape changed since @abgov/nx-adsp:vue-app generated it?`);
|
|
18
18
|
}
|
|
19
|
-
const
|
|
20
|
-
?
|
|
19
|
+
const metaEntries = [
|
|
20
|
+
...(route.requiresAuth ? ['requiresAuth: true'] : []),
|
|
21
|
+
...(route.layout ? [`layout: '${route.layout}'`] : []),
|
|
22
|
+
];
|
|
23
|
+
const metaLine = metaEntries.length
|
|
24
|
+
? `\n meta: { ${metaEntries.join(', ')} },`
|
|
21
25
|
: '';
|
|
22
26
|
const routeSnippet = `{\n` +
|
|
23
27
|
` path: '${route.path}',\n` +
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vue-router.js","sourceRoot":"","sources":["../../../../../packages/nx-adsp/src/utils/vue-router.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"vue-router.js","sourceRoot":"","sources":["../../../../../packages/nx-adsp/src/utils/vue-router.ts"],"names":[],"mappings":";;AAmBA,wCAyCC;AA5CD,6EAA6E;AAC7E,wEAAwE;AACxE,6EAA6E;AAC7E,SAAgB,cAAc,CAC5B,IAAU,EACV,WAAmB,EACnB,OAAe,EACf,KAAmB;;IAEnB,MAAM,UAAU,GAAG,GAAG,WAAW,sBAAsB,CAAC;IACxD,MAAM,OAAO,GAAG,MAAA,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,0CAAE,QAAQ,EAAE,CAAC;IAClD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CACb,kBAAkB,UAAU,WAAW,OAAO,0DAA0D,CACzG,CAAC;IACJ,CAAC;IAED,MAAM,MAAM,GAAG,WAAW,CAAC;IAC3B,MAAM,WAAW,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAC5C,IAAI,WAAW,KAAK,CAAC,CAAC,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CACb,mBAAmB,MAAM,QAAQ,UAAU,sEAAsE,CAClH,CAAC;IACJ,CAAC;IAED,MAAM,WAAW,GAAG;QAClB,GAAG,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QACrD,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,YAAY,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;KACvD,CAAC;IACF,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM;QACjC,CAAC,CAAC,mBAAmB,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK;QAChD,CAAC,CAAC,EAAE,CAAC;IACP,MAAM,YAAY,GAChB,KAAK;QACL,gBAAgB,KAAK,CAAC,IAAI,MAAM;QAChC,kCAAkC,KAAK,CAAC,mBAAmB,MAAM,QAAQ,IAAI;QAC7E,QAAQ,CAAC;IAEX,MAAM,QAAQ,GAAG,WAAW,GAAG,MAAM,CAAC,MAAM,CAAC;IAC7C,MAAM,OAAO,GACX,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC;QAC1B,SAAS,YAAY,EAAE;QACvB,OAAO,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC;IAC1B,IAAI,CAAC,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;AAClC,CAAC"}
|