This repository was archived by the owner on Mar 7, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathgui-model.mapper.ts
314 lines (276 loc) · 12.7 KB
/
gui-model.mapper.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"use strict";
import 'core-js/library';
import { JsonSchema } from '../dependencies/json-schema';
import { isString, isNumber, isBoolean, isIntegerNumber, isArray, isObject } from './type-utils';
import { GuiModel, Group, GuiElement, SubDataType,
TranslationError, TypedField } from './gui-model';
/**
* Process a json schema node. This can either be the root or an object inside it when called recursively from within each property.
* @param dataKeyPath The corresponding object path in the schema instance (data) object for the schema element. Used by clients of gui model.
* @param schemaPath The path of the element inside the schema itself. Used for error reporting.
* @param accumulatedErrors A mutable(!) array where any errors during processing are appended.
*/
function processProperties(obj: JsonSchema, dataKeyPath: string, schemaPath: string, accumulatedErrors: TranslationError[]): GuiElement[] {
let result: GuiElement[] = [];
let properties = obj.properties || {};
let requiredKeys = new Set(obj.required || []);
for (let key in properties) {
if (properties.hasOwnProperty(key)) {
validate(key, 'key', 'string', (v) => isString(v), dataKeyPath, accumulatedErrors);
let settingsPropertyKeyPath = (dataKeyPath === '') ? key : dataKeyPath + '.' + key;
let schemaPropertyPath = (schemaPath === '') ? 'properties.' + key : schemaPath + '.' + 'properties.' + key;
let requiredItem = requiredKeys.has(key);
let value = properties[key] as JsonSchema;
if (isObject(value)) {
let element = processProperty(key, value, requiredItem, settingsPropertyKeyPath, schemaPropertyPath, accumulatedErrors);
// Guard against fatal errors in recursive call:
if (element) {
result.push(element);
}
} else {
addError(accumulatedErrors, 'Unsupported element type ' + typeof value, schemaPropertyPath);
}
}
}
return result;
}
/**
* Process a json schema key/value property definition.
*/
function processProperty(key: string, value: any, requiredItem: boolean, keyPath: string, schemaPath: string, accumulatedErrors: TranslationError[]): GuiElement | null {
let type = value.type;
if (!isString(type)) {
addError(accumulatedErrors, 'Type elements must be strings (not ' + typeof type + ') ', schemaPath);
}
let label = value.title || key;
validate(label, 'title', 'string', (v) => isString(v), schemaPath, accumulatedErrors);
let tooltip = value.description || '';
validate(tooltip, 'tooltip', 'string', (v) => isString(v), schemaPath, accumulatedErrors);
if (type === 'string' || type === 'number' || type === 'boolean' || type === 'integer') {
let defaultValue = value.default;
if (defaultValue === undefined || defaultValue === null) {
switch (type) {
case 'number': defaultValue = 0.0; break;
case 'boolean': defaultValue = false; break;
case 'integer': defaultValue = 0; break;
default: defaultValue = '';
}
}
let dataSubType: SubDataType = value.format || 'none';
let enumValues = value.enum; // Undefined otherwise.
validateField(type, dataSubType, defaultValue, enumValues, keyPath, schemaPath, requiredItem, accumulatedErrors);
let prop: GuiElement | null;
switch (type) {
case 'number': prop = createNumberField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues);
break;
case 'boolean': prop = createBooleanField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues);
break;
case 'integer': prop = createIntegerField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues);
break;
case 'string': prop = createStringField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues);
break;
default: prop = null;
addError(accumulatedErrors, 'Unsupported type ' + type, schemaPath);
break;
}
return prop;
} else if (type === 'object') {
let nestedProperties = processProperties(value, keyPath, schemaPath, accumulatedErrors); // Note mutal recursive call.
// Guard against fatal errors in recursive call:
if (nestedProperties != null) {
let group = createGroupProperty(key, keyPath, label, tooltip, requiredItem, nestedProperties);
return group;
}
} else if (type === 'array') {
// TODO: Consider supporting arrays if there are valid use cases: https://spacetelescope.github.io/understanding-json-schema/reference/array.html
addError(accumulatedErrors, 'Arrays not supported (yet)', schemaPath);
} else {
addError(accumulatedErrors, 'Unrecognized element type ' + type, schemaPath);
}
return null;
}
function validateField(type: string, dataSubType: SubDataType, defaultValue: any, enumValues: any[],
keyPath: string, schemaPath: string,
requiredItem: boolean, accumulatedErrors: TranslationError[]): void {
validate(dataSubType, 'format', 'string', (v) => isString(v), schemaPath, accumulatedErrors);
let valueValidator: (value: any) => boolean;
switch (type) {
case 'number': valueValidator = (v) => isNumber(v);
break;
case 'boolean': valueValidator = (v) => isBoolean(v);
break;
case 'integer': valueValidator = (v) => isIntegerNumber(v);
break;
case 'string': valueValidator = (v) => isString(v);
break;
default: valueValidator = (v) => true; // Don't validate.
addError(accumulatedErrors, 'Unsupported type ' + type, schemaPath);
break;
}
validate(defaultValue, 'default', type, valueValidator, schemaPath, accumulatedErrors);
if (enumValues) {
validateArray(enumValues, 'enum', !requiredItem, type, valueValidator, schemaPath, accumulatedErrors);
}
}
/**
* Create an immutable gui input dropdown element for a number, making any contained objects immutable in the process.
*/
function createNumberField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: number,
required: boolean, dataType: SubDataType, values: (number[] | undefined) = undefined): TypedField<number> {
return Object.freeze<TypedField<number>>({
kind: 'field',
name: key,
controlType: values && values.length > 0 ? 'dropdown' : 'input',
label: label,
tooltip: tooltip,
dataObjectPath: objectPath,
defaultValue: defaultValue,
required: required,
type: 'number',
subType: dataType,
values: values ? Object.freeze(values) : values
});
}
/**
* Create an immutable gui input dropdown element for an integer, making any contained objects immutable in the process.
*/
function createIntegerField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: number,
required: boolean, dataType: SubDataType, values: (number[] | undefined) = undefined): TypedField<number> {
return Object.freeze<TypedField<number>>({
kind: 'field',
name: key,
controlType: values && values.length > 0 ? 'dropdown' : 'input',
label: label,
tooltip: tooltip,
dataObjectPath: objectPath,
defaultValue: defaultValue,
values: values ? Object.freeze(values) : values,
required: required,
type: 'integer',
subType: dataType
});
}
/**
* Create an immutable gui input element for a boolean, making any contained objects immutable in the process.
*/
function createBooleanField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: boolean,
required: boolean, dataType: SubDataType, values: (boolean[] | undefined) = undefined): TypedField<boolean> {
return Object.freeze<TypedField<boolean>>({
kind: 'field',
name: key,
controlType: 'yesno',
label: label,
tooltip: tooltip,
dataObjectPath: objectPath,
defaultValue: defaultValue,
values: values ? Object.freeze(values) : values,
required: required,
type: 'boolean',
subType: dataType
});
}
/**
* Create an immutable gui input dropdown element for a string, making any contained objects immutable in the process.
*/
function createStringField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: string,
required: boolean, dataType: SubDataType, values: (string[] | undefined) = undefined): TypedField<string> {
return Object.freeze<TypedField<string>>({
kind: 'field',
name: key,
controlType: values && values.length > 0 ? 'dropdown' : 'input',
label: label,
tooltip: tooltip,
dataObjectPath: objectPath,
defaultValue: defaultValue,
values: values ? Object.freeze(values) : values,
required: required,
type: 'string',
subType: dataType
});
}
/**
* Create an immutable gui group element with nested gui elements inside, making any contained objects immutable in the process.
*/
function createGroupProperty(key: string, objectPath: string, label: string, tooltip: string, required: boolean, elements: GuiElement[]): Group {
return Object.freeze<Group>({
kind: 'group',
name: key,
controlType: 'group',
dataObjectPath: objectPath,
label: label,
tooltip: tooltip,
required: required,
elements: Object.freeze(elements)
});
}
function validate(value: any, valueName: string, allowedTypeName: string, validator: (value: any) => boolean, schemaPath: string, accumulatedErrors: TranslationError[]) {
if (!validator(value)) {
addError(accumulatedErrors, 'Illegal default ' + value + '. ' + allowedTypeName + ' expected ' , schemaPath + '.' + valueName);
return false;
} else {
return true;
}
}
function validateArray(values: any, valueName: string, allowNulls: boolean, allowedTypeName: string, validator: (value: any) => boolean,
schemaPath: string, accumulatedErrors: TranslationError[]) {
if (!isArray(values)) {
addError(accumulatedErrors, 'Illegal default. Array expected' + values, schemaPath + '.' + valueName);
return false;
} else {
(values as Array<any>).forEach(value => {
if (value === null) {
if (!allowNulls) {
addError(accumulatedErrors, 'Null not allowed in required array', schemaPath + '.' + valueName);
}
} else if (!validator(value)) {
addError(accumulatedErrors, 'Illegal value types in array. Array of ' + allowedTypeName + ' expected', schemaPath + '.' + valueName);
}
});
return true;
}
}
function addError(errors: TranslationError[], errorText: string, schemaPath: string) {
let error = {
schemaPath: schemaPath,
errorText: errorText
};
errors.push(error);
return errors;
}
// --- Actual service starts here ---
/**
* Mapping service that can convert a json schema to a gui model for presentation.
*/
export class GuiModelMapper {
public constructor () {
}
/**
* Converts a json schema into an immutable gui model. In case of errors, the associated error array will be non-empty and the
* resulting gui model may be invalid.
*/
public mapToGuiModel(schema: JsonSchema): GuiModel {
// Setup mutable error reporting array that will have values added during procesing in case of errors.
let errors: TranslationError[] = [];
let result: GuiElement[];
try {
// Exceptions should not be thrown during processing but if they are (due to programming error) than safely process them here:
result = processProperties(schema || {}, '', '', errors);
} catch (err) {
// Fallback: These errors should not occur - if they do the processing should be made more rubust to avoid them:
result = [];
addError(errors, 'Internal error processing json schema: ' + err, '');
}
// The expected result is an expanded group with error information:
return Object.freeze<GuiModel>({
kind: 'group',
name: '',
controlType: 'group',
dataObjectPath: '',
label: '',
tooltip: '',
required: true,
elements: Object.freeze(result),
errors: Object.freeze(errors)
});
}
}