From 6aaddd9c600c10a1ac094932a5e3297860eab9ba Mon Sep 17 00:00:00 2001 From: Srinath Narayanan Date: Thu, 28 Jan 2021 11:17:02 -0800 Subject: [PATCH] Added localization for the Self Serve Model (#406) * added localization for selfserve model * added comment * addressed PR comments * fixed format errors * Addressed PR comments --- package-lock.json | 46 + package.json | 4 + .../SmartUi/SmartUiComponent.test.tsx | 30 +- .../Controls/SmartUi/SmartUiComponent.tsx | 42 +- src/Localization/en/translations.json | 33 + src/SelfServe/Decorators.tsx | 28 +- src/SelfServe/Example/SelfServeExample.rp.ts | 16 +- src/SelfServe/Example/SelfServeExample.tsx | 56 +- src/SelfServe/SelfServeComponent.test.tsx | 16 +- src/SelfServe/SelfServeComponent.tsx | 117 ++- src/SelfServe/SelfServeTypes.ts | 16 +- src/SelfServe/SelfServeUtils.test.tsx | 56 +- src/SelfServe/SelfServeUtils.tsx | 25 +- src/SelfServe/SqlX/SqlX.tsx | 16 +- .../SelfServeComponent.test.tsx.snap | 877 +----------------- src/i18n.ts | 31 + 16 files changed, 367 insertions(+), 1042 deletions(-) create mode 100644 src/Localization/en/translations.json create mode 100644 src/i18n.ts diff --git a/package-lock.json b/package-lock.json index e2d59b682..af44fe3f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11665,6 +11665,14 @@ } } }, + "html-parse-stringify2": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz", + "integrity": "sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=", + "requires": { + "void-elements": "^2.0.1" + } + }, "html-to-react": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/html-to-react/-/html-to-react-1.4.5.tgz", @@ -11850,6 +11858,30 @@ "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", "dev": true }, + "i18next": { + "version": "19.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-19.8.4.tgz", + "integrity": "sha512-FfVPNWv+felJObeZ6DSXZkj9QM1Ivvh7NcFCgA8XPtJWHz0iXVa9BUy+QY8EPrCLE+vWgDfV/sc96BgXVo6HAA==", + "requires": { + "@babel/runtime": "^7.12.0" + } + }, + "i18next-browser-languagedetector": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-6.0.1.tgz", + "integrity": "sha512-3H+OsNQn3FciomUU0d4zPFHsvJv4X66lBelXk9hnIDYDsveIgT7dWZ3/VvcSlpKk9lvCK770blRZ/CwHMXZqWw==", + "requires": { + "@babel/runtime": "^7.5.5" + } + }, + "i18next-http-backend": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-1.0.23.tgz", + "integrity": "sha512-2iXwUmawM4kozvGN+k7G9u/bYQdgqtTXVK0cWvLSOpUCTaW30ZzRhgu0FBfinb71XjwUEvdqb95jNrEFjrwGKw==", + "requires": { + "node-fetch": "2.6.1" + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -17900,6 +17932,15 @@ "prop-types": "^15.6.1" } }, + "react-i18next": { + "version": "11.8.5", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.8.5.tgz", + "integrity": "sha512-2jY/8NkhNv2KWBnZuhHxTn13aMxAbvhiDUNskm+1xVVnrPId78l8fA7fCyVeO3XU1kptM0t4MtvxV1Nu08cjLw==", + "requires": { + "@babel/runtime": "^7.3.1", + "html-parse-stringify2": "2.0.1" + } + }, "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -21373,6 +21414,11 @@ "integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==", "dev": true }, + "void-elements": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", + "integrity": "sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=" + }, "w3c-hr-time": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", diff --git a/package.json b/package.json index 7e8d649fd..aa9b811c9 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,9 @@ "eslint-plugin-react": "7.20.0", "hasher": "1.2.0", "html2canvas": "1.0.0-rc.5", + "i18next": "19.8.4", + "i18next-browser-languagedetector": "6.0.1", + "i18next-http-backend": "1.0.23", "immutable": "4.0.0-rc.12", "is-ci": "2.0.0", "jquery": "3.5.1", @@ -86,6 +89,7 @@ "react-dnd-html5-backend": "9.4.0", "react-dom": "16.13.1", "react-hotkeys": "2.0.0", + "react-i18next": "11.8.5", "react-notification-system": "0.2.17", "react-redux": "7.1.3", "redux": "4.0.4", diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx index 3e13580f6..f9ed76dfa 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.test.tsx @@ -8,10 +8,10 @@ describe("SmartUiComponent", () => { root: { id: "root", info: { - message: "Start at $24/mo per database", + messageTKey: "Start at $24/mo per database", link: { href: "https://aka.ms/azure-cosmos-db-pricing", - text: "More Details", + textTKey: "More Details", }, }, children: [ @@ -21,10 +21,10 @@ describe("SmartUiComponent", () => { dataFieldName: "description", type: "string", description: { - text: "this is an example description text.", + textTKey: "this is an example description text.", link: { href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", - text: "Click here for more information.", + textTKey: "Click here for more information.", }, }, }, @@ -32,7 +32,7 @@ describe("SmartUiComponent", () => { { id: "throughput", input: { - label: "Throughput (input)", + labelTKey: "Throughput (input)", dataFieldName: "throughput", type: "number", min: 400, @@ -45,7 +45,7 @@ describe("SmartUiComponent", () => { { id: "throughput2", input: { - label: "Throughput (Slider)", + labelTKey: "Throughput (Slider)", dataFieldName: "throughput2", type: "number", min: 400, @@ -58,7 +58,7 @@ describe("SmartUiComponent", () => { { id: "throughput3", input: { - label: "Throughput (invalid)", + labelTKey: "Throughput (invalid)", dataFieldName: "throughput3", type: "boolean", min: 400, @@ -72,7 +72,7 @@ describe("SmartUiComponent", () => { { id: "containerId", input: { - label: "Container id", + labelTKey: "Container id", dataFieldName: "containerId", type: "string", }, @@ -80,9 +80,9 @@ describe("SmartUiComponent", () => { { id: "analyticalStore", input: { - label: "Analytical Store", - trueLabel: "Enabled", - falseLabel: "Disabled", + labelTKey: "Analytical Store", + trueLabelTKey: "Enabled", + falseLabelTKey: "Disabled", defaultValue: true, dataFieldName: "analyticalStore", type: "boolean", @@ -91,7 +91,7 @@ describe("SmartUiComponent", () => { { id: "database", input: { - label: "Database", + labelTKey: "Database", dataFieldName: "database", type: "object", choices: [ @@ -117,6 +117,9 @@ describe("SmartUiComponent", () => { onError={() => { return; }} + getTranslation={(key: string) => { + return key; + }} /> ); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -145,6 +148,9 @@ describe("SmartUiComponent", () => { onError={() => { return; }} + getTranslation={(key: string) => { + return key; + }} /> ); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx index 0bde146ea..f0ce2adcc 100644 --- a/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx +++ b/src/Explorer/Controls/SmartUi/SmartUiComponent.tsx @@ -18,6 +18,7 @@ import { NumberUiType, SmartUiInput, } from "../../../SelfServe/SelfServeTypes"; +import { TFunction } from "i18next"; /** * Generic UX renderer @@ -34,8 +35,8 @@ interface BaseDisplay { } interface BaseInput extends BaseDisplay { - label: string; - placeholder?: string; + labelTKey: string; + placeholderTKey?: string; errorMessage?: string; } @@ -51,8 +52,8 @@ interface NumberInput extends BaseInput { } interface BooleanInput extends BaseInput { - trueLabel: string; - falseLabel: string; + trueLabelTKey: string; + falseLabelTKey: string; defaultValue?: boolean; } @@ -89,6 +90,7 @@ export interface SmartUiComponentProps { onInputChange: (input: AnyDisplay, newValue: InputType) => void; onError: (hasError: boolean) => void; disabled: boolean; + getTranslation: TFunction; } interface SmartUiComponentState { @@ -122,10 +124,10 @@ export class SmartUiComponent extends React.Component - {info.message} + {this.props.getTranslation(info.messageTKey)} {info.link && ( - {info.link.text} + {this.props.getTranslation(info.link.textTKey)} )} @@ -139,10 +141,10 @@ export class SmartUiComponent extends React.Component this.props.onInputChange(input, newValue)} styles={{ @@ -165,10 +167,10 @@ export class SmartUiComponent extends React.Component - {input.description.text}{" "} + {this.props.getTranslation(input.description.textTKey)}{" "} {description.link && ( - {input.description.link.text} + {this.props.getTranslation(input.description.link.textTKey)} )} @@ -219,12 +221,12 @@ export class SmartUiComponent extends React.Component this.props.onInputChange(input, checked)} styles={{ root: { width: 400 } }} @@ -296,7 +298,7 @@ export class SmartUiComponent extends React.Component this.props.onInputChange(input, item.key.toString())} - placeholder={placeholder} + placeholder={this.props.getTranslation(placeholderTKey)} disabled={disabled} options={choices.map((c) => ({ key: c.key, - text: c.label, + text: this.props.getTranslation(c.label), }))} styles={{ root: { width: 400 }, diff --git a/src/Localization/en/translations.json b/src/Localization/en/translations.json new file mode 100644 index 000000000..5732c02a3 --- /dev/null +++ b/src/Localization/en/translations.json @@ -0,0 +1,33 @@ +{ + "translations": { + "Common": { + "Save": "Save", + "Discard": "Discard", + "Refresh": "Refesh" + }, + "SelfServeExample": { + "North Central US": "North Central US", + "West US": "West US", + "East US 2": "East US 2", + "ClassInfo": "This is a self serve class", + "RegionDropdownInfo": "More regions can be added in the future.", + "ValidationError": "Regions and AccountName should not be empty.", + "DescriptionText": "This class sets collection and database throughput.", + "DecriptionLinkText": "Click here for more information", + "Regions": "Regions", + "RegionsPlaceholder": "Select a region", + "Enable Logging": "Enable Logging", + "Enable": "Enable", + "Disable": "Disable", + "Account Name": "Account Name", + "AccountNamePlaceHolder": "Enter the account name", + "Collection Throughput": "Collection Throughput", + "Enable DB level throughput": "Enable DB level throughput", + "Database Throughput": "Database Throughput", + "RefreshMessage": "Self Serve Example successfully refreshing", + "SubmissionMessage": "Submitted successfully" + }, + "SqlX": { + } + } +} \ No newline at end of file diff --git a/src/SelfServe/Decorators.tsx b/src/SelfServe/Decorators.tsx index 9c060e6c0..e524ce7a7 100644 --- a/src/SelfServe/Decorators.tsx +++ b/src/SelfServe/Decorators.tsx @@ -8,7 +8,7 @@ interface Decorator { } interface InputOptionsBase { - label: string; + labelTKey: string; } export interface NumberInputOptions extends InputOptionsBase { @@ -19,17 +19,17 @@ export interface NumberInputOptions extends InputOptionsBase { } export interface StringInputOptions extends InputOptionsBase { - placeholder?: (() => Promise) | string; + placeholderTKey?: (() => Promise) | string; } export interface BooleanInputOptions extends InputOptionsBase { - trueLabel: (() => Promise) | string; - falseLabel: (() => Promise) | string; + trueLabelTKey: (() => Promise) | string; + falseLabelTKey: (() => Promise) | string; } export interface ChoiceInputOptions extends InputOptionsBase { choices: (() => Promise) | ChoiceItem[]; - placeholder?: (() => Promise) | string; + placeholderTKey?: (() => Promise) | string; } export interface DescriptionDisplayOptions { @@ -48,7 +48,7 @@ const isNumberInputOptions = (inputOptions: InputOptions): inputOptions is Numbe }; const isBooleanInputOptions = (inputOptions: InputOptions): inputOptions is BooleanInputOptions => { - return "trueLabel" in inputOptions; + return "trueLabelTKey" in inputOptions; }; const isChoiceInputOptions = (inputOptions: InputOptions): inputOptions is ChoiceInputOptions => { @@ -92,7 +92,7 @@ export const PropertyInfo = (info: (() => Promise) | Info): PropertyDecora export const Values = (inputOptions: InputOptions): PropertyDecorator => { if (isNumberInputOptions(inputOptions)) { return addToMap( - { name: "label", value: inputOptions.label }, + { name: "labelTKey", value: inputOptions.labelTKey }, { name: "min", value: inputOptions.min }, { name: "max", value: inputOptions.max }, { name: "step", value: inputOptions.step }, @@ -100,22 +100,22 @@ export const Values = (inputOptions: InputOptions): PropertyDecorator => { ); } else if (isBooleanInputOptions(inputOptions)) { return addToMap( - { name: "label", value: inputOptions.label }, - { name: "trueLabel", value: inputOptions.trueLabel }, - { name: "falseLabel", value: inputOptions.falseLabel } + { name: "labelTKey", value: inputOptions.labelTKey }, + { name: "trueLabelTKey", value: inputOptions.trueLabelTKey }, + { name: "falseLabelTKey", value: inputOptions.falseLabelTKey } ); } else if (isChoiceInputOptions(inputOptions)) { return addToMap( - { name: "label", value: inputOptions.label }, - { name: "placeholder", value: inputOptions.placeholder }, + { name: "labelTKey", value: inputOptions.labelTKey }, + { name: "placeholderTKey", value: inputOptions.placeholderTKey }, { name: "choices", value: inputOptions.choices } ); } else if (isDescriptionDisplayOptions(inputOptions)) { return addToMap({ name: "description", value: inputOptions.description }); } else { return addToMap( - { name: "label", value: inputOptions.label }, - { name: "placeholder", value: inputOptions.placeholder } + { name: "labelTKey", value: inputOptions.labelTKey }, + { name: "placeholderTKey", value: inputOptions.placeholderTKey } ); } }; diff --git a/src/SelfServe/Example/SelfServeExample.rp.ts b/src/SelfServe/Example/SelfServeExample.rp.ts index 76cbde9b9..62dfbc3ec 100644 --- a/src/SelfServe/Example/SelfServeExample.rp.ts +++ b/src/SelfServe/Example/SelfServeExample.rp.ts @@ -16,10 +16,22 @@ export interface InitializeResponse { dbThroughput: number; } -export const getMaxThroughput = async (): Promise => { +export const getMaxCollectionThroughput = async (): Promise => { return 10000; }; +export const getMinCollectionThroughput = async (): Promise => { + return 400; +}; + +export const getMaxDatabaseThroughput = async (): Promise => { + return 10000; +}; + +export const getMinDatabaseThroughput = async (): Promise => { + return 400; +}; + export const update = async ( regions: Regions, enableLogging: boolean, @@ -59,6 +71,6 @@ export const onRefreshSelfServeExample = async (): Promise => { const isUpdateInProgress = databaseAccountGetResults.properties.provisioningState !== "Succeeded"; return { isUpdateInProgress: isUpdateInProgress, - notificationMessage: "Self Serve Example successfully refreshing", + notificationMessage: "RefreshMessage", }; }; diff --git a/src/SelfServe/Example/SelfServeExample.tsx b/src/SelfServe/Example/SelfServeExample.tsx index 39459989f..01dca3a29 100644 --- a/src/SelfServe/Example/SelfServeExample.tsx +++ b/src/SelfServe/Example/SelfServeExample.tsx @@ -10,7 +10,16 @@ import { SelfServeNotificationType, SmartUiInput, } from "../SelfServeTypes"; -import { onRefreshSelfServeExample, getMaxThroughput, Regions, update, initialize } from "./SelfServeExample.rp"; +import { + onRefreshSelfServeExample, + Regions, + update, + initialize, + getMinDatabaseThroughput, + getMaxDatabaseThroughput, + getMinCollectionThroughput, + getMaxCollectionThroughput, +} from "./SelfServeExample.rp"; const regionDropdownItems: ChoiceItem[] = [ { label: "North Central US", key: Regions.NorthCentralUS }, @@ -19,11 +28,11 @@ const regionDropdownItems: ChoiceItem[] = [ ]; const selfServeExampleInfo: Info = { - message: "This is a self serve class", + messageTKey: "ClassInfo", }; const regionDropdownInfo: Info = { - message: "More regions can be added in the future.", + messageTKey: "RegionDropdownInfo", }; const onRegionsChange = (currentState: Map, newValue: InputType): Map => { @@ -50,7 +59,7 @@ const onEnableDbLevelThroughputChange = ( const validate = (currentvalues: Map): void => { if (!currentvalues.get("regions").value || !currentvalues.get("accountName").value) { - throw new Error("Regions and AccountName should not be empty."); + throw new Error("ValidationError"); } }; @@ -66,6 +75,9 @@ const validate = (currentvalues: Map): void => { You can test this self serve UI by using the featureflag '?feature.selfServeType=example' and plumb in similar feature flags for your own self serve class. + + All string to be used should be present in the "src/Localization" folder, in the language specific json files. The + corresponding key should be given as the value for the fields like "label", the error message etc. */ /* @@ -117,7 +129,7 @@ export default class SelfServeExample extends SelfServeBaseClass { let dbThroughput = currentValues.get("dbThroughput")?.value as number; dbThroughput = enableDbLevelThroughput ? dbThroughput : undefined; await update(regions, enableLogging, accountName, collectionThroughput, dbThroughput); - return { message: "submitted successfully", type: SelfServeNotificationType.info }; + return { message: "SubmissionMessage", type: SelfServeNotificationType.info }; }; /* @@ -161,10 +173,10 @@ export default class SelfServeExample extends SelfServeBaseClass { */ @Values({ description: { - text: "This class sets collection and database throughput.", + textTKey: "DescriptionText", link: { href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", - text: "Click here for more information", + textTKey: "DecriptionLinkText", }, }, }) @@ -193,26 +205,26 @@ export default class SelfServeExample extends SelfServeBaseClass { any other value of "regions" */ @OnChange(onRegionsChange) - @Values({ label: "Regions", choices: regionDropdownItems, placeholder: "Select a region" }) + @Values({ labelTKey: "Regions", choices: regionDropdownItems, placeholderTKey: "RegionsPlaceholder" }) regions: ChoiceItem; @Values({ - label: "Enable Logging", - trueLabel: "Enable", - falseLabel: "Disable", + labelTKey: "Enable Logging", + trueLabelTKey: "Enable", + falseLabelTKey: "Disable", }) enableLogging: boolean; @Values({ - label: "Account Name", - placeholder: "Enter the account name", + labelTKey: "Account Name", + placeholderTKey: "AccountNamePlaceHolder", }) accountName: string; @Values({ - label: "Collection Throughput", - min: 400, - max: getMaxThroughput, + labelTKey: "Collection Throughput", + min: getMinCollectionThroughput, + max: getMaxCollectionThroughput, step: 100, uiType: NumberUiType.Spinner, }) @@ -224,16 +236,16 @@ export default class SelfServeExample extends SelfServeBaseClass { */ @OnChange(onEnableDbLevelThroughputChange) @Values({ - label: "Enable DB level throughput", - trueLabel: "Enable", - falseLabel: "Disable", + labelTKey: "Enable DB level throughput", + trueLabelTKey: "Enable", + falseLabelTKey: "Disable", }) enableDbLevelThroughput: boolean; @Values({ - label: "Database Throughput", - min: 400, - max: getMaxThroughput, + labelTKey: "Database Throughput", + min: getMinDatabaseThroughput, + max: getMaxDatabaseThroughput, step: 100, uiType: NumberUiType.Slider, }) diff --git a/src/SelfServe/SelfServeComponent.test.tsx b/src/SelfServe/SelfServeComponent.test.tsx index 5544caeb5..afb002498 100644 --- a/src/SelfServe/SelfServeComponent.test.tsx +++ b/src/SelfServe/SelfServeComponent.test.tsx @@ -34,17 +34,17 @@ describe("SelfServeComponent", () => { root: { id: "root", info: { - message: "Start at $24/mo per database", + messageTKey: "Start at $24/mo per database", link: { href: "https://aka.ms/azure-cosmos-db-pricing", - text: "More Details", + textTKey: "More Details", }, }, children: [ { id: "throughput", input: { - label: "Throughput (input)", + labelTKey: "Throughput (input)", dataFieldName: "throughput", type: "number", min: 400, @@ -57,7 +57,7 @@ describe("SelfServeComponent", () => { { id: "containerId", input: { - label: "Container id", + labelTKey: "Container id", dataFieldName: "containerId", type: "string", }, @@ -65,9 +65,9 @@ describe("SelfServeComponent", () => { { id: "analyticalStore", input: { - label: "Analytical Store", - trueLabel: "Enabled", - falseLabel: "Disabled", + labelTKey: "Analytical Store", + trueLabelTKey: "Enabled", + falseLabelTKey: "Disabled", defaultValue: true, dataFieldName: "analyticalStore", type: "boolean", @@ -76,7 +76,7 @@ describe("SelfServeComponent", () => { { id: "database", input: { - label: "Database", + labelTKey: "Database", dataFieldName: "database", type: "object", choices: [ diff --git a/src/SelfServe/SelfServeComponent.tsx b/src/SelfServe/SelfServeComponent.tsx index d4189d144..0d80b3654 100644 --- a/src/SelfServe/SelfServeComponent.tsx +++ b/src/SelfServe/SelfServeComponent.tsx @@ -26,6 +26,9 @@ import { } from "./SelfServeTypes"; import { SmartUiComponent, SmartUiDescriptor } from "../Explorer/Controls/SmartUi/SmartUiComponent"; import { getMessageBarType } from "./SelfServeUtils"; +import { Translation } from "react-i18next"; +import { TFunction } from "i18next"; +import "../i18n"; export interface SelfServeComponentProps { descriptor: SelfServeDescriptor; @@ -43,6 +46,8 @@ export interface SelfServeComponentState { } export class SelfServeComponent extends React.Component { + private smartUiGeneratorClassName: string; + componentDidMount(): void { this.performRefresh(); this.initializeSmartUiComponent(); @@ -60,6 +65,7 @@ export class SelfServeComponent extends React.Component { @@ -147,8 +153,8 @@ export class SelfServeComponent extends React.Component, baselineValues: Map ): Promise => { - input.label = await this.getResolvedValue(input.label); - input.placeholder = await this.getResolvedValue(input.placeholder); + input.labelTKey = await this.getResolvedValue(input.labelTKey); + input.placeholderTKey = await this.getResolvedValue(input.placeholderTKey); switch (input.type) { case "string": { @@ -177,8 +183,8 @@ export class SelfServeComponent extends React.Component { this.setState({ notification: { - message: `Error: ${error.message}`, + message: `${error.message}`, type: SelfServeNotificationType.error, }, }); @@ -273,11 +279,15 @@ export class SelfServeComponent extends React.Component { + public getCommonTranslation = (translationFunction: TFunction, key: string): string => { + return translationFunction(`Common.${key}`); + }; + + private getCommandBarItems = (translate: TFunction): ICommandBarItemProps[] => { return [ { key: "save", - text: "Save", + text: this.getCommonTranslation(translate, "Save"), iconProps: { iconName: "Save" }, split: true, disabled: this.isSaveButtonDisabled(), @@ -285,7 +295,7 @@ export class SelfServeComponent extends React.Component { + const translation = translationFunction(messageKey); + if (translation === `${this.smartUiGeneratorClassName}.${messageKey}`) { + return messageKey; + } + return translation; + }; + public render(): JSX.Element { const containerStackTokens: IStackTokens = { childrenGap: 5 }; if (this.state.compileErrorMessage) { return {this.state.compileErrorMessage}; } return ( -
- - - {this.state.isInitializing ? ( - - ) : ( - <> - {this.state.refreshResult?.isUpdateInProgress && ( - - {this.state.refreshResult.notificationMessage} - - )} - {this.state.notification && ( - this.setState({ notification: undefined })} - > - {this.state.notification.message} - - )} - - - )} - -
+ + {(translate) => { + const getTranslation = (key: string): string => { + return translate(`${this.smartUiGeneratorClassName}.${key}`); + }; + + return ( +
+ + + {this.state.isInitializing ? ( + + ) : ( + <> + {this.state.refreshResult?.isUpdateInProgress && ( + + {getTranslation(this.state.refreshResult.notificationMessage)} + + )} + {this.state.notification && ( + this.setState({ notification: undefined })} + > + {this.getNotificationMessageTranslation(getTranslation, this.state.notification.message)} + + )} + + + )} + +
+ ); + }} +
); } } diff --git a/src/SelfServe/SelfServeTypes.ts b/src/SelfServe/SelfServeTypes.ts index 93314c867..96fa966e7 100644 --- a/src/SelfServe/SelfServeTypes.ts +++ b/src/SelfServe/SelfServeTypes.ts @@ -2,9 +2,9 @@ interface BaseInput { dataFieldName: string; errorMessage?: string; type: InputTypeValue; - label?: (() => Promise) | string; + labelTKey?: (() => Promise) | string; onChange?: (currentState: Map, newValue: InputType) => Map; - placeholder?: (() => Promise) | string; + placeholderTKey?: (() => Promise) | string; } export interface NumberInput extends BaseInput { @@ -16,8 +16,8 @@ export interface NumberInput extends BaseInput { } export interface BooleanInput extends BaseInput { - trueLabel: (() => Promise) | string; - falseLabel: (() => Promise) | string; + trueLabelTKey: (() => Promise) | string; + falseLabelTKey: (() => Promise) | string; defaultValue?: boolean; } @@ -92,18 +92,18 @@ export type ChoiceItem = { label: string; key: string }; export type InputType = number | string | boolean | ChoiceItem; export interface Info { - message: string; + messageTKey: string; link?: { href: string; - text: string; + textTKey: string; }; } export interface Description { - text: string; + textTKey: string; link?: { href: string; - text: string; + textTKey: string; }; } diff --git a/src/SelfServe/SelfServeUtils.test.tsx b/src/SelfServe/SelfServeUtils.test.tsx index 531eaa26a..c870d3718 100644 --- a/src/SelfServe/SelfServeUtils.test.tsx +++ b/src/SelfServe/SelfServeUtils.test.tsx @@ -58,7 +58,7 @@ describe("SelfServeUtils", () => { id: "dbThroughput", dataFieldName: "dbThroughput", type: "number", - label: "Database Throughput", + labelTKey: "Database Throughput", min: 1, max: 5, step: 1, @@ -71,7 +71,7 @@ describe("SelfServeUtils", () => { id: "collThroughput", dataFieldName: "collThroughput", type: "number", - label: "Coll Throughput", + labelTKey: "Coll Throughput", min: 1, max: 5, step: 1, @@ -84,7 +84,7 @@ describe("SelfServeUtils", () => { id: "invalidThroughput", dataFieldName: "invalidThroughput", type: "boolean", - label: "Invalid Coll Throughput", + labelTKey: "Invalid Coll Throughput", min: 1, max: 5, step: 1, @@ -98,8 +98,8 @@ describe("SelfServeUtils", () => { id: "collName", dataFieldName: "collName", type: "string", - label: "Coll Name", - placeholder: "placeholder text", + labelTKey: "Coll Name", + placeholderTKey: "placeholder text", }, ], [ @@ -108,9 +108,9 @@ describe("SelfServeUtils", () => { id: "enableLogging", dataFieldName: "enableLogging", type: "boolean", - label: "Enable Logging", - trueLabel: "Enable", - falseLabel: "Disable", + labelTKey: "Enable Logging", + trueLabelTKey: "Enable", + falseLabelTKey: "Disable", }, ], [ @@ -119,8 +119,8 @@ describe("SelfServeUtils", () => { id: "invalidEnableLogging", dataFieldName: "invalidEnableLogging", type: "boolean", - label: "Invalid Enable Logging", - placeholder: "placeholder text", + labelTKey: "Invalid Enable Logging", + placeholderTKey: "placeholder text", }, ], [ @@ -129,7 +129,7 @@ describe("SelfServeUtils", () => { id: "regions", dataFieldName: "regions", type: "object", - label: "Regions", + labelTKey: "Regions", choices: [ { label: "South West US", key: "SWUS" }, { label: "North Central US", key: "NCUS" }, @@ -143,14 +143,14 @@ describe("SelfServeUtils", () => { id: "invalidRegions", dataFieldName: "invalidRegions", type: "object", - label: "Invalid Regions", - placeholder: "placeholder text", + labelTKey: "Invalid Regions", + placeholderTKey: "placeholder text", }, ], ]); const expectedDescriptor = { root: { - id: "root", + id: "TestClass", children: [ { id: "dbThroughput", @@ -158,7 +158,7 @@ describe("SelfServeUtils", () => { id: "dbThroughput", dataFieldName: "dbThroughput", type: "number", - label: "Database Throughput", + labelTKey: "Database Throughput", min: 1, max: 5, step: 1, @@ -172,7 +172,7 @@ describe("SelfServeUtils", () => { id: "collThroughput", dataFieldName: "collThroughput", type: "number", - label: "Coll Throughput", + labelTKey: "Coll Throughput", min: 1, max: 5, step: 1, @@ -186,7 +186,7 @@ describe("SelfServeUtils", () => { id: "invalidThroughput", dataFieldName: "invalidThroughput", type: "boolean", - label: "Invalid Coll Throughput", + labelTKey: "Invalid Coll Throughput", min: 1, max: 5, step: 1, @@ -201,8 +201,8 @@ describe("SelfServeUtils", () => { id: "collName", dataFieldName: "collName", type: "string", - label: "Coll Name", - placeholder: "placeholder text", + labelTKey: "Coll Name", + placeholderTKey: "placeholder text", }, children: [] as Node[], }, @@ -212,9 +212,9 @@ describe("SelfServeUtils", () => { id: "enableLogging", dataFieldName: "enableLogging", type: "boolean", - label: "Enable Logging", - trueLabel: "Enable", - falseLabel: "Disable", + labelTKey: "Enable Logging", + trueLabelTKey: "Enable", + falseLabelTKey: "Disable", }, children: [] as Node[], }, @@ -224,8 +224,8 @@ describe("SelfServeUtils", () => { id: "invalidEnableLogging", dataFieldName: "invalidEnableLogging", type: "boolean", - label: "Invalid Enable Logging", - placeholder: "placeholder text", + labelTKey: "Invalid Enable Logging", + placeholderTKey: "placeholder text", errorMessage: "label, truelabel and falselabel are required for boolean input 'invalidEnableLogging'.", }, children: [] as Node[], @@ -236,7 +236,7 @@ describe("SelfServeUtils", () => { id: "regions", dataFieldName: "regions", type: "object", - label: "Regions", + labelTKey: "Regions", choices: [ { label: "South West US", key: "SWUS" }, { label: "North Central US", key: "NCUS" }, @@ -251,8 +251,8 @@ describe("SelfServeUtils", () => { id: "invalidRegions", dataFieldName: "invalidRegions", type: "object", - label: "Invalid Regions", - placeholder: "placeholder text", + labelTKey: "Invalid Regions", + placeholderTKey: "placeholder text", errorMessage: "label and choices are required for Choice input 'invalidRegions'.", }, children: [] as Node[], @@ -270,7 +270,7 @@ describe("SelfServeUtils", () => { "invalidRegions", ], }; - const descriptor = mapToSmartUiDescriptor(context); + const descriptor = mapToSmartUiDescriptor("TestClass", context); expect(descriptor).toEqual(expectedDescriptor); }); }); diff --git a/src/SelfServe/SelfServeUtils.tsx b/src/SelfServe/SelfServeUtils.tsx index 5c30e0b6b..d46aff571 100644 --- a/src/SelfServe/SelfServeUtils.tsx +++ b/src/SelfServe/SelfServeUtils.tsx @@ -32,14 +32,14 @@ export interface DecoratorProperties { id: string; info?: (() => Promise) | Info; type?: InputTypeValue; - label?: (() => Promise) | string; - placeholder?: (() => Promise) | string; + labelTKey?: (() => Promise) | string; + placeholderTKey?: (() => Promise) | string; dataFieldName?: string; min?: (() => Promise) | number; max?: (() => Promise) | number; step?: (() => Promise) | number; - trueLabel?: (() => Promise) | string; - falseLabel?: (() => Promise) | string; + trueLabelTKey?: (() => Promise) | string; + falseLabelTKey?: (() => Promise) | string; choices?: (() => Promise) | ChoiceItem[]; uiType?: string; errorMessage?: string; @@ -100,18 +100,21 @@ export const updateContextWithDecorator = { const context = Reflect.getMetadata(className, target) as Map; - const smartUiDescriptor = mapToSmartUiDescriptor(context); + const smartUiDescriptor = mapToSmartUiDescriptor(className, context); Reflect.defineMetadata(className, smartUiDescriptor, target); }; -export const mapToSmartUiDescriptor = (context: Map): SelfServeDescriptor => { +export const mapToSmartUiDescriptor = ( + className: string, + context: Map +): SelfServeDescriptor => { const root = context.get("root"); context.delete("root"); const inputNames: string[] = []; const smartUiDescriptor: SelfServeDescriptor = { root: { - id: "root", + id: className, info: root?.info, children: [], }, @@ -147,7 +150,7 @@ const addToDescriptor = ( const getInput = (value: DecoratorProperties): AnyDisplay => { switch (value.type) { case "number": - if (!value.label || !value.step || !value.uiType || !value.min || !value.max) { + if (!value.labelTKey || !value.step || !value.uiType || !value.min || !value.max) { value.errorMessage = `label, step, min, max and uiType are required for number input '${value.id}'.`; } return value as NumberInput; @@ -155,17 +158,17 @@ const getInput = (value: DecoratorProperties): AnyDisplay => { if (value.description) { return value as DescriptionDisplay; } - if (!value.label) { + if (!value.labelTKey) { value.errorMessage = `label is required for string input '${value.id}'.`; } return value as StringInput; case "boolean": - if (!value.label || !value.trueLabel || !value.falseLabel) { + if (!value.labelTKey || !value.trueLabelTKey || !value.falseLabelTKey) { value.errorMessage = `label, truelabel and falselabel are required for boolean input '${value.id}'.`; } return value as BooleanInput; default: - if (!value.label || !value.choices) { + if (!value.labelTKey || !value.choices) { value.errorMessage = `label and choices are required for Choice input '${value.id}'.`; } return value as ChoiceInput; diff --git a/src/SelfServe/SqlX/SqlX.tsx b/src/SelfServe/SqlX/SqlX.tsx index add077a6f..77cb812f9 100644 --- a/src/SelfServe/SqlX/SqlX.tsx +++ b/src/SelfServe/SqlX/SqlX.tsx @@ -62,10 +62,10 @@ export default class SqlX extends SelfServeBaseClass { @Values({ description: { - text: "Provisioning dedicated gateways for SqlX accounts.", + textTKey: "Provisioning dedicated gateways for SqlX accounts.", link: { href: "https://docs.microsoft.com/en-us/azure/cosmos-db/introduction", - text: "Learn more about dedicated gateway.", + textTKey: "Learn more about dedicated gateway.", }, }, }) @@ -73,21 +73,21 @@ export default class SqlX extends SelfServeBaseClass { @OnChange(onEnableDedicatedGatewayChange) @Values({ - label: "Dedicated Gateway", - trueLabel: "Enable", - falseLabel: "Disable", + labelTKey: "Dedicated Gateway", + trueLabelTKey: "Enable", + falseLabelTKey: "Disable", }) enableDedicatedGateway: boolean; @Values({ - label: "SKUs", + labelTKey: "SKUs", choices: getSkus, - placeholder: "Select SKUs", + placeholderTKey: "Select SKUs", }) sku: ChoiceItem; @Values({ - label: "Number of instances", + labelTKey: "Number of instances", min: getInstancesMin, max: getInstancesMax, step: 1, diff --git a/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap index b87f5be11..09c5ae177 100644 --- a/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap +++ b/src/SelfServe/__snapshots__/SelfServeComponent.test.tsx.snap @@ -1,686 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SelfServeComponent message bar and spinner snapshots 1`] = ` -
- - - - refresh performed successfully - - - submitted successfully - - Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - } - } - descriptor={ - Object { - "initialize": [MockFunction] { - "calls": Array [ - Array [], - Array [], - Array [], - Array [], - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "inputNames": Array [ - "throughput", - "analyticalStore", - "database", - ], - "onRefresh": [MockFunction] { - "calls": Array [ - Array [], - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "onSave": [MockFunction] { - "calls": Array [ - Array [ - Map { - "throughput" => Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - }, - ], - Array [ - Map { - "throughput" => Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "root": Object { - "children": Array [ - Object { - "id": "throughput", - "info": undefined, - "input": Object { - "dataFieldName": "throughput", - "defaultValue": 400, - "label": "Throughput (input)", - "max": 500, - "min": 400, - "placeholder": undefined, - "step": 10, - "type": "number", - "uiType": "Spinner", - }, - }, - Object { - "id": "containerId", - "info": undefined, - "input": Object { - "dataFieldName": "containerId", - "label": "Container id", - "placeholder": undefined, - "type": "string", - }, - }, - Object { - "id": "analyticalStore", - "info": undefined, - "input": Object { - "dataFieldName": "analyticalStore", - "defaultValue": true, - "falseLabel": "Disabled", - "label": "Analytical Store", - "placeholder": undefined, - "trueLabel": "Enabled", - "type": "boolean", - }, - }, - Object { - "id": "database", - "info": undefined, - "input": Object { - "choices": Array [ - Object { - "key": "db1", - "label": "Database 1", - }, - Object { - "key": "db2", - "label": "Database 2", - }, - Object { - "key": "db3", - "label": "Database 3", - }, - ], - "dataFieldName": "database", - "defaultKey": "db2", - "label": "Database", - "placeholder": undefined, - "type": "object", - }, - }, - ], - "id": "root", - "info": Object { - "link": Object { - "href": "https://aka.ms/azure-cosmos-db-pricing", - "text": "More Details", - }, - "message": "Start at $24/mo per database", - }, - }, - } - } - disabled={true} - onError={[Function]} - onInputChange={[Function]} - /> - -
+ + + `; exports[`SelfServeComponent message bar and spinner snapshots 2`] = ` -
- - - - submitted successfully - - Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - } - } - descriptor={ - Object { - "initialize": [MockFunction] { - "calls": Array [ - Array [], - Array [], - Array [], - Array [], - Array [], - Array [], - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "inputNames": Array [ - "throughput", - "analyticalStore", - "database", - ], - "onRefresh": [MockFunction] { - "calls": Array [ - Array [], - Array [], - Array [], - Array [], - Array [], - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "onSave": [MockFunction] { - "calls": Array [ - Array [ - Map { - "throughput" => Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - }, - ], - Array [ - Map { - "throughput" => Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - }, - ], - Array [ - Map { - "throughput" => Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - }, - ], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "root": Object { - "children": Array [ - Object { - "id": "throughput", - "info": undefined, - "input": Object { - "dataFieldName": "throughput", - "defaultValue": 400, - "label": "Throughput (input)", - "max": 500, - "min": 400, - "placeholder": undefined, - "step": 10, - "type": "number", - "uiType": "Spinner", - }, - }, - Object { - "id": "containerId", - "info": undefined, - "input": Object { - "dataFieldName": "containerId", - "label": "Container id", - "placeholder": undefined, - "type": "string", - }, - }, - Object { - "id": "analyticalStore", - "info": undefined, - "input": Object { - "dataFieldName": "analyticalStore", - "defaultValue": true, - "falseLabel": "Disabled", - "label": "Analytical Store", - "placeholder": undefined, - "trueLabel": "Enabled", - "type": "boolean", - }, - }, - Object { - "id": "database", - "info": undefined, - "input": Object { - "choices": Array [ - Object { - "key": "db1", - "label": "Database 1", - }, - Object { - "key": "db2", - "label": "Database 2", - }, - Object { - "key": "db3", - "label": "Database 3", - }, - ], - "dataFieldName": "database", - "defaultKey": "db2", - "label": "Database", - "placeholder": undefined, - "type": "object", - }, - }, - ], - "id": "root", - "info": Object { - "link": Object { - "href": "https://aka.ms/azure-cosmos-db-pricing", - "text": "More Details", - }, - "message": "Start at $24/mo per database", - }, - }, - } - } - disabled={false} - onError={[Function]} - onInputChange={[Function]} - /> - -
+ + + `; exports[`SelfServeComponent message bar and spinner snapshots 3`] = ` -
- - - - -
+ + + `; exports[`SelfServeComponent message bar and spinner snapshots 4`] = ` @@ -692,195 +27,7 @@ exports[`SelfServeComponent message bar and spinner snapshots 4`] = ` `; exports[`SelfServeComponent should render and honor save, discard, refresh actions 1`] = ` -
- - - Object { - "value": 450, - }, - "analyticalStore" => Object { - "value": false, - }, - "database" => Object { - "value": "db2", - }, - } - } - descriptor={ - Object { - "initialize": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "inputNames": Array [ - "throughput", - "analyticalStore", - "database", - ], - "onRefresh": [MockFunction] { - "calls": Array [ - Array [], - ], - "results": Array [ - Object { - "type": "return", - "value": Promise {}, - }, - ], - }, - "onSave": [MockFunction], - "root": Object { - "children": Array [ - Object { - "id": "throughput", - "info": undefined, - "input": Object { - "dataFieldName": "throughput", - "defaultValue": 400, - "label": "Throughput (input)", - "max": 500, - "min": 400, - "placeholder": undefined, - "step": 10, - "type": "number", - "uiType": "Spinner", - }, - }, - Object { - "id": "containerId", - "info": undefined, - "input": Object { - "dataFieldName": "containerId", - "label": "Container id", - "placeholder": undefined, - "type": "string", - }, - }, - Object { - "id": "analyticalStore", - "info": undefined, - "input": Object { - "dataFieldName": "analyticalStore", - "defaultValue": true, - "falseLabel": "Disabled", - "label": "Analytical Store", - "placeholder": undefined, - "trueLabel": "Enabled", - "type": "boolean", - }, - }, - Object { - "id": "database", - "info": undefined, - "input": Object { - "choices": Array [ - Object { - "key": "db1", - "label": "Database 1", - }, - Object { - "key": "db2", - "label": "Database 2", - }, - Object { - "key": "db3", - "label": "Database 3", - }, - ], - "dataFieldName": "database", - "defaultKey": "db2", - "label": "Database", - "placeholder": undefined, - "type": "object", - }, - }, - ], - "id": "root", - "info": Object { - "link": Object { - "href": "https://aka.ms/azure-cosmos-db-pricing", - "text": "More Details", - }, - "message": "Start at $24/mo per database", - }, - }, - } - } - disabled={false} - onError={[Function]} - onInputChange={[Function]} - /> - -
+ + + `; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 000000000..f91c17138 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,31 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; +import XHR from "i18next-http-backend"; +import EnglishTranslations from "./Localization/en/translations.json"; + +i18n + .use(XHR) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: EnglishTranslations, + }, + fallbackLng: "en", + detection: { order: ["navigator", "cookie", "localStorage", "sessionStorage", "querystring", "htmlTag"] }, + debug: process.env.NODE_ENV === "development", + ns: ["translations"], + defaultNS: "translations", + keySeparator: ".", + interpolation: { + formatSeparator: ",", + }, + react: { + wait: true, + bindI18n: "languageChanged loaded", + bindI18nStore: "added removed", + nsMode: "default", + useSuspense: false, + }, + });