diff --git a/Composer/packages/adaptive-form/package.json b/Composer/packages/adaptive-form/package.json index 541e9151de..69fe0acae0 100644 --- a/Composer/packages/adaptive-form/package.json +++ b/Composer/packages/adaptive-form/package.json @@ -18,9 +18,6 @@ }, "license": "MIT", "peerDependencies": { - "@bfc/code-editor": "*", - "@bfc/extension-client": "*", - "@bfc/intellisense": "*", "@uifabric/fluent-theme": "^7.1.4", "@uifabric/icons": "^7.3.0", "@uifabric/styling": "^7.7.4", @@ -30,20 +27,17 @@ "react-dom": "16.13.1" }, "devDependencies": { - "@bfc/code-editor": "*", - "@bfc/extension-client": "*", - "@bfc/intellisense": "*", "@botframework-composer/test-utils": "*", "@types/lodash": "^4.14.149", - "@types/react": "16.9.23", - "format-message": "^6.2.3", - "react": "16.13.1", - "react-dom": "16.13.1" + "@types/react": "16.9.23" }, "dependencies": { + "@bfc/built-in-functions": "*", + "@bfc/code-editor": "*", + "@bfc/extension-client": "*", + "@bfc/intellisense": "*", "@emotion/core": "^10.0.27", "lodash": "^4.17.19", - "react-error-boundary": "^1.2.5", - "@bfc/built-in-functions": "*" + "react-error-boundary": "^1.2.5" } } diff --git a/Composer/packages/adaptive-form/src/components/expressions/ExpressionFieldToolbar.tsx b/Composer/packages/adaptive-form/src/components/expressions/ExpressionFieldToolbar.tsx new file mode 100644 index 0000000000..3343a24702 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/expressions/ExpressionFieldToolbar.tsx @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { DirectionalHint } from 'office-ui-fabric-react/lib/ContextualMenu'; +import React from 'react'; +import { Callout } from 'office-ui-fabric-react/lib/Callout'; +import { FieldToolbar } from '@bfc/code-editor'; +import { useShellApi } from '@bfc/extension-client'; + +const inputs = ['input', 'textarea']; + +type Props = { + container: HTMLDivElement | null; + target: HTMLInputElement | HTMLTextAreaElement | null; + value?: string; + onChange: (expression: string) => void; + onClearTarget: () => void; +}; + +const jsFieldToolbarMenuClassName = 'js-field-toolbar-menu'; + +export const ExpressionFieldToolbar = (props: Props) => { + const { onClearTarget, container, target, value = '', onChange } = props; + const { projectId, shellApi } = useShellApi(); + + const [memoryVariables, setMemoryVariables] = React.useState(); + + React.useEffect(() => { + const abortController = new AbortController(); + (async () => { + try { + const variables = await shellApi.getMemoryVariables(projectId, { signal: abortController.signal }); + setMemoryVariables(variables); + } catch (e) { + // error can be due to abort + } + })(); + }, [projectId]); + + React.useEffect(() => { + const keyDownHandler = (e: KeyboardEvent) => { + if ( + e.key === 'Escape' && + (!document.activeElement || inputs.includes(document.activeElement.tagName.toLowerCase())) + ) { + onClearTarget(); + } + }; + + const focusHandler = (e: FocusEvent) => { + if (container?.contains(e.target as Node)) { + return; + } + + if ( + !e + .composedPath() + .filter((n) => n instanceof Element) + .map((n) => (n as Element).className) + .some((c) => c.indexOf(jsFieldToolbarMenuClassName) !== -1) + ) { + onClearTarget(); + } + }; + + document.addEventListener('focusin', focusHandler); + document.addEventListener('keydown', keyDownHandler); + + return () => { + document.removeEventListener('focusin', focusHandler); + document.removeEventListener('keydown', keyDownHandler); + }; + }, [container, onClearTarget]); + + const onSelectToolbarMenuItem = React.useCallback( + (text: string) => { + if (typeof target?.selectionStart === 'number') { + const start = target.selectionStart; + const end = typeof target?.selectionEnd === 'number' ? target.selectionEnd : target.selectionStart; + + const updatedItem = [value.slice(0, start), text, value.slice(end)].join(''); + onChange(updatedItem); + + setTimeout(() => { + target.setSelectionRange(updatedItem.length, updatedItem.length); + }, 0); + } + + target?.focus(); + }, + [target, value, onChange] + ); + return target ? ( + + + + ) : null; +}; diff --git a/Composer/packages/adaptive-form/src/components/expressions/ExpressionsListMenu.tsx b/Composer/packages/adaptive-form/src/components/expressions/ExpressionsListMenu.tsx deleted file mode 100644 index 51efc6e68c..0000000000 --- a/Composer/packages/adaptive-form/src/components/expressions/ExpressionsListMenu.tsx +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { ContextualMenu, DirectionalHint } from 'office-ui-fabric-react/lib/ContextualMenu'; -import React, { useCallback, useMemo } from 'react'; -import { builtInFunctionsGrouping, getBuiltInFunctionInsertText } from '@bfc/built-in-functions'; - -import { expressionGroupingsToMenuItems } from './utils/expressionsListMenuUtils'; - -const componentMaxHeight = 400; - -type ExpressionsListMenuProps = { - onExpressionSelected: (expression: string) => void; - onMenuMount: (menuContainerElms: HTMLDivElement[]) => void; -}; -export const ExpressionsListMenu = (props: ExpressionsListMenuProps) => { - const { onExpressionSelected, onMenuMount } = props; - - const containerRef = React.createRef(); - - const onExpressionKeySelected = useCallback( - (key) => { - const insertText = getBuiltInFunctionInsertText(key); - onExpressionSelected('= ' + insertText); - }, - [onExpressionSelected] - ); - - const onLayerMounted = useCallback(() => { - const elms = document.querySelectorAll('.ms-ContextualMenu-Callout'); - onMenuMount(Array.prototype.slice.call(elms)); - }, [onMenuMount]); - - const menuItems = useMemo( - () => - expressionGroupingsToMenuItems( - builtInFunctionsGrouping, - onExpressionKeySelected, - onLayerMounted, - componentMaxHeight - ), - [onExpressionKeySelected, onLayerMounted] - ); - - return ( -
-
- ); -}; diff --git a/Composer/packages/adaptive-form/src/components/fields/IntellisenseFields.tsx b/Composer/packages/adaptive-form/src/components/fields/IntellisenseFields.tsx index fafbf7fbbb..4dddf478db 100644 --- a/Composer/packages/adaptive-form/src/components/fields/IntellisenseFields.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/IntellisenseFields.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState } from 'react'; import { getIntellisenseUrl } from '../../utils/getIntellisenseUrl'; import { ExpressionSwitchWindow } from '../expressions/ExpressionSwitchWindow'; -import { ExpressionsListMenu } from '../expressions/ExpressionsListMenu'; +import { ExpressionFieldToolbar } from '../expressions/ExpressionFieldToolbar'; import { JsonField } from './JsonField'; import { NumberField } from './NumberField'; @@ -68,23 +68,25 @@ export const IntellisenseExpressionField: React.FC> = (props) const scopes = ['expressions', 'user-variables']; const intellisenseServerUrlRef = useRef(getIntellisenseUrl()); - const [expressionsListContainerElements, setExpressionsListContainerElements] = useState([]); + const [containerElm, setContainerElm] = useState(null); + const [toolbarTargetElm, setToolbarTargetElm] = useState(null); + + const focus = React.useCallback( + (id: string, value?: string, event?: React.FocusEvent) => { + if (event?.target) { + event.stopPropagation(); + setToolbarTargetElm(event.target as HTMLInputElement | HTMLTextAreaElement); + } + }, + [] + ); - const completionListOverrideResolver = (value: string) => { - return value === '=' ? ( - onChange(expression)} - onMenuMount={(refs) => { - setExpressionsListContainerElements(refs); - }} - /> - ) : null; - }; + const onClearTarget = React.useCallback(() => { + setToolbarTargetElm(null); + }, []); return ( > = (props) onKeyUpTextField, onClickTextField, }) => ( - onValueChanged(newValue || '')} - onClick={onClickTextField} - onKeyDown={onKeyDownTextField} - onKeyUp={onKeyUpTextField} - /> +
+ onValueChanged(newValue || '')} + onClick={onClickTextField} + onFocus={focus} + onKeyDown={onKeyDownTextField} + onKeyUp={onKeyUpTextField} + /> + +
)}
); diff --git a/Composer/packages/adaptive-form/src/components/fields/StringField.tsx b/Composer/packages/adaptive-form/src/components/fields/StringField.tsx index 444604b4ce..34263050ee 100644 --- a/Composer/packages/adaptive-form/src/components/fields/StringField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/StringField.tsx @@ -61,7 +61,7 @@ export const StringField: React.FC> = function StringField(pr const handleFocus = (e: React.FocusEvent) => { if (typeof onFocus === 'function') { e.stopPropagation(); - onFocus(id, value); + onFocus(id, value, e); } }; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx index abaa31ae2e..b90e1e59c7 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/StringField.test.tsx @@ -34,7 +34,7 @@ describe('', () => { const input = getByLabelText('a label'); fireEvent.focus(input); - expect(onFocus).toHaveBeenCalledWith('string field', 'string value'); + expect(onFocus).toHaveBeenCalledWith('string field', 'string value', expect.any(Object)); fireEvent.blur(input); expect(onBlur).toHaveBeenCalledWith('string field', 'string value'); diff --git a/Composer/packages/client/__tests__/pages/botProjectsSettings/RootBotExternalService.test.tsx b/Composer/packages/client/__tests__/pages/botProjectsSettings/RootBotExternalService.test.tsx index acee253a04..a2986a5986 100644 --- a/Composer/packages/client/__tests__/pages/botProjectsSettings/RootBotExternalService.test.tsx +++ b/Composer/packages/client/__tests__/pages/botProjectsSettings/RootBotExternalService.test.tsx @@ -13,7 +13,7 @@ import { projectMetaDataState, botProjectIdsState, dialogState, - luFilesState, + luFilesSelectorFamily, } from '../../../src/recoilModel'; const state = { @@ -63,7 +63,7 @@ describe('Root Bot External Service', () => { set(dialogState({ projectId: state.projectId, dialogId: state.dialogs[0].id }), state.dialogs[0]); set(dialogState({ projectId: state.projectId, dialogId: state.dialogs[1].id }), state.dialogs[1]); set(botProjectIdsState, state.botProjectIdsState); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(projectMetaDataState(state.projectId), state.projectMetaDataState); set(settingsState(state.projectId), state.settings); set(dispatcherState, { diff --git a/Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx b/Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx index 302aaf7ae0..a8d24a5c0d 100644 --- a/Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx +++ b/Composer/packages/client/__tests__/pages/design/DebugPanel/diagnosticList.test.tsx @@ -14,7 +14,7 @@ import { formDialogSchemaIdsState, jsonSchemaFilesState, lgFilesSelectorFamily, - luFilesState, + luFilesSelectorFamily, schemasState, settingsState, } from '../../../../src/recoilModel'; @@ -110,7 +110,7 @@ describe('', () => { set(currentProjectIdState, state.projectId); set(botProjectIdsState, [state.projectId]); set(dialogIdsState(state.projectId), []); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(lgFilesSelectorFamily(state.projectId), state.lgFiles); set(jsonSchemaFilesState(state.projectId), state.jsonSchemaFiles); set(botDiagnosticsState(state.projectId), state.diagnostics); diff --git a/Composer/packages/client/__tests__/pages/design/useAssetsParsingState.test.tsx b/Composer/packages/client/__tests__/pages/design/useAssetsParsingState.test.tsx new file mode 100644 index 0000000000..20af6191bf --- /dev/null +++ b/Composer/packages/client/__tests__/pages/design/useAssetsParsingState.test.tsx @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { renderHook } from '@botframework-composer/test-utils/lib/hooks'; +import * as React from 'react'; +import { useSetRecoilState } from 'recoil'; +import { RecoilRoot } from 'recoil'; +import { act } from '@botframework-composer/test-utils/lib/hooks'; + +import { useAssetsParsingState } from '../../../src/pages/design/useAssetsParsingState'; +import { + currentProjectIdState, + designPageLocationState, + lgFileState, + localeState, + luFileState, + qnaFileState, +} from '../../../src/recoilModel'; + +const state = { + luFiles: [ + { + id: 'test.en-us', + isContentUnparsed: false, + }, + ], + lgFiles: [ + { + id: 'test.en-us', + isContentUnparsed: false, + }, + ], + qnaFiles: [ + { + id: 'test.en-us', + isContentUnparsed: false, + }, + ], + focusPath: '', + locale: 'en-us', + projectId: 'test', +}; + +const useRecoilTestHook = (projectId: string) => { + const setLuFile = useSetRecoilState(luFileState({ projectId, luFileId: 'test.en-us' })); + const isParsing = useAssetsParsingState(state.projectId); + + return { setLuFile, isParsing }; +}; + +describe('useAssetsParsingState', () => { + let result; + beforeEach(() => { + const initRecoilState = ({ set }) => { + set(designPageLocationState(state.projectId), { dialogId: 'test' }); + set(currentProjectIdState, state.projectId); + set(localeState(state.projectId), 'en-us'); + set(luFileState({ projectId: state.projectId, luFileId: 'test.en-us' }), state.luFiles); + set(lgFileState({ projectId: state.projectId, lgFileId: 'test.en-us' }), state.luFiles); + set(qnaFileState({ projectId: state.projectId, qnaFileId: 'test.en-us' }), state.luFiles); + }; + + const wrapper = (props: { children?: React.ReactNode }) => { + const { children } = props; + return {children}; + }; + + const rendered = renderHook(() => useRecoilTestHook(state.projectId), { + wrapper, + }); + result = rendered.result; + }); + + it('should be true if the file content is unparsed', async () => { + const { setLuFile } = result.current; + expect(result.current.isParsing).toBeFalsy(); + act(() => { + setLuFile({ + id: 'test.en-us', + isContentUnparsed: true, + }); + }); + expect(result.current.isParsing).toBeTruthy(); + act(() => { + setLuFile({ + id: 'test.en-us', + isContentUnparsed: false, + }); + }); + expect(result.current.isParsing).toBeFalsy(); + }); +}); diff --git a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx index 98edc46d0c..ef5d85281e 100644 --- a/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/knowledge-base/QnAPage.test.tsx @@ -10,7 +10,7 @@ import { renderWithRecoil } from '../../testUtils'; import { localeState, dialogsSelectorFamily, - qnaFilesState, + qnaFilesSelectorFamily, settingsState, schemasState, dispatcherState, @@ -59,7 +59,7 @@ const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(localeState(state.projectId), state.locale); set(dialogsSelectorFamily(state.projectId), state.dialogs); - set(qnaFilesState(state.projectId), state.qnaFiles); + set(qnaFilesSelectorFamily(state.projectId), state.qnaFiles); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); set(dispatcherState, { diff --git a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx index 3e1bf307c3..909950eb8b 100644 --- a/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx +++ b/Composer/packages/client/__tests__/pages/language-understanding/LUPage.test.tsx @@ -10,7 +10,7 @@ import { renderWithRecoil } from '../../testUtils'; import { localeState, dialogsSelectorFamily, - luFilesState, + luFilesSelectorFamily, settingsState, schemasState, currentProjectIdState, @@ -50,7 +50,7 @@ const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(localeState(state.projectId), state.locale); set(dialogsSelectorFamily(state.projectId), state.dialogs); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(settingsState(state.projectId), state.settings); set(schemasState(state.projectId), mockProjectResponse.schemas); }; diff --git a/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx b/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx index 820ec06c2b..eb788bc841 100644 --- a/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx +++ b/Composer/packages/client/__tests__/pages/notifications/diagnosticList.test.tsx @@ -13,7 +13,7 @@ import { formDialogSchemaIdsState, jsonSchemaFilesState, lgFilesSelectorFamily, - luFilesState, + luFilesSelectorFamily, schemasState, settingsState, } from '../../../src/recoilModel'; @@ -110,7 +110,7 @@ describe('', () => { set(currentProjectIdState, state.projectId); set(botProjectIdsState, [state.projectId]); set(dialogIdsState(state.projectId), []); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(lgFilesSelectorFamily(state.projectId), state.lgFiles); set(jsonSchemaFilesState(state.projectId), state.jsonSchemaFiles); set(botDiagnosticsState(state.projectId), state.diagnostics); diff --git a/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx b/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx index 02efef8bae..792e59b3b3 100644 --- a/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx +++ b/Composer/packages/client/__tests__/pages/notifications/diagnostics.test.tsx @@ -14,8 +14,8 @@ import { formDialogSchemaIdsState, jsonSchemaFilesState, lgFilesSelectorFamily, - luFilesState, - qnaFilesState, + luFilesSelectorFamily, + qnaFilesSelectorFamily, schemasState, settingsState, } from '../../../src/recoilModel'; @@ -142,9 +142,9 @@ describe('', () => { set(botProjectIdsState, [state.projectId]); set(dialogIdsState(state.projectId), ['test']); set(dialogState({ projectId: state.projectId, dialogId: 'test' }), state.dialogs[0]); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(lgFilesSelectorFamily(state.projectId), state.lgFiles); - set(qnaFilesState(state.projectId), state.qnaFiles); + set(qnaFilesSelectorFamily(state.projectId), state.qnaFiles); set(jsonSchemaFilesState(state.projectId), state.jsonSchemaFiles); set(botDiagnosticsState(state.projectId), state.diagnostics); set(settingsState(state.projectId), state.settings); diff --git a/Composer/packages/client/__tests__/shell/luApi.test.tsx b/Composer/packages/client/__tests__/shell/luApi.test.tsx index 4c910d47eb..cfb1d1646c 100644 --- a/Composer/packages/client/__tests__/shell/luApi.test.tsx +++ b/Composer/packages/client/__tests__/shell/luApi.test.tsx @@ -6,7 +6,7 @@ import * as React from 'react'; import { RecoilRoot } from 'recoil'; import { useLuApi } from '../../src/shell/luApi'; -import { localeState, luFilesState, dispatcherState, currentProjectIdState } from '../../src/recoilModel'; +import { localeState, luFilesSelectorFamily, dispatcherState, currentProjectIdState } from '../../src/recoilModel'; import { Dispatcher } from '../../src/recoilModel/dispatchers'; jest.mock('../../src/recoilModel/parsers/luWorker', () => { @@ -34,7 +34,7 @@ describe('use luApi hooks', () => { const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(localeState(state.projectId), 'en-us'); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(dispatcherState, (current: Dispatcher) => ({ ...current, updateLuFile: updateLuFileMockMock, diff --git a/Composer/packages/client/__tests__/shell/triggerApi.test.tsx b/Composer/packages/client/__tests__/shell/triggerApi.test.tsx index f40e61ce11..0b527c9507 100644 --- a/Composer/packages/client/__tests__/shell/triggerApi.test.tsx +++ b/Composer/packages/client/__tests__/shell/triggerApi.test.tsx @@ -8,7 +8,7 @@ import { RecoilRoot } from 'recoil'; import { useTriggerApi } from '../../src/shell/triggerApi'; import { localeState, - luFilesState, + luFilesSelectorFamily, lgFilesSelectorFamily, dialogsSelectorFamily, schemasState, @@ -52,7 +52,7 @@ describe('use triggerApi hooks', () => { const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); set(localeState(state.projectId), 'en-us'); - set(luFilesState(state.projectId), state.luFiles); + set(luFilesSelectorFamily(state.projectId), state.luFiles); set(lgFilesSelectorFamily(state.projectId), state.lgFiles); set(dialogsSelectorFamily(state.projectId), state.dialogs); set(schemasState(state.projectId), state.schemas); diff --git a/Composer/packages/client/src/components/BotRuntimeController/publishDialog.tsx b/Composer/packages/client/src/components/BotRuntimeController/publishDialog.tsx index 9d57ba68bf..d60362b8f7 100644 --- a/Composer/packages/client/src/components/BotRuntimeController/publishDialog.tsx +++ b/Composer/packages/client/src/components/BotRuntimeController/publishDialog.tsx @@ -22,7 +22,7 @@ import { Text, Tips, Links, nameRegex, LUIS_REGIONS } from '../../constants'; import { FieldConfig, useForm } from '../../hooks/useForm'; import { getReferredQnaFiles } from '../../utils/qnaUtil'; import { getLuisBuildLuFiles } from '../../utils/luUtil'; -import { luFilesState, qnaFilesState, dialogsSelectorFamily } from '../../recoilModel'; +import { luFilesSelectorFamily, qnaFilesSelectorFamily, dialogsSelectorFamily } from '../../recoilModel'; // -------------------- Styles -------------------- // const textFieldLabel = css` @@ -91,8 +91,8 @@ interface IPublishDialogProps { export const PublishDialog: React.FC = (props) => { const { isOpen, onDismiss, onPublish, botName, config, projectId } = props; const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const qnaConfigShow = getReferredQnaFiles(qnaFiles, dialogs).length > 0; const luConfigShow = getLuisBuildLuFiles(luFiles, dialogs).length > 0; diff --git a/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx b/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx index c1a8b0634f..9ff23c05a0 100644 --- a/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx +++ b/Composer/packages/client/src/components/TriggerCreationModal/TriggerCreationModal.tsx @@ -12,7 +12,7 @@ import { useRecoilValue } from 'recoil'; import { TriggerFormData, TriggerFormDataErrors } from '../../utils/dialogUtil'; import { userSettingsState } from '../../recoilModel/atoms'; -import { currentDialogState, dialogsSelectorFamily, localeState, luFilesState } from '../../recoilModel'; +import { currentDialogState, dialogsSelectorFamily, localeState, luFilesSelectorFamily } from '../../recoilModel'; import { isRegExRecognizerType, resolveRecognizer$kind } from '../../utils/dialogValidator'; import TelemetryClient from '../../telemetry/TelemetryClient'; @@ -50,7 +50,7 @@ export const TriggerCreationModal: React.FC = (props) const isRegEx = isRegExRecognizerType(dialogFile); const regexIntents = (dialogFile?.content?.recognizer as RegexRecognizer)?.intents ?? []; - const luFiles = useRecoilValue(luFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const locale = useRecoilValue(localeState(projectId)); const currentDialog = useRecoilValue(currentDialogState({ projectId, dialogId })); const luFile = luFiles.find((f) => f.id === `${currentDialog?.id}.${locale}`); diff --git a/Composer/packages/client/src/hooks/useResolver.ts b/Composer/packages/client/src/hooks/useResolver.ts index ac89aa885c..c64e986cc7 100644 --- a/Composer/packages/client/src/hooks/useResolver.ts +++ b/Composer/packages/client/src/hooks/useResolver.ts @@ -4,15 +4,15 @@ import { useRef } from 'react'; import { lgImportResolverGenerator } from '@bfc/shared'; import { useRecoilValue } from 'recoil'; -import { dialogsSelectorFamily, luFilesState, localeState, qnaFilesState } from '../recoilModel'; +import { dialogsSelectorFamily, luFilesSelectorFamily, localeState, qnaFilesSelectorFamily } from '../recoilModel'; import { lgFilesSelectorFamily } from '../recoilModel/selectors/lg'; export const useResolvers = (projectId: string) => { const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(projectId)); const locale = useRecoilValue(localeState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const lgFilesRef = useRef(lgFiles); lgFilesRef.current = lgFiles; diff --git a/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx b/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx index 39f9c1bc22..644b93e521 100644 --- a/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx +++ b/Composer/packages/client/src/pages/botProject/RootBotExternalService.tsx @@ -20,8 +20,8 @@ import { NeutralColors, SharedColors } from '@uifabric/fluent-theme'; import { dispatcherState, settingsState, - luFilesState, - qnaFilesState, + luFilesSelectorFamily, + qnaFilesSelectorFamily, botDisplayNameState, dialogsWithLuProviderSelectorFamily, } from '../../recoilModel'; @@ -167,8 +167,8 @@ export const RootBotExternalService: React.FC = (pr const rootqnaKey = groupQnAKey.root; const dialogs = useRecoilValue(dialogsWithLuProviderSelectorFamily(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const botName = useRecoilValue(botDisplayNameState(projectId)); const isLUISKeyNeeded = BotIndexer.shouldUseLuis(dialogs, luFiles); const isQnAKeyNeeded = BotIndexer.shouldUseQnA(dialogs, qnaFiles); diff --git a/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx b/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx index 8f3c2217f1..25d4fad1db 100644 --- a/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx +++ b/Composer/packages/client/src/pages/botProject/SkillBotExternalService.tsx @@ -14,8 +14,8 @@ import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { dispatcherState, settingsState, - luFilesState, - qnaFilesState, + luFilesSelectorFamily, + qnaFilesSelectorFamily, dialogsSelectorFamily, botDisplayNameState, } from '../../recoilModel'; @@ -66,8 +66,8 @@ export const SkillBotExternalService: React.FC = ( const skillQnAKey = groupQnAKey[projectId]; const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const isLUISKeyNeeded = BotIndexer.shouldUseLuis(dialogs, luFiles); const isQnAKeyNeeded = BotIndexer.shouldUseQnA(dialogs, qnaFiles); diff --git a/Composer/packages/client/src/pages/design/Modals.tsx b/Composer/packages/client/src/pages/design/Modals.tsx index dd8968e656..b20dab9bc3 100644 --- a/Composer/packages/client/src/pages/design/Modals.tsx +++ b/Composer/packages/client/src/pages/design/Modals.tsx @@ -15,7 +15,7 @@ import { schemasState, showCreateDialogModalState, localeState, - qnaFilesState, + qnaFilesSelectorFamily, displaySkillManifestState, brokenSkillInfoState, brokenSkillRepairCallbackState, @@ -42,7 +42,7 @@ type ModalsProps = { projectId: string; }; const Modals: React.FC = ({ projectId = '' }) => { - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const schemas = useRecoilValue(schemasState(projectId)); const displaySkillManifestNameIdentifier = useRecoilValue(displaySkillManifestState); diff --git a/Composer/packages/client/src/pages/design/PropertyPanel.tsx b/Composer/packages/client/src/pages/design/PropertyPanel.tsx index cd7580239e..3b521cf0c9 100644 --- a/Composer/packages/client/src/pages/design/PropertyPanel.tsx +++ b/Composer/packages/client/src/pages/design/PropertyPanel.tsx @@ -17,9 +17,11 @@ import { skillNameIdentifierByProjectIdSelector, } from '../../recoilModel'; import { undoVersionState } from '../../recoilModel/undo/history'; +import { LoadingSpinner } from '../../components/LoadingSpinner'; import { PropertyEditor } from './PropertyEditor'; import { ManifestEditor } from './ManifestEditor'; +import useAssetsParsingState from './useAssetsParsingState'; type PropertyViewProps = { projectId: string; @@ -33,7 +35,7 @@ const PropertyPanel: React.FC = React.memo(({ projectId = '', const { isRemote: isRemoteSkill } = useRecoilValue(projectMetaDataState(projectId)); const skillsByProjectId = useRecoilValue(skillNameIdentifierByProjectIdSelector); const skills = useRecoilValue(skillsStateSelector); - + const loading = useAssetsParsingState(projectId); const skillManifestFile = useMemo(() => { if (!isSkill) return undefined; @@ -51,11 +53,13 @@ const PropertyPanel: React.FC = React.memo(({ projectId = '', return ( - {isRemoteSkill && skillManifestFile ? ( - - ) : ( - - )} + {loading && } + {!loading && + (isRemoteSkill && skillManifestFile ? ( + + ) : ( + + ))} ); }); diff --git a/Composer/packages/client/src/pages/design/VisualPanel.tsx b/Composer/packages/client/src/pages/design/VisualPanel.tsx index 5ba6c31c9e..d231efc0a6 100644 --- a/Composer/packages/client/src/pages/design/VisualPanel.tsx +++ b/Composer/packages/client/src/pages/design/VisualPanel.tsx @@ -21,11 +21,13 @@ import { import { triggerNotSupported } from '../../utils/dialogValidator'; import { decodeDesignerPathToArrayPath } from '../../utils/convertUtils/designerPathEncoder'; import TelemetryClient from '../../telemetry/TelemetryClient'; +import { LoadingSpinner } from '../../components/LoadingSpinner'; import { WarningMessage } from './WarningMessage'; import { visualPanel } from './styles'; import VisualPanelHeader from './VisualPanelHeader'; import VisualEditorWrapper from './VisualEditorWrapper'; +import useAssetsParsingState from './useAssetsParsingState'; type VisualPanelProps = { projectId: string; @@ -37,7 +39,7 @@ const VisualPanel: React.FC = React.memo(({ projectId }) => { const currentDialog = useRecoilValue(currentDialogState({ dialogId, projectId })); const schemas = useRecoilValue(schemasState(projectId)); const { isRemote: isRemoteSkill } = useRecoilValue(projectMetaDataState(projectId)); - + const loading = useAssetsParsingState(projectId); const { updateDialog, navTo } = useRecoilValue(dispatcherState); const selected = decodeDesignerPathToArrayPath(currentDialog?.content, encodedSelected || ''); @@ -76,29 +78,31 @@ const VisualPanel: React.FC = React.memo(({ projectId }) => { /> )} - {dialogJsonVisible && currentDialog ? ( - { - updateDialog({ id: currentDialog.id, content: data, projectId }); - }} - /> - ) : withWarning ? ( - { - setWarningIsVisible(false); - }} - onOk={() => navTo(projectId, dialogId ?? null)} - /> - ) : ( - - )} + {loading && } + {!loading && + (dialogJsonVisible && currentDialog ? ( + { + updateDialog({ id: currentDialog.id, content: data, projectId }); + }} + /> + ) : withWarning ? ( + { + setWarningIsVisible(false); + }} + onOk={() => navTo(projectId, dialogId ?? null)} + /> + ) : ( + + ))} ); }); diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx index 41be8bcf9d..55fcbc39a5 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx @@ -15,10 +15,10 @@ import { SkillManifestFile } from '@bfc/shared'; import { dispatcherState, skillManifestsState, - qnaFilesState, + qnaFilesSelectorFamily, dialogsSelectorFamily, dialogSchemasState, - luFilesState, + luFilesSelectorFamily, } from '../../../recoilModel'; import { editorSteps, ManifestEditorSteps, order } from './constants'; @@ -35,8 +35,8 @@ interface ExportSkillModalProps { const ExportSkillModal: React.FC = ({ onSubmit, onDismiss: handleDismiss, projectId }) => { const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const skillManifests = useRecoilValue(skillManifestsState(projectId)); const { updateSkillManifest } = useRecoilValue(dispatcherState); diff --git a/Composer/packages/client/src/pages/design/useAssetsParsingState.tsx b/Composer/packages/client/src/pages/design/useAssetsParsingState.tsx new file mode 100644 index 0000000000..08d3ccae52 --- /dev/null +++ b/Composer/packages/client/src/pages/design/useAssetsParsingState.tsx @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { localeState, lgFileState, designPageLocationState, luFileState, qnaFileState } from '../../recoilModel'; + +//check if all the files needed in current page have parse result. +//If some of the files still are still doing the parsing, return true. +// This is use to show during the parsing stage. +export const useAssetsParsingState = (projectId: string) => { + const { dialogId } = useRecoilValue(designPageLocationState(projectId)); + const locale = useRecoilValue(localeState(projectId)); + const currentLg = useRecoilValue(lgFileState({ projectId, lgFileId: `${dialogId}.${locale}` })); + const currentLu = useRecoilValue(luFileState({ projectId, luFileId: `${dialogId}.${locale}` })); + const currentQna = useRecoilValue(qnaFileState({ projectId, qnaFileId: `${dialogId}.${locale}` })); + const [isParsing, setIsParsing] = useState(false); + + useEffect(() => { + const currentAssets = [currentLg, currentLu, currentQna].filter((item) => item.id); + + if (!currentAssets.length) return; + + if (currentAssets.some((item) => item.id && item.isContentUnparsed)) { + !isParsing && setIsParsing(true); + } else { + isParsing && setIsParsing(false); + } + }, [currentLg, currentLu, currentQna]); + + return isParsing; +}; + +export default useAssetsParsingState; diff --git a/Composer/packages/client/src/pages/design/useEmptyPropsHandler.tsx b/Composer/packages/client/src/pages/design/useEmptyPropsHandler.tsx index a46a478f7c..f3da0450cd 100644 --- a/Composer/packages/client/src/pages/design/useEmptyPropsHandler.tsx +++ b/Composer/packages/client/src/pages/design/useEmptyPropsHandler.tsx @@ -3,13 +3,25 @@ import { useEffect } from 'react'; import { globalHistory, WindowLocation } from '@reach/router'; -import { PromptTab } from '@bfc/shared'; -import { useRecoilValue } from 'recoil'; +import { LgFile, LuFile, PromptTab, QnAFile } from '@bfc/shared'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { getDialogData } from '../../utils/dialogUtil'; import { getFocusPath } from '../../utils/navigation'; -import { dispatcherState, currentDialogState } from '../../recoilModel'; +import { + dispatcherState, + currentDialogState, + localeState, + lgFileState, + lgFilesSelectorFamily, + luFileState, + qnaFileState, + settingsState, +} from '../../recoilModel'; import { decodeDesignerPathToArrayPath } from '../../utils/convertUtils/designerPathEncoder'; +import lgDiagnosticWorker from '../../recoilModel/parsers/lgDiagnosticWorker'; +import luWorker from '../../recoilModel/parsers/luWorker'; +import qnaWorker from '../../recoilModel/parsers/qnaWorker'; const getTabFromFragment = () => { const tab = window.location.hash.substring(1); @@ -26,7 +38,18 @@ export const useEmptyPropsHandler = ( const activeBot = skillId ?? projectId; const currentDialog = useRecoilValue(currentDialogState({ dialogId, projectId: activeBot })); - + const locale = useRecoilValue(localeState(activeBot)); + const settings = useRecoilValue(settingsState(activeBot)); + const [currentLg, setCurrentLg] = useRecoilState( + lgFileState({ projectId: activeBot, lgFileId: `${dialogId}.${locale}` }) + ); + const [currentLu, setCurrentLu] = useRecoilState( + luFileState({ projectId: activeBot, luFileId: `${dialogId}.${locale}` }) + ); + const [currentQna, setCurrentQna] = useRecoilState( + qnaFileState({ projectId: activeBot, qnaFileId: `${dialogId}.${locale}` }) + ); + const lgFiles = useRecoilValue(lgFilesSelectorFamily(projectId)); const { updateDialog, setDesignPageLocation, navTo } = useRecoilValue(dispatcherState); // migration: add id to dialog when dialog doesn't have id @@ -41,6 +64,49 @@ export const useEmptyPropsHandler = ( } }, [currentDialog]); + useEffect(() => { + if (!currentDialog || !currentLg.id) return; + let isMounted = true; + if (currentLg.isContentUnparsed) { + //for current dialog, check the lg file to make sure the file is parsed. + lgDiagnosticWorker.parse(activeBot, currentLg.id, currentLg.content, lgFiles).then((result) => { + isMounted ? setCurrentLg(result as LgFile) : null; + }); + } + + return () => { + isMounted = false; + }; + }, [currentDialog, currentLg, lgFiles]); + + useEffect(() => { + if (!currentDialog || !currentLu.id) return; + let isMounted = true; + if (currentLu.isContentUnparsed) { + //for current dialog, check the lu file to make sure the file is parsed. + luWorker.parse(currentLu.id, currentLu.content, settings.luFeatures).then((result) => { + isMounted ? setCurrentLu(result as LuFile) : null; + }); + return () => { + isMounted = false; + }; + } + }, [currentDialog, currentLu]); + + useEffect(() => { + if (!currentDialog || !currentQna.id) return; + let isMounted = true; + if (currentQna.isContentUnparsed) { + //for current dialog, check the qna file to make sure the file is parsed. + qnaWorker.parse(currentQna.id, currentQna.content).then((result) => { + isMounted ? setCurrentQna(result as QnAFile) : null; + }); + } + return () => { + isMounted = false; + }; + }, [currentDialog, currentQna]); + useEffect(() => { if (!location || !currentDialog || !activeBot) return; diff --git a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx index 502af634a8..1671f4c2ae 100644 --- a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx @@ -12,7 +12,7 @@ import { RouteComponentProps, Router } from '@reach/router'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { navigateTo } from '../../utils/navigation'; import { Page } from '../../components/Page'; -import { dialogIdsState, qnaFilesState, dispatcherState, createQnAOnState } from '../../recoilModel'; +import { dialogIdsState, qnaFilesSelectorFamily, dispatcherState, createQnAOnState } from '../../recoilModel'; import { CreateQnAModal } from '../../components/QnA'; import TelemetryClient from '../../telemetry/TelemetryClient'; @@ -33,7 +33,7 @@ const QnAPage: React.FC = (props) => { const baseURL = skillId == null ? `/bot/${projectId}/` : `/bot/${projectId}/skill/${skillId}/`; const actions = useRecoilValue(dispatcherState); - const qnaFiles = useRecoilValue(qnaFilesState(actualProjectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(actualProjectId)); //To do: support other languages const locale = 'en-us'; //const locale = useRecoilValue(localeState); diff --git a/Composer/packages/client/src/pages/knowledge-base/table-view.tsx b/Composer/packages/client/src/pages/knowledge-base/table-view.tsx index ba88fa1e56..5cb2f420b7 100644 --- a/Composer/packages/client/src/pages/knowledge-base/table-view.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/table-view.tsx @@ -34,7 +34,7 @@ import { NeutralColors } from '@uifabric/fluent-theme'; import emptyQnAIcon from '../../images/emptyQnAIcon.svg'; import { navigateTo } from '../../utils/navigation'; -import { dialogsSelectorFamily, qnaFilesState, localeState } from '../../recoilModel'; +import { dialogsSelectorFamily, qnaFilesSelectorFamily, localeState } from '../../recoilModel'; import { dispatcherState } from '../../recoilModel'; import { getBaseName } from '../../utils/fileUtil'; import { EditableField } from '../../components/EditableField'; @@ -92,7 +92,7 @@ const TableView: React.FC = (props) => { const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsSelectorFamily(actualProjectId)); - const qnaFiles = useRecoilValue(qnaFilesState(actualProjectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(actualProjectId)); const locale = useRecoilValue(localeState(actualProjectId)); const { removeQnAImport, diff --git a/Composer/packages/client/src/pages/language-generation/code-editor.tsx b/Composer/packages/client/src/pages/language-generation/code-editor.tsx index f226009d4e..aa852e0de6 100644 --- a/Composer/packages/client/src/pages/language-generation/code-editor.tsx +++ b/Composer/packages/client/src/pages/language-generation/code-editor.tsx @@ -16,10 +16,11 @@ import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'reac import { useRecoilValue } from 'recoil'; import { dispatcherState, userSettingsState } from '../../recoilModel'; -import { localeState, settingsState } from '../../recoilModel/atoms/botState'; +import { dialogState, localeState, settingsState } from '../../recoilModel/atoms/botState'; import { getMemoryVariables } from '../../recoilModel/dispatchers/utils/project'; import { lgFilesSelectorFamily } from '../../recoilModel/selectors/lg'; import TelemetryClient from '../../telemetry/TelemetryClient'; +import { navigateTo } from '../../utils/navigation'; import { DiffCodeEditor } from '../language-understanding/diff-editor'; const lspServerPath = '/lg-language-server'; @@ -40,6 +41,7 @@ const CodeEditor: React.FC = (props) => { const locale = useRecoilValue(localeState(actualProjectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(actualProjectId)); const settings = useRecoilValue(settingsState(actualProjectId)); + const currentDialog = useRecoilValue(dialogState({ projectId: actualProjectId, dialogId })); const { languages, defaultLanguage } = settings; @@ -174,6 +176,24 @@ const CodeEditor: React.FC = (props) => { templateId: template?.name, }; + const navigateToLgPage = useCallback( + (lgFileId: string, options?: { templateId?: string; line?: number }) => { + // eslint-disable-next-line security/detect-non-literal-regexp + const pattern = new RegExp(`.${locale}`, 'g'); + const fileId = currentDialog.isFormDialog ? lgFileId : lgFileId.replace(pattern, ''); + let url = currentDialog.isFormDialog + ? `/bot/${actualProjectId}/language-generation/${currentDialog.id}/item/${fileId}` + : `/bot/${actualProjectId}/language-generation/${fileId}`; + if (options?.line) { + url = url + `/edit#L=${options.line}`; + } else if (options?.templateId) { + url = url + `/edit?t=${options.templateId}`; + } + navigateTo(url); + }, + [actualProjectId, locale] + ); + const currentLanguageFileEditor = useMemo(() => { return ( = (props) => { value={content} onChange={onChange} onChangeSettings={handleSettingsChange} + onNavigateToLgPage={navigateToLgPage} /> ); }, [lgOption, userSettings.codeEditor]); diff --git a/Composer/packages/client/src/pages/language-understanding/LUPage.tsx b/Composer/packages/client/src/pages/language-understanding/LUPage.tsx index 64a2d3df50..dfdf0af878 100644 --- a/Composer/packages/client/src/pages/language-understanding/LUPage.tsx +++ b/Composer/packages/client/src/pages/language-understanding/LUPage.tsx @@ -11,7 +11,7 @@ import { useRecoilValue } from 'recoil'; import { navigateTo, buildURL } from '../../utils/navigation'; import { LoadingSpinner } from '../../components/LoadingSpinner'; import { Page } from '../../components/Page'; -import { localeState, luFilesState } from '../../recoilModel'; +import { localeState, luFilesSelectorFamily } from '../../recoilModel'; import TableView from './table-view'; @@ -26,7 +26,7 @@ const LUPage: React.FC = (props) => { const { dialogId, projectId, skillId, luFileId, file } = props; const actualProjectId = skillId ?? projectId; - const luFiles = useRecoilValue(luFilesState(actualProjectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(actualProjectId)); const locale = useRecoilValue(localeState(actualProjectId)); const settings = useRecoilValue(settingsState(actualProjectId)); diff --git a/Composer/packages/client/src/pages/language-understanding/table-view.tsx b/Composer/packages/client/src/pages/language-understanding/table-view.tsx index e79ebec04a..8dd80df04e 100644 --- a/Composer/packages/client/src/pages/language-understanding/table-view.tsx +++ b/Composer/packages/client/src/pages/language-understanding/table-view.tsx @@ -24,7 +24,13 @@ import { EditableField } from '../../components/EditableField'; import { getExtension } from '../../utils/fileUtil'; import { languageListTemplates } from '../../components/MultiLanguage'; import { navigateTo } from '../../utils/navigation'; -import { dispatcherState, luFilesState, localeState, settingsState, dialogsSelectorFamily } from '../../recoilModel'; +import { + dispatcherState, + localeState, + settingsState, + dialogsSelectorFamily, + luFilesSelectorFamily, +} from '../../recoilModel'; import { formCell, luPhraseCell, tableCell, editableFieldContainer } from './styles'; interface TableViewProps extends RouteComponentProps<{ dialogId: string; skillId: string; projectId: string }> { @@ -52,7 +58,7 @@ const TableView: React.FC = (props) => { const { updateLuIntent } = useRecoilValue(dispatcherState); - const luFiles = useRecoilValue(luFilesState(actualProjectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(actualProjectId)); const locale = useRecoilValue(localeState(actualProjectId)); const settings = useRecoilValue(settingsState(actualProjectId)); const dialogs = useRecoilValue(dialogsSelectorFamily(actualProjectId)); diff --git a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx index e5fd3a79b8..ccd557c032 100644 --- a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx +++ b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx @@ -10,13 +10,13 @@ import { BotAssets } from '@bfc/shared'; import { useRecoilValue } from 'recoil'; import isEmpty from 'lodash/isEmpty'; -import { dialogsSelectorFamily } from './selectors'; +import { createMissingLgTemplatesForDialogs } from '../utils/lgUtil'; + +import { dialogsSelectorFamily, luFilesSelectorFamily, qnaFilesSelectorFamily } from './selectors'; import { UndoRoot } from './undo/history'; import { prepareAxios } from './../utils/auth'; import createDispatchers, { Dispatcher } from './dispatchers'; import { - luFilesState, - qnaFilesState, skillManifestsState, dialogSchemasState, settingsState, @@ -25,6 +25,7 @@ import { jsonSchemaFilesState, crossTrainConfigState, dispatcherState, + projectIndexingState, } from './atoms'; import { localBotsWithoutErrorsSelector, formDialogSchemasSelectorFamily } from './selectors'; import { Recognizer } from './Recognizers'; @@ -33,7 +34,7 @@ import { lgFilesSelectorFamily } from './selectors/lg'; const getBotAssets = async (projectId, snapshot: Snapshot): Promise => { const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const lgFiles = await snapshot.getPromise(lgFilesSelectorFamily(projectId)); const skillManifests = await snapshot.getPromise(skillManifestsState(projectId)); const setting = await snapshot.getPromise(settingsState(projectId)); @@ -43,7 +44,7 @@ const getBotAssets = async (projectId, snapshot: Snapshot): Promise = const jsonSchemaFiles = await snapshot.getPromise(jsonSchemaFilesState(projectId)); const recognizers = await snapshot.getPromise(recognizersSelectorFamily(projectId)); const crossTrainConfig = await snapshot.getPromise(crossTrainConfigState(projectId)); - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); return { projectId, @@ -93,6 +94,19 @@ const InitDispatcher = ({ onLoad }) => { return null; }; +const repairBotProject = async (projectId: string, snapshot: Snapshot, previousSnapshot: Snapshot) => { + const indexingState = await snapshot.getPromise(projectIndexingState(projectId)); + const preIndexingState = await previousSnapshot.getPromise(projectIndexingState(projectId)); + if (indexingState === false && preIndexingState == true) { + const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); + const lgFiles = await snapshot.getPromise(lgFilesSelectorFamily(projectId)); + + const updatedLgFiles = await createMissingLgTemplatesForDialogs(projectId, dialogs, lgFiles); + const { updateAllLgFiles } = await snapshot.getPromise(dispatcherState); + updateAllLgFiles({ projectId, lgFiles: updatedLgFiles }); + } +}; + export const DispatcherWrapper = ({ children }) => { const [loaded, setLoaded] = useState(false); const botProjects = useRecoilValue(localBotsWithoutErrorsSelector); @@ -105,6 +119,7 @@ export const DispatcherWrapper = ({ children }) => { const previousAssets = await getBotAssets(projectId, previousSnapshot); const filePersistence = await snapshot.getPromise(filePersistenceState(projectId)); if (!isEmpty(filePersistence)) { + await repairBotProject(projectId, snapshot, previousSnapshot); if (filePersistence.isErrorHandlerEmpty()) { filePersistence.registerErrorHandler(setProjectError); } diff --git a/Composer/packages/client/src/recoilModel/Recognizers.tsx b/Composer/packages/client/src/recoilModel/Recognizers.tsx index d2acaaa4dc..5955b4ac33 100644 --- a/Composer/packages/client/src/recoilModel/Recognizers.tsx +++ b/Composer/packages/client/src/recoilModel/Recognizers.tsx @@ -13,8 +13,8 @@ import { getLuProvider } from '../utils/dialogUtil'; import * as luUtil from './../utils/luUtil'; import * as buildUtil from './../utils/buildUtil'; -import { crossTrainConfigState, filePersistenceState, luFilesState, qnaFilesState, settingsState } from './atoms'; -import { dialogsSelectorFamily } from './selectors'; +import { crossTrainConfigState, filePersistenceState, settingsState } from './atoms'; +import { dialogsSelectorFamily, luFilesSelectorFamily, qnaFilesSelectorFamily } from './selectors'; import { recognizersSelectorFamily } from './selectors/recognizers'; export const LuisRecognizerTemplate = (target: string, fileName: string) => ({ @@ -164,8 +164,8 @@ export const Recognizer = React.memo((props: { projectId: string }) => { const setRecognizers = useSetRecoilState(recognizersSelectorFamily(projectId)); const [crossTrainConfig, setCrossTrainConfig] = useRecoilState(crossTrainConfigState(projectId)); const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const settings = useRecoilValue(settingsState(projectId)); const curRecognizers = useRecoilValue(recognizersSelectorFamily(projectId)); const curRecognizersRef = useRef(curRecognizers); @@ -210,6 +210,9 @@ export const Recognizer = React.memo((props: { projectId: string }) => { useEffect(() => { try { + //if the lu file still in the loading stage, do nothing + if (luFiles.some((item) => item.isContentUnparsed)) return; + const referredLuFiles = luUtil.checkLuisBuild(luFiles, dialogs); const curCrossTrainConfig = buildUtil.createCrossTrainConfig(dialogs, referredLuFiles, settings.languages); diff --git a/Composer/packages/client/src/recoilModel/atoms/botState.ts b/Composer/packages/client/src/recoilModel/atoms/botState.ts index 1a6d4b316f..bb2729a3f6 100644 --- a/Composer/packages/client/src/recoilModel/atoms/botState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/botState.ts @@ -56,9 +56,43 @@ const emptyLg: LgFile = { templates: [], allTemplates: [], imports: [], + isContentUnparsed: true, +}; + +const emptyLu: LuFile = { + id: '', + content: '', + diagnostics: [], + intents: [], + empty: true, + resource: { + Sections: [], + Errors: [], + Content: '', + }, + imports: [], + isContentUnparsed: true, +}; + +const emptyQna: QnAFile = { + id: '', + content: '', + diagnostics: [], + qnaSections: [], + imports: [], + options: [], + empty: true, + resource: { + Sections: [], + Errors: [], + Content: '', + }, + isContentUnparsed: true, }; type LgStateParams = { projectId: string; lgFileId: string }; +type LuStateParams = { projectId: string; luFileId: string }; +type QnaStateParams = { projectId: string; qnaFileId: string }; export const lgFileState = atomFamily({ key: getFullyQualifiedKey('lg'), @@ -74,6 +108,34 @@ export const lgFileIdsState = atomFamily({ }, }); +export const qnaFileState = atomFamily({ + key: getFullyQualifiedKey('qna'), + default: () => { + return emptyQna; + }, +}); + +export const qnaFileIdsState = atomFamily({ + key: getFullyQualifiedKey('qnaFileIds'), + default: () => { + return []; + }, +}); + +export const luFileState = atomFamily({ + key: getFullyQualifiedKey('lu'), + default: () => { + return emptyLu; + }, +}); + +export const luFileIdsState = atomFamily({ + key: getFullyQualifiedKey('luFileIds'), + default: () => { + return []; + }, +}); + type dialogStateParams = { projectId: string; dialogId: string }; export const dialogState = atomFamily({ key: getFullyQualifiedKey('dialog'), @@ -151,13 +213,6 @@ export const botRuntimeErrorState = atomFamily({ }, }); -export const luFilesState = atomFamily({ - key: getFullyQualifiedKey('luFiles'), - default: (id) => { - return []; - }, -}); - export const recognizerIdsState = atomFamily({ key: getFullyQualifiedKey('recognizerIds'), default: (id) => { @@ -307,11 +362,6 @@ export const isEjectRuntimeExistState = atomFamily({ default: false, }); -export const qnaFilesState = atomFamily({ - key: getFullyQualifiedKey('qnaFiles'), - default: [], -}); - export const jsonSchemaFilesState = atomFamily({ key: getFullyQualifiedKey('jsonSchemaFiles'), default: [], @@ -376,3 +426,8 @@ export const webChatLogsState = atomFamily({ key: getFullyQualifiedKey('webChatLogs'), default: [], }); + +export const projectIndexingState = atomFamily({ + key: getFullyQualifiedKey('projectIndexing'), + default: false, +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx index 989a2e0eca..4a77ce7529 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx @@ -8,16 +8,19 @@ import { act, HookResult } from '@botframework-composer/test-utils/lib/hooks'; import { dialogsDispatcher } from '../dialogs'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; import { - luFilesState, schemasState, dialogSchemasState, actionsSeedState, onCreateDialogCompleteState, showCreateDialogModalState, - qnaFilesState, dispatcherState, } from '../../atoms'; -import { dialogsSelectorFamily, lgFilesSelectorFamily } from '../../selectors'; +import { + dialogsSelectorFamily, + lgFilesSelectorFamily, + luFilesSelectorFamily, + qnaFilesSelectorFamily, +} from '../../selectors'; import { Dispatcher } from '..'; const projectId = '42345.23432'; @@ -93,12 +96,12 @@ describe('dialog dispatcher', () => { const useRecoilTestHook = () => { const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(projectId)); const actionsSeed = useRecoilValue(actionsSeedState(projectId)); const onCreateDialogComplete = useRecoilValue(onCreateDialogCompleteState(projectId)); const showCreateDialogModal = useRecoilValue(showCreateDialogModalState); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const currentDispatcher = useRecoilValue(dispatcherState); return { @@ -122,8 +125,8 @@ describe('dialog dispatcher', () => { { recoilState: dialogsSelectorFamily(projectId), initialValue: [{ id: '1' }, { id: '2' }] }, { recoilState: dialogSchemasState(projectId), initialValue: [{ id: '1' }, { id: '2' }] }, { recoilState: lgFilesSelectorFamily(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] }, - { recoilState: luFilesState(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] }, - { recoilState: qnaFilesState(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] }, + { recoilState: luFilesSelectorFamily(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] }, + { recoilState: qnaFilesSelectorFamily(projectId), initialValue: [{ id: '1.en-us' }, { id: '2.en-us' }] }, { recoilState: schemasState(projectId), initialValue: { sdk: { content: '' } } }, ], dispatcher: { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx index ef9f6ac21b..993d3bc347 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx @@ -46,6 +46,7 @@ const lgFiles = [ diagnostics: [], imports: [], allTemplates: [{ name: 'Hello', body: '-hi', parameters: [] }], + isContentUnparsed: false, }, ] as LgFile[]; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx index b65846179e..7510f85939 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lu.test.tsx @@ -8,9 +8,10 @@ import { act, HookResult } from '@botframework-composer/test-utils/lib/hooks'; import { luUtil } from '@bfc/indexers'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; -import { luFilesState, currentProjectIdState, dispatcherState } from '../../atoms'; +import { currentProjectIdState, dispatcherState } from '../../atoms'; import { Dispatcher } from '..'; import { luDispatcher } from '../lu'; +import { luFilesSelectorFamily } from '../../selectors'; const luFeatures = {}; @@ -40,7 +41,7 @@ const getLuIntent = (Name, Body): LuIntentSection => describe('Lu dispatcher', () => { const useRecoilTestHook = () => { - const [luFiles, setLuFiles] = useRecoilState(luFilesState(projectId)); + const [luFiles, setLuFiles] = useRecoilState(luFilesSelectorFamily(projectId)); const currentDispatcher = useRecoilValue(dispatcherState); return { @@ -55,7 +56,7 @@ describe('Lu dispatcher', () => { beforeEach(() => { const { result } = renderRecoilHook(useRecoilTestHook, { states: [ - { recoilState: luFilesState(projectId), initialValue: luFiles }, + { recoilState: luFilesSelectorFamily(projectId), initialValue: luFiles }, { recoilState: currentProjectIdState, initialValue: projectId }, ], dispatcher: { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx index 8be42b0d4a..945bf977b2 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/multilang.test.tsx @@ -6,7 +6,6 @@ import { act, HookResult } from '@botframework-composer/test-utils/lib/hooks'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; import { - luFilesState, settingsState, localeState, actionsSeedState, @@ -15,7 +14,7 @@ import { currentProjectIdState, dispatcherState, } from '../../atoms'; -import { dialogsSelectorFamily, lgFilesSelectorFamily } from '../../selectors'; +import { dialogsSelectorFamily, lgFilesSelectorFamily, luFilesSelectorFamily } from '../../selectors'; import { Dispatcher } from '..'; import { multilangDispatcher } from '../multilang'; @@ -43,7 +42,7 @@ describe('Multilang dispatcher', () => { const dialogs = useRecoilValue(dialogsSelectorFamily(state.projectId)); const locale = useRecoilValue(localeState(state.projectId)); const settings = useRecoilValue(settingsState(state.projectId)); - const luFiles = useRecoilValue(luFilesState(state.projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(state.projectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(state.projectId)); const onAddLanguageDialogComplete = useRecoilValue(onAddLanguageDialogCompleteState(state.projectId)); const onDelLanguageDialogComplete = useRecoilValue(onDelLanguageDialogCompleteState(state.projectId)); @@ -72,7 +71,7 @@ describe('Multilang dispatcher', () => { { recoilState: dialogsSelectorFamily(state.projectId), initialValue: state.dialogs }, { recoilState: localeState(state.projectId), initialValue: state.locale }, { recoilState: lgFilesSelectorFamily(state.projectId), initialValue: state.lgFiles }, - { recoilState: luFilesState(state.projectId), initialValue: state.luFiles }, + { recoilState: luFilesSelectorFamily(state.projectId), initialValue: state.luFiles }, { recoilState: settingsState(state.projectId), initialValue: state.settings }, ], dispatcher: { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx index 10c66dda54..d0344008c1 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx @@ -24,7 +24,6 @@ import { runtimeTemplatesState, currentProjectIdState, skillManifestsState, - luFilesState, settingsState, botEnvironmentState, botDiagnosticsState, @@ -41,7 +40,7 @@ import { botProjectSpaceLoadedState, dispatcherState, } from '../../atoms'; -import { dialogsSelectorFamily, lgFilesSelectorFamily } from '../../selectors'; +import { dialogsSelectorFamily, lgFilesSelectorFamily, luFilesSelectorFamily } from '../../selectors'; import { Dispatcher } from '../../dispatchers'; import { BotStatus } from '../../../constants'; @@ -68,12 +67,37 @@ jest.mock('../../parsers/lgWorker', () => { return { flush: () => new Promise((resolve) => resolve(null)), addProject: () => new Promise((resolve) => resolve(null)), + + parseAll: (id, files) => + new Promise((resolve) => + resolve( + files.map(({ id, content }) => { + const result = require('@bfc/indexers').lgUtil.parse(id, content, files); + delete result.parseResult; + return result; + }) + ) + ), }; }); jest.mock('../../parsers/luWorker', () => { return { flush: () => new Promise((resolve) => resolve(null)), + parseAll: (files, luFeatures) => + new Promise((resolve) => + resolve(files.map(({ id, content }) => require('@bfc/indexers').luUtil.parse(id, content, luFeatures))) + ), + }; +}); + +jest.mock('../../parsers/qnaWorker', () => { + return { + flush: () => new Promise((resolve) => resolve(null)), + parseAll: (files) => + new Promise((resolve) => + resolve(files.map(({ id, content }) => require('@bfc/indexers').qnaUtil.parse(id, content))) + ), }; }); @@ -113,7 +137,7 @@ describe('Project dispatcher', () => { const location = useRecoilValue(locationState(projectId)); const botName = useRecoilValue(botDisplayNameState(projectId)); const skillManifests = useRecoilValue(skillManifestsState(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(projectId)); const settings = useRecoilValue(settingsState(projectId)); const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx index 9842306749..c1e3085333 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/qna.test.tsx @@ -9,8 +9,9 @@ import { act, HookResult } from '@botframework-composer/test-utils/lib/hooks'; import { qnaDispatcher } from '../qna'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; -import { qnaFilesState, currentProjectIdState, localeState, dispatcherState } from '../../atoms'; +import { currentProjectIdState, localeState, dispatcherState } from '../../atoms'; import { Dispatcher } from '..'; +import { qnaFilesSelectorFamily } from '../../selectors'; jest.mock('../../parsers/qnaWorker', () => { const filterParseResult = (qnaFile: QnAFile) => { @@ -55,7 +56,7 @@ const qnaFiles = [qna1]; describe('QnA dispatcher', () => { const useRecoilTestHook = () => { - const [qnaFiles, setQnAFiles] = useRecoilState(qnaFilesState(projectId)); + const [qnaFiles, setQnAFiles] = useRecoilState(qnaFilesSelectorFamily(projectId)); const currentDispatcher = useRecoilValue(dispatcherState); return { @@ -70,7 +71,7 @@ describe('QnA dispatcher', () => { beforeEach(() => { const { result } = renderRecoilHook(useRecoilTestHook, { states: [ - { recoilState: qnaFilesState(projectId), initialValue: qnaFiles }, + { recoilState: qnaFilesSelectorFamily(projectId), initialValue: qnaFiles }, { recoilState: currentProjectIdState, initialValue: projectId }, { recoilState: localeState(projectId), initialValue: locale }, ], diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx index 6e7160638f..404c180a75 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx @@ -10,15 +10,13 @@ import { lgDispatcher } from '../lg'; import { luDispatcher } from '../lu'; import { navigationDispatcher } from '../navigation'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; +import { schemasState, dialogSchemasState, actionsSeedState, dispatcherState } from '../../atoms'; import { - luFilesState, - schemasState, - dialogSchemasState, - actionsSeedState, - qnaFilesState, - dispatcherState, -} from '../../atoms'; -import { dialogsSelectorFamily, lgFilesSelectorFamily } from '../../selectors'; + dialogsSelectorFamily, + lgFilesSelectorFamily, + luFilesSelectorFamily, + qnaFilesSelectorFamily, +} from '../../selectors'; import { Dispatcher } from '..'; import { DialogInfo } from '../../../../../types'; @@ -104,10 +102,10 @@ describe('trigger dispatcher', () => { const useRecoilTestHook = () => { const dialogs: DialogInfo[] = useRecoilValue(dialogsSelectorFamily(projectId)); const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); - const luFiles = useRecoilValue(luFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(projectId)); const actionsSeed = useRecoilValue(actionsSeedState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const currentDispatcher = useRecoilValue(dispatcherState); return { @@ -141,14 +139,14 @@ describe('trigger dispatcher', () => { ], }, { - recoilState: luFilesState(projectId), + recoilState: luFilesSelectorFamily(projectId), initialValue: [ { id: '1.en-us', content: '' }, { id: '2.en-us', content: '' }, ], }, { - recoilState: qnaFilesState(projectId), + recoilState: qnaFilesSelectorFamily(projectId), initialValue: [ { id: '1.en-us', content: '' }, { id: '2.en-us', content: '' }, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts index c0f3a9afcc..b47ae1162b 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts @@ -10,8 +10,8 @@ import { Text, BotStatus } from '../../constants'; import httpClient from '../../utils/httpUtil'; import luFileStatusStorage from '../../utils/luFileStatusStorage'; import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage'; -import { luFilesState, qnaFilesState, botStatusState, botRuntimeErrorState } from '../atoms'; -import { dialogsWithLuProviderSelectorFamily } from '../selectors'; +import { botStatusState, botRuntimeErrorState } from '../atoms'; +import { dialogsWithLuProviderSelectorFamily, luFilesSelectorFamily, qnaFilesSelectorFamily } from '../selectors'; import { getReferredQnaFiles } from '../../utils/qnaUtil'; const checkEmptyQuestionOrAnswerInQnAFile = (sections) => { @@ -27,8 +27,8 @@ export const builderDispatcher = () => { ) => { const { set, snapshot } = callbackHelpers; const dialogs = await snapshot.getPromise(dialogsWithLuProviderSelectorFamily(projectId)); - const luFiles = await snapshot.getPromise(luFilesState(projectId)); - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const referredLuFiles = luUtil.checkLuisBuild(luFiles, dialogs); const referredQnaFiles = getReferredQnaFiles(qnaFiles, dialogs, false); const errorMsg = referredQnaFiles.reduce( diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lg.ts b/Composer/packages/client/src/recoilModel/dispatchers/lg.ts index b9bf94143b..ce8f827d5b 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/lg.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/lg.ts @@ -475,6 +475,12 @@ export const lgDispatcher = () => { } ); + const updateAllLgFiles = useRecoilCallback( + ({ set }: CallbackInterface) => ({ projectId, lgFiles }: { projectId: string; lgFiles: LgFile[] }) => { + set(lgFilesSelectorFamily(projectId), lgFiles); + } + ); + return { updateLgFile, createLgFile, @@ -486,5 +492,6 @@ export const lgDispatcher = () => { removeLgTemplates, copyLgTemplate, reparseAllLgFiles, + updateAllLgFiles, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts index 1b4e0e1ae1..61449a9cf8 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts @@ -9,8 +9,10 @@ import formatMessage from 'format-message'; import luWorker from '../parsers/luWorker'; import { getBaseName, getExtension } from '../../utils/fileUtil'; import luFileStatusStorage from '../../utils/luFileStatusStorage'; -import { luFilesState, localeState, settingsState } from '../atoms/botState'; +import { localeState, settingsState, luFileIdsState } from '../atoms/botState'; +import { luFilesSelectorFamily } from '../selectors/lu'; +import { luFileState } from './../atoms/botState'; import { setError } from './shared'; const intentIsNotEmpty = ({ Name, Body }) => { @@ -27,37 +29,41 @@ const initialBody = '- '; * */ -const luFilesAtomUpdater = ( +const updateLuFiles = ( + { set }: CallbackInterface, + projectId: string, changes: { adds?: LuFile[]; deletes?: LuFile[]; updates?: LuFile[]; }, - filter?: (oldList: LuFile[]) => (changeItem: LuFile) => boolean + getLatestFile?: (current: LuFile, changed: LuFile) => LuFile ) => { - return (oldList: LuFile[]) => { - const updates = changes.updates ? (filter ? changes.updates.filter(filter(oldList)) : changes.updates) : []; - const adds = changes.adds ? (filter ? changes.adds.filter(filter(oldList)) : changes.adds) : []; - const deletes = changes.deletes - ? filter - ? changes.deletes.filter(filter(oldList)).map(({ id }) => id) - : changes.deletes.map(({ id }) => id) - : []; - - // updates - let newList = oldList.map((file) => { - const changedFile = updates.find(({ id }) => id === file.id); - return changedFile ?? file; - }); + const { updates, adds, deletes } = changes; - // deletes - newList = newList.filter((file) => !deletes.includes(file.id)); + // updates + updates?.forEach((luFile) => { + set(luFileState({ projectId, luFileId: luFile.id }), (oldLuFile) => + getLatestFile ? getLatestFile(oldLuFile, luFile) : luFile + ); + }); - // adds - newList = adds.concat(newList); + // deletes + if (deletes?.length) { + const deletedIds = deletes.map((file) => file.id); + set(luFileIdsState(projectId), (ids) => ids.filter((id) => !deletedIds.includes(id))); + } - return newList; - }; + // adds + if (adds?.length) { + const addedIds = adds.map((file) => file.id); + adds.forEach((luFile) => { + set(luFileState({ projectId, luFileId: luFile.id }), (oldLuFile) => + getLatestFile ? getLatestFile(oldLuFile, luFile) : luFile + ); + }); + set(luFileIdsState(projectId), (ids) => [...ids, ...addedIds]); + } }; const getRelatedLuFileChanges = async ( luFiles: LuFile[], @@ -119,8 +125,8 @@ export const createLuFileState = async ( callbackHelpers: CallbackInterface, { id, content, projectId }: { id: string; content: string; projectId: string } ) => { - const { set, snapshot } = callbackHelpers; - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const { snapshot } = callbackHelpers; + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const locale = await snapshot.getPromise(localeState(projectId)); const { languages, luFeatures } = await snapshot.getPromise(settingsState(projectId)); const createdLuId = `${id}.${locale}`; @@ -141,15 +147,15 @@ export const createLuFileState = async ( }); }); - set(luFilesState(projectId), luFilesAtomUpdater({ adds: changes })); + updateLuFiles(callbackHelpers, projectId, { adds: changes }); }; export const removeLuFileState = async ( callbackHelpers: CallbackInterface, { id, projectId }: { id: string; projectId: string } ) => { - const { set, snapshot } = callbackHelpers; - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const { snapshot } = callbackHelpers; + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const locale = await snapshot.getPromise(localeState(projectId)); const targetLuFile = luFiles.find((item) => item.id === id) || luFiles.find((item) => item.id === `${id}.${locale}`); @@ -164,7 +170,7 @@ export const removeLuFileState = async ( luFileStatusStorage.removeFileStatus(projectId, targetLuFile.id); } }); - set(luFilesState(projectId), luFilesAtomUpdater({ deletes: [targetLuFile] })); + updateLuFiles(callbackHelpers, projectId, { deletes: [targetLuFile] }); }; export const luDispatcher = () => { @@ -180,19 +186,14 @@ export const luDispatcher = () => { }) => { const { set, snapshot } = callbackHelpers; //set content first - set(luFilesState(projectId), (prevLuFiles) => { - return prevLuFiles.map((file) => { - if (file.id === id) { - return { - ...file, - content, - }; - } - return file; - }); + set(luFileState({ projectId, luFileId: id }), (prevLuFile) => { + return { + ...prevLuFile, + content, + }; }); - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const { luFeatures } = await snapshot.getPromise(settingsState(projectId)); try { @@ -203,19 +204,11 @@ export const luDispatcher = () => { * Why other methods do not need double check content? * Because this method already did set content before call luFilesAtomUpdater. */ - set( - luFilesState(projectId), - luFilesAtomUpdater({ updates: updatedFiles }, (prevLuFiles) => { - const targetInState = prevLuFiles.find((file) => file.id === updatedFile.id); - const targetInCurrentChange = updatedFiles.find((file) => file.id === updatedFile.id); - // compare to drop expired content already setted above. - if (targetInState?.content !== targetInCurrentChange?.content) { - return (luFile) => luFile.id !== updatedFile.id; - } else { - return () => true; - } - }) - ); + updateLuFiles(callbackHelpers, projectId, { updates: updatedFiles }, (current, changed) => { + // compare to drop expired content already setted above. + if (current.id === id && current?.content !== changed?.content) return current; + return changed; + }); } catch (error) { setError(callbackHelpers, error); } @@ -234,8 +227,8 @@ export const luDispatcher = () => { intent: LuIntentSection; projectId: string; }) => { - const { set, snapshot } = callbackHelpers; - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const { snapshot } = callbackHelpers; + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const { luFeatures } = await snapshot.getPromise(settingsState(projectId)); const luFile = luFiles.find((temp) => temp.id === id); @@ -263,8 +256,7 @@ export const luDispatcher = () => { )) as LuFile; changes.push(updatedFile); } - set(luFilesState(projectId), luFilesAtomUpdater({ updates: changes })); - + updateLuFiles(callbackHelpers, projectId, { updates: changes }); // body change, only update current locale file } else { const updatedFile = (await luWorker.updateIntent( @@ -273,7 +265,7 @@ export const luDispatcher = () => { { Body: intent.Body }, luFeatures )) as LuFile; - set(luFilesState(projectId), luFilesAtomUpdater({ updates: [updatedFile] })); + updateLuFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } } catch (error) { setError(callbackHelpers, error); @@ -291,8 +283,8 @@ export const luDispatcher = () => { intent: LuIntentSection; projectId: string; }) => { - const { set, snapshot } = callbackHelpers; - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const { snapshot } = callbackHelpers; + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const { luFeatures } = await snapshot.getPromise(settingsState(projectId)); const file = luFiles.find((temp) => temp.id === id); @@ -300,7 +292,7 @@ export const luDispatcher = () => { try { const updatedFile = (await luWorker.addIntent(file, intent, luFeatures)) as LuFile; const updatedFiles = await getRelatedLuFileChanges(luFiles, updatedFile, projectId, luFeatures); - set(luFilesState(projectId), luFilesAtomUpdater({ updates: updatedFiles })); + updateLuFiles(callbackHelpers, projectId, { updates: updatedFiles }); } catch (error) { setError(callbackHelpers, error); } @@ -317,8 +309,8 @@ export const luDispatcher = () => { intentName: string; projectId: string; }) => { - const { set, snapshot } = callbackHelpers; - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const { snapshot } = callbackHelpers; + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const { luFeatures } = await snapshot.getPromise(settingsState(projectId)); const file = luFiles.find((temp) => temp.id === id); @@ -326,7 +318,7 @@ export const luDispatcher = () => { try { const updatedFile = (await luWorker.removeIntent(file, intentName, luFeatures)) as LuFile; const updatedFiles = await getRelatedLuFileChanges(luFiles, updatedFile, projectId, luFeatures); - set(luFilesState(projectId), luFilesAtomUpdater({ updates: updatedFiles })); + updateLuFiles(callbackHelpers, projectId, { updates: updatedFiles }); } catch (error) { setError(callbackHelpers, error); } diff --git a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts index 459cf85e39..2723307bdf 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts @@ -10,9 +10,9 @@ import languageStorage from '../../utils/languageStorage'; import { getExtension } from '../../utils/fileUtil'; import { localBotsDataSelector, rootBotProjectIdSelector } from '../selectors/project'; import { lgFilesSelectorFamily } from '../selectors/lg'; +import { luFilesSelectorFamily } from '../selectors'; import { - luFilesState, localeState, settingsState, showAddLanguageModalState, @@ -20,7 +20,6 @@ import { onDelLanguageDialogCompleteState, showDelLanguageModalState, botDisplayNameState, - // qnaFilesState, } from './../atoms/botState'; const copyLanguageResources = (files: any[], fromLanguage: string, toLanguages: string[]): any[] => { @@ -93,19 +92,14 @@ export const multilangDispatcher = () => { const onAddLanguageDialogComplete = (await snapshot.getPromise(onAddLanguageDialogCompleteState(projectId))).func; // copy files from default language - set(lgFilesSelectorFamily(projectId), (prevlgFiles) => { - const addedLgFiles = copyLanguageResources(prevlgFiles, defaultLang, languages); - return [...prevlgFiles, ...addedLgFiles]; + set(lgFilesSelectorFamily(projectId), (oldLgFiles) => { + const addedLgFiles = copyLanguageResources(oldLgFiles, defaultLang, languages); + return [...oldLgFiles, ...addedLgFiles]; }); - set(luFilesState(projectId), (prevluFiles) => { + set(luFilesSelectorFamily(projectId), (prevluFiles) => { const addedLuFiles = copyLanguageResources(prevluFiles, defaultLang, languages); return [...prevluFiles, ...addedLuFiles]; }); - //TODO: support QnA multilang in future. - // set(qnaFilesState(projectId), (prevQnAFiles) => { - // const addedQnAFiles = copyLanguageResources(prevQnAFiles, defaultLang, languages); - // return [...prevQnAFiles, ...addedQnAFiles]; - // }); set(settingsState(projectId), (prevSettings) => { const settings: any = cloneDeep(prevSettings); if (Array.isArray(settings.languages)) { @@ -136,18 +130,14 @@ export const multilangDispatcher = () => { const onDelLanguageDialogComplete = (await snapshot.getPromise(onDelLanguageDialogCompleteState(projectId))).func; // copy files from default language - set(lgFilesSelectorFamily(projectId), (prevlgFiles) => { - const { left: leftLgFiles } = deleteLanguageResources(prevlgFiles, languages); + set(lgFilesSelectorFamily(projectId), (prevLgFiles) => { + const { left: leftLgFiles } = deleteLanguageResources(prevLgFiles, languages); return leftLgFiles; }); - set(luFilesState(projectId), (prevluFiles) => { - const { left: leftLuFiles } = deleteLanguageResources(prevluFiles, languages); + set(luFilesSelectorFamily(projectId), (prevLuFiles) => { + const { left: leftLuFiles } = deleteLanguageResources(prevLuFiles, languages); return leftLuFiles; }); - // set(qnaFilesState(projectId), (prevQnAFiles) => { - // const { left: leftQnAFiles } = deleteLanguageResources(prevQnAFiles, languages); - // return leftQnAFiles; - // }); set(settingsState(projectId), (prevSettings) => { const settings: any = cloneDeep(prevSettings); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index a41633c861..3f274f2100 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -533,7 +533,7 @@ export const projectDispatcher = () => { const creationFlowType = await callbackHelpers.snapshot.getPromise(creationFlowTypeState); callbackHelpers.set(botOpeningMessage, response.data.latestMessage); - const { botFiles, projectData } = loadProjectData(response.data.result); + const { botFiles, projectData } = await loadProjectData(response.data.result); const projectId = response.data.result.id; if (creationFlowType === 'Skill') { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts index 8e56a86edc..76a5786352 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts @@ -14,12 +14,15 @@ import { isEjectRuntimeExistState, filePersistenceState, settingsState, - luFilesState, - qnaFilesState, } from '../atoms/botState'; import { openInEmulator } from '../../utils/navigation'; import { botEndpointsState } from '../atoms'; -import { rootBotProjectIdSelector, dialogsSelectorFamily } from '../selectors'; +import { + rootBotProjectIdSelector, + dialogsSelectorFamily, + luFilesSelectorFamily, + qnaFilesSelectorFamily, +} from '../selectors'; import * as luUtil from '../../utils/luUtil'; import { ClientStorage } from '../../utils/storage'; @@ -187,8 +190,8 @@ export const publisherDispatcher = () => { try { const { snapshot } = callbackHelpers; const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); - const luFiles = await snapshot.getPromise(luFilesState(projectId)); - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const referredLuFiles = luUtil.checkLuisBuild(luFiles, dialogs); const response = await httpClient.post(`/publish/${projectId}/publish/${target.name}`, { accessToken: token, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts index 2f37de8cca..5f03be956d 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/qna.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/qna.ts @@ -7,12 +7,13 @@ import { qnaUtil } from '@bfc/indexers'; import qnaWorker from '../parsers/qnaWorker'; import { - qnaFilesState, settingsState, showCreateQnAFromScratchDialogState, showCreateQnAFromUrlDialogState, onCreateQnAFromScratchDialogCompleteState, onCreateQnAFromUrlDialogCompleteState, + qnaFileState, + qnaFileIdsState, } from '../atoms/botState'; import { createQnAOnState } from '../atoms/appState'; import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage'; @@ -24,7 +25,7 @@ import { getQnaPendingNotification, } from '../../utils/notifications'; import httpClient from '../../utils/httpUtil'; -import { rootBotProjectIdSelector } from '../selectors'; +import { qnaFilesSelectorFamily, rootBotProjectIdSelector } from '../selectors'; import { addNotificationInternal, deleteNotificationInternal, createNotification } from './notification'; @@ -36,57 +37,60 @@ import { addNotificationInternal, deleteNotificationInternal, createNotification * */ -const qnaFilesAtomUpdater = ( +const updateQnaFiles = ( + { set }: CallbackInterface, + projectId: string, changes: { adds?: QnAFile[]; deletes?: QnAFile[]; updates?: QnAFile[]; }, - filter?: (oldList: QnAFile[]) => (changeItem: QnAFile) => boolean + getLatestFile?: (current: QnAFile, changed: QnAFile) => QnAFile ) => { - return (oldList: QnAFile[]) => { - const updates = changes.updates ? (filter ? changes.updates.filter(filter(oldList)) : changes.updates) : []; - const adds = changes.adds ? (filter ? changes.adds.filter(filter(oldList)) : changes.adds) : []; - const deletes = changes.deletes - ? filter - ? changes.deletes.filter(filter(oldList)).map(({ id }) => id) - : changes.deletes.map(({ id }) => id) - : []; - - // updates - let newList = oldList.map((file) => { - const changedFile = updates.find(({ id }) => id === file.id); - return changedFile ?? file; - }); + const { updates, adds, deletes } = changes; - // deletes - newList = newList.filter((file) => !deletes.includes(file.id)); + // updates + updates?.forEach((qnaFile) => { + set(qnaFileState({ projectId, qnaFileId: qnaFile.id }), (oldQnaFile) => + getLatestFile ? getLatestFile(oldQnaFile, qnaFile) : qnaFile + ); + }); - // adds - newList = adds.concat(newList); + // deletes + if (deletes?.length) { + const deletedIds = deletes.map((file) => file.id); + set(qnaFileIdsState(projectId), (ids) => ids.filter((id) => !deletedIds.includes(id))); + } - return newList; - }; + // adds + if (adds?.length) { + const addedIds = adds.map((file) => file.id); + adds.forEach((qnaFile) => { + set(qnaFileState({ projectId, qnaFileId: qnaFile.id }), (oldQnaFile) => + getLatestFile ? getLatestFile(oldQnaFile, qnaFile) : qnaFile + ); + }); + set(qnaFileIdsState(projectId), (ids) => [...ids, ...addedIds]); + } }; export const updateQnAFileState = async ( callbackHelpers: CallbackInterface, { id, content, projectId }: { id: string; content: string; projectId: string } ) => { - const { set } = callbackHelpers; //To do: support other languages on qna id = id.endsWith('.source') ? id : `${getBaseName(id)}.en-us`; const updatedQnAFile = (await qnaWorker.parse(id, content)) as QnAFile; - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedQnAFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedQnAFile] }); }; export const createQnAFileState = async ( callbackHelpers: CallbackInterface, { id, content, projectId }: { id: string; content: string; projectId: string } ) => { - const { set, snapshot } = callbackHelpers; - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); //const locale = await snapshot.getPromise(localeState(projectId)); //To do: support other languages on qna const locale = 'en-us'; @@ -107,7 +111,7 @@ export const createQnAFileState = async ( id: fileId, }); }); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ adds: changes })); + updateQnaFiles(callbackHelpers, projectId, { adds: changes }); }; /** @@ -120,8 +124,8 @@ export const removeQnAFileState = async ( callbackHelpers: CallbackInterface, { id, projectId }: { id: string; projectId: string } ) => { - const { set, snapshot } = callbackHelpers; - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); //const locale = await snapshot.getPromise(localeState(projectId)); //To do: support other languages on qna const locale = 'en-us'; @@ -137,15 +141,15 @@ export const removeQnAFileState = async ( qnaFileStatusStorage.removeFileStatus(projectId, targetQnAFile.id); } }); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ deletes: [targetQnAFile] })); + updateQnaFiles(callbackHelpers, projectId, { deletes: [targetQnAFile] }); }; export const createKBFileState = async ( callbackHelpers: CallbackInterface, { id, name, content, projectId }: { id: string; name: string; content: string; projectId: string } ) => { - const { set, snapshot } = callbackHelpers; - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const createdSourceQnAId = `${name}.source`; if (qnaFiles.find((qna) => qna.id === createdSourceQnAId)) { @@ -173,15 +177,15 @@ export const createKBFileState = async ( } qnaFileStatusStorage.updateFileStatus(projectId, createdSourceQnAId); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: updatedQnAFiles, adds: [createdQnAFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: updatedQnAFiles, adds: [createdQnAFile] }); }; export const removeKBFileState = async ( callbackHelpers: CallbackInterface, { id, projectId }: { id: string; projectId: string } ) => { - const { set, snapshot } = callbackHelpers; - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); // const locale = await snapshot.getPromise(localeState(projectId)); //To do: support other languages on qna const locale = 'en-us'; @@ -197,7 +201,7 @@ export const removeKBFileState = async ( qnaFileStatusStorage.removeFileStatus(projectId, targetQnAFile.id); } }); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ deletes: [targetQnAFile] })); + updateQnaFiles(callbackHelpers, projectId, { deletes: [targetQnAFile] }); }; export const renameKBFileState = async ( @@ -205,7 +209,7 @@ export const renameKBFileState = async ( { id, name, projectId }: { id: string; name: string; projectId: string } ) => { const { set, snapshot } = callbackHelpers; - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); //const locale = await snapshot.getPromise(localeState(projectId)); //To do: support other languages const locale = 'en-us'; @@ -223,16 +227,11 @@ export const renameKBFileState = async ( } qnaFileStatusStorage.removeFileStatus(projectId, targetQnAFile.id); - set(qnaFilesState(projectId), (prevQnAFiles) => { - return prevQnAFiles.map((file) => { - if (file.id === targetQnAFile.id) { - return { - ...file, - id: name, - }; - } - return file; - }); + set(qnaFileState({ projectId, qnaFileId: id }), (prevQnAFile) => { + return { + ...prevQnAFile, + id: name, + }; }); }; @@ -465,7 +464,7 @@ ${response.data} ); const updateQnAQuestion = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sectionId, questionId, @@ -478,18 +477,19 @@ ${response.data} content: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; // const updatedFile = await updateQnAFileState(callbackHelpers, { id, content }); const updatedFile = qnaUtil.updateQnAQuestion(qnaFile, sectionId, questionId, content); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const updateQnAAnswer = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sectionId, content, @@ -500,17 +500,18 @@ ${response.data} content: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; const updatedFile = qnaUtil.updateQnAAnswer(qnaFile, sectionId, content); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const createQnAQuestion = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sectionId, content, @@ -521,17 +522,18 @@ ${response.data} content: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; const updatedFile = qnaUtil.createQnAQuestion(qnaFile, sectionId, content); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const removeQnAQuestion = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sectionId, questionId, @@ -542,17 +544,18 @@ ${response.data} questionId: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; const updatedFile = qnaUtil.removeQnAQuestion(qnaFile, sectionId, questionId); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const createQnAPairs = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, content, projectId, @@ -561,18 +564,19 @@ ${response.data} content: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; // insert into head, need investigate const updatedFile = qnaUtil.insertSection(qnaFile, 0, content); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const removeQnAPairs = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sectionId, projectId, @@ -581,17 +585,18 @@ ${response.data} sectionId: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; const updatedFile = qnaUtil.removeSection(qnaFile, sectionId); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const createQnAImport = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sourceId, projectId, @@ -600,16 +605,17 @@ ${response.data} sourceId: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; const updatedFile = qnaUtil.addImport(qnaFile, `${sourceId}.qna`); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const removeQnAImport = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sourceId, projectId, @@ -618,16 +624,17 @@ ${response.data} sourceId: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; const updatedFile = qnaUtil.removeImport(qnaFile, `${sourceId}.qna`); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const updateQnAImport = useRecoilCallback( - ({ set, snapshot }: CallbackInterface) => async ({ + (callbackHelpers: CallbackInterface) => async ({ id, sourceId, newSourceId, @@ -638,13 +645,14 @@ ${response.data} newSourceId: string; projectId: string; }) => { - const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId)); + const { snapshot } = callbackHelpers; + const qnaFiles = await snapshot.getPromise(qnaFilesSelectorFamily(projectId)); const qnaFile = qnaFiles.find((temp) => temp.id === id); if (!qnaFile) return qnaFiles; let updatedFile = qnaUtil.removeImport(qnaFile, `${sourceId}.qna`); updatedFile = qnaUtil.addImport(updatedFile, `${newSourceId}.qna`); - set(qnaFilesState(projectId), qnaFilesAtomUpdater({ updates: [updatedFile] })); + updateQnaFiles(callbackHelpers, projectId, { updates: [updatedFile] }); } ); const removeQnAKB = useRecoilCallback( diff --git a/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts b/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts index a1104ae9e4..38a0a8897c 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts @@ -5,8 +5,8 @@ import { useRecoilCallback, CallbackInterface } from 'recoil'; import { BaseSchema, deleteActions, ITriggerCondition, LgTemplate, LgTemplateSamples, SDKKinds } from '@bfc/shared'; import get from 'lodash/get'; -import { luFilesState, schemasState, dialogState, localeState } from '../atoms/botState'; -import { dialogsSelectorFamily } from '../selectors'; +import { schemasState, dialogState, localeState } from '../atoms/botState'; +import { dialogsSelectorFamily, luFilesSelectorFamily } from '../selectors'; import { onChooseIntentKey, generateNewDialog, @@ -49,7 +49,7 @@ export const triggerDispatcher = () => { const { snapshot } = callbackHelpers; const dispatcher = await snapshot.getPromise(dispatcherState); const lgFiles = await snapshot.getPromise(lgFilesSelectorFamily(projectId)); - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); const dialog = await snapshot.getPromise(dialogState({ projectId, dialogId })); const schemas = await snapshot.getPromise(schemasState(projectId)); @@ -130,7 +130,7 @@ export const triggerDispatcher = () => { const { snapshot } = callbackHelpers; const dispatcher = await snapshot.getPromise(dispatcherState); const locale = await snapshot.getPromise(localeState(projectId)); - const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesSelectorFamily(projectId)); const luFile = luFiles.find((file) => file.id === `${dialogId}.${locale}`); const { removeLuIntent, removeLgTemplates } = dispatcher; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts index 34ec545479..9b2c140c43 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts @@ -3,7 +3,7 @@ import path from 'path'; -import { indexer } from '@bfc/indexers'; +import { BotIndexer, indexer } from '@bfc/indexers'; import { BotProjectFile, BotProjectSpace, @@ -62,9 +62,7 @@ import { jsonSchemaFilesState, localeState, locationState, - luFilesState, projectMetaDataState, - qnaFilesState, recentProjectsState, schemasState, settingsState, @@ -80,30 +78,22 @@ import lgWorker from '../../parsers/lgWorker'; import luWorker from '../../parsers/luWorker'; import qnaWorker from '../../parsers/qnaWorker'; import FilePersistence from '../../persistence/FilePersistence'; -import { botRuntimeOperationsSelector, rootBotProjectIdSelector } from '../../selectors'; +import { + botRuntimeOperationsSelector, + luFilesSelectorFamily, + qnaFilesSelectorFamily, + rootBotProjectIdSelector, +} from '../../selectors'; import { undoHistoryState } from '../../undo/history'; import UndoHistory from '../../undo/undoHistory'; import { logMessage, setError } from '../shared'; import { setRootBotSettingState } from '../setting'; import { lgFilesSelectorFamily } from '../../selectors/lg'; -import { createMissingLgTemplatesForDialogs } from '../../../utils/lgUtil'; import { getPublishProfileFromPayload } from '../../../utils/electronUtil'; -import { crossTrainConfigState } from './../../atoms/botState'; +import { crossTrainConfigState, projectIndexingState } from './../../atoms/botState'; import { recognizersSelectorFamily } from './../../selectors/recognizers'; -const repairBotProject = async ( - callbackHelpers: CallbackInterface, - { projectId, botFiles }: { projectId: string; botFiles: any } -) => { - const { set } = callbackHelpers; - const lgFiles: LgFile[] = botFiles.lgFiles; - const dialogs: DialogInfo[] = botFiles.dialogs; - - const updatedLgFiles = await createMissingLgTemplatesForDialogs(projectId, dialogs, lgFiles); - set(lgFilesSelectorFamily(projectId), updatedLgFiles); -}; - export const resetBotStates = async ({ reset }: CallbackInterface, projectId: string) => { const botStates = Object.keys(botstates); botStates.forEach((state) => { @@ -239,19 +229,115 @@ export const navigateToSkillBot = (rootProjectId: string, skillId: string, mainD } }; -export const loadProjectData = (data) => { +const emptyLgFile = (id: string, content: string): LgFile => { + return { + id, + content, + diagnostics: [], + templates: [], + allTemplates: [], + imports: [], + isContentUnparsed: true, + }; +}; + +const emptyLuFile = (id: string, content: string): LuFile => { + return { + id, + content, + diagnostics: [], + intents: [], + empty: true, + resource: { + Sections: [], + Errors: [], + Content: '', + }, + imports: [], + isContentUnparsed: true, + }; +}; + +const emptyQnaFile = (id: string, content: string): QnAFile => { + return { + id, + content, + diagnostics: [], + qnaSections: [], + imports: [], + options: [], + empty: true, + resource: { + Sections: [], + Errors: [], + Content: '', + }, + isContentUnparsed: true, + }; +}; + +const parseAllAssets = async ({ set }: CallbackInterface, projectId: string, botFiles: any) => { + const { luFiles, lgFiles, qnaFiles, mergedSettings } = botFiles; + + const [parsedLgFiles, parsedLuFiles, parsedQnaFiles] = await Promise.all([ + lgWorker.parseAll(projectId, lgFiles), + luWorker.parseAll(luFiles, mergedSettings.luFeatures), + qnaWorker.parseAll(qnaFiles), + ]); + + set(lgFilesSelectorFamily(projectId), (oldFiles) => { + return oldFiles.map((item) => { + const file = (parsedLgFiles as LgFile[]).find((file) => file.id === item.id); + return file && item.isContentUnparsed ? file : item; + }); + }); + + set(luFilesSelectorFamily(projectId), (oldFiles) => { + return oldFiles.map((item) => { + const file = (parsedLuFiles as LuFile[]).find((file) => file.id === item.id); + return file && item.isContentUnparsed ? file : item; + }); + }); + + set(qnaFilesSelectorFamily(projectId), (oldFiles) => { + return oldFiles.map((item) => { + const file = (parsedQnaFiles as QnAFile[]).find((file) => file.id === item.id); + return file && item.isContentUnparsed ? file : item; + }); + }); + + set(projectIndexingState(projectId), false); +}; + +export const loadProjectData = async (data) => { const { files, botName, settings, id: projectId } = data; const mergedSettings = getMergedSettings(projectId, settings, botName); - const storedLocale = languageStorage.get(botName)?.locale; - const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage; - const indexedFiles = indexer.index(files, botName, locale, mergedSettings); + const indexedFiles = indexer.index(files, botName); + + const { lgResources, luResources, qnaResources } = indexedFiles; + //parse all resources with worker + lgWorker.addProject(projectId); + + const lgFiles = lgResources.map(({ id, content }) => emptyLgFile(id, content)); + const luFiles = luResources.map(({ id, content }) => emptyLuFile(id, content)); + const qnaFiles = qnaResources.map(({ id, content }) => emptyQnaFile(id, content)); // migrate script move qna pairs in *.qna to *-manual.source.qna. // TODO: remove after a period of time. - const updateQnAFiles = reformQnAToContainerKB(projectId, indexedFiles.qnaFiles); + const updateQnAFiles = reformQnAToContainerKB(projectId, qnaFiles); + + const assets = { ...indexedFiles, lgFiles, luFiles, qnaFiles: updateQnAFiles }; + //Validate all files + const diagnostics = BotIndexer.validate({ + ...assets, + setting: settings, + botProjectFile: assets.botProjectSpaceFiles[0], + }); + + const botFiles = { ...assets, mergedSettings, diagnostics }; return { - botFiles: { ...indexedFiles, qnaFiles: updateQnAFiles, mergedSettings }, + botFiles, projectData: data, error: undefined, }; @@ -263,7 +349,7 @@ export const fetchProjectDataByPath = async ( ): Promise<{ botFiles: any; projectData: any; error: any }> => { try { const response = await httpClient.put(`/projects/open`, { path, storageId }); - const projectData = loadProjectData(response.data); + const projectData = await loadProjectData(response.data); return projectData; } catch (ex) { return { @@ -277,7 +363,7 @@ export const fetchProjectDataByPath = async ( export const fetchProjectDataById = async (projectId): Promise<{ botFiles: any; projectData: any; error: any }> => { try { const response = await httpClient.get(`/projects/${projectId}`); - const projectData = loadProjectData(response.data); + const projectData = await loadProjectData(response.data); return projectData; } catch (ex) { return { @@ -389,8 +475,6 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any set(recognizersSelectorFamily(projectId), recognizers); set(crossTrainConfigState(projectId), crossTrainConfig); - await lgWorker.addProject(projectId, lgFiles); - // Form dialogs set( formDialogSchemaIdsState(projectId), @@ -401,14 +485,14 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any }); set(skillManifestsState(projectId), skillManifests); - set(luFilesState(projectId), initLuFilesStatus(botName, luFiles, dialogs)); + set(luFilesSelectorFamily(projectId), initLuFilesStatus(botName, luFiles, dialogs)); set(lgFilesSelectorFamily(projectId), lgFiles); set(jsonSchemaFilesState(projectId), jsonSchemaFiles); set(dialogSchemasState(projectId), dialogSchemas); set(botEnvironmentState(projectId), botEnvironment); set(botDisplayNameState(projectId), botName); - set(qnaFilesState(projectId), initQnaFilesStatus(botName, qnaFiles, dialogs)); + set(qnaFilesSelectorFamily(projectId), initQnaFilesStatus(botName, qnaFiles, dialogs)); set(botStatusState(projectId), BotStatus.inactive); set(locationState(projectId), location); set(schemasState(projectId), schemas); @@ -419,9 +503,8 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any set(filePersistenceState(projectId), new FilePersistence(projectId)); set(undoHistoryState(projectId), new UndoHistory(projectId)); - - // async repair bot assets, add missing lg templates - repairBotProject(callbackHelpers, { projectId, botFiles }); + set(projectIndexingState(projectId), true); + parseAllAssets(callbackHelpers, projectId, botFiles); return mainDialog; }; @@ -529,7 +612,7 @@ export const createNewBotFromTemplate = async ( alias, preserveRoot, }); - const { botFiles, projectData } = loadProjectData(response.data); + const { botFiles, projectData } = await loadProjectData(response.data); const projectId = response.data.id; if (settingStorage.get(projectId)) { settingStorage.remove(projectId); @@ -757,7 +840,7 @@ export const saveProject = async (callbackHelpers, oldProjectData) => { description, location, }); - const data = loadProjectData(response.data); + const data = await loadProjectData(response.data); if (data.error) { throw data.error; } diff --git a/Composer/packages/client/src/recoilModel/parsers/__test__/lgWorker.test.ts b/Composer/packages/client/src/recoilModel/parsers/__test__/lgWorker.test.ts index 1d8e6fc5de..4b08dca445 100644 --- a/Composer/packages/client/src/recoilModel/parsers/__test__/lgWorker.test.ts +++ b/Composer/packages/client/src/recoilModel/parsers/__test__/lgWorker.test.ts @@ -24,6 +24,7 @@ const lgFiles = [ { id: 'common.en-us', content: `\r\n# Hello\r\n-hi`, + isContentUnparsed: true, }, ] as LgFile[]; @@ -35,7 +36,7 @@ const getLgTemplate = (name, body): LgTemplate => describe('test lg worker', () => { it('cache the new project', async () => { - await lgWorker.addProject('test', lgFiles); + await lgWorker.addProject('test'); expect(lgCache.projects.has('test')).toBeTruthy(); }); diff --git a/Composer/packages/client/src/recoilModel/parsers/__test__/qnaWorker.test.ts b/Composer/packages/client/src/recoilModel/parsers/__test__/qnaWorker.test.ts new file mode 100644 index 0000000000..0e699905e7 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/parsers/__test__/qnaWorker.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import qnaWorker from '../qnaWorker'; + +jest.mock('./../workers/qnaParser.worker.ts', () => { + class Test { + onmessage = (data) => data; + + postMessage = (data) => { + const payload = require('../workers/qnaParser.worker').handleMessage(data); + this.onmessage({ data: { id: data.id, payload } }); + }; + } + + return Test; +}); + +describe('test qna worker', () => { + it('get expected parse result', async () => { + const content = `# ? Hi + ${'```'} + Hello + ${'```'}`; + const result: any = await qnaWorker.parse('', content); + expect(result.qnaSections.length).toBe(1); + expect(result.isContentUnparsed).toBe(false); + }); + + it('get expected parse result', async () => { + const content = `# ? Hi + ${'```'} + Hello + ${'```'}`; + const content1 = `# ? How + ${'```'} + No + ${'```'}`; + const result: any = await qnaWorker.parseAll([ + { + id: '1', + content, + }, + { + id: '2', + content: content1, + }, + ]); + expect(result[0].qnaSections.length).toBe(1); + }); +}); diff --git a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts index b808f66d49..83a246df1a 100644 --- a/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/lgWorker.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { LgFile, LgTemplate } from '@bfc/shared'; +import { LgFile, LgTemplate, TextFile } from '@bfc/shared'; import Worker from './workers/lgParser.worker.ts'; import { BaseWorker } from './baseWorker'; @@ -15,12 +15,13 @@ import { LgCopyTemplatePayload, LgNewCachePayload, LgCleanCachePayload, + LgParseAllPayload, } from './types'; // Wrapper class class LgWorker extends BaseWorker { - addProject(projectId: string, lgFiles: LgFile[]) { - return this.sendMsg(LgActionType.NewCache, { projectId, lgFiles }); + addProject(projectId: string) { + return this.sendMsg(LgActionType.NewCache, { projectId }); } removeProject(projectId: string) { @@ -31,6 +32,10 @@ class LgWorker extends BaseWorker { return this.sendMsg(LgActionType.Parse, { id, content, lgFiles, projectId }); } + parseAll(projectId: string, lgResources: TextFile[]) { + return this.sendMsg(LgActionType.ParseAll, { lgResources, projectId }); + } + addTemplate(projectId: string, lgFile: LgFile, template: LgTemplate, lgFiles: LgFile[]) { return this.sendMsg(LgActionType.AddTemplate, { lgFile, template, lgFiles, projectId }); } diff --git a/Composer/packages/client/src/recoilModel/parsers/luWorker.ts b/Composer/packages/client/src/recoilModel/parsers/luWorker.ts index fd43f85c8b..0b15f1b4b5 100644 --- a/Composer/packages/client/src/recoilModel/parsers/luWorker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/luWorker.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { LuIntentSection, LuFile } from '@bfc/shared'; +import { LuIntentSection, LuFile, TextFile } from '@bfc/shared'; import Worker from './workers/luParser.worker.ts'; import { BaseWorker } from './baseWorker'; @@ -12,6 +12,7 @@ import { LuRemoveIntentsPayload, LuRemoveIntentPayload, LuUpdateIntentPayload, + LuParseAllPayload, } from './types'; // Wrapper class class LuWorker extends BaseWorker { @@ -20,6 +21,11 @@ class LuWorker extends BaseWorker { return this.sendMsg(LuActionType.Parse, payload); } + parseAll(luResources: TextFile[], luFeatures) { + const payload = { luResources, luFeatures }; + return this.sendMsg(LuActionType.ParseAll, payload); + } + addIntent(luFile: LuFile, intent: LuIntentSection, luFeatures) { const payload = { luFile, intent, luFeatures }; return this.sendMsg(LuActionType.AddIntent, payload); diff --git a/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts b/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts index 44bef644c0..134b1b05de 100644 --- a/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/qnaWorker.ts @@ -1,30 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { TextFile } from '@bfc/shared'; + import Worker from './workers/qnaParser.worker.ts'; import { BaseWorker } from './baseWorker'; -import { QnAPayload, QnAActionType } from './types'; +import { QnAActionType, QnAParseAllPayload, QnAParsePayload } from './types'; // Wrapper class class QnAWorker extends BaseWorker { parse(id: string, content: string) { const payload = { id, content }; - return this.sendMsg(QnAActionType.Parse, payload); - } - - addSection(content: string, newContent: string) { - const payload = { content, newContent }; - return this.sendMsg(QnAActionType.AddSection, payload); - } - - updateSection(indexId: number, content: string, newContent: string) { - const payload = { indexId, content, newContent }; - return this.sendMsg(QnAActionType.UpdateSection, payload); + return this.sendMsg(QnAActionType.Parse, payload); } - removeSection(indexId: number, content: string) { - const payload = { content, indexId }; - return this.sendMsg(QnAActionType.RemoveSection, payload); + parseAll(qnaResources: TextFile[]) { + const payload = { qnaResources }; + return this.sendMsg(QnAActionType.ParseAll, payload); } } diff --git a/Composer/packages/client/src/recoilModel/parsers/types.ts b/Composer/packages/client/src/recoilModel/parsers/types.ts index 77311b6dfd..34a74a85de 100644 --- a/Composer/packages/client/src/recoilModel/parsers/types.ts +++ b/Composer/packages/client/src/recoilModel/parsers/types.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { LuIntentSection, LgFile, LuFile, QnASection, FileInfo, LgTemplate, ILUFeaturesConfig } from '@bfc/shared'; +import { LuIntentSection, LgFile, LuFile, FileInfo, LgTemplate, ILUFeaturesConfig, TextFile } from '@bfc/shared'; import { FileAsset } from '../persistence/types'; @@ -10,6 +10,11 @@ export type LuParsePayload = { luFeatures: ILUFeaturesConfig; }; +export type LuParseAllPayload = { + luResources: TextFile[]; + luFeatures: ILUFeaturesConfig; +}; + export type LuAddIntentPayload = { luFile: LuFile; intent: LuIntentSection; @@ -86,13 +91,17 @@ export interface LgRemoveAllTemplatesPayload { export interface LgNewCachePayload { projectId: string; - lgFiles: LgFile[]; } export interface LgCleanCachePayload { projectId: string; } +export type LgParseAllPayload = { + projectId: string; + lgResources: TextFile[]; +}; + export interface LgCopyTemplatePayload { projectId: string; lgFile: LgFile; @@ -109,10 +118,13 @@ export type IndexPayload = { luFeatures: { key: string; value: boolean }; }; -export type QnAPayload = { +export type QnAParsePayload = { content: string; - id?: string; - section?: QnASection; + id: string; +}; + +export type QnAParseAllPayload = { + qnaResources: TextFile[]; }; export enum LuActionType { @@ -122,6 +134,7 @@ export enum LuActionType { RemoveIntent = 'remove-intent', AddIntents = 'add-intents', RemoveIntents = 'remove-intents', + ParseAll = 'parse-all', } export enum LgActionType { @@ -134,6 +147,7 @@ export enum LgActionType { RemoveTemplate = 'remove-template', RemoveAllTemplates = 'remove-all-templates', CopyTemplate = 'copy-template', + ParseAll = 'parse-all', } export enum IndexerActionType { @@ -142,9 +156,7 @@ export enum IndexerActionType { export enum QnAActionType { Parse = 'parse', - AddSection = 'add-section', - UpdateSection = 'update-section', - RemoveSection = 'remove-section', + ParseAll = 'parse-all', } export type FilesDifferencePayload = { diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/calculator.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/calculator.worker.ts index 610da18579..b724a07bda 100644 --- a/Composer/packages/client/src/recoilModel/parsers/workers/calculator.worker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/workers/calculator.worker.ts @@ -17,9 +17,18 @@ type MessageEvent = DifferenceMessage; const ctx: Worker = self as any; +const getCompareFields = (value: FileAsset) => { + const { id, content } = value; + return { id, content }; +}; + +const comparator = (value: FileAsset, other: FileAsset) => { + return isEqual(getCompareFields(value), getCompareFields(other)); +}; + export function getDifferenceItems(target: FileAsset[], origin: FileAsset[]) { - const changes1 = differenceWith(target, origin, isEqual); - const changes2 = differenceWith(origin, target, isEqual); + const changes1 = differenceWith(target, origin, comparator); + const changes2 = differenceWith(origin, target, comparator); const deleted = changes2.filter((change) => !target.some((file) => change.id === file.id)); const { updated, added } = changes1.reduce( (result: { updated: FileAsset[]; added: FileAsset[] }, change) => { diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts index 67bc1377f4..9003ec80dd 100644 --- a/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/workers/lgParser.worker.ts @@ -14,6 +14,7 @@ import { LgCopyTemplatePayload, LgNewCachePayload, LgCleanCachePayload, + LgParseAllPayload, } from '../types'; const ctx: Worker = self as any; @@ -78,6 +79,12 @@ interface CleanCacheMeassage { payload: LgCleanCachePayload; } +type ParseAllMessage = { + id: string; + type: LgActionType.ParseAll; + payload: LgParseAllPayload; +}; + type LgMessageEvent = | NewCacheMessage | CleanCacheMeassage @@ -87,7 +94,8 @@ type LgMessageEvent = | UpdateMessage | RemoveMessage | RemoveAllMessage - | CopyMessage; + | CopyMessage + | ParseAllMessage; type LgResources = Map; @@ -127,11 +135,8 @@ export class LgCache { this.projects.delete(projectId); } - public addProject(projectId: string, lgFiles: LgFile[]) { + public addProject(projectId: string) { const lgResources = new Map(); - lgFiles.forEach((file) => { - lgResources.set(file.id, lgUtil.parse(file.id, file.content, lgFiles)); - }); this.projects.set(projectId, lgResources); } } @@ -159,8 +164,8 @@ export const handleMessage = (msg: LgMessageEvent) => { let payload: any = null; switch (msg.type) { case LgActionType.NewCache: { - const { projectId, lgFiles } = msg.payload; - cache.addProject(projectId, lgFiles); + const { projectId } = msg.payload; + cache.addProject(projectId); break; } @@ -179,6 +184,18 @@ export const handleMessage = (msg: LgMessageEvent) => { break; } + case LgActionType.ParseAll: { + const { lgResources, projectId } = msg.payload; + + payload = lgResources.map(({ id, content }) => { + const lgFile = lgUtil.parse(id, content, lgResources); + cache.set(projectId, lgFile); + return filterParseResult(lgFile); + }); + + break; + } + case LgActionType.AddTemplate: { const { lgFile, template, lgFiles, projectId } = msg.payload; const result = lgUtil.addTemplate(getTargetFile(projectId, lgFile), template, lgFileResolver(lgFiles)); diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts index c304e994f9..97872fadf3 100644 --- a/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/workers/luParser.worker.ts @@ -11,6 +11,7 @@ import { LuUpdateIntentPayload, LuAddIntentsPayload, LuAddIntentPayload, + LuParseAllPayload, } from '../types'; const ctx: Worker = self as any; @@ -50,7 +51,20 @@ interface RemoveIntentsMessage { payload: LuRemoveIntentsPayload; } -type LuMessageEvent = ParseMessage | AddMessage | AddsMessage | UpdateMessage | RemoveMessage | RemoveIntentsMessage; +type ParseAllMessage = { + id: string; + type: LuActionType.ParseAll; + payload: LuParseAllPayload; +}; + +type LuMessageEvent = + | ParseMessage + | AddMessage + | AddsMessage + | UpdateMessage + | RemoveMessage + | RemoveIntentsMessage + | ParseAllMessage; export const handleMessage = (msg: LuMessageEvent) => { let result: any = null; @@ -61,6 +75,12 @@ export const handleMessage = (msg: LuMessageEvent) => { break; } + case LuActionType.ParseAll: { + const { luResources, luFeatures } = msg.payload; + result = luResources.map(({ id, content }) => luUtil.parse(id, content, luFeatures)); + break; + } + case LuActionType.AddIntent: { const { luFile, intent, luFeatures } = msg.payload; result = luUtil.addIntent(luFile, intent, luFeatures); diff --git a/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts b/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts index cc463d5f9d..48367856e8 100644 --- a/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts +++ b/Composer/packages/client/src/recoilModel/parsers/workers/qnaParser.worker.ts @@ -2,34 +2,47 @@ // Licensed under the MIT License. import * as qnaUtil from '@bfc/indexers/lib/utils/qnaUtil'; -import { QnAActionType } from './../types'; +import { QnAActionType, QnAParseAllPayload, QnAParsePayload } from './../types'; const ctx: Worker = self as any; -ctx.onmessage = function (msg) { - const { id: msgId, type, payload } = msg.data; - const { content, id, file, indexId } = payload; +type ParseMessage = { + id: string; + type: QnAActionType.Parse; + payload: QnAParsePayload; +}; + +type ParseAllMessage = { + id: string; + type: QnAActionType.ParseAll; + payload: QnAParseAllPayload; +}; + +type QnaMessageEvent = ParseMessage | ParseAllMessage; + +export const handleMessage = (msg: QnaMessageEvent) => { let result: any = null; - try { - switch (type) { - case QnAActionType.Parse: { - result = qnaUtil.parse(id, content); - break; - } - case QnAActionType.AddSection: { - result = qnaUtil.addSection(file.content, content); - break; - } - case QnAActionType.UpdateSection: { - result = qnaUtil.updateSection(indexId, file.content, content); - break; - } - case QnAActionType.RemoveSection: { - result = qnaUtil.removeSection(indexId, file.content); - break; - } + switch (msg.type) { + case QnAActionType.Parse: { + const { id, content } = msg.payload; + result = qnaUtil.parse(id, content); + break; } - ctx.postMessage({ id: msgId, payload: result }); + + case QnAActionType.ParseAll: { + const { qnaResources } = msg.payload; + result = qnaResources.map(({ id, content }) => qnaUtil.parse(id, content)); + break; + } + } + return result; +}; + +ctx.onmessage = function (event) { + const msg = event.data as QnaMessageEvent; + try { + const payload = handleMessage(msg); + ctx.postMessage({ id: msg.id, payload }); } catch (error) { - ctx.postMessage({ id: msgId, error: error.message }); + ctx.postMessage({ id: msg.id, error: error.message }); } }; diff --git a/Composer/packages/client/src/recoilModel/selectors/diagnosticsPageSelector.ts b/Composer/packages/client/src/recoilModel/selectors/diagnosticsPageSelector.ts index b3ee13ac74..9d55b64ddc 100644 --- a/Composer/packages/client/src/recoilModel/selectors/diagnosticsPageSelector.ts +++ b/Composer/packages/client/src/recoilModel/selectors/diagnosticsPageSelector.ts @@ -8,7 +8,7 @@ import formatMessage from 'format-message'; import { getReferredLuFiles } from '../../utils/luUtil'; import { INavTreeItem } from '../../components/NavTree'; -import { botDisplayNameState, dialogIdsState, qnaFilesState } from '../atoms/botState'; +import { botDisplayNameState, dialogIdsState } from '../atoms/botState'; import { DialogDiagnostic, LgDiagnostic, @@ -25,7 +25,6 @@ import { botProjectIdsState, dialogSchemasState, jsonSchemaFilesState, - luFilesState, projectMetaDataState, settingsState, skillManifestsState, @@ -36,6 +35,8 @@ import { formDialogSchemasSelectorFamily, rootBotProjectIdSelector } from './pro import { recognizersSelectorFamily } from './recognizers'; import { dialogDiagnosticsSelectorFamily, dialogsWithLuProviderSelectorFamily } from './validatedDialogs'; import { lgFilesSelectorFamily } from './lg'; +import { luFilesSelectorFamily } from './lu'; +import { qnaFilesSelectorFamily } from './qna'; export const botAssetsSelectFamily = selectorFamily({ key: 'botAssetsSelectFamily', @@ -44,12 +45,12 @@ export const botAssetsSelectFamily = selectorFamily({ if (!projectsMetaData || projectsMetaData.isRemote) return null; const dialogs = get(dialogsWithLuProviderSelectorFamily(projectId)); - const luFiles = get(luFilesState(projectId)); + const luFiles = get(luFilesSelectorFamily(projectId)); const lgFiles = get(lgFilesSelectorFamily(projectId)); const setting = get(settingsState(projectId)); const skillManifests = get(skillManifestsState(projectId)); const dialogSchemas = get(dialogSchemasState(projectId)); - const qnaFiles = get(qnaFilesState(projectId)); + const qnaFiles = get(qnaFilesSelectorFamily(projectId)); const formDialogSchemas = get(formDialogSchemasSelectorFamily(projectId)); const botProjectFile = get(botProjectFileState(projectId)); const jsonSchemaFiles = get(jsonSchemaFilesState(projectId)); diff --git a/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts b/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts index f0cc3b176e..a487d90f05 100644 --- a/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts +++ b/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts @@ -6,9 +6,10 @@ import uniqBy from 'lodash/uniqBy'; import { selectorFamily } from 'recoil'; import { getBaseName } from '../../utils/fileUtil'; -import { localeState, luFilesState } from '../atoms'; +import { localeState } from '../atoms'; import { lgFilesSelectorFamily } from './lg'; +import { luFilesSelectorFamily } from './lu'; // Finds all the file imports starting from a given dialog file. export const getLanguageFileImports = ( @@ -75,7 +76,7 @@ export const luImportsSelectorFamily = selectorFamily - get(luFilesState(projectId)).find((f) => f.id === fileId || f.id === `${fileId}.${locale}`) as LuFile; + get(luFilesSelectorFamily(projectId)).find((f) => f.id === fileId || f.id === `${fileId}.${locale}`) as LuFile; return getLanguageFileImports(dialogId, getFile); }, diff --git a/Composer/packages/client/src/recoilModel/selectors/index.ts b/Composer/packages/client/src/recoilModel/selectors/index.ts index dbf78c2b31..25164ad6fe 100644 --- a/Composer/packages/client/src/recoilModel/selectors/index.ts +++ b/Composer/packages/client/src/recoilModel/selectors/index.ts @@ -11,3 +11,5 @@ export * from './skills'; export * from './localRuntimeBuilder'; export * from './diagnosticsPageSelector'; export * from './lg'; +export * from './lu'; +export * from './qna'; diff --git a/Composer/packages/client/src/recoilModel/selectors/lg.ts b/Composer/packages/client/src/recoilModel/selectors/lg.ts index 45fb854e85..c4a45d7694 100644 --- a/Composer/packages/client/src/recoilModel/selectors/lg.ts +++ b/Composer/packages/client/src/recoilModel/selectors/lg.ts @@ -19,7 +19,6 @@ export const lgFilesSelectorFamily = selectorFamily({ }, set: (projectId: string) => ({ set }, newLgFiles) => { const newLgFileArray = newLgFiles as LgFile[]; - set( lgFileIdsState(projectId), newLgFileArray.map((lgFile) => lgFile.id) diff --git a/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts b/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts index bcc270fcb1..31f0d36666 100644 --- a/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts +++ b/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts @@ -7,21 +7,15 @@ import { checkForPVASchema } from '@bfc/shared'; import { BotStatus } from '../../constants'; import { isAbsHosted } from '../../utils/envUtil'; -import { - botDisplayNameState, - botStatusState, - dispatcherState, - luFilesState, - qnaFilesState, - schemasState, - settingsState, -} from '../atoms'; +import { botDisplayNameState, botStatusState, dispatcherState, schemasState, settingsState } from '../atoms'; import { Dispatcher } from '../dispatchers'; import { isBuildConfigComplete as isBuildConfigurationComplete, needsBuild } from '../../utils/buildUtil'; import { getSensitiveProperties } from '../dispatchers/utils/project'; import { dialogsSelectorFamily } from './dialogs'; import { localBotsWithoutErrorsSelector, rootBotProjectIdSelector } from './project'; +import { luFilesSelectorFamily } from './lu'; +import { qnaFilesSelectorFamily } from './qna'; export const trackBotStatusesSelector = selectorFamily({ key: 'trackBotStatusesSelector', @@ -54,8 +48,8 @@ export const buildEssentialsSelector = selectorFamily({ qna: settings.qna, }; const dialogs = get(dialogsSelectorFamily(projectId)); - const luFiles = get(luFilesState(projectId)); - const qnaFiles = get(qnaFilesState(projectId)); + const luFiles = get(luFilesSelectorFamily(projectId)); + const qnaFiles = get(qnaFilesSelectorFamily(projectId)); const buildRequired = get(botBuildRequiredSelector(projectId)); const status = get(botStatusState(projectId)); diff --git a/Composer/packages/client/src/recoilModel/selectors/lu.ts b/Composer/packages/client/src/recoilModel/selectors/lu.ts new file mode 100644 index 0000000000..555118b23e --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/lu.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { LuFile } from '@bfc/shared'; +import { selectorFamily, DefaultValue } from 'recoil'; + +import { luFileIdsState, luFileState } from '../atoms'; + +export const luFilesSelectorFamily = selectorFamily({ + key: 'luFiles', + get: (projectId: string) => ({ get }) => { + const luFileIds = get(luFileIdsState(projectId)); + + return luFileIds.map((luFileId) => { + return get(luFileState({ projectId, luFileId })); + }); + }, + set: (projectId: string) => ({ set }, newLuFiles: LuFile[] | DefaultValue) => { + if (newLuFiles instanceof DefaultValue) return; + set( + luFileIdsState(projectId), + newLuFiles.map((luFile) => luFile.id) + ); + newLuFiles.forEach((luFile) => set(luFileState({ projectId, luFileId: luFile.id }), luFile)); + }, +}); diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts index e049c5d721..0de67fe70f 100644 --- a/Composer/packages/client/src/recoilModel/selectors/project.ts +++ b/Composer/packages/client/src/recoilModel/selectors/project.ts @@ -16,8 +16,6 @@ import { botProjectIdsState, formDialogSchemaIdsState, formDialogSchemaState, - luFilesState, - qnaFilesState, skillManifestsState, dialogSchemasState, jsonSchemaFilesState, @@ -37,6 +35,8 @@ import { buildEssentialsSelector, lgImportsSelectorFamily, luImportsSelectorFamily, + luFilesSelectorFamily, + qnaFilesSelectorFamily, dialogsWithLuProviderSelectorFamily, } from '../selectors'; @@ -132,9 +132,9 @@ export const botProjectSpaceSelector = selector({ const result = botProjects.map((projectId: string) => { const { isRemote, isRootBot } = get(projectMetaDataState(projectId)); const dialogs = get(dialogsWithLuProviderSelectorFamily(projectId)); - const luFiles = get(luFilesState(projectId)); + const luFiles = get(luFilesSelectorFamily(projectId)); const lgFiles = get(lgFilesSelectorFamily(projectId)); - const qnaFiles = get(qnaFilesState(projectId)); + const qnaFiles = get(qnaFilesSelectorFamily(projectId)); const formDialogSchemas = get(formDialogSchemasSelectorFamily(projectId)); const botProjectFile = get(botProjectFileState(projectId)); const metaData = get(projectMetaDataState(projectId)); @@ -221,12 +221,12 @@ export const perProjectDiagnosticsSelectorFamily = selectorFamily({ const rootSetting = get(settingsState(rootBotId)); const dialogs = get(dialogsWithLuProviderSelectorFamily(projectId)); const formDialogSchemas = get(formDialogSchemasSelectorFamily(projectId)); - const luFiles = get(luFilesState(projectId)); + const luFiles = get(luFilesSelectorFamily(projectId)); const lgFiles = get(lgFilesSelectorFamily(projectId)); const setting = get(settingsState(projectId)); const skillManifests = get(skillManifestsState(projectId)); const dialogSchemas = get(dialogSchemasState(projectId)); - const qnaFiles = get(qnaFilesState(projectId)); + const qnaFiles = get(qnaFilesSelectorFamily(projectId)); const botProjectFile = get(botProjectFileState(projectId)); const jsonSchemaFiles = get(jsonSchemaFilesState(projectId)); const botAssets: BotAssets = { diff --git a/Composer/packages/client/src/recoilModel/selectors/qna.ts b/Composer/packages/client/src/recoilModel/selectors/qna.ts new file mode 100644 index 0000000000..6b2be2148a --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/qna.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { QnAFile } from '@bfc/shared'; +import { DefaultValue, selectorFamily } from 'recoil'; + +import { qnaFileIdsState, qnaFileState } from '../atoms'; + +export const qnaFilesSelectorFamily = selectorFamily({ + key: 'qnaFiles', + get: (projectId: string) => ({ get }) => { + const qnaFileIds = get(qnaFileIdsState(projectId)); + + return qnaFileIds.map((qnaFileId) => { + return get(qnaFileState({ projectId, qnaFileId })); + }); + }, + set: (projectId: string) => ({ set }, newQnaFiles: QnAFile[] | DefaultValue) => { + if (newQnaFiles instanceof DefaultValue) return; + + set( + qnaFileIdsState(projectId), + newQnaFiles.map((qnaFile) => qnaFile.id) + ); + newQnaFiles.forEach((qnaFile) => set(qnaFileState({ projectId, qnaFileId: qnaFile.id }), qnaFile)); + }, +}); diff --git a/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx b/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx index 281f3a793f..abac532093 100644 --- a/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx +++ b/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx @@ -9,7 +9,6 @@ import { HookResult } from '@botframework-composer/test-utils/lib/hooks'; import { UndoRoot, undoFunctionState, undoHistoryState } from '../history'; import { - luFilesState, projectMetaDataState, currentProjectIdState, botProjectIdsState, @@ -18,7 +17,7 @@ import { canRedoState, dispatcherState, } from '../../atoms'; -import { dialogsSelectorFamily, lgFilesSelectorFamily } from '../../selectors'; +import { dialogsSelectorFamily, lgFilesSelectorFamily, luFilesSelectorFamily } from '../../selectors'; import { renderRecoilHook } from '../../../../__tests__/testUtils/react-recoil-hooks-testing-library'; import UndoHistory from '../undoHistory'; import { undoStatusSelectorFamily } from '../../selectors/undo'; @@ -72,7 +71,7 @@ describe('', () => { { recoilState: botProjectIdsState, initialValue: [projectId] }, { recoilState: dialogsSelectorFamily(projectId), initialValue: [{ id: '1', content: '' }] }, { recoilState: lgFilesSelectorFamily(projectId), initialValue: [{ id: '1.lg' }, { id: '2' }] }, - { recoilState: luFilesState(projectId), initialValue: [{ id: '1.lu' }, { id: '2' }] }, + { recoilState: luFilesSelectorFamily(projectId), initialValue: [{ id: '1.lu' }, { id: '2' }] }, { recoilState: currentProjectIdState, initialValue: projectId }, { recoilState: undoHistoryState(projectId), initialValue: new UndoHistory(projectId) }, { recoilState: canUndoState(projectId), initialValue: false }, diff --git a/Composer/packages/client/src/recoilModel/undo/trackedAtoms.ts b/Composer/packages/client/src/recoilModel/undo/trackedAtoms.ts index 566f6c493f..6cc6f5355c 100644 --- a/Composer/packages/client/src/recoilModel/undo/trackedAtoms.ts +++ b/Composer/packages/client/src/recoilModel/undo/trackedAtoms.ts @@ -3,12 +3,11 @@ import { RecoilState } from 'recoil'; -import { luFilesState } from '../atoms'; -import { dialogsSelectorFamily } from '../selectors'; +import { dialogsSelectorFamily, luFilesSelectorFamily } from '../selectors'; import { lgFilesSelectorFamily } from '../selectors/lg'; export type AtomAssetsMap = Map, any>; export const trackedAtoms = (projectId: string): RecoilState[] => { - return [dialogsSelectorFamily(projectId), luFilesState(projectId), lgFilesSelectorFamily(projectId)]; + return [dialogsSelectorFamily(projectId), luFilesSelectorFamily(projectId), lgFilesSelectorFamily(projectId)]; }; diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index 483619f7be..71a4b693c1 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -29,11 +29,11 @@ import { schemasState, focusPathState, localeState, - qnaFilesState, + qnaFilesSelectorFamily, designPageLocationState, botDisplayNameState, dialogSchemasState, - luFilesState, + luFilesSelectorFamily, rateInfoState, rootBotProjectIdSelector, featureFlagsState, @@ -83,11 +83,11 @@ export function useShell(source: EventSource, projectId: string): Shell { const focusPath = useRecoilValue(focusPathState(projectId)); const skills = useRecoilValue(skillsStateSelector); const locale = useRecoilValue(localeState(projectId)); - const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesSelectorFamily(projectId)); const undoFunction = useRecoilValue(undoFunctionState(projectId)); const designPageLocation = useRecoilValue(designPageLocationState(projectId)); const { undo, redo, commitChanges } = undoFunction; - const luFiles = useRecoilValue(luFilesState(projectId)); + const luFiles = useRecoilValue(luFilesSelectorFamily(projectId)); const lgFiles = useRecoilValue(lgFilesSelectorFamily(projectId)); const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); const botName = useRecoilValue(botDisplayNameState(projectId)); diff --git a/Composer/packages/extension-client/src/types/form.ts b/Composer/packages/extension-client/src/types/form.ts index 477757f639..002c561d3b 100644 --- a/Composer/packages/extension-client/src/types/form.ts +++ b/Composer/packages/extension-client/src/types/form.ts @@ -39,7 +39,7 @@ export interface FieldProps { cursorPosition?: number; onChange: ChangeHandler; - onFocus?: (id: string, value?: T) => void; + onFocus?: (id: string, value?: T, event?: React.FocusEvent) => void; onBlur?: (id: string, value?: T) => void; onKeyDown?: (event: React.KeyboardEvent) => void; diff --git a/Composer/packages/intellisense/src/components/Intellisense.tsx b/Composer/packages/intellisense/src/components/Intellisense.tsx index 48a0a5f44d..46db876208 100644 --- a/Composer/packages/intellisense/src/components/Intellisense.tsx +++ b/Composer/packages/intellisense/src/components/Intellisense.tsx @@ -71,7 +71,7 @@ export const Intellisense = React.memo( if (didComplete.current) { didComplete.current = false; } else { - if (completionItems && completionItems.length) { + if (completionItems?.length) { setShowCompletionList(true); } else { setShowCompletionList(false); @@ -93,24 +93,21 @@ export const Intellisense = React.memo( shouldBlur = false; } - if ( - completionListOverrideContainerElements && - completionListOverrideContainerElements.some((item) => !checkIsOutside(x, y, item)) - ) { + if (completionListOverrideContainerElements?.some((item) => !checkIsOutside(x, y, item))) { shouldBlur = false; } if (shouldBlur) { setShowCompletionList(false); setCursorPosition(-1); - onBlur && onBlur(id); + onBlur?.(id); } }; const keydownHandler = (event: KeyboardEvent) => { if ((event.key === 'Escape' || event.key === 'Tab') && focused) { setShowCompletionList(false); - onBlur && onBlur(id); + onBlur?.(id); } }; diff --git a/Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx b/Composer/packages/lib/code-editor/src/components/toolbar/FieldToolbar.tsx similarity index 61% rename from Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx rename to Composer/packages/lib/code-editor/src/components/toolbar/FieldToolbar.tsx index 8c2f3dfca6..fed98a1beb 100644 --- a/Composer/packages/lib/code-editor/src/lg/LgEditorToolbar.tsx +++ b/Composer/packages/lib/code-editor/src/components/toolbar/FieldToolbar.tsx @@ -5,30 +5,16 @@ import { LgTemplate } from '@botframework-composer/types'; import { FluentTheme, NeutralColors } from '@uifabric/fluent-theme'; import formatMessage from 'format-message'; import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; -import { VerticalDivider } from 'office-ui-fabric-react/lib/Divider'; import { IContextualMenuProps } from 'office-ui-fabric-react/lib/ContextualMenu'; +import { VerticalDivider } from 'office-ui-fabric-react/lib/Divider'; import * as React from 'react'; -import { createSvgIcon } from '@fluentui/react-icons'; -import { withTooltip } from '../utils/withTooltip'; +import { useEditorToolbarItems } from '../../hooks/useEditorToolbarItems'; +import { defaultMenuHeight, jsLgToolbarMenuClassName } from '../../lg/constants'; +import { ToolbarButtonPayload } from '../../types'; +import { withTooltip } from '../../utils/withTooltip'; -import { jsLgToolbarMenuClassName } from './constants'; -import { useLgEditorToolbarItems } from './hooks/useLgEditorToolbarItems'; import { ToolbarButtonMenu } from './ToolbarButtonMenu'; -import { ToolbarButtonPayload } from './types'; - -const svgIconStyle = { fill: NeutralColors.black, margin: '0 4px', width: 16, height: 16 }; - -const popExpandSvgIcon = ( - - - -); - -const menuHeight = 32; const dividerStyles = { divider: { @@ -39,14 +25,14 @@ const dividerStyles = { const moreButtonStyles = { root: { fontSize: FluentTheme.fonts.small.fontSize, - height: menuHeight, + height: defaultMenuHeight, }, menuIcon: { fontSize: 8, color: NeutralColors.black }, }; const commandBarStyles = { root: { - height: menuHeight, + height: defaultMenuHeight, padding: 0, fontSize: FluentTheme.fonts.small.fontSize, }, @@ -74,19 +60,30 @@ const configureMenuProps = (props: IContextualMenuProps | undefined, className: return props; }; -export type LgEditorToolbarProps = { +export type FieldToolbarProps = { + onSelectToolbarMenuItem: (itemText: string, itemType: ToolbarButtonPayload['kind']) => void; + excludedToolbarItems?: ToolbarButtonPayload['kind'][]; lgTemplates?: readonly LgTemplate[]; properties?: readonly string[]; - onSelectToolbarMenuItem: (itemText: string, itemType: ToolbarButtonPayload['kind']) => void; - moreToolbarItems?: readonly ICommandBarItemProps[]; + moreToolbarItems?: ICommandBarItemProps[]; + farItems?: ICommandBarItemProps[]; className?: string; - onPopExpand?: () => void; + dismissHandlerClassName?: string; }; -export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { - const { className, properties, lgTemplates, moreToolbarItems, onSelectToolbarMenuItem, onPopExpand } = props; - - const { functionRefPayload, propertyRefPayload, templateRefPayload } = useLgEditorToolbarItems( +export const FieldToolbar = React.memo((props: FieldToolbarProps) => { + const { + className, + excludedToolbarItems, + properties, + lgTemplates, + moreToolbarItems, + farItems, + dismissHandlerClassName = jsLgToolbarMenuClassName, + onSelectToolbarMenuItem, + } = props; + + const { functionRefPayload, propertyRefPayload, templateRefPayload } = useEditorToolbarItems( lgTemplates ?? [], properties ?? [], onSelectToolbarMenuItem @@ -106,8 +103,8 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { [] ); - const fixedItems: ICommandBarItemProps[] = React.useMemo( - () => [ + const fixedItems: ICommandBarItemProps[] = React.useMemo(() => { + const items = [ { key: 'template', disabled: !templateRefPayload?.data?.templates?.length, @@ -115,6 +112,7 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { ), @@ -126,34 +124,44 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { ), }, { key: 'function', - commandBarButtonAs: () => , + commandBarButtonAs: () => ( + + ), }, - ], - [ - TooltipTemplateButton, - TooltipPropertyButton, - TooltipFunctionButton, - templateRefPayload, - propertyRefPayload, - functionRefPayload, - ] - ); + ]; + + return items.filter(({ key }) => !excludedToolbarItems?.includes(key as ToolbarButtonPayload['kind'])); + }, [ + TooltipTemplateButton, + TooltipPropertyButton, + TooltipFunctionButton, + templateRefPayload, + propertyRefPayload, + functionRefPayload, + excludedToolbarItems, + dismissHandlerClassName, + ]); const moreItems = React.useMemo( () => moreToolbarItems?.map((itemProps) => ({ ...itemProps, - subMenuProps: configureMenuProps(itemProps.subMenuProps, jsLgToolbarMenuClassName), + subMenuProps: configureMenuProps(itemProps.subMenuProps, dismissHandlerClassName), buttonStyles: moreButtonStyles, - className: jsLgToolbarMenuClassName, + className: dismissHandlerClassName, })) ?? [], - [moreToolbarItems] + [moreToolbarItems, dismissHandlerClassName] ); const items = React.useMemo( @@ -167,30 +175,6 @@ export const LgEditorToolbar = React.memo((props: LgEditorToolbarProps) => { [fixedItems, moreItems] ); - const popExpand = React.useCallback(() => { - onPopExpand?.(); - }, [onPopExpand]); - - const farItems = React.useMemo( - () => - onPopExpand - ? [ - { - key: 'popExpandButton', - buttonStyles: moreButtonStyles, - className: jsLgToolbarMenuClassName, - onRenderIcon: () => { - let PopExpandIcon = createSvgIcon({ svg: () => popExpandSvgIcon, displayName: 'PopExpandIcon' }); - PopExpandIcon = withTooltip({ content: formatMessage('Pop out editor') }, PopExpandIcon); - return ; - }, - onClick: popExpand, - }, - ] - : [], - [popExpand] - ); - return ( {}; @@ -84,16 +84,13 @@ const OneLiner = styled.div({ const svgIconStyle = { fill: NeutralColors.black, margin: '0 4px', width: 16, height: 16 }; const iconStyles = { root: { color: NeutralColors.black, margin: '0 4px', width: 16, height: 16 } }; -const calloutProps = { - styles: { - calloutMain: { overflowY: 'hidden' }, - }, - layerProps: { className: jsLgToolbarMenuClassName }, -}; - type ToolbarButtonMenuProps = { payload: ToolbarButtonPayload; disabled?: boolean; + + // This className can be use for handling dismal of the toolbar in the container + // due to the portal nature of this component + dismissHandlerClassName?: string; }; const getIcon = (kind: ToolbarButtonPayload['kind']): JSX.Element => { @@ -140,11 +137,21 @@ const TooltipItem = React.memo(({ text, tooltip }: { text?: string; tooltip?: st }); export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { - const { payload, disabled = false } = props; + const { payload, disabled = false, dismissHandlerClassName = jsLgToolbarMenuClassName } = props; const [propertyTreeExpanded, setPropertyTreeExpanded] = React.useState>({}); const uiStrings = React.useMemo(() => getStrings(payload.kind), [payload.kind]); + const calloutProps = React.useMemo( + () => ({ + styles: { + calloutMain: { overflowY: 'hidden' }, + }, + layerProps: { className: dismissHandlerClassName }, + }), + [dismissHandlerClassName] + ); + const propertyTreeConfig = React.useMemo(() => { if (payload.kind === 'property') { const { properties, onSelectProperty } = (payload as PropertyRefPayload).data; @@ -175,7 +182,7 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { subMenuProps: { calloutProps: { calloutMaxHeight: 432, - layerProps: { className: jsLgToolbarMenuClassName }, + layerProps: { className: dismissHandlerClassName }, }, contextualMenuItemAs: (itemProps: IContextualMenuItemProps) => { return ( @@ -424,13 +431,13 @@ export const ToolbarButtonMenu = React.memo((props: ToolbarButtonMenuProps) => { } as IContextualMenuProps; } } - }, [items, onRenderMenuList, onDismiss, propertyTreeExpanded, query]); + }, [items, calloutProps, onRenderMenuList, onDismiss, propertyTreeExpanded, query]); const renderIcon = React.useCallback(() => getIcon(payload.kind), [payload.kind]); return ( crypto.randomBytes(arr.length), diff --git a/Composer/packages/lib/code-editor/src/components/toolbar/index.ts b/Composer/packages/lib/code-editor/src/components/toolbar/index.ts new file mode 100644 index 0000000000..24e0c87287 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/components/toolbar/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export * from './FieldToolbar'; diff --git a/Composer/packages/lib/code-editor/src/lg/hooks/useLgEditorToolbarItems.ts b/Composer/packages/lib/code-editor/src/hooks/useEditorToolbarItems.ts similarity index 96% rename from Composer/packages/lib/code-editor/src/lg/hooks/useLgEditorToolbarItems.ts rename to Composer/packages/lib/code-editor/src/hooks/useEditorToolbarItems.ts index cab7d24ef1..4e15abfd6e 100644 --- a/Composer/packages/lib/code-editor/src/lg/hooks/useLgEditorToolbarItems.ts +++ b/Composer/packages/lib/code-editor/src/hooks/useEditorToolbarItems.ts @@ -7,7 +7,7 @@ import * as React from 'react'; import { FunctionRefPayload, PropertyRefPayload, TemplateRefPayload, ToolbarButtonPayload } from '../types'; -export const useLgEditorToolbarItems = ( +export const useEditorToolbarItems = ( lgTemplates: readonly LgTemplate[], properties: readonly string[], selectToolbarMenuItem: (itemText: string, itemType: ToolbarButtonPayload['kind']) => void diff --git a/Composer/packages/lib/code-editor/src/index.ts b/Composer/packages/lib/code-editor/src/index.ts index 7e3663d86a..cb57f5fee1 100644 --- a/Composer/packages/lib/code-editor/src/index.ts +++ b/Composer/packages/lib/code-editor/src/index.ts @@ -15,3 +15,5 @@ export * from './LuEditor'; export * from './QnAEditor'; export * from './constants'; export * from './utils/lgValidate'; +export * from './types'; +export * from './components/toolbar'; diff --git a/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx b/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx index 319eea43dd..4afa00f711 100644 --- a/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx +++ b/Composer/packages/lib/code-editor/src/lg/LgCodeEditor.tsx @@ -11,10 +11,12 @@ import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { Link } from 'office-ui-fabric-react/lib/Link'; import { Stack } from 'office-ui-fabric-react/lib/Stack'; import { Dialog } from 'office-ui-fabric-react/lib/Dialog'; +import { ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; import { Text } from 'office-ui-fabric-react/lib/Text'; import React, { useEffect, useState } from 'react'; import { listen, MessageConnection } from 'vscode-ws-jsonrpc'; import omit from 'lodash/omit'; +import { createSvgIcon } from '@fluentui/react-icons'; import { BaseEditor, OnInit } from '../BaseEditor'; import { LG_HELP } from '../constants'; @@ -23,9 +25,29 @@ import { LgCodeEditorProps } from '../types'; import { computeRequiredEdits } from '../utils/lgUtils'; import { createLanguageClient, createUrl, createWebSocket, sendRequestWithRetry } from '../utils/lspUtil'; import { withTooltip } from '../utils/withTooltip'; +import { FieldToolbar } from '../components/toolbar/FieldToolbar'; +import { ToolbarButtonPayload } from '../types'; -import { LgEditorToolbar as DefaultLgEditorToolbar } from './LgEditorToolbar'; -import { ToolbarButtonPayload } from './types'; +import { jsLgToolbarMenuClassName, defaultMenuHeight } from './constants'; + +const farItemButtonStyles = { + root: { + fontSize: FluentTheme.fonts.small.fontSize, + height: defaultMenuHeight, + }, + menuIcon: { fontSize: 8, color: NeutralColors.black }, +}; + +const svgIconStyle = { fill: NeutralColors.black, margin: '0 4px', width: 16, height: 16 }; + +const popExpandSvgIcon = ( + + + +); const placeholder = formatMessage( `> To learn more about the LG file format, read the documentation at @@ -64,7 +86,7 @@ const LgTemplateLink = withTooltip( const templateLinkTokens = { childrenGap: 4 }; -const LgEditorToolbar = styled(DefaultLgEditorToolbar)({ +const EditorToolbar = styled(FieldToolbar)({ border: `1px solid ${NeutralColors.gray120}`, borderBottom: 'none', }); @@ -85,6 +107,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { quickSuggestions: true, wordBasedSuggestions: false, folding: true, + definitions: true, ...props.options, }; @@ -96,6 +119,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { memoryVariables, lgTemplates, telemetryClient, + showDirectTemplateLink, onNavigateToLgPage, popExpandOptions, onChange, @@ -114,6 +138,10 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { const [expanded, setExpanded] = useState(false); useEffect(() => { + if (props.options?.readOnly) { + return; + } + if (!editor) return; if (!window.monacoServiceInstance) { @@ -133,16 +161,34 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { ['botbuilderlg'], connection ); + sendRequestWithRetry(languageClient, 'initializeDocuments', { lgOption, uri }); const disposable = languageClient.start(); connection.onClose(() => disposable.dispose()); window.monacoLGEditorInstance = languageClient; + + languageClient.onReady().then(() => + languageClient.onNotification('GotoDefinition', (result) => { + if (lgOption?.projectId) { + onNavigateToLgPage?.(result.fileId, { templateId: result.templateId, line: result.line }); + } + }) + ); }, }); } else { - sendRequestWithRetry(window.monacoLGEditorInstance, 'initializeDocuments', { lgOption, uri }); + if (!props.options?.readOnly) { + sendRequestWithRetry(window.monacoLGEditorInstance, 'initializeDocuments', { lgOption, uri }); + } + window.monacoLGEditorInstance.onReady().then(() => + window.monacoLGEditorInstance.onNotification('GotoDefinition', (result) => { + if (lgOption?.projectId) { + onNavigateToLgPage?.(result.fileId, { templateId: result.templateId, line: result.line }); + } + }) + ); } - }, [editor]); + }, [editor, onNavigateToLgPage]); const onInit: OnInit = (monaco) => { registerLGLanguage(monaco); @@ -177,7 +223,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { ); const navigateToLgPage = React.useCallback(() => { - onNavigateToLgPage?.(lgOption?.fileId ?? 'common', lgOption?.templateId); + onNavigateToLgPage?.(lgOption?.fileId ?? 'common', { templateId: lgOption?.templateId, line: undefined }); }, [onNavigateToLgPage, lgOption]); const onExpandedEditorChange = React.useCallback( @@ -198,21 +244,37 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { [onChange] ); + const toolbarFarItems = React.useMemo( + () => + popExpandOptions + ? [ + { + key: 'popExpandButton', + buttonStyles: farItemButtonStyles, + className: jsLgToolbarMenuClassName, + onRenderIcon: () => { + let PopExpandIcon = createSvgIcon({ svg: () => popExpandSvgIcon, displayName: 'PopExpandIcon' }); + PopExpandIcon = withTooltip({ content: formatMessage('Pop out editor') }, PopExpandIcon); + return ; + }, + onClick: () => { + setExpanded(true); + popExpandOptions.onEditorPopToggle?.(true); + }, + }, + ] + : [], + [popExpandOptions] + ); + return ( <> {!toolbarHidden && ( - { - setExpanded(true); - popExpandOptions.onEditorPopToggle?.(true); - } - : undefined - } onSelectToolbarMenuItem={selectToolbarMenuItem} /> )} @@ -228,7 +290,7 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { theme="lgtheme" onInit={onInit} /> - {onNavigateToLgPage && lgOption && ( + {showDirectTemplateLink && onNavigateToLgPage && lgOption && ( {formatMessage('Template name: ')} @@ -251,8 +313,9 @@ export const LgCodeEditor = (props: LgCodeEditorProps) => { }} > diff --git a/Composer/packages/lib/code-editor/src/lg/LgSpeechModalityToolbar.tsx b/Composer/packages/lib/code-editor/src/lg/LgSpeechModalityToolbar.tsx index b3c18fce9e..4326564118 100644 --- a/Composer/packages/lib/code-editor/src/lg/LgSpeechModalityToolbar.tsx +++ b/Composer/packages/lib/code-editor/src/lg/LgSpeechModalityToolbar.tsx @@ -12,9 +12,9 @@ import { Link } from 'office-ui-fabric-react/lib/Link'; import * as React from 'react'; import { ItemWithTooltip } from '../components/ItemWithTooltip'; +import { FieldToolbar, FieldToolbarProps } from '../components/toolbar/FieldToolbar'; import { jsLgToolbarMenuClassName } from './constants'; -import { LgEditorToolbar, LgEditorToolbarProps } from './LgEditorToolbar'; const menuItemStyles = { fontSize: FluentTheme.fonts.small.fontSize, @@ -26,7 +26,7 @@ const ssmlHeaderTooltipProps = { calloutProps: { layerProps: { className: jsLgTo export type SSMLTagType = 'break' | 'prosody' | 'audio'; -type Props = Omit & { +type Props = Omit & { onInsertSSMLTag: (tagType: SSMLTagType) => void; }; @@ -72,5 +72,5 @@ export const LgSpeechModalityToolbar = React.memo((props: Props) => { subMenuProps, ]); - return ; + return ; }); diff --git a/Composer/packages/lib/code-editor/src/lg/constants.ts b/Composer/packages/lib/code-editor/src/lg/constants.ts index 2da9ec35de..1408bfee0a 100644 --- a/Composer/packages/lib/code-editor/src/lg/constants.ts +++ b/Composer/packages/lib/code-editor/src/lg/constants.ts @@ -5,6 +5,8 @@ export const activityTemplateType = 'Activity'; export const jsLgToolbarMenuClassName = 'js-lg-toolbar-menu'; +export const defaultMenuHeight = 32; + export const lgCardAttachmentTemplates = [ 'adaptive', 'hero', diff --git a/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx b/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx index d81c55a287..40e9fb1345 100644 --- a/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx +++ b/Composer/packages/lib/code-editor/src/lg/modalityEditors/StringArrayEditor.tsx @@ -11,9 +11,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { LGOption } from '../../utils'; import { getCursorContextWithinLine } from '../../utils/lgUtils'; import { jsLgToolbarMenuClassName } from '../constants'; -import { LgEditorToolbar } from '../LgEditorToolbar'; +import { FieldToolbar } from '../../components/toolbar/FieldToolbar'; import { LgSpeechModalityToolbar, SSMLTagType } from '../LgSpeechModalityToolbar'; -import { ToolbarButtonPayload } from '../types'; +import { ToolbarButtonPayload } from '../../types'; import { StringArrayItem } from './StringArrayItem'; @@ -284,7 +284,7 @@ export const StringArrayEditor = React.memo( onSelectToolbarMenuItem={onSelectToolbarMenuItem} /> ) : ( - void; - }; -}; - -export type PropertyItem = { - id: string; - name: string; - children: PropertyItem[]; -}; - -export type PropertyRefPayload = { - kind: 'property'; - data: { properties: readonly string[]; onSelectProperty: (property: string, itemType: 'property') => void }; -}; - -export type FunctionRefPayload = { - kind: 'function'; - data: { - functions: readonly { key: string; name: string; children: string[] }[]; - onSelectFunction: (functionString: string, itemType: 'function') => void; - }; -}; - -export type ToolbarButtonPayload = TemplateRefPayload | PropertyRefPayload | FunctionRefPayload; - export type LgLanguageContext = | 'expression' | 'singleQuote' diff --git a/Composer/packages/lib/code-editor/src/lu/__test__/InsertEntityButton.test.tsx b/Composer/packages/lib/code-editor/src/lu/__test__/InsertEntityButton.test.tsx index 00c9af1111..8e12cc9b79 100644 --- a/Composer/packages/lib/code-editor/src/lu/__test__/InsertEntityButton.test.tsx +++ b/Composer/packages/lib/code-editor/src/lu/__test__/InsertEntityButton.test.tsx @@ -39,6 +39,7 @@ const luFile: LuFile = { content: '', imports: [], resource: { Content: '', Errors: [], Sections: [] }, + isContentUnparsed: false, }; jest.useFakeTimers(); diff --git a/Composer/packages/lib/code-editor/src/types.ts b/Composer/packages/lib/code-editor/src/types.ts index da761d65ca..668e6d91bf 100644 --- a/Composer/packages/lib/code-editor/src/types.ts +++ b/Composer/packages/lib/code-editor/src/types.ts @@ -32,7 +32,8 @@ export type LgCodeEditorProps = LgCommonEditorProps & BaseEditorProps & { popExpandOptions?: { onEditorPopToggle?: (expanded: boolean) => void; popExpandTitle: string }; toolbarHidden?: boolean; - onNavigateToLgPage?: (lgFileId: string, templateId?: string) => void; + showDirectTemplateLink?: boolean; + onNavigateToLgPage?: (lgFileId: string, options?: { templateId?: string; line?: number }) => void; languageServer?: | { host?: string; @@ -42,3 +43,32 @@ export type LgCodeEditorProps = LgCommonEditorProps & } | string; }; + +export type PropertyItem = { + id: string; + name: string; + children: PropertyItem[]; +}; + +export type TemplateRefPayload = { + kind: 'template'; + data: { + templates: readonly LgTemplate[]; + onSelectTemplate: (templateString: string, itemType: 'template') => void; + }; +}; + +export type PropertyRefPayload = { + kind: 'property'; + data: { properties: readonly string[]; onSelectProperty: (property: string, itemType: 'property') => void }; +}; + +export type FunctionRefPayload = { + kind: 'function'; + data: { + functions: readonly { key: string; name: string; children: string[] }[]; + onSelectFunction: (functionString: string, itemType: 'function') => void; + }; +}; + +export type ToolbarButtonPayload = TemplateRefPayload | PropertyRefPayload | FunctionRefPayload; diff --git a/Composer/packages/lib/code-editor/src/utils/__tests__/lgUtils.test.ts b/Composer/packages/lib/code-editor/src/utils/__tests__/fieldToolbarUtils.test.ts similarity index 97% rename from Composer/packages/lib/code-editor/src/utils/__tests__/lgUtils.test.ts rename to Composer/packages/lib/code-editor/src/utils/__tests__/fieldToolbarUtils.test.ts index aba70e4858..413fe45c5f 100644 --- a/Composer/packages/lib/code-editor/src/utils/__tests__/lgUtils.test.ts +++ b/Composer/packages/lib/code-editor/src/utils/__tests__/fieldToolbarUtils.test.ts @@ -3,7 +3,7 @@ import * as crypto from 'crypto'; -import { computePropertyItemTree, getAllNodes } from '../lgUtils'; +import { computePropertyItemTree, getAllNodes } from '../fieldToolbarUtils'; (global as any).crypto = { getRandomValues: (arr: any[]) => crypto.randomBytes(arr.length), diff --git a/Composer/packages/lib/code-editor/src/utils/fieldToolbarUtils.ts b/Composer/packages/lib/code-editor/src/utils/fieldToolbarUtils.ts new file mode 100644 index 0000000000..0bfb0ec5d4 --- /dev/null +++ b/Composer/packages/lib/code-editor/src/utils/fieldToolbarUtils.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import uniq from 'lodash/uniq'; + +import { PropertyItem } from '../types'; + +/** + * Converts the list pf properties to a tree and returns the root. + * @param properties List of available properties. + */ +export const computePropertyItemTree = (properties: readonly string[]): PropertyItem => { + // Generate random unique ids + const generateId = () => { + const arr = crypto.getRandomValues(new Uint32Array(1)); + return `${arr[0]}`; + }; + + const items = properties.slice().sort(); + const dummyRoot = { id: 'root', name: 'root', children: [] }; + + const helper = (currentNode: PropertyItem, prefix: string, scopedItems: string[], level: number) => { + const uniques = uniq(scopedItems.map((i) => i.split('.')[level])).filter(Boolean); + const children = uniques.map((name) => ({ id: generateId(), name, children: [] })); + for (const n of children) { + helper( + n, + `${prefix}${prefix ? '.' : ''}${n.name}`, + items.filter((i) => i.startsWith(`${prefix}${prefix ? '.' : ''}${n.name}`)), + level + 1 + ); + } + currentNode.children = children; + }; + + helper(dummyRoot, '', items, 0); + + return dummyRoot; +}; + +const getPath = (item: T, parents: Record) => { + const path: string[] = []; + let currentItem = item; + if (currentItem) { + while (currentItem) { + path.push(currentItem.name); + currentItem = parents[currentItem.id]; + while (currentItem && currentItem.id.indexOf('root') !== -1) { + currentItem = parents[currentItem.id]; + } + } + } + return path.reverse().join('.'); +}; + +/** + * Returns a flat list of nodes, their level by id, and the path from root to that node. + * @param root Root of the tree. + * @param options Options including current state of expanded nodes, and if the root should be skipped. + */ +export const getAllNodes = ( + root: T, + options?: Partial<{ expanded: Record; skipRoot: boolean }> +): { + nodes: T[]; + levels: Record; + paths: Record; +} => { + const nodes: T[] = []; + const levels: Record = {}; + const parents: Record = {}; + const paths: Record = {}; + + if (options?.skipRoot && options?.expanded) { + options.expanded[root.id] = true; + } + + const addNode = (node: T, parent: T | null, level = 0) => { + if (!options?.skipRoot || node.id !== root.id) { + nodes.push(node); + } + levels[node.id] = level; + if (parent) { + parents[node.id] = parent; + } + paths[node.id] = getPath(node, parents); + if (options?.expanded) { + if (!options.expanded[node.id]) { + return; + } + } + if (node?.children?.length) { + node.children.forEach((n) => addNode(n, node, level + 1)); + } + }; + + addNode(root, null); + + return { nodes, levels, paths }; +}; diff --git a/Composer/packages/lib/code-editor/src/utils/lgUtils.ts b/Composer/packages/lib/code-editor/src/utils/lgUtils.ts index 93a50cce28..825f291cf3 100644 --- a/Composer/packages/lib/code-editor/src/utils/lgUtils.ts +++ b/Composer/packages/lib/code-editor/src/utils/lgUtils.ts @@ -2,9 +2,8 @@ // Licensed under the MIT License. import { generateDesignerId, LgTemplate } from '@bfc/shared'; -import uniq from 'lodash/uniq'; -import { LgLanguageContext, PropertyItem } from '../lg/types'; +import { LgLanguageContext } from '../lg/types'; import { MonacoPosition, MonacoRange, MonacoEdit } from './monacoTypes'; @@ -125,100 +124,6 @@ export const computeRequiredEdits = (text: string, editor: any): MonacoEdit[] | } }; -/** - * Converts the list pf properties to a tree and returns the root. - * @param properties List of available properties. - */ -export const computePropertyItemTree = (properties: readonly string[]): PropertyItem => { - // Generate random unique ids - const generateId = () => { - const arr = crypto.getRandomValues(new Uint32Array(1)); - return `${arr[0]}`; - }; - - const items = properties.slice().sort(); - const dummyRoot = { id: 'root', name: 'root', children: [] }; - - const helper = (currentNode: PropertyItem, prefix: string, scopedItems: string[], level: number) => { - const uniques = uniq(scopedItems.map((i) => i.split('.')[level])).filter(Boolean); - const children = uniques.map((name) => ({ id: generateId(), name, children: [] })); - for (const n of children) { - helper( - n, - `${prefix}${prefix ? '.' : ''}${n.name}`, - items.filter((i) => i.startsWith(`${prefix}${prefix ? '.' : ''}${n.name}`)), - level + 1 - ); - } - currentNode.children = children; - }; - - helper(dummyRoot, '', items, 0); - - return dummyRoot; -}; - -const getPath = (item: T, parents: Record) => { - const path: string[] = []; - let currentItem = item; - if (currentItem) { - while (currentItem) { - path.push(currentItem.name); - currentItem = parents[currentItem.id]; - while (currentItem && currentItem.id.indexOf('root') !== -1) { - currentItem = parents[currentItem.id]; - } - } - } - return path.reverse().join('.'); -}; - -/** - * Returns a flat list of nodes, their level by id, and the path from root to that node. - * @param root Root of the tree. - * @param options Options including current state of expanded nodes, and if the root should be skipped. - */ -export const getAllNodes = ( - root: T, - options?: Partial<{ expanded: Record; skipRoot: boolean }> -): { - nodes: T[]; - levels: Record; - paths: Record; -} => { - const nodes: T[] = []; - const levels: Record = {}; - const parents: Record = {}; - const paths: Record = {}; - - if (options?.skipRoot && options?.expanded) { - options.expanded[root.id] = true; - } - - const addNode = (node: T, parent: T | null, level = 0) => { - if (!options?.skipRoot || node.id !== root.id) { - nodes.push(node); - } - levels[node.id] = level; - if (parent) { - parents[node.id] = parent; - } - paths[node.id] = getPath(node, parents); - if (options?.expanded) { - if (!options.expanded[node.id]) { - return; - } - } - if (node?.children?.length) { - node.children.forEach((n) => addNode(n, node, level + 1)); - } - }; - - addNode(root, null); - - return { nodes, levels, paths }; -}; - export const getUniqueTemplateName = (templateId: string, templates?: readonly LgTemplate[]): string => { const id = `${templateId}_${generateDesignerId()}`; return !templates || templates.find(({ name }) => name === id) diff --git a/Composer/packages/lib/indexers/src/index.ts b/Composer/packages/lib/indexers/src/index.ts index 029b870241..90b4ff43d4 100644 --- a/Composer/packages/lib/indexers/src/index.ts +++ b/Composer/packages/lib/indexers/src/index.ts @@ -1,21 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { DialogSetting, FileInfo, lgImportResolverGenerator } from '@bfc/shared'; +import { FileInfo } from '@bfc/shared'; import { recognizerIndexer } from './recognizerIndexer'; import { dialogIndexer } from './dialogIndexer'; import { dialogSchemaIndexer } from './dialogSchemaIndexer'; import { jsonSchemaFileIndexer } from './jsonSchemaFileIndexer'; -import { lgIndexer } from './lgIndexer'; -import { luIndexer } from './luIndexer'; -import { qnaIndexer } from './qnaIndexer'; import { skillManifestIndexer } from './skillManifestIndexer'; import { botProjectSpaceIndexer } from './botProjectSpaceIndexer'; import { FileExtensions } from './utils/fileExtensions'; import { getExtension, getBaseName } from './utils/help'; import { formDialogSchemaIndexer } from './formDialogSchemaIndexer'; import { crossTrainConfigIndexer } from './crossTrainConfigIndexer'; -import { BotIndexer } from './botIndexer'; class Indexer { private classifyFile(files: FileInfo[]) { @@ -69,28 +65,23 @@ class Indexer { ); }; - private getLgImportResolver = (files: FileInfo[], locale: string) => { - const lgFiles = files.map(({ name, content }) => { - return { - id: getBaseName(name, '.lg'), - content, - }; - }); - - return lgImportResolverGenerator(lgFiles, '.lg', locale); + private getResources = (files: FileInfo[], extension: string) => { + return files.map(({ name, content }) => ({ + id: getBaseName(name, extension), + content, + })); }; - public index(files: FileInfo[], botName: string, locale: string, settings: DialogSetting) { + public index(files: FileInfo[], botName: string) { const result = this.classifyFile(files); - const luFeatures = settings.luFeatures; const { dialogs, recognizers } = this.separateDialogsAndRecognizers(result[FileExtensions.Dialog]); const { skillManifestFiles, crossTrainConfigs } = this.separateConfigAndManifests(result[FileExtensions.Manifest]); const assets = { dialogs: dialogIndexer.index(dialogs, botName), dialogSchemas: dialogSchemaIndexer.index(result[FileExtensions.DialogSchema]), - lgFiles: lgIndexer.index(result[FileExtensions.lg], this.getLgImportResolver(result[FileExtensions.lg], locale)), - luFiles: luIndexer.index(result[FileExtensions.Lu], luFeatures), - qnaFiles: qnaIndexer.index(result[FileExtensions.QnA]), + lgResources: this.getResources(result[FileExtensions.lg], FileExtensions.lg), + luResources: this.getResources(result[FileExtensions.Lu], FileExtensions.Lu), + qnaResources: this.getResources(result[FileExtensions.QnA], FileExtensions.QnA), skillManifests: skillManifestIndexer.index(skillManifestFiles), botProjectSpaceFiles: botProjectSpaceIndexer.index(result[FileExtensions.BotProjectSpace]), jsonSchemaFiles: jsonSchemaFileIndexer.index(result[FileExtensions.Json]), @@ -98,9 +89,7 @@ class Indexer { recognizers: recognizerIndexer.index(recognizers), crossTrainConfig: crossTrainConfigIndexer.index(crossTrainConfigs), }; - const botProjectFile = assets.botProjectSpaceFiles[0]; - const diagnostics = BotIndexer.validate({ ...assets, setting: settings, botProjectFile }); - return { ...assets, diagnostics }; + return { ...assets }; } } diff --git a/Composer/packages/lib/indexers/src/utils/lgUtil.ts b/Composer/packages/lib/indexers/src/utils/lgUtil.ts index d120e8e0f3..a20e12f5aa 100644 --- a/Composer/packages/lib/indexers/src/utils/lgUtil.ts +++ b/Composer/packages/lib/indexers/src/utils/lgUtil.ts @@ -84,7 +84,17 @@ export function convertTemplatesToLgFile(id = '', content: string, parseResult: }; }); - return { id, content, templates, allTemplates, diagnostics, imports, options: parseResult.options, parseResult }; + return { + id, + content, + templates, + allTemplates, + diagnostics, + imports, + options: parseResult.options, + parseResult, + isContentUnparsed: false, + }; } export function increaseNameUtilNotExist(templates: LgTemplate[], name: string): string { diff --git a/Composer/packages/lib/indexers/src/utils/luUtil.ts b/Composer/packages/lib/indexers/src/utils/luUtil.ts index 1cd6539b0e..d9db779482 100644 --- a/Composer/packages/lib/indexers/src/utils/luUtil.ts +++ b/Composer/packages/lib/indexers/src/utils/luUtil.ts @@ -129,6 +129,7 @@ export function convertLuParseResultToLuFile( diagnostics, imports, resource: { Sections, Errors, Content }, + isContentUnparsed: false, }; } diff --git a/Composer/packages/lib/indexers/src/utils/qnaUtil.ts b/Composer/packages/lib/indexers/src/utils/qnaUtil.ts index 77043decec..47043bdf7b 100644 --- a/Composer/packages/lib/indexers/src/utils/qnaUtil.ts +++ b/Composer/packages/lib/indexers/src/utils/qnaUtil.ts @@ -148,6 +148,7 @@ export function convertQnAParseResultToQnAFile(id = '', resource: LuParseResourc resource: { Sections, Errors, Content }, imports, options, + isContentUnparsed: false, }; } diff --git a/Composer/packages/tools/language-servers/language-generation/__tests__/LGUtil.test.ts b/Composer/packages/tools/language-servers/language-generation/__tests__/LGUtil.test.ts index b33ff26483..164d6d62fc 100644 --- a/Composer/packages/tools/language-servers/language-generation/__tests__/LGUtil.test.ts +++ b/Composer/packages/tools/language-servers/language-generation/__tests__/LGUtil.test.ts @@ -13,7 +13,6 @@ import { extractLUISContent, getSuggestionEntities, suggestionAllEntityTypes, - getLineByIndex, generateDiagnostic, convertDiagnostics, convertSeverity, @@ -90,11 +89,6 @@ describe('LG LSP Server Function Unit Tests', () => { expect(result).toEqual(['name', 'zipcode']); }); - it('Test getLineByIndex function', () => { - const result = getLineByIndex(textDoc2, 2); - expect(result).toEqual('line2'); - }); - it('Test generateDiagnostic function', () => { const result = generateDiagnostic('No Template Found', DiagnosticSeverity.Error, textDoc2); expect(result.message).toEqual('No Template Found'); diff --git a/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts b/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts index 12c78be9ae..597ae16e63 100644 --- a/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts +++ b/Composer/packages/tools/language-servers/language-generation/src/LGServer.ts @@ -1,5 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import path from 'path'; + import URI from 'vscode-uri'; import { IConnection, TextDocuments } from 'vscode-languageserver'; import formatMessage from 'format-message'; @@ -19,6 +21,7 @@ import { DocumentOnTypeFormattingParams, FoldingRangeParams, FoldingRange, + Location, } from 'vscode-languageserver-protocol'; import get from 'lodash/get'; import uniq from 'lodash/uniq'; @@ -40,7 +43,7 @@ import { cardTypes, cardPropDict, cardPropPossibleValueType, - getLineByIndex, + createFoldingRanges, } from './utils'; // define init methods call from client @@ -57,6 +60,8 @@ export class LGServer { private _lgParser = new LgParser(); private _luisEntities: string[] = []; private _lastLuContent: string[] = []; + private _lgFile: LgFile | undefined = undefined; + private _templateDefinitions: Record = {}; private _curDefinedVariblesInLG: Record = {}; private _otherDefinedVariblesInLG: Record = {}; private _mergedVariables: Record = {}; @@ -94,6 +99,7 @@ export class LGServer { }, hoverProvider: true, foldingRangeProvider: true, + definitionProvider: true, documentOnTypeFormattingProvider: { firstTriggerCharacter: '\n', }, @@ -101,6 +107,7 @@ export class LGServer { }; }); this.connection.onCompletion(async (params) => await this.completion(params)); + this.connection.onDefinition((params: TextDocumentPositionParams) => this.definitionHandler(params)); this.connection.onHover(async (params) => await this.hover(params)); this.connection.onDocumentOnTypeFormatting((docTypingParams) => this.docTypeFormat(docTypingParams)); this.connection.onFoldingRanges((foldingRangeParams: FoldingRangeParams) => @@ -113,6 +120,7 @@ export class LGServer { const textDocument = this.documents.get(uri); if (textDocument) { this.addLGDocument(textDocument, lgOption); + this.recordTemplatesDefintions(lgOption); this.validateLgOption(textDocument, lgOption); this.validate(textDocument); this.getOtherLGVariables(lgOption); @@ -136,56 +144,51 @@ export class LGServer { this.connection.listen(); } - protected foldingRangeHandler(params: FoldingRangeParams): FoldingRange[] { + protected definitionHandler(params: TextDocumentPositionParams): Location | undefined { const document = this.documents.get(params.textDocument.uri); - const items: FoldingRange[] = []; if (!document) { - return items; + return; } - const lineCount = document.lineCount; - let i = 0; - while (i < lineCount) { - const currLine = getLineByIndex(document, i); - if (currLine?.startsWith('>>')) { - for (let j = i + 1; j < lineCount; j++) { - if (getLineByIndex(document, j)?.startsWith('>>')) { - items.push(FoldingRange.create(i, j - 1)); - i = j - 1; - break; - } - - if (j === lineCount - 1) { - items.push(FoldingRange.create(i, j)); - i = j; - } - } + const importRegex = /^\s*\[[^[\]]+\](\([^()]+\))/; + const curLine = document.getText().split(/\r?\n/g)[params.position.line]; + if (importRegex.test(curLine)) { + const importedFile = curLine.match(importRegex)?.[1]; + if (importedFile) { + const source = importedFile.substr(1, importedFile.length - 2); // remove starting [ and tailing + const fileId = path.parse(source).name; + this.connection.sendNotification('GotoDefinition', { fileId: fileId }); + return; } + } + + const wordRange = getRangeAtPosition(document, params.position); + const word = document.getText(wordRange); + const curFileResult = this._lgFile?.templates.find((t) => t.name === word); - i = i + 1; + if (curFileResult?.range) { + return Location.create( + params.textDocument.uri, + Range.create(curFileResult.range.start.line - 1, 0, curFileResult.range.end.line, 0) + ); } - for (let i = 0; i < lineCount; i++) { - const currLine = getLineByIndex(document, i); - if (currLine?.startsWith('#')) { - let j = i + 1; - for (j = i + 1; j < lineCount; j++) { - const secLine = getLineByIndex(document, j); - if (secLine?.startsWith('>>') || secLine?.startsWith('#')) { - items.push(FoldingRange.create(i, j - 1)); - i = j - 1; - break; - } - } + const refResult = this._templateDefinitions[word]; + if (refResult) { + this.connection.sendNotification('GotoDefinition', refResult); + } - if (i !== j - 1) { - items.push(FoldingRange.create(i, j - 1)); - i == j - 2; - } - } + return; + } + + protected foldingRangeHandler(params: FoldingRangeParams): FoldingRange[] { + const document = this.documents.get(params.textDocument.uri); + if (!document) { + return []; } - return items; + const lines = document.getText().split(/\r?\n/g); + return [...createFoldingRanges(lines, '>>'), ...createFoldingRanges(lines, '#')]; } protected updateObject(propertyList: string[], varaibles: Record): void { @@ -255,6 +258,7 @@ export class LGServer { return await this._lgParser.updateTemplate(lgFile, templateId, { body: content }, lgTextFiles); } } + return await this._lgParser.parse(fileId || uri, content, lgTextFiles); }; const lgDocument: LGDocument = { @@ -267,6 +271,55 @@ export class LGServer { this.LGDocuments.push(lgDocument); } + protected async recordTemplatesDefintions(lgOption?: LGOption) { + const { fileId, projectId } = lgOption || {}; + if (projectId) { + const curLocale = this.getLocale(fileId); + const fileIdWitoutLocale = this.removeLocaleInId(fileId); + const lgTextFiles = projectId ? this.getLgResources(projectId) : []; + for (const file of lgTextFiles) { + //Only stroe templates in other LG files + if (this.removeLocaleInId(file.id) !== fileIdWitoutLocale && this.getLocale(file.id) === curLocale) { + const lgTemplates = await this._lgParser.parse(file.id, file.content, lgTextFiles); + this._templateDefinitions = {}; + for (const template of lgTemplates.templates) { + this._templateDefinitions[template.name] = { + fileId: file.id, + templateId: template.name, + line: template?.range?.start?.line, + }; + } + } + } + } + } + + private removeLocaleInId(fileId: string | undefined): string { + if (!fileId) { + return ''; + } + + const idx = fileId.lastIndexOf('.'); + if (idx !== -1) { + return fileId.substring(0, idx); + } else { + return fileId; + } + } + + private getLocale(fileId: string | undefined): string { + if (!fileId) { + return ''; + } + + const idx = fileId.lastIndexOf('.'); + if (idx !== -1) { + return fileId.substring(idx, fileId.length); + } else { + return ''; + } + } + protected getLGDocument(document: TextDocument): LGDocument | undefined { return this.LGDocuments.find(({ uri }) => uri === document.uri); } @@ -898,6 +951,7 @@ export class LGServer { return; } + this._lgFile = lgFile; if (text.length === 0) { this.cleanDiagnostics(document); return; diff --git a/Composer/packages/tools/language-servers/language-generation/src/utils.ts b/Composer/packages/tools/language-servers/language-generation/src/utils.ts index 089f3783c1..989c9969fc 100644 --- a/Composer/packages/tools/language-servers/language-generation/src/utils.ts +++ b/Composer/packages/tools/language-servers/language-generation/src/utils.ts @@ -7,6 +7,7 @@ import { DiagnosticSeverity as LGDiagnosticSeverity } from 'botbuilder-lg'; import { Diagnostic as BFDiagnostic, LgFile } from '@bfc/shared'; import { parser } from '@microsoft/bf-lu/lib/parser'; import { offsetRange } from '@bfc/indexers'; +import { FoldingRange } from 'vscode-languageserver'; import { LGResource, Templates } from 'botbuilder-lg'; import { Expression } from 'adaptive-expressions'; import uniq from 'lodash/uniq'; @@ -54,7 +55,7 @@ export function getRangeAtPosition(document: TextDocument, position: Position): const pos = position.character; const lineText = text.split(/\r?\n/g)[line]; let match: RegExpMatchArray | null; - const wordDefinition = /[a-zA-Z0-9_/.]+/g; + const wordDefinition = /[a-zA-Z0-9_]+/g; while ((match = wordDefinition.exec(lineText))) { const matchIndex = match.index || 0; if (matchIndex > pos) { @@ -221,11 +222,31 @@ export const suggestionAllEntityTypes = [ 'composites', ]; -export function getLineByIndex(document: TextDocument, line: number) { - const lineCount = document.lineCount; - if (line >= lineCount || line < 0) return null; +export function createFoldingRanges(lines: string[], prefix: string) { + const items: FoldingRange[] = []; - return document.getText().split(/\r?\n/g)[line]; + if (!lines || lines.length === 0) { + return items; + } + + const lineCount = lines.length; + let startIdx = -1; + + for (let i = 0; i < lineCount; i++) { + if (lines[i].trim().startsWith(prefix)) { + if (startIdx !== -1) { + items.push(FoldingRange.create(startIdx, i - 1)); + } + + startIdx = i; + } + } + + if (startIdx !== -1) { + items.push(FoldingRange.create(startIdx, lineCount - 1)); + } + + return items; } function findExpr(pst: any, result: string[]): void { diff --git a/Composer/packages/tools/language-servers/language-understanding/src/LUServer.ts b/Composer/packages/tools/language-servers/language-understanding/src/LUServer.ts index c4e1ec4556..3c88ed234b 100644 --- a/Composer/packages/tools/language-servers/language-understanding/src/LUServer.ts +++ b/Composer/packages/tools/language-servers/language-understanding/src/LUServer.ts @@ -19,33 +19,23 @@ import { DocumentOnTypeFormattingParams, FoldingRange, } from 'vscode-languageserver-protocol'; -import { updateIntent, isValid, checkSection, PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; -import { luIndexer } from '@bfc/indexers'; -import { parser } from '@microsoft/bf-lu/lib/parser'; +import { isValid, PlaceHolderSectionName } from '@bfc/indexers/lib/utils/luUtil'; +import { LuParser } from './luParser'; import { EntityTypesObj, LineState } from './entityEnum'; import * as util from './matchingPattern'; -import { - LUImportResolverDelegate, - LUOption, - LUDocument, - generateDiagnostic, - convertDiagnostics, - getLineByIndex, -} from './utils'; +import { LUOption, LUDocument, generateDiagnostic, convertDiagnostics, createFoldingRanges } from './utils'; // define init methods call from client const LABELEXPERIENCEREQUEST = 'labelingExperienceRequest'; const InitializeDocumentsMethodName = 'initializeDocuments'; -const { parse } = luIndexer; -const { parseFile } = parser; - export class LUServer { protected workspaceRoot: URI | undefined; protected readonly documents = new TextDocuments(); protected readonly pendingValidationRequests = new Map(); protected LUDocuments: LUDocument[] = []; + private luParser = new LuParser(); constructor( protected readonly connection: IConnection, @@ -113,50 +103,16 @@ export class LUServer { protected async foldingRangeHandler(params: FoldingRangeParams): Promise { const document = this.documents.get(params.textDocument.uri); - const items: FoldingRange[] = []; if (!document) { - return items; - } - - const lineCount = document.lineCount; - let i = 0; - while (i < lineCount) { - const currLine = getLineByIndex(document, i); - if (currLine?.startsWith('>>')) { - for (let j = i + 1; j < lineCount; j++) { - if (getLineByIndex(document, j)?.startsWith('>>')) { - items.push(FoldingRange.create(i, j - 1)); - i = j - 1; - break; - } - - if (j === lineCount - 1) { - items.push(FoldingRange.create(i, j)); - i = j; - } - } - } - - i = i + 1; + return []; } - const luResource = parse(document.getText(), undefined, {}).resource; - const sections = luResource.Sections; - for (const section in luResource.Sections) { - const start = sections[section].Range.Start.Line - 1; - let end = sections[section].Range.End.Line - 1; - const sectionLastLine = getLineByIndex(document, end); - if (sectionLastLine?.startsWith('>>')) { - end = end - 1; - } - - items.push(FoldingRange.create(start, end)); - } + const lines = document.getText().split(/\r?\n/g); - return items; + return [...createFoldingRanges(lines, '>>'), ...createFoldingRanges(lines, '#')]; } - protected validateLuOption(document: TextDocument, luOption?: LUOption) { + protected async validateLuOption(document: TextDocument, luOption?: LUOption) { if (!luOption) return; const diagnostics: string[] = []; @@ -165,7 +121,7 @@ export class LUServer { diagnostics.push('[Error luOption] importResolver is required but not exist.'); } else { const { fileId, sectionId } = luOption; - const luFile = this.getLUDocument(document)?.index(); + const luFile = await this.getLUDocument(document)?.index(); if (!luFile) { diagnostics.push(`[Error luOption] File ${fileId}.lu do not exist`); } else if (sectionId && sectionId !== PlaceHolderSectionName) { @@ -181,9 +137,9 @@ export class LUServer { ); } - protected getImportResolver(document: TextDocument) { + protected async getImportResolver(document: TextDocument) { const editorContent = document.getText(); - const internalImportResolver = () => { + const internalImportResolver = async () => { return { id: document.uri, content: editorContent, @@ -193,14 +149,14 @@ export class LUServer { if (this.importResolver && fileId && projectId) { const resolver = this.importResolver; - return (source: string, id: string) => { + return async (source: string, id: string) => { const plainLuFile = resolver(source, id, projectId); if (!plainLuFile) { this.sendDiagnostics(document, [ generateDiagnostic(`lu file: ${fileId}.lu not exist on server`, DiagnosticSeverity.Error, document), ]); } - const luFile = luIndexer.parse(plainLuFile.content, plainLuFile.id, luFeatures); + const luFile = await this.luParser.parse(plainLuFile.content, plainLuFile.id, luFeatures); let { content } = luFile; /** * source is . means use as file resolver, not import resolver @@ -208,7 +164,9 @@ export class LUServer { * so here build the full content from server file content and editor content */ if (source === '.' && sectionId) { - content = updateIntent(luFile, sectionId, { Name: sectionId, Body: editorContent }, luFeatures).content; + content = ( + await this.luParser.updateIntent(luFile, sectionId, { Name: sectionId, Body: editorContent }, luFeatures) + ).content; } return { id, content }; }; @@ -217,23 +175,23 @@ export class LUServer { return internalImportResolver; } - protected addLUDocument(document: TextDocument, luOption?: LUOption) { + protected async addLUDocument(document: TextDocument, luOption?: LUOption) { const { uri } = document; const { fileId, sectionId, projectId, luFeatures = {} } = luOption || {}; - const index = () => { - const importResolver: LUImportResolverDelegate = this.getImportResolver(document); + const index = async () => { + const importResolver = await this.getImportResolver(document); let content: string = document.getText(); // if inline mode, composite local with server resolved file. if (this.importResolver && fileId && sectionId) { try { - content = importResolver('.', `${fileId}.lu`).content; + content = (await importResolver('.', `${fileId}.lu`)).content; } catch (error) { // ignore if file not exist } } const id = fileId || uri; - const { intents: sections, diagnostics } = parse(content, id, luFeatures); + const { intents: sections, diagnostics } = await this.luParser.parse(content, id, luFeatures); return { sections, diagnostics, content }; }; @@ -284,7 +242,7 @@ export class LUServer { const edits: TextEdit[] = []; const curLineNumber = params.position.line; const luDoc = this.getLUDocument(document); - const text = luDoc?.index().content || document.getText(); + const text = (await (luDoc?.index()).content) || document.getText(); const lines = text.split('\n'); const position = params.position; const textBeforeCurLine = lines.slice(0, curLineNumber).join('\n'); @@ -417,7 +375,7 @@ export class LUServer { const log = false; const locale = 'en-us'; try { - parsedContent = await parseFile(text, log, locale); + parsedContent = await this.luParser.parseFile(text, log, locale); } catch (e) { // nothing to do in catch block } @@ -439,7 +397,7 @@ export class LUServer { const range = Range.create(position.line, 0, position.line, position.character); const curLineContent = document.getText(range); const luDoc = this.getLUDocument(document); - const text = luDoc?.index().content || document.getText(); + const text = (await luDoc?.index()).content || document.getText(); const lines = text.split('\n'); const curLineNumber = params.position.line; //const textBeforeCurLine = lines.slice(0, curLineNumber).join('\n'); @@ -682,14 +640,14 @@ export class LUServer { } } - protected doValidate(document: TextDocument): void { + protected async doValidate(document: TextDocument): Promise { const text = document.getText(); const luDoc = this.getLUDocument(document); if (!luDoc) { return; } const { fileId, sectionId } = luDoc; - const luFile = luDoc.index(); + const luFile = await luDoc.index(); if (!luFile) { return; } @@ -703,7 +661,7 @@ export class LUServer { // if inline editor, concat new content for validate if (fileId && sectionId) { - const sectionDiags = checkSection( + const sectionDiags = await this.luParser.checkSection( { Name: sectionId, Body: text, diff --git a/Composer/packages/tools/language-servers/language-understanding/src/luParser.ts b/Composer/packages/tools/language-servers/language-understanding/src/luParser.ts new file mode 100644 index 0000000000..9ff75c09f6 --- /dev/null +++ b/Composer/packages/tools/language-servers/language-understanding/src/luParser.ts @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { fork, ChildProcess } from 'child_process'; +import path from 'path'; + +import { parser } from '@microsoft/bf-lu/lib/parser'; +import { updateIntent, checkSection } from '@bfc/indexers/lib/utils/luUtil'; +import { luIndexer } from '@bfc/indexers'; +import { LuFile, ILUFeaturesConfig, LuIntentSection, Diagnostic } from '@bfc/shared'; +import uniqueId from 'lodash/uniqueId'; + +export const isTest = process.env?.NODE_ENV === 'test'; +export interface WorkerMsg { + id: string; + type: 'parse' | 'updateIntent' | 'parseFile' | 'checkSection' | 'getFoldingRanges'; + error?: any; + payload?: any; +} + +export class LuParserWithoutWorker { + public async parse(content: string, id = '', config: ILUFeaturesConfig): Promise { + return luIndexer.parse(content, id, config); + } + public async updateIntent( + luFile: LuFile, + intentName: string, + intent: { Name?: string; Body?: string } | null, + luFeatures: ILUFeaturesConfig + ): Promise { + return updateIntent(luFile, intentName, intent, luFeatures); + } + public async parseFile(text, log, locale): Promise { + return await parser.parseFile(text, log, locale); + } + public async checkSection(intent: LuIntentSection, enableSections = true): Promise { + return checkSection(intent, enableSections); + } +} + +class LuParserWithWorker { + private static _worker: ChildProcess; + private resolves = {}; + private rejects = {}; + + constructor() { + LuParserWithWorker.worker.on('message', this.handleMsg.bind(this)); + } + + public async parse(content: string, id = '', config: ILUFeaturesConfig): Promise { + const msgId = uniqueId(); + const msg = { id: msgId, type: 'parse', payload: { id, content, config } }; + return new Promise((resolve, reject) => { + this.resolves[msgId] = resolve; + this.rejects[msgId] = reject; + LuParserWithWorker.worker.send(msg); + }); + } + public async updateIntent( + luFile: LuFile, + intentName: string, + intent: { Name?: string; Body?: string } | null, + luFeatures: ILUFeaturesConfig + ): Promise { + const msgId = uniqueId(); + const msg = { id: msgId, type: 'updateTemplate', payload: { luFile, intentName, intent, luFeatures } }; + return new Promise((resolve, reject) => { + this.resolves[msgId] = resolve; + this.rejects[msgId] = reject; + LuParserWithWorker.worker.send(msg); + }); + } + + public async parseFile(text: string, log, locale: string): Promise { + const msgId = uniqueId(); + const msg = { id: msgId, type: 'updateTemplate', payload: { text, log, locale } }; + return new Promise((resolve, reject) => { + this.resolves[msgId] = resolve; + this.rejects[msgId] = reject; + LuParserWithWorker.worker.send(msg); + }); + } + + public async checkSection(intent: LuIntentSection, enableSections = true): Promise { + const msgId = uniqueId(); + const msg = { id: msgId, type: 'checkSection', payload: { intent, enableSections } }; + return new Promise((resolve, reject) => { + this.resolves[msgId] = resolve; + this.rejects[msgId] = reject; + LuParserWithWorker.worker.send(msg); + }); + } + + // Handle incoming calculation result + public handleMsg(msg: WorkerMsg) { + const { id, error, payload } = msg; + if (error) { + const reject = this.rejects[id]; + if (reject) reject(error); + } else { + const resolve = this.resolves[id]; + if (resolve) resolve(payload); + } + + // purge used callbacks + delete this.resolves[id]; + delete this.rejects[id]; + } + + static get worker() { + if (this._worker && !this._worker.killed) { + return this._worker; + } + + const workerScriptPath = path.join(__dirname, 'luWorker.js'); + // set exec arguments to empty, avoid fork nodemon `--inspect` error + this._worker = fork(workerScriptPath, [], { execArgv: [] }); + return this._worker; + } +} + +// Do not use worker when running test. +const LuParser = LuParserWithWorker; + +export { LuParser }; diff --git a/Composer/packages/tools/language-servers/language-understanding/src/luWorker.ts b/Composer/packages/tools/language-servers/language-understanding/src/luWorker.ts new file mode 100644 index 0000000000..3c3753a0e1 --- /dev/null +++ b/Composer/packages/tools/language-servers/language-understanding/src/luWorker.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { luIndexer } from '@bfc/indexers'; +import { updateIntent, checkSection } from '@bfc/indexers/lib/utils/luUtil'; +import { parser } from '@microsoft/bf-lu/lib/parser'; + +import { WorkerMsg } from './luParser'; +process.on('message', async (msg: WorkerMsg) => { + try { + switch (msg.type) { + case 'parse': { + const { id, content, config } = msg.payload; + process.send?.({ id: msg.id, payload: luIndexer.parse(content, id, config) }); + break; + } + + case 'updateIntent': { + const { luFile, intentName, intent, luFeatures } = msg.payload; + process.send?.({ id: msg.id, payload: updateIntent(luFile, intentName, intent, luFeatures) }); + break; + } + + case 'parseFile': { + const { text, log, locale } = msg.payload; + process.send?.({ id: msg.id, payload: await parser.parseFile(text, log, locale) }); + break; + } + + case 'checkSection': { + const { intent, enableSections } = msg.payload; + process.send?.({ id: msg.id, payload: checkSection(intent, enableSections) }); + break; + } + } + } catch (error) { + process.send?.({ id: msg.id, error }); + } +}); diff --git a/Composer/packages/tools/language-servers/language-understanding/src/utils.ts b/Composer/packages/tools/language-servers/language-understanding/src/utils.ts index 9c0064b769..d9be289998 100644 --- a/Composer/packages/tools/language-servers/language-understanding/src/utils.ts +++ b/Composer/packages/tools/language-servers/language-understanding/src/utils.ts @@ -5,6 +5,7 @@ import { Range, Position, DiagnosticSeverity, Diagnostic } from 'vscode-language import { TextDocument } from 'vscode-languageserver-textdocument'; import { offsetRange } from '@bfc/indexers'; import { DiagnosticSeverity as BFDiagnosticSeverity, Diagnostic as BFDiagnostic } from '@bfc/shared'; +import { FoldingRange } from 'vscode-languageserver'; export interface LUOption { projectId: string; @@ -72,9 +73,29 @@ export function convertDiagnostics(lgDiags: BFDiagnostic[] = [], document: TextD return diagnostics; } -export function getLineByIndex(document: TextDocument, line: number) { - const lineCount = document.lineCount; - if (line >= lineCount || line < 0) return null; +export function createFoldingRanges(lines: string[], prefix: string) { + const items: FoldingRange[] = []; - return document.getText().split(/\r?\n/g)[line]; + if (!lines?.length) { + return items; + } + + const lineCount = lines.length; + let startIdx = -1; + + for (let i = 0; i < lineCount; i++) { + if (lines[i].trim().startsWith(prefix)) { + if (startIdx !== -1) { + items.push(FoldingRange.create(startIdx, i - 1)); + } + + startIdx = i; + } + } + + if (startIdx !== -1) { + items.push(FoldingRange.create(startIdx, lineCount - 1)); + } + + return items; } diff --git a/Composer/packages/types/src/indexers.ts b/Composer/packages/types/src/indexers.ts index 21c106694d..9a4f16948b 100644 --- a/Composer/packages/types/src/indexers.ts +++ b/Composer/packages/types/src/indexers.ts @@ -117,6 +117,7 @@ export type LuFile = { resource: LuParseResource; imports: { id: string; path: string; description: string }[]; published?: boolean; + isContentUnparsed: boolean; }; export type LuParseResourceSection = { @@ -155,6 +156,7 @@ export type QnAFile = { options: { id: string; name: string; value: string }[]; empty: boolean; resource: LuParseResource; + isContentUnparsed: boolean; }; export type LgTemplate = { @@ -196,6 +198,7 @@ export type LgFile = { imports: { id: string; path: string; description: string }[]; options?: string[]; parseResult?: any; + isContentUnparsed: boolean; }; export type Manifest = { diff --git a/Composer/packages/ui-plugins/lg/src/LgField.tsx b/Composer/packages/ui-plugins/lg/src/LgField.tsx index b8092ebab5..e96d6a2174 100644 --- a/Composer/packages/ui-plugins/lg/src/LgField.tsx +++ b/Composer/packages/ui-plugins/lg/src/LgField.tsx @@ -213,13 +213,20 @@ const LgField: React.FC> = (props) => { }, [editorMode, allowResponseEditor, props.onChange, shellApi.telemetryClient]); const navigateToLgPage = useCallback( - (lgFileId: string, templateId?: string) => { + (lgFileId: string, options?: { templateId?: string; line?: number }) => { // eslint-disable-next-line security/detect-non-literal-regexp const pattern = new RegExp(`.${locale}`, 'g'); const fileId = currentDialog.isFormDialog ? lgFileId : lgFileId.replace(pattern, ''); - const url = currentDialog.isFormDialog + let url = currentDialog.isFormDialog ? `/bot/${projectId}/language-generation/${currentDialog.id}/item/${fileId}` - : `/bot/${projectId}/language-generation/${fileId}${templateId ? `/edit?t=${templateId}` : ''}`; + : `/bot/${projectId}/language-generation/${fileId}`; + + if (options?.line) { + url = url + `/edit#L=${options.line}`; + } else if (options?.templateId) { + url = url + `/edit?t=${options.templateId}`; + } + shellApi.navigateTo(url); }, [shellApi, projectId, locale] @@ -282,6 +289,7 @@ const LgField: React.FC> = (props) => {