Improve tab bar (#10333)

- Add close button to Editor tab
- Convert Settings page into a modal (not guaranteed to be the final design, but it makes sense to change it to a modal for now at least)

# Important Notes
None
This commit is contained in:
somebody1234 2024-06-25 02:02:22 +10:00 committed by GitHub
parent e6c8ec7ab5
commit b52e8eb9b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 407 additions and 462 deletions

View File

@ -122,6 +122,13 @@ export function locateStopProjectButton(page: test.Locator | test.Page) {
return page.getByLabel('Stop execution')
}
/** Close a modal. */
export function closeModal(page: test.Page) {
return test.test.step('Close modal', async () => {
await page.getByLabel('Close').click()
})
}
/** Find all labels in the labels panel (if any) on the current page. */
export function locateLabelsPanelLabels(page: test.Page, name?: string) {
return (

View File

@ -1,5 +1,4 @@
/** @file The base class from which all `Actions` classes are derived. */
import * as test from '@playwright/test'
// ====================
@ -78,10 +77,11 @@ export default class BaseActions implements PromiseLike<void> {
}
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
into<T extends new (page: test.Page, promise: Promise<void>) => InstanceType<T>>(
clazz: T
): InstanceType<T> {
return new clazz(this.page, this.promise)
into<
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
Args extends readonly unknown[],
>(clazz: T, ...args: Args): InstanceType<T> {
return new clazz(this.page, this.promise, ...args)
}
/** Perform an action on the current page. This should generally be avoided in favor of using

View File

@ -3,31 +3,24 @@ import * as test from 'playwright/test'
import * as actions from '../actions'
import type * as baseActions from './BaseActions'
import BaseActions from './BaseActions'
import * as contextMenuActions from './contextMenuActions'
import EditorPageActions from './EditorPageActions'
import * as goToPageActions from './goToPageActions'
import NewDataLinkModalActions from './NewDataLinkModalActions'
import * as openUserMenuAction from './openUserMenuAction'
import PageActions from './PageActions'
import StartModalActions from './StartModalActions'
import * as userMenuActions from './userMenuActions'
// ========================
// === DrivePageActions ===
// ========================
/** Actions for the "drive" page. */
export default class DrivePageActions extends BaseActions {
export default class DrivePageActions extends PageActions {
/** Actions for navigating to another page. */
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
return goToPageActions.goToPageActions(this.step.bind(this))
}
/** Actions related to the User Menu. */
get userMenu() {
return userMenuActions.userMenuActions(this.step.bind(this))
}
/** Actions related to context menus. */
get contextMenu() {
return contextMenuActions.contextMenuActions(this.step.bind(this))
@ -145,11 +138,6 @@ export default class DrivePageActions extends BaseActions {
).into(EditorPageActions)
}
/** Open the User Menu. */
openUserMenu() {
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
}
/** Interact with the drive view (the main container of this page). */
withDriveView(callback: baseActions.LocatorCallback) {
return this.step('Interact with drive view', page => callback(actions.locateDriveView(page)))

View File

@ -1,28 +1,15 @@
/** @file Actions for the "editor" page. */
import BaseActions from './BaseActions'
import * as goToPageActions from './goToPageActions'
import * as openUserMenuAction from './openUserMenuAction'
import * as userMenuActions from './userMenuActions'
import PageActions from './PageActions'
// =========================
// === EditorPageActions ===
// =========================
/** Actions for the "editor" page. */
export default class EditorPageActions extends BaseActions {
export default class EditorPageActions extends PageActions {
/** Actions for navigating to another page. */
get goToPage(): Omit<goToPageActions.GoToPageActions, 'editor'> {
return goToPageActions.goToPageActions(this.step.bind(this))
}
/** Actions related to the User Menu. */
get userMenu() {
return userMenuActions.userMenuActions(this.step.bind(this))
}
/** Open the User Menu. */
openUserMenu() {
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
}
}

View File

@ -0,0 +1,21 @@
/** @file Actions common to all pages. */
import BaseActions from './BaseActions'
import * as openUserMenuAction from './openUserMenuAction'
import * as userMenuActions from './userMenuActions'
// ===================
// === PageActions ===
// ===================
/** Actions common to all pages. */
export default class PageActions extends BaseActions {
/** Actions related to the User Menu. */
get userMenu() {
return userMenuActions.userMenuActions(this.step.bind(this))
}
/** Open the User Menu. */
openUserMenu() {
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
}
}

View File

@ -1,8 +1,6 @@
/** @file Actions for the "settings" page. */
import BaseActions from './BaseActions'
import * as goToPageActions from './goToPageActions'
import * as openUserMenuAction from './openUserMenuAction'
import * as userMenuActions from './userMenuActions'
import PageActions from './PageActions'
// ===========================
// === SettingsPageActions ===
@ -10,19 +8,9 @@ import * as userMenuActions from './userMenuActions'
// TODO: split settings page actions into different classes for each settings tab.
/** Actions for the "settings" page. */
export default class SettingsPageActions extends BaseActions {
export default class SettingsPageActions extends PageActions {
/** Actions for navigating to another page. */
get goToPage(): Omit<goToPageActions.GoToPageActions, 'settings'> {
get goToPage(): Omit<goToPageActions.GoToPageActions, 'drive'> {
return goToPageActions.goToPageActions(this.step.bind(this))
}
/** Actions related to the User Menu. */
get userMenu() {
return userMenuActions.userMenuActions(this.step.bind(this))
}
/** Open the User Menu. */
openUserMenu() {
return openUserMenuAction.openUserMenuAction(this.step.bind(this))
}
}

View File

@ -34,10 +34,7 @@ export function goToPageActions(
).into(DrivePageActions),
editor: () =>
step('Go to "Spatial Analysis" page', page =>
page
.getByRole('button')
.filter({ has: page.getByText('Spatial Analysis') })
.click()
page.getByRole('button').and(page.getByLabel('Spatial Analysis')).click()
).into(EditorPageActions),
settings: () =>
step('Go to "settings" page', page => BaseActions.press(page, 'Mod+,')).into(

View File

@ -4,7 +4,6 @@ import type * as test from 'playwright/test'
import type * as baseActions from './BaseActions'
import type BaseActions from './BaseActions'
import LoginPageActions from './LoginPageActions'
import SettingsPageActions from './SettingsPageActions'
// =======================
// === UserMenuActions ===
@ -13,7 +12,6 @@ import SettingsPageActions from './SettingsPageActions'
/** Actions for the user menu. */
export interface UserMenuActions<T extends BaseActions> {
readonly downloadApp: (callback: (download: test.Download) => Promise<void> | void) => T
readonly goToSettingsPage: () => SettingsPageActions
readonly logout: () => LoginPageActions
readonly goToLoginPage: () => LoginPageActions
}
@ -34,10 +32,6 @@ export function userMenuActions<T extends BaseActions>(
await callback(await downloadPromise)
})
},
goToSettingsPage: () =>
step('Go to Settings (user menu)', page =>
page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
).into(SettingsPageActions),
logout: () =>
step('Logout (user menu)', page =>
page.getByRole('button', { name: 'Logout' }).getByText('Logout').click()

View File

@ -17,6 +17,7 @@ test.test('members settings', async ({ page }) => {
const otherUserName = 'second.user_'
const otherUser = api.addUser(otherUserName)
// await actions.closeModal(page)
await actions.relog({ page })
await localActions.go(page)
await test
@ -24,6 +25,7 @@ test.test('members settings', async ({ page }) => {
.toHaveText([api.currentUser()?.name ?? '', otherUserName])
api.deleteUser(otherUser.userId)
// await actions.closeModal(page)
await actions.relog({ page })
await localActions.go(page)
await test

View File

@ -12,6 +12,9 @@ appConfig.loadTestEnvironmentVariables()
/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/strict-boolean-expressions */
const DEBUG = process.env.PWDEBUG === '1'
const TIMEOUT_MS = DEBUG ? 100_000_000 : 30_000
export default test.defineConfig({
testDir: './e2e',
fullyParallel: true,
@ -20,9 +23,9 @@ export default test.defineConfig({
repeatEach: process.env.CI ? 3 : 1,
expect: {
toHaveScreenshot: { threshold: 0 },
timeout: 30_000,
timeout: TIMEOUT_MS,
},
timeout: 30_000,
timeout: TIMEOUT_MS,
reporter: 'html',
use: {
baseURL: 'http://localhost:8080',
@ -30,8 +33,12 @@ export default test.defineConfig({
launchOptions: {
ignoreDefaultArgs: ['--headless'],
args: [
// Much closer to headful Chromium than classic headless.
'--headless=new',
...(DEBUG
? []
: [
// Much closer to headful Chromium than classic headless.
'--headless=new',
]),
// Required for `backdrop-filter: blur` to work.
'--use-angle=swiftshader',
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by

View File

@ -73,7 +73,7 @@ const DIALOG_STYLES = twv.tv({
header:
'sticky grid grid-cols-[1fr_auto_1fr] items-center border-b border-primary/10 transition-[border-color] duration-150',
closeButton: 'col-start-1 col-end-1 mr-auto',
heading: 'col-start-2 col-end-2 my-0',
heading: 'col-start-2 col-end-2 my-0 text-center',
content: 'relative flex-auto overflow-y-auto p-3.5',
},
})

View File

@ -140,8 +140,13 @@ export default function DateInput(props: DateInputProps) {
</div>
</FocusRing>
{isPickerVisible && (
<div className="absolute left-1/2 top-text-h mt-date-input-gap">
<div className="relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft before:absolute before:inset-0 before:rounded-2xl before:backdrop-blur-3xl">
<div className="absolute left-1/2 top-text-h z-1 mt-date-input-gap">
<div
className={ariaComponents.DIALOG_BACKGROUND({
className:
'relative -translate-x-1/2 rounded-2xl border border-primary/10 p-date-input shadow-soft',
})}
>
<div className="relative mb-date-input-gap">
<div className="flex items-center">
<ariaComponents.Button

View File

@ -2,7 +2,6 @@
import * as React from 'react'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import Chat from '#/layouts/Chat'
import ChatPlaceholder from '#/layouts/ChatPlaceholder'
@ -25,25 +24,12 @@ export interface PageProps extends Readonly<React.PropsWithChildren> {
export default function Page(props: PageProps) {
const { hideInfoBar = false, children, hideChat = false } = props
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const { unsetModal } = modalProvider.useSetModal()
const session = authProvider.useUserSession()
const doCloseChat = () => {
setIsHelpChatOpen(false)
}
React.useEffect(() => {
const onClick = () => {
if (getSelection()?.type !== 'Range') {
unsetModal()
}
}
document.addEventListener('click', onClick)
return () => {
document.removeEventListener('click', onClick)
}
}, [unsetModal])
return (
<>
{children}

View File

@ -2,7 +2,6 @@
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as toast from 'react-toastify'
import ArrowUpIcon from 'enso-assets/arrow_up.svg'
import PlayIcon from 'enso-assets/play.svg'
@ -32,8 +31,6 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
// === Constants ===
// =================
const LOADING_MESSAGE =
'Your environment is being created. It will take some time, please be patient.'
/** The corresponding {@link spinner.SpinnerState} for each {@link backendModule.ProjectState},
* when using the remote backend. */
const REMOTE_SPINNER_STATE: Readonly<Record<backendModule.ProjectState, spinner.SpinnerState>> = {
@ -73,8 +70,8 @@ export interface ProjectIconProps {
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
readonly doCloseEditor: () => void
readonly doOpenEditor: (switchPage: boolean) => void
readonly doCloseEditor: (id: backendModule.ProjectId) => void
readonly doOpenEditor: () => void
}
/** An interactive icon indicating the status of a project. */
@ -118,10 +115,6 @@ export default function ProjectIcon(props: ProjectIconProps) {
const [shouldSwitchPage, setShouldSwitchPage] = React.useState(false)
const doOpenEditorRef = React.useRef(doOpenEditor)
doOpenEditorRef.current = doOpenEditor
const toastId: toast.Id = React.useId()
const isOpening =
backendModule.IS_OPENING[item.projectState.type] &&
item.projectState.type !== backendModule.ProjectState.placeholder
const isCloud = backend.type === backendModule.BackendType.remote
const isOtherUserUsingProject =
isCloud && item.projectState.openedBy != null && item.projectState.openedBy !== user.email
@ -136,6 +129,30 @@ export default function ProjectIcon(props: ProjectIconProps) {
const openProjectMutate = openProjectMutation.mutateAsync
const getProjectDetailsMutate = getProjectDetailsMutation.mutateAsync
const openEditorMutation = reactQuery.useMutation({
mutationKey: ['openEditor', item.id],
networkMode: 'always',
mutationFn: async () => {
const projectPromise = waitUntilProjectIsReadyMutation.mutateAsync([
item.id,
item.parentId,
item.title,
])
if (shouldOpenWhenReady) {
doOpenEditor()
}
setProjectStartupInfo({
project: projectPromise,
projectAsset: item,
setProjectAsset: setItem,
backendType: backend.type,
accessToken: session?.accessToken ?? null,
})
await projectPromise
},
})
const openEditorMutate = openEditorMutation.mutate
const openProject = React.useCallback(
async (shouldRunInBackground: boolean) => {
if (state !== backendModule.ProjectState.opened) {
@ -150,6 +167,9 @@ export default function ProjectIcon(props: ProjectIconProps) {
},
item.title,
])
if (!shouldRunInBackground) {
openEditorMutate()
}
} catch (error) {
const project = await getProjectDetailsMutate([item.id, item.parentId, item.title])
// `setState` is not used here as `project` contains the full state information,
@ -166,52 +186,13 @@ export default function ProjectIcon(props: ProjectIconProps) {
session,
toastAndLog,
openProjectMutate,
openEditorMutate,
getProjectDetailsMutate,
setState,
setItem,
]
)
const openEditorMutation = reactQuery.useMutation({
mutationKey: ['openEditor', item.id],
networkMode: 'always',
mutationFn: async (abortController: AbortController) => {
if (!isRunningInBackground && isCloud) {
toast.toast.loading(LOADING_MESSAGE, { toastId })
}
const project = await waitUntilProjectIsReadyMutation.mutateAsync([
item.id,
item.parentId,
item.title,
abortController,
])
setProjectStartupInfo({
project,
projectAsset: item,
setProjectAsset: setItem,
backendType: backend.type,
accessToken: session?.accessToken ?? null,
})
if (!abortController.signal.aborted) {
toast.toast.dismiss(toastId)
setState(backendModule.ProjectState.opened)
}
},
})
const openEditorMutate = openEditorMutation.mutate
React.useEffect(() => {
if (isOpening) {
const abortController = new AbortController()
openEditorMutate(abortController)
return () => {
abortController.abort()
}
} else {
return
}
}, [isOpening, openEditorMutate])
React.useEffect(() => {
// Ensure that the previous spinner state is visible for at least one frame.
requestAnimationFrame(() => {
@ -235,8 +216,20 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
} else {
if (backendModule.IS_OPENING_OR_OPENED[state]) {
if (!isRunningInBackground) {
doOpenEditor(true)
const projectPromise = waitUntilProjectIsReadyMutation.mutateAsync([
item.id,
item.parentId,
item.title,
])
setProjectStartupInfo({
project: projectPromise,
projectAsset: item,
setProjectAsset: setItem,
backendType: backend.type,
accessToken: session?.accessToken ?? null,
})
if (!isRunningInBackground && event.shouldAutomaticallySwitchPage) {
doOpenEditor()
}
} else {
setShouldOpenWhenReady(!event.runInBackground)
@ -266,7 +259,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
React.useEffect(() => {
if (state === backendModule.ProjectState.opened) {
if (shouldOpenWhenReady) {
doOpenEditorRef.current(shouldSwitchPage)
doOpenEditorRef.current()
setShouldOpenWhenReady(false)
}
}
@ -274,9 +267,8 @@ export default function ProjectIcon(props: ProjectIconProps) {
const closeProject = async () => {
if (!isRunningInBackground) {
doCloseEditor()
doCloseEditor(item.id)
}
toast.toast.dismiss(toastId)
setShouldOpenWhenReady(false)
setState(backendModule.ProjectState.closing)
await closeProjectMutation.mutateAsync([item.id, item.title])
@ -371,7 +363,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
tooltipPlacement="right"
className="h-6 border-0"
onPress={() => {
doOpenEditor(true)
doOpenEditor()
}}
/>
)}

View File

@ -320,12 +320,8 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
setProjectStartupInfo={setProjectStartupInfo}
doOpenEditor={switchPage => {
doOpenEditor(asset, setAsset, switchPage)
}}
doCloseEditor={() => {
doCloseEditor(asset)
}}
doOpenEditor={doOpenEditor}
doCloseEditor={doCloseEditor}
/>
)}
<EditableSpan

View File

@ -61,7 +61,7 @@ export default function AssetVersion(props: AssetVersionProps) {
return (
<div
className={tailwindMerge.twMerge(
'flex w-full flex-shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2',
'flex w-full shrink-0 basis-0 select-none flex-row gap-4 rounded-2xl p-2',
placeholder && 'opacity-50'
)}
>

View File

@ -329,12 +329,8 @@ export interface AssetsTableState {
title?: string | null,
override?: boolean
) => void
readonly doOpenEditor: (
project: backendModule.ProjectAsset,
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
switchPage: boolean
) => void
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
readonly doOpenEditor: () => void
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
readonly doCopy: () => void
readonly doCut: () => void
readonly doPaste: (
@ -361,7 +357,6 @@ export interface AssetsTableProps {
readonly category: Category
readonly setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
readonly initialProjectName: string | null
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
readonly assetListEvents: assetListEvent.AssetListEvent[]
readonly dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
@ -369,21 +364,16 @@ export interface AssetsTableProps {
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
readonly doOpenEditor: (
backend: Backend,
project: backendModule.ProjectAsset,
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
switchPage: boolean
) => void
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
readonly doOpenEditor: () => void
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
}
/** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) {
const { hidden, query, setQuery, setProjectStartupInfo, setCanDownload, category } = props
const { setSuggestions, initialProjectName, projectStartupInfo } = props
const { setSuggestions, initialProjectName } = props
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
const { doOpenEditor: doOpenEditorRaw, doCloseEditor: doCloseEditorRaw } = props
const { doOpenEditor, doCloseEditor } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const { user } = authProvider.useNonPartialUserSession()
@ -1806,26 +1796,6 @@ export default function AssetsTable(props: AssetsTableProps) {
}
})
const doOpenEditor = React.useCallback(
(
project: backendModule.ProjectAsset,
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
switchPage: boolean
) => {
doOpenEditorRaw(backend, project, setProject, switchPage)
},
[backend, doOpenEditorRaw]
)
const doCloseEditor = React.useCallback(
(project: backendModule.ProjectAsset) => {
if (project.id === projectStartupInfo?.projectAsset.id) {
doCloseEditorRaw(project)
}
},
[projectStartupInfo, doCloseEditorRaw]
)
const doCopy = React.useCallback(() => {
unsetModal()
setPasteData({ type: PasteType.copy, data: selectedKeysRef.current })

View File

@ -28,7 +28,6 @@ import * as ariaComponents from '#/components/AriaComponents'
import * as result from '#/components/Result'
import * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import * as projectManager from '#/services/ProjectManager'
import AssetQuery from '#/utilities/AssetQuery'
@ -69,20 +68,14 @@ export interface DriveProps {
readonly dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
readonly assetEvents: assetEvent.AssetEvent[]
readonly dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
readonly projectStartupInfo: backendModule.ProjectStartupInfo | null
readonly setProjectStartupInfo: (projectStartupInfo: backendModule.ProjectStartupInfo) => void
readonly doOpenEditor: (
backend: Backend,
project: backendModule.ProjectAsset,
setProject: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
switchPage: boolean
) => void
readonly doCloseEditor: (project: backendModule.ProjectAsset) => void
readonly doOpenEditor: () => void
readonly doCloseEditor: (projectId: backendModule.ProjectId) => void
}
/** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function Drive(props: DriveProps) {
const { hidden, initialProjectName, projectStartupInfo } = props
const { hidden, initialProjectName } = props
const { assetListEvents, dispatchAssetListEvent, assetEvents, dispatchAssetEvent } = props
const { setProjectStartupInfo, doOpenEditor, doCloseEditor, category, setCategory } = props
@ -337,7 +330,6 @@ export default function Drive(props: DriveProps) {
category={category}
setSuggestions={setSuggestions}
initialProjectName={initialProjectName}
projectStartupInfo={projectStartupInfo}
assetEvents={assetEvents}
dispatchAssetEvent={dispatchAssetEvent}
assetListEvents={assetListEvents}

View File

@ -1,12 +1,18 @@
/** @file The container that launches the IDE. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider'
import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense'
import type Backend from '#/services/Backend'
import * as backendModule from '#/services/Backend'
@ -29,14 +35,53 @@ export interface EditorProps {
/** The container that launches the IDE. */
export default function Editor(props: EditorProps) {
const { hidden, projectStartupInfo } = props
const editor = projectStartupInfo && (
<EditorInternal {...props} projectStartupInfo={projectStartupInfo} />
)
return hidden ? (
<React.Suspense>
<errorBoundary.ErrorBoundary FallbackComponent={() => null}>
{editor}
</errorBoundary.ErrorBoundary>
</React.Suspense>
) : (
<suspense.Suspense loaderProps={{ minHeight: 'full' }}>
<errorBoundary.ErrorBoundary>{editor}</errorBoundary.ErrorBoundary>
</suspense.Suspense>
)
}
// ======================
// === EditorInternal ===
// ======================
/** Props for an {@link EditorInternal}. */
interface EditorInternalProps extends EditorProps {
readonly projectStartupInfo: backendModule.ProjectStartupInfo
}
/** An internal editor. */
function EditorInternal(props: EditorInternalProps) {
const { hidden, ydocUrl, projectStartupInfo, appRunner: AppRunner } = props
const toastAndLog = toastAndLogHooks.useToastAndLog()
const { getText } = textProvider.useText()
const gtagEvent = gtagHooks.useGtagEvent()
const gtagEventRef = React.useRef(gtagEvent)
gtagEventRef.current = gtagEvent
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const projectQuery = reactQuery.useSuspenseQuery({
queryKey: ['editorProject'],
queryFn: () => projectStartupInfo.project,
staleTime: 0,
meta: { persist: false },
})
const project = projectQuery.data
const logEvent = React.useCallback(
(message: string, projectId?: string | null, metadata?: object | null) => {
if (remoteBackend) {
@ -48,30 +93,28 @@ export default function Editor(props: EditorProps) {
const renameProject = React.useCallback(
(newName: string) => {
if (projectStartupInfo != null) {
let backend: Backend | null
switch (projectStartupInfo.backendType) {
case backendModule.BackendType.local:
backend = localBackend
break
case backendModule.BackendType.remote:
backend = remoteBackend
break
}
const { id: projectId, parentId, title } = projectStartupInfo.projectAsset
backend
?.updateProject(
projectId,
{ projectName: newName, ami: null, ideVersion: null, parentId },
title
)
.then(
() => {
projectStartupInfo.setProjectAsset?.(object.merger({ title: newName }))
},
e => toastAndLog('renameProjectError', e)
)
let backend: Backend | null
switch (projectStartupInfo.backendType) {
case backendModule.BackendType.local:
backend = localBackend
break
case backendModule.BackendType.remote:
backend = remoteBackend
break
}
const { id: projectId, parentId, title } = projectStartupInfo.projectAsset
backend
?.updateProject(
projectId,
{ projectName: newName, ami: null, ideVersion: null, parentId },
title
)
.then(
() => {
projectStartupInfo.setProjectAsset?.(object.merger({ title: newName }))
},
e => toastAndLog('renameProjectError', e)
)
},
[remoteBackend, localBackend, projectStartupInfo, toastAndLog]
)
@ -85,19 +128,14 @@ export default function Editor(props: EditorProps) {
}, [projectStartupInfo, hidden])
const appProps: types.EditorProps | null = React.useMemo(() => {
// eslint-disable-next-line no-restricted-syntax
if (projectStartupInfo == null) return null
const { project } = projectStartupInfo
const projectId = projectStartupInfo.projectAsset.id
const jsonAddress = project.jsonAddress
const binaryAddress = project.binaryAddress
const ydocAddress = ydocUrl ?? ''
if (jsonAddress == null) {
toastAndLog('noJSONEndpointError')
return null
throw new Error(getText('noJSONEndpointError'))
} else if (binaryAddress == null) {
toastAndLog('noBinaryEndpointError')
return null
throw new Error(getText('noBinaryEndpointError'))
} else {
return {
config: {
@ -121,9 +159,20 @@ export default function Editor(props: EditorProps) {
renameProject,
}
}
}, [projectStartupInfo, toastAndLog, hidden, logEvent, ydocUrl, renameProject])
}, [
projectStartupInfo.projectAsset.id,
project.jsonAddress,
project.binaryAddress,
project.packageName,
project.name,
ydocUrl,
getText,
hidden,
logEvent,
renameProject,
])
if (projectStartupInfo == null || AppRunner == null || appProps == null) {
if (AppRunner == null) {
return <></>
} else {
// Currently the GUI component needs to be fully rerendered whenever the project is changed. Once

View File

@ -1,8 +1,7 @@
/** @file Switcher to choose the currently visible full-screen page. */
import * as React from 'react'
import DriveIcon from 'enso-assets/drive.svg'
import WorkspaceIcon from 'enso-assets/workspace.svg'
import invariant from 'tiny-invariant'
import type * as text from '#/text'
@ -14,17 +13,6 @@ import FocusArea from '#/components/styled/FocusArea'
import * as tailwindMerge from '#/utilities/tailwindMerge'
// ============
// === Page ===
// ============
/** Main content of the screen. Only one should be visible at a time. */
export enum Page {
drive = 'drive',
editor = 'editor',
settings = 'settings',
}
// =================
// === Constants ===
// =================
@ -32,40 +20,34 @@ export enum Page {
/** The corner radius of the tabs. */
const TAB_RADIUS_PX = 24
const PAGE_DATA: PageUIData[] = [
{ page: Page.drive, icon: DriveIcon, nameId: 'drivePageName' },
{ page: Page.editor, icon: WorkspaceIcon, nameId: 'editorPageName' },
]
// =====================
// === TabBarContext ===
// =====================
// ==================
// === PageUIData ===
// ==================
/** Data describing how to display a button for a page. */
interface PageUIData {
readonly page: Page
readonly icon: string
readonly nameId: Extract<text.TextId, `${Page}PageName`>
/** Context for a {@link TabBarContext}. */
interface TabBarContextValue {
readonly updateClipPath: (element: HTMLDivElement | null) => void
}
// ====================
// === PageSwitcher ===
// ====================
const TabBarContext = React.createContext<TabBarContextValue | null>(null)
/** Props for a {@link PageSwitcher}. */
export interface PageSwitcherProps {
readonly page: Page
readonly setPage: (page: Page) => void
readonly isEditorDisabled: boolean
/** Custom hook to get tab bar context. */
function useTabBarContext() {
const context = React.useContext(TabBarContext)
invariant(context, '`useTabBarContext` must be used inside a `<TabBar />`')
return context
}
// ==============
// === TabBar ===
// ==============
/** Props for a {@link TabBar}. */
export interface TabBarProps extends Readonly<React.PropsWithChildren> {}
/** Switcher to choose the currently visible full-screen page. */
export default function PageSwitcher(props: PageSwitcherProps) {
const { page, setPage, isEditorDisabled } = props
const visiblePageData = React.useMemo(
() => PAGE_DATA.filter(pageData => (pageData.page !== Page.editor ? true : !isEditorDisabled)),
[isEditorDisabled]
)
export default function TabBar(props: TabBarProps) {
const { children } = props
const cleanupResizeObserverRef = React.useRef(() => {})
const backgroundRef = React.useRef<HTMLDivElement | null>(null)
const selectedTabRef = React.useRef<HTMLDivElement | null>(null)
@ -120,38 +102,19 @@ export default function PageSwitcher(props: PageSwitcherProps) {
}
}
React.useEffect(() => {
if (visiblePageData.every(pageData => page !== pageData.page)) {
updateClipPath(null)
}
}, [page, updateClipPath, visiblePageData])
return (
<div className="relative flex grow">
<div
ref={element => {
backgroundRef.current = element
updateResizeObserver(element)
}}
className="pointer-events-none absolute inset-0 bg-primary/5"
/>
<Tabs>
{visiblePageData.map(pageData => {
const isActive = page === pageData.page
return (
<Tab
key={pageData.page}
ref={isActive ? updateClipPath : null}
isActive={isActive}
onPress={() => {
setPage(pageData.page)
}}
{...pageData}
/>
)
})}
</Tabs>
</div>
<TabBarContext.Provider value={{ updateClipPath }}>
<div className="relative flex grow">
<div
ref={element => {
backgroundRef.current = element
updateResizeObserver(element)
}}
className="pointer-events-none absolute inset-0 bg-primary/5"
/>
<Tabs>{children}</Tabs>
</div>
</TabBarContext.Provider>
)
}
@ -162,7 +125,7 @@ export default function PageSwitcher(props: PageSwitcherProps) {
/** Props for a {@link TabsInternal}. */
export interface InternalTabsProps extends Readonly<React.PropsWithChildren> {}
/** A tab list in a {@link PageSwitcher}. */
/** A tab list in a {@link TabBar}. */
function TabsInternal(props: InternalTabsProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { children } = props
return (
@ -186,23 +149,26 @@ const Tabs = React.forwardRef(TabsInternal)
// ===========
/** Props for a {@link Tab}. */
interface InternalTabProps extends PageUIData {
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly isActive: boolean
readonly icon: string
readonly labelId: text.TextId
readonly onPress: () => void
readonly onClose?: () => void
}
/** A tab in a {@link PageSwitcher}. */
function TabInternal(props: InternalTabProps, ref: React.ForwardedRef<HTMLDivElement>) {
const { isActive, page, nameId, icon, onPress } = props
/** A tab in a {@link TabBar}. */
export function Tab(props: InternalTabProps) {
const { isActive, icon, labelId, children, onPress, onClose } = props
const { updateClipPath } = useTabBarContext()
const { getText } = textProvider.useText()
return (
<div
key={page}
ref={ref}
ref={isActive ? updateClipPath : null}
className={tailwindMerge.twMerge(
'h-full transition-[padding-left]',
page !== page && 'hover:enabled:bg-frame'
'group relative h-full',
!isActive && 'hover:enabled:bg-frame'
)}
>
<ariaComponents.Button
@ -211,13 +177,22 @@ function TabInternal(props: InternalTabProps, ref: React.ForwardedRef<HTMLDivEle
icon={icon}
isDisabled={isActive}
isActive={isActive}
className="flex h-full items-center gap-3 px-4"
aria-label={getText(labelId)}
tooltip={false}
className={tailwindMerge.twMerge(
'relative flex h-full items-center gap-3 px-4',
onClose && 'pr-10'
)}
onPress={onPress}
>
{getText(nameId)}
{children}
</ariaComponents.Button>
{onClose && (
<ariaComponents.CloseButton
className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover:opacity-100"
onPress={onClose}
/>
)}
</div>
)
}
const Tab = React.forwardRef(TabInternal)

View File

@ -1,50 +0,0 @@
/** @file The top-bar of dashboard. */
import * as React from 'react'
import * as backendProvider from '#/providers/BackendProvider'
import type * as pageSwitcher from '#/layouts/PageSwitcher'
import PageSwitcher from '#/layouts/PageSwitcher'
import UserBar from '#/layouts/UserBar'
import type * as backendModule from '#/services/Backend'
// ==============
// === TopBar ===
// ==============
/** Props for a {@link TopBar}. */
export interface TopBarProps {
readonly page: pageSwitcher.Page
readonly setPage: (page: pageSwitcher.Page) => void
readonly projectAsset: backendModule.ProjectAsset | null
readonly setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>> | null
readonly isEditorDisabled: boolean
readonly setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
readonly doRemoveSelf: () => void
readonly onSignOut: () => void
}
/** The {@link TopBarProps.setQuery} parameter is used to communicate with the parent component,
* because `searchVal` may change parent component's project list. */
export default function TopBar(props: TopBarProps) {
const { page, setPage, projectAsset, setProjectAsset, isEditorDisabled } = props
const { setIsHelpChatOpen, doRemoveSelf, onSignOut } = props
const remoteBackend = backendProvider.useRemoteBackend()
return (
<div className="flex">
<PageSwitcher page={page} setPage={setPage} isEditorDisabled={isEditorDisabled} />
<UserBar
backend={remoteBackend}
page={page}
setPage={setPage}
setIsHelpChatOpen={setIsHelpChatOpen}
projectAsset={projectAsset}
setProjectAsset={setProjectAsset}
doRemoveSelf={doRemoveSelf}
onSignOut={onSignOut}
/>
</div>
)
}

View File

@ -12,7 +12,6 @@ import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as textProvider from '#/providers/TextProvider'
import * as pageSwitcher from '#/layouts/PageSwitcher'
import UserMenu from '#/layouts/UserMenu'
import * as aria from '#/components/aria'
@ -37,39 +36,34 @@ export interface UserBarProps {
/** When `true`, the element occupies space in the layout but is not visible.
* Defaults to `false`. */
readonly invisible?: boolean
readonly page: pageSwitcher.Page
readonly setPage: (page: pageSwitcher.Page) => void
readonly isOnEditorPage: boolean
readonly setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
readonly projectAsset: backendModule.ProjectAsset | null
readonly setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>> | null
readonly doRemoveSelf: () => void
readonly goToSettingsPage: () => void
readonly onSignOut: () => void
}
/** A toolbar containing chat and the user menu. */
export default function UserBar(props: UserBarProps) {
const { backend, invisible = false, page, setPage, setIsHelpChatOpen } = props
const { projectAsset, setProjectAsset, doRemoveSelf, onSignOut } = props
const { backend, invisible = false, isOnEditorPage, setIsHelpChatOpen } = props
const { projectAsset, setProjectAsset, doRemoveSelf, goToSettingsPage, onSignOut } = props
const { user } = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const { getText } = textProvider.useText()
const { isFeatureUnderPaywall } = billing.usePaywall({ plan: user.plan })
const self =
projectAsset?.permissions?.find(
backendModule.isUserPermissionAnd(permissions => permissions.user.userId === user.userId)
) ?? null
const shouldShowShareButton =
backend != null &&
page === pageSwitcher.Page.editor &&
backend?.type === backendModule.BackendType.remote &&
isOnEditorPage &&
projectAsset != null &&
setProjectAsset != null &&
self != null
const shouldShowUpgradeButton = isFeatureUnderPaywall('inviteUser')
const shouldShowInviteButton =
backend != null && !shouldShowShareButton && !shouldShowUpgradeButton
@ -141,12 +135,12 @@ export default function UserBar(props: UserBarProps) {
buttonClassName="rounded-full after:rounded-full"
className="h-row-h w-row-h rounded-full"
onPress={() => {
setModal(<UserMenu setPage={setPage} onSignOut={onSignOut} />)
setModal(<UserMenu goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />)
}}
/>
{/* Required for shortcuts to work. */}
<div className="hidden">
<UserMenu hidden setPage={setPage} onSignOut={onSignOut} />
<UserMenu hidden goToSettingsPage={goToSettingsPage} onSignOut={onSignOut} />
</div>
</div>
</div>

View File

@ -10,8 +10,6 @@ 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 * as aria from '#/components/aria'
import MenuEntry from '#/components/MenuEntry'
import Modal from '#/components/Modal'
@ -31,13 +29,13 @@ import * as tailwindMerge from '#/utilities/tailwindMerge'
export interface UserMenuProps {
/** If `true`, disables `data-testid` because it will not be visible. */
readonly hidden?: boolean
readonly setPage: (page: pageSwitcher.Page) => void
readonly goToSettingsPage: () => void
readonly onSignOut: () => void
}
/** Handling the UserMenuItem click event logic and displaying its content. */
export default function UserMenu(props: UserMenuProps) {
const { hidden = false, setPage, onSignOut } = props
const { hidden = false, goToSettingsPage, onSignOut } = props
const [initialized, setInitialized] = React.useState(false)
const localBackend = backendProvider.useLocalBackend()
const { signOut } = authProvider.useAuth()
@ -112,13 +110,7 @@ export default function UserMenu(props: UserMenuProps) {
}}
/>
)}
<MenuEntry
action="settings"
doAction={() => {
unsetModal()
setPage(pageSwitcher.Page.settings)
}}
/>
<MenuEntry action="settings" doAction={goToSettingsPage} />
{aboutThisAppMenuEntry}
<MenuEntry
action="signOut"

View File

@ -2,6 +2,9 @@
* interactive components. */
import * as React from 'react'
import DriveIcon from 'enso-assets/drive.svg'
import SettingsIcon from 'enso-assets/settings.svg'
import WorkspaceIcon from 'enso-assets/workspace.svg'
import * as detect from 'enso-common/src/detect'
import * as eventHooks from '#/hooks/eventHooks'
@ -12,6 +15,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'
@ -23,14 +27,14 @@ import Chat from '#/layouts/Chat'
import ChatPlaceholder from '#/layouts/ChatPlaceholder'
import Drive from '#/layouts/Drive'
import Editor from '#/layouts/Editor'
import * as pageSwitcher from '#/layouts/PageSwitcher'
import Settings from '#/layouts/Settings'
import TopBar from '#/layouts/TopBar'
import * as tabBar from '#/layouts/TabBar'
import TabBar from '#/layouts/TabBar'
import UserBar from '#/layouts/UserBar'
import Page from '#/components/Page'
import * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend'
import type * as projectManager from '#/services/ProjectManager'
import * as array from '#/utilities/array'
@ -44,12 +48,19 @@ import type * as types from '../../../../types/types'
// === Global configuration ===
// ============================
/** Main content of the screen. Only one should be visible at a time. */
enum TabType {
drive = 'drive',
editor = 'editor',
settings = 'settings',
}
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly page: pageSwitcher.Page
readonly projectStartupInfo: backendModule.ProjectStartupInfo
readonly page: TabType
readonly projectStartupInfo: backendModule.ProjectStartupInfo<backendModule.Project>
}
}
@ -57,7 +68,7 @@ LocalStorage.registerKey('isAssetPanelVisible', {
tryParse: value => (value === true ? value : null),
})
const PAGES = Object.values(pageSwitcher.Page)
const PAGES = Object.values(TabType)
LocalStorage.registerKey('page', {
tryParse: value => (array.includes(PAGES, value) ? value : null),
})
@ -114,6 +125,7 @@ export default function Dashboard(props: DashboardProps) {
const session = authProvider.useNonPartialUserSession()
const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText()
const { modalRef } = modalProvider.useModalRef()
const { updateModal, unsetModal } = modalProvider.useSetModal()
const { localStorage } = localStorageProvider.useLocalStorage()
@ -126,9 +138,8 @@ export default function Dashboard(props: DashboardProps) {
// These pages MUST be ROUTER PAGES.
const [page, setPage] = searchParamsState.useSearchParamsState(
'page',
() => localStorage.get('page') ?? pageSwitcher.Page.drive,
(value: unknown): value is pageSwitcher.Page =>
array.includes(Object.values(pageSwitcher.Page), value)
() => localStorage.get('page') ?? TabType.drive,
(value: unknown): value is TabType => array.includes(Object.values(TabType), value)
)
const [projectStartupInfo, setProjectStartupInfo] =
React.useState<backendModule.ProjectStartupInfo | null>(null)
@ -167,13 +178,13 @@ export default function Dashboard(props: DashboardProps) {
React.useEffect(() => {
const savedProjectStartupInfo = localStorage.get('projectStartupInfo')
if (initialProjectName != null) {
if (page === pageSwitcher.Page.editor) {
setPage(pageSwitcher.Page.drive)
if (page === TabType.editor) {
setPage(TabType.drive)
}
} else if (savedProjectStartupInfo != null) {
if (savedProjectStartupInfo.backendType === backendModule.BackendType.remote) {
if (remoteBackend != null) {
setPage(pageSwitcher.Page.drive)
setPage(TabType.drive)
void (async () => {
const abortController = new AbortController()
setOpenProjectAbortController(abortController)
@ -184,17 +195,19 @@ export default function Dashboard(props: DashboardProps) {
savedProjectStartupInfo.projectAsset.title
)
if (backendModule.IS_OPENING_OR_OPENED[oldProject.state.type]) {
const project = await remoteBackend.waitUntilProjectIsReady(
const project = remoteBackend.waitUntilProjectIsReady(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title,
abortController
)
if (!abortController.signal.aborted) {
setProjectStartupInfo(object.merge(savedProjectStartupInfo, { project }))
if (page === pageSwitcher.Page.editor) {
setPage(page)
}
setProjectStartupInfo(
object.merge<backendModule.ProjectStartupInfo>(savedProjectStartupInfo, {
project,
})
)
if (page === TabType.editor) {
setPage(page)
}
}
} catch {
@ -214,13 +227,15 @@ export default function Dashboard(props: DashboardProps) {
},
savedProjectStartupInfo.projectAsset.title
)
const project = await localBackend.getProjectDetails(
const project = localBackend.getProjectDetails(
savedProjectStartupInfo.projectAsset.id,
savedProjectStartupInfo.projectAsset.parentId,
savedProjectStartupInfo.projectAsset.title
)
setProjectStartupInfo(object.merge(savedProjectStartupInfo, { project }))
if (page === pageSwitcher.Page.editor) {
setProjectStartupInfo(
object.merge<backendModule.ProjectStartupInfo>(savedProjectStartupInfo, { project })
)
if (page === TabType.editor) {
setPage(page)
}
})()
@ -248,7 +263,9 @@ export default function Dashboard(props: DashboardProps) {
React.useEffect(() => {
if (initializedRef.current) {
if (projectStartupInfo != null) {
localStorage.set('projectStartupInfo', projectStartupInfo)
void Promise.resolve(projectStartupInfo.project).then(project => {
localStorage.set('projectStartupInfo', { ...projectStartupInfo, project })
})
} else {
localStorage.delete('projectStartupInfo')
}
@ -256,9 +273,7 @@ export default function Dashboard(props: DashboardProps) {
}, [projectStartupInfo, localStorage])
React.useEffect(() => {
if (page !== pageSwitcher.Page.settings) {
localStorage.set('page', page)
}
localStorage.set('page', page)
}, [page, localStorage])
React.useEffect(
@ -268,13 +283,7 @@ export default function Dashboard(props: DashboardProps) {
updateModal(oldModal => {
if (oldModal == null) {
queueMicrotask(() => {
setPage(oldPage => {
if (oldPage !== pageSwitcher.Page.settings) {
return oldPage
} else {
return localStorage.get('page') ?? pageSwitcher.Page.drive
}
})
setPage(localStorage.get('page') ?? TabType.drive)
})
return oldModal
} else {
@ -305,39 +314,26 @@ export default function Dashboard(props: DashboardProps) {
}
}, [inputBindings])
const doOpenEditor = React.useCallback(
async (
backend: Backend,
newProject: backendModule.ProjectAsset,
setProjectAsset: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>,
switchPage: boolean
) => {
if (switchPage) {
setPage(pageSwitcher.Page.editor)
}
if (projectStartupInfo?.project.projectId !== newProject.id) {
setProjectStartupInfo({
project: await backend.getProjectDetails(
newProject.id,
newProject.parentId,
newProject.title
),
projectAsset: newProject,
setProjectAsset: setProjectAsset,
backendType: backend.type,
accessToken: session.accessToken,
const doOpenEditor = React.useCallback(() => {
setPage(TabType.editor)
}, [setPage])
const doCloseEditor = React.useCallback(
(id: backendModule.ProjectId) => {
if (id === projectStartupInfo?.projectAsset.id) {
setProjectStartupInfo(currentInfo => {
if (id === currentInfo?.projectAsset.id) {
setPage(TabType.drive)
return null
} else {
return currentInfo
}
})
}
},
[projectStartupInfo?.project.projectId, session.accessToken, setPage]
[projectStartupInfo?.projectAsset.id, setPage]
)
const doCloseEditor = React.useCallback((closingProject: backendModule.ProjectAsset) => {
setProjectStartupInfo(oldInfo =>
oldInfo?.projectAsset.id === closingProject.id ? null : oldInfo
)
}, [])
const doRemoveSelf = React.useCallback(() => {
if (projectStartupInfo?.projectAsset != null) {
const id = projectStartupInfo.projectAsset.id
@ -347,8 +343,8 @@ export default function Dashboard(props: DashboardProps) {
}, [projectStartupInfo?.projectAsset, dispatchAssetListEvent])
const onSignOut = React.useCallback(() => {
if (page === pageSwitcher.Page.editor) {
setPage(pageSwitcher.Page.drive)
if (page === TabType.editor) {
setPage(TabType.drive)
}
setProjectStartupInfo(null)
}, [page, setPage])
@ -363,22 +359,71 @@ export default function Dashboard(props: DashboardProps) {
unsetModal()
}}
>
<TopBar
projectAsset={projectStartupInfo?.projectAsset ?? null}
setProjectAsset={projectStartupInfo?.setProjectAsset ?? null}
page={page}
setPage={setPage}
isEditorDisabled={projectStartupInfo == null}
setIsHelpChatOpen={setIsHelpChatOpen}
doRemoveSelf={doRemoveSelf}
onSignOut={onSignOut}
/>
<div className="flex">
<TabBar>
<tabBar.Tab
isActive={page === TabType.drive}
icon={DriveIcon}
labelId="drivePageName"
onPress={() => {
setPage(TabType.drive)
}}
>
{getText('drivePageName')}
</tabBar.Tab>
{projectStartupInfo != null && (
<tabBar.Tab
isActive={page === TabType.editor}
icon={WorkspaceIcon}
labelId="editorPageName"
onPress={() => {
setPage(TabType.editor)
}}
onClose={() => {
dispatchAssetEvent({
type: AssetEventType.closeProject,
id: projectStartupInfo.projectAsset.id,
})
setPage(TabType.drive)
}}
>
{projectStartupInfo.projectAsset.title}
</tabBar.Tab>
)}
{page === TabType.settings && (
<tabBar.Tab
isActive
icon={SettingsIcon}
labelId="settingsPageName"
onPress={() => {
setPage(TabType.settings)
}}
onClose={() => {
setPage(TabType.drive)
}}
>
{getText('settingsPageName')}
</tabBar.Tab>
)}
</TabBar>
<UserBar
backend={remoteBackend}
isOnEditorPage={page === TabType.editor}
setIsHelpChatOpen={setIsHelpChatOpen}
projectAsset={projectStartupInfo?.projectAsset ?? null}
setProjectAsset={projectStartupInfo?.setProjectAsset ?? null}
doRemoveSelf={doRemoveSelf}
goToSettingsPage={() => {
setPage(TabType.settings)
}}
onSignOut={onSignOut}
/>
</div>
<Drive
category={category}
setCategory={setCategory}
hidden={page !== pageSwitcher.Page.drive}
hidden={page !== TabType.drive}
initialProjectName={initialProjectName}
projectStartupInfo={projectStartupInfo}
setProjectStartupInfo={setProjectStartupInfo}
assetListEvents={assetListEvents}
dispatchAssetListEvent={dispatchAssetListEvent}
@ -388,13 +433,12 @@ export default function Dashboard(props: DashboardProps) {
doCloseEditor={doCloseEditor}
/>
<Editor
hidden={page !== pageSwitcher.Page.editor}
hidden={page !== TabType.editor}
ydocUrl={ydocUrl}
projectStartupInfo={projectStartupInfo}
appRunner={appRunner}
/>
{page === pageSwitcher.Page.settings && <Settings backend={remoteBackend} />}
{page === TabType.settings && <Settings backend={remoteBackend} />}
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat
isOpen={isHelpChatOpen}

View File

@ -292,8 +292,10 @@ export interface BackendProject extends Project {
}
/** Information required to open a project. */
export interface ProjectStartupInfo {
readonly project: Project
export interface ProjectStartupInfo<
ProjectType extends Project | Promise<Project> = Project | Promise<Project>,
> {
readonly project: ProjectType
readonly projectAsset: ProjectAsset
// This MUST BE optional because it is lost when `JSON.stringify`ing to put in `localStorage`.
readonly setProjectAsset?: React.Dispatch<React.SetStateAction<ProjectAsset>>

View File

@ -1070,7 +1070,11 @@ export default class RemoteBackend extends Backend {
abortController: AbortController = new AbortController()
) {
let project = await this.getProjectDetails(projectId, directory, title)
while (!abortController.signal.aborted && project.state.type !== backend.ProjectState.opened) {
while (project.state.type !== backend.ProjectState.opened) {
if (abortController.signal.aborted) {
// eslint-disable-next-line no-restricted-syntax
throw new Error()
}
await new Promise<void>(resolve => {
setTimeout(resolve, CHECK_STATUS_INTERVAL_MS)
})

View File

@ -178,7 +178,7 @@
"resend": "Resend",
"invite": "Invite",
"copy": "Copy",
"copied": "Copied",
"copied": "Copied!",
"color": "Color",
"labels": "Labels",
"views": "Views",
@ -202,7 +202,7 @@
"keyboardShortcuts": "Keyboard Shortcuts",
"dangerZone": "Danger Zone",
"profilePicture": "Profile picture",
"settingsFor": "Settings for ",
"settingsFor": "Settings for",
"inviteMembers": "Invite Members",
"seatsLeft": "You have $0 / $1 seats left on your plan. Upgrade to invite more users.",
"noSeatsLeft": "You have reached the limit of users for your plan. Upgrade to invite more users.",
@ -464,6 +464,7 @@
"drivePageName": "Data Catalog",
"editorPageName": "Spatial Analysis",
"settingsPageName": "Settings",
"chatButtonAltText": "Chat",
"inviteButtonAltText": "Invite others to try Enso",
"shareButtonAltText": "Share",

View File

@ -207,6 +207,8 @@ declare global {
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly IS_IN_PLAYWRIGHT_TEST?: `${boolean}`
// @ts-expect-error The index signature is intentional to disallow unknown env vars.
readonly PWDEBUG?: '1'
// === Electron watch script variables ===