From cf9d75745765c3f4470be632839734b0ba3c4cbc Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Tue, 16 Jul 2024 19:55:45 +1000 Subject: [PATCH] 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 - :warning: `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. --- app/ide-desktop/lib/client/package.json | 2 +- app/ide-desktop/lib/client/src/index.ts | 12 +- app/ide-desktop/lib/client/src/ipc.ts | 2 + app/ide-desktop/lib/client/src/preload.ts | 3 + app/ide-desktop/lib/client/src/security.ts | 5 + .../lib/common/src/services/Backend.ts | 19 +- app/ide-desktop/lib/dashboard/e2e/actions.ts | 52 ++- .../lib/dashboard/e2e/actions/BaseActions.ts | 58 ++- .../dashboard/e2e/actions/DrivePageActions.ts | 117 +++++- .../e2e/actions/contextMenuActions.ts | 13 +- .../dashboard/e2e/actions/goToPageActions.ts | 2 +- .../dashboard/e2e/actions/userMenuActions.ts | 13 +- app/ide-desktop/lib/dashboard/e2e/api.ts | 72 ++-- .../lib/dashboard/e2e/assetPanel.spec.ts | 96 ++--- .../lib/dashboard/e2e/assetSearchBar.spec.ts | 21 +- .../dashboard/e2e/assetsTableFeatures.spec.ts | 111 ++--- .../lib/dashboard/e2e/copy.spec.ts | 385 ++++++++--------- .../lib/dashboard/e2e/createAsset.spec.ts | 62 ++- .../lib/dashboard/e2e/dataLinkEditor.spec.ts | 12 +- .../lib/dashboard/e2e/delete.spec.ts | 84 ++-- .../lib/dashboard/e2e/driveView.spec.ts | 80 ++-- .../lib/dashboard/e2e/labels.spec.ts | 61 +-- .../lib/dashboard/e2e/loginLogout.spec.ts | 30 +- .../lib/dashboard/e2e/membersSettings.spec.ts | 2 +- .../e2e/organizationSettings.spec.ts | 4 +- .../lib/dashboard/e2e/pageSwitcher.spec.ts | 30 +- .../lib/dashboard/e2e/signUp.spec.ts | 87 ++-- .../lib/dashboard/e2e/sort.spec.ts | 56 +-- .../lib/dashboard/e2e/startModal.spec.ts | 18 +- .../lib/dashboard/e2e/userMenu.spec.ts | 26 +- .../lib/dashboard/e2e/userSettings.spec.ts | 6 +- .../src/components/dashboard/AssetRow.tsx | 68 +-- .../components/dashboard/FileNameColumn.tsx | 7 +- .../src/components/dashboard/ProjectIcon.tsx | 40 +- .../dashboard/ProjectNameColumn.tsx | 26 +- .../src/components/dashboard/column.ts | 4 - .../lib/dashboard/src/hooks/projectHooks.ts | 370 ++++++++++++++++ .../src/hooks/searchParamsStateHooks.ts | 77 ++++ .../src/layouts/AssetContextMenu.tsx | 43 +- .../dashboard/src/layouts/AssetProperties.tsx | 15 +- .../lib/dashboard/src/layouts/AssetsTable.tsx | 239 +++++------ .../lib/dashboard/src/layouts/Drive.tsx | 26 +- .../lib/dashboard/src/layouts/Editor.tsx | 15 +- .../lib/dashboard/src/layouts/TabBar.tsx | 10 +- .../src/modals/ConfirmDeleteModal.tsx | 2 +- .../src/modals/ConfirmDeleteUserModal.tsx | 2 +- .../src/modals/DuplicateAssetsModal.tsx | 2 +- .../src/modals/EditAssetDescriptionModal.tsx | 4 +- .../src/modals/ManageLabelsModal.tsx | 4 +- .../dashboard/src/modals/NewLabelModal.tsx | 2 +- .../src/modals/NewUserGroupModal.tsx | 2 +- .../src/modals/UpsertDatalinkModal.tsx | 2 +- .../src/modals/UpsertSecretModal.tsx | 2 +- .../src/pages/dashboard/Dashboard.tsx | 394 ++---------------- .../src/providers/ProjectsProvider.tsx | 238 +++++++++++ .../dashboard/src/services/LocalBackend.ts | 107 +++-- .../dashboard/src/services/ProjectManager.ts | 232 ++++++++++- .../dashboard/src/services/RemoteBackend.ts | 28 +- .../lib/dashboard/src/utilities/HttpClient.ts | 2 +- .../lib/dashboard/src/utilities/download.ts | 29 +- .../dashboard/src/utilities/inputBindings.ts | 5 +- .../dashboard/src/utilities/permissions.ts | 1 - app/ide-desktop/lib/types/globals.d.ts | 1 + flake.nix | 36 +- nix/bin/rustup | 1 + pnpm-lock.yaml | 27 +- 66 files changed, 2157 insertions(+), 1447 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/hooks/projectHooks.ts create mode 100644 app/ide-desktop/lib/dashboard/src/providers/ProjectsProvider.tsx create mode 100755 nix/bin/rustup diff --git a/app/ide-desktop/lib/client/package.json b/app/ide-desktop/lib/client/package.json index 9d0bd46de2e..440011a71c8 100644 --- a/app/ide-desktop/lib/client/package.json +++ b/app/ide-desktop/lib/client/package.json @@ -36,7 +36,7 @@ "@babel/plugin-syntax-import-attributes": "^7.24.7", "@electron/notarize": "2.1.0", "@types/node": "^20.11.21", - "electron": "25.7.0", + "electron": "31.2.0", "electron-builder": "^24.13.3", "enso-common": "workspace:*", "enso-gui2": "workspace:*", diff --git a/app/ide-desktop/lib/client/src/index.ts b/app/ide-desktop/lib/client/src/index.ts index 6449e1056d3..50fc46ef7bd 100644 --- a/app/ide-desktop/lib/client/src/index.ts +++ b/app/ide-desktop/lib/client/src/index.ts @@ -38,8 +38,7 @@ import * as urlAssociations from 'url-associations' const logger = contentConfig.logger if (process.env.ELECTRON_DEV_MODE === 'true' && process.env.NODE_MODULES_PATH != null) { - require.main?.paths.unshift(process.env.NODE_MODULES_PATH) - console.log(require.main?.paths) + module.paths.unshift(process.env.NODE_MODULES_PATH) } // =========== @@ -451,6 +450,15 @@ class App { event.reply(ipc.Channel.importProjectFromPath, path, info) } ) + electron.ipcMain.on( + ipc.Channel.downloadURL, + (_event, url: string, headers?: Record) => { + electron.BrowserWindow.getFocusedWindow()?.webContents.downloadURL( + url, + headers ? { headers } : {} + ) + } + ) electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => { electron.shell.showItemInFolder(fullPath) }) diff --git a/app/ide-desktop/lib/client/src/ipc.ts b/app/ide-desktop/lib/client/src/ipc.ts index 11e27b6fa0f..b7b0feed802 100644 --- a/app/ide-desktop/lib/client/src/ipc.ts +++ b/app/ide-desktop/lib/client/src/ipc.ts @@ -29,5 +29,7 @@ export enum Channel { openFileBrowser = 'open-file-browser', /** Show a file or folder in the system file browser. */ showItemInFolder = 'show-item-in-folder', + /** Download a file using its URL. */ + downloadURL = 'download-url', showAboutModal = 'show-about-modal', } diff --git a/app/ide-desktop/lib/client/src/preload.ts b/app/ide-desktop/lib/client/src/preload.ts index b5c405900bb..c412eb80f9f 100644 --- a/app/ide-desktop/lib/client/src/preload.ts +++ b/app/ide-desktop/lib/client/src/preload.ts @@ -203,6 +203,9 @@ electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API) // ================== const SYSTEM_API = { + downloadURL: (url: string, headers?: Record) => { + electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers) + }, showItemInFolder: (fullPath: string) => { electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath) }, diff --git a/app/ide-desktop/lib/client/src/security.ts b/app/ide-desktop/lib/client/src/security.ts index 8825861cab5..276aa1b895f 100644 --- a/app/ide-desktop/lib/client/src/security.ts +++ b/app/ide-desktop/lib/client/src/security.ts @@ -15,6 +15,11 @@ const TRUSTED_HOSTS = [ 'production-enso-domain.auth.eu-west-1.amazoncognito.com', 'production-enso-organizations-files.s3.amazonaws.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', // This (`localhost`) is required to access Project Manager HTTP endpoints. // This should be changed appropriately if the Project Manager's port number becomes dynamic. diff --git a/app/ide-desktop/lib/common/src/services/Backend.ts b/app/ide-desktop/lib/common/src/services/Backend.ts index 23c69b5acd5..8ed8c44926f 100644 --- a/app/ide-desktop/lib/common/src/services/Backend.ts +++ b/app/ide-desktop/lib/common/src/services/Backend.ts @@ -212,8 +212,6 @@ export interface ProjectStateType { readonly ec2PublicIpAddress?: string readonly currentSessionId?: string readonly openedBy?: EmailAddress - /** Only present on the Local backend. */ - readonly path?: Path } export const IS_OPENING: Readonly> = { @@ -242,7 +240,7 @@ export const IS_OPENING_OR_OPENED: Readonly> = { /** Common `Project` fields returned by all `Project`-related endpoints. */ export interface BaseProject { - readonly organizationId: string + readonly organizationId: OrganizationId readonly projectId: ProjectId readonly name: string } @@ -1053,15 +1051,11 @@ export interface UpdateFileRequestBody { export interface UpdateAssetRequestBody { readonly parentDirectoryId: DirectoryId | null readonly description: string | null - /** Only present on the Local backend. */ - readonly projectPath?: Path } /** HTTP request body for the "delete asset" endpoint. */ export interface DeleteAssetRequestBody { readonly force: boolean - /** Only used by the Local backend. */ - readonly parentId: DirectoryId } /** HTTP request body for the "create project" endpoint. */ @@ -1078,8 +1072,6 @@ export interface UpdateProjectRequestBody { readonly projectName: string | null readonly ami: Ami | null readonly ideVersion: VersionNumber | null - /** Only used by the Local backend. */ - readonly parentId: DirectoryId } /** HTTP request body for the "open project" endpoint. */ @@ -1463,11 +1455,6 @@ export default abstract class Backend { projectId?: string | null, metadata?: object | null ): Promise - /** Return a {@link Promise} that resolves only when a project is ready to open. */ - abstract waitUntilProjectIsReady( - projectId: ProjectId, - directory: DirectoryId | null, - title: string, - abortSignal?: AbortSignal - ): Promise + /** Download from an arbitrary URL that is assumed to originate from this backend. */ + abstract download(url: string, name?: string): Promise } diff --git a/app/ide-desktop/lib/dashboard/e2e/actions.ts b/app/ide-desktop/lib/dashboard/e2e/actions.ts index 91629b6c7a8..54073802ca8 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions.ts @@ -375,8 +375,7 @@ export function locateNewUserGroupModal(page: test.Page) { /** Find a user menu (if any) on the current page. */ export function locateUserMenu(page: test.Page) { - // This has no identifying features. - return page.getByTestId('user-menu') + return page.getByAltText('User Settings').locator('visible=true') } /** 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. */ export async function go(page: test.Page) { 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. */ export async function go(page: test.Page) { 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. */ export async function go(page: test.Page) { 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. */ export async function go(page: test.Page) { 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() }) } @@ -571,7 +574,8 @@ export namespace settings { /** Navigate so that the "organization profile picture" settings section is visible. */ export async function go(page: test.Page) { 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() }) } @@ -591,7 +595,8 @@ export namespace settings { /** Navigate so that the "members" settings section is visible. */ export async function go(page: test.Page, force = false) { 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 }) }) } @@ -876,11 +881,10 @@ export const mockApi = apiModule.mockApi /** Set up all mocks, without logging in. */ // This syntax is required for Playwright to work properly. // eslint-disable-next-line no-restricted-syntax -export async function mockAll({ page, setupAPI }: MockParams) { - return await test.test.step('Execute all mocks', async () => { - const api = await mockApi({ page, setupAPI }) +export function mockAll({ page, setupAPI }: MockParams) { + return new LoginPageActions(page).step('Execute all mocks', async () => { + await mockApi({ 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. */ // This syntax is required for Playwright to work properly. // 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 () => { - const mocks = await mockAll({ page, setupAPI }) + const api = await mockApi({ page, setupAPI }) + await mockDate({ page, setupAPI }) await login({ page, setupAPI }) - return { ...mocks, pageActions: new DrivePageActions(page) } + return api }) } diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/BaseActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/BaseActions.ts index fa7bd482115..8f9f59acf9a 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/BaseActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/BaseActions.ts @@ -1,6 +1,10 @@ /** @file The base class from which all `Actions` classes are derived. */ import * as test from '@playwright/test' +import type * as inputBindings from '#/utilities/inputBindings' + +import * as actions from '../actions' + // ==================== // === PageCallback === // ==================== @@ -29,13 +33,19 @@ export interface LocatorCallback { * * [`thenable`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise#thenables */ -export default class BaseActions implements PromiseLike { +export default class BaseActions implements Promise { /** Create a {@link BaseActions}. */ constructor( protected readonly page: test.Page, 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` * on all other platforms. */ static press(page: test.Page, keyOrShortcut: string): Promise { @@ -55,7 +65,6 @@ export default class BaseActions implements PromiseLike { } }) } - /** Proxies the `then` method of the internal {@link Promise}. */ async then( // The following types are copied almost verbatim from the type definitions for `Promise`. @@ -72,10 +81,17 @@ export default class BaseActions implements PromiseLike { * to treat this class as a {@link Promise}. */ // The following types are copied almost verbatim from the type definitions for `Promise`. // eslint-disable-next-line no-restricted-syntax - async catch(onrejected?: ((reason: unknown) => T) | null | undefined) { + async catch(onrejected?: ((reason: unknown) => PromiseLike | T) | null | undefined) { 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 { + await this.promise.finally(onfinally) + } + /** Return a {@link BaseActions} with the same {@link Promise} but a different type. */ into< T extends new (page: test.Page, promise: Promise, ...args: Args) => InstanceType, @@ -98,13 +114,45 @@ export default class BaseActions implements PromiseLike { } /** 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))) } /** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control` * on all other platforms. */ - press(keyOrShortcut: string): this { + press(keyOrShortcut: inputBindings.AutocompleteKeybind) { return this.do(page => BaseActions.press(page, keyOrShortcut)) } + + /** Perform actions until a predicate passes. */ + retry( + callback: (actions: this) => this, + predicate: (page: test.Page) => Promise, + 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(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)) + }) + } } diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts index 5c1ed354469..fa4fd0899c9 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/DrivePageActions.ts @@ -10,6 +10,22 @@ import NewDataLinkModalActions from './NewDataLinkModalActions' import PageActions from './PageActions' 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 === // ======================== @@ -80,47 +96,111 @@ export default class DrivePageActions extends PageActions { }, /** Click to select a specific row. */ clickRow(index: number) { - return self.step('Click drive table row', page => - actions - .locateAssetRows(page) - .nth(index) - .click({ position: actions.ASSET_ROW_SAFE_POSITION }) + return self.step(`Click drive table row #${index}`, page => + 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 * assets when right clicking on a selected asset when multiple assets are selected. */ rightClickRow(index: number) { - return self.step('Click drive table row', page => - actions - .locateAssetRows(page) + return self.step(`Right click drive table row #${index}`, page => + locateAssetRows(page) .nth(index) .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. */ withRows(callback: baseActions.LocatorCallback) { 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 * placeholder row displayed when there are no assets to show. */ expectPlaceholderRow() { return self.step('Expect placeholder row', async page => { - const assetRows = actions.locateAssetRows(page) - await test.expect(assetRows).toHaveCount(1) - await test.expect(assetRows).toHaveText(/You have no files/) + const rows = locateAssetRows(page) + await test.expect(rows).toHaveCount(1) + 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 * placeholder row displayed when there are no assets in Trash. */ expectTrashPlaceholderRow() { return self.step('Expect trash placeholder row', async page => { - const assetRows = actions.locateAssetRows(page) - await test.expect(assetRows).toHaveCount(1) - await test.expect(assetRows).toHaveText(/Your trash is empty/) + const rows = locateAssetRows(page) + await test.expect(rows).toHaveCount(1) + 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. */ withAssetPanel(callback: baseActions.LocatorCallback) { return this.step('Interact with asset panel', async page => { diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/contextMenuActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/contextMenuActions.ts index de800001d77..ae43c2ead38 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/contextMenuActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/contextMenuActions.ts @@ -1,6 +1,7 @@ /** @file Actions for the context menu. */ import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' +import EditorPageActions from './EditorPageActions' // ========================== // === ContextMenuActions === @@ -19,9 +20,11 @@ export interface ContextMenuActions { readonly share: () => T readonly label: () => T readonly duplicate: () => T + readonly duplicateProject: () => EditorPageActions readonly copy: () => T readonly cut: () => T readonly paste: () => T + readonly copyAsPath: () => T readonly download: () => T readonly uploadFiles: () => T readonly newFolder: () => T @@ -91,9 +94,13 @@ export function contextMenuActions( step('Duplicate (context menu)', page => 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: () => step('Copy (context menu)', page => - page.getByRole('button', { name: 'Copy' }).getByText('Copy').click() + page.getByRole('button', { name: 'Copy' }).getByText('Copy', { exact: true }).click() ), cut: () => step('Cut (context menu)', page => @@ -103,6 +110,10 @@ export function contextMenuActions( step('Paste (context menu)', page => 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: () => step('Download (context menu)', page => page.getByRole('button', { name: 'Download' }).getByText('Download').click() diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/goToPageActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/goToPageActions.ts index ad428cbcc2a..9c8231668a3 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/goToPageActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/goToPageActions.ts @@ -34,7 +34,7 @@ export function goToPageActions( ).into(DrivePageActions), editor: () => step('Go to "Spatial Analysis" page', page => - page.getByRole('button').and(page.getByLabel('Spatial Analysis')).click() + page.getByTestId('editor-tab-button').click() ).into(EditorPageActions), settings: () => step('Go to "settings" page', page => BaseActions.press(page, 'Mod+,')).into( diff --git a/app/ide-desktop/lib/dashboard/e2e/actions/userMenuActions.ts b/app/ide-desktop/lib/dashboard/e2e/actions/userMenuActions.ts index bd48a99f908..11d7878881d 100644 --- a/app/ide-desktop/lib/dashboard/e2e/actions/userMenuActions.ts +++ b/app/ide-desktop/lib/dashboard/e2e/actions/userMenuActions.ts @@ -4,6 +4,7 @@ import type * as test from 'playwright/test' import type * as baseActions from './BaseActions' import type BaseActions from './BaseActions' import LoginPageActions from './LoginPageActions' +import SettingsPageActions from './SettingsPageActions' // ======================= // === UserMenuActions === @@ -12,6 +13,7 @@ import LoginPageActions from './LoginPageActions' /** Actions for the user menu. */ export interface UserMenuActions { readonly downloadApp: (callback: (download: test.Download) => Promise | void) => T + readonly settings: () => SettingsPageActions readonly logout: () => LoginPageActions readonly goToLoginPage: () => LoginPageActions } @@ -25,13 +27,16 @@ export function userMenuActions( step: (name: string, callback: baseActions.PageCallback) => T ): UserMenuActions { return { - downloadApp: (callback: (download: test.Download) => Promise | void) => { - return step('Download app (user menu)', async page => { + downloadApp: (callback: (download: test.Download) => Promise | void) => + step('Download app (user menu)', async page => { const downloadPromise = page.waitForEvent('download') await page.getByRole('button', { name: 'Download App' }).getByText('Download App').click() 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: () => step('Logout (user menu)', page => page.getByRole('button', { name: 'Logout' }).getByText('Logout').click() diff --git a/app/ide-desktop/lib/dashboard/e2e/api.ts b/app/ide-desktop/lib/dashboard/e2e/api.ts index d0bd6448cce..1756b528b95 100644 --- a/app/ide-desktop/lib/dashboard/e2e/api.ts +++ b/app/ide-desktop/lib/dashboard/e2e/api.ts @@ -38,7 +38,7 @@ const BASE_URL = 'https://mock/' // =============== /** Parameters for {@link mockApi}. */ -interface MockParams { +export interface MockParams { readonly page: test.Page readonly setupAPI?: SetupAPI | null | undefined } @@ -51,10 +51,17 @@ export interface SetupAPI { (api: Awaited>): Promise | void } +/** The return type of {@link mockApi}. */ +export interface MockApi extends Awaited> {} + +// 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 = mockApiInternal + /** Add route handlers for the mock API to a page. */ // This syntax is required for Playwright to work properly. // 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 const defaultEmail = 'email@example.com' as backend.EmailAddress const defaultUsername = 'user name' @@ -148,7 +155,7 @@ export async function mockApi({ page, setupAPI }: MockParams) { type: backend.AssetType.project, id: backend.ProjectId('project-' + uniqueString.uniqueString()), projectState: { - type: backend.ProjectState.opened, + type: backend.ProjectState.closed, volumeId: '', }, title, @@ -479,26 +486,27 @@ export async function mockApi({ page, setupAPI }: MockParams) { // === Endpoints with dummy implementations === await get(remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID), (_route, request) => { - const projectId = request.url().match(/[/]projects[/](.+?)[/]copy/)?.[1] ?? '' - return { - organizationId: defaultOrganizationId, - projectId: backend.ProjectId(projectId), - name: 'example project name', - state: { - type: backend.ProjectState.opened, - volumeId: '', - openedBy: defaultEmail, - }, - packageName: 'Project_root', - // eslint-disable-next-line @typescript-eslint/naming-convention - ide_version: null, - // eslint-disable-next-line @typescript-eslint/naming-convention - engine_version: { - value: '2023.2.1-nightly.2023.9.29', - lifecycle: backend.VersionLifecycle.development, - }, - address: backend.Address('ws://example.com/'), - } satisfies backend.ProjectRaw + const projectId = backend.ProjectId(request.url().match(/[/]projects[/]([^?/]+)/)?.[1] ?? '') + const project = assetMap.get(projectId) + if (!project?.projectState) { + throw new Error('Attempting to get a project that does not exist.') + } else { + return { + organizationId: defaultOrganizationId, + projectId: projectId, + name: 'example project name', + state: project.projectState, + packageName: 'Project_root', + // eslint-disable-next-line @typescript-eslint/naming-convention + ide_version: null, + // eslint-disable-next-line @typescript-eslint/naming-convention + engine_version: { + value: '2023.2.1-nightly.2023.9.29', + lifecycle: backend.VersionLifecycle.development, + }, + address: backend.Address('ws://localhost/'), + } satisfies backend.ProjectRaw + } }) // === Endpoints returning `void` === @@ -508,7 +516,7 @@ export async function mockApi({ page, setupAPI }: MockParams) { interface Body { 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 const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : 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 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 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 delete_(remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => { @@ -774,7 +792,7 @@ export async function mockApi({ page, setupAPI }: MockParams) { organizationId: defaultOrganizationId, packageName: 'Project_root', projectId: id, - state: { type: backend.ProjectState.opened, volumeId: '' }, + state: { type: backend.ProjectState.closed, volumeId: '' }, } addProject(title, { description: null, diff --git a/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts index 65b1d6d71ce..897766c718d 100644 --- a/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts @@ -23,59 +23,55 @@ const EMAIL = 'baz.quux@email.com' // ============= test.test('open and close asset panel', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - .createFolder() - .driveTable.clickRow(0) - .withAssetPanel(async assetPanel => { - await actions.expectNotOnScreen(assetPanel) - }) - .toggleAssetPanel() - .withAssetPanel(async assetPanel => { - await actions.expectOnScreen(assetPanel) - }) - .toggleAssetPanel() - .withAssetPanel(async assetPanel => { - await actions.expectNotOnScreen(assetPanel) - }) - ) + actions + .mockAllAndLogin({ page }) + .createFolder() + .driveTable.clickRow(0) + .withAssetPanel(async assetPanel => { + await actions.expectNotOnScreen(assetPanel) + }) + .toggleAssetPanel() + .withAssetPanel(async assetPanel => { + await actions.expectOnScreen(assetPanel) + }) + .toggleAssetPanel() + .withAssetPanel(async assetPanel => { + await actions.expectNotOnScreen(assetPanel) + }) ) test.test('asset panel contents', ({ page }) => - actions.mockAll({ page }).then( - async ({ pageActions, api }) => - await pageActions - .do(() => { - const { defaultOrganizationId, defaultUserId } = api - api.addProject('project', { - description: DESCRIPTION, - permissions: [ - { - permission: permissions.PermissionAction.own, - user: { - organizationId: defaultOrganizationId, - // Using the default ID causes the asset to have a dynamic username. - userId: backend.UserId(defaultUserId + '2'), - name: USERNAME, - email: backend.EmailAddress(EMAIL), - }, + actions + .mockAll({ + page, + setupAPI: api => { + const { defaultOrganizationId, defaultUserId } = api + api.addProject('project', { + description: DESCRIPTION, + permissions: [ + { + permission: permissions.PermissionAction.own, + user: { + organizationId: defaultOrganizationId, + // Using the default ID causes the asset to have a dynamic username. + userId: backend.UserId(defaultUserId + '2'), + name: USERNAME, + email: backend.EmailAddress(EMAIL), }, - ], - }) + }, + ], }) - .login() - .do(async thePage => { - await actions.passTermsAndConditionsDialog({ page: thePage }) - }) - .driveTable.clickRow(0) - .toggleAssetPanel() - .do(async () => { - await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) - // `getByText` is required so that this assertion works if there are multiple permissions. - await test - .expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)) - .toBeVisible() - }) - ) + }, + }) + .login() + .do(async thePage => { + await actions.passTermsAndConditionsDialog({ page: thePage }) + }) + .driveTable.clickRow(0) + .toggleAssetPanel() + .do(async () => { + await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) + // `getByText` is required so that this assertion works if there are multiple permissions. + await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible() + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts index 155eb9d14ae..31c67a51b03 100644 --- a/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts @@ -32,17 +32,20 @@ test.test('tags', 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 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() for (const label of await labels.all()) { diff --git a/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts b/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts index c11851c169b..36349a0e460 100644 --- a/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/assetsTableFeatures.spec.ts @@ -5,42 +5,51 @@ import * as actions from './actions' const PASS_TIMEOUT = 5_000 -test.test('extra columns should stick to right side of assets table', async ({ page }) => { - await actions.mockAllAndLogin({ page }) - await actions.locateAccessedByProjectsColumnToggle(page).click() - await actions.locateAccessedDataColumnToggle(page).click() - await actions.locateAssetsTable(page).evaluate(element => { - let scrollableParent: HTMLElement | SVGElement | null = element - while ( - scrollableParent != null && - scrollableParent.scrollWidth <= scrollableParent.clientWidth - ) { - scrollableParent = scrollableParent.parentElement - } - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) - }) - const extraColumns = actions.locateExtraColumns(page) - 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) +test.test('extra columns should stick to right side of assets table', ({ page }) => + actions + .mockAllAndLogin({ page }) + .driveTable.toggleColumn.accessedByProjects() + .driveTable.toggleColumn.accessedData() + .withAssetsTable(async table => { + await table.evaluate(element => { + let scrollableParent: HTMLElement | SVGElement | null = element + while ( + scrollableParent != null && + scrollableParent.scrollWidth <= scrollableParent.clientWidth + ) { + scrollableParent = scrollableParent.parentElement + } + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + scrollableParent?.scrollTo({ left: 999999, behavior: 'instant' }) + }) }) - .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 }) => { - const { api } = await actions.mockAllAndLogin({ page }) - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - for (let i = 0; i < 100; i += 1) { - api.addFile('a') - } + await actions.mockAllAndLogin({ + page, + setupAPI: api => { + // 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.locateAccessedByProjectsColumnToggle(page).click() @@ -78,19 +87,23 @@ test.test('extra columns should stick to top of scroll container', async ({ page .toPass({ timeout: PASS_TIMEOUT }) }) -test.test('can drop onto root directory dropzone', async ({ page }) => { - const { api } = await actions.mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) - const asset = api.addDirectory('a') - api.addFile('b', { parentId: asset.id }) - await actions.reload({ page }) - - await assetRows.nth(0).dblclick() - 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) - await assetRows.nth(1).dragTo(actions.locateRootDirectoryDropzone(page), { force: true }) - const firstLeft = await actions.getAssetRowLeftPx(assetRows.nth(0)) - const secondLeft = await actions.getAssetRowLeftPx(assetRows.nth(1)) - test.expect(firstLeft, 'siblings have same indentation').toEqual(secondLeft) -}) +test.test('can drop onto root directory dropzone', ({ page }) => + actions + .mockAllAndLogin({ page }) + .createFolder() + .uploadFile('b', 'testing') + .driveTable.doubleClickRow(0) + .driveTable.withRows(async rows => { + 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) + }) + .driveTable.dragRow(1, actions.locateRootDirectoryDropzone(page)) + .driveTable.withRows(async rows => { + const firstLeft = await actions.getAssetRowLeftPx(rows.nth(0)) + // 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) + }) +) diff --git a/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts b/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts index 2b7f0d9d4ed..bf76e016703 100644 --- a/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/copy.spec.ts @@ -2,220 +2,191 @@ import * as test from '@playwright/test' import * as actions from './actions' - -// ===================== -// === 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/) - }) -} +import EditorPageActions from './actions/EditorPageActions' // ============= // === Tests === // ============= -test.test.beforeEach(({ page }) => { - return actions.mockAllAndLogin({ page }) -}) - -test.test('copy', 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 locateCopyButton(page).click() - // Assets: [0: Folder 2 , 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 2, 1: Folder 1, 2: Folder 2 (copy) ] - 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 , 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) ] - 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 , 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 ] - 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 ] - 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 , 1: Folder 1] - await actions.clickAssetRow(assetRows.nth(1)) - await actions.press(page, 'Mod+V') - // Assets: [0: Folder 1, 1: Folder 2 ] - 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) +test.test('copy', ({ page }) => + actions + .mockAllAndLogin({ page }) + // Assets: [0: Folder 1] + .createFolder() + // Assets: [0: Folder 2, 1: Folder 1] + .createFolder() + .driveTable.rightClickRow(0) + // Assets: [0: Folder 2 , 1: Folder 1] + .contextMenu.copy() + .driveTable.rightClickRow(1) + // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] + .contextMenu.paste() + .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) }) - .toPass() -}) +) -test.test('duplicate', async ({ page }) => { - const assetRows = actions.locateAssetRows(page) +test.test('copy (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 , 1: Folder 1] + .press('Mod+C') + .driveTable.clickRow(1) + // Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) ] + .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() - // Assets: [0: Folder 1] - await assetRows.nth(0).click({ button: 'right' }) - await test.expect(actions.locateContextMenus(page)).toBeVisible() - await locateDuplicateButton(page).click() - // Assets: [0: Folder 1 (copy), 1: Folder 1] - await test.expect(assetRows).toHaveCount(2) - await test.expect(actions.locateContextMenus(page)).not.toBeVisible() - await test.expect(assetRows.nth(0)).toBeVisible() - await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/) -}) +test.test('move', ({ page }) => + actions + .mockAllAndLogin({ page }) + // Assets: [0: Folder 1] + .createFolder() + // Assets: [0: Folder 2, 1: Folder 1] + .createFolder() + .driveTable.rightClickRow(0) + // Assets: [0: Folder 2 , 1: Folder 1] + .contextMenu.cut() + .driveTable.rightClickRow(1) + // Assets: [0: Folder 1, 1: Folder 2 ] + .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 }) => { - const assetRows = actions.locateAssetRows(page) +test.test('move (drag)', ({ page }) => + actions + .mockAllAndLogin({ page }) + // Assets: [0: Folder 1] + .createFolder() + // Assets: [0: Folder 2, 1: Folder 1] + .createFolder() + // Assets: [0: Folder 1, 1: Folder 2 ] + .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() - // Assets: [0: Folder 1] - await actions.clickAssetRow(assetRows.nth(0)) - await actions.press(page, 'Mod+D') - // Assets: [0: Folder 1 (copy), 1: Folder 1] - await test.expect(assetRows).toHaveCount(2) - await test.expect(assetRows.nth(0)).toBeVisible() - await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/) -}) +test.test('move to trash', ({ page }) => + actions + .mockAllAndLogin({ page }) + // Assets: [0: Folder 1] + .createFolder() + // Assets: [0: Folder 2, 1: Folder 1] + .createFolder() + // 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 , 1: Folder 1] + .press('Mod+X') + .driveTable.clickRow(1) + // Assets: [0: Folder 1, 1: Folder 2 ] + .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[)]/) + }) +) diff --git a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts index fda606496c2..7cce2668a63 100644 --- a/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts @@ -21,45 +21,43 @@ const SECRET_VALUE = 'a secret value' // ============= test.test('create folder', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions.createFolder().driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/) - }) - ) + actions + .mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + await test.expect(rows.nth(0)).toBeVisible() + await test.expect(rows.nth(0)).toHaveText(/^New Folder 1/) + }) ) test.test('create project', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - .newEmptyProject() - .do(thePage => test.expect(actions.locateEditor(thePage)).toBeAttached()) - .goToPage.drive() - .driveTable.withRows(rows => test.expect(rows).toHaveCount(1)) - ) + actions + .mockAllAndLogin({ page }) + .newEmptyProject() + .do(thePage => test.expect(actions.locateEditor(thePage)).toBeAttached()) + .goToPage.drive() + .driveTable.withRows(rows => test.expect(rows).toHaveCount(1)) ) test.test('upload file', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions.uploadFile(FILE_NAME, FILE_CONTENTS).driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) - }) - ) + actions + .mockAllAndLogin({ page }) + .uploadFile(FILE_NAME, FILE_CONTENTS) + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + await test.expect(rows.nth(0)).toBeVisible() + await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + FILE_NAME)) + }) ) test.test('create secret', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions.createSecret(SECRET_NAME, SECRET_VALUE).driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - await test.expect(rows.nth(0)).toBeVisible() - await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) - }) - ) + actions + .mockAllAndLogin({ page }) + .createSecret(SECRET_NAME, SECRET_VALUE) + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + await test.expect(rows.nth(0)).toBeVisible() + await test.expect(rows.nth(0)).toHaveText(new RegExp('^' + SECRET_NAME)) + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/dataLinkEditor.spec.ts b/app/ide-desktop/lib/dashboard/e2e/dataLinkEditor.spec.ts index 1aafc4b6920..1d06ed41a4c 100644 --- a/app/ide-desktop/lib/dashboard/e2e/dataLinkEditor.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/dataLinkEditor.spec.ts @@ -6,10 +6,10 @@ import * as actions from './actions' const DATA_LINK_NAME = 'a data link' test.test('data link editor', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions.openDataLinkModal().withNameInput(async input => { - await input.fill(DATA_LINK_NAME) - }) - ) + actions + .mockAllAndLogin({ page }) + .openDataLinkModal() + .withNameInput(async input => { + await input.fill(DATA_LINK_NAME) + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts b/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts index add0b4f7fd7..f2c36002d87 100644 --- a/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/delete.spec.ts @@ -4,51 +4,47 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('delete and restore', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - .createFolder() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - .driveTable.rightClickRow(0) - .contextMenu.moveToTrash() - .driveTable.expectPlaceholderRow() - .goToCategory.trash() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - .driveTable.rightClickRow(0) - .contextMenu.restoreFromTrash() - .driveTable.expectTrashPlaceholderRow() - .goToCategory.cloud() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - ) + actions + .mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) + .driveTable.rightClickRow(0) + .contextMenu.moveToTrash() + .driveTable.expectPlaceholderRow() + .goToCategory.trash() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) + .driveTable.rightClickRow(0) + .contextMenu.restoreFromTrash() + .driveTable.expectTrashPlaceholderRow() + .goToCategory.cloud() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) ) test.test('delete and restore (keyboard)', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - .createFolder() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - .driveTable.clickRow(0) - .press('Delete') - .driveTable.expectPlaceholderRow() - .goToCategory.trash() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - .driveTable.clickRow(0) - .press('Mod+R') - .driveTable.expectTrashPlaceholderRow() - .goToCategory.cloud() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - ) + actions + .mockAllAndLogin({ page }) + .createFolder() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) + .driveTable.clickRow(0) + .press('Delete') + .driveTable.expectPlaceholderRow() + .goToCategory.trash() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) + .driveTable.clickRow(0) + .press('Mod+R') + .driveTable.expectTrashPlaceholderRow() + .goToCategory.cloud() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts b/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts index 18a428cad9a..71d9776e857 100644 --- a/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts @@ -4,49 +4,41 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('drive view', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - .withDriveView(async view => { - await test.expect(view).toBeVisible() - }) - .driveTable.expectPlaceholderRow() - .newEmptyProject() - .do(async () => { - await test.expect(actions.locateEditor(page)).toBeAttached() - }) - .goToPage.drive() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(1) - }) - .do(async () => { - await test.expect(actions.locateAssetsTable(page)).toBeVisible() - }) - .newEmptyProject() - .do(async () => { - await test.expect(actions.locateEditor(page)).toBeAttached() - }) - .goToPage.drive() - .driveTable.withRows(async rows => { - await test.expect(rows).toHaveCount(2) - }) - // 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 - // new project is created. - .driveTable.withRows(async rows => { - 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. + actions + .mockAllAndLogin({ page }) + .withDriveView(async view => { + await test.expect(view).toBeVisible() + }) + .driveTable.expectPlaceholderRow() + .newEmptyProject() + .do(async () => { + await test.expect(actions.locateEditor(page)).toBeAttached() + }) + .goToPage.drive() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) + .do(async () => { + await test.expect(actions.locateAssetsTable(page)).toBeVisible() + }) + .newEmptyProject() + .do(async () => { + await test.expect(actions.locateEditor(page)).toBeAttached() + }) + .goToPage.drive() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(2) + }) + // 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 + // new project is created. + .driveTable.withRows(async rows => { + await actions.locateStopProjectButton(rows.nth(0)).click() + }) // Project context menu - // .driveTable.rightClickRow(0) - // .withContextMenus(async menus => { - // // actions.locateContextMenus(page) - // await test.expect(menus).toBeVisible() - // }) - // .contextMenu.moveToTrash() - // .driveTable.withRows(async rows => { - // await test.expect(rows).toHaveCount(1) - // }) - ) + .driveTable.rightClickRow(0) + .contextMenu.moveToTrash() + .driveTable.withRows(async rows => { + await test.expect(rows).toHaveCount(1) + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts b/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts index a2aab19ae1e..1594b05b379 100644 --- a/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/labels.spec.ts @@ -6,21 +6,25 @@ import * as backend from '#/services/Backend' import * as actions from './actions' test.test('drag labels onto single row', async ({ page }) => { - const { api } = await actions.mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) 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) - 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 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 }) => { - const { api } = await actions.mockAllAndLogin({ page }) - const assetRows = actions.locateAssetRows(page) 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) - 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 actions.clickAssetRow(assetRows.nth(0)) diff --git a/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts b/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts index 14dd2ba7780..a363c43fcc5 100644 --- a/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts @@ -8,20 +8,18 @@ import * as actions from './actions' // ============= test.test('login and logout', ({ page }) => - actions.mockAll({ page }).then( - async ({ pageActions }) => - await pageActions - .login() - .do(async thePage => { - await actions.passTermsAndConditionsDialog({ page: thePage }) - await test.expect(actions.locateDriveView(thePage)).toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible() - }) - .openUserMenu() - .userMenu.logout() - .do(async thePage => { - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - await test.expect(actions.locateLoginButton(thePage)).toBeVisible() - }) - ) + actions + .mockAll({ page }) + .login() + .do(async thePage => { + await actions.passTermsAndConditionsDialog({ page: thePage }) + await test.expect(actions.locateDriveView(thePage)).toBeVisible() + await test.expect(actions.locateLoginButton(thePage)).not.toBeVisible() + }) + .openUserMenu() + .userMenu.logout() + .do(async thePage => { + await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() + await test.expect(actions.locateLoginButton(thePage)).toBeVisible() + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts b/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts index 8d8504e9b54..94ce8107f7c 100644 --- a/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/membersSettings.spec.ts @@ -4,7 +4,7 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('members settings', async ({ page }) => { - const { api } = await actions.mockAllAndLogin({ page }) + const api = await actions.mockAllAndLoginAndExposeAPI({ page }) const localActions = actions.settings.members // Setup diff --git a/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts b/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts index e2db0a1d98e..ff3242000b2 100644 --- a/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/organizationSettings.spec.ts @@ -4,7 +4,7 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('organization settings', async ({ page }) => { - const { api } = await actions.mockAllAndLogin({ page }) + const api = await actions.mockAllAndLoginAndExposeAPI({ page }) const localActions = actions.settings.organization // Setup @@ -76,7 +76,7 @@ test.test('organization settings', 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 await localActions.go(page) diff --git a/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts b/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts index 90dfa8799d3..546100e647d 100644 --- a/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts @@ -4,20 +4,18 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('page switcher', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - // Create a new project so that the editor page can be switched to. - .newEmptyProject() - .goToPage.drive() - .do(async thePage => { - await test.expect(actions.locateDriveView(thePage)).toBeVisible() - await test.expect(actions.locateEditor(thePage)).not.toBeVisible() - }) - .goToPage.editor() - .do(async thePage => { - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - await test.expect(actions.locateEditor(thePage)).toBeVisible() - }) - ) + actions + .mockAllAndLogin({ page }) + // Create a new project so that the editor page can be switched to. + .newEmptyProject() + .goToPage.drive() + .do(async thePage => { + await test.expect(actions.locateDriveView(thePage)).toBeVisible() + await test.expect(actions.locateEditor(thePage)).not.toBeVisible() + }) + .goToPage.editor() + .do(async thePage => { + await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() + await test.expect(actions.locateEditor(thePage)).toBeVisible() + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts b/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts index b2fafa5200d..223d76999a7 100644 --- a/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/signUp.spec.ts @@ -2,6 +2,7 @@ import * as test from '@playwright/test' import * as actions from './actions' +import type * as api from './api' // ================= // === Constants === @@ -75,46 +76,48 @@ test.test('sign up without organization id', async ({ page }) => { .toBe(api.defaultOrganizationId) }) -test.test('sign up flow', ({ page }) => - actions.mockAll({ page }).then( - async ({ pageActions, api }) => - await pageActions - .do(() => { - api.setCurrentUser(null) +test.test('sign up flow', ({ page }) => { + let api!: api.MockApi + return actions + .mockAll({ + page, + setupAPI: theApi => { + api = theApi + theApi.setCurrentUser(null) - // These values should be different, otherwise the email and name may come from the defaults. - test.expect(EMAIL).not.toStrictEqual(api.defaultEmail) - test.expect(NAME).not.toStrictEqual(api.defaultName) - }) - .loginAsNewUser(EMAIL, actions.VALID_PASSWORD) - .do(async thePage => { - await actions.passTermsAndConditionsDialog({ page: thePage }) - }) - .setUsername(NAME) - .do(async thePage => { - await test.expect(actions.locateUpgradeButton(thePage)).toBeVisible() - await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() - }) - .do(() => { - // Logged in, and account enabled - const currentUser = api.currentUser() - test.expect(currentUser).toBeDefined() - if (currentUser != null) { - // This is required because `UserOrOrganization` is `readonly`. - // 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() - .login(EMAIL, actions.VALID_PASSWORD) - .do(async () => { - 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) - test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME) - }) - ) -) + // These values should be different, otherwise the email and name may come from the defaults. + test.expect(EMAIL).not.toStrictEqual(theApi.defaultEmail) + test.expect(NAME).not.toStrictEqual(theApi.defaultName) + }, + }) + .loginAsNewUser(EMAIL, actions.VALID_PASSWORD) + .do(async thePage => { + await actions.passTermsAndConditionsDialog({ page: thePage }) + }) + .setUsername(NAME) + .do(async thePage => { + await test.expect(actions.locateUpgradeButton(thePage)).toBeVisible() + await test.expect(actions.locateDriveView(thePage)).not.toBeVisible() + }) + .do(() => { + // Logged in, and account enabled + const currentUser = api.currentUser() + test.expect(currentUser).toBeDefined() + if (currentUser != null) { + // This is required because `UserOrOrganization` is `readonly`. + // 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() + .login(EMAIL, actions.VALID_PASSWORD) + .do(async () => { + 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) + test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME) + }) +}) diff --git a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts index c03ebe06c57..6df43f1337d 100644 --- a/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/sort.spec.ts @@ -20,35 +20,39 @@ const MIN_MS = 60_000 // ============= 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 nameHeading = actions.locateNameColumnHeading(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 }) // By default, assets should be grouped by type. diff --git a/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts b/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts index 53c55b40ec2..598bb9f22bf 100644 --- a/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/startModal.spec.ts @@ -4,14 +4,12 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('create project from template', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions - .openStartModal() - .createProjectFromTemplate(0) - .do(async thePage => { - await test.expect(actions.locateEditor(thePage)).toBeAttached() - await test.expect(actions.locateSamples(page).first()).not.toBeVisible() - }) - ) + actions + .mockAllAndLogin({ page }) + .openStartModal() + .createProjectFromTemplate(0) + .do(async thePage => { + await test.expect(actions.locateEditor(thePage)).toBeAttached() + await test.expect(actions.locateSamples(page).first()).not.toBeVisible() + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts b/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts index b46c0701314..48b5cdb1a7f 100644 --- a/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts @@ -4,20 +4,20 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('user menu', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions.openUserMenu().do(async thePage => { - await test.expect(actions.locateUserMenu(thePage)).toBeVisible() - }) - ) + actions + .mockAllAndLogin({ page }) + .openUserMenu() + .do(async thePage => { + await test.expect(actions.locateUserMenu(thePage)).toBeVisible() + }) ) test.test('download app', ({ page }) => - actions.mockAllAndLogin({ page }).then( - async ({ pageActions }) => - await pageActions.openUserMenu().userMenu.downloadApp(async download => { - await download.cancel() - test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/) - }) - ) + actions + .mockAllAndLogin({ page }) + .openUserMenu() + .userMenu.downloadApp(async download => { + await download.cancel() + test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/) + }) ) diff --git a/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts b/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts index 64965f525c3..dc79f0a4091 100644 --- a/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts +++ b/app/ide-desktop/lib/dashboard/e2e/userSettings.spec.ts @@ -4,7 +4,7 @@ import * as test from '@playwright/test' import * as actions from './actions' test.test('user settings', async ({ page }) => { - const { api } = await actions.mockAllAndLogin({ page }) + const api = await actions.mockAllAndLoginAndExposeAPI({ page }) const localActions = actions.settings.userAccount 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 }) => { - const { api } = await actions.mockAllAndLogin({ page }) + const api = await actions.mockAllAndLoginAndExposeAPI({ page }) const localActions = actions.settings.changePassword await localActions.go(page) @@ -79,7 +79,7 @@ test.test('change password form', 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 await localActions.go(page) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx index 65356d4d186..74590b6b607 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/AssetRow.tsx @@ -15,8 +15,6 @@ import * as textProvider from '#/providers/TextProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' -import type * as dashboard from '#/pages/dashboard/Dashboard' - import AssetContextMenu from '#/layouts/AssetContextMenu' import type * as assetsTable from '#/layouts/AssetsTable' import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' @@ -92,24 +90,14 @@ export interface AssetRowProps props: AssetRowInnerProps, event: React.MouseEvent ) => void - readonly doOpenProject: (project: dashboard.Project) => void - readonly doCloseProject: (project: dashboard.Project) => void readonly updateAssetRef: React.Ref<(asset: backendModule.AnyAsset) => void> } /** A row containing an {@link backendModule.AnyAsset}. */ export default function AssetRow(props: AssetRowProps) { - const { - item: rawItem, - hidden: hiddenRaw, - selected, - isSoleSelected, - isKeyboardSelected, - isOpened, - updateAssetRef, - } = props + const { selected, isSoleSelected, isKeyboardSelected, isOpened } = 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 { setIsAssetPanelTemporarilyVisible, scrollContainerRef, rootDirectoryId, backend } = state const { visibilities } = state @@ -164,8 +152,8 @@ export default function AssetRow(props: AssetRowProps) { }, [rawItem]) React.useEffect(() => { - // Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to avoid - // re-rendering the parent. + // Mutation is HIGHLY INADVISABLE in React, however it is useful here as we want to update the + // parent's state while avoiding re-rendering the parent. rawItem.item = asset }, [asset, rawItem]) const setAsset = setAssetHooks.useSetAsset(asset, setItem) @@ -242,20 +230,6 @@ export default function AssetRow(props: AssetRowProps) { oldItem.with({ directoryKey: nonNullNewParentKey, directoryId: nonNullNewParentId }) ) 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 if (!isCloud) { 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. - // eslint-disable-next-line no-restricted-syntax - id: newId as never, - parentId: nonNullNewParentId, - projectState: newProjectState, - }) + // This is SAFE as the type of `newId` is not changed from its original type. + // eslint-disable-next-line no-restricted-syntax + const newAsset = object.merge(asset, { id: newId as never, parentId: nonNullNewParentId }) dispatchAssetListEvent({ type: AssetListEventType.move, newParentKey: nonNullNewParentKey, @@ -299,11 +269,7 @@ export default function AssetRow(props: AssetRowProps) { setAsset(newAsset) await updateAssetMutate([ asset.id, - { - parentDirectoryId: newParentId ?? rootDirectoryId, - description: null, - ...(asset.projectState?.path == null ? {} : { projectPath: asset.projectState.path }), - }, + { parentDirectoryId: newParentId ?? rootDirectoryId, description: null }, asset.title, ]) } catch (error) { @@ -381,11 +347,7 @@ export default function AssetRow(props: AssetRowProps) { // Ignored. The project was already closed. } } - await deleteAssetMutate([ - asset.id, - { force: forever, parentId: asset.parentId }, - asset.title, - ]) + await deleteAssetMutate([asset.id, { force: forever }, asset.title]) dispatchAssetListEvent({ type: AssetListEventType.delete, key: item.key }) } catch (error) { setInsertionVisibility(Visibility.visible) @@ -524,7 +486,7 @@ export default function AssetRow(props: AssetRowProps) { asset.title, ]) if (details.url != null) { - download.download(details.url, asset.title) + await backend.download(details.url, asset.title) } else { const error: unknown = getText('projectHasNoSourceFilesPhrase') toastAndLog('downloadProjectError', error, asset.title) @@ -541,7 +503,7 @@ export default function AssetRow(props: AssetRowProps) { asset.title, ]) if (details.url != null) { - download.download(details.url, asset.title) + await backend.download(details.url, asset.title) } else { const error: unknown = getText('fileNotFoundPhrase') toastAndLog('downloadFileError', error, asset.title) @@ -575,9 +537,11 @@ export default function AssetRow(props: AssetRowProps) { } } else { if (asset.type === backendModule.AssetType.project) { + const projectsDirectory = localBackend.extractTypeAndId(asset.parentId).id const uuid = localBackend.extractTypeAndId(asset.id).id - download.download( - `./api/project-manager/projects/${uuid}/enso-project`, + const queryString = new URLSearchParams({ projectsDirectory }).toString() + await backend.download( + `./api/project-manager/projects/${uuid}/enso-project?${queryString}`, `${asset.title}.enso-project` ) } @@ -910,8 +874,6 @@ export default function AssetRow(props: AssetRowProps) { rowState={rowState} setRowState={setRowState} isEditable={state.category !== Category.trash} - doOpenProject={doOpenProject} - doCloseProject={doCloseProject} /> ) diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx index 2fbec67c5b4..ff109a492d3 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/FileNameColumn.tsx @@ -51,7 +51,12 @@ export default function FileNameColumn(props: FileNameColumnProps) { const isCloud = backend.type === backendModule.BackendType.remote 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) => { if (isEditable) { diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx index 892f29b6fd3..05128909f35 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectIcon.tsx @@ -7,11 +7,11 @@ import ArrowUpIcon from 'enso-assets/arrow_up.svg' import PlayIcon from 'enso-assets/play.svg' import StopIcon from 'enso-assets/stop.svg' +import * as projectHooks from '#/hooks/projectHooks' + import * as authProvider from '#/providers/AuthProvider' import * as textProvider from '#/providers/TextProvider' -import * as dashboard from '#/pages/dashboard/Dashboard' - import * as ariaComponents from '#/components/AriaComponents' import Spinner from '#/components/Spinner' import StatelessSpinner, * as spinner from '#/components/StatelessSpinner' @@ -61,15 +61,15 @@ export interface ProjectIconProps { readonly backend: Backend readonly isOpened: boolean 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. */ export default function ProjectIcon(props: ProjectIconProps) { 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 { getText } = textProvider.useText() @@ -80,7 +80,7 @@ export default function ProjectIcon(props: ProjectIconProps) { isLoading, isError, } = reactQuery.useQuery({ - ...dashboard.createGetProjectDetailsQuery.createPassiveListener(item.id), + ...projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.id), select: data => data.state.type, 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) { case null: case backendModule.ProjectState.created: @@ -139,9 +149,7 @@ export default function ProjectIcon(props: ProjectIconProps) { aria-label={getText('openInEditor')} tooltipPlacement="left" extraClickZone="xsmall" - onPress={() => { - doOpenProject(item.id, false) - }} + onPress={doOpenProject} /> ) case backendModule.ProjectState.openInProgress: @@ -160,9 +168,7 @@ export default function ProjectIcon(props: ProjectIconProps) { tooltipPlacement="left" className={tailwindMerge.twJoin(isRunningInBackground && 'text-green')} {...(isOtherUserUsingProject ? { title: getText('otherUserIsUsingProjectError') } : {})} - onPress={() => { - doCloseProject(item.id) - }} + onPress={doCloseProject} /> { - doCloseProject(item.id) - }} + onPress={doCloseProject} /> { - openProjectTab(item.id) - }} + onPress={doOpenProjectTab} /> )} diff --git a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx index c636456efbd..cf89b9cc48f 100644 --- a/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx +++ b/app/ide-desktop/lib/dashboard/src/components/dashboard/ProjectNameColumn.tsx @@ -4,6 +4,7 @@ import * as React from 'react' import NetworkIcon from 'enso-assets/network.svg' import * as backendHooks from '#/hooks/backendHooks' +import * as projectHooks from '#/hooks/projectHooks' import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' @@ -53,18 +54,16 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { setRowState, state, isEditable, - doCloseProject, - doOpenProject, backendType, isOpened, } = props - const { backend, selectedKeys } = state - const { nodeMap, doOpenEditor } = state + const { backend, selectedKeys, nodeMap } = state const toastAndLog = toastAndLogHooks.useToastAndLog() const { user } = authProvider.useNonPartialUserSession() const { getText } = textProvider.useText() const inputBindings = inputBindingsProvider.useInputBindings() const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent() + const doOpenProject = projectHooks.useOpenProject() if (item.type !== backendModule.AssetType.project) { // eslint-disable-next-line no-restricted-syntax @@ -96,7 +95,12 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { const updateProjectMutation = backendHooks.useBackendMutation(backend, 'updateProject') const duplicateProjectMutation = backendHooks.useBackendMutation(backend, 'duplicateProject') 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) => { if (isEditable) { @@ -115,7 +119,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { try { await updateProjectMutation.mutateAsync([ asset.id, - { ami: null, ideVersion: null, projectName: newTitle, parentId: asset.parentId }, + { ami: null, ideVersion: null, projectName: newTitle }, asset.title, ]) } catch (error) { @@ -183,9 +187,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) { id: createdProject.projectId, projectState: object.merge(projectState, { 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 // `projectState` key to be absent. 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} /> )} > readonly isEditable: boolean - readonly doOpenProject: (project: dashboard.Project) => void - readonly doCloseProject: (project: dashboard.Project) => void } /** Props for a {@link AssetColumn}. */ diff --git a/app/ide-desktop/lib/dashboard/src/hooks/projectHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/projectHooks.ts new file mode 100644 index 00000000000..cd8850188c0 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/hooks/projectHooks.ts @@ -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 + } +} + +// ================= +// === Constants === +// ================= + +const PROJECT_SCHEMA = z + .object({ + id: z.custom(x => typeof x === 'string'), + parentId: z.custom(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 +/** + * 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['id'] + readonly parentId: backendModule.Asset['parentId'] + readonly title: backendModule.Asset['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({ + 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) + } + }) +} diff --git a/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts b/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts index 1f3898ee5fe..5ff5e29fc8e 100644 --- a/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts +++ b/app/ide-desktop/lib/dashboard/src/hooks/searchParamsStateHooks.ts @@ -14,6 +14,10 @@ import * as lazyMemo from '#/hooks/useLazyMemoHooks' import * as safeJsonParse from '#/utilities/safeJsonParse' +// =================================== +// === SearchParamsStateReturnType === +// =================================== + /** * The return type of the `useSearchParamsState` hook. */ @@ -21,6 +25,10 @@ type SearchParamsStateReturnType = Readonly< [value: T, setValue: (nextValue: React.SetStateAction) => void, clear: () => void] > +// ============================ +// === useSearchParamsState === +// ============================ + /** * 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. @@ -89,3 +97,72 @@ export function useSearchParamsState( 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( + key: string, + defaultValue: T | (() => T), + predicate: (unknown: unknown) => unknown is T = (unknown): unknown is T => true +): SearchParamsStateReturnType { + 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(() => { + 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) => { + if (nextValue instanceof Function) { + nextValue = nextValue(value) + } + + if (nextValue === lazyDefaultValueInitializer()) { + clear() + } else { + searchParams.set(prefixedKey, JSON.stringify(nextValue)) + setSearchParams(searchParams) + } + }) + + return [value, setValue, clear] +} diff --git a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx index dc11734f3c8..5a5da0d694e 100644 --- a/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx +++ b/app/ide-desktop/lib/dashboard/src/layouts/AssetContextMenu.tsx @@ -6,6 +6,7 @@ import * as toast from 'react-toastify' import * as billingHooks from '#/hooks/billing' import * as copyHooks from '#/hooks/copyHooks' +import * as projectHooks from '#/hooks/projectHooks' import * as setAssetHooks from '#/hooks/setAssetHooks' import * as toastAndLogHooks from '#/hooks/toastAndLogHooks' @@ -17,8 +18,6 @@ import * as textProvider from '#/providers/TextProvider' import AssetEventType from '#/events/AssetEventType' import AssetListEventType from '#/events/AssetListEventType' -import * as dashboard from '#/pages/dashboard/Dashboard' - import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider' import Category, * as categoryModule from '#/layouts/CategorySwitcher/Category' import GlobalContextMenu from '#/layouts/GlobalContextMenu' @@ -36,7 +35,7 @@ import ManagePermissionsModal from '#/modals/ManagePermissionsModal' import UpsertSecretModal from '#/modals/UpsertSecretModal' 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 permissions from '#/utilities/permissions' @@ -72,6 +71,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { const { user } = authProvider.useNonPartialUserSession() const { setModal, unsetModal } = modalProvider.useSetModal() const remoteBackend = backendProvider.useRemoteBackend() + const localBackend = backendProvider.useLocalBackend() const { getText } = textProvider.useText() const toastAndLog = toastAndLogHooks.useToastAndLog() const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent() @@ -87,8 +87,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { : isCloud ? `${item.path}${item.type === backendModule.AssetType.datalink ? '.datalink' : ''}` : asset.type === backendModule.AssetType.project - ? asset.projectState.path ?? null - : localBackend.extractTypeAndId(asset.id).id + ? localBackend?.getProjectDirectoryPath(asset.id) ?? null + : localBackendModule.extractTypeAndId(asset.id).id const copyMutation = copyHooks.useCopy({ copyText: path ?? '' }) const { isFeatureUnderPaywall } = billingHooks.usePaywall({ plan: user.plan }) @@ -103,7 +103,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { const { data } = reactQuery.useQuery( item.item.type === backendModule.AssetType.project - ? dashboard.createGetProjectDetailsQuery.createPassiveListener(item.item.id) + ? projectHooks.createGetProjectDetailsQuery.createPassiveListener(item.item.id) : { queryKey: ['__IGNORED__'] } ) @@ -254,7 +254,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { } else { try { 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 // completes, as the current backend is not the remote @@ -406,20 +406,21 @@ export default function AssetContextMenu(props: AssetContextMenuProps) { /> )} {isCloud && managesThisAsset && self != null &&