Merge branch 'develop' into wip/gmt/10480-tweaks

This commit is contained in:
Gregory Travis 2024-07-16 10:15:02 -04:00
commit a894da7ed6
90 changed files with 2435 additions and 1774 deletions

View File

@ -33,6 +33,7 @@ class DropDownLocator {
} }
async expectVisible(): Promise<void> { async expectVisible(): Promise<void> {
await expect(this.dropDown).toHaveCount(1)
await expect(this.dropDown).toBeVisible() await expect(this.dropDown).toBeVisible()
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,9 +23,8 @@ const EMAIL = 'baz.quux@email.com'
// ============= // =============
test.test('open and close asset panel', ({ page }) => test.test('open and close asset panel', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions
.createFolder() .createFolder()
.driveTable.clickRow(0) .driveTable.clickRow(0)
.withAssetPanel(async assetPanel => { .withAssetPanel(async assetPanel => {
@ -39,14 +38,13 @@ test.test('open and close asset panel', ({ page }) =>
.withAssetPanel(async assetPanel => { .withAssetPanel(async assetPanel => {
await actions.expectNotOnScreen(assetPanel) await actions.expectNotOnScreen(assetPanel)
}) })
)
) )
test.test('asset panel contents', ({ page }) => test.test('asset panel contents', ({ page }) =>
actions.mockAll({ page }).then( actions
async ({ pageActions, api }) => .mockAll({
await pageActions page,
.do(() => { setupAPI: api => {
const { defaultOrganizationId, defaultUserId } = api const { defaultOrganizationId, defaultUserId } = api
api.addProject('project', { api.addProject('project', {
description: DESCRIPTION, description: DESCRIPTION,
@ -63,6 +61,7 @@ test.test('asset panel contents', ({ page }) =>
}, },
], ],
}) })
},
}) })
.login() .login()
.do(async thePage => { .do(async thePage => {
@ -73,9 +72,6 @@ test.test('asset panel contents', ({ page }) =>
.do(async () => { .do(async () => {
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION) await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(DESCRIPTION)
// `getByText` is required so that this assertion works if there are multiple permissions. // `getByText` is required so that this assertion works if there are multiple permissions.
await test await test.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME)).toBeVisible()
.expect(actions.locateAssetPanelPermissions(page).getByText(USERNAME))
.toBeVisible()
}) })
)
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,9 +4,8 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('delete and restore', ({ page }) => test.test('delete and restore', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions
.createFolder() .createFolder()
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1) await test.expect(rows).toHaveCount(1)
@ -25,13 +24,11 @@ test.test('delete and restore', ({ page }) =>
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1) await test.expect(rows).toHaveCount(1)
}) })
)
) )
test.test('delete and restore (keyboard)', ({ page }) => test.test('delete and restore (keyboard)', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions
.createFolder() .createFolder()
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1) await test.expect(rows).toHaveCount(1)
@ -50,5 +47,4 @@ test.test('delete and restore (keyboard)', ({ page }) =>
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
await test.expect(rows).toHaveCount(1) await test.expect(rows).toHaveCount(1)
}) })
)
) )

View File

@ -4,9 +4,8 @@ import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
test.test('drive view', ({ page }) => test.test('drive view', ({ page }) =>
actions.mockAllAndLogin({ page }).then( actions
async ({ pageActions }) => .mockAllAndLogin({ page })
await pageActions
.withDriveView(async view => { .withDriveView(async view => {
await test.expect(view).toBeVisible() await test.expect(view).toBeVisible()
}) })
@ -36,17 +35,10 @@ test.test('drive view', ({ page }) =>
.driveTable.withRows(async rows => { .driveTable.withRows(async rows => {
await actions.locateStopProjectButton(rows.nth(0)).click() await actions.locateStopProjectButton(rows.nth(0)).click()
}) })
// FIXME(#10488): This test fails because the mock endpoint returns the project is opened,
// but it must be stopped first to delete the project.
// Project context menu // Project context menu
// .driveTable.rightClickRow(0) .driveTable.rightClickRow(0)
// .withContextMenus(async menus => { .contextMenu.moveToTrash()
// // actions.locateContextMenus(page) .driveTable.withRows(async rows => {
// await test.expect(menus).toBeVisible() await test.expect(rows).toHaveCount(1)
// }) })
// .contextMenu.moveToTrash()
// .driveTable.withRows(async rows => {
// await test.expect(rows).toHaveCount(1)
// })
)
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@
import * as test from '@playwright/test' import * as test from '@playwright/test'
import * as actions from './actions' import * as actions from './actions'
import type * as api from './api'
// ================= // =================
// === Constants === // === Constants ===
@ -75,16 +76,19 @@ test.test('sign up without organization id', async ({ page }) => {
.toBe(api.defaultOrganizationId) .toBe(api.defaultOrganizationId)
}) })
test.test('sign up flow', ({ page }) => test.test('sign up flow', ({ page }) => {
actions.mockAll({ page }).then( let api!: api.MockApi
async ({ pageActions, api }) => return actions
await pageActions .mockAll({
.do(() => { page,
api.setCurrentUser(null) setupAPI: theApi => {
api = theApi
theApi.setCurrentUser(null)
// These values should be different, otherwise the email and name may come from the defaults. // These values should be different, otherwise the email and name may come from the defaults.
test.expect(EMAIL).not.toStrictEqual(api.defaultEmail) test.expect(EMAIL).not.toStrictEqual(theApi.defaultEmail)
test.expect(NAME).not.toStrictEqual(api.defaultName) test.expect(NAME).not.toStrictEqual(theApi.defaultName)
},
}) })
.loginAsNewUser(EMAIL, actions.VALID_PASSWORD) .loginAsNewUser(EMAIL, actions.VALID_PASSWORD)
.do(async thePage => { .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()?.email, 'new user has correct email').toBe(EMAIL)
test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME) test.expect(api.currentUser()?.name, 'new user has correct name').toBe(NAME)
}) })
) })
)

View File

@ -20,10 +20,9 @@ const MIN_MS = 60_000
// ============= // =============
test.test('sort', async ({ page }) => { test.test('sort', async ({ page }) => {
const { api } = await actions.mockAll({ page }) await actions.mockAll({
const assetRows = actions.locateAssetRows(page) page,
const nameHeading = actions.locateNameColumnHeading(page) setupAPI: api => {
const modifiedHeading = actions.locateModifiedColumnHeading(page)
const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS)) const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS))
const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS)) const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS)) const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
@ -49,6 +48,11 @@ test.test('sort', async ({ page }) => {
// g directory // g directory
// c project // c project
// d file // d file
},
})
const assetRows = actions.locateAssetRows(page)
const nameHeading = actions.locateNameColumnHeading(page)
const modifiedHeading = actions.locateModifiedColumnHeading(page)
await actions.login({ page }) await actions.login({ page })
// By default, assets should be grouped by type. // By default, assets should be grouped by type.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2618,6 +2618,8 @@ type DB_Table
or changing Float32 to Float64 are silently ignored. or changing Float32 to Float64 are silently ignored.
However, bigger changes, like a Binary type column getting coerced to Mixed - _will_ still be reported. However, bigger changes, like a Binary type column getting coerced to Mixed - _will_ still be reported.
if expected_type_kind == actual_type_kind then Nothing else if expected_type_kind == actual_type_kind then Nothing else
# If the reverse was an implicit conversion, undoing it also should not yield warnings:
if self.connection.dialect.get_type_mapping.is_implicit_conversion actual_type expected_type then Nothing else
warnings_builder.append (Inexact_Type_Coercion.Warning expected_type actual_type) warnings_builder.append (Inexact_Type_Coercion.Warning expected_type actual_type)
result = max_rows.attach_warning materialized_table result = max_rows.attach_warning materialized_table

View File

@ -495,15 +495,15 @@ floating_point_div = Base_Generator.lift_binary_op "/" x-> y->
## PRIVATE ## PRIVATE
mod_op = Base_Generator.lift_binary_op "MOD" x-> y-> mod_op = Base_Generator.lift_binary_op "MOD" x-> y->
x ++ " - FLOOR(CAST(" ++ x ++ " AS float) / CAST(" ++ y ++ " AS float)) * " ++ y x ++ " % " ++ y
## PRIVATE ## PRIVATE
decimal_div = Base_Generator.lift_binary_op "DECIMAL_DIV" x-> y-> decimal_div = Base_Generator.lift_binary_op "DECIMAL_DIV" x-> y->
SQL_Builder.code "CAST(" ++ x ++ " AS decimal) / CAST(" ++ y ++ " AS decimal)" x ++ " / " ++ y
## PRIVATE ## PRIVATE
decimal_mod = Base_Generator.lift_binary_op "DECIMAL_MOD" x-> y-> decimal_mod = Base_Generator.lift_binary_op "DECIMAL_MOD" x-> y->
x ++ " - FLOOR(CAST(" ++ x ++ " AS decimal) / CAST(" ++ y ++ " AS decimal)) * " ++ y x ++ " % " ++ y
## PRIVATE ## PRIVATE
supported_replace_params : Hashset Replace_Params supported_replace_params : Hashset Replace_Params

View File

@ -44,7 +44,7 @@ public class SetExecutionEnvironmentCommand extends AsynchronousCommand {
var logger = ctx.executionService().getLogger(); var logger = ctx.executionService().getLogger();
ctx.locking() ctx.locking()
.withContextLock( .withContextLock(
contextId, ctx.locking().getOrCreateContextLock(contextId),
this.getClass(), this.getClass(),
() -> { () -> {
var oldEnvironment = ctx.executionService().getContext().getExecutionEnvironment(); var oldEnvironment = ctx.executionService().getContext().getExecutionEnvironment();

View File

@ -0,0 +1,4 @@
package org.enso.interpreter.instrument.execution;
/** Wrapper around a lock used for Context access. */
public abstract class ContextLock {}

View File

@ -39,19 +39,19 @@ public interface Locking {
/** /**
* Executes `callable` while holding a context lock * Executes `callable` while holding a context lock
* *
* @param contextId context id for which the lock is being requested * @param contextLock lock used to ensure exclusive access
* @param where the class requesting the lock * @param where the class requesting the lock
* @param callable code to be executed while holding the lock * @param callable code to be executed while holding the lock
* @return the result of calling `callable` or null, if no result is expected * @return the result of calling `callable` or null, if no result is expected
*/ */
<T> T withContextLock(UUID contextId, Class<?> where, Callable<T> callable); <T> T withContextLock(ContextLock contextLock, Class<?> where, Callable<T> callable);
/** /**
* Removes a context lock. * Removes a context lock.
* *
* @param contextId a context to remove * @param a context lock to remove
*/ */
void removeContextLock(UUID contextId); void removeContextLock(ContextLock contextLock);
/** /**
* Executes `callable` while holding a file lock * Executes `callable` while holding a file lock
@ -62,4 +62,12 @@ public interface Locking {
* @return the result of calling `callable` or null, if no result is expected * @return the result of calling `callable` or null, if no result is expected
*/ */
<T> T withFileLock(File file, Class<?> where, Callable<T> callable); <T> T withFileLock(File file, Class<?> where, Callable<T> callable);
/**
* Gets an existing context lock, or creates a fresh one, for the given context ID.
*
* @param contextId context id for which a lock will be returned
* @return lock wrapper
*/
ContextLock getOrCreateContextLock(UUID contextId);
} }

View File

@ -1,6 +1,5 @@
package org.enso.interpreter.instrument.job; package org.enso.interpreter.instrument.job;
import com.oracle.truffle.api.TruffleLogger;
import java.util.UUID; import java.util.UUID;
import org.enso.interpreter.instrument.OneshotExpression; import org.enso.interpreter.instrument.OneshotExpression;
import org.enso.interpreter.instrument.execution.Executable; import org.enso.interpreter.instrument.execution.Executable;
@ -34,10 +33,9 @@ public class ExecuteExpressionJob extends Job<Executable> implements UniqueJob<E
@Override @Override
public Executable run(RuntimeContext ctx) { public Executable run(RuntimeContext ctx) {
TruffleLogger logger = ctx.executionService().getLogger();
return ctx.locking() return ctx.locking()
.withContextLock( .withContextLock(
contextId, ctx.locking().getOrCreateContextLock(contextId),
this.getClass(), this.getClass(),
() -> { () -> {
OneshotExpression oneshotExpression = OneshotExpression oneshotExpression =

View File

@ -35,9 +35,10 @@ class DestroyContextCmd(
private def removeContext()(implicit ctx: RuntimeContext): Unit = { private def removeContext()(implicit ctx: RuntimeContext): Unit = {
ctx.jobControlPlane.abortJobs(request.contextId) ctx.jobControlPlane.abortJobs(request.contextId)
val contextLock = ctx.locking.getOrCreateContextLock(request.contextId)
try { try {
ctx.locking.withContextLock( ctx.locking.withContextLock(
request.contextId, contextLock,
this.getClass, this.getClass,
() => { () => {
ctx.contextManager.destroy(request.contextId) ctx.contextManager.destroy(request.contextId)
@ -45,7 +46,7 @@ class DestroyContextCmd(
} }
) )
} finally { } finally {
ctx.locking.removeContextLock(request.contextId) ctx.locking.removeContextLock(contextLock)
} }
} }

View File

@ -23,7 +23,7 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
private val compilationLock = new ReentrantReadWriteLock(true) private val compilationLock = new ReentrantReadWriteLock(true)
/** The highest lock. Always obtain first. Guarded by contextMapLock. */ /** The highest lock. Always obtain first. Guarded by contextMapLock. */
private var contextLocks = Map.empty[UUID, ReentrantLock] private var contextLocks = Map.empty[UUID, ContextLock]
/** Guards contextLocks */ /** Guards contextLocks */
private val contextMapLock = new ReentrantLock() private val contextMapLock = new ReentrantLock()
@ -31,13 +31,13 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
/** Guards fileLocks */ /** Guards fileLocks */
private val fileMapLock = new ReentrantLock() private val fileMapLock = new ReentrantLock()
private def getContextLock(contextId: UUID): Lock = { private def getContextLock(contextId: UUID): ContextLock = {
contextMapLock.lock() contextMapLock.lock()
try { try {
if (contextLocks.contains(contextId)) { if (contextLocks.contains(contextId)) {
contextLocks(contextId) contextLocks(contextId)
} else { } else {
val lock = new ReentrantLock(true) val lock = new ContextLockImpl(new ReentrantLock(true), contextId)
contextLocks += (contextId -> lock) contextLocks += (contextId -> lock)
lock lock
} }
@ -184,45 +184,19 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
} }
} }
private def acquireContextLock(contextId: UUID): Long = {
assertNotLocked(
compilationLock,
true,
s"Cannot acquire context ${contextId} lock when having compilation read lock"
)
assertNotLocked(
compilationLock,
false,
s"Cannot acquire context ${contextId} lock when having compilation write lock"
)
assertNoFileLock(s"Cannot acquire context ${contextId}")
assertNotLocked(
pendingEditsLock,
s"Cannot acquire context ${contextId} lock when having pending edits lock"
)
logLockAcquisition(getContextLock(contextId), s"$contextId context")
}
private def releaseContextLock(contextId: UUID): Unit = {
contextMapLock.lock()
try {
if (contextLocks.contains(contextId)) {
contextLocks(contextId).unlock()
}
} finally {
contextMapLock.unlock()
}
}
/** @inheritdoc */ /** @inheritdoc */
override def withContextLock[T]( override def withContextLock[T](
contextId: UUID, lock: ContextLock,
where: Class[_], where: Class[_],
callable: Callable[T] callable: Callable[T]
): T = { ): T = {
val contextLock = lock.asInstanceOf[ContextLockImpl]
var contextLockTimestamp: Long = 0 var contextLockTimestamp: Long = 0
try { try {
contextLockTimestamp = acquireContextLock(contextId); contextLockTimestamp = logLockAcquisition(
contextLock.lock,
"context lock"
) //acquireContextLock(contextId);
callable.call() callable.call()
} catch { } catch {
case ie: InterruptedException => case ie: InterruptedException =>
@ -230,7 +204,7 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
null.asInstanceOf[T] null.asInstanceOf[T]
} finally { } finally {
if (contextLockTimestamp != 0) { if (contextLockTimestamp != 0) {
releaseContextLock(contextId) contextLock.lock.unlock()
logger.log( logger.log(
Level.FINEST, Level.FINEST,
s"Kept context lock [{0}] for {1} milliseconds", s"Kept context lock [{0}] for {1} milliseconds",
@ -244,12 +218,16 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
} }
/** @inheritdoc */ /** @inheritdoc */
override def removeContextLock(contextId: UUID): Unit = { override def removeContextLock(lock: ContextLock): Unit = {
val contextLock = lock.asInstanceOf[ContextLockImpl]
contextMapLock.lock() contextMapLock.lock()
try { try {
if (contextLocks.contains(contextId)) { if (contextLocks.contains(contextLock.uuid)) {
contextLocks(contextId).unlock() assertNotLocked(
contextLocks -= contextId contextLock.lock,
s"Cannot remove context ${contextLock.uuid} lock when having a lock on it"
)
contextLocks -= contextLock.uuid
} }
} finally { } finally {
contextMapLock.unlock() contextMapLock.unlock()
@ -338,8 +316,9 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
contextMapLock.lock() contextMapLock.lock()
try { try {
for (ctx <- contextLocks) { for (ctx <- contextLocks) {
val contextLock = ctx._2.asInstanceOf[ContextLockImpl]
assertNotLocked( assertNotLocked(
ctx._2, contextLock.lock,
msg + s" lock when having context ${ctx._1} lock" msg + s" lock when having context ${ctx._1} lock"
) )
} }
@ -347,4 +326,26 @@ class ReentrantLocking(logger: TruffleLogger) extends Locking {
contextMapLock.unlock() contextMapLock.unlock()
} }
} }
override def getOrCreateContextLock(contextId: UUID): ContextLock = {
assertNotLocked(
compilationLock,
true,
s"Cannot acquire context ${contextId} lock when having compilation read lock"
)
assertNotLocked(
compilationLock,
false,
s"Cannot acquire context ${contextId} lock when having compilation write lock"
)
assertNoFileLock(s"Cannot acquire context ${contextId}")
assertNotLocked(
pendingEditsLock,
s"Cannot acquire context ${contextId} lock when having pending edits lock"
)
getContextLock(contextId)
}
private case class ContextLockImpl(val lock: ReentrantLock, val uuid: UUID)
extends ContextLock {}
} }

View File

@ -31,7 +31,7 @@ class DetachVisualizationJob(
/** @inheritdoc */ /** @inheritdoc */
override def run(implicit ctx: RuntimeContext): Unit = { override def run(implicit ctx: RuntimeContext): Unit = {
ctx.locking.withContextLock( ctx.locking.withContextLock(
contextId, ctx.locking.getOrCreateContextLock(contextId),
this.getClass, this.getClass,
() => { () => {
ctx.contextManager.removeVisualization( ctx.contextManager.removeVisualization(

View File

@ -28,7 +28,7 @@ class ExecuteJob(
/** @inheritdoc */ /** @inheritdoc */
override def run(implicit ctx: RuntimeContext): Unit = { override def run(implicit ctx: RuntimeContext): Unit = {
ctx.locking.withContextLock( ctx.locking.withContextLock(
contextId, ctx.locking.getOrCreateContextLock(contextId),
this.getClass, this.getClass,
() => () =>
ctx.locking.withReadCompilationLock( ctx.locking.withReadCompilationLock(

View File

@ -61,7 +61,7 @@ class UpsertVisualizationJob(
/** @inheritdoc */ /** @inheritdoc */
override def run(implicit ctx: RuntimeContext): Option[Executable] = { override def run(implicit ctx: RuntimeContext): Option[Executable] = {
ctx.locking.withContextLock( ctx.locking.withContextLock(
config.executionContextId, ctx.locking.getOrCreateContextLock(config.executionContextId),
this.getClass, this.getClass,
() => { () => {
val maybeCallable = val maybeCallable =

View File

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

1
nix/bin/rustup Executable file
View File

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

View File

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

View File

@ -197,21 +197,36 @@ snowflake_specific_spec suite_builder default_connection db_name setup =
data.t.at "doubles" . value_type . is_floating_point . should_be_true data.t.at "doubles" . value_type . is_floating_point . should_be_true
group_builder.specify "will report true integer types but infer smartly when materialized (small numbers become Integer in-memory, not Decimal)" <| group_builder.specify "will report true integer types but infer smartly when materialized (small numbers become Integer in-memory, not Decimal)" <|
t1 = table_builder [["small_ints", [1, 2, 3]], ["big_ints", [2^100, 2^110, 1]]] . sort "small_ints" t1 = table_builder [["small_ints", [1, 2, 3]]]
# Integer types are NUMBER(38, 0) in Snowflake so they are all mapped to decimal # Integer types are NUMBER(38, 0) in Snowflake so they are all mapped to decimal
t1.at "small_ints" . value_type . should_equal (Value_Type.Decimal 38 0) t1.at "small_ints" . value_type . should_equal (Value_Type.Decimal 38 0)
t1.at "big_ints" . value_type . should_equal (Value_Type.Decimal 38 0) # The fact that Integer is coerced to Decimal is an expected thing in Snowflake, so we don't warn about this.
Problems.assume_no_problems t1
in_memory = t1.read in_memory1 = t1.read
# But when read back to in-memory, they are inferred as Integer type to avoid the BigInteger overhead # But when read back to in-memory, they are inferred as Integer type to avoid the BigInteger overhead
in_memory.at "small_ints" . value_type . should_equal (Value_Type.Integer Bits.Bits_64) in_memory1.at "small_ints" . value_type . should_equal (Value_Type.Integer Bits.Bits_64)
# Again, when materialized the conversion Decimal->Integer is a feature, so it should not cause warning.
Problems.assume_no_problems in_memory1
in_memory1.at "small_ints" . to_vector . should_contain_the_same_elements_as [1, 2, 3]
t2 = table_builder [["big_ints", [2^100, 2^110, 1]]]
t2.at "big_ints" . value_type . should_equal (Value_Type.Decimal 38 0)
# For the decimal column we get a warning because the type changed:
w = Problems.expect_only_warning Inexact_Type_Coercion t2
w.requested_type . should_equal (Value_Type.Decimal Nothing 0)
w.actual_type . should_equal (Value_Type.Decimal 38 0)
in_memory2 = t2.remove_warnings.read
# Unless the values are actually big, then the Decimal type is kept, but its precision is lost, as in-memory BigInteger does not store it. # Unless the values are actually big, then the Decimal type is kept, but its precision is lost, as in-memory BigInteger does not store it.
in_memory.at "big_ints" . value_type . should_equal (Value_Type.Decimal Nothing 0) in_memory2.at "big_ints" . value_type . should_equal (Value_Type.Decimal Nothing 0)
# The Decimal type loses 'precision' but that is no reason to warn, so we should not see any warnings here:
Problems.assume_no_problems in_memory2
# Check correctness of values # Check correctness of values
in_memory.at "small_ints" . to_vector . should_equal [1, 2, 3] in_memory2.at "big_ints" . to_vector . should_contain_the_same_elements_as [2^100, 2^110, 1]
in_memory.at "big_ints" . to_vector . should_equal [2^100, 2^110, 1]
group_builder.specify "correctly handles Decimal and Float types" <| group_builder.specify "correctly handles Decimal and Float types" <|
table_name = Name_Generator.random_name "DecimalFloat" table_name = Name_Generator.random_name "DecimalFloat"
@ -222,6 +237,11 @@ snowflake_specific_spec suite_builder default_connection db_name setup =
t1.at "d3" . value_type . should_equal (Value_Type.Decimal 38 0) t1.at "d3" . value_type . should_equal (Value_Type.Decimal 38 0)
t1.at "f" . value_type . should_equal Value_Type.Float t1.at "f" . value_type . should_equal Value_Type.Float
# We expect warnings about coercing Decimal types
w1 = Problems.expect_warning Inexact_Type_Coercion t1
w1.requested_type . should_equal (Value_Type.Decimal 24 -3)
w1.actual_type . should_equal (Value_Type.Decimal 38 0)
t1.update_rows (Table.new [["d1", [1.2345678910]], ["d2", [12.3456]], ["d3", [1234567.8910]], ["f", [1.5]]]) update_action=Update_Action.Insert . should_succeed t1.update_rows (Table.new [["d1", [1.2345678910]], ["d2", [12.3456]], ["d3", [1234567.8910]], ["f", [1.5]]]) update_action=Update_Action.Insert . should_succeed
m1 = t1.read m1 = t1.read
@ -593,7 +613,7 @@ add_snowflake_specs suite_builder create_connection_fn db_name =
Common_Spec.add_specs suite_builder prefix create_connection_fn Common_Spec.add_specs suite_builder prefix create_connection_fn
common_selection = Common_Table_Operations.Main.Test_Selection.Config supports_case_sensitive_columns=True order_by_unicode_normalization_by_default=True allows_mixed_type_comparisons=False fixed_length_text_columns=False different_size_integer_types=False removes_trailing_whitespace_casting_from_char_to_varchar=True supports_decimal_type=True supported_replace_params=supported_replace_params run_advanced_edge_case_tests_by_default=False supports_date_time_without_timezone=True supports_nanoseconds_in_time=True common_selection = Common_Table_Operations.Main.Test_Selection.Config supports_case_sensitive_columns=True order_by_unicode_normalization_by_default=True allows_mixed_type_comparisons=False fixed_length_text_columns=False different_size_integer_types=False removes_trailing_whitespace_casting_from_char_to_varchar=True supports_decimal_type=True supported_replace_params=supported_replace_params run_advanced_edge_case_tests_by_default=False supports_date_time_without_timezone=True supports_nanoseconds_in_time=True is_nan_comparable=True
aggregate_selection = Common_Table_Operations.Aggregate_Spec.Test_Selection.Config first_last=False first_last_row_order=False aggregation_problems=False text_concat=False aggregate_selection = Common_Table_Operations.Aggregate_Spec.Test_Selection.Config first_last=False first_last_row_order=False aggregation_problems=False text_concat=False
agg_in_memory_table = ((Project_Description.new enso_dev.Table_Tests).data / "data.csv") . read agg_in_memory_table = ((Project_Description.new enso_dev.Table_Tests).data / "data.csv") . read

View File

@ -116,6 +116,8 @@ type Names_Data
t = table_builder [["a", [1, 2, 3]], ["b", ['x', 'y', 'z']], ["c", [1.0, 2.0, 3.0]], ["d", [True, False, True]]] . sort "a" t = table_builder [["a", [1, 2, 3]], ["b", ['x', 'y', 'z']], ["c", [1.0, 2.0, 3.0]], ["d", [True, False, True]]] . sort "a"
[t] [t]
type Lazy_Ref
Value ~get
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
@ -845,14 +847,17 @@ add_specs suite_builder setup =
case setup.test_selection.is_nan_and_nothing_distinct of case setup.test_selection.is_nan_and_nothing_distinct of
True -> True ->
t = build_sorted_table [["X", [1.5, 3.0, Number.positive_infinity, Number.negative_infinity, Number.nan, Nothing]], ["Y", [1, 2, 3, 4, 5, Nothing]], ["Z", ["1", "2", "3", "4", "5", Nothing]]] table = Lazy_Ref.Value <|
build_sorted_table [["X", [1.5, 3.0, Number.positive_infinity, Number.negative_infinity, Number.nan, Nothing]], ["Y", [1, 2, 3, 4, 5, Nothing]], ["Z", ["1", "2", "3", "4", "5", Nothing]]]
group_builder.specify "should support is_nan" <| group_builder.specify "should support is_nan" <|
t = table.get
t.at "X" . is_nan . to_vector . should_equal [False, False, False, False, True, Nothing] t.at "X" . is_nan . to_vector . should_equal [False, False, False, False, True, Nothing]
t.at "Y" . is_nan . to_vector . should_equal [False, False, False, False, False, Nothing] t.at "Y" . is_nan . to_vector . should_equal [False, False, False, False, False, Nothing]
t.at "Z" . is_nan . to_vector . should_fail_with Invalid_Value_Type t.at "Z" . is_nan . to_vector . should_fail_with Invalid_Value_Type
group_builder.specify "should support is_infinite" <| group_builder.specify "should support is_infinite" <|
t = table.get
t.at "X" . is_infinite . to_vector . should_equal [False, False, True, True, False, Nothing] t.at "X" . is_infinite . to_vector . should_equal [False, False, True, True, False, Nothing]
t.at "Y" . is_infinite . to_vector . should_equal [False, False, False, False, False, Nothing] t.at "Y" . is_infinite . to_vector . should_equal [False, False, False, False, False, Nothing]
t.at "Z" . is_infinite . to_vector . should_fail_with Invalid_Value_Type t.at "Z" . is_infinite . to_vector . should_fail_with Invalid_Value_Type

View File

@ -13,53 +13,81 @@ from Standard.Test import all
from project.Util import all from project.Util import all
from project.Common_Table_Operations.Util import run_default_backend from project.Common_Table_Operations.Util import run_default_backend, build_sorted_table
main filter=Nothing = run_default_backend (add_specs detailed=True) filter main filter=Nothing = run_default_backend (add_specs detailed=True) filter
type Lazy_Ref
Value ~get
add_specs suite_builder detailed setup = add_specs suite_builder detailed setup =
prefix = setup.prefix prefix = setup.prefix
table_builder = setup.table_builder table_builder = build_sorted_table setup
column_a_description = ["A", [1, 2, 3, 4, 5]]
# Create Test Table column_odd_description = ["Bad] Name", [True, False, True, False, True]]
column_a = ["A", [1, 2, 3, 4, 5]] test_table = Lazy_Ref.Value <|
column_b = ["B", [1.0, 1.5, 2.5, 4, 6]] column_b = ["B", [1.0, 1.5, 2.5, 4, 6]]
column_c = ["C", ["Hello", "World", "Hello World!", "", Nothing]] column_c = ["C", ["Hello", "World", "Hello World!", "", Nothing]]
column_odd = ["Bad] Name", [True, False, True, False, True]] table_builder [column_a_description, column_b, column_c, column_odd_description]
test_table = table_builder [column_a, column_b, column_c, column_odd]
pending_datetime = if setup.test_selection.date_time.not then "Date/Time operations are not supported by this backend." pending_datetime = if setup.test_selection.date_time.not then "Date/Time operations are not supported by this backend."
epsilon=0.0000000001 epsilon=0.0000000001
tester expression value = Test.with_clue "{expr = {"+expression+"}}: " <| check_results got_values expected =
new_column = test_table.evaluate_expression expression expected_vec = case expected of
expected = case value of _ : Vector -> expected
_ : Vector -> value _ -> Vector.new test_table.get.row_count _->expected
_ -> Vector.new test_table.row_count _->value
values = new_column.to_vector got_values . each_with_index i->v->
values . each_with_index i->v-> e = expected_vec.at i
e = expected.at i
match = case e of match = case e of
_ : Number -> case v of _ : Number -> case v of
_ : Number -> e.equals v epsilon _ : Number -> e.equals v epsilon
# If the backend returns Decimal for that case, we convert it to Float before comparing:
_ : Decimal -> e.equals v.to_float epsilon
_ -> Test.fail "Expected cell to be a number "+e.pretty+" but got a value of non-numeric type: "+v.pretty _ -> Test.fail "Expected cell to be a number "+e.pretty+" but got a value of non-numeric type: "+v.pretty
_ -> e == v _ -> e == v
if match.not then values.should_equal expected if match.not then got_values.should_equal expected_vec
tester expression value = Test.with_clue "{expr = {"+expression+"}}: " <|
new_column = test_table.get.evaluate_expression expression
new_column.name . should_equal expression new_column.name . should_equal expression
check_results new_column.to_vector value
specify_test label group_builder action expression_test=tester pending=Nothing = case pending of specify_test label group_builder action pending=Nothing = case pending of
Nothing -> Nothing ->
case detailed of case detailed of
True -> True ->
specify_tester expression value = specify_tester expression value =
group_builder.specify (label + ": " + expression) <| group_builder.specify (label + ": " + expression) <|
expression_test expression value tester expression value
action specify_tester action specify_tester
False -> False ->
group_builder.specify label (action expression_test) # We will batch the operation for better performance.
group_builder.specify label <|
batch = Vector.build batch_builder->
add_to_batch expression value =
new_column = test_table.get.evaluate_expression expression
new_column.name . should_equal expression
batch_builder.append [batch_builder.length, new_column, value]
action add_to_batch
expr_column_name ix = "expr_"+ix.to_text
batched_expression = batch.fold test_table.get acc-> entry->
ix = entry.at 0
new_column = entry.at 1
acc.set new_column as=(expr_column_name ix) set_mode=..Add
batched_expression_without_columns = batched_expression.select_columns "expr_.*".to_regex
materialized = batched_expression_without_columns.read
batch.each entry->
ix = entry.at 0
new_column = entry.at 1
expected = entry.at 2
Test.with_clue "{expr = {"+new_column.name+"}}: " <|
got_vector = materialized.at (expr_column_name ix) . to_vector
check_results got_vector expected
_ -> group_builder.specify label Nothing pending _ -> group_builder.specify label Nothing pending
suite_builder.group prefix+"Expression Integer literals" group_builder-> suite_builder.group prefix+"Expression Integer literals" group_builder->
@ -96,8 +124,8 @@ add_specs suite_builder detailed setup =
suite_builder.group prefix+"Expression Text literals" group_builder-> suite_builder.group prefix+"Expression Text literals" group_builder->
specify_test "should be able to get a Column" group_builder expression_test-> specify_test "should be able to get a Column" group_builder expression_test->
expression_test "[A]" (column_a.at 1) expression_test "[A]" column_a_description.second
expression_test "[Bad]] Name]" (column_odd.at 1) expression_test "[Bad]] Name]" column_odd_description.second
group_builder.specify "should sanitize names" <| group_builder.specify "should sanitize names" <|
t = table_builder [["X", ['\0', 'x', '']]] t = table_builder [["X", ['\0', 'x', '']]]
@ -348,23 +376,23 @@ add_specs suite_builder detailed setup =
expression_test "max([A], [B], 3)" [3, 3, 3, 4, 6] expression_test "max([A], [B], 3)" [3, 3, 3, 4, 6]
suite_builder.group prefix+"Expression Errors should be handled" group_builder-> suite_builder.group prefix+"Expression Errors should be handled" group_builder->
error_tester expression fail_ctor = expect_error ctor expr =
test_table.set (expr expression) as="NEW_COL" . should_fail_with Expression_Error expr.should_fail_with Expression_Error
test_table.set (expr expression) as="NEW_COL" . catch . should_be_a fail_ctor expr.catch.should_be_a ctor
specify_test "should fail with Syntax_Error if badly formed" group_builder expression_test=error_tester expression_test-> group_builder.specify "should fail with Syntax_Error if badly formed" <|
expression_test "IIF [A] THEN 1 ELSE 2" Expression_Error.Syntax_Error expect_error Expression_Error.Syntax_Error <| test_table.get.evaluate_expression "IIF [A] THEN 1 ELSE 2"
expression_test "A + B" Expression_Error.Syntax_Error expect_error Expression_Error.Syntax_Error <| test_table.get.evaluate_expression "A + B"
expression_test "#2022-31-21#" Expression_Error.Syntax_Error expect_error Expression_Error.Syntax_Error <| test_table.get.evaluate_expression "#2022-31-21#"
specify_test "should fail with Unsupported_Operation if not sufficient arguments" group_builder expression_test=error_tester expression_test-> group_builder.specify "should fail with Unsupported_Operation if not sufficient arguments" <|
expression_test "unknown([C])" Expression_Error.Unsupported_Operation expect_error Expression_Error.Unsupported_Operation <| test_table.get.evaluate_expression "unknown([C])"
specify_test "should fail with Argument_Mismatch if not sufficient arguments" group_builder expression_test=error_tester expression_test-> group_builder.specify "should fail with Argument_Mismatch if not sufficient arguments" <|
expression_test "starts_with([C])" Expression_Error.Argument_Mismatch expect_error Expression_Error.Argument_Mismatch <| test_table.get.evaluate_expression "starts_with([C])"
specify_test "should fail with Argument_Mismatch if too many arguments" group_builder expression_test=error_tester expression_test-> group_builder.specify "should fail with Argument_Mismatch if too many arguments" <|
expression_test "is_empty([C], 'Hello')" Expression_Error.Argument_Mismatch expect_error Expression_Error.Argument_Mismatch <| test_table.get.evaluate_expression "is_empty([C], 'Hello')"
suite_builder.group prefix+"Expression Warnings should be reported" group_builder-> suite_builder.group prefix+"Expression Warnings should be reported" group_builder->
group_builder.specify "should report floating point equality" <| group_builder.specify "should report floating point equality" <|

View File

@ -20,15 +20,6 @@ from project.Common_Table_Operations.Util import run_default_backend
main filter=Nothing = run_default_backend add_specs filter main filter=Nothing = run_default_backend add_specs filter
type Data
Value ~connection
setup create_connection_fn =
Data.Value (create_connection_fn Nothing)
teardown self =
self.connection.close
## Currently these tests rely on filtering preserving the insertion ordering ## Currently these tests rely on filtering preserving the insertion ordering
within tables. This is not necessarily guaranteed by RDBMS, so we may adapt within tables. This is not necessarily guaranteed by RDBMS, so we may adapt
@ -37,17 +28,9 @@ type Data
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
test_selection = setup.test_selection test_selection = setup.test_selection
create_connection_fn = setup.create_connection_func table_builder = setup.light_table_builder
suite_builder.group prefix+"Table.filter" group_builder-> suite_builder.group prefix+"Table.filter" group_builder->
data = Data.setup create_connection_fn
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
group_builder.specify "by integer comparisons" <| group_builder.specify "by integer comparisons" <|
t = table_builder [["ix", [1, 2, 3, 4, 5]], ["X", [100, 3, Nothing, 4, 12]], ["Y", [100, 4, 2, Nothing, 11]]] t = table_builder [["ix", [1, 2, 3, 4, 5]], ["X", [100, 3, Nothing, 4, 12]], ["Y", [100, 4, 2, Nothing, 11]]]
t.filter "X" (Filter_Condition.Less than=10) . at "X" . to_vector . should_equal [3, 4] t.filter "X" (Filter_Condition.Less than=10) . at "X" . to_vector . should_equal [3, 4]
@ -115,13 +98,15 @@ add_specs suite_builder setup =
t.filter "X" (Filter_Condition.Less than=10.0) . at "X" . to_vector . should_equal [2.5, Number.negative_infinity] t.filter "X" (Filter_Condition.Less than=10.0) . at "X" . to_vector . should_equal [2.5, Number.negative_infinity]
# In PostgreSQL, NaN is greater than any other value, so it is > 10.0; in other implementations it is usually not greater nor smaller, so it gets filtered out. # In some backends, NaN is greater than any other value, so it is > 10.0; in other implementations it is usually not greater nor smaller, so it gets filtered out.
nan_is_comparable = setup.test_selection.is_nan_comparable
t.filter "X" (Filter_Condition.Greater than=10.0) . at "ix" . to_vector . should_equal <| t.filter "X" (Filter_Condition.Greater than=10.0) . at "ix" . to_vector . should_equal <|
if prefix.contains "PostgreSQL" . not then [1, 5] else [1, 4, 5] if nan_is_comparable then [1, 4, 5] else [1, 5]
# Similarly, PostgreSQL treats NaN==NaN # Similarly, PostgreSQL and Snowflake treats NaN==NaN, we assume `nan_is_comparable` implies that.
# If needed, this may become a separate flag in the future.
t.filter "X" (Filter_Condition.Equal to=Number.nan) . at "ix" . to_vector . should_equal <| t.filter "X" (Filter_Condition.Equal to=Number.nan) . at "ix" . to_vector . should_equal <|
if prefix.contains "PostgreSQL" . not then [] else [4] if nan_is_comparable then [4] else []
t.filter "X" (Filter_Condition.Equal to=Number.positive_infinity) . at "ix" . to_vector . should_equal [5] t.filter "X" (Filter_Condition.Equal to=Number.positive_infinity) . at "ix" . to_vector . should_equal [5]
t.filter "X" Filter_Condition.Is_Infinite . at "ix" . to_vector . should_equal [5, 6] t.filter "X" Filter_Condition.Is_Infinite . at "ix" . to_vector . should_equal [5, 6]
@ -473,14 +458,6 @@ add_specs suite_builder setup =
Problems.assume_no_problems (t.filter "x" (Filter_Condition.Is_In [[Nothing, Nothing]])) Problems.assume_no_problems (t.filter "x" (Filter_Condition.Is_In [[Nothing, Nothing]]))
suite_builder.group prefix+"Table.filter by an expression" group_builder-> suite_builder.group prefix+"Table.filter by an expression" group_builder->
data = Data.setup create_connection_fn
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
group_builder.specify "by a boolean column" <| group_builder.specify "by a boolean column" <|
t = table_builder [["ix", [1, 2, 3, 4, 5]], ["b", [True, False, Nothing, True, True]]] t = table_builder [["ix", [1, 2, 3, 4, 5]], ["b", [True, False, Nothing, True, True]]]
t.filter (expr "[b]") . at "ix" . to_vector . should_equal [1, 4, 5] t.filter (expr "[b]") . at "ix" . to_vector . should_equal [1, 4, 5]

View File

@ -14,30 +14,23 @@ from Standard.Test import all
from project.Common_Table_Operations.Util import expect_column_names, run_default_backend from project.Common_Table_Operations.Util import expect_column_names, run_default_backend
type Data type Lazy_Ref
Value ~connection Value ~get
setup create_connection_fn =
Data.Value (create_connection_fn Nothing)
teardown self = self.connection.close
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
create_connection_fn = setup.create_connection_func
materialize = setup.materialize materialize = setup.materialize
table_builder = setup.table_builder
common_t1 = Lazy_Ref.Value <|
table_builder [["X", [1, 2]], ["Y", [4, 5]]]
common_t2 = Lazy_Ref.Value <|
table_builder [["Z", ['a', 'b']], ["W", ['c', 'd']]]
suite_builder.group prefix+"Table.cross_join" group_builder-> suite_builder.group prefix+"Table.cross_join" group_builder->
data = Data.setup create_connection_fn
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
group_builder.specify "should allow to create a cross product of two tables in the right order" <| group_builder.specify "should allow to create a cross product of two tables in the right order" <|
t1 = table_builder [["X", [1, 2]], ["Y", [4, 5]]] t1 = common_t1.get
t2 = table_builder [["Z", ['a', 'b']], ["W", ['c', 'd']]] t2 = common_t2.get
t3 = t1.cross_join t2 t3 = t1.cross_join t2
expect_column_names ["X", "Y", "Z", "W"] t3 expect_column_names ["X", "Y", "Z", "W"] t3
@ -56,7 +49,7 @@ add_specs suite_builder setup =
False -> r.should_equal expected_rows False -> r.should_equal expected_rows
group_builder.specify "should work correctly with empty tables" <| group_builder.specify "should work correctly with empty tables" <|
t1 = table_builder [["X", [1, 2]], ["Y", [4, 5]]] t1 = common_t1.get
t2 = table_builder [["Z", ['a']], ["W", ['c']]] t2 = table_builder [["Z", ['a']], ["W", ['c']]]
# Workaround to easily create empty table until table builder allows that directly. # Workaround to easily create empty table until table builder allows that directly.
empty = t2.filter "Z" Filter_Condition.Is_Nothing empty = t2.filter "Z" Filter_Condition.Is_Nothing
@ -73,7 +66,7 @@ add_specs suite_builder setup =
t4.at "X" . to_vector . should_equal [] t4.at "X" . to_vector . should_equal []
group_builder.specify "should respect the right row limit" <| group_builder.specify "should respect the right row limit" <|
t2 = table_builder [["X", [1, 2]]] t2 = common_t1.get.select_columns ["X"]
t3 = table_builder [["X", [1, 2, 3]]] t3 = table_builder [["X", [1, 2, 3]]]
t100 = table_builder [["Y", 0.up_to 100 . to_vector]] t100 = table_builder [["Y", 0.up_to 100 . to_vector]]
t101 = table_builder [["Y", 0.up_to 101 . to_vector]] t101 = table_builder [["Y", 0.up_to 101 . to_vector]]
@ -108,7 +101,7 @@ add_specs suite_builder setup =
False -> r.should_equal expected_rows False -> r.should_equal expected_rows
group_builder.specify "should allow self-joins" <| group_builder.specify "should allow self-joins" <|
t1 = table_builder [["X", [1, 2]], ["Y", [4, 5]]] t1 = common_t1.get
t2 = t1.cross_join t1 t2 = t1.cross_join t1
expect_column_names ["X", "Y", "Right X", "Right Y"] t2 expect_column_names ["X", "Y", "Right X", "Right Y"] t2
@ -171,8 +164,8 @@ add_specs suite_builder setup =
False -> r.should_equal expected_rows False -> r.should_equal expected_rows
group_builder.specify "Cross join is not possible via call to .join" <| group_builder.specify "Cross join is not possible via call to .join" <|
t1 = table_builder [["X", [1, 2]], ["Y", [4, 5]]] t1 = common_t1.get
t2 = table_builder [["Z", ['a', 'b']], ["W", ['c', 'd']]] t2 = common_t2.get
Test.expect_panic_with (t1.join t2 join_kind=Join_Kind_Cross on=[]) Type_Error Test.expect_panic_with (t1.join t2 join_kind=Join_Kind_Cross on=[]) Type_Error
group_builder.specify "should gracefully handle tables from different backends" <| group_builder.specify "should gracefully handle tables from different backends" <|

View File

@ -28,38 +28,24 @@ Comparable.from (that:My_Type) = Comparable.new that My_Type_Comparator
type Data type Data
Value ~data Value ~data
connection self = self.data.at 0 t1 self = self.data.at 0
t1 self = self.data.at 1 t2 self = self.data.at 1
t2 self = self.data.at 2 t3 self = self.data.at 2
t3 self = self.data.at 3 t4 self = self.data.at 3
t4 self = self.data.at 4
setup create_connection_fn table_builder = Data.Value <|
connection = create_connection_fn Nothing
t1 = table_builder [["X", [1, 2, 3]], ["Y", [4, 5, 6]]] connection=connection
t2 = table_builder [["Z", [2, 3, 2, 4]], ["W", [4, 5, 6, 7]]] connection=connection
t3 = table_builder [["X", [1, 1, 1, 2, 2, 2]], ["Y", ["A", "B", "B", "C", "C", "A"]], ["Z", [1, 2, 3, 4, 5, 6]]] connection=connection
t4 = table_builder [["X", [1, 1, 3, 2, 2, 4]], ["Y", ["B", "B", "C", "C", "D", "A"]], ["Z", [1, 2, 3, 4, 5, 6]]] connection=connection
[connection, t1, t2, t3, t4]
teardown self = self.connection.close
setup table_builder = Data.Value <|
t1 = table_builder [["X", [1, 2, 3]], ["Y", [4, 5, 6]]]
t2 = table_builder [["Z", [2, 3, 2, 4]], ["W", [4, 5, 6, 7]]]
t3 = table_builder [["X", [1, 1, 1, 2, 2, 2]], ["Y", ["A", "B", "B", "C", "C", "A"]], ["Z", [1, 2, 3, 4, 5, 6]]]
t4 = table_builder [["X", [1, 1, 3, 2, 2, 4]], ["Y", ["B", "B", "C", "C", "D", "A"]], ["Z", [1, 2, 3, 4, 5, 6]]]
[t1, t2, t3, t4]
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
table_builder = setup.table_builder table_builder = setup.table_builder
create_connection_fn = setup.create_connection_func
materialize = setup.materialize materialize = setup.materialize
data = Data.setup table_builder
suite_builder.group prefix+"Table.join" group_builder-> suite_builder.group prefix+"Table.join" group_builder->
data = Data.setup create_connection_fn table_builder
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
group_builder.specify "should by default do a Left Outer join on equality of first column in the left table, correlated with column of the same name in the right one" <| group_builder.specify "should by default do a Left Outer join on equality of first column in the left table, correlated with column of the same name in the right one" <|
t3 = table_builder [["Z", [4, 5, 6, 7]], ["X", [2, 3, 2, 4]]] t3 = table_builder [["Z", [4, 5, 6, 7]], ["X", [2, 3, 2, 4]]]
t4 = data.t1.join t3 |> materialize |> _.sort ["X", "Z"] t4 = data.t1.join t3 |> materialize |> _.sort ["X", "Z"]

View File

@ -110,6 +110,9 @@ type Test_Selection
- is_nan_and_nothing_distinct: Specifies if the backend is able to - is_nan_and_nothing_distinct: Specifies if the backend is able to
distinguish between a decimal NaN value and a missing value (Enso's distinguish between a decimal NaN value and a missing value (Enso's
Nothing, or SQL's NULL). If `False`, NaN is treated as a NULL. Nothing, or SQL's NULL). If `False`, NaN is treated as a NULL.
- is_nan_comparable: Specifies if NaN value is
treated as greater than all numbers. If `False`, `NaN` is expected to
yield False to both < and > comparisons.
- distinct_returns_first_row_from_group_if_ordered: If `order_by` was - distinct_returns_first_row_from_group_if_ordered: If `order_by` was
applied before, the distinct operation will return the first row from applied before, the distinct operation will return the first row from
each group. Guaranteed in the in-memory backend, but may not be each group. Guaranteed in the in-memory backend, but may not be
@ -150,7 +153,7 @@ type Test_Selection
- supports_date_time_without_timezone: Specifies if the backend supports - supports_date_time_without_timezone: Specifies if the backend supports
date/time operations without a timezone (true for most Database backends). date/time operations without a timezone (true for most Database backends).
Defaults to `.is_integer`. Defaults to `.is_integer`.
Config supports_case_sensitive_columns=True order_by=True natural_ordering=False case_insensitive_ordering=True order_by_unicode_normalization_by_default=False case_insensitive_ascii_only=False allows_mixed_type_comparisons=True supports_unicode_normalization=False is_nan_and_nothing_distinct=True distinct_returns_first_row_from_group_if_ordered=True date_time=True fixed_length_text_columns=False length_restricted_text_columns=True removes_trailing_whitespace_casting_from_char_to_varchar=False different_size_integer_types=True supports_8bit_integer=False supports_decimal_type=False supports_time_duration=False supports_nanoseconds_in_time=False supports_mixed_columns=False supported_replace_params=Nothing run_advanced_edge_case_tests_by_default=True supports_date_time_without_timezone=False Config supports_case_sensitive_columns=True order_by=True natural_ordering=False case_insensitive_ordering=True order_by_unicode_normalization_by_default=False case_insensitive_ascii_only=False allows_mixed_type_comparisons=True supports_unicode_normalization=False is_nan_and_nothing_distinct=True is_nan_comparable=False distinct_returns_first_row_from_group_if_ordered=True date_time=True fixed_length_text_columns=False length_restricted_text_columns=True removes_trailing_whitespace_casting_from_char_to_varchar=False different_size_integer_types=True supports_8bit_integer=False supports_decimal_type=False supports_time_duration=False supports_nanoseconds_in_time=False supports_mixed_columns=False supported_replace_params=Nothing run_advanced_edge_case_tests_by_default=True supports_date_time_without_timezone=False
## Specifies if the advanced edge case tests shall be run. ## Specifies if the advanced edge case tests shall be run.

View File

@ -13,30 +13,11 @@ from project.Common_Table_Operations.Util import run_default_backend
main filter=Nothing = run_default_backend add_specs filter main filter=Nothing = run_default_backend add_specs filter
type Data
Value ~connection
setup create_connection_fn = Data.Value <|
connection = create_connection_fn Nothing
connection
teardown self =
self.connection.close
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
create_connection_fn = setup.create_connection_func table_builder = setup.light_table_builder
suite_builder.group prefix+"Column.map" group_builder-> suite_builder.group prefix+"Column.map" group_builder->
data = Data.setup create_connection_fn
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
if setup.is_database then if setup.is_database then
group_builder.specify "should report unsupported error" <| group_builder.specify "should report unsupported error" <|
t = table_builder [["X", [1, 2, 3]]] t = table_builder [["X", [1, 2, 3]]]
@ -151,14 +132,6 @@ add_specs suite_builder setup =
r8.catch.to_display_text . should_contain "Expected type Date, but got a value 42 of type Integer (16 bits)" r8.catch.to_display_text . should_contain "Expected type Date, but got a value 42 of type Integer (16 bits)"
suite_builder.group prefix+"Column.zip" group_builder-> suite_builder.group prefix+"Column.zip" group_builder->
data = Data.setup create_connection_fn
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
if setup.is_database then if setup.is_database then
group_builder.specify "should report unsupported error" <| group_builder.specify "should report unsupported error" <|
t = table_builder [["X", [1, 2, 3]], ["Y", [4, 5, 6]]] t = table_builder [["X", [1, 2, 3]], ["Y", [4, 5, 6]]]

View File

@ -8,64 +8,46 @@ from Standard.Database.Errors import Unsupported_Database_Operation
from Standard.Test import all from Standard.Test import all
from project.Common_Table_Operations.Util import run_default_backend from project.Common_Table_Operations.Util import run_default_backend, build_sorted_table
main filter=Nothing = run_default_backend add_specs filter main filter=Nothing = run_default_backend add_specs filter
type Data type Lazy_Ref
Value ~data Value ~get
connection self = self.data.at 0 add_specs suite_builder setup =
t0 self = self.data.at 1 prefix = setup.prefix
t1 self = self.data.at 2 test_selection = setup.test_selection
t3 self = self.data.at 3 table_builder = build_sorted_table setup
t4 self = self.data.at 4
setup create_connection_fn table_builder = t0 = Lazy_Ref.Value <|
connection = create_connection_fn Nothing table_builder [["a", [0, 1, Nothing, 42, Nothing, 5]], ["b", [True, Nothing, True, False, Nothing, False]], ["c", ["", "foo", "bar", Nothing, Nothing, " "]]]
t0 = table_builder [["a", [0, 1, Nothing, 42, Nothing, 5]], ["b", [True, Nothing, True, False, Nothing, False]], ["c", ["", "foo", "bar", Nothing, Nothing, " "]]] connection=connection t1 = Lazy_Ref.Value <|
t1 =
a = ["a", [1, Nothing, 3, 4]] a = ["a", [1, Nothing, 3, 4]]
b = ["b", ["a", "b", Nothing, " "]] b = ["b", ["a", "b", Nothing, " "]]
c = ["c", [10, 20, 30, 40]] c = ["c", [10, 20, 30, 40]]
d = ["d", [Nothing, True, False, True]] d = ["d", [Nothing, True, False, True]]
e = ["e", ["", "", "foo", "bar"]] e = ["e", ["", "", "foo", "bar"]]
f = ["f", [Nothing, "", Nothing, ""]] f = ["f", [Nothing, "", Nothing, ""]]
table_builder [a, b, c, d, e, f] connection=connection table_builder [a, b, c, d, e, f]
t3 = table_builder [["X", [2.0, 1.5, Number.nan, Number.nan]], ["Y", [Nothing, 2.0, Nothing, 5.0]]] t3 = Lazy_Ref.Value <|
t4 = table_builder [["X", [2.0, 1.5, Number.nan, Number.nan]], ["Y", [Nothing, 2.0, Nothing, 5.0]]]
t4 = Lazy_Ref.Value <|
c = ["c", [10, 20, 40, 30]] c = ["c", [10, 20, 40, 30]]
g = ["g", [Number.nan, 1, 2, 3.4]] g = ["g", [Number.nan, 1, 2, 3.4]]
h = ["h", [Number.nan, Nothing, Number.nan, Nothing]] h = ["h", [Number.nan, Nothing, Number.nan, Nothing]]
table_builder [c, g, h] table_builder [c, g, h]
Data.Value [connection, t0, t1, t3, t4]
teardown self =
self.connection.close
add_specs suite_builder setup =
prefix = setup.prefix
create_connection_fn = setup.create_connection_func
test_selection = setup.test_selection
suite_builder.group prefix+"Dropping Missing Values" group_builder-> suite_builder.group prefix+"Dropping Missing Values" group_builder->
data = Data.setup create_connection_fn setup.table_builder
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
group_builder.specify "filter_blank_rows should drop rows that contain at least one missing cell" <| group_builder.specify "filter_blank_rows should drop rows that contain at least one missing cell" <|
d = data.t0.filter_blank_rows when=Blank_Selector.Any_Cell d = t0.get.filter_blank_rows when=Blank_Selector.Any_Cell
d.row_count . should_equal 1 d.row_count . should_equal 1
d.at "a" . to_vector . should_equal [5] d.at "a" . to_vector . should_equal [5]
d.at "b" . to_vector . should_equal [False] d.at "b" . to_vector . should_equal [False]
d.at "c" . to_vector . should_equal [" "] d.at "c" . to_vector . should_equal [" "]
group_builder.specify "filter_blank_rows should drop rows that are all blank" <| group_builder.specify "filter_blank_rows should drop rows that are all blank" <|
d2 = data.t0.filter_blank_rows when=Blank_Selector.All_Cells d2 = t0.get.filter_blank_rows when=Blank_Selector.All_Cells
d2.at "a" . to_vector . should_equal [0, 1, Nothing, 42, 5] d2.at "a" . to_vector . should_equal [0, 1, Nothing, 42, 5]
d2.at "b" . to_vector . should_equal [True, Nothing, True, False, False] d2.at "b" . to_vector . should_equal [True, Nothing, True, False, False]
d2.at "c" . to_vector . should_equal ["", "foo", "bar", Nothing, " "] d2.at "c" . to_vector . should_equal ["", "foo", "bar", Nothing, " "]
@ -97,76 +79,76 @@ add_specs suite_builder setup =
t2.at 42 . to_vector . should_equal [42] t2.at 42 . to_vector . should_equal [42]
group_builder.specify "should allow to select blank columns" <| group_builder.specify "should allow to select blank columns" <|
r1 = data.t1.select_blank_columns r1 = t1.get.select_blank_columns
r1.columns.map .name . should_equal ["f"] r1.columns.map .name . should_equal ["f"]
r1.at "f" . to_vector . should_equal [Nothing, "", Nothing, ""] r1.at "f" . to_vector . should_equal [Nothing, "", Nothing, ""]
r2 = data.t1.select_blank_columns when=Blank_Selector.Any_Cell r2 = t1.get.select_blank_columns when=Blank_Selector.Any_Cell
r2.columns.map .name . should_equal ["a", "b", "d", "e", "f"] r2.columns.map .name . should_equal ["a", "b", "d", "e", "f"]
r2.at "d" . to_vector . should_equal [Nothing, True, False, True] r2.at "d" . to_vector . should_equal [Nothing, True, False, True]
group_builder.specify "should allow to remove blank columns" <| group_builder.specify "should allow to remove blank columns" <|
r1 = data.t1.remove_blank_columns r1 = t1.get.remove_blank_columns
r1.columns.map .name . should_equal ["a", "b", "c", "d", "e"] r1.columns.map .name . should_equal ["a", "b", "c", "d", "e"]
r1.at "a" . to_vector . should_equal [1, Nothing, 3, 4] r1.at "a" . to_vector . should_equal [1, Nothing, 3, 4]
r2 = data.t1.remove_blank_columns when=Blank_Selector.Any_Cell r2 = t1.get.remove_blank_columns when=Blank_Selector.Any_Cell
r2.columns.map .name . should_equal ["c"] r2.columns.map .name . should_equal ["c"]
r2.at "c" . to_vector . should_equal [10, 20, 30, 40] r2.at "c" . to_vector . should_equal [10, 20, 30, 40]
if test_selection.is_nan_and_nothing_distinct then if test_selection.is_nan_and_nothing_distinct then
group_builder.specify "should not treat NaNs as blank by default" <| group_builder.specify "should not treat NaNs as blank by default" <|
r1 = data.t3.filter_blank_rows when=Blank_Selector.Any_Cell r1 = t3.get.filter_blank_rows when=Blank_Selector.Any_Cell
# We cannot use `Vector.==` because `NaN != NaN`. # We cannot use `Vector.==` because `NaN != NaN`.
r1.at "X" . to_vector . to_text . should_equal "[1.5, NaN]" r1.at "X" . to_vector . to_text . should_equal "[1.5, NaN]"
r1.at "Y" . to_vector . should_equal [2.0, 5.0] r1.at "Y" . to_vector . should_equal [2.0, 5.0]
r2 = data.t3.filter_blank_rows when=Blank_Selector.All_Cells r2 = t3.get.filter_blank_rows when=Blank_Selector.All_Cells
r2.at "X" . to_vector . to_text . should_equal "[2.0, 1.5, NaN, NaN]" r2.at "X" . to_vector . to_text . should_equal "[2.0, 1.5, NaN, NaN]"
r2.at "Y" . to_vector . should_equal [Nothing, 2.0, Nothing, 5.0] r2.at "Y" . to_vector . should_equal [Nothing, 2.0, Nothing, 5.0]
r3 = data.t4.remove_blank_columns r3 = t4.get.remove_blank_columns
r3.columns.map .name . should_equal ["c", "g", "h"] r3.columns.map .name . should_equal ["c", "g", "h"]
r3.at "g" . to_vector . to_text . should_equal "[NaN, 1.0, 2.0, 3.4]" r3.at "g" . to_vector . to_text . should_equal "[NaN, 1.0, 2.0, 3.4]"
r4 = data.t4.remove_blank_columns when=Blank_Selector.Any_Cell r4 = t4.get.remove_blank_columns when=Blank_Selector.Any_Cell
r4.columns.map .name . should_equal ["c", "g"] r4.columns.map .name . should_equal ["c", "g"]
r4.at "g" . to_vector . to_text . should_equal "[NaN, 1.0, 2.0, 3.4]" r4.at "g" . to_vector . to_text . should_equal "[NaN, 1.0, 2.0, 3.4]"
r5 = data.t4.select_blank_columns when=Blank_Selector.Any_Cell r5 = t4.get.select_blank_columns when=Blank_Selector.Any_Cell
r5.columns.map .name . should_equal ["h"] r5.columns.map .name . should_equal ["h"]
r5.at "h" . to_vector . to_text . should_equal "[NaN, Nothing, NaN, Nothing]" r5.at "h" . to_vector . to_text . should_equal "[NaN, Nothing, NaN, Nothing]"
group_builder.specify "should allow to treat NaNs as blank if asked" <| group_builder.specify "should allow to treat NaNs as blank if asked" <|
r1 = data.t3.filter_blank_rows when=Blank_Selector.Any_Cell treat_nans_as_blank=True r1 = t3.get.filter_blank_rows when=Blank_Selector.Any_Cell treat_nans_as_blank=True
# We cannot use `Vector.==` because `NaN != NaN`. # We cannot use `Vector.==` because `NaN != NaN`.
r1.at "X" . to_vector . should_equal [1.5] r1.at "X" . to_vector . should_equal [1.5]
r1.at "Y" . to_vector . should_equal [2.0] r1.at "Y" . to_vector . should_equal [2.0]
r2 = data.t3.filter_blank_rows when=Blank_Selector.All_Cells treat_nans_as_blank=True r2 = t3.get.filter_blank_rows when=Blank_Selector.All_Cells treat_nans_as_blank=True
r2.at "X" . to_vector . to_text . should_equal "[2.0, 1.5, NaN]" r2.at "X" . to_vector . to_text . should_equal "[2.0, 1.5, NaN]"
r2.at "Y" . to_vector . should_equal [Nothing, 2.0, 5.0] r2.at "Y" . to_vector . should_equal [Nothing, 2.0, 5.0]
r3 = data.t4.remove_blank_columns when=Blank_Selector.All_Cells treat_nans_as_blank=True r3 = t4.get.remove_blank_columns when=Blank_Selector.All_Cells treat_nans_as_blank=True
r3.columns.map .name . should_equal ["c", "g"] r3.columns.map .name . should_equal ["c", "g"]
r3.at "g" . to_vector . to_text . should_equal "[NaN, 1.0, 2.0, 3.4]" r3.at "g" . to_vector . to_text . should_equal "[NaN, 1.0, 2.0, 3.4]"
r4 = data.t4.select_blank_columns when=Blank_Selector.All_Cells treat_nans_as_blank=True r4 = t4.get.select_blank_columns when=Blank_Selector.All_Cells treat_nans_as_blank=True
r4.columns.map .name . should_equal ["h"] r4.columns.map .name . should_equal ["h"]
r4.at "h" . to_vector . to_text . should_equal "[NaN, Nothing, NaN, Nothing]" r4.at "h" . to_vector . to_text . should_equal "[NaN, Nothing, NaN, Nothing]"
r5 = data.t4.remove_blank_columns when=Blank_Selector.Any_Cell treat_nans_as_blank=True r5 = t4.get.remove_blank_columns when=Blank_Selector.Any_Cell treat_nans_as_blank=True
r5.columns.map .name . should_equal ["c"] r5.columns.map .name . should_equal ["c"]
r5.at "c" . to_vector . should_equal [10, 20, 40, 30] r5.at "c" . to_vector . should_equal [10, 20, 40, 30]
r6 = data.t4.select_blank_columns when=Blank_Selector.Any_Cell treat_nans_as_blank=True r6 = t4.get.select_blank_columns when=Blank_Selector.Any_Cell treat_nans_as_blank=True
r6.columns.map .name . should_equal ["g", "h"] r6.columns.map .name . should_equal ["g", "h"]
r6.at "h" . to_vector . to_text . should_equal "[NaN, Nothing, NaN, Nothing]" r6.at "h" . to_vector . to_text . should_equal "[NaN, Nothing, NaN, Nothing]"
if test_selection.is_nan_and_nothing_distinct.not then if test_selection.is_nan_and_nothing_distinct.not then
group_builder.specify "this backend treats NaN as Nothing" <| group_builder.specify "this backend treats NaN as Nothing" <|
data.t3.at "X" . to_vector . should_equal [2.0, 1.5, Nothing, Nothing] t3.get.at "X" . to_vector . should_equal [2.0, 1.5, Nothing, Nothing]
data.t3.at "X" . is_nan . to_vector . should_fail_with Unsupported_Database_Operation t3.get.at "X" . is_nan . to_vector . should_fail_with Unsupported_Database_Operation
group_builder.specify "select_blank_columns and remove_blank_columns should deal with edge cases" <| group_builder.specify "select_blank_columns and remove_blank_columns should deal with edge cases" <|
t = table_builder [["X", [1, 2, 3, 4]]] t = table_builder [["X", [1, 2, 3, 4]]]
@ -184,57 +166,41 @@ add_specs suite_builder setup =
r3.catch.to_display_text . should_equal "No columns in the result, because of another problem: No columns were blank." r3.catch.to_display_text . should_equal "No columns in the result, because of another problem: No columns were blank."
suite_builder.group prefix+"Filling Missing Values" group_builder-> suite_builder.group prefix+"Filling Missing Values" group_builder->
data = Data.setup create_connection_fn setup.table_builder
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
group_builder.specify "should coerce long and double types to double" <| group_builder.specify "should coerce long and double types to double" <|
table = table_builder [["X", [1, Nothing, 2, Nothing]], ["Y", [0.5, Nothing, Nothing, 0.25]]] table = table_builder [["X", [1, Nothing, 2, Nothing]], ["Y", [0.5, Nothing, Nothing, 0.25]]]
ints = table.at "X" ints = table.at "X"
ints_filled = ints.fill_nothing 0.5 ints_filled = ints.fill_nothing 0.5
ints_filled.to_vector . should_equal [1.0, 0.5, 2.0, 0.5] ints_filled.to_vector . should_equal [1.0, 0.5, 2.0, 0.5]
ints_filled.value_type.is_floating_point.should_be_true ints_filled.value_type.should_be_a (Value_Type.Float ...)
decimals = table.at "Y" decimals = table.at "Y"
decimals_filled = decimals.fill_nothing 42 decimals_filled = decimals.fill_nothing 42
decimals_filled.to_vector . should_equal [0.5, 42.0, 42.0, 0.25] decimals_filled.to_vector . should_equal [0.5, 42.0, 42.0, 0.25]
decimals_filled.value_type.is_floating_point.should_be_true decimals_filled.value_type.should_be_a (Value_Type.Float ...)
r1 = ints.fill_nothing decimals r1 = ints.fill_nothing decimals
r1.to_vector . should_equal [1.0, Nothing, 2.0, 0.25] r1.to_vector . should_equal [1.0, Nothing, 2.0, 0.25]
vt1 = r1.value_type r1.value_type.should_be_a (Value_Type.Float ...)
Test.with_clue "r1.value_type="+vt1.to_display_text+": " <|
vt1.is_floating_point.should_be_true
r2 = ints.coalesce [decimals, 133] r2 = ints.coalesce [decimals, 133]
r2.to_vector . should_equal [1.0, 133, 2.0, 0.25] r2.to_vector . should_equal [1.0, 133, 2.0, 0.25]
vt2 = r2.value_type r2.value_type.should_be_a (Value_Type.Float ...)
Test.with_clue "r2.value_type="+vt2.to_display_text+": " <|
vt2.is_floating_point.should_be_true
t2 = table_builder [["X", [1, Nothing]], ["Y", [0.5, Nothing]]] t2 = table_builder [["X", [1, Nothing]], ["Y", [0.5, Nothing]]]
r3 = (t2.at "X").fill_nothing (t2.at "Y") r3 = (t2.at "X").fill_nothing (t2.at "Y")
r3.to_vector . should_equal [1.0, Nothing] r3.to_vector . should_equal [1.0, Nothing]
vt3 = r3.value_type r3.value_type.should_be_a (Value_Type.Float ...)
Test.with_clue "r3.value_type="+vt3.to_display_text+": " <|
vt3.is_floating_point.should_be_true
r4 = ints.fill_nothing 100.0 r4 = ints.fill_nothing 100.0
r4.to_vector . should_equal [1, 100, 2, 100] r4.to_vector . should_equal [1, 100, 2, 100]
vt4 = r4.value_type r4.value_type.should_be_a (Value_Type.Float ...)
Test.with_clue "r4.value_type="+vt4.to_display_text+": " <|
vt4.is_floating_point.should_be_true
group_builder.specify "should keep String, Boolean, Long and Double type" <| group_builder.specify "should keep String, Boolean, Long and Double type" <|
table = table_builder [["X", ["a", Nothing, "b", Nothing]], ["Y", [True, False, Nothing, Nothing]], ["Z", [1, Nothing, 2, Nothing]], ["W", [0.5, Nothing, Nothing, 0.25]]] table = table_builder [["X", ["a", Nothing, "b", Nothing]], ["Y", [True, False, Nothing, Nothing]], ["Z", [1, Nothing, 2, Nothing]], ["W", [0.5, Nothing, Nothing, 0.25]]]
strs = table.at "X" strs = table.at "X"
strs_filled = strs.fill_nothing "X" strs_filled = strs.fill_nothing "X"
strs_filled.to_vector . should_equal ["a", "X", "b", "X"] strs_filled.to_vector . should_equal ["a", "X", "b", "X"]
strs_filled.value_type.is_text.should_be_true strs_filled.value_type.should_be_a (Value_Type.Char ...)
bools = table.at "Y" bools = table.at "Y"
bools_filled = bools.fill_nothing False bools_filled = bools.fill_nothing False
@ -244,12 +210,12 @@ add_specs suite_builder setup =
ints = table.at "Z" ints = table.at "Z"
ints_filled = ints.fill_nothing 42 ints_filled = ints.fill_nothing 42
ints_filled.to_vector . should_equal [1, 42, 2, 42] ints_filled.to_vector . should_equal [1, 42, 2, 42]
ints_filled.value_type.is_integer.should_be_true setup.expect_integer_type <| ints_filled
decimals = table.at "W" decimals = table.at "W"
decimals_filled = decimals.fill_nothing 1.0 decimals_filled = decimals.fill_nothing 1.0
decimals_filled.to_vector . should_equal [0.5, 1.0, 1.0, 0.25] decimals_filled.to_vector . should_equal [0.5, 1.0, 1.0, 0.25]
decimals_filled.value_type.is_floating_point.should_be_true decimals_filled.value_type.should_be_a (Value_Type.Float ...)
group_builder.specify "should not allow mixing types by default" <| group_builder.specify "should not allow mixing types by default" <|
table = table_builder [["X", [1, Nothing, 2, Nothing]], ["Y", [True, False, Nothing, Nothing]], ["Z", [0.5, Nothing, Nothing, 0.25]]] table = table_builder [["X", [1, Nothing, 2, Nothing]], ["Y", [True, False, Nothing, Nothing]], ["Z", [0.5, Nothing, Nothing, 0.25]]]
@ -304,6 +270,7 @@ add_specs suite_builder setup =
e = a.fill_nothing c e = a.fill_nothing c
e.to_vector . should_equal ["a", "abc", "c"] e.to_vector . should_equal ["a", "abc", "c"]
e.value_type.should_be_a (Value_Type.Char ...)
Test.with_clue "e.value_type="+e.value_type.to_display_text+": " <| Test.with_clue "e.value_type="+e.value_type.to_display_text+": " <|
e.value_type.variable_length.should_be_true e.value_type.variable_length.should_be_true

View File

@ -14,14 +14,8 @@ main filter=Nothing = run_default_backend add_specs filter
type My_Type type My_Type
Value x:Text Value x:Text
type Data type Lazy_Ref
Value ~connection Value ~get
setup create_connection_fn =
Data.Value (create_connection_fn Nothing)
teardown self = self.connection.close
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
@ -264,12 +258,13 @@ add_specs suite_builder setup =
_ -> False _ -> False
if is_comparable then if is_comparable then
table = table_builder_typed [["x", [value, Nothing, other_value, other_value, Nothing, value, Nothing]]] value_type table = Lazy_Ref.Value <|
table_builder_typed [["x", [value, Nothing, other_value, other_value, Nothing, value, Nothing]]] value_type
group_builder.specify "Correctly handle Nothing in .sort (asc) for "+value_type.to_text <| group_builder.specify "Correctly handle Nothing in .sort (asc) for "+value_type.to_text <|
t1 = table . sort [..Name "x" ..Ascending] t1 = table.get . sort [..Name "x" ..Ascending]
t1.at "x" . to_vector . should_equal [Nothing, Nothing, Nothing, value, value, other_value, other_value] t1.at "x" . to_vector . should_equal [Nothing, Nothing, Nothing, value, value, other_value, other_value]
group_builder.specify "Correctly handle Nothing in .sort (desc) for "+value_type.to_text <| group_builder.specify "Correctly handle Nothing in .sort (desc) for "+value_type.to_text <|
t1 = table . sort [..Name "x" ..Descending] t1 = table.get . sort [..Name "x" ..Descending]
t1.at "x" . to_vector . should_equal [other_value, other_value, value, value, Nothing, Nothing, Nothing] t1.at "x" . to_vector . should_equal [other_value, other_value, value, value, Nothing, Nothing, Nothing]

View File

@ -16,37 +16,26 @@ from project.Common_Table_Operations.Util import run_default_backend
from Standard.Test import all from Standard.Test import all
type Data type Lazy_Ref
Value ~connection Value ~get
setup create_connection_fn =
Data.Value (create_connection_fn Nothing)
teardown self = self.connection.close
main filter=Nothing = run_default_backend add_specs filter main filter=Nothing = run_default_backend add_specs filter
add_specs suite_builder setup = add_specs suite_builder setup =
prefix = setup.prefix prefix = setup.prefix
materialize = setup.materialize materialize = setup.materialize
create_connection_fn = setup.create_connection_func table_builder = setup.table_builder
suite_builder.group prefix+"Table Text Cleanse" group_builder-> suite_builder.group prefix+"Table Text Cleanse" group_builder->
data = Data.setup create_connection_fn
group_builder.teardown <|
data.teardown
table_builder cols =
setup.table_builder cols connection=data.connection
flight = ["Flight", [" BA0123", "BA0123 ", " SG0456 ", "BA 0123", " S G 0 4 5 6 "]] flight = ["Flight", [" BA0123", "BA0123 ", " SG0456 ", "BA 0123", " S G 0 4 5 6 "]]
passenger = ["Passenger", [" Albert Einstein", "Marie Curie ", " Isaac Newton ", "Stephen Hawking", " A d a Lovelace "]] passenger = ["Passenger", [" Albert Einstein", "Marie Curie ", " Isaac Newton ", "Stephen Hawking", " A d a Lovelace "]]
ticket_price = ["Ticket Price", [101, 576, 73, 112, 74]] ticket_price = ["Ticket Price", [101, 576, 73, 112, 74]]
table = table_builder [flight, passenger, ticket_price] table = Lazy_Ref.Value <|
table_builder [flight, passenger, ticket_price]
group_builder.specify "should remove leading whitespace" <| group_builder.specify "should remove leading whitespace" <|
clean_flight = ["Flight", ["BA0123", "BA0123 ", "SG0456 ", "BA 0123", "S G 0 4 5 6 "]] clean_flight = ["Flight", ["BA0123", "BA0123 ", "SG0456 ", "BA 0123", "S G 0 4 5 6 "]]
clean_passenger = ["Passenger", ["Albert Einstein", "Marie Curie ", "Isaac Newton ", "Stephen Hawking", "A d a Lovelace "]] clean_passenger = ["Passenger", ["Albert Einstein", "Marie Curie ", "Isaac Newton ", "Stephen Hawking", "A d a Lovelace "]]
expected_table = Table.new [clean_flight, clean_passenger, ticket_price] expected_table = Table.new [clean_flight, clean_passenger, ticket_price]
res = table.text_cleanse ["Flight", "Passenger"] [..Leading_Whitespace] res = table.get.text_cleanse ["Flight", "Passenger"] [..Leading_Whitespace]
case res.is_error && setup.is_database of case res.is_error && setup.is_database of
True -> True ->
res.should_fail_with Unsupported_Database_Operation res.should_fail_with Unsupported_Database_Operation
@ -58,7 +47,7 @@ add_specs suite_builder setup =
clean_flight = ["Flight", ["BA0123", "BA0123 ", "SG0456 ", "BA 0123", "S G 0 4 5 6 "]] clean_flight = ["Flight", ["BA0123", "BA0123 ", "SG0456 ", "BA 0123", "S G 0 4 5 6 "]]
clean_passenger = ["Passenger", ["Albert Einstein", "Marie Curie ", "Isaac Newton ", "Stephen Hawking", "A d a Lovelace "]] clean_passenger = ["Passenger", ["Albert Einstein", "Marie Curie ", "Isaac Newton ", "Stephen Hawking", "A d a Lovelace "]]
expected_table = Table.new [clean_flight, clean_passenger, ticket_price] expected_table = Table.new [clean_flight, clean_passenger, ticket_price]
res = table.text_cleanse [(regex "Fl.*"), (regex "P.*")] [..Leading_Whitespace] res = table.get.text_cleanse [(regex "Fl.*"), (regex "P.*")] [..Leading_Whitespace]
case res.is_error && setup.is_database of case res.is_error && setup.is_database of
True -> True ->
res.should_fail_with Unsupported_Database_Operation res.should_fail_with Unsupported_Database_Operation
@ -70,7 +59,7 @@ add_specs suite_builder setup =
clean_flight = ["Flight", ["BA0123", "BA0123 ", "SG0456 ", "BA 0123", "S G 0 4 5 6 "]] clean_flight = ["Flight", ["BA0123", "BA0123 ", "SG0456 ", "BA 0123", "S G 0 4 5 6 "]]
clean_passenger = ["Passenger", ["Albert Einstein", "Marie Curie ", "Isaac Newton ", "Stephen Hawking", "A d a Lovelace "]] clean_passenger = ["Passenger", ["Albert Einstein", "Marie Curie ", "Isaac Newton ", "Stephen Hawking", "A d a Lovelace "]]
expected_table = Table.new [clean_flight, clean_passenger, ticket_price] expected_table = Table.new [clean_flight, clean_passenger, ticket_price]
res = table.text_cleanse [..By_Type ..Char] [..Leading_Whitespace] res = table.get.text_cleanse [..By_Type ..Char] [..Leading_Whitespace]
case res.is_error && setup.is_database of case res.is_error && setup.is_database of
True -> True ->
res.should_fail_with Unsupported_Database_Operation res.should_fail_with Unsupported_Database_Operation
@ -79,7 +68,7 @@ add_specs suite_builder setup =
r.length . should_equal 5 r.length . should_equal 5
r.should_equal (expected_table . rows . map .to_vector) r.should_equal (expected_table . rows . map .to_vector)
group_builder.specify "should error if applied to non-text column" <| group_builder.specify "should error if applied to non-text column" <|
table.text_cleanse ["Ticket Price"] [..Leading_Whitespace] . should_fail_with Invalid_Value_Type table.get.text_cleanse ["Ticket Price"] [..Leading_Whitespace] . should_fail_with Invalid_Value_Type
suite_builder.group "Column Text Cleanse" group_builder-> suite_builder.group "Column Text Cleanse" group_builder->
test_col = Column.from_vector "Test" [" It was", "the best ", "of times", " it was the worst of times "] test_col = Column.from_vector "Test" [" It was", "the best ", "of times", " it was the worst of times "]
group_builder.specify "should remove leading whitespace" <| group_builder.specify "should remove leading whitespace" <|

View File

@ -690,7 +690,7 @@ add_postgres_specs suite_builder create_connection_fn db_name =
Common_Spec.add_specs suite_builder prefix create_connection_fn Common_Spec.add_specs suite_builder prefix create_connection_fn
common_selection = Common_Table_Operations.Main.Test_Selection.Config supports_case_sensitive_columns=True order_by_unicode_normalization_by_default=True allows_mixed_type_comparisons=False fixed_length_text_columns=True removes_trailing_whitespace_casting_from_char_to_varchar=True supports_decimal_type=True supported_replace_params=supported_replace_params run_advanced_edge_case_tests_by_default=True supports_date_time_without_timezone=True common_selection = Common_Table_Operations.Main.Test_Selection.Config supports_case_sensitive_columns=True order_by_unicode_normalization_by_default=True allows_mixed_type_comparisons=False fixed_length_text_columns=True removes_trailing_whitespace_casting_from_char_to_varchar=True supports_decimal_type=True supported_replace_params=supported_replace_params run_advanced_edge_case_tests_by_default=True supports_date_time_without_timezone=True is_nan_comparable=True
aggregate_selection = Common_Table_Operations.Aggregate_Spec.Test_Selection.Config first_last_row_order=False aggregation_problems=False aggregate_selection = Common_Table_Operations.Aggregate_Spec.Test_Selection.Config first_last_row_order=False aggregation_problems=False
agg_in_memory_table = (enso_project.data / "data.csv") . read agg_in_memory_table = (enso_project.data / "data.csv") . read