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:
somebody1234 2024-01-31 21:35:41 +10:00 committed by GitHub
parent 06f1c772d8
commit cbf6d41e4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
209 changed files with 4172 additions and 3071 deletions

View File

@ -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

View File

@ -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

View File

@ -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']

View File

@ -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': [

View File

@ -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"
} }

View 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][]

View File

@ -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'],

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"checkJs": false,
"skipLibCheck": false
},
"include": ["./src/"]
}

View File

@ -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) {

View File

@ -0,0 +1,3 @@
playwright-report/
playwright/.cache/
dist/

View File

@ -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'],

View 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>

View File

@ -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()

View 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
}

View 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,
}
}

View 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()
})

View 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)
})

View File

@ -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()

View 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[)]/)
})

View 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))
})

View 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)
})

View 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)
})

View 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))
})

View 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()
})

View 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()
}
}
})

View 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)
})

View 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()
})

View File

@ -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()
}) })

View 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()
})

View File

@ -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)

View File

@ -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

View File

@ -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

View 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/)
})

View 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/)
})

View File

@ -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

View File

@ -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)

View File

@ -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"

View File

@ -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'),
},
},
},
})

View File

@ -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,
}, },

View File

@ -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>

View File

@ -1 +0,0 @@
/** @file The file in which the test runner will append the built component code. */

View File

@ -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

View File

@ -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)
} }

View File

@ -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 {

View File

@ -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 ===

View File

@ -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 {

View File

@ -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>
)
} }
} }

View File

@ -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}

View File

@ -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 ${

View File

@ -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

View File

@ -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}

View File

@ -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}

View File

@ -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,
})

View File

@ -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

View File

@ -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'

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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 ===

View File

@ -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>
) )
} }
} }

View File

@ -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 ===
// ================= // =================

View File

@ -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'
// ================= // =================

View File

@ -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 ===
// ================= // =================

View File

@ -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>

View File

@ -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>
) )
} }

View File

@ -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

View File

@ -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

View File

@ -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>
) )

View File

@ -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))}</>

View File

@ -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 ===
// ================= // =================

View File

@ -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 ===
// ======================== // ========================

View File

@ -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,
} }

View File

@ -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>
) )
} }

View File

@ -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>
) )
} }

View File

@ -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">

View File

@ -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 ===
// =================== // ===================

View File

@ -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' {

View File

@ -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' {

View File

@ -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'
// =================== // ===================

View 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]
)
}

View File

@ -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'
// ====================== // ======================

View File

@ -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'
// ================= // =================

View File

@ -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

View File

@ -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)

View File

@ -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>

View File

@ -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)

View File

@ -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 =

View File

@ -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 ===
// ======================= // =======================

View File

@ -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)),
}) })
} }
} }

View File

@ -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',
} }

View File

@ -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 ===
// =========================== // ===========================

View File

@ -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}

View File

@ -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" />

View File

@ -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'

View File

@ -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>

View File

@ -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} />
)
}} }}
/> />
)} )}

View File

@ -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 ===
// ================= // =================

View File

@ -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)

View File

@ -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