diff --git a/Composer/packages/client/__tests__/store/reducer/reducer.test.js b/Composer/packages/client/__tests__/store/reducer/reducer.test.js index cc48da250d..cc0e7521dd 100644 --- a/Composer/packages/client/__tests__/store/reducer/reducer.test.js +++ b/Composer/packages/client/__tests__/store/reducer/reducer.test.js @@ -24,8 +24,18 @@ describe('test all reducer handlers', () => { expect(result.dialogs[0]).toBe('test dialogs'); }); it('test updateLgTemplate reducer', () => { - const result = reducer({}, { type: ActionTypes.UPDATE_LG_SUCCESS, payload: { response: mockResponse } }); - expect(result.lgFiles[0]).toBe('test lgFiles'); + const result = reducer( + { lgFiles: [{ id: 'common.lg', content: 'test lgFiles' }] }, + { + type: ActionTypes.UPDATE_LG_SUCCESS, + payload: { + id: 'common.lg', + content: ` # bfdactivity-003038 + - You said '@{turn.activity.text}'`, + }, + } + ); + expect(result.lgFiles[0].templates.length).toBe(1); }); it('test getStorageFileSuccess reducer', () => { diff --git a/Composer/packages/client/src/store/action/lg.ts b/Composer/packages/client/src/store/action/lg.ts index b7ad9ff040..5511ed332d 100644 --- a/Composer/packages/client/src/store/action/lg.ts +++ b/Composer/packages/client/src/store/action/lg.ts @@ -1,28 +1,51 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import clonedeep from 'lodash/cloneDeep'; +import debounce from 'lodash/debounce'; +import { ActionTypes } from '../../constants'; +import httpClient from '../../utils/httpUtil'; import * as lgUtil from '../../utils/lgUtil'; -import { ActionCreator } from '../types'; +import { undoable } from '../middlewares/undo'; +import { ActionCreator, State } from '../types'; -import { ActionTypes } from './../../constants'; -import httpClient from './../../utils/httpUtil'; +import { fetchProject } from './project'; +import { setError } from './error'; -export const updateLgFile: ActionCreator = async ({ dispatch }, { id, content }) => { +//remove editor's debounce and add it to action +export const debouncedUpdateLg = debounce(async (store, id, content) => { try { - const response = await httpClient.put(`/projects/opened/lgFiles/${id}`, { id, content }); - dispatch({ - type: ActionTypes.UPDATE_LG_SUCCESS, - payload: { response }, - }); + await httpClient.put(`/projects/opened/lgFiles/${id}`, { id, content }); } catch (err) { - dispatch({ - type: ActionTypes.UPDATE_LG_FAILURE, - payload: null, - error: err, + setError(store, { + message: err.response && err.response.data.message ? err.response.data.message : err, + summary: 'UPDATE LG ERROR', }); + //if update lg error, do a full refresh. + fetchProject(store); } +}, 500); + +export const updateLgFile: ActionCreator = async (store, { id, content }) => { + store.dispatch({ type: ActionTypes.UPDATE_LG_SUCCESS, payload: { id, content } }); + debouncedUpdateLg(store, id, content); }; +export const undoableUpdateLgFile = undoable( + updateLgFile, + (state: State, args: any[], isEmpty) => { + if (isEmpty) { + const id = args[0].id; + const content = clonedeep(state.lgFiles.find(lgFile => lgFile.id === id)?.content); + return [{ id, content }]; + } else { + return args; + } + }, + updateLgFile, + updateLgFile +); + export const createLgFile: ActionCreator = async ({ dispatch }, { id, content }) => { try { const response = await httpClient.post(`/projects/opened/lgFiles`, { id, content }); @@ -57,25 +80,25 @@ export const removeLgFile: ActionCreator = async ({ dispatch }, { id }) => { export const updateLgTemplate: ActionCreator = async (store, { file, templateName, template }) => { const newContent = lgUtil.updateTemplate(file.content, templateName, template); - return await updateLgFile(store, { id: file.id, content: newContent }); + return await undoableUpdateLgFile(store, { id: file.id, content: newContent }); }; export const createLgTemplate: ActionCreator = async (store, { file, template }) => { const newContent = lgUtil.addTemplate(file.content, template); - return await updateLgFile(store, { id: file.id, content: newContent }); + return await undoableUpdateLgFile(store, { id: file.id, content: newContent }); }; export const removeLgTemplate: ActionCreator = async (store, { file, templateName }) => { const newContent = lgUtil.removeTemplate(file.content, templateName); - return await updateLgFile(store, { id: file.id, content: newContent }); + return await undoableUpdateLgFile(store, { id: file.id, content: newContent }); }; export const removeLgTemplates: ActionCreator = async (store, { file, templateNames }) => { const newContent = lgUtil.removeTemplates(file.content, templateNames); - return await updateLgFile(store, { id: file.id, content: newContent }); + return await undoableUpdateLgFile(store, { id: file.id, content: newContent }); }; export const copyLgTemplate: ActionCreator = async (store, { file, fromTemplateName, toTemplateName }) => { const newContent = lgUtil.copyTemplate(file.content, fromTemplateName, toTemplateName); - return await updateLgFile(store, { id: file.id, content: newContent }); + return await undoableUpdateLgFile(store, { id: file.id, content: newContent }); }; diff --git a/Composer/packages/client/src/store/action/navigation.ts b/Composer/packages/client/src/store/action/navigation.ts index 64f5deaacb..e2d662e6bf 100644 --- a/Composer/packages/client/src/store/action/navigation.ts +++ b/Composer/packages/client/src/store/action/navigation.ts @@ -7,6 +7,7 @@ import { ActionCreator } from './../types'; import { ActionTypes } from './../../constants'; import { updateBreadcrumb, navigateTo, checkUrl, getUrlSearch, BreadcrumbUpdateType } from './../../utils/navigation'; import { debouncedUpdateDialog } from './dialog'; +import { debouncedUpdateLg } from './lg'; export const setDesignPageLocation: ActionCreator = ( { dispatch }, @@ -25,6 +26,7 @@ export const navTo: ActionCreator = ({ getState }, dialogId, breadcrumb = []) => if (checkUrl(currentUri, state.designPageLocation)) return; //if dialog change we should flush some debounced functions debouncedUpdateDialog.flush(); + debouncedUpdateLg.flush(); navigateTo(currentUri, { state: { breadcrumb } }); }; diff --git a/Composer/packages/client/src/store/reducer/index.ts b/Composer/packages/client/src/store/reducer/index.ts index 5f75345bfe..4b2efc606c 100644 --- a/Composer/packages/client/src/store/reducer/index.ts +++ b/Composer/packages/client/src/store/reducer/index.ts @@ -5,6 +5,7 @@ import get from 'lodash/get'; import set from 'lodash/set'; import { dialogIndexer } from '@bfc/indexers'; import { SensitiveProperties } from '@bfc/shared'; +import { Diagnostic, DiagnosticSeverity, LgTemplate, lgIndexer } from '@bfc/indexers'; import { ActionTypes, FileTypes } from '../../constants'; import { DialogSetting, ReducerFunc } from '../types'; @@ -105,8 +106,21 @@ const createDialogSuccess: ReducerFunc = (state, { response }) => { return state; }; -const updateLgTemplate: ReducerFunc = (state, { response }) => { - state.lgFiles = response.data.lgFiles; +const updateLgTemplate: ReducerFunc = (state, { id, content }) => { + state.lgFiles = state.lgFiles.map(lgFile => { + if (lgFile.id === id) { + const { check, parse } = lgIndexer; + const diagnostics = check(content, id); + let templates: LgTemplate[] = []; + try { + templates = parse(content, id); + } catch (err) { + diagnostics.push(new Diagnostic(err.message, id, DiagnosticSeverity.Error)); + } + return { ...lgFile, templates, diagnostics }; + } + return lgFile; + }); return state; }; diff --git a/Composer/packages/extensions/obiformeditor/src/Form/widgets/LgEditorWidget.tsx b/Composer/packages/extensions/obiformeditor/src/Form/widgets/LgEditorWidget.tsx index ceae1e2017..f5fe1796c7 100644 --- a/Composer/packages/extensions/obiformeditor/src/Form/widgets/LgEditorWidget.tsx +++ b/Composer/packages/extensions/obiformeditor/src/Form/widgets/LgEditorWidget.tsx @@ -1,11 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { LgEditor } from '@bfc/code-editor'; import { LgMetaData, LgTemplateRef } from '@bfc/shared'; -import debounce from 'lodash/debounce'; import { filterTemplateDiagnostics } from '@bfc/indexers'; +import debounce from 'lodash/debounce'; +import isEqual from 'lodash/isEqual'; import { FormContext } from '../types'; @@ -48,11 +49,10 @@ export const LgEditorWidget: React.FC = props => { const lgFileId = formContext.currentDialog.lgFile || 'common'; const lgFile = formContext.lgFiles && formContext.lgFiles.find(file => file.id === lgFileId); - const updateLgTemplate = useMemo( - () => - debounce((body: string) => { - formContext.shellApi.updateLgTemplate(lgFileId, lgName, body).catch(() => {}); - }, 500), + const updateLgTemplate = useCallback( + (body: string) => { + formContext.shellApi.updateLgTemplate(lgFileId, lgName, body).catch(() => {}); + }, [lgName, lgFileId] ); @@ -76,6 +76,22 @@ export const LgEditorWidget: React.FC = props => { ? diagnostic.message.split('error message: ')[diagnostic.message.split('error message: ').length - 1] : ''; const [localValue, setLocalValue] = useState(template.body); + const sync = useRef( + debounce((shellData: any, localData: any) => { + if (!isEqual(shellData, localData)) { + setLocalValue(shellData); + } + }, 750) + ).current; + + useEffect(() => { + sync(template.body, localValue); + + return () => { + sync.cancel(); + }; + }, [template.body]); + const lgOption = { fileId: lgFileId, templateId: lgName, @@ -88,20 +104,12 @@ export const LgEditorWidget: React.FC = props => { updateLgTemplate(body); props.onChange(new LgTemplateRef(lgName).toString()); } else { - updateLgTemplate.flush(); formContext.shellApi.removeLgTemplate(lgFileId, lgName); props.onChange(); } } }; - // update the template on mount to get validation - useEffect(() => { - if (localValue) { - updateLgTemplate(localValue); - } - }, []); - return ( = props => { const [localData, setLocalData] = useState(data); const type = getType(localData); + const sync = useRef( + debounce((shellData: FormData, localData: FormData) => { + if (!isEqual(shellData, localData)) { + setLocalData(shellData); + } + }, 750) + ).current; + useEffect(() => { - if (!isEqual(localData, data)) { - setLocalData(data); - } + sync(data, localData); + + return () => { + sync.cancel(); + }; }, [data]); const formErrors = useMemo(() => { diff --git a/Composer/packages/lib/shared/src/types/shell.ts b/Composer/packages/lib/shared/src/types/shell.ts index eada777162..0077441749 100644 --- a/Composer/packages/lib/shared/src/types/shell.ts +++ b/Composer/packages/lib/shared/src/types/shell.ts @@ -20,7 +20,7 @@ export interface ShellData { botName: string; currentDialog: any; data: { - $type: string; + $type?: string; [key: string]: any; }; dialogId: string;