diff --git a/craco.config.js b/craco.config.js index ec11e0f6..0328b949 100644 --- a/craco.config.js +++ b/craco.config.js @@ -2,7 +2,7 @@ // // https://github.com/gsoft-inc/craco/blob/master/packages/craco/README.md#configuration -const { addBeforeLoader, loaderByName } = require('@craco/craco'); +const { addBeforeLoader, loaderByName, getLoader } = require('@craco/craco'); const CopyPlugin = require('copy-webpack-plugin'); const LicensePlugin = require('license-webpack-plugin').LicenseWebpackPlugin; const MonacoWebpackPlugin = require('monaco-editor-webpack-plugin'); @@ -183,6 +183,9 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`, }; module.exports = { + babel: { + plugins: ['@shopify/react-i18n/babel'], + }, webpack: { plugins: [ new CopyPlugin({ diff --git a/src/about/AboutDialog.tsx b/src/about/AboutDialog.tsx index a71d5805..1f45c259 100644 --- a/src/about/AboutDialog.tsx +++ b/src/about/AboutDialog.tsx @@ -17,8 +17,7 @@ import { } from '../app/constants'; import LicenseDialog from '../licenses/LicenseDialog'; import ExternalLinkIcon from '../utils/ExternalLinkIcon'; -import { AboutStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; import './about.scss'; @@ -30,11 +29,11 @@ const AboutDialog: React.VoidFunctionComponent = ({ }) => { const [isLicenseDialogOpen, setIsLicenseDialogOpen] = useState(false); - const [i18n] = useI18n({ id: 'about', translations: { en }, fallback: en }); + const [i18n] = useI18n(); return ( @@ -43,7 +42,7 @@ const AboutDialog: React.VoidFunctionComponent = ({

- {i18n.translate(AboutStringId.Description)} + {i18n.translate(I18nId.Description)}

{`v${firmwareVersion} (${appName} v${appVersion})`}

{pybricksCopyright}

@@ -54,14 +53,14 @@ const AboutDialog: React.VoidFunctionComponent = ({

- {i18n.translate(AboutStringId.ChangelogButtonLabel)} + {i18n.translate(I18nId.ChangelogButtonLabel)} - {i18n.translate(AboutStringId.WebsiteButtonLabel)} + {i18n.translate(I18nId.WebsiteButtonLabel)}
diff --git a/src/about/i18n.en.json b/src/about/i18n.en.json deleted file mode 100644 index 3cd3402c..00000000 --- a/src/about/i18n.en.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "about": { - "title": "About {appName}", - "description": "MicroPython for LEGO® Powered Up smart hubs.", - "licenseButton": { "label": "Software Licenses" }, - "changelogButton": { "label": "Changelog" }, - "websiteButton": { "label": "Pybricks Website" } - } -} diff --git a/src/about/i18n.test.ts b/src/about/i18n.test.ts index 5c7a14ea..925768f1 100644 --- a/src/about/i18n.test.ts +++ b/src/about/i18n.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { lookup } from '../../test'; -import { AboutStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for AboutStringId', () => { - test.each(Object.values(AboutStringId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/about/i18n.ts b/src/about/i18n.ts index fd7137da..4a6c2c1b 100644 --- a/src/about/i18n.ts +++ b/src/about/i18n.ts @@ -3,10 +3,10 @@ // About dialog translation keys. -export enum AboutStringId { - Title = 'about.title', - Description = 'about.description', - LicenseButtonLabel = 'about.licenseButton.label', - ChangelogButtonLabel = 'about.changelogButton.label', - WebsiteButtonLabel = 'about.websiteButton.label', +export enum I18nId { + Title = 'title', + Description = 'description', + LicenseButtonLabel = 'licenseButton.label', + ChangelogButtonLabel = 'changelogButton.label', + WebsiteButtonLabel = 'websiteButton.label', } diff --git a/src/about/translations/en.json b/src/about/translations/en.json new file mode 100644 index 00000000..f7b56d99 --- /dev/null +++ b/src/about/translations/en.json @@ -0,0 +1,7 @@ +{ + "title": "About {appName}", + "description": "MicroPython for LEGO® Powered Up smart hubs.", + "licenseButton": { "label": "Software Licenses" }, + "changelogButton": { "label": "Changelog" }, + "websiteButton": { "label": "Pybricks Website" } +} diff --git a/src/editor/Editor.tsx b/src/editor/Editor.tsx index 4c48d6af..cde23d4b 100644 --- a/src/editor/Editor.tsx +++ b/src/editor/Editor.tsx @@ -3,7 +3,7 @@ import { Menu, MenuDivider, MenuItem } from '@blueprintjs/core'; import { ContextMenu2, ResizeSensor2 } from '@blueprintjs/popover2'; -import { useI18n } from '@shopify/react-i18n'; +import { I18n, useI18n } from '@shopify/react-i18n'; import tomorrowNightEightiesTheme from 'monaco-themes/themes/Tomorrow-Night-Eighties.json'; import xcodeTheme from 'monaco-themes/themes/Xcode_default.json'; import React, { useState } from 'react'; @@ -15,8 +15,7 @@ import { fileStorageWriteFile } from '../fileStorage/actions'; import { compile } from '../mpy/actions'; import { settingsToggleShowDocs } from '../settings/actions'; import { isMacOS } from '../utils/os'; -import { EditorStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; import * as pybricksMicroPython from './pybricksMicroPython'; import { UntitledHintContribution } from './untitledHint'; @@ -62,13 +61,12 @@ monaco.editor.defineTheme( const xcodeId = 'xcode'; monaco.editor.defineTheme(xcodeId, xcodeTheme as monaco.editor.IStandaloneThemeData); -type EditorContextMenuProps = { editor: EditorType }; +type EditorContextMenuProps = { editor: EditorType; i18n: I18n }; const EditorContextMenu: React.VoidFunctionComponent = ({ editor, + i18n, }) => { - const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en }); - const hasEditor = editor !== null; const selection = editor?.getSelection(); @@ -85,7 +83,7 @@ const EditorContextMenu: React.VoidFunctionComponent = ( editor?.focus(); editor?.trigger(null, 'editor.action.clipboardCopyAction', null); }} - text={i18n.translate(EditorStringId.Copy)} + text={i18n.translate(I18nId.Copy)} icon="duplicate" label={isMacOS() ? 'Cmd-C' : 'Ctrl-C'} disabled={!hasSelection} @@ -95,7 +93,7 @@ const EditorContextMenu: React.VoidFunctionComponent = ( editor?.focus(); editor?.trigger(null, 'editor.action.clipboardPasteAction', null); }} - text={i18n.translate(EditorStringId.Paste)} + text={i18n.translate(I18nId.Paste)} icon="clipboard" label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'} disabled={!hasEditor} @@ -105,7 +103,7 @@ const EditorContextMenu: React.VoidFunctionComponent = ( editor?.focus(); editor?.trigger(null, 'editor.action.selectAll', null); }} - text={i18n.translate(EditorStringId.SelectAll)} + text={i18n.translate(I18nId.SelectAll)} icon="blank" label={isMacOS() ? 'Cmd-A' : 'Ctrl-A'} disabled={!hasEditor} @@ -116,7 +114,7 @@ const EditorContextMenu: React.VoidFunctionComponent = ( editor?.focus(); editor?.trigger(null, 'undo', null); }} - text={i18n.translate(EditorStringId.Undo)} + text={i18n.translate(I18nId.Undo)} icon="undo" label={isMacOS() ? 'Cmd-Z' : 'Ctrl-Z'} disabled={!canUndo} @@ -126,7 +124,7 @@ const EditorContextMenu: React.VoidFunctionComponent = ( editor?.focus(); editor?.trigger(null, 'redo', null); }} - text={i18n.translate(EditorStringId.Redo)} + text={i18n.translate(I18nId.Redo)} icon="redo" label={isMacOS() ? 'Cmd-Shift-Z' : 'Ctrl-Shift-Z'} disabled={!canRedo} @@ -143,7 +141,7 @@ const Editor: React.VoidFunctionComponent = ({ onEditorChanged }) = const [editor, setEditor] = useState(null); const { isDarkMode } = useTernaryDarkMode(); - const [i18n] = useI18n({ id: 'editor', translations: { en }, fallback: en }); + const [i18n] = useI18n(); return ( editor?.layout()}> @@ -152,7 +150,7 @@ const Editor: React.VoidFunctionComponent = ({ onEditorChanged }) = // NB: we have to create a new context menu each time it is // shown in order to get some state, like canUndo and canRedo // that don't have events to monitor changes. - content={() => } + content={() => } popoverProps={{ onClosed: () => editor?.focus() }} > = ({ onEditorChanged }) = subscriptions.push( new UntitledHintContribution( editor, - i18n.translate(EditorStringId.Placeholder), + i18n.translate(I18nId.Placeholder), ), ); subscriptions.push( editor.addAction({ id: 'pybricks.action.toggleDocs', - label: i18n.translate(EditorStringId.ToggleDocs), + label: i18n.translate(I18nId.ToggleDocs), run: () => { // we have to use dispatch here instead of // toggleIsSettingShowDocsEnabled since this @@ -193,7 +191,7 @@ const Editor: React.VoidFunctionComponent = ({ onEditorChanged }) = subscriptions.push( editor.addAction({ id: 'pybricks.action.check', - label: i18n.translate(EditorStringId.Check), + label: i18n.translate(I18nId.Check), // REVISIT: the compile options here might need to be changed - hopefully there is // one setting that works for all hub types for cases where we aren't connected. run: (e) => { diff --git a/src/editor/i18n.en.json b/src/editor/i18n.en.json deleted file mode 100644 index 288ef667..00000000 --- a/src/editor/i18n.en.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "editor": { - "placeholder": "Write your program here...", - "check": "Check Syntax", - "toggleDocs": "Toggle Documentation", - "copy": "Copy", - "paste": "Paste", - "selectAll": "Select All", - "undo": "Undo", - "redo": "Redo" - } -} diff --git a/src/editor/i18n.test.ts b/src/editor/i18n.test.ts index e95107bf..4d7102cf 100644 --- a/src/editor/i18n.test.ts +++ b/src/editor/i18n.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { lookup } from '../../test'; -import { EditorStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId as I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for EditorStringId', () => { - test.each(Object.values(EditorStringId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/editor/i18n.ts b/src/editor/i18n.ts index 8f9902d5..bc55aee8 100644 --- a/src/editor/i18n.ts +++ b/src/editor/i18n.ts @@ -1,15 +1,15 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Editor translation keys. -export enum EditorStringId { - Placeholder = 'editor.placeholder', - Check = 'editor.check', - ToggleDocs = 'editor.toggleDocs', - Copy = 'editor.copy', - Paste = 'editor.paste', - SelectAll = 'editor.selectAll', - Undo = 'editor.undo', - Redo = 'editor.redo', +export enum I18nId { + Placeholder = 'placeholder', + Check = 'check', + ToggleDocs = 'toggleDocs', + Copy = 'copy', + Paste = 'paste', + SelectAll = 'selectAll', + Undo = 'undo', + Redo = 'redo', } diff --git a/src/editor/translations/en.json b/src/editor/translations/en.json new file mode 100644 index 00000000..76b7742c --- /dev/null +++ b/src/editor/translations/en.json @@ -0,0 +1,10 @@ +{ + "placeholder": "Write your program here...", + "check": "Check Syntax", + "toggleDocs": "Toggle Documentation", + "copy": "Copy", + "paste": "Paste", + "selectAll": "Select All", + "undo": "Undo", + "redo": "Redo" +} diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index db1f2969..cdbc5bf8 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -11,7 +11,7 @@ import { IconName, useHotkeys, } from '@blueprintjs/core'; -import { useI18n } from '@shopify/react-i18n'; +import { I18n, useI18n } from '@shopify/react-i18n'; import React, { useCallback, useMemo, useState } from 'react'; import { ControlledTreeEnvironment, @@ -32,10 +32,9 @@ import { useSelector } from '../reducers'; import { isMacOS } from '../utils/os'; import { preventBrowserNativeContextMenu } from '../utils/react'; import { TreeItemContext, TreeItemData, renderers } from '../utils/tree-renderer'; -import NewFileWizard from './NewFileWizard'; import { explorerDeleteFile, explorerImportFiles, explorerRenameFile } from './actions'; -import { ExplorerStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import NewFileWizard from './newFileWizard/NewFileWizard'; import RenameFileDialog from './renameFileDialog/RenameFileDialog'; import './explorer.scss'; @@ -43,13 +42,15 @@ type ActionButtonProps = { /** The icon to use for the button. */ icon: IconName; /** The tooltip translation ID for the tooltip text. */ - toolTipId: ExplorerStringId; + toolTipId: I18nId; /** Replacements if required by `toolTipId` */ toolTipReplacements?: { [key: string]: string }; /** If provided, controls button disabled state. */ disabled?: boolean; /** If false, prevent focus. Default is true. */ focusable?: boolean; + /** Translation context. */ + i18n: I18n; /** Callback for button click event. */ onClick: () => void; }; @@ -60,10 +61,9 @@ const ActionButton: React.VoidFunctionComponent = ({ toolTipReplacements, disabled, focusable, + i18n, onClick, }) => { - const [i18n] = useI18n({ id: 'explorer', translations: { en }, fallback: en }); - return ( diff --git a/src/explorer/newFileWizard/i18n.ts b/src/explorer/newFileWizard/i18n.ts new file mode 100644 index 00000000..e60cfcff --- /dev/null +++ b/src/explorer/newFileWizard/i18n.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +export enum I18nId { + Title = 'title', + SmartHubLabel = 'smartHub.label', + ActionCreate = 'action.create', +} diff --git a/src/explorer/newFileWizard/translations/en.json b/src/explorer/newFileWizard/translations/en.json new file mode 100644 index 00000000..7c17058d --- /dev/null +++ b/src/explorer/newFileWizard/translations/en.json @@ -0,0 +1,9 @@ +{ + "title": "Create a new file", + "smartHub": { + "label": "Smart hub" + }, + "action": { + "create": "Create" + } +} diff --git a/src/explorer/renameFileDialog/18n.test.ts b/src/explorer/renameFileDialog/18n.test.ts index afd2a2f5..e706ba28 100644 --- a/src/explorer/renameFileDialog/18n.test.ts +++ b/src/explorer/renameFileDialog/18n.test.ts @@ -2,11 +2,11 @@ // Copyright (c) 2022 The Pybricks Authors import { lookup } from '../../../test'; -import { RenameFileDialogStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for RenameFileStringId', () => { - test.each(Object.values(RenameFileDialogStringId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/explorer/renameFileDialog/RenameFileDialog.tsx b/src/explorer/renameFileDialog/RenameFileDialog.tsx index 7755c803..de4ce7e7 100644 --- a/src/explorer/renameFileDialog/RenameFileDialog.tsx +++ b/src/explorer/renameFileDialog/RenameFileDialog.tsx @@ -10,20 +10,15 @@ import { validateFileName, } from '../../pybricksMicropython/lib'; import { useSelector } from '../../reducers'; -import FileNameFormGroup from '../FileNameFormGroup'; +import FileNameFormGroup from '../fileNameFormGroup/FileNameFormGroup'; import { renameFileDialogDidAccept, renameFileDialogDidCancel } from './actions'; -import { RenameFileDialogStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; const RenameFileDialog: React.VFC = () => { const dispatch = useDispatch(); const isOpen = useSelector((s) => s.explorer.renameFileDialog.isOpen); const oldName = useSelector((s) => s.explorer.renameFileDialog.fileName); - const [i18n] = useI18n({ - id: 'renameFileDialog', - translations: { en }, - fallback: en, - }); + const [i18n] = useI18n(); const [baseName, extension] = oldName.split(/(\.\w+)$/); @@ -47,7 +42,7 @@ const RenameFileDialog: React.VFC = () => { return ( { disabled={result !== FileNameValidationResult.IsOk} type="submit" > - {i18n.translate(RenameFileDialogStringId.ActionRename)} + {i18n.translate(I18nId.ActionRename)} diff --git a/src/explorer/renameFileDialog/i18n.en.json b/src/explorer/renameFileDialog/i18n.en.json deleted file mode 100644 index 775399a0..00000000 --- a/src/explorer/renameFileDialog/i18n.en.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "renameFileDialog": { - "title": "Rename '{fileName}'", - "action": { - "rename": "Rename" - } - } -} diff --git a/src/explorer/renameFileDialog/i18n.ts b/src/explorer/renameFileDialog/i18n.ts index 70743a11..cbbf27c2 100644 --- a/src/explorer/renameFileDialog/i18n.ts +++ b/src/explorer/renameFileDialog/i18n.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors -export enum RenameFileDialogStringId { - Title = 'renameFileDialog.title', - ActionRename = 'renameFileDialog.action.rename', +export enum I18nId { + Title = 'title', + ActionRename = 'action.rename', } diff --git a/src/explorer/renameFileDialog/translations/en.json b/src/explorer/renameFileDialog/translations/en.json new file mode 100644 index 00000000..32a0d576 --- /dev/null +++ b/src/explorer/renameFileDialog/translations/en.json @@ -0,0 +1,6 @@ +{ + "title": "Rename '{fileName}'", + "action": { + "rename": "Rename" + } +} diff --git a/src/explorer/translations/en.json b/src/explorer/translations/en.json new file mode 100644 index 00000000..c8ee59b8 --- /dev/null +++ b/src/explorer/translations/en.json @@ -0,0 +1,28 @@ +{ + "header": { + "exportAllTooltip": "Backup all files", + "importTooltip": "Import a file", + "addNewTooltip": "Create a new file" + }, + "tree": { + "label": "File Explorer", + "liveDescriptor": { + "intro": { + "accessibilityGuide": "Accessibility guide for tree {treeLabel}.", + "navigation": "Navigate the tree with the arrow keys. Start typing the name of a file to search for a file. Additional keybindings are available:", + "keybindings": { + "primaryAction": "{key} to open the file in the code editor", + "rename": "{key} to start renaming the focused file", + "export": "{key} to export the focused file", + "delete": "{key} to delete the focused file" + } + }, + "searching": "Searching." + } + }, + "treeItem": { + "deleteTooltip": "Delete {fileName}", + "exportTooltip": "Export {fileName}", + "renameTooltip": "Rename {fileName}" + } +} diff --git a/src/firmware/FlashButton.tsx b/src/firmware/FlashButton.tsx index 04e580bc..870bcb0a 100644 --- a/src/firmware/FlashButton.tsx +++ b/src/firmware/FlashButton.tsx @@ -9,7 +9,7 @@ import * as notificationActions from '../notifications/actions'; import { useSelector } from '../reducers'; import { useSettingFlashCurrentProgram, useSettingHubName } from '../settings/hooks'; import OpenFileButton, { OpenFileButtonProps } from '../toolbar/OpenFileButton'; -import { TooltipId } from '../toolbar/i18n'; +import { I18nId } from '../toolbar/i18n'; import { flashFirmware } from './actions'; import firmwareIcon from './firmware.svg'; @@ -30,7 +30,7 @@ const FlashButton: React.VoidFunctionComponent = ({ label }) = label={label} fileExtension=".zip" icon={firmwareIcon} - tooltip={flashing ? TooltipId.FlashProgress : TooltipId.Flash} + tooltip={flashing ? I18nId.FlashProgress : I18nId.Flash} enabled={ bootloaderConnection === BootloaderConnectionState.Disconnected && bleConnection === BleConnectionState.Disconnected diff --git a/src/hub/BluetoothButton.tsx b/src/hub/BluetoothButton.tsx index 9ec0a7db..1bad7a4a 100644 --- a/src/hub/BluetoothButton.tsx +++ b/src/hub/BluetoothButton.tsx @@ -8,7 +8,7 @@ import { BleConnectionState } from '../ble/reducers'; import { BootloaderConnectionState } from '../lwp3-bootloader/reducers'; import { useSelector } from '../reducers'; import ActionButton, { ActionButtonProps } from '../toolbar/ActionButton'; -import { TooltipId } from '../toolbar/i18n'; +import { I18nId } from '../toolbar/i18n'; import btConnectedIcon from './bt-connected.svg'; import btDisconnectedIcon from './bt-disconnected.svg'; @@ -30,9 +30,7 @@ const BluetoothButton: React.VoidFunctionComponent = ({ = ({ = ({ = ({ dispatch(stop())} diff --git a/src/licenses/LicenseDialog.tsx b/src/licenses/LicenseDialog.tsx index a68db1cf..8481f4ea 100644 --- a/src/licenses/LicenseDialog.tsx +++ b/src/licenses/LicenseDialog.tsx @@ -11,7 +11,7 @@ import { NonIdealState, Spinner, } from '@blueprintjs/core'; -import { useI18n } from '@shopify/react-i18n'; +import { I18n, useI18n } from '@shopify/react-i18n'; import React, { useCallback, useMemo, useState } from 'react'; import { ControlledTreeEnvironment, @@ -23,8 +23,7 @@ import { import { useFetch } from 'usehooks-ts'; import { appName } from '../app/constants'; import { TreeItemData, renderers } from '../utils/tree-renderer'; -import { LicenseStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; import './license.scss'; @@ -39,13 +38,16 @@ interface LicenseInfo extends TreeItemData { type LicenseList = ReadonlyArray; type LicenseListPanelProps = { + /** Called when item is clicked. */ onItemClick(info?: LicenseInfo): void; + /** Translation context. */ + i18n: I18n; }; const LicenseListPanel: React.VoidFunctionComponent = ({ onItemClick, + i18n, }) => { - const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); const { data, error } = useFetch('static/oss-licenses.json'); const [focusedItem, setFocusedItem] = useState(); const [activeItem, setActiveItem] = useState(); @@ -99,11 +101,7 @@ const LicenseListPanel: React.VoidFunctionComponent = ({
{contents === undefined ? ( - {error ? ( - i18n.translate(LicenseStringId.ErrorFetchFailed) - ) : ( - - )} + {error ? i18n.translate(I18nId.ErrorFetchFailed) : } ) : ( @@ -126,25 +124,23 @@ const LicenseListPanel: React.VoidFunctionComponent = ({ type LicenseInfoPanelProps = { /** The license info to show or undefined if no license info is selected. */ licenseInfo: LicenseInfo | undefined; + /** Translation context. */ + i18n: I18n; }; const LicenseInfoPanel = React.forwardRef( - ({ licenseInfo }, ref) => { - const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); - + ({ licenseInfo, i18n }, ref) => { return (
{licenseInfo === undefined ? ( - {i18n.translate(LicenseStringId.SelectPackageHelp)} + {i18n.translate(I18nId.SelectPackageHelp)} ) : (

- - {i18n.translate(LicenseStringId.PackageLabel)} - {' '} + {i18n.translate(I18nId.PackageLabel)}{' '} {licenseInfo.name}{' '} v{licenseInfo.version} @@ -153,15 +149,13 @@ const LicenseInfoPanel = React.forwardRef {licenseInfo.author && (

- {i18n.translate(LicenseStringId.AuthorLabel)} + {i18n.translate(I18nId.AuthorLabel)} {' '} {licenseInfo.author}

)}

- - {i18n.translate(LicenseStringId.LicenseLabel)} - {' '} + {i18n.translate(I18nId.LicenseLabel)}{' '} {licenseInfo.license}

@@ -189,18 +183,18 @@ const LicenseDialog: React.VoidFunctionComponent = ({ const [licenseInfo, setLicenseInfo] = useState(undefined); const infoDiv = React.useRef(null); - const [i18n] = useI18n({ id: 'license', translations: { en }, fallback: en }); + const [i18n] = useI18n(); return (
- {i18n.translate(LicenseStringId.Description, { + {i18n.translate(I18nId.Description, { name: appName, })} @@ -210,8 +204,13 @@ const LicenseDialog: React.VoidFunctionComponent = ({ infoDiv.current?.scrollTo(0, 0); setLicenseInfo(info); }} + i18n={i18n} + /> + -
diff --git a/src/licenses/i18n.en.json b/src/licenses/i18n.en.json deleted file mode 100644 index 662c21c6..00000000 --- a/src/licenses/i18n.en.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "license": { - "title": "Open Source Software Licenses", - "description": "{name} is built on open source software. By using {name} you are agreeing to the terms and conditions of all of the included software licenses.", - "packageLabel": "Package:", - "authorLabel": "Author:", - "licenseLabel": "License:", - "error": { - "fetchFailed": "Failed to load license data." - }, - "help": { - "selectPackage": "Select a package to view the license." - } - } -} diff --git a/src/licenses/i18n.test.ts b/src/licenses/i18n.test.ts index a42e74d2..925768f1 100644 --- a/src/licenses/i18n.test.ts +++ b/src/licenses/i18n.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { lookup } from '../../test'; -import { LicenseStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for LicenseStringId', () => { - test.each(Object.values(LicenseStringId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/licenses/i18n.ts b/src/licenses/i18n.ts index 460a83cb..01c55045 100644 --- a/src/licenses/i18n.ts +++ b/src/licenses/i18n.ts @@ -3,12 +3,12 @@ // License dialog translation keys. -export enum LicenseStringId { - Title = 'license.title', - Description = 'license.description', - PackageLabel = 'license.packageLabel', - AuthorLabel = 'license.authorLabel', - LicenseLabel = 'license.licenseLabel', - ErrorFetchFailed = 'license.error.fetchFailed', - SelectPackageHelp = 'license.help.selectPackage', +export enum I18nId { + Title = 'title', + Description = 'description', + PackageLabel = 'packageLabel', + AuthorLabel = 'authorLabel', + LicenseLabel = 'licenseLabel', + ErrorFetchFailed = 'error.fetchFailed', + SelectPackageHelp = 'help.selectPackage', } diff --git a/src/licenses/translations/en.json b/src/licenses/translations/en.json new file mode 100644 index 00000000..24629b37 --- /dev/null +++ b/src/licenses/translations/en.json @@ -0,0 +1,13 @@ +{ + "title": "Open Source Software Licenses", + "description": "{name} is built on open source software. By using {name} you are agreeing to the terms and conditions of all of the included software licenses.", + "packageLabel": "Package:", + "authorLabel": "Author:", + "licenseLabel": "License:", + "error": { + "fetchFailed": "Failed to load license data." + }, + "help": { + "selectPackage": "Select a package to view the license." + } +} diff --git a/src/notifications/NotificationAction.tsx b/src/notifications/NotificationAction.tsx index e884067b..cd2fbff9 100644 --- a/src/notifications/NotificationAction.tsx +++ b/src/notifications/NotificationAction.tsx @@ -5,11 +5,10 @@ import { Replacements, useI18n } from '@shopify/react-i18n'; import React from 'react'; -import { MessageId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; type NotificationActionProps = { - messageId: MessageId; + messageId: I18nId; replacements?: Replacements; }; @@ -17,11 +16,7 @@ const NotificationAction: React.VoidFunctionComponent = messageId, replacements, }) => { - const [i18n] = useI18n({ - id: 'notification', - translations: { en }, - fallback: en, - }); + const [i18n] = useI18n(); return <>{i18n.translate(messageId, replacements)}; }; diff --git a/src/notifications/NotificationMessage.tsx b/src/notifications/NotificationMessage.tsx index 5c8f4f95..d6a6a0ca 100644 --- a/src/notifications/NotificationMessage.tsx +++ b/src/notifications/NotificationMessage.tsx @@ -5,11 +5,10 @@ import { Replacements, useI18n } from '@shopify/react-i18n'; import React from 'react'; -import { MessageId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; type NotificationMessageProps = { - messageId: MessageId; + messageId: I18nId; replacements?: Replacements; }; @@ -17,11 +16,7 @@ const NotificationMessage: React.VoidFunctionComponent messageId, replacements, }) => { - const [i18n] = useI18n({ - id: 'notification', - translations: { en }, - fallback: en, - }); + const [i18n] = useI18n(); let message = i18n.translate(messageId, replacements) as | React.ReactElement diff --git a/src/notifications/UnexpectedErrorNotification.tsx b/src/notifications/UnexpectedErrorNotification.tsx index 49ed8ace..06a47208 100644 --- a/src/notifications/UnexpectedErrorNotification.tsx +++ b/src/notifications/UnexpectedErrorNotification.tsx @@ -6,22 +6,17 @@ import { AnchorButton, Button, ButtonGroup, Intent } from '@blueprintjs/core'; import { useI18n } from '@shopify/react-i18n'; import React from 'react'; -import { MessageId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; type UnexpectedErrorNotificationProps = { - messageId: MessageId; + messageId: I18nId; err: Error; }; const UnexpectedErrorNotification: React.VoidFunctionComponent< UnexpectedErrorNotificationProps > = ({ messageId, err }) => { - const [i18n] = useI18n({ - id: 'notification', - translations: { en }, - fallback: en, - }); + const [i18n] = useI18n(); return ( <> @@ -37,7 +32,7 @@ const UnexpectedErrorNotification: React.VoidFunctionComponent< ) } > - {i18n.translate(MessageId.CopyErrorMessage)} + {i18n.translate(I18nId.CopyErrorMessage)} - {i18n.translate(MessageId.ReportBug)} + {i18n.translate(I18nId.ReportBug)}
diff --git a/src/notifications/i18n.test.ts b/src/notifications/i18n.test.ts index 4eea1a95..d0ceda4a 100644 --- a/src/notifications/i18n.test.ts +++ b/src/notifications/i18n.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { lookup } from '../../test'; -import { MessageId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for MessageIds', () => { - test.each(Object.values(MessageId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index 7235dfc1..4fd185b3 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -3,7 +3,7 @@ // // Notification translation keys. -export enum MessageId { +export enum I18nId { AppNoUpdateFound = 'app.noUpdateFound', CopyErrorMessage = 'copyErrorMessage', ReportBug = 'reportBug', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index a2f153b6..8a18f004 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -44,7 +44,7 @@ import { } from '../service-worker/actions'; import * as I18nToaster from './I18nToaster'; import { add } from './actions'; -import { MessageId } from './i18n'; +import { I18nId } from './i18n'; import notification from './sagas'; function createTestToasterSaga(): { toaster: IToaster; saga: AsyncSaga } { @@ -155,7 +155,7 @@ test.each([ await saga.end(); }); -test.each([[didCompile(new Uint8Array()), MessageId.MpyError]])( +test.each([[didCompile(new Uint8Array()), I18nId.MpyError]])( 'actions that should close a notification: %o', async (action: AnyAction, key: string) => { const { toaster, saga } = createTestToasterSaga(); @@ -176,7 +176,7 @@ describe('delete file saga', () => { saga.put(explorerDeleteFile('test.file')); - toaster.dismiss(MessageId.ExplorerDeleteFileMessage); + toaster.dismiss(I18nId.ExplorerDeleteFileMessage); await saga.end(); }); @@ -188,7 +188,7 @@ describe('delete file saga', () => { const toast = toaster .getToasts() - .find((t) => t.key === MessageId.ExplorerDeleteFileMessage); + .find((t) => t.key === I18nId.ExplorerDeleteFileMessage); expect(toast).toBeDefined(); expect(toast?.action).toBeDefined(); @@ -211,17 +211,13 @@ describe('delete file saga', () => { saga.put(explorerDeleteFile('test.file')); expect( - toaster - .getToasts() - .find((t) => t.key === MessageId.ExplorerDeleteFileMessage), + toaster.getToasts().find((t) => t.key === I18nId.ExplorerDeleteFileMessage), ).toBeDefined(); saga.put(fileStorageDidRemoveItem('test.file')); expect( - toaster - .getToasts() - .find((t) => t.key === MessageId.ExplorerDeleteFileMessage), + toaster.getToasts().find((t) => t.key === I18nId.ExplorerDeleteFileMessage), ).toBeUndefined(); await saga.end(); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 7e6be560..50398c39 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -40,7 +40,7 @@ import NotificationAction from './NotificationAction'; import NotificationMessage from './NotificationMessage'; import UnexpectedErrorNotification from './UnexpectedErrorNotification'; import { add as addNotification } from './actions'; -import { MessageId } from './i18n'; +import { I18nId } from './i18n'; type NotificationContext = { toaster: IToaster; @@ -102,7 +102,7 @@ function helpAction(helpUrl: string): ActionProps & LinkProps { } function dispatchAction( - messageId: MessageId, + messageId: I18nId, onClick: (event: React.MouseEvent) => void, icon?: IconName, ): ActionProps { @@ -124,7 +124,7 @@ function dispatchAction( */ function* showSingleton( level: Level, - messageId: MessageId, + messageId: I18nId, replacements?: Replacements, action?: ActionProps & LinkProps, onDismiss?: (didTimeoutExpire: boolean) => void, @@ -160,7 +160,7 @@ function* showSingleton( } /** Shows a special notification for unexpected errors. */ -function* showUnexpectedError(messageId: MessageId, err: Error): Generator { +function* showUnexpectedError(messageId: I18nId, err: Error): Generator { const { toaster } = yield* getContext('notification'); toaster.show({ intent: mapIntent(Level.Error), @@ -175,28 +175,28 @@ function* showBleDeviceDidFailToConnectError( ): Generator { switch (action.reason) { case BleDeviceFailToConnectReasonType.NoGatt: - yield* showSingleton(Level.Error, MessageId.BleGattPermission); + yield* showSingleton(Level.Error, I18nId.BleGattPermission); break; case BleDeviceFailToConnectReasonType.NoPybricksService: - yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, { + yield* showSingleton(Level.Error, I18nId.BleGattServiceNotFound, { serviceName: 'Pybricks', hubName: 'Pybricks Hub', }); break; case BleDeviceFailToConnectReasonType.NoDeviceInfoService: - yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, { + yield* showSingleton(Level.Error, I18nId.BleGattServiceNotFound, { serviceName: 'Device Information', hubName: 'Pybricks Hub', }); break; case BleDeviceFailToConnectReasonType.NoBluetooth: - yield* showSingleton(Level.Error, MessageId.BleNoBluetooth); + yield* showSingleton(Level.Error, I18nId.BleNoBluetooth); break; case BleDeviceFailToConnectReasonType.NoWebBluetooth: yield* showSingleton( Level.Error, - MessageId.BleNoWebBluetooth, + I18nId.BleNoWebBluetooth, undefined, helpAction( 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', @@ -204,7 +204,7 @@ function* showBleDeviceDidFailToConnectError( ); break; case BleDeviceFailToConnectReasonType.Unknown: - yield* showUnexpectedError(MessageId.BleUnexpectedError, action.err); + yield* showUnexpectedError(I18nId.BleUnexpectedError, action.err); break; } } @@ -214,7 +214,7 @@ function* showBootloaderDidFailToConnectError( ): Generator { switch (action.reason) { case BootloaderConnectionFailureReason.GattServiceNotFound: - yield* showSingleton(Level.Error, MessageId.BleGattServiceNotFound, { + yield* showSingleton(Level.Error, I18nId.BleGattServiceNotFound, { serviceName: 'LEGO Bootloader', hubName: 'LEGO Bootloader', }); @@ -222,7 +222,7 @@ function* showBootloaderDidFailToConnectError( case BootloaderConnectionFailureReason.NoWebBluetooth: yield* showSingleton( Level.Error, - MessageId.BleNoWebBluetooth, + I18nId.BleNoWebBluetooth, undefined, helpAction( 'https://github.com/WebBluetoothCG/web-bluetooth/blob/master/implementation-status.md', @@ -230,10 +230,10 @@ function* showBootloaderDidFailToConnectError( ); break; case BootloaderConnectionFailureReason.NoBluetooth: - yield* showSingleton(Level.Error, MessageId.BleNoBluetooth); + yield* showSingleton(Level.Error, I18nId.BleNoBluetooth); break; case BootloaderConnectionFailureReason.Unknown: - yield* showUnexpectedError(MessageId.BleUnexpectedError, action.err); + yield* showUnexpectedError(I18nId.BleUnexpectedError, action.err); break; } } @@ -243,58 +243,55 @@ function* showFlashFirmwareError( ): Generator { switch (action.reason.reason) { case FailToFinishReasonType.TimedOut: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareTimedOut); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareTimedOut); break; case FailToFinishReasonType.BleError: - yield* showUnexpectedError( - MessageId.FlashFirmwareBleError, - action.reason.err, - ); + yield* showUnexpectedError(I18nId.FlashFirmwareBleError, action.reason.err); break; case FailToFinishReasonType.Disconnected: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareDisconnected); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareDisconnected); break; case FailToFinishReasonType.HubError: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareHubError); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareHubError); // istanbul ignore next if (process.env.NODE_ENV !== 'test') { console.error(action.reason.hubError); } break; case FailToFinishReasonType.NoFirmware: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareUnsupportedDevice); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareUnsupportedDevice); break; case FailToFinishReasonType.DeviceMismatch: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareDeviceMismatch); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareDeviceMismatch); break; case FailToFinishReasonType.FailedToFetch: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareFailToFetch, { + yield* showSingleton(Level.Error, I18nId.FlashFirmwareFailToFetch, { status: action.reason.response.statusText, }); break; case FailToFinishReasonType.ZipError: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadZipFile); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareBadZipFile); // istanbul ignore next if (process.env.NODE_ENV !== 'test') { console.error(action.reason.err); } break; case FailToFinishReasonType.BadMetadata: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareBadMetadata); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareBadMetadata); // istanbul ignore next if (process.env.NODE_ENV !== 'test') { console.error(action.reason.property, action.reason.problem); } break; case FailToFinishReasonType.FailedToCompile: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareCompileError); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareCompileError); break; case FailToFinishReasonType.FirmwareSize: - yield* showSingleton(Level.Error, MessageId.FlashFirmwareSizeTooBig); + yield* showSingleton(Level.Error, I18nId.FlashFirmwareSizeTooBig); break; case FailToFinishReasonType.Unknown: yield* showUnexpectedError( - MessageId.FlashFirmwareUnexpectedError, + I18nId.FlashFirmwareUnexpectedError, action.reason.err, ); break; @@ -303,11 +300,11 @@ function* showFlashFirmwareError( function* dismissCompilerError(): Generator { const { toaster } = yield* getContext('notification'); - toaster.dismiss(MessageId.MpyError); + toaster.dismiss(I18nId.MpyError); } function* showCompilerError(action: ReturnType): Generator { - yield* showSingleton(Level.Error, MessageId.MpyError, { + yield* showSingleton(Level.Error, I18nId.MpyError, { errorMessage: React.createElement( 'pre', { style: { whiteSpace: 'pre-wrap', wordBreak: 'keep-all' } }, @@ -331,13 +328,13 @@ function* handleAddNotification(action: ReturnType): Gen function* showServiceWorkerUpdate(): Generator { const ch = channel>(); const userAction = dispatchAction( - MessageId.ServiceWorkerUpdateAction, + I18nId.ServiceWorkerUpdateAction, ch.put, 'refresh', ); yield* showSingleton( Level.Info, - MessageId.ServiceWorkerUpdateMessage, + I18nId.ServiceWorkerUpdateMessage, { appName, action: React.createElement('strong', undefined, userAction.text), @@ -362,7 +359,7 @@ function* showNoUpdateInfo(action: ReturnType): Gen intent: mapIntent(Level.Info), icon: mapIcon(Level.Info), message: React.createElement(NotificationMessage, { - messageId: MessageId.AppNoUpdateFound, + messageId: I18nId.AppNoUpdateFound, replacements: { appName }, }), }); @@ -379,32 +376,32 @@ function* checkVersion( `>=${pythonVersionToSemver(firmwareVersion)}`, ) ) { - yield* showSingleton(Level.Error, MessageId.CheckFirmwareTooOld); + yield* showSingleton(Level.Error, I18nId.CheckFirmwareTooOld); } } function* showFileStorageFailToInitialize( action: ReturnType, ): Generator { - yield* showUnexpectedError(MessageId.FileStorageFailedToInitialize, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToInitialize, action.error); } function* showFileStorageFailToRead( action: ReturnType, ): Generator { - yield* showUnexpectedError(MessageId.FileStorageFailedToRead, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToRead, action.error); } function* showFileStorageFailToWrite( action: ReturnType, ): Generator { - yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToWrite, action.error); } function* showFileStorageFailToDelete( action: ReturnType, ): Generator { - yield* showUnexpectedError(MessageId.FileStorageFailedToDelete, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToDelete, action.error); } function* showFileStorageFailToExport( @@ -415,7 +412,7 @@ function* showFileStorageFailToExport( return; } - yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToExport, action.error); } function* showFileStorageFailToArchive( @@ -426,21 +423,17 @@ function* showFileStorageFailToArchive( return; } - yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error); + yield* showUnexpectedError(I18nId.FileStorageFailedToExport, action.error); } function* showDeleteFileWarning(action: ReturnType) { const ch = channel>(); - const userAction = dispatchAction( - MessageId.ExplorerDeleteFileAction, - ch.put, - 'trash', - ); + const userAction = dispatchAction(I18nId.ExplorerDeleteFileAction, ch.put, 'trash'); // TODO: this should probably not be a singleton yield* showSingleton( Level.Warning, - MessageId.ExplorerDeleteFileMessage, + I18nId.ExplorerDeleteFileMessage, { fileName: React.createElement('strong', undefined, action.fileName), }, @@ -460,7 +453,7 @@ function* showDeleteFileWarning(action: ReturnType) { // shown, close the notification if (didRemoveFile) { const { toaster } = yield* getContext('notification'); - toaster.dismiss(MessageId.ExplorerDeleteFileMessage); + toaster.dismiss(I18nId.ExplorerDeleteFileMessage); return; } @@ -476,7 +469,7 @@ function* showExplorerFailToImportFiles( return; } - yield* showUnexpectedError(MessageId.ExplorerFailedToImportFiles, action.error); + yield* showUnexpectedError(I18nId.ExplorerFailedToImportFiles, action.error); } export default function* (): Generator { diff --git a/src/notifications/i18n.en.json b/src/notifications/translations/en.json similarity index 100% rename from src/notifications/i18n.en.json rename to src/notifications/translations/en.json diff --git a/src/settings/SettingsButton.tsx b/src/settings/SettingsButton.tsx index 4bbd322b..43692b3d 100644 --- a/src/settings/SettingsButton.tsx +++ b/src/settings/SettingsButton.tsx @@ -3,7 +3,7 @@ import React from 'react'; import ActionButton, { ActionButtonProps } from '../toolbar/ActionButton'; -import { TooltipId } from '../toolbar/i18n'; +import { I18nId } from '../toolbar/i18n'; import settingsIcon from './settings.svg'; type SettingsButtonProps = Pick; @@ -15,7 +15,7 @@ const SettingsButton: React.VoidFunctionComponent = ({ return ( diff --git a/src/settings/SettingsDrawer.tsx b/src/settings/SettingsDrawer.tsx index c685bdf5..59122527 100644 --- a/src/settings/SettingsDrawer.tsx +++ b/src/settings/SettingsDrawer.tsx @@ -40,8 +40,7 @@ import { useSettingHubName, useSettingIsShowDocsEnabled, } from './hooks'; -import { SettingsStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; import './settings.scss'; type SettingsProps = { @@ -77,17 +76,13 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ const dispatch = useDispatch(); - const [i18n] = useI18n({ - id: 'settings', - translations: { en }, - fallback: en, - }); + const [i18n] = useI18n(); const hotkeys = useMemo( () => [ { combo: 'mod+d', - label: i18n.translate(SettingsStringId.AppearanceDocumentationTooltip), + label: i18n.translate(I18nId.AppearanceDocumentationTooltip), global: true, preventDefault: true, onKeyDown: toggleIsSettingShowDocsEnabled, @@ -112,7 +107,7 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ size={DrawerSize.SMALL} title={ - {i18n.translate(SettingsStringId.Title)} + {i18n.translate(I18nId.Title)} } onOpening={handleDrawerOpening} @@ -123,18 +118,15 @@ const SettingsDrawer: React.VoidFunctionComponent = ({
{isMacOS() ? 'Cmd' : 'Ctrl'}-+, - out: {isMacOS() ? 'Cmd' : 'Ctrl'}--, - }, - )} + label={i18n.translate(I18nId.AppearanceTitle)} + helperText={i18n.translate(I18nId.AppearanceZoomHelp, { + in: {isMacOS() ? 'Cmd' : 'Ctrl'}-+, + out: {isMacOS() ? 'Cmd' : 'Ctrl'}--, + })} > = ({ > @@ -154,18 +146,14 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ /> setTernaryDarkMode( @@ -177,10 +165,10 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ /> - + = ({ > @@ -201,9 +189,7 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ = ({ className={Classes.INLINE} htmlFor="hub-name-input" > - {i18n.translate( - SettingsStringId.FirmwareHubNameLabel, - )} + {i18n.translate(I18nId.FirmwareHubNameLabel)} = ({ isHubNameValid ? undefined : ( = ({ /> - + - {i18n.translate(SettingsStringId.HelpProjectsLabel)} + {i18n.translate(I18nId.HelpProjectsLabel)} = ({ href={pybricksSupportUrl} target="blank_" > - {i18n.translate(SettingsStringId.HelpSupportLabel)} + {i18n.translate(I18nId.HelpSupportLabel)} = ({ href={pybricksGitterUrl} target="blank_" > - {i18n.translate(SettingsStringId.HelpChatLabel)} + {i18n.translate(I18nId.HelpChatLabel)} = ({ href={pybricksBugReportsUrl} target="blank_" > - {i18n.translate(SettingsStringId.HelpBugsLabel)} + {i18n.translate(I18nId.HelpBugsLabel)} = ({ @@ -302,7 +286,7 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ onClick={() => dispatch(appShowInstallPrompt())} loading={promptingInstall} > - {i18n.translate(SettingsStringId.AppInstallLabel)} + {i18n.translate(I18nId.AppInstallLabel)} )} {isServiceWorkerRegistered && !updateAvailable && ( @@ -311,9 +295,7 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ onClick={() => dispatch(appCheckForUpdate())} loading={checkingForUpdate} > - {i18n.translate( - SettingsStringId.AppCheckForUpdateLabel, - )} + {i18n.translate(I18nId.AppCheckForUpdateLabel)} )} {isServiceWorkerRegistered && updateAvailable && ( @@ -321,7 +303,7 @@ const SettingsDrawer: React.VoidFunctionComponent = ({ icon="refresh" onClick={() => dispatch(appReload())} > - {i18n.translate(SettingsStringId.AppRestartLabel)} + {i18n.translate(I18nId.AppRestartLabel)} )} diff --git a/src/settings/i18n.en.json b/src/settings/i18n.en.json deleted file mode 100644 index 66f0b25b..00000000 --- a/src/settings/i18n.en.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "settings": { - "title": "Settings & Help", - "appearance": { - "title": "Appearance", - "documentation": { - "label": "Documentation", - "tooltip": "Show or hide the documentation pane." - }, - "dark-mode": { - "label": "Dark mode", - "tooltip": "Enable or disable dark mode." - }, - "zoom": { - "help": "Use {in} and {out} to zoom." - } - }, - "firmware": { - "title": "Firmware", - "flash-current-program": { - "label": "Include current program", - "tooltip": "Select to include your program when installing the firmware." - }, - "hub-name": { - "label": "Hub name", - "tooltip": "Hub name to use when flashing the firmware.", - "error": { - "tooltip": "The name is too long." - } - } - }, - "help": { - "title": "Help", - "projects": { - "label": "Example Projects" - }, - "support": { - "label": "Support" - }, - "chat": { - "label": "Chat" - }, - "bugs": { - "label": "Bug Reports" - } - }, - "app": { - "title": "App", - "offlineUseHelp": "Ready for offline use.", - "install": { - "label": "Install as App" - }, - "checkForUpdate": { - "label": "Check for Update" - }, - "restart": { - "label": "Restart to Install Update" - }, - "about": { - "label": "About" - } - } - } -} diff --git a/src/settings/i18n.en.test.ts b/src/settings/i18n.en.test.ts index 89604446..925768f1 100644 --- a/src/settings/i18n.en.test.ts +++ b/src/settings/i18n.en.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors import { lookup } from '../../test'; -import { SettingsStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for SettingsStringIds', () => { - test.each(Object.values(SettingsStringId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/settings/i18n.ts b/src/settings/i18n.ts index 69902f61..5ec8849d 100644 --- a/src/settings/i18n.ts +++ b/src/settings/i18n.ts @@ -1,31 +1,31 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // // Settings translation keys. -export enum SettingsStringId { - Title = 'settings.title', - AppearanceTitle = 'settings.appearance.title', - AppearanceDocumentationLabel = 'settings.appearance.documentation.label', - AppearanceDocumentationTooltip = 'settings.appearance.documentation.tooltip', - AppearanceDarkModeLabel = 'settings.appearance.dark-mode.label', - AppearanceDarkModeTooltip = 'settings.appearance.dark-mode.tooltip', - AppearanceZoomHelp = 'settings.appearance.zoom.help', - FirmwareTitle = 'settings.firmware.title', - FirmwareCurrentProgramLabel = 'settings.firmware.flash-current-program.label', - FirmwareCurrentProgramTooltip = 'settings.firmware.flash-current-program.tooltip', - FirmwareHubNameLabel = 'settings.firmware.hub-name.label', - FirmwareHubNameTooltip = 'settings.firmware.hub-name.tooltip', - FirmwareHubNameErrorTooltip = 'settings.firmware.hub-name.error.tooltip', - HelpTitle = 'settings.help.title', - HelpProjectsLabel = 'settings.help.projects.label', - HelpSupportLabel = 'settings.help.support.label', - HelpChatLabel = 'settings.help.chat.label', - HelpBugsLabel = 'settings.help.bugs.label', - AppTitle = 'settings.app.title', - AppOfflineUseHelp = 'settings.app.offlineUseHelp', - AppInstallLabel = 'settings.app.install.label', - AppCheckForUpdateLabel = 'settings.app.checkForUpdate.label', - AppRestartLabel = 'settings.app.restart.label', - AppAboutLabel = 'settings.app.about.label', +export enum I18nId { + Title = 'title', + AppearanceTitle = 'appearance.title', + AppearanceDocumentationLabel = 'appearance.documentation.label', + AppearanceDocumentationTooltip = 'appearance.documentation.tooltip', + AppearanceDarkModeLabel = 'appearance.dark-mode.label', + AppearanceDarkModeTooltip = 'appearance.dark-mode.tooltip', + AppearanceZoomHelp = 'appearance.zoom.help', + FirmwareTitle = 'firmware.title', + FirmwareCurrentProgramLabel = 'firmware.flash-current-program.label', + FirmwareCurrentProgramTooltip = 'firmware.flash-current-program.tooltip', + FirmwareHubNameLabel = 'firmware.hub-name.label', + FirmwareHubNameTooltip = 'firmware.hub-name.tooltip', + FirmwareHubNameErrorTooltip = 'firmware.hub-name.error.tooltip', + HelpTitle = 'help.title', + HelpProjectsLabel = 'help.projects.label', + HelpSupportLabel = 'help.support.label', + HelpChatLabel = 'help.chat.label', + HelpBugsLabel = 'help.bugs.label', + AppTitle = 'app.title', + AppOfflineUseHelp = 'app.offlineUseHelp', + AppInstallLabel = 'app.install.label', + AppCheckForUpdateLabel = 'app.checkForUpdate.label', + AppRestartLabel = 'app.restart.label', + AppAboutLabel = 'app.about.label', } diff --git a/src/settings/translations/en.json b/src/settings/translations/en.json new file mode 100644 index 00000000..0d2da0ba --- /dev/null +++ b/src/settings/translations/en.json @@ -0,0 +1,62 @@ +{ + "title": "Settings & Help", + "appearance": { + "title": "Appearance", + "documentation": { + "label": "Documentation", + "tooltip": "Show or hide the documentation pane." + }, + "dark-mode": { + "label": "Dark mode", + "tooltip": "Enable or disable dark mode." + }, + "zoom": { + "help": "Use {in} and {out} to zoom." + } + }, + "firmware": { + "title": "Firmware", + "flash-current-program": { + "label": "Include current program", + "tooltip": "Select to include your program when installing the firmware." + }, + "hub-name": { + "label": "Hub name", + "tooltip": "Hub name to use when flashing the firmware.", + "error": { + "tooltip": "The name is too long." + } + } + }, + "help": { + "title": "Help", + "projects": { + "label": "Example Projects" + }, + "support": { + "label": "Support" + }, + "chat": { + "label": "Chat" + }, + "bugs": { + "label": "Bug Reports" + } + }, + "app": { + "title": "App", + "offlineUseHelp": "Ready for offline use.", + "install": { + "label": "Install as App" + }, + "checkForUpdate": { + "label": "Check for Update" + }, + "restart": { + "label": "Restart to Install Update" + }, + "about": { + "label": "About" + } + } +} diff --git a/src/status-bar/StatusBar.tsx b/src/status-bar/StatusBar.tsx index 5a858195..5115eb64 100644 --- a/src/status-bar/StatusBar.tsx +++ b/src/status-bar/StatusBar.tsx @@ -3,13 +3,12 @@ import { Button, Intent, ProgressBar } from '@blueprintjs/core'; import { Classes as Classes2, Popover2, Popover2Props } from '@blueprintjs/popover2'; -import { useI18n } from '@shopify/react-i18n'; +import { I18n, useI18n } from '@shopify/react-i18n'; import React from 'react'; import { BleConnectionState } from '../ble/reducers'; import { useSelector } from '../reducers'; import { preventBrowserNativeContextMenu } from '../utils/react'; -import { MessageId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; import './status-bar.scss'; @@ -18,13 +17,16 @@ const commonPopoverProps: Partial = { placement: 'top', }; -const HubInfoButton: React.VFC = (_props) => { +type HubInfoButtonProps = { + /** Translation context. */ + i18n: I18n; +}; + +const HubInfoButton: React.VoidFunctionComponent = ({ i18n }) => { const deviceName = useSelector((s) => s.ble.deviceName); const deviceType = useSelector((s) => s.ble.deviceType); const deviceFirmwareVersion = useSelector((s) => s.ble.deviceFirmwareVersion); - const [i18n] = useI18n({ id: 'statusBar', translations: { en }, fallback: en }); - return ( { - {i18n.translate(MessageId.HubInfoConnectedTo)} + {i18n.translate(I18nId.HubInfoConnectedTo)} {deviceName} - - {i18n.translate(MessageId.HubInfoHubType)} - + {i18n.translate(I18nId.HubInfoHubType)} {deviceType} - {i18n.translate(MessageId.HubInfoFirmware)} + {i18n.translate(I18nId.HubInfoFirmware)} v{deviceFirmwareVersion} @@ -59,32 +59,37 @@ const HubInfoButton: React.VFC = (_props) => { } > - ); }; -const BatteryIndicator: React.VFC = (_props) => { +type BatteryIndicatorProps = { + /** Translation context. */ + i18n: I18n; +}; + +const BatteryIndicator: React.VoidFunctionComponent = ({ + i18n, +}) => { const charging = useSelector((s) => s.ble.deviceBatteryCharging); const lowBatteryWarning = useSelector((s) => s.ble.deviceLowBatteryWarning); - const [i18n] = useI18n({ id: 'statusBar', translations: { en }, fallback: en }); - return ( {i18n.translate( - lowBatteryWarning ? MessageId.BatteryLow : MessageId.BatteryOk, + lowBatteryWarning ? I18nId.BatteryLow : I18nId.BatteryOk, )} } >
@@ -103,6 +108,7 @@ const BatteryIndicator: React.VFC = (_props) => { const StatusBar: React.VFC = (_props) => { const connection = useSelector((s) => s.ble.connection); + const [i18n] = useI18n(); return (
{ > {connection === BleConnectionState.Connected && ( <> - - + + )}
diff --git a/src/status-bar/i18n.test.ts b/src/status-bar/i18n.test.ts index d5939dd8..d0ceda4a 100644 --- a/src/status-bar/i18n.test.ts +++ b/src/status-bar/i18n.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020-2021 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { lookup } from '../../test'; -import { MessageId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for MessageIds', () => { - test.each(Object.values(MessageId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/status-bar/i18n.ts b/src/status-bar/i18n.ts index 5abced99..f00d261a 100644 --- a/src/status-bar/i18n.ts +++ b/src/status-bar/i18n.ts @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2021 The Pybricks Authors +// Copyright (c) 2021-2022 The Pybricks Authors // // Status bar translation keys. -export enum MessageId { +export enum I18nId { BatteryTitle = 'battery.title', BatteryLow = 'battery.low', BatteryOk = 'battery.ok', diff --git a/src/status-bar/i18n.en.json b/src/status-bar/translations/en.json similarity index 100% rename from src/status-bar/i18n.en.json rename to src/status-bar/translations/en.json diff --git a/src/terminal/Terminal.tsx b/src/terminal/Terminal.tsx index 09e1f025..6ce5a1cc 100644 --- a/src/terminal/Terminal.tsx +++ b/src/terminal/Terminal.tsx @@ -12,8 +12,7 @@ import { FitAddon } from 'xterm-addon-fit'; import { isMacOS } from '../utils/os'; import { TerminalContext } from './TerminalContext'; import { receiveData } from './actions'; -import { TerminalStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; import 'xterm/css/xterm.css'; @@ -55,7 +54,7 @@ function createContextMenu( xterm: XTerm, ): (props: ContextMenu2ContentProps) => JSX.Element { const contextMenu = (_props: ContextMenu2ContentProps): JSX.Element => { - const [i18n] = useI18n({ id: 'terminal', translations: { en }, fallback: en }); + const [i18n] = useI18n(); return ( @@ -66,7 +65,7 @@ function createContextMenu( navigator.clipboard.writeText(selected); } }} - text={i18n.translate(TerminalStringId.Copy)} + text={i18n.translate(I18nId.Copy)} icon="duplicate" label={isMacOS() ? 'Cmd-C' : 'Ctrl-Shift-C'} disabled={!xterm.hasSelection()} @@ -75,19 +74,19 @@ function createContextMenu( onClick={async (): Promise => { xterm.paste(await navigator.clipboard.readText()); }} - text={i18n.translate(TerminalStringId.Paste)} + text={i18n.translate(I18nId.Paste)} icon="clipboard" label={isMacOS() ? 'Cmd-V' : 'Ctrl-V'} /> xterm.selectAll()} - text={i18n.translate(TerminalStringId.SelectAll)} + text={i18n.translate(I18nId.SelectAll)} icon="blank" /> xterm.clear()} - text={i18n.translate(TerminalStringId.Clear)} + text={i18n.translate(I18nId.Clear)} icon="trash" /> diff --git a/src/terminal/i18n.en.json b/src/terminal/i18n.en.json deleted file mode 100644 index 3575aa9b..00000000 --- a/src/terminal/i18n.en.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "terminal": { - "copy": "Copy", - "paste": "Paste", - "selectAll": "Select All", - "clear": "Clear" - } -} diff --git a/src/terminal/i18n.test.ts b/src/terminal/i18n.test.ts index 920f97b6..d0ceda4a 100644 --- a/src/terminal/i18n.test.ts +++ b/src/terminal/i18n.test.ts @@ -1,12 +1,12 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors import { lookup } from '../../test'; -import { TerminalStringId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; +import en from './translations/en.json'; -describe('Ensure .json file has matches for TerminalStringIds', () => { - test.each(Object.values(TerminalStringId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/terminal/i18n.ts b/src/terminal/i18n.ts index 330dd5d0..e8091901 100644 --- a/src/terminal/i18n.ts +++ b/src/terminal/i18n.ts @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2020 The Pybricks Authors +// Copyright (c) 2020-2022 The Pybricks Authors // // Terminal translation keys. -export enum TerminalStringId { - Copy = 'terminal.copy', - Paste = 'terminal.paste', - SelectAll = 'terminal.selectAll', - Clear = 'terminal.clear', +export enum I18nId { + Copy = 'copy', + Paste = 'paste', + SelectAll = 'selectAll', + Clear = 'clear', } diff --git a/src/terminal/translations/en.json b/src/terminal/translations/en.json new file mode 100644 index 00000000..2a7e5caa --- /dev/null +++ b/src/terminal/translations/en.json @@ -0,0 +1,6 @@ +{ + "copy": "Copy", + "paste": "Paste", + "selectAll": "Select All", + "clear": "Clear" +} diff --git a/src/toolbar/ActionButton.tsx b/src/toolbar/ActionButton.tsx index 7f749cba..4c6d5c5c 100644 --- a/src/toolbar/ActionButton.tsx +++ b/src/toolbar/ActionButton.tsx @@ -14,8 +14,7 @@ import { useI18n } from '@shopify/react-i18n'; import React, { useEffect, useMemo, useState } from 'react'; import { tooltipDelay } from '../app/constants'; import { pointerEventsNone } from '../utils/react'; -import { TooltipId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; const smallScreenThreshold = 700; @@ -25,9 +24,9 @@ export interface ActionButtonProps { /** Keyboard shortcut. */ readonly keyboardShortcut?: string; /** Tooltip text that appears when hovering over the button. */ - readonly tooltip: TooltipId; + readonly tooltip: I18nId; /** Tooltip text that appears when hovering over the button and @showProgress is true. */ - readonly progressTooltip?: TooltipId; + readonly progressTooltip?: I18nId; /** Icon shown on the button. */ readonly icon: string; /** When true or undefined, the button is enabled. */ @@ -51,7 +50,7 @@ const ActionButton: React.VoidFunctionComponent = ({ progress, onAction, }) => { - const [i18n] = useI18n({ id: 'actionButton', translations: { en }, fallback: en }); + const [i18n] = useI18n(); const [isSmallScreen, setIsSmallScreen] = useState( window.innerWidth <= smallScreenThreshold, diff --git a/src/toolbar/OpenFileButton.tsx b/src/toolbar/OpenFileButton.tsx index 0ca9fff9..496280bd 100644 --- a/src/toolbar/OpenFileButton.tsx +++ b/src/toolbar/OpenFileButton.tsx @@ -8,8 +8,7 @@ import React, { useEffect, useState } from 'react'; import { useDropzone } from 'react-dropzone'; import { tooltipDelay } from '../app/constants'; import { pointerEventsNone } from '../utils/react'; -import { TooltipId } from './i18n'; -import en from './i18n.en.json'; +import { I18nId } from './i18n'; const smallScreenThreshold = 700; export interface OpenFileButtonProps { @@ -18,7 +17,7 @@ export interface OpenFileButtonProps { /** The accepted file extension */ readonly fileExtension: string; /** Tooltip text that appears when hovering over the button. */ - readonly tooltip: TooltipId; + readonly tooltip: I18nId; /** Icon shown on the button. */ readonly icon: string; /** When true or undefined, the button is enabled. */ @@ -50,11 +49,7 @@ const OpenFileButton: React.VoidFunctionComponent = ({ onReject, onClick, }) => { - const [i18n] = useI18n({ - id: 'openFileButton', - translations: { en }, - fallback: en, - }); + const [i18n] = useI18n(); const [isSmallScreen, setIsSmallScreen] = useState( window.innerWidth <= smallScreenThreshold, @@ -108,7 +103,7 @@ const OpenFileButton: React.VoidFunctionComponent = ({ { - test.each(Object.values(TooltipId))('%s', (id) => { +describe('Ensure .json file has matches for I18nId', () => { + test.each(Object.values(I18nId))('%s', (id) => { expect(lookup(en, id)).toBeDefined(); }); }); diff --git a/src/toolbar/i18n.ts b/src/toolbar/i18n.ts index 1221aa09..557b0e31 100644 --- a/src/toolbar/i18n.ts +++ b/src/toolbar/i18n.ts @@ -3,7 +3,7 @@ // // Toolbar button translation keys. -export enum TooltipId { +export enum I18nId { Run = 'run.action.tooltip', RunProgress = 'run.progress.tooltip', Stop = 'stop.tooltip', diff --git a/src/toolbar/i18n.en.json b/src/toolbar/translations/en.json similarity index 100% rename from src/toolbar/i18n.en.json rename to src/toolbar/translations/en.json