diff --git a/Composer/packages/client/src/pages/design/PropertyEditor.tsx b/Composer/packages/client/src/pages/design/PropertyEditor.tsx index 9882d3f9ea..1d0b796036 100644 --- a/Composer/packages/client/src/pages/design/PropertyEditor.tsx +++ b/Composer/packages/client/src/pages/design/PropertyEditor.tsx @@ -9,7 +9,7 @@ import Extension from '@bfc/extension'; import formatMessage from 'format-message'; import { Resizable, ResizeCallback } from 're-resizable'; -import { useShell } from '../../useShell'; +import { useShell } from '../../shell'; import plugins from '../../plugins'; import { formEditor } from './styles'; diff --git a/Composer/packages/client/src/pages/design/VisualEditor.tsx b/Composer/packages/client/src/pages/design/VisualEditor.tsx index 74e6983522..b60b2e9fc3 100644 --- a/Composer/packages/client/src/pages/design/VisualEditor.tsx +++ b/Composer/packages/client/src/pages/design/VisualEditor.tsx @@ -12,7 +12,7 @@ import Extension from '@bfc/extension'; import grayComposerIcon from '../../images/grayComposerIcon.svg'; import { StoreContext } from '../../store'; -import { useShell } from '../../useShell'; +import { useShell } from '../../shell'; import plugins from '../../plugins'; import { middleTriggerContainer, middleTriggerElements, triggerButton, visualEditor } from './styles'; diff --git a/Composer/packages/client/src/shell/index.ts b/Composer/packages/client/src/shell/index.ts new file mode 100644 index 0000000000..08556b3fc5 --- /dev/null +++ b/Composer/packages/client/src/shell/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './useShell'; diff --git a/Composer/packages/client/src/shell/lgApi.ts b/Composer/packages/client/src/shell/lgApi.ts new file mode 100644 index 0000000000..858e28c4d7 --- /dev/null +++ b/Composer/packages/client/src/shell/lgApi.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useContext, useEffect, useState } from 'react'; +import { LgFile } from '@bfc/shared'; +import throttle from 'lodash/throttle'; +import mapValues from 'lodash/mapValues'; + +import * as lgUtil from '../utils/lgUtil'; +import { State, BoundActionHandlers } from '../store/types'; +import { StoreContext } from '../store'; + +const createThrottledFunc = fn => throttle(fn, 1000, { leading: true, trailing: true }); + +function createLgApi(state: State, actions: BoundActionHandlers, lgFileResolver: (id: string) => LgFile | undefined) { + const api = { + getLgTemplates: id => { + if (id === undefined) throw new Error('must have a file id'); + const focusedDialogId = state.focusPath.split('#').shift() || id; + const file = lgFileResolver(focusedDialogId); + if (!file) throw new Error(`lg file ${id} not found`); + return file.templates; + }, + + updateLgTemplate: async (id: string, templateName: string, templateBody: string) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!templateName) throw new Error(`templateName is missing or empty`); + const template = { name: templateName, body: templateBody, parameters: [] }; + + const projectId = state.projectId; + + lgUtil.checkSingleLgTemplate(template); + + await actions.updateLgTemplate({ + file, + projectId, + templateName, + template, + }); + }, + + copyLgTemplate: async (id, fromTemplateName, toTemplateName) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!fromTemplateName || !toTemplateName) throw new Error(`templateName is missing or empty`); + + const projectId = state.projectId; + + return actions.copyLgTemplate({ + file, + projectId, + fromTemplateName, + toTemplateName, + }); + }, + + removeLgTemplate: async (id, templateName) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!templateName) throw new Error(`templateName is missing or empty`); + const projectId = state.projectId; + + return actions.removeLgTemplate({ + file, + projectId, + templateName, + }); + }, + + removeLgTemplates: async (id, templateNames) => { + const file = lgFileResolver(id); + if (!file) throw new Error(`lg file ${id} not found`); + if (!templateNames) throw new Error(`templateName is missing or empty`); + const projectId = state.projectId; + + return actions.removeLgTemplates({ + file, + projectId, + templateNames, + }); + }, + }; + + return mapValues(api, fn => createThrottledFunc(fn)); +} + +export function useLgApi() { + const { state, actions, resolvers } = useContext(StoreContext); + const { projectId, focusPath } = state; + const { lgFileResolver } = resolvers; + const [api, setApi] = useState(createLgApi(state, actions, lgFileResolver)); + + useEffect(() => { + const newApi = createLgApi(state, actions, lgFileResolver); + setApi(newApi); + + return () => { + Object.keys(newApi).forEach(apiName => { + newApi[apiName].flush(); + }); + }; + }, [projectId, focusPath]); + + return api; +} diff --git a/Composer/packages/client/src/useShell.ts b/Composer/packages/client/src/shell/useShell.ts similarity index 69% rename from Composer/packages/client/src/useShell.ts rename to Composer/packages/client/src/shell/useShell.ts index 267df31478..50e367ec5e 100644 --- a/Composer/packages/client/src/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -6,14 +6,15 @@ import { ShellApi, ShellData } from '@bfc/shared'; import isEqual from 'lodash/isEqual'; import get from 'lodash/get'; -import * as lgUtil from './utils/lgUtil'; -import * as luUtil from './utils/luUtil'; -import { updateRegExIntent } from './utils/dialogUtil'; -import { StoreContext } from './store'; -import { getDialogData, setDialogData, sanitizeDialogData } from './utils'; -import { OpenAlertModal, DialogStyle } from './components/Modal'; -import { getFocusPath } from './utils/navigation'; -import { isAbsHosted } from './utils/envUtil'; +import * as luUtil from '../utils/luUtil'; +import { updateRegExIntent } from '../utils/dialogUtil'; +import { StoreContext } from '../store'; +import { getDialogData, setDialogData, sanitizeDialogData } from '../utils'; +import { OpenAlertModal, DialogStyle } from '../components/Modal'; +import { getFocusPath } from '../utils/navigation'; +import { isAbsHosted } from '../utils/envUtil'; + +import { useLgApi } from './lgApi'; const FORM_EDITOR = 'PropertyEditor'; @@ -21,7 +22,7 @@ type EventSource = 'VisualEditor' | 'PropertyEditor'; export function useShell(source: EventSource): { api: ShellApi; data: ShellData } { const { state, actions, resolvers } = useContext(StoreContext); - const { lgFileResolver, luFileResolver } = resolvers; + const { luFileResolver } = resolvers; const { botName, breadcrumb, @@ -36,9 +37,9 @@ export function useShell(source: EventSource): { api: ShellApi; data: ShellData userSettings, skills, } = state; + const lgApi = useLgApi(); const updateDialog = actions.updateDialog; const updateLuFile = actions.updateLuFile; //if debounced, error can't pass to form - const updateLgTemplate = actions.updateLgTemplate; const { dialogId, selected, focused, promptTab } = designPageLocation; @@ -49,73 +50,6 @@ export function useShell(source: EventSource): { api: ShellApi; data: ShellData }, {}); }, [dialogs]); - function getLgTemplates(id) { - if (id === undefined) throw new Error('must have a file id'); - const focusedDialogId = focusPath.split('#').shift() || id; - const file = lgFileResolver(focusedDialogId); - if (!file) throw new Error(`lg file ${id} not found`); - return file.templates; - } - - async function updateLgTemplateHandler(id: string, templateName: string, templateBody: string) { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!templateName) throw new Error(`templateName is missing or empty`); - const template = { name: templateName, body: templateBody, parameters: [] }; - - const projectId = state.projectId; - - lgUtil.checkSingleLgTemplate(template); - - await updateLgTemplate({ - file, - projectId, - templateName, - template, - }); - } - - async function copyLgTemplateHandler(id, fromTemplateName, toTemplateName) { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!fromTemplateName || !toTemplateName) throw new Error(`templateName is missing or empty`); - - const projectId = state.projectId; - - return actions.copyLgTemplate({ - file, - projectId, - fromTemplateName, - toTemplateName, - }); - } - - async function removeLgTemplateHandler(id, templateName) { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!templateName) throw new Error(`templateName is missing or empty`); - const projectId = state.projectId; - - return actions.removeLgTemplate({ - file, - projectId, - templateName, - }); - } - - async function removeLgTemplatesHandler(id, templateNames) { - const file = lgFileResolver(id); - if (!file) throw new Error(`lg file ${id} not found`); - if (!templateNames) throw new Error(`templateName is missing or empty`); - const projectId = state.projectId; - - return actions.removeLgTemplates({ - file, - projectId, - templateNames, - }); - } - async function updateLuIntentHandler(id, intentName, intent) { const file = luFileResolver(id); if (!file) throw new Error(`lu file ${id} not found`); @@ -213,11 +147,7 @@ export function useShell(source: EventSource): { api: ShellApi; data: ShellData actions.navTo(dialogId); } }, - getLgTemplates, - updateLgTemplate: updateLgTemplateHandler, - copyLgTemplate: copyLgTemplateHandler, - removeLgTemplate: removeLgTemplateHandler, - removeLgTemplates: removeLgTemplatesHandler, + ...lgApi, updateLuIntent: updateLuIntentHandler, updateRegExIntent: updateRegExIntentHandler, removeLuIntent: removeLuIntentHandler, diff --git a/Composer/packages/client/src/store/persistence/FileOperation.ts b/Composer/packages/client/src/store/persistence/FileOperation.ts index 67b09ead2f..dd39ef5c87 100644 --- a/Composer/packages/client/src/store/persistence/FileOperation.ts +++ b/Composer/packages/client/src/store/persistence/FileOperation.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { FileInfo } from '@bfc/shared'; -import debounce from 'lodash/debounce'; +import throttle from 'lodash/throttle'; import { FileErrorHandler } from './types'; import * as client from './http'; @@ -11,18 +11,22 @@ export class FileOperation { private projectId: string; private errorHandler: FileErrorHandler = error => {}; - private debouncedUpdate = debounce(async (content: string) => { - if (this.file) { - try { - const response = await client.updateFile(this.projectId, this.file.name, content); - this.file.content = content; - this.file.lastModified = response.data.lastModified; - } catch (error) { - //the error can't be thrown out to upper layer - this.errorHandler(error); + private throttledUpdate = throttle( + async (content: string) => { + if (this.file) { + try { + const response = await client.updateFile(this.projectId, this.file.name, content); + this.file.content = content; + this.file.lastModified = response.data.lastModified; + } catch (error) { + //the error can't be thrown out to upper layer + this.errorHandler(error); + } } - } - }, 500); + }, + 500, + { leading: true, trailing: true } + ); constructor(projectId: string, file?: FileInfo) { this.projectId = projectId; @@ -31,12 +35,12 @@ export class FileOperation { async updateFile(content: string, errorHandler: FileErrorHandler) { this.errorHandler = errorHandler; - await this.debouncedUpdate(content); + await this.throttledUpdate(content); } async removeFile() { if (this.file) { - this.debouncedUpdate.cancel(); + this.throttledUpdate.cancel(); await client.deleteFile(this.projectId, this.file.name); } } @@ -46,6 +50,6 @@ export class FileOperation { } flush() { - this.debouncedUpdate.flush(); + this.throttledUpdate.flush(); } }