mirror of
https://github.com/enso-org/enso.git
synced 2024-12-25 22:12:01 +03:00
More E2E tests; export default
classes from modules (#8730)
This is a prerequisite for adding a CI action for E2E tests. - Fix E2E tests - Remove visual regression testing (VRT) and associated fixtures (screenshots) for now - Switch dashboard almost fully to Vite, from ESBuild, to match GUI2's build tooling. - Add some new E2E tests: - Creating assets - Deleting assets - Creating assets from the samples on the home page - Sort assets - Includes fixes for sorting: - Group sorted assets by type again (regression) (see https://github.com/enso-org/cloud-v2/issues/554) - Make sorting by title, case insensitive. This is because it is more intuitive for non-programmers if all items with uppercase names *aren't* separated from those with lowercase names - especially since the Windows FS is case-insensitive. - Normalization of Unicode letters is *not* currently being done. It can potentially be added later. - Double-clicking *anywhere* on a directory row now expands it. Previously it was only being expanded when double clicking - Add recursive label adding/removal to mirror backend - Note: The current implementation is not exactly the same as the backend's implementation. - Fix https://github.com/enso-org/cloud-v2/issues/872 - Unset "saved project details" (for opening the last open project) if fetching it produces an error. # Important Notes - All tests pass. (run `npm run test:e2e` in `app/ide-desktop/lib/dashboard`) - All `npm` commands should be run in `app/ide-desktop/lib/dashboard`. `dashboard:*` npm scripts have been removed from `app/ide-desktop` to prevent a mess. - `npm run dev` confirmed to still work. Note that it has not been changed as it was already using Vite. - `npm run build` now uses `vite build`. This has been tested using a local HTTP server that supports `404.html`. - Other cases have been tested: - `npm run test:e2e` works (all tests pass) - `./run ide build` works - `./run ide watch` works - `./run ide2 build` works - `./run gui watch` works
This commit is contained in:
parent
06f1c772d8
commit
cbf6d41e4c
@ -3,3 +3,5 @@
|
||||
0aa7d7ee4d969ec8e8f9d376e72741dca324fdf6
|
||||
# Update code style to use rust fmt (#3131)
|
||||
c822256e6c531e56e894f9f92654654f63cfd6bc
|
||||
# Change dashboard to use 2 spaces for indentation (#8798)
|
||||
5f1333a519235b27fc04457de1fd07b1a0128073
|
||||
|
@ -32,6 +32,7 @@ app/ide-desktop/lib/client/electron-builder-config.json
|
||||
app/ide-desktop/lib/content-config/src/config.json
|
||||
app/ide-desktop/lib/dashboard/playwright-report/
|
||||
app/ide-desktop/lib/dashboard/playwright/.cache/
|
||||
app/ide-desktop/lib/dashboard/dist/
|
||||
app/gui/view/documentation/assets/stylesheet.css
|
||||
app/gui2/rust-ffi/pkg
|
||||
app/gui2/src/assets/font-*.css
|
||||
|
@ -36,7 +36,7 @@ import * as set from 'lib0/set'
|
||||
import { toast } from 'react-toastify'
|
||||
import type { NodeMetadata } from 'shared/yjsModel'
|
||||
import { computed, onMounted, onScopeDispose, onUnmounted, ref, watch } from 'vue'
|
||||
import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/projectManager'
|
||||
import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/ProjectManager'
|
||||
import { type Usage } from './ComponentBrowser/input'
|
||||
|
||||
const EXECUTION_MODES = ['design', 'live']
|
||||
|
@ -512,12 +512,12 @@ export default [
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'lib/dashboard/test*/**/*.ts',
|
||||
'lib/dashboard/test*/**/*.mts',
|
||||
'lib/dashboard/test*/**/*.cts',
|
||||
'lib/dashboard/test*/**/*.tsx',
|
||||
'lib/dashboard/test*/**/*.mtsx',
|
||||
'lib/dashboard/test*/**/*.ctsx',
|
||||
'lib/dashboard/e2e/**/*.ts',
|
||||
'lib/dashboard/e2e/**/*.mts',
|
||||
'lib/dashboard/e2e/**/*.cts',
|
||||
'lib/dashboard/e2e/**/*.tsx',
|
||||
'lib/dashboard/e2e/**/*.mtsx',
|
||||
'lib/dashboard/e2e/**/*.ctsx',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-properties': [
|
||||
|
@ -2,9 +2,9 @@
|
||||
"name": "enso-common",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"main": "./src/index.js",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
".": "./src/index.js",
|
||||
"./src/detect": "./src/detect.ts",
|
||||
"./src/gtag": "./src/gtag.ts"
|
||||
}
|
||||
|
28
app/ide-desktop/lib/common/src/index.d.ts
vendored
Normal file
28
app/ide-desktop/lib/common/src/index.d.ts
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
/** @file This module contains metadata about the product and distribution,
|
||||
* and various other constants that are needed in multiple sibling packages.
|
||||
*
|
||||
* Code in this package is used by two or more sibling packages of this package. The code is defined
|
||||
* here when it is not possible for a sibling package to own that code without introducing a
|
||||
* circular dependency in our packages. */
|
||||
|
||||
// ========================
|
||||
// === Product metadata ===
|
||||
// ========================
|
||||
|
||||
/** URL protocol scheme for deep links to authentication flow pages, without the `:` suffix.
|
||||
*
|
||||
* For example: the deep link URL
|
||||
* `enso://authentication/register?code=...&state=...` uses this scheme. */
|
||||
export const DEEP_LINK_SCHEME: string
|
||||
|
||||
/** Name of the product. */
|
||||
export const PRODUCT_NAME: string
|
||||
|
||||
/** Company name, used as the copyright holder. */
|
||||
export const COMPANY_NAME: string
|
||||
|
||||
/** COOP, COEP, and CORP headers: https://web.dev/coop-coep/
|
||||
*
|
||||
* These are required to increase the resolution of `performance.now()` timers,
|
||||
* making profiling a lot more accurate and consistent. */
|
||||
export const COOP_COEP_CORP_HEADERS: [header: string, value: string][]
|
@ -21,11 +21,12 @@ export const PRODUCT_NAME = 'Enso'
|
||||
/** Company name, used as the copyright holder. */
|
||||
export const COMPANY_NAME = 'New Byte Order sp. z o.o.'
|
||||
|
||||
/** COOP, COEP, and CORP headers: https://web.dev/coop-coep/
|
||||
/** @type {[header: string, value: string][]}
|
||||
* COOP, COEP, and CORP headers: https://web.dev/coop-coep/
|
||||
*
|
||||
* These are required to increase the resolution of `performance.now()` timers,
|
||||
* making profiling a lot more accurate and consistent. */
|
||||
export const COOP_COEP_CORP_HEADERS: [header: string, value: string][] = [
|
||||
export const COOP_COEP_CORP_HEADERS = [
|
||||
['Cross-Origin-Embedder-Policy', 'credentialless'],
|
||||
['Cross-Origin-Opener-Policy', 'same-origin'],
|
||||
['Cross-Origin-Resource-Policy', 'same-origin'],
|
9
app/ide-desktop/lib/common/tsconfig.json
Normal file
9
app/ide-desktop/lib/common/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": false,
|
||||
"checkJs": false,
|
||||
"skipLibCheck": false
|
||||
},
|
||||
"include": ["./src/"]
|
||||
}
|
@ -300,7 +300,7 @@ class Main implements AppRunner {
|
||||
const ideElement = document.getElementById('root')
|
||||
if (ideElement) {
|
||||
ideElement.style.top = '-100vh'
|
||||
ideElement.style.display = 'fixed'
|
||||
ideElement.style.position = 'fixed'
|
||||
}
|
||||
const ide2Element = document.getElementById('app')
|
||||
if (ide2Element) {
|
||||
|
3
app/ide-desktop/lib/dashboard/.prettierignore
Normal file
3
app/ide-desktop/lib/dashboard/.prettierignore
Normal file
@ -0,0 +1,3 @@
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
dist/
|
@ -22,10 +22,27 @@ module.exports = {
|
||||
'',
|
||||
'^enso-',
|
||||
'',
|
||||
'^#[/](?!components[/]).*$',
|
||||
'^#[/]App',
|
||||
'^#[/]appUtils',
|
||||
'',
|
||||
'^#[/]hooks[/]',
|
||||
'',
|
||||
'^#[/]providers[/]',
|
||||
'',
|
||||
'^#[/]events[/]',
|
||||
'',
|
||||
'^#[/]pages[/]',
|
||||
'',
|
||||
'^#[/]layouts[/]',
|
||||
'',
|
||||
'^#[/]components[/]',
|
||||
'',
|
||||
'^#[/]services[/]',
|
||||
'',
|
||||
'^#[/]utilities[/]',
|
||||
'',
|
||||
'^#[/]authentication[/]',
|
||||
'',
|
||||
'^[.]',
|
||||
],
|
||||
importOrderParserPlugins: ['typescript', 'jsx', 'importAssertions'],
|
||||
|
51
app/ide-desktop/lib/dashboard/404.html
Normal file
51
app/ide-desktop/lib/dashboard/404.html
Normal file
@ -0,0 +1,51 @@
|
||||
<!--
|
||||
FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/345
|
||||
This file is used by both the `content` and `dashboard` packages. The `dashboard` package uses it
|
||||
via a symlink. This is temporary, while the `content` and `dashboard` have separate entrypoints
|
||||
for cloud and desktop. Once they are merged, the symlink must be removed.
|
||||
-->
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<!-- FIXME https://github.com/validator/validator/issues/917 -->
|
||||
<!-- FIXME Security Vulnerabilities: https://github.com/enso-org/ide/issues/226 -->
|
||||
<!-- NOTE `frame-src` section of `http-equiv` required only for authorization -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="
|
||||
default-src 'self';
|
||||
frame-src 'self' data: https://accounts.google.com https://enso-org.firebaseapp.com;
|
||||
script-src 'self' 'unsafe-eval' data: https://*;
|
||||
style-src 'self' 'unsafe-inline' data: https://*;
|
||||
connect-src 'self' data: ws://localhost:* ws://127.0.0.1:* http://localhost:* https://* wss://*;
|
||||
worker-src 'self' blob:;
|
||||
img-src 'self' blob: data: https://*;
|
||||
font-src 'self' data: https://*"
|
||||
/>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="
|
||||
width=device-width,
|
||||
initial-scale = 1.0,
|
||||
maximum-scale = 1.0,
|
||||
user-scalable = no"
|
||||
/>
|
||||
<title>Enso</title>
|
||||
<script type="module" src="./src/entrypoint.ts" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="enso-dashboard" class="enso-dashboard"></div>
|
||||
<div id="enso-chat" class="enso-chat"></div>
|
||||
<noscript>
|
||||
This page requires JavaScript to run. Please enable it in your browser.
|
||||
</noscript>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-CLTBJ37MDM"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
@ -1,53 +0,0 @@
|
||||
/** @file Entry point for the bundler. */
|
||||
import * as fs from 'node:fs/promises'
|
||||
import * as path from 'node:path'
|
||||
import * as url from 'node:url'
|
||||
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
import * as bundler from './esbuild-config'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
export const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
|
||||
export const ANALYZE = process.argv.includes('--analyze')
|
||||
|
||||
// ===============
|
||||
// === Bundler ===
|
||||
// ===============
|
||||
|
||||
/** Clean up old build output and runs the esbuild bundler. */
|
||||
async function bundle() {
|
||||
try {
|
||||
try {
|
||||
await fs.rm('./build', { recursive: true })
|
||||
} catch {
|
||||
// Ignored.
|
||||
}
|
||||
const opts = bundler.bundlerOptions({
|
||||
outputPath: './build',
|
||||
devMode: false,
|
||||
})
|
||||
opts.entryPoints.push(
|
||||
path.resolve(THIS_PATH, 'src', 'index.html'),
|
||||
path.resolve(THIS_PATH, 'src', 'entrypoint.ts')
|
||||
)
|
||||
opts.metafile = ANALYZE
|
||||
opts.loader['.html'] = 'copy'
|
||||
const result = await esbuild.build(opts)
|
||||
await fs.copyFile('build/index.html', 'build/404.html')
|
||||
if (result.metafile) {
|
||||
console.log(await esbuild.analyzeMetafile(result.metafile))
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
// The error is being re-thrown.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
void bundle()
|
753
app/ide-desktop/lib/dashboard/e2e/actions.ts
Normal file
753
app/ide-desktop/lib/dashboard/e2e/actions.ts
Normal file
@ -0,0 +1,753 @@
|
||||
/** @file Various actions, locators, and constants used in end-to-end tests. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as apiModule from './api'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** An example password that does not meet validation requirements. */
|
||||
export const INVALID_PASSWORD = 'password'
|
||||
/** An example password that meets validation requirements. */
|
||||
export const VALID_PASSWORD = 'Password0!'
|
||||
/** An example valid email address. */
|
||||
export const VALID_EMAIL = 'email@example.com'
|
||||
|
||||
// ================
|
||||
// === Locators ===
|
||||
// ================
|
||||
|
||||
// === Input locators ===
|
||||
|
||||
/** Find an email input (if any) on the current page. */
|
||||
export function locateEmailInput(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Email')
|
||||
}
|
||||
|
||||
/** Find a password input (if any) on the current page. */
|
||||
export function locatePasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your password')
|
||||
}
|
||||
|
||||
/** Find a "confirm password" input (if any) on the current page. */
|
||||
export function locateConfirmPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Confirm password')
|
||||
}
|
||||
|
||||
/** Find an "old password" input (if any) on the current page. */
|
||||
export function locateOldPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByLabel('Old password')
|
||||
}
|
||||
|
||||
/** Find a "new password" input (if any) on the current page. */
|
||||
export function locateNewPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your new password')
|
||||
}
|
||||
|
||||
/** Find a "confirm new password" input (if any) on the current page. */
|
||||
export function locateConfirmNewPasswordInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Confirm your new password')
|
||||
}
|
||||
|
||||
/** Find a "username" input (if any) on the current page. */
|
||||
export function locateUsernameInput(page: test.Locator | test.Page) {
|
||||
return page.getByPlaceholder('Enter your username')
|
||||
}
|
||||
|
||||
/** Find a "name" input for a "new label" modal (if any) on the current page. */
|
||||
export function locateNewLabelModalNameInput(page: test.Locator | test.Page) {
|
||||
return locateNewLabelModal(page).getByLabel('Name')
|
||||
}
|
||||
|
||||
/** Find all color radio button inputs for a "new label" modal (if any) on the current page. */
|
||||
export function locateNewLabelModalColorButtons(page: test.Locator | test.Page) {
|
||||
return (
|
||||
locateNewLabelModal(page)
|
||||
.filter({ has: page.getByText('Color') })
|
||||
// The `radio` inputs are invisible, so they cannot be used in the locator.
|
||||
.getByRole('button')
|
||||
)
|
||||
}
|
||||
|
||||
/** Find a "name" input for an "upsert secret" modal (if any) on the current page. */
|
||||
export function locateSecretNameInput(page: test.Locator | test.Page) {
|
||||
return locateUpsertSecretModal(page).getByPlaceholder('Enter the name of the secret')
|
||||
}
|
||||
|
||||
/** Find a "value" input for an "upsert secret" modal (if any) on the current page. */
|
||||
export function locateSecretValueInput(page: test.Locator | test.Page) {
|
||||
return locateUpsertSecretModal(page).getByPlaceholder('Enter the value of the secret')
|
||||
}
|
||||
|
||||
/** Find a search bar input (if any) on the current page. */
|
||||
export function locateSearchBarInput(page: test.Locator | test.Page) {
|
||||
return locateSearchBar(page).getByPlaceholder(
|
||||
'Type to search for projects, data connectors, users, and more.'
|
||||
)
|
||||
}
|
||||
|
||||
/** Find the name column of the given assets table row. */
|
||||
export function locateAssetRowName(locator: test.Locator) {
|
||||
return locator.getByTestId('asset-row-name')
|
||||
}
|
||||
|
||||
// === Button locators ===
|
||||
|
||||
/** Find a toast close button (if any) on the current page. */
|
||||
export function locateToastCloseButton(page: test.Locator | test.Page) {
|
||||
// There is no other simple way to uniquely identify this element.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
return page.locator('.Toastify__close-button')
|
||||
}
|
||||
|
||||
/** Find a login button (if any) on the current page. */
|
||||
export function locateLoginButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Login', exact: true }).getByText('Login')
|
||||
}
|
||||
|
||||
/** Find a register button (if any) on the current page. */
|
||||
export function locateRegisterButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Register' }).getByText('Register')
|
||||
}
|
||||
|
||||
/** Find a reset button (if any) on the current page. */
|
||||
export function locateResetButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Reset' }).getByText('Reset')
|
||||
}
|
||||
|
||||
/** Find a user menu button (if any) on the current page. */
|
||||
export function locateUserMenuButton(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Open user menu')
|
||||
}
|
||||
|
||||
/** Find a change password button (if any) on the current page. */
|
||||
export function locateChangePasswordButton(page: test.Locator | test.Page) {
|
||||
return page
|
||||
.getByRole('button', { name: 'Change your password' })
|
||||
.getByText('Change your password')
|
||||
}
|
||||
|
||||
/** Find a "sign out" button (if any) on the current page. */
|
||||
export function locateLogoutButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Logout' }).getByText('Logout')
|
||||
}
|
||||
|
||||
/** Find a "set username" button (if any) on the current page. */
|
||||
export function locateSetUsernameButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Set Username' }).getByText('Set Username')
|
||||
}
|
||||
|
||||
/** Find a "delete" button (if any) on the current page. */
|
||||
export function locateDeleteButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Delete' }).getByText('Delete')
|
||||
}
|
||||
|
||||
/** Find a button to delete something (if any) on the current page. */
|
||||
export function locateDeleteIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Delete')
|
||||
}
|
||||
|
||||
/** Find a "create" button (if any) on the current page. */
|
||||
export function locateCreateButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Create' }).getByText('Create')
|
||||
}
|
||||
|
||||
/** Find a button to open the editor (if any) on the current page. */
|
||||
export function locatePlayOrOpenProjectButton(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Open in editor')
|
||||
}
|
||||
|
||||
/** Find a button to close the project (if any) on the current page. */
|
||||
export function locateStopProjectButton(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Stop execution')
|
||||
}
|
||||
|
||||
/** Find all labels in the labels panel (if any) on the current page. */
|
||||
export function locateLabelsPanelLabels(page: test.Locator | test.Page) {
|
||||
return (
|
||||
locateLabelsPanel(page)
|
||||
.getByRole('button')
|
||||
// The delete button is also a `button`.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
.and(page.locator(':nth-child(1)'))
|
||||
)
|
||||
}
|
||||
|
||||
/** Find a "home" button (if any) on the current page. */
|
||||
export function locateHomeButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Home' }).getByText('Home')
|
||||
}
|
||||
|
||||
/** Find a "trash" button (if any) on the current page. */
|
||||
export function locateTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Trash' }).getByText('Trash')
|
||||
}
|
||||
|
||||
/** Find a tick button (if any) on the current page. */
|
||||
export function locateEditingTick(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Confirm Edit')
|
||||
}
|
||||
|
||||
/** Find a cross button (if any) on the current page. */
|
||||
export function locateEditingCross(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Cancel Edit')
|
||||
}
|
||||
|
||||
/** Find labels in the "Labels" column of the assets table (if any) on the current page. */
|
||||
export function locateAssetLabels(page: test.Locator | test.Page) {
|
||||
return page.getByTestId('asset-label')
|
||||
}
|
||||
|
||||
/** Find a toggle for the "Labels" column (if any) on the current page. */
|
||||
export function locateLabelsColumnToggle(page: test.Locator | test.Page) {
|
||||
return page.getByAltText(/^(?:Show|Hide) Labels$/)
|
||||
}
|
||||
|
||||
/** Find a toggle for the "Accessed by projects" column (if any) on the current page. */
|
||||
export function locateAccessedByProjectsColumnToggle(page: test.Locator | test.Page) {
|
||||
return page.getByAltText(/^(?:Show|Hide) Accessed by projects$/)
|
||||
}
|
||||
|
||||
/** Find a toggle for the "Accessed data" column (if any) on the current page. */
|
||||
export function locateAccessedDataColumnToggle(page: test.Locator | test.Page) {
|
||||
return page.getByAltText(/^(?:Show|Hide) Accessed data$/)
|
||||
}
|
||||
|
||||
/** Find a toggle for the "Docs" column (if any) on the current page. */
|
||||
export function locateDocsColumnToggle(page: test.Locator | test.Page) {
|
||||
return page.getByAltText(/^(?:Show|Hide) Docs$/)
|
||||
}
|
||||
|
||||
/** Find a button for the "Recent" category (if any) on the current page. */
|
||||
export function locateRecentCategory(page: test.Locator | test.Page) {
|
||||
return page.getByTitle('Go To Recent')
|
||||
}
|
||||
|
||||
/** Find a button for the "Home" category (if any) on the current page. */
|
||||
export function locateHomeCategory(page: test.Locator | test.Page) {
|
||||
return page.getByTitle('Go To Homoe')
|
||||
}
|
||||
|
||||
/** Find a button for the "Trash" category (if any) on the current page. */
|
||||
export function locateTrashCategory(page: test.Locator | test.Page) {
|
||||
return page.getByTitle('Go To Trash')
|
||||
}
|
||||
|
||||
// === Context menu buttons ===
|
||||
|
||||
/** Find an "open" button (if any) on the current page. */
|
||||
export function locateOpenButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Open' }).getByText('Open')
|
||||
}
|
||||
|
||||
/** Find an "upload to cloud" button (if any) on the current page. */
|
||||
export function locateUploadToCloudButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Upload To Cloud' }).getByText('Upload To Cloud')
|
||||
}
|
||||
|
||||
/** Find a "rename" button (if any) on the current page. */
|
||||
export function locateRenameButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Rename' }).getByText('Rename')
|
||||
}
|
||||
|
||||
/** Find a "snapshot" button (if any) on the current page. */
|
||||
export function locateSnapshotButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Snapshot' }).getByText('Snapshot')
|
||||
}
|
||||
|
||||
/** Find a "move to trash" button (if any) on the current page. */
|
||||
export function locateMoveToTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Move To Trash' }).getByText('Move To Trash')
|
||||
}
|
||||
|
||||
/** Find a "move all to trash" button (if any) on the current page. */
|
||||
export function locateMoveAllToTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Move All To Trash' }).getByText('Move All To Trash')
|
||||
}
|
||||
|
||||
/** Find a "restore from trash" button (if any) on the current page. */
|
||||
export function locateRestoreFromTrashButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Restore From Trash' }).getByText('Restore From Trash')
|
||||
}
|
||||
|
||||
/** Find a "restore all from trash" button (if any) on the current page. */
|
||||
export function locateRestoreAllFromTrashButton(page: test.Locator | test.Page) {
|
||||
return page
|
||||
.getByRole('button', { name: 'Restore All From Trash' })
|
||||
.getByText('Restore All From Trash')
|
||||
}
|
||||
|
||||
/** Find a "share" button (if any) on the current page. */
|
||||
export function locateShareButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Share' }).getByText('Share')
|
||||
}
|
||||
|
||||
/** Find a "label" button (if any) on the current page. */
|
||||
export function locateLabelButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Label' }).getByText('Label')
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function locateCopyButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Copy' }).getByText('Copy')
|
||||
}
|
||||
|
||||
/** Find a "cut" button (if any) on the current page. */
|
||||
export 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. */
|
||||
export function locatePasteButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Paste' }).getByText('Paste')
|
||||
}
|
||||
|
||||
/** Find a "download" button (if any) on the current page. */
|
||||
export function locateDownloadButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Download' }).getByText('Download')
|
||||
}
|
||||
|
||||
/** Find a "download app" button (if any) on the current page. */
|
||||
export function locateDownloadAppButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Download App' }).getByText('Download App')
|
||||
}
|
||||
|
||||
/** Find an "upload files" button (if any) on the current page. */
|
||||
export function locateUploadFilesButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'Upload Files' }).getByText('Upload Files')
|
||||
}
|
||||
|
||||
/** Find a "new project" button (if any) on the current page. */
|
||||
export function locateNewProjectButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Project' }).getByText('New Project')
|
||||
}
|
||||
|
||||
/** Find a "new folder" button (if any) on the current page. */
|
||||
export function locateNewFolderButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Folder' }).getByText('New Folder')
|
||||
}
|
||||
|
||||
/** Find a "new secret" button (if any) on the current page. */
|
||||
export function locateNewSecretButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Secret' }).getByText('New Secret')
|
||||
}
|
||||
|
||||
/** Find a "new data connector" button (if any) on the current page. */
|
||||
export function locateNewDataConnectorButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'New Data Connector' }).getByText('New Data Connector')
|
||||
}
|
||||
|
||||
/** Find a "new label" button (if any) on the current page. */
|
||||
export function locateNewLabelButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('button', { name: 'new label' }).getByText('new label')
|
||||
}
|
||||
|
||||
/** Find an "upgrade" button (if any) on the current page. */
|
||||
export function locateUpgradeButton(page: test.Locator | test.Page) {
|
||||
return page.getByRole('link', { name: 'Upgrade', exact: true }).getByText('Upgrade')
|
||||
}
|
||||
|
||||
/** Find a "new folder" icon (if any) on the current page. */
|
||||
export function locateNewFolderIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('New Folder')
|
||||
}
|
||||
|
||||
/** Find a "new secret" icon (if any) on the current page. */
|
||||
export function locateNewSecretIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('New Secret')
|
||||
}
|
||||
|
||||
/** Find a "upload files" icon (if any) on the current page. */
|
||||
export function locateUploadFilesIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Upload Files')
|
||||
}
|
||||
|
||||
/** Find a "download files" icon (if any) on the current page. */
|
||||
export function locateDownloadFilesIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Download Files')
|
||||
}
|
||||
|
||||
/** Find an icon to open or close the asset panel (if any) on the current page. */
|
||||
export function locateAssetPanelIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Open Asset Panel').or(page.getByAltText('Close Asset Panel'))
|
||||
}
|
||||
|
||||
/** Find a list of tags in the search bar (if any) on the current page. */
|
||||
export function locateSearchBarTags(page: test.Locator | test.Page) {
|
||||
return locateSearchBar(page).getByTestId('asset-search-tag-names').getByRole('button')
|
||||
}
|
||||
|
||||
/** Find a list of labels in the search bar (if any) on the current page. */
|
||||
export function locateSearchBarLabels(page: test.Locator | test.Page) {
|
||||
return locateSearchBar(page).getByTestId('asset-search-labels').getByRole('button')
|
||||
}
|
||||
|
||||
/** Find a list of labels in the search bar (if any) on the current page. */
|
||||
export function locateSearchBarSuggestions(page: test.Locator | test.Page) {
|
||||
return locateSearchBar(page).getByTestId('asset-search-suggestion')
|
||||
}
|
||||
|
||||
// === Icon locators ===
|
||||
|
||||
// These are specifically icons that are not also buttons.
|
||||
// Icons that *are* buttons belong in the "Button locators" section.
|
||||
|
||||
/** Find a "sort ascending" icon (if any) on the current page. */
|
||||
export function locateSortAscendingIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Sort Ascending')
|
||||
}
|
||||
|
||||
/** Find a "sort descending" icon (if any) on the current page. */
|
||||
export function locateSortDescendingIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Sort Descending')
|
||||
}
|
||||
|
||||
// === Page locators ===
|
||||
|
||||
/** Find a "home page" icon (if any) on the current page. */
|
||||
export function locateHomePageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Go to home page')
|
||||
}
|
||||
|
||||
/** Find a "drive page" icon (if any) on the current page. */
|
||||
export function locateDrivePageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Go to drive page')
|
||||
}
|
||||
|
||||
/** Find an "editor page" icon (if any) on the current page. */
|
||||
export function locateEditorPageIcon(page: test.Locator | test.Page) {
|
||||
return page.getByAltText('Go to editor page')
|
||||
}
|
||||
|
||||
/** Find a "name" column heading (if any) on the current page. */
|
||||
export function locateNameColumnHeading(page: test.Locator | test.Page) {
|
||||
return page.getByTitle('Sort by name').or(page.getByTitle('Stop sorting by name'))
|
||||
}
|
||||
|
||||
/** Find a "modified" column heading (if any) on the current page. */
|
||||
export function locateModifiedColumnHeading(page: test.Locator | test.Page) {
|
||||
return page
|
||||
.getByTitle('Sort by modification date')
|
||||
.or(page.getByTitle('Stop sorting by modification date'))
|
||||
}
|
||||
|
||||
// === Container locators ===
|
||||
|
||||
/** Find a drive view (if any) on the current page. */
|
||||
export function locateDriveView(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('drive-view')
|
||||
}
|
||||
|
||||
/** Find a samples list (if any) on the current page. */
|
||||
export function locateSamplesList(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('samples')
|
||||
}
|
||||
|
||||
/** Find all samples list (if any) on the current page. */
|
||||
export function locateSamples(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return locateSamplesList(page).getByRole('button')
|
||||
}
|
||||
|
||||
/** Find a modal background (if any) on the current page. */
|
||||
export function locateModalBackground(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('modal-background')
|
||||
}
|
||||
|
||||
/** Find an editor container (if any) on the current page. */
|
||||
export function locateEditor(page: test.Page) {
|
||||
// This is fine as this element is defined in `index.html`, rather than from React.
|
||||
// Using `data-testid` may be more correct though.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
return page.locator('#root')
|
||||
}
|
||||
|
||||
/** Find an assets table (if any) on the current page. */
|
||||
export function locateAssetsTable(page: test.Page) {
|
||||
return locateDriveView(page).getByRole('table')
|
||||
}
|
||||
|
||||
/** Find assets table rows (if any) on the current page. */
|
||||
export function locateAssetRows(page: test.Page) {
|
||||
return locateAssetsTable(page).locator('tbody').getByRole('row')
|
||||
}
|
||||
|
||||
/** Find the name column of the given asset row. */
|
||||
export function locateAssetName(locator: test.Locator) {
|
||||
return locator.locator('> :nth-child(1)')
|
||||
}
|
||||
|
||||
/** Find assets table rows that represent directories that can be expanded (if any)
|
||||
* on the current page. */
|
||||
export function locateExpandableDirectories(page: test.Page) {
|
||||
return locateAssetRows(page).filter({ has: page.getByAltText('Expand') })
|
||||
}
|
||||
|
||||
/** Find assets table rows that represent directories that can be collapsed (if any)
|
||||
* on the current page. */
|
||||
export function locateCollapsibleDirectories(page: test.Page) {
|
||||
return locateAssetRows(page).filter({ has: page.getByAltText('Collapse') })
|
||||
}
|
||||
|
||||
/** Find a "change password" modal (if any) on the current page. */
|
||||
export function locateChangePasswordModal(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('change-password-modal')
|
||||
}
|
||||
|
||||
/** Find a "confirm delete" modal (if any) on the current page. */
|
||||
export function locateConfirmDeleteModal(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('confirm-delete-modal')
|
||||
}
|
||||
|
||||
/** Find a "new label" modal (if any) on the current page. */
|
||||
export function locateNewLabelModal(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('new-label-modal')
|
||||
}
|
||||
|
||||
/** Find an "upsert secret" modal (if any) on the current page. */
|
||||
export function locateUpsertSecretModal(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('upsert-secret-modal')
|
||||
}
|
||||
|
||||
/** Find a user menu (if any) on the current page. */
|
||||
export function locateUserMenu(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('user-menu')
|
||||
}
|
||||
|
||||
/** Find a "set username" panel (if any) on the current page. */
|
||||
export function locateSetUsernamePanel(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('set-username-panel')
|
||||
}
|
||||
|
||||
/** Find a set of context menus (if any) on the current page. */
|
||||
export function locateContextMenus(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('context-menus')
|
||||
}
|
||||
|
||||
/** Find a labels panel (if any) on the current page. */
|
||||
export function locateLabelsPanel(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('labels')
|
||||
}
|
||||
|
||||
/** Find a list of labels (if any) on the current page. */
|
||||
export function locateLabelsList(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('labels-list')
|
||||
}
|
||||
|
||||
/** Find an asset panel (if any) on the current page. */
|
||||
export function locateAssetPanel(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('asset-panel')
|
||||
}
|
||||
|
||||
/** Find a search bar (if any) on the current page. */
|
||||
export function locateSearchBar(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return page.getByTestId('asset-search-bar')
|
||||
}
|
||||
|
||||
// === Content locators ===
|
||||
|
||||
/** Find an asset description in an asset panel (if any) on the current page. */
|
||||
export function locateAssetPanelDescription(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return locateAssetPanel(page).getByTestId('asset-panel-description')
|
||||
}
|
||||
|
||||
/** Find asset permissions in an asset panel (if any) on the current page. */
|
||||
export function locateAssetPanelPermissions(page: test.Locator | test.Page) {
|
||||
// This has no identifying features.
|
||||
return locateAssetPanel(page).getByTestId('asset-panel-permissions').getByRole('button')
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === Visual layout utilities ===
|
||||
// ===============================
|
||||
|
||||
/** Get the left side of the bounding box of an asset row. The locator MUST be for an asset row.
|
||||
* DO NOT assume the left side of the outer container will change. This means that it is NOT SAFE
|
||||
* to do anything with the returned values other than comparing them. */
|
||||
export function getAssetRowLeftPx(locator: test.Locator) {
|
||||
return locator.evaluate(el => el.children[0]?.children[0]?.getBoundingClientRect().left ?? 0)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === expectPlaceholderRow ===
|
||||
// ============================
|
||||
|
||||
/** 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 = 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/)
|
||||
})
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export async function expectTrashPlaceholderRow(page: test.Page) {
|
||||
const assetRows = locateAssetRows(page)
|
||||
await test.test.step('Expect trash placeholder row', async () => {
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows).toHaveText(/Your trash is empty/)
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === Keyboard utilities ===
|
||||
// ==========================
|
||||
|
||||
/** `Meta` (`Cmd`) on macOS, and `Control` on all other platforms. */
|
||||
export async function modModifier(page: test.Page) {
|
||||
let userAgent = ''
|
||||
await test.test.step('Detect browser OS', async () => {
|
||||
userAgent = await page.evaluate(() => navigator.userAgent)
|
||||
})
|
||||
return /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control'
|
||||
}
|
||||
|
||||
/** Press a key, replacing the text `Mod` with `Meta` (`Cmd`) on macOS, and `Control`
|
||||
* on all other platforms. */
|
||||
export async function press(page: test.Page, keyOrShortcut: string) {
|
||||
if (/\bMod\b|\bDelete\b/.test(keyOrShortcut)) {
|
||||
let userAgent = ''
|
||||
await test.test.step('Detect browser OS', async () => {
|
||||
userAgent = await page.evaluate(() => navigator.userAgent)
|
||||
})
|
||||
// This should be `Meta` (`Cmd`) on macOS, and `Control` on all other systems
|
||||
const ctrlKey = /\bMac OS\b/i.test(userAgent) ? 'Meta' : 'Control'
|
||||
const deleteKey = /\bMac OS\b/i.test(userAgent) ? 'Backspace' : 'Delete'
|
||||
await page.keyboard.press(
|
||||
keyOrShortcut.replace(/\bMod\b/g, ctrlKey).replace(/\bDelete\b/, deleteKey)
|
||||
)
|
||||
} else {
|
||||
await page.keyboard.press(keyOrShortcut)
|
||||
}
|
||||
}
|
||||
|
||||
// =============
|
||||
// === login ===
|
||||
// =============
|
||||
|
||||
/** Perform a successful login. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function login(
|
||||
{ page }: MockParams,
|
||||
email = 'email@example.com',
|
||||
password = VALID_PASSWORD
|
||||
) {
|
||||
await page.goto('/')
|
||||
await locateEmailInput(page).fill(email)
|
||||
await locatePasswordInput(page).fill(password)
|
||||
await locateLoginButton(page).click()
|
||||
await locateToastCloseButton(page).click()
|
||||
}
|
||||
|
||||
// ================
|
||||
// === mockDate ===
|
||||
// ================
|
||||
|
||||
/** A placeholder date for visual regression testing. */
|
||||
const MOCK_DATE = Number(new Date('01/23/45 01:23:45'))
|
||||
|
||||
/** Parameters for {@link mockDate}. */
|
||||
interface MockParams {
|
||||
page: test.Page
|
||||
}
|
||||
|
||||
/** Replace `Date` with a version that returns a fixed time. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
async function mockDate({ page }: MockParams) {
|
||||
// https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728
|
||||
await page.addInitScript(`{
|
||||
Date = class extends Date {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) {
|
||||
super(${MOCK_DATE});
|
||||
} else {
|
||||
super(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
const __DateNowOffset = ${MOCK_DATE} - Date.now();
|
||||
const __DateNow = Date.now;
|
||||
Date.now = () => __DateNow() + __DateNowOffset;
|
||||
}`)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === mockIDEContainer ===
|
||||
// ========================
|
||||
|
||||
/** Make the IDE container have a non-zero size. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockIDEContainer({ page }: MockParams) {
|
||||
await page.evaluate(() => {
|
||||
const ideContainer = document.getElementById('root')
|
||||
if (ideContainer) {
|
||||
ideContainer.style.height = '100vh'
|
||||
ideContainer.style.width = '100vw'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === mockApi ===
|
||||
// ===============
|
||||
|
||||
// This is a function, even though it does not use function syntax.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export const mockApi = apiModule.mockApi
|
||||
|
||||
// ===============
|
||||
// === mockAll ===
|
||||
// ===============
|
||||
|
||||
/** Set up all mocks, without logging in. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockAll({ page }: MockParams) {
|
||||
const api = await mockApi({ page })
|
||||
await mockDate({ page })
|
||||
await mockIDEContainer({ page })
|
||||
return { api }
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === mockAllAndLogin ===
|
||||
// =======================
|
||||
|
||||
/** Set up all mocks, and log in with dummy credentials. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockAllAndLogin({ page }: MockParams) {
|
||||
const mocks = await mockAll({ page })
|
||||
await login({ page })
|
||||
// This MUST run after login, otherwise the element's styles are reset when the browser
|
||||
// is navigated to another page.
|
||||
await mockIDEContainer({ page })
|
||||
return mocks
|
||||
}
|
714
app/ide-desktop/lib/dashboard/e2e/api.ts
Normal file
714
app/ide-desktop/lib/dashboard/e2e/api.ts
Normal file
@ -0,0 +1,714 @@
|
||||
/** @file The mock API. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
import * as backend from '../src/services/Backend'
|
||||
import type * as remoteBackend from '../src/services/RemoteBackend'
|
||||
import * as remoteBackendPaths from '../src/services/remoteBackendPaths'
|
||||
import * as config from '../src/utilities/config'
|
||||
import * as dateTime from '../src/utilities/dateTime'
|
||||
import * as uniqueString from '../src/utilities/uniqueString'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The HTTP status code representing a response with an empty body. */
|
||||
const HTTP_STATUS_NO_CONTENT = 204
|
||||
/** The HTTP status code representing a bad request. */
|
||||
const HTTP_STATUS_BAD_REQUEST = 400
|
||||
/** The HTTP status code representing a URL that does not exist. */
|
||||
const HTTP_STATUS_NOT_FOUND = 404
|
||||
/** An asset ID that is a path glob. */
|
||||
const GLOB_ASSET_ID: backend.AssetId = backend.DirectoryId('*')
|
||||
/** A directory ID that is a path glob. */
|
||||
const GLOB_DIRECTORY_ID = backend.DirectoryId('*')
|
||||
/** A project ID that is a path glob. */
|
||||
const GLOB_PROJECT_ID = backend.ProjectId('*')
|
||||
/** A tag ID that is a path glob. */
|
||||
const GLOB_TAG_ID = backend.TagId('*')
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
const BASE_URL = config.ACTIVE_CONFIG.apiUrl + '/'
|
||||
|
||||
// ===============
|
||||
// === mockApi ===
|
||||
// ===============
|
||||
|
||||
/** Parameters for {@link mockApi}. */
|
||||
interface MockParams {
|
||||
page: test.Page
|
||||
}
|
||||
|
||||
/** Add route handlers for the mock API to a page. */
|
||||
// This syntax is required for Playwright to work properly.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
export async function mockApi({ page }: MockParams) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const defaultEmail = 'email@example.com' as backend.EmailAddress
|
||||
const defaultUsername = 'user name'
|
||||
const defaultOrganizationId = backend.UserOrOrganizationId('organization-placeholder id')
|
||||
const defaultDirectoryId = backend.DirectoryId('directory-placeholder id')
|
||||
const defaultUser: backend.UserOrOrganization = {
|
||||
email: defaultEmail,
|
||||
name: defaultUsername,
|
||||
id: defaultOrganizationId,
|
||||
profilePicture: null,
|
||||
isEnabled: true,
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
}
|
||||
let currentUser: backend.UserOrOrganization | null = defaultUser
|
||||
const assetMap = new Map<backend.AssetId, backend.AnyAsset>()
|
||||
const deletedAssets = new Set<backend.AssetId>()
|
||||
const assets: backend.AnyAsset[] = []
|
||||
const labels: backend.Label[] = []
|
||||
const labelsByValue = new Map<backend.LabelName, backend.Label>()
|
||||
const labelMap = new Map<backend.TagId, backend.Label>()
|
||||
|
||||
const addAsset = <T extends backend.AnyAsset>(asset: T) => {
|
||||
assets.push(asset)
|
||||
assetMap.set(asset.id, asset)
|
||||
return asset
|
||||
}
|
||||
|
||||
const deleteAsset = (assetId: backend.AssetId) => {
|
||||
deletedAssets.add(assetId)
|
||||
}
|
||||
|
||||
const undeleteAsset = (assetId: backend.AssetId) => {
|
||||
deletedAssets.delete(assetId)
|
||||
}
|
||||
|
||||
const createDirectory = (
|
||||
title: string,
|
||||
rest: Partial<backend.DirectoryAsset> = {}
|
||||
): backend.DirectoryAsset =>
|
||||
object.merge(
|
||||
{
|
||||
type: backend.AssetType.directory,
|
||||
id: backend.DirectoryId('directory-' + uniqueString.uniqueString()),
|
||||
projectState: null,
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
},
|
||||
rest
|
||||
)
|
||||
|
||||
const createProject = (
|
||||
title: string,
|
||||
rest: Partial<backend.ProjectAsset> = {}
|
||||
): backend.ProjectAsset =>
|
||||
object.merge(
|
||||
{
|
||||
type: backend.AssetType.project,
|
||||
id: backend.ProjectId('project-' + uniqueString.uniqueString()),
|
||||
projectState: {
|
||||
type: backend.ProjectState.opened,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
volume_id: '',
|
||||
},
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
},
|
||||
rest
|
||||
)
|
||||
|
||||
const createFile = (title: string, rest: Partial<backend.FileAsset> = {}): backend.FileAsset =>
|
||||
object.merge(
|
||||
{
|
||||
type: backend.AssetType.file,
|
||||
id: backend.FileId('file-' + uniqueString.uniqueString()),
|
||||
projectState: null,
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
},
|
||||
rest
|
||||
)
|
||||
|
||||
const createSecret = (
|
||||
title: string,
|
||||
rest: Partial<backend.SecretAsset> = {}
|
||||
): backend.SecretAsset =>
|
||||
object.merge(
|
||||
{
|
||||
type: backend.AssetType.secret,
|
||||
id: backend.SecretId('secret-' + uniqueString.uniqueString()),
|
||||
projectState: null,
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
description: null,
|
||||
labels: [],
|
||||
parentId: defaultDirectoryId,
|
||||
permissions: [],
|
||||
},
|
||||
rest
|
||||
)
|
||||
|
||||
const createLabel = (value: string, color: backend.LChColor): backend.Label => ({
|
||||
id: backend.TagId('tag-' + uniqueString.uniqueString()),
|
||||
value: backend.LabelName(value),
|
||||
color,
|
||||
})
|
||||
|
||||
const addDirectory = (title: string, rest?: Partial<backend.DirectoryAsset>) => {
|
||||
return addAsset(createDirectory(title, rest))
|
||||
}
|
||||
|
||||
const addProject = (title: string, rest?: Partial<backend.ProjectAsset>) => {
|
||||
return addAsset(createProject(title, rest))
|
||||
}
|
||||
|
||||
const addFile = (title: string, rest?: Partial<backend.FileAsset>) => {
|
||||
return addAsset(createFile(title, rest))
|
||||
}
|
||||
|
||||
const addSecret = (title: string, rest?: Partial<backend.SecretAsset>) => {
|
||||
return addAsset(createSecret(title, rest))
|
||||
}
|
||||
|
||||
const addLabel = (value: string, color: backend.LChColor) => {
|
||||
const label = createLabel(value, color)
|
||||
labels.push(label)
|
||||
labelsByValue.set(label.value, label)
|
||||
labelMap.set(label.id, label)
|
||||
return label
|
||||
}
|
||||
|
||||
const setLabels = (id: backend.AssetId, newLabels: backend.LabelName[]) => {
|
||||
const ids = new Set<backend.AssetId>([id])
|
||||
for (const [innerId, asset] of assetMap) {
|
||||
if (ids.has(asset.parentId)) {
|
||||
ids.add(innerId)
|
||||
}
|
||||
}
|
||||
for (const innerId of ids) {
|
||||
const asset = assetMap.get(innerId)
|
||||
if (asset != null) {
|
||||
asset.labels = newLabels
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await test.test.step('Mock API', async () => {
|
||||
await page.route('https://www.google-analytics.com/**', async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
|
||||
await page.route('https://www.googletagmanager.com/gtag/js*', async route => {
|
||||
await route.fulfill({
|
||||
contentType: 'text/javascript',
|
||||
body: 'export {};',
|
||||
})
|
||||
})
|
||||
|
||||
const isOnline = await page.evaluate(() => navigator.onLine)
|
||||
|
||||
if (!isOnline) {
|
||||
await page.route('https://fonts.googleapis.com/*', async route => {
|
||||
await route.abort()
|
||||
})
|
||||
}
|
||||
|
||||
await page.route(BASE_URL + '**', (_route, request) => {
|
||||
throw new Error(`Missing route handler for '${request.url().replace(BASE_URL, '')}'.`)
|
||||
})
|
||||
|
||||
// === Endpoints returning arrays ===
|
||||
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.LIST_DIRECTORY_PATH + '*',
|
||||
async (route, request) => {
|
||||
/** The type for the search query for this endpoint. */
|
||||
interface Query {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
parent_id?: string
|
||||
filter_by?: backend.FilterBy
|
||||
labels?: backend.LabelName[]
|
||||
recent_projects?: boolean
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
}
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const body = Object.fromEntries(
|
||||
new URL(request.url()).searchParams.entries()
|
||||
) as unknown as Query
|
||||
const parentId = body.parent_id ?? defaultDirectoryId
|
||||
let filteredAssets = assets.filter(asset => asset.parentId === parentId)
|
||||
// This lint rule is broken; there is clearly a case for `undefined` below.
|
||||
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
|
||||
switch (body.filter_by) {
|
||||
case backend.FilterBy.active: {
|
||||
filteredAssets = filteredAssets.filter(asset => !deletedAssets.has(asset.id))
|
||||
break
|
||||
}
|
||||
case backend.FilterBy.trashed: {
|
||||
filteredAssets = filteredAssets.filter(asset => deletedAssets.has(asset.id))
|
||||
break
|
||||
}
|
||||
case backend.FilterBy.recent: {
|
||||
filteredAssets = assets
|
||||
.filter(asset => !deletedAssets.has(asset.id))
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
.slice(0, 10)
|
||||
break
|
||||
}
|
||||
case backend.FilterBy.all:
|
||||
case null: {
|
||||
// do nothing
|
||||
break
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
case undefined: {
|
||||
// do nothing
|
||||
break
|
||||
}
|
||||
}
|
||||
filteredAssets.sort(
|
||||
(a, b) => backend.ASSET_TYPE_ORDER[a.type] - backend.ASSET_TYPE_ORDER[b.type]
|
||||
)
|
||||
await route.fulfill({
|
||||
json: {
|
||||
assets: filteredAssets,
|
||||
} satisfies remoteBackend.ListDirectoryResponseBody,
|
||||
})
|
||||
}
|
||||
)
|
||||
await page.route(BASE_URL + remoteBackendPaths.LIST_FILES_PATH + '*', async route => {
|
||||
await route.fulfill({
|
||||
json: { files: [] } satisfies remoteBackend.ListFilesResponseBody,
|
||||
})
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.LIST_PROJECTS_PATH + '*', async route => {
|
||||
await route.fulfill({
|
||||
json: { projects: [] } satisfies remoteBackend.ListProjectsResponseBody,
|
||||
})
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.LIST_SECRETS_PATH + '*', async route => {
|
||||
await route.fulfill({
|
||||
json: { secrets: [] } satisfies remoteBackend.ListSecretsResponseBody,
|
||||
})
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.LIST_TAGS_PATH + '*', async route => {
|
||||
await route.fulfill({
|
||||
json: { tags: labels } satisfies remoteBackend.ListTagsResponseBody,
|
||||
})
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.LIST_USERS_PATH + '*', async route => {
|
||||
await route.fulfill({
|
||||
json: { users: [] } satisfies remoteBackend.ListUsersResponseBody,
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.LIST_VERSIONS_PATH + '*',
|
||||
async (route, request) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
versions: [
|
||||
{
|
||||
ami: null,
|
||||
created: dateTime.toRfc3339(new Date()),
|
||||
number: {
|
||||
lifecycle:
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
'Development' satisfies `${backend.VersionLifecycle.development}` as backend.VersionLifecycle.development,
|
||||
value: '2023.2.1-dev',
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, no-restricted-syntax
|
||||
version_type: (new URL(request.url()).searchParams.get('version_type') ??
|
||||
'') as backend.VersionType,
|
||||
} satisfies backend.Version,
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// === Unimplemented endpoints ===
|
||||
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.getProjectDetailsPath(GLOB_PROJECT_ID),
|
||||
async (route, request) => {
|
||||
const projectId = request.url().match(/[/]projects[/](.+?)[/]copy/)?.[1] ?? ''
|
||||
await route.fulfill({
|
||||
json: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
organizationId: defaultOrganizationId,
|
||||
projectId: backend.ProjectId(projectId),
|
||||
name: 'example project name',
|
||||
state: {
|
||||
type: backend.ProjectState.opened,
|
||||
volume_id: '',
|
||||
opened_by: defaultEmail,
|
||||
},
|
||||
packageName: 'Project_root',
|
||||
ide_version: null,
|
||||
engine_version: {
|
||||
value: '2023.2.1-nightly.2023.9.29',
|
||||
lifecycle: backend.VersionLifecycle.development,
|
||||
},
|
||||
address: backend.Address('ws://example.com/'),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
} satisfies backend.ProjectRaw,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// === Endpoints returning `void` ===
|
||||
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.copyAssetPath(GLOB_ASSET_ID),
|
||||
async (route, request) => {
|
||||
/** The type for the JSON request payload for this endpoint. */
|
||||
interface Body {
|
||||
parentDirectoryId: backend.DirectoryId
|
||||
}
|
||||
const assetId = request.url().match(/[/]assets[/](.+?)[/]copy/)?.[1]
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const asset = assetId != null ? assetMap.get(assetId as backend.AssetId) : null
|
||||
if (asset == null) {
|
||||
if (assetId == null) {
|
||||
await route.fulfill({
|
||||
status: HTTP_STATUS_BAD_REQUEST,
|
||||
json: { error: 'Invalid Asset ID' },
|
||||
})
|
||||
} else {
|
||||
await route.fulfill({
|
||||
status: HTTP_STATUS_NOT_FOUND,
|
||||
json: { error: 'Asset does not exist' },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: Body = await request.postDataJSON()
|
||||
const parentId = body.parentDirectoryId
|
||||
// Can be any asset ID.
|
||||
const id = backend.DirectoryId(uniqueString.uniqueString())
|
||||
const json: backend.CopyAssetResponse = {
|
||||
asset: {
|
||||
id,
|
||||
parentId,
|
||||
title: asset.title + ' (copy)',
|
||||
},
|
||||
}
|
||||
const newAsset = { ...asset }
|
||||
newAsset.id = id
|
||||
newAsset.parentId = parentId
|
||||
newAsset.title += ' (copy)'
|
||||
addAsset(newAsset)
|
||||
await route.fulfill({ json })
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(BASE_URL + remoteBackendPaths.INVITE_USER_PATH + '*', async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.CREATE_PERMISSION_PATH + '*', async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID), async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.closeProjectPath(GLOB_PROJECT_ID),
|
||||
async route => {
|
||||
await route.fulfill()
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.openProjectPath(GLOB_PROJECT_ID),
|
||||
async route => {
|
||||
await route.fulfill()
|
||||
}
|
||||
)
|
||||
await page.route(BASE_URL + remoteBackendPaths.deleteTagPath(GLOB_TAG_ID), async route => {
|
||||
await route.fulfill()
|
||||
})
|
||||
|
||||
// === Other endpoints ===
|
||||
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.updateAssetPath(GLOB_ASSET_ID),
|
||||
async (route, request) => {
|
||||
if (request.method() === 'PATCH') {
|
||||
const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? ''
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.UpdateAssetRequestBody = request.postDataJSON()
|
||||
// This could be an id for an arbitrary asset, but pretend it's a
|
||||
// `DirectoryId` to make TypeScript happy.
|
||||
const asset = assetMap.get(backend.DirectoryId(assetId))
|
||||
if (asset != null) {
|
||||
if (body.description != null) {
|
||||
asset.description = body.description
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.associateTagPath(GLOB_ASSET_ID),
|
||||
async (route, request) => {
|
||||
if (request.method() === 'PATCH') {
|
||||
const assetId = request.url().match(/[/]assets[/]([^/?]+)/)?.[1] ?? ''
|
||||
/** The type for the JSON request payload for this endpoint. */
|
||||
interface Body {
|
||||
labels: backend.LabelName[]
|
||||
}
|
||||
/** The type for the JSON response payload for this endpoint. */
|
||||
interface Response {
|
||||
tags: backend.Label[]
|
||||
}
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: Body = await request.postDataJSON()
|
||||
// This could be an id for an arbitrary asset, but pretend it's a
|
||||
// `DirectoryId` to make TypeScript happy.
|
||||
setLabels(backend.DirectoryId(assetId), body.labels)
|
||||
const json: Response = {
|
||||
tags: body.labels.flatMap(value => {
|
||||
const label = labelsByValue.get(value)
|
||||
return label != null ? [label] : []
|
||||
}),
|
||||
}
|
||||
await route.fulfill({ json })
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.updateDirectoryPath(GLOB_DIRECTORY_ID),
|
||||
async (route, request) => {
|
||||
if (request.method() === 'PUT') {
|
||||
const directoryId = request.url().match(/[/]directories[/]([^?]+)/)?.[1] ?? ''
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.UpdateDirectoryRequestBody = request.postDataJSON()
|
||||
const asset = assetMap.get(backend.DirectoryId(directoryId))
|
||||
if (asset == null) {
|
||||
await route.abort()
|
||||
} else {
|
||||
asset.title = body.title
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: backend.DirectoryId(directoryId),
|
||||
parentId: asset.parentId,
|
||||
title: body.title,
|
||||
} satisfies backend.UpdatedDirectory,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.deleteAssetPath(GLOB_ASSET_ID),
|
||||
async (route, request) => {
|
||||
if (request.method() === 'DELETE') {
|
||||
const assetId = request.url().match(/[/]assets[/]([^?]+)/)?.[1] ?? ''
|
||||
// This could be an id for an arbitrary asset, but pretend it's a
|
||||
// `DirectoryId` to make TypeScript happy.
|
||||
deleteAsset(backend.DirectoryId(assetId))
|
||||
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.UNDO_DELETE_ASSET_PATH,
|
||||
async (route, request) => {
|
||||
if (request.method() === 'PATCH') {
|
||||
/** The type for the JSON request payload for this endpoint. */
|
||||
interface Body {
|
||||
assetId: backend.AssetId
|
||||
}
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: Body = await request.postDataJSON()
|
||||
undeleteAsset(body.assetId)
|
||||
await route.fulfill({ status: HTTP_STATUS_NO_CONTENT })
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.CREATE_USER_PATH + '*',
|
||||
async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
// The type of the body sent by this app is statically known.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.CreateUserRequestBody = await request.postDataJSON()
|
||||
const id = body.organizationId ?? defaultUser.id
|
||||
const rootDirectoryId = backend.DirectoryId(id.replace(/^organization-/, 'directory-'))
|
||||
currentUser = {
|
||||
email: body.userEmail,
|
||||
name: body.userName,
|
||||
id: body.organizationId ?? defaultUser.id,
|
||||
profilePicture: null,
|
||||
isEnabled: false,
|
||||
rootDirectoryId,
|
||||
}
|
||||
await route.fulfill({ json: currentUser })
|
||||
} else if (request.method() === 'GET') {
|
||||
if (currentUser != null) {
|
||||
await route.fulfill({ json: [] })
|
||||
} else {
|
||||
await route.fulfill({ status: HTTP_STATUS_BAD_REQUEST })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(BASE_URL + remoteBackendPaths.USERS_ME_PATH + '*', async route => {
|
||||
await route.fulfill({
|
||||
json: currentUser,
|
||||
})
|
||||
})
|
||||
await page.route(BASE_URL + remoteBackendPaths.CREATE_TAG_PATH + '*', async route => {
|
||||
if (route.request().method() === 'POST') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.CreateTagRequestBody = route.request().postDataJSON()
|
||||
const json: backend.Label = {
|
||||
id: backend.TagId(`tag-${uniqueString.uniqueString()}`),
|
||||
value: backend.LabelName(body.value),
|
||||
color: body.color,
|
||||
}
|
||||
await route.fulfill({ json })
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
})
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.CREATE_PROJECT_PATH + '*',
|
||||
async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.CreateProjectRequestBody = request.postDataJSON()
|
||||
const title = body.projectName
|
||||
const id = backend.ProjectId(`project-${uniqueString.uniqueString()}`)
|
||||
const parentId =
|
||||
body.parentDirectoryId ??
|
||||
backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
|
||||
const json: backend.CreatedProject = {
|
||||
name: title,
|
||||
organizationId: defaultOrganizationId,
|
||||
packageName: 'Project_root',
|
||||
projectId: id,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
state: { type: backend.ProjectState.opened, volume_id: '' },
|
||||
}
|
||||
addProject(title, {
|
||||
description: null,
|
||||
id,
|
||||
labels: [],
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
parentId,
|
||||
permissions: [
|
||||
{
|
||||
user: {
|
||||
pk: backend.Subject(''),
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
user_name: defaultUsername,
|
||||
user_email: defaultEmail,
|
||||
organization_id: defaultOrganizationId,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
permission: permissions.PermissionAction.own,
|
||||
},
|
||||
],
|
||||
projectState: json.state,
|
||||
})
|
||||
await route.fulfill({ json })
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
await page.route(
|
||||
BASE_URL + remoteBackendPaths.CREATE_DIRECTORY_PATH + '*',
|
||||
async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
const body: backend.CreateDirectoryRequestBody = request.postDataJSON()
|
||||
const title = body.title
|
||||
const id = backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
|
||||
const parentId =
|
||||
body.parentId ?? backend.DirectoryId(`directory-${uniqueString.uniqueString()}`)
|
||||
const json: backend.CreatedDirectory = { title, id, parentId }
|
||||
addDirectory(title, {
|
||||
description: null,
|
||||
id,
|
||||
labels: [],
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
parentId,
|
||||
permissions: [
|
||||
{
|
||||
user: {
|
||||
pk: backend.Subject(''),
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
user_name: defaultUsername,
|
||||
user_email: defaultEmail,
|
||||
organization_id: defaultOrganizationId,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
permission: permissions.PermissionAction.own,
|
||||
},
|
||||
],
|
||||
projectState: null,
|
||||
})
|
||||
await route.fulfill({ json })
|
||||
} else {
|
||||
await route.fallback()
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
defaultEmail,
|
||||
defaultName: defaultUsername,
|
||||
defaultOrganizationId,
|
||||
defaultUser,
|
||||
rootDirectoryId: defaultDirectoryId,
|
||||
/** Returns the current value of `currentUser`. This is a getter, so its return value
|
||||
* SHOULD NOT be cached. */
|
||||
get currentUser() {
|
||||
return currentUser
|
||||
},
|
||||
setCurrentUser: (user: backend.UserOrOrganization | null) => {
|
||||
currentUser = user
|
||||
},
|
||||
addAsset,
|
||||
deleteAsset,
|
||||
undeleteAsset,
|
||||
createDirectory,
|
||||
createProject,
|
||||
createFile,
|
||||
createSecret,
|
||||
addDirectory,
|
||||
addProject,
|
||||
addFile,
|
||||
addSecret,
|
||||
createLabel,
|
||||
addLabel,
|
||||
setLabels,
|
||||
}
|
||||
}
|
54
app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts
Normal file
54
app/ide-desktop/lib/dashboard/e2e/assetPanel.spec.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/** @file Tests for the asset panel. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('open and close asset panel', async ({ page }) => {
|
||||
await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await assetRows.nth(0).click()
|
||||
await test.expect(actions.locateAssetPanel(page)).not.toBeVisible()
|
||||
await actions.locateAssetPanelIcon(page).click()
|
||||
await test.expect(actions.locateAssetPanel(page)).toBeVisible()
|
||||
await actions.locateAssetPanelIcon(page).click()
|
||||
await test.expect(actions.locateAssetPanel(page)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.test('asset panel contents', async ({ page }) => {
|
||||
const { api } = await actions.mockAll({ page })
|
||||
const { defaultOrganizationId } = api
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const description = 'foo bar'
|
||||
const username = 'baz quux'
|
||||
const email = 'baz.quux@email.com'
|
||||
api.addProject('project', {
|
||||
description,
|
||||
permissions: [
|
||||
{
|
||||
permission: permissions.PermissionAction.own,
|
||||
user: {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
pk: backend.Subject(''),
|
||||
organization_id: defaultOrganizationId,
|
||||
user_name: username,
|
||||
user_email: backend.EmailAddress(email),
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await page.goto('/')
|
||||
await actions.login({ page })
|
||||
|
||||
await assetRows.nth(0).click()
|
||||
await actions.locateAssetPanelIcon(page).click()
|
||||
await test.expect(actions.locateAssetPanelDescription(page)).toHaveText(description)
|
||||
// `getByText` is required so that this assertion works if there are multiple permissions.
|
||||
await test.expect(actions.locateAssetPanelPermissions(page).getByText(username)).toBeVisible()
|
||||
})
|
118
app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts
Normal file
118
app/ide-desktop/lib/dashboard/e2e/assetSearchBar.spec.ts
Normal file
@ -0,0 +1,118 @@
|
||||
/** @file Test the search bar and its suggestions. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('tags', async ({ page }) => {
|
||||
await actions.mockAllAndLogin({ page })
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const tags = actions.locateSearchBarTags(page)
|
||||
|
||||
await searchBarInput.click()
|
||||
for (const positiveTag of await tags.all()) {
|
||||
await searchBarInput.selectText()
|
||||
await searchBarInput.press('Backspace')
|
||||
const text = (await positiveTag.textContent()) ?? ''
|
||||
test.expect(text.length).toBeGreaterThan(0)
|
||||
await positiveTag.click()
|
||||
await test.expect(searchBarInput).toHaveValue(text)
|
||||
}
|
||||
|
||||
await page.keyboard.down('Shift')
|
||||
for (const negativeTag of await tags.all()) {
|
||||
await searchBarInput.selectText()
|
||||
await searchBarInput.press('Backspace')
|
||||
const text = (await negativeTag.textContent()) ?? ''
|
||||
test.expect(text.length).toBeGreaterThan(0)
|
||||
await negativeTag.click()
|
||||
await test.expect(searchBarInput).toHaveValue(text)
|
||||
}
|
||||
})
|
||||
|
||||
test.test('labels', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const labels = actions.locateSearchBarLabels(page)
|
||||
api.addLabel('aaaa', backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('bbbb', backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
await actions.login({ page })
|
||||
|
||||
await searchBarInput.click()
|
||||
for (const label of await labels.all()) {
|
||||
const name = (await label.textContent()) ?? ''
|
||||
test.expect(name.length).toBeGreaterThan(0)
|
||||
await label.click()
|
||||
await test.expect(searchBarInput).toHaveValue('label:' + name)
|
||||
await label.click()
|
||||
await test.expect(searchBarInput).toHaveValue('-label:' + name)
|
||||
await label.click()
|
||||
await test.expect(searchBarInput).toHaveValue('')
|
||||
}
|
||||
})
|
||||
|
||||
test.test('suggestions', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const suggestions = actions.locateSearchBarSuggestions(page)
|
||||
api.addDirectory('foo')
|
||||
api.addProject('bar')
|
||||
api.addSecret('baz')
|
||||
api.addSecret('quux')
|
||||
await actions.login({ page })
|
||||
|
||||
await searchBarInput.click()
|
||||
for (const suggestion of await suggestions.all()) {
|
||||
const name = (await suggestion.textContent()) ?? ''
|
||||
test.expect(name.length).toBeGreaterThan(0)
|
||||
await suggestion.click()
|
||||
await test.expect(searchBarInput).toHaveValue('name:' + name)
|
||||
await searchBarInput.selectText()
|
||||
await searchBarInput.press('Backspace')
|
||||
}
|
||||
})
|
||||
|
||||
test.test('suggestions (keyboard)', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const suggestions = actions.locateSearchBarSuggestions(page)
|
||||
api.addDirectory('foo')
|
||||
api.addProject('bar')
|
||||
api.addSecret('baz')
|
||||
api.addSecret('quux')
|
||||
await actions.login({ page })
|
||||
|
||||
await searchBarInput.click()
|
||||
for (const suggestion of await suggestions.all()) {
|
||||
const name = (await suggestion.textContent()) ?? ''
|
||||
test.expect(name.length).toBeGreaterThan(0)
|
||||
await page.press('body', 'Tab')
|
||||
await test.expect(searchBarInput).toHaveValue('name:' + name)
|
||||
}
|
||||
})
|
||||
|
||||
test.test('complex flows', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const searchBarInput = actions.locateSearchBarInput(page)
|
||||
const firstName = 'foo'
|
||||
api.addDirectory(firstName)
|
||||
api.addProject('bar')
|
||||
api.addSecret('baz')
|
||||
api.addSecret('quux')
|
||||
await actions.login({ page })
|
||||
|
||||
await searchBarInput.click()
|
||||
await page.press('body', 'Tab')
|
||||
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
|
||||
await searchBarInput.selectText()
|
||||
await searchBarInput.press('Backspace')
|
||||
await test.expect(searchBarInput).toHaveValue('')
|
||||
await page.press('body', 'Tab')
|
||||
await test.expect(searchBarInput).toHaveValue('name:' + firstName)
|
||||
})
|
@ -2,18 +2,16 @@
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import * as api from './api'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('change password modal', async ({ page }) => {
|
||||
await api.mockApi(page)
|
||||
await actions.login(page)
|
||||
|
||||
// Screenshot #1: Change password modal
|
||||
// Change password modal
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
await actions.locateChangePasswordButton(page).click()
|
||||
await test.expect(actions.locateChangePasswordModal(page)).toHaveScreenshot()
|
||||
await test.expect(actions.locateChangePasswordModal(page)).toBeVisible()
|
||||
|
||||
// Screenshot #2: Invalid old password
|
||||
// Invalid old password
|
||||
await actions.locateOldPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
test
|
||||
.expect(
|
||||
@ -23,7 +21,7 @@ test.test('change password modal', async ({ page }) => {
|
||||
.toBe(false)
|
||||
await actions.locateResetButton(page).click()
|
||||
|
||||
// Screenshot #3: Invalid new password
|
||||
// Invalid new password
|
||||
await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
test
|
||||
@ -34,18 +32,18 @@ test.test('change password modal', async ({ page }) => {
|
||||
.toBe(false)
|
||||
await actions.locateResetButton(page).click()
|
||||
|
||||
// Screenshot #4: Invalid "confirm new password"
|
||||
// Invalid new password confirmation
|
||||
await actions.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
test
|
||||
.expect(
|
||||
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
|
||||
'form should reject invalid "confirm new password"'
|
||||
'form should reject invalid new password confirmation'
|
||||
)
|
||||
.toBe(false)
|
||||
await actions.locateResetButton(page).click()
|
||||
|
||||
// Screenshot #5: After form submission
|
||||
// After form submission
|
||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateResetButton(page).click()
|
||||
await test.expect(actions.locateChangePasswordModal(page)).not.toBeAttached()
|
171
app/ide-desktop/lib/dashboard/e2e/copy.spec.ts
Normal file
171
app/ide-desktop/lib/dashboard/e2e/copy.spec.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/** @file Test copying, moving, cutting and pasting. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('copy', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locateCopyButton(page).click()
|
||||
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await assetRows.nth(1).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locatePasteButton(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(3)
|
||||
await test.expect(assetRows.nth(2)).toBeVisible()
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
})
|
||||
|
||||
test.test('copy (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).click()
|
||||
await actions.press(page, 'Mod+C')
|
||||
// Assets: [0: Folder 2 <copied>, 1: Folder 1]
|
||||
await assetRows.nth(1).click()
|
||||
await actions.press(page, 'Mod+V')
|
||||
// Assets: [0: Folder 2, 1: Folder 1, 2: Folder 2 (copy) <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(3)
|
||||
await test.expect(assetRows.nth(2)).toBeVisible()
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^New Folder 2 [(]copy[)]/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(2))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
})
|
||||
|
||||
test.test('move', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locateCutButton(page).click()
|
||||
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await assetRows.nth(1).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locatePasteButton(page).click()
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
})
|
||||
|
||||
test.test('move (drag)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).dragTo(assetRows.nth(1))
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
})
|
||||
|
||||
test.test('move to trash', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
await assetRows.nth(0).click()
|
||||
await assetRows.nth(1).click()
|
||||
await assetRows.nth(0).dragTo(actions.locateTrashCategory(page))
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
await actions.expectPlaceholderRow(page)
|
||||
await actions.locateTrashCategory(page).click()
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
})
|
||||
|
||||
test.test('move (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 2, 1: Folder 1]
|
||||
await assetRows.nth(0).click()
|
||||
await actions.press(page, 'Mod+X')
|
||||
// Assets: [0: Folder 2 <cut>, 1: Folder 1]
|
||||
await assetRows.nth(1).click()
|
||||
await actions.press(page, 'Mod+V')
|
||||
// Assets: [0: Folder 1, 1: Folder 2 <child { depth=1 }>]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(1)).toBeVisible()
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^New Folder 2/)
|
||||
const parentLeft = await actions.getAssetRowLeftPx(assetRows.nth(0))
|
||||
const childLeft = await actions.getAssetRowLeftPx(assetRows.nth(1))
|
||||
test.expect(childLeft, 'child is indented further than parent').toBeGreaterThan(parentLeft)
|
||||
})
|
||||
|
||||
test.test('cut (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await assetRows.nth(0).click()
|
||||
await actions.press(page, 'Mod+X')
|
||||
test
|
||||
.expect(await assetRows.nth(0).evaluate(el => Number(getComputedStyle(el).opacity)))
|
||||
.toBeLessThan(1)
|
||||
})
|
||||
|
||||
test.test('duplicate', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await test.expect(actions.locateContextMenus(page)).toBeVisible()
|
||||
await actions.locateDuplicateButton(page).click()
|
||||
// Assets: [0: Folder 1 (copy), 1: Folder 1]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(actions.locateContextMenus(page)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/)
|
||||
})
|
||||
|
||||
test.test('duplicate (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await assetRows.nth(0).click()
|
||||
await actions.press(page, 'Mod+D')
|
||||
// Assets: [0: Folder 1 (copy), 1: Folder 1]
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1 [(]copy[)]/)
|
||||
})
|
60
app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts
Normal file
60
app/ide-desktop/lib/dashboard/e2e/createAsset.spec.ts
Normal file
@ -0,0 +1,60 @@
|
||||
/** @file Test copying, moving, cutting and pasting. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('create folder', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
// Assets: [0: Folder 1]
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^New Folder 1/)
|
||||
})
|
||||
|
||||
test.test('create project', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
// Assets: [0: Project 1]
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test.test('upload file', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
const fileChooserPromise = page.waitForEvent('filechooser')
|
||||
await actions.locateUploadFilesIcon(page).click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
const name = 'foo.txt'
|
||||
const content = 'hello world'
|
||||
await fileChooser.setFiles([
|
||||
{
|
||||
name,
|
||||
buffer: Buffer.from(content),
|
||||
mimeType: 'text/plain',
|
||||
},
|
||||
])
|
||||
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + name))
|
||||
})
|
||||
|
||||
test.test('create secret', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewSecretIcon(page).click()
|
||||
const name = 'a secret name'
|
||||
const value = 'a secret value'
|
||||
await actions.locateSecretNameInput(page).fill(name)
|
||||
await actions.locateSecretValueInput(page).fill(value)
|
||||
await actions.locateCreateButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + name))
|
||||
})
|
50
app/ide-desktop/lib/dashboard/e2e/delete.spec.ts
Normal file
50
app/ide-desktop/lib/dashboard/e2e/delete.spec.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/** @file Test copying, moving, cutting and pasting. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('delete and restore', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const contextMenu = actions.locateContextMenus(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await actions.locateMoveToTrashButton(contextMenu).click()
|
||||
|
||||
await actions.expectPlaceholderRow(page)
|
||||
|
||||
await actions.locateTrashButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
await actions.locateRestoreFromTrashButton(contextMenu).click()
|
||||
await actions.expectTrashPlaceholderRow(page)
|
||||
|
||||
await actions.locateHomeButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
||||
|
||||
test.test('delete and restore (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await assetRows.nth(0).click()
|
||||
await actions.press(page, 'Delete')
|
||||
await actions.expectPlaceholderRow(page)
|
||||
|
||||
await actions.locateTrashButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
|
||||
await assetRows.nth(0).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.expectTrashPlaceholderRow(page)
|
||||
|
||||
await actions.locateHomeButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
33
app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts
Normal file
33
app/ide-desktop/lib/dashboard/e2e/driveView.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/** @file Test the drive view. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('drive view', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
// Drive view
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await actions.expectPlaceholderRow(page)
|
||||
// Assets table with one asset
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
// The placeholder row becomes hidden.
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(actions.locateAssetsTable(page)).toBeVisible()
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
await test.expect(assetRows).toHaveCount(2)
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
// The last opened project needs to be stopped, to remove the toast notification notifying the
|
||||
// user that project creation may take a while. Previously opened projects are stopped when the
|
||||
// new project is created.
|
||||
await actions.locateStopProjectButton(assetRows.nth(0)).click()
|
||||
// Project context menu
|
||||
await assetRows.nth(0).click({ button: 'right' })
|
||||
const contextMenu = actions.locateContextMenus(page)
|
||||
await test.expect(contextMenu).toBeVisible()
|
||||
await actions.locateMoveToTrashButton(contextMenu).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
})
|
92
app/ide-desktop/lib/dashboard/e2e/editAssetName.spec.ts
Normal file
92
app/ide-desktop/lib/dashboard/e2e/editAssetName.spec.ts
Normal file
@ -0,0 +1,92 @@
|
||||
/** @file Test copying, moving, cutting and pasting. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('edit name', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const mod = await actions.modModifier(page)
|
||||
const newName = 'foo bar baz'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateEditingTick(assetRows.nth(0)).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName))
|
||||
})
|
||||
|
||||
test.test('edit name (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const newName = 'foo bar baz quux'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).press('Enter')
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + newName))
|
||||
})
|
||||
|
||||
test.test('cancel editing name', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const mod = await actions.modModifier(page)
|
||||
const newName = 'foo bar baz'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateEditingCross(assetRows.nth(0)).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
||||
test.test('cancel editing name (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const newName = 'foo bar baz quux'
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill(newName)
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).press('Escape')
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
||||
test.test('change to blank name', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const mod = await actions.modModifier(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click({ modifiers: [mod] })
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill('')
|
||||
await actions.locateEditingTick(assetRows.nth(0)).click()
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
||||
|
||||
test.test('change to blank name (keyboard)', async ({ page }) => {
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
|
||||
await actions.locateNewFolderIcon(page).click()
|
||||
const oldName = (await actions.locateAssetRowName(assetRows.nth(0)).textContent()) ?? ''
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).click()
|
||||
await actions.press(page, 'Mod+R')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).fill('')
|
||||
await actions.locateAssetRowName(assetRows.nth(0)).press('Enter')
|
||||
await test.expect(assetRows).toHaveCount(1)
|
||||
await test.expect(assetRows.nth(0)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(new RegExp('^' + oldName))
|
||||
})
|
20
app/ide-desktop/lib/dashboard/e2e/homePage.spec.ts
Normal file
20
app/ide-desktop/lib/dashboard/e2e/homePage.spec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
/** @file Test the "change password" modal. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('create empty project', async ({ page }) => {
|
||||
await actions.locateHomePageIcon(page).click()
|
||||
// The first "sample" is a button to create a new empty project.
|
||||
await actions.locateSamples(page).nth(0).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test.test('create project from template', async ({ page }) => {
|
||||
await actions.locateHomePageIcon(page).click()
|
||||
// The second "sample" is the first template.
|
||||
await actions.locateSamples(page).nth(1).click()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
202
app/ide-desktop/lib/dashboard/e2e/labels.spec.ts
Normal file
202
app/ide-desktop/lib/dashboard/e2e/labels.spec.ts
Normal file
@ -0,0 +1,202 @@
|
||||
/** @file Test dragging of labels. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test('drag labels onto single row', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labels = actions.locateLabelsPanelLabels(page)
|
||||
const label = 'aaaa'
|
||||
api.addLabel(label, backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('bbbb', backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
api.addDirectory('foo')
|
||||
api.addSecret('bar')
|
||||
api.addFile('baz')
|
||||
api.addSecret('quux')
|
||||
await actions.login({ page })
|
||||
|
||||
await actions.locateLabelsColumnToggle(page).click()
|
||||
await labels.nth(0).dragTo(assetRows.nth(1))
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).not.toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).not.toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.test('drag labels onto multiple rows', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labels = actions.locateLabelsPanelLabels(page)
|
||||
const label = 'aaaa'
|
||||
api.addLabel(label, backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('bbbb', backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
api.addDirectory('foo')
|
||||
api.addSecret('bar')
|
||||
api.addFile('baz')
|
||||
api.addSecret('quux')
|
||||
await actions.login({ page })
|
||||
|
||||
await actions.locateLabelsColumnToggle(page).click()
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
await assetRows.nth(0).click()
|
||||
await assetRows.nth(2).click()
|
||||
await labels.nth(0).dragTo(assetRows.nth(2))
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(0)).getByText(label)).toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(1)).getByText(label)).not.toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(2)).getByText(label)).toBeVisible()
|
||||
await test.expect(actions.locateAssetLabels(assetRows.nth(3)).getByText(label)).not.toBeVisible()
|
||||
})
|
||||
|
||||
test.test('drag (recursive)', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labels = actions.locateLabelsPanelLabels(page)
|
||||
const label = 'bbbb'
|
||||
api.addLabel('aaaa', backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel(label, backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
const assetsWithLabel = new Set<string>()
|
||||
const shouldHaveLabel = <T extends backend.AnyAsset>(asset: T) => {
|
||||
assetsWithLabel.add(asset.title)
|
||||
return asset
|
||||
}
|
||||
const directory1 = shouldHaveLabel(api.addDirectory('foo'))
|
||||
const directory2 = shouldHaveLabel(api.addDirectory('bar'))
|
||||
shouldHaveLabel(api.addFile('baz', { parentId: directory1.id }))
|
||||
shouldHaveLabel(api.addSecret('quux', { parentId: directory1.id }))
|
||||
const directory3 = api.addDirectory('directory 3')
|
||||
const directory5 = shouldHaveLabel(api.addDirectory('directory 5', { parentId: directory3.id }))
|
||||
api.addFile('file 1', { parentId: directory3.id })
|
||||
api.addProject('file 2', { parentId: directory3.id })
|
||||
api.addFile('another file')
|
||||
const directory4 = shouldHaveLabel(api.addDirectory('blargle', { parentId: directory2.id }))
|
||||
shouldHaveLabel(api.addProject('abcd', { parentId: directory2.id }))
|
||||
shouldHaveLabel(api.addProject('efgh', { parentId: directory2.id }))
|
||||
shouldHaveLabel(api.addFile('ijkl', { parentId: directory4.id }))
|
||||
shouldHaveLabel(api.addProject('mnop', { parentId: directory4.id }))
|
||||
shouldHaveLabel(api.addSecret('secret 1', { parentId: directory5.id }))
|
||||
shouldHaveLabel(api.addFile('yet another file', { parentId: directory5.id }))
|
||||
await actions.login({ page })
|
||||
|
||||
await actions.locateLabelsColumnToggle(page).click()
|
||||
let didExpandRows = false
|
||||
do {
|
||||
didExpandRows = false
|
||||
const directories = await actions.locateExpandableDirectories(page).all()
|
||||
// If going through the directories in forward order, the positions change when
|
||||
// one directory is expanded, making the double click happend on the wrong row
|
||||
// for all directories after it.
|
||||
for (const directory of directories.reverse()) {
|
||||
didExpandRows = true
|
||||
await directory.dblclick()
|
||||
}
|
||||
} while (didExpandRows)
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
const directory1Row = assetRows.filter({ hasText: directory1.title })
|
||||
await directory1Row.click()
|
||||
await assetRows.filter({ hasText: directory2.title }).click()
|
||||
await assetRows.filter({ hasText: directory5.title }).click()
|
||||
await labels.nth(1).dragTo(directory1Row)
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
for (const row of await actions.locateAssetRows(page).all()) {
|
||||
const name = await actions.locateAssetName(row).innerText()
|
||||
const labelElement = actions.locateAssetLabels(row).getByText(label)
|
||||
if (assetsWithLabel.has(name)) {
|
||||
await test.expect(labelElement).toBeVisible()
|
||||
} else {
|
||||
await test.expect(labelElement).not.toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test.test('drag (inverted, recursive)', async ({ page }) => {
|
||||
const { api } = await actions.mockAllAndLogin({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const labels = actions.locateLabelsPanelLabels(page)
|
||||
const label = 'bbbb'
|
||||
api.addLabel('aaaa', backend.COLORS[0])
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const backendLabel = api.addLabel(label, backend.COLORS[1]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('cccc', backend.COLORS[2]!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
api.addLabel('dddd', backend.COLORS[3]!)
|
||||
const assetsWithoutLabel = new Set<string>()
|
||||
const shouldNotHaveLabel = <T extends backend.AnyAsset>(asset: T) => {
|
||||
assetsWithoutLabel.add(asset.title)
|
||||
return asset
|
||||
}
|
||||
const directory1 = shouldNotHaveLabel(api.addDirectory('foo'))
|
||||
const directory2 = shouldNotHaveLabel(api.addDirectory('bar'))
|
||||
shouldNotHaveLabel(api.addFile('baz', { parentId: directory1.id }))
|
||||
shouldNotHaveLabel(api.addSecret('quux', { parentId: directory1.id }))
|
||||
const directory3 = api.addDirectory('directory 3')
|
||||
const directory5 = shouldNotHaveLabel(
|
||||
api.addDirectory('directory 5', { parentId: directory3.id })
|
||||
)
|
||||
api.addFile('file 1', { parentId: directory3.id })
|
||||
api.addProject('file 2', { parentId: directory3.id })
|
||||
api.addFile('another file')
|
||||
const directory4 = shouldNotHaveLabel(api.addDirectory('blargle', { parentId: directory2.id }))
|
||||
shouldNotHaveLabel(api.addProject('abcd', { parentId: directory2.id }))
|
||||
shouldNotHaveLabel(api.addProject('efgh', { parentId: directory2.id }))
|
||||
shouldNotHaveLabel(api.addFile('ijkl', { parentId: directory4.id }))
|
||||
shouldNotHaveLabel(api.addProject('mnop', { parentId: directory4.id }))
|
||||
shouldNotHaveLabel(api.addSecret('secret 1', { parentId: directory5.id }))
|
||||
shouldNotHaveLabel(api.addFile('yet another file', { parentId: directory5.id }))
|
||||
api.setLabels(api.rootDirectoryId, [backendLabel.value])
|
||||
await actions.login({ page })
|
||||
|
||||
await actions.locateLabelsColumnToggle(page).click()
|
||||
/** The default position (the center) cannot be clicked on as it lands exactly on a label -
|
||||
* which has its own mouse action. It also cannot be too far left, otherwise it triggers
|
||||
* edit mode for the name. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
const somewhereInRow = { x: 300, y: 16 }
|
||||
let didExpandRows = false
|
||||
do {
|
||||
didExpandRows = false
|
||||
const directories = await actions.locateExpandableDirectories(page).all()
|
||||
// If going through the directories in forward order, the positions change when
|
||||
// one directory is expanded, making the double click happend on the wrong row
|
||||
// for all directories after it.
|
||||
for (const directory of directories.reverse()) {
|
||||
didExpandRows = true
|
||||
await directory.dblclick({ position: somewhereInRow })
|
||||
}
|
||||
} while (didExpandRows)
|
||||
await page.keyboard.down(await actions.modModifier(page))
|
||||
const directory1Row = assetRows.filter({ hasText: directory1.title })
|
||||
await directory1Row.click({ position: somewhereInRow })
|
||||
await assetRows.filter({ hasText: directory2.title }).click({ position: somewhereInRow })
|
||||
await assetRows.filter({ hasText: directory5.title }).click({ position: somewhereInRow })
|
||||
await labels.nth(1).dragTo(directory1Row)
|
||||
await page.keyboard.up(await actions.modModifier(page))
|
||||
for (const row of await actions.locateAssetRows(page).all()) {
|
||||
const name = await actions.locateAssetName(row).innerText()
|
||||
const labelElement = actions.locateAssetLabels(row).getByText(label)
|
||||
if (assetsWithoutLabel.has(name)) {
|
||||
await test.expect(labelElement).not.toBeVisible()
|
||||
} else {
|
||||
await test.expect(labelElement).toBeVisible()
|
||||
}
|
||||
}
|
||||
})
|
51
app/ide-desktop/lib/dashboard/e2e/labelsPanel.spec.ts
Normal file
51
app/ide-desktop/lib/dashboard/e2e/labelsPanel.spec.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/** @file Test the labels sidebar panel. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('labels', async ({ page }) => {
|
||||
// Empty labels panel
|
||||
await test.expect(actions.locateLabelsPanel(page)).toBeVisible()
|
||||
|
||||
// "Create label" modal
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
await test.expect(actions.locateNewLabelModal(page)).toBeVisible()
|
||||
await page.press('body', 'Escape')
|
||||
await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible()
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
await actions.locateModalBackground(page).click()
|
||||
await test.expect(actions.locateNewLabelModal(page)).not.toBeVisible()
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
|
||||
// "Create label" modal with name set
|
||||
await actions.locateNewLabelModalNameInput(page).fill('New Label')
|
||||
await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/)
|
||||
|
||||
await page.press('body', 'Escape')
|
||||
|
||||
// "Create label" modal with color set
|
||||
// The exact number is allowed to vary; but to click the fourth color, there must be at least
|
||||
// four colors.
|
||||
await actions.locateNewLabelButton(page).click()
|
||||
test.expect(await actions.locateNewLabelModalColorButtons(page).count()).toBeGreaterThanOrEqual(4)
|
||||
// `force: true` is required because the `label` needs to handle the click event, not the
|
||||
// `button`.
|
||||
await actions.locateNewLabelModalColorButtons(page).nth(4).click({ force: true })
|
||||
await test.expect(actions.locateNewLabelModal(page)).toBeVisible()
|
||||
|
||||
// "Create label" modal with name and color set
|
||||
await actions.locateNewLabelModalNameInput(page).fill('New Label')
|
||||
await test.expect(actions.locateNewLabelModal(page)).toHaveText(/^New Label/)
|
||||
|
||||
// Labels panel with one entry
|
||||
await actions.locateCreateButton(actions.locateNewLabelModal(page)).click()
|
||||
await test.expect(actions.locateLabelsPanel(page)).toBeVisible()
|
||||
|
||||
// Empty labels panel again, after deleting the only entry
|
||||
await actions.locateLabelsPanelLabels(page).first().hover()
|
||||
await actions.locateDeleteIcon(actions.locateLabelsPanel(page)).first().click()
|
||||
await actions.locateDeleteButton(page).click()
|
||||
test.expect(await actions.locateLabelsPanelLabels(page).count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
24
app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts
Normal file
24
app/ide-desktop/lib/dashboard/e2e/loginLogout.spec.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
// Do not login in setup, because this test needs to test login.
|
||||
test.test.beforeEach(actions.mockAll)
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('login and logout', async ({ page }) => {
|
||||
// After sign in
|
||||
await actions.login({ page })
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await test.expect(actions.locateLoginButton(page)).not.toBeVisible()
|
||||
|
||||
// After sign out
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
await actions.locateLogoutButton(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateLoginButton(page)).toBeVisible()
|
||||
})
|
@ -3,15 +3,16 @@ import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAll)
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('login screen', async ({ page }) => {
|
||||
// Screenshot omitted - it is already taken by `loginLogout.spec.ts`.
|
||||
await page.goto('/')
|
||||
|
||||
// Screenshot #2: Invalid email
|
||||
// Invalid email
|
||||
await actions.locateEmailInput(page).fill('invalid email')
|
||||
test
|
||||
.expect(
|
||||
@ -21,14 +22,14 @@ test.test('login screen', async ({ page }) => {
|
||||
.toBe(false)
|
||||
await actions.locateLoginButton(page).click()
|
||||
|
||||
// Screenshot #3: Invalid password
|
||||
// Invalid password
|
||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
||||
await actions.locatePasswordInput(page).type(actions.INVALID_PASSWORD)
|
||||
await actions.locatePasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||
test
|
||||
.expect(
|
||||
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
|
||||
'form should reject invalid password'
|
||||
'form should accept invalid password'
|
||||
)
|
||||
.toBe(false)
|
||||
.toBe(true)
|
||||
await actions.locateLoginButton(page).click()
|
||||
})
|
27
app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts
Normal file
27
app/ide-desktop/lib/dashboard/e2e/pageSwitcher.spec.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/** @file Test the login flow. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('page switcher', async ({ page }) => {
|
||||
// Create a new project so that the editor page can be switched to.
|
||||
await actions.locateNewProjectButton(page).click()
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
|
||||
await actions.locateDrivePageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
await test.expect(actions.locateSamplesList(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).not.toBeVisible()
|
||||
|
||||
await actions.locateHomePageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateSamplesList(page)).toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).not.toBeVisible()
|
||||
|
||||
await actions.locateEditorPageIcon(page).click()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateSamplesList(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateEditor(page)).toBeVisible()
|
||||
})
|
@ -2,14 +2,9 @@
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import * as apiModule from './api'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
// =============
|
||||
|
||||
test.test('sign up flow', async ({ page }) => {
|
||||
const api = await apiModule.mockApi(page)
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
await page.goto('/')
|
||||
|
||||
@ -20,25 +15,27 @@ test.test('sign up flow', async ({ page }) => {
|
||||
test.expect(email).not.toStrictEqual(api.defaultEmail)
|
||||
test.expect(name).not.toStrictEqual(api.defaultName)
|
||||
|
||||
// Screenshot #1: Set username panel
|
||||
// Set username panel
|
||||
await actions.locateEmailInput(page).fill(email)
|
||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||
await actions.locateLoginButton(page).click()
|
||||
await test.expect(actions.locateSetUsernamePanel(page)).toHaveScreenshot()
|
||||
await test.expect(actions.locateSetUsernamePanel(page)).toBeVisible()
|
||||
|
||||
// Screenshot #2: Logged in, but account disabled
|
||||
// Logged in, but account disabled
|
||||
await actions.locateUsernameInput(page).fill(name)
|
||||
await actions.locateSetUsernameButton(page).click()
|
||||
await test.expect(page).toHaveScreenshot()
|
||||
await test.expect(actions.locateUpgradeButton(page)).toBeVisible()
|
||||
await test.expect(actions.locateDriveView(page)).not.toBeVisible()
|
||||
|
||||
// Screenshot #3: Logged in, and account enabled
|
||||
// Logged in, and account enabled
|
||||
const currentUser = api.currentUser
|
||||
test.expect(currentUser).toBeDefined()
|
||||
if (currentUser != null) {
|
||||
currentUser.isEnabled = true
|
||||
}
|
||||
await actions.login(page, email)
|
||||
await test.expect(page).toHaveScreenshot()
|
||||
await actions.login({ page }, email)
|
||||
await test.expect(actions.locateUpgradeButton(page)).not.toBeVisible()
|
||||
await test.expect(actions.locateDriveView(page)).toBeVisible()
|
||||
|
||||
test.expect(api.currentUser?.email, 'new user has correct email').toBe(email)
|
||||
test.expect(api.currentUser?.name, 'new user has correct name').toBe(name)
|
@ -2,7 +2,6 @@
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import * as apiModule from './api'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
@ -17,7 +16,7 @@ test.test('sign up with organization id', async ({ page }) => {
|
||||
await page.goto(
|
||||
'/registration?' + new URLSearchParams([['organization_id', organizationId]]).toString()
|
||||
)
|
||||
const api = await apiModule.mockApi(page)
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
@ -2,7 +2,6 @@
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
import * as apiModule from './api'
|
||||
|
||||
// =============
|
||||
// === Tests ===
|
||||
@ -12,7 +11,7 @@ test.test('sign up without organization id', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await page.goto('/registration')
|
||||
const api = await apiModule.mockApi(page)
|
||||
const api = await actions.mockApi({ page })
|
||||
api.setCurrentUser(null)
|
||||
|
||||
// Sign up
|
137
app/ide-desktop/lib/dashboard/e2e/sort.spec.ts
Normal file
137
app/ide-desktop/lib/dashboard/e2e/sort.spec.ts
Normal file
@ -0,0 +1,137 @@
|
||||
/** @file Test sorting of assets columns. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
||||
|
||||
const START_DATE_EPOCH_MS = 1.7e12
|
||||
/** The number of milliseconds in a minute. */
|
||||
const MIN_MS = 60_000
|
||||
|
||||
test.test('sort', async ({ page }) => {
|
||||
const { api } = await actions.mockAll({ page })
|
||||
const assetRows = actions.locateAssetRows(page)
|
||||
const nameHeading = actions.locateNameColumnHeading(page)
|
||||
const modifiedHeading = actions.locateModifiedColumnHeading(page)
|
||||
const date1 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS))
|
||||
const date2 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 1 * MIN_MS))
|
||||
const date3 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 2 * MIN_MS))
|
||||
const date4 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 3 * MIN_MS))
|
||||
const date5 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 4 * MIN_MS))
|
||||
const date6 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 5 * MIN_MS))
|
||||
const date7 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 6 * MIN_MS))
|
||||
const date8 = dateTime.toRfc3339(new Date(START_DATE_EPOCH_MS + 7 * MIN_MS))
|
||||
api.addDirectory('a directory', { modifiedAt: date4 })
|
||||
api.addDirectory('G directory', { modifiedAt: date6 })
|
||||
api.addProject('C project', { modifiedAt: date7 })
|
||||
api.addSecret('H secret', { modifiedAt: date2 })
|
||||
api.addProject('b project', { modifiedAt: date1 })
|
||||
api.addFile('d file', { modifiedAt: date8 })
|
||||
api.addSecret('f secret', { modifiedAt: date3 })
|
||||
api.addFile('e file', { modifiedAt: date5 })
|
||||
// By date:
|
||||
// b project
|
||||
// h secret
|
||||
// f secret
|
||||
// a directory
|
||||
// e file
|
||||
// g directory
|
||||
// c project
|
||||
// d file
|
||||
await page.goto('/')
|
||||
await actions.login({ page })
|
||||
|
||||
// By default, assets should be grouped by type.
|
||||
// Assets in each group are ordered by insertion order.
|
||||
await test.expect(actions.locateSortAscendingIcon(nameHeading)).not.toBeVisible()
|
||||
await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible()
|
||||
await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).not.toBeVisible()
|
||||
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^f secret/)
|
||||
|
||||
// Sort by name ascending.
|
||||
await nameHeading.click()
|
||||
await test.expect(actions.locateSortAscendingIcon(nameHeading)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^f secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^H secret/)
|
||||
|
||||
// Sort by name descending.
|
||||
await nameHeading.click()
|
||||
await test.expect(actions.locateSortDescendingIcon(nameHeading)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^f secret/)
|
||||
|
||||
// Sorting should be unset.
|
||||
await nameHeading.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await test.expect(actions.locateSortAscendingIcon(nameHeading)).not.toBeVisible()
|
||||
await test.expect(actions.locateSortDescendingIcon(nameHeading)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^f secret/)
|
||||
|
||||
// Sort by date ascending.
|
||||
await modifiedHeading.click()
|
||||
await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^f secret/)
|
||||
|
||||
// Sort by date descending.
|
||||
await modifiedHeading.click()
|
||||
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^f secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^H secret/)
|
||||
|
||||
// Sorting should be unset.
|
||||
await modifiedHeading.click()
|
||||
await page.mouse.move(0, 0)
|
||||
await test.expect(actions.locateSortAscendingIcon(modifiedHeading)).not.toBeVisible()
|
||||
await test.expect(actions.locateSortDescendingIcon(modifiedHeading)).not.toBeVisible()
|
||||
await test.expect(assetRows.nth(0)).toHaveText(/^a directory/)
|
||||
await test.expect(assetRows.nth(1)).toHaveText(/^G directory/)
|
||||
await test.expect(assetRows.nth(2)).toHaveText(/^C project/)
|
||||
await test.expect(assetRows.nth(3)).toHaveText(/^b project/)
|
||||
await test.expect(assetRows.nth(4)).toHaveText(/^d file/)
|
||||
await test.expect(assetRows.nth(5)).toHaveText(/^e file/)
|
||||
await test.expect(assetRows.nth(6)).toHaveText(/^H secret/)
|
||||
await test.expect(assetRows.nth(7)).toHaveText(/^f secret/)
|
||||
})
|
21
app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts
Normal file
21
app/ide-desktop/lib/dashboard/e2e/userMenu.spec.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/** @file Test the user menu. */
|
||||
import * as test from '@playwright/test'
|
||||
|
||||
import * as actions from './actions'
|
||||
|
||||
test.test.beforeEach(actions.mockAllAndLogin)
|
||||
|
||||
test.test('user menu', async ({ page }) => {
|
||||
// User menu
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
await test.expect(actions.locateUserMenu(page)).toBeVisible()
|
||||
})
|
||||
|
||||
test.test('download app', async ({ page }) => {
|
||||
await actions.locateUserMenuButton(page).click()
|
||||
const downloadPromise = page.waitForEvent('download')
|
||||
await actions.locateDownloadAppButton(page).click()
|
||||
const download = await downloadPromise
|
||||
await download.cancel()
|
||||
test.expect(download.url()).toMatch(/^https:[/][/]objects.githubusercontent.com/)
|
||||
})
|
@ -33,8 +33,6 @@
|
||||
user-scalable = no"
|
||||
/>
|
||||
<title>Enso</title>
|
||||
<!-- Generated by the build script based on the Enso Font package. -->
|
||||
<link rel="stylesheet" href="./src/ensoFont.css" />
|
||||
<script type="module" src="./src/entrypoint.ts" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@ -44,11 +42,6 @@
|
||||
<noscript>
|
||||
This page requires JavaScript to run. Please enable it in your browser.
|
||||
</noscript>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.2/dist/twemoji.min.js"
|
||||
integrity="sha384-D6GSzpW7fMH86ilu73eB95ipkfeXcMPoOGVst/L04yqSSe+RTUY0jXcuEIZk0wrT"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
|
@ -1,15 +0,0 @@
|
||||
/** @file Temporary file to print base64 of a png file. */
|
||||
import * as fs from 'node:fs/promises'
|
||||
|
||||
const ROOT = './test-results/'
|
||||
|
||||
for (const childName of await fs.readdir(ROOT)) {
|
||||
const childPath = ROOT + childName
|
||||
for (const fileName of await fs.readdir(childPath)) {
|
||||
const filePath = childPath + '/' + fileName
|
||||
const file = await fs.readFile(filePath)
|
||||
console.log(filePath, file.toString('base64'))
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(1)
|
@ -15,23 +15,23 @@
|
||||
"scripts": {
|
||||
"compile": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsx bundle.ts",
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"start": "tsx start.ts",
|
||||
"dev:e2e": "vite -c vite.test.config.ts",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest",
|
||||
"test:browsers": "npx --yes playwright install && npm run test:component && npm run test:e2e-and-log",
|
||||
"test:component": "playwright test -c playwright-component.config.ts",
|
||||
"test:e2e": "npx playwright test -c playwright-e2e.config.ts",
|
||||
"test:e2e-and-log": "npm run test:e2e || npx tsx log-screenshot-diffs.ts"
|
||||
"test:e2e": "npx playwright test",
|
||||
"test:e2e:debug": "npx playwright test --ui"
|
||||
},
|
||||
"//": [
|
||||
"@fortawesome/fontawesome-svg-core is required as a peer dependency for @fortawesome/react-fontawesome"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-amplify/auth": "^5.6.5",
|
||||
"@aws-amplify/core": "^5.8.5",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@heroicons/react": "^2.0.15",
|
||||
"@sentry/react": "^7.74.0",
|
||||
"enso-common": "^1.0.0",
|
||||
"is-network-error": "^1.0.1",
|
||||
@ -70,7 +70,6 @@
|
||||
"react-toastify": "^9.1.3",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"ts-plugin-namespace-auto-import": "^1.0.0",
|
||||
"tsx": "^3.12.6",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
"vitest": "^0.34.4"
|
||||
|
@ -1,47 +0,0 @@
|
||||
/** @file Playwright component testing configuration. */
|
||||
import vitePluginYaml from '@modyfi/vite-plugin-yaml'
|
||||
import * as componentTesting from '@playwright/experimental-ct-react'
|
||||
|
||||
// This is an autogenerated file.
|
||||
/* eslint-disable @typescript-eslint/strict-boolean-expressions, @typescript-eslint/no-magic-numbers */
|
||||
export default componentTesting.defineConfig({
|
||||
testDir: './test-component',
|
||||
snapshotDir: './__snapshots__',
|
||||
timeout: 10_000,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
...(process.env.CI ? { workers: 1 } : {}),
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...componentTesting.devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...componentTesting.devices['Desktop Firefox'] },
|
||||
},
|
||||
...(process.env.CI
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...componentTesting.devices['Desktop Safari'] },
|
||||
},
|
||||
]),
|
||||
],
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
ctPort: 3100,
|
||||
ctViteConfig: {
|
||||
plugins: [vitePluginYaml()],
|
||||
define: {
|
||||
// These are constants, and MUST be `CONSTANT_CASE`.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
['REDIRECT_OVERRIDE']: 'undefined',
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
['process.env.NODE_ENV']: JSON.stringify('production'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
@ -9,11 +9,10 @@ import * as test from '@playwright/test'
|
||||
/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/strict-boolean-expressions */
|
||||
|
||||
export default test.defineConfig({
|
||||
testDir: './test-e2e',
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
timeout: 10000,
|
||||
...(process.env.CI ? { workers: 1 } : {}),
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: true,
|
||||
workers: 1,
|
||||
expect: {
|
||||
toHaveScreenshot: { threshold: 0 },
|
||||
},
|
||||
@ -42,7 +41,7 @@ export default test.defineConfig({
|
||||
},
|
||||
},
|
||||
webServer: {
|
||||
command: 'npx tsx test-server.ts',
|
||||
command: 'npm run dev:e2e',
|
||||
port: 8080,
|
||||
reuseExistingServer: false,
|
||||
},
|
@ -1,12 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Testing Page</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
@ -1 +0,0 @@
|
||||
/** @file The file in which the test runner will append the built component code. */
|
@ -41,8 +41,18 @@ import * as toastify from 'react-toastify'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
|
||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import LocalStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import ModalProvider from '#/providers/ModalProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
import ShortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
|
||||
import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode'
|
||||
import ForgotPassword from '#/pages/authentication/ForgotPassword'
|
||||
@ -51,17 +61,13 @@ import Registration from '#/pages/authentication/Registration'
|
||||
import ResetPassword from '#/pages/authentication/ResetPassword'
|
||||
import SetUsername from '#/pages/authentication/SetUsername'
|
||||
import Dashboard from '#/pages/dashboard/Dashboard'
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import LocalStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import ModalProvider from '#/providers/ModalProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
import ShortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import type * as backend from '#/services/backend'
|
||||
import * as localBackend from '#/services/localBackend'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
|
||||
import type Backend from '#/services/Backend'
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
|
||||
import ShortcutManager, * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
|
||||
// ======================
|
||||
// === getMainPageUrl ===
|
||||
@ -143,14 +149,16 @@ function AppRouter(props: AppProps) {
|
||||
// @ts-expect-error This is used exclusively for debugging.
|
||||
window.navigate = navigate
|
||||
}
|
||||
const [shortcuts] = React.useState(() => shortcutsModule.ShortcutRegistry.createWithDefaults())
|
||||
const [shortcutManager] = React.useState(() => ShortcutManager.createWithDefaults())
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
const isTargetEditable =
|
||||
event.target instanceof HTMLInputElement ||
|
||||
(event.target instanceof HTMLElement && event.target.isContentEditable)
|
||||
const shouldHandleEvent = isTargetEditable ? !shortcutsModule.isTextInputEvent(event) : true
|
||||
if (shouldHandleEvent && shortcuts.handleKeyboardEvent(event)) {
|
||||
const shouldHandleEvent = isTargetEditable
|
||||
? !shortcutManagerModule.isTextInputEvent(event)
|
||||
: true
|
||||
if (shouldHandleEvent && shortcutManager.handleKeyboardEvent(event)) {
|
||||
event.preventDefault()
|
||||
// This is required to prevent the event from propagating to the event handler
|
||||
// that focuses the search input.
|
||||
@ -161,7 +169,7 @@ function AppRouter(props: AppProps) {
|
||||
return () => {
|
||||
document.body.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [shortcuts])
|
||||
}, [shortcutManager])
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
const authService = React.useMemo(() => {
|
||||
const authConfig = { navigate, ...props }
|
||||
@ -169,8 +177,8 @@ function AppRouter(props: AppProps) {
|
||||
}, [props, /* should never change */ navigate])
|
||||
const userSession = authService.cognito.userSession.bind(authService.cognito)
|
||||
const registerAuthEventListener = authService.registerAuthEventListener
|
||||
const initialBackend: backend.Backend = isAuthenticationDisabled
|
||||
? new localBackend.LocalBackend(projectManagerUrl)
|
||||
const initialBackend: Backend = isAuthenticationDisabled
|
||||
? new LocalBackend(projectManagerUrl)
|
||||
: // This is safe, because the backend is always set by the authentication flow.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
null!
|
||||
@ -231,7 +239,9 @@ function AppRouter(props: AppProps) {
|
||||
</router.Routes>
|
||||
)
|
||||
let result = routes
|
||||
result = <ShortcutsProvider shortcuts={shortcuts}>{result}</ShortcutsProvider>
|
||||
result = (
|
||||
<ShortcutManagerProvider shortcutManager={shortcutManager}>{result}</ShortcutManagerProvider>
|
||||
)
|
||||
result = <ModalProvider>{result}</ModalProvider>
|
||||
result = (
|
||||
<AuthProvider
|
||||
|
@ -29,30 +29,22 @@
|
||||
* Amplify reuses some codes for multiple kinds of errors. In the case of ambiguous errors, the
|
||||
* `kind` field provides a unique string that can be used to brand the error in place of the
|
||||
* `internalCode`, when rethrowing the error. */
|
||||
// These SHOULD NOT import any runtime code.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type * as amplify from '@aws-amplify/auth'
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
import type * as cognito from 'amazon-cognito-identity-js'
|
||||
import * as results from 'ts-results'
|
||||
|
||||
import * as original from '../../src/authentication/cognito'
|
||||
import type * as config from '../../src/authentication/config'
|
||||
import type * as loggerProvider from '../../src/providers/LoggerProvider'
|
||||
import type * as loggerProvider from '../providers/LoggerProvider'
|
||||
// @ts-expect-error This is a mock file that needs to reference its original file.
|
||||
import * as original from './cognito.ts'
|
||||
import type * as config from './config'
|
||||
/* eslint-enable no-restricted-syntax */
|
||||
|
||||
import * as listen from './listen'
|
||||
import * as listen from './listen.mock'
|
||||
|
||||
// This file exports a subset of the values from the original file.
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
export {
|
||||
ConfirmSignUpErrorKind,
|
||||
CurrentSessionErrorKind,
|
||||
ForgotPasswordErrorKind,
|
||||
ForgotPasswordSubmitErrorKind,
|
||||
SignInWithPasswordErrorKind,
|
||||
SignUpErrorKind,
|
||||
} from '../../src/authentication/cognito'
|
||||
// @ts-expect-error This is a mock file that needs to reference its original file.
|
||||
export { CognitoErrorType } from './cognito.ts'
|
||||
|
||||
// There are unused function parameters in this file.
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
@ -105,7 +97,7 @@ export class Cognito {
|
||||
const expirationDate = date + TEN_HOURS_S
|
||||
if (!this.isSignedIn) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||
throw original.CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage
|
||||
throw 'No current user'
|
||||
} else {
|
||||
return Promise.resolve<cognito.CognitoUserSession>({
|
||||
isValid: () => true,
|
||||
@ -150,7 +142,7 @@ export class Cognito {
|
||||
})
|
||||
}
|
||||
})
|
||||
const amplifySession = currentSession.mapErr(original.intoCurrentSessionErrorKind)
|
||||
const amplifySession = currentSession.mapErr(original.intoCurrentSessionErrorType)
|
||||
return amplifySession.map(parseUserSession).unwrapOr(null)
|
||||
}
|
||||
|
@ -35,9 +35,10 @@ import * as results from 'ts-results'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as config from '#/authentication/config'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
|
||||
import * as config from '#/authentication/config'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -48,19 +49,6 @@ import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
* constant defined in the AWS Amplify library. */
|
||||
const GITHUB_PROVIDER = 'Github'
|
||||
|
||||
const MESSAGES = {
|
||||
signInWithPassword: {
|
||||
userNotFound: 'User not found. Please sign up first.',
|
||||
userNotConfirmed: 'User is not confirmed. Please check your email for a confirmation link.',
|
||||
incorrectUsernameOrPassword: 'Incorrect username or password.',
|
||||
},
|
||||
forgotPassword: {
|
||||
userNotFound: 'Cannot reset password as user not found.',
|
||||
userNotConfirmed: `Cannot reset password for user with an unverified email. \
|
||||
Please verify your email first.`,
|
||||
},
|
||||
}
|
||||
|
||||
// ================
|
||||
// === UserInfo ===
|
||||
// ================
|
||||
@ -151,10 +139,24 @@ function isAuthError(error: unknown): error is AuthError {
|
||||
// === CognitoError ===
|
||||
// ====================
|
||||
|
||||
/** Internal IDs of Cognito errors that may occur when requesting a password reset. */
|
||||
export enum CognitoErrorType {
|
||||
userAlreadyConfirmed = 'UserAlreadyConfirmed',
|
||||
usernameExists = 'UsernameExists',
|
||||
invalidParameter = 'InvalidParameter',
|
||||
invalidPassword = 'InvalidPassword',
|
||||
notAuthorized = 'NotAuthorized',
|
||||
userNotConfirmed = 'UserNotConfirmed',
|
||||
userNotFound = 'UserNotFound',
|
||||
amplifyError = 'AmplifyError',
|
||||
authError = 'AuthError',
|
||||
noCurrentUser = 'NoCurrentUser',
|
||||
}
|
||||
|
||||
/** Base interface for all errors output from this module.
|
||||
* Every user-facing error MUST extend this interface. */
|
||||
interface CognitoError {
|
||||
kind: string
|
||||
type: CognitoErrorType
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -190,7 +192,7 @@ export class Cognito {
|
||||
* Will refresh the {@link UserSession} if it has expired. */
|
||||
async userSession() {
|
||||
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
|
||||
const amplifySession = currentSession.mapErr(intoCurrentSessionErrorKind)
|
||||
const amplifySession = currentSession.mapErr(intoCurrentSessionErrorType)
|
||||
return amplifySession.map(parseUserSession).unwrapOr(null)
|
||||
}
|
||||
|
||||
@ -380,24 +382,12 @@ function parseUserSession(session: cognito.CognitoUserSession): UserSession {
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when getting the current session. */
|
||||
export enum CurrentSessionErrorKind {
|
||||
noCurrentUser = 'NoCurrentUser',
|
||||
}
|
||||
|
||||
export const CURRENT_SESSION_NO_CURRENT_USER_ERROR = {
|
||||
internalMessage: 'No current user',
|
||||
kind: CurrentSessionErrorKind.noCurrentUser,
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an {@link AmplifyError} into a {@link CurrentSessionErrorKind} if it is a known error,
|
||||
/** Convert an {@link AmplifyError} into a {@link CognitoErrorType} if it is a known error,
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized.
|
||||
*/
|
||||
export function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
|
||||
if (error === CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage) {
|
||||
return CURRENT_SESSION_NO_CURRENT_USER_ERROR.kind
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
export function intoCurrentSessionErrorType(error: unknown): CognitoErrorType.noCurrentUser {
|
||||
if (error === 'No current user') {
|
||||
return CognitoErrorType.noCurrentUser
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
@ -436,31 +426,12 @@ function intoSignUpParams(
|
||||
}
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when signing up. */
|
||||
export enum SignUpErrorKind {
|
||||
usernameExists = 'UsernameExists',
|
||||
invalidParameter = 'InvalidParameter',
|
||||
invalidPassword = 'InvalidPassword',
|
||||
}
|
||||
|
||||
const SIGN_UP_USERNAME_EXISTS_ERROR = {
|
||||
internalCode: 'UsernameExistsException',
|
||||
kind: SignUpErrorKind.usernameExists,
|
||||
}
|
||||
|
||||
const SIGN_UP_INVALID_PARAMETER_ERROR = {
|
||||
internalCode: 'InvalidParameterException',
|
||||
kind: SignUpErrorKind.invalidParameter,
|
||||
}
|
||||
|
||||
const SIGN_UP_INVALID_PASSWORD_ERROR = {
|
||||
internalCode: 'InvalidPasswordException',
|
||||
kind: SignUpErrorKind.invalidPassword,
|
||||
}
|
||||
|
||||
/** An error that may occur when signing up. */
|
||||
export interface SignUpError extends CognitoError {
|
||||
kind: SignUpErrorKind
|
||||
type:
|
||||
| CognitoErrorType.invalidParameter
|
||||
| CognitoErrorType.invalidPassword
|
||||
| CognitoErrorType.usernameExists
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -470,19 +441,19 @@ export interface SignUpError extends CognitoError {
|
||||
* @throws {Error} If the error is not recognized.
|
||||
*/
|
||||
export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
||||
if (error.code === SIGN_UP_USERNAME_EXISTS_ERROR.internalCode) {
|
||||
if (error.code === 'UsernameExistsException') {
|
||||
return {
|
||||
kind: SIGN_UP_USERNAME_EXISTS_ERROR.kind,
|
||||
type: CognitoErrorType.usernameExists,
|
||||
message: error.message,
|
||||
}
|
||||
} else if (error.code === SIGN_UP_INVALID_PARAMETER_ERROR.internalCode) {
|
||||
} else if (error.code === 'InvalidParameterException') {
|
||||
return {
|
||||
kind: SIGN_UP_INVALID_PARAMETER_ERROR.kind,
|
||||
type: CognitoErrorType.invalidParameter,
|
||||
message: error.message,
|
||||
}
|
||||
} else if (error.code === SIGN_UP_INVALID_PASSWORD_ERROR.internalCode) {
|
||||
} else if (error.code === 'InvalidPasswordException') {
|
||||
return {
|
||||
kind: SIGN_UP_INVALID_PASSWORD_ERROR.kind,
|
||||
type: CognitoErrorType.invalidPassword,
|
||||
message: error.message,
|
||||
}
|
||||
} else {
|
||||
@ -494,28 +465,9 @@ export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
||||
// === ConfirmSignUp ===
|
||||
// =====================
|
||||
|
||||
/** Internal IDs of errors that may occur when confirming registration. */
|
||||
export enum ConfirmSignUpErrorKind {
|
||||
userAlreadyConfirmed = 'UserAlreadyConfirmed',
|
||||
userNotFound = 'UserNotFound',
|
||||
}
|
||||
|
||||
const CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR = {
|
||||
internalCode: 'NotAuthorizedException',
|
||||
internalMessage: 'User cannot be confirmed. Current status is CONFIRMED',
|
||||
kind: ConfirmSignUpErrorKind.userAlreadyConfirmed,
|
||||
}
|
||||
|
||||
const CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR = {
|
||||
internalCode: 'UserNotFoundException',
|
||||
internalMessage: 'Username/client id combination not found.',
|
||||
kind: ConfirmSignUpErrorKind.userNotFound,
|
||||
message: 'Incorrect email or confirmation code.',
|
||||
}
|
||||
|
||||
/** An error that may occur when confirming registration. */
|
||||
export interface ConfirmSignUpError extends CognitoError {
|
||||
kind: ConfirmSignUpErrorKind
|
||||
type: CognitoErrorType.userAlreadyConfirmed | CognitoErrorType.userNotFound
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -524,26 +476,26 @@ export interface ConfirmSignUpError extends CognitoError {
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError {
|
||||
if (
|
||||
error.code === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalCode &&
|
||||
error.message === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalMessage
|
||||
error.code === 'NotAuthorizedException' &&
|
||||
error.message === 'User cannot be confirmed. Current status is CONFIRMED'
|
||||
) {
|
||||
return {
|
||||
/** Don't re-use the original `error.code` here because Amplify overloads the same code
|
||||
* for multiple kinds of errors. We replace it with a custom code that has no
|
||||
* ambiguity. */
|
||||
kind: CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.kind,
|
||||
type: CognitoErrorType.userAlreadyConfirmed,
|
||||
message: error.message,
|
||||
}
|
||||
} else if (
|
||||
error.code === CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.internalCode &&
|
||||
error.message === CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.internalMessage
|
||||
error.code === 'UserNotFoundException' &&
|
||||
error.message === 'Username/client id combination not found.'
|
||||
) {
|
||||
return {
|
||||
/** Don't re-use the original `error.code` here because Amplify overloads the same code
|
||||
* for multiple kinds of errors. We replace it with a custom code that has no
|
||||
* ambiguity. */
|
||||
kind: CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.kind,
|
||||
message: CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.message,
|
||||
type: CognitoErrorType.userNotFound,
|
||||
message: 'Incorrect email or confirmation code.',
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
@ -554,16 +506,12 @@ export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignU
|
||||
// === SignInWithPassword ===
|
||||
// ==========================
|
||||
|
||||
/** Internal IDs of errors that may occur when signing in with a password. */
|
||||
export enum SignInWithPasswordErrorKind {
|
||||
notAuthorized = 'NotAuthorized',
|
||||
userNotConfirmed = 'UserNotConfirmed',
|
||||
userNotFound = 'UserNotFound',
|
||||
}
|
||||
|
||||
/** An error that may occur when signing in with a password. */
|
||||
export interface SignInWithPasswordError extends CognitoError {
|
||||
kind: SignInWithPasswordErrorKind
|
||||
type:
|
||||
| CognitoErrorType.notAuthorized
|
||||
| CognitoErrorType.userNotConfirmed
|
||||
| CognitoErrorType.userNotFound
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -574,18 +522,18 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW
|
||||
switch (error.code) {
|
||||
case 'UserNotFoundException':
|
||||
return {
|
||||
kind: SignInWithPasswordErrorKind.userNotFound,
|
||||
message: MESSAGES.signInWithPassword.userNotFound,
|
||||
type: CognitoErrorType.userNotFound,
|
||||
message: 'User not found. Please sign up first.',
|
||||
}
|
||||
case 'UserNotConfirmedException':
|
||||
return {
|
||||
kind: SignInWithPasswordErrorKind.userNotConfirmed,
|
||||
message: MESSAGES.signInWithPassword.userNotConfirmed,
|
||||
type: CognitoErrorType.userNotConfirmed,
|
||||
message: 'User not confirmed. Please check your email for a confirmation link.',
|
||||
}
|
||||
case 'NotAuthorizedException':
|
||||
return {
|
||||
kind: SignInWithPasswordErrorKind.notAuthorized,
|
||||
message: MESSAGES.signInWithPassword.incorrectUsernameOrPassword,
|
||||
type: CognitoErrorType.notAuthorized,
|
||||
message: 'Incorrect username or password.',
|
||||
}
|
||||
default:
|
||||
throw error
|
||||
@ -596,27 +544,9 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW
|
||||
// === ForgotPassword ===
|
||||
// ======================
|
||||
|
||||
/** Internal IDs of errors that may occur when requesting a password reset. */
|
||||
export enum ForgotPasswordErrorKind {
|
||||
userNotConfirmed = 'UserNotConfirmed',
|
||||
userNotFound = 'UserNotFound',
|
||||
}
|
||||
|
||||
const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = {
|
||||
internalCode: 'InvalidParameterException',
|
||||
kind: ForgotPasswordErrorKind.userNotConfirmed,
|
||||
message: `Cannot reset password for the user as there is no registered/verified email or \
|
||||
phone_number`,
|
||||
}
|
||||
|
||||
const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = {
|
||||
internalCode: 'UserNotFoundException',
|
||||
kind: ForgotPasswordErrorKind.userNotFound,
|
||||
}
|
||||
|
||||
/** An error that may occur when requesting a password reset. */
|
||||
export interface ForgotPasswordError extends CognitoError {
|
||||
kind: ForgotPasswordErrorKind
|
||||
type: CognitoErrorType.userNotConfirmed | CognitoErrorType.userNotFound
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -624,18 +554,22 @@ export interface ForgotPasswordError extends CognitoError {
|
||||
* else re-throws the error.
|
||||
* @throws {Error} If the error is not recognized. */
|
||||
export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError {
|
||||
if (error.code === FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.internalCode) {
|
||||
if (error.code === 'UserNotFoundException') {
|
||||
return {
|
||||
kind: FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.kind,
|
||||
message: MESSAGES.forgotPassword.userNotFound,
|
||||
type: CognitoErrorType.userNotFound,
|
||||
message: 'Cannot reset password as user not found.',
|
||||
}
|
||||
} else if (
|
||||
error.code === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.internalCode &&
|
||||
error.message === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.message
|
||||
error.code === 'InvalidParameterException' &&
|
||||
error.message ===
|
||||
'Cannot reset password for the user as there is no registered/verified email or ' +
|
||||
'phone_number'
|
||||
) {
|
||||
return {
|
||||
kind: FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.kind,
|
||||
message: MESSAGES.forgotPassword.userNotConfirmed,
|
||||
type: CognitoErrorType.userNotConfirmed,
|
||||
message:
|
||||
'Cannot reset password for user with an unverified email. ' +
|
||||
'Please verify your email first.',
|
||||
}
|
||||
} else {
|
||||
throw error
|
||||
@ -646,15 +580,9 @@ export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPassw
|
||||
// === ForgotPasswordSubmit ===
|
||||
// ============================
|
||||
|
||||
/** Internal IDs of errors that may occur when resetting a password. */
|
||||
export enum ForgotPasswordSubmitErrorKind {
|
||||
amplifyError = 'AmplifyError',
|
||||
authError = 'AuthError',
|
||||
}
|
||||
|
||||
/** An error that may occur when resetting a password. */
|
||||
export interface ForgotPasswordSubmitError extends CognitoError {
|
||||
kind: ForgotPasswordSubmitErrorKind
|
||||
type: CognitoErrorType.amplifyError | CognitoErrorType.authError
|
||||
message: string
|
||||
}
|
||||
|
||||
@ -664,12 +592,12 @@ export interface ForgotPasswordSubmitError extends CognitoError {
|
||||
export function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError {
|
||||
if (isAuthError(error)) {
|
||||
return {
|
||||
kind: ForgotPasswordSubmitErrorKind.authError,
|
||||
type: CognitoErrorType.authError,
|
||||
message: error.log,
|
||||
}
|
||||
} else if (isAmplifyError(error)) {
|
||||
return {
|
||||
kind: ForgotPasswordSubmitErrorKind.amplifyError,
|
||||
type: CognitoErrorType.amplifyError,
|
||||
message: error.message,
|
||||
}
|
||||
} else {
|
||||
|
@ -7,11 +7,14 @@ import * as common from 'enso-common'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
|
||||
import * as config from '#/utilities/config'
|
||||
|
||||
import * as cognito from '#/authentication/cognito'
|
||||
import * as auth from '#/authentication/config'
|
||||
import * as listen from '#/authentication/listen'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as config from '#/utilities/config'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
|
@ -1,7 +1,7 @@
|
||||
/** @file A color picker to select from a predetermined list of colors. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backend from '#/services/backend'
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
/** Props for a {@link ColorPicker}. */
|
||||
export interface ColorPickerProps {
|
||||
|
@ -4,18 +4,20 @@ import * as React from 'react'
|
||||
import CrossIcon from 'enso-assets/cross.svg'
|
||||
import TickIcon from 'enso-assets/tick.svg'
|
||||
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
|
||||
// ====================
|
||||
// === EditableSpan ===
|
||||
// ====================
|
||||
|
||||
/** Props of an {@link EditableSpan} that are passed through to the base element. */
|
||||
type EditableSpanPassthroughProps = JSX.IntrinsicElements['input'] & JSX.IntrinsicElements['span']
|
||||
|
||||
/** Props for an {@link EditableSpan}. */
|
||||
export interface EditableSpanProps extends Omit<EditableSpanPassthroughProps, 'onSubmit'> {
|
||||
export interface EditableSpanProps {
|
||||
// This matches the capitalization of `data-` attributes in React.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'data-testid'?: string
|
||||
className?: string
|
||||
editable?: boolean
|
||||
checkSubmittable?: (value: string) => boolean
|
||||
onSubmit: (value: string) => void
|
||||
@ -27,17 +29,9 @@ export interface EditableSpanProps extends Omit<EditableSpanPassthroughProps, 'o
|
||||
|
||||
/** A `<span>` that can turn into an `<input type="text">`. */
|
||||
export default function EditableSpan(props: EditableSpanProps) {
|
||||
const {
|
||||
editable = false,
|
||||
checkSubmittable,
|
||||
children,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
inputPattern,
|
||||
inputTitle,
|
||||
...passthrough
|
||||
} = props
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { 'data-testid': dataTestId, className, editable = false, children } = props
|
||||
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const [isSubmittable, setIsSubmittable] = React.useState(true)
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
const cancelled = React.useRef(false)
|
||||
@ -50,8 +44,8 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
if (editable) {
|
||||
return shortcuts.registerKeyboardHandlers({
|
||||
[shortcutsModule.KeyboardAction.cancelEditName]: () => {
|
||||
return shortcutManager.registerKeyboardHandlers({
|
||||
[shortcutManagerModule.KeyboardAction.cancelEditName]: () => {
|
||||
onCancel()
|
||||
cancelled.current = true
|
||||
inputRef.current?.blur()
|
||||
@ -60,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [editable, shortcuts, onCancel])
|
||||
}, [editable, shortcutManager, onCancel])
|
||||
|
||||
React.useEffect(() => {
|
||||
cancelled.current = false
|
||||
@ -80,19 +74,19 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
}}
|
||||
>
|
||||
<input
|
||||
data-testid={dataTestId}
|
||||
className={className}
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
size={1}
|
||||
defaultValue={children}
|
||||
onBlur={event => {
|
||||
passthrough.onBlur?.(event)
|
||||
if (!cancelled.current) {
|
||||
event.currentTarget.form?.requestSubmit()
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
passthrough.onKeyDown?.(event)
|
||||
if (
|
||||
!event.isPropagationStopped() &&
|
||||
((event.ctrlKey &&
|
||||
@ -119,26 +113,35 @@ export default function EditableSpan(props: EditableSpanProps) {
|
||||
setIsSubmittable(checkSubmittable(event.currentTarget.value))
|
||||
},
|
||||
})}
|
||||
{...passthrough}
|
||||
/>
|
||||
{isSubmittable && (
|
||||
<button type="submit" className="mx-0.5">
|
||||
<img src={TickIcon} />
|
||||
<img src={TickIcon} alt="Confirm Edit" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mx-0.5"
|
||||
onMouseDown={() => {
|
||||
cancelled.current = true
|
||||
}}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
window.setTimeout(() => {
|
||||
cancelled.current = false
|
||||
})
|
||||
}}
|
||||
>
|
||||
<img src={CrossIcon} />
|
||||
<img src={CrossIcon} alt="Cancel Edit" />
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
} else {
|
||||
return <span {...passthrough}>{children}</span>
|
||||
return (
|
||||
<span data-testid={dataTestId} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
/** @file An entry in a menu. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import KeyboardShortcut from '#/components/dashboard/keyboardShortcut'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
|
||||
// =================
|
||||
// === MenuEntry ===
|
||||
// =================
|
||||
@ -14,7 +15,7 @@ import SvgMask from '#/components/SvgMask'
|
||||
/** Props for a {@link MenuEntry}. */
|
||||
export interface MenuEntryProps {
|
||||
hidden?: boolean
|
||||
action: shortcutsModule.KeyboardAction
|
||||
action: shortcutManagerModule.KeyboardAction
|
||||
/** When true, the button is not clickable. */
|
||||
disabled?: boolean
|
||||
title?: string
|
||||
@ -25,18 +26,18 @@ export interface MenuEntryProps {
|
||||
/** An item in a menu. */
|
||||
export default function MenuEntry(props: MenuEntryProps) {
|
||||
const { hidden = false, action, disabled = false, title, paddingClassName, doAction } = props
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const info = shortcuts.keyboardShortcutInfo[action]
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const info = shortcutManager.keyboardShortcutInfo[action]
|
||||
React.useEffect(() => {
|
||||
// This is slower than registering every shortcut in the context menu at once.
|
||||
if (!disabled) {
|
||||
return shortcuts.registerKeyboardHandlers({
|
||||
return shortcutManager.registerKeyboardHandlers({
|
||||
[action]: doAction,
|
||||
})
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [disabled, shortcuts, action, doAction])
|
||||
}, [disabled, shortcutManager, action, doAction])
|
||||
return hidden ? null : (
|
||||
<button
|
||||
disabled={disabled}
|
||||
@ -52,8 +53,8 @@ export default function MenuEntry(props: MenuEntryProps) {
|
||||
<div className="flex items-center gap-3">
|
||||
<SvgMask
|
||||
style={{
|
||||
width: shortcutsModule.ICON_SIZE_PX,
|
||||
height: shortcutsModule.ICON_SIZE_PX,
|
||||
width: shortcutManagerModule.ICON_SIZE_PX,
|
||||
height: shortcutManagerModule.ICON_SIZE_PX,
|
||||
}}
|
||||
src={info.icon}
|
||||
className={info.colorClass}
|
||||
|
@ -9,6 +9,8 @@ import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
/** Props for a {@link Modal}. */
|
||||
export interface ModalProps extends React.PropsWithChildren {
|
||||
/** If `true`, disables `data-testid` because it will not be visible. */
|
||||
hidden?: boolean
|
||||
// This can intentionally be `undefined`, in order to simplify consumers of this component.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
centered?: boolean | undefined
|
||||
@ -22,11 +24,15 @@ export interface ModalProps extends React.PropsWithChildren {
|
||||
* background transparency can be enabled with Tailwind's `bg-opacity` classes, like
|
||||
* `className="bg-opacity-50"`. */
|
||||
export default function Modal(props: ModalProps) {
|
||||
const { children, centered = false, style, className, onClick, onContextMenu } = props
|
||||
const { hidden = false, children, centered = false, style, className } = props
|
||||
const { onClick, onContextMenu } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
<div
|
||||
// The name comes from a third-party API and cannot be changed.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
{...(!hidden ? { 'data-testid': 'modal-background' } : {})}
|
||||
style={style}
|
||||
// This MUST be z-3, unlike all other elements, because it MUST show above the IDE.
|
||||
className={`inset-0 z-3 ${
|
||||
|
@ -6,11 +6,12 @@ import ConnectorIcon from 'enso-assets/connector.svg'
|
||||
import FolderIcon from 'enso-assets/folder.svg'
|
||||
import NetworkIcon from 'enso-assets/network.svg'
|
||||
|
||||
import * as backend from '#/services/backend'
|
||||
import * as fileIcon from '#/utilities/fileIcon'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as fileIcon from '#/utilities/fileIcon'
|
||||
|
||||
/** Props for an {@link AssetIcon}. */
|
||||
export interface AssetIconProps {
|
||||
asset: backend.AnyAsset
|
||||
|
@ -5,10 +5,11 @@ import DocsIcon from 'enso-assets/docs.svg'
|
||||
import SettingsIcon from 'enso-assets/settings.svg'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
|
||||
import Button from '#/components/Button'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
/** Props for an {@link AssetInfoBar}. */
|
||||
export interface AssetInfoBarProps {
|
||||
canToggleSettingsPanel: boolean
|
||||
@ -41,6 +42,7 @@ export default function AssetInfoBar(props: AssetInfoBarProps) {
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
alt={isSettingsPanelVisible ? 'Close Asset Panel' : 'Open Asset Panel'}
|
||||
active={canToggleSettingsPanel && isSettingsPanelVisible}
|
||||
disabled={!canToggleSettingsPanel}
|
||||
image={SettingsIcon}
|
||||
|
@ -3,30 +3,39 @@ import * as React from 'react'
|
||||
|
||||
import BlankIcon from 'enso-assets/blank.svg'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import AssetContextMenu from '#/layouts/dashboard/AssetContextMenu'
|
||||
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import AssetContextMenu from '#/layouts/dashboard/AssetContextMenu'
|
||||
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
|
||||
|
||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||
import * as columnModule from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as download from '#/utilities/download'
|
||||
import * as drag from '#/utilities/drag'
|
||||
import * as errorModule from '#/utilities/error'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import Visibility, * as visibilityModule from '#/utilities/visibility'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -45,8 +54,8 @@ const EMPTY_DIRECTORY_PLACEHOLDER = <span className="px-2 opacity-75">This folde
|
||||
/** Common properties for state and setters passed to event handlers on an {@link AssetRow}. */
|
||||
export interface AssetRowInnerProps {
|
||||
key: backendModule.AssetId
|
||||
item: assetTreeNode.AssetTreeNode
|
||||
setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AssetTreeNode>>
|
||||
item: AssetTreeNode
|
||||
setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||
state: assetsTable.AssetsTableState
|
||||
rowState: assetsTable.AssetRowState
|
||||
setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
|
||||
@ -55,13 +64,10 @@ export interface AssetRowInnerProps {
|
||||
/** Props for an {@link AssetRow}. */
|
||||
export interface AssetRowProps
|
||||
extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
|
||||
keyProp: backendModule.AssetId
|
||||
tableRowRef?: React.RefObject<HTMLTableRowElement>
|
||||
item: assetTreeNode.AssetTreeNode
|
||||
item: AssetTreeNode
|
||||
state: assetsTable.AssetsTableState
|
||||
hidden: boolean
|
||||
initialRowState: assetsTable.AssetRowState
|
||||
columns: column.AssetColumn[]
|
||||
columns: columnUtils.Column[]
|
||||
selected: boolean
|
||||
setSelected: (selected: boolean) => void
|
||||
isSoleSelectedItem: boolean
|
||||
@ -72,9 +78,8 @@ export interface AssetRowProps
|
||||
|
||||
/** A row containing an {@link backendModule.AnyAsset}. */
|
||||
export default function AssetRow(props: AssetRowProps) {
|
||||
const { keyProp: key, item: rawItem, initialRowState, hidden: hiddenRaw, selected } = props
|
||||
const { isSoleSelectedItem, setSelected, allowContextMenu, onContextMenu, state } = props
|
||||
const { tableRowRef, columns, onClick } = props
|
||||
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelectedItem, setSelected } = props
|
||||
const { allowContextMenu, onContextMenu, state, columns, onClick } = props
|
||||
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||
const { setAssetSettingsPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||
|
||||
@ -88,8 +93,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
const asset = item.item
|
||||
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
||||
object.merge(initialRowState, { setVisibility: setInsertionVisibility })
|
||||
object.merge(assetRowUtils.INITIAL_ROW_STATE, { setVisibility: setInsertionVisibility })
|
||||
)
|
||||
const key = AssetTreeNode.getKey(item)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const outerVisibility = visibilities.get(key)
|
||||
const visibility =
|
||||
@ -107,7 +113,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
// re - rendering the parent.
|
||||
rawItem.item = asset
|
||||
}, [asset, rawItem])
|
||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selected && insertionVisibility !== Visibility.visible) {
|
||||
@ -541,11 +547,26 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
<>
|
||||
{!hidden && (
|
||||
<tr
|
||||
ref={tableRowRef}
|
||||
draggable
|
||||
tabIndex={-1}
|
||||
className={`h-8 transition duration-300 ease-in-out ${
|
||||
visibilityModule.CLASS_NAME[visibility]
|
||||
} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||
onClick={event => {
|
||||
unsetModal()
|
||||
onClick(innerProps, event)
|
||||
if (
|
||||
asset.type === backendModule.AssetType.directory &&
|
||||
eventModule.isDoubleClick(event) &&
|
||||
!rowState.isEditingName
|
||||
) {
|
||||
// This must be processed on the next tick, otherwise it will be overridden
|
||||
// by the default click handler.
|
||||
window.setTimeout(() => {
|
||||
setSelected(false)
|
||||
})
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
}
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
if (allowContextMenu) {
|
||||
@ -569,9 +590,6 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
onContextMenu?.(innerProps, event)
|
||||
}
|
||||
}}
|
||||
className={`h-8 transition duration-300 ease-in-out ${
|
||||
visibilityModule.CLASS_NAME[visibility]
|
||||
} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||
onDragStart={event => {
|
||||
if (rowState.isEditingName || !isCloud) {
|
||||
event.preventDefault()
|
||||
@ -589,6 +607,7 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
}, DRAG_EXPAND_DELAY_MS)
|
||||
}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
props.onDragOver?.(event)
|
||||
onDragOver(event)
|
||||
}}
|
||||
onDragOver={event => {
|
||||
@ -607,7 +626,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
) {
|
||||
window.clearTimeout(dragOverTimeoutHandle.current)
|
||||
}
|
||||
clearDragState()
|
||||
if (event.currentTarget === event.target) {
|
||||
clearDragState()
|
||||
}
|
||||
props.onDragLeave?.(event)
|
||||
}}
|
||||
onDrop={event => {
|
||||
@ -635,9 +656,9 @@ export default function AssetRow(props: AssetRowProps) {
|
||||
{columns.map(column => {
|
||||
// This is a React component even though it does not contain JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const Render = column.render
|
||||
const Render = columnModule.COLUMN_RENDERER[column]
|
||||
return (
|
||||
<td key={column.id} className={column.className ?? ''}>
|
||||
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
|
||||
<Render
|
||||
keyProp={key}
|
||||
item={item}
|
||||
|
@ -0,0 +1,14 @@
|
||||
/** @file Utilities related to `AssetRow`s. */
|
||||
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
|
||||
|
||||
import * as set from '#/utilities/set'
|
||||
|
||||
/** The default {@link assetsTable.AssetRowState} associated with an `AssetRow`. */
|
||||
export const INITIAL_ROW_STATE: assetsTable.AssetRowState = Object.freeze({
|
||||
setVisibility: () => {
|
||||
// Ignored. This MUST be replaced by the row component. It should also update `visibility`.
|
||||
},
|
||||
isEditingName: false,
|
||||
temporarilyAddedLabels: set.EMPTY,
|
||||
temporarilyRemovedLabels: set.EMPTY,
|
||||
})
|
@ -3,11 +3,12 @@ import * as React from 'react'
|
||||
|
||||
import BreadcrumbArrowIcon from 'enso-assets/breadcrumb_arrow.svg'
|
||||
|
||||
import type * as backend from '#/services/backend'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
import AssetIcon from '#/components/dashboard/AssetIcon'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
/** Props for an {@link AssetSummary}. */
|
||||
export interface AssetSummaryProps {
|
||||
asset: backend.AnyAsset
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import Modal from '#/components/Modal'
|
||||
|
@ -4,25 +4,29 @@ import * as React from 'react'
|
||||
import FolderIcon from 'enso-assets/folder.svg'
|
||||
import TriangleDownIcon from 'enso-assets/triangle_down.svg'
|
||||
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as string from '#/utilities/string'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
import * as string from '#/utilities/string'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
// =====================
|
||||
// === DirectoryName ===
|
||||
// =====================
|
||||
@ -34,18 +38,18 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
|
||||
* @throws {Error} when the asset is not a {@link backendModule.DirectoryAsset}.
|
||||
* This should never happen. */
|
||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
const { item, setItem, selected, setSelected, state, rowState, setRowState } = props
|
||||
const { item, setItem, selected, state, rowState, setRowState } = props
|
||||
const { numberOfSelectedItems, assetEvents, dispatchAssetListEvent, nodeMap } = state
|
||||
const { doToggleDirectoryExpansion } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const asset = item.item
|
||||
if (asset.type !== backendModule.AssetType.directory) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`DirectoryNameColumn` can only display directory assets.')
|
||||
throw new Error('`DirectoryNameColumn` can only display folders.')
|
||||
}
|
||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
|
||||
const doRename = async (newTitle: string) => {
|
||||
@ -133,23 +137,16 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
((selected && numberOfSelectedItems === 1) ||
|
||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||
) {
|
||||
event.stopPropagation()
|
||||
setRowState(object.merger({ isEditingName: true }))
|
||||
} else if (eventModule.isDoubleClick(event)) {
|
||||
if (!rowState.isEditingName) {
|
||||
// This must be processed on the next tick, otherwise it will be overridden
|
||||
// by the default click handler.
|
||||
window.setTimeout(() => {
|
||||
setSelected(false)
|
||||
}, 0)
|
||||
doToggleDirectoryExpansion(asset.id, item.key, asset.title)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SvgMask
|
||||
src={TriangleDownIcon}
|
||||
alt={item.children == null ? 'Expand' : 'Collapse'}
|
||||
className={`hidden group-hover:inline-block cursor-pointer h-4 w-4 m-1 transition-transform duration-300 ${
|
||||
item.children != null ? '' : '-rotate-90'
|
||||
}`}
|
||||
@ -160,7 +157,11 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
/>
|
||||
<SvgMask src={FolderIcon} className="group-hover:hidden h-4 w-4 m-1" />
|
||||
<EditableSpan
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className={`cursor-pointer bg-transparent grow leading-170 h-6 py-px ${
|
||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||
}`}
|
||||
checkSubmittable={newTitle =>
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
child =>
|
||||
@ -176,9 +177,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
||||
onCancel={() => {
|
||||
setRowState(object.merger({ isEditingName: false }))
|
||||
}}
|
||||
className={`cursor-pointer bg-transparent grow leading-170 h-6 py-px ${
|
||||
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{asset.title}
|
||||
</EditableSpan>
|
||||
|
@ -1,25 +1,29 @@
|
||||
/** @file The icon and name of a {@link backendModule.FileAsset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as fileIcon from '#/utilities/fileIcon'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as fileIcon from '#/utilities/fileIcon'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
// ================
|
||||
// === FileName ===
|
||||
// ================
|
||||
@ -32,16 +36,16 @@ export interface FileNameColumnProps extends column.AssetColumnProps {}
|
||||
* This should never happen. */
|
||||
export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
const { item, setItem, selected, state, rowState, setRowState } = props
|
||||
const { assetEvents, dispatchAssetListEvent } = state
|
||||
const { nodeMap, assetEvents, dispatchAssetListEvent } = state
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const asset = item.item
|
||||
if (asset.type !== backendModule.AssetType.file) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`FileNameColumn` can only display file assets.')
|
||||
throw new Error('`FileNameColumn` can only display files.')
|
||||
}
|
||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
|
||||
// context menu entry should be re-added.
|
||||
@ -129,7 +133,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
onClick={event => {
|
||||
if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
(selected || shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
||||
(selected ||
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||
) {
|
||||
setRowState(object.merger({ isEditingName: true }))
|
||||
}
|
||||
@ -137,7 +142,20 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
>
|
||||
<SvgMask src={fileIcon.fileIcon()} className="m-1" />
|
||||
<EditableSpan
|
||||
data-testid="asset-row-name"
|
||||
editable={false}
|
||||
className="bg-transparent grow leading-170 h-6 py-px"
|
||||
checkSubmittable={newTitle =>
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
child =>
|
||||
// All siblings,
|
||||
child.key === item.key ||
|
||||
// that are not directories,
|
||||
backendModule.assetIsDirectory(child.item) ||
|
||||
// must have a different name.
|
||||
child.item.title !== newTitle
|
||||
)
|
||||
}
|
||||
onSubmit={async newTitle => {
|
||||
setRowState(object.merger({ isEditingName: false }))
|
||||
if (newTitle !== asset.title) {
|
||||
@ -153,7 +171,6 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
||||
onCancel={() => {
|
||||
setRowState(object.merger({ isEditingName: false }))
|
||||
}}
|
||||
className="bg-transparent grow leading-170 h-6 py-px"
|
||||
>
|
||||
{asset.title}
|
||||
</EditableSpan>
|
||||
|
@ -1,7 +1,7 @@
|
||||
/** @file An label that can be applied to an asset. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backend from '#/services/backend'
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
// =============
|
||||
// === Label ===
|
||||
@ -12,6 +12,9 @@ interface InternalLabelProps
|
||||
extends React.PropsWithChildren,
|
||||
Omit<JSX.IntrinsicElements['button'], 'color' | 'onClick'>,
|
||||
Required<Pick<JSX.IntrinsicElements['button'], 'onClick'>> {
|
||||
// This matches the capitalization of `data-` attributes in React.
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'data-testid'?: string
|
||||
/** When true, the button is not faded out even when not hovered. */
|
||||
active?: boolean
|
||||
/** When true, the button has a red border signifying that it will be deleted,
|
||||
@ -29,6 +32,7 @@ interface InternalLabelProps
|
||||
/** An label that can be applied to an asset. */
|
||||
export default function Label(props: InternalLabelProps) {
|
||||
const {
|
||||
'data-testid': dataTestId,
|
||||
active = false,
|
||||
disabled = false,
|
||||
color,
|
||||
@ -47,6 +51,7 @@ export default function Label(props: InternalLabelProps) {
|
||||
: 'text-not-selected'
|
||||
return (
|
||||
<button
|
||||
data-testid={dataTestId}
|
||||
disabled={disabled}
|
||||
className={`flex items-center rounded-full whitespace-nowrap gap-1.5 h-6 px-2.25 transition-all ${className} ${
|
||||
negated
|
||||
|
@ -1,5 +1,5 @@
|
||||
/** @file Constants related to labels. */
|
||||
import type * as backend from '#/services/backend'
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
|
@ -11,9 +11,9 @@ import * as permissionsModule from '#/utilities/permissions'
|
||||
export interface PermissionDisplayProps extends React.PropsWithChildren {
|
||||
action: permissionsModule.PermissionAction
|
||||
className?: string
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>
|
||||
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>
|
||||
onMouseLeave?: React.MouseEventHandler<HTMLButtonElement>
|
||||
}
|
||||
|
||||
/** Colored border around icons and text indicating permissions. */
|
||||
@ -26,7 +26,7 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
||||
case permissionsModule.Permission.admin:
|
||||
case permissionsModule.Permission.edit: {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={`${
|
||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||
} inline-block rounded-full whitespace-nowrap h-6 px-1.75 py-0.5 ${className ?? ''}`}
|
||||
@ -35,13 +35,13 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
case permissionsModule.Permission.read:
|
||||
case permissionsModule.Permission.view: {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={`relative inline-block rounded-full whitespace-nowrap ${className ?? ''}`}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
@ -60,7 +60,7 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
/** @file A selector for all possible permissions. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as backend from '#/services/backend'
|
||||
import type * as permissions from '#/utilities/permissions'
|
||||
import * as permissionsModule from '#/utilities/permissions'
|
||||
|
||||
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import type * as permissions from '#/utilities/permissions'
|
||||
import * as permissionsModule from '#/utilities/permissions'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
@ -1,7 +1,8 @@
|
||||
/** @file A selector for all possible permission types. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backend from '#/services/backend'
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
// =================
|
||||
|
@ -7,23 +7,27 @@ import ArrowUpIcon from 'enso-assets/arrow_up.svg'
|
||||
import PlayIcon from 'enso-assets/play.svg'
|
||||
import StopIcon from 'enso-assets/stop.svg'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as remoteBackend from '#/services/remoteBackend'
|
||||
import * as errorModule from '#/utilities/error'
|
||||
import * as localStorageModule from '#/utilities/localStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import Spinner, * as spinner from '#/components/Spinner'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import * as remoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as errorModule from '#/utilities/error'
|
||||
import * as localStorageModule from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
@ -3,29 +3,33 @@ import * as React from 'react'
|
||||
|
||||
import NetworkIcon from 'enso-assets/network.svg'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as validation from '#/utilities/validation'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import ProjectIcon from '#/components/dashboard/ProjectIcon'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as validation from '#/utilities/validation'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
// ===================
|
||||
// === ProjectName ===
|
||||
// ===================
|
||||
@ -43,13 +47,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const asset = item.item
|
||||
if (asset.type !== backendModule.AssetType.project) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`ProjectNameColumn` can only display project assets.')
|
||||
throw new Error('`ProjectNameColumn` can only display projects.')
|
||||
}
|
||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
const ownPermission =
|
||||
asset.permissions?.find(permission => permission.user.user_email === organization?.email) ??
|
||||
null
|
||||
@ -251,7 +255,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
onClick={event => {
|
||||
if (rowState.isEditingName || isOtherUserUsingProject) {
|
||||
// The project should neither be edited nor opened in these cases.
|
||||
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.open, event)) {
|
||||
} else if (
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.open, event)
|
||||
) {
|
||||
// It is a double click; open the project.
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
@ -259,7 +265,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
shouldAutomaticallySwitchPage: true,
|
||||
runInBackground: false,
|
||||
})
|
||||
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.run, event)) {
|
||||
} else if (
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.run, event)
|
||||
) {
|
||||
dispatchAssetEvent({
|
||||
type: AssetEventType.openProject,
|
||||
id: asset.id,
|
||||
@ -270,7 +278,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
!isRunning &&
|
||||
eventModule.isSingleClick(event) &&
|
||||
((selected && numberOfSelectedItems === 1) ||
|
||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||
) {
|
||||
setRowState(object.merger({ isEditingName: true }))
|
||||
}
|
||||
@ -296,7 +304,15 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
/>
|
||||
)}
|
||||
<EditableSpan
|
||||
data-testid="asset-row-name"
|
||||
editable={rowState.isEditingName}
|
||||
className={`bg-transparent grow leading-170 h-6 py-px ${
|
||||
rowState.isEditingName
|
||||
? 'cursor-text'
|
||||
: canExecute && !isOtherUserUsingProject
|
||||
? 'cursor-pointer'
|
||||
: ''
|
||||
}`}
|
||||
checkSubmittable={newTitle =>
|
||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||
child =>
|
||||
@ -318,13 +334,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
||||
inputTitle: validation.LOCAL_PROJECT_NAME_TITLE,
|
||||
}
|
||||
: {})}
|
||||
className={`bg-transparent grow leading-170 h-6 py-px ${
|
||||
rowState.isEditingName
|
||||
? 'cursor-text'
|
||||
: canExecute && !isOtherUserUsingProject
|
||||
? 'cursor-pointer'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{asset.title}
|
||||
</EditableSpan>
|
||||
|
@ -3,25 +3,29 @@ import * as React from 'react'
|
||||
|
||||
import ConnectorIcon from 'enso-assets/connector.svg'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as indent from '#/utilities/indent'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import EditableSpan from '#/components/EditableSpan'
|
||||
|
||||
// =====================
|
||||
// === ConnectorName ===
|
||||
// =====================
|
||||
@ -38,20 +42,13 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const asset = item.item
|
||||
if (asset.type !== backendModule.AssetType.secret) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
throw new Error('`SecretNameColumn` can only display secrets.')
|
||||
}
|
||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
||||
|
||||
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
|
||||
// context menu entry should be re-added.
|
||||
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
|
||||
const doRename = async () => {
|
||||
await Promise.resolve(null)
|
||||
}
|
||||
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||
|
||||
eventHooks.useEventHandler(assetEvents, async event => {
|
||||
switch (event.type) {
|
||||
@ -122,7 +119,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
onClick={event => {
|
||||
if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
(selected || shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
||||
(selected ||
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||
) {
|
||||
setRowState(object.merger({ isEditingName: true }))
|
||||
} else if (eventModule.isDoubleClick(event)) {
|
||||
@ -144,27 +142,10 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
||||
}}
|
||||
>
|
||||
<img src={ConnectorIcon} className="m-1" />
|
||||
<EditableSpan
|
||||
editable={false}
|
||||
onSubmit={async newTitle => {
|
||||
setRowState(object.merger({ isEditingName: false }))
|
||||
if (newTitle !== asset.title) {
|
||||
const oldTitle = asset.title
|
||||
setAsset(object.merger({ title: newTitle }))
|
||||
try {
|
||||
await doRename()
|
||||
} catch {
|
||||
setAsset(object.merger({ title: oldTitle }))
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setRowState(object.merger({ isEditingName: false }))
|
||||
}}
|
||||
className="bg-transparent grow leading-170 h-6 py-px"
|
||||
>
|
||||
{/* Secrets cannot be renamed. */}
|
||||
<span data-testid="asset-row-name" className="bg-transparent grow leading-170 h-6 py-px">
|
||||
{asset.title}
|
||||
</EditableSpan>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -2,12 +2,15 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
/** Props for a {@link UserPermissions}. */
|
||||
export interface UserPermissionsProps {
|
||||
asset: backendModule.Asset
|
||||
|
@ -1,7 +1,5 @@
|
||||
/** @file Column types and column display modes. */
|
||||
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
|
||||
import type * as backendModule from '#/services/backend'
|
||||
import type * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import DocsColumn from '#/components/dashboard/column/DocsColumn'
|
||||
@ -11,6 +9,10 @@ import NameColumn from '#/components/dashboard/column/NameColumn'
|
||||
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
|
||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
|
||||
// ===================
|
||||
// === AssetColumn ===
|
||||
// ===================
|
||||
@ -18,8 +20,8 @@ import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
/** Props for an arbitrary variant of {@link backendModule.Asset}. */
|
||||
export interface AssetColumnProps {
|
||||
keyProp: backendModule.AssetId
|
||||
item: assetTreeNode.AssetTreeNode
|
||||
setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AssetTreeNode>>
|
||||
item: AssetTreeNode
|
||||
setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||
selected: boolean
|
||||
setSelected: (selected: boolean) => void
|
||||
isSoleSelectedItem: boolean
|
||||
|
@ -4,17 +4,13 @@ import * as React from 'react'
|
||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import type * as backendModule from '#/services/backend'
|
||||
import * as assetQuery from '#/utilities/assetQuery'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcuts from '#/utilities/shortcuts'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
@ -23,6 +19,14 @@ import Label from '#/components/dashboard/Label'
|
||||
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as assetQuery from '#/utilities/AssetQuery'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcutManager from '#/utilities/ShortcutManager'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// ====================
|
||||
// === LabelsColumn ===
|
||||
// ====================
|
||||
@ -71,6 +75,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
.map(label => (
|
||||
<Label
|
||||
key={label}
|
||||
data-testid="asset-label"
|
||||
title="Right click to remove label."
|
||||
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||
active={!temporarilyRemovedLabels.has(label)}
|
||||
@ -104,7 +109,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
||||
setModal(
|
||||
<ContextMenus key={`label-${label}`} event={event}>
|
||||
<ContextMenu>
|
||||
<MenuEntry action={shortcuts.KeyboardAction.delete} doAction={doDelete} />
|
||||
<MenuEntry action={shortcutManager.KeyboardAction.delete} doAction={doDelete} />
|
||||
</ContextMenu>
|
||||
</ContextMenus>
|
||||
)
|
||||
|
@ -1,10 +1,10 @@
|
||||
/** @file A column displaying the time at which the asset was last modified. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
|
||||
/** A column displaying the time at which the asset was last modified. */
|
||||
export default function LastModifiedColumn(props: column.AssetColumnProps) {
|
||||
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
|
||||
|
@ -1,14 +1,14 @@
|
||||
/** @file The icon and name of an {@link backendModule.Asset}. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as backendModule from '#/services/backend'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import DirectoryNameColumn from '#/components/dashboard/DirectoryNameColumn'
|
||||
import FileNameColumn from '#/components/dashboard/FileNameColumn'
|
||||
import ProjectNameColumn from '#/components/dashboard/ProjectNameColumn'
|
||||
import SecretNameColumn from '#/components/dashboard/SecretNameColumn'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
// =================
|
||||
// === AssetName ===
|
||||
// =================
|
||||
|
@ -3,19 +3,23 @@ import * as React from 'react'
|
||||
|
||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import type * as backendModule from '#/services/backend'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// ========================
|
||||
// === SharedWithColumn ===
|
||||
// ========================
|
||||
|
@ -6,8 +6,9 @@ import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||
import SortDescendingIcon from 'enso-assets/sort_descending.svg'
|
||||
import TagIcon from 'enso-assets/tag.svg'
|
||||
|
||||
import * as backend from '#/services/backend'
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
import * as backend from '#/services/Backend'
|
||||
|
||||
import SortDirection from '#/utilities/SortDirection'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
@ -110,12 +111,14 @@ export function getColumnList(backendType: backend.BackendType, extraColumns: Se
|
||||
]
|
||||
}
|
||||
}
|
||||
} // =================
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
/** The corresponding icon URL for each {@link sorting.SortDirection}. */
|
||||
|
||||
export const SORT_ICON: Record<sorting.SortDirection, string> = {
|
||||
[sorting.SortDirection.ascending]: SortAscendingIcon,
|
||||
[sorting.SortDirection.descending]: SortDescendingIcon,
|
||||
/** The corresponding icon URL for each {@link SortDirection}. */
|
||||
export const SORT_ICON: Record<SortDirection, string> = {
|
||||
[SortDirection.ascending]: SortAscendingIcon,
|
||||
[SortDirection.descending]: SortDescendingIcon,
|
||||
}
|
||||
|
@ -4,12 +4,12 @@ import * as React from 'react'
|
||||
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||
import TimeIcon from 'enso-assets/time.svg'
|
||||
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import SortDirection, * as sortDirectionModule from '#/utilities/SortDirection'
|
||||
|
||||
/** A heading for the "Modified" column. */
|
||||
export default function ModifiedColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
||||
const { state } = props
|
||||
@ -17,7 +17,14 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const isSortActive = sortColumn === columnUtils.Column.modified && sortDirection != null
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
title={
|
||||
!isSortActive
|
||||
? 'Sort by modification date'
|
||||
: sortDirection === SortDirection.ascending
|
||||
? 'Sort by modification date descending'
|
||||
: 'Stop sorting by modification date'
|
||||
}
|
||||
className="flex items-center cursor-pointer gap-2"
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true)
|
||||
@ -28,10 +35,10 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (sortColumn === columnUtils.Column.modified) {
|
||||
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
||||
setSortDirection(sortDirectionModule.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
||||
} else {
|
||||
setSortColumn(columnUtils.Column.modified)
|
||||
setSortDirection(sorting.SortDirection.ascending)
|
||||
setSortDirection(SortDirection.ascending)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -40,9 +47,14 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
||||
{columnUtils.COLUMN_NAME[columnUtils.Column.modified]}
|
||||
</span>
|
||||
<img
|
||||
alt={
|
||||
!isSortActive || sortDirection === SortDirection.ascending
|
||||
? 'Sort Ascending'
|
||||
: 'Sort Descending'
|
||||
}
|
||||
src={isSortActive ? columnUtils.SORT_ICON[sortDirection] : SortAscendingIcon}
|
||||
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
|
||||
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'invisible'}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
@ -3,11 +3,11 @@ import * as React from 'react'
|
||||
|
||||
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
|
||||
import type * as column from '#/components/dashboard/column'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
|
||||
import SortDirection, * as sortDirectionModule from '#/utilities/SortDirection'
|
||||
|
||||
/** A heading for the "Name" column. */
|
||||
export default function NameColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
||||
const { state } = props
|
||||
@ -15,8 +15,15 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const isSortActive = sortColumn === columnUtils.Column.name && sortDirection != null
|
||||
return (
|
||||
<div
|
||||
className="flex items-center cursor-pointer gap-2 pt-1 pb-1.5"
|
||||
<button
|
||||
title={
|
||||
!isSortActive
|
||||
? 'Sort by name'
|
||||
: sortDirection === SortDirection.ascending
|
||||
? 'Sort by name descending'
|
||||
: 'Stop sorting by name'
|
||||
}
|
||||
className="flex items-center gap-2 pt-1 pb-1.5"
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true)
|
||||
}}
|
||||
@ -26,10 +33,10 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (sortColumn === columnUtils.Column.name) {
|
||||
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
||||
setSortDirection(sortDirectionModule.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
||||
} else {
|
||||
setSortColumn(columnUtils.Column.name)
|
||||
setSortDirection(sorting.SortDirection.ascending)
|
||||
setSortDirection(SortDirection.ascending)
|
||||
}
|
||||
}}
|
||||
>
|
||||
@ -37,9 +44,14 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
||||
{columnUtils.COLUMN_NAME[columnUtils.Column.name]}
|
||||
</span>
|
||||
<img
|
||||
alt={
|
||||
!isSortActive || sortDirection === SortDirection.ascending
|
||||
? 'Sort Ascending'
|
||||
: 'Sort Descending'
|
||||
}
|
||||
src={isSortActive ? columnUtils.SORT_ICON[sortDirection] : SortAscendingIcon}
|
||||
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
|
||||
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'invisible'}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
@ -8,11 +8,12 @@ import ShiftKeyIcon from 'enso-assets/shift_key.svg'
|
||||
import WindowsKeyIcon from 'enso-assets/windows_key.svg'
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
|
||||
// ========================
|
||||
// === KeyboardShortcut ===
|
||||
// ========================
|
||||
@ -25,7 +26,7 @@ const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX }
|
||||
/** Icons for modifier keys (if they exist). */
|
||||
const MODIFIER_MAPPINGS: Record<
|
||||
detect.Platform,
|
||||
Partial<Record<shortcutsModule.ModifierKey, React.ReactNode>>
|
||||
Partial<Record<shortcutManagerModule.ModifierKey, React.ReactNode>>
|
||||
> = {
|
||||
// The names are intentionally not in `camelCase`, as they are case-sensitive.
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
@ -59,20 +60,20 @@ const MODIFIER_MAPPINGS: Record<
|
||||
|
||||
/** Props for a {@link KeyboardShortcut} */
|
||||
export interface KeyboardShortcutProps {
|
||||
action: shortcutsModule.KeyboardAction
|
||||
action: shortcutManagerModule.KeyboardAction
|
||||
}
|
||||
|
||||
/** A visual representation of a keyboard shortcut. */
|
||||
export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||
const { action } = props
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const shortcut = shortcuts.keyboardShortcuts[action][0]
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const shortcut = shortcutManager.keyboardShortcuts[action][0]
|
||||
if (shortcut == null) {
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
<div className={`flex items-center h-6 ${detect.isOnMacOS() ? 'gap-0.5' : 'gap-0.75'}`}>
|
||||
{shortcutsModule.getModifierKeysOfShortcut(shortcut).map(
|
||||
{shortcutManagerModule.getModifierKeysOfShortcut(shortcut).map(
|
||||
modifier =>
|
||||
MODIFIER_MAPPINGS[detect.platform()][modifier] ?? (
|
||||
<span key={modifier} className="leading-170 h-6 py-px">
|
||||
|
@ -1,41 +1,8 @@
|
||||
/** @file Entry point into the cloud dashboard. */
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import '#/tailwind.css'
|
||||
|
||||
import * as authentication from '#/index'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
/** Path to the SSE endpoint over which esbuild sends events. */
|
||||
const ESBUILD_PATH = './esbuild'
|
||||
/** SSE event indicating a build has finished. */
|
||||
const ESBUILD_EVENT_NAME = 'change'
|
||||
/** Path to the service worker that resolves all extensionless paths to `/index.html`.
|
||||
* This service worker is required for client-side routing to work when doing local development. */
|
||||
const SERVICE_WORKER_PATH = './serviceWorker.js'
|
||||
|
||||
// ===================
|
||||
// === Live reload ===
|
||||
// ===================
|
||||
|
||||
if (detect.IS_DEV_MODE && (!(typeof IS_VITE !== 'undefined') || !IS_VITE)) {
|
||||
new EventSource(ESBUILD_PATH).addEventListener(ESBUILD_EVENT_NAME, () => {
|
||||
// This acts like `location.reload`, but it preserves the query-string.
|
||||
// The `toString()` is to bypass a lint without using a comment.
|
||||
location.href = location.href.toString()
|
||||
})
|
||||
void navigator.serviceWorker.register(SERVICE_WORKER_PATH)
|
||||
} else {
|
||||
await navigator.serviceWorker
|
||||
// `navigator.serviceWorker` may be disabled in certainsituations, for example in Private mode
|
||||
// on Safari.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
?.getRegistration()
|
||||
.then(serviceWorker => serviceWorker?.unregister())
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === Entry point ===
|
||||
// ===================
|
||||
|
@ -1,9 +1,10 @@
|
||||
/** @file Events related to changes in asset state. */
|
||||
import type AssetEventType from '#/events/AssetEventType'
|
||||
import type * as backendModule from '#/services/backend'
|
||||
|
||||
import type * as spinner from '#/components/Spinner'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '#/hooks/eventHooks' {
|
||||
|
@ -1,9 +1,10 @@
|
||||
/** @file Events related to changes in the asset list. */
|
||||
import type AssetListEventType from '#/events/AssetListEventType'
|
||||
import type * as backend from '#/services/backend'
|
||||
|
||||
import type * as spinner from '#/components/Spinner'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '#/hooks/eventHooks' {
|
||||
|
@ -5,6 +5,7 @@ import * as React from 'react'
|
||||
import * as router from 'react-router'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
|
||||
// ===================
|
||||
|
36
app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts
Normal file
36
app/ide-desktop/lib/dashboard/src/hooks/setAssetHooks.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/** @file A hook that turns a `set` function for an {@link AssetTreeNode} to a `set` function
|
||||
* on its item, for a specific type of item. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
|
||||
// ===================
|
||||
// === useSetAsset ===
|
||||
// ===================
|
||||
|
||||
/** Converts a React set state action for an {@link AssetTreeNode} to a set state action for any
|
||||
* subset of {@link backend.AnyAsset}. This is unsafe when `T` does not match the type of the
|
||||
* item contained in the `AssetTreeNode`, so this MUST be guarded by checking that the item is of
|
||||
* the correct type. A value of type `T` must be provided as the first parameter to ensure that this
|
||||
* has been done. */
|
||||
export function useSetAsset<T extends backend.AnyAsset>(
|
||||
_value: T,
|
||||
setNode: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||
) {
|
||||
return React.useCallback(
|
||||
(valueOrUpdater: React.SetStateAction<T>) => {
|
||||
setNode(oldNode => {
|
||||
const item =
|
||||
typeof valueOrUpdater === 'function'
|
||||
? // This is SAFE, because it is a mistake for an item to change type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
valueOrUpdater(oldNode.item as T)
|
||||
: valueOrUpdater
|
||||
return oldNode.with({ item })
|
||||
})
|
||||
},
|
||||
[/* should never change */ setNode]
|
||||
)
|
||||
}
|
@ -4,6 +4,7 @@ import * as React from 'react'
|
||||
import * as toastify from 'react-toastify'
|
||||
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
|
||||
import * as errorModule from '#/utilities/error'
|
||||
|
||||
// ======================
|
||||
|
@ -11,6 +11,7 @@ import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import type * as app from '#/App'
|
||||
import App from '#/App'
|
||||
|
||||
import * as config from '#/utilities/config'
|
||||
|
||||
// =================
|
||||
|
@ -3,24 +3,21 @@ import * as React from 'react'
|
||||
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu'
|
||||
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
|
||||
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as remoteBackendModule from '#/services/remoteBackend'
|
||||
import * as http from '#/utilities/http'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcuts from '#/utilities/shortcuts'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
@ -29,6 +26,14 @@ import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||
import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal'
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import HttpClient from '#/utilities/HttpClient'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcutManager from '#/utilities/ShortcutManager'
|
||||
|
||||
// ========================
|
||||
// === AssetContextMenu ===
|
||||
// ========================
|
||||
@ -98,7 +103,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
<ContextMenu hidden={hidden}>
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.restoreFromTrash}
|
||||
action={shortcutManager.KeyboardAction.restoreFromTrash}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
@ -119,7 +124,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
!isOtherUserUsingProject && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.open}
|
||||
action={shortcutManager.KeyboardAction.open}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
@ -134,7 +139,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
{asset.type === backendModule.AssetType.project && isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.run}
|
||||
action={shortcutManager.KeyboardAction.run}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
@ -152,7 +157,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
!isOtherUserUsingProject && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.close}
|
||||
action={shortcutManager.KeyboardAction.close}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
@ -165,15 +170,15 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
{asset.type === backendModule.AssetType.project && !isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.uploadToCloud}
|
||||
action={shortcutManager.KeyboardAction.uploadToCloud}
|
||||
doAction={async () => {
|
||||
unsetModal()
|
||||
if (accessToken == null) {
|
||||
toastAndLog('Cannot upload to cloud in offline mode')
|
||||
} else {
|
||||
try {
|
||||
const client = new http.Client([['Authorization', `Bearer ${accessToken}`]])
|
||||
const remoteBackend = new remoteBackendModule.RemoteBackend(client, logger)
|
||||
const client = new HttpClient([['Authorization', `Bearer ${accessToken}`]])
|
||||
const remoteBackend = new RemoteBackend(client, logger)
|
||||
const projectResponse = await fetch(
|
||||
`./api/project-manager/projects/${asset.id}/enso-project`
|
||||
)
|
||||
@ -205,7 +210,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
asset.type !== backendModule.AssetType.project &&
|
||||
asset.type !== backendModule.AssetType.directory
|
||||
}
|
||||
action={shortcuts.KeyboardAction.rename}
|
||||
action={shortcutManager.KeyboardAction.rename}
|
||||
doAction={() => {
|
||||
setRowState(object.merger({ isEditingName: true }))
|
||||
unsetModal()
|
||||
@ -215,7 +220,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
{asset.type === backendModule.AssetType.secret && canEditThisAsset && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.edit}
|
||||
action={shortcutManager.KeyboardAction.edit}
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<UpsertSecretModal
|
||||
@ -237,7 +242,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled
|
||||
action={shortcuts.KeyboardAction.snapshot}
|
||||
action={shortcutManager.KeyboardAction.snapshot}
|
||||
doAction={() => {
|
||||
// No backend support yet.
|
||||
}}
|
||||
@ -248,8 +253,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
hidden={hidden}
|
||||
action={
|
||||
backend.type === backendModule.BackendType.local
|
||||
? shortcuts.KeyboardAction.delete
|
||||
: shortcuts.KeyboardAction.moveToTrash
|
||||
? shortcutManager.KeyboardAction.delete
|
||||
: shortcutManager.KeyboardAction.moveToTrash
|
||||
}
|
||||
doAction={() => {
|
||||
if (backend.type === backendModule.BackendType.remote) {
|
||||
@ -270,7 +275,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
{isCloud && managesThisAsset && self != null && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.share}
|
||||
action={shortcutManager.KeyboardAction.share}
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
@ -292,7 +297,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
{isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.label}
|
||||
action={shortcutManager.KeyboardAction.label}
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<ManageLabelsModal
|
||||
@ -310,7 +315,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled={!isCloud}
|
||||
action={shortcuts.KeyboardAction.duplicate}
|
||||
action={shortcutManager.KeyboardAction.duplicate}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetListEvent({
|
||||
@ -322,15 +327,19 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
}}
|
||||
/>
|
||||
{isCloud && (
|
||||
<MenuEntry hidden={hidden} action={shortcuts.KeyboardAction.copy} doAction={doCopy} />
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcutManager.KeyboardAction.copy}
|
||||
doAction={doCopy}
|
||||
/>
|
||||
)}
|
||||
{isCloud && !isOtherUserUsingProject && (
|
||||
<MenuEntry hidden={hidden} action={shortcuts.KeyboardAction.cut} doAction={doCut} />
|
||||
<MenuEntry hidden={hidden} action={shortcutManager.KeyboardAction.cut} doAction={doCut} />
|
||||
)}
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
disabled={isCloud && asset.type !== backendModule.AssetType.file}
|
||||
action={shortcuts.KeyboardAction.download}
|
||||
action={shortcutManager.KeyboardAction.download}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetEvent({
|
||||
@ -342,7 +351,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
||||
{hasPasteData && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.paste}
|
||||
action={shortcutManager.KeyboardAction.paste}
|
||||
doAction={() => {
|
||||
const [directoryKey, directoryId] =
|
||||
item.item.type === backendModule.AssetType.directory
|
||||
|
@ -3,24 +3,46 @@ import * as React from 'react'
|
||||
|
||||
import FindIcon from 'enso-assets/find.svg'
|
||||
|
||||
import type * as backend from '#/services/backend'
|
||||
import * as array from '#/utilities/array'
|
||||
import * as assetQuery from '#/utilities/assetQuery'
|
||||
import * as shortcuts from '#/utilities/shortcuts'
|
||||
|
||||
import Label from '#/components/dashboard/Label'
|
||||
|
||||
/** A suggested query based on */
|
||||
import type * as backend from '#/services/Backend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import AssetQuery, * as assetQuery from '#/utilities/AssetQuery'
|
||||
import * as shortcutManager from '#/utilities/ShortcutManager'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** The reason behind a new query. */
|
||||
enum QuerySource {
|
||||
/** A query change initiated by tabbing. While *technically* internal, it is semantically
|
||||
* different in that tabbing does not update the base query. */
|
||||
tabbing = 'tabbing',
|
||||
/** A query change initiated from code in this component. */
|
||||
internal = 'internal',
|
||||
/** A query change initiated by typing in the search bar. */
|
||||
typing = 'typing',
|
||||
/** A query change initiated from code in another component. */
|
||||
external = 'external',
|
||||
}
|
||||
|
||||
/** A suggested query. */
|
||||
export interface Suggestion {
|
||||
render: () => React.ReactNode
|
||||
addToQuery: (query: assetQuery.AssetQuery) => assetQuery.AssetQuery
|
||||
deleteFromQuery: (query: assetQuery.AssetQuery) => assetQuery.AssetQuery
|
||||
addToQuery: (query: AssetQuery) => AssetQuery
|
||||
deleteFromQuery: (query: AssetQuery) => AssetQuery
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === AssetSearchBar ===
|
||||
// ======================
|
||||
|
||||
/** Props for a {@link AssetSearchBar}. */
|
||||
export interface AssetSearchBarProps {
|
||||
query: assetQuery.AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||
query: AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
labels: backend.Label[]
|
||||
suggestions: Suggestion[]
|
||||
}
|
||||
@ -28,7 +50,6 @@ export interface AssetSearchBarProps {
|
||||
/** A search bar containing a text input, and a list of suggestions. */
|
||||
export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const { query, setQuery, labels, suggestions: rawSuggestions } = props
|
||||
const [isTabbing, setIsTabbing] = React.useState(false)
|
||||
/** A cached query as of the start of tabbing. */
|
||||
const baseQuery = React.useRef(query)
|
||||
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
|
||||
@ -39,50 +60,50 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
||||
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
|
||||
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
|
||||
const [wasQueryModified, setWasQueryModified] = React.useState(false)
|
||||
const [wasQueryTyped, setWasQueryTyped] = React.useState(false)
|
||||
const querySource = React.useRef(QuerySource.external)
|
||||
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
|
||||
const rootRef = React.useRef<HTMLLabelElement>(null)
|
||||
const searchRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTabbing && !isShiftPressed) {
|
||||
baseQuery.current = query
|
||||
}
|
||||
}, [isTabbing, isShiftPressed, query])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasQueryTyped) {
|
||||
baseQuery.current = query
|
||||
if (searchRef.current != null) {
|
||||
searchRef.current.value = query.toString()
|
||||
}
|
||||
}
|
||||
}, [wasQueryTyped, query])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isTabbing && !isShiftPressed) {
|
||||
setSuggestions(rawSuggestions)
|
||||
suggestionsRef.current = rawSuggestions
|
||||
}
|
||||
}, [isTabbing, isShiftPressed, rawSuggestions])
|
||||
|
||||
React.useEffect(() => {
|
||||
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
||||
}, [areSuggestionsVisible])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!wasQueryModified && selectedIndex == null) {
|
||||
setQuery(baseQuery.current)
|
||||
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
|
||||
baseQuery.current = query
|
||||
}
|
||||
// `wasQueryModified` MUST NOT be a dependency, as it is always set to `false` immediately
|
||||
// after it is set to true.
|
||||
// This effect MUST only run when `query` changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIndex, /* should never change */ setQuery])
|
||||
}, [query])
|
||||
|
||||
React.useEffect(() => {
|
||||
let newQuery = query
|
||||
if (wasQueryModified) {
|
||||
if (querySource.current !== QuerySource.tabbing) {
|
||||
setSelectedIndex(null)
|
||||
}
|
||||
if (
|
||||
querySource.current !== QuerySource.internal &&
|
||||
querySource.current !== QuerySource.tabbing
|
||||
) {
|
||||
if (searchRef.current != null) {
|
||||
searchRef.current.value = query.toString()
|
||||
}
|
||||
}
|
||||
}, [query])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
|
||||
setSuggestions(rawSuggestions)
|
||||
suggestionsRef.current = rawSuggestions
|
||||
}
|
||||
}, [isShiftPressed, rawSuggestions])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
querySource.current === QuerySource.internal ||
|
||||
querySource.current === QuerySource.tabbing
|
||||
) {
|
||||
let newQuery = query
|
||||
const suggestion = selectedIndex == null ? null : suggestions[selectedIndex]
|
||||
if (suggestion != null) {
|
||||
newQuery = suggestion.addToQuery(baseQuery.current)
|
||||
@ -95,15 +116,9 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
searchRef.current.value = newQuery.toString()
|
||||
}
|
||||
}
|
||||
setWasQueryModified(false)
|
||||
}, [
|
||||
wasQueryModified,
|
||||
query,
|
||||
baseQuery,
|
||||
selectedIndex,
|
||||
suggestions,
|
||||
/* should never change */ setQuery,
|
||||
])
|
||||
// This effect MUST only run when `selectedIndex` changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedIndex])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@ -111,17 +126,27 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
if (areSuggestionsVisibleRef.current) {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
setIsTabbing(true)
|
||||
querySource.current = QuerySource.tabbing
|
||||
setSelectedIndex(oldIndex => {
|
||||
const length = Math.max(1, suggestionsRef.current.length)
|
||||
if (event.shiftKey) {
|
||||
return oldIndex == null
|
||||
? suggestionsRef.current.length - 1
|
||||
: (oldIndex + suggestionsRef.current.length - 1) % suggestionsRef.current.length
|
||||
return oldIndex == null ? length - 1 : (oldIndex + length - 1) % length
|
||||
} else {
|
||||
return oldIndex == null ? 0 : (oldIndex + 1) % suggestionsRef.current.length
|
||||
return oldIndex == null ? 0 : (oldIndex + 1) % length
|
||||
}
|
||||
})
|
||||
setWasQueryModified(true)
|
||||
// FIXME: `setQuery`?
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
querySource.current = QuerySource.external
|
||||
if (searchRef.current != null) {
|
||||
searchRef.current.focus()
|
||||
const end = searchRef.current.value.length
|
||||
searchRef.current.setSelectionRange(end, end)
|
||||
}
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
@ -132,14 +157,14 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
!(event.target instanceof HTMLInputElement) &&
|
||||
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
|
||||
(!(event.target instanceof Node) || rootRef.current?.contains(event.target) !== true) &&
|
||||
shortcuts.isTextInputEvent(event)
|
||||
shortcutManager.isTextInputEvent(event)
|
||||
) {
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
if (
|
||||
event.target instanceof Node &&
|
||||
rootRef.current?.contains(event.target) === true &&
|
||||
shortcuts.isPotentiallyShortcut(event)
|
||||
shortcutManager.isPotentiallyShortcut(event)
|
||||
) {
|
||||
searchRef.current?.focus()
|
||||
}
|
||||
@ -155,39 +180,30 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset `querySource` after all other effects have run.
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (areSuggestionsVisibleRef.current) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
baseQuery.current = query
|
||||
setIsTabbing(false)
|
||||
setSelectedIndex(null)
|
||||
searchRef.current?.focus()
|
||||
const end = searchRef.current?.value.length ?? 0
|
||||
searchRef.current?.setSelectionRange(end, end)
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}
|
||||
if (querySource.current !== QuerySource.typing && searchRef.current != null) {
|
||||
searchRef.current.value = query.toString()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
if (querySource.current !== QuerySource.tabbing) {
|
||||
baseQuery.current = query
|
||||
querySource.current = QuerySource.external
|
||||
}
|
||||
}, [query, setQuery])
|
||||
}, [query, /* should never change */ setQuery])
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={rootRef}
|
||||
data-testid="asset-search-bar"
|
||||
tabIndex={-1}
|
||||
onFocus={() => {
|
||||
setAreSuggestionsVisible(true)
|
||||
}}
|
||||
onBlur={event => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setIsTabbing(false)
|
||||
setSelectedIndex(null)
|
||||
if (querySource.current === QuerySource.tabbing) {
|
||||
querySource.current = QuerySource.external
|
||||
}
|
||||
setAreSuggestionsVisible(false)
|
||||
}
|
||||
}}
|
||||
@ -200,15 +216,10 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
size={1}
|
||||
placeholder="Type to search for projects, data connectors, users, and more."
|
||||
className="peer relative z-1 grow bg-transparent leading-5 h-6 py-px xl:placeholder:text-center"
|
||||
onFocus={() => {
|
||||
if (!wasQueryModified) {
|
||||
setSelectedIndex(null)
|
||||
}
|
||||
}}
|
||||
onChange={event => {
|
||||
if (!wasQueryModified) {
|
||||
setQuery(assetQuery.AssetQuery.fromString(event.target.value))
|
||||
setWasQueryTyped(true)
|
||||
if (querySource.current !== QuerySource.internal) {
|
||||
querySource.current = QuerySource.typing
|
||||
setQuery(AssetQuery.fromString(event.target.value))
|
||||
}
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
@ -219,6 +230,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey
|
||||
) {
|
||||
// Clone the query to refresh results.
|
||||
setQuery(query.clone())
|
||||
}
|
||||
}}
|
||||
@ -228,8 +240,11 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
{areSuggestionsVisible && (
|
||||
<div className="relative flex flex-col gap-2">
|
||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||
<div className="flex flex-wrap gap-2 whitespace-nowrap px-2 pointer-events-auto">
|
||||
{assetQuery.AssetQuery.tagNames.flatMap(entry => {
|
||||
<div
|
||||
data-testid="asset-search-tag-names"
|
||||
className="flex flex-wrap gap-2 whitespace-nowrap px-2 pointer-events-auto"
|
||||
>
|
||||
{AssetQuery.tagNames.flatMap(entry => {
|
||||
const [key, tag] = entry
|
||||
return tag == null || isShiftPressed !== tag.startsWith('-')
|
||||
? []
|
||||
@ -238,13 +253,8 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
key={key}
|
||||
className="bg-frame rounded-full h-6 px-2 hover:bg-frame-selected transition-all"
|
||||
onClick={() => {
|
||||
setWasQueryModified(true)
|
||||
setSelectedIndex(null)
|
||||
setQuery(oldQuery => {
|
||||
const newQuery = oldQuery.add({ [key]: [[]] })
|
||||
baseQuery.current = newQuery
|
||||
return newQuery
|
||||
})
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(query.add({ [key]: [[]] }))
|
||||
}}
|
||||
>
|
||||
{tag}:
|
||||
@ -253,7 +263,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
})}
|
||||
</div>
|
||||
{/* Asset labels */}
|
||||
<div className="flex gap-2 p-2 pointer-events-auto">
|
||||
<div data-testid="asset-search-labels" className="flex gap-2 p-2 pointer-events-auto">
|
||||
{labels.map(label => {
|
||||
const negated = query.negativeLabels.some(term =>
|
||||
array.shallowEqual(term, [label.value])
|
||||
@ -268,8 +278,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
}
|
||||
negated={negated}
|
||||
onClick={event => {
|
||||
setWasQueryModified(true)
|
||||
setSelectedIndex(null)
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(oldQuery => {
|
||||
const newQuery = assetQuery.toggleLabel(
|
||||
oldQuery,
|
||||
@ -289,7 +298,10 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
{/* Suggestions */}
|
||||
<div className="flex flex-col max-h-[16rem] overflow-y-auto">
|
||||
{suggestions.map((suggestion, index) => (
|
||||
// This should not be a `<button>`, since `render()` may output a
|
||||
// tree containing a button.
|
||||
<div
|
||||
data-testid="asset-search-suggestion"
|
||||
key={index}
|
||||
ref={el => {
|
||||
if (index === selectedIndex) {
|
||||
@ -305,8 +317,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||
: ''
|
||||
}`}
|
||||
onClick={event => {
|
||||
setSelectedIndex(null)
|
||||
setWasQueryModified(true)
|
||||
querySource.current = QuerySource.internal
|
||||
setQuery(
|
||||
selectedIndices.has(index)
|
||||
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||
|
@ -3,30 +3,35 @@ import * as React from 'react'
|
||||
|
||||
import PenIcon from 'enso-assets/pen.svg'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
|
||||
import type Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import type * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
import UserBar from '#/layouts/dashboard/UserBar'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import type * as backendModule from '#/services/backend'
|
||||
import type * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
import Button from '#/components/Button'
|
||||
import AssetInfoBar from '#/components/dashboard/AssetInfoBar'
|
||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||
|
||||
import type * as backendModule from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
|
||||
// ==========================
|
||||
// === AssetSettingsPanel ===
|
||||
// ==========================
|
||||
|
||||
/** The subset of {@link AssetSettingsPanelProps} that are required to be supplied by the row. */
|
||||
export interface AssetSettingsPanelRequiredProps {
|
||||
item: assetTreeNode.AssetTreeNode
|
||||
setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AssetTreeNode>>
|
||||
item: AssetTreeNode
|
||||
setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||
}
|
||||
|
||||
/** Props for a {@link AssetSettingsPanel}. */
|
||||
@ -58,7 +63,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const setItem = React.useCallback(
|
||||
(valueOrUpdater: React.SetStateAction<assetTreeNode.AssetTreeNode>) => {
|
||||
(valueOrUpdater: React.SetStateAction<AssetTreeNode>) => {
|
||||
innerSetItem(valueOrUpdater)
|
||||
rawSetItem(valueOrUpdater)
|
||||
},
|
||||
@ -97,6 +102,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="asset-panel"
|
||||
className="absolute flex flex-col h-full border-black/[0.12] border-l-2 gap-8 w-120 pl-3 pr-4 py-2.25"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
@ -137,7 +143,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="py-1 self-stretch">
|
||||
<div data-testid="asset-panel-description" className="py-1 self-stretch">
|
||||
{!isEditingDescription ? (
|
||||
<span className="leading-170 py-px">{item.item.description}</span>
|
||||
) : (
|
||||
@ -182,7 +188,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
||||
<span className="text-lg leading-144.5 h-7 py-px">Settings</span>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<tr data-testid="asset-panel-permissions">
|
||||
<td className="min-w-32 px-0 py-1">
|
||||
<span className="inline-block leading-170 h-6 py-px">Shared with</span>
|
||||
</td>
|
||||
|
@ -3,45 +3,31 @@ import * as React from 'react'
|
||||
|
||||
import * as toast from 'react-toastify'
|
||||
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as asyncEffectHooks from '#/hooks/asyncEffectHooks'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||
import AssetsTableContextMenu from '#/layouts/dashboard/AssetsTableContextMenu'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import DuplicateAssetsModal from '#/layouts/dashboard/DuplicateAssetsModal'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as array from '#/utilities/array'
|
||||
import * as assetQuery from '#/utilities/assetQuery'
|
||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as drag from '#/utilities/drag'
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as localStorageModule from '#/utilities/localStorage'
|
||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
||||
import PasteType from '#/utilities/PasteType'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as sorting from '#/utilities/sorting'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
import Button from '#/components/Button'
|
||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||
import AssetRow from '#/components/dashboard/AssetRow'
|
||||
import * as columnModule from '#/components/dashboard/column'
|
||||
import * as assetRowUtils from '#/components/dashboard/AssetRow/assetRowUtils'
|
||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||
import NameColumn from '#/components/dashboard/column/NameColumn'
|
||||
import * as columnHeading from '#/components/dashboard/columnHeading'
|
||||
@ -49,6 +35,26 @@ import Label from '#/components/dashboard/Label'
|
||||
import DragModal from '#/components/DragModal'
|
||||
import Spinner, * as spinner from '#/components/Spinner'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import type * as assetQuery from '#/utilities/AssetQuery'
|
||||
import AssetQuery from '#/utilities/AssetQuery'
|
||||
import AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as drag from '#/utilities/drag'
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as localStorageModule from '#/utilities/LocalStorage'
|
||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
||||
import PasteType from '#/utilities/PasteType'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as set from '#/utilities/set'
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
import SortDirection from '#/utilities/SortDirection'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
import Visibility from '#/utilities/visibility'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -57,9 +63,6 @@ import Spinner, * as spinner from '#/components/Spinner'
|
||||
const LOADING_SPINNER_SIZE = 36
|
||||
/** The number of pixels the header bar should shrink when the extra column selector is visible. */
|
||||
const TABLE_HEADER_WIDTH_SHRINKAGE_PX = 116
|
||||
/** A value that represents that the first argument is less than the second argument, in a
|
||||
* sorting function. */
|
||||
const COMPARE_LESS_THAN = -1
|
||||
/** The default placeholder row. */
|
||||
const PLACEHOLDER = (
|
||||
<span className="opacity-75">
|
||||
@ -148,18 +151,18 @@ const SUGGESTIONS_FOR_NEGATIVE_TYPE: assetSearchBar.Suggestion[] = [
|
||||
/** Return a directory, with new children added into its list of children.
|
||||
* All children MUST have the same asset type. */
|
||||
function insertAssetTreeNodeChildren(
|
||||
item: assetTreeNode.AssetTreeNode,
|
||||
item: AssetTreeNode,
|
||||
children: backendModule.AnyAsset[],
|
||||
directoryKey: backendModule.AssetId,
|
||||
directoryId: backendModule.DirectoryId
|
||||
): assetTreeNode.AssetTreeNode {
|
||||
): AssetTreeNode {
|
||||
const depth = item.depth + 1
|
||||
const typeOrder = children[0] != null ? backendModule.ASSET_TYPE_ORDER[children[0].type] : 0
|
||||
const nodes = (item.children ?? []).filter(
|
||||
node => node.item.type !== backendModule.AssetType.specialEmpty
|
||||
)
|
||||
const nodesToInsert = children.map(asset =>
|
||||
assetTreeNode.AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth)
|
||||
AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth)
|
||||
)
|
||||
const newNodes = array.splicedBefore(
|
||||
nodes,
|
||||
@ -172,12 +175,12 @@ function insertAssetTreeNodeChildren(
|
||||
/** Return a directory, with new children added into its list of children.
|
||||
* The children MAY be of different asset types. */
|
||||
function insertArbitraryAssetTreeNodeChildren(
|
||||
item: assetTreeNode.AssetTreeNode,
|
||||
item: AssetTreeNode,
|
||||
children: backendModule.AnyAsset[],
|
||||
directoryKey: backendModule.AssetId,
|
||||
directoryId: backendModule.DirectoryId,
|
||||
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
|
||||
): assetTreeNode.AssetTreeNode {
|
||||
): AssetTreeNode {
|
||||
const depth = item.depth + 1
|
||||
const nodes = (item.children ?? []).filter(
|
||||
node => node.item.type !== backendModule.AssetType.specialEmpty
|
||||
@ -199,7 +202,7 @@ function insertArbitraryAssetTreeNodeChildren(
|
||||
if (firstChild) {
|
||||
const typeOrder = backendModule.ASSET_TYPE_ORDER[firstChild.type]
|
||||
const nodesToInsert = childrenOfSpecificType.map(asset =>
|
||||
assetTreeNode.AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth, getKey)
|
||||
AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth, getKey)
|
||||
)
|
||||
newNodes = array.splicedBefore(
|
||||
newNodes,
|
||||
@ -217,9 +220,7 @@ function insertArbitraryAssetTreeNodeChildren(
|
||||
|
||||
const CATEGORY_TO_FILTER_BY: Record<Category, backendModule.FilterBy | null> = {
|
||||
[Category.recent]: null,
|
||||
[Category.drafts]: null,
|
||||
[Category.home]: backendModule.FilterBy.active,
|
||||
[Category.root]: null,
|
||||
[Category.trash]: backendModule.FilterBy.trashed,
|
||||
}
|
||||
|
||||
@ -238,19 +239,17 @@ export interface AssetsTableState {
|
||||
setPasteData: (pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>>) => void
|
||||
sortColumn: columnUtils.SortableColumn | null
|
||||
setSortColumn: (column: columnUtils.SortableColumn | null) => void
|
||||
sortDirection: sorting.SortDirection | null
|
||||
setSortDirection: (sortDirection: sorting.SortDirection | null) => void
|
||||
query: assetQuery.AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||
sortDirection: SortDirection | null
|
||||
setSortDirection: (sortDirection: SortDirection | null) => void
|
||||
query: AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
assetEvents: assetEvent.AssetEvent[]
|
||||
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
setAssetSettingsPanelProps: React.Dispatch<
|
||||
React.SetStateAction<assetSettingsPanel.AssetSettingsPanelRequiredProps | null>
|
||||
>
|
||||
nodeMap: Readonly<
|
||||
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>>
|
||||
>
|
||||
nodeMap: Readonly<React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>>
|
||||
doToggleDirectoryExpansion: (
|
||||
directoryId: backendModule.DirectoryId,
|
||||
key: backendModule.AssetId,
|
||||
@ -279,20 +278,10 @@ export interface AssetRowState {
|
||||
temporarilyRemovedLabels: ReadonlySet<backendModule.LabelName>
|
||||
}
|
||||
|
||||
/** The default {@link AssetRowState} associated with a {@link AssetRow}. */
|
||||
const INITIAL_ROW_STATE = Object.freeze<AssetRowState>({
|
||||
setVisibility: () => {
|
||||
// Ignored. This MUST be replaced by the row component. It should also update `visibility`.
|
||||
},
|
||||
isEditingName: false,
|
||||
temporarilyAddedLabels: set.EMPTY,
|
||||
temporarilyRemovedLabels: set.EMPTY,
|
||||
})
|
||||
|
||||
/** Props for a {@link AssetsTable}. */
|
||||
export interface AssetsTableProps {
|
||||
query: assetQuery.AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||
query: AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
setCanDownloadFiles: (canDownloadFiles: boolean) => void
|
||||
category: Category
|
||||
allLabels: Map<backendModule.LabelName, backendModule.Label>
|
||||
@ -331,13 +320,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [extraColumns, setExtraColumns] = React.useState(() => new Set<columnUtils.ExtraColumn>())
|
||||
const [sortColumn, setSortColumn] = React.useState<columnUtils.SortableColumn | null>(null)
|
||||
const [sortDirection, setSortDirection] = React.useState<sorting.SortDirection | null>(null)
|
||||
const [sortDirection, setSortDirection] = React.useState<SortDirection | null>(null)
|
||||
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<backendModule.AssetId>())
|
||||
const [pasteData, setPasteData] = React.useState<pasteDataModule.PasteData<
|
||||
Set<backendModule.AssetId>
|
||||
@ -348,9 +337,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
() => organization?.rootDirectoryId ?? backendModule.DirectoryId(''),
|
||||
[organization]
|
||||
)
|
||||
const [assetTree, setAssetTree] = React.useState<assetTreeNode.AssetTreeNode>(() => {
|
||||
const [assetTree, setAssetTree] = React.useState<AssetTreeNode>(() => {
|
||||
const rootParentDirectoryId = backendModule.DirectoryId('')
|
||||
return assetTreeNode.AssetTreeNode.fromAsset(
|
||||
return AssetTreeNode.fromAsset(
|
||||
backendModule.createRootDirectoryAsset(rootDirectoryId),
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
@ -366,19 +355,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
: PLACEHOLDER
|
||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||
const headerRowRef = React.useRef<HTMLTableRowElement>(null)
|
||||
const assetTreeRef = React.useRef<assetTreeNode.AssetTreeNode>(assetTree)
|
||||
const assetTreeRef = React.useRef<AssetTreeNode>(assetTree)
|
||||
const pasteDataRef = React.useRef<pasteDataModule.PasteData<Set<backendModule.AssetId>> | null>(
|
||||
null
|
||||
)
|
||||
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>>(
|
||||
new Map<backendModule.AssetId, assetTreeNode.AssetTreeNode>()
|
||||
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>(
|
||||
new Map<backendModule.AssetId, AssetTreeNode>()
|
||||
)
|
||||
const filter = React.useMemo(() => {
|
||||
const globCache: Record<string, RegExp> = {}
|
||||
if (/^\s*$/.test(query.query)) {
|
||||
return null
|
||||
} else {
|
||||
return (node: assetTreeNode.AssetTreeNode) => {
|
||||
return (node: AssetTreeNode) => {
|
||||
const assetType =
|
||||
node.item.type === backendModule.AssetType.directory
|
||||
? 'folder'
|
||||
@ -480,32 +469,53 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
if (sortColumn == null || sortDirection == null) {
|
||||
return assetTree.preorderTraversal()
|
||||
} else {
|
||||
const sortDescendingMultiplier = -1
|
||||
const multiplier = {
|
||||
[sorting.SortDirection.ascending]: 1,
|
||||
[sorting.SortDirection.descending]: sortDescendingMultiplier,
|
||||
[SortDirection.ascending]: 1,
|
||||
[SortDirection.descending]: -1,
|
||||
}[sortDirection]
|
||||
let compare: (a: assetTreeNode.AssetTreeNode, b: assetTreeNode.AssetTreeNode) => number
|
||||
let compare: (a: AssetTreeNode, b: AssetTreeNode) => number
|
||||
switch (sortColumn) {
|
||||
case columnUtils.Column.name: {
|
||||
compare = (a, b) =>
|
||||
multiplier *
|
||||
(a.item.title > b.item.title ? 1 : a.item.title < b.item.title ? COMPARE_LESS_THAN : 0)
|
||||
|
||||
compare = (a, b) => {
|
||||
const aTypeOrder = backendModule.ASSET_TYPE_ORDER[a.item.type]
|
||||
const bTypeOrder = backendModule.ASSET_TYPE_ORDER[b.item.type]
|
||||
const typeDelta = aTypeOrder - bTypeOrder
|
||||
const aTitle = a.item.title.toLowerCase()
|
||||
const bTitle = b.item.title.toLowerCase()
|
||||
if (typeDelta !== 0) {
|
||||
return typeDelta
|
||||
} else if (aTitle === bTitle) {
|
||||
const delta = a.item.title > b.item.title ? 1 : a.item.title < b.item.title ? -1 : 0
|
||||
return multiplier * delta
|
||||
} else {
|
||||
const delta = aTitle > bTitle ? 1 : aTitle < bTitle ? -1 : 0
|
||||
return multiplier * delta
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case columnUtils.Column.modified: {
|
||||
compare = (a, b) =>
|
||||
multiplier * (Number(new Date(a.item.modifiedAt)) - Number(new Date(b.item.modifiedAt)))
|
||||
compare = (a, b) => {
|
||||
const aTypeOrder = backendModule.ASSET_TYPE_ORDER[a.item.type]
|
||||
const bTypeOrder = backendModule.ASSET_TYPE_ORDER[b.item.type]
|
||||
const typeDelta = aTypeOrder - bTypeOrder
|
||||
if (typeDelta !== 0) {
|
||||
return typeDelta
|
||||
} else {
|
||||
const aOrder = Number(new Date(a.item.modifiedAt))
|
||||
const bOrder = Number(new Date(b.item.modifiedAt))
|
||||
return multiplier * (aOrder - bOrder)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return assetTree.preorderTraversal(tree => Array.from(tree).sort(compare))
|
||||
return assetTree.preorderTraversal(tree => [...tree].sort(compare))
|
||||
}
|
||||
}, [assetTree, sortColumn, sortDirection])
|
||||
const visibilities = React.useMemo(() => {
|
||||
const map = new Map<backendModule.AssetId, Visibility>()
|
||||
const processNode = (node: assetTreeNode.AssetTreeNode) => {
|
||||
const processNode = (node: AssetTreeNode) => {
|
||||
let displayState = Visibility.hidden
|
||||
const visible = filter?.(node) ?? true
|
||||
for (const child of node.children ?? []) {
|
||||
@ -546,7 +556,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
|
||||
React.useEffect(() => {
|
||||
const nodeToSuggestion = (
|
||||
node: assetTreeNode.AssetTreeNode,
|
||||
node: AssetTreeNode,
|
||||
key: assetQuery.AssetQueryKey = 'names'
|
||||
): assetSearchBar.Suggestion => ({
|
||||
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
|
||||
@ -566,7 +576,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
)
|
||||
const allVisible = (negative = false) =>
|
||||
allVisibleNodes().map(node => nodeToSuggestion(node, negative ? 'negativeNames' : 'names'))
|
||||
const terms = assetQuery.AssetQuery.terms(query.query)
|
||||
const terms = AssetQuery.terms(query.query)
|
||||
const term = terms.find(otherTerm => otherTerm.values.length === 0) ?? terms[terms.length - 1]
|
||||
const termValues = term?.values ?? []
|
||||
const shouldOmitNames = terms.some(otherTerm => otherTerm.tag === 'name')
|
||||
@ -613,7 +623,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
new Set(extensions),
|
||||
(extension): assetSearchBar.Suggestion => ({
|
||||
render: () =>
|
||||
assetQuery.AssetQuery.termToString({
|
||||
AssetQuery.termToString({
|
||||
tag: `${negative ? '-' : ''}extension`,
|
||||
values: [extension],
|
||||
}),
|
||||
@ -641,7 +651,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
new Set(['today', ...modifieds]),
|
||||
(modified): assetSearchBar.Suggestion => ({
|
||||
render: () =>
|
||||
assetQuery.AssetQuery.termToString({
|
||||
AssetQuery.termToString({
|
||||
tag: `${negative ? '-' : ''}modified`,
|
||||
values: [modified],
|
||||
}),
|
||||
@ -672,7 +682,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
new Set(owners),
|
||||
(owner): assetSearchBar.Suggestion => ({
|
||||
render: () =>
|
||||
assetQuery.AssetQuery.termToString({
|
||||
AssetQuery.termToString({
|
||||
tag: `${negative ? '-' : ''}owner`,
|
||||
values: [owner],
|
||||
}),
|
||||
@ -744,8 +754,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}, [pasteData])
|
||||
|
||||
React.useEffect(() => {
|
||||
return shortcuts.registerKeyboardHandlers({
|
||||
[shortcutsModule.KeyboardAction.cancelCut]: () => {
|
||||
return shortcutManager.registerKeyboardHandlers({
|
||||
[shortcutManagerModule.KeyboardAction.cancelCut]: () => {
|
||||
if (pasteDataRef.current == null) {
|
||||
return false
|
||||
} else {
|
||||
@ -758,7 +768,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [/* should never change */ shortcuts, /* should never change */ dispatchAssetEvent])
|
||||
}, [/* should never change */ shortcutManager, /* should never change */ dispatchAssetEvent])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
@ -798,13 +808,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setInitialized(true)
|
||||
const rootParentDirectoryId = backendModule.DirectoryId('')
|
||||
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
|
||||
const newRootNode = new assetTreeNode.AssetTreeNode(
|
||||
const newRootNode = new AssetTreeNode(
|
||||
rootDirectoryId,
|
||||
rootDirectory,
|
||||
rootParentDirectoryId,
|
||||
rootParentDirectoryId,
|
||||
newAssets.map(asset =>
|
||||
assetTreeNode.AssetTreeNode.fromAsset(asset, rootDirectory.id, rootDirectory.id, 0)
|
||||
AssetTreeNode.fromAsset(asset, rootDirectory.id, rootDirectory.id, 0)
|
||||
),
|
||||
-1
|
||||
)
|
||||
@ -882,7 +892,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
case backendModule.BackendType.remote: {
|
||||
const queuedDirectoryListings = new Map<backendModule.AssetId, backendModule.AnyAsset[]>()
|
||||
const withChildren = (node: assetTreeNode.AssetTreeNode): assetTreeNode.AssetTreeNode => {
|
||||
const withChildren = (node: AssetTreeNode): AssetTreeNode => {
|
||||
const queuedListing = queuedDirectoryListings.get(node.item.id)
|
||||
if (queuedListing == null || !backendModule.assetIsDirectory(node.item)) {
|
||||
return node
|
||||
@ -892,12 +902,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
return node.with({
|
||||
children: queuedListing.map(asset =>
|
||||
withChildren(
|
||||
assetTreeNode.AssetTreeNode.fromAsset(
|
||||
asset,
|
||||
directoryAsset.id,
|
||||
directoryAsset.id,
|
||||
depth
|
||||
)
|
||||
AssetTreeNode.fromAsset(asset, directoryAsset.id, directoryAsset.id, depth)
|
||||
)
|
||||
),
|
||||
})
|
||||
@ -1058,7 +1063,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
? item
|
||||
: item.with({
|
||||
children: [
|
||||
assetTreeNode.AssetTreeNode.fromAsset(
|
||||
AssetTreeNode.fromAsset(
|
||||
backendModule.createSpecialLoadingAsset(directoryId),
|
||||
key,
|
||||
directoryId,
|
||||
@ -1098,7 +1103,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
}
|
||||
const childAssetNodes = Array.from(childAssetsMap.values(), child =>
|
||||
assetTreeNode.AssetTreeNode.fromAsset(child, key, directoryId, item.depth + 1)
|
||||
AssetTreeNode.fromAsset(child, key, directoryId, item.depth + 1)
|
||||
)
|
||||
const specialEmptyAsset: backendModule.SpecialEmptyAsset | null =
|
||||
(initialChildren != null && initialChildren.length !== 0) ||
|
||||
@ -1108,7 +1113,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
const children =
|
||||
specialEmptyAsset != null
|
||||
? [
|
||||
assetTreeNode.AssetTreeNode.fromAsset(
|
||||
AssetTreeNode.fromAsset(
|
||||
specialEmptyAsset,
|
||||
key,
|
||||
directoryId,
|
||||
@ -1117,9 +1122,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
]
|
||||
: initialChildren == null || initialChildren.length === 0
|
||||
? childAssetNodes
|
||||
: [...initialChildren, ...childAssetNodes].sort(
|
||||
assetTreeNode.AssetTreeNode.compare
|
||||
)
|
||||
: [...initialChildren, ...childAssetNodes].sort(AssetTreeNode.compare)
|
||||
return item.with({ children })
|
||||
}
|
||||
})
|
||||
@ -1690,8 +1693,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
React.useEffect(() => {
|
||||
const onDocumentClick = (event: MouseEvent) => {
|
||||
if (
|
||||
!shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditional, event) &&
|
||||
!shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditionalRange, event) &&
|
||||
!shortcutManager.matchesMouseAction(
|
||||
shortcutManagerModule.MouseAction.selectAdditional,
|
||||
event
|
||||
) &&
|
||||
!shortcutManager.matchesMouseAction(
|
||||
shortcutManagerModule.MouseAction.selectAdditionalRange,
|
||||
event
|
||||
) &&
|
||||
selectedKeys.size !== 0
|
||||
) {
|
||||
setSelectedKeys(new Set())
|
||||
@ -1701,7 +1710,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
return () => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
}
|
||||
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcuts])
|
||||
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcutManager])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
@ -1724,28 +1733,36 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
return [key]
|
||||
} else {
|
||||
const index1 = displayItems.findIndex(
|
||||
innerItem => assetTreeNode.AssetTreeNode.getKey(innerItem) === previouslySelectedKey
|
||||
innerItem => AssetTreeNode.getKey(innerItem) === previouslySelectedKey
|
||||
)
|
||||
const index2 = displayItems.findIndex(
|
||||
innerItem => assetTreeNode.AssetTreeNode.getKey(innerItem) === key
|
||||
innerItem => AssetTreeNode.getKey(innerItem) === key
|
||||
)
|
||||
const selectedItems =
|
||||
index1 <= index2
|
||||
? displayItems.slice(index1, index2 + 1)
|
||||
: displayItems.slice(index2, index1 + 1)
|
||||
return selectedItems.map(assetTreeNode.AssetTreeNode.getKey)
|
||||
return selectedItems.map(AssetTreeNode.getKey)
|
||||
}
|
||||
}
|
||||
if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectRange, event)) {
|
||||
if (
|
||||
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.selectRange, event)
|
||||
) {
|
||||
setSelectedKeys(new Set(getNewlySelectedKeys()))
|
||||
} else if (
|
||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditionalRange, event)
|
||||
shortcutManager.matchesMouseAction(
|
||||
shortcutManagerModule.MouseAction.selectAdditionalRange,
|
||||
event
|
||||
)
|
||||
) {
|
||||
setSelectedKeys(
|
||||
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
|
||||
)
|
||||
} else if (
|
||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditional, event)
|
||||
shortcutManager.matchesMouseAction(
|
||||
shortcutManagerModule.MouseAction.selectAdditional,
|
||||
event
|
||||
)
|
||||
) {
|
||||
setSelectedKeys(oldSelectedItems => {
|
||||
const newItems = new Set(oldSelectedItems)
|
||||
@ -1761,24 +1778,27 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}
|
||||
setPreviouslySelectedKey(key)
|
||||
},
|
||||
[displayItems, previouslySelectedKey, shortcuts, /* should never change */ setSelectedKeys]
|
||||
[
|
||||
displayItems,
|
||||
previouslySelectedKey,
|
||||
shortcutManager,
|
||||
/* should never change */ setSelectedKeys,
|
||||
]
|
||||
)
|
||||
|
||||
const columns = columnUtils.getColumnList(backend.type, extraColumns).map(column => ({
|
||||
id: column,
|
||||
className: columnUtils.COLUMN_CSS_CLASS[column],
|
||||
heading: columnHeading.COLUMN_HEADING[column],
|
||||
render: columnModule.COLUMN_RENDERER[column],
|
||||
}))
|
||||
const columns = columnUtils.getColumnList(backend.type, extraColumns)
|
||||
|
||||
const headerRow = (
|
||||
<tr ref={headerRowRef} className="sticky top-0">
|
||||
{columns.map(column => {
|
||||
// This is a React component, even though it does not contain JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const Heading = column.heading
|
||||
const Heading = columnHeading.COLUMN_HEADING[column]
|
||||
return (
|
||||
<th key={column.id} className={`text-sm font-semibold ${column.className}`}>
|
||||
<th
|
||||
key={column}
|
||||
className={`text-sm font-semibold ${columnUtils.COLUMN_CSS_CLASS[column]}`}
|
||||
>
|
||||
<Heading state={state} />
|
||||
</th>
|
||||
)
|
||||
@ -1796,21 +1816,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
</tr>
|
||||
) : (
|
||||
displayItems.map(item => {
|
||||
const key = assetTreeNode.AssetTreeNode.getKey(item)
|
||||
const key = AssetTreeNode.getKey(item)
|
||||
const isSelected = selectedKeys.has(key)
|
||||
const isSoleSelectedItem = selectedKeys.size === 1 && isSelected
|
||||
return (
|
||||
<AssetRow
|
||||
columns={columns}
|
||||
// The following two lines are safe; the type error occurs because a property
|
||||
// with a conditional type is being destructured.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
state={state as never}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
initialRowState={INITIAL_ROW_STATE as never}
|
||||
key={key}
|
||||
keyProp={key}
|
||||
columns={columns}
|
||||
item={item}
|
||||
state={state}
|
||||
hidden={visibilities.get(item.key) === Visibility.hidden}
|
||||
selected={isSelected}
|
||||
setSelected={selected => {
|
||||
@ -1827,13 +1841,11 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
setSelectedKeys(new Set([key]))
|
||||
}
|
||||
}}
|
||||
draggable={true}
|
||||
onDragStart={event => {
|
||||
if (!selectedKeys.has(key)) {
|
||||
setPreviouslySelectedKey(key)
|
||||
setSelectedKeys(new Set([key]))
|
||||
}
|
||||
|
||||
setSelectedKeys(oldSelectedKeys => {
|
||||
const nodes = assetTree
|
||||
.preorderTraversal()
|
||||
@ -1862,7 +1874,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
// Default states.
|
||||
isSoleSelectedItem={false}
|
||||
selected={false}
|
||||
rowState={INITIAL_ROW_STATE}
|
||||
rowState={assetRowUtils.INITIAL_ROW_STATE}
|
||||
// The drag placeholder cannot be interacted with.
|
||||
setSelected={() => {}}
|
||||
setItem={() => {}}
|
||||
@ -1882,6 +1894,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const ids = oldSelectedKeys.has(key) ? oldSelectedKeys : new Set([key])
|
||||
// Expand ids to include ids of children as well.
|
||||
for (const node of assetTree.preorderTraversal()) {
|
||||
if (ids.has(node.key) && node.children != null) {
|
||||
for (const child of node.children) {
|
||||
ids.add(child.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
let labelsPresent = 0
|
||||
for (const selectedKey of ids) {
|
||||
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||
@ -1921,7 +1941,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
}}
|
||||
onDrop={event => {
|
||||
setSelectedKeys(oldSelectedKeys => {
|
||||
const ids = oldSelectedKeys.has(key) ? oldSelectedKeys : new Set([key])
|
||||
const ids = oldSelectedKeys.has(key) ? new Set(oldSelectedKeys) : new Set([key])
|
||||
// Expand ids to include ids of children as well.
|
||||
for (const node of assetTree.preorderTraversal()) {
|
||||
if (ids.has(node.key) && node.children != null) {
|
||||
for (const child of node.children) {
|
||||
ids.add(child.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
const payload = drag.LABELS.lookup(event)
|
||||
if (payload != null) {
|
||||
event.preventDefault()
|
||||
@ -2051,6 +2079,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
||||
key={column}
|
||||
active={extraColumns.has(column)}
|
||||
image={columnUtils.EXTRA_COLUMN_IMAGES[column]}
|
||||
alt={`${extraColumns.has(column) ? 'Show' : 'Hide'} ${
|
||||
columnUtils.COLUMN_NAME[column]
|
||||
}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
const newExtraColumns = new Set(extraColumns)
|
||||
|
@ -2,27 +2,31 @@
|
||||
* are selected. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import type * as assetTreeNode from '#/utilities/assetTreeNode'
|
||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcuts from '#/utilities/shortcuts'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import ContextMenus from '#/components/ContextMenus'
|
||||
import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal'
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
||||
import * as permissions from '#/utilities/permissions'
|
||||
import * as shortcutManager from '#/utilities/ShortcutManager'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -42,9 +46,7 @@ export interface AssetsTableContextMenuProps {
|
||||
pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>> | null
|
||||
selectedKeys: Set<backendModule.AssetId>
|
||||
setSelectedKeys: (items: Set<backendModule.AssetId>) => void
|
||||
nodeMapRef: React.MutableRefObject<
|
||||
ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>
|
||||
>
|
||||
nodeMapRef: React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>
|
||||
event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'>
|
||||
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||
@ -72,7 +74,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
// This works because all items are mutated, ensuring their value stays
|
||||
// up to date.
|
||||
const ownsAllSelectedAssets =
|
||||
isCloud ||
|
||||
!isCloud ||
|
||||
(organization != null &&
|
||||
Array.from(selectedKeys, key => {
|
||||
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
|
||||
@ -122,7 +124,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
<ContextMenu hidden={hidden}>
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.restoreAllFromTrash}
|
||||
action={shortcutManager.KeyboardAction.restoreAllFromTrash}
|
||||
doAction={doRestoreAll}
|
||||
/>
|
||||
</ContextMenu>
|
||||
@ -132,8 +134,8 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
return null
|
||||
} else {
|
||||
const deleteAction = isCloud
|
||||
? shortcuts.KeyboardAction.moveAllToTrash
|
||||
: shortcuts.KeyboardAction.deleteAll
|
||||
? shortcutManager.KeyboardAction.moveAllToTrash
|
||||
: shortcutManager.KeyboardAction.deleteAll
|
||||
return (
|
||||
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
||||
{selectedKeys.size !== 0 && (
|
||||
@ -144,21 +146,21 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
||||
{isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.copyAll}
|
||||
action={shortcutManager.KeyboardAction.copyAll}
|
||||
doAction={doCopy}
|
||||
/>
|
||||
)}
|
||||
{isCloud && ownsAllSelectedAssets && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.cutAll}
|
||||
action={shortcutManager.KeyboardAction.cutAll}
|
||||
doAction={doCut}
|
||||
/>
|
||||
)}
|
||||
{pasteData != null && pasteData.data.size > 0 && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.pasteAll}
|
||||
action={shortcutManager.KeyboardAction.pasteAll}
|
||||
doAction={() => {
|
||||
const [firstKey] = selectedKeys
|
||||
const selectedNode =
|
||||
|
@ -5,10 +5,11 @@ import CloudIcon from 'enso-assets/cloud.svg'
|
||||
import NotCloudIcon from 'enso-assets/not_cloud.svg'
|
||||
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
// =======================
|
||||
// === BackendSwitcher ===
|
||||
// =======================
|
||||
|
@ -3,20 +3,21 @@ import * as React from 'react'
|
||||
|
||||
import Home2Icon from 'enso-assets/home2.svg'
|
||||
import RecentIcon from 'enso-assets/recent.svg'
|
||||
import RootIcon from 'enso-assets/root.svg'
|
||||
import TempIcon from 'enso-assets/temp.svg'
|
||||
import Trash2Icon from 'enso-assets/trash2.svg'
|
||||
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as drag from '#/utilities/drag'
|
||||
import * as localStorageModule from '#/utilities/localStorage'
|
||||
|
||||
import SvgMask from '#/components/SvgMask'
|
||||
|
||||
import * as drag from '#/utilities/drag'
|
||||
import * as localStorageModule from '#/utilities/LocalStorage'
|
||||
|
||||
// ============================
|
||||
// === CategorySwitcherItem ===
|
||||
// ============================
|
||||
@ -27,8 +28,6 @@ interface InternalCategorySwitcherItemProps {
|
||||
active?: boolean
|
||||
/** When true, the button is not clickable. */
|
||||
disabled?: boolean
|
||||
/** A title that is only shown when `disabled` is true. */
|
||||
hidden: boolean
|
||||
image: string
|
||||
name: string
|
||||
iconClassName?: string
|
||||
@ -39,27 +38,31 @@ interface InternalCategorySwitcherItemProps {
|
||||
|
||||
/** An entry in a {@link CategorySwitcher}. */
|
||||
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
||||
const { active = false, disabled = false, hidden, image, name, iconClassName, onClick } = props
|
||||
const { active = false, disabled = false, image, name, iconClassName, onClick } = props
|
||||
const { onDragOver, onDrop } = props
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center rounded-full gap-2 h-8 px-2 ${hidden ? 'hidden' : ''} ${
|
||||
<button
|
||||
disabled={disabled}
|
||||
title={`Go To ${name}`}
|
||||
className={`group flex items-center rounded-full gap-2 h-8 px-2 hover:bg-frame-selected transition-colors ${
|
||||
active ? 'bg-frame-selected' : 'text-not-selected'
|
||||
} ${
|
||||
disabled
|
||||
? ''
|
||||
: 'hover:text-primary hover:bg-frame-selected cursor-pointer hover:opacity-100'
|
||||
} ${!active && disabled ? 'cursor-not-allowed' : ''}`}
|
||||
{...(disabled ? {} : { onClick, onDragOver, onDrop })}
|
||||
} ${disabled ? '' : 'hover:text-primary hover:bg-frame-selected hover:opacity-100'} ${
|
||||
!active && disabled ? 'cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
// Required because `dragover` does not fire on `mouseenter`.
|
||||
onDragEnter={onDragOver}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<SvgMask
|
||||
src={image}
|
||||
className={`${active ? 'text-icon-selected' : 'text-icon-not-selected'} ${
|
||||
disabled ? '' : 'group-hover:text-icon-selected'
|
||||
className={`group-hover:text-icon-selected ${
|
||||
active ? 'text-icon-selected' : 'text-icon-not-selected'
|
||||
} ${iconClassName ?? ''}`}
|
||||
/>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@ -67,35 +70,17 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
||||
// === CategorySwitcher ===
|
||||
// ========================
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
Category.recent,
|
||||
Category.drafts,
|
||||
Category.home,
|
||||
Category.root,
|
||||
Category.trash,
|
||||
]
|
||||
|
||||
const IS_NOT_YET_IMPLEMENTED: Record<Category, boolean> = {
|
||||
[Category.recent]: false,
|
||||
[Category.drafts]: true,
|
||||
[Category.home]: false,
|
||||
[Category.root]: true,
|
||||
[Category.trash]: false,
|
||||
}
|
||||
const CATEGORIES: Category[] = [Category.recent, Category.home, Category.trash]
|
||||
|
||||
const CATEGORY_ICONS: Record<Category, string> = {
|
||||
[Category.recent]: RecentIcon,
|
||||
[Category.drafts]: TempIcon,
|
||||
[Category.home]: Home2Icon,
|
||||
[Category.root]: RootIcon,
|
||||
[Category.trash]: Trash2Icon,
|
||||
}
|
||||
|
||||
const CATEGORY_CLASS_NAMES: Record<Category, string> = {
|
||||
[Category.recent]: '-ml-0.5',
|
||||
[Category.drafts]: '-ml-0.5',
|
||||
[Category.home]: '',
|
||||
[Category.root]: '',
|
||||
[Category.trash]: '',
|
||||
} as const
|
||||
|
||||
@ -126,7 +111,6 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
key={currentCategory}
|
||||
active={category === currentCategory}
|
||||
disabled={category === currentCategory}
|
||||
hidden={IS_NOT_YET_IMPLEMENTED[currentCategory]}
|
||||
image={CATEGORY_ICONS[currentCategory]}
|
||||
name={currentCategory}
|
||||
iconClassName={CATEGORY_CLASS_NAMES[currentCategory]}
|
||||
@ -154,7 +138,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
||||
dispatchAssetEvent({
|
||||
type:
|
||||
category === Category.trash ? AssetEventType.restore : AssetEventType.delete,
|
||||
ids: new Set(payload.map(item => item.asset.id)),
|
||||
ids: new Set(payload.map(item => item.key)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -7,9 +7,7 @@
|
||||
/** The categories available in the category switcher. */
|
||||
enum Category {
|
||||
recent = 'Recent',
|
||||
drafts = 'Drafts',
|
||||
home = 'Home',
|
||||
root = 'Root',
|
||||
trash = 'Trash',
|
||||
}
|
||||
|
||||
|
@ -6,13 +6,14 @@ import LockIcon from 'enso-assets/lock.svg'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as string from '#/utilities/string'
|
||||
import * as validation from '#/utilities/validation'
|
||||
|
||||
import Input from '#/components/Input'
|
||||
import Modal from '#/components/Modal'
|
||||
import SubmitButton from '#/components/SubmitButton'
|
||||
|
||||
import * as string from '#/utilities/string'
|
||||
import * as validation from '#/utilities/validation'
|
||||
|
||||
// ===========================
|
||||
// === ChangePasswordModal ===
|
||||
// ===========================
|
||||
|
@ -10,17 +10,19 @@ import TriangleDownIcon from 'enso-assets/triangle_down.svg'
|
||||
import * as chat from 'enso-chat/chat'
|
||||
|
||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as animations from '#/utilities/animations'
|
||||
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
|
||||
import Twemoji from '#/components/Twemoji'
|
||||
|
||||
import * as config from '#/utilities/config'
|
||||
import * as dateTime from '#/utilities/dateTime'
|
||||
import * as newtype from '#/utilities/newtype'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import Twemoji from '#/components/Twemoji'
|
||||
|
||||
// ================
|
||||
// === Newtypes ===
|
||||
// ================
|
||||
@ -38,8 +40,6 @@ const MessageId = newtype.newtypeConstructor<chat.MessageId>()
|
||||
// to switch projects, and undo history may be lost.
|
||||
|
||||
export const HELP_CHAT_ID = 'enso-chat'
|
||||
export const ANIMATION_DURATION_MS = 200
|
||||
export const WIDTH_PX = 336
|
||||
/** The size (both width and height) of each reaction button. */
|
||||
const REACTION_BUTTON_SIZE = 20
|
||||
/** The size (both width and height) of each reaction on a message. */
|
||||
@ -398,13 +398,7 @@ export default function Chat(props: ChatProps) {
|
||||
const [isAtBottom, setIsAtBottom] = React.useState(true)
|
||||
const [messagesHeightBeforeMessageHistory, setMessagesHeightBeforeMessageHistory] =
|
||||
React.useState<number | null>(null)
|
||||
// TODO: proper URL
|
||||
const [websocket] = React.useState(() => new WebSocket(config.ACTIVE_CONFIG.chatUrl))
|
||||
const [right, setTargetRight] = animations.useInterpolateOverTime(
|
||||
animations.interpolationFunctionEaseInOut,
|
||||
ANIMATION_DURATION_MS,
|
||||
-WIDTH_PX
|
||||
)
|
||||
const [webSocket, setWebsocket] = React.useState<WebSocket | null>(null)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const messageInputRef = React.useRef<HTMLTextAreaElement>(null!)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@ -415,10 +409,22 @@ export default function Chat(props: ChatProps) {
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
websocket.close()
|
||||
if (isOpen) {
|
||||
const newWebSocket = new WebSocket(config.ACTIVE_CONFIG.chatUrl)
|
||||
setWebsocket(newWebSocket)
|
||||
return () => {
|
||||
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||
newWebSocket.close()
|
||||
} else {
|
||||
newWebSocket.addEventListener('open', () => {
|
||||
newWebSocket.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [websocket])
|
||||
}, [isOpen])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = messagesRef.current
|
||||
@ -434,9 +440,9 @@ export default function Chat(props: ChatProps) {
|
||||
|
||||
const sendMessage = React.useCallback(
|
||||
(message: chat.ChatClientMessageData) => {
|
||||
websocket.send(JSON.stringify(message))
|
||||
webSocket?.send(JSON.stringify(message))
|
||||
},
|
||||
[/* should never change */ websocket]
|
||||
[webSocket]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
@ -561,44 +567,16 @@ export default function Chat(props: ChatProps) {
|
||||
accessToken,
|
||||
})
|
||||
}
|
||||
websocket.addEventListener('message', onMessage)
|
||||
websocket.addEventListener('open', onOpen)
|
||||
webSocket?.addEventListener('message', onMessage)
|
||||
webSocket?.addEventListener('open', onOpen)
|
||||
return () => {
|
||||
websocket.removeEventListener('message', onMessage)
|
||||
websocket.removeEventListener('open', onOpen)
|
||||
webSocket?.removeEventListener('message', onMessage)
|
||||
webSocket?.removeEventListener('open', onOpen)
|
||||
}
|
||||
}, [
|
||||
websocket,
|
||||
shouldIgnoreMessageLimit,
|
||||
logger,
|
||||
threads,
|
||||
messages,
|
||||
accessToken,
|
||||
/* should never change */ sendMessage,
|
||||
])
|
||||
}, [webSocket, shouldIgnoreMessageLimit, logger, threads, messages, accessToken, sendMessage])
|
||||
|
||||
const container = document.getElementById(HELP_CHAT_ID)
|
||||
|
||||
React.useEffect(() => {
|
||||
// The types come from a third-party API and cannot be changed.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
let handle: number | undefined
|
||||
if (container != null) {
|
||||
if (isOpen) {
|
||||
container.style.display = ''
|
||||
setTargetRight(0)
|
||||
} else {
|
||||
setTargetRight(-WIDTH_PX)
|
||||
handle = window.setTimeout(() => {
|
||||
container.style.display = 'none'
|
||||
}, ANIMATION_DURATION_MS)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}, [isOpen, container, setTargetRight])
|
||||
|
||||
const switchThread = React.useCallback(
|
||||
(newThreadId: chat.ThreadId) => {
|
||||
const threadData = threads.find(thread => thread.id === newThreadId)
|
||||
@ -682,10 +660,9 @@ export default function Chat(props: ChatProps) {
|
||||
|
||||
return reactDom.createPortal(
|
||||
<div
|
||||
style={{ right }}
|
||||
className={`text-xs text-chat flex flex-col fixed top-0 right-0 backdrop-blur-3xl h-screen border-ide-bg-dark border-l-2 w-83.5 py-1 z-1 ${
|
||||
className={`text-xs text-chat flex flex-col fixed top-0 right-0 backdrop-blur-3xl h-screen border-ide-bg-dark border-l-2 w-83.5 py-1 z-1 transition-transform ${
|
||||
page === pageSwitcher.Page.editor ? 'bg-ide-bg' : 'bg-frame-selected'
|
||||
}`}
|
||||
} ${isOpen ? '' : 'translate-x-full'}`}
|
||||
>
|
||||
<ChatHeader
|
||||
threads={threads}
|
||||
|
@ -6,11 +6,13 @@ import * as reactDom from 'react-dom'
|
||||
import CloseLargeIcon from 'enso-assets/close_large.svg'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
|
||||
import * as chat from '#/layouts/dashboard/Chat'
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import * as animations from '#/utilities/animations'
|
||||
|
||||
/** Props for a {@link ChatPlaceholder}. */
|
||||
export interface ChatPlaceholderProps {
|
||||
@ -25,44 +27,18 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
||||
const { page, isOpen, doClose } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const navigate = navigateHooks.useNavigate()
|
||||
const [right, setTargetRight] = animations.useInterpolateOverTime(
|
||||
animations.interpolationFunctionEaseInOut,
|
||||
chat.ANIMATION_DURATION_MS,
|
||||
-chat.WIDTH_PX
|
||||
)
|
||||
|
||||
const container = document.getElementById(chat.HELP_CHAT_ID)
|
||||
|
||||
React.useEffect(() => {
|
||||
// The types come from a third-party API and cannot be changed.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
let handle: number | undefined
|
||||
if (container != null) {
|
||||
if (isOpen) {
|
||||
container.style.display = ''
|
||||
setTargetRight(0)
|
||||
} else {
|
||||
setTargetRight(-chat.WIDTH_PX)
|
||||
handle = window.setTimeout(() => {
|
||||
container.style.display = 'none'
|
||||
}, chat.ANIMATION_DURATION_MS)
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}, [isOpen, container, setTargetRight])
|
||||
|
||||
if (container == null) {
|
||||
logger.error('Chat container not found.')
|
||||
return null
|
||||
} else {
|
||||
return reactDom.createPortal(
|
||||
<div
|
||||
style={{ right }}
|
||||
className={`text-xs text-chat flex flex-col fixed top-0 right-0 backdrop-blur-3xl h-screen border-ide-bg-dark border-l-2 w-83.5 py-1 z-1 ${
|
||||
className={`text-xs text-chat flex flex-col fixed top-0 right-0 backdrop-blur-3xl h-screen border-ide-bg-dark border-l-2 w-83.5 py-1 z-1 transition-transform ${
|
||||
page === pageSwitcher.Page.editor ? 'bg-ide-bg' : 'bg-frame-selected'
|
||||
}`}
|
||||
} ${isOpen ? '' : 'translate-x-full'}`}
|
||||
>
|
||||
<div className="flex text-sm font-semibold mx-4 mt-2">
|
||||
<div className="grow" />
|
||||
|
@ -2,6 +2,7 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
@ -4,12 +4,20 @@ import * as React from 'react'
|
||||
import * as common from 'enso-common'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
|
||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||
import AssetsTable from '#/layouts/dashboard/AssetsTable'
|
||||
@ -18,19 +26,17 @@ import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import DriveBar from '#/layouts/dashboard/DriveBar'
|
||||
import Labels from '#/layouts/dashboard/Labels'
|
||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import type * as assetQuery from '#/utilities/assetQuery'
|
||||
import * as github from '#/utilities/github'
|
||||
import * as localStorageModule from '#/utilities/localStorage'
|
||||
import * as projectManager from '#/utilities/projectManager'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
import type * as spinner from '#/components/Spinner'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import type AssetQuery from '#/utilities/AssetQuery'
|
||||
import * as github from '#/utilities/github'
|
||||
import * as localStorageModule from '#/utilities/LocalStorage'
|
||||
import * as projectManager from '#/utilities/ProjectManager'
|
||||
import * as uniqueString from '#/utilities/uniqueString'
|
||||
|
||||
// ===================
|
||||
// === DriveStatus ===
|
||||
// ===================
|
||||
@ -66,8 +72,8 @@ export interface DriveProps {
|
||||
dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
|
||||
assetEvents: assetEvent.AssetEvent[]
|
||||
dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
|
||||
query: assetQuery.AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
||||
query: AssetQuery
|
||||
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||
labels: backendModule.Label[]
|
||||
setLabels: React.Dispatch<React.SetStateAction<backendModule.Label[]>>
|
||||
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
||||
@ -267,7 +273,7 @@ export default function Drive(props: DriveProps) {
|
||||
]
|
||||
)
|
||||
|
||||
const doCreateDataConnector = React.useCallback(
|
||||
const doCreateSecret = React.useCallback(
|
||||
(name: string, value: string) => {
|
||||
dispatchAssetListEvent({
|
||||
type: AssetListEventType.newSecret,
|
||||
@ -373,7 +379,7 @@ export default function Drive(props: DriveProps) {
|
||||
doCreateProject={doCreateProject}
|
||||
doUploadFiles={doUploadFiles}
|
||||
doCreateDirectory={doCreateDirectory}
|
||||
doCreateDataConnector={doCreateDataConnector}
|
||||
doCreateSecret={doCreateSecret}
|
||||
dispatchAssetEvent={dispatchAssetEvent}
|
||||
/>
|
||||
</div>
|
||||
|
@ -7,18 +7,22 @@ import AddFolderIcon from 'enso-assets/add_folder.svg'
|
||||
import DataDownloadIcon from 'enso-assets/data_download.svg'
|
||||
import DataUploadIcon from 'enso-assets/data_upload.svg'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
||||
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
|
||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
|
||||
import Button from '#/components/Button'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||
|
||||
// ================
|
||||
// === DriveBar ===
|
||||
// ================
|
||||
@ -29,7 +33,7 @@ export interface DriveBarProps {
|
||||
canDownloadFiles: boolean
|
||||
doCreateProject: () => void
|
||||
doCreateDirectory: () => void
|
||||
doCreateDataConnector: (name: string, value: string) => void
|
||||
doCreateSecret: (name: string, value: string) => void
|
||||
doUploadFiles: (files: File[]) => void
|
||||
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||
}
|
||||
@ -38,31 +42,31 @@ export interface DriveBarProps {
|
||||
* and a column display mode switcher. */
|
||||
export default function DriveBar(props: DriveBarProps) {
|
||||
const { category, canDownloadFiles, doCreateProject, doCreateDirectory } = props
|
||||
const { doCreateDataConnector, doUploadFiles, dispatchAssetEvent } = props
|
||||
const { doCreateSecret, doUploadFiles, dispatchAssetEvent } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
||||
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
||||
const isCloud = backend.type === backendModule.BackendType.remote
|
||||
const isHomeCategory = category === Category.home || !isCloud
|
||||
|
||||
React.useEffect(() => {
|
||||
return shortcuts.registerKeyboardHandlers({
|
||||
return shortcutManager.registerKeyboardHandlers({
|
||||
...(backend.type !== backendModule.BackendType.local
|
||||
? {
|
||||
[shortcutsModule.KeyboardAction.newFolder]: () => {
|
||||
[shortcutManagerModule.KeyboardAction.newFolder]: () => {
|
||||
doCreateDirectory()
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
[shortcutsModule.KeyboardAction.newProject]: () => {
|
||||
[shortcutManagerModule.KeyboardAction.newProject]: () => {
|
||||
doCreateProject()
|
||||
},
|
||||
[shortcutsModule.KeyboardAction.uploadFiles]: () => {
|
||||
[shortcutManagerModule.KeyboardAction.uploadFiles]: () => {
|
||||
uploadFilesRef.current?.click()
|
||||
},
|
||||
})
|
||||
}, [backend.type, doCreateDirectory, doCreateProject, /* should never change */ shortcuts])
|
||||
}, [backend.type, doCreateDirectory, doCreateProject, /* should never change */ shortcutManager])
|
||||
|
||||
return (
|
||||
<div className="flex h-8 py-0.5">
|
||||
@ -107,15 +111,13 @@ export default function DriveBar(props: DriveBarProps) {
|
||||
<Button
|
||||
active={isHomeCategory}
|
||||
disabled={!isHomeCategory}
|
||||
error="You can only create a new data connector in Home."
|
||||
error="You can only create a secret in Home."
|
||||
image={AddConnectorIcon}
|
||||
alt="New Data Connector"
|
||||
alt="New Secret"
|
||||
disabledOpacityClassName="opacity-20"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<UpsertSecretModal id={null} name={null} doCreate={doCreateDataConnector} />
|
||||
)
|
||||
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,18 +1,21 @@
|
||||
/** @file A modal opened when uploaded assets. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import type * as assetEvent from '#/events/assetEvent'
|
||||
import AssetEventType from '#/events/AssetEventType'
|
||||
import type * as assetListEvent from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as string from '#/utilities/string'
|
||||
|
||||
import AssetSummary from '#/components/dashboard/AssetSummary'
|
||||
import Modal from '#/components/Modal'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as fileInfo from '#/utilities/fileInfo'
|
||||
import * as string from '#/utilities/string'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
@ -2,7 +2,9 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||
import * as backendModule from '#/services/backend'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as load from '#/utilities/load'
|
||||
|
||||
// =================
|
||||
@ -47,10 +49,12 @@ export default function Editor(props: EditorProps) {
|
||||
if (ideElement != null) {
|
||||
if (hidden) {
|
||||
ideElement.style.top = '-100vh'
|
||||
ideElement.style.display = 'fixed'
|
||||
ideElement.style.position = 'fixed'
|
||||
ideElement.style.visibility = 'hidden'
|
||||
} else {
|
||||
ideElement.style.top = ''
|
||||
ideElement.style.display = 'absolute'
|
||||
ideElement.style.position = 'absolute'
|
||||
ideElement.style.visibility = ''
|
||||
}
|
||||
}
|
||||
const ide2Element = document.getElementById(IDE2_ELEMENT_ID)
|
||||
|
@ -1,18 +1,22 @@
|
||||
/** @file A context menu available everywhere in the directory. */
|
||||
import * as React from 'react'
|
||||
|
||||
import type * as assetListEventModule from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as backendModule from '#/services/backend'
|
||||
import * as shortcuts from '#/utilities/shortcuts'
|
||||
|
||||
import type * as assetListEventModule from '#/events/assetListEvent'
|
||||
import AssetListEventType from '#/events/AssetListEventType'
|
||||
|
||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
||||
|
||||
import ContextMenu from '#/components/ContextMenu'
|
||||
import MenuEntry from '#/components/MenuEntry'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
|
||||
import * as shortcutManager from '#/utilities/ShortcutManager'
|
||||
|
||||
/** Props for a {@link GlobalContextMenu}. */
|
||||
export interface GlobalContextMenuProps {
|
||||
hidden?: boolean
|
||||
@ -63,8 +67,8 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
hidden={hidden}
|
||||
action={
|
||||
backend.type === backendModule.BackendType.local
|
||||
? shortcuts.KeyboardAction.uploadProjects
|
||||
: shortcuts.KeyboardAction.uploadFiles
|
||||
? shortcutManager.KeyboardAction.uploadProjects
|
||||
: shortcutManager.KeyboardAction.uploadFiles
|
||||
}
|
||||
doAction={() => {
|
||||
if (filesInputRef.current?.isConnected === true) {
|
||||
@ -93,7 +97,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
{isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.newProject}
|
||||
action={shortcutManager.KeyboardAction.newProject}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetListEvent({
|
||||
@ -110,7 +114,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
{isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.newFolder}
|
||||
action={shortcutManager.KeyboardAction.newFolder}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
dispatchAssetListEvent({
|
||||
@ -124,7 +128,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
{isCloud && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.newDataConnector}
|
||||
action={shortcutManager.KeyboardAction.newDataConnector}
|
||||
doAction={() => {
|
||||
setModal(
|
||||
<UpsertSecretModal
|
||||
@ -147,7 +151,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
||||
{isCloud && directoryKey == null && hasCopyData && (
|
||||
<MenuEntry
|
||||
hidden={hidden}
|
||||
action={shortcuts.KeyboardAction.paste}
|
||||
action={shortcutManager.KeyboardAction.paste}
|
||||
doAction={() => {
|
||||
unsetModal()
|
||||
doPaste(rootDirectoryId, rootDirectoryId)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user