diff --git a/Composer/.gitignore b/Composer/.gitignore index 1603d74bba..756c18e624 100644 --- a/Composer/.gitignore +++ b/Composer/.gitignore @@ -6,3 +6,10 @@ cypress/results cypress/videos TestBots/ + +l10ntemp/ + +packages/server/schemas/*.schema +packages/server/schemas/*.uischema +!packages/server/schemas/sdk.schema +!packages/server/schemas/sdk.uischema diff --git a/Composer/cypress/integration/LuisDeploy.spec.ts b/Composer/cypress/integration/LuisDeploy.spec.ts index 1c79844e86..ade1d8a447 100644 --- a/Composer/cypress/integration/LuisDeploy.spec.ts +++ b/Composer/cypress/integration/LuisDeploy.spec.ts @@ -20,7 +20,7 @@ context('Luis Deploy', () => { status: 200, response: 'fixture:luPublish/success', }); - cy.findByText('Start Bot').click(); + cy.findByText(/^(Start|Restart) Bot$/).click(); // clear its settings before cy.enterTextAndSubmit('ProjectNameInput', 'MyProject'); diff --git a/Composer/package.json b/Composer/package.json index 2c5fc3905a..76b2afedca 100644 --- a/Composer/package.json +++ b/Composer/package.json @@ -10,7 +10,9 @@ "set-value": "^3.0.2", "kind-of": "^6.0.3", "elliptic": "^6.5.3", - "bl": "^2.2.1" + "@babel/parser": "^7.11.3", + "bl": "^2.2.1", + "node-forge": "^0.10.0" }, "engines": { "node": ">=12" @@ -36,7 +38,7 @@ "scripts": { "build": "node scripts/begin.js && yarn build:prod", "build:prod": "yarn build:dev && yarn build:server && yarn build:client && yarn build:electron", - "build:dev": "yarn build:test && yarn build:lib && yarn build:tools && yarn build:extensions && yarn build:plugins", + "build:dev": "yarn build:test && yarn build:lib && yarn build:tools && yarn build:extensions && yarn build:plugins && yarn l10n", "build:test": "yarn workspace @bfc/test-utils build", "build:lib": "yarn workspace @bfc/libs build:all", "build:electron": "yarn workspace @bfc/electron-server build", @@ -68,7 +70,12 @@ "typecheck": "concurrently --kill-others-on-fail \"npm:typecheck:*\"", "typecheck:server": "yarn workspace @bfc/server typecheck", "typecheck:client": "yarn workspace @bfc/client typecheck", - "tableflip": "rimraf node_modules/ **/node_modules && yarn && yarn build" + "tableflip": "rimraf node_modules/ **/node_modules && yarn && yarn build", + "l10n:extract": "cross-env NODE_ENV=production format-message extract -g underscored_crc32 -o packages/server/src/locales/en-US.json l10ntemp/**/*.js", + "l10n:extractJson": "node scripts/l10n-extractJson.js", + "l10n:transform": "node scripts/l10n-transform.js", + "l10n:babel": "babel ./packages --extensions \".ts,.tsx,.jsx,.js\" --out-dir l10ntemp --presets=@babel/react,@babel/typescript --plugins=@babel/plugin-proposal-class-properties --ignore \"packages/**/__tests__\",\"packages/**/node_modules\",\"packages/**/build/**/*.js\"", + "l10n": "yarn l10n:babel && yarn l10n:extract && yarn l10n:transform packages/server/src/locales/en-US.json && yarn l10n:extractJson packages/server/schemas" }, "husky": { "hooks": { diff --git a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/TriggerSummary/TriggerSummary.tsx b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/TriggerSummary/TriggerSummary.tsx index 8f179e8538..7a033e1b63 100644 --- a/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/TriggerSummary/TriggerSummary.tsx +++ b/Composer/packages/adaptive-flow/src/adaptive-flow-renderer/widgets/TriggerSummary/TriggerSummary.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ -import { ConceptLabels } from '@bfc/shared'; +import { conceptLabels } from '@bfc/shared'; import { jsx } from '@emotion/core'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; import get from 'lodash/get'; @@ -18,7 +18,7 @@ import { } from './triggerStyles'; function getLabel(data: any): string { - const labelOverrides = ConceptLabels[data.$kind]; + const labelOverrides = conceptLabels()[data.$kind]; if (labelOverrides) { return labelOverrides.subtitle || labelOverrides.title; } @@ -27,7 +27,8 @@ function getLabel(data: any): string { function getName(data: any): string { return ( - data.intent || get(data, '$designer.name', ConceptLabels[data.$kind] ? ConceptLabels[data.$kind].title : data.$kind) + data.intent || + get(data, '$designer.name', conceptLabels()[data.$kind] ? conceptLabels()[data.$kind].title : data.$kind) ); } diff --git a/Composer/packages/adaptive-form/src/components/FormRow.tsx b/Composer/packages/adaptive-form/src/components/FormRow.tsx index 95aa9352b1..5114b4c8b2 100644 --- a/Composer/packages/adaptive-form/src/components/FormRow.tsx +++ b/Composer/packages/adaptive-form/src/components/FormRow.tsx @@ -98,6 +98,7 @@ const FormRow: React.FC = (props) => { ); } + return ; }; diff --git a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx index 5f86ffccd8..cff71f2607 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx @@ -75,7 +75,8 @@ describe('', () => { 'EchoBot-1', '', expect.stringMatching(/(\/|\\)test-folder(\/|\\)Desktop/), - '' + '', + 'en-US' ); }); }); diff --git a/Composer/packages/client/__tests__/utils/dialogUtil.test.js b/Composer/packages/client/__tests__/utils/dialogUtil.test.js index 2520dc81aa..718bcbe1e6 100644 --- a/Composer/packages/client/__tests__/utils/dialogUtil.test.js +++ b/Composer/packages/client/__tests__/utils/dialogUtil.test.js @@ -13,7 +13,7 @@ import { getEventTypes, getActivityTypes, getFriendlyName, - getbreadcrumbLabel, + getBreadcrumbLabel, getSelected, } from '../../src/utils/dialogUtil'; @@ -234,9 +234,9 @@ describe('getFriendlyName', () => { }); }); -describe('getbreadcrumbLabel', () => { +describe('getBreadcrumbLabel', () => { it('return breadcrumb label', () => { - const name = getbreadcrumbLabel(dialogs, 'id1', null, null); + const name = getBreadcrumbLabel(dialogs, 'id1', null, null); expect(name).toBe('MainDialog'); }); }); diff --git a/Composer/packages/client/__tests__/utils/fileUtil.test.js b/Composer/packages/client/__tests__/utils/fileUtil.test.js index d69cd29137..cb1d54b565 100644 --- a/Composer/packages/client/__tests__/utils/fileUtil.test.js +++ b/Composer/packages/client/__tests__/utils/fileUtil.test.js @@ -1,7 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { getExtension, getBaseName, upperCaseName } from '../../src/utils/fileUtil'; +import formatMessage from 'format-message'; + +import { getExtension, getBaseName, upperCaseName, loadLocale } from '../../src/utils/fileUtil'; +import httpClient from '../../src/utils/httpUtil'; + +jest.mock('../../src/utils/httpUtil'); const files = ['a.text', 'a.b.text', 1]; @@ -37,3 +42,29 @@ describe('upperCaseName', () => { expect(result3).toEqual(1); }); }); + +describe('loadLocale', () => { + const LOCALE = 'en-test'; + it("does not set locale if it can't find one", async () => { + jest.spyOn(httpClient, 'get').mockImplementation(() => ({ data: null })); + + expect(await loadLocale(LOCALE)).toBeNull(); + }); + it('does not set locale if the server returns an error page', async () => { + jest.spyOn(httpClient, 'get').mockImplementation(() => ({ data: 'error page' })); + + expect(await loadLocale(LOCALE)).toBeNull(); + }); + it('sets locale if it does find one', async () => { + const RESPONSE = { data: { abc: 'def' } }; + + jest.spyOn(httpClient, 'get').mockImplementation(() => RESPONSE); + + expect(await loadLocale(LOCALE)).toMatchObject({ + locale: LOCALE, + generateId: expect.anything(), + missingTranslation: 'ignore', + translations: { [LOCALE]: RESPONSE.data }, + }); + }); +}); diff --git a/Composer/packages/client/package.json b/Composer/packages/client/package.json index bb5b491bb1..8bb8e2289a 100644 --- a/Composer/packages/client/package.json +++ b/Composer/packages/client/package.json @@ -15,8 +15,7 @@ "test": "jest", "lint": "eslint --quiet --ext .js,.jsx,.ts,.tsx ./src ./__tests__", "lint:fix": "yarn lint --fix", - "typecheck": "tsc --noEmit", - "extract": "cross-env NODE_ENV=production format-message extract -g underscored_crc32 -o locales/en-US.json src/**/*.ts src/**/*.tsx" + "typecheck": "tsc --noEmit" }, "proxy": "http://localhost:5000", "dependencies": { @@ -43,6 +42,7 @@ "axios": "^0.19.2", "babel-plugin-extract-format-message": "^6.2.3", "format-message": "^6.2.3", + "format-message-generate-id": "^6.2.3", "immer": "^5.2.0", "jwt-decode": "^2.2.0", "lodash": "^4.17.19", diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 907901ffb6..3a1fd4bbeb 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -8,18 +8,25 @@ import { useRecoilValue } from 'recoil'; import { Header } from './components/Header'; import { Announcement } from './components/AppComponents/Announcement'; import { MainContainer } from './components/AppComponents/MainContainer'; +import { userSettingsState } from './recoilModel'; +import { loadLocale } from './utils/fileUtil'; import { dispatcherState } from './recoilModel/DispatcherWrapper'; initializeIcons(undefined, { disableWarnings: true }); export const App: React.FC = () => { + const { appLocale } = useRecoilValue(userSettingsState); + useEffect(() => { + loadLocale(appLocale); + }, [appLocale]); + const { fetchExtensions } = useRecoilValue(dispatcherState); useEffect(() => { fetchExtensions(); }); return ( - +
diff --git a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx index f3d9eca532..5e2493766b 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx @@ -16,6 +16,7 @@ import { templateProjectsState, storagesState, focusedStorageFolderState, + userSettingsState, localeState, } from '../../recoilModel'; import Home from '../../pages/home/Home'; @@ -51,6 +52,7 @@ const CreationFlow: React.FC = () => { const templateProjects = useRecoilValue(templateProjectsState); const storages = useRecoilValue(storagesState); const focusedStorageFolder = useRecoilValue(focusedStorageFolderState); + const { appLocale } = useRecoilValue(userSettingsState); const locale = useRecoilValue(localeState); const cachedProjectId = useProjectIdCache(); const currentStorageIndex = useRef(0); @@ -103,7 +105,14 @@ const CreationFlow: React.FC = () => { }; const handleCreateNew = async (formData, templateId: string) => { - await createProject(templateId || '', formData.name, formData.description, formData.location, formData.schemaUrl); + await createProject( + templateId || '', + formData.name, + formData.description, + formData.location, + formData.schemaUrl, + appLocale + ); }; const handleSaveAs = (formData) => { diff --git a/Composer/packages/client/src/index.tsx b/Composer/packages/client/src/index.tsx index 3c765ed782..294e6af4ab 100644 --- a/Composer/packages/client/src/index.tsx +++ b/Composer/packages/client/src/index.tsx @@ -3,21 +3,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import formatMessage from 'format-message'; import { CacheProvider } from '@emotion/core'; import createCache from '@emotion/cache'; import { RecoilRoot } from 'recoil'; import './index.css'; + import { App } from './App'; import { DispatcherWrapper } from './recoilModel'; const appHostElm = document.getElementById('root'); -formatMessage.setup({ - missingTranslation: process.env.NODE_ENV === 'development' ? 'warning' : 'ignore', -}); - const emotionCache = createCache({ // @ts-ignore nonce: window.__nonce__, diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 7cccc2fdfa..87bb9a8678 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -20,7 +20,7 @@ import { DialogDeleting } from '../../constants'; import { createSelectedPath, deleteTrigger, - getbreadcrumbLabel, + getBreadcrumbLabel, qnaMatcherKey, TriggerFormData, getDialogData, @@ -461,7 +461,7 @@ const DesignPage: React.FC 0 ? breadcrumb.reduce((result, item, index) => { const { dialogId, selected, focused } = item; - const text = getbreadcrumbLabel(dialogs, dialogId, selected, focused); + const text = getBreadcrumbLabel(dialogs, dialogId, selected, focused); if (text) { result.push({ // @ts-ignore diff --git a/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx b/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx index 94d9ef7eac..f94d35cbc2 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx @@ -17,6 +17,7 @@ import { onboardingState, userSettingsState, dispatcherState } from '../../../re import { container, section } from './styles'; import { SettingToggle } from './SettingToggle'; +import { SettingDropdown } from './SettingDropdown'; import * as images from './images'; const ElectronSettings = lazy(() => @@ -43,8 +44,24 @@ const AppSettings: React.FC = () => { updateUserSettings({ codeEditor: { [key]: checked } }); }; + const onLocaleChange = (appLocale: string) => { + updateUserSettings({ appLocale }); + }; + const renderElectronSettings = isElectron(); + const languageOptions = [{ key: 'en-US', text: formatMessage('English (US)') }]; + if (process.env.NODE_ENV !== 'production') { + languageOptions.push({ + key: 'en-US-pseudo', + text: formatMessage('Pseudo'), + }); + languageOptions.push({ + key: 'en-US-DoesNotExist', + text: formatMessage('Does Not Exist'), + }); + } + return (
@@ -127,7 +144,17 @@ const AppSettings: React.FC = () => { onToggle={onCodeEditorChange('wordWrap')} />
- +
+

{formatMessage('Application Language')}

+ +
}>{renderElectronSettings && }
); diff --git a/Composer/packages/client/src/pages/setting/app-settings/SettingDropdown.tsx b/Composer/packages/client/src/pages/setting/app-settings/SettingDropdown.tsx new file mode 100644 index 0000000000..013edaa40e --- /dev/null +++ b/Composer/packages/client/src/pages/setting/app-settings/SettingDropdown.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React from 'react'; +import { Label } from 'office-ui-fabric-react/lib/Label'; +import { useId } from '@uifabric/react-hooks'; +import kebabCase from 'lodash/kebabCase'; +import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown'; + +import * as styles from './styles'; + +interface ISettingToggleProps { + description: React.ReactChild; + id?: string; + image: string; + onChange: (key: string) => void; + title: string; + options: { key: string; text: string }[]; + selected?: string; +} + +const SettingDropdown: React.FC = (props) => { + const { id, title, description, image, onChange, options, selected } = props; + const uniqueId = useId(kebabCase(title)); + + return ( +
+ +
+ +

{description}

+
+
+ onChange(option?.key?.toString() ?? '')} + /> +
+
+ ); +}; + +export { SettingDropdown }; diff --git a/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx b/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx index 2d3ec13778..e125c0ae4d 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx @@ -28,7 +28,7 @@ const SettingToggle: React.FC = (props) => { return (