From f2a90f18a129938987f5855cd073cde4d88c2e18 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Fri, 18 Oct 2019 12:16:59 +0300 Subject: [PATCH] Json Schema Form: Add Fieldset, Array and Date fields. --- ui-ngx/package-lock.json | 54 ++++++ ui-ngx/package.json | 4 + .../json-form/json-form.component.ts | 2 +- .../json-form/react/json-form-array.tsx | 177 ++++++++++++++++++ .../json-form/react/json-form-checkbox.tsx | 2 +- .../json-form/react/json-form-date.tsx | 82 ++++++++ .../json-form/react/json-form-fieldset.tsx | 44 +++++ .../json-form/react/json-form-rc-select.tsx | 2 +- .../json-form/react/json-form-react.tsx | 2 - .../json-form/react/json-form-schema-form.tsx | 20 +- .../json-form/react/json-form-utils.ts | 57 +++++- .../json-form/react/json-form.models.ts | 27 ++- 12 files changed, 445 insertions(+), 28 deletions(-) create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-date.tsx create mode 100644 ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx diff --git a/ui-ngx/package-lock.json b/ui-ngx/package-lock.json index f73e8fda59..705b838aef 100644 --- a/ui-ngx/package-lock.json +++ b/ui-ngx/package-lock.json @@ -1331,6 +1331,19 @@ } } }, + "@date-io/core": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@date-io/core/-/core-1.3.11.tgz", + "integrity": "sha512-Yxf2ei0vjU38Fizswr/Uwub5QeRiLOHiTRiHUuTdg+biVB+1EUk+h5szas9SEWA2pZDlSo73F5TPuu+zKqOIBQ==" + }, + "@date-io/date-fns": { + "version": "1.3.11", + "resolved": "https://registry.npmjs.org/@date-io/date-fns/-/date-fns-1.3.11.tgz", + "integrity": "sha512-6Pvk4gwCU4L19XYzDUrro861JCQjZkJQjugxAA+M8wsDTW75A5rmSZGa6g2rQQXfg6ox4B7HBx9p6JYDsSPX0g==", + "requires": { + "@date-io/core": "^1.3.11" + } + }, "@emotion/hash": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.7.3.tgz", @@ -1366,6 +1379,26 @@ "react-transition-group": "^4.3.0" } }, + "@material-ui/icons": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@material-ui/icons/-/icons-4.5.1.tgz", + "integrity": "sha512-YZ/BgJbXX4a0gOuKWb30mBaHaoXRqPanlePam83JQPZ/y4kl+3aW0Wv9tlR70hB5EGAkEJGW5m4ktJwMgxQAeA==", + "requires": { + "@babel/runtime": "^7.4.4" + } + }, + "@material-ui/pickers": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@material-ui/pickers/-/pickers-3.2.7.tgz", + "integrity": "sha512-dDi8G8TOXssXZQsGCRM4zoDnWMY4O/vvqVCH4ViIHflvS4ek4v30IlFcSONI5jGzL0dmJhNKso2UEn7qS3iZ3g==", + "requires": { + "@babel/runtime": "^7.6.0", + "@types/styled-jsx": "^2.2.8", + "clsx": "^1.0.2", + "react-transition-group": "^4.0.0", + "rifm": "^0.7.0" + } + }, "@material-ui/styles": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.5.0.tgz", @@ -1651,6 +1684,14 @@ "integrity": "sha512-K5K+yml8LTo9bWJI/rECfIPrGgxdpeNbj+d53lwN4QjW1MCwlkhUms+gtdzigTeUyBr09+u8BwOIY3MXvHdcsA==", "dev": true }, + "@types/styled-jsx": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@types/styled-jsx/-/styled-jsx-2.2.8.tgz", + "integrity": "sha512-Yjye9VwMdYeXfS71ihueWRSxrruuXTwKCbzue4+5b2rjnQ//AtyM7myZ1BEhNhBQ/nL/RE7bdToUoLln2miKvg==", + "requires": { + "@types/react": "*" + } + }, "@types/tinycolor2": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.2.tgz", @@ -3725,6 +3766,11 @@ "assert-plus": "^1.0.0" } }, + "date-fns": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.5.0.tgz", + "integrity": "sha512-I6Tkis01//nRcmvMQw/MRE1HAtcuA5Ie6jGPb8bJZJub7494LGOObqkV3ParnsSVviAjk5C8mNKDqYVBzCopWg==" + }, "date-format": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/date-format/-/date-format-2.1.0.tgz", @@ -9131,6 +9177,14 @@ "integrity": "sha512-5C9HXdzK8EAqN7JDif30jqsBzavB7wLpaubisuQIGHWf2gUXSpzy6ArX/+Da8RjFpagWsCn+pIgxTMAmKw9Zug==", "dev": true }, + "rifm": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/rifm/-/rifm-0.7.0.tgz", + "integrity": "sha512-DSOJTWHD67860I5ojetXdEQRIBvF6YcpNe53j0vn1vp9EUb9N80EiZTxgP+FkDKorWC8PZw052kTF4C1GOivCQ==", + "requires": { + "@babel/runtime": "^7.3.1" + } + }, "rimraf": { "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 9998fddd9a..53a9138cad 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -24,8 +24,11 @@ "@angular/platform-browser-dynamic": "~8.2.11", "@angular/router": "~8.2.11", "@auth0/angular-jwt": "^3.0.0", + "@date-io/date-fns": "^1.3.11", "@mat-datetimepicker/core": "^2.0.1", "@material-ui/core": "^4.5.1", + "@material-ui/icons": "^4.5.1", + "@material-ui/pickers": "^3.2.7", "@ngrx/effects": "^8.2.0", "@ngrx/store": "^8.2.0", "@ngrx/store-devtools": "^8.2.0", @@ -38,6 +41,7 @@ "base64-js": "^1.3.1", "compass-sass-mixins": "^0.12.7", "core-js": "^3.1.4", + "date-fns": "^2.5.0", "deep-equal": "^1.0.1", "flot": "git://github.com/thingsboard/flot.git#0.9-work", "flot.curvedlines": "git://github.com/MichaelZinsmaier/CurvedLines.git#master", diff --git a/ui-ngx/src/app/shared/components/json-form/json-form.component.ts b/ui-ngx/src/app/shared/components/json-form/json-form.component.ts index 346f009c85..3786cb0ba0 100644 --- a/ui-ngx/src/app/shared/components/json-form/json-form.component.ts +++ b/ui-ngx/src/app/shared/components/json-form/json-form.component.ts @@ -159,7 +159,7 @@ export class JsonFormComponent implements OnInit, ControlValueAccessor, Validato onFullscreenChanged() {} - private onModelChange(key: string | string[], val: any) { + private onModelChange(key: (string | number)[], val: any) { if (isString(val) && val === '') { val = undefined; } diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx new file mode 100644 index 0000000000..60ff467d68 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-array.tsx @@ -0,0 +1,177 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from 'react'; +import JsonFormUtils from './json-form-utils'; +import ThingsboardBaseComponent from './json-form-base-component'; +import Button from '@material-ui/core/Button'; +import _ from 'lodash'; +import IconButton from '@material-ui/core/IconButton'; +import ClearIcon from '@material-ui/icons/Clear'; +import AddIcon from '@material-ui/icons/Add'; +import Tooltip from '@material-ui/core/Tooltip'; +import { + JsonFormData, + JsonFormFieldProps, + JsonFormFieldState +} from '@shared/components/json-form/react/json-form.models'; + +interface ThingsboardArrayState extends JsonFormFieldState { + model: any[]; + keys: number[]; +} + +class ThingsboardArray extends React.Component { + + constructor(props) { + super(props); + this.onAppend = this.onAppend.bind(this); + this.onDelete = this.onDelete.bind(this); + const model = JsonFormUtils.selectOrSet(this.props.form.key, this.props.model) || []; + const keys: number[] = []; + for (let i = 0; i < model.length; i++) { + keys.push(i); + } + this.state = { + model, + keys + }; + } + + componentDidMount() { + if (this.props.form.startEmpty !== true && this.state.model.length === 0) { + this.onAppend(); + } + } + + onAppend() { + let empty; + if (this.props.form && this.props.form.schema && this.props.form.schema.items) { + const items = this.props.form.schema.items; + if (items.type && items.type.indexOf('object') !== -1) { + empty = {}; + if (!this.props.options || this.props.options.setSchemaDefaults !== false) { + empty = typeof items.default !== 'undefined' ? items.default : empty; + if (empty) { + JsonFormUtils.traverseSchema(items, (prop, path) => { + if (typeof prop.default !== 'undefined') { + JsonFormUtils.selectOrSet(path, empty, prop.default); + } + }); + } + } + } else if (items.type && items.type.indexOf('array') !== -1) { + empty = []; + if (!this.props.options || this.props.options.setSchemaDefaults !== false) { + empty = items.default || empty; + } + } else { + if (!this.props.options || this.props.options.setSchemaDefaults !== false) { + empty = items.default || empty; + } + } + } + const newModel = this.state.model; + newModel.push(empty); + const newKeys = this.state.keys; + let key = 0; + if (newKeys.length > 0) { + key = newKeys[newKeys.length - 1] + 1; + } + newKeys.push(key); + this.setState({ + model: newModel, + keys: newKeys + } + ); + this.props.onChangeValidate(this.state.model); + } + + onDelete(index: number) { + const newModel = this.state.model; + newModel.splice(index, 1); + const newKeys = this.state.keys; + newKeys.splice(index, 1); + this.setState( + { + model: newModel, + keys: newKeys + } + ); + this.props.onChangeValidate(this.state.model); + } + + setIndex(index: number) { + return (form: JsonFormData) => { + if (form.key) { + form.key[form.key.indexOf('')] = index; + } + }; + } + + copyWithIndex(form: JsonFormData, index: number): JsonFormData { + const copy: JsonFormData = _.cloneDeep(form); + copy.arrayIndex = index; + JsonFormUtils.traverseForm(copy, this.setIndex(index)); + return copy; + } + + render() { + const arrays = []; + const fields = []; + const model = this.state.model; + const keys = this.state.keys; + const items = this.props.form.items; + for (let i = 0; i < model.length; i++ ) { + let removeButton: JSX.Element = null; + if (!this.props.form.readonly) { + const boundOnDelete = this.onDelete.bind(this, i); + removeButton = ; + } + const forms = (this.props.form.items as JsonFormData[]).map((form, index) => { + const copy = this.copyWithIndex(form, i); + return this.props.builder(copy, this.props.model, index, this.props.onChange, + this.props.onColorClick, this.props.onToggleFullscreen, this.props.mapper); + }); + arrays.push( +
  • + {removeButton} + {forms} +
  • + ); + } + let addButton: JSX.Element = null; + if (!this.props.form.readonly) { + addButton = ; + } + + return ( +
    +
    +
    {this.props.form.title}
    +
      + {arrays} +
    +
    + {addButton} +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardArray); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx index 5bee74868e..d0fe753e20 100644 --- a/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-checkbox.tsx @@ -26,7 +26,7 @@ class ThingsboardCheckbox extends React.Component { + + constructor(props) { + super(props); + this.onDatePicked = this.onDatePicked.bind(this); + let value: Date | null = null; + if (this.props.value && typeof this.props.value === 'number') { + value = new Date(this.props.value); + } + this.state = { + currentValue: value + }; + } + + + onDatePicked(date: Date | null) { + this.setState({ + currentValue: date + }); + this.props.onChangeValidate(date ? date.getTime() : null); + } + + render() { + + let fieldClass = 'tb-date-field'; + if (this.props.form.required) { + fieldClass += ' tb-required'; + } + if (this.props.form.readonly) { + fieldClass += ' tb-readonly'; + } + + return ( + +
    + + +
    +
    + ); + } +} + +export default ThingsboardBaseComponent(ThingsboardDate); diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx new file mode 100644 index 0000000000..f2aca21788 --- /dev/null +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-fieldset.tsx @@ -0,0 +1,44 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as React from 'react'; +import { + JsonFormData, + JsonFormFieldProps, + JsonFormFieldState +} from '@shared/components/json-form/react/json-form.models'; + +class ThingsboardFieldSet extends React.Component { + + render() { + const forms = (this.props.form.items as JsonFormData[]).map((form: JsonFormData, index) => { + return this.props.builder(form, this.props.model, index, this.props.onChange, + this.props.onColorClick, this.props.onToggleFullscreen, this.props.mapper); + }); + + return ( +
    +
    + {this.props.form.title} +
    +
    + {forms} +
    +
    + ); + } +} + +export default ThingsboardFieldSet; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx b/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx index 1002b1f5a4..02fedb6071 100644 --- a/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-rc-select.tsx @@ -39,7 +39,7 @@ class ThingsboardRcSelect extends React.Component { textarea: ThingsboardText, select: ThingsboardSelect, radios: ThingsboardRadios, - // date: ThingsboardDate, + date: ThingsboardDate, // image: ThingsboardImage, checkbox: ThingsboardCheckbox, help: ThingsboardHelp, - // array: ThingsboardArray, + array: ThingsboardArray, // javascript: ThingsboardJavaScript, // json: ThingsboardJson, // html: ThingsboardHtml, // css: ThingsboardCss, // color: ThingsboardColor, 'rc-select': ThingsboardRcSelect, - // fieldset: ThingsboardFieldSet + fieldset: ThingsboardFieldSet }; this.onChange = this.onChange.bind(this); @@ -71,7 +71,7 @@ class ThingsboardSchemaForm extends React.Component { this.hasConditions = false; } - onChange(key: string | string[], val: any) { + onChange(key: (string | number)[], val: any) { this.props.onModelChange(key, val); if (this.hasConditions) { this.forceUpdate(); @@ -90,7 +90,7 @@ class ThingsboardSchemaForm extends React.Component { builder(form: JsonFormData, model: any, index: number, - onChange: (key: string | string[], val: any) => void, + onChange: (key: (string | number)[], val: any) => void, onColorClick: (event: MouseEvent, key: string, val: string) => void, onToggleFullscreen: () => void, mapper: {[type: string]: any}): JSX.Element { diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts b/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts index 1649d06ca5..df58a1918c 100644 --- a/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form-utils.ts @@ -17,7 +17,13 @@ import * as tv from 'tv4'; import ObjectPath from 'objectpath'; import _ from 'lodash'; -import { SchemaValidationResult, DefaultsFormOptions, FormOption } from './json-form.models'; +import { + SchemaValidationResult, + DefaultsFormOptions, + FormOption, + JsonSchemaData, + JsonFormData +} from './json-form.models'; import { isDefined, isString, isUndefined } from '@core/utils'; import * as equal from 'deep-equal'; @@ -433,7 +439,7 @@ function merge(schema: any, form: any[], ignore: { [key: string]: boolean }, opt })); } -function selectOrSet(projection: string | string[], obj: any, valueToSet?: any): any { +function selectOrSet(projection: string | (string | number)[], obj: any, valueToSet?: any): any { const numRe = /^\d+$/; if (!obj) { @@ -474,7 +480,7 @@ function selectOrSet(projection: string | string[], obj: any, valueToSet?: any): return value; } -function updateValue(projection: string | string[], obj: any, valueToSet: any): boolean { +function updateValue(projection: string | (string | number)[], obj: any, valueToSet: any): boolean { const numRe = /^\d+$/; if (!obj) { @@ -531,12 +537,55 @@ function setValue(obj: any, key: string, val: any): boolean { return changed; } +function traverseSchema(schema: JsonSchemaData, fn: (prop: any, path: string[]) => any, path?: string[], ignoreArrays?: boolean) { + ignoreArrays = typeof ignoreArrays !== 'undefined' ? ignoreArrays : true; + + path = path || []; + + const traverse = ($schema: JsonSchemaData, $fn: (prop: any, path: string[]) => any, $path: string[]) => { + $fn($schema, $path); + for (const k of Object.keys($schema.properties)) { + if ($schema.properties.hasOwnProperty(k)) { + const currentPath = $path.slice(); + currentPath.push(k); + traverse($schema.properties[k], $fn, currentPath); + } + } + if (!ignoreArrays && $schema.items) { + const arrPath = $path.slice(); + arrPath.push(''); + traverse($schema.items, $fn, arrPath); + } + }; + + traverse(schema, fn, path || []); +} + +function traverseForm(form: JsonFormData, fn: (form: JsonFormData) => any) { + fn(form); + if (form.items) { + form.items.forEach((f) => { + traverseForm(f, fn); + }); + } + + if (form.tabs) { + form.tabs.forEach((tab) => { + tab.items.forEach((f) => { + traverseForm(f, fn); + }); + }); + } +} + const utils = { validateBySchema, validate, merge, updateValue, - selectOrSet + selectOrSet, + traverseSchema, + traverseForm }; export default utils; diff --git a/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts b/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts index 5ddfe1f3c0..a90dc47392 100644 --- a/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts +++ b/ui-ngx/src/app/shared/components/json-form/react/json-form.models.ts @@ -55,7 +55,7 @@ export interface JsonFormProps { isFullscreen: boolean; ignore?: {[key: string]: boolean}; option: FormOption; - onModelChange?: (key: string | string[], val: any) => void; + onModelChange?: (key: (string | number)[], val: any) => void; onColorClick?: (event: MouseEvent, key: string, val: string) => void; onToggleFullscreen?: () => void; mapper?: {[type: string]: any}; @@ -67,9 +67,16 @@ export interface KeyLabelItem { value?: string; } +export interface JsonSchemaData { + type: string; + default: any; + items?: JsonSchemaData; + properties?: any; +} + export interface JsonFormData { type: string; - key: string | string[]; + key: (string | number)[]; title: string; readonly: boolean; required: boolean; @@ -79,16 +86,15 @@ export interface JsonFormData { rows?: number; rowsMax?: number; placeholder?: string; - schema: { - type: string; - default: any; - }; + schema: JsonSchemaData; titleMap: { value: any; name: string; }[]; - items?: Array; + items?: Array | Array; + tabs?: Array; tags?: any; + startEmpty?: boolean; [key: string]: any; } @@ -99,17 +105,20 @@ export interface JsonFormFieldProps { builder: (form: JsonFormData, model: any, index: number, - onChange: (key: string | string[], val: any) => void, + onChange: (key: (string | number)[], val: any) => void, onColorClick: (event: MouseEvent, key: string, val: string) => void, onToggleFullscreen: () => void, mapper: {[type: string]: any}) => JSX.Element; mapper?: {[type: string]: any}; - onChange?: (key: string | string[], val: any) => void; + onChange?: (key: (string | number)[], val: any) => void; onColorClick?: (event: MouseEvent, key: string, val: string) => void; onChangeValidate?: (e: any) => void; onToggleFullscreen?: () => void; valid?: boolean; error?: string; + options?: { + setSchemaDefaults?: boolean; + }; } export interface JsonFormFieldState {