diff --git a/Composer/packages/ui-plugins/select-dialog/package.json b/Composer/packages/ui-plugins/select-dialog/package.json index 0c82c144ca..13f4c35964 100644 --- a/Composer/packages/ui-plugins/select-dialog/package.json +++ b/Composer/packages/ui-plugins/select-dialog/package.json @@ -43,6 +43,7 @@ "react-dom": "16.13.1" }, "dependencies": { - "@emotion/core": "^10.0.27" + "@emotion/core": "^10.0.27", + "ajv": "7.2.3" } } diff --git a/Composer/packages/ui-plugins/select-dialog/src/DialogOptionsField.tsx b/Composer/packages/ui-plugins/select-dialog/src/DialogOptionsField.tsx index e5fd0575c9..5eea067d24 100644 --- a/Composer/packages/ui-plugins/select-dialog/src/DialogOptionsField.tsx +++ b/Composer/packages/ui-plugins/select-dialog/src/DialogOptionsField.tsx @@ -4,11 +4,22 @@ import React from 'react'; import styled from '@emotion/styled'; import { FieldProps, JSONSchema7, useShellApi } from '@bfc/extension-client'; -import { FieldLabel, JsonField, SchemaField, IntellisenseTextField, WithTypeIcons } from '@bfc/adaptive-form'; +import { FieldLabel, IntellisenseTextField, OpenObjectField, WithTypeIcons, SchemaField } from '@bfc/adaptive-form'; import Stack from 'office-ui-fabric-react/lib/components/Stack/Stack'; import { FluentTheme, NeutralColors } from '@uifabric/fluent-theme'; import formatMessage from 'format-message'; import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import Ajv, { AnySchemaObject } from 'ajv'; + +const loadSchema = async (uri: string) => { + const res = await fetch(uri); + return res.body as AnySchemaObject; +}; + +const ajv = new Ajv({ + loadSchema, + strict: false, +}); const IntellisenseTextFieldWithIcon = WithTypeIcons(IntellisenseTextField); @@ -42,16 +53,22 @@ const styles = { const dropdownCalloutProps = { styles: { root: { minWidth: 140 } } }; -const getInitialSelectedKey = (value?: string | Record, schema?: JSONSchema7): string => { - if (typeof value !== 'string' && schema) { +const getSelectedKey = ( + value?: string | Record, + schema?: JSONSchema7, + validSchema = false +): string => { + if (typeof value !== 'string' && schema && validSchema) { return 'form'; - } else if (typeof value !== 'string' && !schema) { - return 'code'; + } else if (typeof value !== 'string' && (!schema || !validSchema)) { + return 'object'; } else { return 'expression'; } }; +type JSONValidationStatus = 'valid' | 'inValid' | 'validating'; + const DialogOptionsField: React.FC = ({ description, uiOptions, @@ -68,11 +85,39 @@ const DialogOptionsField: React.FC = ({ [dialog, dialogSchemas] ); - const [selectedKey, setSelectedKey] = React.useState(getInitialSelectedKey(options, schema)); + const [selectedKey, setSelectedKey] = React.useState(); + const [validationStatus, setValidationStatus] = React.useState('validating'); - React.useLayoutEffect(() => { - setSelectedKey(getInitialSelectedKey(options, schema)); - }, [dialog]); + const mountRef = React.useRef(false); + + React.useEffect(() => { + mountRef.current = true; + if (schema && Object.keys(schema.properties || {}).length) { + (async () => { + setValidationStatus('validating'); + try { + const validate = await ajv.compileAsync(schema, true); + const valid = validate(schema); + + if (mountRef.current) { + setValidationStatus(valid ? 'valid' : 'inValid'); + setSelectedKey(getSelectedKey(options, schema, true)); + } + } catch (error) { + if (mountRef.current) { + setValidationStatus('inValid'); + setSelectedKey(getSelectedKey(options, schema, false)); + } + } + })(); + } else { + setSelectedKey(getSelectedKey(options, schema, false)); + } + + return () => { + mountRef.current = false; + }; + }, [schema]); const change = React.useCallback( (newOptions?: string | Record) => { @@ -84,13 +129,18 @@ const DialogOptionsField: React.FC = ({ const onDropdownChange = React.useCallback( (_: React.FormEvent, option?: IDropdownOption) => { if (option) { - setSelectedKey(option.key as string); - if (option.key === 'expression') { + // When the user switched between data types - either a string (expression) or an object (form or object) - we need to set + // options to undefined so we don't incorrectly pass a string to an object editor or pass an object to a string editor. + + // If selectedKey is currently set to expression and the user is switching to form or object, set the value to undefined. + // If the user is switching to expression meaning the selectedKey is currently set to form or form, set the value to undefined. + if (option.key === 'expression' || selectedKey === 'expression') { change(); } + setSelectedKey(option.key as string); } }, - [change] + [change, selectedKey] ); const typeOptions = React.useMemo(() => { @@ -98,24 +148,24 @@ const DialogOptionsField: React.FC = ({ { key: 'form', text: formatMessage('form'), - disabled: !schema || !Object.keys(schema).length, + disabled: !schema || validationStatus !== 'valid', }, { - key: 'code', - text: formatMessage('code editor'), + key: 'object', + text: formatMessage('object'), }, { key: 'expression', text: 'expression', }, ]; - }, [schema]); + }, [schema, validationStatus]); let Field = IntellisenseTextFieldWithIcon; if (selectedKey === 'form') { Field = SchemaField; - } else if (selectedKey === 'code') { - Field = JsonField; + } else if (selectedKey === 'object') { + Field = OpenObjectField; } return ( @@ -144,9 +194,9 @@ const DialogOptionsField: React.FC = ({ id={`${id}.options`} label={false} name={'options'} - schema={schema || {}} + schema={(selectedKey === 'form' ? schema : { type: 'object', additionalProperties: true }) || {}} uiOptions={{}} - value={options || selectedKey === 'expression' ? '' : {}} + value={options} onChange={change} /> diff --git a/Composer/packages/ui-plugins/select-dialog/src/__tests__/DialogOptions.test.tsx b/Composer/packages/ui-plugins/select-dialog/src/__tests__/DialogOptions.test.tsx index 9c213347d4..a2c76d6fd1 100644 --- a/Composer/packages/ui-plugins/select-dialog/src/__tests__/DialogOptions.test.tsx +++ b/Composer/packages/ui-plugins/select-dialog/src/__tests__/DialogOptions.test.tsx @@ -10,28 +10,28 @@ import { EditorExtension } from '@bfc/extension-client'; import { DialogOptionsField } from '../DialogOptionsField'; jest.mock('@bfc/adaptive-form', () => { - const AdaptiveForm = jest.requireActual('@bfc/adaptive-form'); + const MockAdaptiveForm = jest.requireActual('@bfc/adaptive-form'); return { - ...AdaptiveForm, - JsonField: () =>
Json Field
, + ...MockAdaptiveForm, + OpenObjectField: () =>
Object Field
, SchemaField: () =>
Options Form
, IntellisenseTextField: () =>
Intellisense Text Field
, }; }); jest.mock('office-ui-fabric-react/lib/Dropdown', () => { - const Dropdown = jest.requireActual('office-ui-fabric-react/lib/Dropdown'); + const MockDropdown = jest.requireActual('office-ui-fabric-react/lib/Dropdown'); return { - ...Dropdown, + ...MockDropdown, Dropdown: ({ onChange }) => ( ), }; @@ -88,7 +88,7 @@ describe('DialogOptionsField', () => { }); it('should render the JsonField if the dialog schema is undefined and options is not a string', async () => { const { findByText } = renderDialogOptionsField({ value: { dialog: 'dialog2', options: {} } }); - await findByText('Json Field'); + await findByText('Object Field'); }); it('should render the IntellisenseTextField if options is a string', async () => { const { findByText } = renderDialogOptionsField({ value: { dialog: 'dialog2', options: '=user.data' } }); @@ -102,10 +102,10 @@ describe('DialogOptionsField', () => { // Should initially render Options Form await findByText('Options Form'); - // Switch to Json field - const button = await findByText('Switch to Json Field'); + // Switch to Object field + const button = await findByText('Switch to Object Field'); fireEvent.click(button); - await findByText('Json Field'); + await findByText('Object Field'); }); }); diff --git a/Composer/yarn.lock b/Composer/yarn.lock index 4d83071ffc..7c21dd258a 100644 --- a/Composer/yarn.lock +++ b/Composer/yarn.lock @@ -6027,6 +6027,16 @@ ajv-keywords@^3.4.1: resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.4.1.tgz#ef916e271c64ac12171fd8384eaae6b2345854da" integrity sha512-RO1ibKvd27e6FEShVFfPALuHI3WjSVNeK5FIsmme/LYRNxjKuNj+Dt7bucLa6NdSv3JcVTyMlm9kGR84z1XpaQ== +ajv@7.2.3: + version "7.2.3" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-7.2.3.tgz#ca78d1cf458d7d36d1c3fa0794dd143406db5772" + integrity sha512-idv5WZvKVXDqKralOImQgPM9v6WOdLNa0IY3B3doOjw/YxRGT8I+allIJ6kd7Uaj+SF1xZUSU+nPM5aDNBVtnw== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + ajv@^4.7.0: version "4.11.8" resolved "https://registry.yarnpkg.com/ajv/-/ajv-4.11.8.tgz#82ffb02b29e662ae53bdc20af15947706739c536" @@ -14995,6 +15005,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.2.3: version "0.2.3" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" @@ -19933,7 +19948,7 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= -require-from-string@^2.0.1: +require-from-string@^2.0.1, require-from-string@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==