├── docs ├── example_form.png ├── example.schema.json └── example.gui.model.json ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── src ├── index.ts ├── lib │ ├── util.ts │ ├── type-utils.ts │ ├── constants.ts │ ├── gui-model.ts │ └── gui-model.mapper.ts ├── test │ ├── test-util.ts │ ├── index.ts │ ├── schemas.ts │ └── gui-models.ts ├── cli │ └── index.ts └── dependencies │ └── json-schema.ts ├── CHANGELOG.md ├── .gitignore ├── tsconfig.json ├── LICENSE ├── tslint.json ├── package.json └── README.md /docs/example_form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mmc41/json-schema-js-gui-model/HEAD/docs/example_form.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "./node_modules/typescript/lib" 4 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './lib/gui-model'; 2 | export * from './lib/constants'; 3 | export * from './lib/util'; 4 | export { JsonSchema } from './dependencies/json-schema'; 5 | export { GuiModelMapper } from './lib/gui-model.mapper'; 6 | 7 | -------------------------------------------------------------------------------- /src/lib/util.ts: -------------------------------------------------------------------------------- 1 | import { GuiElement } from './gui-model'; 2 | 3 | /** 4 | * Calculate the max nesting level of a GuiElement. 5 | */ 6 | export function maxGuiElementDepth(element: GuiElement): number { 7 | if (element.kind === 'group') { 8 | let depths: number[] = element.elements.map(e => maxGuiElementDepth(e)); 9 | return 1 + (depths.length === 0 ? 0 : Math.max.apply(null, depths)); 10 | } else { 11 | return 1; 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v1.0.0 Initial stable release 2 | v2.0.0 ES5 retargeting for easier browser compatibility. 3 | Removed isRoot. 4 | v2.0.1 Updated dependencies to latest versions, incl. typescript to 2.1.5 5 | Arrays in model are now declared as ReadOnly. 6 | v2.1.1 Fixed JsonSchema bug. 7 | Made values in TypedField nullable 8 | Added optional comment fields. 9 | Turned on strict null-checks for typescript. 10 | Removed test files from distribution npm 11 | 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "0.1.0", 5 | "command": "npm", 6 | "isShellCommand": true, 7 | "showOutput": "always", 8 | "suppressTaskName": true, 9 | "echoCommand": true, 10 | "tasks": [ 11 | { 12 | "taskName": "build", 13 | "args": ["run", "build"] 14 | }, 15 | { 16 | "taskName": "test", 17 | "args": ["run", "test"] 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/lib/type-utils.ts: -------------------------------------------------------------------------------- 1 | export function isString(obj: any) { 2 | return (typeof obj) === 'string'; 3 | } 4 | 5 | export function isBoolean(obj: any) { 6 | return (typeof obj) === 'boolean'; 7 | } 8 | 9 | export function isNumber(obj: any) { 10 | return (typeof obj) === 'number'; 11 | } 12 | 13 | export function isIntegerNumber(obj: any) { 14 | return (typeof obj) === 'number' && Number.isInteger(obj); 15 | } 16 | 17 | export function isObject(obj: any) { 18 | return obj != null && (typeof obj) === 'object' && !(obj instanceof Array); 19 | } 20 | 21 | export function isArray(obj: any) { 22 | return (obj instanceof Array); 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | dist 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { GuiModel, Group } from './gui-model'; 2 | import { JsonSchema } from '../dependencies/json-schema'; 3 | 4 | export let EMPTY_SCHEMA: JsonSchema = Object.freeze({}); 5 | 6 | export let EMPTY_GUI_MODEL: GuiModel = Object.freeze({ 7 | kind: 'group', 8 | name: '', 9 | controlType: 'group', 10 | label: '', 11 | tooltip: '', 12 | dataObjectPath: '', 13 | required: true, 14 | elements: [], 15 | errors: [] 16 | }); 17 | 18 | export let EMPTY_GUI_MODEL_GROUP: Group = Object.freeze({ 19 | kind: 'group', 20 | name: '', 21 | controlType: 'group', 22 | label: '', 23 | tooltip: '', 24 | dataObjectPath: '', 25 | required: true, 26 | elements: [] 27 | }); 28 | 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src", 6 | "target": "es5", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "declaration": true, 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "watch": false, 13 | "allowJs": false, 14 | "removeComments": false, 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "strictNullChecks": true, 21 | "pretty": true, 22 | "listFiles": false, 23 | "listEmittedFiles": false 24 | }, 25 | "include": [ 26 | "src/**/*" 27 | ], 28 | "exclude": [ 29 | "node_modules" 30 | ] 31 | } -------------------------------------------------------------------------------- /src/test/test-util.ts: -------------------------------------------------------------------------------- 1 | import { isArray, isObject } from '../lib/type-utils'; 2 | 3 | /** 4 | * Return a copy of the object with all properties set to undefined removed. 5 | * Useful when comparing objects that are the same execpt for properties set to undefined. 6 | */ 7 | export function deeplyStripUndefined(source: any): any { 8 | if (source === null || source === undefined) { 9 | return source; 10 | } else if (isArray(source)) { 11 | return source.map((e: any) => deeplyStripUndefined(e)); 12 | } else if (isObject(source)) { 13 | let copy = {}; 14 | 15 | for (let key in source) { 16 | if (source.hasOwnProperty(key)) { 17 | let orgValue = source[key]; 18 | if (orgValue !== undefined) { 19 | copy[key] = deeplyStripUndefined(source[key]); 20 | } 21 | } 22 | } 23 | 24 | return copy; 25 | } else { 26 | return source; 27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Morten M. Christensen (mmc41) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | { 9 | "type": "node2", 10 | "request": "launch", 11 | "name": "Mocha Tests", 12 | "cwd": "${workspaceRoot}", 13 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/mocha", 14 | "windows": { 15 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/mocha.cmd" 16 | }, 17 | "runtimeArgs": [ 18 | "-u", 19 | "bdd", 20 | "--timeout", 21 | "999999", 22 | "--colors", 23 | "${workspaceRoot}/dist/test/index.js" 24 | ], 25 | "console": "internalConsole", 26 | "internalConsoleOptions": "openOnSessionStart", 27 | "stopOnEntry": false, 28 | "smartStep": true, 29 | "skipFiles": [ 30 | "${workspaceRoot}/node_modules/**/*.js" 31 | ], 32 | "preLaunchTask": "build", 33 | "restart": false, 34 | "env": { 35 | "NODE_ENV": "testing" 36 | } 37 | } 38 | ] 39 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "class-name": true, 4 | "comment-format": [ 5 | true, 6 | "check-space" 7 | ], 8 | "indent": [ 9 | true, 10 | "spaces" 11 | ], 12 | "no-duplicate-variable": true, 13 | "no-eval": true, 14 | "no-internal-module": true, 15 | "no-trailing-whitespace": true, 16 | "no-unsafe-finally": true, 17 | "no-var-keyword": true, 18 | "one-line": [ 19 | true, 20 | "check-open-brace", 21 | "check-whitespace" 22 | ], 23 | "quotemark": [ 24 | false, 25 | "double" 26 | ], 27 | "semicolon": [ 28 | true, 29 | "always" 30 | ], 31 | "triple-equals": [ 32 | true, 33 | "allow-null-check" 34 | ], 35 | "typedef-whitespace": [ 36 | true, 37 | { 38 | "call-signature": "nospace", 39 | "index-signature": "nospace", 40 | "parameter": "nospace", 41 | "property-declaration": "nospace", 42 | "variable-declaration": "nospace" 43 | } 44 | ], 45 | "variable-name": [ 46 | true, 47 | "ban-keywords" 48 | ], 49 | "whitespace": [ 50 | true, 51 | "check-branch", 52 | "check-decl", 53 | "check-operator", 54 | "check-separator", 55 | "check-type" 56 | ] 57 | } 58 | } -------------------------------------------------------------------------------- /docs/example.schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "type": "object", 4 | "properties": { 5 | "authentication": { 6 | "type": "object", 7 | "title": "Authentication", 8 | "description": "an authentication description here", 9 | "properties": { 10 | "user": { 11 | "type": "string", 12 | "minLength": 1, 13 | "default": "", 14 | "title" : "User", 15 | "description": "a username" 16 | }, 17 | "password": { 18 | "type": "string", 19 | "minLength": 1, 20 | "default": "", 21 | "title" : "Password", 22 | "description": "a password" 23 | }, 24 | "scheme": { 25 | "type": "string", 26 | "default": "basic" 27 | }, 28 | "preemptive": { 29 | "type": "boolean", 30 | "default": true 31 | } 32 | }, 33 | "required": [ 34 | "user", 35 | "password" 36 | ] 37 | }, 38 | "server": { 39 | "type": "object", 40 | "title": "Server", 41 | "properties": { 42 | "host": { 43 | "type": "string", 44 | "default": "" 45 | }, 46 | "port": { 47 | "type": "integer", 48 | "multipleOf": 1, 49 | "maximum": 65535, 50 | "minimum": 0, 51 | "exclusiveMaximum": false, 52 | "exclusiveMinimum": false, 53 | "default": 80 54 | }, 55 | "protocol": { 56 | "type": "string", 57 | "default": "http", 58 | "enum" : ["http", "ftp"] 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "json-schema-js-gui-model", 3 | "version": "2.1.1", 4 | "description": "Handy gui model and associated translator that is useful when constructing javascript UI forms from json-schemas", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "scripts": { 8 | "watch": "tsc && npm-run-all --continue-on-error --parallel build:watch test:watch", 9 | "test": "tsc && mocha dist/test/index.js", 10 | "test:watch": "mocha --watch dist/test/index.js", 11 | "build": "tsc", 12 | "build:watch": "tsc -w", 13 | "clean": "rimraf dist", 14 | "lint": "tslint src/**/*.ts", 15 | "mapToGuiModel": "node dist/cli/index.js" 16 | }, 17 | "bin": { 18 | "mapToGuiModel": "dist/cli/index.js" 19 | }, 20 | "files": [ 21 | "dist/cli", 22 | "dist/dependencies", 23 | "dist/lib", 24 | "dist/index*", 25 | "LICENSE", 26 | "CHANGELOG.md", 27 | "README.md" 28 | ], 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/mmc41/json-schema-js-gui-model.git" 32 | }, 33 | "keywords": [ 34 | "json-schema", 35 | "javascript", 36 | "typescript", 37 | "gui", 38 | "model" 39 | ], 40 | "author": "Morten M. Christensen (mmc41)", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/mmc41/json-schema-js-gui-model/issues" 44 | }, 45 | "homepage": "https://github.com/mmc41/json-schema-js-gui-model#readme", 46 | "devDependencies": { 47 | "@types/chai": "3.4.35", 48 | "@types/core-js": "0.9.35", 49 | "@types/mocha": "2.2.39", 50 | "@types/node": "7.0.5", 51 | "chai": "3.5.0", 52 | "ghooks": "^2.0.0", 53 | "mocha": "3.2.0", 54 | "nodemon": "1.11.0", 55 | "npm-run-all": "4.0.2", 56 | "rimraf": "2.6.1", 57 | "ts-npm-lint": "0.1.0", 58 | "tslint": "4.4.2", 59 | "typescript": "2.2.1" 60 | }, 61 | "engines": { 62 | "node": ">=6.0.0", 63 | "npm": ">=3.0.0" 64 | }, 65 | "config": { 66 | "ghooks": { 67 | "pre-commit": "npm run test" 68 | } 69 | }, 70 | "dependencies": { 71 | "core-js": "2.4.1" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/cli/index.ts: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | import { argv, stdin, stdout, exit } from 'process'; 4 | import { readFileSync, writeFileSync, existsSync } from 'fs'; 5 | 6 | import { JsonSchema } from '../dependencies/json-schema'; 7 | import { GuiModelMapper } from '../lib/gui-model.mapper'; 8 | import { GuiModel } from '../lib/gui-model'; 9 | 10 | /** 11 | * Name of bin executable specified in package.json: 12 | */ 13 | const npmCmd = 'mapToGuiModel'; 14 | 15 | /** 16 | * Description of to use the npm executable cli command: 17 | */ 18 | const usageString = 'Usage: ' + npmCmd + ' sourcePath destPath'; 19 | 20 | /** 21 | * Help that abouts with error output 22 | */ 23 | function failure(msg: string) { 24 | console.error(msg); 25 | exit(-1); 26 | } 27 | 28 | // Validate cmd arguments: 29 | let userArgs = argv.slice(2); 30 | if (userArgs.length !== 2) { 31 | failure(usageString); 32 | } 33 | 34 | let sourcePath = userArgs[0]; 35 | let destPath = userArgs[1]; 36 | 37 | // Read input: 38 | let source: string = ''; 39 | 40 | if (!existsSync(sourcePath)) { 41 | failure('Error. Could not locate source file \"' + sourcePath + '\".'); 42 | } 43 | 44 | try { 45 | source = readFileSync(sourcePath, { 46 | encoding: 'utf8', 47 | flag: 'r' 48 | }); 49 | } catch (e) { 50 | failure('Error reading file \"' + sourcePath + '\"'); 51 | } 52 | 53 | // Convert input: 54 | let sourceJson: any; 55 | try { 56 | sourceJson = JSON.parse(source); 57 | } catch (e) { 58 | failure('Error. File \"' + sourcePath + '\" is not a valid json file'); 59 | } 60 | 61 | // Generate output: 62 | let mapper: GuiModelMapper = new GuiModelMapper(); 63 | 64 | let result; 65 | try { 66 | let resultObj = mapper.mapToGuiModel(sourceJson as JsonSchema); 67 | result = JSON.stringify(resultObj, null, 2); 68 | } catch (e) { 69 | failure('Error. Unexpected internal error while mapping \"' + sourcePath + '\".'); 70 | } 71 | 72 | // Check if file exists already?: 73 | if (existsSync(destPath)) { 74 | failure('Error. Output file \"' + destPath + '\" already exists.'); 75 | } 76 | 77 | // Write output: 78 | try { 79 | writeFileSync(destPath, result, { 80 | encoding: 'utf8', 81 | mode: 0o666, 82 | flag: 'wx' // Don't allow overwriting files for safety. 83 | }); 84 | } catch (e) { 85 | failure('Error. Could not write new file \"' + destPath + '\".'); 86 | } 87 | 88 | // Success msg: 89 | console.log('Successfully mapped json schema in \"' + sourcePath + '\" to gui model and stored result in \"' + destPath + '\"'); 90 | exit(0); 91 | 92 | -------------------------------------------------------------------------------- /src/lib/gui-model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The main gui controls that should be used. 3 | */ 4 | export type ControlType = 5 | 'group' | 'input' | 'dropdown' | 'yesno'; 6 | 7 | /** 8 | * The javascript data types used. 9 | */ 10 | export type DataType = 11 | 'string' | 'number' | 'integer' | 'boolean'; 12 | 13 | /** 14 | * Special subtypes for strings; superset of json schema string formats. 15 | */ 16 | export type StringSubDataType = 17 | 'text' 18 | | 'password' 19 | | 'date' 20 | | 'time' 21 | | 'date-time' 22 | | 'uri' 23 | | 'email' 24 | | 'hostname' 25 | | 'ipv4' 26 | | 'ipv6' 27 | | 'regex' 28 | | 'uuid' 29 | | 'json-pointer' 30 | | 'relative-json-pointer' 31 | ; 32 | 33 | export type IntegerSubType = 34 | 'port-number' 35 | | 'miliseconds' 36 | | 'seconds' 37 | | 'minutes' 38 | | 'hours' 39 | | 'days' 40 | ; 41 | 42 | export type SubDataType = StringSubDataType | IntegerSubType | 'none'; 43 | 44 | /** 45 | * Common interface for all gui elements. 46 | */ 47 | export interface GuiElementBase { 48 | readonly name: string; 49 | 50 | readonly controlType: ControlType; 51 | 52 | readonly label: string; 53 | readonly tooltip: string; 54 | 55 | /** 56 | * A string representing a path to the property in json data file conforming to the plugin schema, 57 | * where each path component is seperated by a dot. Same as string representation of 58 | * Lodash.get method or mariocasciaro's object-path (https://github.com/mariocasciaro/object-path). 59 | * This string is unique for all elements in a model and may thus be used as a key if needed. 60 | */ 61 | readonly dataObjectPath: string; 62 | readonly required: boolean; 63 | readonly comment?: string; 64 | } 65 | 66 | 67 | /** 68 | * Base interface for all types of gui input elements that are not containers for other elements. 69 | */ 70 | export interface FieldBase extends GuiElementBase { 71 | readonly kind: 'field'; 72 | readonly type: DataType; 73 | readonly subType: SubDataType; 74 | }; 75 | 76 | export interface TypedField extends FieldBase { 77 | readonly defaultValue: T | null; 78 | readonly values?: ReadonlyArray; 79 | } 80 | 81 | /** 82 | * A containers for other gui elements. 83 | */ 84 | export interface Group extends GuiElementBase { 85 | readonly kind: 'group'; 86 | readonly elements: ReadonlyArray; 87 | } 88 | 89 | export interface TranslationError { 90 | readonly schemaPath: string; 91 | readonly errorText: string; 92 | }; 93 | 94 | /** 95 | * Represents a full gui model. Essentially a group but with an extra errors field. 96 | */ 97 | export interface GuiModel extends Group { 98 | readonly errors: ReadonlyArray; 99 | } 100 | 101 | export type Field = TypedField | TypedField | TypedField; 102 | 103 | export type GuiElement = Group | Field; 104 | -------------------------------------------------------------------------------- /src/test/index.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:max-line-length */ 2 | "use strict"; 3 | 4 | import 'core-js/library'; 5 | 6 | import { JsonSchema } from '../dependencies/json-schema'; 7 | import { GuiModelMapper } from '../lib/gui-model.mapper'; 8 | import { GuiModel, TypedField } from '../lib/gui-model'; 9 | 10 | import { simple_schema1, simple_schema2, complex_schema1, 11 | test_different_elements_schema1, test_groups_schema1, 12 | invalid_schema1 } from './schemas'; 13 | 14 | import { simple_gui_model1 as expected_simple_gui_model1, 15 | simple_gui_model2 as expected_simple_gui_model2, 16 | test_different_elements_gui_model1 as expected_test_elements_schema1, 17 | test_groups_gui_model1 as expected_test_groups_gui_model1, 18 | complex_gui_model1 as expected_complex_gui_model1, 19 | invalid_gui_model1 as expected_invalid_gui_model1 20 | } from './gui-models'; 21 | 22 | import { EMPTY_GUI_MODEL } from '../lib/constants'; 23 | 24 | import { maxGuiElementDepth } from '../lib/util'; 25 | 26 | import { deeplyStripUndefined } from './test-util'; 27 | import { expect } from 'chai'; 28 | 29 | describe('Service: GuiModelMapper', () => { 30 | let mapper: GuiModelMapper = new GuiModelMapper(); 31 | 32 | function map(schema: JsonSchema): GuiModel { 33 | return deeplyStripUndefined(mapper.mapToGuiModel(schema)); 34 | } 35 | 36 | it('should map simple model 1 correctly', () => { 37 | let guiModel = map(simple_schema1); 38 | expect(guiModel).to.deep.equal(expected_simple_gui_model1); 39 | }); 40 | 41 | it('should map simple model 2 correctly', () => { 42 | let guiModel = map(simple_schema2); 43 | expect(guiModel).to.deep.equal(expected_simple_gui_model2); 44 | }); 45 | 46 | it('should map different elements test schema 1 correctly', () => { 47 | let guiModel = map(test_different_elements_schema1); 48 | expect(guiModel).to.deep.equal(expected_test_elements_schema1); 49 | }); 50 | 51 | it('should map group test model 1 correctly', () => { 52 | let guiModel = map(test_groups_schema1); 53 | expect(guiModel).to.deep.equal(expected_test_groups_gui_model1); 54 | }); 55 | 56 | it('should map complex model 1 correctly', () => { 57 | let guiModel = map(complex_schema1); 58 | expect(guiModel).to.deep.equal(expected_complex_gui_model1); 59 | }); 60 | 61 | it('should report error(s)', () => { 62 | let guiModel = map(invalid_schema1 as JsonSchema); 63 | expect(guiModel.errors.length).to.be.greaterThan(0); 64 | expect(guiModel).to.deep.equal(expected_invalid_gui_model1); 65 | }); 66 | }); 67 | 68 | describe('Utils', () => { 69 | it('should calculate maxDepth of empty model correctly', () => { 70 | let depth = maxGuiElementDepth(EMPTY_GUI_MODEL); 71 | expect(depth).to.equal(1); 72 | }); 73 | 74 | it('should calculate maxDepth of field correctly', () => { 75 | let field: TypedField = { 76 | kind: 'field', 77 | name: '', 78 | controlType: 'input', 79 | label: '', 80 | tooltip: '', 81 | dataObjectPath: '', 82 | defaultValue: '', 83 | values: [], 84 | required: true, 85 | type: 'string', 86 | subType: 'text' 87 | }; 88 | 89 | let depth = maxGuiElementDepth(field); 90 | expect(depth).to.equal(1); 91 | }); 92 | 93 | it('should calculate maxDepth of complex example correctly', () => { 94 | let depth = maxGuiElementDepth(expected_complex_gui_model1); 95 | expect(depth).to.equal(3); 96 | }); 97 | }); 98 | 99 | -------------------------------------------------------------------------------- /docs/example.gui.model.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "group", 3 | "name": "", 4 | "controlType": "group", 5 | "dataObjectPath": "", 6 | "label": "", 7 | "tooltip": "", 8 | "isRoot": true, 9 | "required": true, 10 | "elements": [ 11 | { 12 | "kind": "group", 13 | "name": "authentication", 14 | "controlType": "group", 15 | "dataObjectPath": "authentication", 16 | "label": "Authentication", 17 | "tooltip": "an authentication description here", 18 | "isRoot": false, 19 | "required": false, 20 | "elements": [ 21 | { 22 | "kind": "field", 23 | "name": "user", 24 | "controlType": "input", 25 | "label": "User", 26 | "tooltip": "a username", 27 | "dataObjectPath": "authentication.user", 28 | "defaultValue": "", 29 | "required": true, 30 | "type": "string", 31 | "subType": "none" 32 | }, 33 | { 34 | "kind": "field", 35 | "name": "password", 36 | "controlType": "input", 37 | "label": "Password", 38 | "tooltip": "a password", 39 | "dataObjectPath": "authentication.password", 40 | "defaultValue": "", 41 | "required": true, 42 | "type": "string", 43 | "subType": "none" 44 | }, 45 | { 46 | "kind": "field", 47 | "name": "scheme", 48 | "controlType": "input", 49 | "label": "scheme", 50 | "tooltip": "", 51 | "dataObjectPath": "authentication.scheme", 52 | "defaultValue": "basic", 53 | "required": false, 54 | "type": "string", 55 | "subType": "none" 56 | }, 57 | { 58 | "kind": "field", 59 | "name": "preemptive", 60 | "controlType": "yesno", 61 | "label": "preemptive", 62 | "tooltip": "", 63 | "dataObjectPath": "authentication.preemptive", 64 | "defaultValue": true, 65 | "required": false, 66 | "type": "boolean", 67 | "subType": "none" 68 | } 69 | ] 70 | }, 71 | { 72 | "kind": "group", 73 | "name": "server", 74 | "controlType": "group", 75 | "dataObjectPath": "server", 76 | "label": "Server", 77 | "tooltip": "", 78 | "isRoot": false, 79 | "required": false, 80 | "elements": [ 81 | { 82 | "kind": "field", 83 | "name": "host", 84 | "controlType": "input", 85 | "label": "host", 86 | "tooltip": "", 87 | "dataObjectPath": "server.host", 88 | "defaultValue": "", 89 | "required": false, 90 | "type": "string", 91 | "subType": "none" 92 | }, 93 | { 94 | "kind": "field", 95 | "name": "port", 96 | "controlType": "input", 97 | "label": "port", 98 | "tooltip": "", 99 | "dataObjectPath": "server.port", 100 | "defaultValue": 80, 101 | "required": false, 102 | "type": "integer", 103 | "subType": "none" 104 | }, 105 | { 106 | "kind": "field", 107 | "name": "protocol", 108 | "controlType": "dropdown", 109 | "label": "protocol", 110 | "tooltip": "", 111 | "dataObjectPath": "server.protocol", 112 | "defaultValue": "http", 113 | "values": [ 114 | "http", 115 | "ftp" 116 | ], 117 | "required": false, 118 | "type": "string", 119 | "subType": "none" 120 | } 121 | ] 122 | } 123 | ], 124 | "errors": [] 125 | } -------------------------------------------------------------------------------- /src/dependencies/json-schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MIT License 3 | * 4 | * Copyright (c) 2016 Richard Adams (https://github.com/enriched) 5 | * 6 | * Permission is hereby granted, free of charge, to any person obtaining a copy 7 | * of this software and associated documentation files (the "Software"), to deal 8 | * in the Software without restriction, including without limitation the rights 9 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | * copies of the Software, and to permit persons to whom the Software is 11 | * furnished to do so, subject to the following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be included in all 14 | * copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | * SOFTWARE. 23 | */ 24 | 25 | // Adapted from: https://gist.github.com/enriched/c84a2a99f886654149908091a3183e15 26 | // with a few changes such as: 27 | // * format attribute added. 28 | // * type of $schema is a string 29 | 30 | export interface JsonSchema { 31 | $ref?: string; 32 | ///////////////////////////////////////////////// 33 | // Schema Metadata 34 | ///////////////////////////////////////////////// 35 | /** 36 | * This is important because it tells refs where 37 | * the root of the document is located 38 | */ 39 | id?: string; 40 | /** 41 | * It is recommended that the meta-schema is 42 | * included in the root of any JSON Schema 43 | */ 44 | $schema?: string; 45 | /** 46 | * Title of the schema 47 | */ 48 | title?: string; 49 | /** 50 | * Schema description 51 | */ 52 | description?: string; 53 | /** 54 | * Default json for the object represented by 55 | * this schema 56 | */ 57 | 'default'?: any; 58 | 59 | ///////////////////////////////////////////////// 60 | // Number Validation 61 | ///////////////////////////////////////////////// 62 | /** 63 | * The value must be a multiple of the number 64 | * (e.g. 10 is a multiple of 5) 65 | */ 66 | multipleOf?: number; 67 | maximum?: number; 68 | /** 69 | * If true maximum must be > value, >= otherwise 70 | */ 71 | exclusiveMaximum?: boolean; 72 | minimum?: number; 73 | /** 74 | * If true minimum must be < value, <= otherwise 75 | */ 76 | exclusiveMinimum?: boolean; 77 | 78 | ///////////////////////////////////////////////// 79 | // String Validation 80 | ///////////////////////////////////////////////// 81 | maxLength?: number; 82 | minLength?: number; 83 | /** 84 | * This is a regex string that the value must 85 | * conform to 86 | */ 87 | pattern?: string; 88 | 89 | /** 90 | * For semantic validation. 91 | */ 92 | format?: string; 93 | 94 | ///////////////////////////////////////////////// 95 | // Array Validation 96 | ///////////////////////////////////////////////// 97 | additionalItems?: boolean | JsonSchema; 98 | items?: JsonSchema | JsonSchema[]; 99 | maxItems?: number; 100 | minItems?: number; 101 | uniqueItems?: boolean; 102 | 103 | ///////////////////////////////////////////////// 104 | // Object Validation 105 | ///////////////////////////////////////////////// 106 | maxProperties?: number; 107 | minProperties?: number; 108 | required?: string[]; 109 | additionalProperties?: boolean | JsonSchema; 110 | /** 111 | * Holds simple JSON Schema definitions for 112 | * referencing from elsewhere. 113 | */ 114 | definitions?: {[key: string]: JsonSchema}; 115 | /** 116 | * The keys that can exist on the object with the 117 | * json schema that should validate their value 118 | */ 119 | properties?: {[property: string]: JsonSchema}; 120 | /** 121 | * The key of this object is a regex for which 122 | * properties the schema applies to 123 | */ 124 | patternProperties?: {[pattern: string]: JsonSchema}; 125 | /** 126 | * If the key is present as a property then the 127 | * string of properties must also be present. 128 | * If the value is a JSON Schema then it must 129 | * also be valid for the object if the key is 130 | * present. 131 | */ 132 | dependencies?: {[key: string]: JsonSchema | string[]}; 133 | 134 | ///////////////////////////////////////////////// 135 | // Generic 136 | ///////////////////////////////////////////////// 137 | /** 138 | * Enumerates the values that this schema can be 139 | * e.g. 140 | * {"type": "string", 141 | * "enum": ["red", "green", "blue"]} 142 | */ 143 | 'enum'?: any[]; 144 | /** 145 | * The basic type of this schema, can be one of 146 | * [string, number, object, array, boolean, null] 147 | * or an array of the acceptable types 148 | */ 149 | type?: string | string[]; 150 | 151 | ///////////////////////////////////////////////// 152 | // Combining Schemas 153 | ///////////////////////////////////////////////// 154 | allOf?: JsonSchema[]; 155 | anyOf?: JsonSchema[]; 156 | oneOf?: JsonSchema[]; 157 | /** 158 | * The entity being validated must not match this schema 159 | */ 160 | not?: JsonSchema; 161 | } 162 | -------------------------------------------------------------------------------- /src/test/schemas.ts: -------------------------------------------------------------------------------- 1 | // ------------------------------------------------------------- 2 | // This file contains schemas for test input by mapper 3 | // ------------------------------------------------------------- 4 | 5 | import { JsonSchema } from '../dependencies/json-schema'; 6 | 7 | export const simple_schema1: JsonSchema = { 8 | '$schema': 'http://json-schema.org/draft-04/schema#', 9 | 'type': 'object', 10 | 'properties': { 11 | 'username': { 12 | 'type': 'string' 13 | }, 14 | 'password': { 15 | 'type': 'string' 16 | } 17 | }, 18 | 'required': [ 19 | 'username', 20 | 'password' 21 | ], 22 | 'additionalProperties': false 23 | }; 24 | 25 | export const simple_schema2: JsonSchema = { 26 | '$schema': 'http://json-schema.org/draft-04/schema#', 27 | 'type': 'object', 28 | 'properties': { 29 | 'username': { 30 | 'type': 'string', 31 | 'title': 'user name', 32 | 'description': 'a username description here', 33 | 'default' : 'username default', 34 | 'minLength' : 3, 35 | 'maxLength' : 80, 36 | 'pattern' : '[0-9\\wøæåÆØÅ]+' 37 | }, 38 | 'password': { 39 | 'type': 'string', 40 | 'title': 'password', 41 | 'description': 'a password description here', 42 | 'default' : 'password default', 43 | 'minLength' : 8, 44 | 'maxLength' : 256 45 | } 46 | }, 47 | 'required': [ 48 | 'username', 49 | 'password' 50 | ], 51 | 'additionalProperties': false 52 | }; 53 | 54 | export const test_different_elements_schema1: JsonSchema = { 55 | '$schema': 'http://json-schema.org/draft-04/schema#', 56 | 'type': 'object', 57 | 'properties': { 58 | 'username': { 59 | 'type': 'string', 60 | 'title': 'User name', 61 | 'description': 'a username description here', 62 | 'default' : 'username default', 63 | 'minLength' : 3, 64 | 'maxLength' : 80, 65 | 'pattern' : '[0-9\\wæøåÆØÅ]+' 66 | }, 67 | 'usertype': { 68 | 'type': 'string', 69 | 'title': 'User type', 70 | 'description': 'a type description here', 71 | 'default' : 'user', 72 | 'enum' : [ 'user', 'superuser'] 73 | }, 74 | 'host': { 75 | 'type': 'string', 76 | 'title': 'Hostname', 77 | 'description': 'a hostname description here', 78 | 'default' : 'localhost', 79 | 'format' : 'hostname', 80 | }, 81 | 'age': { 82 | 'type': 'integer', 83 | 'title': 'Age', 84 | 'description': 'an age description here', 85 | 'default' : 18, 86 | 'maximum' : 99, 87 | 'minimum' : 15, 88 | 'exclusiveMaximum': true, 89 | 'exclusiveMinimum': true, 90 | 'multipleOf' : 2 91 | }, 92 | 'size': { 93 | 'type': 'integer', 94 | 'title': 'Size', 95 | 'description': 'a size description here', 96 | 'default' : 0, 97 | 'enum' : [0, 1, 2, null] 98 | }, 99 | 'rate': { 100 | 'type': 'number', 101 | 'title': 'Rate', 102 | 'description': 'a rate description here', 103 | 'default' : 41.42 104 | }, 105 | 'rank': { 106 | 'type': 'number', 107 | 'title': 'Rank', 108 | 'description': 'a rank description here', 109 | 'default' : 3.14, 110 | 'enum' : [3.14, 0.33, 9.99] 111 | }, 112 | 'registered': { 113 | 'type': 'boolean', 114 | 'title': 'Registered', 115 | 'description': 'a registered description here', 116 | 'default' : false 117 | } 118 | }, 119 | 'required': [ 120 | 'username', 121 | 'usertype' 122 | ], 123 | 'additionalProperties': false 124 | }; 125 | 126 | export const test_groups_schema1: JsonSchema = { 127 | '$schema': 'http://json-schema.org/draft-04/schema#', 128 | 'type': 'object', 129 | 'properties': { 130 | 'simple1': { 131 | 'type': 'string', 132 | 'title': 'A simple field level 1', 133 | }, 134 | 'group1': { 135 | 'type': 'object', 136 | 'title': 'Group at level 1', 137 | 'properties': { 138 | 'simple2': { 139 | 'type': 'string', 140 | 'title': 'A simple field level 2', 141 | }, 142 | 'group2': { 143 | 'type': 'object', 144 | 'title': 'Group at level 2', 145 | 'properties': { 146 | 'group3': { 147 | 'type': 'object', 148 | 'title': 'Group at level 3', 149 | 'properties': { 150 | 'simple4': { 151 | 'type': 'string', 152 | 'title': 'A simple field level 4', 153 | } 154 | }}, 155 | 'simple3': { 156 | 'type': 'string', 157 | 'title': 'A simple field level 3', 158 | } 159 | }} 160 | } 161 | } 162 | }, 163 | 'additionalProperties': false 164 | }; 165 | 166 | export const complex_schema1: JsonSchema = { 167 | '$schema': 'http://json-schema.org/draft-04/schema#', 168 | 'type': 'object', 169 | 'properties': { 170 | 'authentication': { 171 | 'type': 'object', 172 | 'title': 'Authentication', 173 | 'description': 'an authentication description here', 174 | 'properties': { 175 | 'user': { 176 | 'type': 'string', 177 | 'minLength': 1, 178 | 'default': '', 179 | 'title' : 'User', 180 | 'description': 'a username', 181 | }, 182 | 'password': { 183 | 'type': 'string', 184 | 'minLength': 1, 185 | 'default': '', 186 | 'title' : 'Password', 187 | 'description': 'a password', 188 | }, 189 | 'scheme': { 190 | 'type': 'string', 191 | 'default': 'basic' 192 | }, 193 | 'preemptive': { 194 | 'type': 'boolean', 195 | 'default': true 196 | } 197 | }, 198 | 'required': [ 199 | 'user', 200 | 'password', 201 | 'scheme', 202 | 'preemptive' 203 | ] 204 | }, 205 | 'server': { 206 | 'type': 'object', 207 | 'title': 'Server', 208 | 'properties': { 209 | 'host': { 210 | 'type': 'string', 211 | 'default': '' 212 | }, 213 | 'port': { 214 | 'type': 'integer', 215 | 'multipleOf': 1, 216 | 'maximum': 65535, 217 | 'minimum': 0, 218 | 'exclusiveMaximum': false, 219 | 'exclusiveMinimum': false, 220 | 'default': 80 221 | }, 222 | 'protocol': { 223 | 'type': 'string', 224 | 'default': 'http', 225 | 'enum' : ['http', 'ftp'] 226 | } 227 | }, 228 | 'required': [ 229 | 'host', 230 | 'port', 231 | 'protocol' 232 | ] 233 | }, 234 | 'testing': { 235 | 'type': 'object', 236 | 'title': 'Testing', 237 | 'properties': { 238 | 'beforeOperationDelay': { 239 | 'type': 'integer', 240 | 'multipleOf': 1, 241 | 'default': 1 242 | }, 243 | 'afterOperationDelay': { 244 | 'type': 'integer', 245 | 'multipleOf': 1, 246 | 'default': 2 247 | } 248 | }, 249 | 'required': [ 250 | 'beforeOperationDelay' 251 | ] 252 | } 253 | }, 254 | 'required': [ 255 | 'authentication', 256 | 'server' 257 | ], 258 | 'additionalProperties': false 259 | }; 260 | 261 | export const invalid_schema1: any = { 262 | $schema: 'http://json-schema.org/draft-04/schema#', 263 | type: 'object', 264 | properties: { 265 | username: 'string', 266 | password: { 267 | type: false, 268 | } 269 | } 270 | }; 271 | 272 | 273 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # json-schema-js-gui-model 2 | 3 | Use this library typescript/javascript library when you need to construct many different custom UI forms that shares common 4 | characteristics, want to be in charge of how exactly your UI form should present itself, want to 5 | pick your own web framework for the UI, and want to use a json schema to drive the UI forms 6 | but finds json schema complex to process and lacking of UI information. 7 | 8 | This library contains a handy framework-agnostic gui model and associated translator that can be used as a basis when constructing 9 | dynamic javascript UI forms (in any web framework) from json-schemas. For details, refer to the declared gui 10 | model [here](src/lib/gui-model.ts) and the translator declared at the bottom of this [file](src/lib/gui-model.mapper.ts). 11 | 12 | Clients of this library are themselves responsible for constructing a UI form 13 | dynamically using the gui model provided by this library. Such UI code will be 14 | different depending on the exact web framework used and this out of scope of this more 15 | fundamental and general project. 16 | 17 | This library is on purpose keept small with few runtime-dependencies. It can be used from both nodejs v6+ 18 | and with a es5 capable browser. 19 | 20 | ## Getting started 21 | 22 | ```npm install json-schema-js-gui-model``` 23 | 24 | Schemas can be translated using the exported GuiModelMapper class or by the the command line 25 | command *mapToGuiModel* when the library is installed with -g option by npm. 26 | 27 | Code (typescript example): 28 | ```typescript 29 | import { GuiModelMapper, GuiModel, JsonSchema } from 'json-schema-js-gui-model'; 30 | 31 | let mapper: GuiModelMapper = new GuiModelMapper(); 32 | let input: JsonSchema = ... schema ... 33 | let output: GuiModel = mapper.mapToGuiModel(input); 34 | 35 | ``` 36 | 37 | Command-line: (requires global npm install) 38 | ``` 39 | mapToGuiModel sourceSchema destFile 40 | ``` 41 | 42 | ## The gui model and it's usage 43 | 44 | The gui model is intended for easy consumption when visualizing a UI form. The gui model does not 45 | contain any validation elements. 46 | 47 | The constructed UI should still use the json schema for validation purposes. If the form is 48 | carefully constructed the output will conform to the underlaying json schema when valid. A fast 49 | schema validator like [ajv](https://github.com/epoberezkin/ajv) can easily do validation of a form 50 | in realtime at each keypress if necessary. 51 | 52 | ## Example from schema to gui model to ui form: 53 | 54 | **Example input schema:** 55 | ``` 56 | { 57 | "$schema": "http://json-schema.org/draft-04/schema#", 58 | "type": "object", 59 | "properties": { 60 | "authentication": { 61 | "type": "object", 62 | "title": "Authentication", 63 | "description": "an authentication description here", 64 | "properties": { 65 | "user": { 66 | "type": "string", 67 | "minLength": 1, 68 | "default": "", 69 | "title" : "User", 70 | "description": "a username" 71 | }, 72 | "password": { 73 | "type": "string", 74 | "minLength": 1, 75 | "default": "", 76 | "title" : "Password", 77 | "description": "a password" 78 | }, 79 | "scheme": { 80 | "type": "string", 81 | "default": "basic" 82 | }, 83 | "preemptive": { 84 | "type": "boolean", 85 | "default": true 86 | } 87 | }, 88 | "required": [ 89 | "user", 90 | "password" 91 | ] 92 | }, 93 | "server": { 94 | "type": "object", 95 | "title": "Server", 96 | "properties": { 97 | "host": { 98 | "type": "string", 99 | "default": "" 100 | }, 101 | "port": { 102 | "type": "integer", 103 | "multipleOf": 1, 104 | "maximum": 65535, 105 | "minimum": 0, 106 | "exclusiveMaximum": false, 107 | "exclusiveMinimum": false, 108 | "default": 80 109 | }, 110 | "protocol": { 111 | "type": "string", 112 | "default": "http", 113 | "enum" : ["http", "ftp"] 114 | } 115 | } 116 | } 117 | } 118 | } 119 | ``` 120 | 121 | **Example gui model output:** 122 | ``` 123 | { 124 | "kind": "group", 125 | "name": "", 126 | "controlType": "group", 127 | "dataObjectPath": "", 128 | "label": "", 129 | "tooltip": "", 130 | "required": true, 131 | "elements": [ 132 | { 133 | "kind": "group", 134 | "name": "authentication", 135 | "controlType": "group", 136 | "dataObjectPath": "authentication", 137 | "label": "Authentication", 138 | "tooltip": "an authentication description here", 139 | "required": false, 140 | "elements": [ 141 | { 142 | "kind": "field", 143 | "name": "user", 144 | "controlType": "input", 145 | "label": "User", 146 | "tooltip": "a username", 147 | "dataObjectPath": "authentication.user", 148 | "defaultValue": "", 149 | "required": true, 150 | "type": "string", 151 | "subType": "none" 152 | }, 153 | { 154 | "kind": "field", 155 | "name": "password", 156 | "controlType": "input", 157 | "label": "Password", 158 | "tooltip": "a password", 159 | "dataObjectPath": "authentication.password", 160 | "defaultValue": "", 161 | "required": true, 162 | "type": "string", 163 | "subType": "none" 164 | }, 165 | { 166 | "kind": "field", 167 | "name": "scheme", 168 | "controlType": "input", 169 | "label": "scheme", 170 | "tooltip": "", 171 | "dataObjectPath": "authentication.scheme", 172 | "defaultValue": "basic", 173 | "required": false, 174 | "type": "string", 175 | "subType": "none" 176 | }, 177 | { 178 | "kind": "field", 179 | "name": "preemptive", 180 | "controlType": "yesno", 181 | "label": "preemptive", 182 | "tooltip": "", 183 | "dataObjectPath": "authentication.preemptive", 184 | "defaultValue": true, 185 | "required": false, 186 | "type": "boolean", 187 | "subType": "none" 188 | } 189 | ] 190 | }, 191 | { 192 | "kind": "group", 193 | "name": "server", 194 | "controlType": "group", 195 | "dataObjectPath": "server", 196 | "label": "Server", 197 | "tooltip": "", 198 | "required": false, 199 | "elements": [ 200 | { 201 | "kind": "field", 202 | "name": "host", 203 | "controlType": "input", 204 | "label": "host", 205 | "tooltip": "", 206 | "dataObjectPath": "server.host", 207 | "defaultValue": "", 208 | "required": false, 209 | "type": "string", 210 | "subType": "none" 211 | }, 212 | { 213 | "kind": "field", 214 | "name": "port", 215 | "controlType": "input", 216 | "label": "port", 217 | "tooltip": "", 218 | "dataObjectPath": "server.port", 219 | "defaultValue": 80, 220 | "required": false, 221 | "type": "integer", 222 | "subType": "none" 223 | }, 224 | { 225 | "kind": "field", 226 | "name": "protocol", 227 | "controlType": "dropdown", 228 | "label": "protocol", 229 | "tooltip": "", 230 | "dataObjectPath": "server.protocol", 231 | "defaultValue": "http", 232 | "values": [ 233 | "http", 234 | "ftp" 235 | ], 236 | "required": false, 237 | "type": "string", 238 | "subType": "none" 239 | } 240 | ] 241 | } 242 | ], 243 | "errors": [] 244 | } 245 | ``` 246 | 247 | **Example of a corresponding UI form (for illustration only - not provided by this library):** 248 | 249 | ![Example UI form](docs/example_form.png) 250 | 251 | 252 | ## Status and future plans 253 | 254 | The current version appears to work fine in my own project but has not been tested beyond that. Some advanced 255 | schema constructs like links are not yet supported. 256 | 257 | I am considering to support some kind of json schema ui extensions in order to construct a even more detailed gui model. 258 | 259 | 260 | 261 | -------------------------------------------------------------------------------- /src/test/gui-models.ts: -------------------------------------------------------------------------------- 1 | import { GuiModel } from '../lib/gui-model'; 2 | 3 | /* tslint:disable:max-line-length */ 4 | 5 | export const simple_gui_model1: GuiModel = Object.freeze( 6 | { 7 | kind: 'group', 8 | name: '', 9 | controlType: 'group', 10 | label: '', 11 | tooltip: '', 12 | dataObjectPath: '', 13 | required: true, 14 | elements: [ 15 | { kind: 'field', name: 'username', controlType: 'input', label: 'username', tooltip: '', dataObjectPath: 'username', defaultValue: '', required: true, type: 'string', subType: 'none' }, 16 | { kind: 'field', name: 'password', controlType: 'input', label: 'password', tooltip: '', dataObjectPath: 'password', defaultValue: '', required: true, type: 'string', subType: 'none' } 17 | ], 18 | errors: [] 19 | }); 20 | 21 | export const simple_gui_model2: GuiModel = Object.freeze({ 22 | kind: 'group', 23 | name: '', 24 | controlType: 'group', 25 | label: '', 26 | tooltip: '', 27 | dataObjectPath: '', 28 | required: true, 29 | elements: [ 30 | { kind: 'field', name: 'username', controlType: 'input', label: 'user name', tooltip: 'a username description here', dataObjectPath: 'username', defaultValue: 'username default', required: true, type: 'string', subType: 'none' }, 31 | { kind: 'field', name: 'password', controlType: 'input', label: 'password', tooltip: 'a password description here', dataObjectPath: 'password', defaultValue: 'password default', required: true, type: 'string', subType: 'none' } 32 | ], 33 | errors: [] 34 | }); 35 | 36 | export const test_different_elements_gui_model1: GuiModel = Object.freeze({ 37 | kind: 'group', 38 | name: '', 39 | controlType: 'group', 40 | label: '', 41 | tooltip: '', 42 | dataObjectPath: '', 43 | required: true, 44 | elements: [ 45 | { kind: 'field', name: 'username', controlType: 'input', label: 'User name', tooltip: 'a username description here', dataObjectPath: 'username', defaultValue: 'username default', required: true, type: 'string', subType: 'none' }, 46 | { kind: 'field', name: 'usertype', controlType: 'dropdown', label: 'User type', tooltip: 'a type description here', dataObjectPath: 'usertype', defaultValue: 'user', values: [ 'user', 'superuser'], required: true, type: 'string', subType: 'none' }, 47 | { kind: 'field', name: 'host', controlType: 'input', label: 'Hostname', tooltip: 'a hostname description here', dataObjectPath: 'host', defaultValue: 'localhost', required: false, type: 'string', subType: 'hostname' }, 48 | { kind: 'field', name: 'age', controlType: 'input', label: 'Age', tooltip: 'an age description here', dataObjectPath: 'age', defaultValue: 18, required: false, type: 'integer', subType: 'none' }, 49 | { kind: 'field', name: 'size', controlType: 'dropdown', label: 'Size', tooltip: 'a size description here', dataObjectPath: 'size', defaultValue: 0, values: [ 0, 1, 2, null], required: false, type: 'integer', subType: 'none' }, 50 | { kind: 'field', name: 'rate', controlType: 'input', label: 'Rate', tooltip: 'a rate description here', dataObjectPath: 'rate', defaultValue: 41.42, required: false, type: 'number', subType: 'none' }, 51 | { kind: 'field', name: 'rank', controlType: 'dropdown', label: 'Rank', tooltip: 'a rank description here', dataObjectPath: 'rank', defaultValue: 3.14, values: [ 3.14, 0.33, 9.99], required: false, type: 'number', subType: 'none'}, 52 | { kind: 'field', name: 'registered', controlType: 'yesno', label: 'Registered', tooltip: 'a registered description here', dataObjectPath: 'registered', defaultValue: false, required: false, type: 'boolean', subType: 'none' } 53 | ], 54 | errors: [] 55 | }); 56 | 57 | export const test_groups_gui_model1: GuiModel = Object.freeze({ 58 | kind: 'group', 59 | name: '', 60 | controlType: 'group', 61 | label: '', 62 | tooltip: '', 63 | dataObjectPath: '', 64 | required: true, 65 | elements: [ 66 | { kind: 'field', name: 'simple1', controlType: 'input', label: 'A simple field level 1', tooltip: '', dataObjectPath: 'simple1', defaultValue: '', required: false, type: 'string', subType: 'none' }, 67 | { kind: 'group', name: 'group1', controlType: 'group', label: 'Group at level 1', tooltip: '', dataObjectPath: 'group1', required: false, 68 | elements: [ 69 | { kind: 'field', name: 'simple2', controlType: 'input', label: 'A simple field level 2', tooltip: '', dataObjectPath: 'group1.simple2', defaultValue: '', required: false, type: 'string', subType: 'none' }, 70 | { kind: 'group', name: 'group2', controlType: 'group', label: 'Group at level 2', tooltip: '', dataObjectPath: 'group1.group2', required: false, 71 | elements: [ 72 | { kind: 'group', name: 'group3', controlType: 'group', label: 'Group at level 3', tooltip: '', dataObjectPath: 'group1.group2.group3', required: false, 73 | elements: [ 74 | { kind: 'field', name: 'simple4', controlType: 'input', label: 'A simple field level 4', tooltip: '', dataObjectPath: 'group1.group2.group3.simple4', defaultValue: '', required: false, type: 'string', subType: 'none' } 75 | ] 76 | }, 77 | { kind: 'field', name: 'simple3', controlType: 'input', label: 'A simple field level 3', tooltip: '', dataObjectPath: 'group1.group2.simple3', defaultValue: '', required: false, type: 'string', subType: 'none' } 78 | ] 79 | } 80 | ] 81 | } 82 | ], 83 | errors: [] 84 | }); 85 | 86 | export const complex_gui_model1: GuiModel = Object.freeze({ 87 | kind: 'group', 88 | name: '', 89 | controlType: 'group', 90 | label: '', 91 | tooltip: '', 92 | dataObjectPath: '', 93 | required: true, 94 | elements: [ 95 | { kind: 'group', name: 'authentication', controlType: 'group', label: 'Authentication', tooltip: 'an authentication description here', dataObjectPath: 'authentication', required: true, 96 | elements: [ { kind: 'field', name: 'user', controlType: 'input', label: 'User', tooltip: 'a username', dataObjectPath: 'authentication.user', defaultValue: '', required: true, type: 'string', subType: 'none' }, 97 | { kind: 'field', name: 'password', controlType: 'input', label: 'Password', tooltip: 'a password', dataObjectPath: 'authentication.password', defaultValue: '', required: true, type: 'string', subType: 'none' }, 98 | { kind: 'field', name: 'scheme', controlType: 'input', label: 'scheme', tooltip: '', dataObjectPath: 'authentication.scheme', defaultValue: 'basic', required: true, type: 'string', subType: 'none' }, 99 | { kind: 'field', name: 'preemptive', controlType: 'yesno', label: 'preemptive', tooltip: '', dataObjectPath: 'authentication.preemptive', defaultValue: true, required: true, type: 'boolean', subType: 'none'} 100 | ] 101 | }, 102 | { kind: 'group', name: 'server', controlType: 'group', label: 'Server', tooltip: '', dataObjectPath: 'server', required: true, 103 | elements: [ { kind: 'field', name: 'host', controlType: 'input', label: 'host', tooltip: '', dataObjectPath: 'server.host', defaultValue: '', required: true, type: 'string', subType: 'none' }, 104 | { kind: 'field', name: 'port', controlType: 'input', label: 'port', tooltip: '', dataObjectPath: 'server.port', defaultValue: 80, required: true, type: 'integer', subType: 'none' }, 105 | { kind: 'field', name: 'protocol', controlType: 'dropdown', label: 'protocol', tooltip: '', dataObjectPath: 'server.protocol', defaultValue: 'http', values: ['http', 'ftp'], required: true, type: 'string', subType: 'none' } 106 | ] 107 | }, 108 | { kind: 'group', name: 'testing', controlType: 'group', label: 'Testing', tooltip: '', dataObjectPath: 'testing', required: false, 109 | elements: [ { kind: 'field', name: 'beforeOperationDelay', controlType: 'input', label: 'beforeOperationDelay', tooltip: '', dataObjectPath: 'testing.beforeOperationDelay', defaultValue: 1, required: true, type: 'integer', subType: 'none' }, 110 | { kind: 'field', name: 'afterOperationDelay', controlType: 'input', label: 'afterOperationDelay', tooltip: '', dataObjectPath: 'testing.afterOperationDelay', defaultValue: 2, required: false, type: 'integer', subType: 'none' } 111 | ] 112 | } 113 | ], 114 | errors: [] 115 | }); 116 | 117 | export const invalid_gui_model1: GuiModel = { 118 | kind : 'group', name: '', controlType: 'group', dataObjectPath: '', label: '', tooltip: '', required: true, elements: [], 119 | 'errors': [ 120 | { schemaPath: 'properties.username', errorText: 'Unsupported element type string' }, 121 | { schemaPath: 'properties.password', errorText: 'Type elements must be strings (not boolean) ' }, 122 | { schemaPath: 'properties.password', errorText: 'Unrecognized element type false' } 123 | ] 124 | }; -------------------------------------------------------------------------------- /src/lib/gui-model.mapper.ts: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import 'core-js/library'; 4 | import { JsonSchema } from '../dependencies/json-schema'; 5 | import { isString, isNumber, isBoolean, isIntegerNumber, isArray, isObject } from './type-utils'; 6 | 7 | import { GuiModel, Group, GuiElement, SubDataType, 8 | TranslationError, TypedField } from './gui-model'; 9 | 10 | /** 11 | * Process a json schema node. This can either be the root or an object inside it when called recursively from within each property. 12 | * @param dataKeyPath The corresponding object path in the schema instance (data) object for the schema element. Used by clients of gui model. 13 | * @param schemaPath The path of the element inside the schema itself. Used for error reporting. 14 | * @param accumulatedErrors A mutable(!) array where any errors during processing are appended. 15 | */ 16 | function processProperties(obj: JsonSchema, dataKeyPath: string, schemaPath: string, accumulatedErrors: TranslationError[]): GuiElement[] { 17 | let result: GuiElement[] = []; 18 | 19 | let properties = obj.properties || {}; 20 | let requiredKeys = new Set(obj.required || []); 21 | 22 | for (let key in properties) { 23 | if (properties.hasOwnProperty(key)) { 24 | validate(key, 'key', 'string', (v) => isString(v), dataKeyPath, accumulatedErrors); 25 | 26 | let settingsPropertyKeyPath = (dataKeyPath === '') ? key : dataKeyPath + '.' + key; 27 | let schemaPropertyPath = (schemaPath === '') ? 'properties.' + key : schemaPath + '.' + 'properties.' + key; 28 | 29 | let requiredItem = requiredKeys.has(key); 30 | 31 | let value = properties[key] as JsonSchema; 32 | if (isObject(value)) { 33 | let element = processProperty(key, value, requiredItem, settingsPropertyKeyPath, schemaPropertyPath, accumulatedErrors); 34 | // Guard against fatal errors in recursive call: 35 | if (element) { 36 | result.push(element); 37 | } 38 | } else { 39 | addError(accumulatedErrors, 'Unsupported element type ' + typeof value, schemaPropertyPath); 40 | } 41 | } 42 | } 43 | 44 | return result; 45 | } 46 | 47 | /** 48 | * Process a json schema key/value property definition. 49 | */ 50 | function processProperty(key: string, value: any, requiredItem: boolean, keyPath: string, schemaPath: string, accumulatedErrors: TranslationError[]): GuiElement | null { 51 | let type = value.type; 52 | if (!isString(type)) { 53 | addError(accumulatedErrors, 'Type elements must be strings (not ' + typeof type + ') ', schemaPath); 54 | } 55 | 56 | let label = value.title || key; 57 | validate(label, 'title', 'string', (v) => isString(v), schemaPath, accumulatedErrors); 58 | 59 | let tooltip = value.description || ''; 60 | validate(tooltip, 'tooltip', 'string', (v) => isString(v), schemaPath, accumulatedErrors); 61 | 62 | if (type === 'string' || type === 'number' || type === 'boolean' || type === 'integer') { 63 | let defaultValue = value.default; 64 | if (defaultValue === undefined || defaultValue === null) { 65 | switch (type) { 66 | case 'number': defaultValue = 0.0; break; 67 | case 'boolean': defaultValue = false; break; 68 | case 'integer': defaultValue = 0; break; 69 | default: defaultValue = ''; 70 | } 71 | } 72 | 73 | let dataSubType: SubDataType = value.format || 'none'; 74 | let enumValues = value.enum; // Undefined otherwise. 75 | 76 | validateField(type, dataSubType, defaultValue, enumValues, keyPath, schemaPath, requiredItem, accumulatedErrors); 77 | 78 | let prop: GuiElement | null; 79 | switch (type) { 80 | case 'number': prop = createNumberField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues); 81 | break; 82 | case 'boolean': prop = createBooleanField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues); 83 | break; 84 | case 'integer': prop = createIntegerField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues); 85 | break; 86 | case 'string': prop = createStringField(key, keyPath, label, tooltip, defaultValue, requiredItem, dataSubType, enumValues); 87 | break; 88 | 89 | default: prop = null; 90 | addError(accumulatedErrors, 'Unsupported type ' + type, schemaPath); 91 | break; 92 | } 93 | 94 | return prop; 95 | } else if (type === 'object') { 96 | let nestedProperties = processProperties(value, keyPath, schemaPath, accumulatedErrors); // Note mutal recursive call. 97 | 98 | // Guard against fatal errors in recursive call: 99 | if (nestedProperties != null) { 100 | let group = createGroupProperty(key, keyPath, label, tooltip, requiredItem, nestedProperties); 101 | return group; 102 | } 103 | } else if (type === 'array') { 104 | // TODO: Consider supporting arrays if there are valid use cases: https://spacetelescope.github.io/understanding-json-schema/reference/array.html 105 | addError(accumulatedErrors, 'Arrays not supported (yet)', schemaPath); 106 | } else { 107 | addError(accumulatedErrors, 'Unrecognized element type ' + type, schemaPath); 108 | } 109 | 110 | return null; 111 | } 112 | 113 | function validateField(type: string, dataSubType: SubDataType, defaultValue: any, enumValues: any[], 114 | keyPath: string, schemaPath: string, 115 | requiredItem: boolean, accumulatedErrors: TranslationError[]): void { 116 | 117 | validate(dataSubType, 'format', 'string', (v) => isString(v), schemaPath, accumulatedErrors); 118 | 119 | let valueValidator: (value: any) => boolean; 120 | switch (type) { 121 | case 'number': valueValidator = (v) => isNumber(v); 122 | break; 123 | case 'boolean': valueValidator = (v) => isBoolean(v); 124 | break; 125 | case 'integer': valueValidator = (v) => isIntegerNumber(v); 126 | break; 127 | case 'string': valueValidator = (v) => isString(v); 128 | break; 129 | default: valueValidator = (v) => true; // Don't validate. 130 | addError(accumulatedErrors, 'Unsupported type ' + type, schemaPath); 131 | break; 132 | } 133 | 134 | validate(defaultValue, 'default', type, valueValidator, schemaPath, accumulatedErrors); 135 | if (enumValues) { 136 | validateArray(enumValues, 'enum', !requiredItem, type, valueValidator, schemaPath, accumulatedErrors); 137 | } 138 | } 139 | 140 | /** 141 | * Create an immutable gui input dropdown element for a number, making any contained objects immutable in the process. 142 | */ 143 | function createNumberField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: number, 144 | required: boolean, dataType: SubDataType, values: (number[] | undefined) = undefined): TypedField { 145 | return Object.freeze>({ 146 | kind: 'field', 147 | name: key, 148 | controlType: values && values.length > 0 ? 'dropdown' : 'input', 149 | label: label, 150 | tooltip: tooltip, 151 | dataObjectPath: objectPath, 152 | defaultValue: defaultValue, 153 | required: required, 154 | type: 'number', 155 | subType: dataType, 156 | values: values ? Object.freeze(values) : values 157 | }); 158 | } 159 | 160 | /** 161 | * Create an immutable gui input dropdown element for an integer, making any contained objects immutable in the process. 162 | */ 163 | function createIntegerField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: number, 164 | required: boolean, dataType: SubDataType, values: (number[] | undefined) = undefined): TypedField { 165 | return Object.freeze>({ 166 | kind: 'field', 167 | name: key, 168 | controlType: values && values.length > 0 ? 'dropdown' : 'input', 169 | label: label, 170 | tooltip: tooltip, 171 | dataObjectPath: objectPath, 172 | defaultValue: defaultValue, 173 | values: values ? Object.freeze(values) : values, 174 | required: required, 175 | type: 'integer', 176 | subType: dataType 177 | }); 178 | } 179 | 180 | /** 181 | * Create an immutable gui input element for a boolean, making any contained objects immutable in the process. 182 | */ 183 | function createBooleanField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: boolean, 184 | required: boolean, dataType: SubDataType, values: (boolean[] | undefined) = undefined): TypedField { 185 | return Object.freeze>({ 186 | kind: 'field', 187 | name: key, 188 | controlType: 'yesno', 189 | label: label, 190 | tooltip: tooltip, 191 | dataObjectPath: objectPath, 192 | defaultValue: defaultValue, 193 | values: values ? Object.freeze(values) : values, 194 | required: required, 195 | type: 'boolean', 196 | subType: dataType 197 | }); 198 | } 199 | 200 | /** 201 | * Create an immutable gui input dropdown element for a string, making any contained objects immutable in the process. 202 | */ 203 | function createStringField(key: string, objectPath: string, label: string, tooltip: string, defaultValue: string, 204 | required: boolean, dataType: SubDataType, values: (string[] | undefined) = undefined): TypedField { 205 | return Object.freeze>({ 206 | kind: 'field', 207 | name: key, 208 | controlType: values && values.length > 0 ? 'dropdown' : 'input', 209 | label: label, 210 | tooltip: tooltip, 211 | dataObjectPath: objectPath, 212 | defaultValue: defaultValue, 213 | values: values ? Object.freeze(values) : values, 214 | required: required, 215 | type: 'string', 216 | subType: dataType 217 | }); 218 | } 219 | 220 | /** 221 | * Create an immutable gui group element with nested gui elements inside, making any contained objects immutable in the process. 222 | */ 223 | function createGroupProperty(key: string, objectPath: string, label: string, tooltip: string, required: boolean, elements: GuiElement[]): Group { 224 | return Object.freeze({ 225 | kind: 'group', 226 | name: key, 227 | controlType: 'group', 228 | dataObjectPath: objectPath, 229 | label: label, 230 | tooltip: tooltip, 231 | required: required, 232 | elements: Object.freeze(elements) 233 | }); 234 | } 235 | 236 | function validate(value: any, valueName: string, allowedTypeName: string, validator: (value: any) => boolean, schemaPath: string, accumulatedErrors: TranslationError[]) { 237 | if (!validator(value)) { 238 | addError(accumulatedErrors, 'Illegal default ' + value + '. ' + allowedTypeName + ' expected ' , schemaPath + '.' + valueName); 239 | return false; 240 | } else { 241 | return true; 242 | } 243 | } 244 | 245 | function validateArray(values: any, valueName: string, allowNulls: boolean, allowedTypeName: string, validator: (value: any) => boolean, 246 | schemaPath: string, accumulatedErrors: TranslationError[]) { 247 | if (!isArray(values)) { 248 | addError(accumulatedErrors, 'Illegal default. Array expected' + values, schemaPath + '.' + valueName); 249 | return false; 250 | } else { 251 | (values as Array).forEach(value => { 252 | if (value === null) { 253 | if (!allowNulls) { 254 | addError(accumulatedErrors, 'Null not allowed in required array', schemaPath + '.' + valueName); 255 | } 256 | } else if (!validator(value)) { 257 | addError(accumulatedErrors, 'Illegal value types in array. Array of ' + allowedTypeName + ' expected', schemaPath + '.' + valueName); 258 | } 259 | }); 260 | return true; 261 | } 262 | } 263 | 264 | function addError(errors: TranslationError[], errorText: string, schemaPath: string) { 265 | let error = { 266 | schemaPath: schemaPath, 267 | errorText: errorText 268 | }; 269 | 270 | errors.push(error); 271 | return errors; 272 | } 273 | 274 | // --- Actual service starts here --- 275 | 276 | /** 277 | * Mapping service that can convert a json schema to a gui model for presentation. 278 | */ 279 | export class GuiModelMapper { 280 | public constructor () { 281 | } 282 | 283 | /** 284 | * Converts a json schema into an immutable gui model. In case of errors, the associated error array will be non-empty and the 285 | * resulting gui model may be invalid. 286 | */ 287 | public mapToGuiModel(schema: JsonSchema): GuiModel { 288 | // Setup mutable error reporting array that will have values added during procesing in case of errors. 289 | let errors: TranslationError[] = []; 290 | 291 | let result: GuiElement[]; 292 | try { 293 | // Exceptions should not be thrown during processing but if they are (due to programming error) than safely process them here: 294 | result = processProperties(schema || {}, '', '', errors); 295 | } catch (err) { 296 | // Fallback: These errors should not occur - if they do the processing should be made more rubust to avoid them: 297 | result = []; 298 | addError(errors, 'Internal error processing json schema: ' + err, ''); 299 | } 300 | 301 | // The expected result is an expanded group with error information: 302 | return Object.freeze({ 303 | kind: 'group', 304 | name: '', 305 | controlType: 'group', 306 | dataObjectPath: '', 307 | label: '', 308 | tooltip: '', 309 | required: true, 310 | elements: Object.freeze(result), 311 | errors: Object.freeze(errors) 312 | }); 313 | } 314 | } 315 | --------------------------------------------------------------------------------