Move all user-facing text to a central location (#8831)

- Closes https://github.com/enso-org/cloud-v2/issues/861
- Adds a `getText` function and React Context to abstract away all text that is shown to users

The main immediate benefit is making all text much more discoverable - both being able to know of every piece of text used across the entire application, and making it a lot easier to refactor certain strings when needed rather than having to hunt for strings to replace (and potentially miss one).

The longer term benefit is that this will make it easy to add localization, by simply adding another JSON file with the same keys as the existing one, and adding a little bit of logic.

# Important Notes
None
This commit is contained in:
somebody1234 2024-03-25 18:13:24 +10:00 committed by GitHub
parent 90bbee352e
commit 770e18768a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
95 changed files with 1762 additions and 918 deletions

View File

@ -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',
},
]

View File

@ -24,6 +24,7 @@ module.exports = {
'',
'^#[/]App',
'^#[/]appUtils',
'^#[/]text',
'',
'^#[/]configurations[/]',
'',

View File

@ -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. */

View File

@ -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 (
<button
@ -30,7 +32,11 @@ export default function Button(props: ButtonProps) {
>
<SvgMask
src={image}
{...(!active && disabled && error != null ? { title: error } : {})}
{...(!active && disabled && error != null
? { title: error }
: title != null
? { title }
: {})}
{...(alt != null ? { alt } : {})}
className={className}
/>

View File

@ -5,6 +5,8 @@ import CrossIcon from 'enso-assets/cross.svg'
import FolderArrowDoubleIcon from 'enso-assets/folder_arrow_double.svg'
import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
import * as dateTime from '#/utilities/dateTime'
@ -41,6 +43,7 @@ export interface DateInputProps {
/** An input that outputs a {@link Date}. */
export default function DateInput(props: DateInputProps) {
const { date, onInput } = props
const { getText } = textProvider.useText()
const year = date?.getFullYear() ?? new Date().getFullYear()
const monthIndex = date?.getMonth() ?? new Date().getMonth()
const [isPickerVisible, setIsPickerVisible] = React.useState(false)
@ -167,13 +170,13 @@ export default function DateInput(props: DateInputProps) {
</caption>
<thead>
<tr>
<th className="text-tight min-w-date-cell p">M</th>
<th className="text-tight min-w-date-cell p">Tu</th>
<th className="text-tight min-w-date-cell p">W</th>
<th className="text-tight min-w-date-cell p">Th</th>
<th className="text-tight min-w-date-cell p">F</th>
<th className="text-tight min-w-date-cell p">Sa</th>
<th className="text-tight min-w-date-cell p">Su</th>
<th className="text-tight min-w-date-cell p">{getText('mondayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('tuesdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('wednesdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('thursdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('fridayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('saturdayAbbr')}</th>
<th className="text-tight min-w-date-cell p">{getText('sundayAbbr')}</th>
</tr>
</thead>
<tbody>

View File

@ -7,6 +7,7 @@ import TickIcon from 'enso-assets/tick.svg'
import * as eventCalback from '#/hooks/eventCallbackHooks'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
@ -35,6 +36,7 @@ export interface EditableSpanProps {
export default function EditableSpan(props: EditableSpanProps) {
const { 'data-testid': dataTestId, className, editable = false, children } = props
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const [isSubmittable, setIsSubmittable] = React.useState(true)
const inputRef = React.useRef<HTMLInputElement>(null)
@ -118,7 +120,7 @@ export default function EditableSpan(props: EditableSpanProps) {
type="submit"
className="mx-tick-cross-button my-auto flex rounded-full transition-colors hover:bg-hover-bg"
>
<SvgMask src={TickIcon} alt="Confirm Edit" className="size-icon" />
<SvgMask src={TickIcon} alt={getText('confirmEdit')} className="size-icon" />
</button>
)}
<button
@ -135,7 +137,7 @@ export default function EditableSpan(props: EditableSpanProps) {
})
}}
>
<SvgMask src={CrossIcon} alt="Cancel Edit" className="size-icon" />
<SvgMask src={CrossIcon} alt={getText('cancelEdit')} className="size-icon" />
</button>
</form>
)

View File

@ -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)

View File

@ -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<Record<inputBindings.DashboardBindingKey, text.TextId>> = {
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) {
>
<div className="flex items-center gap-menu-entry whitespace-nowrap">
<SvgMask src={info.icon ?? BlankIcon} color={info.color} className="size-icon" />
{label ?? info.name}
{label ?? getText(ACTION_TO_TEXT_ID[action])}
</div>
<KeyboardShortcut action={action} />
</button>

View File

@ -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]}`}
/>
</svg>
)

View File

@ -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 (
<div
className={`pointer-events-auto flex h-row shrink-0 cursor-default items-center gap-icons rounded-full bg-frame px-icons-x ${
@ -34,10 +36,10 @@ export default function AssetInfoBar(props: AssetInfoBarProps) {
}}
>
<Button
alt={isAssetPanelVisible ? 'Close Asset Panel' : 'Open Asset Panel'}
alt={isAssetPanelVisible ? getText('closeAssetPanel') : getText('openAssetPanel')}
active={isAssetPanelVisible}
image={SettingsIcon}
error="Select exactly one asset to see its settings."
error={getText('multipleAssetsSettingsError')}
onClick={() => {
setIsAssetPanelVisible(visible => !visible)
}}

View File

@ -10,6 +10,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
@ -28,7 +29,6 @@ import AssetTreeNode from '#/utilities/AssetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
import * as drag from '#/utilities/drag'
import * as errorModule from '#/utilities/error'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
@ -45,10 +45,6 @@ const HEADER_HEIGHT_PX = 34
/** The amount of time (in milliseconds) the drag item must be held over this component
* to make a directory row expand. */
const DRAG_EXPAND_DELAY_MS = 500
/** Placeholder row for directories that are empty. */
const EMPTY_DIRECTORY_PLACEHOLDER = (
<span className="px-name-column-x placeholder">This folder is empty.</span>
)
// ================
// === AssetRow ===
@ -87,13 +83,14 @@ export interface AssetRowProps
export default function AssetRow(props: AssetRowProps) {
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelected, isKeyboardSelected } = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent, nodeMap } = state
const { setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef } = state
const { user, userInfo } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const [item, setItem] = React.useState(rawItem)
@ -139,11 +136,12 @@ export default function AssetRow(props: AssetRowProps) {
modifiedAt: dateTime.toRfc3339(new Date()),
})
)
newParentId ??= user?.rootDirectoryId ?? backendModule.DirectoryId('')
const copiedAsset = await backend.copyAsset(
asset.id,
newParentId ?? user?.rootDirectoryId ?? backendModule.DirectoryId(''),
newParentId,
asset.title,
null
nodeMap.current.get(newParentId)?.item.title ?? '(unknown)'
)
setAsset(
// This is SAFE, as the type of the copied asset is guaranteed to be the same
@ -152,7 +150,7 @@ export default function AssetRow(props: AssetRowProps) {
object.merger(copiedAsset.asset as Partial<backendModule.AnyAsset>)
)
} catch (error) {
toastAndLog(`Could not copy '${asset.title}'`, error)
toastAndLog('copyAssetError', error, asset.title)
// Delete the new component representing the asset that failed to insert.
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
}
@ -163,8 +161,9 @@ export default function AssetRow(props: AssetRowProps) {
userInfo,
asset,
item.key,
toastAndLog,
/* should never change */ nodeMap,
/* should never change */ setAsset,
/* should never change */ toastAndLog,
/* should never change */ dispatchAssetListEvent,
]
)
@ -195,7 +194,7 @@ export default function AssetRow(props: AssetRowProps) {
asset.title
)
} catch (error) {
toastAndLog(`Could not move '${asset.title}'`, error)
toastAndLog('moveAssetError', error, asset.title)
setAsset(object.merger({ parentId: asset.parentId }))
setItem(oldItem =>
oldItem.with({ directoryKey: item.directoryKey, directoryId: item.directoryId })
@ -217,8 +216,8 @@ export default function AssetRow(props: AssetRowProps) {
item.directoryId,
item.directoryKey,
item.key,
toastAndLog,
/* should never change */ setAsset,
/* should never change */ toastAndLog,
/* should never change */ dispatchAssetListEvent,
]
)
@ -269,10 +268,7 @@ export default function AssetRow(props: AssetRowProps) {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
)
toastAndLog('deleteAssetError', error, asset.title)
}
},
[
@ -292,15 +288,9 @@ export default function AssetRow(props: AssetRowProps) {
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) {
setInsertionVisibility(Visibility.visible)
toastAndLog(`Unable to restore ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error)
toastAndLog('restoreAssetError', error, asset.title)
}
}, [
backend,
dispatchAssetListEvent,
asset,
/* should never change */ item.key,
/* should never change */ toastAndLog,
])
}, [backend, dispatchAssetListEvent, asset, toastAndLog, /* should never change */ item.key])
eventHooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
@ -369,12 +359,11 @@ export default function AssetRow(props: AssetRowProps) {
if (details.url != null) {
download.download(details.url, asset.title)
} else {
toastAndLog(
`Could not download project '${asset.title}': project has no source files`
)
const error: unknown = getText('projectHasNoSourceFilesPhrase')
toastAndLog('downloadProjectError', error, asset.title)
}
} catch (error) {
toastAndLog(`Could not download project '${asset.title}'`, error)
toastAndLog('downloadProjectError', error, asset.title)
}
break
}
@ -384,10 +373,11 @@ export default function AssetRow(props: AssetRowProps) {
if (details.url != null) {
download.download(details.url, asset.title)
} else {
toastAndLog(`Could not download file '${asset.title}': file not found`)
const error: unknown = getText('fileNotFoundPhrase')
toastAndLog('downloadFileError', error, asset.title)
}
} catch (error) {
toastAndLog(`Could not download file '${asset.title}'`, error)
toastAndLog('downloadFileError', error, asset.title)
}
break
}
@ -404,12 +394,12 @@ export default function AssetRow(props: AssetRowProps) {
fileName
)
} catch (error) {
toastAndLog(`Could not download Data Link '${asset.title}'`, error)
toastAndLog('downloadDataLinkError', error, asset.title)
}
break
}
default: {
toastAndLog('You can only download files and Data Links')
toastAndLog('downloadInvalidTypeError')
break
}
}
@ -782,7 +772,7 @@ export default function AssetRow(props: AssetRowProps) {
className={`flex h-row items-center rounded-full ${indent.indentClass(item.depth)}`}
>
<img src={BlankIcon} />
{EMPTY_DIRECTORY_PLACEHOLDER}
<span className="px-name-column-x placeholder">{getText('thisFolderIsEmpty')}</span>
</div>
</td>
</tr>

View File

@ -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 (
<div
className={`flex min-h-row items-center gap-icon-with-text rounded-default bg-frame px-button-x ${className}`}
@ -39,7 +42,9 @@ export default function AssetSummary(props: AssetSummaryProps) {
)}
</span>
{!isNew && (
<span>last modified on {dateTime.formatDateTime(new Date(asset.modifiedAt))}</span>
<span>
{getText('lastModifiedOn', dateTime.formatDateTime(new Date(asset.modifiedAt)))}
</span>
)}
<span>{asset.labels}</span>
</div>

View File

@ -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)
}
}
}

View File

@ -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) {
>
<SvgMask
src={FolderArrowIcon}
alt={item.children == null ? 'Expand' : 'Collapse'}
alt={item.children == null ? getText('expand') : getText('collapse')}
className={`m-name-column-icon hidden size-icon cursor-pointer transition-transform duration-arrow group-hover:inline-block ${
item.children != null ? 'rotate-90' : ''
}`}

View File

@ -8,9 +8,12 @@ import ShiftKeyIcon from 'enso-assets/shift_key.svg'
import WindowsKeyIcon from 'enso-assets/windows_key.svg'
import * as detect from 'enso-common/src/detect'
import type * as text from '#/text'
import type * as dashboardInputBindings from '#/configurations/inputBindings'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
@ -25,34 +28,44 @@ const ICON_SIZE_PX = 13
const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX }
/** Props for values of {@link MODIFIER_JSX}. */
interface InternalModifierProps {
readonly getText: ReturnType<typeof textProvider.useText>['getText']
}
/** Icons for modifier keys (if they exist). */
const MODIFIER_JSX: Readonly<
Record<detect.Platform, Partial<Record<inputBindingsModule.ModifierKey, React.ReactNode>>>
Record<
detect.Platform,
Partial<
Record<inputBindingsModule.ModifierKey, (props: InternalModifierProps) => React.ReactNode>
>
>
> = {
// The names are intentionally not in `camelCase`, as they are case-sensitive.
/* eslint-disable @typescript-eslint/naming-convention */
[detect.Platform.macOS]: {
Meta: <SvgMask style={ICON_STYLE} key="Meta" src={CommandKeyIcon} />,
Shift: <SvgMask style={ICON_STYLE} key="Shift" src={ShiftKeyIcon} />,
Alt: <SvgMask style={ICON_STYLE} key="Alt" src={OptionKeyIcon} />,
Ctrl: <SvgMask style={ICON_STYLE} key="Ctrl" src={CtrlKeyIcon} />,
Meta: () => <SvgMask style={ICON_STYLE} key="Meta" src={CommandKeyIcon} />,
Shift: () => <SvgMask style={ICON_STYLE} key="Shift" src={ShiftKeyIcon} />,
Alt: () => <SvgMask style={ICON_STYLE} key="Alt" src={OptionKeyIcon} />,
Ctrl: () => <SvgMask style={ICON_STYLE} key="Ctrl" src={CtrlKeyIcon} />,
},
[detect.Platform.windows]: {
Meta: <SvgMask style={ICON_STYLE} key="Meta" src={WindowsKeyIcon} />,
Meta: () => <SvgMask style={ICON_STYLE} key="Meta" src={WindowsKeyIcon} />,
},
[detect.Platform.linux]: {
Meta: (
Meta: props => (
<span key="Meta" className="text">
Super
{props.getText('superModifier')}
</span>
),
},
[detect.Platform.unknown]: {
// Assume the system is Unix-like and calls the key that triggers `event.metaKey`
// the "Super" key.
Meta: (
Meta: props => (
<span key="Meta" className="text">
Super
{props.getText('superModifier')}
</span>
),
},
@ -69,6 +82,16 @@ const KEY_CHARACTER: Readonly<Record<string, string>> = {
/* eslint-enable @typescript-eslint/naming-convention */
} satisfies Partial<Record<inputBindingsModule.Key, string>>
const MODIFIER_TO_TEXT_ID: Readonly<Record<inputBindingsModule.ModifierKey, text.TextId>> = {
// The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
Ctrl: 'ctrlModifier',
Alt: 'altModifier',
Meta: 'metaModifier',
Shift: 'shiftModifier',
/* eslint-enable @typescript-eslint/naming-convention */
} satisfies { [K in inputBindingsModule.ModifierKey]: `${Lowercase<K>}Modifier` }
/** Props for a {@link KeyboardShortcut}, specifying the keyboard action. */
export interface KeyboardShortcutActionProps {
readonly action: dashboardInputBindings.DashboardBindingKey
@ -84,6 +107,7 @@ export type KeyboardShortcutProps = KeyboardShortcutActionProps | KeyboardShortc
/** A visual representation of a keyboard shortcut. */
export default function KeyboardShortcut(props: KeyboardShortcutProps) {
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const shortcutString =
'shortcut' in props ? props.shortcut : inputBindings.metadata[props.action].bindings[0]
@ -102,9 +126,9 @@ export default function KeyboardShortcut(props: KeyboardShortcutProps) {
>
{modifiers.map(
modifier =>
MODIFIER_JSX[detect.platform()][modifier] ?? (
MODIFIER_JSX[detect.platform()][modifier]?.({ getText }) ?? (
<span key={modifier} className="text">
{modifier}
{getText(MODIFIER_TO_TEXT_ID[modifier])}
</span>
)
)}

View File

@ -1,6 +1,8 @@
/** @file A selector for all possible permissions. */
import * as React from 'react'
import * as textProvider from '#/providers/TextProvider'
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
import Modal from '#/components/Modal'
@ -53,6 +55,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
const { showDelete = false, disabled = false, input = false, typeSelectorYOffsetPx } = props
const { error, selfPermission, action: actionRaw, assetType, className } = props
const { onChange, doDelete } = props
const { getText } = textProvider.useText()
const [action, setActionRaw] = React.useState(actionRaw)
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]
@ -138,7 +141,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
} h-text grow rounded-l-full px-permission-mini-button-x py-permission-mini-button-y`}
onClick={doShowPermissionTypeSelector}
>
{permission.type}
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
</button>
<button
type="button"
@ -158,7 +161,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
)
}}
>
docs
{getText('docsPermissionModifier')}
</button>
<button
type="button"
@ -178,7 +181,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
)
}}
>
exec
{getText('execPermissionModifier')}
</button>
</div>
)
@ -195,7 +198,7 @@ export default function PermissionSelector(props: PermissionSelectorProps) {
} h-text w-permission-display rounded-full`}
onClick={doShowPermissionTypeSelector}
>
{permission.type}
{getText(permissionsModule.TYPE_TO_TEXT_ID[permission.type])}
</button>
)
break

View File

@ -115,6 +115,9 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
>
{data.type}
</div>
{/* This is a symbol that should never need to be localized, since it is effectively
* an icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<span className="text font-normal">=</span>
{data.previous != null && (
<>
@ -125,6 +128,9 @@ export default function PermissionTypeSelector(props: PermissionTypeSelectorProp
>
{data.previous}
</div>
{/* This is a symbol that should never need to be localized, since it is effectively
* an icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<span className="text font-normal">+</span>
</>
)}

View File

@ -13,6 +13,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -23,7 +24,6 @@ import SvgMask from '#/components/SvgMask'
import * as backendModule from '#/services/Backend'
import * as remoteBackend from '#/services/RemoteBackend'
import * as errorModule from '#/utilities/error'
import * as object from '#/utilities/object'
// =================
@ -85,6 +85,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
const { user } = authProvider.useNonPartialUserSession()
const { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const state = item.projectState.type
const setState = React.useCallback(
(stateOrUpdater: React.SetStateAction<backendModule.ProjectState>) => {
@ -174,9 +175,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
} catch (error) {
const project = await backend.getProjectDetails(item.id, item.title)
setItem(object.merger({ projectState: project.state }))
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ?? `Could not open project '${item.title}'`
)
toastAndLog('openProjectError', error, item.title)
setState(backendModule.ProjectState.closed)
}
},
@ -185,7 +184,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
backend,
item,
closeProjectAbortController,
/* should never change */ toastAndLog,
toastAndLog,
/* should never change */ setState,
/* should never change */ setItem,
]
@ -343,7 +342,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
doOpenManually(item.id)
}}
>
<SvgMask alt="Open in editor" src={PlayIcon} className="size-project-icon" />
<SvgMask alt={getText('openInEditor')} src={PlayIcon} className="size-project-icon" />
</button>
)
case backendModule.ProjectState.openInProgress:
@ -365,7 +364,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<Spinner size={ICON_SIZE_PX} state={spinnerState} />
</div>
<SvgMask
alt="Stop execution"
alt={getText('stopExecution')}
src={StopIcon}
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
/>
@ -388,7 +387,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
<Spinner className="size-project-icon" state={spinnerState} />
</div>
<SvgMask
alt="Stop execution"
alt={getText('stopExecution')}
src={StopIcon}
className={`size-project-icon ${isRunningInBackground ? 'text-green' : ''}`}
/>
@ -402,7 +401,11 @@ export default function ProjectIcon(props: ProjectIconProps) {
doOpenEditor(true)
}}
>
<SvgMask alt="Open in editor" src={ArrowUpIcon} className="size-project-icon" />
<SvgMask
alt={getText('openInEditor')}
src={ArrowUpIcon}
className="size-project-icon"
/>
</button>
)}
</div>

View File

@ -10,6 +10,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
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'
@ -46,6 +47,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const asset = item.item
if (asset.type !== backendModule.AssetType.project) {
@ -84,7 +86,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
asset.title
)
} catch (error) {
toastAndLog('Could not rename project', error)
toastAndLog('renameProjectError', error)
setAsset(object.merger({ title: oldTitle }))
}
}
@ -149,7 +151,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Error creating new project', error)
toastAndLog('createProjectError', error)
}
}
break
@ -184,24 +186,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
})
id = await response.text()
}
const listedProject = await backend.getProjectDetails(
backendModule.ProjectId(id),
null
)
const projectId = backendModule.ProjectId(id)
const listedProject = await backend.getProjectDetails(projectId, file.name)
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
title: listedProject.packageName,
id: backendModule.ProjectId(id),
})
)
setAsset(object.merge(asset, { title: listedProject.packageName, id: projectId }))
} else {
const createdFile = await backend.uploadFile(
{
fileId,
fileName: `${title}.${extension}`,
parentDirectoryId: asset.parentId,
},
{ fileId, fileName: `${title}.${extension}`, parentDirectoryId: asset.parentId },
file
)
const project = createdFile.project
@ -210,11 +201,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} else {
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
title,
id: project.projectId,
projectState: project.state,
})
object.merge(asset, { title, id: project.projectId, projectState: project.state })
)
return
}
@ -222,15 +209,12 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} catch (error) {
switch (event.type) {
case AssetEventType.uploadFiles: {
dispatchAssetListEvent({
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Could not upload project', error)
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
toastAndLog('uploadProjectError', error)
break
}
case AssetEventType.updateFiles: {
toastAndLog('Could not update project', error)
toastAndLog('updateProjectError', error)
break
}
}
@ -335,7 +319,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
{...(backend.type === backendModule.BackendType.local
? {
inputPattern: validation.LOCAL_PROJECT_NAME_PATTERN,
inputTitle: validation.LOCAL_PROJECT_NAME_TITLE,
inputTitle: getText('projectNameCannotBeEmpty'),
}
: {})}
>

View File

@ -82,7 +82,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
case AssetEventType.newSecret: {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Data connectors cannot be created on the local backend')
toastAndLog('localBackendSecretError')
} else {
rowState.setVisibility(Visibility.faded)
try {
@ -98,7 +98,7 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Error creating new data connector', error)
toastAndLog('createSecretError', error)
}
}
}

View File

@ -1,9 +1,12 @@
/** @file A user and their permissions for a specific asset. */
import * as React from 'react'
import type * as text from '#/text'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import PermissionSelector from '#/components/dashboard/PermissionSelector'
@ -11,6 +14,24 @@ import * as backendModule from '#/services/Backend'
import * as object from '#/utilities/object'
// =================
// === Constants ===
// =================
const ASSET_TYPE_TO_TEXT_ID: Readonly<Record<backendModule.AssetType, text.TextId>> = {
[backendModule.AssetType.directory]: 'directoryAssetType',
[backendModule.AssetType.project]: 'projectAssetType',
[backendModule.AssetType.file]: 'fileAssetType',
[backendModule.AssetType.secret]: 'secretAssetType',
[backendModule.AssetType.dataLink]: 'connectorAssetType',
[backendModule.AssetType.specialEmpty]: 'specialEmptyAssetType',
[backendModule.AssetType.specialLoading]: 'specialLoadingAssetType',
} satisfies { [Type in backendModule.AssetType]: `${Type}AssetType` }
// ======================
// === UserPermission ===
// ======================
/** Props for a {@link UserPermission}. */
export interface UserPermissionProps {
readonly asset: backendModule.Asset
@ -26,8 +47,10 @@ export default function UserPermission(props: UserPermissionProps) {
const { asset, self, isOnlyOwner, doDelete } = props
const { userPermission: initialUserPermission, setUserPermission: outerSetUserPermission } = props
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [userPermission, setUserPermission] = React.useState(initialUserPermission)
const assetTypeName = getText(ASSET_TYPE_TO_TEXT_ID[asset.type])
React.useEffect(() => {
setUserPermission(initialUserPermission)
@ -45,7 +68,7 @@ export default function UserPermission(props: UserPermissionProps) {
} catch (error) {
setUserPermission(userPermission)
outerSetUserPermission(userPermission)
toastAndLog(`Could not set permissions of '${newUserPermissions.user.user_email}'`, error)
toastAndLog('setPermissionsError', error, newUserPermissions.user.user_email)
}
}
@ -54,11 +77,7 @@ export default function UserPermission(props: UserPermissionProps) {
<PermissionSelector
showDelete
disabled={isOnlyOwner && userPermission.user.sk === self.user.sk}
error={
isOnlyOwner
? `This ${backendModule.ASSET_TYPE_NAME[asset.type]} must have at least one owner.`
: null
}
error={isOnlyOwner ? getText('needsOwnerError', assetTypeName) : null}
selfPermission={self.permission}
action={userPermission.permission}
assetType={asset.type}

View File

@ -8,6 +8,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Category from '#/layouts/CategorySwitcher/Category'
@ -39,6 +40,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
const session = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const self = asset.permissions?.find(
permission => permission.user.user_email === session.user?.email
@ -66,7 +68,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
<Label
key={label}
data-testid="asset-label"
title="Right click to remove label."
title={getText('rightClickToRemoveLabel')}
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
active={!temporarilyRemovedLabels.has(label)}
disabled={temporarilyRemovedLabels.has(label)}
@ -83,16 +85,18 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
unsetModal()
setAsset(oldAsset => {
const newLabels = oldAsset.labels?.filter(oldLabel => oldLabel !== label) ?? []
void backend.associateTag(asset.id, newLabels, asset.title).catch(error => {
toastAndLog(null, error)
setAsset(oldAsset2 =>
oldAsset2.labels?.some(oldLabel => oldLabel === label) === true
? oldAsset2
: object.merge(oldAsset2, {
labels: [...(oldAsset2.labels ?? []), label],
})
)
})
void backend
.associateTag(asset.id, newLabels, asset.title)
.catch((error: unknown) => {
toastAndLog(null, error)
setAsset(oldAsset2 =>
oldAsset2.labels?.some(oldLabel => oldLabel === label) === true
? oldAsset2
: object.merge(oldAsset2, {
labels: [...(oldAsset2.labels ?? []), label],
})
)
})
return object.merge(oldAsset, { labels: newLabels })
})
}

View File

@ -7,6 +7,8 @@ import PeopleIcon from 'enso-assets/people.svg'
import TagIcon from 'enso-assets/tag.svg'
import TimeIcon from 'enso-assets/time.svg'
import type * as text from '#/text'
import * as backend from '#/services/Backend'
// =============
@ -65,16 +67,15 @@ export const COLUMN_ICONS: Readonly<Record<Column, string>> = {
[Column.docs]: DocsIcon,
}
/** English names for every column except for the name column. */
export const COLUMN_NAME: Readonly<Record<Column, string>> = {
[Column.name]: 'Name',
[Column.modified]: 'Modified',
[Column.sharedWith]: 'Shared with',
[Column.labels]: 'Labels',
[Column.accessedByProjects]: 'Accessed by projects',
[Column.accessedData]: 'Accessed data',
[Column.docs]: 'Docs',
}
export const COLUMN_SHOW_TEXT_ID: Readonly<Record<Column, text.TextId>> = {
[Column.name]: 'nameColumnShow',
[Column.modified]: 'modifiedColumnShow',
[Column.sharedWith]: 'sharedWithColumnShow',
[Column.labels]: 'labelsColumnShow',
[Column.accessedByProjects]: 'accessedByProjectsColumnShow',
[Column.accessedData]: 'accessedDataColumnShow',
[Column.docs]: 'docsColumnShow',
} satisfies { [C in Column]: `${C}ColumnShow` }
const COLUMN_CSS_CLASSES =
'text-left bg-clip-padding border-transparent border-y border-2 last:border-r-0 last:rounded-r-full last:w-full'

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -11,21 +13,20 @@ import SvgMask from '#/components/SvgMask'
export default function AccessedByProjectsColumnHeading(props: column.AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask
src={AccessedByProjectsIcon}
className="size-icon"
title="Hide this column"
title={getText('accessedByProjectsColumnHide')}
onClick={event => {
event.stopPropagation()
hideColumn(columnUtils.Column.accessedByProjects)
}}
/>
<span className="text-header">
{columnUtils.COLUMN_NAME[columnUtils.Column.accessedByProjects]}
</span>
<span className="text-header">{getText('accessedByProjectsColumnName')}</span>
</div>
)
}

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import AccessedDataIcon from 'enso-assets/accessed_data.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -11,21 +13,20 @@ import SvgMask from '#/components/SvgMask'
export default function AccessedDataColumnHeading(props: column.AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask
src={AccessedDataIcon}
className="size-icon"
title="Hide this column"
title={getText('accessedDataColumnHide')}
onClick={event => {
event.stopPropagation()
hideColumn(columnUtils.Column.accessedData)
}}
/>
<span className="text-header">
{columnUtils.COLUMN_NAME[columnUtils.Column.accessedData]}
</span>
<span className="text-header">{getText('accessedDataColumnName')}</span>
</div>
)
}

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import DocsIcon from 'enso-assets/docs.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -11,19 +13,20 @@ import SvgMask from '#/components/SvgMask'
export default function DocsColumnHeading(props: column.AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask
src={DocsIcon}
className="size-icon"
title="Hide this column"
title={getText('docsColumnHide')}
onClick={event => {
event.stopPropagation()
hideColumn(columnUtils.Column.docs)
}}
/>
<span className="text-header">{columnUtils.COLUMN_NAME[columnUtils.Column.docs]}</span>
<span className="text-header">{getText('docsColumnName')}</span>
</div>
)
}

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import TagIcon from 'enso-assets/tag.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -11,19 +13,20 @@ import SvgMask from '#/components/SvgMask'
export default function LabelsColumnHeading(props: column.AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask
src={TagIcon}
className="size-icon"
title="Hide this column"
title={getText('labelsColumnHide')}
onClick={event => {
event.stopPropagation()
hideColumn(columnUtils.Column.labels)
}}
/>
<span className="text-header">{columnUtils.COLUMN_NAME[columnUtils.Column.labels]}</span>
<span className="text-header">{getText('labelsColumnName')}</span>
</div>
)
}

View File

@ -4,6 +4,8 @@ import * as React from 'react'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import TimeIcon from 'enso-assets/time.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -14,6 +16,7 @@ import * as sorting from '#/utilities/sorting'
export default function ModifiedColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
const { state } = props
const { sortInfo, setSortInfo, hideColumn } = state
const { getText } = textProvider.useText()
const isSortActive = sortInfo?.field === columnUtils.Column.modified
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
@ -21,10 +24,10 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
<button
title={
!isSortActive
? 'Sort by modification date'
? getText('sortByModificationDate')
: isDescending
? 'Stop sorting by modification date'
: 'Sort by modification date descending'
? getText('stopSortingByModificationDate')
: getText('sortByModificationDateDescending')
}
className="group flex h-drive-table-heading w-full cursor-pointer items-center gap-icon-with-text"
onClick={event => {
@ -42,15 +45,15 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
<SvgMask
src={TimeIcon}
className="size-icon"
title="Hide this column"
title={getText('modifiedColumnHide')}
onClick={event => {
event.stopPropagation()
hideColumn(columnUtils.Column.modified)
}}
/>
<span className="text-header">{columnUtils.COLUMN_NAME[columnUtils.Column.modified]}</span>
<span className="text-header">{getText('modifiedColumnName')}</span>
<img
alt={isDescending ? 'Sort Descending' : 'Sort Ascending'}
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={`transition-all duration-arrow ${
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
@ -12,6 +14,7 @@ import * as sorting from '#/utilities/sorting'
export default function NameColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
const { state } = props
const { sortInfo, setSortInfo } = state
const { getText } = textProvider.useText()
const isSortActive = sortInfo?.field === columnUtils.Column.name
const isDescending = sortInfo?.direction === sorting.SortDirection.descending
@ -19,10 +22,10 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
<button
title={
!isSortActive
? 'Sort by name'
? getText('sortByName')
: isDescending
? 'Stop sorting by name'
: 'Sort by name descending'
? getText('stopSortingByName')
: getText('sortByNameDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
@ -37,9 +40,9 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
}
}}
>
<span className="text-header">{columnUtils.COLUMN_NAME[columnUtils.Column.name]}</span>
<span className="text-header">{getText('nameColumnName')}</span>
<img
alt={isDescending ? 'Sort Descending' : 'Sort Ascending'}
alt={isDescending ? getText('sortDescending') : getText('sortAscending')}
src={SortAscendingIcon}
className={`transition-all duration-arrow ${
isSortActive ? 'selectable active' : 'transparent group-hover:selectable'

View File

@ -3,6 +3,8 @@ import * as React from 'react'
import PeopleIcon from 'enso-assets/people.svg'
import * as textProvider from '#/providers/TextProvider'
import type * as column from '#/components/dashboard/column'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
@ -11,19 +13,20 @@ import SvgMask from '#/components/SvgMask'
export default function SharedWithColumnHeading(props: column.AssetColumnHeadingProps) {
const { state } = props
const { hideColumn } = state
const { getText } = textProvider.useText()
return (
<div className="flex h-drive-table-heading w-full items-center gap-icon-with-text">
<SvgMask
src={PeopleIcon}
className="size-icon"
title="Hide this column"
title={getText('sharedWithColumnHide')}
onClick={event => {
event.stopPropagation()
hideColumn(columnUtils.Column.sharedWith)
}}
/>
<span className="text-header">{columnUtils.COLUMN_NAME[columnUtils.Column.sharedWith]}</span>
<span className="text-header">{getText('sharedWithColumnName')}</span>
</div>
)
}

View File

@ -38,7 +38,7 @@ export function useAsyncEffect<T>(
setValue(result)
}
} catch (error) {
toastAndLog('Error while fetching data', error)
toastAndLog('asyncHookError', error)
}
})()
/** Cancel any future `setValue` calls. */

View File

@ -3,7 +3,10 @@ import * as React from 'react'
import * as toastify from 'react-toastify'
import type * as text from '#/text'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as textProvider from '#/providers/TextProvider'
import * as errorModule from '#/utilities/error'
@ -14,13 +17,22 @@ import * as errorModule from '#/utilities/error'
/** Return a function to send a toast with rendered error message. The same message is also logged
* as an error. */
export function useToastAndLog() {
const { getText } = textProvider.useText()
const logger = loggerProvider.useLogger()
return React.useCallback(
<T>(
messagePrefix: string | null,
error?: errorModule.MustNotBeKnown<T>,
options?: toastify.ToastOptions
<K extends text.TextId, T>(
textId: K | null,
...[error, ...replacements]: text.Replacements[K] extends readonly []
? [error?: errorModule.MustNotBeKnown<T>]
: [error: errorModule.MustNotBeKnown<T> | null, ...replacements: text.Replacements[K]]
) => {
const messagePrefix =
textId == null
? null
: // This is SAFE, as `replacements` is only `[]` if it was already `[]`.
// See the above conditional type.
// eslint-disable-next-line no-restricted-syntax
getText(textId, ...(replacements as text.Replacements[K]))
const message =
error == null
? `${messagePrefix ?? ''}.`
@ -30,10 +42,10 @@ export function useToastAndLog() {
`${
messagePrefix != null ? messagePrefix + ': ' : ''
}${errorModule.getMessageOrToString<unknown>(error)}`
const id = toastify.toast.error(message, options)
const id = toastify.toast.error(message)
logger.error(message)
return id
},
[/* should never change */ logger]
[getText, /* should never change */ logger]
)
}

View File

@ -9,6 +9,7 @@ import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
@ -65,6 +66,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { user, accessToken } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const asset = item.item
const self = asset.permissions?.find(permission => permission.user.user_email === user?.email)
@ -106,7 +108,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
<ContextMenuEntry
hidden={hidden}
action="undelete"
label="Restore From Trash"
label={getText('restoreFromTrashShortcut')}
doAction={() => {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.restore, ids: new Set([asset.id]) })
@ -115,7 +117,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
<ContextMenuEntry
hidden={hidden}
action="delete"
label="Delete Forever"
label={getText('deleteForeverShortcut')}
doAction={() => {
setModal(
<ConfirmDeleteModal
@ -190,11 +192,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
doAction={async () => {
unsetModal()
if (accessToken == null) {
toastAndLog('Cannot upload to cloud in offline mode')
toastAndLog('offlineUploadFilesError')
} else {
try {
const client = new HttpClient([['Authorization', `Bearer ${accessToken}`]])
const remoteBackend = new RemoteBackend(client, logger)
const remoteBackend = new RemoteBackend(client, logger, getText)
const projectResponse = await fetch(
`./api/project-manager/projects/${asset.id}/enso-project`
)
@ -211,9 +213,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
},
await projectResponse.blob()
)
toast.toast.success('Successfully uploaded local project to cloud!')
toast.toast.success(getText('uploadProjectToCloudSuccess'))
} catch (error) {
toastAndLog('Could not upload local project to cloud', error)
toastAndLog('uploadProjectToCloudError', error)
}
}
}}
@ -268,7 +270,11 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
<ContextMenuEntry
hidden={hidden}
action="delete"
label={backend.type === backendModule.BackendType.local ? 'Delete' : 'Move To Trash'}
label={
backend.type === backendModule.BackendType.local
? getText('deleteShortcut')
: getText('moveToTrashShortcut')
}
doAction={() => {
if (backend.type === backendModule.BackendType.remote) {
unsetModal()
@ -276,7 +282,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
} else {
setModal(
<ConfirmDeleteModal
actionText={`delete the ${asset.type} '${asset.title}'`}
actionText={getText('deleteTheAssetTypeTitle', asset.type, asset.title)}
doDelete={doDelete}
/>
)

View File

@ -1,41 +1,40 @@
/**
* @file
*
* Diff view for 2 asset versions for a specific project
*/
/** @file Diff view comparing `Main.enso` of two versions for a specific project. */
import * as react from '@monaco-editor/react'
import * as textProvider from '#/providers/TextProvider'
import Spinner, * as spinnerModule from '#/components/Spinner'
import type * as backendService from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
import type Backend from '#/services/Backend'
import * as useFetchVersionContent from './useFetchVersionContent'
/**
* Props for the AssetDiffView component
*/
// =====================
// === AssetDiffView ===
// =====================
/** Props for an {@link AssetDiffView}. */
export interface AssetDiffViewProps {
readonly versionId: string
readonly latestVersionId: string
readonly projectId: backendService.ProjectId
readonly backend: RemoteBackend
readonly project: backendService.ProjectAsset
readonly backend: Backend
}
/**
* Diff view for asset versions
*/
/** Diff view comparing `Main.enso` of two versions for a specific project. */
export function AssetDiffView(props: AssetDiffViewProps) {
const { versionId, projectId, backend, latestVersionId } = props
const { versionId, project, backend, latestVersionId } = props
const { getText } = textProvider.useText()
const versionContent = useFetchVersionContent.useFetchVersionContent({
versionId,
projectId,
project,
backend,
})
const headContent = useFetchVersionContent.useFetchVersionContent({
versionId: latestVersionId,
projectId,
project,
backend,
})
@ -46,7 +45,7 @@ export function AssetDiffView(props: AssetDiffViewProps) {
)
if (versionContent.isError || headContent.isError) {
return <div className="p-indent-8 text-center">Failed to load content</div>
return <div className="p-indent-8 text-center">{getText('loadFileError')}</div>
} else if (versionContent.isPending || headContent.isPending) {
return loader
} else {
@ -57,7 +56,7 @@ export function AssetDiffView(props: AssetDiffViewProps) {
base: 'vs',
inherit: true,
rules: [],
// This comes from third-party code and we can't change it
// The name comes from a third-party API and cannot be changed.
// eslint-disable-next-line @typescript-eslint/naming-convention
colors: { 'editor.background': '#00000000' },
})

View File

@ -1,55 +1,44 @@
/**
* @file
*
* Fetches the content of a projects Main.enso file with specified version.
*/
/** @file Fetches the content of a projects Main.enso file with specified version. */
import * as reactQuery from '@tanstack/react-query'
import type * as backendService from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
import type Backend from '#/services/Backend'
/**
*
*/
export interface FetchVersionContentProps {
readonly projectId: backendService.ProjectId
readonly versionId: string
readonly backend: RemoteBackend
readonly omitMetadataFromContent?: boolean
}
// =================
// === Constants ===
// =================
const MS_IN_SECOND = 1000
const HUNDRED = 100
const HUNDRED_SECONDS = HUNDRED * MS_IN_SECOND
/**
* Fetches the content of a version.
*/
export function useFetchVersionContent(params: FetchVersionContentProps) {
const { versionId, backend, projectId, omitMetadataFromContent = true } = params
// ==============================
// === useFetchVersionContent ===
// ==============================
/** Options for {@link useFetchVersionContent}. */
export interface FetchVersionContentOptions {
readonly project: backendService.ProjectAsset
readonly versionId: string
readonly backend: Backend
/** If `false`, the metadata is stripped out. Defaults to `false`. */
readonly metadata?: boolean
}
/** Fetch the content of a version. */
export function useFetchVersionContent(params: FetchVersionContentOptions) {
const { versionId, backend, project, metadata = false } = params
return reactQuery.useQuery({
queryKey: ['versionContent', versionId],
queryFn: () => backend.getFileContent(projectId, versionId),
select: data => (omitMetadataFromContent ? omitMetadata(data) : data),
queryFn: () => backend.getFileContent(project.id, versionId, project.title),
select: data => (metadata ? data : omitMetadata(data)),
staleTime: HUNDRED_SECONDS,
})
}
/**
* Removes the metadata from the content of a version.
*/
/** Remove the metadata from the content of a version. */
function omitMetadata(file: string): string {
let [withoutMetadata] = file.split('#### METADATA ####')
if (withoutMetadata == null) {
return file
} else {
while (withoutMetadata[withoutMetadata.length - 1] === '\n') {
withoutMetadata = withoutMetadata.slice(0, -1)
}
return withoutMetadata
}
return file.split('#### METADATA ####')[0]?.replace(/\n+$/, '') ?? file
}

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
@ -64,6 +65,7 @@ export interface AssetPanelProps extends AssetPanelRequiredProps {
export default function AssetPanel(props: AssetPanelProps) {
const { item, setItem, setQuery, category, labels, dispatchAssetEvent } = props
const { getText } = textProvider.useText()
const { localStorage } = localStorageProvider.useLocalStorage()
const [initialized, setInitialized] = React.useState(false)
const [tab, setTab] = React.useState(() => {
@ -117,7 +119,7 @@ export default function AssetPanel(props: AssetPanelProps) {
)
}}
>
Versions
{getText('versions')}
</button>
)}
{/* Spacing. The top right asset and user bars overlap this area. */}
@ -125,7 +127,7 @@ export default function AssetPanel(props: AssetPanelProps) {
</div>
{item == null || setItem == null ? (
<div className="grid grow place-items-center text-lg">
Select exactly one asset to view its details.
{getText('selectExactlyOneAssetToViewItsDetails')}
</div>
) : (
<>

View File

@ -7,6 +7,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
@ -45,6 +46,10 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const { item: itemRaw, setItem: setItemRaw, category, labels, setQuery } = props
const { dispatchAssetEvent } = props
const { user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [item, setItemInner] = React.useState(itemRaw)
const [isEditingDescription, setIsEditingDescription] = React.useState(false)
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
@ -58,9 +63,6 @@ export default function AssetProperties(props: AssetPropertiesProps) {
() => validateDataLink.validateDataLink(dataLinkValue),
[dataLinkValue]
)
const { user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const setItem = React.useCallback(
(valueOrUpdater: React.SetStateAction<AssetTreeNode>) => {
setItemInner(valueOrUpdater)
@ -104,7 +106,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
item.item.title
)
} catch (error) {
toastAndLog('Could not edit asset description')
toastAndLog('editDescriptionError')
setItem(oldItem =>
oldItem.with({
item: object.merge(oldItem.item, { description: oldDescription }),
@ -118,7 +120,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
<>
<div className="flex flex-col items-start gap-side-panel">
<span className="flex h-side-panel-heading items-center gap-side-panel-section py-side-panel-heading-y text-lg leading-snug">
Description
{getText('description')}
{ownsThisAsset && !isEditingDescription && (
<Button
image={PenIcon}
@ -168,7 +170,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
/>
<div className="flex gap-buttons">
<button type="submit" className="button self-start bg-selected-frame">
Update
{getText('update')}
</button>
</div>
</form>
@ -177,13 +179,13 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</div>
<div className="flex flex-col items-start gap-side-panel-section">
<h2 className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug">
Settings
{getText('settings')}
</h2>
<table>
<tbody>
<tr data-testid="asset-panel-permissions" className="h-row">
<td className="text my-auto min-w-side-panel-label p">
<span className="text inline-block">Shared with</span>
<span className="text inline-block">{getText('sharedWith')}</span>
</td>
<td className="w-full p">
<SharedWithColumn
@ -195,7 +197,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
</tr>
<tr data-testid="asset-panel-labels" className="h-row">
<td className="text my-auto min-w-side-panel-label p">
<span className="text inline-block">Labels</span>
<span className="text inline-block">{getText('labels')}</span>
</td>
<td className="w-full p">
{item.item.labels?.map(value => {
@ -214,7 +216,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
{isDataLink && (
<div className="flex flex-col items-start gap-side-panel-section">
<h2 className="h-side-panel-heading py-side-panel-heading-y text-lg leading-snug">
Data Link
{getText('dataLink')}
</h2>
{!isDataLinkFetched ? (
<div className="grid place-items-center self-stretch">
@ -258,7 +260,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
})()
}}
>
Update
{getText('update')}
</button>
<button
type="button"
@ -268,7 +270,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
setEditedDataLinkValue(dataLinkValue)
}}
>
Cancel
{getText('cancel')}
</button>
</div>
)}

View File

@ -5,6 +5,7 @@ import FindIcon from 'enso-assets/find.svg'
import * as detect from 'enso-common/src/detect'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Label from '#/components/dashboard/Label'
@ -55,6 +56,7 @@ export interface AssetSearchBarProps {
/** A search bar containing a text input, and a list of suggestions. */
export default function AssetSearchBar(props: AssetSearchBarProps) {
const { isCloud, query, setQuery, labels, suggestions: rawSuggestions } = props
const { getText } = textProvider.useText()
const { modalRef } = modalProvider.useModalRef()
/** A cached query as of the start of tabbing. */
const baseQuery = React.useRef(query)
@ -234,8 +236,8 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
size={1}
placeholder={
isCloud
? 'Type to search for projects, Data Links, users, and more.'
: 'Type to search for projects.'
? getText('remoteBackendSearchPlaceholder')
: getText('localBackendSearchPlaceholder')
}
className="peer text relative z-1 grow bg-transparent placeholder:text-center"
onChange={event => {
@ -279,7 +281,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
setQuery(query.add({ [key]: [[]] }))
}}
>
{tag}:
{`${tag}:`}
</button>,
]
})}

View File

@ -1,10 +1,12 @@
/** @file Displays information describing a specific version of an asset. */
import Duplicate from 'enso-assets/duplicate.svg'
import * as textProvider from '#/providers/TextProvider'
import * as ariaComponents from '#/components/AriaComponents'
import type Backend from '#/services/Backend'
import * as backendService from '#/services/Backend'
import type RemoteBackend from '#/services/RemoteBackend'
import * as dateTime from '#/utilities/dateTime'
@ -20,25 +22,25 @@ export interface AssetVersionProps {
readonly number: number
readonly version: backendService.S3ObjectVersion
readonly latestVersion: backendService.S3ObjectVersion
readonly backend: RemoteBackend
readonly backend: Backend
}
/** Displays information describing a specific version of an asset. */
export default function AssetVersion(props: AssetVersionProps) {
const { number, version, item, backend, latestVersion } = props
const { getText } = textProvider.useText()
const isProject = item.type === backendService.AssetType.project
const versionName = `Version ${number}`
return (
<div className="flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2">
<div className="flex flex-1 flex-col">
<div>
{versionName} {version.isLatest && `(Latest)`}
{getText('versionX', number)} {version.isLatest && getText('latestIndicator')}
</div>
<time className="text-xs text-not-selected">
on {dateTime.formatDateTime(new Date(version.lastModified))}
{getText('onDateX', dateTime.formatDateTime(new Date(version.lastModified)))}
</time>
</div>
@ -48,19 +50,20 @@ export default function AssetVersion(props: AssetVersionProps) {
<ariaComponents.TooltipTrigger>
<ariaComponents.Button
variant="icon"
aria-label="Compare with latest"
aria-label={getText('compareWithLatest')}
icon={Duplicate}
isDisabled={version.isLatest}
/>
<ariaComponents.Tooltip>Compare with latest</ariaComponents.Tooltip>
<ariaComponents.Tooltip>{getText('compareWithLatest')}</ariaComponents.Tooltip>
</ariaComponents.TooltipTrigger>
<ariaComponents.Dialog type="fullscreen" title={`Compare ${versionName} with latest`}>
<ariaComponents.Dialog
type="fullscreen"
title={getText('compareVersionXWithLatest', number)}
>
<assetDiffView.AssetDiffView
latestVersionId={latestVersion.versionId}
versionId={version.versionId}
projectId={item.id}
project={item}
backend={backend}
/>
</ariaComponents.Dialog>

View File

@ -4,18 +4,18 @@ import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AssetVersion from '#/layouts/AssetVersion'
import * as useAssetVersions from '#/layouts/AssetVersions/useAssetVersions'
import Spinner from '#/components/Spinner'
import * as spinnerModule from '#/components/Spinner'
import RemoteBackend from '#/services/RemoteBackend'
import * as backendService from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import * as assetVersions from './useAssetVersions'
// =====================
// === AssetVersions ===
// =====================
@ -28,23 +28,21 @@ export interface AssetVersionsProps {
/** A list of previous versions of an asset. */
export default function AssetVersions(props: AssetVersionsProps) {
const { item } = props
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const isRemote = backend instanceof RemoteBackend
const isCloud = backend.type === backendService.BackendType.remote
const {
status,
error,
data: versions,
isPending,
} = assetVersions.useAssetVersions({
} = useAssetVersions.useAssetVersions({
backend,
assetId: item.item.id,
title: item.item.title,
onError: backendError => toastAndLog('Could not list versions', backendError),
enabled: isRemote,
onError: backendError => toastAndLog('listVersionsError', backendError),
enabled: isCloud,
})
const latestVersion = versions?.find(version => version.isLatest)
@ -52,16 +50,16 @@ export default function AssetVersions(props: AssetVersionsProps) {
return (
<div className="flex flex-1 shrink-0 flex-col items-center overflow-y-auto overflow-x-hidden">
{(() => {
if (!isRemote) {
return <div>Local assets do not have versions</div>
if (!isCloud) {
return <div>{getText('localAssetsDoNotHaveVersions')}</div>
} else if (isPending) {
return <Spinner size={32} state={spinnerModule.SpinnerState.loadingMedium} />
} else if (status === 'error') {
return <div>Error: {error.message}</div>
return <div>{getText('listVersionsError')}</div>
} else if (versions.length === 0) {
return <div>No versions found</div>
return <div>{getText('noVersionsFound')}</div>
} else if (!latestVersion) {
return <div>Could not fetch the latest version of the file</div>
return <div>{getText('fetchLatestVersionError')}</div>
} else {
return versions.map((version, i) => (
<AssetVersion

View File

@ -12,6 +12,7 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -103,19 +104,6 @@ const LOADING_SPINNER_SIZE_PX = 36
const COLUMNS_SELECTOR_BASE_WIDTH_PX = 4
/** The number of pixels the header bar should shrink per collapsed column. */
const COLUMNS_SELECTOR_ICON_WIDTH_PX = 28
/** The default placeholder row. */
const PLACEHOLDER = (
<span className="px-cell-x placeholder">
You have no files. Go ahead and create one using the buttons above, or open a template from the
home screen.
</span>
)
/** A placeholder row for when a query (text or labels) is active. */
const QUERY_PLACEHOLDER = (
<span className="px-cell-x placeholder">No files match the current filters.</span>
)
/** The placeholder row for the Trash category. */
const TRASH_PLACEHOLDER = <span className="px-cell-x placeholder">Your trash is empty.</span>
const SUGGESTIONS_FOR_NO: assetSearchBar.Suggestion[] = [
{
@ -385,6 +373,7 @@ export default function AssetsTable(props: AssetsTableProps) {
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [initialized, setInitialized] = React.useState(false)
@ -415,12 +404,6 @@ export default function AssetsTable(props: AssetsTableProps) {
)
})
const isCloud = backend.type === backendModule.BackendType.remote
const placeholder =
category === Category.trash
? TRASH_PLACEHOLDER
: query.query !== ''
? QUERY_PLACEHOLDER
: PLACEHOLDER
/** Events sent when the asset list was still loading. */
const queuedAssetListEventsRef = React.useRef<assetListEvent.AssetListEvent[]>([])
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
@ -849,7 +832,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})
})
} else if (initialProjectName != null) {
toastAndLog(`Could not find project '${initialProjectName}'`)
toastAndLog('findProjectError', null, initialProjectName)
}
}
// This effect MUST only run when `initialProjectName` is changed.
@ -923,7 +906,7 @@ export default function AssetsTable(props: AssetsTableProps) {
})
})
} else {
toastAndLog(`Could not find project '${oldNameOfProjectToImmediatelyOpen}'`)
toastAndLog('findProjectError', null, oldNameOfProjectToImmediatelyOpen)
}
}
setQueuedAssetEvents(oldQueuedAssetEvents => {
@ -941,9 +924,9 @@ export default function AssetsTable(props: AssetsTableProps) {
},
[
rootDirectoryId,
toastAndLog,
/* should never change */ setNameOfProjectToImmediatelyOpen,
/* should never change */ dispatchAssetEvent,
/* should never change */ toastAndLog,
]
)
@ -968,7 +951,9 @@ export default function AssetsTable(props: AssetsTableProps) {
recentProjects: category === Category.recent,
labels: null,
},
null
// The root directory has no name. This is also SAFE, as there is a different error
// message when the directory is the root directory (when `parentId == null`).
'(root)'
)
if (!signal.aborted) {
setIsLoading(false)
@ -1027,7 +1012,7 @@ export default function AssetsTable(props: AssetsTableProps) {
return newTree
})
},
error => {
(error: unknown) => {
toastAndLog(null, error)
}
)
@ -1041,7 +1026,9 @@ export default function AssetsTable(props: AssetsTableProps) {
recentProjects: category === Category.recent,
labels: null,
},
null
// The root directory has no name. This is also SAFE, as there is a different error
// message when the directory is the root directory (when `parentId == null`).
'(root)'
)
if (!signal.aborted) {
setIsLoading(false)
@ -1176,7 +1163,7 @@ export default function AssetsTable(props: AssetsTableProps) {
recentProjects: category === Category.recent,
labels: null,
},
title ?? null
title ?? nodeMapRef.current.get(key)?.item.title ?? '(unknown)'
)
if (!abortController.signal.aborted) {
setAssetTree(oldAssetTree =>
@ -1604,12 +1591,8 @@ export default function AssetsTable(props: AssetsTableProps) {
dispatchAssetListEvent={dispatchAssetListEvent}
siblingFileNames={siblingFilesByName.keys()}
siblingProjectNames={siblingProjectsByName.keys()}
nonConflictingCount={
files.length +
projects.length -
conflictingFiles.length -
conflictingProjects.length
}
nonConflictingFileCount={files.length - conflictingFiles.length}
nonConflictingProjectCount={projects.length - conflictingProjects.length}
doUploadNonConflicting={() => {
doToggleDirectoryExpansion(event.parentId, event.parentKey, null, true)
const fileMap = new Map<backendModule.AssetId, File>()
@ -1732,7 +1715,7 @@ export default function AssetsTable(props: AssetsTableProps) {
}
case AssetListEventType.emptyTrash: {
if (category !== Category.trash) {
toastAndLog('Can only empty trash when in Trash')
toastAndLog('canOnlyEmptyTrashWhenInTrash')
} else if (assetTree.children != null) {
const ids = new Set(assetTree.children.map(child => child.item.id))
// This is required to prevent an infinite loop,
@ -2408,7 +2391,15 @@ export default function AssetsTable(props: AssetsTableProps) {
{itemRows}
<tr className="hidden h-row first:table-row">
<td colSpan={columns.length} className="bg-transparent">
{placeholder}
{category === Category.trash ? (
<span className="px-cell-x placeholder">{getText('yourTrashIsEmpty')}</span>
) : query.query !== '' ? (
<span className="px-cell-x placeholder">
{getText('noFilesMatchTheCurrentFilters')}
</span>
) : (
<span className="px-cell-x placeholder">{getText('youHaveNoFiles')}</span>
)}
</td>
</tr>
</tbody>
@ -2473,9 +2464,7 @@ export default function AssetsTable(props: AssetsTableProps) {
key={column}
active
image={columnUtils.COLUMN_ICONS[column]}
alt={`${enabledColumns.has(column) ? 'Show' : 'Hide'} ${
columnUtils.COLUMN_NAME[column]
}`}
alt={getText(columnUtils.COLUMN_SHOW_TEXT_ID[column])}
onClick={event => {
event.stopPropagation()
const newExtraColumns = new Set(enabledColumns)

View File

@ -5,6 +5,7 @@ import * as React from 'react'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -24,21 +25,12 @@ import * as backendModule from '#/services/Backend'
import type AssetTreeNode from '#/utilities/AssetTreeNode'
import type * as pasteDataModule from '#/utilities/pasteData'
import * as permissions from '#/utilities/permissions'
import * as string from '#/utilities/string'
import * as uniqueString from '#/utilities/uniqueString'
// =================
// === Constants ===
// =================
// This is a function, even though does not look like one.
// eslint-disable-next-line no-restricted-syntax
const pluralize = string.makePluralize('item', 'items')
// ==============================
// === AssetsTableContextMenu ===
// ==============================
/** Props for an {@link AssetsTableContextMenu}. */
export interface AssetsTableContextMenuProps {
readonly hidden?: boolean
@ -67,13 +59,13 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
const { backend } = backendProvider.useBackend()
const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const rootDirectoryId = React.useMemo(
() => user?.rootDirectoryId ?? backendModule.DirectoryId(''),
[user]
)
const isCloud = backend.type === backendModule.BackendType.remote
const pluralized = pluralize(selectedKeys.size)
// This works because all items are mutated, ensuring their value stays
// up to date.
const ownsAllSelectedAssets =
@ -96,7 +88,11 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
} else {
setModal(
<ConfirmDeleteModal
actionText={`delete ${selectedKeys.size} selected ${pluralized}`}
actionText={
selectedKeys.size === 1
? getText('deleteSelectedAssetActionText')
: getText('deleteSelectedAssetsActionText', selectedKeys.size)
}
doDelete={() => {
clearSelectedKeys()
dispatchAssetEvent({ type: AssetEventType.delete, ids: selectedKeys })
@ -115,7 +111,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
<ContextMenuEntry
hidden={hidden}
action="undelete"
label="Restore All From Trash"
label={getText('restoreAllFromTrashShortcut')}
doAction={() => {
unsetModal()
dispatchAssetEvent({ type: AssetEventType.restore, ids: selectedKeys })
@ -125,11 +121,15 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
<ContextMenuEntry
hidden={hidden}
action="delete"
label="Delete All Forever"
label={getText('deleteAllForeverShortcut')}
doAction={() => {
setModal(
<ConfirmDeleteModal
actionText={`delete ${selectedKeys.size} selected ${pluralized} forever`}
actionText={
selectedKeys.size === 1
? getText('deleteSelectedAssetForeverActionText')
: getText('deleteSelectedAssetsForeverActionText', selectedKeys.size)
}
doDelete={() => {
clearSelectedKeys()
dispatchAssetEvent({ type: AssetEventType.deleteForever, ids: selectedKeys })
@ -153,21 +153,31 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
<ContextMenuEntry
hidden={hidden}
action="delete"
label={isCloud ? 'Move All To Trash' : 'Delete All'}
label={isCloud ? getText('moveAllToTrashShortcut') : getText('deleteAllShortcut')}
doAction={doDeleteAll}
/>
)}
{isCloud && (
<ContextMenuEntry hidden={hidden} action="copy" label="Copy All" doAction={doCopy} />
<ContextMenuEntry
hidden={hidden}
action="copy"
label={getText('copyAllShortcut')}
doAction={doCopy}
/>
)}
{isCloud && ownsAllSelectedAssets && (
<ContextMenuEntry hidden={hidden} action="cut" label="Cut All" doAction={doCut} />
<ContextMenuEntry
hidden={hidden}
action="cut"
label={getText('cutAllShortcut')}
doAction={doCut}
/>
)}
{pasteData != null && pasteData.data.size > 0 && (
<ContextMenuEntry
hidden={hidden}
action="paste"
label="Paste All"
label={getText('pasteAllShortcut')}
doAction={() => {
const [firstKey] = selectedKeys
const selectedNode =

View File

@ -5,6 +5,7 @@ import CloudIcon from 'enso-assets/cloud.svg'
import NotCloudIcon from 'enso-assets/not_cloud.svg'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
@ -23,6 +24,7 @@ export interface BackendSwitcherProps {
export default function BackendSwitcher(props: BackendSwitcherProps) {
const { setBackendType } = props
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const isCloud = backend.type === backendModule.BackendType.remote
return (
@ -36,7 +38,7 @@ export default function BackendSwitcher(props: BackendSwitcherProps) {
>
<div className="flex items-center gap-icon-with-text">
<SvgMask src={CloudIcon} />
<span className="text">Cloud</span>
<span className="text">{getText('cloud')}</span>
</div>
</button>
<button
@ -48,7 +50,7 @@ export default function BackendSwitcher(props: BackendSwitcherProps) {
>
<div className="flex items-center gap-icon-with-text">
<SvgMask src={NotCloudIcon} />
<span className="text">Local</span>
<span className="text">{getText('local')}</span>
</div>
</button>
</div>

View File

@ -5,7 +5,10 @@ import Home2Icon from 'enso-assets/home2.svg'
import RecentIcon from 'enso-assets/recent.svg'
import Trash2Icon from 'enso-assets/trash2.svg'
import type * as text from '#/text'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -28,6 +31,12 @@ const CATEGORY_ICONS: Readonly<Record<Category, string>> = {
[Category.trash]: Trash2Icon,
}
const CATEGORY_TO_TEXT_ID: Readonly<Record<Category, text.TextId>> = {
[Category.recent]: 'recentCategory',
[Category.home]: 'homeCategory',
[Category.trash]: 'trashCategory',
} satisfies { [C in Category]: `${C}Category` }
// ============================
// === CategorySwitcherItem ===
// ============================
@ -45,6 +54,7 @@ interface InternalCategorySwitcherItemProps {
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
const { category, isCurrent, onClick } = props
const { onDragOver, onDrop } = props
const { getText } = textProvider.useText()
return (
<button
@ -69,7 +79,7 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
category === Category.recent ? '-ml-0.5' : ''
}`}
/>
<span>{category}</span>
<span>{getText(CATEGORY_TO_TEXT_ID[category])}</span>
</button>
)
}
@ -89,10 +99,13 @@ export interface CategorySwitcherProps {
export default function CategorySwitcher(props: CategorySwitcherProps) {
const { category, setCategory, dispatchAssetEvent } = props
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
return (
<div className="flex w-full flex-col gap-sidebar-section-heading">
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">Category</div>
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
{getText('category')}
</div>
<div className="flex flex-col items-start">
{CATEGORIES.map(currentCategory => (
<CategorySwitcherItem

View File

@ -6,9 +6,9 @@
/** The categories available in the category switcher. */
enum Category {
recent = 'Recent',
home = 'Home',
trash = 'Trash',
recent = 'recent',
home = 'home',
trash = 'trash',
}
// This is REQUIRED, as `export default enum` is invalid syntax.

View File

@ -2,7 +2,6 @@
import * as React from 'react'
import * as reactDom from 'react-dom'
import * as toastify from 'react-toastify'
import CloseLargeIcon from 'enso-assets/close_large.svg'
import DefaultUserIcon from 'enso-assets/default_user.svg'
@ -10,9 +9,11 @@ import FolderArrowIcon from 'enso-assets/folder_arrow.svg'
import * as chat from 'enso-chat/chat'
import * as gtagHooks from '#/hooks/gtagHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
import Twemoji from '#/components/Twemoji'
@ -376,7 +377,9 @@ export interface ChatProps {
export default function Chat(props: ChatProps) {
const { isOpen, doClose, endpoint } = props
const { accessToken: rawAccessToken } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const logger = loggerProvider.useLogger()
const toastAndLog = toastAndLogHooks.useToastAndLog()
/** This is SAFE, because this component is only rendered when `accessToken` is present.
* See `dashboard.tsx` for its sole usage. */
@ -577,9 +580,7 @@ export default function Chat(props: ChatProps) {
(newThreadId: chat.ThreadId) => {
const threadData = threads.find(thread => thread.id === newThreadId)
if (threadData == null) {
const message = `Unknown thread id '${newThreadId}'.`
toastify.toast.error(message)
logger.error(message)
toastAndLog('unknownThreadIdError', null, newThreadId)
} else {
sendMessage({
type: chat.ChatMessageDataType.switchThread,
@ -587,7 +588,7 @@ export default function Chat(props: ChatProps) {
})
}
},
[threads, /* should never change */ sendMessage, /* should never change */ logger]
[threads, toastAndLog, /* should never change */ sendMessage]
)
const sendCurrentMessage = React.useCallback(
@ -606,7 +607,7 @@ export default function Chat(props: ChatProps) {
id: MessageId(String(Number(new Date()))),
isStaffMessage: false,
avatar: null,
name: 'Me',
name: getText('me'),
content,
reactions: [],
timestamp: Number(new Date()),
@ -643,6 +644,7 @@ export default function Chat(props: ChatProps) {
threadId,
threadTitle,
shouldIgnoreMessageLimit,
getText,
/* should never change */ sendMessage,
]
)
@ -747,7 +749,7 @@ export default function Chat(props: ChatProps) {
ref={messageInputRef}
rows={1}
required
placeholder="Type your message ..."
placeholder={getText('chatInputPlaceholder')}
className="w-full resize-none rounded-chat-input bg-transparent p-chat-input"
onKeyDown={event => {
switch (event.key) {
@ -784,14 +786,14 @@ export default function Chat(props: ChatProps) {
sendCurrentMessage(event, true)
}}
>
New question? Click to start a new thread!
{getText('clickForNewQuestion')}
</button>
<button
type="submit"
disabled={!isReplyEnabled}
className="rounded-full bg-blue-600/90 px-chat-button-x py-chat-button-y text-white selectable enabled:active"
>
Reply!
{getText('replyExclamation')}
</button>
</div>
</form>
@ -802,7 +804,7 @@ export default function Chat(props: ChatProps) {
className="mx-2 my-1 rounded-default bg-call-to-action/90 p-2 text-center leading-cozy text-white"
onClick={upgradeToPro}
>
Click here to upgrade to Enso Pro and get access to high-priority, live support!
{getText('upgradeToProNag')}
</button>
)}
</div>,

View File

@ -10,6 +10,7 @@ import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as textProvider from '#/providers/TextProvider'
import * as chat from '#/layouts/Chat'
@ -23,6 +24,7 @@ export interface ChatPlaceholderProps {
/** A placeholder component replacing `Chat` when a user is not logged in. */
export default function ChatPlaceholder(props: ChatPlaceholderProps) {
const { isOpen, doClose } = props
const { getText } = textProvider.useText()
const logger = loggerProvider.useLogger()
const navigate = navigateHooks.useNavigate()
@ -44,10 +46,8 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
</div>
<div className="grid grow place-items-center">
<div className="flex flex-col gap-status-page text-center text-base">
<div>
Login or register to access live chat
<br />
with our support team.
<div className="px-missing-functionality-text-x">
{getText('placeholderChatPrompt')}
</div>
<button
className="button self-center bg-help text-white"
@ -55,7 +55,7 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
navigate(appUtils.LOGIN_PATH)
}}
>
Login
{getText('login')}
</button>
<button
className="button self-center bg-help text-white"
@ -63,7 +63,7 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
navigate(appUtils.REGISTRATION_PATH)
}}
>
Register
{getText('register')}
</button>
</div>
</div>

View File

@ -1,8 +1,6 @@
/** @file The directory header bar and directory item listing. */
import * as React from 'react'
import * as common from 'enso-common'
import * as appUtils from '#/appUtils'
import * as eventCallback from '#/hooks/eventCallbackHooks'
@ -13,6 +11,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -120,6 +119,7 @@ export default function Drive(props: DriveProps) {
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const [canDownload, setCanDownload] = React.useState(false)
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
const [category, setCategory] = searchParamsState.useSearchParamsState(
@ -180,9 +180,9 @@ export default function Drive(props: DriveProps) {
const doUploadFiles = React.useCallback(
(files: File[]) => {
if (backend.type !== backendModule.BackendType.local && user == null) {
if (isCloud && sessionType === authProvider.UserSessionType.offline) {
// This should never happen, however display a nice error message in case it does.
toastAndLog('Files cannot be uploaded while offline')
toastAndLog('offlineUploadFilesError')
} else {
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
@ -192,7 +192,13 @@ export default function Drive(props: DriveProps) {
})
}
},
[backend, user, rootDirectoryId, toastAndLog, /* should never change */ dispatchAssetListEvent]
[
isCloud,
rootDirectoryId,
sessionType,
toastAndLog,
/* should never change */ dispatchAssetListEvent,
]
)
const doEmptyTrash = React.useCallback(() => {
@ -248,7 +254,7 @@ export default function Drive(props: DriveProps) {
labelNames => new Set([...labelNames].filter(labelName => labelName !== newLabelName))
)
},
[backend, /* should never change */ toastAndLog, /* should never change */ setLabels]
[backend, toastAndLog, /* should never change */ setLabels]
)
const doDeleteLabel = React.useCallback(
@ -271,9 +277,9 @@ export default function Drive(props: DriveProps) {
},
[
backend,
toastAndLog,
/* should never change */ setQuery,
/* should never change */ dispatchAssetEvent,
/* should never change */ toastAndLog,
/* should never change */ setLabels,
]
)
@ -309,14 +315,14 @@ export default function Drive(props: DriveProps) {
return (
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
<div className="flex flex-col gap-status-page text-center text-base">
<div>You are not logged in.</div>
<div>{getText('youAreNotLoggedIn')}</div>
<button
className="button self-center bg-help text-white"
onClick={() => {
navigate(appUtils.LOGIN_PATH)
}}
>
Login
{getText('login')}
</button>
</div>
</div>
@ -326,8 +332,7 @@ export default function Drive(props: DriveProps) {
return (
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
<div className="flex flex-col gap-status-page text-center text-base">
Could not connect to the Project Manager. Please try restarting {common.PRODUCT_NAME},
or manually launching the Project Manager.
{getText('couldNotConnectToPM')}
</div>
</div>
)
@ -336,9 +341,9 @@ export default function Drive(props: DriveProps) {
return (
<div className={`grid grow place-items-center ${hidden ? 'hidden' : ''}`}>
<div className="flex flex-col gap-status-page text-center text-base">
Upgrade your plan to use {common.PRODUCT_NAME} Cloud.
{getText('upgradeToUseCloud')}
<a className="button self-center bg-help text-white" href="https://enso.org/pricing">
Upgrade
{getText('upgrade')}
</a>
{!supportsLocalBackend && (
<button
@ -346,13 +351,13 @@ export default function Drive(props: DriveProps) {
onClick={async () => {
const downloadUrl = await github.getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('Could not find a download link for the current OS')
toastAndLog('noAppDownloadError')
} else {
download.download(downloadUrl)
}
}}
>
Download Free Edition
{getText('downloadFreeEdition')}
</button>
)}
</div>
@ -369,7 +374,7 @@ export default function Drive(props: DriveProps) {
>
<div className="flex flex-col gap-icons self-start">
<h1 className="h-heading px-heading-x py-heading-y text-xl font-bold leading-snug">
{backend.type === backendModule.BackendType.remote ? 'Cloud Drive' : 'Local Drive'}
{isCloud ? getText('cloudDrive') : getText('localDrive')}
</h1>
<DriveBar
category={category}
@ -384,7 +389,7 @@ export default function Drive(props: DriveProps) {
/>
</div>
<div className="flex flex-1 gap-drive overflow-hidden">
{backend.type === backendModule.BackendType.remote && (
{isCloud && (
<div className="flex w-drive-sidebar flex-col gap-drive-sidebar py-drive-sidebar-y">
<CategorySwitcher
category={category}

View File

@ -11,6 +11,7 @@ import DataUploadIcon from 'enso-assets/data_upload.svg'
import * as backendProvider from '#/providers/BackendProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -51,6 +52,7 @@ export default function DriveBar(props: DriveBarProps) {
const { doCreateSecret, doCreateDataLink, doUploadFiles, dispatchAssetEvent } = props
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
const isCloud = backend.type === backendModule.BackendType.remote
@ -94,13 +96,13 @@ export default function DriveBar(props: DriveBarProps) {
event.stopPropagation()
setModal(
<ConfirmDeleteModal
actionText="all trashed items forever"
actionText={getText('allTrashedItemsForever')}
doDelete={doEmptyTrash}
/>
)
}}
>
<span className="text whitespace-nowrap font-semibold">Clear Trash</span>
<span className="text whitespace-nowrap font-semibold">{getText('clearTrash')}</span>
</button>
</div>
</div>
@ -117,14 +119,14 @@ export default function DriveBar(props: DriveBarProps) {
doCreateProject()
}}
>
<span className="text whitespace-nowrap font-semibold">New Project</span>
<span className="text whitespace-nowrap font-semibold">{getText('newProject')}</span>
</button>
<div className="flex h-row items-center gap-icons rounded-full bg-frame px-drive-bar-icons-x text-black/50">
{isCloud && (
<Button
active
image={AddFolderIcon}
alt="New Folder"
alt={getText('newFolder')}
onClick={() => {
unsetModal()
doCreateDirectory()
@ -135,7 +137,7 @@ export default function DriveBar(props: DriveBarProps) {
<Button
active
image={AddKeyIcon}
alt="New Secret"
alt={getText('newSecret')}
onClick={event => {
event.stopPropagation()
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
@ -146,7 +148,7 @@ export default function DriveBar(props: DriveBarProps) {
<Button
active
image={AddConnectorIcon}
alt="New Data Link"
alt={getText('newDataLink')}
onClick={event => {
event.stopPropagation()
setModal(<UpsertDataLinkModal doCreate={doCreateDataLink} />)
@ -173,7 +175,7 @@ export default function DriveBar(props: DriveBarProps) {
<Button
active
image={DataUploadIcon}
alt="Upload Files"
alt={getText('uploadFiles')}
onClick={() => {
unsetModal()
uploadFilesRef.current?.click()
@ -183,11 +185,9 @@ export default function DriveBar(props: DriveBarProps) {
active={canDownload}
disabled={!canDownload}
image={DataDownloadIcon}
alt="Download Files"
alt={getText('downloadFiles')}
error={
isCloud
? 'You currently can only download files.'
: 'First select a project to download.'
isCloud ? getText('canOnlyDownloadFilesError') : getText('noProjectSelectedError')
}
onClick={event => {
event.stopPropagation()

View File

@ -64,15 +64,15 @@ export default function Editor(props: EditorProps) {
const jsonAddress = project.jsonAddress
const binaryAddress = project.binaryAddress
if (jsonAddress == null) {
toastAndLog("Could not get the address of the project's JSON endpoint")
toastAndLog('noJSONEndpointError')
} else if (binaryAddress == null) {
toastAndLog("Could not get the address of the project's binary endpoint")
toastAndLog('noBinaryEndpointError')
} else {
let assetsRoot: string
switch (backendType) {
case backendModule.BackendType.remote: {
if (project.ideVersion == null) {
toastAndLog('Could not get the IDE version of the project')
toastAndLog('noIdeVersionError')
// This is too deeply nested to easily return from
// eslint-disable-next-line no-restricted-syntax
return
@ -122,7 +122,7 @@ export default function Editor(props: EditorProps) {
{ projectId: project.projectId }
)
} catch (error) {
toastAndLog('Could not open editor', error)
toastAndLog('openEditorError', error)
}
if (backendType === backendModule.BackendType.remote) {
// Restore original URL so that initialization works correctly on refresh.
@ -151,11 +151,7 @@ export default function Editor(props: EditorProps) {
} else {
return
}
}, [
projectStartupInfo,
/* should never change */ appRunner,
/* should never change */ toastAndLog,
])
}, [projectStartupInfo, toastAndLog, /* should never change */ appRunner])
return <></>
}

View File

@ -1,6 +1,8 @@
/** @file Home screen. */
import * as React from 'react'
import * as textProvider from '#/providers/TextProvider'
import Samples from '#/layouts/Samples'
import WhatsNew from '#/layouts/WhatsNew'
@ -23,6 +25,7 @@ export interface HomeProps {
/** Home screen. */
export default function Home(props: HomeProps) {
const { hidden, createProject } = props
const { getText } = textProvider.useText()
return (
<div
className={`flex flex-1 flex-col gap-home overflow-auto scroll-hidden ${
@ -34,10 +37,10 @@ export default function Home(props: HomeProps) {
{/* Header */}
<div className="flex flex-col gap-banner px-banner-x py-banner-y">
<h1 className="self-center py-banner-item text-center text-4xl leading-snug">
Welcome to Enso Community
{getText('welcomeMessage')}
</h1>
<h2 className="self-center py-banner-item text-center text-xl font-normal leading-snug">
Explore templates, plugins, and data sources to kickstart your next big idea.
{getText('welcomeSubtitle')}
</h2>
</div>
<WhatsNew />

View File

@ -5,6 +5,7 @@ import PlusIcon from 'enso-assets/plus.svg'
import Trash2Icon from 'enso-assets/trash2.svg'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Label from '#/components/dashboard/Label'
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
@ -43,13 +44,16 @@ export default function Labels(props: LabelsProps) {
const currentLabels = query.labels
const currentNegativeLabels = query.negativeLabels
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
return (
<div
data-testid="labels"
className="flex w-full flex-col items-start gap-sidebar-section-heading"
>
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">Labels</div>
<div className="text-header px-sidebar-section-heading-x text-sm font-bold">
{getText('labels')}
</div>
<ul data-testid="labels-list" className="flex flex-col items-start gap-labels">
{labels
.filter(label => !deletedLabelNames.has(label.value))
@ -100,7 +104,7 @@ export default function Labels(props: LabelsProps) {
event.stopPropagation()
setModal(
<ConfirmDeleteModal
actionText={`delete the label '${label.value}'`}
actionText={getText('deleteLabelActionText', label.value)}
doDelete={() => {
doDeleteLabel(label.id, label.value)
}}
@ -110,7 +114,7 @@ export default function Labels(props: LabelsProps) {
>
<SvgMask
src={Trash2Icon}
alt="Delete"
alt={getText('delete')}
className="size-icon text-delete transition-all transparent group-hover:active"
/>
</button>
@ -137,7 +141,7 @@ export default function Labels(props: LabelsProps) {
{/* This is a non-standard-sized icon. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<img src={PlusIcon} className="mr-[6px] size-[6px]" />
<span className="text-header">new label</span>
<span className="text-header">{getText('newLabelButtonLabel')}</span>
</Label>
</li>
</ul>

View File

@ -5,6 +5,10 @@ import DriveIcon from 'enso-assets/drive.svg'
import HomeIcon from 'enso-assets/home.svg'
import NetworkIcon from 'enso-assets/network.svg'
import type * as text from '#/text'
import * as textProvider from '#/providers/TextProvider'
import Button from '#/components/Button'
// ====================
@ -20,24 +24,37 @@ export enum Page {
}
/** Error text for each page. */
const ERRORS: Readonly<Record<Page, string | null>> = {
const ERRORS = {
[Page.home]: null,
[Page.drive]: null,
[Page.editor]: 'No project is currently open.',
[Page.editor]: 'noProjectIsCurrentlyOpen',
[Page.settings]: null,
}
} as const satisfies Record<Page, text.TextId | null>
/** Data describing how to display a button for a pageg. */
const PAGE_TO_ALT_TEXT_ID: Readonly<Record<Page, text.TextId>> = {
home: 'homePageAltText',
drive: 'drivePageAltText',
editor: 'editorPageAltText',
settings: 'settingsPageAltText',
} satisfies { [P in Page]: `${P}PageAltText` }
const PAGE_TO_TOOLTIP_ID: Readonly<Record<Page, text.TextId>> = {
home: 'homePageTooltip',
drive: 'drivePageTooltip',
editor: 'editorPageTooltip',
settings: 'settingsPageTooltip',
} satisfies { [P in Page]: `${P}PageTooltip` }
/** Data describing how to display a button for a page. */
interface PageUIData {
readonly page: Page
readonly icon: string
readonly alt: string
}
const PAGE_DATA: PageUIData[] = [
{ page: Page.home, icon: HomeIcon, alt: 'Go to home page' },
{ page: Page.drive, icon: DriveIcon, alt: 'Go to drive page' },
{ page: Page.editor, icon: NetworkIcon, alt: 'Go to editor page' },
{ page: Page.home, icon: HomeIcon },
{ page: Page.drive, icon: DriveIcon },
{ page: Page.editor, icon: NetworkIcon },
]
/** Props for a {@link PageSwitcher}. */
@ -50,6 +67,7 @@ export interface PageSwitcherProps {
/** Switcher to choose the currently visible full-screen page. */
export default function PageSwitcher(props: PageSwitcherProps) {
const { page, setPage, isEditorDisabled } = props
const { getText } = textProvider.useText()
return (
<div
className={`pointer-events-auto flex shrink-0 cursor-default items-center gap-pages rounded-full px-page-switcher-x ${
@ -59,14 +77,16 @@ export default function PageSwitcher(props: PageSwitcherProps) {
{PAGE_DATA.map(pageData => {
const isDisabled =
pageData.page === page || (pageData.page === Page.editor && isEditorDisabled)
const errorId = ERRORS[pageData.page]
return (
<Button
key={pageData.page}
alt={pageData.alt}
image={pageData.icon}
active={page === pageData.page}
alt={getText(PAGE_TO_ALT_TEXT_ID[pageData.page])}
title={getText(PAGE_TO_TOOLTIP_ID[pageData.page])}
disabled={isDisabled}
error={ERRORS[pageData.page]}
error={errorId == null ? null : getText(errorId)}
onClick={() => {
setPage(pageData.page)
}}

View File

@ -9,6 +9,8 @@ import ProjectIcon from 'enso-assets/project_icon.svg'
import SpreadsheetsImage from 'enso-assets/spreadsheets.svg'
import VisualizeImage from 'enso-assets/visualize.png'
import * as textProvider from '#/providers/TextProvider'
import Spinner, * as spinner from '#/components/Spinner'
import SvgMask from '#/components/SvgMask'
@ -35,6 +37,8 @@ const DUMMY_LIKE_COUNT = 10
/** Template metadata. */
export interface Sample {
readonly title: string
/** These should ideally be localized, however, as this is planned to be user-generated, it is
* unlikely that this will be feasible. */
readonly description: string
readonly id: string
readonly background?: string
@ -93,6 +97,7 @@ interface InternalProjectsEntryProps {
/** A button that, when clicked, creates and opens a new blank project. */
function ProjectsEntry(props: InternalProjectsEntryProps) {
const { createProject } = props
const { getText } = textProvider.useText()
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const onClick = () => {
@ -122,7 +127,7 @@ function ProjectsEntry(props: InternalProjectsEntryProps) {
) : (
<img src={ProjectIcon} />
)}
<p className="text-sm font-semibold">New empty project</p>
<p className="text-sm font-semibold">{getText('newEmptyProject')}</p>
</div>
</div>
</button>
@ -148,6 +153,7 @@ interface InternalProjectTileProps {
/** A button that, when clicked, creates and opens a new project based on a template. */
function ProjectTile(props: InternalProjectTileProps) {
const { sample, createProject } = props
const { getText } = textProvider.useText()
const { id, title, description, background } = sample
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
const author = DUMMY_AUTHOR
@ -201,12 +207,12 @@ function ProjectTile(props: InternalProjectTileProps) {
</div>
{/* Normally `flex` */}
<div className="hidden gap-icons">
<div title="Views" className="flex gap-samples-icon-with-text">
<SvgMask alt="Views" src={OpenCountIcon} className="size-icon self-end" />
<div title={getText('views')} className="flex gap-samples-icon-with-text">
<SvgMask alt={getText('views')} src={OpenCountIcon} className="size-icon self-end" />
<span className="self-start font-bold leading-snug">{opens}</span>
</div>
<div title="Likes" className="flex gap-samples-icon-with-text">
<SvgMask alt="Likes" src={HeartIcon} className="size-icon self-end" />
<div title={getText('likes')} className="flex gap-samples-icon-with-text">
<SvgMask alt={getText('likes')} src={HeartIcon} className="size-icon self-end" />
<span className="self-start font-bold leading-snug">{likes}</span>
</div>
</div>
@ -231,9 +237,10 @@ export interface SamplesProps {
/** A list of sample projects. */
export default function Samples(props: SamplesProps) {
const { createProject } = props
const { getText } = textProvider.useText()
return (
<div data-testid="samples" className="flex flex-col gap-subheading px-home-section-x">
<h2 className="text-subheading">Sample and community projects</h2>
<h2 className="text-subheading">{getText('sampleAndCommunityProjects')}</h2>
<div className="grid grid-cols-fill-samples gap-samples">
<ProjectsEntry createProject={createProject} />
{SAMPLES.map(sample => (

View File

@ -5,6 +5,7 @@ import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import AccountSettingsTab from '#/layouts/Settings/AccountSettingsTab'
import ActivityLogSettingsTab from '#/layouts/Settings/ActivityLogSettingsTab'
@ -31,6 +32,7 @@ export default function Settings() {
)
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const [organization, setOrganization] = React.useState<backendModule.OrganizationInfo>(() => ({
pk: user?.id ?? backendModule.OrganizationId(''),
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -89,7 +91,7 @@ export default function Settings() {
return (
<div className="flex flex-1 flex-col gap-settings-header overflow-hidden px-page-x">
<div className="flex h-heading px-heading-x text-xl font-bold">
<span className="py-heading-y">Settings for </span>
<span className="py-heading-y">{getText('settingsFor')}</span>
{/* This UI element does not appear anywhere else. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<div className="ml-[0.625rem] h-[2.25rem] rounded-full bg-frame px-[0.5625rem] pb-[0.3125rem] pt-[0.125rem] leading-snug">

View File

@ -10,6 +10,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import SvgMask from '#/components/SvgMask'
@ -111,6 +112,7 @@ export default function AccountSettingsTab() {
const { setModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const { user, accessToken } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText()
const [passwordFormKey, setPasswordFormKey] = React.useState('')
const [currentPassword, setCurrentPassword] = React.useState('')
const [newPassword, setNewPassword] = React.useState('')
@ -147,7 +149,7 @@ export default function AccountSettingsTab() {
const doUploadUserPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const image = event.target.files?.[0]
if (image == null) {
toastAndLog('Could not upload a new profile picture because no image was found')
toastAndLog('noNewProfilePictureError')
} else {
try {
const newUser = await backend.uploadUserPicture({ fileName: image.name }, image)
@ -165,32 +167,32 @@ export default function AccountSettingsTab() {
<div className="flex h flex-col gap-settings-section lg:h-auto lg:flex-row">
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">User Account</h3>
<h3 className="settings-subheading">{getText('userAccount')}</h3>
<div className="flex flex-col">
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-user-account-settings-label">Name</span>
<span className="text my-auto w-user-account-settings-label">{getText('name')}</span>
<span className="text my-auto grow font-bold">
<Input originalValue={user?.name ?? ''} onSubmit={doUpdateName} />
</span>
</div>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-user-account-settings-label">Email</span>
<span className="text my-auto w-user-account-settings-label">{getText('email')}</span>
<span className="settings-value my-auto grow font-bold">{user?.email ?? ''}</span>
</div>
</div>
</div>
{canChangePassword && (
<div key={passwordFormKey}>
<h3 className="settings-subheading">Change Password</h3>
<h3 className="settings-subheading">{getText('changePassword')}</h3>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-change-password-settings-label">
Current Password
{getText('currentPasswordLabel')}
</span>
<span className="text my-auto grow font-bold">
<Input
type="password"
originalValue=""
placeholder="Enter your current password"
placeholder={getText('currentPasswordPlaceholder')}
onChange={event => {
setCurrentPassword(event.currentTarget.value)
}}
@ -198,19 +200,21 @@ export default function AccountSettingsTab() {
</span>
</div>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-change-password-settings-label">New Password</span>
<span className="text my-auto w-change-password-settings-label">
{getText('newPasswordLabel')}
</span>
<span className="text my-auto grow font-bold">
<Input
type="password"
originalValue=""
placeholder="Enter your new password"
placeholder={getText('newPasswordPlaceholder')}
onChange={event => {
const newValue = event.currentTarget.value
setNewPassword(newValue)
event.currentTarget.setCustomValidity(
newValue === '' || validation.PASSWORD_REGEX.test(newValue)
? ''
: validation.PASSWORD_ERROR
: getText('passwordValidationError')
)
}}
/>
@ -218,13 +222,13 @@ export default function AccountSettingsTab() {
</div>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-change-password-settings-label">
Confirm New Password
{getText('confirmNewPasswordLabel')}
</span>
<span className="text my-auto grow font-bold">
<Input
type="password"
originalValue=""
placeholder="Confirm your new password"
placeholder={getText('confirmNewPasswordPlaceholder')}
onChange={event => {
const newValue = event.currentTarget.value
setConfirmNewPassword(newValue)
@ -248,7 +252,7 @@ export default function AccountSettingsTab() {
void changePassword(currentPassword, newPassword)
}}
>
Change
{getText('change')}
</button>
<button
type="button"
@ -261,7 +265,7 @@ export default function AccountSettingsTab() {
setConfirmNewPassword('')
}}
>
Cancel
{getText('cancel')}
</button>
</div>
</div>
@ -269,7 +273,7 @@ export default function AccountSettingsTab() {
{/* This UI element does not appear anywhere else. */}
{/* eslint-disable-next-line no-restricted-syntax */}
<div className="flex flex-col items-start gap-settings-section-header rounded-2.5xl border-2 border-danger px-[1rem] pb-[0.9375rem] pt-[0.5625rem]">
<h3 className="settings-subheading text-danger">Danger Zone</h3>
<h3 className="settings-subheading text-danger">{getText('dangerZone')}</h3>
<div className="flex gap-buttons">
<button
className="button bg-danger px-delete-user-account-button-x text-inversed opacity-full hover:opacity-full"
@ -285,16 +289,14 @@ export default function AccountSettingsTab() {
)
}}
>
<span className="text inline-block">Delete this user account</span>
<span className="text inline-block">{getText('deleteUserAccountButtonLabel')}</span>
</button>
<span className="text my-auto">
Once deleted, it will be gone forever. Please be certain.
</span>
<span className="text my-auto">{getText('deleteUserAccountWarning')}</span>
</div>
</div>
</div>
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">Profile picture</h3>
<h3 className="settings-subheading">{getText('profilePicture')}</h3>
<label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
<input type="file" className="hidden" accept="image/*" onChange={doUploadUserPicture} />
<img
@ -305,8 +307,7 @@ export default function AccountSettingsTab() {
/>
</label>
<span className="w-profile-picture-caption py-profile-picture-caption-y">
Your profile picture should not be irrelevant, abusive or vulgar. It should not be a
default image provided by Enso.
{getText('profilePictureWarning')}
</span>
</div>
</div>

View File

@ -10,6 +10,7 @@ import TrashIcon from 'enso-assets/trash.svg'
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import DateInput from '#/components/DateInput'
import Dropdown from '#/components/Dropdown'
@ -55,6 +56,7 @@ const EVENT_TYPE_NAME: Record<backendModule.EventType, string> = {
/** Settings tab for viewing and editing organization members. */
export default function ActivityLogSettingsTab() {
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const [startDate, setStartDate] = React.useState<Date | null>(null)
const [endDate, setEndDate] = React.useState<Date | null>(null)
const [types, setTypes] = React.useState<readonly backendModule.EventType[]>([])
@ -117,18 +119,18 @@ export default function ActivityLogSettingsTab() {
return (
<div className="flex flex-col gap-settings-subsection">
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">Activity Log</h3>
<h3 className="settings-subheading">{getText('activityLog')}</h3>
<div className="flex gap-activity-log-filters">
<div className="flex items-center gap-activity-log-filter">
Start Date
{getText('startDate')}
<DateInput date={startDate} onInput={setStartDate} />
</div>
<div className="flex items-center gap-activity-log-filter">
End Date
{getText('endDate')}
<DateInput date={endDate} onInput={setEndDate} />
</div>
<div className="flex items-center gap-activity-log-filter">
Types
{getText('types')}
<Dropdown
multiple
items={backendModule.EVENT_TYPES}
@ -147,7 +149,7 @@ export default function ActivityLogSettingsTab() {
/>
</div>
<div className="flex items-center gap-activity-log-filter">
Users
{getText('users')}
<Dropdown
multiple
items={allEmails}
@ -174,10 +176,10 @@ export default function ActivityLogSettingsTab() {
<button
title={
sortInfo?.field !== ActivityLogSortableColumn.type
? 'Sort by name'
? getText('sortByName')
: isDescending
? 'Stop sorting by name'
: 'Sort by name descending'
? getText('stopSortingByName')
: getText('sortByNameDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
@ -196,19 +198,23 @@ export default function ActivityLogSettingsTab() {
}
}}
>
<span className="text-header">Type</span>
<span className="text-header">{getText('type')}</span>
<img
alt={
sortInfo?.field === ActivityLogSortableColumn.type && isDescending
? 'Sort Descending'
: 'Sort Ascending'
? getText('sortDescending')
: getText('sortAscending')
}
src={SortAscendingIcon}
className={`transition-all duration-arrow ${
sortInfo?.field === ActivityLogSortableColumn.type
? 'selectable active'
: 'transparent group-hover:selectable'
} ${isDescending ? 'rotate-180' : ''}`}
} ${
sortInfo?.field === ActivityLogSortableColumn.type && isDescending
? 'rotate-180'
: ''
}`}
/>
</button>
</th>
@ -216,10 +222,10 @@ export default function ActivityLogSettingsTab() {
<button
title={
sortInfo?.field !== ActivityLogSortableColumn.email
? 'Sort by email'
? getText('sortByEmail')
: isDescending
? 'Stop sorting by email'
: 'Sort by email descending'
? getText('stopSortingByEmail')
: getText('sortByEmailDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
@ -238,19 +244,23 @@ export default function ActivityLogSettingsTab() {
}
}}
>
<span className="text-header">Email</span>
<span className="text-header">{getText('email')}</span>
<img
alt={
sortInfo?.field === ActivityLogSortableColumn.email && isDescending
? 'Sort Descending'
: 'Sort Ascending'
? getText('sortDescending')
: getText('sortAscending')
}
src={SortAscendingIcon}
className={`transition-all duration-arrow ${
sortInfo?.field === ActivityLogSortableColumn.email
? 'selectable active'
: 'transparent group-hover:selectable'
} ${isDescending ? 'rotate-180' : ''}`}
} ${
sortInfo?.field === ActivityLogSortableColumn.email && isDescending
? 'rotate-180'
: ''
}`}
/>
</button>
</th>
@ -258,10 +268,10 @@ export default function ActivityLogSettingsTab() {
<button
title={
sortInfo?.field !== ActivityLogSortableColumn.timestamp
? 'Sort by timestamp'
? getText('sortByTimestamp')
: isDescending
? 'Stop sorting by timestamp'
: 'Sort by timestamp descending'
? getText('stopSortingByTimestamp')
: getText('sortByTimestampDescending')
}
className="group flex h-drive-table-heading w-full items-center gap-icon-with-text px-name-column-x"
onClick={event => {
@ -280,19 +290,23 @@ export default function ActivityLogSettingsTab() {
}
}}
>
<span className="text-header">Timestamp</span>
<span className="text-header">{getText('timestamp')}</span>
<img
alt={
sortInfo?.field === ActivityLogSortableColumn.timestamp && isDescending
? 'Sort Descending'
: 'Sort Ascending'
? getText('sortDescending')
: getText('sortAscending')
}
src={SortAscendingIcon}
className={`transition-all duration-arrow ${
sortInfo?.field === ActivityLogSortableColumn.timestamp
? 'selectable active'
: 'transparent group-hover:selectable'
} ${isDescending ? 'rotate-180' : ''}`}
} ${
sortInfo?.field === ActivityLogSortableColumn.timestamp && isDescending
? 'rotate-180'
: ''
}`}
/>
</button>
</th>

View File

@ -12,6 +12,7 @@ import * as refreshHooks from '#/hooks/refreshHooks'
import * as inputBindingsManager from '#/providers/InputBindingsProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import SvgMask from '#/components/SvgMask'
@ -29,6 +30,7 @@ import * as object from '#/utilities/object'
export default function KeyboardShortcutsSettingsTab() {
const inputBindings = inputBindingsManager.useInputBindings()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [refresh, doRefresh] = refreshHooks.useRefresh()
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
const bodyRef = React.useRef<HTMLTableSectionElement>(null)
@ -68,7 +70,7 @@ export default function KeyboardShortcutsSettingsTab() {
return (
<div className="flex w-full flex-1 flex-col gap-settings-section-header">
<h3 className="settings-subheading">Keyboard shortcuts</h3>
<h3 className="settings-subheading">{getText('keyboardShortcuts')}</h3>
<div className="flex gap-drive-bar">
<button
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
@ -76,8 +78,8 @@ export default function KeyboardShortcutsSettingsTab() {
event.stopPropagation()
setModal(
<ConfirmDeleteModal
actionText="reset all keyboard shortcuts"
actionButtonLabel="Reset All"
actionText={getText('resetAllKeyboardShortcuts')}
actionButtonLabel={getText('resetAll')}
doDelete={() => {
for (const k in inputBindings.metadata) {
// eslint-disable-next-line no-restricted-syntax
@ -89,7 +91,7 @@ export default function KeyboardShortcutsSettingsTab() {
)
}}
>
<span className="text whitespace-nowrap font-semibold">Reset All</span>
<span className="text whitespace-nowrap font-semibold">{getText('resetAll')}</span>
</button>
</div>
{/* There is a horizontal scrollbar for some reason without `px-px`. */}
@ -101,9 +103,9 @@ export default function KeyboardShortcutsSettingsTab() {
<th className="pr-keyboard-shortcuts-icon-column-r min-w-keyboard-shortcuts-icon-column pl-cell-x">
{/* Icon */}
</th>
<th className="min-w-keyboard-shortcuts-name-column px-cell-x">Name</th>
<th className="px-cell-x">Shortcuts</th>
<th className="w-full px-cell-x">Description</th>
<th className="min-w-keyboard-shortcuts-name-column px-cell-x">{getText('name')}</th>
<th className="px-cell-x">{getText('shortcuts')}</th>
<th className="w-full px-cell-x">{getText('description')}</th>
</tr>
</thead>
<tbody ref={bodyRef}>

View File

@ -5,6 +5,7 @@ import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
@ -18,13 +19,14 @@ import InviteUsersModal from '#/modals/InviteUsersModal'
export default function MembersSettingsTab() {
const { backend } = backendProvider.useBackend()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const members = asyncEffectHooks.useAsyncEffect(null, () => backend.listUsers(), [backend])
const isLoading = members == null
return (
<div className="flex flex-col gap-settings-subsection">
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">Members</h3>
<h3 className="settings-subheading">{getText('members')}</h3>
<div className="flex gap-drive-bar">
<button
className="flex h-row items-center rounded-full bg-frame px-new-project-button-x"
@ -33,17 +35,17 @@ export default function MembersSettingsTab() {
setModal(<InviteUsersModal eventTarget={null} />)
}}
>
<span className="text whitespace-nowrap font-semibold">Invite Members</span>
<span className="text whitespace-nowrap font-semibold">{getText('inviteMembers')}</span>
</button>
</div>
<table className="table-fixed self-start rounded-rows">
<thead>
<tr className="h-row">
<th className="w-members-name-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
Name
{getText('name')}
</th>
<th className="w-members-email-column border-x-2 border-transparent bg-clip-padding px-cell-x text-left text-sm font-semibold last:border-r-0">
Email
{getText('email')}
</th>
</tr>
</thead>

View File

@ -8,6 +8,7 @@ import DefaultUserIcon from 'enso-assets/default_user.svg'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as backendModule from '#/services/Backend'
@ -26,8 +27,9 @@ export interface OrganizationSettingsTabProps {
/** Settings tab for viewing and editing organization information. */
export default function OrganizationSettingsTab(props: OrganizationSettingsTabProps) {
const { organization, setOrganization } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const nameRef = React.useRef<HTMLInputElement>(null)
const emailRef = React.useRef<HTMLInputElement>(null)
const websiteRef = React.useRef<HTMLInputElement>(null)
@ -119,7 +121,7 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
const doUploadOrganizationPicture = async (event: React.ChangeEvent<HTMLInputElement>) => {
const image = event.target.files?.[0]
if (image == null) {
toastAndLog('Could not upload a new profile picture because no image was found')
toastAndLog('noNewProfilePictureError')
} else {
try {
const newOrganization = await backend.uploadOrganizationPicture(
@ -160,11 +162,11 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
<div className="flex-0 flex h flex-col gap-settings-section lg:h-auto lg:flex-row">
<div className="flex w-settings-main-section flex-col gap-settings-subsection">
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">Organization</h3>
<h3 className="settings-subheading">{getText('organization')}</h3>
<div className="flex flex-col">
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-organization-settings-label">
Organization display name
{getText('organizationDisplayName')}
</span>
<span className="text my-auto grow font-bold">
<input
@ -182,7 +184,7 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
</span>
</div>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-organization-settings-label">Email</span>
<span className="text my-auto w-organization-settings-label">{getText('email')}</span>
<span className="text my-auto grow font-bold">
<input
ref={emailRef}
@ -210,7 +212,9 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
</span>
</div>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-organization-settings-label">Website</span>
<span className="text my-auto w-organization-settings-label">
{getText('website')}
</span>
<span className="text my-auto grow font-bold">
<input
ref={websiteRef}
@ -227,7 +231,9 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
</span>
</div>
<div className="flex h-row gap-settings-entry">
<span className="text my-auto w-organization-settings-label">Location</span>
<span className="text my-auto w-organization-settings-label">
{getText('location')}
</span>
<span className="text my-auto grow font-bold">
<input
ref={locationRef}
@ -247,7 +253,7 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
</div>
</div>
<div className="flex flex-col gap-settings-section-header">
<h3 className="settings-subheading">Profile picture</h3>
<h3 className="settings-subheading">{getText('profilePicture')}</h3>
<label className="flex h-profile-picture-large w-profile-picture-large cursor-pointer items-center overflow-clip rounded-full transition-colors hover:bg-frame">
<input
type="file"
@ -263,8 +269,7 @@ export default function OrganizationSettingsTab(props: OrganizationSettingsTabPr
/>
</label>
<span className="w-profile-picture-caption py-profile-picture-caption-y">
Your organization&apos;s profile picture should not be irrelevant, abusive or vulgar. It
should not be a default image provided by Enso.
{getText('organizationProfilePictureWarning')}
</span>
</div>
</div>

View File

@ -7,6 +7,7 @@ import DefaultUserIcon from 'enso-assets/default_user.svg'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as pageSwitcher from '#/layouts/PageSwitcher'
import UserMenu from '#/layouts/UserMenu'
@ -42,6 +43,7 @@ export default function UserBar(props: UserBarProps) {
const { type: sessionType, user } = authProvider.useNonPartialUserSession()
const { setModal, updateModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const self =
user != null
? projectAsset?.permissions?.find(
@ -74,7 +76,7 @@ export default function UserBar(props: UserBarProps) {
setModal(<InviteUsersModal eventTarget={null} />)
}}
>
Invite
{getText('invite')}
</button>
)}
{shouldShowShareButton && (
@ -93,7 +95,7 @@ export default function UserBar(props: UserBarProps) {
)
}}
>
Share
{getText('share')}
</button>
)}
<button
@ -113,7 +115,7 @@ export default function UserBar(props: UserBarProps) {
>
<img
src={user?.profilePicture ?? DefaultUserIcon}
alt="Open user menu"
alt={getText('openUserMenu')}
className="pointer-events-none"
height={28}
width={28}

View File

@ -10,6 +10,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as pageSwitcher from '#/layouts/PageSwitcher'
@ -40,6 +41,7 @@ export default function UserMenu(props: UserMenuProps) {
const { signOut } = authProvider.useAuth()
const { user } = authProvider.useNonPartialUserSession()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
React.useEffect(() => {
@ -83,7 +85,7 @@ export default function UserMenu(props: UserMenuProps) {
unsetModal()
const downloadUrl = await github.getDownloadUrl()
if (downloadUrl == null) {
toastAndLog('Could not find a download link for the current OS')
toastAndLog('noAppDownloadError')
} else {
download.download(downloadUrl)
}
@ -113,7 +115,7 @@ export default function UserMenu(props: UserMenuProps) {
) : (
<>
<div className="flex h-profile-picture items-center">
<span className="text">You are not logged in.</span>
<span className="text">{getText('youAreNotLoggedIn')}</span>
</div>
<div className="flex flex-col">
<MenuEntry

View File

@ -5,15 +5,19 @@ import DiscordIcon from 'enso-assets/discord.svg'
import IntegrationsImage from 'enso-assets/integrations.png'
import YoutubeIcon from 'enso-assets/youtube.svg'
import * as textProvider from '#/providers/TextProvider'
// ================
// === WhatsNew ===
// ================
/** Community updates for the app. */
export default function WhatsNew() {
const { getText } = textProvider.useText()
return (
<div className="flex flex-col gap-subheading px-home-section-x">
<h2 className="text-subheading">Discover what&rsquo;s new</h2>
<h2 className="text-subheading">{getText('discoverWhatsNew')}</h2>
<div className="grid grid-cols-fill-news-items gap-news-items">
<a
className="relative col-span-1 h-news-item rounded-default bg-v3 text-tag-text col-span-2-news-item sm:col-span-2"
@ -23,11 +27,9 @@ export default function WhatsNew() {
style={{ background: `url(${IntegrationsImage}) top -85px right -390px / 1055px` }}
>
<div className="absolute bottom flex w-full flex-col p-news-item-description">
<span className="text-subheading font-bold">
Read what&rsquo;s new in Enso 3.0 Beta
</span>
<span className="text-subheading font-bold">{getText('newsItem3Beta')}</span>
<span className="py-news-item-subtitle-y text-sm leading-snug">
Learn about Enso Cloud, new data libraries, and Enso AI.
{getText('newsItem3BetaDescription')}
</span>
</div>
</a>
@ -39,9 +41,9 @@ export default function WhatsNew() {
>
<img className="absolute left-1/2 top-6 mx-auto -translate-x-1/2" src={YoutubeIcon} />
<div className="absolute bottom flex w-full flex-col p-news-item-description">
<span className="text-subheading font-bold">Watch weekly Enso tutorials</span>
<span className="text-subheading font-bold">{getText('newsItemWeeklyTutorials')}</span>
<span className="py-news-item-subtitle-y text-sm leading-snug">
Subscribe not to miss new weekly tutorials.
{getText('newsItemWeeklyTutorialsDescription')}
</span>
</div>
</a>
@ -53,9 +55,9 @@ export default function WhatsNew() {
>
<img className="absolute left-1/2 top-7 mx-auto -translate-x-1/2" src={DiscordIcon} />
<div className="absolute bottom flex w-full flex-col p-news-item-description">
<span className="text-subheading font-bold">Join our community server</span>
<span className="text-subheading font-bold">{getText('newsItemCommunityServer')}</span>
<span className="py-news-item-subtitle-y text-sm leading-snug">
Chat with our team and other Enso users.
{getText('newsItemCommunityServerDescription')}
</span>
</div>
</a>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as detect from 'enso-common/src/detect'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import KeyboardShortcut from '#/components/dashboard/KeyboardShortcut'
import Modal from '#/components/Modal'
@ -50,6 +51,7 @@ export interface CaptureKeyboardShortcutModalProps {
export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShortcutModalProps) {
const { description, existingShortcuts, onSubmit } = props
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [key, setKey] = React.useState<string | null>(null)
const [modifiers, setModifiers] = React.useState<string>('')
const shortcut = key == null ? modifiers : modifiers === '' ? key : `${modifiers}+${key}`
@ -100,14 +102,14 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
}
}}
>
<div className="relative">Enter the new keyboard shortcut for {description}.</div>
<div className="relative">{getText('enterTheNewKeyboardShortcutFor', description)}</div>
<div
className={`relative flex scale-150 items-center justify-center ${
doesAlreadyExist ? 'text-red-600' : ''
}`}
>
{shortcut === '' ? (
<span className="text text-primary/30">No shortcut entered</span>
<span className="text text-primary/30">{getText('noShortcutEntered')}</span>
) : (
<KeyboardShortcut shortcut={shortcut} />
)}
@ -121,10 +123,10 @@ export default function CaptureKeyboardShortcutModal(props: CaptureKeyboardShort
type="submit"
className="button bg-invite text-white enabled:active"
>
Confirm
{getText('confirm')}
</button>
<button type="button" className="button bg-selected-frame active" onClick={unsetModal}>
Cancel
{getText('cancel')}
</button>
</div>
</form>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Modal from '#/components/Modal'
@ -23,8 +24,9 @@ export interface ConfirmDeleteModalProps {
/** A modal for confirming the deletion of an asset. */
export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
const { actionText, actionButtonLabel = 'Delete', doDelete } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const onSubmit = () => {
unsetModal()
@ -57,13 +59,13 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
onSubmit()
}}
>
<div className="relative">Are you sure you want to {actionText}?</div>
<div className="relative">{getText('confirmPrompt', actionText)}</div>
<div className="relative flex gap-buttons">
<button type="submit" className="button bg-delete text-white active">
{actionButtonLabel}
</button>
<button type="button" className="button bg-selected-frame active" onClick={unsetModal}>
Cancel
{getText('cancel')}
</button>
</div>
</form>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Modal from '#/components/Modal'
@ -19,8 +20,9 @@ export interface ConfirmDeleteUserModalProps {
/** A modal for confirming the deletion of a user. */
export default function ConfirmDeleteUserModal(props: ConfirmDeleteUserModalProps) {
const { doDelete } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const onSubmit = async () => {
unsetModal()
@ -53,10 +55,10 @@ export default function ConfirmDeleteUserModal(props: ConfirmDeleteUserModalProp
void onSubmit()
}}
>
<h3 className="py-heading relative h-heading text-xl font-bold">Are you sure?</h3>
<span className="relative">Once deleted, this user account will be gone forever.</span>
<h3 className="py-heading relative h-heading text-xl font-bold">{getText('areYouSure')}</h3>
<span className="relative">{getText('confirmDeleteUserAccountWarning')}</span>
<button type="submit" className="button relative bg-danger text-inversed active">
<span className="text">I confirm that I want to delete this user account.</span>
<span className="text">{getText('confirmDeleteUserAccountButtonLabel')}</span>
</button>
</form>
</Modal>

View File

@ -2,6 +2,7 @@
import * as React from 'react'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -15,24 +16,6 @@ import * as backendModule from '#/services/Backend'
import * as fileInfo from '#/utilities/fileInfo'
import * as object from '#/utilities/object'
import * as string from '#/utilities/string'
// =================
// === Constants ===
// =================
// This is a function, even though it does not look like one.
// eslint-disable-next-line no-restricted-syntax
const pluralizeFile = string.makePluralize('file', 'files')
// This is a function, even though it does not look like one.
// eslint-disable-next-line no-restricted-syntax
const pluralizeProject = string.makePluralize('project', 'projects')
// This is a function, even though it does not look like one.
// eslint-disable-next-line no-restricted-syntax
const pluralizeFileUppercase = string.makePluralize('File', 'Files')
// This is a function, even though it does not look like one.
// eslint-disable-next-line no-restricted-syntax
const pluralizeProjectUppercase = string.makePluralize('Project', 'Projects')
// =============
// === Types ===
@ -64,7 +47,8 @@ export interface DuplicateAssetsModalProps {
readonly dispatchAssetListEvent: (assetListEvent: assetListEvent.AssetListEvent) => void
readonly siblingFileNames: Iterable<string>
readonly siblingProjectNames: Iterable<string>
readonly nonConflictingCount: number
readonly nonConflictingFileCount: number
readonly nonConflictingProjectCount: number
readonly doUploadNonConflicting: () => void
}
@ -75,8 +59,9 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
const { dispatchAssetEvent, dispatchAssetListEvent } = props
const { siblingFileNames: siblingFileNamesRaw } = props
const { siblingProjectNames: siblingProjectNamesRaw } = props
const { nonConflictingCount, doUploadNonConflicting } = props
const { nonConflictingFileCount, nonConflictingProjectCount, doUploadNonConflicting } = props
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [conflictingFiles, setConflictingFiles] = React.useState(conflictingFilesRaw)
const [conflictingProjects, setConflictingProjects] = React.useState(conflictingProjectsRaw)
const [didUploadNonConflicting, setDidUploadNonConflicting] = React.useState(false)
@ -84,34 +69,8 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
const siblingProjectNames = React.useRef(new Set<string>())
const count = conflictingFiles.length + conflictingProjects.length
const firstConflict = conflictingFiles[0] ?? conflictingProjects[0]
let firstConflictTypeName: string
switch (firstConflict?.new.type) {
case backendModule.AssetType.file: {
firstConflictTypeName = 'File'
break
}
case backendModule.AssetType.project: {
firstConflictTypeName = 'Project'
break
}
// eslint-disable-next-line no-restricted-syntax
case undefined: {
// This variable does not matter as it should not be used.
firstConflictTypeName = 'Unknown Asset'
}
}
const otherFilesCount = Math.max(0, conflictingFiles.length - 1)
const otherFilesText =
otherFilesCount === 0 ? '' : `and ${otherFilesCount} other ${pluralizeFile(otherFilesCount)}`
const otherProjectsCount = conflictingProjects.length - (conflictingFiles.length > 0 ? 0 : 1)
const otherProjectsText =
otherProjectsCount === 0
? ''
: `and ${otherProjectsCount}${conflictingFiles.length > 0 ? '' : ' other'} ${pluralizeProject(
otherProjectsCount
)}`
const filesTextUppercase = pluralizeFileUppercase(conflictingFiles.length)
const projectsTextUppercase = pluralizeProjectUppercase(conflictingProjects.length)
React.useEffect(() => {
for (const name of siblingFileNamesRaw) {
@ -209,40 +168,50 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
}}
>
<h1 className="relative text-sm font-semibold">
Duplicate{' '}
{conflictingFiles.length > 0
? conflictingProjects.length > 0
? `${filesTextUppercase} and ${projectsTextUppercase}`
: filesTextUppercase
: projectsTextUppercase}{' '}
Found
? getText('duplicateFilesAndProjectsFound')
: getText('duplicateFilesFound')
: getText('duplicateProjectsFound')}
</h1>
{nonConflictingCount > 0 && (
<div className="relative flex flex-col">
<span className="text">
{nonConflictingCount} {pluralizeFile(nonConflictingCount)} without conflicts
</span>
<button
disabled={didUploadNonConflicting}
type="button"
className="button relative self-start rounded-full bg-selected-frame selectable enabled:active"
onClick={() => {
doUploadNonConflicting()
setDidUploadNonConflicting(true)
}}
>
{didUploadNonConflicting ? 'Uploaded' : 'Upload'}
</button>
</div>
)}
{nonConflictingFileCount > 0 ||
(nonConflictingProjectCount > 0 && (
<div className="relative flex flex-col">
{nonConflictingFileCount > 0 && (
<span className="text">
{nonConflictingFileCount === 1
? getText('fileWithoutConflicts')
: getText('filesWithoutConflicts', nonConflictingFileCount)}
</span>
)}
{nonConflictingProjectCount > 0 && (
<span className="text">
{nonConflictingProjectCount === 1
? getText('projectWithoutConflicts')
: getText('projectsWithoutConflicts', nonConflictingFileCount)}
</span>
)}
<button
disabled={didUploadNonConflicting}
type="button"
className="button relative self-start rounded-full bg-selected-frame selectable enabled:active"
onClick={() => {
doUploadNonConflicting()
setDidUploadNonConflicting(true)
}}
>
{didUploadNonConflicting ? getText('uploaded') : getText('upload')}
</button>
</div>
))}
{firstConflict && (
<>
<div className="flex flex-col">
<span className="relative">Current:</span>
<span className="relative">{getText('currentColon')}</span>
<AssetSummary asset={firstConflict.current} className="relative" />
</div>
<div className="flex flex-col">
<span className="relative">New:</span>
<span className="relative">{getText('newColon')}</span>
<AssetSummary
new
newName={backendModule.stripProjectExtension(findNewName(firstConflict, false))}
@ -269,7 +238,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
}
}}
>
Update
{getText('update')}
</button>
<button
type="button"
@ -288,14 +257,27 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
}
}}
>
Rename New {firstConflictTypeName}
{firstConflict.new.type === backendModule.AssetType.file
? getText('renameNewFile')
: getText('renameNewProject')}
</button>
</div>
)}
</>
)}
{(otherFilesText !== '' || otherProjectsText !== '' || nonConflictingCount > 0) && (
<span className="relative">{[otherFilesText, otherProjectsText].join(' ')}</span>
{otherFilesCount > 0 && (
<span className="relative">
{otherFilesCount === 1
? getText('andOtherFile')
: getText('andOtherFiles', otherFilesCount)}
</span>
)}
{otherProjectsCount > 0 && (
<span className="relative">
{otherProjectsCount === 1
? getText('andOtherProject')
: getText('andOtherProjects', otherProjectsCount)}
</span>
)}
<div className="relative flex gap-icons">
<button
@ -307,7 +289,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
doUpdate([...conflictingFiles, ...conflictingProjects])
}}
>
{count === 1 ? 'Update' : 'Update All'}
{count === 1 ? getText('update') : getText('updateAll')}
</button>
<button
type="button"
@ -319,11 +301,15 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
}}
>
{count === 1
? `Rename New ${firstConflictTypeName}`
: `Rename New ${firstConflictTypeName}s`}
? firstConflict?.new.type === backendModule.AssetType.file
? getText('renameNewFile')
: getText('renameNewProject')
: firstConflict?.new.type === backendModule.AssetType.file
? getText('renameNewFiles')
: getText('renameNewProjects')}
</button>
<button type="button" className="button bg-selected-frame active" onClick={unsetModal}>
Cancel
{getText('cancel')}
</button>
</div>
</form>

View File

@ -11,6 +11,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Modal from '#/components/Modal'
@ -71,6 +72,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
const { user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [newEmails, setNewEmails] = React.useState<string[]>([])
const [email, setEmail] = React.useState<string>('')
@ -101,7 +103,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
userEmail: backendModule.EmailAddress(newEmail),
})
} catch (error) {
toastAndLog(`Could not invite user '${newEmail}'`, error)
toastAndLog('couldNotInviteUser', error, newEmail)
}
})()
}
@ -135,7 +137,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
}}
>
<div className="relative flex flex-col gap-modal rounded-default p-modal-wide pt-modal">
<h2 className="text text-sm font-bold">Invite</h2>
<h2 className="text text-sm font-bold">{getText('invite')}</h2>
<form
className="grow"
onSubmit={event => {
@ -166,7 +168,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
<input
autoFocus
type="text"
placeholder="Type email to invite"
placeholder={getText('typeEmailToInvite')}
className="text max-w-full bg-transparent"
value={email}
onKeyDown={event => {
@ -203,7 +205,7 @@ export default function InviteUsersModal(props: InviteUsersModalProps) {
className="button bg-invite text-tag-text enabled:active"
onClick={doSubmit}
>
Invite
{getText('invite')}
</button>
</div>
</div>

View File

@ -6,6 +6,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import ColorPicker from '#/components/ColorPicker'
import Label from '#/components/dashboard/Label'
@ -42,6 +43,7 @@ export default function ManageLabelsModal<
const { user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const [labels, setLabelsRaw] = React.useState(item.labels ?? [])
const [query, setQuery] = React.useState('')
@ -103,8 +105,8 @@ export default function ManageLabelsModal<
item.id,
item.title,
backend,
toastAndLog,
/* should never change */ setLabels,
/* should never change */ toastAndLog,
]
)
@ -157,7 +159,7 @@ export default function ManageLabelsModal<
}}
>
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<h2 className="text text-sm font-bold">Labels</h2>
<h2 className="text text-sm font-bold">{getText('labels')}</h2>
</div>
<div className="flex gap-input-with-button">
<div
@ -170,16 +172,14 @@ export default function ManageLabelsModal<
style={
!canSelectColor || color == null
? {}
: {
backgroundColor: backendModule.lChColorToCssColor(color),
}
: { backgroundColor: backendModule.lChColorToCssColor(color) }
}
>
<input
autoFocus
type="text"
size={1}
placeholder="Type labels to search"
placeholder={getText('labelSearchPlaceholder')}
className="text grow bg-transparent"
onChange={event => {
setQuery(event.currentTarget.value)
@ -191,7 +191,7 @@ export default function ManageLabelsModal<
disabled={!canCreateNewLabel}
className="button bg-invite px-button-x text-tag-text enabled:active"
>
<div className="h-text py-modal-invite-button-text-y">Create</div>
<div className="h-text py-modal-invite-button-text-y">{getText('create')}</div>
</button>
</div>
{canSelectColor && (

View File

@ -10,6 +10,7 @@ import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Autocomplete from '#/components/Autocomplete'
import PermissionSelector from '#/components/dashboard/PermissionSelector'
@ -58,6 +59,7 @@ export default function ManagePermissionsModal<
const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const [permissions, setPermissions] = React.useState(item.permissions ?? [])
const [users, setUsers] = React.useState<backendModule.SimpleUser[]>([])
const [email, setEmail] = React.useState<string | null>(null)
@ -142,10 +144,10 @@ export default function ManagePermissionsModal<
organizationId: user.id,
userEmail: backendModule.EmailAddress(email),
})
toast.toast.success(`You've invited '${email}' to join Enso!`)
toast.toast.success(getText('inviteSuccess', email))
}
} catch (error) {
toastAndLog(`Could not invite user '${email}'`, error)
toastAndLog('couldNotInviteUser', error, email ?? '(unknown)')
}
} else {
setUsers([])
@ -191,7 +193,7 @@ export default function ManagePermissionsModal<
const usernames = addedUsersPermissions.map(
userPermissions => userPermissions.user.user_name
)
toastAndLog(`Could not set permissions for ${usernames.join(', ')}`, error)
toastAndLog('setPermissionsError', error, usernames.join("', '"))
}
}
}
@ -220,7 +222,7 @@ export default function ManagePermissionsModal<
[...oldPermissions, oldPermission].sort(backendModule.compareUserPermissions)
)
}
toastAndLog(`Could not set permissions of '${userToDelete.user_email}'`, error)
toastAndLog('setPermissionsError', error, userToDelete.user_email)
}
}
}
@ -256,7 +258,7 @@ export default function ManagePermissionsModal<
>
<div className="relative flex flex-col gap-modal rounded-default p-modal">
<div className="flex h-row items-center gap-modal-tabs px-modal-tab-bar-x">
<h2 className="text text-sm font-bold">Invite</h2>
<h2 className="text text-sm font-bold">{getText('invite')}</h2>
{/* Space reserved for other tabs. */}
</div>
<form
@ -283,14 +285,14 @@ export default function ManagePermissionsModal<
placeholder={
// `listedUsers` will always include the current user.
listedUsers?.length !== 1
? 'Type usernames or emails to search or invite'
: 'Enter an email to invite someone'
? getText('inviteUserPlaceholder')
: getText('inviteFirstUserPlaceholder')
}
type="text"
itemsToString={items =>
items.length === 1 && items[0] != null
? items[0].email
: `${items.length} users selected`
: getText('xUsersSelected', items.length)
}
values={users}
setValues={setUsers}

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import ColorPicker from '#/components/ColorPicker'
import Modal from '#/components/Modal'
@ -26,16 +27,15 @@ export default function NewLabelModal(props: NewLabelModalProps) {
const { labels, eventTarget, doCreate } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { unsetModal } = modalProvider.useSetModal()
const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget])
const { getText } = textProvider.useText()
const [value, setName] = React.useState('')
const [color, setColor] = React.useState<backend.LChColor | null>(null)
const labelNames = React.useMemo(
() => new Set<string>(labels.map(label => label.value)),
[labels]
)
const position = React.useMemo(() => eventTarget.getBoundingClientRect(), [eventTarget])
const leastUsedColor = React.useMemo(() => backend.leastUsedColor(labels), [labels])
const [value, setName] = React.useState('')
const [color, setColor] = React.useState<backend.LChColor | null>(null)
const canSubmit = Boolean(value && !labelNames.has(value))
const onSubmit = () => {
@ -72,26 +72,20 @@ export default function NewLabelModal(props: NewLabelModalProps) {
onSubmit()
}}
>
<h1 className="relative text-sm font-semibold">New Label</h1>
<h1 className="relative text-sm font-semibold">{getText('newLabel')}</h1>
<label className="relative flex items-center">
<div className="text w-modal-label">Name</div>
<div className="text w-modal-label">{getText('name')}</div>
<input
autoFocus
size={1}
placeholder="Enter the name of the label"
placeholder={getText('labelNamePlaceholder')}
className={`text grow rounded-full border border-primary/10 bg-transparent px-input-x ${
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
color != null && color.lightness <= 50
? 'text-tag-text placeholder-selected-frame'
: 'text-primary'
}`}
style={
color == null
? {}
: {
backgroundColor: backend.lChColorToCssColor(color),
}
}
style={color == null ? {} : { backgroundColor: backend.lChColorToCssColor(color) }}
onInput={event => {
setName(event.currentTarget.value)
}}
@ -103,7 +97,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
event.preventDefault()
}}
>
<div className="text w-modal-label">Color</div>
<div className="text w-modal-label">{getText('color')}</div>
<div className="grow">
<ColorPicker setColor={setColor} />
</div>
@ -114,10 +108,10 @@ export default function NewLabelModal(props: NewLabelModalProps) {
type="submit"
className="button bg-invite text-white enabled:active"
>
Create
{getText('create')}
</button>
<button type="button" className="button bg-selected-frame active" onClick={unsetModal}>
Cancel
{getText('cancel')}
</button>
</div>
</form>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import SCHEMA from '#/data/dataLinkSchema.json' assert { type: 'json' }
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import DataLinkInput from '#/components/dashboard/DataLinkInput'
import Modal from '#/components/Modal'
@ -32,6 +33,7 @@ export interface UpsertDataLinkModalProps {
export default function UpsertDataLinkModal(props: UpsertDataLinkModalProps) {
const { doCreate } = props
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [name, setName] = React.useState('')
const [value, setValue] = React.useState<NonNullable<unknown> | null>(INITIAL_DATA_LINK_VALUE)
const isValueSubmittable = React.useMemo(() => validateDataLink.validateDataLink(value), [value])
@ -55,12 +57,12 @@ export default function UpsertDataLinkModal(props: UpsertDataLinkModalProps) {
doCreate(name, value)
}}
>
<h1 className="relative text-sm font-semibold">Create Data Link</h1>
<div className="relative flex items-center" title="Must not be blank.">
<div className="text w-modal-label">Name</div>
<h1 className="relative text-sm font-semibold">{getText('createDataLink')}</h1>
<div className="relative flex items-center" title={getText('mustNotBeBlank')}>
<div className="text w-modal-label">{getText('name')}</div>
<input
autoFocus
placeholder="Enter the name of the Data Link"
placeholder={getText('dataLinkNamePlaceholder')}
className={`text grow rounded-full border bg-transparent px-input-x ${
name !== '' ? 'border-primary/10' : 'border-red-700/60'
}`}
@ -79,10 +81,10 @@ export default function UpsertDataLinkModal(props: UpsertDataLinkModalProps) {
disabled={!isSubmittable}
className="button bg-invite text-white enabled:active"
>
Create
{getText('create')}
</button>
<button type="button" className="button bg-selected-frame active" onClick={unsetModal}>
Cancel
{getText('cancel')}
</button>
</div>
</form>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import Modal from '#/components/Modal'
@ -25,6 +26,7 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
const { id, name: nameRaw, doCreate } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { unsetModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const [name, setName] = React.useState(nameRaw ?? '')
const [value, setValue] = React.useState('')
@ -61,14 +63,14 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
}}
>
<h1 className="relative text-sm font-semibold">
{isCreatingSecret ? 'New Secret' : 'Edit Secret'}
{isCreatingSecret ? getText('newSecret') : getText('editSecret')}
</h1>
<label className="relative flex h-row items-center">
<div className="text w-modal-label">Name</div>
<div className="text w-modal-label">{getText('name')}</div>
<input
autoFocus
disabled={!isNameEditable}
placeholder="Enter the name of the secret"
placeholder={getText('secretNamePlaceholder')}
className="text grow rounded-full border border-primary/10 bg-transparent px-input-x selectable enabled:active"
value={name}
onInput={event => {
@ -77,10 +79,12 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
/>
</label>
<label className="relative flex h-row items-center">
<div className="text w-modal-label">Value</div>
<div className="text w-modal-label">{getText('value')}</div>
<input
autoFocus={!isNameEditable}
placeholder={isNameEditable ? 'Enter the value of the secret' : '●●●●●●●●'}
placeholder={
isNameEditable ? getText('secretValuePlaceholder') : getText('secretValueHidden')
}
className="text grow rounded-full border border-primary/10 bg-transparent px-input-x"
onInput={event => {
setValue(event.currentTarget.value)
@ -89,10 +93,10 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
</label>
<div className="relative flex gap-buttons">
<button disabled={!canSubmit} type="submit" className="button bg-invite text-white">
{isCreatingSecret ? 'Create' : 'Update'}
{isCreatingSecret ? getText('create') : getText('update')}
</button>
<button type="button" className="button bg-selected-frame" onClick={unsetModal}>
Cancel
{getText('cancel')}
</button>
</div>
</form>

View File

@ -3,14 +3,13 @@
import * as React from 'react'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
// ===========================
// === ConfirmRegistration ===
@ -18,7 +17,7 @@ import * as loggerProvider from '#/providers/LoggerProvider'
/** An empty component redirecting users based on the backend response to user registration. */
export default function ConfirmRegistration() {
const logger = loggerProvider.useLogger()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const auth = authProvider.useAuth()
const location = router.useLocation()
const navigate = navigateHooks.useNavigate()
@ -41,10 +40,7 @@ export default function ConfirmRegistration() {
navigate(appUtils.LOGIN_PATH + location.search.toString())
}
} catch (error) {
logger.error('Error while confirming sign-up', error)
toastify.toast.error(
'Something went wrong! Please try again or contact the administrators.'
)
toastAndLog('registrationError')
navigate(appUtils.LOGIN_PATH)
}
})()

View File

@ -9,6 +9,7 @@ import GoBackIcon from 'enso-assets/go_back.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import Input from '#/components/Input'
import Link from '#/components/Link'
@ -21,7 +22,7 @@ import SubmitButton from '#/components/SubmitButton'
/** A form for users to request for their password to be reset. */
export default function ForgotPassword() {
const { forgotPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const [email, setEmail] = React.useState('')
return (
@ -33,20 +34,20 @@ export default function ForgotPassword() {
await forgotPassword(email)
}}
>
<div className="self-center text-xl font-medium">Forgot Your Password?</div>
<div className="self-center text-xl font-medium">{getText('forgotYourPassword')}</div>
<Input
required
validate
type="email"
autoComplete="email"
icon={AtIcon}
placeholder="Enter your email"
placeholder={getText('emailPlaceholder')}
value={email}
setValue={setEmail}
/>
<SubmitButton text="Send link" icon={ArrowRightIcon} />
<SubmitButton text={getText('sendLink')} icon={ArrowRightIcon} />
</form>
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text="Go back to login" />
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('goBackToLogin')} />
</div>
)
}

View File

@ -1,7 +1,7 @@
/** @file A loading screen, displayed while the user is logging in. */
import * as React from 'react'
import * as common from 'enso-common'
import * as textProvider from '#/providers/TextProvider'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
@ -18,6 +18,7 @@ const SPINNER_SIZE_PX = 64
/** A loading screen. */
export default function LoadingScreen() {
const { getText } = textProvider.useText()
return (
<div className="grid h-screen w-screen place-items-center text-primary">
<div className="flex flex-col items-center gap-status-page text-center text-base">
@ -25,7 +26,7 @@ export default function LoadingScreen() {
state={statelessSpinner.SpinnerState.loadingMedium}
size={SPINNER_SIZE_PX}
/>
<span>Logging in to {common.PRODUCT_NAME}...</span>
<span>{getText('loadingAppMessage')}</span>
</div>
</div>
)

View File

@ -12,14 +12,13 @@ import LockIcon from 'enso-assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import FontAwesomeIcon from '#/components/FontAwesomeIcon'
import Input from '#/components/Input'
import Link from '#/components/Link'
import SubmitButton from '#/components/SubmitButton'
import * as validation from '#/utilities/validation'
// =============
// === Login ===
// =============
@ -34,6 +33,7 @@ export default function Login(props: LoginProps) {
const { supportsLocalBackend } = props
const location = router.useLocation()
const { signInWithGoogle, signInWithGitHub, signInWithPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email')
@ -46,7 +46,7 @@ export default function Login(props: LoginProps) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-auth text-sm text-primary">
<div className="flex w-full max-w-md flex-col gap-auth rounded-auth bg-selected-frame p-auth shadow-md">
<div className="self-center text-xl font-medium">Login to your account</div>
<div className="self-center text-xl font-medium">{getText('loginToYourAccount')}</div>
<div className="flex flex-col gap-auth">
<button
onMouseDown={() => {
@ -59,7 +59,7 @@ export default function Login(props: LoginProps) {
className="relative rounded-full bg-cloud/10 py-auth-input-y transition-all duration-auth hover:bg-cloud/20 focus:bg-cloud/20"
>
<FontAwesomeIcon icon={fontawesomeIcons.faGoogle} />
Sign up or login with Google
{getText('signUpOrLoginWithGoogle')}
</button>
<button
onMouseDown={() => {
@ -72,7 +72,7 @@ export default function Login(props: LoginProps) {
className="relative rounded-full bg-cloud/10 py-auth-input-y transition-all duration-auth hover:bg-cloud/20 focus:bg-cloud/20"
>
<FontAwesomeIcon icon={fontawesomeIcons.faGithub} />
Sign up or login with GitHub
{getText('signUpOrLoginWithGitHub')}
</button>
</div>
<div />
@ -92,7 +92,7 @@ export default function Login(props: LoginProps) {
type="email"
autoComplete="email"
icon={AtIcon}
placeholder="Enter your email"
placeholder={getText('emailPlaceholder')}
value={email}
setValue={setEmail}
shouldReportValidityRef={shouldReportValidityRef}
@ -105,8 +105,8 @@ export default function Login(props: LoginProps) {
type="password"
autoComplete="current-password"
icon={LockIcon}
placeholder="Enter your password"
error={validation.PASSWORD_ERROR}
placeholder={getText('passwordPlaceholder')}
error={getText('passwordValidationError')}
value={password}
setValue={setPassword}
shouldReportValidityRef={shouldReportValidityRef}
@ -115,22 +115,22 @@ export default function Login(props: LoginProps) {
to={appUtils.FORGOT_PASSWORD_PATH}
className="text-end text-xs text-blue-500 transition-all duration-auth hover:text-blue-700 focus:text-blue-700"
>
Forgot Your Password?
{getText('forgotYourPassword')}
</router.Link>
</div>
<SubmitButton disabled={isSubmitting} text="Login" icon={ArrowRightIcon} />
<SubmitButton disabled={isSubmitting} text={getText('login')} icon={ArrowRightIcon} />
</form>
</div>
<Link
to={appUtils.REGISTRATION_PATH}
icon={CreateAccountIcon}
text="Don't have an account?"
text={getText('dontHaveAnAccount')}
/>
{supportsLocalBackend && (
<Link
to={appUtils.ENTER_OFFLINE_MODE_PATH}
icon={ArrowRightIcon}
text="Continue without creating an account"
text={getText('continueWithoutCreatingAnAccount')}
/>
)}
</div>

View File

@ -12,6 +12,7 @@ import * as appUtils from '#/appUtils'
import * as authProvider from '#/providers/AuthProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as textProvider from '#/providers/TextProvider'
import Input from '#/components/Input'
import Link from '#/components/Link'
@ -46,6 +47,7 @@ export default function Registration() {
const auth = authProvider.useAuth()
const location = router.useLocation()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const query = new URLSearchParams(location.search)
const initialEmail = query.get('email')
@ -76,14 +78,16 @@ export default function Registration() {
setIsSubmitting(false)
}}
>
<div className="self-center text-auth-heading font-medium">Create a new account</div>
<div className="self-center text-auth-heading font-medium">
{getText('createANewAccount')}
</div>
<Input
required
validate
type="email"
autoComplete="email"
icon={AtIcon}
placeholder="Enter your email"
placeholder={getText('emailPlaceholder')}
value={email}
setValue={setEmail}
/>
@ -94,9 +98,9 @@ export default function Registration() {
type="password"
autoComplete="new-password"
icon={LockIcon}
placeholder="Enter your password"
placeholder={getText('passwordPlaceholder')}
pattern={validation.PASSWORD_PATTERN}
error={validation.PASSWORD_ERROR}
error={getText('passwordValidationError')}
value={password}
setValue={setPassword}
/>
@ -107,15 +111,15 @@ export default function Registration() {
type="password"
autoComplete="new-password"
icon={LockIcon}
placeholder="Confirm your password"
placeholder={getText('confirmPasswordPlaceholder')}
pattern={string.regexEscape(password)}
error={validation.CONFIRM_PASSWORD_ERROR}
error={getText('passwordMismatchError')}
value={confirmPassword}
setValue={setConfirmPassword}
/>
<SubmitButton disabled={isSubmitting} text="Register" icon={CreateAccountIcon} />
<SubmitButton disabled={isSubmitting} text={getText('register')} icon={CreateAccountIcon} />
</form>
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text="Already have an account?" />
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('alreadyHaveAnAccount')} />
</div>
)
}

View File

@ -3,7 +3,6 @@
import * as React from 'react'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import ArrowRightIcon from 'enso-assets/arrow_right.svg'
import GoBackIcon from 'enso-assets/go_back.svg'
@ -12,8 +11,10 @@ import LockIcon from 'enso-assets/lock.svg'
import * as appUtils from '#/appUtils'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider'
import Input from '#/components/Input'
import Link from '#/components/Link'
@ -29,8 +30,10 @@ import * as validation from '#/utilities/validation'
/** A form for users to reset their password. */
export default function ResetPassword() {
const { resetPassword } = authProvider.useAuth()
const { getText } = textProvider.useText()
const location = router.useLocation()
const navigate = navigateHooks.useNavigate()
const toastAndLog = toastAndLogHooks.useToastAndLog()
const query = new URLSearchParams(location.search)
const email = query.get('email')
@ -41,17 +44,17 @@ export default function ResetPassword() {
React.useEffect(() => {
if (email == null) {
toastify.toast.error('Could not reset password: missing email address')
toastAndLog('missingEmailError')
navigate(appUtils.LOGIN_PATH)
} else if (verificationCode == null) {
toastify.toast.error('Could not reset password: missing verification code')
toastAndLog('missingVerificationCodeError')
navigate(appUtils.LOGIN_PATH)
}
}, [email, navigate, verificationCode])
}, [email, navigate, verificationCode, getText, /* should never change */ toastAndLog])
const onSubmit = () => {
if (newPassword !== newPasswordConfirm) {
toastify.toast.error('Passwords do not match')
toastAndLog('passwordMismatchError')
return Promise.resolve()
} else {
// These should never be nullish, as the effect should immediately navigate away.
@ -68,14 +71,14 @@ export default function ResetPassword() {
await onSubmit()
}}
>
<div className="self-center text-xl font-medium">Reset your password</div>
<div className="self-center text-xl font-medium">{getText('resetYourPassword')}</div>
<input
required
readOnly
hidden
type="email"
autoComplete="email"
placeholder="Enter your email"
placeholder={getText('emailPlaceholder')}
value={email ?? ''}
/>
<input
@ -84,7 +87,7 @@ export default function ResetPassword() {
hidden
type="text"
autoComplete="one-time-code"
placeholder="Enter the confirmation code"
placeholder={getText('confirmationCodePlaceholder')}
value={verificationCode ?? ''}
/>
<Input
@ -94,9 +97,9 @@ export default function ResetPassword() {
type="password"
autoComplete="new-password"
icon={LockIcon}
placeholder="Enter your new password"
placeholder={getText('newPasswordPlaceholder')}
pattern={validation.PASSWORD_PATTERN}
error={validation.PASSWORD_ERROR}
error={getText('passwordValidationError')}
value={newPassword}
setValue={setNewPassword}
/>
@ -107,15 +110,15 @@ export default function ResetPassword() {
type="password"
autoComplete="new-password"
icon={LockIcon}
placeholder="Confirm your new password"
placeholder={getText('confirmNewPasswordPlaceholder')}
pattern={string.regexEscape(newPassword)}
error={validation.CONFIRM_PASSWORD_ERROR}
error={getText('passwordMismatchError')}
value={newPasswordConfirm}
setValue={setNewPasswordConfirm}
/>
<SubmitButton text="Reset" icon={ArrowRightIcon} />
<SubmitButton text={getText('reset')} icon={ArrowRightIcon} />
</form>
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text="Go back to login" />
<Link to={appUtils.LOGIN_PATH} icon={GoBackIcon} text={getText('goBackToLogin')} />
</div>
)
}

View File

@ -7,6 +7,7 @@ import AtIcon from 'enso-assets/at.svg'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import Input from '#/components/Input'
import SubmitButton from '#/components/SubmitButton'
@ -20,6 +21,7 @@ export default function SetUsername() {
const { setUsername: authSetUsername } = authProvider.useAuth()
const { email } = authProvider.usePartialUserSession()
const { backend } = backendProvider.useBackend()
const { getText } = textProvider.useText()
const [username, setUsername] = React.useState('')
@ -33,18 +35,18 @@ export default function SetUsername() {
await authSetUsername(backend, username, email)
}}
>
<div className="self-center text-xl font-medium">Set your username</div>
<div className="self-center text-xl font-medium">{getText('setYourUsername')}</div>
<Input
id="username"
type="text"
name="username"
autoComplete="off"
icon={AtIcon}
placeholder="Enter your username"
placeholder={getText('usernamePlaceholder')}
value={username}
setValue={setUsername}
/>
<SubmitButton text="Set username" icon={ArrowRightIcon} />
<SubmitButton text={getText('setUsername')} icon={ArrowRightIcon} />
</form>
</div>
)

View File

@ -13,6 +13,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
@ -123,6 +124,7 @@ export default function Dashboard(props: DashboardProps) {
const { modalRef } = modalProvider.useModalRef()
const { updateModal, unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings()
const [initialized, setInitialized] = React.useState(false)
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
@ -212,7 +214,7 @@ export default function Dashboard(props: DashboardProps) {
const httpClient = new HttpClient(
new Headers([['Authorization', `Bearer ${session.accessToken}`]])
)
const remoteBackend = new RemoteBackend(httpClient, logger)
const remoteBackend = new RemoteBackend(httpClient, logger, getText)
void (async () => {
const abortController = new AbortController()
setOpenProjectAbortController(abortController)
@ -367,7 +369,7 @@ export default function Dashboard(props: DashboardProps) {
const client = new HttpClient([
['Authorization', `Bearer ${session.accessToken ?? ''}`],
])
setBackend(new RemoteBackend(client, logger))
setBackend(new RemoteBackend(client, logger, getText))
break
}
}
@ -377,6 +379,7 @@ export default function Dashboard(props: DashboardProps) {
backend.type,
session.accessToken,
logger,
getText,
/* should never change */ projectManagerUrl,
/* should never change */ setBackend,
]

View File

@ -7,11 +7,13 @@ import * as stripe from '@stripe/stripe-js/pure'
import * as toast from 'react-toastify'
import * as appUtils from '#/appUtils'
import type * as text from '#/text'
import * as navigateHooks from '#/hooks/navigateHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import Modal from '#/components/Modal'
@ -29,6 +31,11 @@ let stripePromise: Promise<stripeTypes.Stripe | null> | null = null
/** The delay in milliseconds before redirecting back to the main page. */
const REDIRECT_DELAY_MS = 1_500
const PLAN_TO_TEXT_ID: Readonly<Record<backendModule.Plan, text.TextId>> = {
[backendModule.Plan.solo]: 'soloPlanName',
[backendModule.Plan.team]: 'teamPlanName',
} satisfies { [Plan in backendModule.Plan]: `${Plan}PlanName` }
// =================
// === Subscribe ===
// =================
@ -46,6 +53,7 @@ const REDIRECT_DELAY_MS = 1_500
* sessionStatus.status = { status: 'complete',
* paymentStatus: 'no_payment_required' || 'paid' || 'unpaid' }`). */
export default function Subscribe() {
const { getText } = textProvider.useText()
const navigate = navigateHooks.useNavigate()
// Plan that the user has currently selected, if any (e.g., 'solo', 'team', etc.).
const [plan, setPlan] = React.useState(() => {
@ -121,7 +129,9 @@ export default function Subscribe() {
event.stopPropagation()
}}
>
<div className="self-center text-xl">Upgrade to {string.capitalizeFirst(plan)}</div>
<div className="self-center text-xl">
{getText('upgradeTo', string.capitalizeFirst(plan))}
</div>
<div className="flex h-row items-stretch rounded-full bg-gray-500/30 text-base">
{backendModule.PLANS.map(newPlan => (
<button
@ -134,7 +144,7 @@ export default function Subscribe() {
setPlan(newPlan)
}}
>
{string.capitalizeFirst(newPlan)}
{PLAN_TO_TEXT_ID[newPlan]}
</button>
))}
</div>

View File

@ -18,6 +18,7 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as sessionProvider from '#/providers/SessionProvider'
import * as textProvider from '#/providers/TextProvider'
import LoadingScreen from '#/pages/authentication/LoadingScreen'
@ -157,6 +158,7 @@ export default function AuthProvider(props: AuthProviderProps) {
const { session, deinitializeSession, onSessionError } = sessionProvider.useSession()
const { setBackendWithoutSavingType } = backendProvider.useSetBackend()
const { localStorage } = localStorageProvider.useLocalStorage()
const { getText } = textProvider.useText()
// This must not be `hooks.useNavigate` as `goOffline` would be inaccessible,
// and the function call would error.
// eslint-disable-next-line no-restricted-properties
@ -191,9 +193,10 @@ export default function AuthProvider(props: AuthProviderProps) {
// Provide dummy headers to avoid errors. This `Backend` will never be called as
// the entire UI will be disabled.
const client = new HttpClient([['Authorization', '']])
setBackendWithoutSavingType(new RemoteBackend(client, logger))
setBackendWithoutSavingType(new RemoteBackend(client, logger, getText))
}
}, [
getText,
/* should never change */ projectManagerUrl,
/* should never change */ supportsLocalBackend,
/* should never change */ logger,
@ -209,7 +212,7 @@ export default function AuthProvider(props: AuthProviderProps) {
navigate(appUtils.DASHBOARD_PATH)
return Promise.resolve(true)
},
[/* should never change */ goOfflineInternal, /* should never change */ navigate]
[goOfflineInternal, /* should never change */ navigate]
)
// This component cannot use `useGtagEvent` because `useGtagEvent` depends on the React Context
@ -277,7 +280,7 @@ export default function AuthProvider(props: AuthProviderProps) {
}
} else {
const client = new HttpClient([['Authorization', `Bearer ${session.accessToken}`]])
const backend = new RemoteBackend(client, logger)
const backend = new RemoteBackend(client, logger, getText)
// The backend MUST be the remote backend before login is finished.
// This is because the "set username" flow requires the remote backend.
if (!initialized || userSession == null || userSession.type === UserSessionType.offline) {
@ -393,7 +396,7 @@ export default function AuthProvider(props: AuthProviderProps) {
const withLoadingToast =
<T extends unknown[], R>(action: (...args: T) => Promise<R>) =>
async (...args: T) => {
toast.toast.loading('Please wait...', { toastId })
toast.toast.loading(getText('pleaseWait'), { toastId })
return await action(...args)
}
@ -428,7 +431,7 @@ export default function AuthProvider(props: AuthProviderProps) {
gtagEvent('cloud_sign_up')
const result = await cognito.signUp(username, password, organizationId)
if (result.ok) {
toastSuccess('We have sent you an email with further instructions!')
toastSuccess(getText('signUpSuccess'))
navigate(appUtils.LOGIN_PATH)
} else {
toastError(result.val.message)
@ -449,7 +452,7 @@ export default function AuthProvider(props: AuthProviderProps) {
break
}
case cognitoModule.CognitoErrorType.userNotFound: {
toastError('Incorrect email or confirmation code.')
toastError(getText('confirmSignUpError'))
navigate(appUtils.LOGIN_PATH)
return false
}
@ -458,7 +461,7 @@ export default function AuthProvider(props: AuthProviderProps) {
}
}
}
toastSuccess('Your account has been confirmed! Please log in.')
toastSuccess(getText('confirmSignUpSuccess'))
navigate(appUtils.LOGIN_PATH)
return result.ok
}
@ -471,7 +474,7 @@ export default function AuthProvider(props: AuthProviderProps) {
gtagEvent('cloud_sign_in', { provider: 'Email' })
const result = await cognito.signInWithPassword(email, password)
if (result.ok) {
toastSuccess('Successfully logged in!')
toastSuccess(getText('signInWithPasswordSuccess'))
} else {
if (result.val.type === cognitoModule.CognitoErrorType.userNotFound) {
// It may not be safe to pass the user's password in the URL.
@ -487,7 +490,7 @@ export default function AuthProvider(props: AuthProviderProps) {
if (cognito == null) {
return false
} else if (backend.type === backendModule.BackendType.local) {
toastError('You cannot set your username on the local backend.')
toastError(getText('setUsernameLocalBackend'))
return false
} else {
gtagEvent('cloud_user_created')
@ -503,9 +506,9 @@ export default function AuthProvider(props: AuthProviderProps) {
organizationId != null ? backendModule.OrganizationId(organizationId) : null,
}),
{
success: 'Your username has been set!',
error: 'Could not set your username.',
pending: 'Setting username...',
success: getText('setUsernameSuccess'),
error: getText('setUsernameError'),
pending: getText('settingUsername'),
}
)
const redirectTo = localStorage.get('loginRedirect')
@ -528,7 +531,7 @@ export default function AuthProvider(props: AuthProviderProps) {
} else {
const result = await cognito.forgotPassword(email)
if (result.ok) {
toastSuccess('We have sent you an email with further instructions!')
toastSuccess(getText('forgotPasswordSuccess'))
navigate(appUtils.LOGIN_PATH)
} else {
toastError(result.val.message)
@ -543,7 +546,7 @@ export default function AuthProvider(props: AuthProviderProps) {
} else {
const result = await cognito.forgotPasswordSubmit(email, code, password)
if (result.ok) {
toastSuccess('Successfully reset password!')
toastSuccess(getText('resetPasswordSuccess'))
navigate(appUtils.LOGIN_PATH)
} else {
toastError(result.val.message)
@ -558,7 +561,7 @@ export default function AuthProvider(props: AuthProviderProps) {
} else {
const result = await cognito.changePassword(oldPassword, newPassword)
if (result.ok) {
toastSuccess('Successfully changed password!')
toastSuccess(getText('changePasswordSuccess'))
} else {
toastError(result.val.message)
}
@ -582,9 +585,9 @@ export default function AuthProvider(props: AuthProviderProps) {
// This should not omit success and error toasts as it is not possible
// to render this optimistically.
await toast.toast.promise(cognito.signOut(), {
success: 'Successfully logged out!',
error: 'Could not log out, please try again.',
pending: 'Logging out...',
success: getText('signOutSuccess'),
error: getText('signOutError'),
pending: getText('loggingOut'),
})
return true
}

View File

@ -0,0 +1,63 @@
/** @file The React provider for localized, along with hooks to use the provider via the shared
* React context. */
import * as React from 'react'
import * as text from '#/text'
import * as object from '#/utilities/object'
// ===================
// === TextContext ===
// ===================
/** State contained in a `TextContext`. */
export interface TextContextType {
readonly language: text.Language
readonly setLanguage: (newLanguage: text.Language) => void
}
const TextContext = React.createContext<TextContextType>({
language: text.Language.english,
/** Set `this.language`. It is NOT RECOMMENDED to use the default value, as this does not trigger
* reactive updates. */
setLanguage(language) {
object.unsafeMutable(this).language = language
},
})
/** Props for a {@link TextProvider}. */
export interface TextProviderProps extends Readonly<React.PropsWithChildren> {}
// ====================
// === TextProvider ===
// ====================
/** A React Provider that lets components get the current language. */
export default function TextProvider(props: TextProviderProps) {
const { children } = props
const [language, setLanguage] = React.useState(text.Language.english)
return <TextContext.Provider value={{ language, setLanguage }}>{children}</TextContext.Provider>
}
/** Exposes a property to get localized text, and get and set the current language. */
export function useText() {
const { language, setLanguage } = React.useContext(TextContext)
const localizedText = text.TEXTS[language]
const getText = React.useCallback(
<K extends text.TextId>(key: K, ...replacements: text.Replacements[K]) => {
const template = localizedText[key]
return replacements.length === 0
? template
: template.replace(/[$]([$]|\d+)/g, (_match, placeholder: string) =>
placeholder === '$'
? '$'
: String(replacements[Number(placeholder)] ?? `$${placeholder}`)
)
},
[localizedText]
)
return { language, setLanguage, getText }
}

View File

@ -575,9 +575,9 @@ export enum AssetType {
directory = 'directory',
/** A special {@link AssetType} representing the unknown items of a directory, before the
* request to retrieve the items completes. */
specialLoading = 'special-loading',
specialLoading = 'specialLoading',
/** A special {@link AssetType} representing the sole child of an empty directory. */
specialEmpty = 'special-empty',
specialEmpty = 'specialEmpty',
}
/** The corresponding ID newtype for each {@link AssetType}. */
@ -591,17 +591,6 @@ export interface IdType {
readonly [AssetType.specialEmpty]: EmptyAssetId
}
/** The english name of each asset type. */
export const ASSET_TYPE_NAME: Readonly<Record<AssetType, string>> = {
[AssetType.directory]: 'folder',
[AssetType.project]: 'project',
[AssetType.file]: 'file',
[AssetType.dataLink]: 'Data Link',
[AssetType.secret]: 'secret',
[AssetType.specialLoading]: 'special loading asset',
[AssetType.specialEmpty]: 'special empty asset',
}
/** Integers (starting from 0) corresponding to the order in which each asset type should appear
* in a directory listing. */
export const ASSET_TYPE_ORDER: Readonly<Record<AssetType, number>> = {
@ -1125,65 +1114,60 @@ export default abstract class Backend {
/** Return user details for the current user. */
abstract usersMe(): Promise<User | null>
/** Return a list of assets in a directory. */
abstract listDirectory(
query: ListDirectoryRequestParams,
title: string | null
): Promise<AnyAsset[]>
abstract listDirectory(query: ListDirectoryRequestParams, title: string): Promise<AnyAsset[]>
/** Create a directory. */
abstract createDirectory(body: CreateDirectoryRequestBody): Promise<CreatedDirectory>
/** Change the name of a directory. */
abstract updateDirectory(
directoryId: DirectoryId,
body: UpdateDirectoryRequestBody,
title: string | null
title: string
): Promise<UpdatedDirectory>
/** List previous versions of an asset. */
abstract listAssetVersions(assetId: AssetId, title: string | null): Promise<AssetVersions>
/** Change the parent directory of an asset. */
abstract updateAsset(
assetId: AssetId,
body: UpdateAssetRequestBody,
title: string | null
): Promise<void>
abstract updateAsset(assetId: AssetId, body: UpdateAssetRequestBody, title: string): Promise<void>
/** Delete an arbitrary asset. */
abstract deleteAsset(assetId: AssetId, force: boolean, title: string | null): Promise<void>
abstract deleteAsset(assetId: AssetId, force: boolean, title: string): Promise<void>
/** Restore an arbitrary asset from the trash. */
abstract undoDeleteAsset(assetId: AssetId, title: string | null): Promise<void>
abstract undoDeleteAsset(assetId: AssetId, title: string): Promise<void>
/** Copy an arbitrary asset to another directory. */
abstract copyAsset(
assetId: AssetId,
parentDirectoryId: DirectoryId,
title: string | null,
parentDirectoryTitle: string | null
title: string,
parentDirectoryTitle: string
): Promise<CopyAssetResponse>
/** Return a list of projects belonging to the current user. */
abstract listProjects(): Promise<ListedProject[]>
/** Create a project for the current user. */
abstract createProject(body: CreateProjectRequestBody): Promise<CreatedProject>
/** Close a project. */
abstract closeProject(projectId: ProjectId, title: string | null): Promise<void>
abstract closeProject(projectId: ProjectId, title: string): Promise<void>
/** Return project details. */
abstract getProjectDetails(projectId: ProjectId, title: string | null): Promise<Project>
abstract getProjectDetails(projectId: ProjectId, title: string): Promise<Project>
/** Set a project to an open state. */
abstract openProject(
projectId: ProjectId,
body: OpenProjectRequestBody | null,
title: string | null
title: string
): Promise<void>
/** Change the AMI or IDE version of a project. */
abstract updateProject(
projectId: ProjectId,
body: UpdateProjectRequestBody,
title: string | null
title: string
): Promise<UpdatedProject>
/** Fetch the content of the `Main.enso` file of a project. */
abstract getFileContent(projectId: ProjectId, version: string, title: string): Promise<string>
/** Return project memory, processor and storage usage. */
abstract checkResources(projectId: ProjectId, title: string | null): Promise<ResourceUsage>
abstract checkResources(projectId: ProjectId, title: string): Promise<ResourceUsage>
/** Return a list of files accessible by the current user. */
abstract listFiles(): Promise<FileLocator[]>
/** Upload a file. */
abstract uploadFile(params: UploadFileRequestParams, file: Blob): Promise<FileInfo>
/** Return file details. */
abstract getFileDetails(fileId: FileId, title: string | null): Promise<FileDetails>
abstract getFileDetails(fileId: FileId, title: string): Promise<FileDetails>
/** Create a Data Link. */
abstract createConnector(body: CreateConnectorRequestBody): Promise<ConnectorInfo>
/** Return a Data Link. */
@ -1193,12 +1177,12 @@ export default abstract class Backend {
/** Create a secret environment variable. */
abstract createSecret(body: CreateSecretRequestBody): Promise<SecretId>
/** Return a secret environment variable. */
abstract getSecret(secretId: SecretId, title: string | null): Promise<Secret>
abstract getSecret(secretId: SecretId, title: string): Promise<Secret>
/** Change the value of a secret. */
abstract updateSecret(
secretId: SecretId,
body: UpdateSecretRequestBody,
title: string | null
title: string
): Promise<void>
/** Return the secret environment variables accessible by the user. */
abstract listSecrets(): Promise<SecretInfo[]>
@ -1207,7 +1191,7 @@ export default abstract class Backend {
/** Return all labels accessible by the user. */
abstract listTags(): Promise<Label[]>
/** Set the full list of labels for a specific asset. */
abstract associateTag(assetId: AssetId, tagIds: LabelName[], title: string | null): Promise<void>
abstract associateTag(assetId: AssetId, tagIds: LabelName[], title: string): Promise<void>
/** Delete a label. */
abstract deleteTag(tagId: TagId, value: LabelName): Promise<void>
/** Return a list of backend or IDE versions. */

View File

@ -426,6 +426,11 @@ export default class LocalBackend extends Backend {
return this.invalidOperation()
}
/** Invalid operation. */
override getFileContent() {
return this.invalidOperation()
}
/** Invalid operation. */
override createConnector() {
return this.invalidOperation()

View File

@ -5,7 +5,10 @@
* the response from the API. */
import * as detect from 'enso-common/src/detect'
import type * as text from '#/text'
import type * as loggerProvider from '#/providers/LoggerProvider'
import type * as textProvider from '#/providers/TextProvider'
import Backend, * as backendModule from '#/services/Backend'
import * as remoteBackendPaths from '#/services/remoteBackendPaths'
@ -127,6 +130,10 @@ export interface ListVersionsResponseBody {
// === RemoteBackend ===
// =====================
/** A function that turns a text ID (and a list of replacements, if required) to
* human-readable text. */
type GetText = ReturnType<typeof textProvider.useText>['getText']
/** Information for a cached default version. */
interface DefaultVersionInfo {
readonly version: backendModule.VersionNumber
@ -136,19 +143,20 @@ interface DefaultVersionInfo {
/** Class for sending requests to the Cloud backend API endpoints. */
export default class RemoteBackend extends Backend {
readonly type = backendModule.BackendType.remote
protected defaultVersions: Partial<Record<backendModule.VersionType, DefaultVersionInfo>> = {}
private defaultVersions: Partial<Record<backendModule.VersionType, DefaultVersionInfo>> = {}
/** Create a new instance of the {@link RemoteBackend} API client.
* @throws An error if the `Authorization` header is not set on the given `client`. */
constructor(
private readonly client: HttpClient,
private readonly logger: loggerProvider.Logger
private readonly logger: loggerProvider.Logger,
private getText: ReturnType<typeof textProvider.useText>['getText']
) {
super()
// All of our API endpoints are authenticated, so we expect the `Authorization` header to be
// set.
if (!new Headers(this.client.defaultHeaders).has('Authorization')) {
const message = 'Authorization header not set'
const message = 'Authorization header not set.'
this.logger.error(message)
throw new Error(message)
} else {
@ -161,16 +169,26 @@ export default class RemoteBackend extends Backend {
}
}
/** Set `this.getText`. This function is exposed rather than the property itself to make it clear
* that it is intended to be mutable. */
setGetText(getText: GetText) {
this.getText = getText
}
/** Log an error message and throws an {@link Error} with the specified message.
* @throws {Error} Always. */
async throw(prefix: string, response: Response | null): Promise<never> {
async throw<K extends Extract<text.TextId, `${string}BackendError`>>(
response: Response | null,
textId: K,
...replacements: text.Replacements[K]
): Promise<never> {
const error =
response == null
? { message: 'unknown error' }
: // This is SAFE only when the response has been confirmed to have an erroring status code.
// eslint-disable-next-line no-restricted-syntax
((await response.json()) as RemoteBackendError)
const message = `${prefix}: ${error.message}.`
const message = `${this.getText(textId, ...replacements)}: ${error.message}.`
this.logger.error(message)
throw new Error(message)
}
@ -180,7 +198,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.LIST_USERS_PATH
const response = await this.get<ListUsersResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not list users in the organization`, response)
return await this.throw(response, 'listUsersBackendError')
} else {
return (await response.json()).users
}
@ -193,7 +211,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_USER_PATH
const response = await this.post<backendModule.User>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw('Could not create user', response)
return await this.throw(response, 'createUserBackendError')
} else {
return await response.json()
}
@ -204,11 +222,9 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.UPDATE_CURRENT_USER_PATH
const response = await this.put(path, body)
if (!responseIsSuccessful(response)) {
if (body.username != null) {
return this.throw('Could not change username', response)
} else {
return this.throw('Could not update user', response)
}
return body.username != null
? await this.throw(response, 'updateUsernameBackendError')
: await this.throw(response, 'updateUserBackendError')
} else {
return
}
@ -218,7 +234,7 @@ export default class RemoteBackend extends Backend {
override async deleteUser(): Promise<void> {
const response = await this.delete(remoteBackendPaths.DELETE_USER_PATH)
if (!responseIsSuccessful(response)) {
return this.throw('Could not delete user', response)
return await this.throw(response, 'deleteUserBackendError')
} else {
return
}
@ -229,7 +245,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.INVITE_USER_PATH
const response = await this.post(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not invite user '${body.userEmail}'`, response)
return await this.throw(response, 'inviteUserBackendError', body.userEmail)
} else {
return
}
@ -248,7 +264,7 @@ export default class RemoteBackend extends Backend {
const path = `${remoteBackendPaths.UPLOAD_USER_PICTURE_PATH}?${paramsString}`
const response = await this.putBinary<backendModule.User>(path, file)
if (!responseIsSuccessful(response)) {
return this.throw('Could not upload user profile picture', response)
return await this.throw(response, 'uploadUserPictureBackendError')
} else {
return await response.json()
}
@ -263,7 +279,7 @@ export default class RemoteBackend extends Backend {
// Organization info has not yet been created.
return null
} else if (!responseIsSuccessful(response)) {
return this.throw('Could not get organization', response)
return await this.throw(response, 'getOrganizationBackendError')
} else {
return await response.json()
}
@ -280,7 +296,7 @@ export default class RemoteBackend extends Backend {
// Organization info has not yet been created.
return null
} else if (!responseIsSuccessful(response)) {
return this.throw('Could not update organization', response)
return await this.throw(response, 'updateOrganizationBackendError')
} else {
return await response.json()
}
@ -299,7 +315,7 @@ export default class RemoteBackend extends Backend {
const path = `${remoteBackendPaths.UPLOAD_ORGANIZATION_PICTURE_PATH}?${paramsString}`
const response = await this.putBinary<backendModule.OrganizationInfo>(path, file)
if (!responseIsSuccessful(response)) {
return this.throw('Could not upload user profile picture', response)
return await this.throw(response, 'uploadOrganizationPictureBackendError')
} else {
return await response.json()
}
@ -310,7 +326,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_PERMISSION_PATH
const response = await this.post<backendModule.User>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw('Could not set permissions', response)
return await this.throw(response, 'createPermissionBackendError')
} else {
return
}
@ -332,7 +348,7 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async listDirectory(
query: backendModule.ListDirectoryRequestParams,
title: string | null
title: string
): Promise<backendModule.AnyAsset[]> {
const path = remoteBackendPaths.LIST_DIRECTORY_PATH
const response = await this.get<ListDirectoryResponseBody>(
@ -350,13 +366,17 @@ export default class RemoteBackend extends Backend {
)
if (!responseIsSuccessful(response)) {
if (response.status === STATUS_SERVER_ERROR) {
this.logger.error(
query.parentId != null
? `Error listing directory '${query.parentId}'`
: `Error listing root directory`
)
// The directory is probably empty.
return []
} else if (query.parentId != null) {
const name = title != null ? `'${title}'` : `with ID '${query.parentId}'`
return this.throw(`Could not list folder ${name}`, response)
return await this.throw(response, 'listFolderBackendError', title)
} else {
return this.throw('Could not list root folder', response)
return await this.throw(response, 'listRootFolderBackendError')
}
} else {
return (await response.json()).assets
@ -382,7 +402,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_DIRECTORY_PATH
const response = await this.post<backendModule.CreatedDirectory>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not create folder with name '${body.title}'`, response)
return await this.throw(response, 'createFolderBackendError', body.title)
} else {
return await response.json()
}
@ -393,13 +413,12 @@ export default class RemoteBackend extends Backend {
override async updateDirectory(
directoryId: backendModule.DirectoryId,
body: backendModule.UpdateDirectoryRequestBody,
title: string | null
title: string
) {
const path = remoteBackendPaths.updateDirectoryPath(directoryId)
const response = await this.put<backendModule.UpdatedDirectory>(path, body)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${directoryId}'`
return this.throw(`Could not update folder ${name}`, response)
return await this.throw(response, 'updateFolderBackendError', title)
} else {
return await response.json()
}
@ -408,27 +427,28 @@ export default class RemoteBackend extends Backend {
/** List all previous versions of an asset. */
override async listAssetVersions(
assetId: backendModule.AssetId,
title: string | null
title: string
): Promise<backendModule.AssetVersions> {
const path = remoteBackendPaths.listAssetVersionsPath(assetId)
const response = await this.get<backendModule.AssetVersions>(path)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${assetId}'`
return this.throw(`Could not list versions for ${name}`, response)
return await this.throw(response, 'listAssetVersionsBackendError', title)
} else {
return await response.json()
}
}
/**
* Fetches the content of Main.enso file for a given project.
*/
async getFileContent(projectId: backendModule.ProjectId, version: string): Promise<string> {
/** Fetch the content of the `Main.enso` file of a project. */
override async getFileContent(
projectId: backendModule.ProjectId,
version: string,
title: string
): Promise<string> {
const path = remoteBackendPaths.getProjectContentPath(projectId, version)
const response = await this.get<string>(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not get content of file with ProjectID '${projectId}'`, response)
return this.throw(response, 'getFileContentsBackendError', title)
} else {
return await response.text()
}
@ -439,13 +459,12 @@ export default class RemoteBackend extends Backend {
override async updateAsset(
assetId: backendModule.AssetId,
body: backendModule.UpdateAssetRequestBody,
title: string | null
title: string
) {
const path = remoteBackendPaths.updateAssetPath(assetId)
const response = await this.patch(path, body)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `asset with ID '${assetId}'`
return this.throw(`Could not update ${name}.`, response)
return await this.throw(response, 'updateAssetBackendError', title)
} else {
return
}
@ -453,13 +472,12 @@ export default class RemoteBackend extends Backend {
/** Delete an arbitrary asset.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteAsset(assetId: backendModule.AssetId, force: boolean, title: string | null) {
override async deleteAsset(assetId: backendModule.AssetId, force: boolean, title: string) {
const paramsString = new URLSearchParams([['force', String(force)]]).toString()
const path = remoteBackendPaths.deleteAssetPath(assetId) + '?' + paramsString
const response = await this.delete(path)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `asset with ID '${assetId}'`
return this.throw(`Unable to delete ${name}.`, response)
return await this.throw(response, 'deleteAssetBackendError', title)
} else {
return
}
@ -467,19 +485,11 @@ export default class RemoteBackend extends Backend {
/** Restore an arbitrary asset from the trash.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async undoDeleteAsset(
assetId: backendModule.AssetId,
title: string | null
): Promise<void> {
override async undoDeleteAsset(assetId: backendModule.AssetId, title: string): Promise<void> {
const path = remoteBackendPaths.UNDO_DELETE_ASSET_PATH
const response = await this.patch(path, { assetId })
if (!responseIsSuccessful(response)) {
return this.throw(
`Unable to restore ${
title != null ? `'${title}'` : `asset with ID '${assetId}'`
} from Trash`,
response
)
return await this.throw(response, 'undoDeleteAssetBackendError', title)
} else {
return
}
@ -490,22 +500,15 @@ export default class RemoteBackend extends Backend {
override async copyAsset(
assetId: backendModule.AssetId,
parentDirectoryId: backendModule.DirectoryId,
title: string | null,
parentDirectoryTitle: string | null
title: string,
parentDirectoryTitle: string
): Promise<backendModule.CopyAssetResponse> {
const response = await this.post<backendModule.CopyAssetResponse>(
remoteBackendPaths.copyAssetPath(assetId),
{ parentDirectoryId }
)
if (!responseIsSuccessful(response)) {
return this.throw(
`Unable to copy ${title != null ? `'${title}'` : `asset with ID '${assetId}'`} to ${
parentDirectoryTitle != null
? `'${parentDirectoryTitle}'`
: `directory with ID '${parentDirectoryId}'`
}`,
response
)
return await this.throw(response, 'copyAssetBackendError', title, parentDirectoryTitle)
} else {
return await response.json()
}
@ -517,7 +520,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.LIST_PROJECTS_PATH
const response = await this.get<ListProjectsResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw('Could not list projects', response)
return await this.throw(response, 'listProjectsBackendError')
} else {
return (await response.json()).projects.map(project => ({
...project,
@ -537,7 +540,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_PROJECT_PATH
const response = await this.post<backendModule.CreatedProject>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not create project with name '${body.projectName}'`, response)
return await this.throw(response, 'createProjectBackendError', body.projectName)
} else {
return await response.json()
}
@ -545,17 +548,11 @@ export default class RemoteBackend extends Backend {
/** Close a project.
* @throws An error if a non-successful status code (not 200-299) was received. */
override async closeProject(
projectId: backendModule.ProjectId,
title: string | null
): Promise<void> {
override async closeProject(projectId: backendModule.ProjectId, title: string): Promise<void> {
const path = remoteBackendPaths.closeProjectPath(projectId)
const response = await this.post(path, {})
if (!responseIsSuccessful(response)) {
return this.throw(
`Could not close project ${title != null ? `'${title}'` : `with ID '${projectId}'`}`,
response
)
return await this.throw(response, 'closeProjectBackendError', title)
} else {
return
}
@ -565,17 +562,12 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getProjectDetails(
projectId: backendModule.ProjectId,
title: string | null
title: string
): Promise<backendModule.Project> {
const path = remoteBackendPaths.getProjectDetailsPath(projectId)
const response = await this.get<backendModule.ProjectRaw>(path)
if (!responseIsSuccessful(response)) {
return this.throw(
`Could not get details of project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}`,
response
)
return await this.throw(response, 'getProjectDetailsBackendError', title)
} else {
const project = await response.json()
const ideVersion =
@ -597,15 +589,12 @@ export default class RemoteBackend extends Backend {
override async openProject(
projectId: backendModule.ProjectId,
body: backendModule.OpenProjectRequestBody | null,
title: string | null
title: string
): Promise<void> {
const path = remoteBackendPaths.openProjectPath(projectId)
const response = await this.post(path, body ?? DEFAULT_OPEN_PROJECT_BODY)
if (!responseIsSuccessful(response)) {
return this.throw(
`Could not open project ${title != null ? `'${title}'` : `with ID '${projectId}'`}`,
response
)
return await this.throw(response, 'openProjectBackendError', title)
} else {
return
}
@ -616,15 +605,12 @@ export default class RemoteBackend extends Backend {
override async updateProject(
projectId: backendModule.ProjectId,
body: backendModule.UpdateProjectRequestBody,
title: string | null
title: string
): Promise<backendModule.UpdatedProject> {
const path = remoteBackendPaths.projectUpdatePath(projectId)
const response = await this.put<backendModule.UpdatedProject>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(
`Could not update project ${title != null ? `'${title}'` : `with ID '${projectId}'`}`,
response
)
return await this.throw(response, 'updateProjectBackendError', title)
} else {
return await response.json()
}
@ -634,17 +620,12 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async checkResources(
projectId: backendModule.ProjectId,
title: string | null
title: string
): Promise<backendModule.ResourceUsage> {
const path = remoteBackendPaths.checkResourcesPath(projectId)
const response = await this.get<backendModule.ResourceUsage>(path)
if (!responseIsSuccessful(response)) {
return this.throw(
`Could not get resource usage for project ${
title != null ? `'${title}'` : `with ID '${projectId}'`
}`,
response
)
return await this.throw(response, 'checkResourcesBackendError', title)
} else {
return await response.json()
}
@ -656,7 +637,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.LIST_FILES_PATH
const response = await this.get<ListFilesResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw('Could not list files', response)
return await this.throw(response, 'listFilesBackendError')
} else {
return (await response.json()).files
}
@ -678,11 +659,7 @@ export default class RemoteBackend extends Backend {
const path = `${remoteBackendPaths.UPLOAD_FILE_PATH}?${paramsString}`
const response = await this.postBinary<backendModule.FileInfo>(path, file)
if (!responseIsSuccessful(response)) {
if (params.fileId != null) {
return this.throw(`Could not upload file with ID '${params.fileId}'`, response)
} else {
return this.throw('Could not upload file', response)
}
return await this.throw(response, 'uploadFileBackendError')
} else {
return await response.json()
}
@ -692,15 +669,12 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getFileDetails(
fileId: backendModule.FileId,
title: string | null
title: string
): Promise<backendModule.FileDetails> {
const path = remoteBackendPaths.getFileDetailsPath(fileId)
const response = await this.get<backendModule.FileDetails>(path)
if (!responseIsSuccessful(response)) {
return this.throw(
`Could not get details of project ${title != null ? `'${title}'` : `with ID '${fileId}'`}`,
response
)
return await this.throw(response, 'getFileDetailsBackendError', title)
} else {
return await response.json()
}
@ -714,7 +688,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_CONNECTOR_PATH
const response = await this.post<backendModule.ConnectorInfo>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not create Data Link with name '${body.name}'`, response)
return await this.throw(response, 'createConnectorBackendError', body.name)
} else {
return await response.json()
}
@ -724,13 +698,12 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getConnector(
connectorId: backendModule.ConnectorId,
title: string | null
title: string
): Promise<backendModule.Connector> {
const path = remoteBackendPaths.getConnectorPath(connectorId)
const response = await this.get<backendModule.Connector>(path)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${connectorId}'`
return this.throw(`Could not get Data Link ${name}`, response)
return await this.throw(response, 'getConnectorBackendError', title)
} else {
return await response.json()
}
@ -740,13 +713,12 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteConnector(
connectorId: backendModule.ConnectorId,
title: string | null
title: string
): Promise<void> {
const path = remoteBackendPaths.getConnectorPath(connectorId)
const response = await this.delete(path)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${connectorId}'`
return this.throw(`Could not delete Data Link ${name}`, response)
return await this.throw(response, 'deleteConnectorBackendError', title)
} else {
return
}
@ -760,7 +732,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_SECRET_PATH
const response = await this.post<backendModule.SecretId>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not create secret with name '${body.name}'`, response)
return await this.throw(response, 'createSecretBackendError', body.name)
} else {
return await response.json()
}
@ -770,13 +742,12 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */
override async getSecret(
secretId: backendModule.SecretId,
title: string | null
title: string
): Promise<backendModule.Secret> {
const path = remoteBackendPaths.getSecretPath(secretId)
const response = await this.get<backendModule.Secret>(path)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${secretId}'`
return this.throw(`Could not get secret ${name}`, response)
return await this.throw(response, 'getSecretBackendError', title)
} else {
return await response.json()
}
@ -787,13 +758,12 @@ export default class RemoteBackend extends Backend {
override async updateSecret(
secretId: backendModule.SecretId,
body: backendModule.UpdateSecretRequestBody,
title: string | null
title: string
): Promise<void> {
const path = remoteBackendPaths.updateSecretPath(secretId)
const response = await this.put(path, body)
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${secretId}'`
return this.throw(`Could not update secret ${name}`, response)
return await this.throw(response, 'updateSecretBackendError', title)
} else {
return
}
@ -805,7 +775,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.LIST_SECRETS_PATH
const response = await this.get<ListSecretsResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw('Could not list secrets', response)
return await this.throw(response, 'listSecretsBackendError')
} else {
return (await response.json()).secrets
}
@ -817,7 +787,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.CREATE_TAG_PATH
const response = await this.post<backendModule.Label>(path, body)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not create label '${body.value}'`, response)
return await this.throw(response, 'createLabelBackendError', body.value)
} else {
return await response.json()
}
@ -829,7 +799,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.LIST_TAGS_PATH
const response = await this.get<ListTagsResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not list labels`, response)
return await this.throw(response, 'listLabelsBackendError')
} else {
return (await response.json()).tags
}
@ -840,13 +810,12 @@ export default class RemoteBackend extends Backend {
override async associateTag(
assetId: backendModule.AssetId,
labels: backendModule.LabelName[],
title: string | null
title: string
) {
const path = remoteBackendPaths.associateTagPath(assetId)
const response = await this.patch<ListTagsResponseBody>(path, { labels })
if (!responseIsSuccessful(response)) {
const name = title != null ? `'${title}'` : `with ID '${assetId}'`
return this.throw(`Could not set labels for asset ${name}`, response)
return await this.throw(response, 'associateLabelsBackendError', title)
} else {
return
}
@ -861,7 +830,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.deleteTagPath(tagId)
const response = await this.delete(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not delete label '${value}'`, response)
return await this.throw(response, 'deleteLabelBackendError', value)
} else {
return
}
@ -880,7 +849,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.LIST_VERSIONS_PATH + '?' + paramsString
const response = await this.get<ListVersionsResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not list versions of type '${params.versionType}'`, response)
return await this.throw(response, 'listVersionsBackendError', params.versionType)
} else {
return (await response.json()).versions
}
@ -896,7 +865,7 @@ export default class RemoteBackend extends Backend {
{ plan } satisfies backendModule.CreateCheckoutSessionRequestBody
)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not create checkout session for plan '${plan}'.`, response)
return await this.throw(response, 'createCheckoutSessionBackendError', plan)
} else {
return await response.json()
}
@ -910,7 +879,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.getCheckoutSessionPath(sessionId)
const response = await this.get<backendModule.CheckoutSessionStatus>(path)
if (!responseIsSuccessful(response)) {
return this.throw(`Could not get checkout session for session ID '${sessionId}'.`, response)
return await this.throw(response, 'getCheckoutSessionBackendError', sessionId)
} else {
return await response.json()
}
@ -926,7 +895,7 @@ export default class RemoteBackend extends Backend {
const path = remoteBackendPaths.GET_LOG_EVENTS_PATH
const response = await this.get<ResponseBody>(path)
if (!responseIsSuccessful(response)) {
return this.throw('Could not get audit log events', response)
return this.throw(response, 'getLogEventsBackendError')
} else {
const json = await response.json()
return json.events
@ -942,7 +911,7 @@ export default class RemoteBackend extends Backend {
} else {
const version = (await this.listVersions({ versionType, default: true }))[0]?.number
if (version == null) {
return this.throw(`No default ${versionType} version found`, null)
return await this.throw(null, 'getDefaultVersionBackendError', versionType)
} else {
const info: DefaultVersionInfo = { version, lastUpdatedEpochMs: nowEpochMs }
this.defaultVersions[versionType] = info

View File

@ -30,6 +30,9 @@
--icons-padding-x: 0.5rem;
--icon-size: 1rem;
--project-icon-size: 1.5rem;
/* The horizontal padding of the large text indicating that a feature is not enabled in the user's
* current plan. */
--missing-functionality-text-padding-x: 1rem;
/* The horizontal gap between each icon in a list of buttons. */
--buttons-gap: 0.5rem;
/* The horizontal gap between each icon in a list of icons. */

View File

@ -0,0 +1,479 @@
{
"createFolderError": "Could not create new folder",
"createProjectError": "Could not create new project",
"createDataLinkError": "Could not create new Data Link",
"createSecretError": "Could not create new secret",
"renameFolderError": "Could not rename folder",
"renameProjectError": "Could not rename project",
"uploadProjectError": "Could not upload project",
"updateProjectError": "Could not update project",
"findProjectError": "Could not find project '$0'",
"openProjectError": "Could not open project '$0'",
"copyAssetError": "Could not copy '$0'",
"moveAssetError": "Could not move '$0'",
"deleteAssetError": "Could not delete '$0'",
"restoreAssetError": "Could not restore '$0'",
"localBackendFolderError": "Cannot create folders on the local drive",
"localBackendDataLinkError": "Cannot create Data Links on the local drive",
"localBackendSecretError": "Cannot create secrets on the local drive",
"offlineUploadFilesError": "Cannot upload files when offline",
"noIdeVersionError": "Could not get the IDE version of the project",
"noJSONEndpointError": "Could not get the address of the project's JSON endpoint",
"noBinaryEndpointError": "Could not get the address of the project's binary endpoint",
"listVersionsError": "Could not list versions",
"loadFileError": "Could not load file",
"noAppDownloadError": "Could not find a compatible download link",
"multipleAssetsSettingsError": "Select exactly one asset to see its settings.",
"editDescriptionError": "Could not edit description",
"canOnlyDownloadFilesError": "You currently can only download files.",
"noProjectSelectedError": "First select a project to download.",
"downloadInvalidTypeError": "You can only download files, projects, and Data Links",
"downloadProjectError": "Could not download project '$0'",
"downloadFileError": "Could not download file '$0'",
"downloadDataLinkError": "Could not download Data Link '$0'",
"downloadSelectedFilesError": "Could not download selected files",
"openEditorError": "Could not open editor",
"setPermissionsError": "Could not set permissions for '$0'",
"uploadProjectToCloudError": "Could not upload local project to cloud",
"unknownThreadIdError": "Unknown thread id '$0'.",
"needsOwnerError": "This $0 must have at least one owner.",
"asyncHookError": "Error while fetching data",
"fetchLatestVersionError": "Could not get the latest version of the asset",
"uploadProjectToCloudSuccess": "Successfully uploaded local project to the cloud!",
"projectHasNoSourceFilesPhrase": "project has no source files",
"fileNotFoundPhrase": "file not found",
"noNewProfilePictureError": "Could not upload a new profile picture because no image was found",
"registrationError": "Something went wrong! Please try again or contact the administrators.",
"missingEmailError": "Missing email address",
"missingVerificationCodeError": "Missing verification code",
"passwordMismatchError": "Passwords do not match",
"passwordValidationError": "Your password must include numbers, letters (both lowercase and uppercase) and symbols, and must be between 6 and 256 characters long.",
"confirmSignUpError": "Incorrect email or confirmation code.",
"setUsernameError": "Could not set your username.",
"signOutError": "Could not log out, please try again.",
"signUpSuccess": "We have sent you an email with further instructions!",
"confirmSignUpSuccess": "Your account has been confirmed! Please log in.",
"setUsernameLocalBackend": "You cannot set your username on the local backend.",
"setUsernameSuccess": "Your username has been set!",
"signInWithPasswordSuccess": "Successfully logged in!",
"forgotPasswordSuccess": "We have sent you an email with further instructions!",
"changePasswordSuccess": "Successfully changed password!",
"resetPasswordSuccess": "Successfully reset password!",
"signOutSuccess": "Successfully logged out!",
"listUsersBackendError": "Could not list users in the organization.",
"createUserBackendError": "Could not create user.",
"updateUsernameBackendError": "Could not change username.",
"updateUserBackendError": "Could not update user.",
"deleteUserBackendError": "Could not delete user.",
"uploadUserPictureBackendError": "Could not upload user profile picture.",
"getOrganizationBackendError": "Could not get organization.",
"updateOrganizationBackendError": "Could not update organization.",
"uploadOrganizationPictureBackendError": "Could not upload organization profile picture.",
"inviteUserBackendError": "Could not invite user '$0'.",
"createPermissionBackendError": "Could not set permissions.",
"listFolderBackendError": "Could not list folder '$0'.",
"listRootFolderBackendError": "Could not list root folder.",
"createFolderBackendError": "Could not create folder '$0'.",
"updateFolderBackendError": "Could not update folder '$0'.",
"listAssetVersionsBackendError": "Could not list versions for '$0'.",
"getFileContentsBackendError": "Could not get contents of '$0'",
"updateAssetBackendError": "Could not update '$0'.",
"deleteAssetBackendError": "Could not delete '$0'.",
"undoDeleteAssetBackendError": "Could not restore '$0' from Trash.",
"copyAssetBackendError": "Could not copy '$0' to '$1'.",
"listProjectsBackendError": "Could not list projects.",
"createProjectBackendError": "Could not create project with name '$0'",
"closeProjectBackendError": "Could not close project '$0'.",
"getProjectDetailsBackendError": "Could not get details of project '$0'.",
"openProjectBackendError": "Could not open project '$0'.",
"updateProjectBackendError": "Could not update project '$0'.",
"checkResourcesBackendError": "Could not get resource usage for project '$0'.",
"listFilesBackendError": "Could not list files.",
"uploadFileBackendError": "Could not upload file.",
"uploadFileWithNameBackendError": "Could not upload file '$0'.",
"getFileDetailsBackendError": "Could not get details of project '$0'.",
"createSecretBackendError": "Could not create secret with name '$0'.",
"createConnectorBackendError": "Could not create Data Link with name '$0'.",
"getConnectorBackendError": "Could not get Data Link '$0'.",
"deleteConnectorBackendError": "Could not delete Data Link '$0'.",
"getSecretBackendError": "Could not get secret '$0'.",
"updateSecretBackendError": "Could not update secret '$0'.",
"listSecretsBackendError": "Could not list secrets.",
"createLabelBackendError": "Could not create label '$0'.",
"listLabelsBackendError": "Could not list labels.",
"associateLabelsBackendError": "Could not set labels for asset '$0'.",
"deleteLabelBackendError": "Could not delete label '$0'.",
"listVersionsBackendError": "Could not list $0 versions.",
"createCheckoutSessionBackendError": "Could not create checkout session for plan '$0'.",
"getCheckoutSessionBackendError": "Could not get checkout session for session ID '$0'.",
"getLogEventsBackendError": "Could not get audit log events",
"getDefaultVersionBackendError": "No default $0 version found.",
"directoryAssetType": "folder",
"projectAssetType": "project",
"fileAssetType": "file",
"connectorAssetType": "Data Link",
"secretAssetType": "secret",
"specialLoadingAssetType": "special loading asset",
"specialEmptyAssetType": "special empty asset",
"couldNotConnectToPM": "Could not connect to the Project Manager. Please try restarting Enso, or manually launching the Project Manager.",
"upgradeToUseCloud": "Upgrade your plan to use Enso Cloud.",
"me": "Me",
"login": "Login",
"register": "Register",
"upgrade": "Upgrade",
"settings": "Settings",
"cloud": "Cloud",
"local": "Local",
"category": "Category",
"description": "Description",
"share": "Share",
"value": "Value",
"name": "Name",
"change": "Change",
"confirm": "Confirm",
"cancel": "Cancel",
"create": "Create",
"update": "Update",
"updateAll": "Update All",
"upload": "Upload",
"uploaded": "Uploaded",
"delete": "Delete",
"invite": "Invite",
"color": "Color",
"labels": "Labels",
"views": "Views",
"likes": "Likes",
"shortcuts": "Shortcuts",
"email": "Email",
"password": "Password",
"reset": "Reset",
"members": "Members",
"clearTrash": "Clear Trash",
"sharedWith": "Shared with",
"editSecret": "Edit Secret",
"cloudDrive": "Cloud Drive",
"localDrive": "Local Drive",
"userAccount": "User Account",
"keyboardShortcuts": "Keyboard Shortcuts",
"dangerZone": "Danger Zone",
"profilePicture": "Profile picture",
"settingsFor": "Settings for ",
"inviteMembers": "Invite Members",
"versions": "Versions",
"dataLink": "Data Link",
"createDataLink": "Create Data Link",
"resetAll": "Reset All",
"organization": "Organization",
"organizationDisplayName": "Organization display name",
"website": "Website",
"location": "Location",
"enterSecretPath": "Enter secret path",
"enterText": "Enter text",
"enterNumber": "Enter number",
"enterInteger": "Enter integer",
"itemSingular": "item",
"itemPlural": "items",
"duplicateFilesFound": "Duplicate Files Found",
"duplicateProjectsFound": "Duplicate Projects Found",
"duplicateFilesAndProjectsFound": "Duplicate Files and Projects Found",
"fileWithoutConflicts": "1 file without conflicts",
"filesWithoutConflicts": "$0 files without conflicts",
"projectWithoutConflicts": "1 project without conflicts",
"projectsWithoutConflicts": "$0 projects without conflicts",
"renameNewFile": "Rename New File",
"renameNewProject": "Rename New Project",
"renameNewFiles": "Rename New Files",
"renameNewProjects": "Rename New Projects",
"andOtherFile": "and 1 other file",
"andOtherFiles": "and $0 other files",
"andOtherProject": "and 1 other project",
"andOtherProjects": "and $0 other projects",
"recentCategory": "Recent",
"draftsCategory": "Drafts",
"homeCategory": "Home",
"rootCategory": "Root",
"trashCategory": "Trash",
"newFolder": "New Folder",
"newProject": "New Project",
"uploadFiles": "Upload Files",
"downloadFiles": "Download Files",
"newDataLink": "New Data Link",
"newSecret": "New Secret",
"newLabel": "New Label",
"stopExecution": "Stop execution",
"openInEditor": "Open in editor",
"expand": "Expand",
"collapse": "Collapse",
"sortAscending": "Sort Ascending",
"sortDescending": "Sort Descending",
"sortByName": "Sort by name",
"sortByNameDescending": "Sort by name descending",
"stopSortingByName": "Stop sorting by name",
"sortByModificationDate": "Sort by modification date",
"sortByModificationDateDescending": "Sort by modification date descending",
"stopSortingByModificationDate": "Stop sorting by modification date",
"sortByEmail": "Sort by email",
"sortByEmailDescending": "Sort by email descending",
"stopSortingByEmail": "Stop sorting by email",
"sortByTimestamp": "Sort by timestamp",
"sortByTimestampDescending": "Sort by timestamp descending",
"stopSortingByTimestamp": "Stop sorting by timestamp",
"closeAssetPanel": "Close Asset Panel",
"openAssetPanel": "Open Asset Panel",
"confirmEdit": "Confirm Edit",
"cancelEdit": "Cancel Edit",
"loadingAppMessage": "Logging in to Enso...",
"discoverWhatsNew": "Discover whats new",
"sampleAndCommunityProjects": "Sample and community projects",
"openUserMenu": "Open user menu",
"newEmptyProject": "New empty project",
"noProjectIsCurrentlyOpen": "No project is currently open.",
"youAreNotLoggedIn": "You are not logged in.",
"dropToUploadFiles": "Drop to upload files",
"downloadFreeEdition": "Download Free Edition",
"thisFolderIsEmpty": "This folder is empty.",
"yourTrashIsEmpty": "Your trash is empty.",
"deleteTheAssetTypeTitle": "delete the $0 '$1'",
"notImplemetedYet": "Not implemented yet.",
"newLabelButtonLabel": "new label",
"settingUsername": "Setting username...",
"loggingOut": "Logging out...",
"pleaseWait": "Please wait...",
"currentColon": "Current:",
"newColon": "New:",
"replyExclamation": "Reply!",
"lastModifiedOn": "last modified on $0",
"versionX": "Version $0",
"compareWithLatest": "Compare with latest",
"compareVersionXWithLatest": "Compare version $0 with latest",
"onDateX": "on $0",
"xUsersSelected": "$0 users selected",
"allTrashedItemsForever": "all trashed items forever",
"resetAllKeyboardShortcuts": "reset all keyboard shortcuts",
"mustNotBeBlank": "Must not be blank.",
"rightClickToRemoveLabel": "Right click to remove label.",
"selectExactlyOneAssetToViewItsDetails": "Select exactly one asset to view its details.",
"deleteUserAccountButtonLabel": "Delete this user account",
"deleteUserAccountWarning": "Once deleted, it will be gone forever. Please be certain.",
"areYouSure": "Are you sure?",
"confirmDeleteUserAccountWarning": "Once deleted, this user account will be gone forever.",
"confirmDeleteUserAccountButtonLabel": "Once deleted, this user account will be gone forever.",
"profilePictureWarning": "Your profile picture should not be irrelevant, abusive or vulgar. It should not be a default image provided by Enso.",
"organizationProfilePictureWarning": "Your organizations profile picture should not be irrelevant, abusive or vulgar. It should not be a default image provided by Enso.",
"noFilesMatchTheCurrentFilters": "No files match the current filters.",
"youHaveNoFiles": "You have no files. Go ahead and create one using the buttons above, or open a template from the home screen.",
"placeholderChatPrompt": "Login or register to access live chat with our support team.",
"confirmPrompt": "Are you sure you want to $0?",
"couldNotInviteUser": "Could not invite user '$0'",
"inviteSuccess": "You've invited '$0' to join Enso!",
"clickForNewQuestion": "New question? Click to start a new thread!",
"upgradeToProNag": "Click here to upgrade to Enso Pro and get access to high-priority, live support!",
"projectNameCannotBeEmpty": "Project name cannot be empty.",
"remoteBackendSearchPlaceholder": "Type to search for projects, Data Links, users, and more.",
"localBackendSearchPlaceholder": "Type to search for projects.",
"canOnlyEmptyTrashWhenInTrash": "Can only empty trash when in Trash",
"upgradeTo": "Upgrade to $0",
"localAssetsDoNotHaveVersions": "Local assets do not have versions",
"noVersionsFound": "No versions found",
"latestIndicator": "(Latest)",
"deleteLabelActionText": "delete the label '$0'",
"deleteSelectedAssetActionText": "selected item",
"deleteSelectedAssetsActionText": "$0 selected items",
"deleteSelectedAssetForeverActionText": "selected item forever",
"deleteSelectedAssetsForeverActionText": "$0 selected items forever",
"enterTheNewKeyboardShortcutFor": "Enter the new keyboard shortcut for $0.",
"noShortcutEntered": "No shortcut entered",
"addUser": "Add User",
"typeEmailToInvite": "Type email to invite",
"emailIsBlank": "Email is blank",
"emailIsNotAValidEmail": "'$0' is not a valid email",
"userIsAlreadyInTheOrganization": "'$0' is already in the organization",
"youAreAlreadyAddingUser": "You are already adding '$0'",
"chatInputPlaceholder": "Type your message ...",
"secretValueHidden": "●●●●●●●●",
"secretNamePlaceholder": "Enter the name of the secret",
"secretValuePlaceholder": "Enter the value of the secret",
"dataLinkNamePlaceholder": "Enter the name of the Data Link",
"labelNamePlaceholder": "Enter the name of the label",
"labelSearchPlaceholder": "Type labels to search",
"inviteUserPlaceholder": "Type usernames or emails to search or invite",
"inviteFirstUserPlaceholder": "Enter an email to invite someone",
"loginToYourAccount": "Login to your account",
"passwordPlaceholder": "Enter your password",
"signUpOrLoginWithGoogle": "Sign up or login with Google",
"signUpOrLoginWithGitHub": "Sign up or login with GitHub",
"orLoginWithEmail": "or login with email",
"dontHaveAnAccount": "Don't have an account?",
"continueWithoutCreatingAnAccount": "Continue without creating an account",
"createANewAccount": "Create a new account",
"confirmPasswordLabel": "Confirm password",
"confirmPasswordPlaceholder": "Confirm your password",
"alreadyHaveAnAccount": "Already have an account?",
"setYourUsername": "Set your username",
"usernamePlaceholder": "Enter your username",
"setUsername": "Set username",
"forgotYourPassword": "Forgot Your Password?",
"sendLink": "Send link",
"goBackToLogin": "Go back to login",
"resetYourPassword": "Reset your password",
"emailPlaceholder": "Enter your email",
"confirmationCodePlaceholder": "Enter the confirmation code",
"changePassword": "Change Password",
"currentPasswordLabel": "Current password",
"currentPasswordPlaceholder": "Enter your current password",
"newPasswordLabel": "New password",
"newPasswordPlaceholder": "Enter your new password",
"confirmNewPasswordLabel": "Confirm new password",
"confirmNewPasswordPlaceholder": "Confirm your new password",
"welcomeMessage": "Welcome to Enso Community",
"welcomeSubtitle": "Explore templates, plugins, and data sources to kickstart your next big idea.",
"newsItem3Beta": "Read whats new in Enso 3.0 Beta",
"newsItem3BetaDescription": "Learn about Enso Cloud, new data libraries, and Enso AI.",
"newsItemWeeklyTutorials": "Watch weekly Enso tutorials",
"newsItemWeeklyTutorialsDescription": "Subscribe not to miss new weekly tutorials.",
"newsItemCommunityServer": "Join our community server",
"newsItemCommunityServerDescription": "Chat with our team and other Enso users.",
"homePageAltText": "Home tab",
"drivePageAltText": "Drive tab",
"editorPageAltText": "Project tab",
"settingsPageAltText": "Settings tab",
"homePageTooltip": "Go to homepage",
"drivePageTooltip": "Go to drive",
"editorPageTooltip": "Go to project",
"settingsPageTooltip": "Go to settings",
"soloPlanName": "Solo",
"teamPlanName": "Team",
"metaModifier": "Meta",
"shiftModifier": "Shift",
"altModifier": "Alt",
"ctrlModifier": "Ctrl",
"superModifier": "Super",
"ownerPermissionType": "owner",
"adminPermissionType": "admin",
"editPermissionType": "edit",
"readPermissionType": "read",
"viewPermissionType": "view",
"deletePermissionType": "delete",
"docsPermissionModifier": "docs",
"execPermissionModifier": "exec",
"nameColumnName": "Name",
"modifiedColumnName": "Modified",
"sharedWithColumnName": "Shared with",
"labelsColumnName": "Labels",
"accessedByProjectsColumnName": "Accessed by projects",
"accessedDataColumnName": "Accessed data",
"docsColumnName": "Docs",
"settingsShortcut": "Settings",
"openShortcut": "Open",
"runShortcut": "Run",
"closeShortcut": "Close",
"uploadToCloudShortcut": "Upload To Cloud",
"renameShortcut": "Rename",
"editShortcut": "Edit",
"snapshotShortcut": "Snapshot",
"deleteShortcut": "Delete",
"undeleteShortcut": "Restore From Trash",
"shareShortcut": "Share",
"labelShortcut": "Label",
"duplicateShortcut": "Duplicate",
"copyShortcut": "Copy",
"cutShortcut": "Cut",
"pasteShortcut": "Paste",
"downloadShortcut": "Download",
"uploadFilesShortcut": "Upload Files",
"uploadProjectsShortcut": "Upload Projects",
"newProjectShortcut": "New Project",
"newFolderShortcut": "New Folder",
"newDataLinkShortcut": "New Data Link",
"newSecretShortcut": "New Secret",
"closeModalShortcut": "Close",
"cancelEditNameShortcut": "Cancel Editing",
"signInShortcut": "Login",
"signOutShortcut": "Logout",
"downloadAppShortcut": "Download App",
"cancelCutShortcut": "Cancel Cut",
"editNameShortcut": "Edit Name",
"selectAdditionalShortcut": "Select Additional",
"selectRangeShortcut": "Select Range",
"selectAdditionalRangeShortcut": "Select Additional Range",
"goBackShortcut": "Go Back",
"goForwardShortcut": "Go Forward",
"moveToTrashShortcut": "Move To Trash",
"moveAllToTrashShortcut": "Move All To Trash",
"deleteForeverShortcut": "Delete Forever",
"deleteAllShortcut": "Delete All",
"deleteAllForeverShortcut": "Delete All Forever",
"restoreFromTrashShortcut": "Restore From Trash",
"restoreAllFromTrashShortcut": "Restore All From Trash",
"copyAllShortcut": "Copy All",
"cutAllShortcut": "Cut All",
"pasteAllShortcut": "Paste All",
"nameColumnShow": "Show Name column",
"nameColumnHide": "Hide Name column",
"modifiedColumnShow": "Show Modified date column",
"modifiedColumnHide": "Hide Modified date column",
"sharedWithColumnShow": "Show Shared with column",
"sharedWithColumnHide": "Hide Shared with column",
"labelsColumnShow": "Show Labels column",
"labelsColumnHide": "Hide Labels column",
"accessedByProjectsColumnShow": "Show Accessed by projects column",
"accessedByProjectsColumnHide": "Hide Accessed by projects column",
"accessedDataColumnShow": "Show Accessed data column",
"accessedDataColumnHide": "Hide Accessed data column",
"docsColumnShow": "Show Docs column",
"docsColumnHide": "Hide Doc columns",
"activityLog": "Activity Log",
"startDate": "Start Date",
"endDate": "End Date",
"types": "Types",
"users": "Users",
"type": "Type",
"timestamp": "Timestamp",
"mondayAbbr": "M",
"tuesdayAbbr": "Tu",
"wednesdayAbbr": "W",
"thursdayAbbr": "Th",
"fridayAbbr": "F",
"saturdayAbbr": "Sa",
"sundayAbbr": "Su"
}

View File

@ -0,0 +1,100 @@
/** @file Functions related to displaying text. */
import ENGLISH from '#/text/english.json' assert { type: 'json' }
// =============
// === Types ===
// =============
/** Possible languages in which to display text. */
export enum Language {
english = 'english',
}
/** An object containing the corresponding localized text for each text ID. */
type Texts = typeof ENGLISH
/** All possible text IDs. */
export type TextId = keyof Texts
/** Overrides the default number of placeholders (0). */
interface PlaceholderOverrides {
readonly copyAssetError: [string]
readonly moveAssetError: [string]
readonly findProjectError: [string]
readonly openProjectError: [string]
readonly deleteAssetError: [string]
readonly restoreAssetError: [string]
readonly setPermissionsError: [string]
readonly unknownThreadIdError: [string]
readonly needsOwnerError: [string]
readonly inviteSuccess: [string]
readonly deleteLabelActionText: [string]
readonly deleteSelectedAssetsActionText: [number]
readonly deleteSelectedAssetsForeverActionText: [number]
readonly confirmPrompt: [string]
readonly deleteTheAssetTypeTitle: [string, string]
readonly couldNotInviteUser: [string]
readonly filesWithoutConflicts: [number]
readonly projectsWithoutConflicts: [number]
readonly andOtherFiles: [number]
readonly andOtherProjects: [number]
readonly emailIsNotAValidEmail: [string]
readonly userIsAlreadyInTheOrganization: [string]
readonly youAreAlreadyAddingUser: [string]
readonly lastModifiedOn: [string]
readonly versionX: [number]
readonly compareVersionXWithLatest: [number]
readonly onDateX: [string]
readonly xUsersSelected: [number]
readonly upgradeTo: [string]
readonly enterTheNewKeyboardShortcutFor: [string]
readonly downloadProjectError: [string]
readonly downloadFileError: [string]
readonly downloadDataLinkError: [string]
readonly inviteUserBackendError: [string]
readonly listFolderBackendError: [string]
readonly createFolderBackendError: [string]
readonly updateFolderBackendError: [string]
readonly listAssetVersionsBackendError: [string]
readonly getFileContentsBackendError: [string]
readonly updateAssetBackendError: [string]
readonly deleteAssetBackendError: [string]
readonly undoDeleteAssetBackendError: [string]
readonly copyAssetBackendError: [string, string]
readonly createProjectBackendError: [string]
readonly closeProjectBackendError: [string]
readonly getProjectDetailsBackendError: [string]
readonly openProjectBackendError: [string]
readonly updateProjectBackendError: [string]
readonly checkResourcesBackendError: [string]
readonly uploadFileWithNameBackendError: [string]
readonly getFileDetailsBackendError: [string]
readonly createConnectorBackendError: [string]
readonly getConnectorBackendError: [string]
readonly deleteConnectorBackendError: [string]
readonly createSecretBackendError: [string]
readonly getSecretBackendError: [string]
readonly updateSecretBackendError: [string]
readonly createLabelBackendError: [string]
readonly associateLabelsBackendError: [string]
readonly deleteLabelBackendError: [string]
readonly listVersionsBackendError: [string]
readonly createCheckoutSessionBackendError: [string]
readonly getCheckoutSessionBackendError: [string]
readonly getDefaultVersionBackendError: [string]
}
/** An tuple of `string` for placeholders for each {@link TextId}. */
export interface Replacements
extends PlaceholderOverrides,
Record<Exclude<TextId, keyof PlaceholderOverrides>, []> {}
// =================
// === Constants ===
// =================
export const TEXTS: Readonly<Record<Language, Texts>> = {
[Language.english]: ENGLISH,
}

View File

@ -1,5 +1,7 @@
/** @file Utilities for working with permissions. */
import * as backend from '../services/Backend'
import type * as text from '#/text'
import * as backend from '#/services/Backend'
// ========================
// === PermissionAction ===
@ -134,10 +136,21 @@ export const TYPE_TO_PERMISSION_ACTION: Readonly<Record<Permission, PermissionAc
[Permission.edit]: PermissionAction.edit,
[Permission.read]: PermissionAction.read,
[Permission.view]: PermissionAction.view,
// SHould never happen, but provide a fallback just in case.
// Should never happen, but provide a fallback just in case.
[Permission.delete]: PermissionAction.view,
}
/** The corresponding {@link text.TextId} for each {@link Permission}.
* Assumes no docs sub-permission and no execute sub-permission. */
export const TYPE_TO_TEXT_ID: Readonly<Record<Permission, text.TextId>> = {
[Permission.owner]: 'ownerPermissionType',
[Permission.admin]: 'adminPermissionType',
[Permission.edit]: 'editPermissionType',
[Permission.read]: 'readPermissionType',
[Permission.view]: 'viewPermissionType',
[Permission.delete]: 'deletePermissionType',
} satisfies { [P in Permission]: `${P}PermissionType` }
/** The equivalent backend `PermissionAction` for a `Permissions`. */
export function toPermissionAction(permissions: Permissions): PermissionAction {
switch (permissions.type) {

View File

@ -4,12 +4,6 @@
// === String utilities ===
// ========================
/** Return a function returning the singular or plural form of a word depending on the count of
* items. */
export function makePluralize(singular: string, plural: string) {
return (count: number) => (count === 1 ? singular : plural)
}
/** Return the given string, but with the first letter uppercased. */
export function capitalizeFirst(string: string) {
return string.replace(/^./, match => match.toUpperCase())

View File

@ -18,14 +18,8 @@
*/
export const PASSWORD_PATTERN =
'(?=.*[0-9])(?=.*[A-Z])(?=.*[a-z])(?=.*[ ^$*.\\[\\]\\{\\}\\(\\)?"!@#%&\\/,><\':;\\|_~`=+\\-]).{6,256}'
/** Human readable explanation of password requirements. */
export const PASSWORD_ERROR =
'Your password must include numbers, letters (both lowercase and uppercase) and symbols, ' +
'and must be between 6 and 256 characters long.'
export const PASSWORD_REGEX = new RegExp('^' + PASSWORD_PATTERN + '$')
export const CONFIRM_PASSWORD_ERROR = 'Passwords must match.'
// The Project Manager has restrictions on names of projects.
/** Regex pattern for valid names for local projects.
*
@ -33,5 +27,3 @@ export const CONFIRM_PASSWORD_ERROR = 'Passwords must match.'
* - allow any non-empty string
*/
export const LOCAL_PROJECT_NAME_PATTERN = '.*\\S.*'
/** Human readable explanation of project name restrictions for local projects. */
export const LOCAL_PROJECT_NAME_TITLE = 'Project name cannot be empty.'

View File

@ -323,6 +323,7 @@ export default /** @satisfies {import('tailwindcss').Config} */ ({
'chat-button-y': 'var(--chat-button-padding-y)',
'chat-reaction-bar-y': 'var(--chat-reaction-bar-padding-y)',
'chat-reaction': 'var(--chat-reaction-padding)',
'missing-functionality-text-x': 'var(--missing-functionality-text-padding-x)',
},
margin: {
'top-bar': 'var(--top-bar-margin)',

View File

@ -4,7 +4,7 @@
"src",
"e2e",
"../types",
"./**/*.json",
"./src/**/*.json",
"../../utils.ts",
".prettierrc.cjs",
"*.js",