mirror of
https://github.com/enso-org/enso.git
synced 2024-12-25 11:23:55 +03:00
Even more dashboard fixes (#10541)
- Fix https://github.com/enso-org/cloud-v2/issues/1383 - Fix file download - both on Electron, and in browser - Refresh versions list when uploading neww file version - Fix app crashing when asset is opened in asset panel while switching to Local Backend - Don't show asset id when asset is opened in asset panel, but user is on the Local backend, resulting in the internal Asset ID being shown - Fix drag-n-drop - ⚠️ `npm run dev` is NOT fixed in this PR - however it should already be fixed in another PR which has already been merged. This needs testing to confirm whether it is fixed though. Other changes: - Add support for "duplicate project" endpoint on Local Backend - Fix downloading project from nested directory on Local Backend (not working) - Refactor more E2E tests to use the "new" architecture - Simplify "new" E2E architecture to minimize boilerplate # Important Notes - When testing downloads, both Electron and browser should be tested as they use completely separate implementations for how files are downloaded.
This commit is contained in:
parent
a30b0c60eb
commit
cf9d757457
@ -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:*",
|
||||
|
@ -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<string, string>) => {
|
||||
electron.BrowserWindow.getFocusedWindow()?.webContents.downloadURL(
|
||||
url,
|
||||
headers ? { headers } : {}
|
||||
)
|
||||
}
|
||||
)
|
||||
electron.ipcMain.on(ipc.Channel.showItemInFolder, (_event, fullPath: string) => {
|
||||
electron.shell.showItemInFolder(fullPath)
|
||||
})
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -203,6 +203,9 @@ electron.contextBridge.exposeInMainWorld(MENU_API_KEY, MENU_API)
|
||||
// ==================
|
||||
|
||||
const SYSTEM_API = {
|
||||
downloadURL: (url: string, headers?: Record<string, string>) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.downloadURL, url, headers)
|
||||
},
|
||||
showItemInFolder: (fullPath: string) => {
|
||||
electron.ipcRenderer.send(ipc.Channel.showItemInFolder, fullPath)
|
||||
},
|
||||
|
@ -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.
|
||||
|
@ -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<Record<ProjectState, boolean>> = {
|
||||
@ -242,7 +240,7 @@ export const IS_OPENING_OR_OPENED: Readonly<Record<ProjectState, boolean>> = {
|
||||
|
||||
/** Common `Project` fields returned by all `Project`-related endpoints. */
|
||||
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<void>
|
||||
/** 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<Project>
|
||||
/** Download from an arbitrary URL that is assumed to originate from this backend. */
|
||||
abstract download(url: string, name?: string): Promise<void>
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
@ -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<void> {
|
||||
export default class BaseActions implements Promise<void> {
|
||||
/** 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<void> {
|
||||
@ -55,7 +65,6 @@ export default class BaseActions implements PromiseLike<void> {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Proxies the `then` method of the internal {@link Promise}. */
|
||||
async then<T, E>(
|
||||
// The following types are copied almost verbatim from the type definitions for `Promise`.
|
||||
@ -72,10 +81,17 @@ export default class BaseActions implements PromiseLike<void> {
|
||||
* to treat this class as a {@link Promise}. */
|
||||
// The following types are copied almost verbatim from the type definitions for `Promise`.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async catch<T>(onrejected?: ((reason: unknown) => T) | null | undefined) {
|
||||
async catch<T>(onrejected?: ((reason: unknown) => PromiseLike<T> | T) | null | undefined) {
|
||||
return await this.promise.catch(onrejected)
|
||||
}
|
||||
|
||||
/** Proxies the `catch` method of the internal {@link Promise}.
|
||||
* This method is not required for this to be a `thenable`, but it is still useful
|
||||
* to treat this class as a {@link Promise}. */
|
||||
async finally(onfinally?: (() => void) | null | undefined): Promise<void> {
|
||||
await this.promise.finally(onfinally)
|
||||
}
|
||||
|
||||
/** Return a {@link BaseActions} with the same {@link Promise} but a different type. */
|
||||
into<
|
||||
T extends new (page: test.Page, promise: Promise<void>, ...args: Args) => InstanceType<T>,
|
||||
@ -98,13 +114,45 @@ export default class BaseActions implements PromiseLike<void> {
|
||||
}
|
||||
|
||||
/** Perform an action on the current page. */
|
||||
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<Key extends string>(keyOrShortcut: inputBindings.AutocompleteKeybind<Key>) {
|
||||
return this.do(page => BaseActions.press(page, keyOrShortcut))
|
||||
}
|
||||
|
||||
/** Perform actions until a predicate passes. */
|
||||
retry(
|
||||
callback: (actions: this) => this,
|
||||
predicate: (page: test.Page) => Promise<boolean>,
|
||||
options: { retries?: number; delay?: number } = {}
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const { retries = 3, delay = 1_000 } = options
|
||||
return this.step('Perform actions with retries', async thePage => {
|
||||
for (let i = 0; i < retries; i += 1) {
|
||||
await callback(this)
|
||||
if (await predicate(thePage)) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return
|
||||
}
|
||||
await thePage.waitForTimeout(delay)
|
||||
}
|
||||
throw new Error('This action did not succeed.')
|
||||
})
|
||||
}
|
||||
|
||||
/** Perform actions with the "Mod" modifier key pressed. */
|
||||
withModPressed<R extends BaseActions>(callback: (actions: this) => R) {
|
||||
return callback(
|
||||
this.step('Press "Mod"', async page => {
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
})
|
||||
).step('Release "Mod"', async page => {
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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 => {
|
||||
|
@ -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<T extends BaseActions> {
|
||||
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<T extends BaseActions>(
|
||||
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<T extends BaseActions>(
|
||||
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()
|
||||
|
@ -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(
|
||||
|
@ -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<T extends BaseActions> {
|
||||
readonly downloadApp: (callback: (download: test.Download) => Promise<void> | void) => T
|
||||
readonly settings: () => SettingsPageActions
|
||||
readonly logout: () => LoginPageActions
|
||||
readonly goToLoginPage: () => LoginPageActions
|
||||
}
|
||||
@ -25,13 +27,16 @@ export function userMenuActions<T extends BaseActions>(
|
||||
step: (name: string, callback: baseActions.PageCallback) => T
|
||||
): UserMenuActions<T> {
|
||||
return {
|
||||
downloadApp: (callback: (download: test.Download) => Promise<void> | void) => {
|
||||
return step('Download app (user menu)', async page => {
|
||||
downloadApp: (callback: (download: test.Download) => Promise<void> | 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()
|
||||
|
@ -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<ReturnType<typeof mockApi>>): Promise<void> | void
|
||||
}
|
||||
|
||||
/** The return type of {@link mockApi}. */
|
||||
export interface MockApi extends Awaited<ReturnType<typeof mockApiInternal>> {}
|
||||
|
||||
// This is a function, even though it does not contain function syntax.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const mockApi: (params: MockParams) => Promise<MockApi> = mockApiInternal
|
||||
|
||||
/** Add route handlers for the mock API to a page. */
|
||||
// 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,16 +486,16 @@ 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] ?? ''
|
||||
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: backend.ProjectId(projectId),
|
||||
projectId: projectId,
|
||||
name: 'example project name',
|
||||
state: {
|
||||
type: backend.ProjectState.opened,
|
||||
volumeId: '',
|
||||
openedBy: defaultEmail,
|
||||
},
|
||||
state: project.projectState,
|
||||
packageName: 'Project_root',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ide_version: null,
|
||||
@ -497,8 +504,9 @@ export async function mockApi({ page, setupAPI }: MockParams) {
|
||||
value: '2023.2.1-nightly.2023.9.29',
|
||||
lifecycle: backend.VersionLifecycle.development,
|
||||
},
|
||||
address: backend.Address('ws://example.com/'),
|
||||
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,
|
||||
|
@ -23,9 +23,8 @@ const EMAIL = 'baz.quux@email.com'
|
||||
// =============
|
||||
|
||||
test.test('open and close asset panel', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.createFolder()
|
||||
.driveTable.clickRow(0)
|
||||
.withAssetPanel(async assetPanel => {
|
||||
@ -40,13 +39,12 @@ test.test('open and close asset panel', ({ page }) =>
|
||||
await actions.expectNotOnScreen(assetPanel)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
test.test('asset panel contents', ({ page }) =>
|
||||
actions.mockAll({ page }).then(
|
||||
async ({ pageActions, api }) =>
|
||||
await pageActions
|
||||
.do(() => {
|
||||
actions
|
||||
.mockAll({
|
||||
page,
|
||||
setupAPI: api => {
|
||||
const { defaultOrganizationId, defaultUserId } = api
|
||||
api.addProject('project', {
|
||||
description: DESCRIPTION,
|
||||
@ -63,6 +61,7 @@ test.test('asset panel contents', ({ page }) =>
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
.login()
|
||||
.do(async thePage => {
|
||||
@ -73,9 +72,6 @@ test.test('asset panel contents', ({ page }) =>
|
||||
.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()
|
||||
await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -32,9 +32,9 @@ test.test('tags', async ({ page }) => {
|
||||
})
|
||||
|
||||
test.test('labels', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const labels = actions.locateSearchBarLabels(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]!)
|
||||
@ -42,7 +42,10 @@ test.test('labels', async ({ page }) => {
|
||||
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 })
|
||||
},
|
||||
})
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const labels = actions.locateSearchBarLabels(page)
|
||||
|
||||
await searchBarInput.click()
|
||||
for (const label of await labels.all()) {
|
||||
|
@ -5,11 +5,13 @@ 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 => {
|
||||
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 &&
|
||||
@ -20,8 +22,10 @@ test.test('extra columns should stick to right side of assets table', async ({ p
|
||||
// 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)
|
||||
})
|
||||
.do(async thePage => {
|
||||
const extraColumns = actions.locateExtraColumns(thePage)
|
||||
const assetsTable = actions.locateAssetsTable(thePage)
|
||||
await test
|
||||
.expect(async () => {
|
||||
const extraColumnsRight = await extraColumns.evaluate(
|
||||
@ -34,13 +38,18 @@ test.test('extra columns should stick to right side of assets table', async ({ p
|
||||
})
|
||||
.toPass({ timeout: PASS_TIMEOUT })
|
||||
})
|
||||
)
|
||||
|
||||
test.test('extra columns should stick to top of scroll container', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
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)
|
||||
})
|
||||
)
|
||||
|
@ -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()
|
||||
test.test('copy', ({ page }) =>
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
.createFolder()
|
||||
// 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()
|
||||
.createFolder()
|
||||
.driveTable.rightClickRow(0)
|
||||
// Assets: [0: Folder 2 <copied>, 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()
|
||||
.contextMenu.copy()
|
||||
.driveTable.rightClickRow(1)
|
||||
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(3)
|
||||
await test.expect(assetRows.nth(2)).toBeVisible()
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
|
||||
.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)
|
||||
})
|
||||
)
|
||||
|
||||
test.test('copy (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
test.test('copy (keyboard)', ({ page }) =>
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
.createFolder()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.press(page, 'Mod+C')
|
||||
.createFolder()
|
||||
.driveTable.clickRow(0)
|
||||
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
|
||||
await actions.clickAssetRow(assetRows.nth(1))
|
||||
await actions.press(page, 'Mod+V')
|
||||
.press('Mod+C')
|
||||
.driveTable.clickRow(1)
|
||||
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(3)
|
||||
await test.expect(assetRows.nth(2)).toBeVisible()
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
|
||||
.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)
|
||||
})
|
||||
)
|
||||
|
||||
test.test('move', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
test.test('move', ({ page }) =>
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
.createFolder()
|
||||
// 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()
|
||||
.createFolder()
|
||||
.driveTable.rightClickRow(0)
|
||||
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await assetRows.nth(1).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await locatePasteButton(page).click()
|
||||
.contextMenu.cut()
|
||||
.driveTable.rightClickRow(1)
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
.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('move (drag)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
test.test('move (drag)', ({ page }) =>
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
.createFolder()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await actions.dragAssetRowToAssetRow(assetRows.nth(0), assetRows.nth(1))
|
||||
.createFolder()
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
.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)
|
||||
})
|
||||
)
|
||||
|
||||
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))
|
||||
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.
|
||||
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/)
|
||||
.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)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
test.test('move (keyboard)', ({ page }) =>
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
.createFolder()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.press(page, 'Mod+X')
|
||||
.createFolder()
|
||||
.driveTable.clickRow(0)
|
||||
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
|
||||
await actions.clickAssetRow(assetRows.nth(1))
|
||||
await actions.press(page, 'Mod+V')
|
||||
.press('Mod+X')
|
||||
.driveTable.clickRow(1)
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
.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 }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
await actions.press(page, 'Mod+X')
|
||||
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 assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
|
||||
.expect(await rows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
|
||||
.toBeLessThan(1)
|
||||
})
|
||||
.toPass()
|
||||
})
|
||||
)
|
||||
|
||||
test.test('duplicate', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
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)
|
||||
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(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/)
|
||||
await test.expect(rows.nth(0)).toBeVisible()
|
||||
await test.expect(rows.nth(0)).toHaveText(/^New Project 1 [(]copy[)]/)
|
||||
})
|
||||
)
|
||||
|
||||
test.test('duplicate (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
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('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[)]/)
|
||||
})
|
||||
)
|
||||
|
@ -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 => {
|
||||
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
|
||||
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 => {
|
||||
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 => {
|
||||
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))
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -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 => {
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.openDataLinkModal()
|
||||
.withNameInput(async input => {
|
||||
await input.fill(DATA_LINK_NAME)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -4,9 +4,8 @@ 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
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.createFolder()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
@ -26,12 +25,10 @@ test.test('delete and restore', ({ page }) =>
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
test.test('delete and restore (keyboard)', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.createFolder()
|
||||
.driveTable.withRows(async rows => {
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
@ -51,4 +48,3 @@ test.test('delete and restore (keyboard)', ({ page }) =>
|
||||
await test.expect(rows).toHaveCount(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -4,9 +4,8 @@ import * as test from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('drive view', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.withDriveView(async view => {
|
||||
await test.expect(view).toBeVisible()
|
||||
})
|
||||
@ -36,17 +35,10 @@ test.test('drive view', ({ page }) =>
|
||||
.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.
|
||||
// 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)
|
||||
})
|
||||
)
|
||||
|
@ -6,10 +6,10 @@ 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'
|
||||
const labelEl = actions.locateLabelsPanelLabels(page, label)
|
||||
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]!)
|
||||
@ -21,6 +21,10 @@ test.test('drag labels onto single row', async ({ page }) => {
|
||||
api.addSecret('bar')
|
||||
api.addFile('baz')
|
||||
api.addSecret('quux')
|
||||
},
|
||||
})
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labelEl = actions.locateLabelsPanelLabels(page, label)
|
||||
await actions.relog({ page })
|
||||
|
||||
await test.expect(labelEl).toBeVisible()
|
||||
@ -32,10 +36,10 @@ 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'
|
||||
const labelEl = actions.locateLabelsPanelLabels(page, label)
|
||||
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]!)
|
||||
@ -47,7 +51,10 @@ test.test('drag labels onto multiple rows', async ({ page }) => {
|
||||
api.addSecret('bar')
|
||||
api.addFile('baz')
|
||||
api.addSecret('quux')
|
||||
await actions.relog({ page })
|
||||
},
|
||||
})
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labelEl = actions.locateLabelsPanelLabels(page, label)
|
||||
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
await actions.clickAssetRow(assetRows.nth(0))
|
||||
|
@ -8,9 +8,8 @@ import * as actions from './actions'
|
||||
// =============
|
||||
|
||||
test.test('login and logout', ({ page }) =>
|
||||
actions.mockAll({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
actions
|
||||
.mockAll({ page })
|
||||
.login()
|
||||
.do(async thePage => {
|
||||
await actions.passTermsAndConditionsDialog({ page: thePage })
|
||||
@ -24,4 +23,3 @@ test.test('login and logout', ({ page }) =>
|
||||
await test.expect(actions.locateLoginButton(thePage)).toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -4,9 +4,8 @@ import * as test from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('page switcher', ({ page }) =>
|
||||
actions.mockAllAndLogin({ page }).then(
|
||||
async ({ pageActions }) =>
|
||||
await pageActions
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
// Create a new project so that the editor page can be switched to.
|
||||
.newEmptyProject()
|
||||
.goToPage.drive()
|
||||
@ -20,4 +19,3 @@ test.test('page switcher', ({ page }) =>
|
||||
await test.expect(actions.locateEditor(thePage)).toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import type * as api from './api'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -75,16 +76,19 @@ 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)
|
||||
test.expect(EMAIL).not.toStrictEqual(theApi.defaultEmail)
|
||||
test.expect(NAME).not.toStrictEqual(theApi.defaultName)
|
||||
},
|
||||
})
|
||||
.loginAsNewUser(EMAIL, actions.VALID_PASSWORD)
|
||||
.do(async thePage => {
|
||||
@ -116,5 +120,4 @@ test.test('sign up flow', ({ page }) =>
|
||||
test.expect(api.currentUser()?.email, 'new user has correct email').toBe(EMAIL)
|
||||
test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME)
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
@ -20,10 +20,9 @@ const MIN_MS = 60_000
|
||||
// =============
|
||||
|
||||
test.test('sort', async ({ page }) => {
|
||||
const { api } = await actions.mockAll({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const nameHeading = actions.locateNameColumnHeading(page)
|
||||
const modifiedHeading = actions.locateModifiedColumnHeading(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))
|
||||
@ -49,6 +48,11 @@ test.test('sort', async ({ page }) => {
|
||||
// g directory
|
||||
// c project
|
||||
// d file
|
||||
},
|
||||
})
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const nameHeading = actions.locateNameColumnHeading(page)
|
||||
const modifiedHeading = actions.locateModifiedColumnHeading(page)
|
||||
await actions.login({ page })
|
||||
|
||||
// By default, assets should be grouped by type.
|
||||
|
@ -4,9 +4,8 @@ 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
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.openStartModal()
|
||||
.createProjectFromTemplate(0)
|
||||
.do(async thePage => {
|
||||
@ -14,4 +13,3 @@ test.test('create project from template', ({ page }) =>
|
||||
await test.expect(actions.locateSamples(page).first()).not.toBeVisible()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -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 => {
|
||||
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 => {
|
||||
actions
|
||||
.mockAllAndLogin({ page })
|
||||
.openUserMenu()
|
||||
.userMenu.downloadApp(async download => {
|
||||
await download.cancel()
|
||||
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
|
||||
})
|
||||
)
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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<HTMLTableRowElement>
|
||||
) => 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,
|
||||
})
|
||||
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}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
|
@ -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) {
|
||||
|
@ -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}
|
||||
/>
|
||||
<StatelessSpinner
|
||||
state={spinnerState}
|
||||
@ -187,9 +193,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
tooltipPlacement="left"
|
||||
tooltip={isOtherUserUsingProject ? getText('otherUserIsUsingProjectError') : null}
|
||||
className={tailwindMerge.twMerge(isRunningInBackground && 'text-green')}
|
||||
onPress={() => {
|
||||
doCloseProject(item.id)
|
||||
}}
|
||||
onPress={doCloseProject}
|
||||
/>
|
||||
<Spinner
|
||||
state={spinner.SpinnerState.done}
|
||||
@ -208,9 +212,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
|
||||
icon={ArrowUpIcon}
|
||||
aria-label={getText('openInEditor')}
|
||||
tooltipPlacement="right"
|
||||
onPress={() => {
|
||||
openProjectTab(item.id)
|
||||
}}
|
||||
onPress={doOpenProjectTab}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
<EditableSpan
|
||||
|
@ -1,8 +1,6 @@
|
||||
/** @file Column types and column display modes. */
|
||||
import type * as React from 'react'
|
||||
|
||||
import type * as dashboard from '#/pages/dashboard/Dashboard'
|
||||
|
||||
import type * as assetsTable from '#/layouts/AssetsTable'
|
||||
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
@ -35,8 +33,6 @@ export interface AssetColumnProps {
|
||||
readonly rowState: assetsTable.AssetRowState
|
||||
readonly setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
|
||||
readonly isEditable: boolean
|
||||
readonly doOpenProject: (project: dashboard.Project) => void
|
||||
readonly doCloseProject: (project: dashboard.Project) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link AssetColumn}. */
|
||||
|
370
app/ide-desktop/lib/dashboard/src/hooks/projectHooks.ts
Normal file
370
app/ide-desktop/lib/dashboard/src/hooks/projectHooks.ts
Normal file
@ -0,0 +1,370 @@
|
||||
/** @file Mutations related to project management. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import invariant from 'tiny-invariant'
|
||||
import * as z from 'zod'
|
||||
|
||||
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as projectsProvider from '#/providers/ProjectsProvider'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type LocalBackend from '#/services/LocalBackend'
|
||||
import type RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
|
||||
declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly launchedProjects: z.infer<typeof LAUNCHED_PROJECT_SCHEMA>
|
||||
}
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
const PROJECT_SCHEMA = z
|
||||
.object({
|
||||
id: z.custom<backendModule.ProjectId>(x => typeof x === 'string'),
|
||||
parentId: z.custom<backendModule.DirectoryId>(x => typeof x === 'string'),
|
||||
title: z.string(),
|
||||
type: z.nativeEnum(backendModule.BackendType),
|
||||
})
|
||||
.readonly()
|
||||
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA).readonly()
|
||||
|
||||
/**
|
||||
* Launched project information.
|
||||
*/
|
||||
export type Project = z.infer<typeof PROJECT_SCHEMA>
|
||||
/**
|
||||
* Launched project ID.
|
||||
*/
|
||||
export type ProjectId = backendModule.ProjectId
|
||||
|
||||
LocalStorage.registerKey('launchedProjects', {
|
||||
isUserSpecific: true,
|
||||
schema: LAUNCHED_PROJECT_SCHEMA,
|
||||
})
|
||||
|
||||
// ====================================
|
||||
// === createGetProjectDetailsQuery ===
|
||||
// ====================================
|
||||
|
||||
/** Options for {@link createGetProjectDetailsQuery}. */
|
||||
export interface CreateOpenedProjectQueryOptions {
|
||||
readonly type: backendModule.BackendType
|
||||
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
|
||||
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
|
||||
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
|
||||
readonly remoteBackend: RemoteBackend
|
||||
readonly localBackend: LocalBackend | null
|
||||
}
|
||||
|
||||
/** Project status query. */
|
||||
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
|
||||
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
|
||||
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
const isLocal = type === backendModule.BackendType.local
|
||||
|
||||
return reactQuery.queryOptions({
|
||||
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
|
||||
meta: { persist: false },
|
||||
gcTime: 0,
|
||||
refetchInterval: ({ state }) => {
|
||||
/** Default interval for refetching project status when the project is opened. */
|
||||
const openedIntervalMS = 30_000
|
||||
/** Interval when we open a cloud project.
|
||||
* Since opening a cloud project is a long operation, we want to check the status less often. */
|
||||
const cloudOpeningIntervalMS = 5_000
|
||||
/** Interval when we open a local project or when we want to sync the project status as soon as
|
||||
* possible. */
|
||||
const activeSyncIntervalMS = 100
|
||||
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
|
||||
|
||||
if (state.status === 'error') {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return false
|
||||
}
|
||||
if (isLocal) {
|
||||
if (state.data?.state.type === backendModule.ProjectState.opened) {
|
||||
return openedIntervalMS
|
||||
} else {
|
||||
return activeSyncIntervalMS
|
||||
}
|
||||
} else {
|
||||
if (state.data == null) {
|
||||
return activeSyncIntervalMS
|
||||
} else if (states.includes(state.data.state.type)) {
|
||||
return openedIntervalMS
|
||||
} else {
|
||||
return cloudOpeningIntervalMS
|
||||
}
|
||||
}
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
queryFn: async () => {
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return await backend.getProjectDetails(assetId, parentId, title)
|
||||
},
|
||||
})
|
||||
}
|
||||
createGetProjectDetailsQuery.getQueryKey = (id: ProjectId) => ['project', id] as const
|
||||
createGetProjectDetailsQuery.createPassiveListener = (id: ProjectId) =>
|
||||
reactQuery.queryOptions<backendModule.Project>({
|
||||
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
|
||||
})
|
||||
|
||||
// ==============================
|
||||
// === useOpenProjectMutation ===
|
||||
// ==============================
|
||||
|
||||
/** A mutation to open a project. */
|
||||
export function useOpenProjectMutation() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const session = authProvider.useFullUserSession()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
||||
return reactQuery.useMutation({
|
||||
mutationKey: ['openProject'],
|
||||
networkMode: 'always',
|
||||
mutationFn: ({ title, id, type, parentId }: Project) => {
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return backend.openProject(
|
||||
id,
|
||||
{
|
||||
executeAsync: false,
|
||||
cognitoCredentials: {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.accessToken,
|
||||
clientId: session.clientId,
|
||||
expireAt: session.expireAt,
|
||||
refreshUrl: session.refreshUrl,
|
||||
},
|
||||
parentId,
|
||||
},
|
||||
title
|
||||
)
|
||||
},
|
||||
onMutate: ({ id }) => {
|
||||
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
|
||||
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.openInProgress } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: async (_, { id }) => {
|
||||
await client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === useCloseProjectMutation ===
|
||||
// ===============================
|
||||
|
||||
/** Mutation to close a project. */
|
||||
export function useCloseProjectMutation() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
||||
return reactQuery.useMutation({
|
||||
mutationKey: ['closeProject'],
|
||||
mutationFn: async ({ type, id, title }: Project) => {
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return backend.closeProject(id, title)
|
||||
},
|
||||
onMutate: ({ id }) => {
|
||||
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
|
||||
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onSuccess: (_, { id }) =>
|
||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
onError: (_, { id }) =>
|
||||
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
})
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === useRenameProjectMutation ===
|
||||
// ================================
|
||||
|
||||
/** Mutation to rename a project. */
|
||||
export function useRenameProjectMutation() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
||||
return reactQuery.useMutation({
|
||||
mutationKey: ['renameProject'],
|
||||
mutationFn: ({ newName, project }: { newName: string; project: Project }) => {
|
||||
const { type, id, title } = project
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return backend.updateProject(id, { projectName: newName, ami: null, ideVersion: null }, title)
|
||||
},
|
||||
onSuccess: (_, { project }) =>
|
||||
client.invalidateQueries({
|
||||
queryKey: createGetProjectDetailsQuery.getQueryKey(project.id),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === useOpenProject ===
|
||||
// ======================
|
||||
|
||||
/** Options for {@link useOpenProject}. */
|
||||
export interface OpenProjectOptions {
|
||||
/** Whether to open the project in the background.
|
||||
* Set to `false` to navigate to the project tab.
|
||||
* @default true */
|
||||
readonly openInBackground?: boolean
|
||||
}
|
||||
|
||||
/** A callback to open a project. */
|
||||
export function useOpenProject() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const projectsStore = projectsProvider.useProjectsStore()
|
||||
const addLaunchedProject = projectsProvider.useAddLaunchedProject()
|
||||
const closeAllProjects = useCloseAllProjects()
|
||||
const openProjectMutation = useOpenProjectMutation()
|
||||
const openEditor = useOpenEditor()
|
||||
|
||||
return eventCallbacks.useEventCallback((project: Project, options: OpenProjectOptions = {}) => {
|
||||
const { openInBackground = true } = options
|
||||
|
||||
// Since multiple tabs cannot be opened at the sametime, the opened projects need to be closed first.
|
||||
if (projectsStore.getState().launchedProjects.length > 0) {
|
||||
closeAllProjects()
|
||||
}
|
||||
|
||||
const isOpeningTheSameProject =
|
||||
client.getMutationCache().find({
|
||||
mutationKey: ['openProject'],
|
||||
predicate: mutation => mutation.options.scope?.id === project.id,
|
||||
})?.state.status === 'pending'
|
||||
|
||||
if (!isOpeningTheSameProject) {
|
||||
openProjectMutation.mutate(project)
|
||||
|
||||
const openingProjectMutation = client.getMutationCache().find({
|
||||
mutationKey: ['openProject'],
|
||||
// this is unsafe, but we can't do anything about it
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
predicate: mutation => mutation.state.variables?.id === project.id,
|
||||
})
|
||||
|
||||
openingProjectMutation?.setOptions({
|
||||
...openingProjectMutation.options,
|
||||
scope: { id: project.id },
|
||||
})
|
||||
|
||||
addLaunchedProject(project)
|
||||
|
||||
if (!openInBackground) {
|
||||
openEditor(project.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === useOpenEditor ===
|
||||
// =====================
|
||||
|
||||
/** A function to open the editor. */
|
||||
export function useOpenEditor() {
|
||||
const setPage = projectsProvider.useSetPage()
|
||||
return eventCallbacks.useEventCallback((projectId: ProjectId) => {
|
||||
React.startTransition(() => {
|
||||
setPage(projectId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === useCloseProject ===
|
||||
// =======================
|
||||
|
||||
/** A function to close a project. */
|
||||
export function useCloseProject() {
|
||||
const client = reactQuery.useQueryClient()
|
||||
const closeProjectMutation = useCloseProjectMutation()
|
||||
const removeLaunchedProject = projectsProvider.useRemoveLaunchedProject()
|
||||
const setPage = projectsProvider.useSetPage()
|
||||
|
||||
return eventCallbacks.useEventCallback((project: Project) => {
|
||||
client
|
||||
.getMutationCache()
|
||||
.findAll({
|
||||
mutationKey: ['openProject'],
|
||||
predicate: mutation => mutation.options.scope?.id === project.id,
|
||||
})
|
||||
.forEach(mutation => {
|
||||
mutation.setOptions({ ...mutation.options, retry: false })
|
||||
mutation.destroy()
|
||||
})
|
||||
|
||||
closeProjectMutation.mutate(project)
|
||||
|
||||
client
|
||||
.getMutationCache()
|
||||
.findAll({
|
||||
mutationKey: ['closeProject'],
|
||||
// this is unsafe, but we can't do anything about it
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
predicate: mutation => mutation.state.variables?.id === project.id,
|
||||
})
|
||||
.forEach(mutation => {
|
||||
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
|
||||
})
|
||||
|
||||
removeLaunchedProject(project.id)
|
||||
|
||||
setPage(projectsProvider.TabType.drive)
|
||||
})
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// === useCloseAllProjects ===
|
||||
// ===========================
|
||||
|
||||
/** A function to close all projects. */
|
||||
export function useCloseAllProjects() {
|
||||
const projectsStore = projectsProvider.useProjectsStore()
|
||||
const closeProject = useCloseProject()
|
||||
return eventCallbacks.useEventCallback(() => {
|
||||
for (const launchedProject of projectsStore.getState().launchedProjects) {
|
||||
closeProject(launchedProject)
|
||||
}
|
||||
})
|
||||
}
|
@ -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<T> = Readonly<
|
||||
[value: T, setValue: (nextValue: React.SetStateAction<T>) => void, clear: () => void]
|
||||
>
|
||||
|
||||
// ============================
|
||||
// === useSearchParamsState ===
|
||||
// ============================
|
||||
|
||||
/**
|
||||
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
|
||||
* @param key - The key to store the value in the URL search params.
|
||||
@ -89,3 +97,72 @@ export function useSearchParamsState<T = unknown>(
|
||||
|
||||
return [value, setValue, clear]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to synchronize a state in the URL search params. It returns the value, a setter and a clear function.
|
||||
* @param key - The key to store the value in the URL search params.
|
||||
* @param defaultValue - The default value to use if the key is not present in the URL search params.
|
||||
* @param predicate - A function to check if the value is of the right type.
|
||||
*/
|
||||
export function useSearchParamsStateNonReactive<T = unknown>(
|
||||
key: string,
|
||||
defaultValue: T | (() => T),
|
||||
predicate: (unknown: unknown) => unknown is T = (unknown): unknown is T => true
|
||||
): SearchParamsStateReturnType<T> {
|
||||
const [searchParams, setSearchParams] = reactRouterDom.useSearchParams()
|
||||
|
||||
const prefixedKey = `${appUtils.SEARCH_PARAMS_PREFIX}${key}`
|
||||
|
||||
const lazyDefaultValueInitializer = lazyMemo.useLazyMemoHooks(defaultValue, [])
|
||||
const predicateEventCallback = eventCallback.useEventCallback(predicate)
|
||||
|
||||
const clear = eventCallback.useEventCallback((replace: boolean = false) => {
|
||||
searchParams.delete(prefixedKey)
|
||||
setSearchParams(searchParams, { replace })
|
||||
})
|
||||
|
||||
const unprefixedValue = searchParams.get(key)
|
||||
if (unprefixedValue != null) {
|
||||
searchParams.set(prefixedKey, unprefixedValue)
|
||||
searchParams.delete(key)
|
||||
setSearchParams(searchParams)
|
||||
}
|
||||
|
||||
const rawValue = React.useMemo<T>(() => {
|
||||
const maybeValue = searchParams.get(prefixedKey)
|
||||
const defaultValueFrom = lazyDefaultValueInitializer()
|
||||
|
||||
return maybeValue != null
|
||||
? safeJsonParse.safeJsonParse(maybeValue, defaultValueFrom, (unknown): unknown is T => true)
|
||||
: defaultValueFrom
|
||||
}, [prefixedKey, lazyDefaultValueInitializer, searchParams])
|
||||
|
||||
const isValueValid = predicateEventCallback(rawValue)
|
||||
|
||||
const value = isValueValid ? rawValue : lazyDefaultValueInitializer()
|
||||
|
||||
if (!isValueValid) {
|
||||
clear(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the value in the URL search params. If the next value is the same as the default value, it will remove the key from the URL search params.
|
||||
* Function reference is always the same.
|
||||
* @param nextValue - The next value to set.
|
||||
* @returns void
|
||||
*/
|
||||
const setValue = eventCallback.useEventCallback((nextValue: React.SetStateAction<T>) => {
|
||||
if (nextValue instanceof Function) {
|
||||
nextValue = nextValue(value)
|
||||
}
|
||||
|
||||
if (nextValue === lazyDefaultValueInitializer()) {
|
||||
clear()
|
||||
} else {
|
||||
searchParams.set(prefixedKey, JSON.stringify(nextValue))
|
||||
setSearchParams(searchParams)
|
||||
}
|
||||
})
|
||||
|
||||
return [value, setValue, clear]
|
||||
}
|
||||
|
@ -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,9 +406,9 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
/>
|
||||
)}
|
||||
{isCloud && managesThisAsset && self != null && <Separator hidden={hidden} />}
|
||||
{asset.type === backendModule.AssetType.project && (
|
||||
<ContextMenuEntry
|
||||
hidden={hidden}
|
||||
isDisabled={!isCloud}
|
||||
action="duplicate"
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
@ -420,6 +420,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isCloud && <ContextMenuEntry hidden={hidden} action="copy" doAction={doCopy} />}
|
||||
{path != null && (
|
||||
<ContextMenuEntry
|
||||
|
@ -9,6 +9,7 @@ import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type Category from '#/layouts/CategorySwitcher/Category'
|
||||
@ -22,7 +23,7 @@ import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinn
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import * as localBackend from '#/services/LocalBackend'
|
||||
import * as localBackendModule from '#/services/LocalBackend'
|
||||
|
||||
import type * as assetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as object from '#/utilities/object'
|
||||
@ -49,6 +50,7 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const { getText } = textProvider.useText()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const [item, setItemInner] = React.useState(itemRaw)
|
||||
const [isEditingDescription, setIsEditingDescription] = React.useState(false)
|
||||
const [queuedDescription, setQueuedDescripion] = React.useState<string | null>(null)
|
||||
@ -84,8 +86,8 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const path = isCloud
|
||||
? null
|
||||
: item.item.type === backendModule.AssetType.project
|
||||
? item.item.projectState.path ?? null
|
||||
: localBackend.extractTypeAndId(item.item.id).id
|
||||
? localBackend?.getProjectDirectoryPath(item.item.id) ?? null
|
||||
: localBackendModule.extractTypeAndId(item.item.id).id
|
||||
|
||||
const createDatalinkMutation = backendHooks.useBackendMutation(backend, 'createDatalink')
|
||||
const getDatalinkMutation = backendHooks.useBackendMutation(backend, 'getDatalink')
|
||||
@ -113,14 +115,9 @@ export default function AssetProperties(props: AssetPropertiesProps) {
|
||||
const oldDescription = item.item.description
|
||||
setItem(oldItem => oldItem.with({ item: object.merge(oldItem.item, { description }) }))
|
||||
try {
|
||||
const projectPath = item.item.projectState?.path
|
||||
await updateAssetMutation.mutateAsync([
|
||||
item.item.id,
|
||||
{
|
||||
parentDirectoryId: null,
|
||||
description,
|
||||
...(projectPath == null ? {} : { projectPath }),
|
||||
},
|
||||
{ parentDirectoryId: null, description },
|
||||
item.item.title,
|
||||
])
|
||||
} catch (error) {
|
||||
|
@ -9,6 +9,7 @@ import * as mimeTypes from '#/data/mimeTypes'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
import * as intersectionHooks from '#/hooks/intersectionHooks'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import useOnScroll from '#/hooks/useOnScroll'
|
||||
|
||||
@ -18,6 +19,7 @@ import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
import * as projectsProvider from '#/providers/ProjectsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
@ -25,8 +27,6 @@ import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import type * as dashboard from '#/pages/dashboard/Dashboard'
|
||||
|
||||
import type * as assetPanel from '#/layouts/AssetPanel'
|
||||
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
|
||||
import * as eventListProvider from '#/layouts/AssetsTable/EventListProvider'
|
||||
@ -339,7 +339,6 @@ export interface AssetsTableState {
|
||||
title?: string | null,
|
||||
override?: boolean
|
||||
) => void
|
||||
readonly doOpenEditor: (id: backendModule.ProjectId) => void
|
||||
readonly doCopy: () => void
|
||||
readonly doCut: () => void
|
||||
readonly doPaste: (
|
||||
@ -358,7 +357,6 @@ export interface AssetRowState {
|
||||
|
||||
/** Props for a {@link AssetsTable}. */
|
||||
export interface AssetsTableProps {
|
||||
readonly openedProjects: dashboard.Project[]
|
||||
readonly hidden: boolean
|
||||
readonly query: AssetQuery
|
||||
readonly setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
@ -371,12 +369,6 @@ export interface AssetsTableProps {
|
||||
readonly setAssetPanelProps: (props: assetPanel.AssetPanelRequiredProps | null) => void
|
||||
readonly setIsAssetPanelTemporarilyVisible: (visible: boolean) => void
|
||||
readonly targetDirectoryNodeRef: React.MutableRefObject<assetTreeNode.AnyAssetTreeNode<backendModule.DirectoryAsset> | null>
|
||||
readonly doOpenEditor: (id: dashboard.ProjectId) => void
|
||||
readonly doOpenProject: (
|
||||
project: dashboard.Project,
|
||||
options?: dashboard.OpenProjectOptions
|
||||
) => void
|
||||
readonly doCloseProject: (project: dashboard.Project) => void
|
||||
readonly assetManagementApiRef: React.Ref<AssetManagementApi>
|
||||
}
|
||||
|
||||
@ -390,19 +382,13 @@ export interface AssetManagementApi {
|
||||
|
||||
/** The table of project assets. */
|
||||
export default function AssetsTable(props: AssetsTableProps) {
|
||||
const {
|
||||
hidden,
|
||||
query,
|
||||
setQuery,
|
||||
setCanDownload,
|
||||
category,
|
||||
openedProjects,
|
||||
assetManagementApiRef,
|
||||
} = props
|
||||
const { hidden, query, setQuery, setCanDownload, category, assetManagementApiRef } = props
|
||||
const { setSuggestions, initialProjectName } = props
|
||||
const { doOpenEditor, doOpenProject, doCloseProject } = props
|
||||
const { setAssetPanelProps, targetDirectoryNodeRef, setIsAssetPanelTemporarilyVisible } = props
|
||||
|
||||
const openedProjects = projectsProvider.useLaunchedProjects()
|
||||
const doOpenProject = projectHooks.useOpenProject()
|
||||
|
||||
const { user } = authProvider.useNonPartialUserSession()
|
||||
const backend = backendProvider.useBackend(category)
|
||||
const labels = backendHooks.useBackendListTags(backend)
|
||||
@ -412,12 +398,12 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const inputBindings = inputBindingsProvider.useInputBindings()
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const previousCategoryRef = React.useRef(category)
|
||||
const dispatchAssetEvent = eventListProvider.useDispatchAssetEvent()
|
||||
const dispatchAssetListEvent = eventListProvider.useDispatchAssetListEvent()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const initializedRef = React.useRef(initialized)
|
||||
initializedRef.current = initialized
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [enabledColumns, setEnabledColumns] = React.useState(columnUtils.DEFAULT_ENABLED_COLUMNS)
|
||||
const [sortInfo, setSortInfo] =
|
||||
React.useState<sorting.SortInfo<columnUtils.SortableColumn> | null>(null)
|
||||
@ -432,7 +418,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
ReadonlySet<backendModule.AssetId>
|
||||
> | null>(null)
|
||||
const [, setQueuedAssetEvents] = React.useState<assetEvent.AssetEvent[]>([])
|
||||
const [, setNameOfProjectToImmediatelyOpen] = React.useState(initialProjectName)
|
||||
const nameOfProjectToImmediatelyOpenRef = React.useRef(initialProjectName)
|
||||
const rootDirectoryId = React.useMemo(
|
||||
() => backend.rootDirectoryId(user) ?? backendModule.DirectoryId(''),
|
||||
[backend, user]
|
||||
@ -649,6 +635,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
)
|
||||
|
||||
const updateSecretMutation = backendHooks.useBackendMutation(backend, 'updateSecret')
|
||||
React.useEffect(() => {
|
||||
previousCategoryRef.current = category
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedKeys.size === 0) {
|
||||
@ -868,10 +857,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}, [isCloud, assetTree, query, visibilities, labels, setSuggestions])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoading(true)
|
||||
}, [backend, category])
|
||||
|
||||
React.useEffect(() => {
|
||||
assetTreeRef.current = assetTree
|
||||
const newNodeMap = new Map(assetTree.preorderTraversal().map(asset => [asset.key, asset]))
|
||||
@ -899,34 +884,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}, [hidden, inputBindings, dispatchAssetEvent])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
setNameOfProjectToImmediatelyOpen(initialProjectName)
|
||||
} else {
|
||||
// The project name here might also be a string with project id, e.g. when opening
|
||||
// a project file from explorer on Windows.
|
||||
const isInitialProject = (asset: backendModule.AnyAsset) =>
|
||||
asset.title === initialProjectName || asset.id === initialProjectName
|
||||
const projectToLoad = assetTree
|
||||
.preorderTraversal()
|
||||
.map(node => node.item)
|
||||
.filter(backendModule.assetIsProject)
|
||||
.find(isInitialProject)
|
||||
if (projectToLoad != null) {
|
||||
doOpenProject({
|
||||
type: backendModule.BackendType.local,
|
||||
id: projectToLoad.id,
|
||||
title: projectToLoad.title,
|
||||
parentId: projectToLoad.parentId,
|
||||
})
|
||||
} else if (initialProjectName != null) {
|
||||
toastAndLog('findProjectError', null, initialProjectName)
|
||||
}
|
||||
}
|
||||
// This effect MUST only run when `initialProjectName` is changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialProjectName])
|
||||
|
||||
const setSelectedKeys = React.useCallback(
|
||||
(newSelectedKeys: ReadonlySet<backendModule.AssetId>) => {
|
||||
selectedKeysRef.current = newSelectedKeys
|
||||
@ -962,56 +919,50 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
const overwriteNodes = React.useCallback(
|
||||
(newAssets: backendModule.AnyAsset[]) => {
|
||||
setInitialized(true)
|
||||
mostRecentlySelectedIndexRef.current = null
|
||||
selectionStartIndexRef.current = null
|
||||
// This is required, otherwise we are using an outdated
|
||||
// `nameOfProjectToImmediatelyOpen`.
|
||||
setNameOfProjectToImmediatelyOpen(oldNameOfProjectToImmediatelyOpen => {
|
||||
setInitialized(true)
|
||||
const nameOfProjectToImmediatelyOpen = nameOfProjectToImmediatelyOpenRef.current
|
||||
const rootParentDirectoryId = backendModule.DirectoryId('')
|
||||
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
|
||||
const newRootNode = new AssetTreeNode(
|
||||
rootDirectory,
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
newAssets.map(asset =>
|
||||
const rootId = rootDirectory.id
|
||||
const children = newAssets.map(asset =>
|
||||
AssetTreeNode.fromAsset(
|
||||
asset,
|
||||
rootDirectory.id,
|
||||
rootDirectory.id,
|
||||
rootId,
|
||||
rootId,
|
||||
0,
|
||||
`${backend.rootPath}/${asset.title}`,
|
||||
null
|
||||
)
|
||||
),
|
||||
)
|
||||
const newRootNode = new AssetTreeNode(
|
||||
rootDirectory,
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
children,
|
||||
-1,
|
||||
backend.rootPath,
|
||||
null,
|
||||
rootDirectory.id,
|
||||
rootId,
|
||||
true
|
||||
)
|
||||
setAssetTree(newRootNode)
|
||||
// The project name here might also be a string with project id, e.g.
|
||||
// when opening a project file from explorer on Windows.
|
||||
const isInitialProject = (asset: backendModule.AnyAsset) =>
|
||||
asset.title === oldNameOfProjectToImmediatelyOpen ||
|
||||
asset.id === oldNameOfProjectToImmediatelyOpen
|
||||
if (oldNameOfProjectToImmediatelyOpen != null) {
|
||||
const projectToLoad = newAssets
|
||||
.filter(backendModule.assetIsProject)
|
||||
.find(isInitialProject)
|
||||
asset.title === nameOfProjectToImmediatelyOpen ||
|
||||
asset.id === nameOfProjectToImmediatelyOpen
|
||||
if (nameOfProjectToImmediatelyOpen != null) {
|
||||
const projectToLoad = newAssets.filter(backendModule.assetIsProject).find(isInitialProject)
|
||||
if (projectToLoad != null) {
|
||||
doOpenProject(
|
||||
{
|
||||
type: backendModule.BackendType.local,
|
||||
id: projectToLoad.id,
|
||||
title: projectToLoad.title,
|
||||
parentId: projectToLoad.parentId,
|
||||
},
|
||||
{ openInBackground: false }
|
||||
)
|
||||
const backendType = backendModule.BackendType.local
|
||||
const { id, title, parentId } = projectToLoad
|
||||
doOpenProject({ type: backendType, id, title, parentId }, { openInBackground: false })
|
||||
} else {
|
||||
toastAndLog('findProjectError', null, oldNameOfProjectToImmediatelyOpen)
|
||||
toastAndLog('findProjectError', null, nameOfProjectToImmediatelyOpen)
|
||||
}
|
||||
}
|
||||
setQueuedAssetEvents(oldQueuedAssetEvents => {
|
||||
@ -1024,8 +975,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
return []
|
||||
})
|
||||
return null
|
||||
})
|
||||
nameOfProjectToImmediatelyOpenRef.current = null
|
||||
},
|
||||
[doOpenProject, rootDirectoryId, backend.rootPath, dispatchAssetEvent, toastAndLog]
|
||||
)
|
||||
@ -1054,10 +1004,39 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
],
|
||||
{ queryKey: [], staleTime: 0, meta: { persist: false } }
|
||||
)
|
||||
const isLoading =
|
||||
rootDirectoryQuery.isLoading || rootDirectoryQuery.isPending || rootDirectoryQuery.isFetching
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
nameOfProjectToImmediatelyOpenRef.current = initialProjectName
|
||||
} else {
|
||||
// The project name here might also be a string with project id, e.g. when opening
|
||||
// a project file from explorer on Windows.
|
||||
const isInitialProject = (asset: backendModule.AnyAsset) =>
|
||||
asset.title === initialProjectName || asset.id === initialProjectName
|
||||
const projectToLoad = assetTree
|
||||
.preorderTraversal()
|
||||
.map(node => node.item)
|
||||
.filter(backendModule.assetIsProject)
|
||||
.find(isInitialProject)
|
||||
if (projectToLoad != null) {
|
||||
doOpenProject({
|
||||
type: backendModule.BackendType.local,
|
||||
id: projectToLoad.id,
|
||||
title: projectToLoad.title,
|
||||
parentId: projectToLoad.parentId,
|
||||
})
|
||||
} else if (initialProjectName != null) {
|
||||
toastAndLog('findProjectError', null, initialProjectName)
|
||||
}
|
||||
}
|
||||
// This effect MUST only run when `initialProjectName` is changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialProjectName])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rootDirectoryQuery.data) {
|
||||
setIsLoading(false)
|
||||
overwriteNodes(rootDirectoryQuery.data)
|
||||
}
|
||||
}, [rootDirectoryQuery.data, overwriteNodes])
|
||||
@ -1763,9 +1742,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
type: backendModule.ProjectState.placeholder,
|
||||
volumeId: '',
|
||||
openedBy: user.email,
|
||||
...(event.original.projectState.path != null
|
||||
? { path: event.original.projectState.path }
|
||||
: {}),
|
||||
},
|
||||
labels: [],
|
||||
description: null,
|
||||
@ -1954,6 +1930,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDrop = (event: React.DragEvent) => {
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const state = React.useMemo<AssetsTableState>(
|
||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||
() => ({
|
||||
@ -1974,7 +1963,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
nodeMap: nodeMapRef,
|
||||
hideColumn,
|
||||
doToggleDirectoryExpansion,
|
||||
doOpenEditor,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
@ -1988,7 +1976,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
sortInfo,
|
||||
query,
|
||||
doToggleDirectoryExpansion,
|
||||
doOpenEditor,
|
||||
doCopy,
|
||||
doCut,
|
||||
doPaste,
|
||||
@ -2270,7 +2257,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
displayItems.map((item, i) => {
|
||||
const key = AssetTreeNode.getKey(item)
|
||||
const isSelected = (visuallySelectedKeysOverride ?? selectedKeys).has(key)
|
||||
const isSoleSelected = selectedKeys.size === 1 && isSelected
|
||||
const isSoleSelected = isSelected && selectedKeys.size === 1
|
||||
|
||||
return (
|
||||
<AssetRow
|
||||
@ -2290,8 +2277,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
item={item}
|
||||
state={state}
|
||||
hidden={hidden || visibilities.get(item.key) === Visibility.hidden}
|
||||
doOpenProject={doOpenProject}
|
||||
doCloseProject={doCloseProject}
|
||||
selected={isSelected}
|
||||
setSelected={selected => {
|
||||
setSelectedKeys(set.withPresence(selectedKeysRef.current, key, selected))
|
||||
@ -2361,8 +2346,6 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setItem={() => {}}
|
||||
setRowState={() => {}}
|
||||
isEditable={false}
|
||||
doCloseProject={doCloseProject}
|
||||
doOpenProject={doOpenProject}
|
||||
/>
|
||||
))}
|
||||
</DragModal>
|
||||
@ -2548,6 +2531,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
ids: new Set(filtered.map(dragItem => dragItem.asset.id)),
|
||||
})
|
||||
}
|
||||
handleFileDrop(event)
|
||||
}}
|
||||
onClick={() => {
|
||||
setSelectedKeys(new Set())
|
||||
@ -2685,16 +2669,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
onDragOver={onDropzoneDragOver}
|
||||
onDrop={event => {
|
||||
setIsDraggingFiles(false)
|
||||
if (event.dataTransfer.types.includes('Files')) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.uploadFiles,
|
||||
parentKey: rootDirectoryId,
|
||||
parentId: rootDirectoryId,
|
||||
files: Array.from(event.dataTransfer.files),
|
||||
})
|
||||
}
|
||||
handleFileDrop(event)
|
||||
}}
|
||||
>
|
||||
<SvgMask src={DropFilesImage} className="size-8" />
|
||||
|
@ -13,8 +13,6 @@ import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import type * as dashboard from '#/pages/dashboard/Dashboard'
|
||||
|
||||
import type * as assetPanel from '#/layouts/AssetPanel'
|
||||
import AssetPanel from '#/layouts/AssetPanel'
|
||||
import type * as assetSearchBar from '#/layouts/AssetSearchBar'
|
||||
@ -62,30 +60,16 @@ enum DriveStatus {
|
||||
|
||||
/** Props for a {@link Drive}. */
|
||||
export interface DriveProps {
|
||||
readonly openedProjects: dashboard.Project[]
|
||||
readonly category: Category
|
||||
readonly setCategory: (category: Category) => void
|
||||
readonly hidden: boolean
|
||||
readonly initialProjectName: string | null
|
||||
readonly doOpenEditor: (id: dashboard.ProjectId) => void
|
||||
readonly doOpenProject: (project: dashboard.Project) => void
|
||||
readonly doCloseProject: (project: dashboard.Project) => void
|
||||
readonly assetsManagementApiRef: React.Ref<assetsTable.AssetManagementApi>
|
||||
}
|
||||
|
||||
/** Contains directory path and directory contents (projects, folders, secrets and files). */
|
||||
export default function Drive(props: DriveProps) {
|
||||
const {
|
||||
openedProjects,
|
||||
doOpenEditor,
|
||||
doCloseProject,
|
||||
category,
|
||||
setCategory,
|
||||
hidden,
|
||||
initialProjectName,
|
||||
doOpenProject,
|
||||
assetsManagementApiRef,
|
||||
} = props
|
||||
const { category, setCategory, hidden, initialProjectName, assetsManagementApiRef } = props
|
||||
|
||||
const { isOffline } = offlineHooks.useOffline()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
@ -99,8 +83,10 @@ export default function Drive(props: DriveProps) {
|
||||
const [suggestions, setSuggestions] = React.useState<readonly assetSearchBar.Suggestion[]>([])
|
||||
const [canDownload, setCanDownload] = React.useState(false)
|
||||
const [didLoadingProjectManagerFail, setDidLoadingProjectManagerFail] = React.useState(false)
|
||||
const [assetPanelProps, setAssetPanelProps] =
|
||||
const [assetPanelPropsRaw, setAssetPanelProps] =
|
||||
React.useState<assetPanel.AssetPanelRequiredProps | null>(null)
|
||||
const assetPanelProps =
|
||||
backend.type === assetPanelPropsRaw?.backend?.type ? assetPanelPropsRaw : null
|
||||
const [isAssetPanelEnabled, setIsAssetPanelEnabled] = React.useState(
|
||||
() => localStorage.get('isAssetPanelVisible') ?? false
|
||||
)
|
||||
@ -326,7 +312,6 @@ export default function Drive(props: DriveProps) {
|
||||
) : (
|
||||
<AssetsTable
|
||||
assetManagementApiRef={assetsManagementApiRef}
|
||||
openedProjects={openedProjects}
|
||||
hidden={hidden}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
@ -337,9 +322,6 @@ export default function Drive(props: DriveProps) {
|
||||
setAssetPanelProps={setAssetPanelProps}
|
||||
setIsAssetPanelTemporarilyVisible={setIsAssetPanelTemporarilyVisible}
|
||||
targetDirectoryNodeRef={targetDirectoryNodeRef}
|
||||
doOpenEditor={doOpenEditor}
|
||||
doOpenProject={doOpenProject}
|
||||
doCloseProject={doCloseProject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -8,12 +8,11 @@ import type * as types from 'enso-common/src/types'
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as dashboard from '#/pages/dashboard/Dashboard'
|
||||
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
@ -36,8 +35,8 @@ export interface EditorProps {
|
||||
readonly isOpening: boolean
|
||||
readonly isOpeningFailed: boolean
|
||||
readonly openingError: Error | null
|
||||
readonly startProject: (project: dashboard.Project) => void
|
||||
readonly project: dashboard.Project
|
||||
readonly startProject: (project: projectHooks.Project) => void
|
||||
readonly project: projectHooks.Project
|
||||
readonly hidden: boolean
|
||||
readonly ydocUrl: string | null
|
||||
readonly appRunner: types.EditorRunner | null
|
||||
@ -52,7 +51,7 @@ export default function Editor(props: EditorProps) {
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
||||
const projectStatusQuery = dashboard.createGetProjectDetailsQuery({
|
||||
const projectStatusQuery = projectHooks.createGetProjectDetailsQuery({
|
||||
type: project.type,
|
||||
assetId: project.id,
|
||||
parentId: project.parentId,
|
||||
@ -86,11 +85,7 @@ export default function Editor(props: EditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge.twJoin('contents', hidden && 'hidden')}
|
||||
data-testid="gui-editor-root"
|
||||
data-testvalue={project.id}
|
||||
>
|
||||
<div className={twMerge.twJoin('contents', hidden && 'hidden')} data-testvalue={project.id}>
|
||||
{(() => {
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
|
@ -6,9 +6,9 @@ import invariant from 'tiny-invariant'
|
||||
|
||||
import type * as text from 'enso-common/src/text'
|
||||
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
|
||||
import * as dashboard from '#/pages/dashboard/Dashboard'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as aria from '#/components/aria'
|
||||
import * as ariaComponents from '#/components/AriaComponents'
|
||||
@ -167,7 +167,8 @@ const Tabs = React.forwardRef(TabsInternal)
|
||||
|
||||
/** Props for a {@link Tab}. */
|
||||
interface InternalTabProps extends Readonly<React.PropsWithChildren> {
|
||||
readonly project?: dashboard.Project
|
||||
readonly 'data-testid'?: string
|
||||
readonly project?: projectHooks.Project
|
||||
readonly isActive: boolean
|
||||
readonly icon: string
|
||||
readonly labelId: text.TextId
|
||||
@ -200,7 +201,7 @@ export function Tab(props: InternalTabProps) {
|
||||
|
||||
const { isLoading, data } = reactQuery.useQuery<backend.Project>(
|
||||
project?.id
|
||||
? dashboard.createGetProjectDetailsQuery.createPassiveListener(project.id)
|
||||
? projectHooks.createGetProjectDetailsQuery.createPassiveListener(project.id)
|
||||
: { queryKey: ['__IGNORE__'], queryFn: reactQuery.skipToken }
|
||||
)
|
||||
|
||||
@ -223,6 +224,7 @@ export function Tab(props: InternalTabProps) {
|
||||
)}
|
||||
>
|
||||
<ariaComponents.Button
|
||||
data-testid={props['data-testid']}
|
||||
size="custom"
|
||||
variant="custom"
|
||||
loaderPosition="icon"
|
||||
|
@ -67,7 +67,7 @@ export default function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||
}}
|
||||
>
|
||||
<aria.Text className="relative">{getText('confirmPrompt', actionText)}</aria.Text>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button
|
||||
size="medium"
|
||||
variant="delete"
|
||||
|
@ -58,7 +58,7 @@ export default function ConfirmDeleteUserModal(props: ConfirmDeleteUserModalProp
|
||||
<aria.Text className="relative mb-2 text-balance text-center">
|
||||
{getText('confirmDeleteUserAccountWarning')}
|
||||
</aria.Text>
|
||||
<ariaComponents.ButtonGroup className="self-center">
|
||||
<ariaComponents.ButtonGroup className="relative self-center">
|
||||
<ariaComponents.Button
|
||||
size="custom"
|
||||
variant="custom"
|
||||
|
@ -272,7 +272,7 @@ export default function DuplicateAssetsModal(props: DuplicateAssetsModalProps) {
|
||||
: getText('andOtherProjects', otherProjectsCount)}
|
||||
</aria.Text>
|
||||
)}
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
onPress={() => {
|
||||
|
@ -86,7 +86,7 @@ export default function EditAssetDescriptionModal(props: EditAssetDescriptionMod
|
||||
|
||||
{error && <div className="relative text-sm text-red-500">{error.message}</div>}
|
||||
|
||||
<div className="relative flex gap-2">
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button variant="submit" type="submit" loading={isPending}>
|
||||
{actionButtonLabel}
|
||||
</ariaComponents.Button>
|
||||
@ -98,7 +98,7 @@ export default function EditAssetDescriptionModal(props: EditAssetDescriptionMod
|
||||
>
|
||||
{getText('editAssetDescriptionModalCancel')}
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</ariaComponents.ButtonGroup>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
|
@ -163,7 +163,7 @@ export default function ManageLabelsModal<
|
||||
{
|
||||
<FocusArea direction="horizontal">
|
||||
{innerProps => (
|
||||
<div className="flex gap-input-with-button" {...innerProps}>
|
||||
<ariaComponents.ButtonGroup className="relative" {...innerProps}>
|
||||
<FocusRing within>
|
||||
<div
|
||||
className={tailwindMerge.twMerge(
|
||||
@ -199,7 +199,7 @@ export default function ManageLabelsModal<
|
||||
>
|
||||
{getText('create')}
|
||||
</ariaComponents.Button>
|
||||
</div>
|
||||
</ariaComponents.ButtonGroup>
|
||||
)}
|
||||
</FocusArea>
|
||||
}
|
||||
|
@ -130,7 +130,7 @@ export default function NewLabelModal(props: NewLabelModalProps) {
|
||||
</ColorPicker>
|
||||
)}
|
||||
</FocusArea>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}>
|
||||
{getText('create')}
|
||||
</ariaComponents.Button>
|
||||
|
@ -107,7 +107,7 @@ export default function NewUserGroupModal(props: NewUserGroupModalProps) {
|
||||
</div>
|
||||
<aria.FieldError className="text-red-700/90">{nameError}</aria.FieldError>
|
||||
</aria.TextField>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button
|
||||
variant="submit"
|
||||
isDisabled={!canSubmit}
|
||||
|
@ -92,7 +92,7 @@ export default function UpsertDatalinkModal(props: UpsertDatalinkModalProps) {
|
||||
<div className="relative">
|
||||
<DatalinkInput dropdownTitle="Type" value={value} setValue={setValue} />
|
||||
</div>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button variant="submit" isDisabled={!isSubmittable} onPress={doSubmit}>
|
||||
{getText('create')}
|
||||
</ariaComponents.Button>
|
||||
|
@ -122,7 +122,7 @@ export default function UpsertSecretModal(props: UpsertSecretModalProps) {
|
||||
)}
|
||||
</FocusArea>
|
||||
</div>
|
||||
<ariaComponents.ButtonGroup>
|
||||
<ariaComponents.ButtonGroup className="relative">
|
||||
<ariaComponents.Button variant="submit" isDisabled={!canSubmit} onPress={doSubmit}>
|
||||
{isCreatingSecret ? getText('create') : getText('update')}
|
||||
</ariaComponents.Button>
|
||||
|
@ -2,8 +2,6 @@
|
||||
* interactive components. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import invariant from 'tiny-invariant'
|
||||
import * as validator from 'validator'
|
||||
import * as z from 'zod'
|
||||
|
||||
@ -14,6 +12,7 @@ import * as detect from 'enso-common/src/detect'
|
||||
import type * as types from 'enso-common/src/types'
|
||||
|
||||
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
|
||||
import * as projectHooks from '#/hooks/projectHooks'
|
||||
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -21,6 +20,7 @@ import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import ProjectsProvider, * as projectsProvider from '#/providers/ProjectsProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
@ -43,10 +43,8 @@ import Page from '#/components/Page'
|
||||
import ManagePermissionsModal from '#/modals/ManagePermissionsModal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type LocalBackend from '#/services/LocalBackend'
|
||||
import * as localBackendModule from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
import type RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
@ -56,51 +54,15 @@ import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
|
||||
/** Main content of the screen. Only one should be visible at a time. */
|
||||
enum TabType {
|
||||
drive = 'drive',
|
||||
settings = 'settings',
|
||||
}
|
||||
|
||||
declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly isAssetPanelVisible: boolean
|
||||
readonly page: z.infer<typeof PAGES_SCHEMA>
|
||||
readonly launchedProjects: z.infer<typeof LAUNCHED_PROJECT_SCHEMA>
|
||||
}
|
||||
}
|
||||
|
||||
LocalStorage.registerKey('isAssetPanelVisible', { schema: z.boolean() })
|
||||
|
||||
const PROJECT_SCHEMA = z.object({
|
||||
id: z.custom<backendModule.ProjectId>(),
|
||||
parentId: z.custom<backendModule.DirectoryId>(),
|
||||
title: z.string(),
|
||||
type: z.nativeEnum(backendModule.BackendType),
|
||||
})
|
||||
const LAUNCHED_PROJECT_SCHEMA = z.array(PROJECT_SCHEMA)
|
||||
|
||||
/**
|
||||
* Launched project information.
|
||||
*/
|
||||
export type Project = z.infer<typeof PROJECT_SCHEMA>
|
||||
/**
|
||||
* Launched project ID.
|
||||
*/
|
||||
export type ProjectId = Project['id']
|
||||
|
||||
LocalStorage.registerKey('launchedProjects', {
|
||||
isUserSpecific: true,
|
||||
schema: LAUNCHED_PROJECT_SCHEMA,
|
||||
})
|
||||
|
||||
const PAGES_SCHEMA = z.nativeEnum(TabType).or(z.custom<backendModule.ProjectId>())
|
||||
|
||||
LocalStorage.registerKey('page', {
|
||||
schema: PAGES_SCHEMA,
|
||||
})
|
||||
|
||||
// =================
|
||||
// === Dashboard ===
|
||||
// =================
|
||||
@ -114,101 +76,13 @@ export interface DashboardProps {
|
||||
readonly ydocUrl: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface OpenProjectOptions {
|
||||
/**
|
||||
* Whether to open the project in the background.
|
||||
* Set to `false` to navigate to the project tab.
|
||||
* @default true
|
||||
*/
|
||||
readonly openInBackground?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface CreateOpenedProjectQueryOptions {
|
||||
readonly type: backendModule.BackendType
|
||||
readonly assetId: backendModule.Asset<backendModule.AssetType.project>['id']
|
||||
readonly parentId: backendModule.Asset<backendModule.AssetType.project>['parentId']
|
||||
readonly title: backendModule.Asset<backendModule.AssetType.project>['title']
|
||||
readonly remoteBackend: RemoteBackend
|
||||
readonly localBackend: LocalBackend | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Project status query.
|
||||
*/
|
||||
export function createGetProjectDetailsQuery(options: CreateOpenedProjectQueryOptions) {
|
||||
const { assetId, parentId, title, remoteBackend, localBackend, type } = options
|
||||
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
const isLocal = type === backendModule.BackendType.local
|
||||
|
||||
return reactQuery.queryOptions({
|
||||
queryKey: createGetProjectDetailsQuery.getQueryKey(assetId),
|
||||
meta: { persist: false },
|
||||
gcTime: 0,
|
||||
refetchInterval: ({ state }) => {
|
||||
/**
|
||||
* Default interval for refetching project status when the project is opened.
|
||||
*/
|
||||
const openedIntervalMS = 30_000
|
||||
/**
|
||||
* Interval when we open a cloud project.
|
||||
* Since opening a cloud project is a long operation, we want to check the status less often.
|
||||
*/
|
||||
const cloudOpeningIntervalMS = 5_000
|
||||
/**
|
||||
* Interval when we open a local project or when we want to sync the project status as soon as possible.
|
||||
*/
|
||||
const activeSyncIntervalMS = 100
|
||||
const states = [backendModule.ProjectState.opened, backendModule.ProjectState.closed]
|
||||
|
||||
if (state.status === 'error') {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return false
|
||||
}
|
||||
|
||||
if (isLocal) {
|
||||
if (state.data?.state.type === backendModule.ProjectState.opened) {
|
||||
return openedIntervalMS
|
||||
} else {
|
||||
return activeSyncIntervalMS
|
||||
}
|
||||
} else {
|
||||
if (state.data == null) {
|
||||
return activeSyncIntervalMS
|
||||
} else if (states.includes(state.data.state.type)) {
|
||||
return openedIntervalMS
|
||||
} else {
|
||||
return cloudOpeningIntervalMS
|
||||
}
|
||||
}
|
||||
},
|
||||
refetchIntervalInBackground: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnMount: true,
|
||||
queryFn: () => {
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return backend.getProjectDetails(assetId, parentId, title)
|
||||
},
|
||||
})
|
||||
}
|
||||
createGetProjectDetailsQuery.getQueryKey = (id: Project['id']) => ['project', id] as const
|
||||
createGetProjectDetailsQuery.createPassiveListener = (id: Project['id']) =>
|
||||
reactQuery.queryOptions<backendModule.Project>({
|
||||
queryKey: createGetProjectDetailsQuery.getQueryKey(id),
|
||||
})
|
||||
|
||||
/** The component that contains the entire UI. */
|
||||
export default function Dashboard(props: DashboardProps) {
|
||||
return (
|
||||
<EventListProvider>
|
||||
<ProjectsProvider>
|
||||
<DashboardInner {...props} />
|
||||
</ProjectsProvider>
|
||||
</EventListProvider>
|
||||
)
|
||||
}
|
||||
@ -216,8 +90,7 @@ export default function Dashboard(props: DashboardProps) {
|
||||
/** The component that contains the entire UI. */
|
||||
function DashboardInner(props: DashboardProps) {
|
||||
const { appRunner, initialProjectName: initialProjectNameRaw, ydocUrl } = props
|
||||
const { user, ...session } = authProvider.useFullUserSession()
|
||||
const remoteBackend = backendProvider.useRemoteBackendStrict()
|
||||
const { user } = authProvider.useFullUserSession()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
const { getText } = textProvider.useText()
|
||||
const { modalRef } = modalProvider.useModalRef()
|
||||
@ -251,52 +124,10 @@ function DashboardInner(props: DashboardProps) {
|
||||
)
|
||||
const isCloud = categoryModule.isCloud(category)
|
||||
|
||||
const [launchedProjects, privateSetLaunchedProjects] = React.useState<Project[]>(
|
||||
() => localStorage.get('launchedProjects') ?? []
|
||||
)
|
||||
|
||||
// These pages MUST be ROUTER PAGES.
|
||||
const [page, privateSetPage] = searchParamsState.useSearchParamsState(
|
||||
'page',
|
||||
() => localStorage.get('page') ?? TabType.drive,
|
||||
(value: unknown): value is Project['id'] | TabType => {
|
||||
return (
|
||||
array.includes(Object.values(TabType), value) || launchedProjects.some(p => p.id === value)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const page = projectsProvider.usePage()
|
||||
const launchedProjects = projectsProvider.useLaunchedProjects()
|
||||
const selectedProject = launchedProjects.find(p => p.id === page) ?? null
|
||||
|
||||
const setLaunchedProjects = eventCallbacks.useEventCallback(
|
||||
(fn: (currentState: Project[]) => Project[]) => {
|
||||
React.startTransition(() => {
|
||||
privateSetLaunchedProjects(currentState => {
|
||||
const nextState = fn(currentState)
|
||||
localStorage.set('launchedProjects', nextState)
|
||||
return nextState
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const addLaunchedProject = eventCallbacks.useEventCallback((project: Project) => {
|
||||
setLaunchedProjects(currentState => [...currentState, project])
|
||||
})
|
||||
|
||||
const removeLaunchedProject = eventCallbacks.useEventCallback((projectId: Project['id']) => {
|
||||
setLaunchedProjects(currentState => currentState.filter(({ id }) => id !== projectId))
|
||||
})
|
||||
|
||||
const clearLaunchedProjects = eventCallbacks.useEventCallback(() => {
|
||||
setLaunchedProjects(() => [])
|
||||
})
|
||||
|
||||
const setPage = eventCallbacks.useEventCallback((nextPage: Project['id'] | TabType) => {
|
||||
privateSetPage(nextPage)
|
||||
localStorage.set('page', nextPage)
|
||||
})
|
||||
|
||||
if (isCloud && !isUserEnabled && localBackend != null) {
|
||||
setTimeout(() => {
|
||||
// This sets `BrowserRouter`, so it must not be set synchronously.
|
||||
@ -304,92 +135,20 @@ function DashboardInner(props: DashboardProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openProjectMutation = 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) })
|
||||
},
|
||||
})
|
||||
|
||||
const closeProjectMutation = reactQuery.useMutation({
|
||||
mutationKey: ['closeProject'],
|
||||
mutationFn: async ({ type, id, title }: Project) => {
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return backend.closeProject(id, title)
|
||||
},
|
||||
onMutate: ({ id }) => {
|
||||
const queryKey = createGetProjectDetailsQuery.getQueryKey(id)
|
||||
|
||||
client.setQueryData(queryKey, { state: { type: backendModule.ProjectState.closing } })
|
||||
|
||||
void client.cancelQueries({ queryKey })
|
||||
void client.invalidateQueries({ queryKey })
|
||||
},
|
||||
onSuccess: (_, { id }) =>
|
||||
client.resetQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
onError: (_, { id }) =>
|
||||
client.invalidateQueries({ queryKey: createGetProjectDetailsQuery.getQueryKey(id) }),
|
||||
})
|
||||
|
||||
const client = reactQuery.useQueryClient()
|
||||
|
||||
const renameProjectMutation = reactQuery.useMutation({
|
||||
mutationFn: ({ newName, project }: { newName: string; project: Project }) => {
|
||||
const { parentId, type, id, title } = project
|
||||
const backend = type === backendModule.BackendType.remote ? remoteBackend : localBackend
|
||||
|
||||
invariant(backend != null, 'Backend is null')
|
||||
|
||||
return backend.updateProject(
|
||||
id,
|
||||
{ projectName: newName, ami: null, ideVersion: null, parentId },
|
||||
title
|
||||
)
|
||||
},
|
||||
onSuccess: (_, { project }) =>
|
||||
client.invalidateQueries({
|
||||
queryKey: createGetProjectDetailsQuery.getQueryKey(project.id),
|
||||
}),
|
||||
})
|
||||
const setPage = projectsProvider.useSetPage()
|
||||
const openEditor = projectHooks.useOpenEditor()
|
||||
const openProject = projectHooks.useOpenProject()
|
||||
const closeProject = projectHooks.useCloseProject()
|
||||
const closeAllProjects = projectHooks.useCloseAllProjects()
|
||||
const clearLaunchedProjects = projectsProvider.useClearLaunchedProjects()
|
||||
const openProjectMutation = projectHooks.useOpenProjectMutation()
|
||||
const renameProjectMutation = projectHooks.useRenameProjectMutation()
|
||||
|
||||
eventListProvider.useAssetEventListener(event => {
|
||||
switch (event.type) {
|
||||
case AssetEventType.openProject: {
|
||||
const { title, parentId, backendType, id, runInBackground } = event
|
||||
doOpenProject(
|
||||
openProject(
|
||||
{ title, parentId, type: backendType, id },
|
||||
{ openInBackground: runInBackground }
|
||||
)
|
||||
@ -397,7 +156,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
}
|
||||
case AssetEventType.closeProject: {
|
||||
const { title, parentId, backendType, id } = event
|
||||
doCloseProject({ title, parentId, type: backendType, id })
|
||||
closeProject({ title, parentId, type: backendType, id })
|
||||
break
|
||||
}
|
||||
default: {
|
||||
@ -416,7 +175,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
updateModal(oldModal => {
|
||||
if (oldModal == null) {
|
||||
queueMicrotask(() => {
|
||||
setPage(localStorage.get('page') ?? TabType.drive)
|
||||
setPage(localStorage.get('page') ?? projectsProvider.TabType.drive)
|
||||
})
|
||||
return oldModal
|
||||
} else {
|
||||
@ -447,96 +206,14 @@ function DashboardInner(props: DashboardProps) {
|
||||
}
|
||||
}, [inputBindings])
|
||||
|
||||
const doOpenProject = eventCallbacks.useEventCallback(
|
||||
(project: Project, options: OpenProjectOptions = {}) => {
|
||||
const { openInBackground = true } = options
|
||||
|
||||
// since we don't support multitabs, we need to close opened project first
|
||||
if (launchedProjects.length > 0) {
|
||||
doCloseAllProjects()
|
||||
}
|
||||
|
||||
const isOpeningTheSameProject =
|
||||
client.getMutationCache().find({
|
||||
mutationKey: ['openProject'],
|
||||
predicate: mutation => mutation.options.scope?.id === project.id,
|
||||
})?.state.status === 'pending'
|
||||
|
||||
if (!isOpeningTheSameProject) {
|
||||
openProjectMutation.mutate(project)
|
||||
|
||||
const openingProjectMutation = client.getMutationCache().find({
|
||||
mutationKey: ['openProject'],
|
||||
// this is unsafe, but we can't do anything about it
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
predicate: mutation => mutation.state.variables?.id === project.id,
|
||||
})
|
||||
|
||||
openingProjectMutation?.setOptions({
|
||||
...openingProjectMutation.options,
|
||||
scope: { id: project.id },
|
||||
})
|
||||
|
||||
addLaunchedProject(project)
|
||||
|
||||
if (!openInBackground) {
|
||||
doOpenEditor(project.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const doOpenEditor = eventCallbacks.useEventCallback((projectId: Project['id']) => {
|
||||
React.startTransition(() => {
|
||||
setPage(projectId)
|
||||
})
|
||||
})
|
||||
|
||||
const doCloseProject = eventCallbacks.useEventCallback((project: Project) => {
|
||||
client
|
||||
.getMutationCache()
|
||||
.findAll({
|
||||
mutationKey: ['openProject'],
|
||||
predicate: mutation => mutation.options.scope?.id === project.id,
|
||||
})
|
||||
.forEach(mutation => {
|
||||
mutation.setOptions({ ...mutation.options, retry: false })
|
||||
mutation.destroy()
|
||||
})
|
||||
|
||||
closeProjectMutation.mutate(project)
|
||||
|
||||
client
|
||||
.getMutationCache()
|
||||
.findAll({
|
||||
mutationKey: ['closeProject'],
|
||||
// this is unsafe, but we can't do anything about it
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
predicate: mutation => mutation.state.variables?.id === project.id,
|
||||
})
|
||||
.forEach(mutation => {
|
||||
mutation.setOptions({ ...mutation.options, scope: { id: project.id } })
|
||||
})
|
||||
|
||||
removeLaunchedProject(project.id)
|
||||
|
||||
setPage(TabType.drive)
|
||||
})
|
||||
|
||||
const doCloseAllProjects = eventCallbacks.useEventCallback(() => {
|
||||
for (const launchedProject of launchedProjects) {
|
||||
doCloseProject(launchedProject)
|
||||
}
|
||||
})
|
||||
|
||||
const doRemoveSelf = eventCallbacks.useEventCallback((project: Project) => {
|
||||
const doRemoveSelf = eventCallbacks.useEventCallback((project: projectHooks.Project) => {
|
||||
dispatchAssetListEvent({ type: AssetListEventType.removeSelf, id: project.id })
|
||||
doCloseProject(project)
|
||||
closeProject(project)
|
||||
})
|
||||
|
||||
const onSignOut = eventCallbacks.useEventCallback(() => {
|
||||
setPage(TabType.drive)
|
||||
doCloseAllProjects()
|
||||
setPage(projectsProvider.TabType.drive)
|
||||
closeAllProjects()
|
||||
clearLaunchedProjects()
|
||||
})
|
||||
|
||||
@ -580,11 +257,11 @@ function DashboardInner(props: DashboardProps) {
|
||||
<div className="flex">
|
||||
<TabBar>
|
||||
<tabBar.Tab
|
||||
isActive={page === TabType.drive}
|
||||
isActive={page === projectsProvider.TabType.drive}
|
||||
icon={DriveIcon}
|
||||
labelId="drivePageName"
|
||||
onPress={() => {
|
||||
setPage(TabType.drive)
|
||||
setPage(projectsProvider.TabType.drive)
|
||||
}}
|
||||
>
|
||||
{getText('drivePageName')}
|
||||
@ -592,6 +269,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
|
||||
{launchedProjects.map(project => (
|
||||
<tabBar.Tab
|
||||
data-testid="editor-tab-button"
|
||||
project={project}
|
||||
key={project.id}
|
||||
isActive={page === project.id}
|
||||
@ -601,26 +279,26 @@ function DashboardInner(props: DashboardProps) {
|
||||
setPage(project.id)
|
||||
}}
|
||||
onClose={() => {
|
||||
doCloseProject(project)
|
||||
closeProject(project)
|
||||
}}
|
||||
onLoadEnd={() => {
|
||||
doOpenEditor(project.id)
|
||||
openEditor(project.id)
|
||||
}}
|
||||
>
|
||||
{project.title}
|
||||
</tabBar.Tab>
|
||||
))}
|
||||
|
||||
{page === TabType.settings && (
|
||||
{page === projectsProvider.TabType.settings && (
|
||||
<tabBar.Tab
|
||||
isActive
|
||||
icon={SettingsIcon}
|
||||
labelId="settingsPageName"
|
||||
onPress={() => {
|
||||
setPage(TabType.settings)
|
||||
setPage(projectsProvider.TabType.settings)
|
||||
}}
|
||||
onClose={() => {
|
||||
setPage(TabType.drive)
|
||||
setPage(projectsProvider.TabType.drive)
|
||||
}}
|
||||
>
|
||||
{getText('settingsPageName')}
|
||||
@ -632,7 +310,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
onShareClick={selectedProject ? doOpenShareModal : undefined}
|
||||
setIsHelpChatOpen={setIsHelpChatOpen}
|
||||
goToSettingsPage={() => {
|
||||
setPage(TabType.settings)
|
||||
setPage(projectsProvider.TabType.settings)
|
||||
}}
|
||||
onSignOut={onSignOut}
|
||||
/>
|
||||
@ -640,14 +318,10 @@ function DashboardInner(props: DashboardProps) {
|
||||
|
||||
<Drive
|
||||
assetsManagementApiRef={assetManagementApiRef}
|
||||
openedProjects={launchedProjects}
|
||||
category={category}
|
||||
setCategory={setCategory}
|
||||
hidden={page !== TabType.drive}
|
||||
hidden={page !== projectsProvider.TabType.drive}
|
||||
initialProjectName={initialProjectName}
|
||||
doOpenProject={doOpenProject}
|
||||
doOpenEditor={doOpenEditor}
|
||||
doCloseProject={doCloseProject}
|
||||
/>
|
||||
|
||||
{launchedProjects.map(project => (
|
||||
@ -668,7 +342,7 @@ function DashboardInner(props: DashboardProps) {
|
||||
/>
|
||||
))}
|
||||
|
||||
{page === TabType.settings && <Settings />}
|
||||
{page === projectsProvider.TabType.settings && <Settings />}
|
||||
{process.env.ENSO_CLOUD_CHAT_URL != null ? (
|
||||
<Chat
|
||||
isOpen={isHelpChatOpen}
|
||||
|
238
app/ide-desktop/lib/dashboard/src/providers/ProjectsProvider.tsx
Normal file
238
app/ide-desktop/lib/dashboard/src/providers/ProjectsProvider.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
/** @file The React provider (and associated hooks) for providing reactive events. */
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
import * as z from 'zod'
|
||||
import * as zustand from 'zustand'
|
||||
|
||||
import * as eventCallbacks from '#/hooks/eventCallbackHooks'
|
||||
import type * as projectHooks from '#/hooks/projectHooks'
|
||||
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
|
||||
// ===============
|
||||
// === TabType ===
|
||||
// ===============
|
||||
|
||||
/** Main content of the screen. Only one should be visible at a time. */
|
||||
export enum TabType {
|
||||
drive = 'drive',
|
||||
settings = 'settings',
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
|
||||
declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly isAssetPanelVisible: boolean
|
||||
readonly page: z.infer<typeof PAGES_SCHEMA>
|
||||
}
|
||||
}
|
||||
|
||||
const PAGES_SCHEMA = z
|
||||
.nativeEnum(TabType)
|
||||
.or(z.custom<projectHooks.ProjectId>(value => typeof value === 'string'))
|
||||
|
||||
LocalStorage.registerKey('page', { schema: PAGES_SCHEMA })
|
||||
|
||||
// =====================
|
||||
// === ProjectsStore ===
|
||||
// =====================
|
||||
|
||||
/** The state of this zustand store. */
|
||||
interface ProjectsStore {
|
||||
readonly page: projectHooks.ProjectId | TabType
|
||||
readonly setPage: (page: projectHooks.ProjectId | TabType) => void
|
||||
readonly launchedProjects: readonly projectHooks.Project[]
|
||||
readonly addLaunchedProject: (project: projectHooks.Project) => void
|
||||
readonly removeLaunchedProject: (projectId: projectHooks.ProjectId) => void
|
||||
readonly clearLaunchedProjects: () => void
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === ProjectsContext ===
|
||||
// =======================
|
||||
|
||||
/** State contained in a `ProjectsContext`. */
|
||||
export interface ProjectsContextType extends zustand.StoreApi<ProjectsStore> {}
|
||||
|
||||
const ProjectsContext = React.createContext<ProjectsContextType | null>(null)
|
||||
|
||||
/** Props for a {@link ProjectsProvider}. */
|
||||
export interface ProjectsProviderProps extends Readonly<React.PropsWithChildren> {}
|
||||
|
||||
// ========================
|
||||
// === ProjectsProvider ===
|
||||
// ========================
|
||||
|
||||
/** A React provider (and associated hooks) for determining whether the current area
|
||||
* containing the current element is focused. */
|
||||
export default function ProjectsProvider(props: ProjectsProviderProps) {
|
||||
const { children } = props
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const [store] = React.useState(() => {
|
||||
return zustand.createStore<ProjectsStore>(set => ({
|
||||
page: TabType.drive,
|
||||
setPage: page => {
|
||||
set({ page })
|
||||
},
|
||||
launchedProjects: localStorage.get('launchedProjects') ?? [],
|
||||
addLaunchedProject: project => {
|
||||
set(({ launchedProjects }) => ({ launchedProjects: [...launchedProjects, project] }))
|
||||
},
|
||||
removeLaunchedProject: projectId => {
|
||||
set(({ launchedProjects }) => ({
|
||||
launchedProjects: launchedProjects.filter(({ id }) => id !== projectId),
|
||||
}))
|
||||
},
|
||||
clearLaunchedProjects: () => {
|
||||
set({ launchedProjects: [] })
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
return (
|
||||
<ProjectsContext.Provider value={store}>
|
||||
<PageSynchronizer />
|
||||
{children}
|
||||
</ProjectsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === PageSynchronizer ===
|
||||
// ========================
|
||||
|
||||
/** A component to synchronize React state with search parmas state. */
|
||||
function PageSynchronizer() {
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const store = useProjectsStore()
|
||||
const providerSetPage = useSetPage()
|
||||
const [page, privateSetPage] = searchParamsState.useSearchParamsState(
|
||||
'page',
|
||||
() => store.getState().page,
|
||||
(value: unknown): value is projectHooks.ProjectId | TabType => {
|
||||
return (
|
||||
array.includes(Object.values(TabType), value) ||
|
||||
store.getState().launchedProjects.some(p => p.id === value)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
providerSetPage(page)
|
||||
}, [page, providerSetPage])
|
||||
|
||||
React.useEffect(() =>
|
||||
store.subscribe(state => {
|
||||
privateSetPage(state.page)
|
||||
})
|
||||
)
|
||||
|
||||
React.useEffect(() =>
|
||||
store.subscribe(state => {
|
||||
localStorage.set('launchedProjects', state.launchedProjects)
|
||||
})
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === useProjectsStore ===
|
||||
// ========================
|
||||
|
||||
/** The projects store. */
|
||||
export function useProjectsStore() {
|
||||
const store = React.useContext(ProjectsContext)
|
||||
|
||||
invariant(store, 'Projects store store can only be used inside an `ProjectsProvider`.')
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// =============================
|
||||
// === useAddLaunchedProject ===
|
||||
// =============================
|
||||
|
||||
/** A function to retrieve all launched projects. */
|
||||
export function useLaunchedProjects() {
|
||||
const store = useProjectsStore()
|
||||
return zustand.useStore(store, state => state.launchedProjects)
|
||||
}
|
||||
|
||||
// =============================
|
||||
// === useAddLaunchedProject ===
|
||||
// =============================
|
||||
|
||||
/** A function to add a new launched projoect. */
|
||||
export function useAddLaunchedProject() {
|
||||
const store = useProjectsStore()
|
||||
const addLaunchedProject = zustand.useStore(store, state => state.addLaunchedProject)
|
||||
return eventCallbacks.useEventCallback((project: projectHooks.Project) => {
|
||||
React.startTransition(() => {
|
||||
addLaunchedProject(project)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === useRemoveLaunchedProject ===
|
||||
// ================================
|
||||
|
||||
/** A function to remove a launched project. */
|
||||
export function useRemoveLaunchedProject() {
|
||||
const store = useProjectsStore()
|
||||
const removeLaunchedProject = zustand.useStore(store, state => state.removeLaunchedProject)
|
||||
return eventCallbacks.useEventCallback((projectId: projectHooks.ProjectId) => {
|
||||
React.startTransition(() => {
|
||||
removeLaunchedProject(projectId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === useClearLaunchedProjects ===
|
||||
// ================================
|
||||
|
||||
/** A function to remove all launched projects. */
|
||||
export function useClearLaunchedProjects() {
|
||||
const store = useProjectsStore()
|
||||
const clearLaunchedProjects = zustand.useStore(store, state => state.clearLaunchedProjects)
|
||||
return eventCallbacks.useEventCallback(() => {
|
||||
React.startTransition(() => {
|
||||
clearLaunchedProjects()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === usePage ===
|
||||
// ===============
|
||||
|
||||
/** A function to get the current page. */
|
||||
export function usePage() {
|
||||
const store = useProjectsStore()
|
||||
return zustand.useStore(store, state => state.page)
|
||||
}
|
||||
// ==================
|
||||
// === useSetPage ===
|
||||
// ==================
|
||||
|
||||
/** A function to set the current page. */
|
||||
export function useSetPage() {
|
||||
const store = useProjectsStore()
|
||||
const setPage = zustand.useStore(store, state => state.setPage)
|
||||
return eventCallbacks.useEventCallback((page: projectHooks.ProjectId | TabType) => {
|
||||
React.startTransition(() => {
|
||||
setPage(page)
|
||||
})
|
||||
})
|
||||
}
|
@ -9,6 +9,7 @@ import type ProjectManager from '#/services/ProjectManager'
|
||||
|
||||
import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as download from '#/utilities/download'
|
||||
import * as errorModule from '#/utilities/error'
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
|
||||
@ -162,7 +163,6 @@ export default class LocalBackend extends Backend {
|
||||
this.projectManager.projects.get(entry.metadata.id)?.state ??
|
||||
backend.ProjectState.closed,
|
||||
volumeId: '',
|
||||
path: entry.path,
|
||||
},
|
||||
labels: [],
|
||||
description: null,
|
||||
@ -193,7 +193,7 @@ export default class LocalBackend extends Backend {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
return result.projects.map(project => ({
|
||||
name: project.name,
|
||||
organizationId: '',
|
||||
organizationId: backend.OrganizationId(''),
|
||||
projectId: newProjectId(project.id),
|
||||
packageName: project.name,
|
||||
state: {
|
||||
@ -218,20 +218,12 @@ export default class LocalBackend extends Backend {
|
||||
missingComponentAction: projectManager.MissingComponentAction.install,
|
||||
...(projectsDirectory == null ? {} : { projectsDirectory }),
|
||||
})
|
||||
const path = projectManager.joinPath(
|
||||
projectsDirectory ?? this.projectManager.rootDirectory,
|
||||
project.projectNormalizedName
|
||||
)
|
||||
return {
|
||||
name: project.projectName,
|
||||
organizationId: '',
|
||||
organizationId: backend.OrganizationId(''),
|
||||
projectId: newProjectId(project.projectId),
|
||||
packageName: project.projectName,
|
||||
state: {
|
||||
type: backend.ProjectState.closed,
|
||||
volumeId: '',
|
||||
path,
|
||||
},
|
||||
state: { type: backend.ProjectState.closed, volumeId: '' },
|
||||
}
|
||||
}
|
||||
|
||||
@ -293,7 +285,7 @@ export default class LocalBackend extends Backend {
|
||||
ideVersion: version,
|
||||
jsonAddress: null,
|
||||
binaryAddress: null,
|
||||
organizationId: '',
|
||||
organizationId: backend.OrganizationId(''),
|
||||
packageName: project.name,
|
||||
projectId,
|
||||
state: { type: backend.ProjectState.closed, volumeId: '' },
|
||||
@ -313,7 +305,7 @@ export default class LocalBackend extends Backend {
|
||||
},
|
||||
jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress),
|
||||
binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress),
|
||||
organizationId: '',
|
||||
organizationId: backend.OrganizationId(''),
|
||||
packageName: cachedProject.projectNormalizedName,
|
||||
projectId,
|
||||
state: {
|
||||
@ -364,10 +356,9 @@ export default class LocalBackend extends Backend {
|
||||
await this.projectManager.renameProject({
|
||||
projectId: id,
|
||||
name: projectManager.ProjectName(body.projectName),
|
||||
projectsDirectory: extractTypeAndId(body.parentId).id,
|
||||
})
|
||||
}
|
||||
const parentId = extractTypeAndId(body.parentId).id
|
||||
const parentId = this.projectManager.getProjectDirectoryPath(id)
|
||||
const result = await this.projectManager.listDirectory(parentId)
|
||||
const project = result.flatMap(listedProject =>
|
||||
listedProject.type === projectManager.FileSystemEntryType.ProjectEntry &&
|
||||
@ -390,18 +381,31 @@ export default class LocalBackend extends Backend {
|
||||
engineVersion: version,
|
||||
ideVersion: version,
|
||||
name: project.name,
|
||||
organizationId: '',
|
||||
organizationId: backend.OrganizationId(''),
|
||||
projectId,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Duplicate a specific version of a project. */
|
||||
override async duplicateProject(projectId: backend.ProjectId): Promise<backend.CreatedProject> {
|
||||
const id = extractTypeAndId(projectId).id
|
||||
const project = await this.projectManager.duplicateProject({ projectId: id })
|
||||
return {
|
||||
projectId: newProjectId(project.projectId),
|
||||
name: project.projectName,
|
||||
packageName: project.projectNormalizedName,
|
||||
organizationId: backend.OrganizationId(''),
|
||||
state: { type: backend.ProjectState.closed, volumeId: '' },
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete an arbitrary asset.
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
override async deleteAsset(
|
||||
assetId: backend.AssetId,
|
||||
body: backend.DeleteAssetRequestBody,
|
||||
_body: backend.DeleteAssetRequestBody,
|
||||
title: string | null
|
||||
): Promise<void> {
|
||||
const typeAndId = extractTypeAndId(assetId)
|
||||
@ -413,10 +417,7 @@ export default class LocalBackend extends Backend {
|
||||
}
|
||||
case backend.AssetType.project: {
|
||||
try {
|
||||
await this.projectManager.deleteProject({
|
||||
projectId: typeAndId.id,
|
||||
projectsDirectory: extractTypeAndId(body.parentId).id,
|
||||
})
|
||||
await this.projectManager.deleteProject({ projectId: typeAndId.id })
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
@ -429,10 +430,30 @@ export default class LocalBackend extends Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy an arbitrary asset to another directory. Not yet implemented in the backend.
|
||||
* @throws {Error} Always. */
|
||||
override copyAsset(): Promise<backend.CopyAssetResponse> {
|
||||
throw new Error('Cannot copy assets in local backend yet.')
|
||||
/** Copy an arbitrary asset to another directory. */
|
||||
override async copyAsset(
|
||||
assetId: backend.AssetId,
|
||||
parentDirectoryId: backend.DirectoryId
|
||||
): Promise<backend.CopyAssetResponse> {
|
||||
const typeAndId = extractTypeAndId(assetId)
|
||||
if (typeAndId.type !== backend.AssetType.project) {
|
||||
throw new Error('Only projects can be copied on the Local Backend.')
|
||||
} else {
|
||||
const project = await this.projectManager.duplicateProject({ projectId: typeAndId.id })
|
||||
const projectPath = this.projectManager.projectPaths.get(typeAndId.id)
|
||||
const parentPath =
|
||||
projectPath == null ? null : projectManager.getDirectoryAndName(projectPath).directoryPath
|
||||
if (parentPath !== extractTypeAndId(parentDirectoryId).id) {
|
||||
throw new Error('Cannot duplicate project to a different directory on the Local Backend.')
|
||||
} else {
|
||||
const asset = {
|
||||
id: newProjectId(project.projectId),
|
||||
parentId: parentDirectoryId,
|
||||
title: project.projectName,
|
||||
}
|
||||
return { asset }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a list of engine versions. */
|
||||
@ -559,15 +580,13 @@ export default class LocalBackend extends Backend {
|
||||
): Promise<void> {
|
||||
if (body.parentDirectoryId != null) {
|
||||
const typeAndId = extractTypeAndId(assetId)
|
||||
const from = typeAndId.type === backend.AssetType.project ? body.projectPath : typeAndId.id
|
||||
if (from == null) {
|
||||
throw new Error('Could not move project: project has no `projectPath`.')
|
||||
} else {
|
||||
const from =
|
||||
typeAndId.type !== backend.AssetType.project
|
||||
? typeAndId.id
|
||||
: this.projectManager.getProjectDirectoryPath(typeAndId.id)
|
||||
const fileName = fileInfo.fileName(from)
|
||||
const to = projectManager.joinPath(extractTypeAndId(body.parentDirectoryId).id, fileName)
|
||||
await this.projectManager.moveFile(from, to)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -604,13 +623,10 @@ export default class LocalBackend extends Backend {
|
||||
const to = projectManager.joinPath(projectManager.Path(folderPath), body.title)
|
||||
await this.projectManager.moveFile(from, to)
|
||||
}
|
||||
/** Return a {@link Promise} that resolves only when a project is ready to open. */
|
||||
override async waitUntilProjectIsReady(
|
||||
projectId: backend.ProjectId,
|
||||
directory: backend.DirectoryId | null,
|
||||
title: string
|
||||
) {
|
||||
return await this.getProjectDetails(projectId, directory, title)
|
||||
|
||||
/** Construct a new path using the given parent directory and a file name. */
|
||||
getProjectDirectoryPath(id: backend.ProjectId) {
|
||||
return this.projectManager.getProjectDirectoryPath(extractTypeAndId(id).id)
|
||||
}
|
||||
|
||||
/** Construct a new path using the given parent directory and a file name. */
|
||||
@ -634,9 +650,10 @@ export default class LocalBackend extends Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Invalid operation. */
|
||||
override duplicateProject() {
|
||||
return this.invalidOperation()
|
||||
/** Download from an arbitrary URL that is assumed to originate from this backend. */
|
||||
override async download(url: string, name?: string) {
|
||||
download.download(url, name)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/** Invalid operation. */
|
||||
|
@ -6,7 +6,7 @@ import * as detect from 'enso-common/src/detect'
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import type * as dateTime from '#/utilities/dateTime'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as newtype from '#/utilities/newtype'
|
||||
|
||||
// =================
|
||||
@ -178,7 +178,14 @@ export interface EngineVersion {
|
||||
|
||||
/** The return value of the "list available engine versions" endpoint. */
|
||||
export interface VersionList {
|
||||
readonly versions: EngineVersion[]
|
||||
readonly versions: readonly EngineVersion[]
|
||||
}
|
||||
|
||||
/** The return value of the "duplicate project" endpoint. */
|
||||
export interface DuplicatedProject {
|
||||
readonly projectId: UUID
|
||||
readonly projectName: string
|
||||
readonly projectNormalizedName: string
|
||||
}
|
||||
|
||||
// ====================
|
||||
@ -231,13 +238,19 @@ export interface CreateProjectParams {
|
||||
readonly projectsDirectory?: Path
|
||||
}
|
||||
|
||||
/** Parameters for the "list samples" endpoint. */
|
||||
/** Parameters for the "rename project" endpoint. */
|
||||
export interface RenameProjectParams {
|
||||
readonly projectId: UUID
|
||||
readonly name: ProjectName
|
||||
readonly projectsDirectory?: Path
|
||||
}
|
||||
|
||||
/** Parameters for the "duplicate project" endpoint. */
|
||||
export interface DuplicateProjectParams {
|
||||
readonly projectId: UUID
|
||||
readonly projectsDirectory?: Path
|
||||
}
|
||||
|
||||
/** Parameters for the "delete project" endpoint. */
|
||||
export interface DeleteProjectParams {
|
||||
readonly projectId: UUID
|
||||
@ -266,6 +279,16 @@ function normalizeSlashes(path: string): Path {
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// === getDirectoryAndName ===
|
||||
// ===========================
|
||||
|
||||
/** Split a {@link Path} inito the path of its parent directory, and its file name. */
|
||||
export function getDirectoryAndName(path: Path) {
|
||||
const [, directoryPath = '', fileName = ''] = path.match(/^(.+)[/]([^/]+)$/) ?? []
|
||||
return { directoryPath: Path(directoryPath), fileName }
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === Project Manager ===
|
||||
// =======================
|
||||
@ -280,10 +303,19 @@ export enum ProjectManagerEvents {
|
||||
* It should always be in sync with the Rust interface at
|
||||
* `app/gui/controller/engine-protocol/src/project_manager.rs`. */
|
||||
export default class ProjectManager {
|
||||
// This is required so that projects get recursively updated (deleted, renamed or moved).
|
||||
private readonly internalDirectories = new Map<Path, readonly FileSystemEntry[]>()
|
||||
private readonly internalProjects = new Map<UUID, ProjectState>()
|
||||
private readonly internalProjectPaths = new Map<UUID, Path>()
|
||||
// This MUST be declared after `internalDirectories` because it depends on `internalDirectories`.
|
||||
// eslint-disable-next-line @typescript-eslint/member-ordering
|
||||
readonly directories: ReadonlyMap<UUID, ProjectState> = this.internalProjects
|
||||
// This MUST be declared after `internalProjects` because it depends on `internalProjects`.
|
||||
// eslint-disable-next-line @typescript-eslint/member-ordering
|
||||
readonly projects: ReadonlyMap<UUID, ProjectState> = this.internalProjects
|
||||
// This MUST be declared after `internalProjectPaths` because it depends on `internalProjectPaths`.
|
||||
// eslint-disable-next-line @typescript-eslint/member-ordering
|
||||
readonly projectPaths: ReadonlyMap<UUID, Path> = this.internalProjectPaths
|
||||
private id = 0
|
||||
private resolvers = new Map<number, (value: never) => void>()
|
||||
private rejecters = new Map<number, (reason?: JSONRPCError) => void>()
|
||||
@ -355,6 +387,12 @@ export default class ProjectManager {
|
||||
socket.close()
|
||||
}
|
||||
|
||||
/** Get the directory path of a project. */
|
||||
getProjectDirectoryPath(projectId: UUID) {
|
||||
const projectPath = this.internalProjectPaths.get(projectId)
|
||||
return projectPath == null ? this.rootDirectory : getDirectoryAndName(projectPath).directoryPath
|
||||
}
|
||||
|
||||
/** Open an existing project. */
|
||||
async openProject(params: OpenProjectParams): Promise<OpenProject> {
|
||||
const cached = this.internalProjects.get(params.projectId)
|
||||
@ -388,21 +426,67 @@ export default class ProjectManager {
|
||||
|
||||
/** Create a new project. */
|
||||
async createProject(params: CreateProjectParams): Promise<CreateProject> {
|
||||
return this.sendRequest<CreateProject>('project/create', {
|
||||
const result = await this.sendRequest<CreateProject>('project/create', {
|
||||
missingComponentAction: MissingComponentAction.install,
|
||||
...params,
|
||||
})
|
||||
const directoryPath = params.projectsDirectory ?? this.rootDirectory
|
||||
// Update `internalDirectories` by listing the project's parent directory, because the
|
||||
// directory name of the project is unknown. Deleting the directory is not an option because
|
||||
// that will prevent ALL descendants of the parent directory from being updated.
|
||||
await this.listDirectory(directoryPath)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Rename a project. */
|
||||
async renameProject(params: RenameProjectParams): Promise<void> {
|
||||
return this.sendRequest('project/rename', params)
|
||||
async renameProject(params: Omit<RenameProjectParams, 'projectsDirectory'>): Promise<void> {
|
||||
const path = this.internalProjectPaths.get(params.projectId)
|
||||
const directoryPath =
|
||||
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
|
||||
const fullParams: RenameProjectParams = { ...params, projectsDirectory: directoryPath }
|
||||
await this.sendRequest('project/rename', fullParams)
|
||||
// Update `internalDirectories` by listing the project's parent directory, because the new
|
||||
// directory name of the project is unknown. Deleting the directory is not an option because
|
||||
// that will prevent ALL descendants of the parent directory from being updated.
|
||||
await this.listDirectory(directoryPath)
|
||||
}
|
||||
|
||||
/** Duplicate a project. */
|
||||
async duplicateProject(
|
||||
params: Omit<DuplicateProjectParams, 'projectsDirectory'>
|
||||
): Promise<DuplicatedProject> {
|
||||
const path = this.internalProjectPaths.get(params.projectId)
|
||||
const directoryPath =
|
||||
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
|
||||
const fullParams: DuplicateProjectParams = { ...params, projectsDirectory: directoryPath }
|
||||
const result = this.sendRequest<DuplicatedProject>('project/duplicate', fullParams)
|
||||
// Update `internalDirectories` by listing the project's parent directory, because the
|
||||
// directory name of the project is unknown. Deleting the directory is not an option because
|
||||
// that will prevent ALL descendants of the parent directory from being updated.
|
||||
await this.listDirectory(directoryPath)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Delete a project. */
|
||||
async deleteProject(params: DeleteProjectParams): Promise<void> {
|
||||
async deleteProject(params: Omit<DeleteProjectParams, 'projectsDirectory'>): Promise<void> {
|
||||
const path = this.internalProjectPaths.get(params.projectId)
|
||||
const directoryPath =
|
||||
path == null ? this.rootDirectory : getDirectoryAndName(path).directoryPath
|
||||
const fullParams: DeleteProjectParams = { ...params, projectsDirectory: directoryPath }
|
||||
await this.sendRequest('project/delete', fullParams)
|
||||
this.internalProjectPaths.delete(params.projectId)
|
||||
this.internalProjects.delete(params.projectId)
|
||||
return this.sendRequest('project/delete', params)
|
||||
const siblings = this.internalDirectories.get(directoryPath)
|
||||
if (siblings != null) {
|
||||
this.internalDirectories.set(
|
||||
directoryPath,
|
||||
siblings.filter(
|
||||
entry =>
|
||||
entry.type !== FileSystemEntryType.ProjectEntry ||
|
||||
entry.metadata.id !== params.projectId
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** List installed engine versions. */
|
||||
@ -430,37 +514,161 @@ export default class ProjectManager {
|
||||
}
|
||||
|
||||
/** List directories, projects and files in the given folder. */
|
||||
async listDirectory(parentId: Path | null) {
|
||||
async listDirectory(parentId: Path | null): Promise<readonly FileSystemEntry[]> {
|
||||
/** The type of the response body of this endpoint. */
|
||||
interface ResponseBody {
|
||||
readonly entries: FileSystemEntry[]
|
||||
}
|
||||
parentId ??= this.rootDirectory
|
||||
const response = await this.runStandaloneCommand<ResponseBody>(
|
||||
null,
|
||||
'filesystem-list',
|
||||
parentId ?? this.rootDirectory
|
||||
parentId
|
||||
)
|
||||
return response.entries.map(entry => ({ ...entry, path: normalizeSlashes(entry.path) }))
|
||||
const result = response.entries.map(entry => ({ ...entry, path: normalizeSlashes(entry.path) }))
|
||||
this.internalDirectories.set(parentId, result)
|
||||
for (const entry of result) {
|
||||
if (entry.type === FileSystemEntryType.ProjectEntry) {
|
||||
this.internalProjectPaths.set(entry.metadata.id, entry.path)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Create a directory. */
|
||||
async createDirectory(path: Path) {
|
||||
return this.runStandaloneCommand(null, 'filesystem-create-directory', path)
|
||||
await this.runStandaloneCommand(null, 'filesystem-create-directory', path)
|
||||
this.internalDirectories.set(path, [])
|
||||
const directoryPath = getDirectoryAndName(path).directoryPath
|
||||
const siblings = this.internalDirectories.get(directoryPath)
|
||||
if (siblings) {
|
||||
const now = dateTime.toRfc3339(new Date())
|
||||
this.internalDirectories.set(directoryPath, [
|
||||
...siblings.filter(sibling => sibling.type === FileSystemEntryType.DirectoryEntry),
|
||||
{
|
||||
type: FileSystemEntryType.DirectoryEntry,
|
||||
attributes: {
|
||||
byteSize: 0,
|
||||
creationTime: now,
|
||||
lastAccessTime: now,
|
||||
lastModifiedTime: now,
|
||||
},
|
||||
path,
|
||||
},
|
||||
...siblings.filter(sibling => sibling.type !== FileSystemEntryType.DirectoryEntry),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a file. */
|
||||
async createFile(path: Path, file: Blob) {
|
||||
await this.runStandaloneCommand(file, 'filesystem-write-path', path)
|
||||
const directoryPath = getDirectoryAndName(path).directoryPath
|
||||
const siblings = this.internalDirectories.get(directoryPath)
|
||||
if (siblings) {
|
||||
const now = dateTime.toRfc3339(new Date())
|
||||
this.internalDirectories.set(directoryPath, [
|
||||
...siblings.filter(sibling => sibling.type !== FileSystemEntryType.FileEntry),
|
||||
{
|
||||
type: FileSystemEntryType.FileEntry,
|
||||
attributes: {
|
||||
byteSize: file.size,
|
||||
creationTime: now,
|
||||
lastAccessTime: now,
|
||||
lastModifiedTime: now,
|
||||
},
|
||||
path,
|
||||
},
|
||||
...siblings.filter(sibling => sibling.type === FileSystemEntryType.FileEntry),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Move a file or directory. */
|
||||
async moveFile(from: Path, to: Path) {
|
||||
await this.runStandaloneCommand(null, 'filesystem-move-from', from, '--filesystem-move-to', to)
|
||||
const children = this.internalDirectories.get(from)
|
||||
// Assume a directory needs to be loaded for its children to be loaded.
|
||||
if (children) {
|
||||
const moveChildren = (directoryChildren: readonly FileSystemEntry[]) => {
|
||||
for (const child of directoryChildren) {
|
||||
switch (child.type) {
|
||||
case FileSystemEntryType.DirectoryEntry: {
|
||||
const childChildren = this.internalDirectories.get(child.path)
|
||||
if (childChildren) {
|
||||
moveChildren(childChildren)
|
||||
}
|
||||
break
|
||||
}
|
||||
case FileSystemEntryType.ProjectEntry: {
|
||||
const path = this.internalProjectPaths.get(child.metadata.id)
|
||||
if (path != null) {
|
||||
this.internalProjectPaths.set(child.metadata.id, Path(path.replace(from, to)))
|
||||
}
|
||||
break
|
||||
}
|
||||
case FileSystemEntryType.FileEntry: {
|
||||
// No special extra metadata is stored for files.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
this.internalDirectories.set(
|
||||
from,
|
||||
children.map(child => ({ ...child, path: Path(child.path.replace(from, to)) }))
|
||||
)
|
||||
}
|
||||
moveChildren(children)
|
||||
}
|
||||
const directoryPath = getDirectoryAndName(from).directoryPath
|
||||
const siblings = this.internalDirectories.get(directoryPath)
|
||||
if (siblings) {
|
||||
this.internalDirectories.set(
|
||||
directoryPath,
|
||||
siblings.filter(entry => entry.path !== from)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete a file or directory. */
|
||||
async deleteFile(path: Path) {
|
||||
await this.runStandaloneCommand(null, 'filesystem-delete', path)
|
||||
const children = this.internalDirectories.get(path)
|
||||
// Assume a directory needs to be loaded for its children to be loaded.
|
||||
if (children) {
|
||||
const removeChildren = (directoryChildren: readonly FileSystemEntry[]) => {
|
||||
for (const child of directoryChildren) {
|
||||
switch (child.type) {
|
||||
case FileSystemEntryType.DirectoryEntry: {
|
||||
const childChildren = this.internalDirectories.get(child.path)
|
||||
if (childChildren) {
|
||||
removeChildren(childChildren)
|
||||
}
|
||||
break
|
||||
}
|
||||
case FileSystemEntryType.ProjectEntry: {
|
||||
this.internalProjects.delete(child.metadata.id)
|
||||
this.internalProjectPaths.delete(child.metadata.id)
|
||||
break
|
||||
}
|
||||
case FileSystemEntryType.FileEntry: {
|
||||
// No special extra metadata is stored for files.
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
removeChildren(children)
|
||||
this.internalDirectories.delete(path)
|
||||
}
|
||||
const directoryPath = getDirectoryAndName(path).directoryPath
|
||||
const siblings = this.internalDirectories.get(directoryPath)
|
||||
if (siblings) {
|
||||
this.internalDirectories.set(
|
||||
directoryPath,
|
||||
siblings.filter(entry => entry.path !== path)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove all handlers for a specified request ID. */
|
||||
|
@ -12,6 +12,7 @@ import type * as textProvider from '#/providers/TextProvider'
|
||||
import Backend, * as backend from '#/services/Backend'
|
||||
import * as remoteBackendPaths from '#/services/remoteBackendPaths'
|
||||
|
||||
import * as download from '#/utilities/download'
|
||||
import type HttpClient from '#/utilities/HttpClient'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
@ -35,9 +36,6 @@ const STATUS_NOT_AUTHORIZED = 401
|
||||
/** The number of milliseconds in one day. */
|
||||
const ONE_DAY_MS = 86_400_000
|
||||
|
||||
/** The interval between requests checking whether a project is ready to be opened in the IDE. */
|
||||
const CHECK_STATUS_INTERVAL_MS = 5000
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
@ -537,10 +535,9 @@ export default class RemoteBackend extends Backend {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
override async deleteAsset(
|
||||
assetId: backend.AssetId,
|
||||
bodyRaw: backend.DeleteAssetRequestBody,
|
||||
body: backend.DeleteAssetRequestBody,
|
||||
title: string
|
||||
) {
|
||||
const body = object.omit(bodyRaw, 'parentId')
|
||||
const paramsString = new URLSearchParams([['force', String(body.force)]]).toString()
|
||||
const path = remoteBackendPaths.deleteAssetPath(assetId) + '?' + paramsString
|
||||
const response = await this.delete(path)
|
||||
@ -749,10 +746,9 @@ export default class RemoteBackend extends Backend {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
override async updateProject(
|
||||
projectId: backend.ProjectId,
|
||||
bodyRaw: backend.UpdateProjectRequestBody,
|
||||
body: backend.UpdateProjectRequestBody,
|
||||
title: string
|
||||
): Promise<backend.UpdatedProject> {
|
||||
const body = object.omit(bodyRaw, 'parentId')
|
||||
const path = remoteBackendPaths.projectUpdatePath(projectId)
|
||||
const response = await this.put<backend.UpdatedProject>(path, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -1110,21 +1106,9 @@ export default class RemoteBackend extends Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a {@link Promise} that resolves only when a project is ready to open. */
|
||||
override async waitUntilProjectIsReady(
|
||||
projectId: backend.ProjectId,
|
||||
directory: backend.DirectoryId | null,
|
||||
title: string,
|
||||
abortSignal?: AbortSignal
|
||||
) {
|
||||
let project = await this.getProjectDetails(projectId, directory, title)
|
||||
|
||||
while (project.state.type !== backend.ProjectState.opened && abortSignal?.aborted !== true) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, CHECK_STATUS_INTERVAL_MS))
|
||||
project = await this.getProjectDetails(projectId, directory, title)
|
||||
}
|
||||
|
||||
return project
|
||||
/** Download from an arbitrary URL that is assumed to originate from this backend. */
|
||||
override async download(url: string, name?: string) {
|
||||
await download.downloadWithHeaders(url, this.client.defaultHeaders, name)
|
||||
}
|
||||
|
||||
/** Get the default version given the type of version (IDE or backend). */
|
||||
|
@ -52,7 +52,7 @@ export default class HttpClient {
|
||||
*
|
||||
* This is useful for setting headers that are required for every request, like
|
||||
* authentication tokens. */
|
||||
public defaultHeaders: HeadersInit = {}
|
||||
public defaultHeaders: Record<string, string> = {}
|
||||
) {}
|
||||
|
||||
/** Send an HTTP GET request to the specified URL. */
|
||||
|
@ -1,7 +1,13 @@
|
||||
/** @file A function to initiate a download. */
|
||||
|
||||
/** Initiates a download for the specified url. */
|
||||
// ================
|
||||
// === download ===
|
||||
// ================
|
||||
|
||||
/** Initiate a download for the specified url. */
|
||||
export function download(url: string, name?: string | null) {
|
||||
url = new URL(url, location.toString()).toString()
|
||||
// Avoid using `window.systemApi` because the name is lost.
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = name ?? url.match(/[^/]+$/)?.[0] ?? ''
|
||||
@ -9,3 +15,24 @@ export function download(url: string, name?: string | null) {
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// === downloadWithHeaders ===
|
||||
// ===========================
|
||||
|
||||
/** Initiate a download with the specified headers, for the specified url. */
|
||||
export async function downloadWithHeaders(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
name?: string
|
||||
) {
|
||||
url = new URL(url, location.toString()).toString()
|
||||
if ('systemApi' in window) {
|
||||
window.systemApi.downloadURL(url, headers)
|
||||
} else {
|
||||
const response = await fetch(url, { headers })
|
||||
const body = await response.blob()
|
||||
const objectUrl = URL.createObjectURL(body)
|
||||
download(objectUrl, name ?? url.match(/[^/]+$/)?.[0] ?? '')
|
||||
}
|
||||
}
|
||||
|
@ -322,7 +322,10 @@ type NormalizeKeybindSegment = {
|
||||
/** A segment suggestible by autocomplete. */
|
||||
type SuggestedKeybindSegment = Key | Pointer | `${Modifier}+`
|
||||
/** A helper type used to autocomplete and validate a single keyboard shortcut in the editor. */
|
||||
type AutocompleteKeybind<T extends string, FoundKeyName extends string = never> = T extends '+'
|
||||
export type AutocompleteKeybind<
|
||||
T extends string,
|
||||
FoundKeyName extends string = never,
|
||||
> = T extends '+'
|
||||
? T
|
||||
: T extends `${infer First}+${infer Rest}`
|
||||
? Lowercase<First> extends LowercaseModifier
|
||||
|
@ -1,5 +1,4 @@
|
||||
/** @file Utilities for working with permissions. */
|
||||
|
||||
import * as permissions from 'enso-common/src/utilities/permissions'
|
||||
|
||||
export * from 'enso-common/src/utilities/permissions'
|
||||
|
1
app/ide-desktop/lib/types/globals.d.ts
vendored
1
app/ide-desktop/lib/types/globals.d.ts
vendored
@ -89,6 +89,7 @@ interface MenuApi {
|
||||
|
||||
/** `window.systemApi` exposes functionality related to the operating system. */
|
||||
interface SystemApi {
|
||||
readonly downloadURL: (url: string, headers?: Record<string, string>) => void
|
||||
readonly showItemInFolder: (fullPath: string) => void
|
||||
}
|
||||
|
||||
|
36
flake.nix
36
flake.nix
@ -18,11 +18,22 @@
|
||||
dir = ./.;
|
||||
sha256 = "sha256-o/MRwGYjLPyD1zZQe3LX0dOynwRJpVygfF9+vSnqTOc=";
|
||||
};
|
||||
isOnLinux = pkgs.lib.hasInfix "linux" system;
|
||||
rust-jni = if isOnLinux then with fenix.packages.${system}; combine [
|
||||
minimal.cargo
|
||||
minimal.rustc
|
||||
targets.x86_64-unknown-linux-musl.latest.rust-std
|
||||
] else fenix.packages.${system}.minimal;
|
||||
in
|
||||
pkgs.mkShell {
|
||||
pkgs.mkShell rec {
|
||||
buildInputs = with pkgs; [
|
||||
# === Graal dependencies ===
|
||||
libxcrypt-legacy
|
||||
];
|
||||
|
||||
packages = with pkgs; [
|
||||
# === TypeScript dependencies ===
|
||||
nodejs_20 # should match the Node.JS version of the lambdas
|
||||
nodejs_20
|
||||
corepack
|
||||
# === Electron ===
|
||||
electron
|
||||
@ -32,14 +43,31 @@
|
||||
# === WASM parser dependencies ===
|
||||
rust
|
||||
wasm-pack
|
||||
# Java and SBT omitted for now
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
SHIMS_PATH=$HOME/.local/share/enso/nix-shims
|
||||
# `sccache` can be used to speed up compile times for Rust crates.
|
||||
# `~/.cargo/bin/sccache` is provided by `cargo install sccache`.
|
||||
# `~/.cargo/bin` must be in the `PATH` for the binary to be accessible.
|
||||
export PATH=$HOME/.cargo/bin:$PATH
|
||||
export PATH=$SHIMS_PATH:${rust.out}:$HOME/.cargo/bin:$PATH
|
||||
export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH"
|
||||
|
||||
# `rustup` shim
|
||||
mkdir -p $SHIMS_PATH
|
||||
cat <<END > $SHIMS_PATH/rustup
|
||||
if [ "\$3" = "x86_64-unknown-linux-musl" ]; then
|
||||
echo 'Installing Nix Rust shims'
|
||||
ln -s ${rust-jni.out}/bin/rustc $SHIMS_PATH
|
||||
ln -s ${rust-jni.out}/bin/cargo $SHIMS_PATH
|
||||
else
|
||||
echo 'Uninstalling Nix Rust shims (if installed)'
|
||||
rm -f $SHIMS_PATH/{rustc,cargo}
|
||||
fi
|
||||
END
|
||||
chmod +x $SHIMS_PATH/rustup
|
||||
# Uninstall shims if already installed
|
||||
$SHIMS_PATH/rustup
|
||||
'';
|
||||
});
|
||||
};
|
||||
|
1
nix/bin/rustup
Executable file
1
nix/bin/rustup
Executable file
@ -0,0 +1 @@
|
||||
#!/bin/sh
|
@ -487,8 +487,8 @@ importers:
|
||||
specifier: ^20.11.21
|
||||
version: 20.11.21
|
||||
electron:
|
||||
specifier: 25.7.0
|
||||
version: 25.7.0
|
||||
specifier: 31.2.0
|
||||
version: 31.2.0
|
||||
electron-builder:
|
||||
specifier: ^24.13.3
|
||||
version: 24.13.3(electron-builder-squirrel-windows@24.13.3(dmg-builder@24.13.3))
|
||||
@ -2837,9 +2837,6 @@ packages:
|
||||
'@types/node-fetch@2.6.4':
|
||||
resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==}
|
||||
|
||||
'@types/node@18.19.39':
|
||||
resolution: {integrity: sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==}
|
||||
|
||||
'@types/node@20.11.21':
|
||||
resolution: {integrity: sha512-/ySDLGscFPNasfqStUuWWPfL78jompfIoVzLJPVVAHBh6rpG68+pI2Gk+fNLeI8/f1yPYL4s46EleVIc20F1Ow==}
|
||||
|
||||
@ -4089,8 +4086,8 @@ packages:
|
||||
electron-to-chromium@1.4.815:
|
||||
resolution: {integrity: sha512-OvpTT2ItpOXJL7IGcYakRjHCt8L5GrrN/wHCQsRB4PQa1X9fe+X9oen245mIId7s14xvArCGSTIq644yPUKKLg==}
|
||||
|
||||
electron@25.7.0:
|
||||
resolution: {integrity: sha512-P82EzYZ8k9J21x5syhXV7EkezDmEXwycReXnagfzS0kwepnrlWzq1aDIUWdNvzTdHobky4m/nYcL98qd73mEVA==}
|
||||
electron@31.2.0:
|
||||
resolution: {integrity: sha512-5w+kjOsGiTXytPSErBPNp/3znnuEMKc42RD41MqRoQkiYaR8x/Le2+qWk1cL60UwE/67oeKnOHnnol8xEuldGg==}
|
||||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
@ -10137,10 +10134,6 @@ snapshots:
|
||||
'@types/node': 20.11.21
|
||||
form-data: 3.0.1
|
||||
|
||||
'@types/node@18.19.39':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
|
||||
'@types/node@20.11.21':
|
||||
dependencies:
|
||||
undici-types: 5.26.5
|
||||
@ -11717,10 +11710,10 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.4.815: {}
|
||||
|
||||
electron@25.7.0:
|
||||
electron@31.2.0:
|
||||
dependencies:
|
||||
'@electron/get': 2.0.3
|
||||
'@types/node': 18.19.39
|
||||
'@types/node': 20.11.21
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@ -13820,6 +13813,12 @@ snapshots:
|
||||
dependencies:
|
||||
fast-diff: 1.3.0
|
||||
|
||||
prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3):
|
||||
dependencies:
|
||||
prettier: 3.3.2
|
||||
typescript: 5.5.3
|
||||
optional: true
|
||||
|
||||
prettier-plugin-organize-imports@4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3)):
|
||||
dependencies:
|
||||
prettier: 3.3.2
|
||||
@ -13832,7 +13831,7 @@ snapshots:
|
||||
prettier: 3.3.2
|
||||
optionalDependencies:
|
||||
'@ianvs/prettier-plugin-sort-imports': 4.3.0(@vue/compiler-sfc@3.4.31)(prettier@3.3.2)
|
||||
prettier-plugin-organize-imports: 4.0.0(prettier@3.3.2)(typescript@5.5.3)(vue-tsc@2.0.24(typescript@5.5.3))
|
||||
prettier-plugin-organize-imports: 4.0.0(prettier@3.3.2)(typescript@5.5.3)
|
||||
|
||||
prettier@3.3.2: {}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user