/// /// Copyright © 2016-2024 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 { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; import { TbEditorCompletion, TbEditorCompletions } from '@shared/models/ace/completion.models'; import { deepClone, isDefinedAndNotNull, isEmptyStr, isString, isUndefinedOrNull } from '@core/utils'; import { JsonFormData, JsonSchema, JsonSettingsSchema, KeyLabelItem } from '@shared/legacy/json-form.models'; import JsonFormUtils from '@shared/legacy/json-form-utils'; import { constantColor, Font } from '@shared/models/widget-settings.models'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; export enum FormPropertyType { text = 'text', number = 'number', password = 'password', textarea = 'textarea', switch = 'switch', select = 'select', radios = 'radios', datetime = 'datetime', image = 'image', javascript = 'javascript', json = 'json', html = 'html', css = 'css', markdown = 'markdown', color = 'color', color_settings = 'color_settings', font = 'font', units = 'units', icon = 'icon', fieldset = 'fieldset', array = 'array', htmlSection = 'htmlSection' } export const formPropertyTypes = Object.keys(FormPropertyType) as FormPropertyType[]; export const formPropertyTypeTranslations = new Map( [ [FormPropertyType.text, 'dynamic-form.property.type-text'], [FormPropertyType.number, 'dynamic-form.property.type-number'], [FormPropertyType.password, 'dynamic-form.property.type-password'], [FormPropertyType.textarea, 'dynamic-form.property.type-textarea'], [FormPropertyType.switch, 'dynamic-form.property.type-switch'], [FormPropertyType.select, 'dynamic-form.property.type-select'], [FormPropertyType.radios, 'dynamic-form.property.type-radios'], [FormPropertyType.datetime, 'dynamic-form.property.type-datetime'], [FormPropertyType.image, 'dynamic-form.property.type-image'], [FormPropertyType.javascript, 'dynamic-form.property.type-javascript'], [FormPropertyType.json, 'dynamic-form.property.type-json'], [FormPropertyType.html, 'dynamic-form.property.type-html'], [FormPropertyType.css, 'dynamic-form.property.type-css'], [FormPropertyType.markdown, 'dynamic-form.property.type-markdown'], [FormPropertyType.color, 'dynamic-form.property.type-color'], [FormPropertyType.color_settings, 'dynamic-form.property.type-color-settings'], [FormPropertyType.font, 'dynamic-form.property.type-font'], [FormPropertyType.units, 'dynamic-form.property.type-units'], [FormPropertyType.icon, 'dynamic-form.property.type-icon'], [FormPropertyType.fieldset, 'dynamic-form.property.type-fieldset'], [FormPropertyType.array, 'dynamic-form.property.type-array'], [FormPropertyType.htmlSection, 'dynamic-form.property.type-html-section'] ] ); export const formPropertyRowClasses = ['column', 'column-xs', 'column-lt-md', 'align-start', 'no-border', 'no-gap', 'no-padding', 'same-padding']; export const formPropertyFieldClasses = ['medium-width', 'flex', 'flex-xs', 'flex-lt-md']; export type PropertyConditionFunction = (property: FormProperty, model: any) => boolean; export interface FormPropertyBase { id: string; name: string; group?: string; type: FormPropertyType; default: any; required?: boolean; subLabel?: string; divider?: boolean; fieldSuffix?: string; disableOnProperty?: string; condition?: string; conditionFunction?: PropertyConditionFunction; disabled?: boolean; visible?: boolean; rowClass?: string; fieldClass?: string; } export interface FormTextareaProperty extends FormPropertyBase { rows?: number; } export interface FormNumberProperty extends FormPropertyBase { min?: number; max?: number; step?: number; } export interface FormFieldSetProperty extends FormPropertyBase { properties?: FormProperty[]; } export interface FormArrayProperty extends FormPropertyBase { arrayItemName?: string; arrayItemType?: FormPropertyType; } export interface FormSelectItem { value: any; label: string; } export interface FormSelectProperty extends FormPropertyBase { multiple?: boolean; allowEmptyOption?: boolean; items?: FormSelectItem[]; minItems?: number; maxItems?: number; } export type FormPropertyDirection = 'row' | 'column'; export interface FormRadiosProperty extends FormPropertyBase { direction?: FormPropertyDirection; items?: FormSelectItem[]; } export type FormPropertyDateTimeType = 'date' | 'time' | 'datetime'; export interface FormDateTimeProperty extends FormPropertyBase { allowClear?: boolean; dateTimeType?: FormPropertyDateTimeType; } export interface FormJavascriptProperty extends FormPropertyBase { helpId?: string; } export interface FormMarkdownProperty extends FormPropertyBase { helpId?: string; } export interface FormHtmlSection extends FormPropertyBase { htmlClassList?: string[]; htmlContent?: string; } export type FormProperty = FormPropertyBase & FormTextareaProperty & FormNumberProperty & FormSelectProperty & FormRadiosProperty & FormDateTimeProperty & FormJavascriptProperty & FormMarkdownProperty & FormFieldSetProperty & FormArrayProperty & FormHtmlSection; export const cleanupFormProperties = (properties: FormProperty[]): FormProperty[] => { for (const property of properties) { cleanupFormProperty(property); } return properties; } export const cleanupFormProperty = (property: FormProperty): FormProperty => { if (property.type !== FormPropertyType.number) { delete property.min; delete property.max; delete property.step; } if (property.type !== FormPropertyType.textarea) { delete property.rows; } if (property.type !== FormPropertyType.fieldset) { delete property.properties; } else if (property.properties?.length) { property.properties = cleanupFormProperties(property.properties); } if (property.type !== FormPropertyType.array) { delete property.arrayItemName; delete property.arrayItemType; } if (property.type !== FormPropertyType.select) { delete property.multiple; delete property.allowEmptyOption; delete property.minItems; delete property.maxItems; } if (property.type !== FormPropertyType.radios) { delete property.direction; } if (![FormPropertyType.select, FormPropertyType.radios].includes(property.type)) { delete property.items; } if (property.type !== FormPropertyType.datetime) { delete property.allowClear; delete property.dateTimeType; } if (![FormPropertyType.javascript, FormPropertyType.markdown].includes(property.type)) { delete property.helpId; } if (property.type !== FormPropertyType.htmlSection) { delete property.htmlClassList; delete property.htmlContent; } for (const key of Object.keys(property)) { const val = property[key]; if (isUndefinedOrNull(val) || isEmptyStr(val)) { delete property[key]; } } return property; } export enum FormPropertyContainerType { field = 'field', row = 'row', fieldset = 'fieldset', array = 'array', htmlSection = 'htmlSection' } export interface FormPropertyContainerBase { type: FormPropertyContainerType; label: string; visible: boolean; } export interface FormPropertyRow extends FormPropertyContainerBase { properties?: FormProperty[]; switch?: FormProperty; rowClass?: string; propertiesRowClass?: string; } export interface FormPropertyField extends FormPropertyContainerBase { property?: FormProperty; } export interface FormPropertyFieldset extends FormPropertyContainerBase { property?: FormProperty; properties?: FormProperty[]; } export interface FormPropertyArray extends FormPropertyContainerBase { property?: FormProperty; arrayItemProperty?: FormProperty; } export interface FormPropertyHtml extends FormPropertyContainerBase { property?: FormProperty; safeHtml?: SafeHtml; htmlClass?: string; } export type FormPropertyContainer = FormPropertyField & FormPropertyRow & FormPropertyFieldset & FormPropertyHtml; export interface FormPropertyGroup { title?: string; containers: FormPropertyContainer[]; visible: boolean; } export const toPropertyGroups = (properties: FormProperty[], isArrayItem: boolean, customTranslate: CustomTranslatePipe, sanitizer: DomSanitizer): FormPropertyGroup[] => { const groups: {title: string, properties: FormProperty[]}[] = []; for (let property of properties) { if (!property.group) { let group = groups.length ? groups[groups.length - 1] : null; if (group && !group.title) { group.properties.push(property); } else { groups.push({ title: null, properties: [property] }); } } else { let propertyGroup = groups.find(g => g.title === property.group); if (!propertyGroup) { propertyGroup = { title: property.group, properties: [] }; groups.push(propertyGroup); } propertyGroup.properties.push(property); } } return groups.map(g => ({ title: g.title, containers: toPropertyContainers(g.properties, isArrayItem, customTranslate, sanitizer), visible: true })); }; const toPropertyContainers = (properties: FormProperty[], isArrayItem: boolean, customTranslate: CustomTranslatePipe, sanitizer: DomSanitizer): FormPropertyContainer[] => { const result: FormPropertyContainer[] = []; for (let property of properties) { if (property.type === FormPropertyType.array) { const propertyArray: FormPropertyArray = { property, label: property.name, type: FormPropertyContainerType.array, visible: true }; const arrayItemProperty = deepClone(property); arrayItemProperty.name = property.arrayItemName; arrayItemProperty.type = property.arrayItemType; arrayItemProperty.required = true; delete arrayItemProperty.disableOnProperty; delete arrayItemProperty.condition; delete arrayItemProperty.conditionFunction; delete arrayItemProperty.group; propertyArray.arrayItemProperty = arrayItemProperty; result.push(propertyArray); } else if (property.type === FormPropertyType.fieldset) { const propertyFieldset: FormPropertyFieldset = { property, label: property.name, type: FormPropertyContainerType.fieldset, properties: property.properties, visible: true }; result.push(propertyFieldset); } else if (property.type === FormPropertyType.htmlSection) { const propertyHtml: FormPropertyHtml = { property, label: property.name, type: FormPropertyContainerType.htmlSection, htmlClass: property.htmlClassList ? property.htmlClassList.join(' ') : '', safeHtml: sanitizer.bypassSecurityTrustHtml(property.htmlContent), visible: true }; result.push(propertyHtml); } else if (isSingleFieldPropertyType(property.type) || isArrayItem) { const propertyField: FormPropertyField = { property, label: property.name, type: FormPropertyContainerType.field, visible: true }; result.push(propertyField); } else { let propertyRow = result.find(r => r.type === FormPropertyContainerType.row && r.label === property.name); if (!propertyRow) { propertyRow = { label: property.name, type: FormPropertyContainerType.row, properties: [], rowClass: property.rowClass, propertiesRowClass: 'row flex-end align-center', visible: true }; result.push(propertyRow); const rowClasses = (propertyRow.rowClass || '').split(' ').filter(cls => cls.trim().length > 0); if (!rowClasses.includes('flex-wrap')) { rowClasses.push('flex-wrap'); } propertyRow.rowClass = rowClasses.join(' '); } if (property.type === FormPropertyType.switch) { propertyRow.switch = property; } else { propertyRow.properties.push(property); } } } for (let container of result.filter(c => c.type === FormPropertyContainerType.row && !c.switch && c.properties?.length === 1)) { const property = container.properties[0]; if (isInputFieldPropertyType(property.type)) { const labelText = customTranslate.transform(property.name); if (property.type !== FormPropertyType.number && labelText.length > 40) { container.type = FormPropertyContainerType.field; container.property = property; delete container.properties; delete container.rowClass; } else { container.propertiesRowClass = 'gt-xs:align-center xs:flex-col gt-xs:flex-row gt-xs:justify-end'; const rowClasses = (container.rowClass || '').split(' ').filter(cls => cls.trim().length > 0); if (!rowClasses.includes('column-xs')) { rowClasses.push('column-xs'); } if (property.fieldClass && property.fieldClass.split(' ').includes('flex')) { container.propertiesRowClass += ' overflow-hidden'; if (rowClasses.includes('flex-wrap')) { rowClasses.splice(rowClasses.indexOf('flex-wrap'), 1); } } container.rowClass = rowClasses.join(' '); } } } return result; } export const isPropertyTypeAllowedForRow = (type: FormPropertyType): boolean => { return !isSingleFieldPropertyType(type) && ![FormPropertyType.fieldset, FormPropertyType.array, FormPropertyType.htmlSection].includes(type); } export const isSingleFieldPropertyType = (type: FormPropertyType): boolean => { return [FormPropertyType.radios, FormPropertyType.textarea, FormPropertyType.image, FormPropertyType.javascript, FormPropertyType.json, FormPropertyType.html, FormPropertyType.css, FormPropertyType.markdown].includes(type); } export const isInputFieldPropertyType = (type: FormPropertyType): boolean => { return [FormPropertyType.text, FormPropertyType.password, FormPropertyType.number, FormPropertyType.select, FormPropertyType.datetime, FormPropertyType.textarea].includes(type); } export const defaultFormProperties = (properties: FormProperty[]): {[id: string]: any} => { const formProperties: {[id: string]: any} = {}; for (const property of properties) { if (property.type !== FormPropertyType.htmlSection) { formProperties[property.id] = defaultFormPropertyValue(property); } } return formProperties; }; export const defaultFormPropertyValue = (property: FormProperty): any => { if (property.type === FormPropertyType.array) { return []; } else if (property.type === FormPropertyType.fieldset) { const propertyValue: {[id: string]: any} = {}; for (const childProperty of property.properties) { if (childProperty.type !== FormPropertyType.htmlSection) { propertyValue[childProperty.id] = defaultFormPropertyValue(childProperty); } } return propertyValue; } else { return property.default; } } export const propertyValid = (property: FormProperty): boolean => !(!property.id || !property.name || !property.type || (property.type === FormPropertyType.array && !property.arrayItemType)); export const defaultPropertyValue = (type: FormPropertyType): any => { switch (type) { case FormPropertyType.text: case FormPropertyType.textarea: case FormPropertyType.password: case FormPropertyType.javascript: case FormPropertyType.json: case FormPropertyType.html: case FormPropertyType.css: case FormPropertyType.markdown: return ''; case FormPropertyType.number: return 0; case FormPropertyType.switch: return false; case FormPropertyType.color: return '#000'; case FormPropertyType.color_settings: return constantColor('#000'); case FormPropertyType.font: return { size: 12, sizeUnit: 'px', family: 'Roboto', weight: 'normal', style: 'normal', lineHeight: '1' } as Font; case FormPropertyType.units: return ''; case FormPropertyType.icon: return 'star'; case FormPropertyType.fieldset: case FormPropertyType.array: case FormPropertyType.select: case FormPropertyType.radios: case FormPropertyType.datetime: case FormPropertyType.htmlSection: case FormPropertyType.image: return null; } }; export const formPropertyCompletions = (properties: FormProperty[], customTranslate: CustomTranslatePipe): TbEditorCompletions => { const propertiesCompletions: TbEditorCompletions = {}; for (const property of properties) { if (property.type !== FormPropertyType.htmlSection) { propertiesCompletions[property.id] = formPropertyCompletion(property, customTranslate); } } return propertiesCompletions; } export const formPropertyCompletion = (property: FormProperty, customTranslate: CustomTranslatePipe): TbEditorCompletion => { let description = customTranslate.transform(property.name, property.name); if (property.subLabel) { description += ` ${customTranslate.transform(property.subLabel, property.subLabel)}`; } const isArray = property.type === FormPropertyType.array; const type = isArray ? property.arrayItemType : property.type; if (type === FormPropertyType.select) { if (property.multiple || isArray) { description += '

Possible values of array element:'; } else { description += '

Possible values:'; } description += ``; } if (type === FormPropertyType.datetime) { if (isArray) { description += '

Stores array of time values in milliseconds since midnight, January 1, 1970 UTC.'; } else { description += '

Stores time value in milliseconds since midnight, January 1, 1970 UTC.'; } } const completion: TbEditorCompletion = { meta: 'property', description, type: formPropertyCompletionType(property) }; if (type === FormPropertyType.fieldset && !isArray) { completion.children = {}; for (const childProperty of property.properties) { if (childProperty.type !== FormPropertyType.htmlSection) { completion.children[childProperty.id] = formPropertyCompletion(childProperty, customTranslate); } } } return completion; }; const formPropertyCompletionType = (property: FormProperty): string => { const isArray = property.type === FormPropertyType.array; const type = isArray ? property.arrayItemType : property.type; let typeStr: string; switch (type) { case FormPropertyType.text: case FormPropertyType.password: case FormPropertyType.textarea: typeStr = 'string'; break; case FormPropertyType.number: typeStr = 'number'; break; case FormPropertyType.switch: typeStr = 'boolean'; break; case FormPropertyType.datetime: typeStr = 'number'; break; case FormPropertyType.image: typeStr = 'image URL string'; break; case FormPropertyType.select: case FormPropertyType.radios: const items = property.items || []; const types: string[] = []; items.forEach(item => { const type = typeof item.value; if (!types.includes(type)) { types.push(type); } }); const typesString = types.length ? types.join(' | ') : 'string'; if (property.type === FormPropertyType.select && property.multiple) { typeStr = `Array<${typesString}>`; } else { typeStr = typesString; } break; case FormPropertyType.color: typeStr = 'color string'; break; case FormPropertyType.color_settings: typeStr = 'ColorProcessor'; break; case FormPropertyType.font: typeStr = 'Font'; break; case FormPropertyType.units: typeStr = 'units string'; break; case FormPropertyType.icon: typeStr = 'icon string'; break; case FormPropertyType.fieldset: typeStr = 'object'; break; case FormPropertyType.javascript: typeStr = 'JavaScript function body string'; break; case FormPropertyType.json: typeStr = 'JSON string'; break; case FormPropertyType.html: typeStr = 'HTML string'; break; case FormPropertyType.css: typeStr = 'CSS string'; break; case FormPropertyType.markdown: typeStr = 'Markdown string'; break; default: typeStr = 'unknown'; break; } if (isArray) { typeStr = `Array<${typeStr}>`; } return typeStr; }; export const jsonFormSchemaToFormProperties = (rawSchema: string | any) : FormProperty[] => { try { const properties: FormProperty[] = []; let settingsSchema: JsonSettingsSchema; if (!rawSchema || rawSchema === '') { settingsSchema = {}; } else { settingsSchema = isString(rawSchema) ? JSON.parse(rawSchema) : rawSchema; } if (settingsSchema.schema) { const schema = settingsSchema.schema; const form = settingsSchema.form || ['*']; const groupInfoes = settingsSchema.groupInfoes || []; if (form.length > 0) { if (groupInfoes.length) { for (const info of groupInfoes) { const theForm: any[] = form[info.formIndex]; properties.push(...schemaFormToProperties(schema, theForm, info.GroupTitle)); } } else { properties.push(...schemaFormToProperties(schema, form)); } } } return properties; } catch (e) { console.warn('Failed to convert old JSON form schema to form properties:', e); return []; } } const schemaFormToProperties = (schema: JsonSchema, theForm: any[], groupTitle?: string): FormProperty[] => { const merged = JsonFormUtils.merge(schema, theForm, {}, { formDefaults: { startEmpty: true } }); return merged.map((form: JsonFormData) => jsonFormDataToProperty(form, 0, groupTitle)).filter(p => p != null); } const jsonFormDataToProperty = (form: JsonFormData, level: number, groupTitle?: string): FormProperty => { if (form.key && form.key.length > level) { const id = form.key[level] + ''; let property: FormProperty = { id, name: form.title || id, group: groupTitle, type: null, default: (isDefinedAndNotNull(form.default) ? form.default : form.schema?.default) || null, required: isDefinedAndNotNull(form.required) ? form.required : false }; if (form.condition?.length) { property.condition = `return ${form.condition};`; } switch (form.type) { case 'number': property.type = FormPropertyType.number; break; case 'text': property.type = FormPropertyType.text; property.fieldClass = 'flex'; break; case 'password': property.type = FormPropertyType.password; property.fieldClass = 'flex'; break; case 'textarea': property.type = FormPropertyType.textarea; property.rows = form.rows || form.rowsMax || 2; break; case 'checkbox': property.type = FormPropertyType.switch; break; case 'rc-select': property.type = FormPropertyType.select; if (form.items?.length) { property.items = (form.items as KeyLabelItem[]).map(item => ({value: item.value, label: item.label})); } else { property.items = []; } property.multiple = form.multiple; property.fieldClass = 'flex'; property.allowEmptyOption = isDefinedAndNotNull(form.allowClear) ? form.allowClear : false; if (property.multiple) { if (typeof (form.schema as any)?.minItems === 'number') { property.minItems = (form.schema as any).minItems; } if (typeof (form.schema as any)?.maxItems === 'number') { property.maxItems = (form.schema as any).maxItems; } } break; case 'select': property.type = FormPropertyType.select; if (form.titleMap?.length) { property.items = form.titleMap.map(item => ({value: item.value, label: item.name})); } else { property.items = []; } property.multiple = false; property.fieldClass = 'flex'; property.allowEmptyOption = false; break; case 'radios': property.type = FormPropertyType.radios; if (form.titleMap?.length) { property.items = form.titleMap.map(item => ({value: item.value, label: item.name})); } else { property.items = []; } property.direction = form.direction === 'row' ? 'row' : 'column'; break; case 'date': property.type = FormPropertyType.datetime; property.dateTimeType = 'date'; property.fieldClass = 'flex'; property.allowClear = true; break; case 'image': property.type = FormPropertyType.image; break; case 'color': property.type = FormPropertyType.color; break; case 'icon': property.type = FormPropertyType.icon; break; case 'fieldset': property.type = FormPropertyType.fieldset; property.properties = form.items ? (form.items as JsonFormData[]).map(item => jsonFormDataToProperty(item, level+1)).filter(p => p !== null) : []; break; case 'javascript': property.type = FormPropertyType.javascript; property.helpId = form.helpId; break; case 'json': property.type = FormPropertyType.json; break; case 'html': property.type = FormPropertyType.html; break; case 'css': property.type = FormPropertyType.css; break; case 'markdown': property.type = FormPropertyType.markdown; break; case 'help': property.type = FormPropertyType.htmlSection; property.htmlContent = form.description || ''; property.htmlClassList = form.htmlClass ? form.htmlClass.split(' ') : []; break; case 'array': if (form.items?.length) { const arrayItemSchema = form.schema.items; if (arrayItemSchema && arrayItemSchema.type && arrayItemSchema.type !== 'array') { if (arrayItemSchema.type === 'object') { property.arrayItemType = FormPropertyType.fieldset; property.arrayItemName = ''; property.properties = form.items ? (form.items as JsonFormData[]).map(item => jsonFormDataToProperty(item, level+2)).filter(p => p !== null) : []; } else { const item: JsonFormData = form.items[0] as JsonFormData; const arrayProperty = jsonFormDataToProperty(item, 0); arrayProperty.arrayItemType = arrayProperty.type; arrayProperty.arrayItemName = arrayProperty.name; arrayProperty.id = property.id; arrayProperty.name = property.name; arrayProperty.group = property.group; arrayProperty.condition = property.condition; arrayProperty.required = property.required; property = arrayProperty; } property.type = FormPropertyType.array; } } break; } if (!property.type) { return null; } return property; } return null; }