@mk-kit/ui 0.45.0 → 0.47.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/README.md +19 -8
- package/fesm2022/mk-kit-ui-core.mjs +51 -4
- package/fesm2022/mk-kit-ui-core.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-embed.mjs +356 -0
- package/fesm2022/mk-kit-ui-embed.mjs.map +1 -0
- package/fesm2022/mk-kit-ui-feedback.mjs +10 -6
- package/fesm2022/mk-kit-ui-feedback.mjs.map +1 -1
- package/fesm2022/mk-kit-ui-forms.mjs +21 -75
- package/fesm2022/mk-kit-ui-forms.mjs.map +1 -1
- package/package.json +6 -1
- package/schematics/collection.json +5 -0
- package/schematics/crud/files.js +615 -0
- package/schematics/crud/index.js +134 -0
- package/schematics/crud/model.js +157 -0
- package/schematics/crud/schema.json +48 -0
- package/types/mk-kit-ui-core.d.ts +31 -2
- package/types/mk-kit-ui-embed.d.ts +124 -0
- package/types/mk-kit-ui-feedback.d.ts +4 -0
- package/types/mk-kit-ui-forms.d.ts +13 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mk-kit/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.47.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"rxjs": "^7.8.0"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
+
"@mk-kit/validators": "^0.1.0",
|
|
42
43
|
"tslib": "^2.3.0"
|
|
43
44
|
},
|
|
44
45
|
"sideEffects": false,
|
|
@@ -103,6 +104,10 @@
|
|
|
103
104
|
"types": "./types/mk-kit-ui-dynamic-form.d.ts",
|
|
104
105
|
"default": "./fesm2022/mk-kit-ui-dynamic-form.mjs"
|
|
105
106
|
},
|
|
107
|
+
"./embed": {
|
|
108
|
+
"types": "./types/mk-kit-ui-embed.d.ts",
|
|
109
|
+
"default": "./fesm2022/mk-kit-ui-embed.mjs"
|
|
110
|
+
},
|
|
106
111
|
"./feedback": {
|
|
107
112
|
"types": "./types/mk-kit-ui-feedback.d.ts",
|
|
108
113
|
"default": "./fesm2022/mk-kit-ui-feedback.mjs"
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
"description": "Migrates a PrimeNG app to @mk-kit/ui: rewrites imports, class names and 1:1 selectors, annotates what needs a manual touch, cleans angular.json / package.json and writes a report.",
|
|
11
11
|
"factory": "./migrate-primeng/index#migratePrimeng",
|
|
12
12
|
"schema": "./migrate-primeng/schema.json"
|
|
13
|
+
},
|
|
14
|
+
"crud": {
|
|
15
|
+
"description": "Generates a CRUD slice for one entity: model + columns + form schema, data service (in-memory or --api), mk-table list page, mk-dynamic-form create/edit page, lazy routes and a harness spec.",
|
|
16
|
+
"factory": "./crud/index#crud",
|
|
17
|
+
"schema": "./crud/schema.json"
|
|
13
18
|
}
|
|
14
19
|
}
|
|
15
20
|
}
|
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.titleField = titleField;
|
|
4
|
+
exports.routesConstName = routesConstName;
|
|
5
|
+
exports.modelFile = modelFile;
|
|
6
|
+
exports.serviceFile = serviceFile;
|
|
7
|
+
exports.listPageFile = listPageFile;
|
|
8
|
+
exports.formPageFile = formPageFile;
|
|
9
|
+
exports.routesFile = routesFile;
|
|
10
|
+
exports.specFile = specFile;
|
|
11
|
+
exports.crudFiles = crudFiles;
|
|
12
|
+
const model_1 = require("./model");
|
|
13
|
+
/** Fields whose value reads naturally as text (filter haystack, title pick). */
|
|
14
|
+
const TEXTUAL = new Set(['string', 'textarea', 'email', 'url']);
|
|
15
|
+
/** The field used to name a record in messages ("Delete X?"), if any. */
|
|
16
|
+
function titleField(entity) {
|
|
17
|
+
return entity.fields.find((f) => TEXTUAL.has(f.type));
|
|
18
|
+
}
|
|
19
|
+
/** Name of the exported routes const (`PRODUCTS_ROUTES`, `ORDER_LINES_ROUTES`). */
|
|
20
|
+
function routesConstName(entity) {
|
|
21
|
+
return `${entity.pluralFile.replace(/-/g, '_').toUpperCase()}_ROUTES`;
|
|
22
|
+
}
|
|
23
|
+
/** A deterministic sample value for seed row `i` (1-based), as a TS literal. */
|
|
24
|
+
function sampleLiteral(field, i, entity) {
|
|
25
|
+
switch (field.type) {
|
|
26
|
+
case 'number':
|
|
27
|
+
return String(i * 10);
|
|
28
|
+
case 'currency':
|
|
29
|
+
return (i * 10 - 0.01).toFixed(2);
|
|
30
|
+
case 'boolean':
|
|
31
|
+
return String(i % 2 === 1);
|
|
32
|
+
case 'date':
|
|
33
|
+
return `new Date('2026-0${i}-15')`;
|
|
34
|
+
case 'datetime':
|
|
35
|
+
return `new Date('2026-0${i}-15T09:30:00Z')`;
|
|
36
|
+
case 'select':
|
|
37
|
+
return `'${field.options[(i - 1) % field.options.length]}'`;
|
|
38
|
+
case 'tags':
|
|
39
|
+
return `['sample', 'tag-${i}']`;
|
|
40
|
+
case 'email':
|
|
41
|
+
return `'${entity.propertyName.toLowerCase()}${i}@example.com'`;
|
|
42
|
+
case 'url':
|
|
43
|
+
return `'https://example.com/${entity.pluralFile}/${i}'`;
|
|
44
|
+
default:
|
|
45
|
+
return `'${field.label} ${i}'`;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** The value a fresh form starts from, as a TS literal. */
|
|
49
|
+
function emptyLiteral(field) {
|
|
50
|
+
switch (field.type) {
|
|
51
|
+
case 'number':
|
|
52
|
+
case 'currency':
|
|
53
|
+
return '0';
|
|
54
|
+
case 'boolean':
|
|
55
|
+
return 'false';
|
|
56
|
+
case 'date':
|
|
57
|
+
case 'datetime':
|
|
58
|
+
return 'null';
|
|
59
|
+
case 'select':
|
|
60
|
+
return `'${field.options[0]}'`;
|
|
61
|
+
case 'tags':
|
|
62
|
+
return '[]';
|
|
63
|
+
default:
|
|
64
|
+
return `''`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** `mk-dynamic-form` field type for a crud field type. */
|
|
68
|
+
function dynamicType(field) {
|
|
69
|
+
switch (field.type) {
|
|
70
|
+
case 'string':
|
|
71
|
+
return 'text';
|
|
72
|
+
case 'boolean':
|
|
73
|
+
return 'switch';
|
|
74
|
+
default:
|
|
75
|
+
return field.type;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** One column literal of the list table. */
|
|
79
|
+
function columnLiteral(field) {
|
|
80
|
+
const parts = [`key: '${field.key}'`, `header: '${field.label}'`, 'sortable: true'];
|
|
81
|
+
switch (field.type) {
|
|
82
|
+
case 'number':
|
|
83
|
+
parts.push(`align: 'end'`);
|
|
84
|
+
break;
|
|
85
|
+
case 'currency':
|
|
86
|
+
parts.push(`align: 'end'`, `format: (value) => (typeof value === 'number' ? value.toFixed(2) : '')`);
|
|
87
|
+
break;
|
|
88
|
+
case 'boolean':
|
|
89
|
+
parts.push(`format: (value) => (value ? 'Yes' : 'No')`);
|
|
90
|
+
break;
|
|
91
|
+
case 'date':
|
|
92
|
+
parts.push(`format: (value) => (value instanceof Date ? value.toLocaleDateString() : '')`);
|
|
93
|
+
break;
|
|
94
|
+
case 'datetime':
|
|
95
|
+
parts.push(`format: (value) => (value instanceof Date ? value.toLocaleString() : '')`);
|
|
96
|
+
break;
|
|
97
|
+
case 'tags':
|
|
98
|
+
parts.push(`format: (value) => (Array.isArray(value) ? value.join(', ') : '')`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
return ` { ${parts.join(', ')} },`;
|
|
102
|
+
}
|
|
103
|
+
/** One dynamic-form field literal. */
|
|
104
|
+
function schemaFieldLiteral(field) {
|
|
105
|
+
const parts = [`key: '${field.key}'`, `type: '${dynamicType(field)}'`, `label: '${field.label}'`];
|
|
106
|
+
if (field.required)
|
|
107
|
+
parts.push('required: true');
|
|
108
|
+
if (field.type === 'select') {
|
|
109
|
+
const options = field.options.map((o) => `{ label: '${(0, model_1.humanize)(o)}', value: '${o}' }`).join(', ');
|
|
110
|
+
parts.push(`options: [${options}]`);
|
|
111
|
+
}
|
|
112
|
+
if (field.type === 'textarea')
|
|
113
|
+
parts.push(`props: { rows: 4 }`);
|
|
114
|
+
return ` { ${parts.join(', ')} },`;
|
|
115
|
+
}
|
|
116
|
+
/** `<entity>.model.ts` — the one source of truth the other files import. */
|
|
117
|
+
/** Fields whose runtime value is a `Date` (lost over JSON transport). */
|
|
118
|
+
function dateFields(entity) {
|
|
119
|
+
return entity.fields.filter((f) => f.type === 'date' || f.type === 'datetime');
|
|
120
|
+
}
|
|
121
|
+
function modelFile(entity) {
|
|
122
|
+
const { className, constName, human, fields } = entity;
|
|
123
|
+
const iface = fields.map((f) => ` ${f.key}: ${(0, model_1.tsType)(f)};`).join('\n');
|
|
124
|
+
const empty = fields.map((f) => `${f.key}: ${emptyLiteral(f)}`).join(', ');
|
|
125
|
+
const columns = fields.map(columnLiteral).join('\n');
|
|
126
|
+
const schema = fields.map(schemaFieldLiteral).join('\n');
|
|
127
|
+
const dates = dateFields(entity);
|
|
128
|
+
const revive = dates.length
|
|
129
|
+
? `
|
|
130
|
+
/** Revives date fields after JSON transport (an API answers ISO strings). */
|
|
131
|
+
export function revive${className}(row: ${className}): ${className} {
|
|
132
|
+
return {
|
|
133
|
+
...row,
|
|
134
|
+
${dates.map((f) => ` ${f.key}: row.${f.key} ? new Date(row.${f.key}) : null,`).join('\n')}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
`
|
|
138
|
+
: '';
|
|
139
|
+
return `import type { MkDynamicSchema } from '@mk-kit/ui/dynamic-form';
|
|
140
|
+
import type { MkTableColumn } from '@mk-kit/ui/table';
|
|
141
|
+
|
|
142
|
+
/** One ${human} record, as the pages and the service exchange it. */
|
|
143
|
+
export interface ${className} {
|
|
144
|
+
id: string;
|
|
145
|
+
${iface}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What the form edits — everything but the server-owned id. */
|
|
149
|
+
export type ${className}Draft = Omit<${className}, 'id'>;
|
|
150
|
+
|
|
151
|
+
/** The value a fresh "new ${human}" form starts from. */
|
|
152
|
+
export function empty${className}(): ${className}Draft {
|
|
153
|
+
return { ${empty} };
|
|
154
|
+
}
|
|
155
|
+
${revive}
|
|
156
|
+
|
|
157
|
+
/** Columns of the list table (the \`actions\` column is a template in the page). */
|
|
158
|
+
export const ${constName}_COLUMNS: MkTableColumn<${className}>[] = [
|
|
159
|
+
${columns}
|
|
160
|
+
{ key: 'actions', header: '', align: 'end', stack: 'footer' },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
/** Form schema — grow it with hints, spans and \`showWhen\` conditions as needed. */
|
|
164
|
+
export const ${constName}_SCHEMA: MkDynamicSchema = {
|
|
165
|
+
columns: 2,
|
|
166
|
+
fields: [
|
|
167
|
+
${schema}
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
/** `<entity>.service.ts` — in-memory by default, HttpClient when `--api` is set. */
|
|
173
|
+
function serviceFile(entity, api) {
|
|
174
|
+
return api ? httpServiceFile(entity, api) : memoryServiceFile(entity);
|
|
175
|
+
}
|
|
176
|
+
function seedLiteral(entity, i) {
|
|
177
|
+
const fields = entity.fields.map((f) => `${f.key}: ${sampleLiteral(f, i, entity)}`).join(', ');
|
|
178
|
+
return ` { id: '${entity.fileName}-${i}', ${fields} },`;
|
|
179
|
+
}
|
|
180
|
+
function memoryServiceFile(entity) {
|
|
181
|
+
const { className, propertyName, human, humanPlural } = entity;
|
|
182
|
+
const seeds = [1, 2, 3].map((i) => seedLiteral(entity, i)).join('\n');
|
|
183
|
+
const textual = entity.fields.filter((f) => TEXTUAL.has(f.type) || f.type === 'select' || f.type === 'tags');
|
|
184
|
+
const haystack = textual.length
|
|
185
|
+
? textual.map((f) => `row.${f.key}`).join(', ')
|
|
186
|
+
: `...Object.values(row)`;
|
|
187
|
+
return `import { Injectable } from '@angular/core';
|
|
188
|
+
import type { MkDataPage, MkDataRequest } from '@mk-kit/ui/table';
|
|
189
|
+
import { ${className}, ${className}Draft } from './${entity.fileName}.model';
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* In-memory ${human} store so the generated pages run immediately. Swap the
|
|
193
|
+
* method bodies for real API calls when the backend exists — the pages only
|
|
194
|
+
* depend on this surface (re-run the schematic with \`--api\` for an
|
|
195
|
+
* HttpClient version).
|
|
196
|
+
*/
|
|
197
|
+
@Injectable({ providedIn: 'root' })
|
|
198
|
+
export class ${className}Service {
|
|
199
|
+
private ${propertyName}s: ${className}[] = [
|
|
200
|
+
${seeds}
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/** One page of ${humanPlural}, honouring the table's filter / sort / paging. */
|
|
204
|
+
async list(req: MkDataRequest): Promise<MkDataPage<${className}>> {
|
|
205
|
+
let rows = [...this.${propertyName}s];
|
|
206
|
+
const query = req.filter.trim().toLowerCase();
|
|
207
|
+
if (query) {
|
|
208
|
+
rows = rows.filter((row) => [${haystack}].join(' ').toLowerCase().includes(query));
|
|
209
|
+
}
|
|
210
|
+
if (req.sort) {
|
|
211
|
+
const { active, direction } = req.sort;
|
|
212
|
+
const dir = direction === 'desc' ? -1 : 1;
|
|
213
|
+
rows.sort((a, b) => {
|
|
214
|
+
const av = a[active as keyof ${className}];
|
|
215
|
+
const bv = b[active as keyof ${className}];
|
|
216
|
+
return dir * String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true });
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const start = (req.page - 1) * req.pageSize;
|
|
220
|
+
return { rows: rows.slice(start, start + req.pageSize), total: rows.length };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async get(id: string): Promise<${className} | undefined> {
|
|
224
|
+
return this.${propertyName}s.find((row) => row.id === id);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async create(draft: ${className}Draft): Promise<${className}> {
|
|
228
|
+
const row: ${className} = { id: crypto.randomUUID(), ...draft };
|
|
229
|
+
this.${propertyName}s = [...this.${propertyName}s, row];
|
|
230
|
+
return row;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async update(id: string, draft: ${className}Draft): Promise<${className}> {
|
|
234
|
+
const row: ${className} = { id, ...draft };
|
|
235
|
+
this.${propertyName}s = this.${propertyName}s.map((r) => (r.id === id ? row : r));
|
|
236
|
+
return row;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async remove(id: string): Promise<void> {
|
|
240
|
+
this.${propertyName}s = this.${propertyName}s.filter((row) => row.id !== id);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
function httpServiceFile(entity, api) {
|
|
246
|
+
const { className, human, humanPlural } = entity;
|
|
247
|
+
const revive = dateFields(entity).length > 0;
|
|
248
|
+
const modelImports = revive
|
|
249
|
+
? `${className}, ${className}Draft, revive${className}`
|
|
250
|
+
: `${className}, ${className}Draft`;
|
|
251
|
+
const listBody = revive
|
|
252
|
+
? ` const page = await firstValueFrom(this.http.get<MkDataPage<${className}>>(this.base, { params }));
|
|
253
|
+
return { ...page, rows: page.rows.map(revive${className}) };`
|
|
254
|
+
: ` return firstValueFrom(this.http.get<MkDataPage<${className}>>(this.base, { params }));`;
|
|
255
|
+
const wrap = (expr) => (revive ? `revive${className}(await ${expr})` : `await ${expr}`);
|
|
256
|
+
return `import { Injectable, inject } from '@angular/core';
|
|
257
|
+
import { HttpClient, HttpParams } from '@angular/common/http';
|
|
258
|
+
import { firstValueFrom } from 'rxjs';
|
|
259
|
+
import type { MkDataPage, MkDataRequest } from '@mk-kit/ui/table';
|
|
260
|
+
import { ${modelImports} } from './${entity.fileName}.model';
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* REST ${human} service (needs \`provideHttpClient()\` in the app config).
|
|
264
|
+
* Adjust the query params and endpoints to the real backend contract — the
|
|
265
|
+
* pages only depend on this surface.
|
|
266
|
+
*/
|
|
267
|
+
@Injectable({ providedIn: 'root' })
|
|
268
|
+
export class ${className}Service {
|
|
269
|
+
private readonly http = inject(HttpClient);
|
|
270
|
+
private readonly base = '${api}';
|
|
271
|
+
|
|
272
|
+
/** One page of ${humanPlural}; the server answers \`{ rows, total }\`. */
|
|
273
|
+
async list(req: MkDataRequest): Promise<MkDataPage<${className}>> {
|
|
274
|
+
let params = new HttpParams().set('page', req.page).set('pageSize', req.pageSize);
|
|
275
|
+
if (req.filter) params = params.set('filter', req.filter);
|
|
276
|
+
if (req.sort) params = params.set('sort', \`\${req.sort.active},\${req.sort.direction}\`);
|
|
277
|
+
${listBody}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async get(id: string): Promise<${className}> {
|
|
281
|
+
return ${wrap(`firstValueFrom(this.http.get<${className}>(\`\${this.base}/\${id}\`))`)};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async create(draft: ${className}Draft): Promise<${className}> {
|
|
285
|
+
return ${wrap(`firstValueFrom(this.http.post<${className}>(this.base, draft))`)};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async update(id: string, draft: ${className}Draft): Promise<${className}> {
|
|
289
|
+
return ${wrap(`firstValueFrom(this.http.put<${className}>(\`\${this.base}/\${id}\`, draft))`)};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async remove(id: string): Promise<void> {
|
|
293
|
+
await firstValueFrom(this.http.delete<void>(\`\${this.base}/\${id}\`));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
`;
|
|
297
|
+
}
|
|
298
|
+
/** `<entity>-list-page.ts`. */
|
|
299
|
+
function listPageFile(entity) {
|
|
300
|
+
const { className, constName, fileName, pluralFile, human, humanPlural } = entity;
|
|
301
|
+
const title = titleField(entity);
|
|
302
|
+
const confirmTitle = title
|
|
303
|
+
? `\`Delete "\${row.${title.key}}"?\``
|
|
304
|
+
: `'Delete this ${human}?'`;
|
|
305
|
+
return `import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
|
306
|
+
import { RouterLink } from '@angular/router';
|
|
307
|
+
import { MkButton } from '@mk-kit/ui/button';
|
|
308
|
+
import { MkDialogService, MkToastService } from '@mk-kit/ui/feedback';
|
|
309
|
+
import { MkInput } from '@mk-kit/ui/forms';
|
|
310
|
+
import { MkPagination } from '@mk-kit/ui/navigation';
|
|
311
|
+
import { MkTable, MkTableCell, MkTableDataSource } from '@mk-kit/ui/table';
|
|
312
|
+
import { ${className}, ${constName}_COLUMNS } from './${fileName}.model';
|
|
313
|
+
import { ${className}Service } from './${fileName}.service';
|
|
314
|
+
|
|
315
|
+
@Component({
|
|
316
|
+
selector: 'app-${fileName}-list-page',
|
|
317
|
+
imports: [RouterLink, MkButton, MkInput, MkPagination, MkTable, MkTableCell],
|
|
318
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
319
|
+
styles: \`
|
|
320
|
+
.${pluralFile}-list {
|
|
321
|
+
display: grid;
|
|
322
|
+
gap: var(--mk-space-4);
|
|
323
|
+
}
|
|
324
|
+
.${pluralFile}-list__bar {
|
|
325
|
+
display: flex;
|
|
326
|
+
justify-content: space-between;
|
|
327
|
+
gap: var(--mk-space-3);
|
|
328
|
+
}
|
|
329
|
+
\`,
|
|
330
|
+
template: \`
|
|
331
|
+
<div class="${pluralFile}-list">
|
|
332
|
+
<header class="${pluralFile}-list__bar">
|
|
333
|
+
<input
|
|
334
|
+
mkInput
|
|
335
|
+
type="search"
|
|
336
|
+
placeholder="Search ${humanPlural}…"
|
|
337
|
+
aria-label="Search ${humanPlural}"
|
|
338
|
+
(input)="ds.setFilter($any($event.target).value)"
|
|
339
|
+
/>
|
|
340
|
+
<a mkButton routerLink="new">New ${human}</a>
|
|
341
|
+
</header>
|
|
342
|
+
|
|
343
|
+
<mk-table [columns]="columns" [data]="ds.rows()" (sortChange)="ds.setSort($event)">
|
|
344
|
+
<ng-template mkTableCell="actions" let-row="row">
|
|
345
|
+
<a mkButton variant="ghost" size="sm" [routerLink]="[row.id, 'edit']">Edit</a>
|
|
346
|
+
<button mkButton variant="ghost" tone="danger" size="sm" (click)="remove(row)">Delete</button>
|
|
347
|
+
</ng-template>
|
|
348
|
+
</mk-table>
|
|
349
|
+
|
|
350
|
+
@if (ds.error()) {
|
|
351
|
+
<p role="alert">Loading ${humanPlural} failed.</p>
|
|
352
|
+
} @else if (ds.empty()) {
|
|
353
|
+
<p>No ${humanPlural} yet.</p>
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
<mk-pagination
|
|
357
|
+
[total]="ds.total()"
|
|
358
|
+
[pageSize]="ds.pageSize()"
|
|
359
|
+
[page]="ds.page()"
|
|
360
|
+
(pageChange)="ds.setPage($event)"
|
|
361
|
+
/>
|
|
362
|
+
</div>
|
|
363
|
+
\`,
|
|
364
|
+
})
|
|
365
|
+
export class ${className}ListPage {
|
|
366
|
+
private readonly service = inject(${className}Service);
|
|
367
|
+
private readonly dialog = inject(MkDialogService);
|
|
368
|
+
private readonly toasts = inject(MkToastService);
|
|
369
|
+
|
|
370
|
+
protected readonly columns = ${constName}_COLUMNS;
|
|
371
|
+
protected readonly ds = new MkTableDataSource<${className}>((req) => this.service.list(req));
|
|
372
|
+
|
|
373
|
+
protected async remove(row: ${className}): Promise<void> {
|
|
374
|
+
const confirmed = await this.dialog.confirm({
|
|
375
|
+
title: ${confirmTitle},
|
|
376
|
+
message: 'This cannot be undone.',
|
|
377
|
+
confirmText: 'Delete',
|
|
378
|
+
tone: 'danger',
|
|
379
|
+
});
|
|
380
|
+
if (!confirmed) return;
|
|
381
|
+
await this.service.remove(row.id);
|
|
382
|
+
this.toasts.success('${(0, model_1.humanize)(entity.propertyName)} deleted');
|
|
383
|
+
this.ds.refresh();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
`;
|
|
387
|
+
}
|
|
388
|
+
/** `<entity>-form-page.ts` — one page for both `/new` and `/:id/edit`. */
|
|
389
|
+
function formPageFile(entity) {
|
|
390
|
+
const { className, constName, fileName, pluralFile, human } = entity;
|
|
391
|
+
return `import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
|
392
|
+
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
|
393
|
+
import { MkButton } from '@mk-kit/ui/button';
|
|
394
|
+
import { MkDynamicForm } from '@mk-kit/ui/dynamic-form';
|
|
395
|
+
import { MkToastService } from '@mk-kit/ui/feedback';
|
|
396
|
+
import { ${className}Draft, ${constName}_SCHEMA, empty${className} } from './${fileName}.model';
|
|
397
|
+
import { ${className}Service } from './${fileName}.service';
|
|
398
|
+
|
|
399
|
+
@Component({
|
|
400
|
+
selector: 'app-${fileName}-form-page',
|
|
401
|
+
imports: [RouterLink, MkButton, MkDynamicForm],
|
|
402
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
403
|
+
styles: \`
|
|
404
|
+
.${pluralFile}-form {
|
|
405
|
+
display: grid;
|
|
406
|
+
gap: var(--mk-space-4);
|
|
407
|
+
max-width: 40rem;
|
|
408
|
+
}
|
|
409
|
+
\`,
|
|
410
|
+
template: \`
|
|
411
|
+
<div class="${pluralFile}-form">
|
|
412
|
+
<h1>{{ id ? 'Edit ${human}' : 'New ${human}' }}</h1>
|
|
413
|
+
<mk-dynamic-form [schema]="schema" [(value)]="value" (formSubmit)="save($event)">
|
|
414
|
+
<button mkButton type="submit" [loading]="saving()">Save</button>
|
|
415
|
+
<a mkButton variant="ghost" [routerLink]="id ? ['../..'] : ['..']">Cancel</a>
|
|
416
|
+
</mk-dynamic-form>
|
|
417
|
+
</div>
|
|
418
|
+
\`,
|
|
419
|
+
})
|
|
420
|
+
export class ${className}FormPage {
|
|
421
|
+
private readonly route = inject(ActivatedRoute);
|
|
422
|
+
private readonly router = inject(Router);
|
|
423
|
+
private readonly service = inject(${className}Service);
|
|
424
|
+
private readonly toasts = inject(MkToastService);
|
|
425
|
+
|
|
426
|
+
protected readonly schema = ${constName}_SCHEMA;
|
|
427
|
+
protected readonly id = this.route.snapshot.paramMap.get('id');
|
|
428
|
+
protected readonly value = signal<Record<string, unknown>>({ ...empty${className}() });
|
|
429
|
+
protected readonly saving = signal(false);
|
|
430
|
+
|
|
431
|
+
constructor() {
|
|
432
|
+
if (this.id) {
|
|
433
|
+
void this.service.get(this.id).then((row) => {
|
|
434
|
+
if (row) {
|
|
435
|
+
const { id: _id, ...draft } = row;
|
|
436
|
+
this.value.set(draft);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
protected async save(value: Record<string, unknown>): Promise<void> {
|
|
443
|
+
this.saving.set(true);
|
|
444
|
+
try {
|
|
445
|
+
const draft = value as ${className}Draft;
|
|
446
|
+
if (this.id) {
|
|
447
|
+
await this.service.update(this.id, draft);
|
|
448
|
+
} else {
|
|
449
|
+
await this.service.create(draft);
|
|
450
|
+
}
|
|
451
|
+
this.toasts.success(this.id ? '${(0, model_1.humanize)(entity.propertyName)} updated' : '${(0, model_1.humanize)(entity.propertyName)} created');
|
|
452
|
+
await this.router.navigate(this.id ? ['../..'] : ['..'], { relativeTo: this.route });
|
|
453
|
+
} finally {
|
|
454
|
+
this.saving.set(false);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
`;
|
|
459
|
+
}
|
|
460
|
+
/** `<plural>.routes.ts`. */
|
|
461
|
+
function routesFile(entity) {
|
|
462
|
+
const { className, fileName, human } = entity;
|
|
463
|
+
const title = (0, model_1.humanize)(entity.pluralProperty);
|
|
464
|
+
return `import { Routes } from '@angular/router';
|
|
465
|
+
import { ${className}FormPage } from './${fileName}-form-page';
|
|
466
|
+
import { ${className}ListPage } from './${fileName}-list-page';
|
|
467
|
+
|
|
468
|
+
export const ${routesConstName(entity)}: Routes = [
|
|
469
|
+
{ path: '', component: ${className}ListPage, title: '${title}' },
|
|
470
|
+
{ path: 'new', component: ${className}FormPage, title: 'New ${human}' },
|
|
471
|
+
{ path: ':id/edit', component: ${className}FormPage, title: 'Edit ${human}' },
|
|
472
|
+
];
|
|
473
|
+
`;
|
|
474
|
+
}
|
|
475
|
+
/** `<plural>.spec.ts` — a harness-driven spec over a deterministic fake service. */
|
|
476
|
+
function specFile(entity) {
|
|
477
|
+
const { className, fileName, pluralFile, humanPlural } = entity;
|
|
478
|
+
const seeds = [1, 2].map((i) => seedLiteral(entity, i)).join('\n');
|
|
479
|
+
// The first form control is only predictable when the first field renders a
|
|
480
|
+
// plain input — otherwise assert on the heading instead.
|
|
481
|
+
const first = entity.fields[0];
|
|
482
|
+
const firstIsInput = TEXTUAL.has(first.type);
|
|
483
|
+
const editAssertions = firstIsInput
|
|
484
|
+
? ` const input = await loader.get(MkInputHarness);
|
|
485
|
+
expect(input.value()).toBe(${sampleLiteral(first, 1, entity)});`
|
|
486
|
+
: ` expect(fixture.nativeElement.textContent).toContain('Edit ${entity.human}');`;
|
|
487
|
+
const harnessNames = ['MkButtonHarness', 'MkDialogHarness', 'MkHarnessLoader'];
|
|
488
|
+
if (firstIsInput)
|
|
489
|
+
harnessNames.push('MkInputHarness');
|
|
490
|
+
harnessNames.push('MkTableHarness', 'MkToastHarness');
|
|
491
|
+
const harnessImports = harnessNames.map((n) => ` ${n},`).join('\n');
|
|
492
|
+
return `import { Injectable } from '@angular/core';
|
|
493
|
+
import { TestBed } from '@angular/core/testing';
|
|
494
|
+
import type { ComponentFixture } from '@angular/core/testing';
|
|
495
|
+
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
|
496
|
+
import type { MkDataPage, MkDataRequest } from '@mk-kit/ui/table';
|
|
497
|
+
import {
|
|
498
|
+
${harnessImports}
|
|
499
|
+
} from '@mk-kit/ui/testing';
|
|
500
|
+
import { ${className}, ${className}Draft } from './${fileName}.model';
|
|
501
|
+
import { ${className}FormPage } from './${fileName}-form-page';
|
|
502
|
+
import { ${className}ListPage } from './${fileName}-list-page';
|
|
503
|
+
import { ${className}Service } from './${fileName}.service';
|
|
504
|
+
|
|
505
|
+
/** Deterministic stand-in for ${className}Service, seeded per test. */
|
|
506
|
+
@Injectable()
|
|
507
|
+
class Fake${className}Service {
|
|
508
|
+
rows: ${className}[] = [
|
|
509
|
+
${seeds}
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
async list(req: MkDataRequest): Promise<MkDataPage<${className}>> {
|
|
513
|
+
const start = (req.page - 1) * req.pageSize;
|
|
514
|
+
return { rows: this.rows.slice(start, start + req.pageSize), total: this.rows.length };
|
|
515
|
+
}
|
|
516
|
+
async get(id: string): Promise<${className} | undefined> {
|
|
517
|
+
return this.rows.find((row) => row.id === id);
|
|
518
|
+
}
|
|
519
|
+
async create(draft: ${className}Draft): Promise<${className}> {
|
|
520
|
+
const row: ${className} = { id: \`${fileName}-\${this.rows.length + 1}\`, ...draft };
|
|
521
|
+
this.rows = [...this.rows, row];
|
|
522
|
+
return row;
|
|
523
|
+
}
|
|
524
|
+
async update(id: string, draft: ${className}Draft): Promise<${className}> {
|
|
525
|
+
const row: ${className} = { id, ...draft };
|
|
526
|
+
this.rows = this.rows.map((r) => (r.id === id ? row : r));
|
|
527
|
+
return row;
|
|
528
|
+
}
|
|
529
|
+
async remove(id: string): Promise<void> {
|
|
530
|
+
this.rows = this.rows.filter((row) => row.id !== id);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
async function settle(fixture: ComponentFixture<unknown>): Promise<void> {
|
|
535
|
+
fixture.detectChanges();
|
|
536
|
+
await fixture.whenStable();
|
|
537
|
+
fixture.detectChanges();
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
describe('${humanPlural} CRUD', () => {
|
|
541
|
+
afterEach(() => {
|
|
542
|
+
// Overlays (dialogs, toasts) mount on document.body — leave it clean.
|
|
543
|
+
document.body.querySelectorAll('.mk-overlay-backdrop, .mk-overlay-panel, mk-toast').forEach((el) => el.remove());
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
function setup(routeId?: string): void {
|
|
547
|
+
TestBed.configureTestingModule({
|
|
548
|
+
providers: [
|
|
549
|
+
provideRouter([]),
|
|
550
|
+
{ provide: ${className}Service, useClass: Fake${className}Service },
|
|
551
|
+
{
|
|
552
|
+
provide: ActivatedRoute,
|
|
553
|
+
useValue: { snapshot: { paramMap: convertToParamMap(routeId ? { id: routeId } : {}) } },
|
|
554
|
+
},
|
|
555
|
+
],
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
it('lists one row per record', async () => {
|
|
560
|
+
setup();
|
|
561
|
+
const fixture = TestBed.createComponent(${className}ListPage);
|
|
562
|
+
const loader = MkHarnessLoader.fromFixture(fixture);
|
|
563
|
+
await settle(fixture);
|
|
564
|
+
await settle(fixture);
|
|
565
|
+
|
|
566
|
+
const table = await loader.get(MkTableHarness);
|
|
567
|
+
expect(await table.rowCount()).toBe(2);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('deletes a record after confirmation', async () => {
|
|
571
|
+
setup();
|
|
572
|
+
const fixture = TestBed.createComponent(${className}ListPage);
|
|
573
|
+
const loader = MkHarnessLoader.fromFixture(fixture);
|
|
574
|
+
await settle(fixture);
|
|
575
|
+
await settle(fixture);
|
|
576
|
+
|
|
577
|
+
const table = await loader.get(MkTableHarness);
|
|
578
|
+
const firstRow = (await table.rows())[0];
|
|
579
|
+
const deleteButton = await loader.within(firstRow.host).get(MkButtonHarness, { text: 'Delete' });
|
|
580
|
+
await deleteButton.click();
|
|
581
|
+
|
|
582
|
+
const dialog = await loader.document().get(MkDialogHarness);
|
|
583
|
+
await dialog.clickButton('Delete');
|
|
584
|
+
await settle(fixture);
|
|
585
|
+
await settle(fixture);
|
|
586
|
+
|
|
587
|
+
expect(await table.rowCount()).toBe(1);
|
|
588
|
+
const toast = await loader.document().get(MkToastHarness);
|
|
589
|
+
expect(toast.message()).toContain('deleted');
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('loads the record into the form when editing', async () => {
|
|
593
|
+
setup('${fileName}-1');
|
|
594
|
+
const fixture = TestBed.createComponent(${className}FormPage);
|
|
595
|
+
const loader = MkHarnessLoader.fromFixture(fixture);
|
|
596
|
+
await settle(fixture);
|
|
597
|
+
await settle(fixture);
|
|
598
|
+
|
|
599
|
+
${editAssertions}
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
`;
|
|
603
|
+
}
|
|
604
|
+
/** Every generated file, keyed by file name inside the entity directory. */
|
|
605
|
+
function crudFiles(entity, options) {
|
|
606
|
+
const files = new Map();
|
|
607
|
+
files.set(`${entity.fileName}.model.ts`, modelFile(entity));
|
|
608
|
+
files.set(`${entity.fileName}.service.ts`, serviceFile(entity, options.api));
|
|
609
|
+
files.set(`${entity.fileName}-list-page.ts`, listPageFile(entity));
|
|
610
|
+
files.set(`${entity.fileName}-form-page.ts`, formPageFile(entity));
|
|
611
|
+
files.set(`${entity.pluralFile}.routes.ts`, routesFile(entity));
|
|
612
|
+
if (options.spec)
|
|
613
|
+
files.set(`${entity.pluralFile}.spec.ts`, specFile(entity));
|
|
614
|
+
return files;
|
|
615
|
+
}
|