Even more dashboard fixes (#10541)

- Fix https://github.com/enso-org/cloud-v2/issues/1383
- Fix file download - both on Electron, and in browser
- Refresh versions list when uploading neww file version
- Fix app crashing when asset is opened in asset panel while switching to Local Backend
- Don't show asset id when asset is opened in asset panel, but user is on the Local backend, resulting in the internal Asset ID being shown
- Fix drag-n-drop
- ⚠️ `npm run dev` is NOT fixed in this PR - however it should already be fixed in another PR which has already been merged. This needs testing to confirm whether it is fixed though.

Other changes:
- Add support for "duplicate project" endpoint on Local Backend
- Fix downloading project from nested directory on Local Backend (not working)
- Refactor more E2E tests to use the "new" architecture
- Simplify "new" E2E architecture to minimize boilerplate

# Important Notes
- When testing downloads, both Electron and browser should be tested as they use completely separate implementations for how files are downloaded.
This commit is contained in:
somebody1234 2024-07-16 19:55:45 +10:00 committed by GitHub
parent a30b0c60eb
commit cf9d757457
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
66 changed files with 2157 additions and 1447 deletions

View File

@ -36,7 +36,7 @@
"@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-attributes": "^7.24.7",
"@electron/notarize": "2.1.0", "@electron/notarize": "2.1.0",
"@types/node": "^20.11.21", "@types/node": "^20.11.21",
"electron": "25.7.0", "electron": "31.2.0",
"electron-builder": "^24.13.3", "electron-builder": "^24.13.3",
"enso-common": "workspace:*", "enso-common": "workspace:*",
"enso-gui2": "workspace:*", "enso-gui2": "workspace:*",

View File

@ -38,8 +38,7 @@ import * as urlAssociations from 'url-associations'
const logger = contentConfig.logger const logger = contentConfig.logger
if (process.env.ELECTRON_DEV_MODE === 'true' && process.env.NODE_MODULES_PATH != null) { if (process.env.ELECTRON_DEV_MODE === 'true' && process.env.NODE_MODULES_PATH != null) {
require.main?.paths.unshift(process.env.NODE_MODULES_PATH) module.paths.unshift(process.env.NODE_MODULES_PATH)
console.log(require.main?.paths)
} }
// =========== // ===========
@ -451,6 +450,15 @@ class App {
event.reply(ipc.Channel.importProjectFromPath, path, info) event.reply(ipc.Channel.importProjectFromPath, path, info)
} }
) )
electron.ipcMain.on(
ipc.Channel.downloadURL,
(_event, url: string, headers?: Record<string, string>) => {
electron.BrowserWindow.getFocusedWindow()?.webContents.downloadURL(
url,
headers ? { headers } : {}
)
}
)
electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => { electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => {
electron.shell.showItemInFolder(fullPath) electron.shell.showItemInFolder(fullPath)
}) })

View File

@ -29,5 +29,7 @@ export enum Channel {
openFileBrowser = 'open-file-browser', openFileBrowser = 'open-file-browser',
/** Show a file or folder in the system file browser. */ /** Show a file or folder in the system file browser. */
showItemInFolder = 'show-item-in-folder', showItemInFolder = 'show-item-in-folder',
/** Download a file using its URL. */
downloadURL = 'download-url',
showAboutModal = 'show-about-modal', showAboutModal = 'show-about-modal',
} }

View File

@ -203,6 +203,9 @@ electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API)
// ================== // ==================
const SYSTEM_API = { const SYSTEM_API = {
downloadURL: (url: string, headers?: Record<string, string>) => {
electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers)
},
showItemInFolder: (fullPath: string) => { showItemInFolder: (fullPath: string) => {
electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath) electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath)
}, },

View File

@ -15,6 +15,11 @@ const TRUSTED_HOSTS = [
'production-enso-domain.auth.eu-west-1.amazoncognito.com', 'production-enso-domain.auth.eu-west-1.amazoncognito.com',
'production-enso-organizations-files.s3.amazonaws.com', 'production-enso-organizations-files.s3.amazonaws.com',
'pb-enso-domain.auth.eu-west-1.amazoncognito.com', 'pb-enso-domain.auth.eu-west-1.amazoncognito.com',
'7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com',
'lkxuay3ha1.execute-api.eu-west-1.amazonaws.com',
'8rf1a7iy49.execute-api.eu-west-1.amazonaws.com',
'opk1cxpwec.execute-api.eu-west-1.amazonaws.com',
'xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com',
's3.eu-west-1.amazonaws.com', 's3.eu-west-1.amazonaws.com',
// This (`localhost`) is required to access Project Manager HTTP endpoints. // This (`localhost`) is required to access Project Manager HTTP endpoints.
// This should be changed appropriately if the Project Manager's port number becomes dynamic. // This should be changed appropriately if the Project Manager's port number becomes dynamic.

View File

@ -212,8 +212,6 @@ export interface ProjectStateType {
readonly ec2PublicIpAddress?: string readonly ec2PublicIpAddress?: string
readonly currentSessionId?: string readonly currentSessionId?: string
readonly openedBy?: EmailAddress readonly openedBy?: EmailAddress
/** Only present on the Local backend. */
readonly path?: Path
} }
export const IS_OPENING: Readonly<Record<ProjectState, boolean>> = { export const IS_OPENING: Readonly<Record<ProjectState, boolean>> = {
@ -242,7 +240,7 @@ export const IS_OPENING_OR_OPENED: Readonly<Record<ProjectState, boolean>> = {
/** Common `Project` fields returned by all `Project`-related endpoints. */ /** Common `Project` fields returned by all `Project`-related endpoints. */
export interface BaseProject { export interface BaseProject {
readonly organizationId: string readonly organizationId: OrganizationId
readonly projectId: ProjectId readonly projectId: ProjectId
readonly name: string readonly name: string
} }
@ -1053,15 +1051,11 @@ export interface UpdateFileRequestBody {
export interface UpdateAssetRequestBody { export interface UpdateAssetRequestBody {
readonly parentDirectoryId: DirectoryId | null readonly parentDirectoryId: DirectoryId | null
readonly description: string | null readonly description: string | null
/** Only present on the Local backend. */
readonly projectPath?: Path
} }
/** HTTP request body for the "delete asset" endpoint. */ /** HTTP request body for the "delete asset" endpoint. */
export interface DeleteAssetRequestBody { export interface DeleteAssetRequestBody {
readonly force: boolean readonly force: boolean
/** Only used by the Local backend. */
readonly parentId: DirectoryId
} }
/** HTTP request body for the "create project" endpoint. */ /** HTTP request body for the "create project" endpoint. */
@ -1078,8 +1072,6 @@ export interface UpdateProjectRequestBody {
readonly projectName: string | null readonly projectName: string | null
readonly ami: Ami | null readonly ami: Ami | null
readonly ideVersion: VersionNumber | null readonly ideVersion: VersionNumber | null
/** Only used by the Local backend. */
readonly parentId: DirectoryId
} }
/** HTTP request body for the "open project" endpoint. */ /** HTTP request body for the "open project" endpoint. */
@ -1463,11 +1455,6 @@ export default abstract class Backend {
projectId?: string | null, projectId?: string | null,
metadata?: object | null metadata?: object | null
): Promise<void> ): Promise<void>
/** Return a {@link Promise} that resolves only when a project is ready to open. */ /** Download from an arbitrary URL that is assumed to originate from this backend. */
abstract waitUntilProjectIsReady( abstract download(url: string, name?: string): Promise<void>
projectId: ProjectId,
directory: DirectoryId | null,
title: string,
abortSignal?: AbortSignal
): Promise<Project>
} }

View File

@ -375,8 +375,7 @@ export function locateNewUserGroupModal(page: test.Page) {
/** Find a user menu (if any) on the current page. */ /** Find a user menu (if any) on the current page. */
export function locateUserMenu(page: test.Page) { export function locateUserMenu(page: test.Page) {
// This has no identifying features. return page.getByAltText('User Settings').locator('visible=true')
return page.getByTestId('user-menu')
} }
/** Find a "set username" panel (if any) on the current page. */ /** Find a "set username" panel (if any) on the current page. */
@ -463,7 +462,8 @@ export namespace settings {
/** Navigate so that the "user account" settings section is visible. */ /** Navigate so that the "user account" settings section is visible. */
export async function go(page: test.Page) { export async function go(page: test.Page) {
await test.test.step('Go to "user account" settings section', async () => { await test.test.step('Go to "user account" settings section', async () => {
await press(page, 'Mod+,') await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
}) })
} }
@ -482,7 +482,8 @@ export namespace settings {
/** Navigate so that the "change password" settings section is visible. */ /** Navigate so that the "change password" settings section is visible. */
export async function go(page: test.Page) { export async function go(page: test.Page) {
await test.test.step('Go to "change password" settings section', async () => { await test.test.step('Go to "change password" settings section', async () => {
await press(page, 'Mod+,') await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
}) })
} }
@ -516,7 +517,8 @@ export namespace settings {
/** Navigate so that the "profile picture" settings section is visible. */ /** Navigate so that the "profile picture" settings section is visible. */
export async function go(page: test.Page) { export async function go(page: test.Page) {
await test.test.step('Go to "profile picture" settings section', async () => { await test.test.step('Go to "profile picture" settings section', async () => {
await press(page, 'Mod+,') await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
}) })
} }
@ -535,7 +537,8 @@ export namespace settings {
/** Navigate so that the "organization" settings section is visible. */ /** Navigate so that the "organization" settings section is visible. */
export async function go(page: test.Page) { export async function go(page: test.Page) {
await test.test.step('Go to "organization" settings section', async () => { await test.test.step('Go to "organization" settings section', async () => {
await press(page, 'Mod+,') await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
await settings.tab.organization.locate(page).click() await settings.tab.organization.locate(page).click()
}) })
} }
@ -571,7 +574,8 @@ export namespace settings {
/** Navigate so that the "organization profile picture" settings section is visible. */ /** Navigate so that the "organization profile picture" settings section is visible. */
export async function go(page: test.Page) { export async function go(page: test.Page) {
await test.test.step('Go to "organization profile picture" settings section', async () => { await test.test.step('Go to "organization profile picture" settings section', async () => {
await press(page, 'Mod+,') await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
await settings.tab.organization.locate(page).click() await settings.tab.organization.locate(page).click()
}) })
} }
@ -591,7 +595,8 @@ export namespace settings {
/** Navigate so that the "members" settings section is visible. */ /** Navigate so that the "members" settings section is visible. */
export async function go(page: test.Page, force = false) { export async function go(page: test.Page, force = false) {
await test.test.step('Go to "members" settings section', async () => { await test.test.step('Go to "members" settings section', async () => {
await press(page, 'Mod+,') await locateUserMenu(page).click()
await page.getByRole('button', { name: 'Settings' }).getByText('Settings').click()
await settings.tab.members.locate(page).click({ force }) await settings.tab.members.locate(page).click({ force })
}) })
} }
@ -876,11 +881,10 @@ export const mockApi = apiModule.mockApi
/** Set up all mocks, without logging in. */ /** Set up all mocks, without logging in. */
// This syntax is required for Playwright to work properly. // This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export async function mockAll({ page, setupAPI }: MockParams) { export function mockAll({ page, setupAPI }: MockParams) {
return await test.test.step('Execute all mocks', async () => { return new LoginPageActions(page).step('Execute all mocks', async () => {
const api = await mockApi({ page, setupAPI }) await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI }) await mockDate({ page, setupAPI })
return { api, pageActions: new LoginPageActions(page) }
}) })
} }
@ -891,10 +895,28 @@ export async function mockAll({ page, setupAPI }: MockParams) {
/** Set up all mocks, and log in with dummy credentials. */ /** Set up all mocks, and log in with dummy credentials. */
// This syntax is required for Playwright to work properly. // This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export async function mockAllAndLogin({ page, setupAPI }: MockParams) { export function mockAllAndLogin({ page, setupAPI }: MockParams) {
return new DrivePageActions(page)
.step('Execute all mocks', async () => {
await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
})
.do(thePage => login({ page: thePage, setupAPI }))
}
// ===================================
// === mockAllAndLoginAndExposeAPI ===
// ===================================
/** Set up all mocks, and log in with dummy credentials.
* @deprecated Prefer {@link mockAllAndLogin}. */
// This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax
export async function mockAllAndLoginAndExposeAPI({ page, setupAPI }: MockParams) {
return await test.test.step('Execute all mocks and login', async () => { return await test.test.step('Execute all mocks and login', async () => {
const mocks = await mockAll({ page, setupAPI }) const api = await mockApi({ page, setupAPI })
await mockDate({ page, setupAPI })
await login({ page, setupAPI }) await login({ page, setupAPI })
return { ...mocks, pageActions: new DrivePageActions(page) } return api
}) })
} }

View File

@ -1,6 +1,10 @@
/** @file The base class from which all `Actions` classes are derived. */ /** @file The base class from which all `Actions` classes are derived. */
import * as test from '@playwright/test' import * as test from '@playwright/test'
import type * as inputBindings from '#/utilities/inputBindings'
import * as actions from '../actions'
// ==================== // ====================
// === PageCallback === // === PageCallback ===
// ==================== // ====================
@ -29,13 +33,19 @@ export interface LocatorCallback {
* *
* [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables * [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables
*/ */
export default class BaseActions implements PromiseLike<void> { export default class BaseActions implements Promise<void> {
/** Create a {@link BaseActions}. */ /** Create a {@link BaseActions}. */
constructor( constructor(
protected readonly page: test.Page, protected readonly page: test.Page,
private readonly promise = Promise.resolve() private readonly promise = Promise.resolve()
) {} ) {}
/** Get the string name of the class of this instance. Required for this class to implement
* {@link Promise}. */
get [Symbol.toStringTag]() {
return this.constructor.name
}
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` /** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms. */ * on all other platforms. */
static press(page: test.Page, keyOrShortcut: string): Promise<void> { static press(page: test.Page, keyOrShortcut: string): Promise<void> {
@ -55,7 +65,6 @@ export default class BaseActions implements PromiseLike<void> {
} }
}) })
} }
/** Proxies the `then` method of the internal {@link Promise}. */ /** Proxies the `then` method of the internal {@link Promise}. */
async then<T, E>( async then<T, E>(
// The following types are copied almost verbatim from the type definitions for `Promise`. // The following types are copied almost verbatim from the type definitions for `Promise`.
@ -72,10 +81,17 @@ export default class BaseActions implements PromiseLike<void> {
* to treat this class as a {@link Promise}. */ * to treat this class as a {@link Promise}. */
// The following types are copied almost verbatim from the type definitions for `Promise`. // The following types are copied almost verbatim from the type definitions for `Promise`.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
async catch<T>(onrejected?: ((reason: unknown) => T) | null | undefined) { async catch<T>(onrejected?: ((reason: unknown) => PromiseLike<T> | T) | null | undefined) {
return await this.promise.catch(onrejected) return await this.promise.catch(onrejected)
} }
/** Proxies the `catch` method of the internal {@link Promise}.
* This method is not required for this to be a `thenable`, but it is still useful
* to treat this class as a {@link Promise}. */
async finally(onfinally?: (() => void) | null | undefined): Promise<void> {
await this.promise.finally(onfinally)
}
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */ /** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
into< into<
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>, T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
@ -98,13 +114,45 @@ export default class BaseActions implements PromiseLike<void> {
} }
/** Perform an action on the current page. */ /** Perform an action on the current page. */
step(name: string, callback: PageCallback): this { step(name: string, callback: PageCallback) {
return this.do(() => test.test.step(name, () => callback(this.page))) return this.do(() => test.test.step(name, () => callback(this.page)))
} }
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` /** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
* on all other platforms. */ * on all other platforms. */
press(keyOrShortcut: string): this { press<Key extends string>(keyOrShortcut: inputBindings.AutocompleteKeybind<Key>) {
return this.do(page => BaseActions.press(page, keyOrShortcut)) return this.do(page => BaseActions.press(page, keyOrShortcut))
} }
/** Perform actions until a predicate passes. */
retry(
callback: (actions: this) => this,
predicate: (page: test.Page) => Promise<boolean>,
options: { retries?: number; delay?: number } = {}
) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const { retries = 3, delay = 1_000 } = options
return this.step('Perform actions with retries', async thePage => {
for (let i = 0; i < retries; i += 1) {
await callback(this)
if (await predicate(thePage)) {
// eslint-disable-next-line no-restricted-syntax
return
}
await thePage.waitForTimeout(delay)
}
throw new Error('This action did not succeed.')
})
}
/** Perform actions with the "Mod" modifier key pressed. */
withModPressed<R extends BaseActions>(callback: (actions: this) => R) {
return callback(
this.step('Press "Mod"', async page => {
await page.keyboard.down(await actions.modModifier(page))
})
).step('Release "Mod"', async page => {
await page.keyboard.up(await actions.modModifier(page))
})
}
} }

View File

@ -10,6 +10,22 @@ import NewDataLinkModalActions from './NewDataLinkModalActions'
import PageActions from './PageActions' import PageActions from './PageActions'
import StartModalActions from './StartModalActions' import StartModalActions from './StartModalActions'
// =================
// === Constants ===
// =================
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
const ASSET_ROW_SAFE_POSITION = { x: 300, y: 16 }
// =======================
// === locateAssetRows ===
// =======================
/** Find all assets table rows (if any). */
function locateAssetRows(page: test.Page) {
return actions.locateAssetsTable(page).locator('tbody').getByRole('row')
}
// ======================== // ========================
// === DrivePageActions === // === DrivePageActions ===
// ======================== // ========================
@ -80,47 +96,111 @@ export default class DrivePageActions extends PageActions {
}, },
/** Click to select a specific row. */ /** Click to select a specific row. */
clickRow(index: number) { clickRow(index: number) {
return self.step('Click drive table row', page => return self.step(`Click drive table row #${index}`, page =>
actions locateAssetRows(page).nth(index).click({ position: actions.ASSET_ROW_SAFE_POSITION })
.locateAssetRows(page)
.nth(index)
.click({ position: actions.ASSET_ROW_SAFE_POSITION })
) )
}, },
/** Right click a specific row to bring up its context menu, or the context menu for multiple /** Right click a specific row to bring up its context menu, or the context menu for multiple
* assets when right clicking on a selected asset when multiple assets are selected. */ * assets when right clicking on a selected asset when multiple assets are selected. */
rightClickRow(index: number) { rightClickRow(index: number) {
return self.step('Click drive table row', page => return self.step(`Right click drive table row #${index}`, page =>
actions locateAssetRows(page)
.locateAssetRows(page)
.nth(index) .nth(index)
.click({ button: 'right', position: actions.ASSET_ROW_SAFE_POSITION }) .click({ button: 'right', position: actions.ASSET_ROW_SAFE_POSITION })
) )
}, },
/** Double click a row. */
doubleClickRow(index: number) {
return self.step(`Double dlick drive table row #${index}`, page =>
locateAssetRows(page).nth(index).dblclick({ position: actions.ASSET_ROW_SAFE_POSITION })
)
},
/** Interact with the set of all rows in the Drive table. */ /** Interact with the set of all rows in the Drive table. */
withRows(callback: baseActions.LocatorCallback) { withRows(callback: baseActions.LocatorCallback) {
return self.step('Interact with drive table rows', async page => { return self.step('Interact with drive table rows', async page => {
await callback(actions.locateAssetRows(page)) await callback(locateAssetRows(page))
}) })
}, },
/** Drag a row onto another row. */
dragRowToRow(from: number, to: number) {
return self.step(`Drag drive table row #${from} to row #${to}`, async page => {
const rows = locateAssetRows(page)
await rows.nth(from).dragTo(rows.nth(to), {
sourcePosition: ASSET_ROW_SAFE_POSITION,
targetPosition: ASSET_ROW_SAFE_POSITION,
})
})
},
/** Drag a row onto another row. */
dragRow(from: number, to: test.Locator, force?: boolean) {
return self.step(`Drag drive table row #${from} to custom locator`, page =>
locateAssetRows(page)
.nth(from)
.dragTo(to, {
sourcePosition: ASSET_ROW_SAFE_POSITION,
...(force == null ? {} : { force }),
})
)
},
/** A test assertion to confirm that there is only one row visible, and that row is the /** A test assertion to confirm that there is only one row visible, and that row is the
* placeholder row displayed when there are no assets to show. */ * placeholder row displayed when there are no assets to show. */
expectPlaceholderRow() { expectPlaceholderRow() {
return self.step('Expect placeholder row', async page => { return self.step('Expect placeholder row', async page => {
const assetRows = actions.locateAssetRows(page) const rows = locateAssetRows(page)
await test.expect(assetRows).toHaveCount(1) await test.expect(rows).toHaveCount(1)
await test.expect(assetRows).toHaveText(/You have no files/) await test.expect(rows).toHaveText(/You have no files/)
}) })
}, },
/** A test assertion to confirm that there is only one row visible, and that row is the /** A test assertion to confirm that there is only one row visible, and that row is the
* placeholder row displayed when there are no assets in Trash. */ * placeholder row displayed when there are no assets in Trash. */
expectTrashPlaceholderRow() { expectTrashPlaceholderRow() {
return self.step('Expect trash placeholder row', async page => { return self.step('Expect trash placeholder row', async page => {
const assetRows = actions.locateAssetRows(page) const rows = locateAssetRows(page)
await test.expect(assetRows).toHaveCount(1) await test.expect(rows).toHaveCount(1)
await test.expect(assetRows).toHaveText(/Your trash is empty/) await test.expect(rows).toHaveText(/Your trash is empty/)
}) })
}, },
/** Toggle a column's visibility. */
get toggleColumn() {
return {
/** Toggle visibility for the "modified" column. */
modified() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Modified').click()
)
},
/** Toggle visibility for the "shared with" column. */
sharedWith() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Shared With').click()
)
},
/** Toggle visibility for the "labels" column. */
labels() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Labels').click()
)
},
/** Toggle visibility for the "accessed by projects" column. */
accessedByProjects() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Accessed By Projects').click()
)
},
/** Toggle visibility for the "accessed data" column. */
accessedData() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Accessed Data').click()
)
},
/** Toggle visibility for the "docs" column. */
docs() {
return self.step('Expect trash placeholder row', page =>
page.getByAltText('Docs').click()
)
},
}
},
} }
} }
@ -181,6 +261,13 @@ export default class DrivePageActions extends PageActions {
) )
} }
/** Interact with the container element of the assets table. */
withAssetsTable(callback: baseActions.LocatorCallback) {
return this.step('Interact with drive table', async page => {
await callback(actions.locateAssetsTable(page))
})
}
/** Interact with the Asset Panel. */ /** Interact with the Asset Panel. */
withAssetPanel(callback: baseActions.LocatorCallback) { withAssetPanel(callback: baseActions.LocatorCallback) {
return this.step('Interact with asset panel', async page => { return this.step('Interact with asset panel', async page => {

View File

@ -1,6 +1,7 @@
/** @file Actions for the context menu. */ /** @file Actions for the context menu. */
import type * as baseActions from './BaseActions' import type * as baseActions from './BaseActions'
import type BaseActions from './BaseActions' import type BaseActions from './BaseActions'
import EditorPageActions from './EditorPageActions'
// ========================== // ==========================
// === ContextMenuActions === // === ContextMenuActions ===
@ -19,9 +20,11 @@ export interface ContextMenuActions<T extends BaseActions> {
readonly share: () => T readonly share: () => T
readonly label: () => T readonly label: () => T
readonly duplicate: () => T readonly duplicate: () => T
readonly duplicateProject: () => EditorPageActions
readonly copy: () => T readonly copy: () => T
readonly cut: () => T readonly cut: () => T
readonly paste: () => T readonly paste: () => T
readonly copyAsPath: () => T
readonly download: () => T readonly download: () => T
readonly uploadFiles: () => T readonly uploadFiles: () => T
readonly newFolder: () => T readonly newFolder: () => T
@ -91,9 +94,13 @@ export function contextMenuActions<T extends BaseActions>(
step('Duplicate (context menu)', page => step('Duplicate (context menu)', page =>
page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click() page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click()
), ),
duplicateProject: () =>
step('Duplicate project (context menu)', page =>
page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate').click()
).into(EditorPageActions),
copy: () => copy: () =>
step('Copy (context menu)', page => step('Copy (context menu)', page =>
page.getByRole('button', { name: 'Copy' }).getByText('Copy').click() page.getByRole('button', { name: 'Copy' }).getByText('Copy', { exact: true }).click()
), ),
cut: () => cut: () =>
step('Cut (context menu)', page => step('Cut (context menu)', page =>
@ -103,6 +110,10 @@ export function contextMenuActions<T extends BaseActions>(
step('Paste (context menu)', page => step('Paste (context menu)', page =>
page.getByRole('button', { name: 'Paste' }).getByText('Paste').click() page.getByRole('button', { name: 'Paste' }).getByText('Paste').click()
), ),
copyAsPath: () =>
step('Copy as path (context menu)', page =>
page.getByRole('button', { name: 'Copy As Path' }).getByText('Copy As Path').click()
),
download: () => download: () =>
step('Download (context menu)', page => step('Download (context menu)', page =>
page.getByRole('button', { name: 'Download' }).getByText('Download').click() page.getByRole('button', { name: 'Download' }).getByText('Download').click()

View File

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

View File

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

View File

@ -38,7 +38,7 @@ const BASE_URL = 'https://mock/'
// =============== // ===============
/** Parameters for {@link mockApi}. */ /** Parameters for {@link mockApi}. */
interface MockParams { export interface MockParams {
readonly page: test.Page readonly page: test.Page
readonly setupAPI?: SetupAPI | null | undefined readonly setupAPI?: SetupAPI | null | undefined
} }
@ -51,10 +51,17 @@ export interface SetupAPI {
(api: Awaited<ReturnType<typeof mockApi>>): Promise<void> | void (api: Awaited<ReturnType<typeof mockApi>>): Promise<void> | void
} }
/** The return type of {@link mockApi}. */
export interface MockApi extends Awaited<ReturnType<typeof mockApiInternal>> {}
// This is a function, even though it does not contain function syntax.
// eslint-disable-next-line no-restricted-syntax
export const mockApi: (params: MockParams) => Promise<MockApi> = mockApiInternal
/** Add route handlers for the mock API to a page. */ /** Add route handlers for the mock API to a page. */
// This syntax is required for Playwright to work properly. // This syntax is required for Playwright to work properly.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
export async function mockApi({ page, setupAPI }: MockParams) { async function mockApiInternal({ page, setupAPI }: MockParams) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const defaultEmail = 'email@example.com' as backend.EmailAddress const defaultEmail = 'email@example.com' as backend.EmailAddress
const defaultUsername = 'user name' const defaultUsername = 'user name'
@ -148,7 +155,7 @@ export async function mockApi({ page, setupAPI }: MockParams) {
type: backend.AssetType.project, type: backend.AssetType.project,
id: backend.ProjectId('project-' + uniqueString.uniqueString()), id: backend.ProjectId('project-' + uniqueString.uniqueString()),
projectState: { projectState: {
type: backend.ProjectState.opened, type: backend.ProjectState.closed,
volumeId: '', volumeId: '',
}, },
title, title,
@ -479,26 +486,27 @@ export async function mockApi({ page, setupAPI }: MockParams) {
// === Endpoints with dummy implementations === // === Endpoints with dummy implementations ===
await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => {
const projectId = request.url().match(/[/]projects[/](.+?)[/]copy/)?.[1] ?? '' const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
return { const project = assetMap.get(projectId)
organizationId: defaultOrganizationId, if (!project?.projectState) {
projectId: backend.ProjectId(projectId), throw new Error('Attempting to get a project that does not exist.')
name: 'example project name', } else {
state: { return {
type: backend.ProjectState.opened, organizationId: defaultOrganizationId,
volumeId: '', projectId: projectId,
openedBy: defaultEmail, name: 'example project name',
}, state: project.projectState,
packageName: 'Project_root', packageName: 'Project_root',
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
ide_version: null, ide_version: null,
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
engine_version: { engine_version: {
value: '2023.2.1-nightly.2023.9.29', value: '2023.2.1-nightly.2023.9.29',
lifecycle: backend.VersionLifecycle.development, lifecycle: backend.VersionLifecycle.development,
}, },
address: backend.Address('ws://example.com/'), address: backend.Address('ws://localhost/'),
} satisfies backend.ProjectRaw } satisfies backend.ProjectRaw
}
}) })
// === Endpoints returning `void` === // === Endpoints returning `void` ===
@ -508,7 +516,7 @@ export async function mockApi({ page, setupAPI }: MockParams) {
interface Body { interface Body {
readonly parentDirectoryId: backend.DirectoryId readonly parentDirectoryId: backend.DirectoryId
} }
const assetId = request.url().match(/[/]assets[/](.+?)[/]copy/)?.[1] const assetId = request.url().match(/[/]assets[/]([^?/]+)/)?.[1]
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
if (asset == null) { if (asset == null) {
@ -559,10 +567,20 @@ export async function mockApi({ page, setupAPI }: MockParams) {
await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async route => { await delete_(remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async route => {
await route.fulfill() await route.fulfill()
}) })
await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async route => { await post(remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.closed
}
await route.fulfill() await route.fulfill()
}) })
await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async route => { await post(remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID), async (route, request) => {
const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '')
const project = assetMap.get(projectId)
if (project?.projectState) {
object.unsafeMutable(project.projectState).type = backend.ProjectState.opened
}
await route.fulfill() await route.fulfill()
}) })
await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => { await delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => {
@ -774,7 +792,7 @@ export async function mockApi({ page, setupAPI }: MockParams) {
organizationId: defaultOrganizationId, organizationId: defaultOrganizationId,
packageName: 'Project_root', packageName: 'Project_root',
projectId: id, projectId: id,
state: { type: backend.ProjectState.opened, volumeId: '' }, state: { type: backend.ProjectState.closed, volumeId: '' },
} }
addProject(title, { addProject(title, {
description: null, description: null,

View File

@ -23,59 +23,55 @@ const EMAIL = 'baz.quux@email.com'
// ============= // =============
test.test('open and close asset panel', ({ page }) => test.test('open and close asset panel', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions .createFolder()
.createFolder() .driveTable.clickRow(0)
.driveTable.clickRow(0) .withAssetPanel(async assetPanel => {
.withAssetPanel(async assetPanel => { await actions.expectNotOnScreen(assetPanel)
await actions.expectNotOnScreen(assetPanel) })
}) .toggleAssetPanel()
.toggleAssetPanel() .withAssetPanel(async assetPanel => {
.withAssetPanel(async assetPanel => { await actions.expectOnScreen(assetPanel)
await actions.expectOnScreen(assetPanel) })
}) .toggleAssetPanel()
.toggleAssetPanel() .withAssetPanel(async assetPanel => {
.withAssetPanel(async assetPanel => { await actions.expectNotOnScreen(assetPanel)
await actions.expectNotOnScreen(assetPanel) })
})
)
) )
test.test('asset panel contents', ({ page }) => test.test('asset panel contents', ({ page }) =>
actions.mockAll({ page }).then( actions
async ({ pageActions, api }) => .mockAll({
await pageActions page,
.do(() => { setupAPI: api => {
const { defaultOrganizationId, defaultUserId } = api const { defaultOrganizationId, defaultUserId } = api
api.addProject('project', { api.addProject('project', {
description: DESCRIPTION, description: DESCRIPTION,
permissions: [ permissions: [
{ {
permission: permissions.PermissionAction.own, permission: permissions.PermissionAction.own,
user: { user: {
organizationId: defaultOrganizationId, organizationId: defaultOrganizationId,
// Using the default ID causes the asset to have a dynamic username. // Using the default ID causes the asset to have a dynamic username.
userId: backend.UserId(defaultUserId + '2'), userId: backend.UserId(defaultUserId + '2'),
name: USERNAME, name: USERNAME,
email: backend.EmailAddress(EMAIL), email: backend.EmailAddress(EMAIL),
},
}, },
], },
}) ],
}) })
.login() },
.do(async thePage => { })
await actions.passTermsAndConditionsDialog({ page: thePage }) .login()
}) .do(async thePage => {
.driveTable.clickRow(0) await actions.passTermsAndConditionsDialog({ page: thePage })
.toggleAssetPanel() })
.do(async () => { .driveTable.clickRow(0)
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) .toggleAssetPanel()
// `getByText` is required so that this assertion works if there are multiple permissions. .do(async () => {
await test await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)) // `getByText` is required so that this assertion works if there are multiple permissions.
.toBeVisible() await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
}) })
)
) )

View File

@ -32,17 +32,20 @@ test.test('tags', async ({ page }) => {
}) })
test.test('labels', async ({ page }) => { test.test('labels', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) await actions.mockAllAndLogin({
page,
setupAPI: api => {
api.addLabel('aaaa', backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
},
})
const searchBarInput = actions.locateSearchBarInput(page) const searchBarInput = actions.locateSearchBarInput(page)
const labels = actions.locateSearchBarLabels(page) const labels = actions.locateSearchBarLabels(page)
api.addLabel('aaaa', backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
await actions.reload({ page })
await searchBarInput.click() await searchBarInput.click()
for (const label of await labels.all()) { for (const label of await labels.all()) {

View File

@ -5,42 +5,51 @@ import * as actions from './actions'
const PASS_TIMEOUT = 5_000 const PASS_TIMEOUT = 5_000
test.test('extra columns should stick to right side of assets table', async ({ page }) => { test.test('extra columns should stick to right side of assets table', ({ page }) =>
await actions.mockAllAndLogin({ page }) actions
await actions.locateAccessedByProjectsColumnToggle(page).click() .mockAllAndLogin({ page })
await actions.locateAccessedDataColumnToggle(page).click() .driveTable.toggleColumn.accessedByProjects()
await actions.locateAssetsTable(page).evaluate(element => { .driveTable.toggleColumn.accessedData()
let scrollableParent: HTMLElement | SVGElement | null = element .withAssetsTable(async table => {
while ( await table.evaluate(element => {
scrollableParent != null && let scrollableParent: HTMLElement | SVGElement | null = element
scrollableParent.scrollWidth <= scrollableParent.clientWidth while (
) { scrollableParent != null &&
scrollableParent = scrollableParent.parentElement scrollableParent.scrollWidth <= scrollableParent.clientWidth
} ) {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers scrollableParent = scrollableParent.parentElement
scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) }
}) // eslint-disable-next-line @typescript-eslint/no-magic-numbers
const extraColumns = actions.locateExtraColumns(page) scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' })
const assetsTable = actions.locateAssetsTable(page) })
await test
.expect(async () => {
const extraColumnsRight = await extraColumns.evaluate(
element => element.getBoundingClientRect().right
)
const assetsTableRight = await assetsTable.evaluate(
element => element.getBoundingClientRect().right
)
test.expect(extraColumnsRight).toEqual(assetsTableRight)
}) })
.toPass({ timeout: PASS_TIMEOUT }) .do(async thePage => {
}) const extraColumns = actions.locateExtraColumns(thePage)
const assetsTable = actions.locateAssetsTable(thePage)
await test
.expect(async () => {
const extraColumnsRight = await extraColumns.evaluate(
element => element.getBoundingClientRect().right
)
const assetsTableRight = await assetsTable.evaluate(
element => element.getBoundingClientRect().right
)
test.expect(extraColumnsRight).toEqual(assetsTableRight)
})
.toPass({ timeout: PASS_TIMEOUT })
})
)
test.test('extra columns should stick to top of scroll container', async ({ page }) => { test.test('extra columns should stick to top of scroll container', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) await actions.mockAllAndLogin({
// eslint-disable-next-line @typescript-eslint/no-magic-numbers page,
for (let i = 0; i < 100; i += 1) { setupAPI: api => {
api.addFile('a') // eslint-disable-next-line @typescript-eslint/no-magic-numbers
} for (let i = 0; i < 100; i += 1) {
api.addFile('a')
}
},
})
await actions.reload({ page }) await actions.reload({ page })
await actions.locateAccessedByProjectsColumnToggle(page).click() await actions.locateAccessedByProjectsColumnToggle(page).click()
@ -78,19 +87,23 @@ test.test('extra columns should stick to top of scroll container', async ({ page
.toPass({ timeout: PASS_TIMEOUT }) .toPass({ timeout: PASS_TIMEOUT })
}) })
test.test('can drop onto root directory dropzone', async ({ page }) => { test.test('can drop onto root directory dropzone', ({ page }) =>
const { api } = await actions.mockAllAndLogin({ page }) actions
const assetRows = actions.locateAssetRows(page) .mockAllAndLogin({ page })
const asset = api.addDirectory('a') .createFolder()
api.addFile('b', { parentId: asset.id }) .uploadFile('b', 'testing')
await actions.reload({ page }) .driveTable.doubleClickRow(0)
.driveTable.withRows(async rows => {
await assetRows.nth(0).dblclick() const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) test.expect(childLeft, 'Child is indented further than parent').toBeGreaterThan(parentLeft)
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft) })
await assetRows.nth(1).dragTo(actions.locateRootDirectoryDropzone(page), { force: true }) .driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page))
const firstLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) .driveTable.withRows(async rows => {
const secondLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0))
test.expect(firstLeft, 'siblings have same indentation').toEqual(secondLeft) // The second row is the indented child of the directory
}) // (the "this folder is empty" row).
const secondLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(firstLeft, 'Siblings have same indentation').toEqual(secondLeft)
})
)

View File

@ -2,220 +2,191 @@
import * as test from '@playwright/test' import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import EditorPageActions from './actions/EditorPageActions'
// =====================
// === Local actions ===
// =====================
// These actions have been migrated to the new API, and are included here as a temporary measure
// until this file is also migrated to the new API.
/** Find a "duplicate" button (if any) on the current page. */
export function locateDuplicateButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Duplicate' }).getByText('Duplicate')
}
/** Find a "copy" button (if any) on the current page. */
function locateCopyButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Copy' }).getByText('Copy', { exact: true })
}
/** Find a "cut" button (if any) on the current page. */
function locateCutButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Cut' }).getByText('Cut')
}
/** Find a "paste" button (if any) on the current page. */
function locatePasteButton(page: test.Locator | test.Page) {
return page.getByRole('button', { name: 'Paste' }).getByText('Paste')
}
/** A test assertion to confirm that there is only one row visible, and that row is the
* placeholder row displayed when there are no assets to show. */
export async function expectPlaceholderRow(page: test.Page) {
const assetRows = actions.locateAssetRows(page)
await test.test.step('Expect placeholder row', async () => {
await test.expect(assetRows).toHaveCount(1)
await test.expect(assetRows).toHaveText(/You have no files/)
})
}
// ============= // =============
// === Tests === // === Tests ===
// ============= // =============
test.test.beforeEach(({ page }) => { test.test('copy', ({ page }) =>
return actions.mockAllAndLogin({ page }) actions
}) .mockAllAndLogin({ page })
// Assets: [0: Folder 1]
test.test('copy', async ({ page }) => { .createFolder()
const assetRows = actions.locateAssetRows(page) // Assets: [0: Folder 2, 1: Folder 1]
.createFolder()
await actions.locateNewFolderIcon(page).click() .driveTable.rightClickRow(0)
// Assets: [0: Folder 1] // Assets: [0: Folder 2 <copied>, 1: Folder 1]
await actions.locateNewFolderIcon(page).click() .contextMenu.copy()
// Assets: [0: Folder 2, 1: Folder 1] .driveTable.rightClickRow(1)
await assetRows.nth(0).click({ button: 'right' }) // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
await test.expect(actions.locateContextMenus(page)).toBeVisible() .contextMenu.paste()
await locateCopyButton(page).click() .driveTable.withRows(async rows => {
// Assets: [0: Folder 2 <copied>, 1: Folder 1] await test.expect(rows).toHaveCount(3)
await test.expect(actions.locateContextMenus(page)).not.toBeVisible() await test.expect(rows.nth(2)).toBeVisible()
await assetRows.nth(1).click({ button: 'right' }) await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
await test.expect(actions.locateContextMenus(page)).toBeVisible() const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
await locatePasteButton(page).click() const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>] test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
await test.expect(assetRows).toHaveCount(3)
await test.expect(assetRows.nth(2)).toBeVisible()
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
test.test('copy (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 2, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+C')
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(1))
await actions.press(page, 'Mod+V')
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(3)
await test.expect(assetRows.nth(2)).toBeVisible()
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
test.test('move', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 2, 1: Folder 1]
await assetRows.nth(0).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locateCutButton(page).click()
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
await assetRows.nth(1).click({ button: 'right' })
await test.expect(actions.locateContextMenus(page)).toBeVisible()
await locatePasteButton(page).click()
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
test.test('move (drag)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 2, 1: Folder 1]
await actions.dragAssetRowToAssetRow(assetRows.nth(0), assetRows.nth(1))
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
test.test('move to trash', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
await actions.locateNewFolderIcon(page).click()
await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0))
await actions.clickAssetRow(assetRows.nth(1))
// NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still
// held.
await page.keyboard.up(await actions.modModifier(page))
await actions.dragAssetRow(assetRows.nth(0), actions.locateTrashCategory(page))
await expectPlaceholderRow(page)
await actions.locateTrashCategory(page).click()
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(0)).toBeVisible()
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
})
test.test('move (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 1]
await actions.locateNewFolderIcon(page).click()
// Assets: [0: Folder 2, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+X')
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
await actions.clickAssetRow(assetRows.nth(1))
await actions.press(page, 'Mod+V')
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
await test.expect(assetRows).toHaveCount(2)
await test.expect(assetRows.nth(1)).toBeVisible()
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
test.test('cut (keyboard)', async ({ page }) => {
const assetRows = actions.locateAssetRows(page)
await actions.locateNewFolderIcon(page).click()
await actions.clickAssetRow(assetRows.nth(0))
await actions.press(page, 'Mod+X')
// This action is not a builtin `expect` action, so it needs to be manually retried.
await test
.expect(async () => {
test
.expect(await assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
.toBeLessThan(1)
}) })
.toPass() )
})
test.test('duplicate', async ({ page }) => { test.test('copy (keyboard)', ({ page }) =>
const assetRows = actions.locateAssetRows(page) actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
.createFolder()
.driveTable.clickRow(0)
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
.press('Mod+C')
.driveTable.clickRow(1)
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
.press('Mod+V')
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(3)
await test.expect(rows.nth(2)).toBeVisible()
await test.expect(rows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(1))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(2))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
await actions.locateNewFolderIcon(page).click() test.test('move', ({ page }) =>
// Assets: [0: Folder 1] actions
await assetRows.nth(0).click({ button: 'right' }) .mockAllAndLogin({ page })
await test.expect(actions.locateContextMenus(page)).toBeVisible() // Assets: [0: Folder 1]
await locateDuplicateButton(page).click() .createFolder()
// Assets: [0: Folder 1 (copy), 1: Folder 1] // Assets: [0: Folder 2, 1: Folder 1]
await test.expect(assetRows).toHaveCount(2) .createFolder()
await test.expect(actions.locateContextMenus(page)).not.toBeVisible() .driveTable.rightClickRow(0)
await test.expect(assetRows.nth(0)).toBeVisible() // Assets: [0: Folder 2 <cut>, 1: Folder 1]
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/) .contextMenu.cut()
}) .driveTable.rightClickRow(1)
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.contextMenu.paste()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('duplicate (keyboard)', async ({ page }) => { test.test('move (drag)', ({ page }) =>
const assetRows = actions.locateAssetRows(page) actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
.createFolder()
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.driveTable.dragRowToRow(0, 1)
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
await actions.locateNewFolderIcon(page).click() test.test('move to trash', ({ page }) =>
// Assets: [0: Folder 1] actions
await actions.clickAssetRow(assetRows.nth(0)) .mockAllAndLogin({ page })
await actions.press(page, 'Mod+D') // Assets: [0: Folder 1]
// Assets: [0: Folder 1 (copy), 1: Folder 1] .createFolder()
await test.expect(assetRows).toHaveCount(2) // Assets: [0: Folder 2, 1: Folder 1]
await test.expect(assetRows.nth(0)).toBeVisible() .createFolder()
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/) // NOTE: For some reason, `react-aria-components` causes drag-n-drop to break if `Mod` is still
}) // held.
.withModPressed(modActions => modActions.driveTable.clickRow(0).driveTable.clickRow(1))
.driveTable.dragRow(0, actions.locateTrashCategory(page))
.driveTable.expectPlaceholderRow()
.goToCategory.trash()
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveText([/^New Folder 1/, /^New Folder 2/])
})
)
test.test('move (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: Folder 1]
.createFolder()
// Assets: [0: Folder 2, 1: Folder 1]
.createFolder()
.driveTable.clickRow(0)
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
.press('Mod+X')
.driveTable.clickRow(1)
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
.press('Mod+V')
.driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(1)).toBeVisible()
await test.expect(rows.nth(1)).toHaveText(/^New Folder 2/)
const parentLeft = await actions.getAssetRowLeftPx(rows.nth(0))
const childLeft = await actions.getAssetRowLeftPx(rows.nth(1))
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
})
)
test.test('cut (keyboard)', async ({ page }) =>
actions
.mockAllAndLogin({ page })
.createFolder()
.driveTable.clickRow(0)
.press('Mod+X')
.driveTable.withRows(async rows => {
// This action is not a builtin `expect` action, so it needs to be manually retried.
await test
.expect(async () => {
test
.expect(await rows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
.toBeLessThan(1)
})
.toPass()
})
)
test.test('duplicate', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: New Project 1]
.newEmptyProject()
.goToPage.drive()
.driveTable.rightClickRow(0)
.contextMenu.duplicateProject()
.goToPage.drive()
.driveTable.withRows(async rows => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
})
)
test.test('duplicate (keyboard)', ({ page }) =>
actions
.mockAllAndLogin({ page })
// Assets: [0: New Project 1]
.newEmptyProject()
.goToPage.drive()
.driveTable.clickRow(0)
.press('Mod+D')
.into(EditorPageActions)
.goToPage.drive()
.driveTable.withRows(async rows => {
// Assets: [0: New Project 1 (copy), 1: New Project 1]
await test.expect(rows).toHaveCount(2)
await test.expect(rows.nth(0)).toBeVisible()
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
})
)

View File

@ -21,45 +21,43 @@ const SECRET_VALUE = 'a secret value'
// ============= // =============
test.test('create folder', ({ page }) => test.test('create folder', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions.createFolder().driveTable.withRows(async rows => { .createFolder()
await test.expect(rows).toHaveCount(1) .driveTable.withRows(async rows => {
await test.expect(rows.nth(0)).toBeVisible() await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/) await test.expect(rows.nth(0)).toBeVisible()
}) await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/)
) })
) )
test.test('create project', ({ page }) => test.test('create project', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions .newEmptyProject()
.newEmptyProject() .do(thePage => test.expect(actions.locateEditor(thePage)).toBeAttached())
.do(thePage => test.expect(actions.locateEditor(thePage)).toBeAttached()) .goToPage.drive()
.goToPage.drive() .driveTable.withRows(rows => test.expect(rows).toHaveCount(1))
.driveTable.withRows(rows => test.expect(rows).toHaveCount(1))
)
) )
test.test('upload file', ({ page }) => test.test('upload file', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions.uploadFile(FILE_NAME, FILE_CONTENTS).driveTable.withRows(async rows => { .uploadFile(FILE_NAME, FILE_CONTENTS)
await test.expect(rows).toHaveCount(1) .driveTable.withRows(async rows => {
await test.expect(rows.nth(0)).toBeVisible() await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) await test.expect(rows.nth(0)).toBeVisible()
}) await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME))
) })
) )
test.test('create secret', ({ page }) => test.test('create secret', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions.createSecret(SECRET_NAME, SECRET_VALUE).driveTable.withRows(async rows => { .createSecret(SECRET_NAME, SECRET_VALUE)
await test.expect(rows).toHaveCount(1) .driveTable.withRows(async rows => {
await test.expect(rows.nth(0)).toBeVisible() await test.expect(rows).toHaveCount(1)
await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) await test.expect(rows.nth(0)).toBeVisible()
}) await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME))
) })
) )

View File

@ -6,10 +6,10 @@ import * as actions from './actions'
const DATA_LINK_NAME = 'a data link' const DATA_LINK_NAME = 'a data link'
test.test('data link editor', ({ page }) => test.test('data link editor', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions.openDataLinkModal().withNameInput(async input => { .openDataLinkModal()
await input.fill(DATA_LINK_NAME) .withNameInput(async input => {
}) await input.fill(DATA_LINK_NAME)
) })
) )

View File

@ -4,51 +4,47 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('delete and restore', ({ page }) => test.test('delete and restore', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions .createFolder()
.createFolder() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
}) .driveTable.rightClickRow(0)
.driveTable.rightClickRow(0) .contextMenu.moveToTrash()
.contextMenu.moveToTrash() .driveTable.expectPlaceholderRow()
.driveTable.expectPlaceholderRow() .goToCategory.trash()
.goToCategory.trash() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
}) .driveTable.rightClickRow(0)
.driveTable.rightClickRow(0) .contextMenu.restoreFromTrash()
.contextMenu.restoreFromTrash() .driveTable.expectTrashPlaceholderRow()
.driveTable.expectTrashPlaceholderRow() .goToCategory.cloud()
.goToCategory.cloud() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
})
)
) )
test.test('delete and restore (keyboard)', ({ page }) => test.test('delete and restore (keyboard)', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions .createFolder()
.createFolder() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
}) .driveTable.clickRow(0)
.driveTable.clickRow(0) .press('Delete')
.press('Delete') .driveTable.expectPlaceholderRow()
.driveTable.expectPlaceholderRow() .goToCategory.trash()
.goToCategory.trash() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
}) .driveTable.clickRow(0)
.driveTable.clickRow(0) .press('Mod+R')
.press('Mod+R') .driveTable.expectTrashPlaceholderRow()
.driveTable.expectTrashPlaceholderRow() .goToCategory.cloud()
.goToCategory.cloud() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
})
)
) )

View File

@ -4,49 +4,41 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('drive view', ({ page }) => test.test('drive view', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions .withDriveView(async view => {
.withDriveView(async view => { await test.expect(view).toBeVisible()
await test.expect(view).toBeVisible() })
}) .driveTable.expectPlaceholderRow()
.driveTable.expectPlaceholderRow() .newEmptyProject()
.newEmptyProject() .do(async () => {
.do(async () => { await test.expect(actions.locateEditor(page)).toBeAttached()
await test.expect(actions.locateEditor(page)).toBeAttached() })
}) .goToPage.drive()
.goToPage.drive() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(1)
await test.expect(rows).toHaveCount(1) })
}) .do(async () => {
.do(async () => { await test.expect(actions.locateAssetsTable(page)).toBeVisible()
await test.expect(actions.locateAssetsTable(page)).toBeVisible() })
}) .newEmptyProject()
.newEmptyProject() .do(async () => {
.do(async () => { await test.expect(actions.locateEditor(page)).toBeAttached()
await test.expect(actions.locateEditor(page)).toBeAttached() })
}) .goToPage.drive()
.goToPage.drive() .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await test.expect(rows).toHaveCount(2)
await test.expect(rows).toHaveCount(2) })
}) // The last opened project needs to be stopped, to remove the toast notification notifying the
// The last opened project needs to be stopped, to remove the toast notification notifying the // user that project creation may take a while. Previously opened projects are stopped when the
// user that project creation may take a while. Previously opened projects are stopped when the // new project is created.
// new project is created. .driveTable.withRows(async rows => {
.driveTable.withRows(async rows => { await actions.locateStopProjectButton(rows.nth(0)).click()
await actions.locateStopProjectButton(rows.nth(0)).click() })
})
// FIXME(#10488): This test fails because the mock endpoint returns the project is opened,
// but it must be stopped first to delete the project.
// Project context menu // Project context menu
// .driveTable.rightClickRow(0) .driveTable.rightClickRow(0)
// .withContextMenus(async menus => { .contextMenu.moveToTrash()
// // actions.locateContextMenus(page) .driveTable.withRows(async rows => {
// await test.expect(menus).toBeVisible() await test.expect(rows).toHaveCount(1)
// }) })
// .contextMenu.moveToTrash()
// .driveTable.withRows(async rows => {
// await test.expect(rows).toHaveCount(1)
// })
)
) )

View File

@ -6,21 +6,25 @@ import * as backend from '#/services/Backend'
import * as actions from './actions' import * as actions from './actions'
test.test('drag labels onto single row', async ({ page }) => { test.test('drag labels onto single row', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const label = 'aaaa' const label = 'aaaa'
await actions.mockAllAndLogin({
page,
setupAPI: api => {
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label) const labelEl = actions.locateLabelsPanelLabels(page, label)
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
await actions.relog({ page }) await actions.relog({ page })
await test.expect(labelEl).toBeVisible() await test.expect(labelEl).toBeVisible()
@ -32,22 +36,25 @@ test.test('drag labels onto single row', async ({ page }) => {
}) })
test.test('drag labels onto multiple rows', async ({ page }) => { test.test('drag labels onto multiple rows', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page })
const assetRows = actions.locateAssetRows(page)
const label = 'aaaa' const label = 'aaaa'
await actions.mockAllAndLogin({
page,
setupAPI: api => {
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
},
})
const assetRows = actions.locateAssetRows(page)
const labelEl = actions.locateLabelsPanelLabels(page, label) const labelEl = actions.locateLabelsPanelLabels(page, label)
api.addLabel(label, backend.COLORS[0])
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('bbbb', backend.COLORS[1]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('cccc', backend.COLORS[2]!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
api.addLabel('dddd', backend.COLORS[3]!)
api.addDirectory('foo')
api.addSecret('bar')
api.addFile('baz')
api.addSecret('quux')
await actions.relog({ page })
await page.keyboard.down(await actions.modModifier(page)) await page.keyboard.down(await actions.modModifier(page))
await actions.clickAssetRow(assetRows.nth(0)) await actions.clickAssetRow(assetRows.nth(0))

View File

@ -8,20 +8,18 @@ import * as actions from './actions'
// ============= // =============
test.test('login and logout', ({ page }) => test.test('login and logout', ({ page }) =>
actions.mockAll({ page }).then( actions
async ({ pageActions }) => .mockAll({ page })
await pageActions .login()
.login() .do(async thePage => {
.do(async thePage => { await actions.passTermsAndConditionsDialog({ page: thePage })
await actions.passTermsAndConditionsDialog({ page: thePage }) await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateDriveView(thePage)).toBeVisible() await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible() })
}) .openUserMenu()
.openUserMenu() .userMenu.logout()
.userMenu.logout() .do(async thePage => {
.do(async thePage => { await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() await test.expect(actions.locateLoginButton(thePage)).toBeVisible()
await test.expect(actions.locateLoginButton(thePage)).toBeVisible() })
})
)
) )

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('members settings', async ({ page }) => { test.test('members settings', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.members const localActions = actions.settings.members
// Setup // Setup

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('organization settings', async ({ page }) => { test.test('organization settings', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.organization const localActions = actions.settings.organization
// Setup // Setup
@ -76,7 +76,7 @@ test.test('organization settings', async ({ page }) => {
}) })
test.test('upload organization profile picture', async ({ page }) => { test.test('upload organization profile picture', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.organizationProfilePicture const localActions = actions.settings.organizationProfilePicture
await localActions.go(page) await localActions.go(page)

View File

@ -4,20 +4,18 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('page switcher', ({ page }) => test.test('page switcher', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions // Create a new project so that the editor page can be switched to.
// Create a new project so that the editor page can be switched to. .newEmptyProject()
.newEmptyProject() .goToPage.drive()
.goToPage.drive() .do(async thePage => {
.do(async thePage => { await test.expect(actions.locateDriveView(thePage)).toBeVisible()
await test.expect(actions.locateDriveView(thePage)).toBeVisible() await test.expect(actions.locateEditor(thePage)).not.toBeVisible()
await test.expect(actions.locateEditor(thePage)).not.toBeVisible() })
}) .goToPage.editor()
.goToPage.editor() .do(async thePage => {
.do(async thePage => { await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() await test.expect(actions.locateEditor(thePage)).toBeVisible()
await test.expect(actions.locateEditor(thePage)).toBeVisible() })
})
)
) )

View File

@ -2,6 +2,7 @@
import * as test from '@playwright/test' import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import type * as api from './api'
// ================= // =================
// === Constants === // === Constants ===
@ -75,46 +76,48 @@ test.test('sign up without organization id', async ({ page }) => {
.toBe(api.defaultOrganizationId) .toBe(api.defaultOrganizationId)
}) })
test.test('sign up flow', ({ page }) => test.test('sign up flow', ({ page }) => {
actions.mockAll({ page }).then( let api!: api.MockApi
async ({ pageActions, api }) => return actions
await pageActions .mockAll({
.do(() => { page,
api.setCurrentUser(null) setupAPI: theApi => {
api = theApi
theApi.setCurrentUser(null)
// These values should be different, otherwise the email and name may come from the defaults. // These values should be different, otherwise the email and name may come from the defaults.
test.expect(EMAIL).not.toStrictEqual(api.defaultEmail) test.expect(EMAIL).not.toStrictEqual(theApi.defaultEmail)
test.expect(NAME).not.toStrictEqual(api.defaultName) test.expect(NAME).not.toStrictEqual(theApi.defaultName)
}) },
.loginAsNewUser(EMAIL, actions.VALID_PASSWORD) })
.do(async thePage => { .loginAsNewUser(EMAIL, actions.VALID_PASSWORD)
await actions.passTermsAndConditionsDialog({ page: thePage }) .do(async thePage => {
}) await actions.passTermsAndConditionsDialog({ page: thePage })
.setUsername(NAME) })
.do(async thePage => { .setUsername(NAME)
await test.expect(actions.locateUpgradeButton(thePage)).toBeVisible() .do(async thePage => {
await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() await test.expect(actions.locateUpgradeButton(thePage)).toBeVisible()
}) await test.expect(actions.locateDriveView(thePage)).not.toBeVisible()
.do(() => { })
// Logged in, and account enabled .do(() => {
const currentUser = api.currentUser() // Logged in, and account enabled
test.expect(currentUser).toBeDefined() const currentUser = api.currentUser()
if (currentUser != null) { test.expect(currentUser).toBeDefined()
// This is required because `UserOrOrganization` is `readonly`. if (currentUser != null) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, no-extra-semi // This is required because `UserOrOrganization` is `readonly`.
;(currentUser as { isEnabled: boolean }).isEnabled = true // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, no-restricted-syntax, no-extra-semi
} ;(currentUser as { isEnabled: boolean }).isEnabled = true
}) }
.openUserMenu() })
.userMenu.logout() .openUserMenu()
.login(EMAIL, actions.VALID_PASSWORD) .userMenu.logout()
.do(async () => { .login(EMAIL, actions.VALID_PASSWORD)
await test.expect(actions.locateNotEnabledStub(page)).not.toBeVisible() .do(async () => {
await test.expect(actions.locateDriveView(page)).toBeVisible() await test.expect(actions.locateNotEnabledStub(page)).not.toBeVisible()
}) await test.expect(actions.locateDriveView(page)).toBeVisible()
.do(() => { })
test.expect(api.currentUser()?.email, 'new user has correct email').toBe(EMAIL) .do(() => {
test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME) test.expect(api.currentUser()?.email, 'new user has correct email').toBe(EMAIL)
}) test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME)
) })
) })

View File

@ -20,35 +20,39 @@ const MIN_MS = 60_000
// ============= // =============
test.test('sort', async ({ page }) => { test.test('sort', async ({ page }) => {
const { api } = await actions.mockAll({ page }) await actions.mockAll({
page,
setupAPI: api => {
const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS))
const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
const date4 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS))
const date5 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS))
const date6 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS))
const date7 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS))
const date8 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS))
api.addDirectory('a directory', { modifiedAt: date4 })
api.addDirectory('G directory', { modifiedAt: date6 })
api.addProject('C project', { modifiedAt: date7 })
api.addSecret('H secret', { modifiedAt: date2 })
api.addProject('b project', { modifiedAt: date1 })
api.addFile('d file', { modifiedAt: date8 })
api.addSecret('f secret', { modifiedAt: date3 })
api.addFile('e file', { modifiedAt: date5 })
// By date:
// b project
// h secret
// f secret
// a directory
// e file
// g directory
// c project
// d file
},
})
const assetRows = actions.locateAssetRows(page) const assetRows = actions.locateAssetRows(page)
const nameHeading = actions.locateNameColumnHeading(page) const nameHeading = actions.locateNameColumnHeading(page)
const modifiedHeading = actions.locateModifiedColumnHeading(page) const modifiedHeading = actions.locateModifiedColumnHeading(page)
const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS))
const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
const date4 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS))
const date5 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS))
const date6 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS))
const date7 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS))
const date8 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS))
api.addDirectory('a directory', { modifiedAt: date4 })
api.addDirectory('G directory', { modifiedAt: date6 })
api.addProject('C project', { modifiedAt: date7 })
api.addSecret('H secret', { modifiedAt: date2 })
api.addProject('b project', { modifiedAt: date1 })
api.addFile('d file', { modifiedAt: date8 })
api.addSecret('f secret', { modifiedAt: date3 })
api.addFile('e file', { modifiedAt: date5 })
// By date:
// b project
// h secret
// f secret
// a directory
// e file
// g directory
// c project
// d file
await actions.login({ page }) await actions.login({ page })
// By default, assets should be grouped by type. // By default, assets should be grouped by type.

View File

@ -4,14 +4,12 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('create project from template', ({ page }) => test.test('create project from template', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions .openStartModal()
.openStartModal() .createProjectFromTemplate(0)
.createProjectFromTemplate(0) .do(async thePage => {
.do(async thePage => { await test.expect(actions.locateEditor(thePage)).toBeAttached()
await test.expect(actions.locateEditor(thePage)).toBeAttached() await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
await test.expect(actions.locateSamples(page).first()).not.toBeVisible() })
})
)
) )

View File

@ -4,20 +4,20 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('user menu', ({ page }) => test.test('user menu', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions.openUserMenu().do(async thePage => { .openUserMenu()
await test.expect(actions.locateUserMenu(thePage)).toBeVisible() .do(async thePage => {
}) await test.expect(actions.locateUserMenu(thePage)).toBeVisible()
) })
) )
test.test('download app', ({ page }) => test.test('download app', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions.openUserMenu().userMenu.downloadApp(async download => { .openUserMenu()
await download.cancel() .userMenu.downloadApp(async download => {
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/) await download.cancel()
}) test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
) })
) )

View File

@ -4,7 +4,7 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('user settings', async ({ page }) => { test.test('user settings', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.userAccount const localActions = actions.settings.userAccount
test.expect(api.currentUser()?.name).toBe(api.defaultName) test.expect(api.currentUser()?.name).toBe(api.defaultName)
@ -18,7 +18,7 @@ test.test('user settings', async ({ page }) => {
}) })
test.test('change password form', async ({ page }) => { test.test('change password form', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.changePassword const localActions = actions.settings.changePassword
await localActions.go(page) await localActions.go(page)
@ -79,7 +79,7 @@ test.test('change password form', async ({ page }) => {
}) })
test.test('upload profile picture', async ({ page }) => { test.test('upload profile picture', async ({ page }) => {
const { api } = await actions.mockAllAndLogin({ page }) const api = await actions.mockAllAndLoginAndExposeAPI({ page })
const localActions = actions.settings.profilePicture const localActions = actions.settings.profilePicture
await localActions.go(page) await localActions.go(page)

View File

@ -15,8 +15,6 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType' import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType' import AssetListEventType from '#/events/AssetListEventType'
import type * as dashboard from '#/pages/dashboard/Dashboard'
import AssetContextMenu from '#/layouts/AssetContextMenu' import AssetContextMenu from '#/layouts/AssetContextMenu'
import type * as assetsTable from '#/layouts/AssetsTable' import type * as assetsTable from '#/layouts/AssetsTable'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
@ -92,24 +90,14 @@ export interface AssetRowProps
props: AssetRowInnerProps, props: AssetRowInnerProps,
event: React.MouseEvent<HTMLTableRowElement> event: React.MouseEvent<HTMLTableRowElement>
) => void ) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void> readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void>
} }
/** A row containing an {@link backendModule.AnyAsset}. */ /** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) { export default function AssetRow(props: AssetRowProps) {
const { const { selected, isSoleSelected, isKeyboardSelected, isOpened } = props
item: rawItem,
hidden: hiddenRaw,
selected,
isSoleSelected,
isKeyboardSelected,
isOpened,
updateAssetRef,
} = props
const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props const { setSelected, allowContextMenu, onContextMenu, state, columns, onClick } = props
const { grabKeyboardFocus, doOpenProject, doCloseProject } = props const { item: rawItem, hidden: hiddenRaw, updateAssetRef, grabKeyboardFocus } = props
const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state const { nodeMap, setAssetPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state const { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state
const { visibilities } = state const { visibilities } = state
@ -164,8 +152,8 @@ export default function AssetRow(props: AssetRowProps) {
}, [rawItem]) }, [rawItem])
React.useEffect(() => { React.useEffect(() => {
// Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to avoid // Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to update the
// re-rendering the parent. // parent's state while avoiding re-rendering the parent.
rawItem.item = asset rawItem.item = asset
}, [asset, rawItem]) }, [asset, rawItem])
const setAsset = setAssetHooks.useSetAsset(asset, setItem) const setAsset = setAssetHooks.useSetAsset(asset, setItem)
@ -242,20 +230,6 @@ export default function AssetRow(props: AssetRowProps) {
oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId }) oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId })
) )
const newParentPath = localBackend.extractTypeAndId(nonNullNewParentId).id const newParentPath = localBackend.extractTypeAndId(nonNullNewParentId).id
const newProjectState =
asset.projectState == null
? null
: object.merge(
asset.projectState,
asset.projectState.path == null
? {}
: {
path: projectManager.joinPath(
newParentPath,
fileInfo.fileName(asset.projectState.path)
),
}
)
let newId = asset.id let newId = asset.id
if (!isCloud) { if (!isCloud) {
const oldPath = localBackend.extractTypeAndId(asset.id).id const oldPath = localBackend.extractTypeAndId(asset.id).id
@ -282,13 +256,9 @@ export default function AssetRow(props: AssetRowProps) {
} }
} }
} }
const newAsset = object.merge(asset, { // This is SAFE as the type of `newId` is not changed from its original type.
// This is SAFE as the type of `newId` is not changed from its original type. // eslint-disable-next-line no-restricted-syntax
// eslint-disable-next-line no-restricted-syntax const newAsset = object.merge(asset, { id: newId as never, parentId: nonNullNewParentId })
id: newId as never,
parentId: nonNullNewParentId,
projectState: newProjectState,
})
dispatchAssetListEvent({ dispatchAssetListEvent({
type: AssetListEventType.move, type: AssetListEventType.move,
newParentKey: nonNullNewParentKey, newParentKey: nonNullNewParentKey,
@ -299,11 +269,7 @@ export default function AssetRow(props: AssetRowProps) {
setAsset(newAsset) setAsset(newAsset)
await updateAssetMutate([ await updateAssetMutate([
asset.id, asset.id,
{ { parentDirectoryId: newParentId ?? rootDirectoryId, description: null },
parentDirectoryId: newParentId ?? rootDirectoryId,
description: null,
...(asset.projectState?.path == null ? {} : { projectPath: asset.projectState.path }),
},
asset.title, asset.title,
]) ])
} catch (error) { } catch (error) {
@ -381,11 +347,7 @@ export default function AssetRow(props: AssetRowProps) {
// Ignored. The project was already closed. // Ignored. The project was already closed.
} }
} }
await deleteAssetMutate([ await deleteAssetMutate([asset.id, { force: forever }, asset.title])
asset.id,
{ force: forever, parentId: asset.parentId },
asset.title,
])
dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key })
} catch (error) { } catch (error) {
setInsertionVisibility(Visibility.visible) setInsertionVisibility(Visibility.visible)
@ -524,7 +486,7 @@ export default function AssetRow(props: AssetRowProps) {
asset.title, asset.title,
]) ])
if (details.url != null) { if (details.url != null) {
download.download(details.url, asset.title) await backend.download(details.url, asset.title)
} else { } else {
const error: unknown = getText('projectHasNoSourceFilesPhrase') const error: unknown = getText('projectHasNoSourceFilesPhrase')
toastAndLog('downloadProjectError', error, asset.title) toastAndLog('downloadProjectError', error, asset.title)
@ -541,7 +503,7 @@ export default function AssetRow(props: AssetRowProps) {
asset.title, asset.title,
]) ])
if (details.url != null) { if (details.url != null) {
download.download(details.url, asset.title) await backend.download(details.url, asset.title)
} else { } else {
const error: unknown = getText('fileNotFoundPhrase') const error: unknown = getText('fileNotFoundPhrase')
toastAndLog('downloadFileError', error, asset.title) toastAndLog('downloadFileError', error, asset.title)
@ -575,9 +537,11 @@ export default function AssetRow(props: AssetRowProps) {
} }
} else { } else {
if (asset.type === backendModule.AssetType.project) { if (asset.type === backendModule.AssetType.project) {
const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id
const uuid = localBackend.extractTypeAndId(asset.id).id const uuid = localBackend.extractTypeAndId(asset.id).id
download.download( const queryString = new URLSearchParams({ projectsDirectory }).toString()
`./api/project-manager/projects/${uuid}/enso-project`, await backend.download(
`./api/project-manager/projects/${uuid}/enso-project?${queryString}`,
`${asset.title}.enso-project` `${asset.title}.enso-project`
) )
} }
@ -910,8 +874,6 @@ export default function AssetRow(props: AssetRowProps) {
rowState={rowState} rowState={rowState}
setRowState={setRowState} setRowState={setRowState}
isEditable={state.category !== Category.trash} isEditable={state.category !== Category.trash}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
/> />
</td> </td>
) )

View File

@ -51,7 +51,12 @@ export default function FileNameColumn(props: FileNameColumnProps) {
const isCloud = backend.type === backendModule.BackendType.remote const isCloud = backend.type === backendModule.BackendType.remote
const updateFileMutation = backendHooks.useBackendMutation(backend, 'updateFile') const updateFileMutation = backendHooks.useBackendMutation(backend, 'updateFile')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile') const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
})
const setIsEditing = (isEditingName: boolean) => { const setIsEditing = (isEditingName: boolean) => {
if (isEditable) { if (isEditable) {

View File

@ -7,11 +7,11 @@ import ArrowUpIcon from 'enso-assets/arrow_up.svg'
import PlayIcon from 'enso-assets/play.svg' import PlayIcon from 'enso-assets/play.svg'
import StopIcon from 'enso-assets/stop.svg' import StopIcon from 'enso-assets/stop.svg'
import * as projectHooks from '#/hooks/projectHooks'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
import Spinner from '#/components/Spinner' import Spinner from '#/components/Spinner'
import StatelessSpinner, * as spinner from '#/components/StatelessSpinner' import StatelessSpinner, * as spinner from '#/components/StatelessSpinner'
@ -61,15 +61,15 @@ export interface ProjectIconProps {
readonly backend: Backend readonly backend: Backend
readonly isOpened: boolean readonly isOpened: boolean
readonly item: backendModule.ProjectAsset readonly item: backendModule.ProjectAsset
readonly doOpenProject: (id: backendModule.ProjectId, runInBackground: boolean) => void
readonly doCloseProject: (id: backendModule.ProjectId) => void
readonly openProjectTab: (projectId: backendModule.ProjectId) => void
} }
/** An interactive icon indicating the status of a project. */ /** An interactive icon indicating the status of a project. */
export default function ProjectIcon(props: ProjectIconProps) { export default function ProjectIcon(props: ProjectIconProps) {
const { backend, item, isOpened } = props const { backend, item, isOpened } = props
const { openProjectTab, doOpenProject, doCloseProject } = props
const openProject = projectHooks.useOpenProject()
const closeProject = projectHooks.useCloseProject()
const openProjectTab = projectHooks.useOpenEditor()
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
@ -80,7 +80,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
isLoading, isLoading,
isError, isError,
} = reactQuery.useQuery({ } = reactQuery.useQuery({
...dashboard.createGetProjectDetailsQuery.createPassiveListener(item.id), ...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id),
select: data => data.state.type, select: data => data.state.type,
enabled: isOpened, enabled: isOpened,
}) })
@ -125,6 +125,16 @@ export default function ProjectIcon(props: ProjectIconProps) {
} }
})() })()
const doOpenProject = () => {
openProject({ ...item, type: backend.type })
}
const doCloseProject = () => {
closeProject({ ...item, type: backend.type })
}
const doOpenProjectTab = () => {
openProjectTab(item.id)
}
switch (state) { switch (state) {
case null: case null:
case backendModule.ProjectState.created: case backendModule.ProjectState.created:
@ -139,9 +149,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
aria-label={getText('openInEditor')} aria-label={getText('openInEditor')}
tooltipPlacement="left" tooltipPlacement="left"
extraClickZone="xsmall" extraClickZone="xsmall"
onPress={() => { onPress={doOpenProject}
doOpenProject(item.id, false)
}}
/> />
) )
case backendModule.ProjectState.openInProgress: case backendModule.ProjectState.openInProgress:
@ -160,9 +168,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
tooltipPlacement="left" tooltipPlacement="left"
className={tailwindMerge.twJoin(isRunningInBackground && 'text-green')} className={tailwindMerge.twJoin(isRunningInBackground && 'text-green')}
{...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})} {...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})}
onPress={() => { onPress={doCloseProject}
doCloseProject(item.id)
}}
/> />
<StatelessSpinner <StatelessSpinner
state={spinnerState} state={spinnerState}
@ -187,9 +193,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
tooltipPlacement="left" tooltipPlacement="left"
tooltip={isOtherUserUsingProject ? getText('otherUserIsUsingProjectError') : null} tooltip={isOtherUserUsingProject ? getText('otherUserIsUsingProjectError') : null}
className={tailwindMerge.twMerge(isRunningInBackground && 'text-green')} className={tailwindMerge.twMerge(isRunningInBackground && 'text-green')}
onPress={() => { onPress={doCloseProject}
doCloseProject(item.id)
}}
/> />
<Spinner <Spinner
state={spinner.SpinnerState.done} state={spinner.SpinnerState.done}
@ -208,9 +212,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
icon={ArrowUpIcon} icon={ArrowUpIcon}
aria-label={getText('openInEditor')} aria-label={getText('openInEditor')}
tooltipPlacement="right" tooltipPlacement="right"
onPress={() => { onPress={doOpenProjectTab}
openProjectTab(item.id)
}}
/> />
)} )}
</div> </div>

View File

@ -4,6 +4,7 @@ import * as React from 'react'
import NetworkIcon from 'enso-assets/network.svg' import NetworkIcon from 'enso-assets/network.svg'
import * as backendHooks from '#/hooks/backendHooks' import * as backendHooks from '#/hooks/backendHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks' import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -53,18 +54,16 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
setRowState, setRowState,
state, state,
isEditable, isEditable,
doCloseProject,
doOpenProject,
backendType, backendType,
isOpened, isOpened,
} = props } = props
const { backend, selectedKeys } = state const { backend, selectedKeys, nodeMap } = state
const { nodeMap, doOpenEditor } = state
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const doOpenProject = projectHooks.useOpenProject()
if (item.type !== backendModule.AssetType.project) { if (item.type !== backendModule.AssetType.project) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
@ -96,7 +95,12 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject') const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject')
const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject') const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject')
const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails') const getProjectDetailsMutation = backendHooks.useBackendMutation(backend, 'getProjectDetails')
const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile') const uploadFileMutation = backendHooks.useBackendMutation(backend, 'uploadFile', {
meta: {
invalidates: [['assetVersions', item.item.id, item.item.title]],
awaitInvalidates: true,
},
})
const setIsEditing = (isEditingName: boolean) => { const setIsEditing = (isEditingName: boolean) => {
if (isEditable) { if (isEditable) {
@ -115,7 +119,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
try { try {
await updateProjectMutation.mutateAsync([ await updateProjectMutation.mutateAsync([
asset.id, asset.id,
{ ami: null, ideVersion: null, projectName: newTitle, parentId: asset.parentId }, { ami: null, ideVersion: null, projectName: newTitle },
asset.title, asset.title,
]) ])
} catch (error) { } catch (error) {
@ -183,9 +187,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
id: createdProject.projectId, id: createdProject.projectId,
projectState: object.merge(projectState, { projectState: object.merge(projectState, {
type: backendModule.ProjectState.placeholder, type: backendModule.ProjectState.placeholder,
...(backend.type === backendModule.BackendType.remote
? {}
: { path: createdProject.state.path }),
}), }),
}) })
) )
@ -334,13 +335,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// This is a workaround for a temporary bad state in the backend causing the // This is a workaround for a temporary bad state in the backend causing the
// `projectState` key to be absent. // `projectState` key to be absent.
item={object.merge(asset, { projectState })} item={object.merge(asset, { projectState })}
doCloseProject={id => {
doCloseProject({ id, parentId: asset.parentId, title: asset.title, type: backendType })
}}
doOpenProject={id => {
doOpenProject({ id, type: backendType, parentId: asset.parentId, title: asset.title })
}}
openProjectTab={doOpenEditor}
/> />
)} )}
<EditableSpan <EditableSpan

View File

@ -1,8 +1,6 @@
/** @file Column types and column display modes. */ /** @file Column types and column display modes. */
import type * as React from 'react' import type * as React from 'react'
import type * as dashboard from '#/pages/dashboard/Dashboard'
import type * as assetsTable from '#/layouts/AssetsTable' import type * as assetsTable from '#/layouts/AssetsTable'
import * as columnUtils from '#/components/dashboard/column/columnUtils' import * as columnUtils from '#/components/dashboard/column/columnUtils'
@ -35,8 +33,6 @@ export interface AssetColumnProps {
readonly rowState: assetsTable.AssetRowState readonly rowState: assetsTable.AssetRowState
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>> readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
readonly isEditable: boolean readonly isEditable: boolean
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
} }
/** Props for a {@link AssetColumn}. */ /** Props for a {@link AssetColumn}. */

View File

@ -0,0 +1,370 @@
/** @file Mutations related to project management. */
import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as z from 'zod'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as projectsProvider from '#/providers/ProjectsProvider'
import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import type RemoteBackend from '#/services/RemoteBackend'
import LocalStorage from '#/utilities/LocalStorage'
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly launchedProjects: z.infer<typeof LAUNCHED_PROJECT_SCHEMA>
}
}
// =================
// === Constants ===
// =================
const PROJECT_SCHEMA = z
.object({
id: z.custom<backendModule.ProjectId>(x => typeof x === 'string'),
parentId: z.custom<backendModule.DirectoryId>(x => typeof x === 'string'),
title: z.string(),
type: z.nativeEnum(backendModule.BackendType),
})
.readonly()
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA).readonly()
/**
* Launched project information.
*/
export type Project = z.infer<typeof PROJECT_SCHEMA>
/**
* Launched project ID.
*/
export type ProjectId = backendModule.ProjectId
LocalStorage.registerKey('launchedProjects', {
isUserSpecific: true,
schema: LAUNCHED_PROJECT_SCHEMA,
})
// ====================================
// === createGetProjectDetailsQuery ===
// ====================================
/** Options for {@link createGetProjectDetailsQuery}. */
export interface CreateOpenedProjectQueryOptions {
readonly type: backendModule.BackendType
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
readonly remoteBackend: RemoteBackend
readonly localBackend: LocalBackend | null
}
/** Project status query. */
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
const isLocal = type === backendModule.BackendType.local
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
meta: { persist: false },
gcTime: 0,
refetchInterval: ({ state }) => {
/** Default interval for refetching project status when the project is opened. */
const openedIntervalMS = 30_000
/** Interval when we open a cloud project.
* Since opening a cloud project is a long operation, we want to check the status less often. */
const cloudOpeningIntervalMS = 5_000
/** Interval when we open a local project or when we want to sync the project status as soon as
* possible. */
const activeSyncIntervalMS = 100
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
if (state.status === 'error') {
// eslint-disable-next-line no-restricted-syntax
return false
}
if (isLocal) {
if (state.data?.state.type === backendModule.ProjectState.opened) {
return openedIntervalMS
} else {
return activeSyncIntervalMS
}
} else {
if (state.data == null) {
return activeSyncIntervalMS
} else if (states.includes(state.data.state.type)) {
return openedIntervalMS
} else {
return cloudOpeningIntervalMS
}
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
queryFn: async () => {
invariant(backend != null, 'Backend is null')
return await backend.getProjectDetails(assetId, parentId, title)
},
})
}
createGetProjectDetailsQuery.getQueryKey = (id: ProjectId) => ['project', id] as const
createGetProjectDetailsQuery.createPassiveListener = (id: ProjectId) =>
reactQuery.queryOptions<backendModule.Project>({
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
})
// ==============================
// === useOpenProjectMutation ===
// ==============================
/** A mutation to open a project. */
export function useOpenProjectMutation() {
const client = reactQuery.useQueryClient()
const session = authProvider.useFullUserSession()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
return reactQuery.useMutation({
mutationKey: ['openProject'],
networkMode: 'always',
mutationFn: ({ title, id, type, parentId }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.openProject(
id,
{
executeAsync: false,
cognitoCredentials: {
accessToken: session.accessToken,
refreshToken: session.accessToken,
clientId: session.clientId,
expireAt: session.expireAt,
refreshUrl: session.refreshUrl,
},
parentId,
},
title
)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onError: async (_, { id }) => {
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
},
})
}
// ===============================
// === useCloseProjectMutation ===
// ===============================
/** Mutation to close a project. */
export function useCloseProjectMutation() {
const client = reactQuery.useQueryClient()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
return reactQuery.useMutation({
mutationKey: ['closeProject'],
mutationFn: async ({ type, id, title }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.closeProject(id, title)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onSuccess: (_, { id }) =>
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
onError: (_, { id }) =>
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
})
}
// ================================
// === useRenameProjectMutation ===
// ================================
/** Mutation to rename a project. */
export function useRenameProjectMutation() {
const client = reactQuery.useQueryClient()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend()
return reactQuery.useMutation({
mutationKey: ['renameProject'],
mutationFn: ({ newName, project }: { newName: string; project: Project }) => {
const { type, id, title } = project
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.updateProject(id, { projectName: newName, ami: null, ideVersion: null }, title)
},
onSuccess: (_, { project }) =>
client.invalidateQueries({
queryKey: createGetProjectDetailsQuery.getQueryKey(project.id),
}),
})
}
// ======================
// === useOpenProject ===
// ======================
/** Options for {@link useOpenProject}. */
export interface OpenProjectOptions {
/** Whether to open the project in the background.
* Set to `false` to navigate to the project tab.
* @default true */
readonly openInBackground?: boolean
}
/** A callback to open a project. */
export function useOpenProject() {
const client = reactQuery.useQueryClient()
const projectsStore = projectsProvider.useProjectsStore()
const addLaunchedProject = projectsProvider.useAddLaunchedProject()
const closeAllProjects = useCloseAllProjects()
const openProjectMutation = useOpenProjectMutation()
const openEditor = useOpenEditor()
return eventCallbacks.useEventCallback((project: Project, options: OpenProjectOptions = {}) => {
const { openInBackground = true } = options
// Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first.
if (projectsStore.getState().launchedProjects.length > 0) {
closeAllProjects()
}
const isOpeningTheSameProject =
client.getMutationCache().find({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})?.state.status === 'pending'
if (!isOpeningTheSameProject) {
openProjectMutation.mutate(project)
const openingProjectMutation = client.getMutationCache().find({
mutationKey: ['openProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
openingProjectMutation?.setOptions({
...openingProjectMutation.options,
scope: { id: project.id },
})
addLaunchedProject(project)
if (!openInBackground) {
openEditor(project.id)
}
}
})
}
// =====================
// === useOpenEditor ===
// =====================
/** A function to open the editor. */
export function useOpenEditor() {
const setPage = projectsProvider.useSetPage()
return eventCallbacks.useEventCallback((projectId: ProjectId) => {
React.startTransition(() => {
setPage(projectId)
})
})
}
// =======================
// === useCloseProject ===
// =======================
/** A function to close a project. */
export function useCloseProject() {
const client = reactQuery.useQueryClient()
const closeProjectMutation = useCloseProjectMutation()
const removeLaunchedProject = projectsProvider.useRemoveLaunchedProject()
const setPage = projectsProvider.useSetPage()
return eventCallbacks.useEventCallback((project: Project) => {
client
.getMutationCache()
.findAll({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, retry: false })
mutation.destroy()
})
closeProjectMutation.mutate(project)
client
.getMutationCache()
.findAll({
mutationKey: ['closeProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
})
removeLaunchedProject(project.id)
setPage(projectsProvider.TabType.drive)
})
}
// ===========================
// === useCloseAllProjects ===
// ===========================
/** A function to close all projects. */
export function useCloseAllProjects() {
const projectsStore = projectsProvider.useProjectsStore()
const closeProject = useCloseProject()
return eventCallbacks.useEventCallback(() => {
for (const launchedProject of projectsStore.getState().launchedProjects) {
closeProject(launchedProject)
}
})
}

View File

@ -14,6 +14,10 @@ import * as lazyMemo from '#/hooks/useLazyMemoHooks'
import * as safeJsonParse from '#/utilities/safeJsonParse' import * as safeJsonParse from '#/utilities/safeJsonParse'
// ===================================
// === SearchParamsStateReturnType ===
// ===================================
/** /**
* The return type of the `useSearchParamsState` hook. * The return type of the `useSearchParamsState` hook.
*/ */
@ -21,6 +25,10 @@ type SearchParamsStateReturnType<T> = Readonly<
[value: T, setValue: (nextValue: React.SetStateAction<T>) => void, clear: () => void] [value: T, setValue: (nextValue: React.SetStateAction<T>) => void, clear: () => void]
> >
// ============================
// === useSearchParamsState ===
// ============================
/** /**
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function. * Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* @param key - The key to store the value in the URL search params. * @param key - The key to store the value in the URL search params.
@ -89,3 +97,72 @@ export function useSearchParamsState<T = unknown>(
return [value, setValue, clear] return [value, setValue, clear]
} }
/**
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
* @param key - The key to store the value in the URL search params.
* @param defaultValue - The default value to use if the key is not present in the URL search params.
* @param predicate - A function to check if the value is of the right type.
*/
export function useSearchParamsStateNonReactive<T = unknown>(
key: string,
defaultValue: T | (() => T),
predicate: (unknown: unknown) => unknown is T = (unknown): unknown is T => true
): SearchParamsStateReturnType<T> {
const [searchParams, setSearchParams] = reactRouterDom.useSearchParams()
const prefixedKey = `${appUtils.SEARCH_PARAMS_PREFIX}${key}`
const lazyDefaultValueInitializer = lazyMemo.useLazyMemoHooks(defaultValue, [])
const predicateEventCallback = eventCallback.useEventCallback(predicate)
const clear = eventCallback.useEventCallback((replace: boolean = false) => {
searchParams.delete(prefixedKey)
setSearchParams(searchParams, { replace })
})
const unprefixedValue = searchParams.get(key)
if (unprefixedValue != null) {
searchParams.set(prefixedKey, unprefixedValue)
searchParams.delete(key)
setSearchParams(searchParams)
}
const rawValue = React.useMemo<T>(() => {
const maybeValue = searchParams.get(prefixedKey)
const defaultValueFrom = lazyDefaultValueInitializer()
return maybeValue != null
? safeJsonParse.safeJsonParse(maybeValue, defaultValueFrom, (unknown): unknown is T => true)
: defaultValueFrom
}, [prefixedKey, lazyDefaultValueInitializer, searchParams])
const isValueValid = predicateEventCallback(rawValue)
const value = isValueValid ? rawValue : lazyDefaultValueInitializer()
if (!isValueValid) {
clear(true)
}
/**
* Set the value in the URL search params. If the next value is the same as the default value, it will remove the key from the URL search params.
* Function reference is always the same.
* @param nextValue - The next value to set.
* @returns void
*/
const setValue = eventCallback.useEventCallback((nextValue: React.SetStateAction<T>) => {
if (nextValue instanceof Function) {
nextValue = nextValue(value)
}
if (nextValue === lazyDefaultValueInitializer()) {
clear()
} else {
searchParams.set(prefixedKey, JSON.stringify(nextValue))
setSearchParams(searchParams)
}
})
return [value, setValue, clear]
}

View File

@ -6,6 +6,7 @@ import * as toast from 'react-toastify'
import * as billingHooks from '#/hooks/billing' import * as billingHooks from '#/hooks/billing'
import * as copyHooks from '#/hooks/copyHooks' import * as copyHooks from '#/hooks/copyHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as setAssetHooks from '#/hooks/setAssetHooks' import * as setAssetHooks from '#/hooks/setAssetHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
@ -17,8 +18,6 @@ import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType' import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType' import AssetListEventType from '#/events/AssetListEventType'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category' import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category'
import GlobalContextMenu from '#/layouts/GlobalContextMenu' import GlobalContextMenu from '#/layouts/GlobalContextMenu'
@ -36,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import UpsertSecretModal from '#/modals/UpsertSecretModal' import UpsertSecretModal from '#/modals/UpsertSecretModal'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend' import * as localBackendModule from '#/services/LocalBackend'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions' import * as permissions from '#/utilities/permissions'
@ -72,6 +71,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal() const { setModal, unsetModal } = modalProvider.useSetModal()
const remoteBackend = backendProvider.useRemoteBackend() const remoteBackend = backendProvider.useRemoteBackend()
const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
@ -87,8 +87,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
: isCloud : isCloud
? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}` ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}`
: asset.type === backendModule.AssetType.project : asset.type === backendModule.AssetType.project
? asset.projectState.path ?? null ? localBackend?.getProjectDirectoryPath(asset.id) ?? null
: localBackend.extractTypeAndId(asset.id).id : localBackendModule.extractTypeAndId(asset.id).id
const copyMutation = copyHooks.useCopy({ copyText: path ?? '' }) const copyMutation = copyHooks.useCopy({ copyText: path ?? '' })
const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan })
@ -103,7 +103,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
const { data } = reactQuery.useQuery( const { data } = reactQuery.useQuery(
item.item.type === backendModule.AssetType.project item.item.type === backendModule.AssetType.project
? dashboard.createGetProjectDetailsQuery.createPassiveListener(item.item.id) ? projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.item.id)
: { queryKey: ['__IGNORED__'] } : { queryKey: ['__IGNORED__'] }
) )
@ -254,7 +254,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
} else { } else {
try { try {
const projectResponse = await fetch( const projectResponse = await fetch(
`./api/project-manager/projects/${localBackend.extractTypeAndId(asset.id).id}/enso-project` `./api/project-manager/projects/${localBackendModule.extractTypeAndId(asset.id).id}/enso-project`
) )
// This DOES NOT update the cloud assets list when it // This DOES NOT update the cloud assets list when it
// completes, as the current backend is not the remote // completes, as the current backend is not the remote
@ -406,20 +406,21 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
/> />
)} )}
{isCloud && managesThisAsset && self != null && <Separator hidden={hidden} />} {isCloud && managesThisAsset && self != null && <Separator hidden={hidden} />}
<ContextMenuEntry {asset.type === backendModule.AssetType.project && (
hidden={hidden} <ContextMenuEntry
isDisabled={!isCloud} hidden={hidden}
action="duplicate" action="duplicate"
doAction={() => { doAction={() => {
unsetModal() unsetModal()
dispatchAssetListEvent({ dispatchAssetListEvent({
type: AssetListEventType.copy, type: AssetListEventType.copy,
newParentId: item.directoryId, newParentId: item.directoryId,
newParentKey: item.directoryKey, newParentKey: item.directoryKey,
items: [asset], items: [asset],
}) })
}} }}
/> />
)}
{isCloud && <ContextMenuEntry hidden={hidden} action="copy" doAction={doCopy} />} {isCloud && <ContextMenuEntry hidden={hidden} action="copy" doAction={doCopy} />}
{path != null && ( {path != null && (
<ContextMenuEntry <ContextMenuEntry

View File

@ -9,6 +9,7 @@ import * as backendHooks from '#/hooks/backendHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import type Category from '#/layouts/CategorySwitcher/Category' import type Category from '#/layouts/CategorySwitcher/Category'
@ -22,7 +23,7 @@ import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinn
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import type Backend from '#/services/Backend' import type Backend from '#/services/Backend'
import * as localBackend from '#/services/LocalBackend' import * as localBackendModule from '#/services/LocalBackend'
import type * as assetTreeNode from '#/utilities/AssetTreeNode' import type * as assetTreeNode from '#/utilities/AssetTreeNode'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
@ -49,6 +50,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const localBackend = backendProvider.useLocalBackend()
const [item, setItemInner] = React.useState(itemRaw) const [item, setItemInner] = React.useState(itemRaw)
const [isEditingDescription, setIsEditingDescription] = React.useState(false) const [isEditingDescription, setIsEditingDescription] = React.useState(false)
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null) const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
@ -84,8 +86,8 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const path = isCloud const path = isCloud
? null ? null
: item.item.type === backendModule.AssetType.project : item.item.type === backendModule.AssetType.project
? item.item.projectState.path ?? null ? localBackend?.getProjectDirectoryPath(item.item.id) ?? null
: localBackend.extractTypeAndId(item.item.id).id : localBackendModule.extractTypeAndId(item.item.id).id
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink') const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink') const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
@ -113,14 +115,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
const oldDescription = item.item.description const oldDescription = item.item.description
setItem(oldItem => oldItem.with({ item: object.merge(oldItem.item, { description }) })) setItem(oldItem => oldItem.with({ item: object.merge(oldItem.item, { description }) }))
try { try {
const projectPath = item.item.projectState?.path
await updateAssetMutation.mutateAsync([ await updateAssetMutation.mutateAsync([
item.item.id, item.item.id,
{ { parentDirectoryId: null, description },
parentDirectoryId: null,
description,
...(projectPath == null ? {} : { projectPath }),
},
item.item.title, item.item.title,
]) ])
} catch (error) { } catch (error) {

View File

@ -9,6 +9,7 @@ import * as mimeTypes from '#/data/mimeTypes'
import * as backendHooks from '#/hooks/backendHooks' import * as backendHooks from '#/hooks/backendHooks'
import * as intersectionHooks from '#/hooks/intersectionHooks' import * as intersectionHooks from '#/hooks/intersectionHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
import useOnScroll from '#/hooks/useOnScroll' import useOnScroll from '#/hooks/useOnScroll'
@ -18,6 +19,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider' import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import * as navigator2DProvider from '#/providers/Navigator2DProvider' import * as navigator2DProvider from '#/providers/Navigator2DProvider'
import * as projectsProvider from '#/providers/ProjectsProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import type * as assetEvent from '#/events/assetEvent' import type * as assetEvent from '#/events/assetEvent'
@ -25,8 +27,6 @@ import AssetEventType from '#/events/AssetEventType'
import type * as assetListEvent from '#/events/assetListEvent' import type * as assetListEvent from '#/events/assetListEvent'
import AssetListEventType from '#/events/AssetListEventType' import AssetListEventType from '#/events/AssetListEventType'
import type * as dashboard from '#/pages/dashboard/Dashboard'
import type * as assetPanel from '#/layouts/AssetPanel' import type * as assetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar' import type * as assetSearchBar from '#/layouts/AssetSearchBar'
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
@ -339,7 +339,6 @@ export interface AssetsTableState {
title?: string | null, title?: string | null,
override?: boolean override?: boolean
) => void ) => void
readonly doOpenEditor: (id: backendModule.ProjectId) => void
readonly doCopy: () => void readonly doCopy: () => void
readonly doCut: () => void readonly doCut: () => void
readonly doPaste: ( readonly doPaste: (
@ -358,7 +357,6 @@ export interface AssetRowState {
/** Props for a {@link AssetsTable}. */ /** Props for a {@link AssetsTable}. */
export interface AssetsTableProps { export interface AssetsTableProps {
readonly openedProjects: dashboard.Project[]
readonly hidden: boolean readonly hidden: boolean
readonly query: AssetQuery readonly query: AssetQuery
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>> readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
@ -371,12 +369,6 @@ export interface AssetsTableProps {
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null> readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (
project: dashboard.Project,
options?: dashboard.OpenProjectOptions
) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly assetManagementApiRef: React.Ref<AssetManagementApi> readonly assetManagementApiRef: React.Ref<AssetManagementApi>
} }
@ -390,19 +382,13 @@ export interface AssetManagementApi {
/** The table of project assets. */ /** The table of project assets. */
export default function AssetsTable(props: AssetsTableProps) { export default function AssetsTable(props: AssetsTableProps) {
const { const { hidden, query, setQuery, setCanDownload, category, assetManagementApiRef } = props
hidden,
query,
setQuery,
setCanDownload,
category,
openedProjects,
assetManagementApiRef,
} = props
const { setSuggestions, initialProjectName } = props const { setSuggestions, initialProjectName } = props
const { doOpenEditor, doOpenProject, doCloseProject } = props
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
const openedProjects = projectsProvider.useLaunchedProjects()
const doOpenProject = projectHooks.useOpenProject()
const { user } = authProvider.useNonPartialUserSession() const { user } = authProvider.useNonPartialUserSession()
const backend = backendProvider.useBackend(category) const backend = backendProvider.useBackend(category)
const labels = backendHooks.useBackendListTags(backend) const labels = backendHooks.useBackendListTags(backend)
@ -412,12 +398,12 @@ export default function AssetsTable(props: AssetsTableProps) {
const inputBindings = inputBindingsProvider.useInputBindings() const inputBindings = inputBindingsProvider.useInputBindings()
const navigator2D = navigator2DProvider.useNavigator2D() const navigator2D = navigator2DProvider.useNavigator2D()
const toastAndLog = toastAndLogHooks.useToastAndLog() const toastAndLog = toastAndLogHooks.useToastAndLog()
const previousCategoryRef = React.useRef(category)
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
const [initialized, setInitialized] = React.useState(false) const [initialized, setInitialized] = React.useState(false)
const initializedRef = React.useRef(initialized) const initializedRef = React.useRef(initialized)
initializedRef.current = initialized initializedRef.current = initialized
const [isLoading, setIsLoading] = React.useState(true)
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS) const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
const [sortInfo, setSortInfo] = const [sortInfo, setSortInfo] =
React.useState<sorting.SortInfo<columnUtils.SortableColumn> | null>(null) React.useState<sorting.SortInfo<columnUtils.SortableColumn> | null>(null)
@ -432,7 +418,7 @@ export default function AssetsTable(props: AssetsTableProps) {
ReadonlySet<backendModule.AssetId> ReadonlySet<backendModule.AssetId>
> | null>(null) > | null>(null)
const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([]) const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName) const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName)
const rootDirectoryId = React.useMemo( const rootDirectoryId = React.useMemo(
() => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''), () => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''),
[backend, user] [backend, user]
@ -649,6 +635,9 @@ export default function AssetsTable(props: AssetsTableProps) {
) )
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret') const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
React.useEffect(() => {
previousCategoryRef.current = category
})
React.useEffect(() => { React.useEffect(() => {
if (selectedKeys.size === 0) { if (selectedKeys.size === 0) {
@ -868,10 +857,6 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
}, [isCloud, assetTree, query, visibilities, labels, setSuggestions]) }, [isCloud, assetTree, query, visibilities, labels, setSuggestions])
React.useEffect(() => {
setIsLoading(true)
}, [backend, category])
React.useEffect(() => { React.useEffect(() => {
assetTreeRef.current = assetTree assetTreeRef.current = assetTree
const newNodeMap = new Map(assetTree.preorderTraversal().map(asset => [asset.key, asset])) const newNodeMap = new Map(assetTree.preorderTraversal().map(asset => [asset.key, asset]))
@ -899,34 +884,6 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
}, [hidden, inputBindings, dispatchAssetEvent]) }, [hidden, inputBindings, dispatchAssetEvent])
React.useEffect(() => {
if (isLoading) {
setNameOfProjectToImmediatelyOpen(initialProjectName)
} else {
// The project name here might also be a string with project id, e.g. when opening
// a project file from explorer on Windows.
const isInitialProject = (asset: backendModule.AnyAsset) =>
asset.title === initialProjectName || asset.id === initialProjectName
const projectToLoad = assetTree
.preorderTraversal()
.map(node => node.item)
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
doOpenProject({
type: backendModule.BackendType.local,
id: projectToLoad.id,
title: projectToLoad.title,
parentId: projectToLoad.parentId,
})
} else if (initialProjectName != null) {
toastAndLog('findProjectError', null, initialProjectName)
}
}
// This effect MUST only run when `initialProjectName` is changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProjectName])
const setSelectedKeys = React.useCallback( const setSelectedKeys = React.useCallback(
(newSelectedKeys: ReadonlySet<backendModule.AssetId>) => { (newSelectedKeys: ReadonlySet<backendModule.AssetId>) => {
selectedKeysRef.current = newSelectedKeys selectedKeysRef.current = newSelectedKeys
@ -962,70 +919,63 @@ export default function AssetsTable(props: AssetsTableProps) {
const overwriteNodes = React.useCallback( const overwriteNodes = React.useCallback(
(newAssets: backendModule.AnyAsset[]) => { (newAssets: backendModule.AnyAsset[]) => {
setInitialized(true)
mostRecentlySelectedIndexRef.current = null mostRecentlySelectedIndexRef.current = null
selectionStartIndexRef.current = null selectionStartIndexRef.current = null
// This is required, otherwise we are using an outdated // This is required, otherwise we are using an outdated
// `nameOfProjectToImmediatelyOpen`. // `nameOfProjectToImmediatelyOpen`.
setNameOfProjectToImmediatelyOpen(oldNameOfProjectToImmediatelyOpen => { const nameOfProjectToImmediatelyOpen = nameOfProjectToImmediatelyOpenRef.current
setInitialized(true) const rootParentDirectoryId = backendModule.DirectoryId('')
const rootParentDirectoryId = backendModule.DirectoryId('') const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId) const rootId = rootDirectory.id
const newRootNode = new AssetTreeNode( const children = newAssets.map(asset =>
rootDirectory, AssetTreeNode.fromAsset(
rootParentDirectoryId, asset,
rootParentDirectoryId, rootId,
newAssets.map(asset => rootId,
AssetTreeNode.fromAsset( 0,
asset, `${backend.rootPath}/${asset.title}`,
rootDirectory.id, null
rootDirectory.id,
0,
`${backend.rootPath}/${asset.title}`,
null
)
),
-1,
backend.rootPath,
null,
rootDirectory.id,
true
) )
setAssetTree(newRootNode) )
// The project name here might also be a string with project id, e.g. const newRootNode = new AssetTreeNode(
// when opening a project file from explorer on Windows. rootDirectory,
const isInitialProject = (asset: backendModule.AnyAsset) => rootParentDirectoryId,
asset.title === oldNameOfProjectToImmediatelyOpen || rootParentDirectoryId,
asset.id === oldNameOfProjectToImmediatelyOpen children,
if (oldNameOfProjectToImmediatelyOpen != null) { -1,
const projectToLoad = newAssets backend.rootPath,
.filter(backendModule.assetIsProject) null,
.find(isInitialProject) rootId,
if (projectToLoad != null) { true
doOpenProject( )
{ setAssetTree(newRootNode)
type: backendModule.BackendType.local, // The project name here might also be a string with project id, e.g.
id: projectToLoad.id, // when opening a project file from explorer on Windows.
title: projectToLoad.title, const isInitialProject = (asset: backendModule.AnyAsset) =>
parentId: projectToLoad.parentId, asset.title === nameOfProjectToImmediatelyOpen ||
}, asset.id === nameOfProjectToImmediatelyOpen
{ openInBackground: false } if (nameOfProjectToImmediatelyOpen != null) {
) const projectToLoad = newAssets.filter(backendModule.assetIsProject).find(isInitialProject)
} else { if (projectToLoad != null) {
toastAndLog('findProjectError', null, oldNameOfProjectToImmediatelyOpen) const backendType = backendModule.BackendType.local
} const { id, title, parentId } = projectToLoad
doOpenProject({ type: backendType, id, title, parentId }, { openInBackground: false })
} else {
toastAndLog('findProjectError', null, nameOfProjectToImmediatelyOpen)
} }
setQueuedAssetEvents(oldQueuedAssetEvents => { }
if (oldQueuedAssetEvents.length !== 0) { setQueuedAssetEvents(oldQueuedAssetEvents => {
queueMicrotask(() => { if (oldQueuedAssetEvents.length !== 0) {
for (const event of oldQueuedAssetEvents) { queueMicrotask(() => {
dispatchAssetEvent(event) for (const event of oldQueuedAssetEvents) {
} dispatchAssetEvent(event)
}) }
} })
return [] }
}) return []
return null
}) })
nameOfProjectToImmediatelyOpenRef.current = null
}, },
[doOpenProject, rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog] [doOpenProject, rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog]
) )
@ -1054,10 +1004,39 @@ export default function AssetsTable(props: AssetsTableProps) {
], ],
{ queryKey: [], staleTime: 0, meta: { persist: false } } { queryKey: [], staleTime: 0, meta: { persist: false } }
) )
const isLoading =
rootDirectoryQuery.isLoading || rootDirectoryQuery.isPending || rootDirectoryQuery.isFetching
React.useEffect(() => {
if (isLoading) {
nameOfProjectToImmediatelyOpenRef.current = initialProjectName
} else {
// The project name here might also be a string with project id, e.g. when opening
// a project file from explorer on Windows.
const isInitialProject = (asset: backendModule.AnyAsset) =>
asset.title === initialProjectName || asset.id === initialProjectName
const projectToLoad = assetTree
.preorderTraversal()
.map(node => node.item)
.filter(backendModule.assetIsProject)
.find(isInitialProject)
if (projectToLoad != null) {
doOpenProject({
type: backendModule.BackendType.local,
id: projectToLoad.id,
title: projectToLoad.title,
parentId: projectToLoad.parentId,
})
} else if (initialProjectName != null) {
toastAndLog('findProjectError', null, initialProjectName)
}
}
// This effect MUST only run when `initialProjectName` is changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialProjectName])
React.useEffect(() => { React.useEffect(() => {
if (rootDirectoryQuery.data) { if (rootDirectoryQuery.data) {
setIsLoading(false)
overwriteNodes(rootDirectoryQuery.data) overwriteNodes(rootDirectoryQuery.data)
} }
}, [rootDirectoryQuery.data, overwriteNodes]) }, [rootDirectoryQuery.data, overwriteNodes])
@ -1763,9 +1742,6 @@ export default function AssetsTable(props: AssetsTableProps) {
type: backendModule.ProjectState.placeholder, type: backendModule.ProjectState.placeholder,
volumeId: '', volumeId: '',
openedBy: user.email, openedBy: user.email,
...(event.original.projectState.path != null
? { path: event.original.projectState.path }
: {}),
}, },
labels: [], labels: [],
description: null, description: null,
@ -1954,6 +1930,19 @@ export default function AssetsTable(props: AssetsTableProps) {
} }
} }
const handleFileDrop = (event: React.DragEvent) => {
if (event.dataTransfer.types.includes('Files')) {
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
}
}
const state = React.useMemo<AssetsTableState>( const state = React.useMemo<AssetsTableState>(
// The type MUST be here to trigger excess property errors at typecheck time. // The type MUST be here to trigger excess property errors at typecheck time.
() => ({ () => ({
@ -1974,7 +1963,6 @@ export default function AssetsTable(props: AssetsTableProps) {
nodeMap: nodeMapRef, nodeMap: nodeMapRef,
hideColumn, hideColumn,
doToggleDirectoryExpansion, doToggleDirectoryExpansion,
doOpenEditor,
doCopy, doCopy,
doCut, doCut,
doPaste, doPaste,
@ -1988,7 +1976,6 @@ export default function AssetsTable(props: AssetsTableProps) {
sortInfo, sortInfo,
query, query,
doToggleDirectoryExpansion, doToggleDirectoryExpansion,
doOpenEditor,
doCopy, doCopy,
doCut, doCut,
doPaste, doPaste,
@ -2270,7 +2257,7 @@ export default function AssetsTable(props: AssetsTableProps) {
displayItems.map((item, i) => { displayItems.map((item, i) => {
const key = AssetTreeNode.getKey(item) const key = AssetTreeNode.getKey(item)
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key) const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
const isSoleSelected = selectedKeys.size === 1 && isSelected const isSoleSelected = isSelected && selectedKeys.size === 1
return ( return (
<AssetRow <AssetRow
@ -2290,8 +2277,6 @@ export default function AssetsTable(props: AssetsTableProps) {
item={item} item={item}
state={state} state={state}
hidden={hidden || visibilities.get(item.key) === Visibility.hidden} hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
selected={isSelected} selected={isSelected}
setSelected={selected => { setSelected={selected => {
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected)) setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
@ -2361,8 +2346,6 @@ export default function AssetsTable(props: AssetsTableProps) {
setItem={() => {}} setItem={() => {}}
setRowState={() => {}} setRowState={() => {}}
isEditable={false} isEditable={false}
doCloseProject={doCloseProject}
doOpenProject={doOpenProject}
/> />
))} ))}
</DragModal> </DragModal>
@ -2548,6 +2531,7 @@ export default function AssetsTable(props: AssetsTableProps) {
ids: new Set(filtered.map(dragItem => dragItem.asset.id)), ids: new Set(filtered.map(dragItem => dragItem.asset.id)),
}) })
} }
handleFileDrop(event)
}} }}
onClick={() => { onClick={() => {
setSelectedKeys(new Set()) setSelectedKeys(new Set())
@ -2685,16 +2669,7 @@ export default function AssetsTable(props: AssetsTableProps) {
onDragOver={onDropzoneDragOver} onDragOver={onDropzoneDragOver}
onDrop={event => { onDrop={event => {
setIsDraggingFiles(false) setIsDraggingFiles(false)
if (event.dataTransfer.types.includes('Files')) { handleFileDrop(event)
event.preventDefault()
event.stopPropagation()
dispatchAssetListEvent({
type: AssetListEventType.uploadFiles,
parentKey: rootDirectoryId,
parentId: rootDirectoryId,
files: Array.from(event.dataTransfer.files),
})
}
}} }}
> >
<SvgMask src={DropFilesImage} className="size-8" /> <SvgMask src={DropFilesImage} className="size-8" />

View File

@ -13,8 +13,6 @@ import * as textProvider from '#/providers/TextProvider'
import AssetListEventType from '#/events/AssetListEventType' import AssetListEventType from '#/events/AssetListEventType'
import type * as dashboard from '#/pages/dashboard/Dashboard'
import type * as assetPanel from '#/layouts/AssetPanel' import type * as assetPanel from '#/layouts/AssetPanel'
import AssetPanel from '#/layouts/AssetPanel' import AssetPanel from '#/layouts/AssetPanel'
import type * as assetSearchBar from '#/layouts/AssetSearchBar' import type * as assetSearchBar from '#/layouts/AssetSearchBar'
@ -62,30 +60,16 @@ enum DriveStatus {
/** Props for a {@link Drive}. */ /** Props for a {@link Drive}. */
export interface DriveProps { export interface DriveProps {
readonly openedProjects: dashboard.Project[]
readonly category: Category readonly category: Category
readonly setCategory: (category: Category) => void readonly setCategory: (category: Category) => void
readonly hidden: boolean readonly hidden: boolean
readonly initialProjectName: string | null readonly initialProjectName: string | null
readonly doOpenEditor: (id: dashboard.ProjectId) => void
readonly doOpenProject: (project: dashboard.Project) => void
readonly doCloseProject: (project: dashboard.Project) => void
readonly assetsManagementApiRef: React.Ref<assetsTable.AssetManagementApi> readonly assetsManagementApiRef: React.Ref<assetsTable.AssetManagementApi>
} }
/** Contains directory path and directory contents (projects, folders, secrets and files). */ /** Contains directory path and directory contents (projects, folders, secrets and files). */
export default function Drive(props: DriveProps) { export default function Drive(props: DriveProps) {
const { const { category, setCategory, hidden, initialProjectName, assetsManagementApiRef } = props
openedProjects,
doOpenEditor,
doCloseProject,
category,
setCategory,
hidden,
initialProjectName,
doOpenProject,
assetsManagementApiRef,
} = props
const { isOffline } = offlineHooks.useOffline() const { isOffline } = offlineHooks.useOffline()
const { localStorage } = localStorageProvider.useLocalStorage() const { localStorage } = localStorageProvider.useLocalStorage()
@ -99,8 +83,10 @@ export default function Drive(props: DriveProps) {
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([]) const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
const [canDownload, setCanDownload] = React.useState(false) const [canDownload, setCanDownload] = React.useState(false)
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false) const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
const [assetPanelProps, setAssetPanelProps] = const [assetPanelPropsRaw, setAssetPanelProps] =
React.useState<assetPanel.AssetPanelRequiredProps | null>(null) React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
const assetPanelProps =
backend.type === assetPanelPropsRaw?.backend?.type ? assetPanelPropsRaw : null
const [isAssetPanelEnabled, setIsAssetPanelEnabled] = React.useState( const [isAssetPanelEnabled, setIsAssetPanelEnabled] = React.useState(
() => localStorage.get('isAssetPanelVisible') ?? false () => localStorage.get('isAssetPanelVisible') ?? false
) )
@ -326,7 +312,6 @@ export default function Drive(props: DriveProps) {
) : ( ) : (
<AssetsTable <AssetsTable
assetManagementApiRef={assetsManagementApiRef} assetManagementApiRef={assetsManagementApiRef}
openedProjects={openedProjects}
hidden={hidden} hidden={hidden}
query={query} query={query}
setQuery={setQuery} setQuery={setQuery}
@ -337,9 +322,6 @@ export default function Drive(props: DriveProps) {
setAssetPanelProps={setAssetPanelProps} setAssetPanelProps={setAssetPanelProps}
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible} setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
targetDirectoryNodeRef={targetDirectoryNodeRef} targetDirectoryNodeRef={targetDirectoryNodeRef}
doOpenEditor={doOpenEditor}
doOpenProject={doOpenProject}
doCloseProject={doCloseProject}
/> />
)} )}
</div> </div>

View File

@ -8,12 +8,11 @@ import type * as types from 'enso-common/src/types'
import * as appUtils from '#/appUtils' import * as appUtils from '#/appUtils'
import * as gtagHooks from '#/hooks/gtagHooks' import * as gtagHooks from '#/hooks/gtagHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as backendProvider from '#/providers/BackendProvider' import * as backendProvider from '#/providers/BackendProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import * as dashboard from '#/pages/dashboard/Dashboard'
import * as errorBoundary from '#/components/ErrorBoundary' import * as errorBoundary from '#/components/ErrorBoundary'
import * as suspense from '#/components/Suspense' import * as suspense from '#/components/Suspense'
@ -36,8 +35,8 @@ export interface EditorProps {
readonly isOpening: boolean readonly isOpening: boolean
readonly isOpeningFailed: boolean readonly isOpeningFailed: boolean
readonly openingError: Error | null readonly openingError: Error | null
readonly startProject: (project: dashboard.Project) => void readonly startProject: (project: projectHooks.Project) => void
readonly project: dashboard.Project readonly project: projectHooks.Project
readonly hidden: boolean readonly hidden: boolean
readonly ydocUrl: string | null readonly ydocUrl: string | null
readonly appRunner: types.EditorRunner | null readonly appRunner: types.EditorRunner | null
@ -52,7 +51,7 @@ export default function Editor(props: EditorProps) {
const remoteBackend = backendProvider.useRemoteBackendStrict() const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend() const localBackend = backendProvider.useLocalBackend()
const projectStatusQuery = dashboard.createGetProjectDetailsQuery({ const projectStatusQuery = projectHooks.createGetProjectDetailsQuery({
type: project.type, type: project.type,
assetId: project.id, assetId: project.id,
parentId: project.parentId, parentId: project.parentId,
@ -86,11 +85,7 @@ export default function Editor(props: EditorProps) {
} }
return ( return (
<div <div className={twMerge.twJoin('contents', hidden && 'hidden')} data-testvalue={project.id}>
className={twMerge.twJoin('contents', hidden && 'hidden')}
data-testid="gui-editor-root"
data-testvalue={project.id}
>
{(() => { {(() => {
if (projectQuery.isError) { if (projectQuery.isError) {
return ( return (

View File

@ -6,9 +6,9 @@ import invariant from 'tiny-invariant'
import type * as text from 'enso-common/src/text' import type * as text from 'enso-common/src/text'
import * as textProvider from '#/providers/TextProvider' import * as projectHooks from '#/hooks/projectHooks'
import * as dashboard from '#/pages/dashboard/Dashboard' import * as textProvider from '#/providers/TextProvider'
import * as aria from '#/components/aria' import * as aria from '#/components/aria'
import * as ariaComponents from '#/components/AriaComponents' import * as ariaComponents from '#/components/AriaComponents'
@ -167,7 +167,8 @@ const Tabs = React.forwardRef(TabsInternal)
/** Props for a {@link Tab}. */ /** Props for a {@link Tab}. */
interface InternalTabProps extends Readonly<React.PropsWithChildren> { interface InternalTabProps extends Readonly<React.PropsWithChildren> {
readonly project?: dashboard.Project readonly 'data-testid'?: string
readonly project?: projectHooks.Project
readonly isActive: boolean readonly isActive: boolean
readonly icon: string readonly icon: string
readonly labelId: text.TextId readonly labelId: text.TextId
@ -200,7 +201,7 @@ export function Tab(props: InternalTabProps) {
const { isLoading, data } = reactQuery.useQuery<backend.Project>( const { isLoading, data } = reactQuery.useQuery<backend.Project>(
project?.id project?.id
? dashboard.createGetProjectDetailsQuery.createPassiveListener(project.id) ? projectHooks.createGetProjectDetailsQuery.createPassiveListener(project.id)
: { queryKey: ['__IGNORE__'], queryFn: reactQuery.skipToken } : { queryKey: ['__IGNORE__'], queryFn: reactQuery.skipToken }
) )
@ -223,6 +224,7 @@ export function Tab(props: InternalTabProps) {
)} )}
> >
<ariaComponents.Button <ariaComponents.Button
data-testid={props['data-testid']}
size="custom" size="custom"
variant="custom" variant="custom"
loaderPosition="icon" loaderPosition="icon"

View File

@ -67,7 +67,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
}} }}
> >
<aria.Text className="relative">{getText('confirmPrompt', actionText)}</aria.Text> <aria.Text className="relative">{getText('confirmPrompt', actionText)}</aria.Text>
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button <ariaComponents.Button
size="medium" size="medium"
variant="delete" variant="delete"

View File

@ -58,7 +58,7 @@ export default function ConfirmDeleteUserModal(props: ConfirmDeleteUserModalProp
<aria.Text className="relative mb-2 text-balance text-center"> <aria.Text className="relative mb-2 text-balance text-center">
{getText('confirmDeleteUserAccountWarning')} {getText('confirmDeleteUserAccountWarning')}
</aria.Text> </aria.Text>
<ariaComponents.ButtonGroup className="self-center"> <ariaComponents.ButtonGroup className="relative self-center">
<ariaComponents.Button <ariaComponents.Button
size="custom" size="custom"
variant="custom" variant="custom"

View File

@ -272,7 +272,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
: getText('andOtherProjects', otherProjectsCount)} : getText('andOtherProjects', otherProjectsCount)}
</aria.Text> </aria.Text>
)} )}
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button <ariaComponents.Button
variant="submit" variant="submit"
onPress={() => { onPress={() => {

View File

@ -86,7 +86,7 @@ export default function EditAssetDescriptionModal(props: EditAssetDescriptionMod
{error && <div className="relative text-sm text-red-500">{error.message}</div>} {error && <div className="relative text-sm text-red-500">{error.message}</div>}
<div className="relative flex gap-2"> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" type="submit" loading={isPending}> <ariaComponents.Button variant="submit" type="submit" loading={isPending}>
{actionButtonLabel} {actionButtonLabel}
</ariaComponents.Button> </ariaComponents.Button>
@ -98,7 +98,7 @@ export default function EditAssetDescriptionModal(props: EditAssetDescriptionMod
> >
{getText('editAssetDescriptionModalCancel')} {getText('editAssetDescriptionModalCancel')}
</ariaComponents.Button> </ariaComponents.Button>
</div> </ariaComponents.ButtonGroup>
</form> </form>
</Modal> </Modal>
) )

View File

@ -163,7 +163,7 @@ export default function ManageLabelsModal<
{ {
<FocusArea direction="horizontal"> <FocusArea direction="horizontal">
{innerProps => ( {innerProps => (
<div className="flex gap-input-with-button" {...innerProps}> <ariaComponents.ButtonGroup className="relative" {...innerProps}>
<FocusRing within> <FocusRing within>
<div <div
className={tailwindMerge.twMerge( className={tailwindMerge.twMerge(
@ -199,7 +199,7 @@ export default function ManageLabelsModal<
> >
{getText('create')} {getText('create')}
</ariaComponents.Button> </ariaComponents.Button>
</div> </ariaComponents.ButtonGroup>
)} )}
</FocusArea> </FocusArea>
} }

View File

@ -130,7 +130,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
</ColorPicker> </ColorPicker>
)} )}
</FocusArea> </FocusArea>
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}> <ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}>
{getText('create')} {getText('create')}
</ariaComponents.Button> </ariaComponents.Button>

View File

@ -107,7 +107,7 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
</div> </div>
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError> <aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
</aria.TextField> </aria.TextField>
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button <ariaComponents.Button
variant="submit" variant="submit"
isDisabled={!canSubmit} isDisabled={!canSubmit}

View File

@ -92,7 +92,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
<div className="relative"> <div className="relative">
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} /> <DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
</div> </div>
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}> <ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}>
{getText('create')} {getText('create')}
</ariaComponents.Button> </ariaComponents.Button>

View File

@ -122,7 +122,7 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
)} )}
</FocusArea> </FocusArea>
</div> </div>
<ariaComponents.ButtonGroup> <ariaComponents.ButtonGroup className="relative">
<ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}> <ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}>
{isCreatingSecret ? getText('create') : getText('update')} {isCreatingSecret ? getText('create') : getText('update')}
</ariaComponents.Button> </ariaComponents.Button>

View File

@ -2,8 +2,6 @@
* interactive components. */ * interactive components. */
import * as React from 'react' import * as React from 'react'
import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import * as validator from 'validator' import * as validator from 'validator'
import * as z from 'zod' import * as z from 'zod'
@ -14,6 +12,7 @@ import * as detect from 'enso-common/src/detect'
import type * as types from 'enso-common/src/types' import type * as types from 'enso-common/src/types'
import * as eventCallbacks from '#/hooks/eventCallbackHooks' import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import * as projectHooks from '#/hooks/projectHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks' import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as authProvider from '#/providers/AuthProvider' import * as authProvider from '#/providers/AuthProvider'
@ -21,6 +20,7 @@ import * as backendProvider from '#/providers/BackendProvider'
import * as inputBindingsProvider from '#/providers/InputBindingsProvider' import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider' import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider' import * as modalProvider from '#/providers/ModalProvider'
import ProjectsProvider, * as projectsProvider from '#/providers/ProjectsProvider'
import * as textProvider from '#/providers/TextProvider' import * as textProvider from '#/providers/TextProvider'
import AssetEventType from '#/events/AssetEventType' import AssetEventType from '#/events/AssetEventType'
@ -43,10 +43,8 @@ import Page from '#/components/Page'
import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
import * as backendModule from '#/services/Backend' import * as backendModule from '#/services/Backend'
import type LocalBackend from '#/services/LocalBackend'
import * as localBackendModule from '#/services/LocalBackend' import * as localBackendModule from '#/services/LocalBackend'
import * as projectManager from '#/services/ProjectManager' import * as projectManager from '#/services/ProjectManager'
import type RemoteBackend from '#/services/RemoteBackend'
import * as array from '#/utilities/array' import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage' import LocalStorage from '#/utilities/LocalStorage'
@ -56,51 +54,15 @@ import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
// === Global configuration === // === Global configuration ===
// ============================ // ============================
/** Main content of the screen. Only one should be visible at a time. */
enum TabType {
drive = 'drive',
settings = 'settings',
}
declare module '#/utilities/LocalStorage' { declare module '#/utilities/LocalStorage' {
/** */ /** */
interface LocalStorageData { interface LocalStorageData {
readonly isAssetPanelVisible: boolean readonly isAssetPanelVisible: boolean
readonly page: z.infer<typeof PAGES_SCHEMA>
readonly launchedProjects: z.infer<typeof LAUNCHED_PROJECT_SCHEMA>
} }
} }
LocalStorage.registerKey('isAssetPanelVisible', { schema: z.boolean() }) LocalStorage.registerKey('isAssetPanelVisible', { schema: z.boolean() })
const PROJECT_SCHEMA = z.object({
id: z.custom<backendModule.ProjectId>(),
parentId: z.custom<backendModule.DirectoryId>(),
title: z.string(),
type: z.nativeEnum(backendModule.BackendType),
})
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA)
/**
* Launched project information.
*/
export type Project = z.infer<typeof PROJECT_SCHEMA>
/**
* Launched project ID.
*/
export type ProjectId = Project['id']
LocalStorage.registerKey('launchedProjects', {
isUserSpecific: true,
schema: LAUNCHED_PROJECT_SCHEMA,
})
const PAGES_SCHEMA = z.nativeEnum(TabType).or(z.custom<backendModule.ProjectId>())
LocalStorage.registerKey('page', {
schema: PAGES_SCHEMA,
})
// ================= // =================
// === Dashboard === // === Dashboard ===
// ================= // =================
@ -114,101 +76,13 @@ export interface DashboardProps {
readonly ydocUrl: string | null readonly ydocUrl: string | null
} }
/**
*
*/
export interface OpenProjectOptions {
/**
* Whether to open the project in the background.
* Set to `false` to navigate to the project tab.
* @default true
*/
readonly openInBackground?: boolean
}
/**
*
*/
export interface CreateOpenedProjectQueryOptions {
readonly type: backendModule.BackendType
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
readonly remoteBackend: RemoteBackend
readonly localBackend: LocalBackend | null
}
/**
* Project status query.
*/
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
const isLocal = type === backendModule.BackendType.local
return reactQuery.queryOptions({
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
meta: { persist: false },
gcTime: 0,
refetchInterval: ({ state }) => {
/**
* Default interval for refetching project status when the project is opened.
*/
const openedIntervalMS = 30_000
/**
* Interval when we open a cloud project.
* Since opening a cloud project is a long operation, we want to check the status less often.
*/
const cloudOpeningIntervalMS = 5_000
/**
* Interval when we open a local project or when we want to sync the project status as soon as possible.
*/
const activeSyncIntervalMS = 100
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
if (state.status === 'error') {
// eslint-disable-next-line no-restricted-syntax
return false
}
if (isLocal) {
if (state.data?.state.type === backendModule.ProjectState.opened) {
return openedIntervalMS
} else {
return activeSyncIntervalMS
}
} else {
if (state.data == null) {
return activeSyncIntervalMS
} else if (states.includes(state.data.state.type)) {
return openedIntervalMS
} else {
return cloudOpeningIntervalMS
}
}
},
refetchIntervalInBackground: true,
refetchOnWindowFocus: true,
refetchOnMount: true,
queryFn: () => {
invariant(backend != null, 'Backend is null')
return backend.getProjectDetails(assetId, parentId, title)
},
})
}
createGetProjectDetailsQuery.getQueryKey = (id: Project['id']) => ['project', id] as const
createGetProjectDetailsQuery.createPassiveListener = (id: Project['id']) =>
reactQuery.queryOptions<backendModule.Project>({
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
})
/** The component that contains the entire UI. */ /** The component that contains the entire UI. */
export default function Dashboard(props: DashboardProps) { export default function Dashboard(props: DashboardProps) {
return ( return (
<EventListProvider> <EventListProvider>
<DashboardInner {...props} /> <ProjectsProvider>
<DashboardInner {...props} />
</ProjectsProvider>
</EventListProvider> </EventListProvider>
) )
} }
@ -216,8 +90,7 @@ export default function Dashboard(props: DashboardProps) {
/** The component that contains the entire UI. */ /** The component that contains the entire UI. */
function DashboardInner(props: DashboardProps) { function DashboardInner(props: DashboardProps) {
const { appRunner, initialProjectName: initialProjectNameRaw, ydocUrl } = props const { appRunner, initialProjectName: initialProjectNameRaw, ydocUrl } = props
const { user, ...session } = authProvider.useFullUserSession() const { user } = authProvider.useFullUserSession()
const remoteBackend = backendProvider.useRemoteBackendStrict()
const localBackend = backendProvider.useLocalBackend() const localBackend = backendProvider.useLocalBackend()
const { getText } = textProvider.useText() const { getText } = textProvider.useText()
const { modalRef } = modalProvider.useModalRef() const { modalRef } = modalProvider.useModalRef()
@ -251,52 +124,10 @@ function DashboardInner(props: DashboardProps) {
) )
const isCloud = categoryModule.isCloud(category) const isCloud = categoryModule.isCloud(category)
const [launchedProjects, privateSetLaunchedProjects] = React.useState<Project[]>( const page = projectsProvider.usePage()
() => localStorage.get('launchedProjects') ?? [] const launchedProjects = projectsProvider.useLaunchedProjects()
)
// These pages MUST be ROUTER PAGES.
const [page, privateSetPage] = searchParamsState.useSearchParamsState(
'page',
() => localStorage.get('page') ?? TabType.drive,
(value: unknown): value is Project['id'] | TabType => {
return (
array.includes(Object.values(TabType), value) || launchedProjects.some(p => p.id === value)
)
}
)
const selectedProject = launchedProjects.find(p => p.id === page) ?? null const selectedProject = launchedProjects.find(p => p.id === page) ?? null
const setLaunchedProjects = eventCallbacks.useEventCallback(
(fn: (currentState: Project[]) => Project[]) => {
React.startTransition(() => {
privateSetLaunchedProjects(currentState => {
const nextState = fn(currentState)
localStorage.set('launchedProjects', nextState)
return nextState
})
})
}
)
const addLaunchedProject = eventCallbacks.useEventCallback((project: Project) => {
setLaunchedProjects(currentState => [...currentState, project])
})
const removeLaunchedProject = eventCallbacks.useEventCallback((projectId: Project['id']) => {
setLaunchedProjects(currentState => currentState.filter(({ id }) => id !== projectId))
})
const clearLaunchedProjects = eventCallbacks.useEventCallback(() => {
setLaunchedProjects(() => [])
})
const setPage = eventCallbacks.useEventCallback((nextPage: Project['id'] | TabType) => {
privateSetPage(nextPage)
localStorage.set('page', nextPage)
})
if (isCloud && !isUserEnabled && localBackend != null) { if (isCloud && !isUserEnabled && localBackend != null) {
setTimeout(() => { setTimeout(() => {
// This sets `BrowserRouter`, so it must not be set synchronously. // This sets `BrowserRouter`, so it must not be set synchronously.
@ -304,92 +135,20 @@ function DashboardInner(props: DashboardProps) {
}) })
} }
const openProjectMutation = reactQuery.useMutation({ const setPage = projectsProvider.useSetPage()
mutationKey: ['openProject'], const openEditor = projectHooks.useOpenEditor()
networkMode: 'always', const openProject = projectHooks.useOpenProject()
mutationFn: ({ title, id, type, parentId }: Project) => { const closeProject = projectHooks.useCloseProject()
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend const closeAllProjects = projectHooks.useCloseAllProjects()
const clearLaunchedProjects = projectsProvider.useClearLaunchedProjects()
invariant(backend != null, 'Backend is null') const openProjectMutation = projectHooks.useOpenProjectMutation()
const renameProjectMutation = projectHooks.useRenameProjectMutation()
return backend.openProject(
id,
{
executeAsync: false,
cognitoCredentials: {
accessToken: session.accessToken,
refreshToken: session.accessToken,
clientId: session.clientId,
expireAt: session.expireAt,
refreshUrl: session.refreshUrl,
},
parentId,
},
title
)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onError: async (_, { id }) => {
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
},
})
const closeProjectMutation = reactQuery.useMutation({
mutationKey: ['closeProject'],
mutationFn: async ({ type, id, title }: Project) => {
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.closeProject(id, title)
},
onMutate: ({ id }) => {
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
void client.cancelQueries({ queryKey })
void client.invalidateQueries({ queryKey })
},
onSuccess: (_, { id }) =>
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
onError: (_, { id }) =>
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
})
const client = reactQuery.useQueryClient()
const renameProjectMutation = reactQuery.useMutation({
mutationFn: ({ newName, project }: { newName: string; project: Project }) => {
const { parentId, type, id, title } = project
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
invariant(backend != null, 'Backend is null')
return backend.updateProject(
id,
{ projectName: newName, ami: null, ideVersion: null, parentId },
title
)
},
onSuccess: (_, { project }) =>
client.invalidateQueries({
queryKey: createGetProjectDetailsQuery.getQueryKey(project.id),
}),
})
eventListProvider.useAssetEventListener(event => { eventListProvider.useAssetEventListener(event => {
switch (event.type) { switch (event.type) {
case AssetEventType.openProject: { case AssetEventType.openProject: {
const { title, parentId, backendType, id, runInBackground } = event const { title, parentId, backendType, id, runInBackground } = event
doOpenProject( openProject(
{ title, parentId, type: backendType, id }, { title, parentId, type: backendType, id },
{ openInBackground: runInBackground } { openInBackground: runInBackground }
) )
@ -397,7 +156,7 @@ function DashboardInner(props: DashboardProps) {
} }
case AssetEventType.closeProject: { case AssetEventType.closeProject: {
const { title, parentId, backendType, id } = event const { title, parentId, backendType, id } = event
doCloseProject({ title, parentId, type: backendType, id }) closeProject({ title, parentId, type: backendType, id })
break break
} }
default: { default: {
@ -416,7 +175,7 @@ function DashboardInner(props: DashboardProps) {
updateModal(oldModal => { updateModal(oldModal => {
if (oldModal == null) { if (oldModal == null) {
queueMicrotask(() => { queueMicrotask(() => {
setPage(localStorage.get('page') ?? TabType.drive) setPage(localStorage.get('page') ?? projectsProvider.TabType.drive)
}) })
return oldModal return oldModal
} else { } else {
@ -447,96 +206,14 @@ function DashboardInner(props: DashboardProps) {
} }
}, [inputBindings]) }, [inputBindings])
const doOpenProject = eventCallbacks.useEventCallback( const doRemoveSelf = eventCallbacks.useEventCallback((project: projectHooks.Project) => {
(project: Project, options: OpenProjectOptions = {}) => {
const { openInBackground = true } = options
// since we don't support multitabs, we need to close opened project first
if (launchedProjects.length > 0) {
doCloseAllProjects()
}
const isOpeningTheSameProject =
client.getMutationCache().find({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})?.state.status === 'pending'
if (!isOpeningTheSameProject) {
openProjectMutation.mutate(project)
const openingProjectMutation = client.getMutationCache().find({
mutationKey: ['openProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
openingProjectMutation?.setOptions({
...openingProjectMutation.options,
scope: { id: project.id },
})
addLaunchedProject(project)
if (!openInBackground) {
doOpenEditor(project.id)
}
}
}
)
const doOpenEditor = eventCallbacks.useEventCallback((projectId: Project['id']) => {
React.startTransition(() => {
setPage(projectId)
})
})
const doCloseProject = eventCallbacks.useEventCallback((project: Project) => {
client
.getMutationCache()
.findAll({
mutationKey: ['openProject'],
predicate: mutation => mutation.options.scope?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, retry: false })
mutation.destroy()
})
closeProjectMutation.mutate(project)
client
.getMutationCache()
.findAll({
mutationKey: ['closeProject'],
// this is unsafe, but we can't do anything about it
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
predicate: mutation => mutation.state.variables?.id === project.id,
})
.forEach(mutation => {
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
})
removeLaunchedProject(project.id)
setPage(TabType.drive)
})
const doCloseAllProjects = eventCallbacks.useEventCallback(() => {
for (const launchedProject of launchedProjects) {
doCloseProject(launchedProject)
}
})
const doRemoveSelf = eventCallbacks.useEventCallback((project: Project) => {
dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id: project.id }) dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id: project.id })
doCloseProject(project) closeProject(project)
}) })
const onSignOut = eventCallbacks.useEventCallback(() => { const onSignOut = eventCallbacks.useEventCallback(() => {
setPage(TabType.drive) setPage(projectsProvider.TabType.drive)
doCloseAllProjects() closeAllProjects()
clearLaunchedProjects() clearLaunchedProjects()
}) })
@ -580,11 +257,11 @@ function DashboardInner(props: DashboardProps) {
<div className="flex"> <div className="flex">
<TabBar> <TabBar>
<tabBar.Tab <tabBar.Tab
isActive={page === TabType.drive} isActive={page === projectsProvider.TabType.drive}
icon={DriveIcon} icon={DriveIcon}
labelId="drivePageName" labelId="drivePageName"
onPress={() => { onPress={() => {
setPage(TabType.drive) setPage(projectsProvider.TabType.drive)
}} }}
> >
{getText('drivePageName')} {getText('drivePageName')}
@ -592,6 +269,7 @@ function DashboardInner(props: DashboardProps) {
{launchedProjects.map(project => ( {launchedProjects.map(project => (
<tabBar.Tab <tabBar.Tab
data-testid="editor-tab-button"
project={project} project={project}
key={project.id} key={project.id}
isActive={page === project.id} isActive={page === project.id}
@ -601,26 +279,26 @@ function DashboardInner(props: DashboardProps) {
setPage(project.id) setPage(project.id)
}} }}
onClose={() => { onClose={() => {
doCloseProject(project) closeProject(project)
}} }}
onLoadEnd={() => { onLoadEnd={() => {
doOpenEditor(project.id) openEditor(project.id)
}} }}
> >
{project.title} {project.title}
</tabBar.Tab> </tabBar.Tab>
))} ))}
{page === TabType.settings && ( {page === projectsProvider.TabType.settings && (
<tabBar.Tab <tabBar.Tab
isActive isActive
icon={SettingsIcon} icon={SettingsIcon}
labelId="settingsPageName" labelId="settingsPageName"
onPress={() => { onPress={() => {
setPage(TabType.settings) setPage(projectsProvider.TabType.settings)
}} }}
onClose={() => { onClose={() => {
setPage(TabType.drive) setPage(projectsProvider.TabType.drive)
}} }}
> >
{getText('settingsPageName')} {getText('settingsPageName')}
@ -632,7 +310,7 @@ function DashboardInner(props: DashboardProps) {
onShareClick={selectedProject ? doOpenShareModal : undefined} onShareClick={selectedProject ? doOpenShareModal : undefined}
setIsHelpChatOpen={setIsHelpChatOpen} setIsHelpChatOpen={setIsHelpChatOpen}
goToSettingsPage={() => { goToSettingsPage={() => {
setPage(TabType.settings) setPage(projectsProvider.TabType.settings)
}} }}
onSignOut={onSignOut} onSignOut={onSignOut}
/> />
@ -640,14 +318,10 @@ function DashboardInner(props: DashboardProps) {
<Drive <Drive
assetsManagementApiRef={assetManagementApiRef} assetsManagementApiRef={assetManagementApiRef}
openedProjects={launchedProjects}
category={category} category={category}
setCategory={setCategory} setCategory={setCategory}
hidden={page !== TabType.drive} hidden={page !== projectsProvider.TabType.drive}
initialProjectName={initialProjectName} initialProjectName={initialProjectName}
doOpenProject={doOpenProject}
doOpenEditor={doOpenEditor}
doCloseProject={doCloseProject}
/> />
{launchedProjects.map(project => ( {launchedProjects.map(project => (
@ -668,7 +342,7 @@ function DashboardInner(props: DashboardProps) {
/> />
))} ))}
{page === TabType.settings && <Settings />} {page === projectsProvider.TabType.settings && <Settings />}
{process.env.ENSO_CLOUD_CHAT_URL != null ? ( {process.env.ENSO_CLOUD_CHAT_URL != null ? (
<Chat <Chat
isOpen={isHelpChatOpen} isOpen={isHelpChatOpen}

View File

@ -0,0 +1,238 @@
/** @file The React provider (and associated hooks) for providing reactive events. */
import * as React from 'react'
import invariant from 'tiny-invariant'
import * as z from 'zod'
import * as zustand from 'zustand'
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
import type * as projectHooks from '#/hooks/projectHooks'
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as array from '#/utilities/array'
import LocalStorage from '#/utilities/LocalStorage'
// ===============
// === TabType ===
// ===============
/** Main content of the screen. Only one should be visible at a time. */
export enum TabType {
drive = 'drive',
settings = 'settings',
}
// ============================
// === Global configuration ===
// ============================
declare module '#/utilities/LocalStorage' {
/** */
interface LocalStorageData {
readonly isAssetPanelVisible: boolean
readonly page: z.infer<typeof PAGES_SCHEMA>
}
}
const PAGES_SCHEMA = z
.nativeEnum(TabType)
.or(z.custom<projectHooks.ProjectId>(value => typeof value === 'string'))
LocalStorage.registerKey('page', { schema: PAGES_SCHEMA })
// =====================
// === ProjectsStore ===
// =====================
/** The state of this zustand store. */
interface ProjectsStore {
readonly page: projectHooks.ProjectId | TabType
readonly setPage: (page: projectHooks.ProjectId | TabType) => void
readonly launchedProjects: readonly projectHooks.Project[]
readonly addLaunchedProject: (project: projectHooks.Project) => void
readonly removeLaunchedProject: (projectId: projectHooks.ProjectId) => void
readonly clearLaunchedProjects: () => void
}
// =======================
// === ProjectsContext ===
// =======================
/** State contained in a `ProjectsContext`. */
export interface ProjectsContextType extends zustand.StoreApi<ProjectsStore> {}
const ProjectsContext = React.createContext<ProjectsContextType | null>(null)
/** Props for a {@link ProjectsProvider}. */
export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren> {}
// ========================
// === ProjectsProvider ===
// ========================
/** A React provider (and associated hooks) for determining whether the current area
* containing the current element is focused. */
export default function ProjectsProvider(props: ProjectsProviderProps) {
const { children } = props
const { localStorage } = localStorageProvider.useLocalStorage()
const [store] = React.useState(() => {
return zustand.createStore<ProjectsStore>(set => ({
page: TabType.drive,
setPage: page => {
set({ page })
},
launchedProjects: localStorage.get('launchedProjects') ?? [],
addLaunchedProject: project => {
set(({ launchedProjects }) => ({ launchedProjects: [...launchedProjects, project] }))
},
removeLaunchedProject: projectId => {
set(({ launchedProjects }) => ({
launchedProjects: launchedProjects.filter(({ id }) => id !== projectId),
}))
},
clearLaunchedProjects: () => {
set({ launchedProjects: [] })
},
}))
})
return (
<ProjectsContext.Provider value={store}>
<PageSynchronizer />
{children}
</ProjectsContext.Provider>
)
}
// ========================
// === PageSynchronizer ===
// ========================
/** A component to synchronize React state with search parmas state. */
function PageSynchronizer() {
const { localStorage } = localStorageProvider.useLocalStorage()
const store = useProjectsStore()
const providerSetPage = useSetPage()
const [page, privateSetPage] = searchParamsState.useSearchParamsState(
'page',
() => store.getState().page,
(value: unknown): value is projectHooks.ProjectId | TabType => {
return (
array.includes(Object.values(TabType), value) ||
store.getState().launchedProjects.some(p => p.id === value)
)
}
)
React.useEffect(() => {
providerSetPage(page)
}, [page, providerSetPage])
React.useEffect(() =>
store.subscribe(state => {
privateSetPage(state.page)
})
)
React.useEffect(() =>
store.subscribe(state => {
localStorage.set('launchedProjects', state.launchedProjects)
})
)
return null
}
// ========================
// === useProjectsStore ===
// ========================
/** The projects store. */
export function useProjectsStore() {
const store = React.useContext(ProjectsContext)
invariant(store, 'Projects store store can only be used inside an `ProjectsProvider`.')
return store
}
// =============================
// === useAddLaunchedProject ===
// =============================
/** A function to retrieve all launched projects. */
export function useLaunchedProjects() {
const store = useProjectsStore()
return zustand.useStore(store, state => state.launchedProjects)
}
// =============================
// === useAddLaunchedProject ===
// =============================
/** A function to add a new launched projoect. */
export function useAddLaunchedProject() {
const store = useProjectsStore()
const addLaunchedProject = zustand.useStore(store, state => state.addLaunchedProject)
return eventCallbacks.useEventCallback((project: projectHooks.Project) => {
React.startTransition(() => {
addLaunchedProject(project)
})
})
}
// ================================
// === useRemoveLaunchedProject ===
// ================================
/** A function to remove a launched project. */
export function useRemoveLaunchedProject() {
const store = useProjectsStore()
const removeLaunchedProject = zustand.useStore(store, state => state.removeLaunchedProject)
return eventCallbacks.useEventCallback((projectId: projectHooks.ProjectId) => {
React.startTransition(() => {
removeLaunchedProject(projectId)
})
})
}
// ================================
// === useClearLaunchedProjects ===
// ================================
/** A function to remove all launched projects. */
export function useClearLaunchedProjects() {
const store = useProjectsStore()
const clearLaunchedProjects = zustand.useStore(store, state => state.clearLaunchedProjects)
return eventCallbacks.useEventCallback(() => {
React.startTransition(() => {
clearLaunchedProjects()
})
})
}
// ===============
// === usePage ===
// ===============
/** A function to get the current page. */
export function usePage() {
const store = useProjectsStore()
return zustand.useStore(store, state => state.page)
}
// ==================
// === useSetPage ===
// ==================
/** A function to set the current page. */
export function useSetPage() {
const store = useProjectsStore()
const setPage = zustand.useStore(store, state => state.setPage)
return eventCallbacks.useEventCallback((page: projectHooks.ProjectId | TabType) => {
React.startTransition(() => {
setPage(page)
})
})
}

View File

@ -9,6 +9,7 @@ import type ProjectManager from '#/services/ProjectManager'
import * as appBaseUrl from '#/utilities/appBaseUrl' import * as appBaseUrl from '#/utilities/appBaseUrl'
import * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
import * as errorModule from '#/utilities/error' import * as errorModule from '#/utilities/error'
import * as fileInfo from '#/utilities/fileInfo' import * as fileInfo from '#/utilities/fileInfo'
@ -162,7 +163,6 @@ export default class LocalBackend extends Backend {
this.projectManager.projects.get(entry.metadata.id)?.state ?? this.projectManager.projects.get(entry.metadata.id)?.state ??
backend.ProjectState.closed, backend.ProjectState.closed,
volumeId: '', volumeId: '',
path: entry.path,
}, },
labels: [], labels: [],
description: null, description: null,
@ -193,7 +193,7 @@ export default class LocalBackend extends Backend {
const result = await this.projectManager.listProjects({}) const result = await this.projectManager.listProjects({})
return result.projects.map(project => ({ return result.projects.map(project => ({
name: project.name, name: project.name,
organizationId: '', organizationId: backend.OrganizationId(''),
projectId: newProjectId(project.id), projectId: newProjectId(project.id),
packageName: project.name, packageName: project.name,
state: { state: {
@ -218,20 +218,12 @@ export default class LocalBackend extends Backend {
missingComponentAction: projectManager.MissingComponentAction.install, missingComponentAction: projectManager.MissingComponentAction.install,
...(projectsDirectory == null ? {} : { projectsDirectory }), ...(projectsDirectory == null ? {} : { projectsDirectory }),
}) })
const path = projectManager.joinPath(
projectsDirectory ?? this.projectManager.rootDirectory,
project.projectNormalizedName
)
return { return {
name: project.projectName, name: project.projectName,
organizationId: '', organizationId: backend.OrganizationId(''),
projectId: newProjectId(project.projectId), projectId: newProjectId(project.projectId),
packageName: project.projectName, packageName: project.projectName,
state: { state: { type: backend.ProjectState.closed, volumeId: '' },
type: backend.ProjectState.closed,
volumeId: '',
path,
},
} }
} }
@ -293,7 +285,7 @@ export default class LocalBackend extends Backend {
ideVersion: version, ideVersion: version,
jsonAddress: null, jsonAddress: null,
binaryAddress: null, binaryAddress: null,
organizationId: '', organizationId: backend.OrganizationId(''),
packageName: project.name, packageName: project.name,
projectId, projectId,
state: { type: backend.ProjectState.closed, volumeId: '' }, state: { type: backend.ProjectState.closed, volumeId: '' },
@ -313,7 +305,7 @@ export default class LocalBackend extends Backend {
}, },
jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress), jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress),
binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress), binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress),
organizationId: '', organizationId: backend.OrganizationId(''),
packageName: cachedProject.projectNormalizedName, packageName: cachedProject.projectNormalizedName,
projectId, projectId,
state: { state: {
@ -364,10 +356,9 @@ export default class LocalBackend extends Backend {
await this.projectManager.renameProject({ await this.projectManager.renameProject({
projectId: id, projectId: id,
name: projectManager.ProjectName(body.projectName), name: projectManager.ProjectName(body.projectName),
projectsDirectory: extractTypeAndId(body.parentId).id,
}) })
} }
const parentId = extractTypeAndId(body.parentId).id const parentId = this.projectManager.getProjectDirectoryPath(id)
const result = await this.projectManager.listDirectory(parentId) const result = await this.projectManager.listDirectory(parentId)
const project = result.flatMap(listedProject => const project = result.flatMap(listedProject =>
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry && listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
@ -390,18 +381,31 @@ export default class LocalBackend extends Backend {
engineVersion: version, engineVersion: version,
ideVersion: version, ideVersion: version,
name: project.name, name: project.name,
organizationId: '', organizationId: backend.OrganizationId(''),
projectId, projectId,
} }
} }
} }
} }
/** Duplicate a specific version of a project. */
override async duplicateProject(projectId: backend.ProjectId): Promise<backend.CreatedProject> {
const id = extractTypeAndId(projectId).id
const project = await this.projectManager.duplicateProject({ projectId: id })
return {
projectId: newProjectId(project.projectId),
name: project.projectName,
packageName: project.projectNormalizedName,
organizationId: backend.OrganizationId(''),
state: { type: backend.ProjectState.closed, volumeId: '' },
}
}
/** Delete an arbitrary asset. /** Delete an arbitrary asset.
* @throws An error if the JSON-RPC call fails. */ * @throws An error if the JSON-RPC call fails. */
override async deleteAsset( override async deleteAsset(
assetId: backend.AssetId, assetId: backend.AssetId,
body: backend.DeleteAssetRequestBody, _body: backend.DeleteAssetRequestBody,
title: string | null title: string | null
): Promise<void> { ): Promise<void> {
const typeAndId = extractTypeAndId(assetId) const typeAndId = extractTypeAndId(assetId)
@ -413,10 +417,7 @@ export default class LocalBackend extends Backend {
} }
case backend.AssetType.project: { case backend.AssetType.project: {
try { try {
await this.projectManager.deleteProject({ await this.projectManager.deleteProject({ projectId: typeAndId.id })
projectId: typeAndId.id,
projectsDirectory: extractTypeAndId(body.parentId).id,
})
return return
} catch (error) { } catch (error) {
throw new Error( throw new Error(
@ -429,10 +430,30 @@ export default class LocalBackend extends Backend {
} }
} }
/** Copy an arbitrary asset to another directory. Not yet implemented in the backend. /** Copy an arbitrary asset to another directory. */
* @throws {Error} Always. */ override async copyAsset(
override copyAsset(): Promise<backend.CopyAssetResponse> { assetId: backend.AssetId,
throw new Error('Cannot copy assets in local backend yet.') parentDirectoryId: backend.DirectoryId
): Promise<backend.CopyAssetResponse> {
const typeAndId = extractTypeAndId(assetId)
if (typeAndId.type !== backend.AssetType.project) {
throw new Error('Only projects can be copied on the Local Backend.')
} else {
const project = await this.projectManager.duplicateProject({ projectId: typeAndId.id })
const projectPath = this.projectManager.projectPaths.get(typeAndId.id)
const parentPath =
projectPath == null ? null : projectManager.getDirectoryAndName(projectPath).directoryPath
if (parentPath !== extractTypeAndId(parentDirectoryId).id) {
throw new Error('Cannot duplicate project to a different directory on the Local Backend.')
} else {
const asset = {
id: newProjectId(project.projectId),
parentId: parentDirectoryId,
title: project.projectName,
}
return { asset }
}
}
} }
/** Return a list of engine versions. */ /** Return a list of engine versions. */
@ -559,15 +580,13 @@ export default class LocalBackend extends Backend {
): Promise<void> { ): Promise<void> {
if (body.parentDirectoryId != null) { if (body.parentDirectoryId != null) {
const typeAndId = extractTypeAndId(assetId) const typeAndId = extractTypeAndId(assetId)
const from = typeAndId.type === backend.AssetType.project ? body.projectPath : typeAndId.id const from =
if (from == null) { typeAndId.type !== backend.AssetType.project
throw new Error('Could not move project: project has no `projectPath`.') ? typeAndId.id
} else { : this.projectManager.getProjectDirectoryPath(typeAndId.id)
const fileName = fileInfo.fileName(from) const fileName = fileInfo.fileName(from)
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName) const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
await this.projectManager.moveFile(from, to) await this.projectManager.moveFile(from, to)
return
}
} }
} }
@ -604,13 +623,10 @@ export default class LocalBackend extends Backend {
const to = projectManager.joinPath(projectManager.Path(folderPath), body.title) const to = projectManager.joinPath(projectManager.Path(folderPath), body.title)
await this.projectManager.moveFile(from, to) await this.projectManager.moveFile(from, to)
} }
/** Return a {@link Promise} that resolves only when a project is ready to open. */
override async waitUntilProjectIsReady( /** Construct a new path using the given parent directory and a file name. */
projectId: backend.ProjectId, getProjectDirectoryPath(id: backend.ProjectId) {
directory: backend.DirectoryId | null, return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id)
title: string
) {
return await this.getProjectDetails(projectId, directory, title)
} }
/** Construct a new path using the given parent directory and a file name. */ /** Construct a new path using the given parent directory and a file name. */
@ -634,9 +650,10 @@ export default class LocalBackend extends Backend {
} }
} }
/** Invalid operation. */ /** Download from an arbitrary URL that is assumed to originate from this backend. */
override duplicateProject() { override async download(url: string, name?: string) {
return this.invalidOperation() download.download(url, name)
return Promise.resolve()
} }
/** Invalid operation. */ /** Invalid operation. */

View File

@ -6,7 +6,7 @@ import * as detect from 'enso-common/src/detect'
import * as backend from '#/services/Backend' import * as backend from '#/services/Backend'
import * as appBaseUrl from '#/utilities/appBaseUrl' import * as appBaseUrl from '#/utilities/appBaseUrl'
import type * as dateTime from '#/utilities/dateTime' import * as dateTime from '#/utilities/dateTime'
import * as newtype from '#/utilities/newtype' import * as newtype from '#/utilities/newtype'
// ================= // =================
@ -178,7 +178,14 @@ export interface EngineVersion {
/** The return value of the "list available engine versions" endpoint. */ /** The return value of the "list available engine versions" endpoint. */
export interface VersionList { export interface VersionList {
readonly versions: EngineVersion[] readonly versions: readonly EngineVersion[]
}
/** The return value of the "duplicate project" endpoint. */
export interface DuplicatedProject {
readonly projectId: UUID
readonly projectName: string
readonly projectNormalizedName: string
} }
// ==================== // ====================
@ -231,13 +238,19 @@ export interface CreateProjectParams {
readonly projectsDirectory?: Path readonly projectsDirectory?: Path
} }
/** Parameters for the "list samples" endpoint. */ /** Parameters for the "rename project" endpoint. */
export interface RenameProjectParams { export interface RenameProjectParams {
readonly projectId: UUID readonly projectId: UUID
readonly name: ProjectName readonly name: ProjectName
readonly projectsDirectory?: Path readonly projectsDirectory?: Path
} }
/** Parameters for the "duplicate project" endpoint. */
export interface DuplicateProjectParams {
readonly projectId: UUID
readonly projectsDirectory?: Path
}
/** Parameters for the "delete project" endpoint. */ /** Parameters for the "delete project" endpoint. */
export interface DeleteProjectParams { export interface DeleteProjectParams {
readonly projectId: UUID readonly projectId: UUID
@ -266,6 +279,16 @@ function normalizeSlashes(path: string): Path {
} }
} }
// ===========================
// === getDirectoryAndName ===
// ===========================
/** Split a {@link Path} inito the path of its parent directory, and its file name. */
export function getDirectoryAndName(path: Path) {
const [, directoryPath = '', fileName = ''] = path.match(/^(.+)[/]([^/]+)$/) ?? []
return { directoryPath: Path(directoryPath), fileName }
}
// ======================= // =======================
// === Project Manager === // === Project Manager ===
// ======================= // =======================
@ -280,10 +303,19 @@ export enum ProjectManagerEvents {
* It should always be in sync with the Rust interface at * It should always be in sync with the Rust interface at
* `app/gui/controller/engine-protocol/src/project_manager.rs`. */ * `app/gui/controller/engine-protocol/src/project_manager.rs`. */
export default class ProjectManager { export default class ProjectManager {
// This is required so that projects get recursively updated (deleted, renamed or moved).
private readonly internalDirectories = new Map<Path, readonly FileSystemEntry[]>()
private readonly internalProjects = new Map<UUID, ProjectState>() private readonly internalProjects = new Map<UUID, ProjectState>()
private readonly internalProjectPaths = new Map<UUID, Path>()
// This MUST be declared after `internalDirectories` because it depends on `internalDirectories`.
// eslint-disable-next-line @typescript-eslint/member-ordering
readonly directories: ReadonlyMap<UUID, ProjectState> = this.internalProjects
// This MUST be declared after `internalProjects` because it depends on `internalProjects`. // This MUST be declared after `internalProjects` because it depends on `internalProjects`.
// eslint-disable-next-line @typescript-eslint/member-ordering // eslint-disable-next-line @typescript-eslint/member-ordering
readonly projects: ReadonlyMap<UUID, ProjectState> = this.internalProjects readonly projects: ReadonlyMap<UUID, ProjectState> = this.internalProjects
// This MUST be declared after `internalProjectPaths` because it depends on `internalProjectPaths`.
// eslint-disable-next-line @typescript-eslint/member-ordering
readonly projectPaths: ReadonlyMap<UUID, Path> = this.internalProjectPaths
private id = 0 private id = 0
private resolvers = new Map<number, (value: never) => void>() private resolvers = new Map<number, (value: never) => void>()
private rejecters = new Map<number, (reason?: JSONRPCError) => void>() private rejecters = new Map<number, (reason?: JSONRPCError) => void>()
@ -355,6 +387,12 @@ export default class ProjectManager {
socket.close() socket.close()
} }
/** Get the directory path of a project. */
getProjectDirectoryPath(projectId: UUID) {
const projectPath = this.internalProjectPaths.get(projectId)
return projectPath == null ? this.rootDirectory : getDirectoryAndName(projectPath).directoryPath
}
/** Open an existing project. */ /** Open an existing project. */
async openProject(params: OpenProjectParams): Promise<OpenProject> { async openProject(params: OpenProjectParams): Promise<OpenProject> {
const cached = this.internalProjects.get(params.projectId) const cached = this.internalProjects.get(params.projectId)
@ -388,21 +426,67 @@ export default class ProjectManager {
/** Create a new project. */ /** Create a new project. */
async createProject(params: CreateProjectParams): Promise<CreateProject> { async createProject(params: CreateProjectParams): Promise<CreateProject> {
return this.sendRequest<CreateProject>('project/create', { const result = await this.sendRequest<CreateProject>('project/create', {
missingComponentAction: MissingComponentAction.install, missingComponentAction: MissingComponentAction.install,
...params, ...params,
}) })
const directoryPath = params.projectsDirectory ?? this.rootDirectory
// Update `internalDirectories` by listing the project's parent directory, because the
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
await this.listDirectory(directoryPath)
return result
} }
/** Rename a project. */ /** Rename a project. */
async renameProject(params: RenameProjectParams): Promise<void> { async renameProject(params: Omit<RenameProjectParams, 'projectsDirectory'>): Promise<void> {
return this.sendRequest('project/rename', params) const path = this.internalProjectPaths.get(params.projectId)
const directoryPath =
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
const fullParams: RenameProjectParams = { ...params, projectsDirectory: directoryPath }
await this.sendRequest('project/rename', fullParams)
// Update `internalDirectories` by listing the project's parent directory, because the new
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
await this.listDirectory(directoryPath)
}
/** Duplicate a project. */
async duplicateProject(
params: Omit<DuplicateProjectParams, 'projectsDirectory'>
): Promise<DuplicatedProject> {
const path = this.internalProjectPaths.get(params.projectId)
const directoryPath =
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
const fullParams: DuplicateProjectParams = { ...params, projectsDirectory: directoryPath }
const result = this.sendRequest<DuplicatedProject>('project/duplicate', fullParams)
// Update `internalDirectories` by listing the project's parent directory, because the
// directory name of the project is unknown. Deleting the directory is not an option because
// that will prevent ALL descendants of the parent directory from being updated.
await this.listDirectory(directoryPath)
return result
} }
/** Delete a project. */ /** Delete a project. */
async deleteProject(params: DeleteProjectParams): Promise<void> { async deleteProject(params: Omit<DeleteProjectParams, 'projectsDirectory'>): Promise<void> {
const path = this.internalProjectPaths.get(params.projectId)
const directoryPath =
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
const fullParams: DeleteProjectParams = { ...params, projectsDirectory: directoryPath }
await this.sendRequest('project/delete', fullParams)
this.internalProjectPaths.delete(params.projectId)
this.internalProjects.delete(params.projectId) this.internalProjects.delete(params.projectId)
return this.sendRequest('project/delete', params) const siblings = this.internalDirectories.get(directoryPath)
if (siblings != null) {
this.internalDirectories.set(
directoryPath,
siblings.filter(
entry =>
entry.type !== FileSystemEntryType.ProjectEntry ||
entry.metadata.id !== params.projectId
)
)
}
} }
/** List installed engine versions. */ /** List installed engine versions. */
@ -430,37 +514,161 @@ export default class ProjectManager {
} }
/** List directories, projects and files in the given folder. */ /** List directories, projects and files in the given folder. */
async listDirectory(parentId: Path | null) { async listDirectory(parentId: Path | null): Promise<readonly FileSystemEntry[]> {
/** The type of the response body of this endpoint. */ /** The type of the response body of this endpoint. */
interface ResponseBody { interface ResponseBody {
readonly entries: FileSystemEntry[] readonly entries: FileSystemEntry[]
} }
parentId ??= this.rootDirectory
const response = await this.runStandaloneCommand<ResponseBody>( const response = await this.runStandaloneCommand<ResponseBody>(
null, null,
'filesystem-list', 'filesystem-list',
parentId ?? this.rootDirectory parentId
) )
return response.entries.map(entry => ({ ...entry, path: normalizeSlashes(entry.path) })) const result = response.entries.map(entry => ({ ...entry, path: normalizeSlashes(entry.path) }))
this.internalDirectories.set(parentId, result)
for (const entry of result) {
if (entry.type === FileSystemEntryType.ProjectEntry) {
this.internalProjectPaths.set(entry.metadata.id, entry.path)
}
}
return result
} }
/** Create a directory. */ /** Create a directory. */
async createDirectory(path: Path) { async createDirectory(path: Path) {
return this.runStandaloneCommand(null, 'filesystem-create-directory', path) await this.runStandaloneCommand(null, 'filesystem-create-directory', path)
this.internalDirectories.set(path, [])
const directoryPath = getDirectoryAndName(path).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
const now = dateTime.toRfc3339(new Date())
this.internalDirectories.set(directoryPath, [
...siblings.filter(sibling => sibling.type === FileSystemEntryType.DirectoryEntry),
{
type: FileSystemEntryType.DirectoryEntry,
attributes: {
byteSize: 0,
creationTime: now,
lastAccessTime: now,
lastModifiedTime: now,
},
path,
},
...siblings.filter(sibling => sibling.type !== FileSystemEntryType.DirectoryEntry),
])
}
} }
/** Create a file. */ /** Create a file. */
async createFile(path: Path, file: Blob) { async createFile(path: Path, file: Blob) {
await this.runStandaloneCommand(file, 'filesystem-write-path', path) await this.runStandaloneCommand(file, 'filesystem-write-path', path)
const directoryPath = getDirectoryAndName(path).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
const now = dateTime.toRfc3339(new Date())
this.internalDirectories.set(directoryPath, [
...siblings.filter(sibling => sibling.type !== FileSystemEntryType.FileEntry),
{
type: FileSystemEntryType.FileEntry,
attributes: {
byteSize: file.size,
creationTime: now,
lastAccessTime: now,
lastModifiedTime: now,
},
path,
},
...siblings.filter(sibling => sibling.type === FileSystemEntryType.FileEntry),
])
}
} }
/** Move a file or directory. */ /** Move a file or directory. */
async moveFile(from: Path, to: Path) { async moveFile(from: Path, to: Path) {
await this.runStandaloneCommand(null, 'filesystem-move-from', from, '--filesystem-move-to', to) await this.runStandaloneCommand(null, 'filesystem-move-from', from, '--filesystem-move-to', to)
const children = this.internalDirectories.get(from)
// Assume a directory needs to be loaded for its children to be loaded.
if (children) {
const moveChildren = (directoryChildren: readonly FileSystemEntry[]) => {
for (const child of directoryChildren) {
switch (child.type) {
case FileSystemEntryType.DirectoryEntry: {
const childChildren = this.internalDirectories.get(child.path)
if (childChildren) {
moveChildren(childChildren)
}
break
}
case FileSystemEntryType.ProjectEntry: {
const path = this.internalProjectPaths.get(child.metadata.id)
if (path != null) {
this.internalProjectPaths.set(child.metadata.id, Path(path.replace(from, to)))
}
break
}
case FileSystemEntryType.FileEntry: {
// No special extra metadata is stored for files.
break
}
}
}
this.internalDirectories.set(
from,
children.map(child => ({ ...child, path: Path(child.path.replace(from, to)) }))
)
}
moveChildren(children)
}
const directoryPath = getDirectoryAndName(from).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
this.internalDirectories.set(
directoryPath,
siblings.filter(entry => entry.path !== from)
)
}
} }
/** Delete a file or directory. */ /** Delete a file or directory. */
async deleteFile(path: Path) { async deleteFile(path: Path) {
await this.runStandaloneCommand(null, 'filesystem-delete', path) await this.runStandaloneCommand(null, 'filesystem-delete', path)
const children = this.internalDirectories.get(path)
// Assume a directory needs to be loaded for its children to be loaded.
if (children) {
const removeChildren = (directoryChildren: readonly FileSystemEntry[]) => {
for (const child of directoryChildren) {
switch (child.type) {
case FileSystemEntryType.DirectoryEntry: {
const childChildren = this.internalDirectories.get(child.path)
if (childChildren) {
removeChildren(childChildren)
}
break
}
case FileSystemEntryType.ProjectEntry: {
this.internalProjects.delete(child.metadata.id)
this.internalProjectPaths.delete(child.metadata.id)
break
}
case FileSystemEntryType.FileEntry: {
// No special extra metadata is stored for files.
break
}
}
}
}
removeChildren(children)
this.internalDirectories.delete(path)
}
const directoryPath = getDirectoryAndName(path).directoryPath
const siblings = this.internalDirectories.get(directoryPath)
if (siblings) {
this.internalDirectories.set(
directoryPath,
siblings.filter(entry => entry.path !== path)
)
}
} }
/** Remove all handlers for a specified request ID. */ /** Remove all handlers for a specified request ID. */

View File

@ -12,6 +12,7 @@ import type * as textProvider from '#/providers/TextProvider'
import Backend, * as backend from '#/services/Backend' import Backend, * as backend from '#/services/Backend'
import * as remoteBackendPaths from '#/services/remoteBackendPaths' import * as remoteBackendPaths from '#/services/remoteBackendPaths'
import * as download from '#/utilities/download'
import type HttpClient from '#/utilities/HttpClient' import type HttpClient from '#/utilities/HttpClient'
import * as object from '#/utilities/object' import * as object from '#/utilities/object'
@ -35,9 +36,6 @@ const STATUS_NOT_AUTHORIZED = 401
/** The number of milliseconds in one day. */ /** The number of milliseconds in one day. */
const ONE_DAY_MS = 86_400_000 const ONE_DAY_MS = 86_400_000
/** The interval between requests checking whether a project is ready to be opened in the IDE. */
const CHECK_STATUS_INTERVAL_MS = 5000
// ============= // =============
// === Types === // === Types ===
// ============= // =============
@ -537,10 +535,9 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */ * @throws An error if a non-successful status code (not 200-299) was received. */
override async deleteAsset( override async deleteAsset(
assetId: backend.AssetId, assetId: backend.AssetId,
bodyRaw: backend.DeleteAssetRequestBody, body: backend.DeleteAssetRequestBody,
title: string title: string
) { ) {
const body = object.omit(bodyRaw, 'parentId')
const paramsString = new URLSearchParams([['force', String(body.force)]]).toString() const paramsString = new URLSearchParams([['force', String(body.force)]]).toString()
const path = remoteBackendPaths.deleteAssetPath(assetId) + '?' + paramsString const path = remoteBackendPaths.deleteAssetPath(assetId) + '?' + paramsString
const response = await this.delete(path) const response = await this.delete(path)
@ -749,10 +746,9 @@ export default class RemoteBackend extends Backend {
* @throws An error if a non-successful status code (not 200-299) was received. */ * @throws An error if a non-successful status code (not 200-299) was received. */
override async updateProject( override async updateProject(
projectId: backend.ProjectId, projectId: backend.ProjectId,
bodyRaw: backend.UpdateProjectRequestBody, body: backend.UpdateProjectRequestBody,
title: string title: string
): Promise<backend.UpdatedProject> { ): Promise<backend.UpdatedProject> {
const body = object.omit(bodyRaw, 'parentId')
const path = remoteBackendPaths.projectUpdatePath(projectId) const path = remoteBackendPaths.projectUpdatePath(projectId)
const response = await this.put<backend.UpdatedProject>(path, body) const response = await this.put<backend.UpdatedProject>(path, body)
if (!responseIsSuccessful(response)) { if (!responseIsSuccessful(response)) {
@ -1110,21 +1106,9 @@ export default class RemoteBackend extends Backend {
} }
} }
/** Return a {@link Promise} that resolves only when a project is ready to open. */ /** Download from an arbitrary URL that is assumed to originate from this backend. */
override async waitUntilProjectIsReady( override async download(url: string, name?: string) {
projectId: backend.ProjectId, await download.downloadWithHeaders(url, this.client.defaultHeaders, name)
directory: backend.DirectoryId | null,
title: string,
abortSignal?: AbortSignal
) {
let project = await this.getProjectDetails(projectId, directory, title)
while (project.state.type !== backend.ProjectState.opened && abortSignal?.aborted !== true) {
await new Promise<void>(resolve => setTimeout(resolve, CHECK_STATUS_INTERVAL_MS))
project = await this.getProjectDetails(projectId, directory, title)
}
return project
} }
/** Get the default version given the type of version (IDE or backend). */ /** Get the default version given the type of version (IDE or backend). */

View File

@ -52,7 +52,7 @@ export default class HttpClient {
* *
* This is useful for setting headers that are required for every request, like * This is useful for setting headers that are required for every request, like
* authentication tokens. */ * authentication tokens. */
public defaultHeaders: HeadersInit = {} public defaultHeaders: Record<string, string> = {}
) {} ) {}
/** Send an HTTP GET request to the specified URL. */ /** Send an HTTP GET request to the specified URL. */

View File

@ -1,7 +1,13 @@
/** @file A function to initiate a download. */ /** @file A function to initiate a download. */
/** Initiates a download for the specified url. */ // ================
// === download ===
// ================
/** Initiate a download for the specified url. */
export function download(url: string, name?: string | null) { export function download(url: string, name?: string | null) {
url = new URL(url, location.toString()).toString()
// Avoid using `window.systemApi` because the name is lost.
const link = document.createElement('a') const link = document.createElement('a')
link.href = url link.href = url
link.download = name ?? url.match(/[^/]+$/)?.[0] ?? '' link.download = name ?? url.match(/[^/]+$/)?.[0] ?? ''
@ -9,3 +15,24 @@ export function download(url: string, name?: string | null) {
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
} }
// ===========================
// === downloadWithHeaders ===
// ===========================
/** Initiate a download with the specified headers, for the specified url. */
export async function downloadWithHeaders(
url: string,
headers: Record<string, string>,
name?: string
) {
url = new URL(url, location.toString()).toString()
if ('systemApi' in window) {
window.systemApi.downloadURL(url, headers)
} else {
const response = await fetch(url, { headers })
const body = await response.blob()
const objectUrl = URL.createObjectURL(body)
download(objectUrl, name ?? url.match(/[^/]+$/)?.[0] ?? '')
}
}

View File

@ -322,7 +322,10 @@ type NormalizeKeybindSegment = {
/** A segment suggestible by autocomplete. */ /** A segment suggestible by autocomplete. */
type SuggestedKeybindSegment = Key | Pointer | `${Modifier}+` type SuggestedKeybindSegment = Key | Pointer | `${Modifier}+`
/** A helper type used to autocomplete and validate a single keyboard shortcut in the editor. */ /** A helper type used to autocomplete and validate a single keyboard shortcut in the editor. */
type AutocompleteKeybind<T extends string, FoundKeyName extends string = never> = T extends '+' export type AutocompleteKeybind<
T extends string,
FoundKeyName extends string = never,
> = T extends '+'
? T ? T
: T extends `${infer First}+${infer Rest}` : T extends `${infer First}+${infer Rest}`
? Lowercase<First> extends LowercaseModifier ? Lowercase<First> extends LowercaseModifier

View File

@ -1,5 +1,4 @@
/** @file Utilities for working with permissions. */ /** @file Utilities for working with permissions. */
import * as permissions from 'enso-common/src/utilities/permissions' import * as permissions from 'enso-common/src/utilities/permissions'
export * from 'enso-common/src/utilities/permissions' export * from 'enso-common/src/utilities/permissions'

View File

@ -89,6 +89,7 @@ interface MenuApi {
/** `window.systemApi` exposes functionality related to the operating system. */ /** `window.systemApi` exposes functionality related to the operating system. */
interface SystemApi { interface SystemApi {
readonly downloadURL: (url: string, headers?: Record<string, string>) => void
readonly showItemInFolder: (fullPath: string) => void readonly showItemInFolder: (fullPath: string) => void
} }

View File

@ -18,11 +18,22 @@
dir = ./.; dir = ./.;
sha256 = "sha256-o/MRwGYjLPyD1zZQe3LX0dOynwRJpVygfF9+vSnqTOc="; sha256 = "sha256-o/MRwGYjLPyD1zZQe3LX0dOynwRJpVygfF9+vSnqTOc=";
}; };
isOnLinux = pkgs.lib.hasInfix "linux" system;
rust-jni = if isOnLinux then with fenix.packages.${system}; combine [
minimal.cargo
minimal.rustc
targets.x86_64-unknown-linux-musl.latest.rust-std
] else fenix.packages.${system}.minimal;
in in
pkgs.mkShell { pkgs.mkShell rec {
buildInputs = with pkgs; [
# === Graal dependencies ===
libxcrypt-legacy
];
packages = with pkgs; [ packages = with pkgs; [
# === TypeScript dependencies === # === TypeScript dependencies ===
nodejs_20 # should match the Node.JS version of the lambdas nodejs_20
corepack corepack
# === Electron === # === Electron ===
electron electron
@ -32,14 +43,31 @@
# === WASM parser dependencies === # === WASM parser dependencies ===
rust rust
wasm-pack wasm-pack
# Java and SBT omitted for now
]; ];
shellHook = '' shellHook = ''
SHIMS_PATH=$HOME/.local/share/enso/nix-shims
# `sccache` can be used to speed up compile times for Rust crates. # `sccache` can be used to speed up compile times for Rust crates.
# `~/.cargo/bin/sccache` is provided by `cargo install sccache`. # `~/.cargo/bin/sccache` is provided by `cargo install sccache`.
# `~/.cargo/bin` must be in the `PATH` for the binary to be accessible. # `~/.cargo/bin` must be in the `PATH` for the binary to be accessible.
export PATH=$HOME/.cargo/bin:$PATH export PATH=$SHIMS_PATH:${rust.out}:$HOME/.cargo/bin:$PATH
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH"
# `rustup` shim
mkdir -p $SHIMS_PATH
cat <<END > $SHIMS_PATH/rustup
if [ "\$3" = "x86_64-unknown-linux-musl" ]; then
echo 'Installing Nix Rust shims'
ln -s ${rust-jni.out}/bin/rustc $SHIMS_PATH
ln -s ${rust-jni.out}/bin/cargo $SHIMS_PATH
else
echo 'Uninstalling Nix Rust shims (if installed)'
rm -f $SHIMS_PATH/{rustc,cargo}
fi
END
chmod +x $SHIMS_PATH/rustup
# Uninstall shims if already installed
$SHIMS_PATH/rustup
''; '';
}); });
}; };

1
nix/bin/rustup Executable file
View File

@ -0,0 +1 @@
#!/bin/sh

View File

@ -487,8 +487,8 @@ importers:
specifier: ^20.11.21 specifier: ^20.11.21
version: 20.11.21 version: 20.11.21
electron: electron:
specifier: 25.7.0 specifier: 31.2.0
version: 25.7.0 version: 31.2.0
electron-builder: electron-builder:
specifier: ^24.13.3 specifier: ^24.13.3
version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3)) version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
@ -2837,9 +2837,6 @@ packages:
'@types/node-fetch@2.6.4': '@types/node-fetch@2.6.4':
resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==} resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==}
'@types/node@18.19.39':
resolution: {integrity: sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==}
'@types/node@20.11.21': '@types/node@20.11.21':
resolution: {integrity: sha512-/ySDLGscFPNasfqStUuWWPfL78jompfIoVzLJPVVAHBh6rpG68+pI2Gk+fNLeI8/f1yPYL4s46EleVIc20F1Ow==} resolution: {integrity: sha512-/ySDLGscFPNasfqStUuWWPfL78jompfIoVzLJPVVAHBh6rpG68+pI2Gk+fNLeI8/f1yPYL4s46EleVIc20F1Ow==}
@ -4089,8 +4086,8 @@ packages:
electron-to-chromium@1.4.815: electron-to-chromium@1.4.815:
resolution: {integrity: sha512-OvpTT2ItpOXJL7IGcYakRjHCt8L5GrrN/wHCQsRB4PQa1X9fe+X9oen245mIId7s14xvArCGSTIq644yPUKKLg==} resolution: {integrity: sha512-OvpTT2ItpOXJL7IGcYakRjHCt8L5GrrN/wHCQsRB4PQa1X9fe+X9oen245mIId7s14xvArCGSTIq644yPUKKLg==}
electron@25.7.0: electron@31.2.0:
resolution: {integrity: sha512-P82EzYZ8k9J21x5syhXV7EkezDmEXwycReXnagfzS0kwepnrlWzq1aDIUWdNvzTdHobky4m/nYcL98qd73mEVA==} resolution: {integrity: sha512-5w+kjOsGiTXytPSErBPNp/3znnuEMKc42RD41MqRoQkiYaR8x/Le2+qWk1cL60UwE/67oeKnOHnnol8xEuldGg==}
engines: {node: '>= 12.20.55'} engines: {node: '>= 12.20.55'}
hasBin: true hasBin: true
@ -10137,10 +10134,6 @@ snapshots:
'@types/node': 20.11.21 '@types/node': 20.11.21
form-data: 3.0.1 form-data: 3.0.1
'@types/node@18.19.39':
dependencies:
undici-types: 5.26.5
'@types/node@20.11.21': '@types/node@20.11.21':
dependencies: dependencies:
undici-types: 5.26.5 undici-types: 5.26.5
@ -11717,10 +11710,10 @@ snapshots:
electron-to-chromium@1.4.815: {} electron-to-chromium@1.4.815: {}
electron@25.7.0: electron@31.2.0:
dependencies: dependencies:
'@electron/get': 2.0.3 '@electron/get': 2.0.3
'@types/node': 18.19.39 '@types/node': 20.11.21
extract-zip: 2.0.1 extract-zip: 2.0.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@ -13820,6 +13813,12 @@ snapshots:
dependencies: dependencies:
fast-diff: 1.3.0 fast-diff: 1.3.0
prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3):
dependencies:
prettier: 3.3.2
typescript: 5.5.3
optional: true
prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3)): prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3)):
dependencies: dependencies:
prettier: 3.3.2 prettier: 3.3.2
@ -13832,7 +13831,7 @@ snapshots:
prettier: 3.3.2 prettier: 3.3.2
optionalDependencies: optionalDependencies:
'@ianvs/prettier-plugin-sort-imports': 4.3.0(@vue/compiler-sfc@3.4.31)(prettier@3.3.2) '@ianvs/prettier-plugin-sort-imports': 4.3.0(@vue/compiler-sfc@3.4.31)(prettier@3.3.2)
prettier-plugin-organize-imports: 4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3)) prettier-plugin-organize-imports: 4.0.0(prettier@3.3.2)(typescript@5.5.3)
prettier@3.3.2: {} prettier@3.3.2: {}