diff --git a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx index 379b7fbfec..01db61ee6a 100644 --- a/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx +++ b/Composer/packages/client/src/components/ProjectTree/ProjectTree.tsx @@ -23,6 +23,7 @@ import { } from '../../recoilModel'; import { getFriendlyName } from '../../utils/dialogUtil'; import { triggerNotSupported } from '../../utils/dialogValidator'; +import { useFeatureFlag } from '../../utils/hooks'; import { TreeItem } from './treeItem'; import { ExpandableNode } from './ExpandableNode'; @@ -133,6 +134,7 @@ export const ProjectTree: React.FC = ({ const { onboardingAddCoachMarkRef, selectTo, navTo, navigateToFormDialogSchema } = useRecoilValue(dispatcherState); const [filter, setFilter] = useState(''); + const formDialogComposerFeatureEnabled = useFeatureFlag('FORM_DIALOG'); const [selectedLink, setSelectedLink] = useState(); const delayedSetFilter = debounce((newValue) => setFilter(newValue), 1000); const addMainDialogRef = useCallback((mainDialog) => onboardingAddCoachMarkRef({ mainDialog }), []); @@ -164,7 +166,7 @@ export const ProjectTree: React.FC = ({ }; const dialogIsFormDialog = (dialog: DialogInfo) => { - return process.env.COMPOSER_ENABLE_FORMS && dialog.content?.schema !== undefined; + return formDialogComposerFeatureEnabled && dialog.content?.schema !== undefined; }; const formDialogSchemaExists = (projectId: string, dialog: DialogInfo) => { diff --git a/Composer/packages/client/src/recoilModel/selectors/__test__/dialogImports.test.tsx b/Composer/packages/client/src/recoilModel/selectors/__test__/dialogImports.test.tsx new file mode 100644 index 0000000000..49828edb18 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/__test__/dialogImports.test.tsx @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getBaseName } from '../../../utils/fileUtil'; +import { getLanguageFileImports } from '../dialogImports'; + +const files = [ + { + id: 'name1.lg', + content: '[name2.lg](../files/name2.lg)\n[name3.lg](../files/name3.lg)\n', + }, + { + id: 'id.lg', + content: '', + }, + { + id: 'gender.lg', + content: '', + }, + { + id: 'name2.lg', + content: '[name4.lg](../files/name4.lg)\n[name5-entity.lg](../files/name5-entity.lg)\n', + }, + { + id: 'name3.lg', + content: '- Enter a value for name3', + }, + { + id: 'name4.lg', + content: '[name5-entity.lg](../files/name5-entity.lg)', + }, + { + id: 'name5-entity.lg', + content: '- Enter a value for name5', + }, +]; + +describe('dialogImports selectors', () => { + it('should follow all imports and list all unique imports', () => { + const getFile = (id) => files.find((f) => getBaseName(f.id) === id) as { id: string; content: string }; + + const fileImports = getLanguageFileImports('name1', getFile); + expect(fileImports).toEqual([ + { + id: 'name2.lg', + content: '[name4.lg](../files/name4.lg)\n[name5-entity.lg](../files/name5-entity.lg)\n', + }, + { + id: 'name3.lg', + content: '- Enter a value for name3', + }, + { + id: 'name4.lg', + content: '[name5-entity.lg](../files/name5-entity.lg)', + }, + { + id: 'name5-entity.lg', + content: '- Enter a value for name5', + }, + ]); + }); +}); diff --git a/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts b/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts new file mode 100644 index 0000000000..229c4ca702 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/dialogImports.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { LanguageFileImport, LgFile, LuFile } from '@bfc/shared'; +import uniqBy from 'lodash/uniqBy'; +import { selectorFamily } from 'recoil'; + +import { getBaseName } from '../../utils/fileUtil'; +import { localeState, lgFilesState, luFilesState } from '../atoms'; + +// eslint-disable-next-line security/detect-unsafe-regex +const importRegex = /\[(?.*?)]\((?.*?)(?="|\))(?".*")?\)/g; + +const getImportsHelper = (content: string): LanguageFileImport[] => { + const lines = content.split(/\r?\n/g).filter((l) => !!l) ?? []; + + return (lines + .map((l) => { + importRegex.lastIndex = 0; + return importRegex.exec(l) as RegExpExecArray; + }) + .filter(Boolean) as RegExpExecArray[]).map((regExecArr) => ({ + id: getBaseName(regExecArr.groups?.id ?? ''), + importPath: regExecArr.groups?.importPath ?? '', + })); +}; + +// Finds all the file imports starting from a given dialog file. +export const getLanguageFileImports = ( + rootDialogId: string, + getFile: (fileId: string) => T +): T[] => { + const imports: LanguageFileImport[] = []; + + const visitedIds: string[] = []; + const fileIds = [rootDialogId]; + + while (fileIds.length) { + const currentId = fileIds.pop() as string; + // If this file is already visited, then continue. + if (visitedIds.includes(currentId)) { + continue; + } + const file = getFile(currentId); + // If file is not found or file content is empty, then continue. + if (!file || !file.content) { + continue; + } + const currentImports = getImportsHelper(file.content); + visitedIds.push(currentId); + imports.push(...currentImports); + const newIds = currentImports.map((ci) => getBaseName(ci.id)); + fileIds.push(...newIds); + } + + return uniqBy(imports, 'id').map((impExpr) => getFile(impExpr.id)); +}; + +// Returns all the lg files referenced by a dialog file and its referenced lg files. +export const lgImportsSelectorFamily = selectorFamily({ + key: 'lgImports', + get: ({ projectId, dialogId }) => ({ get }) => { + const locale = get(localeState(projectId)); + + const getFile = (fileId: string) => + get(lgFilesState(projectId)).find((f) => f.id === fileId || f.id === `${fileId}.${locale}`) as LgFile; + + return getLanguageFileImports(dialogId, getFile); + }, +}); + +// Returns all the lu files referenced by a dialog file and its referenced lu files. +export const luImportsSelectorFamily = selectorFamily({ + key: 'luImports', + get: ({ projectId, dialogId }) => ({ get }) => { + const locale = get(localeState(projectId)); + + const getFile = (fileId: string) => + get(luFilesState(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 c6cdd3fa4f..062b4fdbe0 100644 --- a/Composer/packages/client/src/recoilModel/selectors/index.ts +++ b/Composer/packages/client/src/recoilModel/selectors/index.ts @@ -6,4 +6,5 @@ export * from './eject'; export * from './extensions'; export * from './validatedDialogs'; export * from './dialogs'; +export * from './dialogImports'; export * from './projectTemplates'; diff --git a/Composer/packages/types/src/indexers.ts b/Composer/packages/types/src/indexers.ts index 5596092ed5..eb953303b9 100644 --- a/Composer/packages/types/src/indexers.ts +++ b/Composer/packages/types/src/indexers.ts @@ -150,6 +150,11 @@ export type LgParsed = { templates: LgTemplate[]; }; +export type LanguageFileImport = { + id: string; + importPath: string; +}; + export type LgFile = { id: string; content: string;