@abgov/nx-adsp 13.18.0-beta.4 → 13.18.0-beta.6
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/generators.json +10 -0
- package/package.json +1 -1
- package/src/generators/vue-admin-crud/files/src/views/__editViewFileName__.vue__tmpl__ +136 -0
- package/src/generators/vue-admin-crud/files/src/views/__listViewFileName__.vue__tmpl__ +76 -0
- package/src/generators/vue-admin-crud/schema.d.ts +34 -0
- package/src/generators/vue-admin-crud/schema.json +54 -0
- package/src/generators/vue-admin-crud/vue-admin-crud.d.ts +3 -0
- package/src/generators/vue-admin-crud/vue-admin-crud.js +55 -0
- package/src/generators/vue-admin-crud/vue-admin-crud.js.map +1 -0
- package/src/generators/vue-admin-crud/vue-admin-crud.spec.ts +186 -0
- package/src/generators/vue-components/files/AGENTS.md__tmpl__ +2 -2
- package/src/generators/vue-components/files/src/index.ts__tmpl__ +1 -0
- package/src/generators/vue-components/files/src/lib/patterns/WorkspaceTable.vue__tmpl__ +142 -0
- package/src/generators/vue-components/files/src/vue-components.spec.ts__tmpl__ +1 -0
- package/src/generators/vue-components/vue-components.spec.ts +1 -0
- package/src/generators/vue-workspace-view/files/src/views/__viewFileName__.vue__tmpl__ +158 -0
- package/src/generators/vue-workspace-view/schema.d.ts +37 -0
- package/src/generators/vue-workspace-view/schema.json +64 -0
- package/src/generators/vue-workspace-view/vue-workspace-view.d.ts +3 -0
- package/src/generators/vue-workspace-view/vue-workspace-view.js +51 -0
- package/src/generators/vue-workspace-view/vue-workspace-view.js.map +1 -0
- package/src/generators/vue-workspace-view/vue-workspace-view.spec.ts +183 -0
package/generators.json
CHANGED
|
@@ -65,6 +65,16 @@
|
|
|
65
65
|
"schema": "./src/generators/vue-detail-view/schema.json",
|
|
66
66
|
"description": "Generator that adds a record-detail view (RecordDetailShell + a --fields spec) to an existing vue-app project."
|
|
67
67
|
},
|
|
68
|
+
"vue-workspace-view": {
|
|
69
|
+
"factory": "./src/generators/vue-workspace-view/vue-workspace-view",
|
|
70
|
+
"schema": "./src/generators/vue-workspace-view/schema.json",
|
|
71
|
+
"description": "Generator that adds a staff-facing, paginated list view (WorkspaceTable + a --columns spec) to an existing vue-app project."
|
|
72
|
+
},
|
|
73
|
+
"vue-admin-crud": {
|
|
74
|
+
"factory": "./src/generators/vue-admin-crud/vue-admin-crud",
|
|
75
|
+
"schema": "./src/generators/vue-admin-crud/schema.json",
|
|
76
|
+
"description": "Generator that adds a simple admin CRUD screen pair (WorkspaceTable list + create/update Edit view) to an existing vue-app project."
|
|
77
|
+
},
|
|
68
78
|
"mean": {
|
|
69
79
|
"factory": "./src/generators/mean/mean",
|
|
70
80
|
"schema": "./src/generators/mean/schema.json",
|
package/package.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { reactive, ref, computed, onMounted } from 'vue';
|
|
3
|
+
import { useRoute, useRouter } from 'vue-router';
|
|
4
|
+
import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
|
|
5
|
+
import { GoabInput, GoabCheckbox } from '<%= goaImportPath %>';
|
|
6
|
+
|
|
7
|
+
const route = useRoute();
|
|
8
|
+
const router = useRouter();
|
|
9
|
+
const kc = useKeycloak();
|
|
10
|
+
|
|
11
|
+
const idParam = computed(() => String(route.params.id ?? ''));
|
|
12
|
+
const isNew = computed(() => idParam.value === 'new' || idParam.value === '');
|
|
13
|
+
|
|
14
|
+
const form = reactive({
|
|
15
|
+
<% fields.forEach(function (field) { -%>
|
|
16
|
+
<%- field.key %>: <%- field.type === 'checkbox' ? 'false' : "''" %>,
|
|
17
|
+
<% }); -%>
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const errors = reactive<Record<string, string | undefined>>({});
|
|
21
|
+
const loading = ref(!isNew.value);
|
|
22
|
+
const saving = ref(false);
|
|
23
|
+
// Separate load vs. save errors -- a save failure must keep the form on
|
|
24
|
+
// screen (with an inline "Save failed" callout), not replace it with the
|
|
25
|
+
// top-level "Unable to load" state a load failure shows instead of the form.
|
|
26
|
+
const loadError = ref<string | null>(null);
|
|
27
|
+
const saveError = ref<string | null>(null);
|
|
28
|
+
const successMessage = ref<string | null>(null);
|
|
29
|
+
|
|
30
|
+
async function load() {
|
|
31
|
+
if (isNew.value) return;
|
|
32
|
+
loading.value = true;
|
|
33
|
+
loadError.value = null;
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch(`/api/<%= resource %>/${idParam.value}`);
|
|
36
|
+
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
37
|
+
const data = await res.json();
|
|
38
|
+
<% fields.forEach(function (field) { -%>
|
|
39
|
+
if (data['<%= field.key %>'] !== undefined) form.<%= field.key %> = data['<%= field.key %>'];
|
|
40
|
+
<% }); -%>
|
|
41
|
+
} catch (e) {
|
|
42
|
+
loadError.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
43
|
+
} finally {
|
|
44
|
+
loading.value = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
onMounted(load);
|
|
49
|
+
|
|
50
|
+
function validate(): boolean {
|
|
51
|
+
let valid = true;
|
|
52
|
+
<% fields.forEach(function (field) { -%>
|
|
53
|
+
<% if (field.type !== 'checkbox' && field.required !== false) { -%>
|
|
54
|
+
if (!form.<%= field.key %> || !form.<%= field.key %>.trim()) {
|
|
55
|
+
errors.<%= field.key %> = '<%= field.label %> is required.';
|
|
56
|
+
valid = false;
|
|
57
|
+
} else {
|
|
58
|
+
errors.<%= field.key %> = undefined;
|
|
59
|
+
}
|
|
60
|
+
<% } -%>
|
|
61
|
+
<% }); -%>
|
|
62
|
+
return valid;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function onSubmit() {
|
|
66
|
+
if (!validate()) return;
|
|
67
|
+
saving.value = true;
|
|
68
|
+
saveError.value = null;
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(
|
|
71
|
+
isNew.value ? '/api/<%= resource %>' : `/api/<%= resource %>/${idParam.value}`,
|
|
72
|
+
{
|
|
73
|
+
method: isNew.value ? 'POST' : 'PUT',
|
|
74
|
+
headers: { 'Content-Type': 'application/json' },
|
|
75
|
+
body: JSON.stringify(form),
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
if (!res.ok) throw new Error(`Failed to save (${res.status})`);
|
|
79
|
+
successMessage.value = isNew.value ? '<%= singularLabel %> created.' : '<%= singularLabel %> saved.';
|
|
80
|
+
setTimeout(() => router.push('<%= route %>'), 600);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
saveError.value = e instanceof Error ? e.message : 'Failed to save.';
|
|
83
|
+
} finally {
|
|
84
|
+
saving.value = false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function goBack() {
|
|
89
|
+
router.push('<%= route %>');
|
|
90
|
+
}
|
|
91
|
+
</script>
|
|
92
|
+
|
|
93
|
+
<template>
|
|
94
|
+
<div>
|
|
95
|
+
<h1>{{ isNew ? 'Create <%= singularLabel %>' : 'Edit <%= singularLabel %>' }}</h1>
|
|
96
|
+
|
|
97
|
+
<goa-callout v-if="successMessage" type="success" heading="Saved">
|
|
98
|
+
<p>{{ successMessage }}</p>
|
|
99
|
+
</goa-callout>
|
|
100
|
+
|
|
101
|
+
<div v-else-if="loading" aria-label="Loading">
|
|
102
|
+
<goa-skeleton type="text" size="3" />
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<goa-callout v-else-if="loadError" type="emergency" heading="Unable to load">
|
|
106
|
+
<p>{{ loadError }}</p>
|
|
107
|
+
</goa-callout>
|
|
108
|
+
|
|
109
|
+
<form v-else @submit.prevent="onSubmit">
|
|
110
|
+
<% fields.forEach(function (field) { -%>
|
|
111
|
+
<% if (field.type === 'checkbox') { -%>
|
|
112
|
+
<goa-form-item label="<%= field.label %>">
|
|
113
|
+
<GoabCheckbox v-model="form.<%= field.key %>" name="<%= field.key %>" text="<%= field.label %>" />
|
|
114
|
+
</goa-form-item>
|
|
115
|
+
<% } else { -%>
|
|
116
|
+
<goa-form-item label="<%= field.label %>"<% if (field.required !== false) { %> requirement="required"<% } %> :error="errors.<%= field.key %>">
|
|
117
|
+
<GoabInput v-model="form.<%= field.key %>" name="<%= field.key %>" type="text" />
|
|
118
|
+
</goa-form-item>
|
|
119
|
+
<% } -%>
|
|
120
|
+
<% }); -%>
|
|
121
|
+
|
|
122
|
+
<goa-callout v-if="saveError" type="emergency" heading="Save failed">
|
|
123
|
+
<p>{{ saveError }}</p>
|
|
124
|
+
</goa-callout>
|
|
125
|
+
|
|
126
|
+
<goa-spacer vspacing="l" />
|
|
127
|
+
|
|
128
|
+
<goa-button-group gap="relaxed">
|
|
129
|
+
<goa-button v-if="kc.authenticated" type="primary" :disabled="saving || undefined" @_click="onSubmit">
|
|
130
|
+
{{ isNew ? 'Create <%= singularLabel %>' : 'Save changes' }}
|
|
131
|
+
</goa-button>
|
|
132
|
+
<goa-button type="secondary" @_click="goBack">Cancel</goa-button>
|
|
133
|
+
</goa-button-group>
|
|
134
|
+
</form>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue';
|
|
3
|
+
import { useKeycloak } from '@dsb-norge/vue-keycloak-js';
|
|
4
|
+
import { WorkspaceTable } from '<%= goaImportPath %>';
|
|
5
|
+
|
|
6
|
+
const kc = useKeycloak();
|
|
7
|
+
|
|
8
|
+
const columns = [
|
|
9
|
+
<% fields.forEach(function (field) { -%>
|
|
10
|
+
{ key: '<%= field.key %>', label: '<%= field.label %>' },
|
|
11
|
+
<% }); -%>
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// The fetched rows' shape isn't known to this generator -- read fields
|
|
15
|
+
// defensively rather than declaring (and likely getting wrong) a fake interface.
|
|
16
|
+
const rows = ref<Record<string, unknown>[]>([]);
|
|
17
|
+
const loading = ref(true);
|
|
18
|
+
const error = ref<string | null>(null);
|
|
19
|
+
|
|
20
|
+
async function load() {
|
|
21
|
+
loading.value = true;
|
|
22
|
+
error.value = null;
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch('/api/<%= resource %>');
|
|
25
|
+
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
26
|
+
const data = await res.json();
|
|
27
|
+
// Accept either a bare array or a { results } envelope.
|
|
28
|
+
rows.value = Array.isArray(data) ? data : (data.results ?? []);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
error.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
31
|
+
} finally {
|
|
32
|
+
loading.value = false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onMounted(load);
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<template>
|
|
40
|
+
<div>
|
|
41
|
+
<div class="admin-crud-topbar">
|
|
42
|
+
<h1><%= heading %></h1>
|
|
43
|
+
<router-link v-if="kc.authenticated" to="<%= route %>/new">
|
|
44
|
+
<goa-button type="primary">Create <%= singularLabel %></goa-button>
|
|
45
|
+
</router-link>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<goa-spacer vspacing="m" />
|
|
49
|
+
|
|
50
|
+
<!-- No page/item-count/per-page-count props -- an admin lookup table this
|
|
51
|
+
small doesn't need WorkspaceTable's pagination. -->
|
|
52
|
+
<WorkspaceTable :columns="columns" :rows="rows" :loading="loading" :error="error" @retry="load">
|
|
53
|
+
<% fields.forEach(function (field) { -%>
|
|
54
|
+
<% if (field.type === 'checkbox') { -%>
|
|
55
|
+
<template #cell-<%= field.key %>="{ row }">
|
|
56
|
+
<goa-badge :type="row['<%= field.key %>'] ? 'success' : 'midtone'" :content="row['<%= field.key %>'] ? 'Yes' : 'No'" />
|
|
57
|
+
</template>
|
|
58
|
+
<% } -%>
|
|
59
|
+
<% }); -%>
|
|
60
|
+
<template #actions="{ row }">
|
|
61
|
+
<router-link :to="`<%= route %>/${row.id}`">
|
|
62
|
+
<goa-button type="tertiary" size="compact">Edit</goa-button>
|
|
63
|
+
</router-link>
|
|
64
|
+
</template>
|
|
65
|
+
</WorkspaceTable>
|
|
66
|
+
</div>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<style scoped>
|
|
70
|
+
.admin-crud-topbar {
|
|
71
|
+
display: flex;
|
|
72
|
+
justify-content: space-between;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: var(--goa-space-m);
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface AdminCrudField {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
type?: 'text' | 'checkbox';
|
|
5
|
+
required?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Schema {
|
|
9
|
+
project: string;
|
|
10
|
+
name: string;
|
|
11
|
+
resource: string;
|
|
12
|
+
route: string;
|
|
13
|
+
/**
|
|
14
|
+
* JSON string on the real CLI (Nx's array-typed CLI coercion only supports
|
|
15
|
+
* comma-separated primitives, not JSON). A real array is also accepted for
|
|
16
|
+
* programmatic callers (e.g. tests).
|
|
17
|
+
*/
|
|
18
|
+
fields: string | AdminCrudField[];
|
|
19
|
+
heading?: string;
|
|
20
|
+
singularLabel?: string;
|
|
21
|
+
requiresAuth?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NormalizedSchema extends Omit<Schema, 'fields'> {
|
|
25
|
+
projectRoot: string;
|
|
26
|
+
/** PascalCase view name with a "ListView" suffix, e.g. RegionsListView. */
|
|
27
|
+
listViewFileName: string;
|
|
28
|
+
/** PascalCase view name with an "EditView" suffix, e.g. RegionsEditView. */
|
|
29
|
+
editViewFileName: string;
|
|
30
|
+
fields: AdminCrudField[];
|
|
31
|
+
heading: string;
|
|
32
|
+
singularLabel: string;
|
|
33
|
+
requiresAuth: boolean;
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"id": "NxAdspVueAdminCrud",
|
|
4
|
+
"title": "Vue Admin CRUD (List + Edit)",
|
|
5
|
+
"description": "Generates a simple admin CRUD screen pair (a WorkspaceTable list view with a Create action and per-row Edit, plus a create/update Edit view) into an existing vue-app project. Suited to small lookup-table style admin screens, not large paginated workspaces -- see vue-workspace-view for that.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"project": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "The vue-app project to add the views to.",
|
|
11
|
+
"$default": {
|
|
12
|
+
"$source": "argv",
|
|
13
|
+
"index": 0
|
|
14
|
+
},
|
|
15
|
+
"x-prompt": "Which project should the admin CRUD screens be added to?"
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "View name, e.g. 'regions' generates src/views/RegionsListView.vue and src/views/RegionsEditView.vue.",
|
|
20
|
+
"$default": {
|
|
21
|
+
"$source": "argv",
|
|
22
|
+
"index": 1
|
|
23
|
+
},
|
|
24
|
+
"x-prompt": "What should the views be called?"
|
|
25
|
+
},
|
|
26
|
+
"resource": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "API resource path segment -- fetches /api/<resource> (list), /api/<resource>/:id (load one), POST /api/<resource> (create), PUT /api/<resource>/:id (update)."
|
|
29
|
+
},
|
|
30
|
+
"route": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "List route path added to router/index.ts, e.g. /regions. The edit/create route is added as `${route}/:id` (visiting `${route}/new` creates)."
|
|
33
|
+
},
|
|
34
|
+
"fields": {
|
|
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."
|
|
37
|
+
},
|
|
38
|
+
"heading": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "List page heading. Defaults to the view name, title-cased."
|
|
41
|
+
},
|
|
42
|
+
"singularLabel": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Singular label used in \"Create <label>\"/\"Edit <label>\" headings and buttons. Defaults to --heading (override for irregular plurals, e.g. --heading=Regions --singularLabel=Region)."
|
|
45
|
+
},
|
|
46
|
+
"requiresAuth": {
|
|
47
|
+
"type": "boolean",
|
|
48
|
+
"description": "Whether the generated routes require authentication.",
|
|
49
|
+
"default": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"required": ["project", "name", "resource", "route", "fields"],
|
|
53
|
+
"additionalProperties": false
|
|
54
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = default_1;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const devkit_1 = require("@nx/devkit");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const vue_router_1 = require("../../utils/vue-router");
|
|
8
|
+
const vue_components_1 = require("../vue-components/vue-components");
|
|
9
|
+
// Nx's own CLI option coercion (coerceTypesInOptions in nx/src/utils/params)
|
|
10
|
+
// only knows how to split an array-typed option on commas -- it has no JSON
|
|
11
|
+
// support, so a real `"type": "array"` schema for --fields silently mangles a
|
|
12
|
+
// JSON array into garbage fragments when invoked from the actual CLI (only
|
|
13
|
+
// programmatic callers, like this generator's own unit tests, ever pass a
|
|
14
|
+
// real array). --fields is `"type": "string"` in schema.json specifically so
|
|
15
|
+
// Nx leaves it alone, and this generator parses the JSON itself.
|
|
16
|
+
function parseFields(fields) {
|
|
17
|
+
const parsed = typeof fields === 'string' ? JSON.parse(fields) : fields;
|
|
18
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
19
|
+
throw new Error('--fields must be a non-empty JSON array of { key, label, type?, required? } objects.');
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
function normalizeOptions(host, options) {
|
|
24
|
+
var _a, _b, _c;
|
|
25
|
+
const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
|
|
26
|
+
const className = (0, devkit_1.names)(options.name).className;
|
|
27
|
+
// className is PascalCase (e.g. "Regions") -- space it out for a readable
|
|
28
|
+
// default heading ("Regions").
|
|
29
|
+
const heading = (_a = options.heading) !== null && _a !== void 0 ? _a : className.replace(/([A-Z])/g, ' $1').trim();
|
|
30
|
+
return Object.assign(Object.assign({}, options), { projectRoot, fields: parseFields(options.fields), listViewFileName: `${className}ListView`, editViewFileName: `${className}EditView`, heading, singularLabel: (_b = options.singularLabel) !== null && _b !== void 0 ? _b : heading, requiresAuth: (_c = options.requiresAuth) !== null && _c !== void 0 ? _c : true });
|
|
31
|
+
}
|
|
32
|
+
function default_1(host, options) {
|
|
33
|
+
return tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
34
|
+
const normalizedOptions = normalizeOptions(host, options);
|
|
35
|
+
// Idempotent -- ensures WorkspaceTable exists even in a project scaffolded
|
|
36
|
+
// before vue-components carried it.
|
|
37
|
+
yield (0, vue_components_1.default)(host);
|
|
38
|
+
(0, devkit_1.generateFiles)(host, path.join(__dirname, 'files'), normalizedOptions.projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath: (0, vue_components_1.vueComponentsImportPath)(host), tmpl: '' }));
|
|
39
|
+
// The edit route also serves create: visiting `${route}/new` matches the
|
|
40
|
+
// same `:id` param (id === 'new'), same convention the real reference
|
|
41
|
+
// implementation this was modeled on uses -- no separate create route.
|
|
42
|
+
(0, vue_router_1.insertVueRoute)(host, normalizedOptions.projectRoot, normalizedOptions.project, {
|
|
43
|
+
path: `${normalizedOptions.route}/:id`,
|
|
44
|
+
componentImportPath: `../views/${normalizedOptions.editViewFileName}.vue`,
|
|
45
|
+
requiresAuth: normalizedOptions.requiresAuth,
|
|
46
|
+
});
|
|
47
|
+
(0, vue_router_1.insertVueRoute)(host, normalizedOptions.projectRoot, normalizedOptions.project, {
|
|
48
|
+
path: normalizedOptions.route,
|
|
49
|
+
componentImportPath: `../views/${normalizedOptions.listViewFileName}.vue`,
|
|
50
|
+
requiresAuth: normalizedOptions.requiresAuth,
|
|
51
|
+
});
|
|
52
|
+
yield (0, devkit_1.formatFiles)(host);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=vue-admin-crud.js.map
|
|
@@ -0,0 +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,4BAiCC;;AAnFD,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;SAC7C,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;SAC7C,CAAC,CAAC;QAEH,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addProjectConfiguration,
|
|
3
|
+
readProjectConfiguration,
|
|
4
|
+
Tree,
|
|
5
|
+
} from '@nx/devkit';
|
|
6
|
+
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
|
7
|
+
import generator from './vue-admin-crud';
|
|
8
|
+
import { Schema } from './schema';
|
|
9
|
+
|
|
10
|
+
// Mirrors the shape vue-app's own template generates -- vue-admin-crud retrofits
|
|
11
|
+
// into this file, so the fixture must match what it actually looks for.
|
|
12
|
+
const ROUTER_FIXTURE = `import { createRouter, createWebHistory } from 'vue-router';
|
|
13
|
+
import HomeView from '../views/HomeView.vue';
|
|
14
|
+
|
|
15
|
+
const router = createRouter({
|
|
16
|
+
history: createWebHistory(import.meta.env.BASE_URL),
|
|
17
|
+
routes: [
|
|
18
|
+
{ path: '/', component: HomeView },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default router;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
describe('Vue Admin CRUD Generator', () => {
|
|
26
|
+
let host: Tree;
|
|
27
|
+
const baseOptions: Schema = {
|
|
28
|
+
project: 'test',
|
|
29
|
+
name: 'regions',
|
|
30
|
+
resource: 'regions',
|
|
31
|
+
route: '/regions',
|
|
32
|
+
fields: [
|
|
33
|
+
{ key: 'name', label: 'Name' },
|
|
34
|
+
{ key: 'active', label: 'Active', type: 'checkbox' },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
|
40
|
+
addProjectConfiguration(host, 'test', { root: 'apps/test' });
|
|
41
|
+
host.write('apps/test/src/router/index.ts', ROUTER_FIXTURE);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('throws when --project does not exist', async () => {
|
|
45
|
+
await expect(
|
|
46
|
+
generator(host, { ...baseOptions, project: 'no-such-app' }),
|
|
47
|
+
).rejects.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("throws a clear error when the project isn't a vue-app (no router/index.ts)", async () => {
|
|
51
|
+
addProjectConfiguration(host, 'not-vue', { root: 'apps/not-vue' });
|
|
52
|
+
await expect(
|
|
53
|
+
generator(host, { ...baseOptions, project: 'not-vue' }),
|
|
54
|
+
).rejects.toThrow(/router\/index\.ts/);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('generates the list view with a Create action and per-row Edit, no pagination props', async () => {
|
|
58
|
+
await generator(host, baseOptions);
|
|
59
|
+
|
|
60
|
+
const view = host
|
|
61
|
+
.read('apps/test/src/views/RegionsListView.vue')
|
|
62
|
+
.toString();
|
|
63
|
+
expect(view).toContain('<h1>Regions</h1>');
|
|
64
|
+
expect(view).toContain("fetch('/api/regions')");
|
|
65
|
+
expect(view).toContain("import { WorkspaceTable } from '@proj/vue-components';");
|
|
66
|
+
expect(view).toContain('<WorkspaceTable');
|
|
67
|
+
// No pagination props bound -- this is the "reused without its pagination/
|
|
68
|
+
// filter props" case, unlike vue-workspace-view (the WorkspaceTable tag
|
|
69
|
+
// itself contains a comment explaining why, hence checking for the bound
|
|
70
|
+
// attribute specifically rather than the bare word).
|
|
71
|
+
expect(view).not.toContain(':item-count=');
|
|
72
|
+
expect(view).not.toContain(':per-page-count=');
|
|
73
|
+
expect(view).toContain('to="/regions/new"');
|
|
74
|
+
expect(view).toContain('Create Regions');
|
|
75
|
+
expect(view).toContain(':to="`/regions/${row.id}`"');
|
|
76
|
+
// Checkbox field renders as a Yes/No badge in the list.
|
|
77
|
+
expect(view).toContain("#cell-active=\"{ row }\"");
|
|
78
|
+
expect(view).toContain("row['active'] ? 'Yes' : 'No'");
|
|
79
|
+
}, 30000);
|
|
80
|
+
|
|
81
|
+
it('generates the edit view with create/update, field-level validation, and success + redirect', async () => {
|
|
82
|
+
await generator(host, baseOptions);
|
|
83
|
+
|
|
84
|
+
const view = host
|
|
85
|
+
.read('apps/test/src/views/RegionsEditView.vue')
|
|
86
|
+
.toString();
|
|
87
|
+
expect(view).toContain("idParam.value === 'new' || idParam.value === ''");
|
|
88
|
+
expect(view).toContain("import { GoabInput, GoabCheckbox } from '@proj/vue-components';");
|
|
89
|
+
expect(view).toContain("name.trim()");
|
|
90
|
+
expect(view).toContain("errors.name = 'Name is required.';");
|
|
91
|
+
// Checkbox has no required-validation block.
|
|
92
|
+
expect(view).not.toContain('errors.active');
|
|
93
|
+
expect(view).toContain("method: isNew.value ? 'POST' : 'PUT'");
|
|
94
|
+
expect(view).toContain("isNew.value ? '/api/regions'");
|
|
95
|
+
expect(view).toContain('fetch(`/api/regions/${idParam.value}`');
|
|
96
|
+
expect(view).toContain("router.push('/regions')");
|
|
97
|
+
expect(view).toContain('Create Regions');
|
|
98
|
+
expect(view).toContain('Edit Regions');
|
|
99
|
+
}, 30000);
|
|
100
|
+
|
|
101
|
+
it('uses --singularLabel over --heading for Create/Edit headings when both are set', async () => {
|
|
102
|
+
await generator(host, {
|
|
103
|
+
...baseOptions,
|
|
104
|
+
heading: 'Regions',
|
|
105
|
+
singularLabel: 'Region',
|
|
106
|
+
});
|
|
107
|
+
const list = host.read('apps/test/src/views/RegionsListView.vue').toString();
|
|
108
|
+
const edit = host.read('apps/test/src/views/RegionsEditView.vue').toString();
|
|
109
|
+
expect(list).toContain('<h1>Regions</h1>');
|
|
110
|
+
expect(list).toContain('Create Region');
|
|
111
|
+
expect(edit).toContain('Create Region');
|
|
112
|
+
expect(edit).toContain('Edit Region');
|
|
113
|
+
}, 30000);
|
|
114
|
+
|
|
115
|
+
it('marks a field as not required with --fields[].required=false', async () => {
|
|
116
|
+
await generator(host, {
|
|
117
|
+
...baseOptions,
|
|
118
|
+
fields: [{ key: 'name', label: 'Name', required: false }],
|
|
119
|
+
});
|
|
120
|
+
const edit = host.read('apps/test/src/views/RegionsEditView.vue').toString();
|
|
121
|
+
expect(edit).not.toContain("errors.name = 'Name is required.'");
|
|
122
|
+
expect(edit).not.toContain('requirement="required"');
|
|
123
|
+
}, 30000);
|
|
124
|
+
|
|
125
|
+
it('accepts --fields as a JSON string, the form the real CLI produces', async () => {
|
|
126
|
+
await generator(host, {
|
|
127
|
+
...baseOptions,
|
|
128
|
+
fields: JSON.stringify(baseOptions.fields),
|
|
129
|
+
});
|
|
130
|
+
const view = host
|
|
131
|
+
.read('apps/test/src/views/RegionsListView.vue')
|
|
132
|
+
.toString();
|
|
133
|
+
expect(view).toContain("{ key: 'name', label: 'Name' }");
|
|
134
|
+
}, 30000);
|
|
135
|
+
|
|
136
|
+
it('throws a clear error when --fields is not valid JSON', async () => {
|
|
137
|
+
await expect(
|
|
138
|
+
generator(host, { ...baseOptions, fields: '{not json' }),
|
|
139
|
+
).rejects.toThrow();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('throws a clear error when --fields parses to an empty array', async () => {
|
|
143
|
+
await expect(
|
|
144
|
+
generator(host, { ...baseOptions, fields: '[]' }),
|
|
145
|
+
).rejects.toThrow(/non-empty/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('inserts both the list and edit routes, requiring auth by default', async () => {
|
|
149
|
+
await generator(host, baseOptions);
|
|
150
|
+
|
|
151
|
+
const routerTs = host.read('apps/test/src/router/index.ts').toString();
|
|
152
|
+
expect(routerTs).toContain("path: '/regions'");
|
|
153
|
+
expect(routerTs).toContain("path: '/regions/:id'");
|
|
154
|
+
expect(routerTs).toContain(
|
|
155
|
+
"component: () => import('../views/RegionsListView.vue')",
|
|
156
|
+
);
|
|
157
|
+
expect(routerTs).toContain(
|
|
158
|
+
"component: () => import('../views/RegionsEditView.vue')",
|
|
159
|
+
);
|
|
160
|
+
expect(
|
|
161
|
+
routerTs.split('meta: { requiresAuth: true }').length - 1,
|
|
162
|
+
).toBe(2);
|
|
163
|
+
// The existing route is untouched, not replaced.
|
|
164
|
+
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
165
|
+
}, 30000);
|
|
166
|
+
|
|
167
|
+
it('omits the requiresAuth meta when --requiresAuth=false', async () => {
|
|
168
|
+
await generator(host, { ...baseOptions, requiresAuth: false });
|
|
169
|
+
const routerTs = host.read('apps/test/src/router/index.ts').toString();
|
|
170
|
+
expect(routerTs).not.toContain('requiresAuth');
|
|
171
|
+
}, 30000);
|
|
172
|
+
|
|
173
|
+
it('ensures the shared WorkspaceTable pattern component exists', async () => {
|
|
174
|
+
await generator(host, baseOptions);
|
|
175
|
+
expect(
|
|
176
|
+
host.exists('libs/vue-components/src/lib/patterns/WorkspaceTable.vue'),
|
|
177
|
+
).toBeTruthy();
|
|
178
|
+
}, 30000);
|
|
179
|
+
|
|
180
|
+
it('does not touch the target project configuration', async () => {
|
|
181
|
+
const before = readProjectConfiguration(host, 'test');
|
|
182
|
+
await generator(host, baseOptions);
|
|
183
|
+
const after = readProjectConfiguration(host, 'test');
|
|
184
|
+
expect(after).toEqual(before);
|
|
185
|
+
}, 30000);
|
|
186
|
+
});
|
|
@@ -7,7 +7,7 @@ app in this workspace imports both instead of carrying its own copy. Generated b
|
|
|
7
7
|
| Folder | Contains | Lifespan |
|
|
8
8
|
|---|---|---|
|
|
9
9
|
| `src/lib/primitives/` | Thin `v-model`/idiomatic-event wrappers over individual `goa-*` elements (`GoabInput`, `GoabButton`, …) | **Interim** — see below |
|
|
10
|
-
| `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`) | **Permanent** |
|
|
10
|
+
| `src/lib/patterns/` | Composite, app-shell components (`AppLayout`, `AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`) | **Permanent** |
|
|
11
11
|
|
|
12
12
|
> **⚠️ `primitives/` is interim — do not invest in it as permanent.** It exists
|
|
13
13
|
> only because GoA DS has not yet published an official Vue wrapper package. When
|
|
@@ -142,7 +142,7 @@ detail); just leave it to fall through from the caller.
|
|
|
142
142
|
|
|
143
143
|
A pattern component is app-shell composition — layout, header/footer chrome,
|
|
144
144
|
banners — not a single-element wrapper. Existing examples: `AppLayout`,
|
|
145
|
-
`AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`.
|
|
145
|
+
`AppHeader`, `AppFooter`, `AppSideMenu`, `SessionExpiredBanner`, `RecordDetailShell`, `WorkspaceTable`.
|
|
146
146
|
|
|
147
147
|
- It's fine to compose `primitives/` wrappers inside a pattern component (e.g.
|
|
148
148
|
`SessionExpiredBanner` uses `GoabButton`) — import them with a relative path
|
|
@@ -23,3 +23,4 @@ export { default as AppFooter } from './lib/patterns/AppFooter.vue';
|
|
|
23
23
|
export { default as AppSideMenu } from './lib/patterns/AppSideMenu.vue';
|
|
24
24
|
export { default as SessionExpiredBanner } from './lib/patterns/SessionExpiredBanner.vue';
|
|
25
25
|
export { default as RecordDetailShell } from './lib/patterns/RecordDetailShell.vue';
|
|
26
|
+
export { default as WorkspaceTable } from './lib/patterns/WorkspaceTable.vue';
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Chrome for a staff-facing, paginated list: loading/error/empty states, the
|
|
3
|
+
// goa-table itself, and goa-pagination wiring. NOT the filter bar (varies too
|
|
4
|
+
// much per view) and NOT cell/action content -- those are scoped slots so the
|
|
5
|
+
// consuming view can render badges, formatted values, or action buttons per
|
|
6
|
+
// column without this component knowing anything about the domain.
|
|
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
|
|
11
|
+
// invented from scratch.
|
|
12
|
+
import { useSlots } from 'vue';
|
|
13
|
+
import GoabButton from '../primitives/GoabButton.vue';
|
|
14
|
+
|
|
15
|
+
interface WorkspaceTableColumn {
|
|
16
|
+
key: string;
|
|
17
|
+
label: string;
|
|
18
|
+
sortable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
columns: WorkspaceTableColumn[];
|
|
24
|
+
rows: Record<string, unknown>[];
|
|
25
|
+
rowKey?: string;
|
|
26
|
+
loading?: boolean;
|
|
27
|
+
error?: string | null;
|
|
28
|
+
emptyMessage?: string;
|
|
29
|
+
page?: number;
|
|
30
|
+
itemCount?: number;
|
|
31
|
+
perPageCount?: number;
|
|
32
|
+
sortBy?: string;
|
|
33
|
+
sortDir?: 'asc' | 'desc';
|
|
34
|
+
}>(),
|
|
35
|
+
{
|
|
36
|
+
rowKey: 'id',
|
|
37
|
+
loading: false,
|
|
38
|
+
error: null,
|
|
39
|
+
emptyMessage: 'No results found.',
|
|
40
|
+
perPageCount: 10,
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const emit = defineEmits<{
|
|
45
|
+
retry: [];
|
|
46
|
+
pageChange: [page: number];
|
|
47
|
+
sort: [key: string];
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const slots = useSlots();
|
|
51
|
+
|
|
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
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<div class="workspace-table">
|
|
65
|
+
<div v-if="loading" aria-label="Loading">
|
|
66
|
+
<goa-skeleton type="text" size="4" />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<goa-callout v-else-if="error" type="emergency" heading="Unable to load">
|
|
70
|
+
<p>{{ error }}</p>
|
|
71
|
+
<goa-spacer vspacing="s" />
|
|
72
|
+
<GoabButton type="tertiary" @click="emit('retry')">Retry</GoabButton>
|
|
73
|
+
</goa-callout>
|
|
74
|
+
|
|
75
|
+
<goa-callout v-else-if="rows.length === 0" type="information" heading="No results">
|
|
76
|
+
<p>{{ emptyMessage }}</p>
|
|
77
|
+
</goa-callout>
|
|
78
|
+
|
|
79
|
+
<template v-else>
|
|
80
|
+
<goa-table width="100%">
|
|
81
|
+
<thead>
|
|
82
|
+
<tr>
|
|
83
|
+
<th
|
|
84
|
+
v-for="column in columns"
|
|
85
|
+
:key="column.key"
|
|
86
|
+
:aria-sort="ariaSort(column, sortBy, sortDir)"
|
|
87
|
+
>
|
|
88
|
+
<button
|
|
89
|
+
v-if="column.sortable"
|
|
90
|
+
type="button"
|
|
91
|
+
class="sort-button"
|
|
92
|
+
@click="emit('sort', column.key)"
|
|
93
|
+
>
|
|
94
|
+
{{ column.label }}
|
|
95
|
+
<span aria-hidden="true">{{
|
|
96
|
+
sortBy === column.key ? (sortDir === 'desc' ? '▼' : '▲') : ''
|
|
97
|
+
}}</span>
|
|
98
|
+
</button>
|
|
99
|
+
<template v-else>{{ column.label }}</template>
|
|
100
|
+
</th>
|
|
101
|
+
<th v-if="slots.actions" />
|
|
102
|
+
</tr>
|
|
103
|
+
</thead>
|
|
104
|
+
<tbody>
|
|
105
|
+
<tr v-for="row in rows" :key="String(row[rowKey])">
|
|
106
|
+
<td v-for="column in columns" :key="column.key">
|
|
107
|
+
<slot :name="`cell-${column.key}`" :row="row">{{ row[column.key] }}</slot>
|
|
108
|
+
</td>
|
|
109
|
+
<td v-if="slots.actions">
|
|
110
|
+
<slot name="actions" :row="row" />
|
|
111
|
+
</td>
|
|
112
|
+
</tr>
|
|
113
|
+
</tbody>
|
|
114
|
+
</goa-table>
|
|
115
|
+
|
|
116
|
+
<goa-spacer vspacing="l" />
|
|
117
|
+
|
|
118
|
+
<goa-pagination
|
|
119
|
+
v-if="page && itemCount && perPageCount && itemCount > perPageCount"
|
|
120
|
+
:page-number="page"
|
|
121
|
+
:item-count="itemCount"
|
|
122
|
+
:per-page-count="perPageCount"
|
|
123
|
+
@_change="(e) => emit('pageChange', (e as CustomEvent<{ page: number }>).detail?.page ?? 1)"
|
|
124
|
+
/>
|
|
125
|
+
</template>
|
|
126
|
+
</div>
|
|
127
|
+
</template>
|
|
128
|
+
|
|
129
|
+
<style scoped>
|
|
130
|
+
.sort-button {
|
|
131
|
+
background: none;
|
|
132
|
+
border: none;
|
|
133
|
+
padding: 0;
|
|
134
|
+
font: inherit;
|
|
135
|
+
font-weight: inherit;
|
|
136
|
+
color: inherit;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
display: inline-flex;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: var(--goa-space-2xs, 0.25rem);
|
|
141
|
+
}
|
|
142
|
+
</style>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, onMounted } from 'vue';
|
|
3
|
+
import { WorkspaceTable } from '<%= goaImportPath %>';
|
|
4
|
+
|
|
5
|
+
const columns = [
|
|
6
|
+
<% columns.forEach(function (column) { -%>
|
|
7
|
+
{ key: '<%= column.key %>', label: '<%= column.label %>', sortable: <%= !!column.sortable %> },
|
|
8
|
+
<% }); -%>
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
// The fetched rows' shape isn't known to this generator -- read fields
|
|
12
|
+
// defensively rather than declaring (and likely getting wrong) a fake interface.
|
|
13
|
+
const rows = ref<Record<string, unknown>[]>([]);
|
|
14
|
+
const loading = ref(true);
|
|
15
|
+
const error = ref<string | null>(null);
|
|
16
|
+
const page = ref(1);
|
|
17
|
+
const itemCount = ref(0);
|
|
18
|
+
const sortBy = ref<string>();
|
|
19
|
+
const sortDir = ref<'asc' | 'desc'>('asc');
|
|
20
|
+
<% if (filterable) { -%>
|
|
21
|
+
const search = ref('');
|
|
22
|
+
let searchDebounce: ReturnType<typeof setTimeout> | undefined;
|
|
23
|
+
<% } -%>
|
|
24
|
+
|
|
25
|
+
const PAGE_SIZE = <%= pageSize %>;
|
|
26
|
+
|
|
27
|
+
async function load() {
|
|
28
|
+
loading.value = true;
|
|
29
|
+
error.value = null;
|
|
30
|
+
try {
|
|
31
|
+
const params = new URLSearchParams({
|
|
32
|
+
page: String(page.value),
|
|
33
|
+
limit: String(PAGE_SIZE),
|
|
34
|
+
});
|
|
35
|
+
<% if (filterable) { -%>
|
|
36
|
+
if (search.value) params.set('search', search.value);
|
|
37
|
+
<% } -%>
|
|
38
|
+
if (sortBy.value) {
|
|
39
|
+
params.set('sortBy', sortBy.value);
|
|
40
|
+
params.set('sortDir', sortDir.value);
|
|
41
|
+
}
|
|
42
|
+
const res = await fetch(`/api/<%= resource %>?${params.toString()}`);
|
|
43
|
+
if (!res.ok) throw new Error(`Failed to load (${res.status})`);
|
|
44
|
+
const data = await res.json();
|
|
45
|
+
// Accept either a bare array or a { results, total } page envelope.
|
|
46
|
+
rows.value = Array.isArray(data) ? data : (data.results ?? []);
|
|
47
|
+
itemCount.value = Array.isArray(data) ? data.length : (data.total ?? rows.value.length);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
error.value = e instanceof Error ? e.message : 'Failed to load.';
|
|
50
|
+
} finally {
|
|
51
|
+
loading.value = false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
onMounted(load);
|
|
56
|
+
|
|
57
|
+
function onPageChange(newPage: number) {
|
|
58
|
+
page.value = newPage;
|
|
59
|
+
void load();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onSort(key: string) {
|
|
63
|
+
if (sortBy.value === key) {
|
|
64
|
+
sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
|
65
|
+
} else {
|
|
66
|
+
sortBy.value = key;
|
|
67
|
+
sortDir.value = 'asc';
|
|
68
|
+
}
|
|
69
|
+
page.value = 1;
|
|
70
|
+
void load();
|
|
71
|
+
}
|
|
72
|
+
<% if (filterable) { -%>
|
|
73
|
+
|
|
74
|
+
function onSearchInput(e: Event) {
|
|
75
|
+
search.value = String((e as CustomEvent<{ value?: string }>).detail?.value ?? '');
|
|
76
|
+
if (searchDebounce) clearTimeout(searchDebounce);
|
|
77
|
+
searchDebounce = setTimeout(() => {
|
|
78
|
+
page.value = 1;
|
|
79
|
+
void load();
|
|
80
|
+
}, 300);
|
|
81
|
+
}
|
|
82
|
+
<% } -%>
|
|
83
|
+
|
|
84
|
+
function formatDate(value: unknown): string {
|
|
85
|
+
if (!value) return '—';
|
|
86
|
+
try {
|
|
87
|
+
return new Date(String(value)).toLocaleString('en-CA');
|
|
88
|
+
} catch {
|
|
89
|
+
return String(value);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatCurrency(value: unknown): string {
|
|
94
|
+
if (value === undefined || value === null || value === '') return '—';
|
|
95
|
+
const n = Number(value);
|
|
96
|
+
if (Number.isNaN(n)) return String(value);
|
|
97
|
+
return new Intl.NumberFormat('en-CA', { style: 'currency', currency: 'CAD' }).format(n);
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
<template>
|
|
102
|
+
<div>
|
|
103
|
+
<h1><%= heading %></h1>
|
|
104
|
+
|
|
105
|
+
<goa-spacer vspacing="m" />
|
|
106
|
+
<% if (filterable) { -%>
|
|
107
|
+
|
|
108
|
+
<goa-input
|
|
109
|
+
name="search"
|
|
110
|
+
type="search"
|
|
111
|
+
:value="search"
|
|
112
|
+
placeholder="Search..."
|
|
113
|
+
width="320px"
|
|
114
|
+
@_change="onSearchInput"
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
<goa-spacer vspacing="l" />
|
|
118
|
+
<% } -%>
|
|
119
|
+
|
|
120
|
+
<WorkspaceTable
|
|
121
|
+
:columns="columns"
|
|
122
|
+
:rows="rows"
|
|
123
|
+
:loading="loading"
|
|
124
|
+
:error="error"
|
|
125
|
+
:page="page"
|
|
126
|
+
:item-count="itemCount"
|
|
127
|
+
:per-page-count="<%= pageSize %>"
|
|
128
|
+
:sort-by="sortBy"
|
|
129
|
+
:sort-dir="sortDir"
|
|
130
|
+
@retry="load"
|
|
131
|
+
@page-change="onPageChange"
|
|
132
|
+
@sort="onSort"
|
|
133
|
+
>
|
|
134
|
+
<% columns.forEach(function (column) { -%>
|
|
135
|
+
<% if (column.type === 'badge') { -%>
|
|
136
|
+
<template #cell-<%= column.key %>="{ row }">
|
|
137
|
+
<goa-badge type="information" :content="String(row['<%= column.key %>'] ?? '—')" />
|
|
138
|
+
</template>
|
|
139
|
+
<% } else if (column.type === 'date') { -%>
|
|
140
|
+
<template #cell-<%= column.key %>="{ row }">
|
|
141
|
+
{{ formatDate(row['<%= column.key %>']) }}
|
|
142
|
+
</template>
|
|
143
|
+
<% } else if (column.type === 'currency') { -%>
|
|
144
|
+
<template #cell-<%= column.key %>="{ row }">
|
|
145
|
+
{{ formatCurrency(row['<%= column.key %>']) }}
|
|
146
|
+
</template>
|
|
147
|
+
<% } -%>
|
|
148
|
+
<% }); -%>
|
|
149
|
+
<% if (detailRoute) { -%>
|
|
150
|
+
<template #actions="{ row }">
|
|
151
|
+
<router-link :to="`<%= detailRoute %>/${row.id}`">
|
|
152
|
+
<goa-button type="tertiary" size="compact">View</goa-button>
|
|
153
|
+
</router-link>
|
|
154
|
+
</template>
|
|
155
|
+
<% } -%>
|
|
156
|
+
</WorkspaceTable>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface WorkspaceViewColumn {
|
|
2
|
+
key: string;
|
|
3
|
+
label: string;
|
|
4
|
+
type?: 'text' | 'date' | 'currency' | 'badge';
|
|
5
|
+
sortable?: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Schema {
|
|
9
|
+
project: string;
|
|
10
|
+
name: string;
|
|
11
|
+
resource: string;
|
|
12
|
+
route: string;
|
|
13
|
+
/**
|
|
14
|
+
* JSON string on the real CLI (Nx's array-typed CLI coercion only supports
|
|
15
|
+
* comma-separated primitives, not JSON -- see schema.json). A real array is
|
|
16
|
+
* also accepted for programmatic callers (e.g. tests).
|
|
17
|
+
*/
|
|
18
|
+
columns: string | WorkspaceViewColumn[];
|
|
19
|
+
detailRoute?: string;
|
|
20
|
+
heading?: string;
|
|
21
|
+
pageSize?: number;
|
|
22
|
+
filterable?: boolean;
|
|
23
|
+
requiresAuth?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface NormalizedSchema extends Omit<Schema, 'columns'> {
|
|
27
|
+
projectRoot: string;
|
|
28
|
+
/** PascalCase view name with a "ListView" suffix, e.g. ApplicationsListView. */
|
|
29
|
+
viewFileName: string;
|
|
30
|
+
columns: WorkspaceViewColumn[];
|
|
31
|
+
heading: string;
|
|
32
|
+
pageSize: number;
|
|
33
|
+
filterable: boolean;
|
|
34
|
+
requiresAuth: boolean;
|
|
35
|
+
/** Always present (null when not given) so the template's `with` binding can reference it. */
|
|
36
|
+
detailRoute: string | null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"id": "NxAdspVueWorkspaceView",
|
|
4
|
+
"title": "Vue Workspace (List) View",
|
|
5
|
+
"description": "Generates a staff-facing, paginated list view (WorkspaceTable + a debounced search filter bar) into an existing vue-app project.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"properties": {
|
|
8
|
+
"project": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "The vue-app project to add the view to.",
|
|
11
|
+
"$default": {
|
|
12
|
+
"$source": "argv",
|
|
13
|
+
"index": 0
|
|
14
|
+
},
|
|
15
|
+
"x-prompt": "Which project should the workspace view be added to?"
|
|
16
|
+
},
|
|
17
|
+
"name": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "View name, e.g. 'applications' generates src/views/ApplicationsListView.vue.",
|
|
20
|
+
"$default": {
|
|
21
|
+
"$source": "argv",
|
|
22
|
+
"index": 1
|
|
23
|
+
},
|
|
24
|
+
"x-prompt": "What should the view be called?"
|
|
25
|
+
},
|
|
26
|
+
"resource": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"description": "API resource path segment -- the view fetches /api/<resource>?page=&limit=&search=&sortBy=&sortDir=."
|
|
29
|
+
},
|
|
30
|
+
"route": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Route path added to router/index.ts, e.g. /applications."
|
|
33
|
+
},
|
|
34
|
+
"columns": {
|
|
35
|
+
"type": "string",
|
|
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
|
+
},
|
|
38
|
+
"detailRoute": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"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."
|
|
41
|
+
},
|
|
42
|
+
"heading": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"description": "Page heading. Defaults to the view name, title-cased."
|
|
45
|
+
},
|
|
46
|
+
"pageSize": {
|
|
47
|
+
"type": "number",
|
|
48
|
+
"description": "Rows per page.",
|
|
49
|
+
"default": 20
|
|
50
|
+
},
|
|
51
|
+
"filterable": {
|
|
52
|
+
"type": "boolean",
|
|
53
|
+
"description": "Whether to generate a debounced search input above the table.",
|
|
54
|
+
"default": true
|
|
55
|
+
},
|
|
56
|
+
"requiresAuth": {
|
|
57
|
+
"type": "boolean",
|
|
58
|
+
"description": "Whether the generated route requires authentication.",
|
|
59
|
+
"default": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"required": ["project", "name", "resource", "route", "columns"],
|
|
63
|
+
"additionalProperties": false
|
|
64
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.default = default_1;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const devkit_1 = require("@nx/devkit");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const vue_router_1 = require("../../utils/vue-router");
|
|
8
|
+
const vue_components_1 = require("../vue-components/vue-components");
|
|
9
|
+
// Nx's own CLI option coercion (coerceTypesInOptions in nx/src/utils/params)
|
|
10
|
+
// only knows how to split an array-typed option on commas -- it has no JSON
|
|
11
|
+
// support, so a real `"type": "array"` schema for --columns silently mangles
|
|
12
|
+
// a JSON array into garbage fragments when invoked from the actual CLI (only
|
|
13
|
+
// programmatic callers, like this generator's own unit tests, ever pass a
|
|
14
|
+
// real array). --columns is `"type": "string"` in schema.json specifically so
|
|
15
|
+
// Nx leaves it alone, and this generator parses the JSON itself.
|
|
16
|
+
function parseColumns(columns) {
|
|
17
|
+
const parsed = typeof columns === 'string' ? JSON.parse(columns) : columns;
|
|
18
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
19
|
+
throw new Error('--columns must be a non-empty JSON array of { key, label, type?, sortable? } objects.');
|
|
20
|
+
}
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
function normalizeOptions(host, options) {
|
|
24
|
+
var _a, _b, _c, _d, _e;
|
|
25
|
+
const { root: projectRoot } = (0, devkit_1.readProjectConfiguration)(host, options.project);
|
|
26
|
+
const className = (0, devkit_1.names)(options.name).className;
|
|
27
|
+
return Object.assign(Object.assign({}, options), { projectRoot, columns: parseColumns(options.columns), viewFileName: `${className}ListView`,
|
|
28
|
+
// className is PascalCase (e.g. "Applications") -- space it out for a
|
|
29
|
+
// readable default heading ("Applications").
|
|
30
|
+
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,
|
|
31
|
+
// EJS's `with` binding only exposes keys that exist on the options object --
|
|
32
|
+
// when --detailRoute is omitted, the key is absent (not undefined), so the
|
|
33
|
+
// template's `<% if (detailRoute) %>` throws a ReferenceError without this.
|
|
34
|
+
detailRoute: (_e = options.detailRoute) !== null && _e !== void 0 ? _e : null });
|
|
35
|
+
}
|
|
36
|
+
function default_1(host, options) {
|
|
37
|
+
return tslib_1.__awaiter(this, void 0, void 0, function* () {
|
|
38
|
+
const normalizedOptions = normalizeOptions(host, options);
|
|
39
|
+
// Idempotent -- ensures WorkspaceTable exists even in a project scaffolded
|
|
40
|
+
// before vue-components carried it.
|
|
41
|
+
yield (0, vue_components_1.default)(host);
|
|
42
|
+
(0, devkit_1.generateFiles)(host, path.join(__dirname, 'files'), normalizedOptions.projectRoot, Object.assign(Object.assign({}, normalizedOptions), { goaImportPath: (0, vue_components_1.vueComponentsImportPath)(host), tmpl: '' }));
|
|
43
|
+
(0, vue_router_1.insertVueRoute)(host, normalizedOptions.projectRoot, normalizedOptions.project, {
|
|
44
|
+
path: normalizedOptions.route,
|
|
45
|
+
componentImportPath: `../views/${normalizedOptions.viewFileName}.vue`,
|
|
46
|
+
requiresAuth: normalizedOptions.requiresAuth,
|
|
47
|
+
});
|
|
48
|
+
yield (0, devkit_1.formatFiles)(host);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=vue-workspace-view.js.map
|
|
@@ -0,0 +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":";;AAqDA,4BAyBC;;AA9ED,uCAMoB;AACpB,6BAA6B;AAC7B,uDAAwD;AACxD,qEAE0C;AAG1C,6EAA6E;AAC7E,4EAA4E;AAC5E,6EAA6E;AAC7E,6EAA6E;AAC7E,0EAA0E;AAC1E,8EAA8E;AAC9E,iEAAiE;AACjE,SAAS,YAAY,CAAC,OAA0B;IAC9C,MAAM,MAAM,GAAG,OAAO,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAC3E,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,MAAM,IAAI,KAAK,CACb,uFAAuF,CACxF,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,uCACK,OAAO,KACV,WAAW,EACX,OAAO,EAAE,YAAY,CAAC,OAAO,CAAC,OAAO,CAAC,EACtC,YAAY,EAAE,GAAG,SAAS,UAAU;QACpC,sEAAsE;QACtE,6CAA6C;QAC7C,OAAO,EAAE,MAAA,OAAO,CAAC,OAAO,mCAAI,SAAS,CAAC,OAAO,CAAC,UAAU,EAAE,KAAK,CAAC,CAAC,IAAI,EAAE,EACvE,QAAQ,EAAE,MAAA,OAAO,CAAC,QAAQ,mCAAI,EAAE,EAChC,UAAU,EAAE,MAAA,OAAO,CAAC,UAAU,mCAAI,IAAI,EACtC,YAAY,EAAE,MAAA,OAAO,CAAC,YAAY,mCAAI,IAAI;QAC1C,6EAA6E;QAC7E,2EAA2E;QAC3E,4EAA4E;QAC5E,WAAW,EAAE,MAAA,OAAO,CAAC,WAAW,mCAAI,IAAI,IACxC;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,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,YAAY,MAAM;YACrE,YAAY,EAAE,iBAAiB,CAAC,YAAY;SAC7C,CAAC,CAAC;QAEH,MAAM,IAAA,oBAAW,EAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;CAAA"}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addProjectConfiguration,
|
|
3
|
+
readProjectConfiguration,
|
|
4
|
+
Tree,
|
|
5
|
+
} from '@nx/devkit';
|
|
6
|
+
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
|
|
7
|
+
import generator from './vue-workspace-view';
|
|
8
|
+
import { Schema } from './schema';
|
|
9
|
+
|
|
10
|
+
// Mirrors the shape vue-app's own template generates -- vue-workspace-view
|
|
11
|
+
// retrofits into this file, so the fixture must match what it actually looks for.
|
|
12
|
+
const ROUTER_FIXTURE = `import { createRouter, createWebHistory } from 'vue-router';
|
|
13
|
+
import HomeView from '../views/HomeView.vue';
|
|
14
|
+
|
|
15
|
+
const router = createRouter({
|
|
16
|
+
history: createWebHistory(import.meta.env.BASE_URL),
|
|
17
|
+
routes: [
|
|
18
|
+
{ path: '/', component: HomeView },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export default router;
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
describe('Vue Workspace View Generator', () => {
|
|
26
|
+
let host: Tree;
|
|
27
|
+
const baseOptions: Schema = {
|
|
28
|
+
project: 'test',
|
|
29
|
+
name: 'applications',
|
|
30
|
+
resource: 'applications',
|
|
31
|
+
route: '/applications',
|
|
32
|
+
columns: [
|
|
33
|
+
{ key: 'status', label: 'Status', type: 'badge' },
|
|
34
|
+
{ key: 'lastSaved', label: 'Last saved', type: 'date', sortable: true },
|
|
35
|
+
{ key: 'requestTotal', label: 'Request total', type: 'currency' },
|
|
36
|
+
{ key: 'serviceModel', label: 'Service Model' },
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
host = createTreeWithEmptyWorkspace({ layout: 'apps-libs' });
|
|
42
|
+
addProjectConfiguration(host, 'test', { root: 'apps/test' });
|
|
43
|
+
host.write('apps/test/src/router/index.ts', ROUTER_FIXTURE);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('throws when --project does not exist', async () => {
|
|
47
|
+
await expect(
|
|
48
|
+
generator(host, { ...baseOptions, project: 'no-such-app' }),
|
|
49
|
+
).rejects.toThrow();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("throws a clear error when the project isn't a vue-app (no router/index.ts)", async () => {
|
|
53
|
+
addProjectConfiguration(host, 'not-vue', { root: 'apps/not-vue' });
|
|
54
|
+
await expect(
|
|
55
|
+
generator(host, { ...baseOptions, project: 'not-vue' }),
|
|
56
|
+
).rejects.toThrow(/router\/index\.ts/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Regression guard: Nx's own CLI option coercion for `"type": "array"` splits
|
|
60
|
+
// on commas, not JSON -- --columns is `"type": "string"` in schema.json
|
|
61
|
+
// specifically so the real CLI's JSON string survives unmangled. Assert the
|
|
62
|
+
// string form the CLI actually produces, not just the array form direct
|
|
63
|
+
// (unit-test) callers use.
|
|
64
|
+
it('accepts --columns as a JSON string, the form the real CLI produces', async () => {
|
|
65
|
+
await generator(host, {
|
|
66
|
+
...baseOptions,
|
|
67
|
+
columns: JSON.stringify(baseOptions.columns),
|
|
68
|
+
});
|
|
69
|
+
const view = host
|
|
70
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
71
|
+
.toString();
|
|
72
|
+
expect(view).toContain(
|
|
73
|
+
"{ key: 'status', label: 'Status', sortable: false }",
|
|
74
|
+
);
|
|
75
|
+
}, 30000);
|
|
76
|
+
|
|
77
|
+
it('throws a clear error when --columns is not valid JSON', async () => {
|
|
78
|
+
await expect(
|
|
79
|
+
generator(host, { ...baseOptions, columns: '{not json' }),
|
|
80
|
+
).rejects.toThrow();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws a clear error when --columns parses to an empty array', async () => {
|
|
84
|
+
await expect(
|
|
85
|
+
generator(host, { ...baseOptions, columns: '[]' }),
|
|
86
|
+
).rejects.toThrow(/non-empty/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('generates the view with columns, sort, and per-type cell slots', async () => {
|
|
90
|
+
await generator(host, baseOptions);
|
|
91
|
+
|
|
92
|
+
const view = host
|
|
93
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
94
|
+
.toString();
|
|
95
|
+
expect(view).toContain('<h1>Applications</h1>');
|
|
96
|
+
expect(view).toContain(
|
|
97
|
+
"{ key: 'status', label: 'Status', sortable: false }",
|
|
98
|
+
);
|
|
99
|
+
expect(view).toContain(
|
|
100
|
+
"{ key: 'lastSaved', label: 'Last saved', sortable: true }",
|
|
101
|
+
);
|
|
102
|
+
expect(view).toContain('fetch(`/api/applications?${params.toString()}`)');
|
|
103
|
+
expect(view).toContain(
|
|
104
|
+
"<goa-badge type=\"information\" :content=\"String(row['status'] ?? '—')\" />",
|
|
105
|
+
);
|
|
106
|
+
expect(view).toContain("formatDate(row['lastSaved'])");
|
|
107
|
+
expect(view).toContain("formatCurrency(row['requestTotal'])");
|
|
108
|
+
// Uses the shared table shell, not hand-rolled loading/pagination markup.
|
|
109
|
+
expect(view).toContain("import { WorkspaceTable } from '@proj/vue-components';");
|
|
110
|
+
expect(view).toContain('<WorkspaceTable');
|
|
111
|
+
// Filterable by default: a debounced search input.
|
|
112
|
+
expect(view).toContain('type="search"');
|
|
113
|
+
expect(view).toContain('searchDebounce');
|
|
114
|
+
}, 30000);
|
|
115
|
+
|
|
116
|
+
it('omits the search input and its wiring when --filterable=false', async () => {
|
|
117
|
+
await generator(host, { ...baseOptions, filterable: false });
|
|
118
|
+
const view = host
|
|
119
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
120
|
+
.toString();
|
|
121
|
+
expect(view).not.toContain('type="search"');
|
|
122
|
+
expect(view).not.toContain('searchDebounce');
|
|
123
|
+
}, 30000);
|
|
124
|
+
|
|
125
|
+
it('generates a View action linking to --detailRoute when set', async () => {
|
|
126
|
+
await generator(host, { ...baseOptions, detailRoute: '/applications' });
|
|
127
|
+
const view = host
|
|
128
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
129
|
+
.toString();
|
|
130
|
+
expect(view).toContain('#actions="{ row }"');
|
|
131
|
+
expect(view).toContain(':to="`/applications/${row.id}`"');
|
|
132
|
+
}, 30000);
|
|
133
|
+
|
|
134
|
+
it('omits the actions slot entirely when --detailRoute is not set', async () => {
|
|
135
|
+
await generator(host, baseOptions);
|
|
136
|
+
const view = host
|
|
137
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
138
|
+
.toString();
|
|
139
|
+
expect(view).not.toContain('#actions');
|
|
140
|
+
}, 30000);
|
|
141
|
+
|
|
142
|
+
it('respects an explicit --heading and --pageSize', async () => {
|
|
143
|
+
await generator(host, { ...baseOptions, heading: 'Grant Applications', pageSize: 50 });
|
|
144
|
+
const view = host
|
|
145
|
+
.read('apps/test/src/views/ApplicationsListView.vue')
|
|
146
|
+
.toString();
|
|
147
|
+
expect(view).toContain('<h1>Grant Applications</h1>');
|
|
148
|
+
expect(view).toContain('PAGE_SIZE = 50');
|
|
149
|
+
expect(view).toContain(':per-page-count="50"');
|
|
150
|
+
}, 30000);
|
|
151
|
+
|
|
152
|
+
it('inserts the route into router/index.ts, requiring auth by default', async () => {
|
|
153
|
+
await generator(host, baseOptions);
|
|
154
|
+
|
|
155
|
+
const routerTs = host.read('apps/test/src/router/index.ts').toString();
|
|
156
|
+
expect(routerTs).toContain("path: '/applications'");
|
|
157
|
+
expect(routerTs).toContain(
|
|
158
|
+
"component: () => import('../views/ApplicationsListView.vue')",
|
|
159
|
+
);
|
|
160
|
+
expect(routerTs).toContain('meta: { requiresAuth: true }');
|
|
161
|
+
expect(routerTs).toContain("{ path: '/', component: HomeView }");
|
|
162
|
+
}, 30000);
|
|
163
|
+
|
|
164
|
+
it('omits the requiresAuth meta when --requiresAuth=false', async () => {
|
|
165
|
+
await generator(host, { ...baseOptions, requiresAuth: false });
|
|
166
|
+
const routerTs = host.read('apps/test/src/router/index.ts').toString();
|
|
167
|
+
expect(routerTs).not.toContain('requiresAuth');
|
|
168
|
+
}, 30000);
|
|
169
|
+
|
|
170
|
+
it('ensures the shared WorkspaceTable pattern component exists', async () => {
|
|
171
|
+
await generator(host, baseOptions);
|
|
172
|
+
expect(
|
|
173
|
+
host.exists('libs/vue-components/src/lib/patterns/WorkspaceTable.vue'),
|
|
174
|
+
).toBeTruthy();
|
|
175
|
+
}, 30000);
|
|
176
|
+
|
|
177
|
+
it('does not touch the target project configuration', async () => {
|
|
178
|
+
const before = readProjectConfiguration(host, 'test');
|
|
179
|
+
await generator(host, baseOptions);
|
|
180
|
+
const after = readProjectConfiguration(host, 'test');
|
|
181
|
+
expect(after).toEqual(before);
|
|
182
|
+
}, 30000);
|
|
183
|
+
});
|