mirror of
https://github.com/enso-org/enso.git
synced 2024-11-29 03:08:55 +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
|
0aa7d7ee4d969ec8e8f9d376e72741dca324fdf6
|
||||||
# Update code style to use rust fmt (#3131)
|
# Update code style to use rust fmt (#3131)
|
||||||
c822256e6c531e56e894f9f92654654f63cfd6bc
|
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/content-config/src/config.json
|
||||||
app/ide-desktop/lib/dashboard/playwright-report/
|
app/ide-desktop/lib/dashboard/playwright-report/
|
||||||
app/ide-desktop/lib/dashboard/playwright/.cache/
|
app/ide-desktop/lib/dashboard/playwright/.cache/
|
||||||
|
app/ide-desktop/lib/dashboard/dist/
|
||||||
app/gui/view/documentation/assets/stylesheet.css
|
app/gui/view/documentation/assets/stylesheet.css
|
||||||
app/gui2/rust-ffi/pkg
|
app/gui2/rust-ffi/pkg
|
||||||
app/gui2/src/assets/font-*.css
|
app/gui2/src/assets/font-*.css
|
||||||
|
@ -36,7 +36,7 @@ import * as set from 'lib0/set'
|
|||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
import type { NodeMetadata } from 'shared/yjsModel'
|
import type { NodeMetadata } from 'shared/yjsModel'
|
||||||
import { computed, onMounted, onScopeDispose, onUnmounted, ref, watch } from 'vue'
|
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'
|
import { type Usage } from './ComponentBrowser/input'
|
||||||
|
|
||||||
const EXECUTION_MODES = ['design', 'live']
|
const EXECUTION_MODES = ['design', 'live']
|
||||||
|
@ -512,12 +512,12 @@ export default [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'lib/dashboard/test*/**/*.ts',
|
'lib/dashboard/e2e/**/*.ts',
|
||||||
'lib/dashboard/test*/**/*.mts',
|
'lib/dashboard/e2e/**/*.mts',
|
||||||
'lib/dashboard/test*/**/*.cts',
|
'lib/dashboard/e2e/**/*.cts',
|
||||||
'lib/dashboard/test*/**/*.tsx',
|
'lib/dashboard/e2e/**/*.tsx',
|
||||||
'lib/dashboard/test*/**/*.mtsx',
|
'lib/dashboard/e2e/**/*.mtsx',
|
||||||
'lib/dashboard/test*/**/*.ctsx',
|
'lib/dashboard/e2e/**/*.ctsx',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-properties': [
|
'no-restricted-properties': [
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
"name": "enso-common",
|
"name": "enso-common",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts",
|
".": "./src/index.js",
|
||||||
"./src/detect": "./src/detect.ts",
|
"./src/detect": "./src/detect.ts",
|
||||||
"./src/gtag": "./src/gtag.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. */
|
/** Company name, used as the copyright holder. */
|
||||||
export const COMPANY_NAME = 'New Byte Order sp. z o.o.'
|
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,
|
* These are required to increase the resolution of `performance.now()` timers,
|
||||||
* making profiling a lot more accurate and consistent. */
|
* 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-Embedder-Policy', 'credentialless'],
|
||||||
['Cross-Origin-Opener-Policy', 'same-origin'],
|
['Cross-Origin-Opener-Policy', 'same-origin'],
|
||||||
['Cross-Origin-Resource-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')
|
const ideElement = document.getElementById('root')
|
||||||
if (ideElement) {
|
if (ideElement) {
|
||||||
ideElement.style.top = '-100vh'
|
ideElement.style.top = '-100vh'
|
||||||
ideElement.style.display = 'fixed'
|
ideElement.style.position = 'fixed'
|
||||||
}
|
}
|
||||||
const ide2Element = document.getElementById('app')
|
const ide2Element = document.getElementById('app')
|
||||||
if (ide2Element) {
|
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-',
|
'^enso-',
|
||||||
'',
|
'',
|
||||||
'^#[/](?!components[/]).*$',
|
'^#[/]App',
|
||||||
|
'^#[/]appUtils',
|
||||||
|
'',
|
||||||
|
'^#[/]hooks[/]',
|
||||||
|
'',
|
||||||
|
'^#[/]providers[/]',
|
||||||
|
'',
|
||||||
|
'^#[/]events[/]',
|
||||||
|
'',
|
||||||
|
'^#[/]pages[/]',
|
||||||
|
'',
|
||||||
|
'^#[/]layouts[/]',
|
||||||
'',
|
'',
|
||||||
'^#[/]components[/]',
|
'^#[/]components[/]',
|
||||||
'',
|
'',
|
||||||
|
'^#[/]services[/]',
|
||||||
|
'',
|
||||||
|
'^#[/]utilities[/]',
|
||||||
|
'',
|
||||||
|
'^#[/]authentication[/]',
|
||||||
|
'',
|
||||||
'^[.]',
|
'^[.]',
|
||||||
],
|
],
|
||||||
importOrderParserPlugins: ['typescript', 'jsx', 'importAssertions'],
|
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 test from '@playwright/test'
|
||||||
|
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
import * as api from './api'
|
|
||||||
|
test.test.beforeEach(actions.mockAllAndLogin)
|
||||||
|
|
||||||
test.test('change password modal', async ({ page }) => {
|
test.test('change password modal', async ({ page }) => {
|
||||||
await api.mockApi(page)
|
// Change password modal
|
||||||
await actions.login(page)
|
|
||||||
|
|
||||||
// Screenshot #1: Change password modal
|
|
||||||
await actions.locateUserMenuButton(page).click()
|
await actions.locateUserMenuButton(page).click()
|
||||||
await actions.locateChangePasswordButton(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)
|
await actions.locateOldPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||||
test
|
test
|
||||||
.expect(
|
.expect(
|
||||||
@ -23,7 +21,7 @@ test.test('change password modal', async ({ page }) => {
|
|||||||
.toBe(false)
|
.toBe(false)
|
||||||
await actions.locateResetButton(page).click()
|
await actions.locateResetButton(page).click()
|
||||||
|
|
||||||
// Screenshot #3: Invalid new password
|
// Invalid new password
|
||||||
await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD)
|
await actions.locateOldPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||||
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
await actions.locateNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||||
test
|
test
|
||||||
@ -34,18 +32,18 @@ test.test('change password modal', async ({ page }) => {
|
|||||||
.toBe(false)
|
.toBe(false)
|
||||||
await actions.locateResetButton(page).click()
|
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.locateNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
await actions.locateConfirmNewPasswordInput(page).fill(actions.INVALID_PASSWORD)
|
||||||
test
|
test
|
||||||
.expect(
|
.expect(
|
||||||
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
|
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
|
||||||
'form should reject invalid "confirm new password"'
|
'form should reject invalid new password confirmation'
|
||||||
)
|
)
|
||||||
.toBe(false)
|
.toBe(false)
|
||||||
await actions.locateResetButton(page).click()
|
await actions.locateResetButton(page).click()
|
||||||
|
|
||||||
// Screenshot #5: After form submission
|
// After form submission
|
||||||
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
await actions.locateConfirmNewPasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||||
await actions.locateResetButton(page).click()
|
await actions.locateResetButton(page).click()
|
||||||
await test.expect(actions.locateChangePasswordModal(page)).not.toBeAttached()
|
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'
|
import * as actions from './actions'
|
||||||
|
|
||||||
|
test.test.beforeEach(actions.mockAll)
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Tests ===
|
// === Tests ===
|
||||||
// =============
|
// =============
|
||||||
|
|
||||||
test.test('login screen', async ({ page }) => {
|
test.test('login screen', async ({ page }) => {
|
||||||
// Screenshot omitted - it is already taken by `loginLogout.spec.ts`.
|
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
// Screenshot #2: Invalid email
|
// Invalid email
|
||||||
await actions.locateEmailInput(page).fill('invalid email')
|
await actions.locateEmailInput(page).fill('invalid email')
|
||||||
test
|
test
|
||||||
.expect(
|
.expect(
|
||||||
@ -21,14 +22,14 @@ test.test('login screen', async ({ page }) => {
|
|||||||
.toBe(false)
|
.toBe(false)
|
||||||
await actions.locateLoginButton(page).click()
|
await actions.locateLoginButton(page).click()
|
||||||
|
|
||||||
// Screenshot #3: Invalid password
|
// Invalid password
|
||||||
await actions.locateEmailInput(page).fill(actions.VALID_EMAIL)
|
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
|
test
|
||||||
.expect(
|
.expect(
|
||||||
await page.evaluate(() => document.querySelector('form')?.checkValidity()),
|
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()
|
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 test from '@playwright/test'
|
||||||
|
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
import * as apiModule from './api'
|
|
||||||
|
|
||||||
// =============
|
|
||||||
// === Tests ===
|
|
||||||
// =============
|
|
||||||
|
|
||||||
test.test('sign up flow', async ({ page }) => {
|
test.test('sign up flow', async ({ page }) => {
|
||||||
const api = await apiModule.mockApi(page)
|
const api = await actions.mockApi({ page })
|
||||||
api.setCurrentUser(null)
|
api.setCurrentUser(null)
|
||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
|
|
||||||
@ -20,25 +15,27 @@ test.test('sign up flow', async ({ page }) => {
|
|||||||
test.expect(email).not.toStrictEqual(api.defaultEmail)
|
test.expect(email).not.toStrictEqual(api.defaultEmail)
|
||||||
test.expect(name).not.toStrictEqual(api.defaultName)
|
test.expect(name).not.toStrictEqual(api.defaultName)
|
||||||
|
|
||||||
// Screenshot #1: Set username panel
|
// Set username panel
|
||||||
await actions.locateEmailInput(page).fill(email)
|
await actions.locateEmailInput(page).fill(email)
|
||||||
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
await actions.locatePasswordInput(page).fill(actions.VALID_PASSWORD)
|
||||||
await actions.locateLoginButton(page).click()
|
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.locateUsernameInput(page).fill(name)
|
||||||
await actions.locateSetUsernameButton(page).click()
|
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
|
const currentUser = api.currentUser
|
||||||
test.expect(currentUser).toBeDefined()
|
test.expect(currentUser).toBeDefined()
|
||||||
if (currentUser != null) {
|
if (currentUser != null) {
|
||||||
currentUser.isEnabled = true
|
currentUser.isEnabled = true
|
||||||
}
|
}
|
||||||
await actions.login(page, email)
|
await actions.login({ page }, email)
|
||||||
await test.expect(page).toHaveScreenshot()
|
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?.email, 'new user has correct email').toBe(email)
|
||||||
test.expect(api.currentUser?.name, 'new user has correct name').toBe(name)
|
test.expect(api.currentUser?.name, 'new user has correct name').toBe(name)
|
@ -2,7 +2,6 @@
|
|||||||
import * as test from '@playwright/test'
|
import * as test from '@playwright/test'
|
||||||
|
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
import * as apiModule from './api'
|
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Tests ===
|
// === Tests ===
|
||||||
@ -17,7 +16,7 @@ test.test('sign up with organization id', async ({ page }) => {
|
|||||||
await page.goto(
|
await page.goto(
|
||||||
'/registration?' + new URLSearchParams([['organization_id', organizationId]]).toString()
|
'/registration?' + new URLSearchParams([['organization_id', organizationId]]).toString()
|
||||||
)
|
)
|
||||||
const api = await apiModule.mockApi(page)
|
const api = await actions.mockApi({ page })
|
||||||
api.setCurrentUser(null)
|
api.setCurrentUser(null)
|
||||||
|
|
||||||
// Sign up
|
// Sign up
|
@ -2,7 +2,6 @@
|
|||||||
import * as test from '@playwright/test'
|
import * as test from '@playwright/test'
|
||||||
|
|
||||||
import * as actions from './actions'
|
import * as actions from './actions'
|
||||||
import * as apiModule from './api'
|
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Tests ===
|
// === Tests ===
|
||||||
@ -12,7 +11,7 @@ test.test('sign up without organization id', async ({ page }) => {
|
|||||||
await page.goto('/')
|
await page.goto('/')
|
||||||
await page.waitForLoadState('domcontentloaded')
|
await page.waitForLoadState('domcontentloaded')
|
||||||
await page.goto('/registration')
|
await page.goto('/registration')
|
||||||
const api = await apiModule.mockApi(page)
|
const api = await actions.mockApi({ page })
|
||||||
api.setCurrentUser(null)
|
api.setCurrentUser(null)
|
||||||
|
|
||||||
// Sign up
|
// 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"
|
user-scalable = no"
|
||||||
/>
|
/>
|
||||||
<title>Enso</title>
|
<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>
|
<script type="module" src="./src/entrypoint.ts" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -44,11 +42,6 @@
|
|||||||
<noscript>
|
<noscript>
|
||||||
This page requires JavaScript to run. Please enable it in your browser.
|
This page requires JavaScript to run. Please enable it in your browser.
|
||||||
</noscript>
|
</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) -->
|
<!-- Google tag (gtag.js) -->
|
||||||
<script
|
<script
|
||||||
async
|
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": {
|
"scripts": {
|
||||||
"compile": "tsc",
|
"compile": "tsc",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"build": "tsx bundle.ts",
|
"build": "vite build",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"start": "tsx start.ts",
|
"dev:e2e": "vite -c vite.test.config.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:unit": "vitest",
|
"test:unit": "vitest",
|
||||||
"test:browsers": "npx --yes playwright install && npm run test:component && npm run test:e2e-and-log",
|
"test:e2e": "npx playwright test",
|
||||||
"test:component": "playwright test -c playwright-component.config.ts",
|
"test:e2e:debug": "npx playwright test --ui"
|
||||||
"test:e2e": "npx playwright test -c playwright-e2e.config.ts",
|
|
||||||
"test:e2e-and-log": "npm run test:e2e || npx tsx log-screenshot-diffs.ts"
|
|
||||||
},
|
},
|
||||||
|
"//": [
|
||||||
|
"@fortawesome/fontawesome-svg-core is required as a peer dependency for @fortawesome/react-fontawesome"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-amplify/auth": "^5.6.5",
|
"@aws-amplify/auth": "^5.6.5",
|
||||||
"@aws-amplify/core": "^5.8.5",
|
"@aws-amplify/core": "^5.8.5",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
"@fortawesome/free-brands-svg-icons": "^6.4.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"@heroicons/react": "^2.0.15",
|
|
||||||
"@sentry/react": "^7.74.0",
|
"@sentry/react": "^7.74.0",
|
||||||
"enso-common": "^1.0.0",
|
"enso-common": "^1.0.0",
|
||||||
"is-network-error": "^1.0.1",
|
"is-network-error": "^1.0.1",
|
||||||
@ -70,7 +70,6 @@
|
|||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"tailwindcss": "^3.2.7",
|
"tailwindcss": "^3.2.7",
|
||||||
"ts-plugin-namespace-auto-import": "^1.0.0",
|
"ts-plugin-namespace-auto-import": "^1.0.0",
|
||||||
"tsx": "^3.12.6",
|
|
||||||
"typescript": "~5.2.2",
|
"typescript": "~5.2.2",
|
||||||
"vite": "^4.4.9",
|
"vite": "^4.4.9",
|
||||||
"vitest": "^0.34.4"
|
"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 */
|
/* eslint-disable @typescript-eslint/no-magic-numbers, @typescript-eslint/strict-boolean-expressions */
|
||||||
|
|
||||||
export default test.defineConfig({
|
export default test.defineConfig({
|
||||||
testDir: './test-e2e',
|
testDir: './e2e',
|
||||||
forbidOnly: !!process.env.CI,
|
fullyParallel: true,
|
||||||
retries: process.env.CI ? 2 : 0,
|
forbidOnly: true,
|
||||||
timeout: 10000,
|
workers: 1,
|
||||||
...(process.env.CI ? { workers: 1 } : {}),
|
|
||||||
expect: {
|
expect: {
|
||||||
toHaveScreenshot: { threshold: 0 },
|
toHaveScreenshot: { threshold: 0 },
|
||||||
},
|
},
|
||||||
@ -42,7 +41,7 @@ export default test.defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npx tsx test-server.ts',
|
command: 'npm run dev:e2e',
|
||||||
port: 8080,
|
port: 8080,
|
||||||
reuseExistingServer: false,
|
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 detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
import * as appUtils from '#/appUtils'
|
||||||
import * as authServiceModule from '#/authentication/service'
|
|
||||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
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 ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
|
||||||
import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode'
|
import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode'
|
||||||
import ForgotPassword from '#/pages/authentication/ForgotPassword'
|
import ForgotPassword from '#/pages/authentication/ForgotPassword'
|
||||||
@ -51,17 +61,13 @@ import Registration from '#/pages/authentication/Registration'
|
|||||||
import ResetPassword from '#/pages/authentication/ResetPassword'
|
import ResetPassword from '#/pages/authentication/ResetPassword'
|
||||||
import SetUsername from '#/pages/authentication/SetUsername'
|
import SetUsername from '#/pages/authentication/SetUsername'
|
||||||
import Dashboard from '#/pages/dashboard/Dashboard'
|
import Dashboard from '#/pages/dashboard/Dashboard'
|
||||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
|
||||||
import BackendProvider from '#/providers/BackendProvider'
|
import type Backend from '#/services/Backend'
|
||||||
import LocalStorageProvider from '#/providers/LocalStorageProvider'
|
import LocalBackend from '#/services/LocalBackend'
|
||||||
import LoggerProvider from '#/providers/LoggerProvider'
|
|
||||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
import ShortcutManager, * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||||
import ModalProvider from '#/providers/ModalProvider'
|
|
||||||
import SessionProvider from '#/providers/SessionProvider'
|
import * as authServiceModule from '#/authentication/service'
|
||||||
import ShortcutsProvider from '#/providers/ShortcutsProvider'
|
|
||||||
import type * as backend from '#/services/backend'
|
|
||||||
import * as localBackend from '#/services/localBackend'
|
|
||||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
|
||||||
|
|
||||||
// ======================
|
// ======================
|
||||||
// === getMainPageUrl ===
|
// === getMainPageUrl ===
|
||||||
@ -143,14 +149,16 @@ function AppRouter(props: AppProps) {
|
|||||||
// @ts-expect-error This is used exclusively for debugging.
|
// @ts-expect-error This is used exclusively for debugging.
|
||||||
window.navigate = navigate
|
window.navigate = navigate
|
||||||
}
|
}
|
||||||
const [shortcuts] = React.useState(() => shortcutsModule.ShortcutRegistry.createWithDefaults())
|
const [shortcutManager] = React.useState(() => ShortcutManager.createWithDefaults())
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
const isTargetEditable =
|
const isTargetEditable =
|
||||||
event.target instanceof HTMLInputElement ||
|
event.target instanceof HTMLInputElement ||
|
||||||
(event.target instanceof HTMLElement && event.target.isContentEditable)
|
(event.target instanceof HTMLElement && event.target.isContentEditable)
|
||||||
const shouldHandleEvent = isTargetEditable ? !shortcutsModule.isTextInputEvent(event) : true
|
const shouldHandleEvent = isTargetEditable
|
||||||
if (shouldHandleEvent && shortcuts.handleKeyboardEvent(event)) {
|
? !shortcutManagerModule.isTextInputEvent(event)
|
||||||
|
: true
|
||||||
|
if (shouldHandleEvent && shortcutManager.handleKeyboardEvent(event)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
// This is required to prevent the event from propagating to the event handler
|
// This is required to prevent the event from propagating to the event handler
|
||||||
// that focuses the search input.
|
// that focuses the search input.
|
||||||
@ -161,7 +169,7 @@ function AppRouter(props: AppProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
document.body.removeEventListener('keydown', onKeyDown)
|
document.body.removeEventListener('keydown', onKeyDown)
|
||||||
}
|
}
|
||||||
}, [shortcuts])
|
}, [shortcutManager])
|
||||||
const mainPageUrl = getMainPageUrl()
|
const mainPageUrl = getMainPageUrl()
|
||||||
const authService = React.useMemo(() => {
|
const authService = React.useMemo(() => {
|
||||||
const authConfig = { navigate, ...props }
|
const authConfig = { navigate, ...props }
|
||||||
@ -169,8 +177,8 @@ function AppRouter(props: AppProps) {
|
|||||||
}, [props, /* should never change */ navigate])
|
}, [props, /* should never change */ navigate])
|
||||||
const userSession = authService.cognito.userSession.bind(authService.cognito)
|
const userSession = authService.cognito.userSession.bind(authService.cognito)
|
||||||
const registerAuthEventListener = authService.registerAuthEventListener
|
const registerAuthEventListener = authService.registerAuthEventListener
|
||||||
const initialBackend: backend.Backend = isAuthenticationDisabled
|
const initialBackend: Backend = isAuthenticationDisabled
|
||||||
? new localBackend.LocalBackend(projectManagerUrl)
|
? new LocalBackend(projectManagerUrl)
|
||||||
: // This is safe, because the backend is always set by the authentication flow.
|
: // This is safe, because the backend is always set by the authentication flow.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
null!
|
null!
|
||||||
@ -231,7 +239,9 @@ function AppRouter(props: AppProps) {
|
|||||||
</router.Routes>
|
</router.Routes>
|
||||||
)
|
)
|
||||||
let result = routes
|
let result = routes
|
||||||
result = <ShortcutsProvider shortcuts={shortcuts}>{result}</ShortcutsProvider>
|
result = (
|
||||||
|
<ShortcutManagerProvider shortcutManager={shortcutManager}>{result}</ShortcutManagerProvider>
|
||||||
|
)
|
||||||
result = <ModalProvider>{result}</ModalProvider>
|
result = <ModalProvider>{result}</ModalProvider>
|
||||||
result = (
|
result = (
|
||||||
<AuthProvider
|
<AuthProvider
|
||||||
|
@ -29,30 +29,22 @@
|
|||||||
* Amplify reuses some codes for multiple kinds of errors. In the case of ambiguous errors, the
|
* 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
|
* `kind` field provides a unique string that can be used to brand the error in place of the
|
||||||
* `internalCode`, when rethrowing the error. */
|
* `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'
|
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 type * as cognito from 'amazon-cognito-identity-js'
|
||||||
import * as results from 'ts-results'
|
import * as results from 'ts-results'
|
||||||
|
|
||||||
import * as original from '../../src/authentication/cognito'
|
import type * as loggerProvider from '../providers/LoggerProvider'
|
||||||
import type * as config from '../../src/authentication/config'
|
// @ts-expect-error This is a mock file that needs to reference its original file.
|
||||||
import type * as loggerProvider from '../../src/providers/LoggerProvider'
|
import * as original from './cognito.ts'
|
||||||
|
import type * as config from './config'
|
||||||
/* eslint-enable no-restricted-syntax */
|
/* 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.
|
// This file exports a subset of the values from the original file.
|
||||||
/* eslint-disable no-restricted-syntax */
|
/* eslint-disable no-restricted-syntax */
|
||||||
export {
|
// @ts-expect-error This is a mock file that needs to reference its original file.
|
||||||
ConfirmSignUpErrorKind,
|
export { CognitoErrorType } from './cognito.ts'
|
||||||
CurrentSessionErrorKind,
|
|
||||||
ForgotPasswordErrorKind,
|
|
||||||
ForgotPasswordSubmitErrorKind,
|
|
||||||
SignInWithPasswordErrorKind,
|
|
||||||
SignUpErrorKind,
|
|
||||||
} from '../../src/authentication/cognito'
|
|
||||||
|
|
||||||
// There are unused function parameters in this file.
|
// There are unused function parameters in this file.
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||||
@ -105,7 +97,7 @@ export class Cognito {
|
|||||||
const expirationDate = date + TEN_HOURS_S
|
const expirationDate = date + TEN_HOURS_S
|
||||||
if (!this.isSignedIn) {
|
if (!this.isSignedIn) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
// eslint-disable-next-line @typescript-eslint/no-throw-literal
|
||||||
throw original.CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage
|
throw 'No current user'
|
||||||
} else {
|
} else {
|
||||||
return Promise.resolve<cognito.CognitoUserSession>({
|
return Promise.resolve<cognito.CognitoUserSession>({
|
||||||
isValid: () => true,
|
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)
|
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 detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
import * as config from '#/authentication/config'
|
|
||||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||||
|
|
||||||
|
import * as config from '#/authentication/config'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
@ -48,19 +49,6 @@ import type * as loggerProvider from '#/providers/LoggerProvider'
|
|||||||
* constant defined in the AWS Amplify library. */
|
* constant defined in the AWS Amplify library. */
|
||||||
const GITHUB_PROVIDER = 'Github'
|
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 ===
|
// === UserInfo ===
|
||||||
// ================
|
// ================
|
||||||
@ -151,10 +139,24 @@ function isAuthError(error: unknown): error is AuthError {
|
|||||||
// === CognitoError ===
|
// === 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.
|
/** Base interface for all errors output from this module.
|
||||||
* Every user-facing error MUST extend this interface. */
|
* Every user-facing error MUST extend this interface. */
|
||||||
interface CognitoError {
|
interface CognitoError {
|
||||||
kind: string
|
type: CognitoErrorType
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,7 +192,7 @@ export class Cognito {
|
|||||||
* Will refresh the {@link UserSession} if it has expired. */
|
* Will refresh the {@link UserSession} if it has expired. */
|
||||||
async userSession() {
|
async userSession() {
|
||||||
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
|
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)
|
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. */
|
/** Convert an {@link AmplifyError} into a {@link CognitoErrorType} if it is a known error,
|
||||||
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,
|
|
||||||
* else re-throws the error.
|
* else re-throws the error.
|
||||||
* @throws {Error} If the error is not recognized.
|
* @throws {Error} If the error is not recognized. */
|
||||||
*/
|
export function intoCurrentSessionErrorType(error: unknown): CognitoErrorType.noCurrentUser {
|
||||||
export function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
|
if (error === 'No current user') {
|
||||||
if (error === CURRENT_SESSION_NO_CURRENT_USER_ERROR.internalMessage) {
|
return CognitoErrorType.noCurrentUser
|
||||||
return CURRENT_SESSION_NO_CURRENT_USER_ERROR.kind
|
|
||||||
} else {
|
} else {
|
||||||
throw error
|
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. */
|
/** An error that may occur when signing up. */
|
||||||
export interface SignUpError extends CognitoError {
|
export interface SignUpError extends CognitoError {
|
||||||
kind: SignUpErrorKind
|
type:
|
||||||
|
| CognitoErrorType.invalidParameter
|
||||||
|
| CognitoErrorType.invalidPassword
|
||||||
|
| CognitoErrorType.usernameExists
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -470,19 +441,19 @@ export interface SignUpError extends CognitoError {
|
|||||||
* @throws {Error} If the error is not recognized.
|
* @throws {Error} If the error is not recognized.
|
||||||
*/
|
*/
|
||||||
export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
||||||
if (error.code === SIGN_UP_USERNAME_EXISTS_ERROR.internalCode) {
|
if (error.code === 'UsernameExistsException') {
|
||||||
return {
|
return {
|
||||||
kind: SIGN_UP_USERNAME_EXISTS_ERROR.kind,
|
type: CognitoErrorType.usernameExists,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
}
|
}
|
||||||
} else if (error.code === SIGN_UP_INVALID_PARAMETER_ERROR.internalCode) {
|
} else if (error.code === 'InvalidParameterException') {
|
||||||
return {
|
return {
|
||||||
kind: SIGN_UP_INVALID_PARAMETER_ERROR.kind,
|
type: CognitoErrorType.invalidParameter,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
}
|
}
|
||||||
} else if (error.code === SIGN_UP_INVALID_PASSWORD_ERROR.internalCode) {
|
} else if (error.code === 'InvalidPasswordException') {
|
||||||
return {
|
return {
|
||||||
kind: SIGN_UP_INVALID_PASSWORD_ERROR.kind,
|
type: CognitoErrorType.invalidPassword,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -494,28 +465,9 @@ export function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
|||||||
// === ConfirmSignUp ===
|
// === 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. */
|
/** An error that may occur when confirming registration. */
|
||||||
export interface ConfirmSignUpError extends CognitoError {
|
export interface ConfirmSignUpError extends CognitoError {
|
||||||
kind: ConfirmSignUpErrorKind
|
type: CognitoErrorType.userAlreadyConfirmed | CognitoErrorType.userNotFound
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -524,26 +476,26 @@ export interface ConfirmSignUpError extends CognitoError {
|
|||||||
* @throws {Error} If the error is not recognized. */
|
* @throws {Error} If the error is not recognized. */
|
||||||
export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError {
|
export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError {
|
||||||
if (
|
if (
|
||||||
error.code === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalCode &&
|
error.code === 'NotAuthorizedException' &&
|
||||||
error.message === CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.internalMessage
|
error.message === 'User cannot be confirmed. Current status is CONFIRMED'
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
/** Don't re-use the original `error.code` here because Amplify overloads the same code
|
/** 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
|
* for multiple kinds of errors. We replace it with a custom code that has no
|
||||||
* ambiguity. */
|
* ambiguity. */
|
||||||
kind: CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR.kind,
|
type: CognitoErrorType.userAlreadyConfirmed,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
error.code === CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.internalCode &&
|
error.code === 'UserNotFoundException' &&
|
||||||
error.message === CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.internalMessage
|
error.message === 'Username/client id combination not found.'
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
/** Don't re-use the original `error.code` here because Amplify overloads the same code
|
/** 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
|
* for multiple kinds of errors. We replace it with a custom code that has no
|
||||||
* ambiguity. */
|
* ambiguity. */
|
||||||
kind: CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.kind,
|
type: CognitoErrorType.userNotFound,
|
||||||
message: CONFIRM_SIGN_UP_USER_NOT_FOUND_ERROR.message,
|
message: 'Incorrect email or confirmation code.',
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw error
|
throw error
|
||||||
@ -554,16 +506,12 @@ export function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignU
|
|||||||
// === SignInWithPassword ===
|
// === 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. */
|
/** An error that may occur when signing in with a password. */
|
||||||
export interface SignInWithPasswordError extends CognitoError {
|
export interface SignInWithPasswordError extends CognitoError {
|
||||||
kind: SignInWithPasswordErrorKind
|
type:
|
||||||
|
| CognitoErrorType.notAuthorized
|
||||||
|
| CognitoErrorType.userNotConfirmed
|
||||||
|
| CognitoErrorType.userNotFound
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -574,18 +522,18 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW
|
|||||||
switch (error.code) {
|
switch (error.code) {
|
||||||
case 'UserNotFoundException':
|
case 'UserNotFoundException':
|
||||||
return {
|
return {
|
||||||
kind: SignInWithPasswordErrorKind.userNotFound,
|
type: CognitoErrorType.userNotFound,
|
||||||
message: MESSAGES.signInWithPassword.userNotFound,
|
message: 'User not found. Please sign up first.',
|
||||||
}
|
}
|
||||||
case 'UserNotConfirmedException':
|
case 'UserNotConfirmedException':
|
||||||
return {
|
return {
|
||||||
kind: SignInWithPasswordErrorKind.userNotConfirmed,
|
type: CognitoErrorType.userNotConfirmed,
|
||||||
message: MESSAGES.signInWithPassword.userNotConfirmed,
|
message: 'User not confirmed. Please check your email for a confirmation link.',
|
||||||
}
|
}
|
||||||
case 'NotAuthorizedException':
|
case 'NotAuthorizedException':
|
||||||
return {
|
return {
|
||||||
kind: SignInWithPasswordErrorKind.notAuthorized,
|
type: CognitoErrorType.notAuthorized,
|
||||||
message: MESSAGES.signInWithPassword.incorrectUsernameOrPassword,
|
message: 'Incorrect username or password.',
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw error
|
throw error
|
||||||
@ -596,27 +544,9 @@ export function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInW
|
|||||||
// === ForgotPassword ===
|
// === 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. */
|
/** An error that may occur when requesting a password reset. */
|
||||||
export interface ForgotPasswordError extends CognitoError {
|
export interface ForgotPasswordError extends CognitoError {
|
||||||
kind: ForgotPasswordErrorKind
|
type: CognitoErrorType.userNotConfirmed | CognitoErrorType.userNotFound
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -624,18 +554,22 @@ export interface ForgotPasswordError extends CognitoError {
|
|||||||
* else re-throws the error.
|
* else re-throws the error.
|
||||||
* @throws {Error} If the error is not recognized. */
|
* @throws {Error} If the error is not recognized. */
|
||||||
export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError {
|
export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordError {
|
||||||
if (error.code === FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.internalCode) {
|
if (error.code === 'UserNotFoundException') {
|
||||||
return {
|
return {
|
||||||
kind: FORGOT_PASSWORD_USER_NOT_FOUND_ERROR.kind,
|
type: CognitoErrorType.userNotFound,
|
||||||
message: MESSAGES.forgotPassword.userNotFound,
|
message: 'Cannot reset password as user not found.',
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
error.code === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.internalCode &&
|
error.code === 'InvalidParameterException' &&
|
||||||
error.message === FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.message
|
error.message ===
|
||||||
|
'Cannot reset password for the user as there is no registered/verified email or ' +
|
||||||
|
'phone_number'
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
kind: FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR.kind,
|
type: CognitoErrorType.userNotConfirmed,
|
||||||
message: MESSAGES.forgotPassword.userNotConfirmed,
|
message:
|
||||||
|
'Cannot reset password for user with an unverified email. ' +
|
||||||
|
'Please verify your email first.',
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw error
|
throw error
|
||||||
@ -646,15 +580,9 @@ export function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPassw
|
|||||||
// === ForgotPasswordSubmit ===
|
// === 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. */
|
/** An error that may occur when resetting a password. */
|
||||||
export interface ForgotPasswordSubmitError extends CognitoError {
|
export interface ForgotPasswordSubmitError extends CognitoError {
|
||||||
kind: ForgotPasswordSubmitErrorKind
|
type: CognitoErrorType.amplifyError | CognitoErrorType.authError
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -664,12 +592,12 @@ export interface ForgotPasswordSubmitError extends CognitoError {
|
|||||||
export function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError {
|
export function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSubmitError {
|
||||||
if (isAuthError(error)) {
|
if (isAuthError(error)) {
|
||||||
return {
|
return {
|
||||||
kind: ForgotPasswordSubmitErrorKind.authError,
|
type: CognitoErrorType.authError,
|
||||||
message: error.log,
|
message: error.log,
|
||||||
}
|
}
|
||||||
} else if (isAmplifyError(error)) {
|
} else if (isAmplifyError(error)) {
|
||||||
return {
|
return {
|
||||||
kind: ForgotPasswordSubmitErrorKind.amplifyError,
|
type: CognitoErrorType.amplifyError,
|
||||||
message: error.message,
|
message: error.message,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -7,11 +7,14 @@ import * as common from 'enso-common'
|
|||||||
import * as detect from 'enso-common/src/detect'
|
import * as detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
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 cognito from '#/authentication/cognito'
|
||||||
import * as auth from '#/authentication/config'
|
import * as auth from '#/authentication/config'
|
||||||
import * as listen from '#/authentication/listen'
|
import * as listen from '#/authentication/listen'
|
||||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
|
||||||
import * as config from '#/utilities/config'
|
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Types ===
|
// === Types ===
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
/** @file A color picker to select from a predetermined list of colors. */
|
/** @file A color picker to select from a predetermined list of colors. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as backend from '#/services/backend'
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
/** Props for a {@link ColorPicker}. */
|
/** Props for a {@link ColorPicker}. */
|
||||||
export interface ColorPickerProps {
|
export interface ColorPickerProps {
|
||||||
|
@ -4,18 +4,20 @@ import * as React from 'react'
|
|||||||
import CrossIcon from 'enso-assets/cross.svg'
|
import CrossIcon from 'enso-assets/cross.svg'
|
||||||
import TickIcon from 'enso-assets/tick.svg'
|
import TickIcon from 'enso-assets/tick.svg'
|
||||||
|
|
||||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
|
||||||
|
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||||
|
|
||||||
// ====================
|
// ====================
|
||||||
// === EditableSpan ===
|
// === 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}. */
|
/** 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
|
editable?: boolean
|
||||||
checkSubmittable?: (value: string) => boolean
|
checkSubmittable?: (value: string) => boolean
|
||||||
onSubmit: (value: string) => void
|
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">`. */
|
/** A `<span>` that can turn into an `<input type="text">`. */
|
||||||
export default function EditableSpan(props: EditableSpanProps) {
|
export default function EditableSpan(props: EditableSpanProps) {
|
||||||
const {
|
const { 'data-testid': dataTestId, className, editable = false, children } = props
|
||||||
editable = false,
|
const { checkSubmittable, onSubmit, onCancel, inputPattern, inputTitle } = props
|
||||||
checkSubmittable,
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
children,
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
inputPattern,
|
|
||||||
inputTitle,
|
|
||||||
...passthrough
|
|
||||||
} = props
|
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
|
||||||
const [isSubmittable, setIsSubmittable] = React.useState(true)
|
const [isSubmittable, setIsSubmittable] = React.useState(true)
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const cancelled = React.useRef(false)
|
const cancelled = React.useRef(false)
|
||||||
@ -50,8 +44,8 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (editable) {
|
if (editable) {
|
||||||
return shortcuts.registerKeyboardHandlers({
|
return shortcutManager.registerKeyboardHandlers({
|
||||||
[shortcutsModule.KeyboardAction.cancelEditName]: () => {
|
[shortcutManagerModule.KeyboardAction.cancelEditName]: () => {
|
||||||
onCancel()
|
onCancel()
|
||||||
cancelled.current = true
|
cancelled.current = true
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
@ -60,7 +54,7 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [editable, shortcuts, onCancel])
|
}, [editable, shortcutManager, onCancel])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
cancelled.current = false
|
cancelled.current = false
|
||||||
@ -80,19 +74,19 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
data-testid={dataTestId}
|
||||||
|
className={className}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
size={1}
|
size={1}
|
||||||
defaultValue={children}
|
defaultValue={children}
|
||||||
onBlur={event => {
|
onBlur={event => {
|
||||||
passthrough.onBlur?.(event)
|
|
||||||
if (!cancelled.current) {
|
if (!cancelled.current) {
|
||||||
event.currentTarget.form?.requestSubmit()
|
event.currentTarget.form?.requestSubmit()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
passthrough.onKeyDown?.(event)
|
|
||||||
if (
|
if (
|
||||||
!event.isPropagationStopped() &&
|
!event.isPropagationStopped() &&
|
||||||
((event.ctrlKey &&
|
((event.ctrlKey &&
|
||||||
@ -119,26 +113,35 @@ export default function EditableSpan(props: EditableSpanProps) {
|
|||||||
setIsSubmittable(checkSubmittable(event.currentTarget.value))
|
setIsSubmittable(checkSubmittable(event.currentTarget.value))
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
{...passthrough}
|
|
||||||
/>
|
/>
|
||||||
{isSubmittable && (
|
{isSubmittable && (
|
||||||
<button type="submit" className="mx-0.5">
|
<button type="submit" className="mx-0.5">
|
||||||
<img src={TickIcon} />
|
<img src={TickIcon} alt="Confirm Edit" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mx-0.5"
|
className="mx-0.5"
|
||||||
|
onMouseDown={() => {
|
||||||
|
cancelled.current = true
|
||||||
|
}}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onCancel()
|
onCancel()
|
||||||
|
window.setTimeout(() => {
|
||||||
|
cancelled.current = false
|
||||||
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={CrossIcon} />
|
<img src={CrossIcon} alt="Cancel Edit" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)
|
)
|
||||||
} else {
|
} 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. */
|
/** @file An entry in a menu. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
|
||||||
|
|
||||||
import KeyboardShortcut from '#/components/dashboard/keyboardShortcut'
|
import KeyboardShortcut from '#/components/dashboard/keyboardShortcut'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
|
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === MenuEntry ===
|
// === MenuEntry ===
|
||||||
// =================
|
// =================
|
||||||
@ -14,7 +15,7 @@ import SvgMask from '#/components/SvgMask'
|
|||||||
/** Props for a {@link MenuEntry}. */
|
/** Props for a {@link MenuEntry}. */
|
||||||
export interface MenuEntryProps {
|
export interface MenuEntryProps {
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
action: shortcutsModule.KeyboardAction
|
action: shortcutManagerModule.KeyboardAction
|
||||||
/** When true, the button is not clickable. */
|
/** When true, the button is not clickable. */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
title?: string
|
title?: string
|
||||||
@ -25,18 +26,18 @@ export interface MenuEntryProps {
|
|||||||
/** An item in a menu. */
|
/** An item in a menu. */
|
||||||
export default function MenuEntry(props: MenuEntryProps) {
|
export default function MenuEntry(props: MenuEntryProps) {
|
||||||
const { hidden = false, action, disabled = false, title, paddingClassName, doAction } = props
|
const { hidden = false, action, disabled = false, title, paddingClassName, doAction } = props
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const info = shortcuts.keyboardShortcutInfo[action]
|
const info = shortcutManager.keyboardShortcutInfo[action]
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
// This is slower than registering every shortcut in the context menu at once.
|
// This is slower than registering every shortcut in the context menu at once.
|
||||||
if (!disabled) {
|
if (!disabled) {
|
||||||
return shortcuts.registerKeyboardHandlers({
|
return shortcutManager.registerKeyboardHandlers({
|
||||||
[action]: doAction,
|
[action]: doAction,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}, [disabled, shortcuts, action, doAction])
|
}, [disabled, shortcutManager, action, doAction])
|
||||||
return hidden ? null : (
|
return hidden ? null : (
|
||||||
<button
|
<button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -52,8 +53,8 @@ export default function MenuEntry(props: MenuEntryProps) {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SvgMask
|
<SvgMask
|
||||||
style={{
|
style={{
|
||||||
width: shortcutsModule.ICON_SIZE_PX,
|
width: shortcutManagerModule.ICON_SIZE_PX,
|
||||||
height: shortcutsModule.ICON_SIZE_PX,
|
height: shortcutManagerModule.ICON_SIZE_PX,
|
||||||
}}
|
}}
|
||||||
src={info.icon}
|
src={info.icon}
|
||||||
className={info.colorClass}
|
className={info.colorClass}
|
||||||
|
@ -9,6 +9,8 @@ import * as modalProvider from '#/providers/ModalProvider'
|
|||||||
|
|
||||||
/** Props for a {@link Modal}. */
|
/** Props for a {@link Modal}. */
|
||||||
export interface ModalProps extends React.PropsWithChildren {
|
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.
|
// This can intentionally be `undefined`, in order to simplify consumers of this component.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
centered?: boolean | undefined
|
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
|
* background transparency can be enabled with Tailwind's `bg-opacity` classes, like
|
||||||
* `className="bg-opacity-50"`. */
|
* `className="bg-opacity-50"`. */
|
||||||
export default function Modal(props: ModalProps) {
|
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()
|
const { unsetModal } = modalProvider.useSetModal()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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}
|
style={style}
|
||||||
// This MUST be z-3, unlike all other elements, because it MUST show above the IDE.
|
// This MUST be z-3, unlike all other elements, because it MUST show above the IDE.
|
||||||
className={`inset-0 z-3 ${
|
className={`inset-0 z-3 ${
|
||||||
|
@ -6,11 +6,12 @@ import ConnectorIcon from 'enso-assets/connector.svg'
|
|||||||
import FolderIcon from 'enso-assets/folder.svg'
|
import FolderIcon from 'enso-assets/folder.svg'
|
||||||
import NetworkIcon from 'enso-assets/network.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 SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as fileIcon from '#/utilities/fileIcon'
|
||||||
|
|
||||||
/** Props for an {@link AssetIcon}. */
|
/** Props for an {@link AssetIcon}. */
|
||||||
export interface AssetIconProps {
|
export interface AssetIconProps {
|
||||||
asset: backend.AnyAsset
|
asset: backend.AnyAsset
|
||||||
|
@ -5,10 +5,11 @@ import DocsIcon from 'enso-assets/docs.svg'
|
|||||||
import SettingsIcon from 'enso-assets/settings.svg'
|
import SettingsIcon from 'enso-assets/settings.svg'
|
||||||
|
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
|
|
||||||
import Button from '#/components/Button'
|
import Button from '#/components/Button'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
/** Props for an {@link AssetInfoBar}. */
|
/** Props for an {@link AssetInfoBar}. */
|
||||||
export interface AssetInfoBarProps {
|
export interface AssetInfoBarProps {
|
||||||
canToggleSettingsPanel: boolean
|
canToggleSettingsPanel: boolean
|
||||||
@ -41,6 +42,7 @@ export default function AssetInfoBar(props: AssetInfoBarProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
alt={isSettingsPanelVisible ? 'Close Asset Panel' : 'Open Asset Panel'}
|
||||||
active={canToggleSettingsPanel && isSettingsPanelVisible}
|
active={canToggleSettingsPanel && isSettingsPanelVisible}
|
||||||
disabled={!canToggleSettingsPanel}
|
disabled={!canToggleSettingsPanel}
|
||||||
image={SettingsIcon}
|
image={SettingsIcon}
|
||||||
|
@ -3,30 +3,39 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import BlankIcon from 'enso-assets/blank.svg'
|
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 eventHooks from '#/hooks/eventHooks'
|
||||||
|
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
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 authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
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 dateTime from '#/utilities/dateTime'
|
||||||
import * as download from '#/utilities/download'
|
import * as download from '#/utilities/download'
|
||||||
import * as drag from '#/utilities/drag'
|
import * as drag from '#/utilities/drag'
|
||||||
import * as errorModule from '#/utilities/error'
|
import * as errorModule from '#/utilities/error'
|
||||||
|
import * as eventModule from '#/utilities/event'
|
||||||
import * as indent from '#/utilities/indent'
|
import * as indent from '#/utilities/indent'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
import * as permissions from '#/utilities/permissions'
|
import * as permissions from '#/utilities/permissions'
|
||||||
import * as set from '#/utilities/set'
|
import * as set from '#/utilities/set'
|
||||||
import Visibility, * as visibilityModule from '#/utilities/visibility'
|
import Visibility, * as visibilityModule from '#/utilities/visibility'
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
|
||||||
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
|
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === 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}. */
|
/** Common properties for state and setters passed to event handlers on an {@link AssetRow}. */
|
||||||
export interface AssetRowInnerProps {
|
export interface AssetRowInnerProps {
|
||||||
key: backendModule.AssetId
|
key: backendModule.AssetId
|
||||||
item: assetTreeNode.AssetTreeNode
|
item: AssetTreeNode
|
||||||
setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AssetTreeNode>>
|
setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||||
state: assetsTable.AssetsTableState
|
state: assetsTable.AssetsTableState
|
||||||
rowState: assetsTable.AssetRowState
|
rowState: assetsTable.AssetRowState
|
||||||
setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
|
setRowState: React.Dispatch<React.SetStateAction<assetsTable.AssetRowState>>
|
||||||
@ -55,13 +64,10 @@ export interface AssetRowInnerProps {
|
|||||||
/** Props for an {@link AssetRow}. */
|
/** Props for an {@link AssetRow}. */
|
||||||
export interface AssetRowProps
|
export interface AssetRowProps
|
||||||
extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
|
extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
|
||||||
keyProp: backendModule.AssetId
|
item: AssetTreeNode
|
||||||
tableRowRef?: React.RefObject<HTMLTableRowElement>
|
|
||||||
item: assetTreeNode.AssetTreeNode
|
|
||||||
state: assetsTable.AssetsTableState
|
state: assetsTable.AssetsTableState
|
||||||
hidden: boolean
|
hidden: boolean
|
||||||
initialRowState: assetsTable.AssetRowState
|
columns: columnUtils.Column[]
|
||||||
columns: column.AssetColumn[]
|
|
||||||
selected: boolean
|
selected: boolean
|
||||||
setSelected: (selected: boolean) => void
|
setSelected: (selected: boolean) => void
|
||||||
isSoleSelectedItem: boolean
|
isSoleSelectedItem: boolean
|
||||||
@ -72,9 +78,8 @@ export interface AssetRowProps
|
|||||||
|
|
||||||
/** A row containing an {@link backendModule.AnyAsset}. */
|
/** A row containing an {@link backendModule.AnyAsset}. */
|
||||||
export default function AssetRow(props: AssetRowProps) {
|
export default function AssetRow(props: AssetRowProps) {
|
||||||
const { keyProp: key, item: rawItem, initialRowState, hidden: hiddenRaw, selected } = props
|
const { item: rawItem, hidden: hiddenRaw, selected, isSoleSelectedItem, setSelected } = props
|
||||||
const { isSoleSelectedItem, setSelected, allowContextMenu, onContextMenu, state } = props
|
const { allowContextMenu, onContextMenu, state, columns, onClick } = props
|
||||||
const { tableRowRef, columns, onClick } = props
|
|
||||||
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
|
||||||
const { setAssetSettingsPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
const { setAssetSettingsPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
|
||||||
|
|
||||||
@ -88,8 +93,9 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
const asset = item.item
|
const asset = item.item
|
||||||
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
|
||||||
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
|
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 isCloud = backend.type === backendModule.BackendType.remote
|
||||||
const outerVisibility = visibilities.get(key)
|
const outerVisibility = visibilities.get(key)
|
||||||
const visibility =
|
const visibility =
|
||||||
@ -107,7 +113,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
// re - rendering the parent.
|
// re - rendering the parent.
|
||||||
rawItem.item = asset
|
rawItem.item = asset
|
||||||
}, [asset, rawItem])
|
}, [asset, rawItem])
|
||||||
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
|
const setAsset = setAssetHooks.useSetAsset(asset, setItem)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (selected && insertionVisibility !== Visibility.visible) {
|
if (selected && insertionVisibility !== Visibility.visible) {
|
||||||
@ -541,11 +547,26 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
<>
|
<>
|
||||||
{!hidden && (
|
{!hidden && (
|
||||||
<tr
|
<tr
|
||||||
ref={tableRowRef}
|
draggable
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
|
className={`h-8 transition duration-300 ease-in-out ${
|
||||||
|
visibilityModule.CLASS_NAME[visibility]
|
||||||
|
} ${isDraggedOver || selected ? 'selected' : ''}`}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
onClick(innerProps, event)
|
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 => {
|
onContextMenu={event => {
|
||||||
if (allowContextMenu) {
|
if (allowContextMenu) {
|
||||||
@ -569,9 +590,6 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
onContextMenu?.(innerProps, event)
|
onContextMenu?.(innerProps, event)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`h-8 transition duration-300 ease-in-out ${
|
|
||||||
visibilityModule.CLASS_NAME[visibility]
|
|
||||||
} ${isDraggedOver || selected ? 'selected' : ''}`}
|
|
||||||
onDragStart={event => {
|
onDragStart={event => {
|
||||||
if (rowState.isEditingName || !isCloud) {
|
if (rowState.isEditingName || !isCloud) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -589,6 +607,7 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
}, DRAG_EXPAND_DELAY_MS)
|
}, DRAG_EXPAND_DELAY_MS)
|
||||||
}
|
}
|
||||||
// Required because `dragover` does not fire on `mouseenter`.
|
// Required because `dragover` does not fire on `mouseenter`.
|
||||||
|
props.onDragOver?.(event)
|
||||||
onDragOver(event)
|
onDragOver(event)
|
||||||
}}
|
}}
|
||||||
onDragOver={event => {
|
onDragOver={event => {
|
||||||
@ -607,7 +626,9 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
) {
|
) {
|
||||||
window.clearTimeout(dragOverTimeoutHandle.current)
|
window.clearTimeout(dragOverTimeoutHandle.current)
|
||||||
}
|
}
|
||||||
|
if (event.currentTarget === event.target) {
|
||||||
clearDragState()
|
clearDragState()
|
||||||
|
}
|
||||||
props.onDragLeave?.(event)
|
props.onDragLeave?.(event)
|
||||||
}}
|
}}
|
||||||
onDrop={event => {
|
onDrop={event => {
|
||||||
@ -635,9 +656,9 @@ export default function AssetRow(props: AssetRowProps) {
|
|||||||
{columns.map(column => {
|
{columns.map(column => {
|
||||||
// This is a React component even though it does not contain JSX.
|
// This is a React component even though it does not contain JSX.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
const Render = column.render
|
const Render = columnModule.COLUMN_RENDERER[column]
|
||||||
return (
|
return (
|
||||||
<td key={column.id} className={column.className ?? ''}>
|
<td key={column} className={columnUtils.COLUMN_CSS_CLASS[column]}>
|
||||||
<Render
|
<Render
|
||||||
keyProp={key}
|
keyProp={key}
|
||||||
item={item}
|
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 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 AssetIcon from '#/components/dashboard/AssetIcon'
|
||||||
|
|
||||||
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
|
|
||||||
/** Props for an {@link AssetSummary}. */
|
/** Props for an {@link AssetSummary}. */
|
||||||
export interface AssetSummaryProps {
|
export interface AssetSummaryProps {
|
||||||
asset: backend.AnyAsset
|
asset: backend.AnyAsset
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
|
||||||
import Modal from '#/components/Modal'
|
import Modal from '#/components/Modal'
|
||||||
|
@ -4,25 +4,29 @@ import * as React from 'react'
|
|||||||
import FolderIcon from 'enso-assets/folder.svg'
|
import FolderIcon from 'enso-assets/folder.svg'
|
||||||
import TriangleDownIcon from 'enso-assets/triangle_down.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 AssetEventType from '#/events/AssetEventType'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
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 type * as column from '#/components/dashboard/column'
|
||||||
import EditableSpan from '#/components/EditableSpan'
|
import EditableSpan from '#/components/EditableSpan'
|
||||||
import SvgMask from '#/components/SvgMask'
|
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 ===
|
// === DirectoryName ===
|
||||||
// =====================
|
// =====================
|
||||||
@ -34,18 +38,18 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
|
|||||||
* @throws {Error} when the asset is not a {@link backendModule.DirectoryAsset}.
|
* @throws {Error} when the asset is not a {@link backendModule.DirectoryAsset}.
|
||||||
* This should never happen. */
|
* This should never happen. */
|
||||||
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
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 { numberOfSelectedItems, assetEvents, dispatchAssetListEvent, nodeMap } = state
|
||||||
const { doToggleDirectoryExpansion } = state
|
const { doToggleDirectoryExpansion } = state
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
if (asset.type !== backendModule.AssetType.directory) {
|
if (asset.type !== backendModule.AssetType.directory) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// 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 isCloud = backend.type === backendModule.BackendType.remote
|
||||||
|
|
||||||
const doRename = async (newTitle: string) => {
|
const doRename = async (newTitle: string) => {
|
||||||
@ -133,23 +137,16 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
|||||||
if (
|
if (
|
||||||
eventModule.isSingleClick(event) &&
|
eventModule.isSingleClick(event) &&
|
||||||
((selected && numberOfSelectedItems === 1) ||
|
((selected && numberOfSelectedItems === 1) ||
|
||||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||||
) {
|
) {
|
||||||
|
event.stopPropagation()
|
||||||
setRowState(object.merger({ isEditingName: true }))
|
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
|
<SvgMask
|
||||||
src={TriangleDownIcon}
|
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 ${
|
className={`hidden group-hover:inline-block cursor-pointer h-4 w-4 m-1 transition-transform duration-300 ${
|
||||||
item.children != null ? '' : '-rotate-90'
|
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" />
|
<SvgMask src={FolderIcon} className="group-hover:hidden h-4 w-4 m-1" />
|
||||||
<EditableSpan
|
<EditableSpan
|
||||||
|
data-testid="asset-row-name"
|
||||||
editable={rowState.isEditingName}
|
editable={rowState.isEditingName}
|
||||||
|
className={`cursor-pointer bg-transparent grow leading-170 h-6 py-px ${
|
||||||
|
rowState.isEditingName ? 'cursor-text' : 'cursor-pointer'
|
||||||
|
}`}
|
||||||
checkSubmittable={newTitle =>
|
checkSubmittable={newTitle =>
|
||||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||||
child =>
|
child =>
|
||||||
@ -176,9 +177,6 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setRowState(object.merger({ isEditingName: false }))
|
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}
|
{asset.title}
|
||||||
</EditableSpan>
|
</EditableSpan>
|
||||||
|
@ -1,25 +1,29 @@
|
|||||||
/** @file The icon and name of a {@link backendModule.FileAsset}. */
|
/** @file The icon and name of a {@link backendModule.FileAsset}. */
|
||||||
import * as React from 'react'
|
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 AssetEventType from '#/events/AssetEventType'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
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 type * as column from '#/components/dashboard/column'
|
||||||
import EditableSpan from '#/components/EditableSpan'
|
import EditableSpan from '#/components/EditableSpan'
|
||||||
import SvgMask from '#/components/SvgMask'
|
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 ===
|
// === FileName ===
|
||||||
// ================
|
// ================
|
||||||
@ -32,16 +36,16 @@ export interface FileNameColumnProps extends column.AssetColumnProps {}
|
|||||||
* This should never happen. */
|
* This should never happen. */
|
||||||
export default function FileNameColumn(props: FileNameColumnProps) {
|
export default function FileNameColumn(props: FileNameColumnProps) {
|
||||||
const { item, setItem, selected, state, rowState, setRowState } = props
|
const { item, setItem, selected, state, rowState, setRowState } = props
|
||||||
const { assetEvents, dispatchAssetListEvent } = state
|
const { nodeMap, assetEvents, dispatchAssetListEvent } = state
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
if (asset.type !== backendModule.AssetType.file) {
|
if (asset.type !== backendModule.AssetType.file) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// 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
|
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
|
||||||
// context menu entry should be re-added.
|
// context menu entry should be re-added.
|
||||||
@ -129,7 +133,8 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
|||||||
onClick={event => {
|
onClick={event => {
|
||||||
if (
|
if (
|
||||||
eventModule.isSingleClick(event) &&
|
eventModule.isSingleClick(event) &&
|
||||||
(selected || shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
(selected ||
|
||||||
|
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||||
) {
|
) {
|
||||||
setRowState(object.merger({ isEditingName: true }))
|
setRowState(object.merger({ isEditingName: true }))
|
||||||
}
|
}
|
||||||
@ -137,7 +142,20 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
|||||||
>
|
>
|
||||||
<SvgMask src={fileIcon.fileIcon()} className="m-1" />
|
<SvgMask src={fileIcon.fileIcon()} className="m-1" />
|
||||||
<EditableSpan
|
<EditableSpan
|
||||||
|
data-testid="asset-row-name"
|
||||||
editable={false}
|
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 => {
|
onSubmit={async newTitle => {
|
||||||
setRowState(object.merger({ isEditingName: false }))
|
setRowState(object.merger({ isEditingName: false }))
|
||||||
if (newTitle !== asset.title) {
|
if (newTitle !== asset.title) {
|
||||||
@ -153,7 +171,6 @@ export default function FileNameColumn(props: FileNameColumnProps) {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setRowState(object.merger({ isEditingName: false }))
|
setRowState(object.merger({ isEditingName: false }))
|
||||||
}}
|
}}
|
||||||
className="bg-transparent grow leading-170 h-6 py-px"
|
|
||||||
>
|
>
|
||||||
{asset.title}
|
{asset.title}
|
||||||
</EditableSpan>
|
</EditableSpan>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
/** @file An label that can be applied to an asset. */
|
/** @file An label that can be applied to an asset. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as backend from '#/services/backend'
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Label ===
|
// === Label ===
|
||||||
@ -12,6 +12,9 @@ interface InternalLabelProps
|
|||||||
extends React.PropsWithChildren,
|
extends React.PropsWithChildren,
|
||||||
Omit<JSX.IntrinsicElements['button'], 'color' | 'onClick'>,
|
Omit<JSX.IntrinsicElements['button'], 'color' | 'onClick'>,
|
||||||
Required<Pick<JSX.IntrinsicElements['button'], '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. */
|
/** When true, the button is not faded out even when not hovered. */
|
||||||
active?: boolean
|
active?: boolean
|
||||||
/** When true, the button has a red border signifying that it will be deleted,
|
/** 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. */
|
/** An label that can be applied to an asset. */
|
||||||
export default function Label(props: InternalLabelProps) {
|
export default function Label(props: InternalLabelProps) {
|
||||||
const {
|
const {
|
||||||
|
'data-testid': dataTestId,
|
||||||
active = false,
|
active = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
color,
|
color,
|
||||||
@ -47,6 +51,7 @@ export default function Label(props: InternalLabelProps) {
|
|||||||
: 'text-not-selected'
|
: 'text-not-selected'
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
data-testid={dataTestId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`flex items-center rounded-full whitespace-nowrap gap-1.5 h-6 px-2.25 transition-all ${className} ${
|
className={`flex items-center rounded-full whitespace-nowrap gap-1.5 h-6 px-2.25 transition-all ${className} ${
|
||||||
negated
|
negated
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
/** @file Constants related to labels. */
|
/** @file Constants related to labels. */
|
||||||
import type * as backend from '#/services/backend'
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
|
@ -11,9 +11,9 @@ import * as permissionsModule from '#/utilities/permissions'
|
|||||||
export interface PermissionDisplayProps extends React.PropsWithChildren {
|
export interface PermissionDisplayProps extends React.PropsWithChildren {
|
||||||
action: permissionsModule.PermissionAction
|
action: permissionsModule.PermissionAction
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: React.MouseEventHandler<HTMLDivElement>
|
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
||||||
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>
|
onMouseEnter?: React.MouseEventHandler<HTMLButtonElement>
|
||||||
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>
|
onMouseLeave?: React.MouseEventHandler<HTMLButtonElement>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Colored border around icons and text indicating permissions. */
|
/** 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.admin:
|
||||||
case permissionsModule.Permission.edit: {
|
case permissionsModule.Permission.edit: {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className={`${
|
className={`${
|
||||||
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
permissionsModule.PERMISSION_CLASS_NAME[permission.type]
|
||||||
} inline-block rounded-full whitespace-nowrap h-6 px-1.75 py-0.5 ${className ?? ''}`}
|
} 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}
|
onMouseLeave={onMouseLeave}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case permissionsModule.Permission.read:
|
case permissionsModule.Permission.read:
|
||||||
case permissionsModule.Permission.view: {
|
case permissionsModule.Permission.view: {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className={`relative inline-block rounded-full whitespace-nowrap ${className ?? ''}`}
|
className={`relative inline-block rounded-full whitespace-nowrap ${className ?? ''}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
@ -60,7 +60,7 @@ export default function PermissionDisplay(props: PermissionDisplayProps) {
|
|||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
/** @file A selector for all possible permissions. */
|
/** @file A selector for all possible permissions. */
|
||||||
import * as React from 'react'
|
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 PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
|
||||||
import Modal from '#/components/Modal'
|
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 ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
/** @file A selector for all possible permission types. */
|
/** @file A selector for all possible permission types. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as backend from '#/services/backend'
|
import * as backend from '#/services/Backend'
|
||||||
|
|
||||||
import * as permissions from '#/utilities/permissions'
|
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 PlayIcon from 'enso-assets/play.svg'
|
||||||
import StopIcon from 'enso-assets/stop.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 eventHooks from '#/hooks/eventHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
import * as remoteBackend from '#/services/remoteBackend'
|
import type * as assetEvent from '#/events/assetEvent'
|
||||||
import * as errorModule from '#/utilities/error'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import * as localStorageModule from '#/utilities/localStorage'
|
|
||||||
import * as object from '#/utilities/object'
|
|
||||||
|
|
||||||
import Spinner, * as spinner from '#/components/Spinner'
|
import Spinner, * as spinner from '#/components/Spinner'
|
||||||
import SvgMask from '#/components/SvgMask'
|
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 ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
|
@ -3,29 +3,33 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import NetworkIcon from 'enso-assets/network.svg'
|
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 eventHooks from '#/hooks/eventHooks'
|
||||||
|
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import * as eventModule from '#/utilities/event'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
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 type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import ProjectIcon from '#/components/dashboard/ProjectIcon'
|
import ProjectIcon from '#/components/dashboard/ProjectIcon'
|
||||||
import EditableSpan from '#/components/EditableSpan'
|
import EditableSpan from '#/components/EditableSpan'
|
||||||
import SvgMask from '#/components/SvgMask'
|
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 ===
|
// === ProjectName ===
|
||||||
// ===================
|
// ===================
|
||||||
@ -43,13 +47,13 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { organization } = authProvider.useNonPartialUserSession()
|
const { organization } = authProvider.useNonPartialUserSession()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
if (asset.type !== backendModule.AssetType.project) {
|
if (asset.type !== backendModule.AssetType.project) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// 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 =
|
const ownPermission =
|
||||||
asset.permissions?.find(permission => permission.user.user_email === organization?.email) ??
|
asset.permissions?.find(permission => permission.user.user_email === organization?.email) ??
|
||||||
null
|
null
|
||||||
@ -251,7 +255,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
onClick={event => {
|
onClick={event => {
|
||||||
if (rowState.isEditingName || isOtherUserUsingProject) {
|
if (rowState.isEditingName || isOtherUserUsingProject) {
|
||||||
// The project should neither be edited nor opened in these cases.
|
// 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.
|
// It is a double click; open the project.
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
type: AssetEventType.openProject,
|
type: AssetEventType.openProject,
|
||||||
@ -259,7 +265,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
shouldAutomaticallySwitchPage: true,
|
shouldAutomaticallySwitchPage: true,
|
||||||
runInBackground: false,
|
runInBackground: false,
|
||||||
})
|
})
|
||||||
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.run, event)) {
|
} else if (
|
||||||
|
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.run, event)
|
||||||
|
) {
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
type: AssetEventType.openProject,
|
type: AssetEventType.openProject,
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
@ -270,7 +278,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
!isRunning &&
|
!isRunning &&
|
||||||
eventModule.isSingleClick(event) &&
|
eventModule.isSingleClick(event) &&
|
||||||
((selected && numberOfSelectedItems === 1) ||
|
((selected && numberOfSelectedItems === 1) ||
|
||||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||||
) {
|
) {
|
||||||
setRowState(object.merger({ isEditingName: true }))
|
setRowState(object.merger({ isEditingName: true }))
|
||||||
}
|
}
|
||||||
@ -296,7 +304,15 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EditableSpan
|
<EditableSpan
|
||||||
|
data-testid="asset-row-name"
|
||||||
editable={rowState.isEditingName}
|
editable={rowState.isEditingName}
|
||||||
|
className={`bg-transparent grow leading-170 h-6 py-px ${
|
||||||
|
rowState.isEditingName
|
||||||
|
? 'cursor-text'
|
||||||
|
: canExecute && !isOtherUserUsingProject
|
||||||
|
? 'cursor-pointer'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
checkSubmittable={newTitle =>
|
checkSubmittable={newTitle =>
|
||||||
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
(nodeMap.current.get(item.directoryKey)?.children ?? []).every(
|
||||||
child =>
|
child =>
|
||||||
@ -318,13 +334,6 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
|
|||||||
inputTitle: validation.LOCAL_PROJECT_NAME_TITLE,
|
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}
|
{asset.title}
|
||||||
</EditableSpan>
|
</EditableSpan>
|
||||||
|
@ -3,25 +3,29 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import ConnectorIcon from 'enso-assets/connector.svg'
|
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 eventHooks from '#/hooks/eventHooks'
|
||||||
|
import * as setAssetHooks from '#/hooks/setAssetHooks'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
import * as assetTreeNode from '#/utilities/assetTreeNode'
|
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 eventModule from '#/utilities/event'
|
||||||
import * as indent from '#/utilities/indent'
|
import * as indent from '#/utilities/indent'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||||
import Visibility from '#/utilities/visibility'
|
import Visibility from '#/utilities/visibility'
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
|
||||||
import EditableSpan from '#/components/EditableSpan'
|
|
||||||
|
|
||||||
// =====================
|
// =====================
|
||||||
// === ConnectorName ===
|
// === ConnectorName ===
|
||||||
// =====================
|
// =====================
|
||||||
@ -38,20 +42,13 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
|||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const { setModal } = modalProvider.useSetModal()
|
const { setModal } = modalProvider.useSetModal()
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const asset = item.item
|
const asset = item.item
|
||||||
if (asset.type !== backendModule.AssetType.secret) {
|
if (asset.type !== backendModule.AssetType.secret) {
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
throw new Error('`SecretNameColumn` can only display secrets.')
|
throw new Error('`SecretNameColumn` can only display secrets.')
|
||||||
}
|
}
|
||||||
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.
|
|
||||||
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
|
|
||||||
const doRename = async () => {
|
|
||||||
await Promise.resolve(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
eventHooks.useEventHandler(assetEvents, async event => {
|
eventHooks.useEventHandler(assetEvents, async event => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
@ -122,7 +119,8 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
|||||||
onClick={event => {
|
onClick={event => {
|
||||||
if (
|
if (
|
||||||
eventModule.isSingleClick(event) &&
|
eventModule.isSingleClick(event) &&
|
||||||
(selected || shortcuts.matchesMouseAction(shortcutsModule.MouseAction.editName, event))
|
(selected ||
|
||||||
|
shortcutManager.matchesMouseAction(shortcutManagerModule.MouseAction.editName, event))
|
||||||
) {
|
) {
|
||||||
setRowState(object.merger({ isEditingName: true }))
|
setRowState(object.merger({ isEditingName: true }))
|
||||||
} else if (eventModule.isDoubleClick(event)) {
|
} else if (eventModule.isDoubleClick(event)) {
|
||||||
@ -144,27 +142,10 @@ export default function SecretNameColumn(props: SecretNameColumnProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<img src={ConnectorIcon} className="m-1" />
|
<img src={ConnectorIcon} className="m-1" />
|
||||||
<EditableSpan
|
{/* Secrets cannot be renamed. */}
|
||||||
editable={false}
|
<span data-testid="asset-row-name" className="bg-transparent grow leading-170 h-6 py-px">
|
||||||
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"
|
|
||||||
>
|
|
||||||
{asset.title}
|
{asset.title}
|
||||||
</EditableSpan>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,15 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
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 PermissionSelector from '#/components/dashboard/PermissionSelector'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as object from '#/utilities/object'
|
||||||
|
|
||||||
/** Props for a {@link UserPermissions}. */
|
/** Props for a {@link UserPermissions}. */
|
||||||
export interface UserPermissionsProps {
|
export interface UserPermissionsProps {
|
||||||
asset: backendModule.Asset
|
asset: backendModule.Asset
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
/** @file Column types and column display modes. */
|
/** @file Column types and column display modes. */
|
||||||
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
|
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 * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import DocsColumn from '#/components/dashboard/column/DocsColumn'
|
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 PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
|
||||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
||||||
|
|
||||||
|
import type * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import type AssetTreeNode from '#/utilities/AssetTreeNode'
|
||||||
|
|
||||||
// ===================
|
// ===================
|
||||||
// === AssetColumn ===
|
// === AssetColumn ===
|
||||||
// ===================
|
// ===================
|
||||||
@ -18,8 +20,8 @@ import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
|||||||
/** Props for an arbitrary variant of {@link backendModule.Asset}. */
|
/** Props for an arbitrary variant of {@link backendModule.Asset}. */
|
||||||
export interface AssetColumnProps {
|
export interface AssetColumnProps {
|
||||||
keyProp: backendModule.AssetId
|
keyProp: backendModule.AssetId
|
||||||
item: assetTreeNode.AssetTreeNode
|
item: AssetTreeNode
|
||||||
setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AssetTreeNode>>
|
setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||||
selected: boolean
|
selected: boolean
|
||||||
setSelected: (selected: boolean) => void
|
setSelected: (selected: boolean) => void
|
||||||
isSoleSelectedItem: boolean
|
isSoleSelectedItem: boolean
|
||||||
|
@ -4,17 +4,13 @@ import * as React from 'react'
|
|||||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
import Plus2Icon from 'enso-assets/plus2.svg'
|
||||||
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
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 authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import type * as backendModule from '#/services/backend'
|
|
||||||
import * as assetQuery from '#/utilities/assetQuery'
|
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||||
import * as object from '#/utilities/object'
|
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
|
||||||
import * as permissions from '#/utilities/permissions'
|
|
||||||
import * as shortcuts from '#/utilities/shortcuts'
|
|
||||||
import * as uniqueString from '#/utilities/uniqueString'
|
|
||||||
|
|
||||||
import ContextMenu from '#/components/ContextMenu'
|
import ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenus from '#/components/ContextMenus'
|
import ContextMenus from '#/components/ContextMenus'
|
||||||
@ -23,6 +19,14 @@ import Label from '#/components/dashboard/Label'
|
|||||||
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
|
||||||
import MenuEntry from '#/components/MenuEntry'
|
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 ===
|
// === LabelsColumn ===
|
||||||
// ====================
|
// ====================
|
||||||
@ -71,6 +75,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
.map(label => (
|
.map(label => (
|
||||||
<Label
|
<Label
|
||||||
key={label}
|
key={label}
|
||||||
|
data-testid="asset-label"
|
||||||
title="Right click to remove label."
|
title="Right click to remove label."
|
||||||
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
|
||||||
active={!temporarilyRemovedLabels.has(label)}
|
active={!temporarilyRemovedLabels.has(label)}
|
||||||
@ -104,7 +109,7 @@ export default function LabelsColumn(props: column.AssetColumnProps) {
|
|||||||
setModal(
|
setModal(
|
||||||
<ContextMenus key={`label-${label}`} event={event}>
|
<ContextMenus key={`label-${label}`} event={event}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<MenuEntry action={shortcuts.KeyboardAction.delete} doAction={doDelete} />
|
<MenuEntry action={shortcutManager.KeyboardAction.delete} doAction={doDelete} />
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</ContextMenus>
|
</ContextMenus>
|
||||||
)
|
)
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
/** @file A column displaying the time at which the asset was last modified. */
|
/** @file A column displaying the time at which the asset was last modified. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
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. */
|
/** A column displaying the time at which the asset was last modified. */
|
||||||
export default function LastModifiedColumn(props: column.AssetColumnProps) {
|
export default function LastModifiedColumn(props: column.AssetColumnProps) {
|
||||||
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
|
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
/** @file The icon and name of an {@link backendModule.Asset}. */
|
/** @file The icon and name of an {@link backendModule.Asset}. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import DirectoryNameColumn from '#/components/dashboard/DirectoryNameColumn'
|
import DirectoryNameColumn from '#/components/dashboard/DirectoryNameColumn'
|
||||||
import FileNameColumn from '#/components/dashboard/FileNameColumn'
|
import FileNameColumn from '#/components/dashboard/FileNameColumn'
|
||||||
import ProjectNameColumn from '#/components/dashboard/ProjectNameColumn'
|
import ProjectNameColumn from '#/components/dashboard/ProjectNameColumn'
|
||||||
import SecretNameColumn from '#/components/dashboard/SecretNameColumn'
|
import SecretNameColumn from '#/components/dashboard/SecretNameColumn'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === AssetName ===
|
// === AssetName ===
|
||||||
// =================
|
// =================
|
||||||
|
@ -3,19 +3,23 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import Plus2Icon from 'enso-assets/plus2.svg'
|
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 authProvider from '#/providers/AuthProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import type * as backendModule from '#/services/backend'
|
|
||||||
import * as object from '#/utilities/object'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import * as permissions from '#/utilities/permissions'
|
|
||||||
import * as uniqueString from '#/utilities/uniqueString'
|
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||||
|
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
|
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 ===
|
// === SharedWithColumn ===
|
||||||
// ========================
|
// ========================
|
||||||
|
@ -6,8 +6,9 @@ import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
|||||||
import SortDescendingIcon from 'enso-assets/sort_descending.svg'
|
import SortDescendingIcon from 'enso-assets/sort_descending.svg'
|
||||||
import TagIcon from 'enso-assets/tag.svg'
|
import TagIcon from 'enso-assets/tag.svg'
|
||||||
|
|
||||||
import * as backend from '#/services/backend'
|
import * as backend from '#/services/Backend'
|
||||||
import * as sorting from '#/utilities/sorting'
|
|
||||||
|
import SortDirection from '#/utilities/SortDirection'
|
||||||
|
|
||||||
// =============
|
// =============
|
||||||
// === Types ===
|
// === Types ===
|
||||||
@ -110,12 +111,14 @@ export function getColumnList(backendType: backend.BackendType, extraColumns: Se
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} // =================
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
/** The corresponding icon URL for each {@link sorting.SortDirection}. */
|
|
||||||
|
|
||||||
export const SORT_ICON: Record<sorting.SortDirection, string> = {
|
/** The corresponding icon URL for each {@link SortDirection}. */
|
||||||
[sorting.SortDirection.ascending]: SortAscendingIcon,
|
export const SORT_ICON: Record<SortDirection, string> = {
|
||||||
[sorting.SortDirection.descending]: SortDescendingIcon,
|
[SortDirection.ascending]: SortAscendingIcon,
|
||||||
|
[SortDirection.descending]: SortDescendingIcon,
|
||||||
}
|
}
|
||||||
|
@ -4,12 +4,12 @@ import * as React from 'react'
|
|||||||
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||||
import TimeIcon from 'enso-assets/time.svg'
|
import TimeIcon from 'enso-assets/time.svg'
|
||||||
|
|
||||||
import * as sorting from '#/utilities/sorting'
|
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
|
import SortDirection, * as sortDirectionModule from '#/utilities/SortDirection'
|
||||||
|
|
||||||
/** A heading for the "Modified" column. */
|
/** A heading for the "Modified" column. */
|
||||||
export default function ModifiedColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
export default function ModifiedColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
||||||
const { state } = props
|
const { state } = props
|
||||||
@ -17,7 +17,14 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
|||||||
const [isHovered, setIsHovered] = React.useState(false)
|
const [isHovered, setIsHovered] = React.useState(false)
|
||||||
const isSortActive = sortColumn === columnUtils.Column.modified && sortDirection != null
|
const isSortActive = sortColumn === columnUtils.Column.modified && sortDirection != null
|
||||||
return (
|
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"
|
className="flex items-center cursor-pointer gap-2"
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
setIsHovered(true)
|
setIsHovered(true)
|
||||||
@ -28,10 +35,10 @@ export default function ModifiedColumnHeading(props: column.AssetColumnHeadingPr
|
|||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (sortColumn === columnUtils.Column.modified) {
|
if (sortColumn === columnUtils.Column.modified) {
|
||||||
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
setSortDirection(sortDirectionModule.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
||||||
} else {
|
} else {
|
||||||
setSortColumn(columnUtils.Column.modified)
|
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]}
|
{columnUtils.COLUMN_NAME[columnUtils.Column.modified]}
|
||||||
</span>
|
</span>
|
||||||
<img
|
<img
|
||||||
|
alt={
|
||||||
|
!isSortActive || sortDirection === SortDirection.ascending
|
||||||
|
? 'Sort Ascending'
|
||||||
|
: 'Sort Descending'
|
||||||
|
}
|
||||||
src={isSortActive ? columnUtils.SORT_ICON[sortDirection] : SortAscendingIcon}
|
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 SortAscendingIcon from 'enso-assets/sort_ascending.svg'
|
||||||
|
|
||||||
import * as sorting from '#/utilities/sorting'
|
|
||||||
|
|
||||||
import type * as column from '#/components/dashboard/column'
|
import type * as column from '#/components/dashboard/column'
|
||||||
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
import * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
|
|
||||||
|
import SortDirection, * as sortDirectionModule from '#/utilities/SortDirection'
|
||||||
|
|
||||||
/** A heading for the "Name" column. */
|
/** A heading for the "Name" column. */
|
||||||
export default function NameColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
export default function NameColumnHeading(props: column.AssetColumnHeadingProps): JSX.Element {
|
||||||
const { state } = props
|
const { state } = props
|
||||||
@ -15,8 +15,15 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
const [isHovered, setIsHovered] = React.useState(false)
|
const [isHovered, setIsHovered] = React.useState(false)
|
||||||
const isSortActive = sortColumn === columnUtils.Column.name && sortDirection != null
|
const isSortActive = sortColumn === columnUtils.Column.name && sortDirection != null
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className="flex items-center cursor-pointer gap-2 pt-1 pb-1.5"
|
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={() => {
|
onMouseEnter={() => {
|
||||||
setIsHovered(true)
|
setIsHovered(true)
|
||||||
}}
|
}}
|
||||||
@ -26,10 +33,10 @@ export default function NameColumnHeading(props: column.AssetColumnHeadingProps)
|
|||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
if (sortColumn === columnUtils.Column.name) {
|
if (sortColumn === columnUtils.Column.name) {
|
||||||
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
setSortDirection(sortDirectionModule.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
|
||||||
} else {
|
} else {
|
||||||
setSortColumn(columnUtils.Column.name)
|
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]}
|
{columnUtils.COLUMN_NAME[columnUtils.Column.name]}
|
||||||
</span>
|
</span>
|
||||||
<img
|
<img
|
||||||
|
alt={
|
||||||
|
!isSortActive || sortDirection === SortDirection.ascending
|
||||||
|
? 'Sort Ascending'
|
||||||
|
: 'Sort Descending'
|
||||||
|
}
|
||||||
src={isSortActive ? columnUtils.SORT_ICON[sortDirection] : SortAscendingIcon}
|
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 WindowsKeyIcon from 'enso-assets/windows_key.svg'
|
||||||
import * as detect from 'enso-common/src/detect'
|
import * as detect from 'enso-common/src/detect'
|
||||||
|
|
||||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
|
||||||
|
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
|
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||||
|
|
||||||
// ========================
|
// ========================
|
||||||
// === KeyboardShortcut ===
|
// === KeyboardShortcut ===
|
||||||
// ========================
|
// ========================
|
||||||
@ -25,7 +26,7 @@ const ICON_STYLE = { width: ICON_SIZE_PX, height: ICON_SIZE_PX }
|
|||||||
/** Icons for modifier keys (if they exist). */
|
/** Icons for modifier keys (if they exist). */
|
||||||
const MODIFIER_MAPPINGS: Record<
|
const MODIFIER_MAPPINGS: Record<
|
||||||
detect.Platform,
|
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.
|
// The names are intentionally not in `camelCase`, as they are case-sensitive.
|
||||||
/* eslint-disable @typescript-eslint/naming-convention */
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
@ -59,20 +60,20 @@ const MODIFIER_MAPPINGS: Record<
|
|||||||
|
|
||||||
/** Props for a {@link KeyboardShortcut} */
|
/** Props for a {@link KeyboardShortcut} */
|
||||||
export interface KeyboardShortcutProps {
|
export interface KeyboardShortcutProps {
|
||||||
action: shortcutsModule.KeyboardAction
|
action: shortcutManagerModule.KeyboardAction
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A visual representation of a keyboard shortcut. */
|
/** A visual representation of a keyboard shortcut. */
|
||||||
export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
export default function KeyboardShortcut(props: KeyboardShortcutProps) {
|
||||||
const { action } = props
|
const { action } = props
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const shortcut = shortcuts.keyboardShortcuts[action][0]
|
const shortcut = shortcutManager.keyboardShortcuts[action][0]
|
||||||
if (shortcut == null) {
|
if (shortcut == null) {
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center h-6 ${detect.isOnMacOS() ? 'gap-0.5' : 'gap-0.75'}`}>
|
<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 =>
|
||||||
MODIFIER_MAPPINGS[detect.platform()][modifier] ?? (
|
MODIFIER_MAPPINGS[detect.platform()][modifier] ?? (
|
||||||
<span key={modifier} className="leading-170 h-6 py-px">
|
<span key={modifier} className="leading-170 h-6 py-px">
|
||||||
|
@ -1,41 +1,8 @@
|
|||||||
/** @file Entry point into the cloud dashboard. */
|
/** @file Entry point into the cloud dashboard. */
|
||||||
import * as detect from 'enso-common/src/detect'
|
|
||||||
|
|
||||||
import '#/tailwind.css'
|
import '#/tailwind.css'
|
||||||
|
|
||||||
import * as authentication from '#/index'
|
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 ===
|
// === Entry point ===
|
||||||
// ===================
|
// ===================
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
/** @file Events related to changes in asset state. */
|
/** @file Events related to changes in asset state. */
|
||||||
import type AssetEventType from '#/events/AssetEventType'
|
import type AssetEventType from '#/events/AssetEventType'
|
||||||
import type * as backendModule from '#/services/backend'
|
|
||||||
|
|
||||||
import type * as spinner from '#/components/Spinner'
|
import type * as spinner from '#/components/Spinner'
|
||||||
|
|
||||||
|
import type * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
// This is required, to whitelist this event.
|
// This is required, to whitelist this event.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
declare module '#/hooks/eventHooks' {
|
declare module '#/hooks/eventHooks' {
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
/** @file Events related to changes in the asset list. */
|
/** @file Events related to changes in the asset list. */
|
||||||
import type AssetListEventType from '#/events/AssetListEventType'
|
import type AssetListEventType from '#/events/AssetListEventType'
|
||||||
import type * as backend from '#/services/backend'
|
|
||||||
|
|
||||||
import type * as spinner from '#/components/Spinner'
|
import type * as spinner from '#/components/Spinner'
|
||||||
|
|
||||||
|
import type * as backend from '#/services/Backend'
|
||||||
|
|
||||||
// This is required, to whitelist this event.
|
// This is required, to whitelist this event.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
declare module '#/hooks/eventHooks' {
|
declare module '#/hooks/eventHooks' {
|
||||||
|
@ -5,6 +5,7 @@ import * as React from 'react'
|
|||||||
import * as router from 'react-router'
|
import * as router from 'react-router'
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
import * as appUtils from '#/appUtils'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
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 toastify from 'react-toastify'
|
||||||
|
|
||||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||||
|
|
||||||
import * as errorModule from '#/utilities/error'
|
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 type * as app from '#/App'
|
||||||
import App from '#/App'
|
import App from '#/App'
|
||||||
|
|
||||||
import * as config from '#/utilities/config'
|
import * as config from '#/utilities/config'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
|
@ -3,24 +3,21 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as toast from 'react-toastify'
|
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 AssetEventType from '#/events/AssetEventType'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
|
||||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||||
import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu'
|
import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu'
|
||||||
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
|
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
|
||||||
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
|
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
|
||||||
import UpsertSecretModal from '#/layouts/dashboard/UpsertSecretModal'
|
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 ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenus from '#/components/ContextMenus'
|
import ContextMenus from '#/components/ContextMenus'
|
||||||
@ -29,6 +26,14 @@ import type * as assetRow from '#/components/dashboard/AssetRow'
|
|||||||
import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal'
|
import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal'
|
||||||
import MenuEntry from '#/components/MenuEntry'
|
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 ===
|
// === AssetContextMenu ===
|
||||||
// ========================
|
// ========================
|
||||||
@ -98,7 +103,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu hidden={hidden}>
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.restoreFromTrash}
|
action={shortcutManager.KeyboardAction.restoreFromTrash}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
@ -119,7 +124,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
!isOtherUserUsingProject && (
|
!isOtherUserUsingProject && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.open}
|
action={shortcutManager.KeyboardAction.open}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
@ -134,7 +139,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{asset.type === backendModule.AssetType.project && isCloud && (
|
{asset.type === backendModule.AssetType.project && isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.run}
|
action={shortcutManager.KeyboardAction.run}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
@ -152,7 +157,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
!isOtherUserUsingProject && (
|
!isOtherUserUsingProject && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.close}
|
action={shortcutManager.KeyboardAction.close}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
@ -165,15 +170,15 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{asset.type === backendModule.AssetType.project && !isCloud && (
|
{asset.type === backendModule.AssetType.project && !isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.uploadToCloud}
|
action={shortcutManager.KeyboardAction.uploadToCloud}
|
||||||
doAction={async () => {
|
doAction={async () => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
if (accessToken == null) {
|
if (accessToken == null) {
|
||||||
toastAndLog('Cannot upload to cloud in offline mode')
|
toastAndLog('Cannot upload to cloud in offline mode')
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const client = new http.Client([['Authorization', `Bearer ${accessToken}`]])
|
const client = new HttpClient([['Authorization', `Bearer ${accessToken}`]])
|
||||||
const remoteBackend = new remoteBackendModule.RemoteBackend(client, logger)
|
const remoteBackend = new RemoteBackend(client, logger)
|
||||||
const projectResponse = await fetch(
|
const projectResponse = await fetch(
|
||||||
`./api/project-manager/projects/${asset.id}/enso-project`
|
`./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.project &&
|
||||||
asset.type !== backendModule.AssetType.directory
|
asset.type !== backendModule.AssetType.directory
|
||||||
}
|
}
|
||||||
action={shortcuts.KeyboardAction.rename}
|
action={shortcutManager.KeyboardAction.rename}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
setRowState(object.merger({ isEditingName: true }))
|
setRowState(object.merger({ isEditingName: true }))
|
||||||
unsetModal()
|
unsetModal()
|
||||||
@ -215,7 +220,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{asset.type === backendModule.AssetType.secret && canEditThisAsset && (
|
{asset.type === backendModule.AssetType.secret && canEditThisAsset && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.edit}
|
action={shortcutManager.KeyboardAction.edit}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<UpsertSecretModal
|
<UpsertSecretModal
|
||||||
@ -237,7 +242,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled
|
disabled
|
||||||
action={shortcuts.KeyboardAction.snapshot}
|
action={shortcutManager.KeyboardAction.snapshot}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
// No backend support yet.
|
// No backend support yet.
|
||||||
}}
|
}}
|
||||||
@ -248,8 +253,8 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={
|
action={
|
||||||
backend.type === backendModule.BackendType.local
|
backend.type === backendModule.BackendType.local
|
||||||
? shortcuts.KeyboardAction.delete
|
? shortcutManager.KeyboardAction.delete
|
||||||
: shortcuts.KeyboardAction.moveToTrash
|
: shortcutManager.KeyboardAction.moveToTrash
|
||||||
}
|
}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
if (backend.type === backendModule.BackendType.remote) {
|
if (backend.type === backendModule.BackendType.remote) {
|
||||||
@ -270,7 +275,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{isCloud && managesThisAsset && self != null && (
|
{isCloud && managesThisAsset && self != null && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.share}
|
action={shortcutManager.KeyboardAction.share}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<ManagePermissionsModal
|
<ManagePermissionsModal
|
||||||
@ -292,7 +297,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{isCloud && (
|
{isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.label}
|
action={shortcutManager.KeyboardAction.label}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<ManageLabelsModal
|
<ManageLabelsModal
|
||||||
@ -310,7 +315,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled={!isCloud}
|
disabled={!isCloud}
|
||||||
action={shortcuts.KeyboardAction.duplicate}
|
action={shortcutManager.KeyboardAction.duplicate}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
@ -322,15 +327,19 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isCloud && (
|
{isCloud && (
|
||||||
<MenuEntry hidden={hidden} action={shortcuts.KeyboardAction.copy} doAction={doCopy} />
|
<MenuEntry
|
||||||
|
hidden={hidden}
|
||||||
|
action={shortcutManager.KeyboardAction.copy}
|
||||||
|
doAction={doCopy}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{isCloud && !isOtherUserUsingProject && (
|
{isCloud && !isOtherUserUsingProject && (
|
||||||
<MenuEntry hidden={hidden} action={shortcuts.KeyboardAction.cut} doAction={doCut} />
|
<MenuEntry hidden={hidden} action={shortcutManager.KeyboardAction.cut} doAction={doCut} />
|
||||||
)}
|
)}
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
disabled={isCloud && asset.type !== backendModule.AssetType.file}
|
disabled={isCloud && asset.type !== backendModule.AssetType.file}
|
||||||
action={shortcuts.KeyboardAction.download}
|
action={shortcutManager.KeyboardAction.download}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
@ -342,7 +351,7 @@ export default function AssetContextMenu(props: AssetContextMenuProps) {
|
|||||||
{hasPasteData && (
|
{hasPasteData && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.paste}
|
action={shortcutManager.KeyboardAction.paste}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
const [directoryKey, directoryId] =
|
const [directoryKey, directoryId] =
|
||||||
item.item.type === backendModule.AssetType.directory
|
item.item.type === backendModule.AssetType.directory
|
||||||
|
@ -3,24 +3,46 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import FindIcon from 'enso-assets/find.svg'
|
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'
|
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 {
|
export interface Suggestion {
|
||||||
render: () => React.ReactNode
|
render: () => React.ReactNode
|
||||||
addToQuery: (query: assetQuery.AssetQuery) => assetQuery.AssetQuery
|
addToQuery: (query: AssetQuery) => AssetQuery
|
||||||
deleteFromQuery: (query: assetQuery.AssetQuery) => assetQuery.AssetQuery
|
deleteFromQuery: (query: AssetQuery) => AssetQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// === AssetSearchBar ===
|
||||||
|
// ======================
|
||||||
|
|
||||||
/** Props for a {@link AssetSearchBar}. */
|
/** Props for a {@link AssetSearchBar}. */
|
||||||
export interface AssetSearchBarProps {
|
export interface AssetSearchBarProps {
|
||||||
query: assetQuery.AssetQuery
|
query: AssetQuery
|
||||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||||
labels: backend.Label[]
|
labels: backend.Label[]
|
||||||
suggestions: Suggestion[]
|
suggestions: Suggestion[]
|
||||||
}
|
}
|
||||||
@ -28,7 +50,6 @@ export interface AssetSearchBarProps {
|
|||||||
/** A search bar containing a text input, and a list of suggestions. */
|
/** A search bar containing a text input, and a list of suggestions. */
|
||||||
export default function AssetSearchBar(props: AssetSearchBarProps) {
|
export default function AssetSearchBar(props: AssetSearchBarProps) {
|
||||||
const { query, setQuery, labels, suggestions: rawSuggestions } = props
|
const { query, setQuery, labels, suggestions: rawSuggestions } = props
|
||||||
const [isTabbing, setIsTabbing] = React.useState(false)
|
|
||||||
/** A cached query as of the start of tabbing. */
|
/** A cached query as of the start of tabbing. */
|
||||||
const baseQuery = React.useRef(query)
|
const baseQuery = React.useRef(query)
|
||||||
const [suggestions, setSuggestions] = React.useState(rawSuggestions)
|
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 [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
|
||||||
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
|
const [areSuggestionsVisible, setAreSuggestionsVisible] = React.useState(false)
|
||||||
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
|
const areSuggestionsVisibleRef = React.useRef(areSuggestionsVisible)
|
||||||
const [wasQueryModified, setWasQueryModified] = React.useState(false)
|
const querySource = React.useRef(QuerySource.external)
|
||||||
const [wasQueryTyped, setWasQueryTyped] = React.useState(false)
|
|
||||||
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
|
const [isShiftPressed, setIsShiftPressed] = React.useState(false)
|
||||||
const rootRef = React.useRef<HTMLLabelElement>(null)
|
const rootRef = React.useRef<HTMLLabelElement>(null)
|
||||||
const searchRef = React.useRef<HTMLInputElement>(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(() => {
|
React.useEffect(() => {
|
||||||
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
areSuggestionsVisibleRef.current = areSuggestionsVisible
|
||||||
}, [areSuggestionsVisible])
|
}, [areSuggestionsVisible])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!wasQueryModified && selectedIndex == null) {
|
if (querySource.current !== QuerySource.tabbing && !isShiftPressed) {
|
||||||
setQuery(baseQuery.current)
|
baseQuery.current = query
|
||||||
}
|
}
|
||||||
// `wasQueryModified` MUST NOT be a dependency, as it is always set to `false` immediately
|
// This effect MUST only run when `query` changes.
|
||||||
// after it is set to true.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedIndex, /* should never change */ setQuery])
|
}, [query])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
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
|
let newQuery = query
|
||||||
if (wasQueryModified) {
|
|
||||||
const suggestion = selectedIndex == null ? null : suggestions[selectedIndex]
|
const suggestion = selectedIndex == null ? null : suggestions[selectedIndex]
|
||||||
if (suggestion != null) {
|
if (suggestion != null) {
|
||||||
newQuery = suggestion.addToQuery(baseQuery.current)
|
newQuery = suggestion.addToQuery(baseQuery.current)
|
||||||
@ -95,15 +116,9 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
searchRef.current.value = newQuery.toString()
|
searchRef.current.value = newQuery.toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setWasQueryModified(false)
|
// This effect MUST only run when `selectedIndex` changes.
|
||||||
}, [
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
wasQueryModified,
|
}, [selectedIndex])
|
||||||
query,
|
|
||||||
baseQuery,
|
|
||||||
selectedIndex,
|
|
||||||
suggestions,
|
|
||||||
/* should never change */ setQuery,
|
|
||||||
])
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
@ -111,17 +126,27 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
if (areSuggestionsVisibleRef.current) {
|
if (areSuggestionsVisibleRef.current) {
|
||||||
if (event.key === 'Tab') {
|
if (event.key === 'Tab') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsTabbing(true)
|
querySource.current = QuerySource.tabbing
|
||||||
setSelectedIndex(oldIndex => {
|
setSelectedIndex(oldIndex => {
|
||||||
|
const length = Math.max(1, suggestionsRef.current.length)
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
return oldIndex == null
|
return oldIndex == null ? length - 1 : (oldIndex + length - 1) % length
|
||||||
? suggestionsRef.current.length - 1
|
|
||||||
: (oldIndex + suggestionsRef.current.length - 1) % suggestionsRef.current.length
|
|
||||||
} else {
|
} 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') {
|
if (event.key === 'Escape') {
|
||||||
@ -132,14 +157,14 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
!(event.target instanceof HTMLInputElement) &&
|
!(event.target instanceof HTMLInputElement) &&
|
||||||
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
|
(!(event.target instanceof HTMLElement) || !event.target.isContentEditable) &&
|
||||||
(!(event.target instanceof Node) || rootRef.current?.contains(event.target) !== true) &&
|
(!(event.target instanceof Node) || rootRef.current?.contains(event.target) !== true) &&
|
||||||
shortcuts.isTextInputEvent(event)
|
shortcutManager.isTextInputEvent(event)
|
||||||
) {
|
) {
|
||||||
searchRef.current?.focus()
|
searchRef.current?.focus()
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
event.target instanceof Node &&
|
event.target instanceof Node &&
|
||||||
rootRef.current?.contains(event.target) === true &&
|
rootRef.current?.contains(event.target) === true &&
|
||||||
shortcuts.isPotentiallyShortcut(event)
|
shortcutManager.isPotentiallyShortcut(event)
|
||||||
) {
|
) {
|
||||||
searchRef.current?.focus()
|
searchRef.current?.focus()
|
||||||
}
|
}
|
||||||
@ -155,39 +180,30 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Reset `querySource` after all other effects have run.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
if (querySource.current !== QuerySource.typing && searchRef.current != null) {
|
||||||
if (areSuggestionsVisibleRef.current) {
|
searchRef.current.value = query.toString()
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
}
|
||||||
|
if (querySource.current !== QuerySource.tabbing) {
|
||||||
baseQuery.current = query
|
baseQuery.current = query
|
||||||
setIsTabbing(false)
|
querySource.current = QuerySource.external
|
||||||
setSelectedIndex(null)
|
|
||||||
searchRef.current?.focus()
|
|
||||||
const end = searchRef.current?.value.length ?? 0
|
|
||||||
searchRef.current?.setSelectionRange(end, end)
|
|
||||||
}
|
}
|
||||||
if (event.key === 'Enter') {
|
}, [query, /* should never change */ setQuery])
|
||||||
setAreSuggestionsVisible(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener('keydown', onKeyDown)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
|
||||||
}
|
|
||||||
}, [query, setQuery])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
|
data-testid="asset-search-bar"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
setAreSuggestionsVisible(true)
|
setAreSuggestionsVisible(true)
|
||||||
}}
|
}}
|
||||||
onBlur={event => {
|
onBlur={event => {
|
||||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
setIsTabbing(false)
|
if (querySource.current === QuerySource.tabbing) {
|
||||||
setSelectedIndex(null)
|
querySource.current = QuerySource.external
|
||||||
|
}
|
||||||
setAreSuggestionsVisible(false)
|
setAreSuggestionsVisible(false)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -200,15 +216,10 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
size={1}
|
size={1}
|
||||||
placeholder="Type to search for projects, data connectors, users, and more."
|
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"
|
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 => {
|
onChange={event => {
|
||||||
if (!wasQueryModified) {
|
if (querySource.current !== QuerySource.internal) {
|
||||||
setQuery(assetQuery.AssetQuery.fromString(event.target.value))
|
querySource.current = QuerySource.typing
|
||||||
setWasQueryTyped(true)
|
setQuery(AssetQuery.fromString(event.target.value))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
@ -219,6 +230,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
!event.ctrlKey
|
!event.ctrlKey
|
||||||
) {
|
) {
|
||||||
|
// Clone the query to refresh results.
|
||||||
setQuery(query.clone())
|
setQuery(query.clone())
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -228,8 +240,11 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
{areSuggestionsVisible && (
|
{areSuggestionsVisible && (
|
||||||
<div className="relative flex flex-col gap-2">
|
<div className="relative flex flex-col gap-2">
|
||||||
{/* Tags (`name:`, `modified:`, etc.) */}
|
{/* Tags (`name:`, `modified:`, etc.) */}
|
||||||
<div className="flex flex-wrap gap-2 whitespace-nowrap px-2 pointer-events-auto">
|
<div
|
||||||
{assetQuery.AssetQuery.tagNames.flatMap(entry => {
|
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
|
const [key, tag] = entry
|
||||||
return tag == null || isShiftPressed !== tag.startsWith('-')
|
return tag == null || isShiftPressed !== tag.startsWith('-')
|
||||||
? []
|
? []
|
||||||
@ -238,13 +253,8 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
key={key}
|
key={key}
|
||||||
className="bg-frame rounded-full h-6 px-2 hover:bg-frame-selected transition-all"
|
className="bg-frame rounded-full h-6 px-2 hover:bg-frame-selected transition-all"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setWasQueryModified(true)
|
querySource.current = QuerySource.internal
|
||||||
setSelectedIndex(null)
|
setQuery(query.add({ [key]: [[]] }))
|
||||||
setQuery(oldQuery => {
|
|
||||||
const newQuery = oldQuery.add({ [key]: [[]] })
|
|
||||||
baseQuery.current = newQuery
|
|
||||||
return newQuery
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tag}:
|
{tag}:
|
||||||
@ -253,7 +263,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{/* Asset labels */}
|
{/* 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 => {
|
{labels.map(label => {
|
||||||
const negated = query.negativeLabels.some(term =>
|
const negated = query.negativeLabels.some(term =>
|
||||||
array.shallowEqual(term, [label.value])
|
array.shallowEqual(term, [label.value])
|
||||||
@ -268,8 +278,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
}
|
}
|
||||||
negated={negated}
|
negated={negated}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
setWasQueryModified(true)
|
querySource.current = QuerySource.internal
|
||||||
setSelectedIndex(null)
|
|
||||||
setQuery(oldQuery => {
|
setQuery(oldQuery => {
|
||||||
const newQuery = assetQuery.toggleLabel(
|
const newQuery = assetQuery.toggleLabel(
|
||||||
oldQuery,
|
oldQuery,
|
||||||
@ -289,7 +298,10 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
{/* Suggestions */}
|
{/* Suggestions */}
|
||||||
<div className="flex flex-col max-h-[16rem] overflow-y-auto">
|
<div className="flex flex-col max-h-[16rem] overflow-y-auto">
|
||||||
{suggestions.map((suggestion, index) => (
|
{suggestions.map((suggestion, index) => (
|
||||||
|
// This should not be a `<button>`, since `render()` may output a
|
||||||
|
// tree containing a button.
|
||||||
<div
|
<div
|
||||||
|
data-testid="asset-search-suggestion"
|
||||||
key={index}
|
key={index}
|
||||||
ref={el => {
|
ref={el => {
|
||||||
if (index === selectedIndex) {
|
if (index === selectedIndex) {
|
||||||
@ -305,8 +317,7 @@ export default function AssetSearchBar(props: AssetSearchBarProps) {
|
|||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
setSelectedIndex(null)
|
querySource.current = QuerySource.internal
|
||||||
setWasQueryModified(true)
|
|
||||||
setQuery(
|
setQuery(
|
||||||
selectedIndices.has(index)
|
selectedIndices.has(index)
|
||||||
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
? suggestion.deleteFromQuery(event.shiftKey ? query : baseQuery.current)
|
||||||
|
@ -3,30 +3,35 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import PenIcon from 'enso-assets/pen.svg'
|
import PenIcon from 'enso-assets/pen.svg'
|
||||||
|
|
||||||
import type * as assetEvent from '#/events/assetEvent'
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
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 Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||||
import type * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
import type * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||||
import UserBar from '#/layouts/dashboard/UserBar'
|
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 Button from '#/components/Button'
|
||||||
import AssetInfoBar from '#/components/dashboard/AssetInfoBar'
|
import AssetInfoBar from '#/components/dashboard/AssetInfoBar'
|
||||||
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
|
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 ===
|
// === AssetSettingsPanel ===
|
||||||
// ==========================
|
// ==========================
|
||||||
|
|
||||||
/** The subset of {@link AssetSettingsPanelProps} that are required to be supplied by the row. */
|
/** The subset of {@link AssetSettingsPanelProps} that are required to be supplied by the row. */
|
||||||
export interface AssetSettingsPanelRequiredProps {
|
export interface AssetSettingsPanelRequiredProps {
|
||||||
item: assetTreeNode.AssetTreeNode
|
item: AssetTreeNode
|
||||||
setItem: React.Dispatch<React.SetStateAction<assetTreeNode.AssetTreeNode>>
|
setItem: React.Dispatch<React.SetStateAction<AssetTreeNode>>
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for a {@link AssetSettingsPanel}. */
|
/** Props for a {@link AssetSettingsPanel}. */
|
||||||
@ -58,7 +63,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
|||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const setItem = React.useCallback(
|
const setItem = React.useCallback(
|
||||||
(valueOrUpdater: React.SetStateAction<assetTreeNode.AssetTreeNode>) => {
|
(valueOrUpdater: React.SetStateAction<AssetTreeNode>) => {
|
||||||
innerSetItem(valueOrUpdater)
|
innerSetItem(valueOrUpdater)
|
||||||
rawSetItem(valueOrUpdater)
|
rawSetItem(valueOrUpdater)
|
||||||
},
|
},
|
||||||
@ -97,6 +102,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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 => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
@ -137,7 +143,7 @@ export default function AssetSettingsPanel(props: AssetSettingsPanelProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<div className="py-1 self-stretch">
|
<div data-testid="asset-panel-description" className="py-1 self-stretch">
|
||||||
{!isEditingDescription ? (
|
{!isEditingDescription ? (
|
||||||
<span className="leading-170 py-px">{item.item.description}</span>
|
<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>
|
<span className="text-lg leading-144.5 h-7 py-px">Settings</span>
|
||||||
<table>
|
<table>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr data-testid="asset-panel-permissions">
|
||||||
<td className="min-w-32 px-0 py-1">
|
<td className="min-w-32 px-0 py-1">
|
||||||
<span className="inline-block leading-170 h-6 py-px">Shared with</span>
|
<span className="inline-block leading-170 h-6 py-px">Shared with</span>
|
||||||
</td>
|
</td>
|
||||||
|
@ -3,45 +3,31 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import * as toast from 'react-toastify'
|
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 type * as assetEvent from '#/events/assetEvent'
|
||||||
import AssetEventType from '#/events/AssetEventType'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import type * as assetListEvent from '#/events/assetListEvent'
|
import type * as assetListEvent from '#/events/assetListEvent'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
import * 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 assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||||
import AssetsTableContextMenu from '#/layouts/dashboard/AssetsTableContextMenu'
|
import AssetsTableContextMenu from '#/layouts/dashboard/AssetsTableContextMenu'
|
||||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||||
import DuplicateAssetsModal from '#/layouts/dashboard/DuplicateAssetsModal'
|
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 Button from '#/components/Button'
|
||||||
import type * as assetRow from '#/components/dashboard/AssetRow'
|
import type * as assetRow from '#/components/dashboard/AssetRow'
|
||||||
import 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 * as columnUtils from '#/components/dashboard/column/columnUtils'
|
||||||
import NameColumn from '#/components/dashboard/column/NameColumn'
|
import NameColumn from '#/components/dashboard/column/NameColumn'
|
||||||
import * as columnHeading from '#/components/dashboard/columnHeading'
|
import * as columnHeading from '#/components/dashboard/columnHeading'
|
||||||
@ -49,6 +35,26 @@ import Label from '#/components/dashboard/Label'
|
|||||||
import DragModal from '#/components/DragModal'
|
import DragModal from '#/components/DragModal'
|
||||||
import Spinner, * as spinner from '#/components/Spinner'
|
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 ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
@ -57,9 +63,6 @@ import Spinner, * as spinner from '#/components/Spinner'
|
|||||||
const LOADING_SPINNER_SIZE = 36
|
const LOADING_SPINNER_SIZE = 36
|
||||||
/** The number of pixels the header bar should shrink when the extra column selector is visible. */
|
/** The number of pixels the header bar should shrink when the extra column selector is visible. */
|
||||||
const TABLE_HEADER_WIDTH_SHRINKAGE_PX = 116
|
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. */
|
/** The default placeholder row. */
|
||||||
const PLACEHOLDER = (
|
const PLACEHOLDER = (
|
||||||
<span className="opacity-75">
|
<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.
|
/** Return a directory, with new children added into its list of children.
|
||||||
* All children MUST have the same asset type. */
|
* All children MUST have the same asset type. */
|
||||||
function insertAssetTreeNodeChildren(
|
function insertAssetTreeNodeChildren(
|
||||||
item: assetTreeNode.AssetTreeNode,
|
item: AssetTreeNode,
|
||||||
children: backendModule.AnyAsset[],
|
children: backendModule.AnyAsset[],
|
||||||
directoryKey: backendModule.AssetId,
|
directoryKey: backendModule.AssetId,
|
||||||
directoryId: backendModule.DirectoryId
|
directoryId: backendModule.DirectoryId
|
||||||
): assetTreeNode.AssetTreeNode {
|
): AssetTreeNode {
|
||||||
const depth = item.depth + 1
|
const depth = item.depth + 1
|
||||||
const typeOrder = children[0] != null ? backendModule.ASSET_TYPE_ORDER[children[0].type] : 0
|
const typeOrder = children[0] != null ? backendModule.ASSET_TYPE_ORDER[children[0].type] : 0
|
||||||
const nodes = (item.children ?? []).filter(
|
const nodes = (item.children ?? []).filter(
|
||||||
node => node.item.type !== backendModule.AssetType.specialEmpty
|
node => node.item.type !== backendModule.AssetType.specialEmpty
|
||||||
)
|
)
|
||||||
const nodesToInsert = children.map(asset =>
|
const nodesToInsert = children.map(asset =>
|
||||||
assetTreeNode.AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth)
|
AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth)
|
||||||
)
|
)
|
||||||
const newNodes = array.splicedBefore(
|
const newNodes = array.splicedBefore(
|
||||||
nodes,
|
nodes,
|
||||||
@ -172,12 +175,12 @@ function insertAssetTreeNodeChildren(
|
|||||||
/** Return a directory, with new children added into its list of children.
|
/** Return a directory, with new children added into its list of children.
|
||||||
* The children MAY be of different asset types. */
|
* The children MAY be of different asset types. */
|
||||||
function insertArbitraryAssetTreeNodeChildren(
|
function insertArbitraryAssetTreeNodeChildren(
|
||||||
item: assetTreeNode.AssetTreeNode,
|
item: AssetTreeNode,
|
||||||
children: backendModule.AnyAsset[],
|
children: backendModule.AnyAsset[],
|
||||||
directoryKey: backendModule.AssetId,
|
directoryKey: backendModule.AssetId,
|
||||||
directoryId: backendModule.DirectoryId,
|
directoryId: backendModule.DirectoryId,
|
||||||
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
|
getKey: ((asset: backendModule.AnyAsset) => backendModule.AssetId) | null = null
|
||||||
): assetTreeNode.AssetTreeNode {
|
): AssetTreeNode {
|
||||||
const depth = item.depth + 1
|
const depth = item.depth + 1
|
||||||
const nodes = (item.children ?? []).filter(
|
const nodes = (item.children ?? []).filter(
|
||||||
node => node.item.type !== backendModule.AssetType.specialEmpty
|
node => node.item.type !== backendModule.AssetType.specialEmpty
|
||||||
@ -199,7 +202,7 @@ function insertArbitraryAssetTreeNodeChildren(
|
|||||||
if (firstChild) {
|
if (firstChild) {
|
||||||
const typeOrder = backendModule.ASSET_TYPE_ORDER[firstChild.type]
|
const typeOrder = backendModule.ASSET_TYPE_ORDER[firstChild.type]
|
||||||
const nodesToInsert = childrenOfSpecificType.map(asset =>
|
const nodesToInsert = childrenOfSpecificType.map(asset =>
|
||||||
assetTreeNode.AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth, getKey)
|
AssetTreeNode.fromAsset(asset, directoryKey, directoryId, depth, getKey)
|
||||||
)
|
)
|
||||||
newNodes = array.splicedBefore(
|
newNodes = array.splicedBefore(
|
||||||
newNodes,
|
newNodes,
|
||||||
@ -217,9 +220,7 @@ function insertArbitraryAssetTreeNodeChildren(
|
|||||||
|
|
||||||
const CATEGORY_TO_FILTER_BY: Record<Category, backendModule.FilterBy | null> = {
|
const CATEGORY_TO_FILTER_BY: Record<Category, backendModule.FilterBy | null> = {
|
||||||
[Category.recent]: null,
|
[Category.recent]: null,
|
||||||
[Category.drafts]: null,
|
|
||||||
[Category.home]: backendModule.FilterBy.active,
|
[Category.home]: backendModule.FilterBy.active,
|
||||||
[Category.root]: null,
|
|
||||||
[Category.trash]: backendModule.FilterBy.trashed,
|
[Category.trash]: backendModule.FilterBy.trashed,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -238,19 +239,17 @@ export interface AssetsTableState {
|
|||||||
setPasteData: (pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>>) => void
|
setPasteData: (pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>>) => void
|
||||||
sortColumn: columnUtils.SortableColumn | null
|
sortColumn: columnUtils.SortableColumn | null
|
||||||
setSortColumn: (column: columnUtils.SortableColumn | null) => void
|
setSortColumn: (column: columnUtils.SortableColumn | null) => void
|
||||||
sortDirection: sorting.SortDirection | null
|
sortDirection: SortDirection | null
|
||||||
setSortDirection: (sortDirection: sorting.SortDirection | null) => void
|
setSortDirection: (sortDirection: SortDirection | null) => void
|
||||||
query: assetQuery.AssetQuery
|
query: AssetQuery
|
||||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||||
dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => void
|
||||||
assetEvents: assetEvent.AssetEvent[]
|
assetEvents: assetEvent.AssetEvent[]
|
||||||
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||||
setAssetSettingsPanelProps: React.Dispatch<
|
setAssetSettingsPanelProps: React.Dispatch<
|
||||||
React.SetStateAction<assetSettingsPanel.AssetSettingsPanelRequiredProps | null>
|
React.SetStateAction<assetSettingsPanel.AssetSettingsPanelRequiredProps | null>
|
||||||
>
|
>
|
||||||
nodeMap: Readonly<
|
nodeMap: Readonly<React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>>
|
||||||
React.MutableRefObject<ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>>
|
|
||||||
>
|
|
||||||
doToggleDirectoryExpansion: (
|
doToggleDirectoryExpansion: (
|
||||||
directoryId: backendModule.DirectoryId,
|
directoryId: backendModule.DirectoryId,
|
||||||
key: backendModule.AssetId,
|
key: backendModule.AssetId,
|
||||||
@ -279,20 +278,10 @@ export interface AssetRowState {
|
|||||||
temporarilyRemovedLabels: ReadonlySet<backendModule.LabelName>
|
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}. */
|
/** Props for a {@link AssetsTable}. */
|
||||||
export interface AssetsTableProps {
|
export interface AssetsTableProps {
|
||||||
query: assetQuery.AssetQuery
|
query: AssetQuery
|
||||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||||
setCanDownloadFiles: (canDownloadFiles: boolean) => void
|
setCanDownloadFiles: (canDownloadFiles: boolean) => void
|
||||||
category: Category
|
category: Category
|
||||||
allLabels: Map<backendModule.LabelName, backendModule.Label>
|
allLabels: Map<backendModule.LabelName, backendModule.Label>
|
||||||
@ -331,13 +320,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
const toastAndLog = toastAndLogHooks.useToastAndLog()
|
||||||
const [initialized, setInitialized] = React.useState(false)
|
const [initialized, setInitialized] = React.useState(false)
|
||||||
const [isLoading, setIsLoading] = React.useState(true)
|
const [isLoading, setIsLoading] = React.useState(true)
|
||||||
const [extraColumns, setExtraColumns] = React.useState(() => new Set<columnUtils.ExtraColumn>())
|
const [extraColumns, setExtraColumns] = React.useState(() => new Set<columnUtils.ExtraColumn>())
|
||||||
const [sortColumn, setSortColumn] = React.useState<columnUtils.SortableColumn | null>(null)
|
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 [selectedKeys, setSelectedKeys] = React.useState(() => new Set<backendModule.AssetId>())
|
||||||
const [pasteData, setPasteData] = React.useState<pasteDataModule.PasteData<
|
const [pasteData, setPasteData] = React.useState<pasteDataModule.PasteData<
|
||||||
Set<backendModule.AssetId>
|
Set<backendModule.AssetId>
|
||||||
@ -348,9 +337,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
() => organization?.rootDirectoryId ?? backendModule.DirectoryId(''),
|
() => organization?.rootDirectoryId ?? backendModule.DirectoryId(''),
|
||||||
[organization]
|
[organization]
|
||||||
)
|
)
|
||||||
const [assetTree, setAssetTree] = React.useState<assetTreeNode.AssetTreeNode>(() => {
|
const [assetTree, setAssetTree] = React.useState<AssetTreeNode>(() => {
|
||||||
const rootParentDirectoryId = backendModule.DirectoryId('')
|
const rootParentDirectoryId = backendModule.DirectoryId('')
|
||||||
return assetTreeNode.AssetTreeNode.fromAsset(
|
return AssetTreeNode.fromAsset(
|
||||||
backendModule.createRootDirectoryAsset(rootDirectoryId),
|
backendModule.createRootDirectoryAsset(rootDirectoryId),
|
||||||
rootParentDirectoryId,
|
rootParentDirectoryId,
|
||||||
rootParentDirectoryId,
|
rootParentDirectoryId,
|
||||||
@ -366,19 +355,19 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
: PLACEHOLDER
|
: PLACEHOLDER
|
||||||
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
const scrollContainerRef = React.useRef<HTMLDivElement>(null)
|
||||||
const headerRowRef = React.useRef<HTMLTableRowElement>(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>(
|
const pasteDataRef = React.useRef<pasteDataModule.PasteData<Set<backendModule.AssetId>> | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>>(
|
const nodeMapRef = React.useRef<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>(
|
||||||
new Map<backendModule.AssetId, assetTreeNode.AssetTreeNode>()
|
new Map<backendModule.AssetId, AssetTreeNode>()
|
||||||
)
|
)
|
||||||
const filter = React.useMemo(() => {
|
const filter = React.useMemo(() => {
|
||||||
const globCache: Record<string, RegExp> = {}
|
const globCache: Record<string, RegExp> = {}
|
||||||
if (/^\s*$/.test(query.query)) {
|
if (/^\s*$/.test(query.query)) {
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
return (node: assetTreeNode.AssetTreeNode) => {
|
return (node: AssetTreeNode) => {
|
||||||
const assetType =
|
const assetType =
|
||||||
node.item.type === backendModule.AssetType.directory
|
node.item.type === backendModule.AssetType.directory
|
||||||
? 'folder'
|
? 'folder'
|
||||||
@ -480,32 +469,53 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
if (sortColumn == null || sortDirection == null) {
|
if (sortColumn == null || sortDirection == null) {
|
||||||
return assetTree.preorderTraversal()
|
return assetTree.preorderTraversal()
|
||||||
} else {
|
} else {
|
||||||
const sortDescendingMultiplier = -1
|
|
||||||
const multiplier = {
|
const multiplier = {
|
||||||
[sorting.SortDirection.ascending]: 1,
|
[SortDirection.ascending]: 1,
|
||||||
[sorting.SortDirection.descending]: sortDescendingMultiplier,
|
[SortDirection.descending]: -1,
|
||||||
}[sortDirection]
|
}[sortDirection]
|
||||||
let compare: (a: assetTreeNode.AssetTreeNode, b: assetTreeNode.AssetTreeNode) => number
|
let compare: (a: AssetTreeNode, b: AssetTreeNode) => number
|
||||||
switch (sortColumn) {
|
switch (sortColumn) {
|
||||||
case columnUtils.Column.name: {
|
case columnUtils.Column.name: {
|
||||||
compare = (a, b) =>
|
compare = (a, b) => {
|
||||||
multiplier *
|
const aTypeOrder = backendModule.ASSET_TYPE_ORDER[a.item.type]
|
||||||
(a.item.title > b.item.title ? 1 : a.item.title < b.item.title ? COMPARE_LESS_THAN : 0)
|
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
|
break
|
||||||
}
|
}
|
||||||
case columnUtils.Column.modified: {
|
case columnUtils.Column.modified: {
|
||||||
compare = (a, b) =>
|
compare = (a, b) => {
|
||||||
multiplier * (Number(new Date(a.item.modifiedAt)) - Number(new Date(b.item.modifiedAt)))
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return assetTree.preorderTraversal(tree => Array.from(tree).sort(compare))
|
return assetTree.preorderTraversal(tree => [...tree].sort(compare))
|
||||||
}
|
}
|
||||||
}, [assetTree, sortColumn, sortDirection])
|
}, [assetTree, sortColumn, sortDirection])
|
||||||
const visibilities = React.useMemo(() => {
|
const visibilities = React.useMemo(() => {
|
||||||
const map = new Map<backendModule.AssetId, Visibility>()
|
const map = new Map<backendModule.AssetId, Visibility>()
|
||||||
const processNode = (node: assetTreeNode.AssetTreeNode) => {
|
const processNode = (node: AssetTreeNode) => {
|
||||||
let displayState = Visibility.hidden
|
let displayState = Visibility.hidden
|
||||||
const visible = filter?.(node) ?? true
|
const visible = filter?.(node) ?? true
|
||||||
for (const child of node.children ?? []) {
|
for (const child of node.children ?? []) {
|
||||||
@ -546,7 +556,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const nodeToSuggestion = (
|
const nodeToSuggestion = (
|
||||||
node: assetTreeNode.AssetTreeNode,
|
node: AssetTreeNode,
|
||||||
key: assetQuery.AssetQueryKey = 'names'
|
key: assetQuery.AssetQueryKey = 'names'
|
||||||
): assetSearchBar.Suggestion => ({
|
): assetSearchBar.Suggestion => ({
|
||||||
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
|
render: () => `${key === 'names' ? '' : '-:'}${node.item.title}`,
|
||||||
@ -566,7 +576,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
)
|
)
|
||||||
const allVisible = (negative = false) =>
|
const allVisible = (negative = false) =>
|
||||||
allVisibleNodes().map(node => nodeToSuggestion(node, negative ? 'negativeNames' : 'names'))
|
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 term = terms.find(otherTerm => otherTerm.values.length === 0) ?? terms[terms.length - 1]
|
||||||
const termValues = term?.values ?? []
|
const termValues = term?.values ?? []
|
||||||
const shouldOmitNames = terms.some(otherTerm => otherTerm.tag === 'name')
|
const shouldOmitNames = terms.some(otherTerm => otherTerm.tag === 'name')
|
||||||
@ -613,7 +623,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
new Set(extensions),
|
new Set(extensions),
|
||||||
(extension): assetSearchBar.Suggestion => ({
|
(extension): assetSearchBar.Suggestion => ({
|
||||||
render: () =>
|
render: () =>
|
||||||
assetQuery.AssetQuery.termToString({
|
AssetQuery.termToString({
|
||||||
tag: `${negative ? '-' : ''}extension`,
|
tag: `${negative ? '-' : ''}extension`,
|
||||||
values: [extension],
|
values: [extension],
|
||||||
}),
|
}),
|
||||||
@ -641,7 +651,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
new Set(['today', ...modifieds]),
|
new Set(['today', ...modifieds]),
|
||||||
(modified): assetSearchBar.Suggestion => ({
|
(modified): assetSearchBar.Suggestion => ({
|
||||||
render: () =>
|
render: () =>
|
||||||
assetQuery.AssetQuery.termToString({
|
AssetQuery.termToString({
|
||||||
tag: `${negative ? '-' : ''}modified`,
|
tag: `${negative ? '-' : ''}modified`,
|
||||||
values: [modified],
|
values: [modified],
|
||||||
}),
|
}),
|
||||||
@ -672,7 +682,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
new Set(owners),
|
new Set(owners),
|
||||||
(owner): assetSearchBar.Suggestion => ({
|
(owner): assetSearchBar.Suggestion => ({
|
||||||
render: () =>
|
render: () =>
|
||||||
assetQuery.AssetQuery.termToString({
|
AssetQuery.termToString({
|
||||||
tag: `${negative ? '-' : ''}owner`,
|
tag: `${negative ? '-' : ''}owner`,
|
||||||
values: [owner],
|
values: [owner],
|
||||||
}),
|
}),
|
||||||
@ -744,8 +754,8 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}, [pasteData])
|
}, [pasteData])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return shortcuts.registerKeyboardHandlers({
|
return shortcutManager.registerKeyboardHandlers({
|
||||||
[shortcutsModule.KeyboardAction.cancelCut]: () => {
|
[shortcutManagerModule.KeyboardAction.cancelCut]: () => {
|
||||||
if (pasteDataRef.current == null) {
|
if (pasteDataRef.current == null) {
|
||||||
return false
|
return false
|
||||||
} else {
|
} 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(() => {
|
React.useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -798,13 +808,13 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
setInitialized(true)
|
setInitialized(true)
|
||||||
const rootParentDirectoryId = backendModule.DirectoryId('')
|
const rootParentDirectoryId = backendModule.DirectoryId('')
|
||||||
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
|
const rootDirectory = backendModule.createRootDirectoryAsset(rootDirectoryId)
|
||||||
const newRootNode = new assetTreeNode.AssetTreeNode(
|
const newRootNode = new AssetTreeNode(
|
||||||
rootDirectoryId,
|
rootDirectoryId,
|
||||||
rootDirectory,
|
rootDirectory,
|
||||||
rootParentDirectoryId,
|
rootParentDirectoryId,
|
||||||
rootParentDirectoryId,
|
rootParentDirectoryId,
|
||||||
newAssets.map(asset =>
|
newAssets.map(asset =>
|
||||||
assetTreeNode.AssetTreeNode.fromAsset(asset, rootDirectory.id, rootDirectory.id, 0)
|
AssetTreeNode.fromAsset(asset, rootDirectory.id, rootDirectory.id, 0)
|
||||||
),
|
),
|
||||||
-1
|
-1
|
||||||
)
|
)
|
||||||
@ -882,7 +892,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
case backendModule.BackendType.remote: {
|
case backendModule.BackendType.remote: {
|
||||||
const queuedDirectoryListings = new Map<backendModule.AssetId, backendModule.AnyAsset[]>()
|
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)
|
const queuedListing = queuedDirectoryListings.get(node.item.id)
|
||||||
if (queuedListing == null || !backendModule.assetIsDirectory(node.item)) {
|
if (queuedListing == null || !backendModule.assetIsDirectory(node.item)) {
|
||||||
return node
|
return node
|
||||||
@ -892,12 +902,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
return node.with({
|
return node.with({
|
||||||
children: queuedListing.map(asset =>
|
children: queuedListing.map(asset =>
|
||||||
withChildren(
|
withChildren(
|
||||||
assetTreeNode.AssetTreeNode.fromAsset(
|
AssetTreeNode.fromAsset(asset, directoryAsset.id, directoryAsset.id, depth)
|
||||||
asset,
|
|
||||||
directoryAsset.id,
|
|
||||||
directoryAsset.id,
|
|
||||||
depth
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@ -1058,7 +1063,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
? item
|
? item
|
||||||
: item.with({
|
: item.with({
|
||||||
children: [
|
children: [
|
||||||
assetTreeNode.AssetTreeNode.fromAsset(
|
AssetTreeNode.fromAsset(
|
||||||
backendModule.createSpecialLoadingAsset(directoryId),
|
backendModule.createSpecialLoadingAsset(directoryId),
|
||||||
key,
|
key,
|
||||||
directoryId,
|
directoryId,
|
||||||
@ -1098,7 +1103,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const childAssetNodes = Array.from(childAssetsMap.values(), child =>
|
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 =
|
const specialEmptyAsset: backendModule.SpecialEmptyAsset | null =
|
||||||
(initialChildren != null && initialChildren.length !== 0) ||
|
(initialChildren != null && initialChildren.length !== 0) ||
|
||||||
@ -1108,7 +1113,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
const children =
|
const children =
|
||||||
specialEmptyAsset != null
|
specialEmptyAsset != null
|
||||||
? [
|
? [
|
||||||
assetTreeNode.AssetTreeNode.fromAsset(
|
AssetTreeNode.fromAsset(
|
||||||
specialEmptyAsset,
|
specialEmptyAsset,
|
||||||
key,
|
key,
|
||||||
directoryId,
|
directoryId,
|
||||||
@ -1117,9 +1122,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
]
|
]
|
||||||
: initialChildren == null || initialChildren.length === 0
|
: initialChildren == null || initialChildren.length === 0
|
||||||
? childAssetNodes
|
? childAssetNodes
|
||||||
: [...initialChildren, ...childAssetNodes].sort(
|
: [...initialChildren, ...childAssetNodes].sort(AssetTreeNode.compare)
|
||||||
assetTreeNode.AssetTreeNode.compare
|
|
||||||
)
|
|
||||||
return item.with({ children })
|
return item.with({ children })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -1690,8 +1693,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const onDocumentClick = (event: MouseEvent) => {
|
const onDocumentClick = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
!shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditional, event) &&
|
!shortcutManager.matchesMouseAction(
|
||||||
!shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditionalRange, event) &&
|
shortcutManagerModule.MouseAction.selectAdditional,
|
||||||
|
event
|
||||||
|
) &&
|
||||||
|
!shortcutManager.matchesMouseAction(
|
||||||
|
shortcutManagerModule.MouseAction.selectAdditionalRange,
|
||||||
|
event
|
||||||
|
) &&
|
||||||
selectedKeys.size !== 0
|
selectedKeys.size !== 0
|
||||||
) {
|
) {
|
||||||
setSelectedKeys(new Set())
|
setSelectedKeys(new Set())
|
||||||
@ -1701,7 +1710,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', onDocumentClick)
|
document.removeEventListener('click', onDocumentClick)
|
||||||
}
|
}
|
||||||
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcuts])
|
}, [selectedKeys, /* should never change */ setSelectedKeys, shortcutManager])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@ -1724,28 +1733,36 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
return [key]
|
return [key]
|
||||||
} else {
|
} else {
|
||||||
const index1 = displayItems.findIndex(
|
const index1 = displayItems.findIndex(
|
||||||
innerItem => assetTreeNode.AssetTreeNode.getKey(innerItem) === previouslySelectedKey
|
innerItem => AssetTreeNode.getKey(innerItem) === previouslySelectedKey
|
||||||
)
|
)
|
||||||
const index2 = displayItems.findIndex(
|
const index2 = displayItems.findIndex(
|
||||||
innerItem => assetTreeNode.AssetTreeNode.getKey(innerItem) === key
|
innerItem => AssetTreeNode.getKey(innerItem) === key
|
||||||
)
|
)
|
||||||
const selectedItems =
|
const selectedItems =
|
||||||
index1 <= index2
|
index1 <= index2
|
||||||
? displayItems.slice(index1, index2 + 1)
|
? displayItems.slice(index1, index2 + 1)
|
||||||
: displayItems.slice(index2, index1 + 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()))
|
setSelectedKeys(new Set(getNewlySelectedKeys()))
|
||||||
} else if (
|
} else if (
|
||||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditionalRange, event)
|
shortcutManager.matchesMouseAction(
|
||||||
|
shortcutManagerModule.MouseAction.selectAdditionalRange,
|
||||||
|
event
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
setSelectedKeys(
|
setSelectedKeys(
|
||||||
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
|
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
|
||||||
)
|
)
|
||||||
} else if (
|
} else if (
|
||||||
shortcuts.matchesMouseAction(shortcutsModule.MouseAction.selectAdditional, event)
|
shortcutManager.matchesMouseAction(
|
||||||
|
shortcutManagerModule.MouseAction.selectAdditional,
|
||||||
|
event
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
setSelectedKeys(oldSelectedItems => {
|
setSelectedKeys(oldSelectedItems => {
|
||||||
const newItems = new Set(oldSelectedItems)
|
const newItems = new Set(oldSelectedItems)
|
||||||
@ -1761,24 +1778,27 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}
|
}
|
||||||
setPreviouslySelectedKey(key)
|
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 => ({
|
const columns = columnUtils.getColumnList(backend.type, extraColumns)
|
||||||
id: column,
|
|
||||||
className: columnUtils.COLUMN_CSS_CLASS[column],
|
|
||||||
heading: columnHeading.COLUMN_HEADING[column],
|
|
||||||
render: columnModule.COLUMN_RENDERER[column],
|
|
||||||
}))
|
|
||||||
|
|
||||||
const headerRow = (
|
const headerRow = (
|
||||||
<tr ref={headerRowRef} className="sticky top-0">
|
<tr ref={headerRowRef} className="sticky top-0">
|
||||||
{columns.map(column => {
|
{columns.map(column => {
|
||||||
// This is a React component, even though it does not contain JSX.
|
// This is a React component, even though it does not contain JSX.
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
const Heading = column.heading
|
const Heading = columnHeading.COLUMN_HEADING[column]
|
||||||
return (
|
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} />
|
<Heading state={state} />
|
||||||
</th>
|
</th>
|
||||||
)
|
)
|
||||||
@ -1796,21 +1816,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
displayItems.map(item => {
|
displayItems.map(item => {
|
||||||
const key = assetTreeNode.AssetTreeNode.getKey(item)
|
const key = AssetTreeNode.getKey(item)
|
||||||
const isSelected = selectedKeys.has(key)
|
const isSelected = selectedKeys.has(key)
|
||||||
const isSoleSelectedItem = selectedKeys.size === 1 && isSelected
|
const isSoleSelectedItem = selectedKeys.size === 1 && isSelected
|
||||||
return (
|
return (
|
||||||
<AssetRow
|
<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}
|
key={key}
|
||||||
keyProp={key}
|
columns={columns}
|
||||||
item={item}
|
item={item}
|
||||||
|
state={state}
|
||||||
hidden={visibilities.get(item.key) === Visibility.hidden}
|
hidden={visibilities.get(item.key) === Visibility.hidden}
|
||||||
selected={isSelected}
|
selected={isSelected}
|
||||||
setSelected={selected => {
|
setSelected={selected => {
|
||||||
@ -1827,13 +1841,11 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
setSelectedKeys(new Set([key]))
|
setSelectedKeys(new Set([key]))
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
draggable={true}
|
|
||||||
onDragStart={event => {
|
onDragStart={event => {
|
||||||
if (!selectedKeys.has(key)) {
|
if (!selectedKeys.has(key)) {
|
||||||
setPreviouslySelectedKey(key)
|
setPreviouslySelectedKey(key)
|
||||||
setSelectedKeys(new Set([key]))
|
setSelectedKeys(new Set([key]))
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedKeys(oldSelectedKeys => {
|
setSelectedKeys(oldSelectedKeys => {
|
||||||
const nodes = assetTree
|
const nodes = assetTree
|
||||||
.preorderTraversal()
|
.preorderTraversal()
|
||||||
@ -1862,7 +1874,7 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
// Default states.
|
// Default states.
|
||||||
isSoleSelectedItem={false}
|
isSoleSelectedItem={false}
|
||||||
selected={false}
|
selected={false}
|
||||||
rowState={INITIAL_ROW_STATE}
|
rowState={assetRowUtils.INITIAL_ROW_STATE}
|
||||||
// The drag placeholder cannot be interacted with.
|
// The drag placeholder cannot be interacted with.
|
||||||
setSelected={() => {}}
|
setSelected={() => {}}
|
||||||
setItem={() => {}}
|
setItem={() => {}}
|
||||||
@ -1882,6 +1894,14 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const ids = oldSelectedKeys.has(key) ? oldSelectedKeys : new Set([key])
|
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
|
let labelsPresent = 0
|
||||||
for (const selectedKey of ids) {
|
for (const selectedKey of ids) {
|
||||||
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
|
const labels = nodeMapRef.current.get(selectedKey)?.item.labels
|
||||||
@ -1921,7 +1941,15 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
}}
|
}}
|
||||||
onDrop={event => {
|
onDrop={event => {
|
||||||
setSelectedKeys(oldSelectedKeys => {
|
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)
|
const payload = drag.LABELS.lookup(event)
|
||||||
if (payload != null) {
|
if (payload != null) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@ -2051,6 +2079,9 @@ export default function AssetsTable(props: AssetsTableProps) {
|
|||||||
key={column}
|
key={column}
|
||||||
active={extraColumns.has(column)}
|
active={extraColumns.has(column)}
|
||||||
image={columnUtils.EXTRA_COLUMN_IMAGES[column]}
|
image={columnUtils.EXTRA_COLUMN_IMAGES[column]}
|
||||||
|
alt={`${extraColumns.has(column) ? 'Show' : 'Hide'} ${
|
||||||
|
columnUtils.COLUMN_NAME[column]
|
||||||
|
}`}
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
const newExtraColumns = new Set(extraColumns)
|
const newExtraColumns = new Set(extraColumns)
|
||||||
|
@ -2,27 +2,31 @@
|
|||||||
* are selected. */
|
* are selected. */
|
||||||
import * as React from 'react'
|
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 authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
import type * as assetTreeNode from '#/utilities/assetTreeNode'
|
import type * as assetEvent from '#/events/assetEvent'
|
||||||
import type * as pasteDataModule from '#/utilities/pasteData'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import * as permissions from '#/utilities/permissions'
|
import type * as assetListEvent from '#/events/assetListEvent'
|
||||||
import * as shortcuts from '#/utilities/shortcuts'
|
|
||||||
import * as string from '#/utilities/string'
|
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
||||||
import * as uniqueString from '#/utilities/uniqueString'
|
import GlobalContextMenu from '#/layouts/dashboard/GlobalContextMenu'
|
||||||
|
|
||||||
import ContextMenu from '#/components/ContextMenu'
|
import ContextMenu from '#/components/ContextMenu'
|
||||||
import ContextMenus from '#/components/ContextMenus'
|
import ContextMenus from '#/components/ContextMenus'
|
||||||
import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal'
|
import ConfirmDeleteModal from '#/components/dashboard/ConfirmDeleteModal'
|
||||||
import MenuEntry from '#/components/MenuEntry'
|
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 ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
@ -42,9 +46,7 @@ export interface AssetsTableContextMenuProps {
|
|||||||
pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>> | null
|
pasteData: pasteDataModule.PasteData<Set<backendModule.AssetId>> | null
|
||||||
selectedKeys: Set<backendModule.AssetId>
|
selectedKeys: Set<backendModule.AssetId>
|
||||||
setSelectedKeys: (items: Set<backendModule.AssetId>) => void
|
setSelectedKeys: (items: Set<backendModule.AssetId>) => void
|
||||||
nodeMapRef: React.MutableRefObject<
|
nodeMapRef: React.MutableRefObject<ReadonlyMap<backendModule.AssetId, AssetTreeNode>>
|
||||||
ReadonlyMap<backendModule.AssetId, assetTreeNode.AssetTreeNode>
|
|
||||||
>
|
|
||||||
event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'>
|
event: Pick<React.MouseEvent<Element, MouseEvent>, 'pageX' | 'pageY'>
|
||||||
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||||
dispatchAssetListEvent: (event: assetListEvent.AssetListEvent) => 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
|
// This works because all items are mutated, ensuring their value stays
|
||||||
// up to date.
|
// up to date.
|
||||||
const ownsAllSelectedAssets =
|
const ownsAllSelectedAssets =
|
||||||
isCloud ||
|
!isCloud ||
|
||||||
(organization != null &&
|
(organization != null &&
|
||||||
Array.from(selectedKeys, key => {
|
Array.from(selectedKeys, key => {
|
||||||
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
|
const userPermissions = nodeMapRef.current.get(key)?.item.permissions
|
||||||
@ -122,7 +124,7 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
<ContextMenu hidden={hidden}>
|
<ContextMenu hidden={hidden}>
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.restoreAllFromTrash}
|
action={shortcutManager.KeyboardAction.restoreAllFromTrash}
|
||||||
doAction={doRestoreAll}
|
doAction={doRestoreAll}
|
||||||
/>
|
/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
@ -132,8 +134,8 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
const deleteAction = isCloud
|
const deleteAction = isCloud
|
||||||
? shortcuts.KeyboardAction.moveAllToTrash
|
? shortcutManager.KeyboardAction.moveAllToTrash
|
||||||
: shortcuts.KeyboardAction.deleteAll
|
: shortcutManager.KeyboardAction.deleteAll
|
||||||
return (
|
return (
|
||||||
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
<ContextMenus key={uniqueString.uniqueString()} hidden={hidden} event={event}>
|
||||||
{selectedKeys.size !== 0 && (
|
{selectedKeys.size !== 0 && (
|
||||||
@ -144,21 +146,21 @@ export default function AssetsTableContextMenu(props: AssetsTableContextMenuProp
|
|||||||
{isCloud && (
|
{isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.copyAll}
|
action={shortcutManager.KeyboardAction.copyAll}
|
||||||
doAction={doCopy}
|
doAction={doCopy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{isCloud && ownsAllSelectedAssets && (
|
{isCloud && ownsAllSelectedAssets && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.cutAll}
|
action={shortcutManager.KeyboardAction.cutAll}
|
||||||
doAction={doCut}
|
doAction={doCut}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{pasteData != null && pasteData.data.size > 0 && (
|
{pasteData != null && pasteData.data.size > 0 && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.pasteAll}
|
action={shortcutManager.KeyboardAction.pasteAll}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
const [firstKey] = selectedKeys
|
const [firstKey] = selectedKeys
|
||||||
const selectedNode =
|
const selectedNode =
|
||||||
|
@ -5,10 +5,11 @@ import CloudIcon from 'enso-assets/cloud.svg'
|
|||||||
import NotCloudIcon from 'enso-assets/not_cloud.svg'
|
import NotCloudIcon from 'enso-assets/not_cloud.svg'
|
||||||
|
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
|
|
||||||
import SvgMask from '#/components/SvgMask'
|
import SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
// =======================
|
// =======================
|
||||||
// === BackendSwitcher ===
|
// === BackendSwitcher ===
|
||||||
// =======================
|
// =======================
|
||||||
|
@ -3,20 +3,21 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import Home2Icon from 'enso-assets/home2.svg'
|
import Home2Icon from 'enso-assets/home2.svg'
|
||||||
import RecentIcon from 'enso-assets/recent.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 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 type * as assetEvent from '#/events/assetEvent'
|
||||||
import AssetEventType from '#/events/AssetEventType'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
|
|
||||||
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
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 SvgMask from '#/components/SvgMask'
|
||||||
|
|
||||||
|
import * as drag from '#/utilities/drag'
|
||||||
|
import * as localStorageModule from '#/utilities/LocalStorage'
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// === CategorySwitcherItem ===
|
// === CategorySwitcherItem ===
|
||||||
// ============================
|
// ============================
|
||||||
@ -27,8 +28,6 @@ interface InternalCategorySwitcherItemProps {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
/** When true, the button is not clickable. */
|
/** When true, the button is not clickable. */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
/** A title that is only shown when `disabled` is true. */
|
|
||||||
hidden: boolean
|
|
||||||
image: string
|
image: string
|
||||||
name: string
|
name: string
|
||||||
iconClassName?: string
|
iconClassName?: string
|
||||||
@ -39,27 +38,31 @@ interface InternalCategorySwitcherItemProps {
|
|||||||
|
|
||||||
/** An entry in a {@link CategorySwitcher}. */
|
/** An entry in a {@link CategorySwitcher}. */
|
||||||
function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
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
|
const { onDragOver, onDrop } = props
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
className={`group flex items-center rounded-full gap-2 h-8 px-2 ${hidden ? 'hidden' : ''} ${
|
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'
|
active ? 'bg-frame-selected' : 'text-not-selected'
|
||||||
} ${
|
} ${disabled ? '' : 'hover:text-primary hover:bg-frame-selected hover:opacity-100'} ${
|
||||||
disabled
|
!active && disabled ? 'cursor-not-allowed' : ''
|
||||||
? ''
|
}`}
|
||||||
: 'hover:text-primary hover:bg-frame-selected cursor-pointer hover:opacity-100'
|
onClick={onClick}
|
||||||
} ${!active && disabled ? 'cursor-not-allowed' : ''}`}
|
// Required because `dragover` does not fire on `mouseenter`.
|
||||||
{...(disabled ? {} : { onClick, onDragOver, onDrop })}
|
onDragEnter={onDragOver}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
<SvgMask
|
<SvgMask
|
||||||
src={image}
|
src={image}
|
||||||
className={`${active ? 'text-icon-selected' : 'text-icon-not-selected'} ${
|
className={`group-hover:text-icon-selected ${
|
||||||
disabled ? '' : 'group-hover:text-icon-selected'
|
active ? 'text-icon-selected' : 'text-icon-not-selected'
|
||||||
} ${iconClassName ?? ''}`}
|
} ${iconClassName ?? ''}`}
|
||||||
/>
|
/>
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,35 +70,17 @@ function CategorySwitcherItem(props: InternalCategorySwitcherItemProps) {
|
|||||||
// === CategorySwitcher ===
|
// === CategorySwitcher ===
|
||||||
// ========================
|
// ========================
|
||||||
|
|
||||||
const CATEGORIES: Category[] = [
|
const CATEGORIES: Category[] = [Category.recent, Category.home, Category.trash]
|
||||||
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 CATEGORY_ICONS: Record<Category, string> = {
|
const CATEGORY_ICONS: Record<Category, string> = {
|
||||||
[Category.recent]: RecentIcon,
|
[Category.recent]: RecentIcon,
|
||||||
[Category.drafts]: TempIcon,
|
|
||||||
[Category.home]: Home2Icon,
|
[Category.home]: Home2Icon,
|
||||||
[Category.root]: RootIcon,
|
|
||||||
[Category.trash]: Trash2Icon,
|
[Category.trash]: Trash2Icon,
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_CLASS_NAMES: Record<Category, string> = {
|
const CATEGORY_CLASS_NAMES: Record<Category, string> = {
|
||||||
[Category.recent]: '-ml-0.5',
|
[Category.recent]: '-ml-0.5',
|
||||||
[Category.drafts]: '-ml-0.5',
|
|
||||||
[Category.home]: '',
|
[Category.home]: '',
|
||||||
[Category.root]: '',
|
|
||||||
[Category.trash]: '',
|
[Category.trash]: '',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@ -126,7 +111,6 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
|||||||
key={currentCategory}
|
key={currentCategory}
|
||||||
active={category === currentCategory}
|
active={category === currentCategory}
|
||||||
disabled={category === currentCategory}
|
disabled={category === currentCategory}
|
||||||
hidden={IS_NOT_YET_IMPLEMENTED[currentCategory]}
|
|
||||||
image={CATEGORY_ICONS[currentCategory]}
|
image={CATEGORY_ICONS[currentCategory]}
|
||||||
name={currentCategory}
|
name={currentCategory}
|
||||||
iconClassName={CATEGORY_CLASS_NAMES[currentCategory]}
|
iconClassName={CATEGORY_CLASS_NAMES[currentCategory]}
|
||||||
@ -154,7 +138,7 @@ export default function CategorySwitcher(props: CategorySwitcherProps) {
|
|||||||
dispatchAssetEvent({
|
dispatchAssetEvent({
|
||||||
type:
|
type:
|
||||||
category === Category.trash ? AssetEventType.restore : AssetEventType.delete,
|
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. */
|
/** The categories available in the category switcher. */
|
||||||
enum Category {
|
enum Category {
|
||||||
recent = 'Recent',
|
recent = 'Recent',
|
||||||
drafts = 'Drafts',
|
|
||||||
home = 'Home',
|
home = 'Home',
|
||||||
root = 'Root',
|
|
||||||
trash = 'Trash',
|
trash = 'Trash',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,13 +6,14 @@ import LockIcon from 'enso-assets/lock.svg'
|
|||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as string from '#/utilities/string'
|
|
||||||
import * as validation from '#/utilities/validation'
|
|
||||||
|
|
||||||
import Input from '#/components/Input'
|
import Input from '#/components/Input'
|
||||||
import Modal from '#/components/Modal'
|
import Modal from '#/components/Modal'
|
||||||
import SubmitButton from '#/components/SubmitButton'
|
import SubmitButton from '#/components/SubmitButton'
|
||||||
|
|
||||||
|
import * as string from '#/utilities/string'
|
||||||
|
import * as validation from '#/utilities/validation'
|
||||||
|
|
||||||
// ===========================
|
// ===========================
|
||||||
// === ChangePasswordModal ===
|
// === ChangePasswordModal ===
|
||||||
// ===========================
|
// ===========================
|
||||||
|
@ -10,17 +10,19 @@ import TriangleDownIcon from 'enso-assets/triangle_down.svg'
|
|||||||
import * as chat from 'enso-chat/chat'
|
import * as chat from 'enso-chat/chat'
|
||||||
|
|
||||||
import * as gtagHooks from '#/hooks/gtagHooks'
|
import * as gtagHooks from '#/hooks/gtagHooks'
|
||||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
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 config from '#/utilities/config'
|
||||||
import * as dateTime from '#/utilities/dateTime'
|
import * as dateTime from '#/utilities/dateTime'
|
||||||
import * as newtype from '#/utilities/newtype'
|
import * as newtype from '#/utilities/newtype'
|
||||||
import * as object from '#/utilities/object'
|
import * as object from '#/utilities/object'
|
||||||
|
|
||||||
import Twemoji from '#/components/Twemoji'
|
|
||||||
|
|
||||||
// ================
|
// ================
|
||||||
// === Newtypes ===
|
// === Newtypes ===
|
||||||
// ================
|
// ================
|
||||||
@ -38,8 +40,6 @@ const MessageId = newtype.newtypeConstructor<chat.MessageId>()
|
|||||||
// to switch projects, and undo history may be lost.
|
// to switch projects, and undo history may be lost.
|
||||||
|
|
||||||
export const HELP_CHAT_ID = 'enso-chat'
|
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. */
|
/** The size (both width and height) of each reaction button. */
|
||||||
const REACTION_BUTTON_SIZE = 20
|
const REACTION_BUTTON_SIZE = 20
|
||||||
/** The size (both width and height) of each reaction on a message. */
|
/** 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 [isAtBottom, setIsAtBottom] = React.useState(true)
|
||||||
const [messagesHeightBeforeMessageHistory, setMessagesHeightBeforeMessageHistory] =
|
const [messagesHeightBeforeMessageHistory, setMessagesHeightBeforeMessageHistory] =
|
||||||
React.useState<number | null>(null)
|
React.useState<number | null>(null)
|
||||||
// TODO: proper URL
|
const [webSocket, setWebsocket] = React.useState<WebSocket | null>(null)
|
||||||
const [websocket] = React.useState(() => new WebSocket(config.ACTIVE_CONFIG.chatUrl))
|
|
||||||
const [right, setTargetRight] = animations.useInterpolateOverTime(
|
|
||||||
animations.interpolationFunctionEaseInOut,
|
|
||||||
ANIMATION_DURATION_MS,
|
|
||||||
-WIDTH_PX
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const messageInputRef = React.useRef<HTMLTextAreaElement>(null!)
|
const messageInputRef = React.useRef<HTMLTextAreaElement>(null!)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
@ -415,10 +409,22 @@ export default function Chat(props: ChatProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
const newWebSocket = new WebSocket(config.ACTIVE_CONFIG.chatUrl)
|
||||||
|
setWebsocket(newWebSocket)
|
||||||
return () => {
|
return () => {
|
||||||
websocket.close()
|
if (newWebSocket.readyState === WebSocket.OPEN) {
|
||||||
|
newWebSocket.close()
|
||||||
|
} else {
|
||||||
|
newWebSocket.addEventListener('open', () => {
|
||||||
|
newWebSocket.close()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}, [websocket])
|
}
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
const element = messagesRef.current
|
const element = messagesRef.current
|
||||||
@ -434,9 +440,9 @@ export default function Chat(props: ChatProps) {
|
|||||||
|
|
||||||
const sendMessage = React.useCallback(
|
const sendMessage = React.useCallback(
|
||||||
(message: chat.ChatClientMessageData) => {
|
(message: chat.ChatClientMessageData) => {
|
||||||
websocket.send(JSON.stringify(message))
|
webSocket?.send(JSON.stringify(message))
|
||||||
},
|
},
|
||||||
[/* should never change */ websocket]
|
[webSocket]
|
||||||
)
|
)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -561,44 +567,16 @@ export default function Chat(props: ChatProps) {
|
|||||||
accessToken,
|
accessToken,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
websocket.addEventListener('message', onMessage)
|
webSocket?.addEventListener('message', onMessage)
|
||||||
websocket.addEventListener('open', onOpen)
|
webSocket?.addEventListener('open', onOpen)
|
||||||
return () => {
|
return () => {
|
||||||
websocket.removeEventListener('message', onMessage)
|
webSocket?.removeEventListener('message', onMessage)
|
||||||
websocket.removeEventListener('open', onOpen)
|
webSocket?.removeEventListener('open', onOpen)
|
||||||
}
|
}
|
||||||
}, [
|
}, [webSocket, shouldIgnoreMessageLimit, logger, threads, messages, accessToken, sendMessage])
|
||||||
websocket,
|
|
||||||
shouldIgnoreMessageLimit,
|
|
||||||
logger,
|
|
||||||
threads,
|
|
||||||
messages,
|
|
||||||
accessToken,
|
|
||||||
/* should never change */ sendMessage,
|
|
||||||
])
|
|
||||||
|
|
||||||
const container = document.getElementById(HELP_CHAT_ID)
|
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(
|
const switchThread = React.useCallback(
|
||||||
(newThreadId: chat.ThreadId) => {
|
(newThreadId: chat.ThreadId) => {
|
||||||
const threadData = threads.find(thread => thread.id === newThreadId)
|
const threadData = threads.find(thread => thread.id === newThreadId)
|
||||||
@ -682,10 +660,9 @@ export default function Chat(props: ChatProps) {
|
|||||||
|
|
||||||
return reactDom.createPortal(
|
return reactDom.createPortal(
|
||||||
<div
|
<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 transition-transform ${
|
||||||
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 ${
|
|
||||||
page === pageSwitcher.Page.editor ? 'bg-ide-bg' : 'bg-frame-selected'
|
page === pageSwitcher.Page.editor ? 'bg-ide-bg' : 'bg-frame-selected'
|
||||||
}`}
|
} ${isOpen ? '' : 'translate-x-full'}`}
|
||||||
>
|
>
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
threads={threads}
|
threads={threads}
|
||||||
|
@ -6,11 +6,13 @@ import * as reactDom from 'react-dom'
|
|||||||
import CloseLargeIcon from 'enso-assets/close_large.svg'
|
import CloseLargeIcon from 'enso-assets/close_large.svg'
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
import * as appUtils from '#/appUtils'
|
||||||
|
|
||||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
import * as navigateHooks from '#/hooks/navigateHooks'
|
||||||
|
|
||||||
|
import * as loggerProvider from '#/providers/LoggerProvider'
|
||||||
|
|
||||||
import * as chat from '#/layouts/dashboard/Chat'
|
import * as chat from '#/layouts/dashboard/Chat'
|
||||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
||||||
import * as loggerProvider from '#/providers/LoggerProvider'
|
|
||||||
import * as animations from '#/utilities/animations'
|
|
||||||
|
|
||||||
/** Props for a {@link ChatPlaceholder}. */
|
/** Props for a {@link ChatPlaceholder}. */
|
||||||
export interface ChatPlaceholderProps {
|
export interface ChatPlaceholderProps {
|
||||||
@ -25,44 +27,18 @@ export default function ChatPlaceholder(props: ChatPlaceholderProps) {
|
|||||||
const { page, isOpen, doClose } = props
|
const { page, isOpen, doClose } = props
|
||||||
const logger = loggerProvider.useLogger()
|
const logger = loggerProvider.useLogger()
|
||||||
const navigate = navigateHooks.useNavigate()
|
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)
|
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) {
|
if (container == null) {
|
||||||
logger.error('Chat container not found.')
|
logger.error('Chat container not found.')
|
||||||
return null
|
return null
|
||||||
} else {
|
} else {
|
||||||
return reactDom.createPortal(
|
return reactDom.createPortal(
|
||||||
<div
|
<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 transition-transform ${
|
||||||
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 ${
|
|
||||||
page === pageSwitcher.Page.editor ? 'bg-ide-bg' : 'bg-frame-selected'
|
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="flex text-sm font-semibold mx-4 mt-2">
|
||||||
<div className="grow" />
|
<div className="grow" />
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
|
|
||||||
import * as authProvider from '#/providers/AuthProvider'
|
import * as authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
@ -4,12 +4,20 @@ import * as React from 'react'
|
|||||||
import * as common from 'enso-common'
|
import * as common from 'enso-common'
|
||||||
|
|
||||||
import * as appUtils from '#/appUtils'
|
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 type * as assetEvent from '#/events/assetEvent'
|
||||||
import AssetEventType from '#/events/AssetEventType'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import type * as assetListEvent from '#/events/assetListEvent'
|
import type * as assetListEvent from '#/events/assetListEvent'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
import * as navigateHooks from '#/hooks/navigateHooks'
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
|
||||||
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
import type * as assetSearchBar from '#/layouts/dashboard/AssetSearchBar'
|
||||||
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
import type * as assetSettingsPanel from '#/layouts/dashboard/AssetSettingsPanel'
|
||||||
import AssetsTable from '#/layouts/dashboard/AssetsTable'
|
import AssetsTable from '#/layouts/dashboard/AssetsTable'
|
||||||
@ -18,19 +26,17 @@ import Category from '#/layouts/dashboard/CategorySwitcher/Category'
|
|||||||
import DriveBar from '#/layouts/dashboard/DriveBar'
|
import DriveBar from '#/layouts/dashboard/DriveBar'
|
||||||
import Labels from '#/layouts/dashboard/Labels'
|
import Labels from '#/layouts/dashboard/Labels'
|
||||||
import * as pageSwitcher from '#/layouts/dashboard/PageSwitcher'
|
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 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 ===
|
// === DriveStatus ===
|
||||||
// ===================
|
// ===================
|
||||||
@ -66,8 +72,8 @@ export interface DriveProps {
|
|||||||
dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
|
dispatchAssetListEvent: (directoryEvent: assetListEvent.AssetListEvent) => void
|
||||||
assetEvents: assetEvent.AssetEvent[]
|
assetEvents: assetEvent.AssetEvent[]
|
||||||
dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
|
dispatchAssetEvent: (directoryEvent: assetEvent.AssetEvent) => void
|
||||||
query: assetQuery.AssetQuery
|
query: AssetQuery
|
||||||
setQuery: React.Dispatch<React.SetStateAction<assetQuery.AssetQuery>>
|
setQuery: React.Dispatch<React.SetStateAction<AssetQuery>>
|
||||||
labels: backendModule.Label[]
|
labels: backendModule.Label[]
|
||||||
setLabels: React.Dispatch<React.SetStateAction<backendModule.Label[]>>
|
setLabels: React.Dispatch<React.SetStateAction<backendModule.Label[]>>
|
||||||
setSuggestions: (suggestions: assetSearchBar.Suggestion[]) => void
|
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) => {
|
(name: string, value: string) => {
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
type: AssetListEventType.newSecret,
|
type: AssetListEventType.newSecret,
|
||||||
@ -373,7 +379,7 @@ export default function Drive(props: DriveProps) {
|
|||||||
doCreateProject={doCreateProject}
|
doCreateProject={doCreateProject}
|
||||||
doUploadFiles={doUploadFiles}
|
doUploadFiles={doUploadFiles}
|
||||||
doCreateDirectory={doCreateDirectory}
|
doCreateDirectory={doCreateDirectory}
|
||||||
doCreateDataConnector={doCreateDataConnector}
|
doCreateSecret={doCreateSecret}
|
||||||
dispatchAssetEvent={dispatchAssetEvent}
|
dispatchAssetEvent={dispatchAssetEvent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,18 +7,22 @@ import AddFolderIcon from 'enso-assets/add_folder.svg'
|
|||||||
import DataDownloadIcon from 'enso-assets/data_download.svg'
|
import DataDownloadIcon from 'enso-assets/data_download.svg'
|
||||||
import DataUploadIcon from 'enso-assets/data_upload.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 backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
|
import * as shortcutManagerProvider from '#/providers/ShortcutManagerProvider'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
import * as shortcutsModule from '#/utilities/shortcuts'
|
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 Button from '#/components/Button'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as shortcutManagerModule from '#/utilities/ShortcutManager'
|
||||||
|
|
||||||
// ================
|
// ================
|
||||||
// === DriveBar ===
|
// === DriveBar ===
|
||||||
// ================
|
// ================
|
||||||
@ -29,7 +33,7 @@ export interface DriveBarProps {
|
|||||||
canDownloadFiles: boolean
|
canDownloadFiles: boolean
|
||||||
doCreateProject: () => void
|
doCreateProject: () => void
|
||||||
doCreateDirectory: () => void
|
doCreateDirectory: () => void
|
||||||
doCreateDataConnector: (name: string, value: string) => void
|
doCreateSecret: (name: string, value: string) => void
|
||||||
doUploadFiles: (files: File[]) => void
|
doUploadFiles: (files: File[]) => void
|
||||||
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
dispatchAssetEvent: (event: assetEvent.AssetEvent) => void
|
||||||
}
|
}
|
||||||
@ -38,31 +42,31 @@ export interface DriveBarProps {
|
|||||||
* and a column display mode switcher. */
|
* and a column display mode switcher. */
|
||||||
export default function DriveBar(props: DriveBarProps) {
|
export default function DriveBar(props: DriveBarProps) {
|
||||||
const { category, canDownloadFiles, doCreateProject, doCreateDirectory } = props
|
const { category, canDownloadFiles, doCreateProject, doCreateDirectory } = props
|
||||||
const { doCreateDataConnector, doUploadFiles, dispatchAssetEvent } = props
|
const { doCreateSecret, doUploadFiles, dispatchAssetEvent } = props
|
||||||
const { backend } = backendProvider.useBackend()
|
const { backend } = backendProvider.useBackend()
|
||||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||||
const { shortcuts } = shortcutsProvider.useShortcuts()
|
const { shortcutManager } = shortcutManagerProvider.useShortcutManager()
|
||||||
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
const uploadFilesRef = React.useRef<HTMLInputElement>(null)
|
||||||
const isCloud = backend.type === backendModule.BackendType.remote
|
const isCloud = backend.type === backendModule.BackendType.remote
|
||||||
const isHomeCategory = category === Category.home || !isCloud
|
const isHomeCategory = category === Category.home || !isCloud
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return shortcuts.registerKeyboardHandlers({
|
return shortcutManager.registerKeyboardHandlers({
|
||||||
...(backend.type !== backendModule.BackendType.local
|
...(backend.type !== backendModule.BackendType.local
|
||||||
? {
|
? {
|
||||||
[shortcutsModule.KeyboardAction.newFolder]: () => {
|
[shortcutManagerModule.KeyboardAction.newFolder]: () => {
|
||||||
doCreateDirectory()
|
doCreateDirectory()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
[shortcutsModule.KeyboardAction.newProject]: () => {
|
[shortcutManagerModule.KeyboardAction.newProject]: () => {
|
||||||
doCreateProject()
|
doCreateProject()
|
||||||
},
|
},
|
||||||
[shortcutsModule.KeyboardAction.uploadFiles]: () => {
|
[shortcutManagerModule.KeyboardAction.uploadFiles]: () => {
|
||||||
uploadFilesRef.current?.click()
|
uploadFilesRef.current?.click()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}, [backend.type, doCreateDirectory, doCreateProject, /* should never change */ shortcuts])
|
}, [backend.type, doCreateDirectory, doCreateProject, /* should never change */ shortcutManager])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-8 py-0.5">
|
<div className="flex h-8 py-0.5">
|
||||||
@ -107,15 +111,13 @@ export default function DriveBar(props: DriveBarProps) {
|
|||||||
<Button
|
<Button
|
||||||
active={isHomeCategory}
|
active={isHomeCategory}
|
||||||
disabled={!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}
|
image={AddConnectorIcon}
|
||||||
alt="New Data Connector"
|
alt="New Secret"
|
||||||
disabledOpacityClassName="opacity-20"
|
disabledOpacityClassName="opacity-20"
|
||||||
onClick={event => {
|
onClick={event => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setModal(
|
setModal(<UpsertSecretModal id={null} name={null} doCreate={doCreateSecret} />)
|
||||||
<UpsertSecretModal id={null} name={null} doCreate={doCreateDataConnector} />
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
@ -1,18 +1,21 @@
|
|||||||
/** @file A modal opened when uploaded assets. */
|
/** @file A modal opened when uploaded assets. */
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import * as modalProvider from '#/providers/ModalProvider'
|
||||||
|
|
||||||
import type * as assetEvent from '#/events/assetEvent'
|
import type * as assetEvent from '#/events/assetEvent'
|
||||||
import AssetEventType from '#/events/AssetEventType'
|
import AssetEventType from '#/events/AssetEventType'
|
||||||
import type * as assetListEvent from '#/events/assetListEvent'
|
import type * as assetListEvent from '#/events/assetListEvent'
|
||||||
import AssetListEventType from '#/events/AssetListEventType'
|
import AssetListEventType from '#/events/AssetListEventType'
|
||||||
import * 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 AssetSummary from '#/components/dashboard/AssetSummary'
|
||||||
import Modal from '#/components/Modal'
|
import Modal from '#/components/Modal'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as fileInfo from '#/utilities/fileInfo'
|
||||||
|
import * as string from '#/utilities/string'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
// === Constants ===
|
// === Constants ===
|
||||||
// =================
|
// =================
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
|
|
||||||
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
import * as toastAndLogHooks from '#/hooks/toastAndLogHooks'
|
||||||
import * as backendModule from '#/services/backend'
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
import * as load from '#/utilities/load'
|
import * as load from '#/utilities/load'
|
||||||
|
|
||||||
// =================
|
// =================
|
||||||
@ -47,10 +49,12 @@ export default function Editor(props: EditorProps) {
|
|||||||
if (ideElement != null) {
|
if (ideElement != null) {
|
||||||
if (hidden) {
|
if (hidden) {
|
||||||
ideElement.style.top = '-100vh'
|
ideElement.style.top = '-100vh'
|
||||||
ideElement.style.display = 'fixed'
|
ideElement.style.position = 'fixed'
|
||||||
|
ideElement.style.visibility = 'hidden'
|
||||||
} else {
|
} else {
|
||||||
ideElement.style.top = ''
|
ideElement.style.top = ''
|
||||||
ideElement.style.display = 'absolute'
|
ideElement.style.position = 'absolute'
|
||||||
|
ideElement.style.visibility = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const ide2Element = document.getElementById(IDE2_ELEMENT_ID)
|
const ide2Element = document.getElementById(IDE2_ELEMENT_ID)
|
||||||
|
@ -1,18 +1,22 @@
|
|||||||
/** @file A context menu available everywhere in the directory. */
|
/** @file A context menu available everywhere in the directory. */
|
||||||
import * as React from 'react'
|
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 authProvider from '#/providers/AuthProvider'
|
||||||
import * as backendProvider from '#/providers/BackendProvider'
|
import * as backendProvider from '#/providers/BackendProvider'
|
||||||
import * as modalProvider from '#/providers/ModalProvider'
|
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 ContextMenu from '#/components/ContextMenu'
|
||||||
import MenuEntry from '#/components/MenuEntry'
|
import MenuEntry from '#/components/MenuEntry'
|
||||||
|
|
||||||
|
import * as backendModule from '#/services/Backend'
|
||||||
|
|
||||||
|
import * as shortcutManager from '#/utilities/ShortcutManager'
|
||||||
|
|
||||||
/** Props for a {@link GlobalContextMenu}. */
|
/** Props for a {@link GlobalContextMenu}. */
|
||||||
export interface GlobalContextMenuProps {
|
export interface GlobalContextMenuProps {
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
@ -63,8 +67,8 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
|||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={
|
action={
|
||||||
backend.type === backendModule.BackendType.local
|
backend.type === backendModule.BackendType.local
|
||||||
? shortcuts.KeyboardAction.uploadProjects
|
? shortcutManager.KeyboardAction.uploadProjects
|
||||||
: shortcuts.KeyboardAction.uploadFiles
|
: shortcutManager.KeyboardAction.uploadFiles
|
||||||
}
|
}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
if (filesInputRef.current?.isConnected === true) {
|
if (filesInputRef.current?.isConnected === true) {
|
||||||
@ -93,7 +97,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
|||||||
{isCloud && (
|
{isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.newProject}
|
action={shortcutManager.KeyboardAction.newProject}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
@ -110,7 +114,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
|||||||
{isCloud && (
|
{isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.newFolder}
|
action={shortcutManager.KeyboardAction.newFolder}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
dispatchAssetListEvent({
|
dispatchAssetListEvent({
|
||||||
@ -124,7 +128,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
|||||||
{isCloud && (
|
{isCloud && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.newDataConnector}
|
action={shortcutManager.KeyboardAction.newDataConnector}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
setModal(
|
setModal(
|
||||||
<UpsertSecretModal
|
<UpsertSecretModal
|
||||||
@ -147,7 +151,7 @@ export default function GlobalContextMenu(props: GlobalContextMenuProps) {
|
|||||||
{isCloud && directoryKey == null && hasCopyData && (
|
{isCloud && directoryKey == null && hasCopyData && (
|
||||||
<MenuEntry
|
<MenuEntry
|
||||||
hidden={hidden}
|
hidden={hidden}
|
||||||
action={shortcuts.KeyboardAction.paste}
|
action={shortcutManager.KeyboardAction.paste}
|
||||||
doAction={() => {
|
doAction={() => {
|
||||||
unsetModal()
|
unsetModal()
|
||||||
doPaste(rootDirectoryId, rootDirectoryId)
|
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