diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index 13e5d8c1415..e62527a83e4 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -219,8 +219,15 @@ const RESTRICTED_SYNTAXES = [ message: 'Use `while (true)` instead of `for (;;)`', }, { - selector: 'CallExpression[callee.name=toastAndLog][arguments.0.value=/\\.$/]', - message: '`toastAndLog` already includes a trailing `.`', + selector: `:matches(\ + JSXAttribute[name.name=/^(?:alt|error|label|placeholder|text|title|actionButtonLabel|actionText|aria-label)$/][value.raw=/^'|^"|^\`/], \ + JSXText[value=/\\S/], \ + JSXAttribute[name.name=/^(?:alt|error|label|placeholder|text|title|actionButtonLabel|actionText|aria-label)$/] ConditionalExpression:matches(\ + [consequent.raw=/^'|^"|^\`/], \ + [alternate.raw=/^'|^"|^\`/]\ + )\ + )`, + message: 'Use a `getText()` from `useText` instead of a literal string', }, ] diff --git a/app/ide-desktop/lib/dashboard/.prettierrc.cjs b/app/ide-desktop/lib/dashboard/.prettierrc.cjs index 937f69df32f..1f27192e509 100644 --- a/app/ide-desktop/lib/dashboard/.prettierrc.cjs +++ b/app/ide-desktop/lib/dashboard/.prettierrc.cjs @@ -24,6 +24,7 @@ module.exports = { '', '^#[/]App', '^#[/]appUtils', + '^#[/]text', '', '^#[/]configurations[/]', '', diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index b3207ac9ed3..07f000f1938 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -199,32 +199,32 @@ export function locateNameColumnToggle(page: test.Locator | test.Page) { /** Find a toggle for the "Modified" column (if any) on the current page. */ export function locateModifiedColumnToggle(page: test.Locator | test.Page) { - return page.getByAltText(/^(?:Show|Hide) Modified date$/) + return page.getByAltText(/^(?:Show|Hide) Modified date column$/) } /** Find a toggle for the "Shared with" column (if any) on the current page. */ export function locateSharedWithColumnToggle(page: test.Locator | test.Page) { - return page.getByAltText(/^(?:Show|Hide) Shared with$/) + return page.getByAltText(/^(?:Show|Hide) Shared with column$/) } /** Find a toggle for the "Labels" column (if any) on the current page. */ export function locateLabelsColumnToggle(page: test.Locator | test.Page) { - return page.getByAltText(/^(?:Show|Hide) Labels$/) + return page.getByAltText(/^(?:Show|Hide) Labels column$/) } /** Find a toggle for the "Accessed by projects" column (if any) on the current page. */ export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) { - return page.getByAltText(/^(?:Show|Hide) Accessed by projects$/) + return page.getByAltText(/^(?:Show|Hide) Accessed by projects column$/) } /** Find a toggle for the "Accessed data" column (if any) on the current page. */ export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) { - return page.getByAltText(/^(?:Show|Hide) Accessed data$/) + return page.getByAltText(/^(?:Show|Hide) Accessed data column$/) } /** Find a toggle for the "Docs" column (if any) on the current page. */ export function locateDocsColumnToggle(page: test.Locator | test.Page) { - return page.getByAltText(/^(?:Show|Hide) Docs$/) + return page.getByAltText(/^(?:Show|Hide) Docs column$/) } /** Find a button for the "Recent" category (if any) on the current page. */ @@ -234,7 +234,7 @@ export function locateRecentCategory(page: test.Locator | test.Page) { /** Find a button for the "Home" category (if any) on the current page. */ export function locateHomeCategory(page: test.Locator | test.Page) { - return page.getByTitle('Go To Homoe') + return page.getByTitle('Go To Home') } /** Find a button for the "Trash" category (if any) on the current page. */ @@ -423,17 +423,22 @@ export function locateSortDescendingIcon(page: test.Locator | test.Page) { /** Find a "home page" icon (if any) on the current page. */ export function locateHomePageIcon(page: test.Locator | test.Page) { - return page.getByAltText('Go to home page') + return page.getByAltText('Home tab') } /** Find a "drive page" icon (if any) on the current page. */ export function locateDrivePageIcon(page: test.Locator | test.Page) { - return page.getByAltText('Go to drive page') + return page.getByAltText('Drive tab') } /** Find an "editor page" icon (if any) on the current page. */ export function locateEditorPageIcon(page: test.Locator | test.Page) { - return page.getByAltText('Go to editor page') + return page.getByAltText('Project tab') +} + +/** Find a "settings page" icon (if any) on the current page. */ +export function locateSettingsPageIcon(page: test.Locator | test.Page) { + return page.getByAltText('Settings tab') } /** Find a "name" column heading (if any) on the current page. */ diff --git a/app/ide-desktop/lib/dashboard/src/components/Button.tsx b/app/ide-desktop/lib/dashboard/src/components/Button.tsx index b8299d5f4ad..6a29589673b 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Button.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Button.tsx @@ -13,6 +13,8 @@ export interface ButtonProps { readonly alt?: string /** A title that is only shown when `disabled` is `true`. */ readonly error?: string | null + /** The default title. */ + readonly title?: string readonly className?: string readonly onClick: (event: React.MouseEvent) => void } @@ -20,7 +22,7 @@ export interface ButtonProps { /** A styled button. */ export default function Button(props: ButtonProps) { const { active = false, disabled = false, image, error } = props - const { alt, className, onClick } = props + const { title, alt, className, onClick } = props return ( )} ) diff --git a/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx b/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx index 53b61b610e3..11f330aa2fd 100644 --- a/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/JSONSchemaInput.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as backendProvider from '#/providers/BackendProvider' +import * as textProvider from '#/providers/TextProvider' import Autocomplete from '#/components/Autocomplete' import Dropdown from '#/components/Dropdown' @@ -32,6 +33,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { // The functionality for inputting `enso-secret`s SHOULD be injected using a plugin, // but it is more convenient to avoid having plugin infrastructure. const { backend } = backendProvider.useBackend() + const { getText } = textProvider.useText() const [value, setValue] = React.useState(valueRaw) const [autocompleteText, setAutocompleteText] = React.useState(() => typeof value === 'string' ? value : null @@ -80,7 +82,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { items={autocompleteItems ?? []} itemToKey={item => item} itemToString={item => item} - placeholder="Enter secret path" + placeholder={getText('enterSecretPath')} matches={(item, text) => item.toLowerCase().includes(text.toLowerCase())} values={isValid ? [value] : []} setValues={values => { @@ -101,7 +103,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${ getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60' }`} - placeholder="Enter text" + placeholder={getText('enterText')} onChange={event => { const newValue: string = event.currentTarget.value setValue(newValue) @@ -121,7 +123,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${ getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60' }`} - placeholder="Enter number" + placeholder={getText('enterNumber')} onChange={event => { const newValue: number = event.currentTarget.valueAsNumber if (Number.isFinite(newValue)) { @@ -142,7 +144,7 @@ export default function JSONSchemaInput(props: JSONSchemaInputProps) { className={`w-data-link-text-input text grow rounded-input border bg-transparent px-input-x read-only:read-only ${ getValidator(path)(value) ? 'border-primary/10' : 'border-red-700/60' }`} - placeholder="Enter integer" + placeholder={getText('enterInteger')} onChange={event => { const newValue: number = Math.floor(event.currentTarget.valueAsNumber) setValue(newValue) diff --git a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx index 1afbc8f8f29..e8c447266f9 100644 --- a/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/MenuEntry.tsx @@ -3,15 +3,60 @@ import * as React from 'react' import BlankIcon from 'enso-assets/blank.svg' +import type * as text from '#/text' + import type * as inputBindings from '#/configurations/inputBindings' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' +import * as textProvider from '#/providers/TextProvider' import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut' import SvgMask from '#/components/SvgMask' import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets' +// ================= +// === Constants === +// ================= + +const ACTION_TO_TEXT_ID: Readonly> = { + settings: 'settingsShortcut', + open: 'openShortcut', + run: 'runShortcut', + close: 'closeShortcut', + uploadToCloud: 'uploadToCloudShortcut', + rename: 'renameShortcut', + edit: 'editShortcut', + snapshot: 'snapshotShortcut', + delete: 'deleteShortcut', + undelete: 'undeleteShortcut', + share: 'shareShortcut', + label: 'labelShortcut', + duplicate: 'duplicateShortcut', + copy: 'copyShortcut', + cut: 'cutShortcut', + paste: 'pasteShortcut', + download: 'downloadShortcut', + uploadFiles: 'uploadFilesShortcut', + uploadProjects: 'uploadProjectsShortcut', + newProject: 'newProjectShortcut', + newFolder: 'newFolderShortcut', + newDataLink: 'newDataLinkShortcut', + newSecret: 'newSecretShortcut', + closeModal: 'closeModalShortcut', + cancelEditName: 'cancelEditNameShortcut', + signIn: 'signInShortcut', + signOut: 'signOutShortcut', + downloadApp: 'downloadAppShortcut', + cancelCut: 'cancelCutShortcut', + editName: 'editNameShortcut', + selectAdditional: 'selectAdditionalShortcut', + selectRange: 'selectRangeShortcut', + selectAdditionalRange: 'selectAdditionalRangeShortcut', + goBack: 'goBackShortcut', + goForward: 'goForwardShortcut', +} satisfies { [Key in inputBindings.DashboardBindingKey]: `${Key}Shortcut` } + // ================= // === MenuEntry === // ================= @@ -40,6 +85,7 @@ export default function MenuEntry(props: MenuEntryProps) { isContextMenuEntry = false, } = props const { doAction } = props + const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() const info = inputBindings.metadata[action] React.useEffect(() => { @@ -69,7 +115,7 @@ export default function MenuEntry(props: MenuEntryProps) { >
- {label ?? info.name} + {label ?? getText(ACTION_TO_TEXT_ID[action])}
diff --git a/app/ide-desktop/lib/dashboard/src/components/Spinner.tsx b/app/ide-desktop/lib/dashboard/src/components/Spinner.tsx index 2c7737d0b47..6c021cfa531 100644 --- a/app/ide-desktop/lib/dashboard/src/components/Spinner.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/Spinner.tsx @@ -53,10 +53,7 @@ export default function Spinner(props: SpinnerProps) { stroke="currentColor" strokeLinecap="round" strokeWidth={3} - className={ - 'origin-center animate-spin-ease transition-stroke-dasharray ' + - `pointer-events-none ${SPINNER_CSS_CLASSES[state]}` - } + className={`pointer-events-none origin-center animate-spin-ease transition-stroke-dasharray ${SPINNER_CSS_CLASSES[state]}`} /> ) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx index 941e7be3b5f..e5c687e4734 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetInfoBar.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import SettingsIcon from 'enso-assets/settings.svg' import * as backendProvider from '#/providers/BackendProvider' +import * as textProvider from '#/providers/TextProvider' import Button from '#/components/Button' @@ -24,6 +25,7 @@ export default function AssetInfoBar(props: AssetInfoBarProps) { setIsAssetPanelEnabled: setIsAssetPanelVisible, } = props const { backend } = backendProvider.useBackend() + const { getText } = textProvider.useText() return (
diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx index 7b41eeb676f..1a19a5d4151 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetSummary.tsx @@ -3,6 +3,8 @@ import * as React from 'react' import BreadcrumbArrowIcon from 'enso-assets/breadcrumb_arrow.svg' +import * as textProvider from '#/providers/TextProvider' + import AssetIcon from '#/components/dashboard/AssetIcon' import type * as backend from '#/services/Backend' @@ -21,6 +23,7 @@ export interface AssetSummaryProps { /** Displays a few details of an asset. */ export default function AssetSummary(props: AssetSummaryProps) { const { asset, new: isNew = false, newName, className } = props + const { getText } = textProvider.useText() return (
{!isNew && ( - last modified on {dateTime.formatDateTime(new Date(asset.modifiedAt))} + + {getText('lastModifiedOn', dateTime.formatDateTime(new Date(asset.modifiedAt)))} + )} {asset.labels}
diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx index 44ce3116b9d..05746ff4ee2 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/DataLinkNameColumn.tsx @@ -85,7 +85,7 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) { case AssetEventType.newDataLink: { if (item.key === event.placeholderId) { if (backend.type !== backendModule.BackendType.remote) { - toastAndLog('Data connectors cannot be created on the local backend') + toastAndLog('localBackendDataLinkError') } else { rowState.setVisibility(Visibility.faded) try { @@ -98,11 +98,8 @@ export default function DataLinkNameColumn(props: DataLinkNameColumnProps) { rowState.setVisibility(Visibility.visible) setAsset(object.merger({ id })) } catch (error) { - dispatchAssetListEvent({ - type: AssetListEventType.delete, - key: item.key, - }) - toastAndLog('Error creating new data connector', error) + dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) + toastAndLog('createDataLinkError', error) } } } diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx index aad719da9e3..7f8993a94ab 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/DirectoryNameColumn.tsx @@ -10,6 +10,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as backendProvider from '#/providers/BackendProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider' +import * as textProvider from '#/providers/TextProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' @@ -42,6 +43,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { const { doToggleDirectoryExpansion } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { backend } = backendProvider.useBackend() + const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() const asset = item.item if (asset.type !== backendModule.AssetType.directory) { @@ -61,7 +63,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { try { await backend.updateDirectory(asset.id, { title: newTitle }, asset.title) } catch (error) { - toastAndLog('Could not rename folder', error) + toastAndLog('renameFolderError', error) setAsset(object.merger({ title: oldTitle })) } } @@ -99,7 +101,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { case AssetEventType.newFolder: { if (item.key === event.placeholderId) { if (backend.type !== backendModule.BackendType.remote) { - toastAndLog('Cannot create folders on the local drive') + toastAndLog('localBackendFolderError') } else { rowState.setVisibility(Visibility.faded) try { @@ -114,7 +116,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { type: AssetListEventType.delete, key: item.key, }) - toastAndLog('Could not create new folder', error) + toastAndLog('createFolderError', error) } } } @@ -154,7 +156,7 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) { >