From 4137a2ce2f5f3f5e45e1f5258e1555dba3419d38 Mon Sep 17 00:00:00 2001 From: Long Alan Date: Thu, 24 Sep 2020 11:34:58 +0800 Subject: [PATCH 1/5] chore: extract build logic from components page (#4153) * add build util to package build logic * refactor * test fix * code refactor Co-authored-by: Dong Lei --- .../client/__tests__/utils/buildUtil.test.ts | 93 +++++++++++ .../client/__tests__/utils/luUtil.test.ts | 87 +--------- .../TestController/TestController.tsx | 52 ++---- .../src/recoilModel/dispatchers/builder.ts | 3 +- .../packages/client/src/utils/buildUtil.ts | 156 ++++++++++++++++++ Composer/packages/client/src/utils/luUtil.ts | 129 +-------------- 6 files changed, 266 insertions(+), 254 deletions(-) create mode 100644 Composer/packages/client/__tests__/utils/buildUtil.test.ts create mode 100644 Composer/packages/client/src/utils/buildUtil.ts diff --git a/Composer/packages/client/__tests__/utils/buildUtil.test.ts b/Composer/packages/client/__tests__/utils/buildUtil.test.ts new file mode 100644 index 0000000000..ce3e9fb67d --- /dev/null +++ b/Composer/packages/client/__tests__/utils/buildUtil.test.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogInfo, LuFile } from '@bfc/shared'; + +import { createCrossTrainConfig } from '../../src/utils/buildUtil'; + +describe('createCrossTrainConfig', () => { + it('should create crosstrain config', () => { + const dialogs = [ + { + id: 'main', + luFile: 'main', + isRoot: true, + intentTriggers: [ + { intent: 'dia1_trigger', dialogs: ['dia1'] }, + { intent: 'dia2_trigger', dialogs: ['dia2'] }, + { intent: 'dias_trigger', dialogs: ['dia5', 'dia6'] }, + { intent: 'no_dialog', dialogs: [] }, + { intent: '', dialogs: ['start_dialog_without_intent'] }, + ], + }, + { + id: 'dia1', + luFile: 'dia1', + intentTriggers: [ + { intent: 'dia3_trigger', dialogs: ['dia3'] }, + { intent: 'dia4_trigger', dialogs: ['dia4'] }, + ], + }, + { + id: 'dia2', + luFile: 'dia2', + intentTriggers: [], + }, + { + id: 'dia3', + luFile: 'dia3', + intentTriggers: [], + }, + { + id: 'dia4', + luFile: 'dia4', + intentTriggers: [], + }, + { + id: 'dia5', + luFile: 'dia5', + intentTriggers: [], + }, + { + id: 'dia6', + luFile: 'dia6', + intentTriggers: [], + }, + { + id: 'start_dialog_without_intent', + luFile: 'start_dialog_without_intent', + intentTriggers: [], + }, + { + id: 'dialog_without_lu', + intentTriggers: [], + }, + ]; + const luFiles = [ + { + id: 'main.en-us', + intents: [ + { Name: 'dia1_trigger' }, + { Name: 'dia2_trigger' }, + { Name: 'dias_trigger' }, + { Name: 'no_dialog' }, + { Name: 'dialog_without_lu' }, + ], + }, + { id: 'dia1.en-us', intents: [{ Name: 'dia3_trigger' }, { Name: 'dia4_trigger' }] }, + { id: 'dia2.en-us' }, + { id: 'dia3.en-us' }, + { id: 'dia5.en-us' }, + { id: 'dia6.en-us' }, + ]; + const config = createCrossTrainConfig(dialogs as DialogInfo[], luFiles as LuFile[]); + expect(config.rootIds.length).toEqual(1); + expect(config.rootIds[0]).toEqual('main.en-us.lu'); + expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); + expect(config.triggerRules['main.en-us.lu'].no_dialog).toEqual(''); + expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); + expect(config.triggerRules['main.en-us.lu'].dias_trigger.length).toBe(2); + expect(config.triggerRules['dia1.en-us.lu'].dia3_trigger).toEqual('dia3.en-us.lu'); + expect(config.triggerRules['dia1.en-us.lu']['dia4.en-us.lu']).toBeUndefined(); + }); +}); diff --git a/Composer/packages/client/__tests__/utils/luUtil.test.ts b/Composer/packages/client/__tests__/utils/luUtil.test.ts index 58f27d7175..47301a5e16 100644 --- a/Composer/packages/client/__tests__/utils/luUtil.test.ts +++ b/Composer/packages/client/__tests__/utils/luUtil.test.ts @@ -3,7 +3,7 @@ import { LuFile, DialogInfo, Diagnostic, DiagnosticSeverity } from '@bfc/shared'; -import { getReferredLuFiles, createCrossTrainConfig, checkLuisBuild } from '../../src/utils/luUtil'; +import { getReferredLuFiles, checkLuisBuild } from '../../src/utils/luUtil'; describe('getReferredLuFiles', () => { it('returns referred luFiles from dialog', () => { @@ -13,91 +13,6 @@ describe('getReferredLuFiles', () => { expect(referred.length).toEqual(1); expect(referred[0].id).toEqual('a.en-us'); }); - - it('should create crosstrain config', () => { - const dialogs = [ - { - id: 'main', - luFile: 'main', - isRoot: true, - intentTriggers: [ - { intent: 'dia1_trigger', dialogs: ['dia1'] }, - { intent: 'dia2_trigger', dialogs: ['dia2'] }, - { intent: 'dias_trigger', dialogs: ['dia5', 'dia6'] }, - { intent: 'no_dialog', dialogs: [] }, - { intent: '', dialogs: ['start_dialog_without_intent'] }, - ], - }, - { - id: 'dia1', - luFile: 'dia1', - intentTriggers: [ - { intent: 'dia3_trigger', dialogs: ['dia3'] }, - { intent: 'dia4_trigger', dialogs: ['dia4'] }, - ], - }, - { - id: 'dia2', - luFile: 'dia2', - intentTriggers: [], - }, - { - id: 'dia3', - luFile: 'dia3', - intentTriggers: [], - }, - { - id: 'dia4', - luFile: 'dia4', - intentTriggers: [], - }, - { - id: 'dia5', - luFile: 'dia5', - intentTriggers: [], - }, - { - id: 'dia6', - luFile: 'dia6', - intentTriggers: [], - }, - { - id: 'start_dialog_without_intent', - luFile: 'start_dialog_without_intent', - intentTriggers: [], - }, - { - id: 'dialog_without_lu', - intentTriggers: [], - }, - ]; - const luFiles = [ - { - id: 'main.en-us', - intents: [ - { Name: 'dia1_trigger' }, - { Name: 'dia2_trigger' }, - { Name: 'dias_trigger' }, - { Name: 'no_dialog' }, - { Name: 'dialog_without_lu' }, - ], - }, - { id: 'dia1.en-us', intents: [{ Name: 'dia3_trigger' }, { Name: 'dia4_trigger' }] }, - { id: 'dia2.en-us' }, - { id: 'dia3.en-us' }, - { id: 'dia5.en-us' }, - { id: 'dia6.en-us' }, - ]; - const config = createCrossTrainConfig(dialogs as DialogInfo[], luFiles as LuFile[]); - expect(config.rootIds.length).toEqual(1); - expect(config.rootIds[0]).toEqual('main.en-us.lu'); - expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); - expect(config.triggerRules['main.en-us.lu'].no_dialog).toEqual(''); - expect(config.triggerRules['main.en-us.lu'].dia1_trigger).toEqual('dia1.en-us.lu'); - expect(config.triggerRules['main.en-us.lu'].dias_trigger.length).toBe(2); - expect(config.triggerRules['dia1.en-us.lu'].dia3_trigger).toEqual('dia3.en-us.lu'); - expect(config.triggerRules['dia1.en-us.lu']['dia4.en-us.lu']).toBeUndefined(); - }); }); it('check the lu files before publish', () => { diff --git a/Composer/packages/client/src/components/TestController/TestController.tsx b/Composer/packages/client/src/components/TestController/TestController.tsx index 0c7f4ed829..1866dd6d0f 100644 --- a/Composer/packages/client/src/components/TestController/TestController.tsx +++ b/Composer/packages/client/src/components/TestController/TestController.tsx @@ -22,13 +22,12 @@ import { botLoadErrorState, } from '../../recoilModel'; import settingsStorage from '../../utils/dialogSettingStorage'; -import { QnaConfig, BotStatus, LuisConfig } from '../../constants'; +import { BotStatus } from '../../constants'; import { isAbsHosted } from '../../utils/envUtil'; import useNotifications from '../../pages/notifications/useNotifications'; import { navigateTo, openInEmulator } from '../../utils/navigation'; -import { getReferredQnaFiles } from '../../utils/qnaUtil'; -import { getReferredLuFiles } from './../../utils/luUtil'; +import { isBuildConfigComplete, needsBuild } from './../../utils/buildUtil'; import { PublishDialog } from './publishDialog'; import { ErrorCallout } from './errorCallout'; import { EmulatorOpenButton } from './emulatorOpenButton'; @@ -153,15 +152,14 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { } } - async function handlePublish(config: IPublishConfig) { + async function handleBuild(config: IPublishConfig) { setBotStatus(BotStatus.publishing, projectId); dismissDialog(); const { luis, qna } = config; - const endpointKey = settings.qna?.endpointKey; await setSettings(projectId, { ...settings, luis: luis, - qna: Object.assign({}, settings.qna, qna, { endpointKey }), + qna: Object.assign({}, settings.qna, qna), }); await build(luis, qna, projectId); } @@ -175,48 +173,24 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { await publishToTarget(projectId, defaultPublishConfig, { comment: '' }, sensitiveSettings); } - function isConfigComplete(config) { - let complete = true; - if (getReferredLuFiles(luFiles, dialogs).length > 0) { - if (Object.values(LuisConfig).some((luisConfigKey) => config.luis[luisConfigKey] === '')) { - complete = false; - } - } - if (getReferredQnaFiles(qnaFiles, dialogs).length > 0) { - if (Object.values(QnaConfig).some((qnaConfigKey) => config.qna[qnaConfigKey] === '')) { - complete = false; - } - } - return complete; - } - - // return true if dialogs have one with default recognizer. - function needsPublish(dialogs) { - let isDefaultRecognizer = false; - if (dialogs.some((dialog) => typeof dialog.content.recognizer === 'string')) { - isDefaultRecognizer = true; - } - return isDefaultRecognizer; - } - async function handleStart() { dismissCallout(); const config = Object.assign( {}, { luis: settings.luis, - qna: { - subscriptionKey: settings.qna?.subscriptionKey, - qnaRegion: settings.qna?.qnaRegion, - endpointKey: settings.qna?.endpointKey, - }, + qna: settings.qna, } ); - if (!isAbsHosted() && needsPublish(dialogs)) { - if (botStatus === BotStatus.failed || botStatus === BotStatus.pending || !isConfigComplete(config)) { + if (!isAbsHosted() && needsBuild(dialogs)) { + if ( + botStatus === BotStatus.failed || + botStatus === BotStatus.pending || + !isBuildConfigComplete(config, dialogs, luFiles, qnaFiles) + ) { openDialog(); } else { - await handlePublish(config); + await handleBuild(config); } } else { await handleLoadBot(); @@ -278,7 +252,7 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { isOpen={modalOpen} projectId={projectId} onDismiss={dismissDialog} - onPublish={handlePublish} + onPublish={handleBuild} /> )} diff --git a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts index 4bf6302f6f..81c92cca33 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts @@ -6,6 +6,7 @@ import { useRecoilCallback, CallbackInterface } from 'recoil'; import { ILuisConfig, IQnAConfig } from '@bfc/shared'; import * as luUtil from '../../utils/luUtil'; +import * as buildUtil from '../../utils/buildUtil'; import { Text, BotStatus } from '../../constants'; import httpClient from '../../utils/httpUtil'; import luFileStatusStorage from '../../utils/luFileStatusStorage'; @@ -48,7 +49,7 @@ export const builderDispatcher = () => { } try { //TODO crosstrain should add locale - const crossTrainConfig = luUtil.createCrossTrainConfig(dialogs, referredLuFiles); + const crossTrainConfig = buildUtil.createCrossTrainConfig(dialogs, referredLuFiles); await httpClient.post(`/projects/${projectId}/build`, { luisConfig, qnaConfig, diff --git a/Composer/packages/client/src/utils/buildUtil.ts b/Composer/packages/client/src/utils/buildUtil.ts new file mode 100644 index 0000000000..29958f92e0 --- /dev/null +++ b/Composer/packages/client/src/utils/buildUtil.ts @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DialogInfo, LuFile } from '@bfc/shared'; +import keys from 'lodash/keys'; + +import { LuisConfig, QnaConfig } from '../constants'; + +import { getReferredLuFiles } from './luUtil'; +import { getReferredQnaFiles } from './qnaUtil'; +import { getBaseName, getExtension } from './fileUtil'; + +function createConfigId(fileId) { + return `${fileId}.lu`; +} + +function getLuFilesByDialogId(dialogId: string, luFiles: LuFile[]) { + return luFiles.filter((lu) => getBaseName(lu.id) === dialogId).map((lu) => createConfigId(lu.id)); +} + +function getFileLocale(fileName: string) { + //file name = 'a.en-us.lu' + return getExtension(getBaseName(fileName)); +} +//replace the dialogId with luFile's name +function addLocaleToConfig(config: ICrossTrainConfig, luFiles: LuFile[]) { + const { rootIds, triggerRules } = config; + config.rootIds = rootIds.reduce((result: string[], id: string) => { + return [...result, ...getLuFilesByDialogId(id, luFiles)]; + }, []); + config.triggerRules = keys(triggerRules).reduce((result, key) => { + const fileNames = getLuFilesByDialogId(key, luFiles); + return { + ...result, + ...fileNames.reduce((result, name) => { + const locale = getFileLocale(name); + const triggers = triggerRules[key]; + keys(triggers).forEach((trigger) => { + if (!result[name]) result[name] = {}; + const ids = triggers[trigger]; + if (Array.isArray(ids)) { + result[name][trigger] = ids.map((id) => (id ? `${id}.${locale}.lu` : id)); + } else { + result[name][trigger] = ids ? `${ids}.${locale}.lu` : ids; + } + }); + return result; + }, {}), + }; + }, {}); + return config; +} +interface ICrossTrainConfig { + rootIds: string[]; + triggerRules: { [key: string]: any }; + intentName: string; + verbose: boolean; +} + +//generate the cross-train config without locale +/* the config is like + { + rootIds: [ + 'main.en-us.lu', + 'main.fr-fr.lu' + ], + triggerRules: { + 'main.en-us.lu': { + 'dia1_trigger': 'dia1.en-us.lu', + 'dia2_trigger': 'dia2.en-us.lu' + }, + 'dia2.en-us.lu': { + 'dia3_trigger': 'dia3.en-us.lu', + 'dia4_trigger': 'dia4.en-us.lu' + }, + 'main.fr-fr.lu': { + 'dia1_trigger': 'dia1.fr-fr.lu' + } + }, + intentName: '_Interruption', + verbose: true + } + */ + +export function createCrossTrainConfig(dialogs: DialogInfo[], luFiles: LuFile[]): ICrossTrainConfig { + const triggerRules = {}; + const countMap = {}; + + //map all referred lu files + luFiles.forEach((file) => { + countMap[getBaseName(file.id)] = 1; + }); + + let rootId = ''; + dialogs.forEach((dialog) => { + if (dialog.isRoot) rootId = dialog.id; + const luFile = luFiles.find((luFile) => getBaseName(luFile.id) === dialog.luFile); + if (luFile) { + const fileId = dialog.id; + const { intentTriggers } = dialog; + // filter intenttrigger which be involved in lu file + //find the trigger's dialog that use a recognizer + intentTriggers + .filter((intentTrigger) => luFile.intents.find((intent) => intent.Name === intentTrigger.intent)) + .forEach((item) => { + //find all dialogs in trigger that has a luis recognizer + const used = item.dialogs.filter((dialog) => !!countMap[dialog]); + + const deduped = Array.from(new Set(used)); + + const result = {}; + if (deduped.length === 1) { + result[item.intent] = deduped[0]; + } else if (deduped.length) { + result[item.intent] = deduped; + } else { + result[item.intent] = ''; + } + + triggerRules[fileId] = { ...triggerRules[fileId], ...result }; + }); + } + }); + + const crossTrainConfig: ICrossTrainConfig = { + rootIds: [], + triggerRules: {}, + intentName: '_Interruption', + verbose: true, + }; + crossTrainConfig.rootIds = keys(countMap).filter( + (key) => (countMap[key] === 0 || key === rootId) && triggerRules[key] + ); + crossTrainConfig.triggerRules = triggerRules; + return addLocaleToConfig(crossTrainConfig, luFiles); +} + +export function isBuildConfigComplete(config, dialogs, luFiles, qnaFiles) { + let complete = true; + if (getReferredLuFiles(luFiles, dialogs).length > 0) { + if (Object.values(LuisConfig).some((luisConfigKey) => config.luis[luisConfigKey] === '')) { + complete = false; + } + } + if (getReferredQnaFiles(qnaFiles, dialogs).length > 0) { + if (Object.values(QnaConfig).some((qnaConfigKey) => config.qna[qnaConfigKey] === '')) { + complete = false; + } + } + return complete; +} + +// return true if dialogs have one with default recognizer. +export function needsBuild(dialogs) { + return dialogs.some((dialog) => typeof dialog.content.recognizer === 'string'); +} diff --git a/Composer/packages/client/src/utils/luUtil.ts b/Composer/packages/client/src/utils/luUtil.ts index 376c54a6b0..787171513e 100644 --- a/Composer/packages/client/src/utils/luUtil.ts +++ b/Composer/packages/client/src/utils/luUtil.ts @@ -6,12 +6,11 @@ * it's designed have no state, input text file, output text file. * for more usage detail, please check client/__tests__/utils/luUtil.test.ts */ -import keys from 'lodash/keys'; import { createSingleMessage, BotIndexer } from '@bfc/indexers'; import { LuFile, DialogInfo, DiagnosticSeverity } from '@bfc/shared'; import formatMessage from 'format-message'; -import { getBaseName, getExtension } from './fileUtil'; +import { getBaseName } from './fileUtil'; export * from '@bfc/indexers/lib/utils/luUtil'; @@ -26,132 +25,6 @@ export function getReferredLuFiles(luFiles: LuFile[], dialogs: DialogInfo[], che }); } -function createConfigId(fileId) { - return `${fileId}.lu`; -} - -function getLuFilesByDialogId(dialogId: string, luFiles: LuFile[]) { - return luFiles.filter((lu) => getBaseName(lu.id) === dialogId).map((lu) => createConfigId(lu.id)); -} - -function getFileLocale(fileName: string) { - //file name = 'a.en-us.lu' - return getExtension(getBaseName(fileName)); -} - -//replace the dialogId with luFile's name -function addLocaleToConfig(config: ICrossTrainConfig, luFiles: LuFile[]) { - const { rootIds, triggerRules } = config; - config.rootIds = rootIds.reduce((result: string[], id: string) => { - return [...result, ...getLuFilesByDialogId(id, luFiles)]; - }, []); - config.triggerRules = keys(triggerRules).reduce((result, key) => { - const fileNames = getLuFilesByDialogId(key, luFiles); - return { - ...result, - ...fileNames.reduce((result, name) => { - const locale = getFileLocale(name); - const triggers = triggerRules[key]; - keys(triggers).forEach((trigger) => { - if (!result[name]) result[name] = {}; - const ids = triggers[trigger]; - if (Array.isArray(ids)) { - result[name][trigger] = ids.map((id) => (id ? `${id}.${locale}.lu` : id)); - } else { - result[name][trigger] = ids ? `${ids}.${locale}.lu` : ids; - } - }); - return result; - }, {}), - }; - }, {}); - return config; -} - -interface ICrossTrainConfig { - rootIds: string[]; - triggerRules: { [key: string]: any }; - intentName: string; - verbose: boolean; -} - -//generate the cross-train config without locale -/* the config is like - { - rootIds: [ - 'main.en-us.lu', - 'main.fr-fr.lu' - ], - triggerRules: { - 'main.en-us.lu': { - 'dia1_trigger': 'dia1.en-us.lu', - 'dia2_trigger': 'dia2.en-us.lu' - }, - 'dia2.en-us.lu': { - 'dia3_trigger': 'dia3.en-us.lu', - 'dia4_trigger': 'dia4.en-us.lu' - }, - 'main.fr-fr.lu': { - 'dia1_trigger': 'dia1.fr-fr.lu' - } - }, - intentName: '_Interruption', - verbose: true - } - */ -export function createCrossTrainConfig(dialogs: DialogInfo[], luFiles: LuFile[]): ICrossTrainConfig { - const triggerRules = {}; - const countMap = {}; - - //map all referred lu files - luFiles.forEach((file) => { - countMap[getBaseName(file.id)] = 1; - }); - - let rootId = ''; - dialogs.forEach((dialog) => { - if (dialog.isRoot) rootId = dialog.id; - const luFile = luFiles.find((luFile) => getBaseName(luFile.id) === dialog.luFile); - if (luFile) { - const fileId = dialog.id; - const { intentTriggers } = dialog; - // filter intenttrigger which be involved in lu file - //find the trigger's dialog that use a recognizer - intentTriggers - .filter((intentTrigger) => luFile.intents.find((intent) => intent.Name === intentTrigger.intent)) - .forEach((item) => { - //find all dialogs in trigger that has a luis recognizer - const used = item.dialogs.filter((dialog) => !!countMap[dialog]); - - const deduped = Array.from(new Set(used)); - - const result = {}; - if (deduped.length === 1) { - result[item.intent] = deduped[0]; - } else if (deduped.length) { - result[item.intent] = deduped; - } else { - result[item.intent] = ''; - } - - triggerRules[fileId] = { ...triggerRules[fileId], ...result }; - }); - } - }); - - const crossTrainConfig: ICrossTrainConfig = { - rootIds: [], - triggerRules: {}, - intentName: '_Interruption', - verbose: true, - }; - crossTrainConfig.rootIds = keys(countMap).filter( - (key) => (countMap[key] === 0 || key === rootId) && triggerRules[key] - ); - crossTrainConfig.triggerRules = triggerRules; - return addLocaleToConfig(crossTrainConfig, luFiles); -} - function generateErrorMessage(invalidLuFile: LuFile[]) { return invalidLuFile.reduce((msg, file) => { const fileErrorText = file.diagnostics.reduce((text, diagnostic) => { From ad77082c7eba023cba20386cde31e7cba2b4b12b Mon Sep 17 00:00:00 2001 From: liweitian Date: Thu, 24 Sep 2020 23:28:45 +0800 Subject: [PATCH 2/5] fix: allows spaces in bot project path (#4260) --- Composer/packages/client/src/recoilModel/dispatchers/storage.ts | 2 +- Composer/packages/server/src/controllers/storage.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts index 6991a6b1f7..39a36ec702 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts @@ -76,7 +76,7 @@ export const storageDispatcher = () => { (callbackHelpers: CallbackInterface) => async (id: string, path: string) => { const { set } = callbackHelpers; try { - const response = await httpClient.get(`/storages/${id}/blobs`, { params: { path } }); + const response = await httpClient.get(`/storages/${id}/blobs`, { params: { path: encodeURIComponent(path) } }); const fetchedFocusStorage = response.data; fetchedFocusStorage.children = fetchedFocusStorage.children.reduce((files, file) => { if (file.type === FileTypes.FOLDER) { diff --git a/Composer/packages/server/src/controllers/storage.ts b/Composer/packages/server/src/controllers/storage.ts index ac2dece498..fe3375a597 100644 --- a/Composer/packages/server/src/controllers/storage.ts +++ b/Composer/packages/server/src/controllers/storage.ts @@ -51,7 +51,7 @@ async function getBlob(req: Request, res: Response) { if (!req.query.path) { throw new Error('path missing from query'); } - const reqpath = decodeURI(req.query.path); + const reqpath = decodeURIComponent(req.query.path); if (!Path.isAbsolute(reqpath)) { throw new Error('path must be absolute'); } From a3eaae483881cea5bd9b241f72e7124ffeb9b781 Mon Sep 17 00:00:00 2001 From: LouisEugeneMSFT <66701106+LouisEugeneMSFT@users.noreply.github.com> Date: Thu, 24 Sep 2020 10:31:50 -0700 Subject: [PATCH 3/5] fix: Object examples not properly displayed as placeholders (#4126) * stringifying object examples * adding test * removing format message Co-authored-by: Andy Brown --- .../src/utils/__tests__/uiOptionsHelpers.test.ts | 8 +++++++- .../packages/adaptive-form/src/utils/uiOptionsHelpers.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts b/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts index 5fc2b71b7d..b4eceda30e 100644 --- a/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts +++ b/Composer/packages/adaptive-form/src/utils/__tests__/uiOptionsHelpers.test.ts @@ -3,7 +3,7 @@ import { FieldProps, UIOptions } from '@bfc/extension-client'; -import { getUiLabel, getUiDescription, getUiPlaceholder } from '../uiOptionsHelpers'; +import { getUiDescription, getUiLabel, getUiPlaceholder } from '../uiOptionsHelpers'; let props; @@ -90,4 +90,10 @@ describe('getUiPlaceholder', () => { 'ex. one, two' ); }); + + it('correctly display examples for non string types', () => { + expect( + getUiPlaceholder({ ...props, placeholder: undefined, schema: { examples: [true, 5, { arg1: 'test' }] } }) + ).toEqual('ex. true, 5, {"arg1":"test"}'); + }); }); diff --git a/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts b/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts index 689ebcea7f..c1461c80ab 100644 --- a/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts +++ b/Composer/packages/adaptive-form/src/utils/uiOptionsHelpers.ts @@ -3,7 +3,6 @@ import { FieldProps } from '@bfc/extension-client'; import startCase from 'lodash/startCase'; -import formatMessage from 'format-message'; export function getUiLabel(props: FieldProps): string | false | undefined { const { uiOptions, schema, name, value, label } = props; @@ -47,7 +46,13 @@ export function getUiPlaceholder(props: FieldProps): string | undefined { } else if (placeholder) { fieldUIPlaceholder = placeholder; } else if (schema && Array.isArray(schema.examples) && schema.examples.length > 0) { - fieldUIPlaceholder = formatMessage('ex. { example }', { example: schema.examples.join(', ') }); + const examplesStrings = schema.examples.map((example) => { + if (typeof example === 'object') { + return JSON.stringify(example); + } + return example; + }); + fieldUIPlaceholder = `ex. ${examplesStrings.join(', ')}`; } if (fieldUIPlaceholder && schema.pattern) { From 69dd86d4390ca9951b044d0bc119005afcc585a6 Mon Sep 17 00:00:00 2001 From: leileizhang Date: Fri, 25 Sep 2020 08:40:36 +0800 Subject: [PATCH 4/5] feat: add notification center for composer (QnA url import) (#4080) * feat: add notification center for composer(qna import) * update some style * update the style * update the icon color * fix conflict * remove the timer when error * update the notification create flow * remove the return in dispatcher * use typs instead of interface * use atomFamily to avoid over rendering * update the set Co-authored-by: Dong Lei Co-authored-by: Chris Whitten --- .../AppComponents/MainContainer.tsx | 3 + .../src/components/NotificationCard.tsx | 221 ++++++++++++++++++ .../src/components/NotificationContainer.tsx | 36 +++ .../__tests__/NotificationCard.test.tsx | 58 +++++ .../client/src/pages/design/DesignPage.tsx | 3 +- .../client/src/recoilModel/atoms/appState.ts | 15 +- .../src/recoilModel/dispatchers/index.ts | 2 + .../recoilModel/dispatchers/notification.ts | 42 ++++ .../client/src/recoilModel/dispatchers/qna.ts | 35 ++- .../selectors/notificationsSelector.ts | 15 ++ .../packages/client/src/recoilModel/types.ts | 4 + .../client/src/utils/notifications.ts | 33 +++ Composer/packages/client/src/utils/timer.ts | 36 +++ 13 files changed, 491 insertions(+), 12 deletions(-) create mode 100644 Composer/packages/client/src/components/NotificationCard.tsx create mode 100644 Composer/packages/client/src/components/NotificationContainer.tsx create mode 100644 Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/notification.ts create mode 100644 Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts create mode 100644 Composer/packages/client/src/utils/notifications.ts create mode 100644 Composer/packages/client/src/utils/timer.ts diff --git a/Composer/packages/client/src/components/AppComponents/MainContainer.tsx b/Composer/packages/client/src/components/AppComponents/MainContainer.tsx index 5b37ffc206..f21dc0c4f2 100644 --- a/Composer/packages/client/src/components/AppComponents/MainContainer.tsx +++ b/Composer/packages/client/src/components/AppComponents/MainContainer.tsx @@ -3,6 +3,8 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; +import { NotificationContainer } from '../NotificationContainer'; + import { SideBar } from './SideBar'; import { RightPanel } from './RightPanel'; import { Assistant } from './Assistant'; @@ -18,6 +20,7 @@ export const MainContainer = () => { + ); }; diff --git a/Composer/packages/client/src/components/NotificationCard.tsx b/Composer/packages/client/src/components/NotificationCard.tsx new file mode 100644 index 0000000000..d865f96301 --- /dev/null +++ b/Composer/packages/client/src/components/NotificationCard.tsx @@ -0,0 +1,221 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css, keyframes } from '@emotion/core'; +import React from 'react'; +import { IconButton, ActionButton } from 'office-ui-fabric-react/lib/Button'; +import { useEffect, useRef, useState } from 'react'; +import { FontSizes } from '@uifabric/fluent-theme'; +import { Shimmer, ShimmerElementType } from 'office-ui-fabric-react/lib/Shimmer'; +import { Icon } from 'office-ui-fabric-react/lib/Icon'; +import formatMessage from 'format-message'; + +import Timer from '../utils/timer'; + +// -------------------- Styles -------------------- // + +const fadeIn = keyframes` + from { opacity: 0; transform: translate3d(40px,0,0) } + to { opacity: 1; translate3d(0,0,0) } +`; + +const fadeOut = (height: number) => keyframes` + from { opacity: 1; height: ${height}px} + to { opacity: 0; height:0} +`; + +const cardContainer = (show: boolean, ref?: HTMLDivElement | null) => () => { + let height = 100; + if (ref) { + height = ref.clientHeight; + } + + return css` + border-left: 4px solid #0078d4; + background: white; + box-shadow: 0 6.4px 14.4px 0 rgba(0, 0, 0, 0.132), 0 1.2px 3.6px 0 rgba(0, 0, 0, 0.108); + width: 340px; + border-radius: 2px; + display: flex; + flex-direction: column; + margin-bottom: 8px; + animation-duration: ${show ? '0.467' : '0.2'}s; + animation-timing-function: ${show ? 'cubic-bezier(0.1, 0.9, 0.2, 1)' : 'linear'}; + animation-fill-mode: both; + animation-name: ${show ? fadeIn : fadeOut(height)}; + `; +}; + +const cancelButton = css` + float: right; + color: #605e5c; + margin-left: auto; + width: 24px; + height: 24px; +`; + +const cardContent = css` + display: flex; + padding: 0 8px 16px 12px; + min-height: 64px; +`; + +const cardDetail = css` + margin-left: 8px; + flex-grow: 1; +`; + +const errorType = css` + margin-top: 4px; + color: #a80000; +`; + +const successType = css` + margin-top: 4px; + color: #27ae60; +`; + +const cardTitle = css` + font-size: ${FontSizes.size16}; + lint-height: 22px; + margin-right: 16px; +`; + +const cardDescription = css` + text-size-adjust: none; + font-size: ${FontSizes.size10}; + margin-top: 8px; + margin-right: 16px; + word-break: break-word; +`; + +const linkButton = css` + color: #0078d4; + float: right; + font-size: 12px; + height: auto; + margin-right: 8px; +`; + +const getShimmerStyles = { + root: { + marginTop: '12px', + marginBottom: '8px', + }, + shimmerWrapper: [ + { + backgroundColor: '#EDEBE9', + }, + ], + shimmerGradient: [ + { + backgroundImage: 'radial-gradient(at 50% 50%, #0078D4 0%, #EDEBE9 100%);', + }, + ], +}; +// -------------------- NotificationCard -------------------- // + +export type NotificationType = 'info' | 'warning' | 'error' | 'pending' | 'success'; + +export type Link = { + label: string; + onClick: () => void; +}; + +export type CardProps = { + type: NotificationType; + title: string; + description?: string; + retentionTime?: number; + link?: Link; + onRenderCardContent?: (props: CardProps) => JSX.Element; +}; + +export type NotificationProps = { + id: string; + cardProps: CardProps; + onDismiss: (id: string) => void; +}; + +const defaultCardContentRenderer = (props: CardProps) => { + const { title, description, type, link } = props; + return ( +
+ {type === 'error' && } + {type === 'success' && } +
+
{title}
+ {description &&
{description}
} + {link && ( + + {link.label} + + )} + {type === 'pending' && ( + + )} +
+
+ ); +}; + +export const NotificationCard = React.memo((props: NotificationProps) => { + const { cardProps, id, onDismiss } = props; + const [show, setShow] = useState(true); + const containerRef = useRef(null); + + const removeNotification = () => { + setShow(false); + }; + + // notification will disappear in 5 secs + const timer = useRef(cardProps.retentionTime ? new Timer(removeNotification, cardProps.retentionTime) : null).current; + + useEffect(() => { + return () => { + if (timer) { + timer.clear(); + } + }; + }, []); + + const handleMouseOver = () => { + // if mouse over stop the time and record the remaining time + if (timer) { + timer.pause(); + } + }; + + const handleMouseLeave = () => { + if (timer) { + timer.resume(); + } + }; + + const handleAnimationEnd = () => { + if (!show) onDismiss(id); + }; + + const renderCard = cardProps.onRenderCardContent || defaultCardContentRenderer; + + return ( +
void 0} + onMouseLeave={handleMouseLeave} + onMouseOver={handleMouseOver} + > + + {renderCard(cardProps)} +
+ ); +}); diff --git a/Composer/packages/client/src/components/NotificationContainer.tsx b/Composer/packages/client/src/components/NotificationContainer.tsx new file mode 100644 index 0000000000..afdc87b5a2 --- /dev/null +++ b/Composer/packages/client/src/components/NotificationContainer.tsx @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import { useRecoilValue } from 'recoil'; +import React from 'react'; + +import { dispatcherState } from '../recoilModel'; +import { notificationsSelector } from '../recoilModel/selectors/notificationsSelector'; + +import { NotificationCard } from './NotificationCard'; + +// -------------------- Styles -------------------- // + +const container = css` + cursor: default; + position: absolute; + right: 0px; + padding: 6px; +`; + +// -------------------- NotificationContainer -------------------- // + +export const NotificationContainer = React.memo(() => { + const notifications = useRecoilValue(notificationsSelector); + const { deleteNotification } = useRecoilValue(dispatcherState); + + return ( +
+ {notifications.map((item) => { + return ; + })} +
+ ); +}); diff --git a/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx b/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx new file mode 100644 index 0000000000..fdff6b95e2 --- /dev/null +++ b/Composer/packages/client/src/components/__tests__/NotificationCard.test.tsx @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as React from 'react'; + +import { renderWithRecoil } from '../../../__tests__/testUtils/renderWithRecoil'; +import { NotificationCard, CardProps } from '../NotificationCard'; +import Timer from '../../utils/timer'; + +jest.useFakeTimers(); + +describe('', () => { + it('should render the NotificationCard', () => { + const cardProps: CardProps = { + title: 'There was error creating your KB', + description: 'error', + retentionTime: 1, + type: 'error', + }; + const onDismiss = jest.fn(); + const { container } = renderWithRecoil(); + + expect(container).toHaveTextContent('There was error creating your KB'); + }); + + it('should render the customized card', () => { + const cardProps: CardProps = { + title: 'There was error creating your KB', + description: 'error', + retentionTime: 5000, + type: 'error', + onRenderCardContent: () =>
customized
, + }; + const onDismiss = jest.fn(); + const { container } = renderWithRecoil(); + + expect(container).toHaveTextContent('customized'); + }); +}); + +describe('Notification Time Management', () => { + it('should invoke callback', () => { + const callback = jest.fn(); + new Timer(callback, 0); + expect(callback).not.toBeCalled(); + jest.runAllTimers(); + expect(callback).toHaveBeenCalled(); + }); + + it('should pause and resume', () => { + const callback = jest.fn(); + const timer = new Timer(callback, 1); + timer.pause(); + expect(timer.pausing).toBeTruthy(); + timer.resume(); + expect(timer.pausing).toBeFalsy(); + }); +}); diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 15e51eb8a0..0bd8663c22 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -555,8 +555,7 @@ const DesignPage: React.FC 0) { await importQnAFromUrls({ id: `${dialogId}.${locale}`, urls, projectId }); diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index 3acebf35ef..ffa97d2f58 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { atom } from 'recoil'; +import { atom, atomFamily } from 'recoil'; import { ProjectTemplate, UserSettings } from '@bfc/shared'; import { @@ -10,6 +10,7 @@ import { RuntimeTemplate, AppUpdateState, BoilerplateVersion, + Notification, ExtensionConfig, } from '../../recoilModel/types'; import { getUserSettings } from '../utils'; @@ -152,6 +153,18 @@ export const boilerplateVersionState = atom({ }, }); +export const notificationIdsState = atom({ + key: getFullyQualifiedKey('notificationIds'), + default: [], +}); + +export const notificationsState = atomFamily({ + key: getFullyQualifiedKey('notification'), + default: (id: string): Notification => { + return { id, type: 'info', title: '' }; + }, +}); + export const extensionsState = atom({ key: getFullyQualifiedKey('extensions'), default: [], diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index 983b536f4e..edbae77387 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -18,6 +18,7 @@ import { settingsDispatcher } from './setting'; import { skillDispatcher } from './skill'; import { userDispatcher } from './user'; import { multilangDispatcher } from './multilang'; +import { notificationDispatcher } from './notification'; import { extensionsDispatcher } from './extensions'; const createDispatchers = () => { @@ -39,6 +40,7 @@ const createDispatchers = () => { ...skillDispatcher(), ...userDispatcher(), ...multilangDispatcher(), + ...notificationDispatcher(), ...extensionsDispatcher(), }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/notification.ts b/Composer/packages/client/src/recoilModel/dispatchers/notification.ts new file mode 100644 index 0000000000..7579d19c77 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/notification.ts @@ -0,0 +1,42 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CallbackInterface, useRecoilCallback } from 'recoil'; +import { v4 as uuid } from 'uuid'; + +import { notificationsState, notificationIdsState } from '../atoms/appState'; +import { CardProps } from '../../components/NotificationCard'; +import { Notification } from '../../recoilModel/types'; + +export const createNotifiction = (notificationCard: CardProps): Notification => { + const id = uuid(6) + ''; + return { id, ...notificationCard }; +}; + +export const addNotificationInternal = ({ set }: CallbackInterface, notification: Notification) => { + set(notificationsState(notification.id), notification); + set(notificationIdsState, (ids) => [...ids, notification.id]); +}; + +export const deleteNotificationInternal = ({ reset, set }: CallbackInterface, id: string) => { + reset(notificationsState(id)); + set(notificationIdsState, (notifications) => { + return notifications.filter((notification) => notification !== id); + }); +}; + +export const notificationDispatcher = () => { + const addNotification = useRecoilCallback((callbackHelper: CallbackInterface) => (notification: Notification) => { + return addNotificationInternal(callbackHelper, notification); + }); + + const deleteNotification = useRecoilCallback((callbackHelper: CallbackInterface) => (id: string) => { + deleteNotificationInternal(callbackHelper, id); + }); + + return { + addNotification, + deleteNotification, + }; +}; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts index aac98d04f8..adbbdedfff 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts @@ -5,13 +5,18 @@ import { QnAFile } from '@bfc/shared'; import { useRecoilCallback, CallbackInterface } from 'recoil'; import qnaWorker from '../parsers/qnaWorker'; -import { qnaFilesState, qnaAllUpViewStatusState, localeState, settingsState } from '../atoms/botState'; -import { QnAAllUpViewStatus } from '../types'; +import { qnaFilesState, localeState, settingsState } from '../atoms/botState'; import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage'; import { getBaseName } from '../../utils/fileUtil'; +import { navigateTo } from '../../utils/navigation'; +import { + getQnaFailedNotification, + getQnaSuccessNotification, + getQnaPendingNotification, +} from '../../utils/notifications'; +import httpClient from '../../utils/httpUtil'; -import httpClient from './../../utils/httpUtil'; -import { setError } from './shared'; +import { addNotificationInternal, deleteNotificationInternal, createNotifiction } from './notification'; export const updateQnAFileState = async ( callbackHelpers: CallbackInterface, @@ -114,10 +119,13 @@ export const qnaDispatcher = () => { urls: string[]; projectId: string; }) => { - const { set, snapshot } = callbackHelpers; + const { snapshot } = callbackHelpers; const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); const qnaFile = qnaFiles.find((f) => f.id === id); - set(qnaAllUpViewStatusState(projectId), QnAAllUpViewStatus.Loading); + + const notification = createNotifiction(getQnaPendingNotification(urls)); + addNotificationInternal(callbackHelpers, notification); + try { const response = await httpClient.get(`/utilities/qna/parse`, { params: { urls: encodeURIComponent(urls.join(',')) }, @@ -125,11 +133,20 @@ export const qnaDispatcher = () => { const content = qnaFile ? qnaFile.content + '\n' + response.data : response.data; await updateQnAFileState(callbackHelpers, { id, content, projectId }); - set(qnaAllUpViewStatusState(projectId), QnAAllUpViewStatus.Success); + const notification = createNotifiction( + getQnaSuccessNotification(() => { + navigateTo(`/bot/${projectId}/knowledge-base/${getBaseName(id)}`); + deleteNotificationInternal(callbackHelpers, notification.id); + }) + ); + addNotificationInternal(callbackHelpers, notification); } catch (err) { - setError(callbackHelpers, err); + addNotificationInternal( + callbackHelpers, + createNotifiction(getQnaFailedNotification(err.response?.data?.message)) + ); } finally { - set(qnaAllUpViewStatusState(projectId), QnAAllUpViewStatus.Success); + deleteNotificationInternal(callbackHelpers, notification.id); } } ); diff --git a/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts b/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts new file mode 100644 index 0000000000..a9a902e5ee --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { selector } from 'recoil'; + +import { notificationIdsState, notificationsState } from '../atoms/appState'; + +export const notificationsSelector = selector({ + key: 'notificationsSelector', + get: ({ get }) => { + const ids = get(notificationIdsState); + const notifications = ids.map((id) => get(notificationsState(id))); + return notifications; + }, +}); diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index 38c3748f73..9053bac96f 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -5,6 +5,8 @@ import { AppUpdaterSettings, CodeEditorSettings, PromptTab } from '@bfc/shared'; import { AppUpdaterStatus } from '../constants'; +import { CardProps } from './../components/NotificationCard'; + export interface StateError { status?: number; summary: string; @@ -127,3 +129,5 @@ export enum QnAAllUpViewStatus { Success, Failed, } + +export type Notification = CardProps & { id: string }; diff --git a/Composer/packages/client/src/utils/notifications.ts b/Composer/packages/client/src/utils/notifications.ts new file mode 100644 index 0000000000..5ea1b4dc64 --- /dev/null +++ b/Composer/packages/client/src/utils/notifications.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import formatMessage from 'format-message'; + +import { CardProps } from './../components/NotificationCard'; + +export const getQnaPendingNotification = (urls: string[]): CardProps => { + return { + title: formatMessage('Creating your knowledge base'), + description: formatMessage('Extracting QNA pairs from {urls}', { urls: urls.join(' ') }), + type: 'pending', + }; +}; + +export const getQnaSuccessNotification = (callback: () => void): CardProps => { + return { + title: formatMessage('Your knowledge base Surface go FAQ is ready!'), + type: 'success', + retentionTime: 5000, + link: { + label: formatMessage('View KB'), + onClick: callback, + }, + }; +}; + +export const getQnaFailedNotification = (error: string): CardProps => { + return { + title: formatMessage('There was error creating your KB'), + description: error, + type: 'error', + }; +}; diff --git a/Composer/packages/client/src/utils/timer.ts b/Composer/packages/client/src/utils/timer.ts new file mode 100644 index 0000000000..b0077b3344 --- /dev/null +++ b/Composer/packages/client/src/utils/timer.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export default class Timer { + timerId: NodeJS.Timeout; + start: number; + remaining: number; + pausing = false; + callback: () => void; + + constructor(callback: () => void, delay: number) { + this.remaining = delay; + this.callback = callback; + this.start = Date.now(); + this.timerId = setTimeout(callback, this.remaining); + } + + pause() { + if (!this.pausing) { + clearTimeout(this.timerId); + this.remaining -= Date.now() - this.start; + this.pausing = true; + } + } + + resume() { + this.pausing = false; + this.start = Date.now(); + clearTimeout(this.timerId); + this.timerId = setTimeout(this.callback, this.remaining); + } + + clear() { + clearTimeout(this.timerId); + } +} From 1143ea03f2307d36088015ee7ca1e8fe498206c6 Mon Sep 17 00:00:00 2001 From: Zhixiang Zhan Date: Fri, 25 Sep 2020 23:27:41 +0800 Subject: [PATCH 5/5] fix: Monaco editor links opened in blank window in electron (#4269) * disable ctrl + click open window in electron * do not use hasOwnProperty * cast window to any Co-authored-by: Andy Brown --- Composer/packages/lib/code-editor/src/BaseEditor.tsx | 3 +++ Composer/packages/lib/code-editor/src/utils/common.ts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/Composer/packages/lib/code-editor/src/BaseEditor.tsx b/Composer/packages/lib/code-editor/src/BaseEditor.tsx index 8b7d38af3f..8de3f7f393 100644 --- a/Composer/packages/lib/code-editor/src/BaseEditor.tsx +++ b/Composer/packages/lib/code-editor/src/BaseEditor.tsx @@ -13,6 +13,8 @@ import { Diagnostic } from '@bfc/shared'; import { findErrors, combineSimpleMessage, findWarnings } from '@bfc/indexers'; import { CodeEditorSettings, assignDefined } from '@bfc/shared'; +import { isElectron } from './utils'; + const defaultOptions = { scrollBeyondLastLine: false, wordWrap: 'off', @@ -32,6 +34,7 @@ const defaultOptions = { renderLineHighlight: 'none', formatOnType: true, fixedOverflowWidgets: true, + links: isElectron() ? false : true, // disable in electron@8.2.4 before monaco editor can set target '_blank' }; const styles = { diff --git a/Composer/packages/lib/code-editor/src/utils/common.ts b/Composer/packages/lib/code-editor/src/utils/common.ts index 3e5b731b3c..82ac572bec 100644 --- a/Composer/packages/lib/code-editor/src/utils/common.ts +++ b/Composer/packages/lib/code-editor/src/utils/common.ts @@ -4,3 +4,7 @@ export function processSize(size) { return !/^\d+$/.test(size) ? size : `${size}px`; } + +export function isElectron(): boolean { + return !(window as any).__IS_ELECTRON__; +}