@table-bro/mcp 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +97 -2
- package/dist/docs/docs.d.ts +21 -0
- package/dist/docs/docs.js +103 -0
- package/dist/docs/pages.d.ts +28 -0
- package/dist/docs/pages.js +2734 -0
- package/dist/docs/topics.d.ts +11 -0
- package/dist/docs/topics.js +410 -0
- package/dist/extension.d.ts +23 -0
- package/dist/extension.js +1 -0
- package/dist/generate/angular.d.ts +26 -0
- package/dist/generate/angular.js +241 -0
- package/dist/generate/cells.d.ts +22 -0
- package/dist/generate/cells.js +157 -0
- package/dist/generate/code.d.ts +48 -0
- package/dist/generate/code.js +153 -0
- package/dist/generate/config.d.ts +16 -0
- package/dist/generate/config.js +95 -0
- package/dist/generate/fillings.d.ts +49 -0
- package/dist/generate/fillings.js +130 -0
- package/dist/generate/filters.d.ts +36 -0
- package/dist/generate/filters.js +254 -0
- package/dist/generate/generate-code.d.ts +34 -0
- package/dist/generate/generate-code.js +59 -0
- package/dist/generate/names.d.ts +52 -0
- package/dist/generate/names.js +93 -0
- package/dist/generate/rows.d.ts +5 -0
- package/dist/generate/rows.js +22 -0
- package/dist/generate/scaffold.d.ts +35 -0
- package/dist/generate/scaffold.js +62 -0
- package/dist/generate/service.d.ts +16 -0
- package/dist/generate/service.js +320 -0
- package/dist/generate/setup.d.ts +34 -0
- package/dist/generate/setup.js +158 -0
- package/dist/generate/vanilla.d.ts +10 -0
- package/dist/generate/vanilla.js +160 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +10 -0
- package/dist/project/inspect-project.d.ts +53 -0
- package/dist/project/inspect-project.js +223 -0
- package/dist/server/main.d.ts +2 -0
- package/dist/server/main.js +8 -0
- package/dist/server/server.d.ts +17 -0
- package/dist/server/server.js +212 -0
- package/dist/spec/builtin-specs.d.ts +13 -0
- package/dist/spec/builtin-specs.js +167 -0
- package/dist/spec/css-variables.d.ts +22 -0
- package/dist/spec/css-variables.js +54 -0
- package/dist/spec/schema.d.ts +19 -0
- package/dist/spec/schema.js +278 -0
- package/dist/spec/skin-config.d.ts +30 -0
- package/dist/spec/skin-config.js +67 -0
- package/dist/spec/spec.d.ts +157 -0
- package/dist/spec/spec.js +2 -0
- package/dist/spec/validate-spec.d.ts +54 -0
- package/dist/spec/validate-spec.js +979 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +71 -4
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import { checkCssValue, cssVariables, excludedCssVariables } from './css-variables.js';
|
|
2
|
+
import { skinCallbacks, skinCallbacksElsewhere, skinConfigElsewhere, skinConfigShape, skinElementContracts } from './skin-config.js';
|
|
3
|
+
import { specIdentifier } from './spec.js';
|
|
4
|
+
/** The issues of each kind that a report gives. */
|
|
5
|
+
const maxReported = 100;
|
|
6
|
+
/** The longest name that gets a suggestion of the nearest name. */
|
|
7
|
+
const maxSuggestedName = 100;
|
|
8
|
+
export class BroSpecIssues {
|
|
9
|
+
errors = [];
|
|
10
|
+
warnings = [];
|
|
11
|
+
error(path, message, fix) {
|
|
12
|
+
this.errors.push(fix ? { path, message, fix } : { path, message });
|
|
13
|
+
}
|
|
14
|
+
warning(path, message, fix) {
|
|
15
|
+
this.warnings.push(fix ? { path, message, fix } : { path, message });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The fields of BroConfig that the spec gives in other places or leaves out, with the reason. */
|
|
19
|
+
const configElsewhere = {
|
|
20
|
+
skin: ['the skin is not a part of the spec', 'pass the skin to generate_code as its parameter'],
|
|
21
|
+
components: ['the settings of a skin are not a part of config in the spec', 'put them into skinConfig.<skin>.components'],
|
|
22
|
+
css: ['the classes of a skin are not a part of config in the spec', 'put them into skinConfig.<skin>.css'],
|
|
23
|
+
filtersTransform: ['a function is not a part of the spec', 'describe the filters in filters: the generator writes filtersTransform'],
|
|
24
|
+
shortColumnTitle: ['a function is not a part of the spec', 'give the short titles in shortTitle of the columns: the generator writes shortColumnTitle'],
|
|
25
|
+
autoColumnWidth: ['not in the spec of the first version: without the auto widths the CSS of the application sizes every column'],
|
|
26
|
+
styleNonce: ['the nonce is a value of the page', 'the page gives it to the config itself'],
|
|
27
|
+
stateAnimationEffect: ['not in the spec of the first version'],
|
|
28
|
+
rowDetailsProgressMode: ['not in the spec of the first version'],
|
|
29
|
+
debug: ['not a part of the spec'],
|
|
30
|
+
debugAnimation: ['not a part of the spec']
|
|
31
|
+
};
|
|
32
|
+
/** The fields of BroConfig of the paid modules, by their packages. */
|
|
33
|
+
const paidConfig = {
|
|
34
|
+
rowDetailsIsland: '@table-bro-extended/island-details'
|
|
35
|
+
};
|
|
36
|
+
/** The fields of BroCol that the spec leaves out, with the reason. */
|
|
37
|
+
const colElsewhere = {
|
|
38
|
+
className: ['the spec gives no classes: the generator sets the class bro-col_text-right of the skin on a column of a number field itself, and another class needs CSS of the application', 'remove className: a column of a number field stands at the right without it; another class goes into cols of the generated page by hand, with its CSS in the application'],
|
|
39
|
+
html: ['the markup of a cell comes from its component', 'describe the cell in cells and name it in component.cell'],
|
|
40
|
+
colSpan: ['not in the spec of the first version'],
|
|
41
|
+
sortDirection: ['the table does not read it']
|
|
42
|
+
};
|
|
43
|
+
const componentElsewhere = {
|
|
44
|
+
selector: ['the tags depend on the skin and the generation', 'name an own cell in component.cell or a ready element of the skins in component.element'],
|
|
45
|
+
attrs: ['an own cell reads the row itself', 'list the fields in the cell of cells'],
|
|
46
|
+
event: ['the generator names the event of the actions of a cell', 'list the actions in the cell of cells']
|
|
47
|
+
};
|
|
48
|
+
const kebabCase = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
49
|
+
const actionName = /^[a-z][a-zA-Z0-9-]*$/;
|
|
50
|
+
const iconName = /^[a-z0-9_]+$/;
|
|
51
|
+
const fieldTypes = ['string', 'number', 'boolean', 'string[]'];
|
|
52
|
+
const reservedFilterNames = ['search', 'advancedFilters'];
|
|
53
|
+
/**
|
|
54
|
+
* Every table has row details by default: validate_spec and generate_code warn about a table without them, as a request
|
|
55
|
+
* may ask for such a table.
|
|
56
|
+
*/
|
|
57
|
+
export function rowDetailsByDefault(spec) {
|
|
58
|
+
const config = (spec && typeof spec === 'object') ? spec.config : undefined;
|
|
59
|
+
return config?.['rowDetailsComponent']
|
|
60
|
+
? []
|
|
61
|
+
: [{
|
|
62
|
+
path: 'config.rowDetailsComponent',
|
|
63
|
+
message: 'the table has no row details, which every table has by default',
|
|
64
|
+
fix: 'add a cell of the kind details with what the row has no room for, name it in config.rowDetailsComponent and give a column the element details-button; leave them out only when the request asks for a table without them'
|
|
65
|
+
}];
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Checks a table spec and tells for every problem where it is, what is wrong and how it should be. The site runs it
|
|
69
|
+
* on every answer of the model, the MCP server before the generator.
|
|
70
|
+
*/
|
|
71
|
+
export function validateSpec(spec, options = {}) {
|
|
72
|
+
const issues = new BroSpecIssues();
|
|
73
|
+
if (!isObject(spec)) {
|
|
74
|
+
issues.error('', 'the spec is not an object', 'pass a BroTableSpec object');
|
|
75
|
+
return report(issues);
|
|
76
|
+
}
|
|
77
|
+
checkKeys(spec, ['specVersion', 'rowType', 'fields', 'cols', 'cells', 'filters', 'rows', 'config', 'toolbar', 'stateMessages', 'skinConfig', 'cssVars', 'api'], '', issues);
|
|
78
|
+
if (spec['specVersion'] !== 1) {
|
|
79
|
+
issues.error('specVersion', 'expected 1');
|
|
80
|
+
}
|
|
81
|
+
if (typeof spec['rowType'] !== 'string' || !/^[A-Z][A-Za-z0-9]*$/.test(spec['rowType'])) {
|
|
82
|
+
issues.error('rowType', 'expected the name of the type of a row in PascalCase', 'as Order');
|
|
83
|
+
}
|
|
84
|
+
const fields = checkFields(spec['fields'], issues);
|
|
85
|
+
const cells = checkCells(spec['cells'], fields, issues);
|
|
86
|
+
const config = isObject(spec['config']) ? spec['config'] : {};
|
|
87
|
+
checkCols(spec['cols'], fields, cells, config, issues);
|
|
88
|
+
checkFilters(spec['filters'], fields, issues);
|
|
89
|
+
checkConfig(spec['config'], cells, options, issues);
|
|
90
|
+
checkUnusedCells(spec, cells, issues);
|
|
91
|
+
checkToolbar(spec['toolbar'], spec, options, issues);
|
|
92
|
+
checkStateMessages(spec['stateMessages'], issues);
|
|
93
|
+
checkSkinConfig(spec['skinConfig'], options, issues);
|
|
94
|
+
checkCssVars(spec['cssVars'], Array.isArray(spec['filters']) && spec['filters'].length > 0, issues);
|
|
95
|
+
checkRows(spec['rows'], fields, options, issues);
|
|
96
|
+
checkApi(spec['api'], fields, config, spec['filters'], issues);
|
|
97
|
+
checkLimits(spec, options.limits, issues);
|
|
98
|
+
return report(issues);
|
|
99
|
+
}
|
|
100
|
+
function checkFields(value, issues) {
|
|
101
|
+
const fields = new Map();
|
|
102
|
+
if (!Array.isArray(value) || !value.length) {
|
|
103
|
+
issues.error('fields', 'expected a non-empty array of the fields of a row', 'as [{"name": "number", "type": "string"}]');
|
|
104
|
+
return fields;
|
|
105
|
+
}
|
|
106
|
+
value.forEach((field, index) => {
|
|
107
|
+
const path = `fields[${index}]`;
|
|
108
|
+
if (!isObject(field)) {
|
|
109
|
+
issues.error(path, 'expected an object');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
checkKeys(field, ['name', 'type', 'values', 'source'], path, issues);
|
|
113
|
+
const name = field['name'];
|
|
114
|
+
if (typeof name !== 'string' || !specIdentifier.test(name)) {
|
|
115
|
+
issues.error(`${path}.name`, 'expected an identifier', 'as customerName');
|
|
116
|
+
}
|
|
117
|
+
else if (fields.has(name)) {
|
|
118
|
+
issues.error(`${path}.name`, `the field ${name} is already declared`, 'every field has a name of its own');
|
|
119
|
+
}
|
|
120
|
+
if (!fieldTypes.includes(field['type'])) {
|
|
121
|
+
issues.error(`${path}.type`, `expected one of ${fieldTypes.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
if (field['values'] !== undefined) {
|
|
124
|
+
if (field['type'] !== 'string' && field['type'] !== 'string[]') {
|
|
125
|
+
issues.error(`${path}.values`, 'values are for fields of the type string or string[]', 'remove values, or give the field the type string or string[]');
|
|
126
|
+
}
|
|
127
|
+
else if (!isStringArray(field['values']) || !field['values'].length || field['values'].some(item => !item)) {
|
|
128
|
+
issues.error(`${path}.values`, 'expected a non-empty array of non-empty strings');
|
|
129
|
+
}
|
|
130
|
+
else if (new Set(field['values']).size !== field['values'].length) {
|
|
131
|
+
issues.error(`${path}.values`, 'the values repeat', `keep one ${field['values'].find((item, index) => field['values'].indexOf(item) !== index)}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (field['source'] !== undefined && (typeof field['source'] !== 'string' || !sourcePath.test(field['source']))) {
|
|
135
|
+
issues.error(`${path}.source`, 'expected the path of a value in an item of the answer of the API', 'as price or meta.createdAt');
|
|
136
|
+
}
|
|
137
|
+
if (typeof name === 'string' && !fields.has(name)) {
|
|
138
|
+
fields.set(name, field);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
return fields;
|
|
142
|
+
}
|
|
143
|
+
/** A path in an answer of an API: names and indexes split by dots, as meta.createdAt or images.0. */
|
|
144
|
+
const sourcePath = /^[A-Za-z_$][\w$-]*(\.[\w$-]+)*$/;
|
|
145
|
+
/** An API gives the rows: its address, the path of the rows, and the source of every field. */
|
|
146
|
+
function checkApi(value, fields, config, filters, issues) {
|
|
147
|
+
if (value === undefined) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (!isObject(value)) {
|
|
151
|
+
issues.error('api', 'expected an object', 'as {"url": "https://dummyjson.com/products", "rows": "products"}');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
checkKeys(value, ['url', 'rows', 'paging', 'total', 'search', 'sort', 'filters'], 'api', issues);
|
|
155
|
+
let url;
|
|
156
|
+
try {
|
|
157
|
+
url = new URL(value['url']);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// The check below names the error
|
|
161
|
+
}
|
|
162
|
+
// The API of the project itself has the path of the site, as /api/orders: the page of the project asks it
|
|
163
|
+
const projectPath = typeof value['url'] === 'string' && /^\/(?!\/)[^\s]*$/.test(value['url']);
|
|
164
|
+
if (!projectPath && (!url || !['http:', 'https:'].includes(url.protocol))) {
|
|
165
|
+
issues.error('api.url', 'expected the address of a GET endpoint: http or https, or the path of the API of the project, as /api/orders');
|
|
166
|
+
}
|
|
167
|
+
if (typeof value['rows'] !== 'string' || (value['rows'] && !sourcePath.test(value['rows']))) {
|
|
168
|
+
issues.error('api.rows', 'expected the path of the array of the rows in the answer, or an empty string for an answer that is the array');
|
|
169
|
+
}
|
|
170
|
+
const parameter = (item) => typeof item === 'string' && /^[\w.$-]+$/.test(item);
|
|
171
|
+
const paging = value['paging'];
|
|
172
|
+
if (paging !== undefined && (!isObject(paging) || !parameter(paging['size']) || (paging['offset'] === undefined) === (paging['page'] === undefined)
|
|
173
|
+
|| ![paging['offset'], paging['page']].every(item => item === undefined || parameter(item)))) {
|
|
174
|
+
issues.error('api.paging', 'expected the query parameters of a page: size, and offset or page', 'as {"size": "limit", "offset": "skip"}');
|
|
175
|
+
}
|
|
176
|
+
// The table shows its pages by the number of all rows
|
|
177
|
+
if ((value['total'] !== undefined || paging !== undefined) && (typeof value['total'] !== 'string' || !sourcePath.test(value['total']))) {
|
|
178
|
+
issues.error('api.total', 'expected the path of the number of all rows in the answer: an API that gives pages has it', 'as total');
|
|
179
|
+
}
|
|
180
|
+
checkApiQuery(value, filters, issues);
|
|
181
|
+
// The page loads the answer and answers the queries of the table from its rows
|
|
182
|
+
if (config['server'] !== true) {
|
|
183
|
+
issues.error('config.server', 'an API gives the rows in the server mode', 'set config.server to true');
|
|
184
|
+
}
|
|
185
|
+
[...fields.values()].forEach((field, index) => {
|
|
186
|
+
if (!field.source) {
|
|
187
|
+
issues.error(`fields[${index}].source`, 'with an API every field needs the path of its value in an item of the answer', `the field ${field.name} takes the path of its value in an item, as ${field.name} or meta.${field.name}`);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* The parameters of the queries of an API that gives pages: it searches, sorts and filters itself, so every filter of
|
|
193
|
+
* the table needs its query parameter. An API that gives all its rows at once takes none: the page answers from them.
|
|
194
|
+
*/
|
|
195
|
+
function checkApiQuery(api, filters, issues) {
|
|
196
|
+
const parameter = (item) => typeof item === 'string' && /^[\w.$[\]-]+$/.test(item);
|
|
197
|
+
const { search, sort, filters: parameters } = api;
|
|
198
|
+
if (api['paging'] === undefined) {
|
|
199
|
+
for (const name of ['search', 'sort', 'filters'].filter(key => api[key] !== undefined)) {
|
|
200
|
+
issues.error(`api.${name}`, 'only an API that gives pages takes the parameters of the queries: the page answers from all the rows of another API', `remove api.${name}, or give the pages of the API in api.paging`);
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (search !== undefined && !parameter(search)) {
|
|
205
|
+
issues.error('api.search', 'expected the query parameter of the text search', 'as search');
|
|
206
|
+
}
|
|
207
|
+
if (sort !== undefined && (!isObject(sort) || !parameter(sort['field']) || (sort['order'] !== undefined && !parameter(sort['order'])))) {
|
|
208
|
+
issues.error('api.sort', 'expected the query parameters of the sort: field, and order unless a minus before the field sorts down', 'as {"field": "ordering"}');
|
|
209
|
+
}
|
|
210
|
+
if (parameters !== undefined && (!isObject(parameters) || !Object.values(parameters).every(parameter))) {
|
|
211
|
+
issues.error('api.filters', 'expected the query parameter of every key of the filters', 'as {"newsSite": "news_site"}');
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const keys = (Array.isArray(filters) ? filters : []).filter(isObject)
|
|
215
|
+
.flatMap(filter => (filter['control'] === 'range') ? [`${filter['name']}From`, `${filter['name']}To`] : [`${filter['name']}`]);
|
|
216
|
+
for (const key of keys.filter(item => !(parameters && item in parameters))) {
|
|
217
|
+
issues.error(`api.filters.${key}`, 'the API filters the rows itself: every filter needs its query parameter', 'or leave the filter out');
|
|
218
|
+
}
|
|
219
|
+
for (const key of Object.keys(parameters ?? {}).filter(item => !keys.includes(item))) {
|
|
220
|
+
issues.error(`api.filters.${key}`, 'expected a key of the filters', keys.length ? `one of ${keys.join(', ')}` : 'the table has no filters');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function checkCells(value, fields, issues) {
|
|
224
|
+
const cells = new Map();
|
|
225
|
+
if (value === undefined) {
|
|
226
|
+
return cells;
|
|
227
|
+
}
|
|
228
|
+
if (!Array.isArray(value)) {
|
|
229
|
+
issues.error('cells', 'expected an array of cells');
|
|
230
|
+
return cells;
|
|
231
|
+
}
|
|
232
|
+
value.forEach((cell, index) => {
|
|
233
|
+
const path = `cells[${index}]`;
|
|
234
|
+
if (!isObject(cell)) {
|
|
235
|
+
issues.error(path, 'expected an object');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
checkKeys(cell, ['name', 'kind', 'shows', 'fields', 'actions'], path, issues);
|
|
239
|
+
const name = cell['name'];
|
|
240
|
+
if (typeof name !== 'string' || !kebabCase.test(name)) {
|
|
241
|
+
issues.error(`${path}.name`, 'expected a name in kebab-case', 'as status or order-actions');
|
|
242
|
+
}
|
|
243
|
+
else if (cells.has(name)) {
|
|
244
|
+
issues.error(`${path}.name`, `the cell ${name} is already declared`, 'every cell has a name of its own');
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
cells.set(name, cell);
|
|
248
|
+
}
|
|
249
|
+
if (cell['kind'] !== undefined && cell['kind'] !== 'cell' && cell['kind'] !== 'details') {
|
|
250
|
+
issues.error(`${path}.kind`, 'expected cell or details');
|
|
251
|
+
}
|
|
252
|
+
if (typeof cell['shows'] !== 'string' || !cell['shows'].trim()) {
|
|
253
|
+
issues.error(`${path}.shows`, 'expected what the cell shows, in words');
|
|
254
|
+
}
|
|
255
|
+
if (!isStringArray(cell['fields'])) {
|
|
256
|
+
issues.error(`${path}.fields`, 'expected an array of the fields of the row the cell reads');
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
cell['fields'].forEach((field, fieldIndex) => checkFieldName(field, `${path}.fields[${fieldIndex}]`, fields, issues));
|
|
260
|
+
}
|
|
261
|
+
if (cell['actions'] !== undefined) {
|
|
262
|
+
checkActions(cell['actions'], `${path}.actions`, issues);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
return cells;
|
|
266
|
+
}
|
|
267
|
+
function checkCols(value, fields, cells, config, issues) {
|
|
268
|
+
if (!Array.isArray(value) || !value.length) {
|
|
269
|
+
issues.error('cols', 'expected a non-empty array of columns');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// The plain columns by their title and field: two of one title and one field show the same thing twice
|
|
273
|
+
const plain = new Map();
|
|
274
|
+
value.forEach((col, index) => {
|
|
275
|
+
const path = `cols[${index}]`;
|
|
276
|
+
if (!isObject(col)) {
|
|
277
|
+
issues.error(path, 'expected an object');
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
checkKeys(col, ['title', 'name', 'shortTitle', 'tooltip', 'dataType', 'sortKey', 'component'], path, issues, colElsewhere);
|
|
281
|
+
if (typeof col['title'] !== 'string') {
|
|
282
|
+
issues.error(`${path}.title`, 'expected a string; an empty one for a column without a title');
|
|
283
|
+
}
|
|
284
|
+
for (const key of ['shortTitle', 'tooltip']) {
|
|
285
|
+
if (col[key] !== undefined && (typeof col[key] !== 'string' || !col[key])) {
|
|
286
|
+
issues.error(`${path}.${key}`, 'expected a non-empty string');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const field = (typeof col['name'] === 'string') ? fields.get(col['name']) : undefined;
|
|
290
|
+
if (col['name'] !== undefined) {
|
|
291
|
+
checkFieldName(col['name'], `${path}.name`, fields, issues);
|
|
292
|
+
}
|
|
293
|
+
if (col['sortKey'] !== undefined) {
|
|
294
|
+
checkFieldName(col['sortKey'], `${path}.sortKey`, fields, issues);
|
|
295
|
+
}
|
|
296
|
+
if (col['dataType'] !== undefined) {
|
|
297
|
+
if (!['string', 'number', 'boolean'].includes(col['dataType'])) {
|
|
298
|
+
issues.error(`${path}.dataType`, 'expected string, number or boolean', 'a date or an amount of money is a string field with the text as the cell shows it, as "Sep 1, 2026" or "$15", and a number field for the sort in sortKey of the column');
|
|
299
|
+
}
|
|
300
|
+
else if (field && field.type !== col['dataType'] && field.type !== 'string[]') {
|
|
301
|
+
issues.warning(`${path}.dataType`, `the field ${field.name} is of the type ${field.type}`, 'leave dataType out: the generator takes it from the field');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if (col['component'] === undefined) {
|
|
305
|
+
if (col['name'] === undefined) {
|
|
306
|
+
issues.error(path, 'the column shows nothing', 'give the field in name or a cell in component');
|
|
307
|
+
}
|
|
308
|
+
else if (typeof col['title'] === 'string' && typeof col['name'] === 'string') {
|
|
309
|
+
const key = JSON.stringify([col['title'], col['name']]);
|
|
310
|
+
if (plain.has(key)) {
|
|
311
|
+
issues.warning(path, `the column shows the field ${col['name']} under the title "${col['title']}" as cols[${plain.get(key)}] does`, 'remove one of them, or give it another title, or a component of its own if it is meant to show the field otherwise');
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
plain.set(key, index);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
checkColComponent(col['component'], `${path}.component`, field, cells, config, issues);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
function checkColComponent(component, path, field, cells, config, issues) {
|
|
323
|
+
if (!isObject(component)) {
|
|
324
|
+
issues.error(path, 'expected {"cell": "<name>"} or {"element": "tags" | "switch" | "details-button"}');
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
checkKeys(component, ['cell', 'element'], path, issues, componentElsewhere);
|
|
328
|
+
if ((component['cell'] === undefined) === (component['element'] === undefined)) {
|
|
329
|
+
issues.error(path, 'expected either cell or element');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (component['cell'] !== undefined) {
|
|
333
|
+
const cell = (typeof component['cell'] === 'string') ? cells.get(component['cell']) : undefined;
|
|
334
|
+
if (!cell) {
|
|
335
|
+
issues.error(`${path}.cell`, `no cell ${component['cell']} in cells`, suggestion(component['cell'], [...cells.keys()]));
|
|
336
|
+
}
|
|
337
|
+
else if (cell.kind === 'details') {
|
|
338
|
+
issues.error(`${path}.cell`, `the cell ${cell.name} is of the kind details`, 'name it in config.rowDetailsComponent');
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const element = component['element'];
|
|
343
|
+
if (element === 'tags' && field?.type !== 'string[]') {
|
|
344
|
+
issues.error(`${path}.element`, 'the tags show a field of the type string[]', 'name such a field in the name of the column');
|
|
345
|
+
}
|
|
346
|
+
else if (element === 'switch' && field?.type !== 'boolean') {
|
|
347
|
+
issues.error(`${path}.element`, 'the switch shows a field of the type boolean', 'name such a field in the name of the column');
|
|
348
|
+
}
|
|
349
|
+
else if (element === 'details-button') {
|
|
350
|
+
if (config['rowDetailsComponent'] === undefined) {
|
|
351
|
+
issues.error(`${path}.element`, 'the details button opens the row details', 'name the details cell in config.rowDetailsComponent');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
else if (element !== 'tags' && element !== 'switch') {
|
|
355
|
+
issues.error(`${path}.element`, 'expected tags, switch or details-button', suggestion(element, ['tags', 'switch', 'details-button']));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function checkFilters(value, fields, issues) {
|
|
359
|
+
if (value === undefined) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!Array.isArray(value)) {
|
|
363
|
+
issues.error('filters', 'expected an array of filters');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
// The keys of the filters in the object of the filters: a range takes two
|
|
367
|
+
const keys = new Set();
|
|
368
|
+
const names = new Set();
|
|
369
|
+
value.forEach((filter, index) => {
|
|
370
|
+
const path = `filters[${index}]`;
|
|
371
|
+
if (!isObject(filter)) {
|
|
372
|
+
issues.error(path, 'expected an object');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
checkKeys(filter, ['name', 'label', 'control', 'field', 'match', 'rule'], path, issues);
|
|
376
|
+
const { name, control, match } = filter;
|
|
377
|
+
if (typeof name !== 'string' || !specIdentifier.test(name)) {
|
|
378
|
+
issues.error(`${path}.name`, 'expected an identifier', 'as customer');
|
|
379
|
+
}
|
|
380
|
+
else if (reservedFilterNames.includes(name)) {
|
|
381
|
+
issues.error(`${path}.name`, `the table uses the filter ${name} itself`, 'choose another name');
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// The name is the name of the control of the form, so two filters of different controls cannot share it
|
|
385
|
+
if (names.has(name)) {
|
|
386
|
+
issues.error(`${path}.name`, `the filter ${name} is already declared`, 'every filter has a name of its own');
|
|
387
|
+
}
|
|
388
|
+
names.add(name);
|
|
389
|
+
for (const key of (control === 'range') ? [`${name}From`, `${name}To`] : [name]) {
|
|
390
|
+
if (keys.has(key)) {
|
|
391
|
+
issues.error(`${path}.name`, `the key ${key} of the filters is already taken`, 'give the filter another name: a range filter takes the keys <name>From and <name>To');
|
|
392
|
+
}
|
|
393
|
+
keys.add(key);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (typeof filter['label'] !== 'string' || !filter['label'].trim()) {
|
|
397
|
+
issues.error(`${path}.label`, 'expected a non-empty string');
|
|
398
|
+
}
|
|
399
|
+
if (!['text', 'select', 'range', 'checkbox'].includes(control)) {
|
|
400
|
+
issues.error(`${path}.control`, 'expected text, select, range or checkbox', suggestion(control, ['text', 'select', 'range', 'checkbox']));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (match !== undefined && !['contains', 'startsWith', 'eq', 'custom'].includes(match)) {
|
|
404
|
+
issues.error(`${path}.match`, 'expected contains, startsWith, eq or custom');
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (match === 'custom') {
|
|
408
|
+
if (typeof filter['rule'] !== 'string' || !filter['rule'].trim()) {
|
|
409
|
+
issues.error(`${path}.rule`, 'a custom match needs its rule in words', 'as "the due date is before today and the status is not paid"');
|
|
410
|
+
}
|
|
411
|
+
if (filter['field'] !== undefined) {
|
|
412
|
+
checkFieldName(filter['field'], `${path}.field`, fields, issues);
|
|
413
|
+
}
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (filter['rule'] !== undefined) {
|
|
417
|
+
issues.error(`${path}.rule`, 'a rule is for the match custom', 'remove rule, or set match to custom');
|
|
418
|
+
}
|
|
419
|
+
if (filter['field'] === undefined) {
|
|
420
|
+
issues.error(`${path}.field`, 'expected the field of the row the filter compares');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const field = checkFieldName(filter['field'], `${path}.field`, fields, issues);
|
|
424
|
+
if (field) {
|
|
425
|
+
checkFilterControl(filter, field, path, issues);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/** The control and the match of a filter fit the type of its field. */
|
|
430
|
+
function checkFilterControl(filter, field, path, issues) {
|
|
431
|
+
const { control, match } = filter;
|
|
432
|
+
if (control === 'text') {
|
|
433
|
+
if (field.type !== 'string') {
|
|
434
|
+
issues.error(`${path}.control`, `a text filter compares a string field, ${field.name} is of the type ${field.type}`, filterFix(field));
|
|
435
|
+
}
|
|
436
|
+
else if (match !== undefined && match !== 'contains' && match !== 'startsWith') {
|
|
437
|
+
issues.error(`${path}.match`, 'a text filter matches with contains or startsWith', 'use contains or startsWith');
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else if (control === 'select') {
|
|
441
|
+
if (field.type !== 'string' && field.type !== 'string[]') {
|
|
442
|
+
issues.error(`${path}.control`, `a select filter compares a string field, ${field.name} is of the type ${field.type}`, filterFix(field));
|
|
443
|
+
}
|
|
444
|
+
else if (!field.values) {
|
|
445
|
+
issues.error(`${path}.control`, `a select filter offers the values of its field, ${field.name} has none`, `list them in values of the field ${field.name}`);
|
|
446
|
+
}
|
|
447
|
+
else if (match !== undefined && match !== 'eq') {
|
|
448
|
+
issues.error(`${path}.match`, 'a select filter matches with eq', 'use eq');
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
else if (control === 'range') {
|
|
452
|
+
if (field.type !== 'number') {
|
|
453
|
+
issues.error(`${path}.control`, `a range filter compares a number field, ${field.name} is of the type ${field.type}`, filterFix(field));
|
|
454
|
+
}
|
|
455
|
+
else if (match !== undefined) {
|
|
456
|
+
issues.error(`${path}.match`, 'a range filter compares with gte and lte itself', 'leave match out');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else if (field.type !== 'boolean') {
|
|
460
|
+
issues.error(`${path}.control`, `a checkbox filter compares a boolean field, ${field.name} is of the type ${field.type}`, filterFix(field));
|
|
461
|
+
}
|
|
462
|
+
else if (match !== undefined && match !== 'eq') {
|
|
463
|
+
issues.error(`${path}.match`, 'a checkbox filter matches with eq', 'use eq');
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function filterFix(field) {
|
|
467
|
+
const controls = {
|
|
468
|
+
string: field.values ? 'select or text' : 'text',
|
|
469
|
+
'string[]': field.values ? 'select' : 'a custom match',
|
|
470
|
+
number: 'range',
|
|
471
|
+
boolean: 'checkbox'
|
|
472
|
+
};
|
|
473
|
+
return `use ${controls[field.type]} for ${field.name}`;
|
|
474
|
+
}
|
|
475
|
+
function checkConfig(value, cells, options, issues) {
|
|
476
|
+
if (value === undefined) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (!isObject(value)) {
|
|
480
|
+
issues.error('config', 'expected an object');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
const extensionChecks = Object.assign({}, ...(options.extensions ?? []).map(extension => extension.config ?? {}));
|
|
484
|
+
const known = ['toolbarTitle', 'pageSize', 'server', 'rowCounter', 'shadowsEnabled', 'theme', 'rowDetailsComponent', 'rowDetailsServer', 'rowDetailsMotion'];
|
|
485
|
+
for (const key of Object.keys(value)) {
|
|
486
|
+
if (extensionChecks[key]) {
|
|
487
|
+
extensionChecks[key](value[key], `config.${key}`, issues, value);
|
|
488
|
+
}
|
|
489
|
+
else if (paidConfig[key]) {
|
|
490
|
+
issues.error(`config.${key}`, `${key} belongs to the paid module ${paidConfig[key]}, which this server does not load; without it the table shows the fail state`, `install ${paidConfig[key]} and start the server with --extension ${paidConfig[key]}, or use ordinary row details, rowDetailsComponent`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
checkKeys(value, [...known, ...Object.keys(extensionChecks), ...Object.keys(paidConfig)], 'config', issues, configElsewhere);
|
|
494
|
+
if (value['toolbarTitle'] !== undefined && typeof value['toolbarTitle'] !== 'string') {
|
|
495
|
+
issues.error('config.toolbarTitle', 'expected a string');
|
|
496
|
+
}
|
|
497
|
+
if (value['pageSize'] !== undefined && (!Number.isInteger(value['pageSize']) || value['pageSize'] < 1)) {
|
|
498
|
+
issues.error('config.pageSize', 'expected the number of rows on a page, from 1');
|
|
499
|
+
}
|
|
500
|
+
for (const key of ['server', 'rowCounter', 'shadowsEnabled', 'rowDetailsServer']) {
|
|
501
|
+
if (value[key] !== undefined && typeof value[key] !== 'boolean') {
|
|
502
|
+
issues.error(`config.${key}`, 'expected true or false');
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (value['theme'] !== undefined && !['light', 'dark', 'auto'].includes(value['theme'])) {
|
|
506
|
+
issues.error('config.theme', 'expected light, dark or auto');
|
|
507
|
+
}
|
|
508
|
+
if (value['rowDetailsMotion'] !== undefined && !['kinetic', 'eased'].includes(value['rowDetailsMotion'])) {
|
|
509
|
+
issues.error('config.rowDetailsMotion', 'expected kinetic or eased');
|
|
510
|
+
}
|
|
511
|
+
const details = value['rowDetailsComponent'];
|
|
512
|
+
if (details !== undefined) {
|
|
513
|
+
if (!isObject(details) || typeof details['cell'] !== 'string') {
|
|
514
|
+
issues.error('config.rowDetailsComponent', 'expected {"cell": "<name of a cell of the kind details>"}');
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
checkKeys(details, ['cell'], 'config.rowDetailsComponent', issues);
|
|
518
|
+
const cell = cells.get(details['cell']);
|
|
519
|
+
if (!cell) {
|
|
520
|
+
issues.error('config.rowDetailsComponent.cell', `no cell ${details['cell']} in cells`, suggestion(details['cell'], [...cells.keys()]));
|
|
521
|
+
}
|
|
522
|
+
else if (cell.kind !== 'details') {
|
|
523
|
+
issues.error('config.rowDetailsComponent.cell', `the cell ${cell.name} is of the kind cell`, 'give it "kind": "details"');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
for (const key of ['rowDetailsServer', 'rowDetailsMotion'].filter(item => value[item] !== undefined)) {
|
|
529
|
+
issues.error(`config.${key}`, 'the row details are not declared', 'name the details cell in config.rowDetailsComponent');
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** A declared cell that no column and no config names is generated for nothing. */
|
|
534
|
+
function checkUnusedCells(spec, cells, issues) {
|
|
535
|
+
if (!cells.size) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const used = new Set();
|
|
539
|
+
if (Array.isArray(spec['cols'])) {
|
|
540
|
+
spec['cols'].forEach((col) => isObject(col) && isObject(col['component']) && used.add(col['component']['cell']));
|
|
541
|
+
}
|
|
542
|
+
if (isObject(spec['config']) && isObject(spec['config']['rowDetailsComponent'])) {
|
|
543
|
+
used.add(spec['config']['rowDetailsComponent']['cell']);
|
|
544
|
+
}
|
|
545
|
+
spec['cells'].forEach((cell, index) => {
|
|
546
|
+
if (isObject(cell) && cells.get(cell['name']) === cell && !used.has(cell['name'])) {
|
|
547
|
+
issues.warning(`cells[${index}]`, `no column and no config.rowDetailsComponent names the cell ${cell['name']}`, 'name it or remove it');
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
function checkToolbar(value, spec, options, issues) {
|
|
552
|
+
if (value === undefined) {
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (!isObject(value)) {
|
|
556
|
+
issues.error('toolbar', 'expected an object');
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
checkKeys(value, ['buttons', 'menu'], 'toolbar', issues);
|
|
560
|
+
const actions = new Set();
|
|
561
|
+
// The mat skin is checked when the project has it, and on the site, which has all skins
|
|
562
|
+
const iconRequired = !options.skin || options.skin === 'mat';
|
|
563
|
+
const checkButton = (button, path, icon) => {
|
|
564
|
+
if (!isObject(button)) {
|
|
565
|
+
issues.error(path, 'expected an object');
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
checkKeys(button, ['title', 'action', 'icon'], path, issues);
|
|
569
|
+
if (typeof button['title'] !== 'string' || !button['title'].trim()) {
|
|
570
|
+
issues.error(`${path}.title`, 'expected a non-empty string');
|
|
571
|
+
}
|
|
572
|
+
else if (button['title'].trim().toLowerCase() === 'filters' && Array.isArray(spec['filters']) && spec['filters'].length) {
|
|
573
|
+
issues.warning(`${path}.title`, 'the generator adds the Filters button itself when the spec has filters', 'remove this button');
|
|
574
|
+
}
|
|
575
|
+
const action = button['action'];
|
|
576
|
+
if (typeof action === 'string' && action.startsWith('bro-')) {
|
|
577
|
+
issues.error(`${path}.action`, 'the actions that start with bro- belong to the table', 'as export');
|
|
578
|
+
}
|
|
579
|
+
else if (typeof action !== 'string' || !actionName.test(action)) {
|
|
580
|
+
issues.error(`${path}.action`, 'expected an action name', 'as export');
|
|
581
|
+
}
|
|
582
|
+
else if (actions.has(action)) {
|
|
583
|
+
issues.error(`${path}.action`, `the action ${action} is already taken in the toolbar`, 'give it an action of its own');
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
actions.add(action);
|
|
587
|
+
}
|
|
588
|
+
if (button['icon'] !== undefined && (typeof button['icon'] !== 'string' || !iconName.test(button['icon']))) {
|
|
589
|
+
issues.error(`${path}.icon`, 'expected the name of a Material icon', 'as download');
|
|
590
|
+
}
|
|
591
|
+
else if (icon && button['icon'] === undefined) {
|
|
592
|
+
issues.error(`${path}.icon`, 'a button of the toolbar of the mat skin is an icon with a tooltip', 'give the name of a Material icon, as download');
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
if (value['buttons'] !== undefined) {
|
|
596
|
+
if (!Array.isArray(value['buttons'])) {
|
|
597
|
+
issues.error('toolbar.buttons', 'expected an array of buttons');
|
|
598
|
+
}
|
|
599
|
+
else {
|
|
600
|
+
value['buttons'].forEach((button, index) => checkButton(button, `toolbar.buttons[${index}]`, iconRequired));
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const menu = value['menu'];
|
|
604
|
+
if (menu !== undefined) {
|
|
605
|
+
if (!isObject(menu)) {
|
|
606
|
+
issues.error('toolbar.menu', 'expected {"title": "More", "items": [...]}');
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
checkKeys(menu, ['title', 'items'], 'toolbar.menu', issues);
|
|
610
|
+
if (typeof menu['title'] !== 'string' || !menu['title'].trim()) {
|
|
611
|
+
issues.error('toolbar.menu.title', 'expected a non-empty string');
|
|
612
|
+
}
|
|
613
|
+
if (!Array.isArray(menu['items']) || !menu['items'].length) {
|
|
614
|
+
issues.error('toolbar.menu.items', 'expected a non-empty array of items');
|
|
615
|
+
}
|
|
616
|
+
else {
|
|
617
|
+
menu['items'].forEach((item, index) => checkButton(item, `toolbar.menu.items[${index}]`, false));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// An action of a cell with the same name comes to the same handler of the page
|
|
622
|
+
if (Array.isArray(spec['cells'])) {
|
|
623
|
+
spec['cells'].forEach((cell, index) => {
|
|
624
|
+
if (isObject(cell) && isStringArray(cell['actions'])) {
|
|
625
|
+
cell['actions'].filter(action => actions.has(action)).forEach(action => {
|
|
626
|
+
issues.warning(`cells[${index}].actions`, `the toolbar sends the action ${action} too`, 'give the actions different names');
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function checkStateMessages(value, issues) {
|
|
633
|
+
if (value === undefined) {
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (!isObject(value)) {
|
|
637
|
+
issues.error('stateMessages', 'expected an object');
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
checkKeys(value, ['loading', 'no-data', 'not-found', 'fail'], 'stateMessages', issues);
|
|
641
|
+
for (const [state, message] of Object.entries(value)) {
|
|
642
|
+
if (typeof message !== 'string' || !message.trim()) {
|
|
643
|
+
issues.error(`stateMessages${key(state)}`, 'expected a non-empty string');
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function checkSkinConfig(value, options, issues) {
|
|
648
|
+
if (value === undefined) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (!isObject(value)) {
|
|
652
|
+
issues.error('skinConfig', 'expected an object with the skins mat, bs or nv');
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
checkKeys(value, ['mat', 'bs', 'nv'], 'skinConfig', issues);
|
|
656
|
+
for (const [skin, config] of Object.entries(value)) {
|
|
657
|
+
const path = `skinConfig.${skin}`;
|
|
658
|
+
if (!['mat', 'bs', 'nv'].includes(skin)) {
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (options.skin && options.skin !== skin) {
|
|
662
|
+
issues.warning(path, `the project has the skin ${options.skin}`, 'remove the settings of other skins');
|
|
663
|
+
}
|
|
664
|
+
if (!isObject(config)) {
|
|
665
|
+
issues.error(path, 'expected an object with css, components or callbacks');
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
checkKeys(config, ['css', 'components', 'callbacks'], path, issues);
|
|
669
|
+
for (const part of ['css', 'components']) {
|
|
670
|
+
if (config[part] !== undefined) {
|
|
671
|
+
checkShape(config[part], skinConfigShape[part], part, path, issues);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// The elements of the mat skin are Angular components with their own templates: they read no classes of the config
|
|
675
|
+
if (skin === 'mat' && isObject(config['components'])) {
|
|
676
|
+
for (const [name, component] of Object.entries(config['components'])) {
|
|
677
|
+
if (isObject(component) && component['css'] !== undefined) {
|
|
678
|
+
issues.error(`${path}.components.${name}.css`, 'the elements of the mat skin are Angular components and read no classes of the config', 'remove css: the classes of the config are for the skins bs and nv');
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (config['callbacks'] !== undefined) {
|
|
683
|
+
checkCallbacks(config['callbacks'], skin, `${path}.callbacks`, issues);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* A value of the skin config fits the types of Table, Bro.
|
|
689
|
+
*
|
|
690
|
+
* @param value - the value of the spec at the path
|
|
691
|
+
* @param shape - the type that Table, Bro gives the value
|
|
692
|
+
* @param configPath - the path in the config of the table, as components.paginator
|
|
693
|
+
* @param base - the path of the skin in the spec, as skinConfig.bs
|
|
694
|
+
* @param issues - the issues of the spec, which take a value that does not fit
|
|
695
|
+
*/
|
|
696
|
+
function checkShape(value, shape, configPath, base, issues) {
|
|
697
|
+
const path = `${base}.${configPath}`;
|
|
698
|
+
if (shape === 'string' || shape === 'boolean') {
|
|
699
|
+
if (typeof value !== shape) {
|
|
700
|
+
issues.error(path, `expected a ${shape}`);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (shape === 'state') {
|
|
705
|
+
if (!isStringArray(value) || value.length !== 2) {
|
|
706
|
+
issues.error(path, 'expected the icon and the message of the state: ["<icon>", "<message>"]');
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (!isObject(value)) {
|
|
711
|
+
issues.error(path, 'expected an object');
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
for (const [name, item] of Object.entries(value)) {
|
|
715
|
+
const itemPath = `${configPath}.${name}`;
|
|
716
|
+
if (shape[name]) {
|
|
717
|
+
checkShape(item, shape[name], itemPath, base, issues);
|
|
718
|
+
// An own element in place of an element of the skin
|
|
719
|
+
if (name === 'selector' && configPath.startsWith('components.') && configPath.split('.').length === 2) {
|
|
720
|
+
const contract = skinElementContracts[configPath.split('.')[1]];
|
|
721
|
+
issues.warning(`${base}.${itemPath}`, `the application defines the element ${item}${contract ? `, and it follows ${contract} of Table, Bro` : ''}`, 'the generator does not write it');
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else if (skinCallbacks.some(callback => callback.path === itemPath) || skinCallbacksElsewhere[itemPath]) {
|
|
725
|
+
issues.error(`${base}.${itemPath}`, 'a function', `give it in callbacks of the skin: {"path": "${itemPath}", "does": "<what it does>"}`);
|
|
726
|
+
}
|
|
727
|
+
else if (skinConfigElsewhere[itemPath]) {
|
|
728
|
+
issues.error(`${base}.${itemPath}`, skinConfigElsewhere[itemPath], 'remove it from skinConfig');
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
issues.error(`${base}.${itemPath}`, `Table, Bro has no ${itemPath}`, suggestion(name, Object.keys(shape)));
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function checkCallbacks(value, skin, path, issues) {
|
|
736
|
+
if (!Array.isArray(value)) {
|
|
737
|
+
issues.error(path, 'expected an array of {"path": "<path of the function>", "does": "<what it does>"}');
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const paths = new Set();
|
|
741
|
+
value.forEach((callback, index) => {
|
|
742
|
+
const callbackPath = `${path}[${index}]`;
|
|
743
|
+
if (!isObject(callback)) {
|
|
744
|
+
issues.error(callbackPath, 'expected an object');
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
checkKeys(callback, ['path', 'does'], callbackPath, issues);
|
|
748
|
+
const name = callback['path'];
|
|
749
|
+
const known = skinCallbacks.find(item => item.path === name);
|
|
750
|
+
if (typeof name === 'string' && skinCallbacksElsewhere[name]) {
|
|
751
|
+
issues.error(`${callbackPath}.path`, skinCallbacksElsewhere[name], 'remove it from the spec: the application sets it in the config of the skin it registers');
|
|
752
|
+
}
|
|
753
|
+
else if (!known) {
|
|
754
|
+
issues.error(`${callbackPath}.path`, `Table, Bro has no function ${name}`, suggestion(name, skinCallbacks.map(item => item.path)));
|
|
755
|
+
}
|
|
756
|
+
else if (!known.skins.includes(skin)) {
|
|
757
|
+
issues.error(`${callbackPath}.path`, `the ${skin} skin does not call ${name}: its elements are Angular components with their own templates`, `remove it: only ${known.skins.join(' and ')} call it`);
|
|
758
|
+
}
|
|
759
|
+
else if (paths.has(name)) {
|
|
760
|
+
issues.error(`${callbackPath}.path`, `the function ${name} is already given`, 'keep one');
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
paths.add(name);
|
|
764
|
+
}
|
|
765
|
+
if (typeof callback['does'] !== 'string' || !callback['does'].trim()) {
|
|
766
|
+
issues.error(`${callbackPath}.does`, 'expected what the function does, in words');
|
|
767
|
+
}
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
function checkCssVars(value, hasFilters, issues) {
|
|
771
|
+
if (value === undefined) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!isObject(value)) {
|
|
775
|
+
issues.error('cssVars', 'expected an object, as {"--bro-row-height": "64px"}');
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
for (const [name, cssValue] of Object.entries(value)) {
|
|
779
|
+
const path = `cssVars${key(name)}`;
|
|
780
|
+
const variable = cssVariables.find(item => item.name === name);
|
|
781
|
+
if (excludedCssVariables[name]) {
|
|
782
|
+
issues.error(path, `the spec may not set ${name}: ${excludedCssVariables[name]}`, 'remove it');
|
|
783
|
+
}
|
|
784
|
+
else if (!variable) {
|
|
785
|
+
issues.error(path, name.startsWith('--bro-') ? `Table, Bro has no variable ${name}; other sizes, fonts and colors need changes of SCSS, which the first version does not make` : 'the spec sets only the --bro-* variables of Table, Bro', suggestion(name, cssVariables.map(item => item.name)));
|
|
786
|
+
}
|
|
787
|
+
else if (typeof cssValue !== 'string') {
|
|
788
|
+
issues.error(path, 'expected a string');
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
const problem = checkCssValue(variable, cssValue);
|
|
792
|
+
if (problem) {
|
|
793
|
+
issues.error(path, problem);
|
|
794
|
+
}
|
|
795
|
+
else if (variable.target === 'drawer' && !hasFilters) {
|
|
796
|
+
issues.warning(path, 'the spec has no filters, so no filter drawer reads it', 'remove it');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function checkRows(value, fields, options, issues) {
|
|
802
|
+
if (value === undefined) {
|
|
803
|
+
if (options.rowsRequired) {
|
|
804
|
+
issues.error('rows', 'expected the rows of the spec');
|
|
805
|
+
}
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
if (!Array.isArray(value) || (options.rowsRequired && !value.length)) {
|
|
809
|
+
issues.error('rows', 'expected a non-empty array of rows');
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
value.forEach((row, index) => {
|
|
813
|
+
const path = `rows[${index}]`;
|
|
814
|
+
if (!isObject(row)) {
|
|
815
|
+
issues.error(path, 'expected an object');
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
for (const [name, cell] of Object.entries(row)) {
|
|
819
|
+
const field = fields.get(name);
|
|
820
|
+
if (!field) {
|
|
821
|
+
issues.error(`${path}${key(name)}`, `no field ${name} in fields`, suggestion(name, [...fields.keys()]));
|
|
822
|
+
}
|
|
823
|
+
else if (cell !== null) {
|
|
824
|
+
checkRowValue(cell, field, `${path}${key(name)}`, options, issues);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
function checkRowValue(value, field, path, options, issues) {
|
|
830
|
+
const strings = [];
|
|
831
|
+
if (field.type === 'string[]') {
|
|
832
|
+
if (!isStringArray(value)) {
|
|
833
|
+
issues.error(path, 'expected an array of strings');
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
strings.push(...value);
|
|
837
|
+
}
|
|
838
|
+
else if (field.type === 'number' ? (typeof value !== 'number' || !Number.isFinite(value)) : typeof value !== field.type) {
|
|
839
|
+
issues.error(path, `expected a ${field.type}`);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
else if (field.type === 'string') {
|
|
843
|
+
strings.push(value);
|
|
844
|
+
}
|
|
845
|
+
for (const item of strings) {
|
|
846
|
+
if (field.values && !field.values.includes(item)) {
|
|
847
|
+
issues.error(path, `${JSON.stringify(item)} is not among the values of the field ${field.name}`, suggestion(item, field.values));
|
|
848
|
+
}
|
|
849
|
+
if (options.limits?.valueLength && item.length > options.limits.valueLength) {
|
|
850
|
+
issues.error(path, `the value is longer than ${options.limits.valueLength} characters`, `shorten it to ${options.limits.valueLength} characters`);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
function checkLimits(spec, limits, issues) {
|
|
855
|
+
for (const part of ['cols', 'rows', 'cells', 'filters']) {
|
|
856
|
+
const limit = limits?.[part];
|
|
857
|
+
const value = spec[part];
|
|
858
|
+
if (limit && Array.isArray(value) && value.length > limit) {
|
|
859
|
+
issues.error(part, `more than ${limit} ${part}`, `keep at most ${limit}`);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** A field named by a column, a cell or a filter is a field of the row. */
|
|
864
|
+
function checkFieldName(name, path, fields, issues) {
|
|
865
|
+
const field = (typeof name === 'string') ? fields.get(name) : undefined;
|
|
866
|
+
if (!field) {
|
|
867
|
+
issues.error(path, `no field ${name} in fields`, suggestion(name, [...fields.keys()]));
|
|
868
|
+
}
|
|
869
|
+
return field;
|
|
870
|
+
}
|
|
871
|
+
function checkActions(value, path, issues) {
|
|
872
|
+
if (!isStringArray(value)) {
|
|
873
|
+
issues.error(path, 'expected an array of action names');
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
value.forEach((action, index) => {
|
|
877
|
+
// An own cell may open the row details with the action of the details button
|
|
878
|
+
if (action.startsWith('bro-') && action !== 'bro-details-toggle') {
|
|
879
|
+
issues.error(`${path}[${index}]`, 'the actions that start with bro- belong to the table', 'name it without bro-, as edit or delete');
|
|
880
|
+
}
|
|
881
|
+
else if (!actionName.test(action)) {
|
|
882
|
+
issues.error(`${path}[${index}]`, 'expected an action name', 'as edit or delete');
|
|
883
|
+
}
|
|
884
|
+
else if (value.indexOf(action) !== index) {
|
|
885
|
+
issues.error(`${path}[${index}]`, `the action ${action} repeats`, 'keep one');
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
/** Reports the unknown keys of an object, with the nearest known name or the reason why the spec leaves them out. */
|
|
890
|
+
function checkKeys(object, known, path, issues, elsewhere = {}) {
|
|
891
|
+
for (const name of Object.keys(object)) {
|
|
892
|
+
if (known.includes(name)) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
const itemPath = path ? `${path}${key(name)}` : name;
|
|
896
|
+
if (elsewhere[name]) {
|
|
897
|
+
issues.error(itemPath, elsewhere[name][0], elsewhere[name][1]);
|
|
898
|
+
}
|
|
899
|
+
else {
|
|
900
|
+
issues.error(itemPath, `unknown field ${name}`, suggestion(name, known));
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* The nearest name as a fix: a typo of a model or a developer by the edit distance, or the full word of a short name,
|
|
906
|
+
* as columns of cols.
|
|
907
|
+
*/
|
|
908
|
+
function suggestion(name, candidates) {
|
|
909
|
+
if (typeof name !== 'string' || !candidates.length) {
|
|
910
|
+
return candidates.length ? `expected one of ${candidates.join(', ')}` : undefined;
|
|
911
|
+
}
|
|
912
|
+
// A name longer than any name of a spec gets no nearest name: the distance of two long texts takes a long time
|
|
913
|
+
if (name.length > maxSuggestedName) {
|
|
914
|
+
return (candidates.length <= 12) ? `expected one of ${candidates.join(', ')}` : undefined;
|
|
915
|
+
}
|
|
916
|
+
const typed = name.toLowerCase();
|
|
917
|
+
let best;
|
|
918
|
+
let bestDistance = Infinity;
|
|
919
|
+
for (const candidate of candidates) {
|
|
920
|
+
const word = candidate.toLowerCase();
|
|
921
|
+
const limit = Math.max(2, Math.floor(word.length / 3));
|
|
922
|
+
// The distance is at least the difference of the lengths, and a short name of a word is that far from it
|
|
923
|
+
const difference = Math.abs(typed.length - word.length);
|
|
924
|
+
const distance = (difference <= limit) ? editDistance(typed, word) : difference;
|
|
925
|
+
const near = distance <= limit || shortens(word, typed);
|
|
926
|
+
if (near && distance < bestDistance) {
|
|
927
|
+
best = candidate;
|
|
928
|
+
bestDistance = distance;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
if (best !== undefined) {
|
|
932
|
+
return `did you mean ${best}?`;
|
|
933
|
+
}
|
|
934
|
+
return (candidates.length <= 12) ? `expected one of ${candidates.join(', ')}` : undefined;
|
|
935
|
+
}
|
|
936
|
+
/** A short name of a word: its first three letters, then the rest of its letters in their order, as cols of columns. */
|
|
937
|
+
function shortens(short, word) {
|
|
938
|
+
if (short.length >= word.length || !word.startsWith(short.slice(0, 3))) {
|
|
939
|
+
return false;
|
|
940
|
+
}
|
|
941
|
+
let index = 0;
|
|
942
|
+
for (const letter of word) {
|
|
943
|
+
if (letter === short[index]) {
|
|
944
|
+
index++;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
return index === short.length;
|
|
948
|
+
}
|
|
949
|
+
function editDistance(a, b) {
|
|
950
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
951
|
+
for (let i = 1; i <= a.length; i++) {
|
|
952
|
+
const current = [i];
|
|
953
|
+
for (let j = 1; j <= b.length; j++) {
|
|
954
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
955
|
+
}
|
|
956
|
+
previous = current;
|
|
957
|
+
}
|
|
958
|
+
return previous[b.length];
|
|
959
|
+
}
|
|
960
|
+
/** A key in a path: .name for an identifier, ['no-data'] for others. */
|
|
961
|
+
function key(name) {
|
|
962
|
+
return specIdentifier.test(name) ? `.${name}` : `['${name}']`;
|
|
963
|
+
}
|
|
964
|
+
function isObject(value) {
|
|
965
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
966
|
+
}
|
|
967
|
+
function isStringArray(value) {
|
|
968
|
+
return Array.isArray(value) && value.every(item => typeof item === 'string');
|
|
969
|
+
}
|
|
970
|
+
/** The report of the issues: the first ones of each kind, so a spec of thousands of errors gives a short answer. */
|
|
971
|
+
function report(issues) {
|
|
972
|
+
const result = { valid: !issues.errors.length, errors: issues.errors.slice(0, maxReported), warnings: issues.warnings.slice(0, maxReported) };
|
|
973
|
+
const errors = issues.errors.length - result.errors.length;
|
|
974
|
+
const warnings = issues.warnings.length - result.warnings.length;
|
|
975
|
+
if (errors || warnings) {
|
|
976
|
+
result.omitted = { ...(errors ? { errors } : {}), ...(warnings ? { warnings } : {}) };
|
|
977
|
+
}
|
|
978
|
+
return result;
|
|
979
|
+
}
|