Re-organize lib/dashboard/ (#8587)

- Significantly flattens directory structure of `lib/dashboard/`

# Important Notes
- Basic testing done on:
- dashboard's `npm run dev` which (since quite recently) uses Vite.
- specifically: `npm run dev` in `app/ide-desktop/lib/dashboard`, OR `npm run dashboard:dev` in `app/ide-desktop`
- dashboard's bundle script (`npm run build`) which uses ESBuild.
- GUI2's own entry point (GUI2's `npm run dev`).
- `./run ide build`
- `./run ide watch`
- `./run ide2 build`
- `./run gui watch`
This commit is contained in:
somebody1234 2024-01-11 02:22:11 +10:00 committed by GitHub
parent f31ecc7c87
commit 8597de1d43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
212 changed files with 3681 additions and 3505 deletions

View File

@ -9,7 +9,7 @@
},
"scripts": {
"dev": "vite",
"build": "npm --workspace enso-authentication run compile && run-p typecheck build-only",
"build": "npm --workspace enso-dashboard run compile && run-p typecheck build-only",
"build:cloud": "cross-env CLOUD_BUILD=true npm run build",
"preview": "vite preview",
"test": "vitest run && playwright test --reporter=html",
@ -52,7 +52,7 @@
"ag-grid-enterprise": "^30.2.1",
"codemirror": "^6.0.1",
"culori": "^3.2.0",
"enso-authentication": "^1.0.0",
"enso-dashboard": "^0.1.0",
"events": "^3.3.0",
"fast-diff": "^1.3.0",
"hash-sum": "^2.0.0",
@ -129,6 +129,7 @@
"vite-plugin-inspect": "^0.7.38",
"vite-plugin-top-level-await": "^1.3.1",
"vitest": "^0.34.2",
"vue-react-wrapper": "^0.3.1",
"vue-tsc": "^1.8.8"
}
}

View File

@ -33,7 +33,7 @@ import * as set from 'lib0/set'
import { toast } from 'react-toastify'
import type { ExprId, NodeMetadata } from 'shared/yjsModel'
import { computed, onMounted, onScopeDispose, onUnmounted, ref, watch } from 'vue'
import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/authentication/src/dashboard/projectManager'
import { ProjectManagerEvents } from '../../../ide-desktop/lib/dashboard/src/utilities/projectManager'
import { type Usage } from './ComponentBrowser/input'
const EXECUTION_MODES = ['design', 'live']

View File

@ -1,8 +1,8 @@
import { baseConfig, configValue, mergeConfig } from '@/util/config'
import { isDevMode } from '@/util/detect'
import { urlParams } from '@/util/urlParams'
import { run as runDashboard } from 'enso-authentication'
import { isOnLinux } from 'enso-common/src/detect'
import * as dashboard from 'enso-dashboard'
import 'enso-dashboard/src/tailwind.css'
const INITIAL_URL_KEY = `Enso-initial-url`
@ -106,7 +106,7 @@ function main() {
const projectManagerUrl = config.engine.projectManagerUrl || PROJECT_MANAGER_URL
const initialProjectName = config.startup.project || null
runDashboard({
dashboard.run({
appRunner,
logger: console,
supportsLocalBackend: !IS_CLOUD_BUILD,

View File

@ -2,7 +2,7 @@
"files": [],
"references": [
{
"path": "../ide-desktop/lib/dashboard/src/authentication/tsconfig.json"
"path": "../ide-desktop/lib/dashboard/tsconfig.json"
},
{
"path": "./tsconfig.node.json"

8
app/ide-desktop/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,8 @@
{
"javascript.preferences.importModuleSpecifier": "non-relative",
"typescript.preferences.importModuleSpecifier": "non-relative",
"javascript.preferences.importModuleSpecifierEnding": "minimal",
"typescript.preferences.importModuleSpecifierEnding": "minimal",
"javascript.updateImportsOnFileMove.enabled": "always",
"typescript.updateImportsOnFileMove.enabled": "always"
}

View File

@ -1,41 +0,0 @@
/** @file Debug-only functions, injected or stripped by the build tool as appropriate. */
/** The logger used by {@link assert}. */
interface Logger {
error: (message: string) => void
}
/** Logs an error . */
export function assert(invariant: boolean, message: string, logger: Logger = console) {
if (!invariant) {
logger.error('assertion failed: ' + message)
}
}
// This is required to make the definition `Object.prototype.$d$` not error.
// eslint-disable-next-line no-restricted-syntax
declare global {
// Documentation is already inherited.
/** */
interface Object {
/** Log self and return self. */
$d$: <T>(this: T, message?: string) => T
}
}
if (!('$d$' in Object.prototype)) {
Object.defineProperty(Object.prototype, '$d$', {
/** Log self and return self. */
value: function <T>(this: T, message?: string) {
if (message != null) {
console.log(message, this)
} else {
console.log(this)
}
return this
},
enumerable: false,
writable: false,
configurable: false,
})
}

View File

@ -30,11 +30,11 @@ const NAME = 'enso'
* `yargs` is a modules we explicitly want the default imports of.
* `node:process` is here because `process.on` does not exist on the namespace import. */
const DEFAULT_IMPORT_ONLY_MODULES =
'node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*|@modyfi\\u002Fvite-plugin-yaml|validator.+'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss`
'@vitejs\\u002Fplugin-react|node:process|chalk|string-length|yargs|yargs\\u002Fyargs|sharp|to-ico|connect|morgan|serve-static|create-servers|electron-is-dev|fast-glob|esbuild-plugin-.+|opener|tailwindcss.*|enso-assets.*|@modyfi\\u002Fvite-plugin-yaml|validator.+'
const OUR_MODULES = 'enso-.*'
const RELATIVE_MODULES =
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations'
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|detect|file-associations|index|ipc|log|naming|paths|preload|project-management|security|url-associations|#\\u002F.*'
const ALLOWED_DEFAULT_IMPORT_MODULES = `${DEFAULT_IMPORT_ONLY_MODULES}|postcss|${RELATIVE_MODULES}`
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
const JSX = ':matches(JSXElement, JSXFragment)'
const NOT_PASCAL_CASE = '/^(?!do[A-Z])(?!_?([A-Z][a-z0-9]*)+$)/'
@ -89,10 +89,6 @@ const RESTRICTED_SYNTAXES = [
selector: `:matches(ImportDefaultSpecifier[local.name=/^${NAME}/i], ImportNamespaceSpecifier > Identifier[name=/^${NAME}/i])`,
message: `Don't prefix modules with \`${NAME}\``,
},
{
selector: 'ExportAllDeclaration',
message: 'No re-exports',
},
{
selector: 'TSTypeLiteral',
message: 'No object types - use interfaces instead',
@ -299,7 +295,6 @@ export default [
],
},
],
'sort-imports': ['error', { allowSeparatedGroups: true }],
'no-constant-condition': ['error', { checkLoops: false }],
'no-restricted-properties': [
'error',
@ -516,10 +511,6 @@ export default [
property: 'useDebugCallback',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: '$d$',
message: 'Avoid leaving debugging statements when committing code',
},
],
},
},
@ -559,10 +550,6 @@ export default [
property: 'useDebugCallback',
message: 'Avoid leaving debugging statements when committing code',
},
{
property: '$d$',
message: 'Avoid leaving debugging statements when committing code',
},
{
object: 'page',
property: 'type',

View File

@ -50,7 +50,7 @@
"dmg-license": "^1.0.11"
},
"scripts": {
"typecheck": "npm run --workspace=enso-gui2 compile-server && tsc --noEmit",
"typecheck": "npm run --workspace=enso-gui2 compile-server && tsc --build",
"start": "tsx start.ts",
"build": "tsx bundle.ts",
"dist": "tsx dist.ts",

View File

@ -7,14 +7,12 @@
"include": [
".",
"../content",
"../dashboard",
"../types",
"../../utils.ts",
"../../../../build.json"
],
"references": [
{
"path": "../../../gui2/tsconfig.server.json"
}
{ "path": "../../../gui2/tsconfig.server.json" },
{ "path": "../dashboard" }
]
}

View File

@ -10,7 +10,6 @@
import * as childProcess from 'node:child_process'
import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as url from 'node:url'
import process from 'node:process'
import * as esbuild from 'esbuild'
@ -35,8 +34,6 @@ interface Watches {
// === Constants ===
// =================
/** The path of this file. */
const THIS_PATH = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)))
const IDE_DIR_PATH = paths.getIdeDirectory()
const PROJECT_MANAGER_BUNDLE_PATH = paths.getProjectManagerBundlePath()
@ -110,9 +107,6 @@ const ALL_BUNDLES_READY = new Promise<Watches>((resolve, reject) => {
},
})
contentOpts.pure.splice(contentOpts.pure.indexOf('assert'), 1)
;(contentOpts.inject = contentOpts.inject ?? []).push(
path.resolve(THIS_PATH, '..', '..', 'debugGlobals.ts')
)
contentOpts.outdir = path.resolve(IDE_DIR_PATH, 'assets')
contentOpts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080')
const contentBuilder = await esbuild.context(contentOpts)

View File

@ -107,7 +107,7 @@ export function bundlerOptions(args: Arguments) {
'.ttf': 'copy',
},
entryPoints: [
pathModule.resolve(THIS_PATH, 'src', 'index.ts'),
pathModule.resolve(THIS_PATH, 'src', 'entrypoint.ts'),
pathModule.resolve(THIS_PATH, 'src', 'index.html'),
pathModule.resolve(THIS_PATH, 'src', 'run.js'),
pathModule.resolve(THIS_PATH, 'src', 'style.css'),

View File

@ -15,7 +15,7 @@
"url": "https://github.com/enso-org/ide/issues"
},
"scripts": {
"typecheck": "tsc --noEmit",
"typecheck": "tsc --build",
"build": "tsx bundle.ts",
"watch": "tsx watch.ts",
"start": "tsx start.ts"
@ -37,7 +37,7 @@
"@types/ws": "^8.5.4",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"enso-authentication": "^1.0.0",
"enso-dashboard": "^0.1.0",
"esbuild": "^0.19.3",
"esbuild-plugin-copy-directories": "^1.0.0",
"esbuild-plugin-time": "^1.0.0",

View File

@ -8,7 +8,7 @@ import * as toastify from 'react-toastify'
import * as app from 'ensogl-runner/src/runner'
import * as common from 'enso-common'
import * as contentConfig from 'enso-content-config'
import * as dashboard from 'enso-authentication'
import * as dashboard from 'enso-dashboard'
import * as detect from 'enso-common/src/detect'
import * as gtag from 'enso-common/src/gtag'

View File

@ -37,7 +37,7 @@
<link rel="stylesheet" href="./style.css" />
<!-- Generated by the build script based on the Enso Font package. -->
<link rel="stylesheet" href="./ensoFont.css" />
<script type="module" src="./index.js" defer></script>
<script type="module" src="./entrypoint.js" defer></script>
<script type="module" src="./run.js" defer></script>
</head>
<body>

View File

@ -2,7 +2,7 @@
* {@link RemoteLogger} provides a convenient way to manage remote logging with access token authorization. */
import * as app from 'ensogl-runner/src/runner'
import * as authConfig from '../../dashboard/src/authentication/src/config'
import * as authConfig from '../../dashboard/src/utilities/config'
const logger = app.log.logger
@ -50,14 +50,12 @@ export async function remoteLog(
metadata: unknown
): Promise<void> {
try {
const headers: HeadersInit = new Headers()
headers.set('Content-Type', 'application/json')
headers.set('Authorization', `Bearer ${accessToken}`)
const response = await fetch(REMOTE_LOG_URL, {
method: 'POST',
headers,
body: JSON.stringify({ message, metadata }),
})
const headers: HeadersInit = [
['Content-Type', 'application/json'],
['Authorization', `Bearer ${accessToken}`],
]
const body = JSON.stringify({ message, metadata })
const response = await fetch(REMOTE_LOG_URL, { method: 'POST', headers, body })
if (!response.ok) {
const errorMessage = `Error while sending log to a remote: Status ${response.status}.`
try {

View File

@ -1,10 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": [
"../types",
"../../utils.ts",
"../../../../build.json",
"../dashboard",
"."
]
"include": ["../types", "../../utils.ts", "../../../../build.json", "."],
"references": [{ "path": "../dashboard" }]
}

View File

@ -39,7 +39,6 @@ async function watch() {
})
)
opts.pure.splice(opts.pure.indexOf('assert'), 1)
;(opts.inject = opts.inject ?? []).push(path.resolve(THIS_PATH, '..', '..', 'debugGlobals.ts'))
opts.define.REDIRECT_OVERRIDE = JSON.stringify('http://localhost:8080')
// This is safe as this entry point is statically known.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion

View File

@ -0,0 +1,36 @@
/** @file Prettier configuration. */
// @ts-check
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
module.exports = {
overrides: [
{
files: ['*.[j|t]s', '*.[j|t]sx', '*.m[j|t]s', '*.c[j|t]s'],
options: {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
printWidth: 100,
tabWidth: 4,
semi: false,
singleQuote: true,
trailingComma: 'es5',
arrowParens: 'avoid',
plugins: ['@ianvs/prettier-plugin-sort-imports'],
// This plugin's options
importOrder: [
'^react$',
'',
'<THIRD_PARTY_MODULES>',
'',
'^enso-',
'',
'^#[/](?!components[/]).*$',
'',
'^#[/]components[/]',
'',
'^[.]',
],
importOrderParserPlugins: ['typescript', 'jsx', 'importAssertions'],
importOrderTypeScriptVersion: '5.0.0',
},
},
],
}

View File

@ -32,7 +32,7 @@ async function bundle() {
})
opts.entryPoints.push(
path.resolve(THIS_PATH, 'src', 'index.html'),
path.resolve(THIS_PATH, 'src', 'index.ts')
path.resolve(THIS_PATH, 'src', 'entrypoint.ts')
)
opts.metafile = ANALYZE
opts.loader['.html'] = 'copy'

View File

@ -10,18 +10,17 @@ import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import * as url from 'node:url'
import type * as esbuild from 'esbuild'
import * as esbuildPluginNodeModules from '@esbuild-plugins/node-modules-polyfill'
import type * as esbuild from 'esbuild'
import esbuildPluginInlineImage from 'esbuild-plugin-inline-image'
import esbuildPluginTime from 'esbuild-plugin-time'
import esbuildPluginYaml from 'esbuild-plugin-yaml'
import postcss from 'postcss'
import tailwindcss from 'tailwindcss'
import tailwindcssNesting from 'tailwindcss/nesting/index.js'
import * as tailwindConfig from './tailwind.config'
import * as utils from '../../utils'
import * as tailwindConfig from './tailwind.config'
// =================
// === Constants ===
@ -67,7 +66,9 @@ export function esbuildPluginGenerateTailwind(): esbuild.Plugin {
)
build.onLoad({ filter: /tailwind\.css$/ }, async loadArgs => {
const content = await fs.readFile(loadArgs.path, 'utf8')
const result = await cssProcessor.process(content, { from: loadArgs.path })
const result = await cssProcessor.process(content, {
from: loadArgs.path,
})
return {
contents: result.content,
loader: 'css',
@ -117,6 +118,9 @@ export function bundlerOptions(args: Arguments) {
esbuildPluginYaml.yamlPlugin({}),
esbuildPluginGenerateTailwind(),
],
alias: {
'#': './src',
},
define: {
// We are defining constants, so it should be `CONSTANT_CASE`.
/* eslint-disable @typescript-eslint/naming-convention */

View File

@ -35,7 +35,7 @@
<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/index.ts" defer></script>
<script type="module" src="./src/entrypoint.ts" defer></script>
</head>
<body>
<div id="root"></div>

View File

@ -36,9 +36,12 @@ import type * as amplify from '@aws-amplify/auth'
import type * as cognito from 'amazon-cognito-identity-js'
import * as results from 'ts-results'
import type * as config from '../../../../src/authentication/src/authentication/config'
import type * as loggerProvider from '../../../../src/authentication/src/providers/logger'
import * as original from '../../../../src/authentication/src/authentication/cognito'
import * as original from '../../src/authentication/cognito'
import type * as config from '../../src/authentication/config'
import type * as loggerProvider from '../../src/providers/LoggerProvider'
/* eslint-enable no-restricted-syntax */
import * as listen from './listen'
// This file exports a subset of the values from the original file.
/* eslint-disable no-restricted-syntax */
@ -49,10 +52,7 @@ export {
ForgotPasswordSubmitErrorKind,
SignInWithPasswordErrorKind,
SignUpErrorKind,
} from '../../../../src/authentication/src/authentication/cognito'
/* eslint-enable no-restricted-syntax */
import * as listen from './listen'
} from '../../src/authentication/cognito'
// There are unused function parameters in this file.
/* eslint-disable @typescript-eslint/no-unused-vars */

View File

@ -2,9 +2,19 @@
"name": "enso-dashboard",
"version": "0.1.0",
"type": "module",
"main": "./src/index.tsx",
"private": true,
"imports": {
"#/*": "./src/*"
},
"exports": {
".": "./src/index.tsx",
"./src/platform": "./src/platform.ts",
"./src/tailwind.css": "./src/tailwind.css"
},
"scripts": {
"typecheck": "tsc",
"compile": "tsc",
"typecheck": "tsc --noEmit",
"build": "tsx bundle.ts",
"dev": "vite",
"start": "tsx start.ts",
@ -16,18 +26,25 @@
"test:e2e-and-log": "npm run test:e2e || npx tsx log-screenshot-diffs.ts"
},
"dependencies": {
"@aws-amplify/auth": "^5.6.5",
"@aws-amplify/core": "^5.8.5",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@heroicons/react": "^2.0.15",
"@sentry/react": "^7.74.0",
"esbuild": "^0.19.3",
"esbuild-plugin-time": "^1.0.0",
"enso-common": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.7.0",
"react-router-dom": "^6.8.1",
"react-toastify": "^9.1.3",
"ts-results": "^3.3.0",
"validator": "^13.11.0"
},
"devDependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@fast-check/vitest": "^0.0.8",
"@ianvs/prettier-plugin-sort-imports": "^4.1.1",
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@playwright/experimental-ct-react": "^1.40.0",
"@playwright/test": "^1.40.0",
@ -37,11 +54,12 @@
"@types/validator": "^13.11.7",
"@typescript-eslint/eslint-plugin": "^6.7.2",
"@typescript-eslint/parser": "^6.7.2",
"@vitejs/plugin-react": "^4.2.1",
"chalk": "^5.3.0",
"enso-authentication": "^1.0.0",
"enso-chat": "git://github.com/enso-org/enso-bot",
"enso-content": "^1.0.0",
"esbuild": "^0.19.3",
"esbuild-plugin-inline-image": "^0.0.9",
"esbuild-plugin-time": "^1.0.0",
"eslint": "^8.49.0",
"eslint-plugin-jsdoc": "^46.8.1",
"eslint-plugin-react": "^7.32.1",
@ -50,6 +68,7 @@
"postcss": "^8.4.29",
"react-toastify": "^9.1.3",
"tailwindcss": "^3.2.7",
"ts-plugin-namespace-auto-import": "^1.0.0",
"tsx": "^3.12.6",
"typescript": "~5.2.2",
"vite": "^4.4.9",
@ -59,5 +78,9 @@
"@esbuild/darwin-x64": "^0.17.15",
"@esbuild/linux-x64": "^0.17.15",
"@esbuild/windows-x64": "^0.17.15"
},
"overrides": {
"@aws-amplify/auth": "../_IGNORED_",
"react-native-url-polyfill": "../_IGNORED_"
}
}

View File

@ -1,7 +1,6 @@
/** @file Playwright component testing configuration. */
import * as componentTesting from '@playwright/experimental-ct-react'
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 */

View File

@ -33,61 +33,35 @@
* signed up but who have not completed email verification or set a username. The remaining
* {@link router.Route}s require fully authenticated users (c.f.
* {@link authProvider.FullUserSession}). */
import * as React from 'react'
import * as router from 'react-router-dom'
import * as toastify from 'react-toastify'
import * as detect from 'enso-common/src/detect'
import * as authServiceModule from '../authentication/service'
import type * as backend from '../dashboard/backend'
import * as hooks from '../hooks'
import * as localBackend from '../dashboard/localBackend'
import * as shortcutsModule from '../dashboard/shortcuts'
import * as authProvider from '../authentication/providers/auth'
import * as backendProvider from '../providers/backend'
import * as localStorageProvider from '../providers/localStorage'
import * as loggerProvider from '../providers/logger'
import * as modalProvider from '../providers/modal'
import * as sessionProvider from '../authentication/providers/session'
import * as shortcutsProvider from '../providers/shortcuts'
import ConfirmRegistration from '../authentication/components/confirmRegistration'
import Dashboard from '../dashboard/components/dashboard'
import EnterOfflineMode from '../authentication/components/enterOfflineMode'
import ForgotPassword from '../authentication/components/forgotPassword'
import Login from '../authentication/components/login'
import Registration from '../authentication/components/registration'
import ResetPassword from '../authentication/components/resetPassword'
import SetUsername from '../authentication/components/setUsername'
// =================
// === Constants ===
// =================
/** Path to the root of the app (i.e., the Cloud dashboard). */
export const DASHBOARD_PATH = '/'
/** Path to the login page. */
export const LOGIN_PATH = '/login'
/** Path to the registration page. */
export const REGISTRATION_PATH = '/registration'
/** Path to the confirm registration page. */
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
/** Path to the forgot password page. */
export const FORGOT_PASSWORD_PATH = '/forgot-password'
/** Path to the reset password page. */
export const RESET_PASSWORD_PATH = '/password-reset'
/** Path to the set username page. */
export const SET_USERNAME_PATH = '/set-username'
/** Path to the offline mode entrypoint. */
export const ENTER_OFFLINE_MODE_PATH = '/offline'
/** A {@link RegExp} matching all paths. */
export const ALL_PATHS_REGEX = new RegExp(
`(?:${DASHBOARD_PATH}|${LOGIN_PATH}|${REGISTRATION_PATH}|${CONFIRM_REGISTRATION_PATH}|` +
`${FORGOT_PASSWORD_PATH}|${RESET_PASSWORD_PATH}|${SET_USERNAME_PATH})$`
)
import * as appUtils from '#/appUtils'
import * as authServiceModule from '#/authentication/service'
import * as hooks from '#/hooks'
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
import EnterOfflineMode from '#/pages/authentication/EnterOfflineMode'
import ForgotPassword from '#/pages/authentication/ForgotPassword'
import Login from '#/pages/authentication/Login'
import Registration from '#/pages/authentication/Registration'
import ResetPassword from '#/pages/authentication/ResetPassword'
import SetUsername from '#/pages/authentication/SetUsername'
import Dashboard from '#/pages/dashboard/Dashboard'
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
import BackendProvider from '#/providers/BackendProvider'
import LocalStorageProvider from '#/providers/LocalStorageProvider'
import LoggerProvider from '#/providers/LoggerProvider'
import type * as loggerProvider from '#/providers/LoggerProvider'
import ModalProvider from '#/providers/ModalProvider'
import SessionProvider from '#/providers/SessionProvider'
import ShortcutsProvider from '#/providers/ShortcutsProvider'
import type * as backend from '#/services/backend'
import * as localBackend from '#/services/localBackend'
import * as shortcutsModule from '#/utilities/shortcuts'
// ======================
// === getMainPageUrl ===
@ -96,7 +70,7 @@ export const ALL_PATHS_REGEX = new RegExp(
/** Returns the URL to the main page. This is the current URL, with the current route removed. */
function getMainPageUrl() {
const mainPageUrl = new URL(window.location.href)
mainPageUrl.pathname = mainPageUrl.pathname.replace(ALL_PATHS_REGEX, '')
mainPageUrl.pathname = mainPageUrl.pathname.replace(appUtils.ALL_PATHS_REGEX, '')
return mainPageUrl
}
@ -162,14 +136,8 @@ export default function App(props: AppProps) {
* because the {@link AppRouter} relies on React hooks, which can't be used in the same React
* component as the component that defines the provider. */
function AppRouter(props: AppProps) {
const {
logger,
supportsLocalBackend,
isAuthenticationDisabled,
shouldShowDashboard,
onAuthenticated,
projectManagerUrl,
} = props
const { logger, supportsLocalBackend, isAuthenticationDisabled, shouldShowDashboard } = props
const { onAuthenticated, projectManagerUrl } = props
const navigate = hooks.useNavigate()
if (detect.IS_DEV_MODE) {
// @ts-expect-error This is used exclusively for debugging.
@ -213,58 +181,63 @@ function AppRouter(props: AppProps) {
<React.Fragment>
{/* Login & registration pages are visible to unauthenticated users. */}
<router.Route element={<authProvider.GuestLayout />}>
<router.Route path={REGISTRATION_PATH} element={<Registration />} />
<router.Route path={appUtils.REGISTRATION_PATH} element={<Registration />} />
<router.Route
path={LOGIN_PATH}
path={appUtils.LOGIN_PATH}
element={<Login supportsLocalBackend={supportsLocalBackend} />}
/>
</router.Route>
{/* Protected pages are visible to authenticated users. */}
<router.Route element={<authProvider.ProtectedLayout />}>
<router.Route
path={DASHBOARD_PATH}
path={appUtils.DASHBOARD_PATH}
element={shouldShowDashboard && <Dashboard {...props} />}
/>
</router.Route>
{/* Semi-protected pages are visible to users currently registering. */}
<router.Route element={<authProvider.SemiProtectedLayout />}>
<router.Route path={SET_USERNAME_PATH} element={<SetUsername />} />
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
</router.Route>
{/* Other pages are visible to unauthenticated and authenticated users. */}
<router.Route path={CONFIRM_REGISTRATION_PATH} element={<ConfirmRegistration />} />
<router.Route path={FORGOT_PASSWORD_PATH} element={<ForgotPassword />} />
<router.Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
<router.Route path={ENTER_OFFLINE_MODE_PATH} element={<EnterOfflineMode />} />
<router.Route
path={appUtils.CONFIRM_REGISTRATION_PATH}
element={<ConfirmRegistration />}
/>
<router.Route path={appUtils.FORGOT_PASSWORD_PATH} element={<ForgotPassword />} />
<router.Route path={appUtils.RESET_PASSWORD_PATH} element={<ResetPassword />} />
<router.Route
path={appUtils.ENTER_OFFLINE_MODE_PATH}
element={<EnterOfflineMode />}
/>
</React.Fragment>
</router.Routes>
)
/** {@link backendProvider.BackendProvider} depends on
* {@link localStorageProvider.LocalStorageProvider}. */
/** {@link BackendProvider} depends on {@link LocalStorageProvider}. */
return (
<loggerProvider.LoggerProvider logger={logger}>
<sessionProvider.SessionProvider
<LoggerProvider logger={logger}>
<SessionProvider
mainPageUrl={mainPageUrl}
userSession={userSession}
registerAuthEventListener={registerAuthEventListener}
>
<localStorageProvider.LocalStorageProvider>
<backendProvider.BackendProvider initialBackend={initialBackend}>
<authProvider.AuthProvider
<LocalStorageProvider>
<BackendProvider initialBackend={initialBackend}>
<AuthProvider
shouldStartInOfflineMode={isAuthenticationDisabled}
supportsLocalBackend={supportsLocalBackend}
authService={authService}
onAuthenticated={onAuthenticated}
projectManagerUrl={projectManagerUrl}
>
<modalProvider.ModalProvider>
<shortcutsProvider.ShortcutsProvider shortcuts={shortcuts}>
<ModalProvider>
<ShortcutsProvider shortcuts={shortcuts}>
{routes}
</shortcutsProvider.ShortcutsProvider>
</modalProvider.ModalProvider>
</authProvider.AuthProvider>
</backendProvider.BackendProvider>
</localStorageProvider.LocalStorageProvider>
</sessionProvider.SessionProvider>
</loggerProvider.LoggerProvider>
</ShortcutsProvider>
</ModalProvider>
</AuthProvider>
</BackendProvider>
</LocalStorageProvider>
</SessionProvider>
</LoggerProvider>
)
}

View File

@ -0,0 +1,27 @@
/** @file Constants related to the application root component. */
// =================
// === Constants ===
// =================
/** Path to the root of the app (i.e., the Cloud dashboard). */
export const DASHBOARD_PATH = '/'
/** Path to the login page. */
export const LOGIN_PATH = '/login'
/** Path to the registration page. */
export const REGISTRATION_PATH = '/registration'
/** Path to the confirm registration page. */
export const CONFIRM_REGISTRATION_PATH = '/confirmation'
/** Path to the forgot password page. */
export const FORGOT_PASSWORD_PATH = '/forgot-password'
/** Path to the reset password page. */
export const RESET_PASSWORD_PATH = '/password-reset'
/** Path to the set username page. */
export const SET_USERNAME_PATH = '/set-username'
/** Path to the offline mode entrypoint. */
export const ENTER_OFFLINE_MODE_PATH = '/offline'
/** A {@link RegExp} matching all paths. */
export const ALL_PATHS_REGEX = new RegExp(
`(?:${DASHBOARD_PATH}|${LOGIN_PATH}|${REGISTRATION_PATH}|${CONFIRM_REGISTRATION_PATH}|` +
`${FORGOT_PASSWORD_PATH}|${RESET_PASSWORD_PATH}|${SET_USERNAME_PATH})$`
)

View File

@ -35,8 +35,8 @@ import * as results from 'ts-results'
import * as detect from 'enso-common/src/detect'
import * as config from './config'
import type * as loggerProvider from '../providers/logger'
import * as config from '#/authentication/config'
import type * as loggerProvider from '#/providers/LoggerProvider'
// =================
// === Constants ===

View File

@ -10,7 +10,7 @@
* pools, Amplify must be configured prior to use. This file defines all the information needed to
* connect to and use these pools. */
import * as newtype from '../newtype'
import * as newtype from '#/utilities/newtype'
// =============
// === Types ===

View File

@ -1,33 +0,0 @@
{
"name": "enso-authentication",
"version": "1.0.0",
"type": "module",
"main": "./src/index.tsx",
"exports": {
".": "./src/index.tsx",
"./src/platform": "./src/platform.ts",
"./tailwind.css": "./tailwind.css"
},
"scripts": {
"compile": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aws-amplify/auth": "^5.6.5",
"@aws-amplify/core": "^5.8.5",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"enso-common": "^1.0.0",
"react-toastify": "^9.1.3",
"react-router-dom": "^6.8.1",
"ts-results": "^3.3.0"
},
"devDependencies": {
"typescript": "~5.2.2"
},
"overrides": {
"@aws-amplify/auth": "../_IGNORED_",
"react-native-url-polyfill": "../_IGNORED_"
}
}

View File

@ -6,12 +6,12 @@ import * as amplify from '@aws-amplify/auth'
import * as common from 'enso-common'
import * as detect from 'enso-common/src/detect'
import * as app from '../components/app'
import * as auth from './config'
import * as cognito from './cognito'
import * as config from '../config'
import * as listen from './listen'
import type * as loggerProvider from '../providers/logger'
import * as appUtils from '#/appUtils'
import * as cognito from '#/authentication/cognito'
import * as auth from '#/authentication/config'
import * as listen from '#/authentication/listen'
import type * as loggerProvider from '#/providers/LoggerProvider'
import * as config from '#/utilities/config'
// =============
// === Types ===
@ -208,7 +208,7 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin
/** If the user is being redirected after clicking the registration confirmation link in their
* email, then the URL will be for the confirmation page path. */
case CONFIRM_REGISTRATION_PATHNAME: {
const redirectUrl = `${app.CONFIRM_REGISTRATION_PATH}${parsedUrl.search}`
const redirectUrl = `${appUtils.CONFIRM_REGISTRATION_PATH}${parsedUrl.search}`
navigate(redirectUrl)
break
}
@ -219,7 +219,7 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin
case SIGN_IN_PATHNAME:
/** If the user is being redirected after a sign-out, then no query args will be present. */
if (parsedUrl.search === '') {
navigate(app.LOGIN_PATH)
navigate(appUtils.LOGIN_PATH)
} else {
handleAuthResponse(url)
}
@ -227,16 +227,16 @@ function setDeepLinkHandler(logger: loggerProvider.Logger, navigate: (url: strin
/** If the user is being redirected after finishing the password reset flow, then the URL will
* be for the login page. */
case LOGIN_PATHNAME:
navigate(app.LOGIN_PATH)
navigate(appUtils.LOGIN_PATH)
break
case REGISTRATION_PATHNAME:
navigate(app.REGISTRATION_PATH + parsedUrl.search)
navigate(`${appUtils.REGISTRATION_PATH}${parsedUrl.search}`)
break
/** If the user is being redirected from a password reset email, then we need to navigate to
* the password reset page, with the verification code and email passed in the URL s-o they can
* be filled in automatically. */
case app.RESET_PASSWORD_PATH: {
const resetPasswordRedirectUrl = app.RESET_PASSWORD_PATH + parsedUrl.search
case appUtils.RESET_PASSWORD_PATH: {
const resetPasswordRedirectUrl = `${appUtils.RESET_PASSWORD_PATH}${parsedUrl.search}`
navigate(resetPasswordRedirectUrl)
break
}

View File

@ -1,20 +0,0 @@
/** @file Tests for `error.ts`. */
import * as v from 'vitest'
import * as errorModule from '../error'
// =============
// === Tests ===
// =============
const MESSAGE = 'A custom error message.'
v.test.each([
{ error: new Error(MESSAGE), message: MESSAGE },
{ error: { message: 'a' }, message: 'a' },
{ error: MESSAGE, message: null },
{ error: {}, message: null },
{ error: null, message: null },
])('`error.tryGetMessage`', ({ error, message }) => {
v.expect(errorModule.tryGetMessage<unknown>(error)).toBe(message)
})

View File

@ -1,536 +0,0 @@
/** @file Column types and column display modes. */
import * as React from 'react'
import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg'
import AccessedDataIcon from 'enso-assets/accessed_data.svg'
import DocsIcon from 'enso-assets/docs.svg'
import PeopleIcon from 'enso-assets/people.svg'
import Plus2Icon from 'enso-assets/plus2.svg'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import SortDescendingIcon from 'enso-assets/sort_descending.svg'
import TagIcon from 'enso-assets/tag.svg'
import TimeIcon from 'enso-assets/time.svg'
import * as assetEvent from './events/assetEvent'
import * as assetQuery from '../assetQuery'
import type * as assetTreeNode from './assetTreeNode'
import * as authProvider from '../authentication/providers/auth'
import * as backendModule from './backend'
import * as backendProvider from '../providers/backend'
import * as dateTime from './dateTime'
import * as hooks from '../hooks'
import * as modalProvider from '../providers/modal'
import * as object from '../object'
import * as permissions from './permissions'
import * as shortcuts from './shortcuts'
import * as sorting from './sorting'
import type * as tableColumn from './components/tableColumn'
import * as uniqueString from '../uniqueString'
import type * as assetsTable from './components/assetsTable'
import * as categorySwitcher from './components/categorySwitcher'
import Label, * as labelModule from './components/label'
import AssetNameColumn from './components/assetNameColumn'
import ContextMenu from './components/contextMenu'
import ContextMenus from './components/contextMenus'
import ManageLabelsModal from './components/manageLabelsModal'
import ManagePermissionsModal from './components/managePermissionsModal'
import MenuEntry from './components/menuEntry'
import PermissionDisplay from './components/permissionDisplay'
import SvgMask from '../authentication/components/svgMask'
// =============
// === Types ===
// =============
/** Determines which columns are visible. */
export enum ColumnDisplayMode {
/** Show only columns which are ready for release. */
release = 'release',
/** Show all columns. */
all = 'all',
/** Show only name and metadata. */
compact = 'compact',
/** Show only columns relevant to documentation editors. */
docs = 'docs',
/** Show only name, metadata, and configuration options. */
settings = 'settings',
}
/** Column type. */
export enum Column {
name = 'name',
modified = 'modified',
sharedWith = 'shared-with',
labels = 'tags',
accessedByProjects = 'accessed-by-projects',
accessedData = 'accessed-data',
docs = 'docs',
}
/** Columns that can be toggled between visible and hidden. */
export type ExtraColumn = (typeof EXTRA_COLUMNS)[number]
/** Columns that can be used as a sort column. */
export type SortableColumn = Column.modified | Column.name
// =================
// === Constants ===
// =================
/** The list of extra columns, in order. */
// This MUST be `as const`, to generate the `ExtraColumn` type above.
// eslint-disable-next-line no-restricted-syntax
export const EXTRA_COLUMNS = [
Column.labels,
Column.accessedByProjects,
Column.accessedData,
Column.docs,
] as const
export const EXTRA_COLUMN_IMAGES: Record<ExtraColumn, string> = {
[Column.labels]: TagIcon,
[Column.accessedByProjects]: AccessedByProjectsIcon,
[Column.accessedData]: AccessedDataIcon,
[Column.docs]: DocsIcon,
}
/** English names for every column except for the name column. */
export const COLUMN_NAME: Record<Column, string> = {
[Column.name]: 'Name',
[Column.modified]: 'Modified',
[Column.sharedWith]: 'Shared with',
[Column.labels]: 'Labels',
[Column.accessedByProjects]: 'Accessed by projects',
[Column.accessedData]: 'Accessed data',
[Column.docs]: 'Docs',
} as const
const COLUMN_CSS_CLASSES =
'text-left bg-clip-padding border-transparent border-l-2 border-r-2 last:border-r-0'
const NORMAL_COLUMN_CSS_CLASSES = `px-2 last:rounded-r-full last:w-full ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */
export const COLUMN_CSS_CLASS: Record<Column, string> = {
[Column.name]: `rounded-rows-skip-level min-w-61.25 p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
[Column.modified]: `min-w-33.25 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.sharedWith]: `min-w-40 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.labels]: `min-w-80 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedByProjects]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedData]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.docs]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
} as const
/** {@link tableColumn.TableColumnProps} for an unknown variant of {@link backendModule.Asset}. */
export type AssetColumnProps = tableColumn.TableColumnProps<
assetTreeNode.AssetTreeNode,
assetsTable.AssetsTableState,
assetsTable.AssetRowState,
backendModule.AssetId
>
// =====================
// === getColumnList ===
// =====================
/** Return the full list of columns given the relevant current state. */
export function getColumnList(
backendType: backendModule.BackendType,
extraColumns: Set<ExtraColumn>
) {
switch (backendType) {
case backendModule.BackendType.local: {
return [Column.name, Column.modified]
}
case backendModule.BackendType.remote: {
return [
Column.name,
Column.modified,
Column.sharedWith,
...EXTRA_COLUMNS.filter(column => extraColumns.has(column)),
]
}
}
}
// ==========================
// === LastModifiedColumn ===
// ==========================
/** A column displaying the time at which the asset was last modified. */
function LastModifiedColumn(props: AssetColumnProps) {
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
}
// ========================
// === SharedWithColumn ===
// ========================
/** The type of the `state` prop of a {@link SharedWithColumn}. */
interface SharedWithColumnStateProp {
category: AssetColumnProps['state']['category']
dispatchAssetEvent: AssetColumnProps['state']['dispatchAssetEvent']
}
/** Props for a {@link SharedWithColumn}. */
export interface SharedWithColumnProps extends Pick<AssetColumnProps, 'item' | 'setItem'> {
state: SharedWithColumnStateProp
}
/** A column listing the users with which this asset is shared. */
export function SharedWithColumn(props: SharedWithColumnProps) {
const {
item: { item: asset },
setItem,
state: { category, dispatchAssetEvent },
} = props
const session = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const self = asset.permissions?.find(
permission => permission.user.user_email === session.organization?.email
)
const managesThisAsset =
category !== categorySwitcher.Category.trash &&
(self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem(oldItem =>
oldItem.with({
item:
typeof valueOrUpdater !== 'function'
? valueOrUpdater
: valueOrUpdater(oldItem.item),
})
)
},
[/* should never change */ setItem]
)
return (
<div className="group flex items-center gap-1">
{(asset.permissions ?? []).map(user => (
<PermissionDisplay key={user.user.pk} action={user.permission}>
{user.user.user_name}
</PermissionDisplay>
))}
{managesThisAsset && (
<button
className="h-4 w-4 invisible pointer-events-none group-hover:visible group-hover:pointer-events-auto"
onClick={event => {
event.stopPropagation()
setModal(
<ManagePermissionsModal
key={uniqueString.uniqueString()}
item={asset}
setItem={setAsset}
self={self}
eventTarget={event.currentTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: assetEvent.AssetEventType.removeSelf,
id: asset.id,
})
}}
/>
)
}}
>
<img className="w-4.5 h-4.5" src={Plus2Icon} />
</button>
)}
</div>
)
}
// ====================
// === LabelsColumn ===
// ====================
/** A column listing the labels on this asset. */
function LabelsColumn(props: AssetColumnProps) {
const {
item: { item: asset },
setItem,
state: { category, labels, setQuery, deletedLabelNames, doCreateLabel },
rowState: { temporarilyAddedLabels, temporarilyRemovedLabels },
} = props
const session = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const [isHovered, setIsHovered] = React.useState(false)
const self = asset.permissions?.find(
permission => permission.user.user_email === session.organization?.email
)
const managesThisAsset =
category !== categorySwitcher.Category.trash &&
(self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem(oldItem =>
oldItem.with({
item:
typeof valueOrUpdater !== 'function'
? valueOrUpdater
: valueOrUpdater(oldItem.item),
})
)
},
[/* should never change */ setItem]
)
return (
<div
className="flex items-center gap-1"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
{(asset.labels ?? [])
.filter(label => !deletedLabelNames.has(label))
.map(label => (
<Label
key={label}
title="Right click to remove label."
color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR}
active={!temporarilyRemovedLabels.has(label)}
disabled={temporarilyRemovedLabels.has(label)}
negated={temporarilyRemovedLabels.has(label)}
onContextMenu={event => {
event.preventDefault()
event.stopPropagation()
const doDelete = () => {
unsetModal()
setAsset(oldAsset => {
const newLabels =
oldAsset.labels?.filter(oldLabel => oldLabel !== label) ??
[]
void backend
.associateTag(asset.id, newLabels, asset.title)
.catch(error => {
toastAndLog(null, error)
setAsset(oldAsset2 =>
oldAsset2.labels?.some(
oldLabel => oldLabel === label
) === true
? oldAsset2
: object.merge(oldAsset2, {
labels: [
...(oldAsset2.labels ?? []),
label,
],
})
)
})
return {
...oldAsset,
labels: newLabels,
}
})
}
setModal(
<ContextMenus key={`label-${label}`} event={event}>
<ContextMenu>
<MenuEntry
action={shortcuts.KeyboardAction.delete}
doAction={doDelete}
/>
</ContextMenu>
</ContextMenus>
)
}}
onClick={event => {
event.preventDefault()
event.stopPropagation()
setQuery(oldQuery =>
assetQuery.toggleLabel(oldQuery, label, event.shiftKey)
)
}}
>
{label}
</Label>
))}
{...[...temporarilyAddedLabels]
.filter(label => asset.labels?.includes(label) !== true)
.map(label => (
<Label
disabled
key={label}
color={labels.get(label)?.color ?? labelModule.DEFAULT_LABEL_COLOR}
className="pointer-events-none"
onClick={() => {}}
>
{label}
</Label>
))}
{managesThisAsset && (
<button
className={`h-4 w-4 ${isHovered ? '' : 'invisible pointer-events-none'}`}
onClick={event => {
event.stopPropagation()
setModal(
<ManageLabelsModal
key={uniqueString.uniqueString()}
item={asset}
setItem={setAsset}
allLabels={labels}
doCreateLabel={doCreateLabel}
eventTarget={event.currentTarget}
/>
)
}}
>
<img className="w-4.5 h-4.5" src={Plus2Icon} />
</button>
)}
</div>
)
}
/** A column listing the users with which this asset is shared. */
export function DocsColumn(props: AssetColumnProps) {
const {
item: { item: asset },
} = props
return <div className="flex items-center gap-1">{asset.description}</div>
}
// =========================
// === PlaceholderColumn ===
// =========================
/** A placeholder component for columns which do not yet have corresponding data to display. */
function PlaceholderColumn() {
return <></>
}
// =================
// === Constants ===
// =================
/** The corresponding icon URL for each {@link sorting.SortDirection}. */
const SORT_ICON: Record<sorting.SortDirection, string> = {
[sorting.SortDirection.ascending]: SortAscendingIcon,
[sorting.SortDirection.descending]: SortDescendingIcon,
}
export const COLUMN_HEADING: Record<
Column,
(props: tableColumn.TableColumnHeadingProps<assetsTable.AssetsTableState>) => JSX.Element
> = {
[Column.name]: props => {
const {
state: { sortColumn, setSortColumn, sortDirection, setSortDirection },
} = props
const [isHovered, setIsHovered] = React.useState(false)
const isSortActive = sortColumn === Column.name && sortDirection != null
return (
<div
className="flex items-center cursor-pointer gap-2 pt-1 pb-1.5"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
onClick={event => {
event.stopPropagation()
if (sortColumn === Column.name) {
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
} else {
setSortColumn(Column.name)
setSortDirection(sorting.SortDirection.ascending)
}
}}
>
<span className="leading-144.5 h-6 py-0.5">{COLUMN_NAME[Column.name]}</span>
<img
src={isSortActive ? SORT_ICON[sortDirection] : SortAscendingIcon}
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
/>
</div>
)
},
[Column.modified]: props => {
const {
state: { sortColumn, setSortColumn, sortDirection, setSortDirection },
} = props
const [isHovered, setIsHovered] = React.useState(false)
const isSortActive = sortColumn === Column.modified && sortDirection != null
return (
<div
className="flex items-center cursor-pointer gap-2"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
onClick={event => {
event.stopPropagation()
if (sortColumn === Column.modified) {
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
} else {
setSortColumn(Column.modified)
setSortDirection(sorting.SortDirection.ascending)
}
}}
>
<SvgMask src={TimeIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">{COLUMN_NAME[Column.modified]}</span>
<img
src={isSortActive ? SORT_ICON[sortDirection] : SortAscendingIcon}
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
/>
</div>
)
},
[Column.sharedWith]: () => (
<div className="flex items-center gap-2">
<SvgMask src={PeopleIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">{COLUMN_NAME[Column.sharedWith]}</span>
</div>
),
[Column.labels]: () => (
<div className="flex items-center gap-2">
<SvgMask src={TagIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">{COLUMN_NAME[Column.labels]}</span>
</div>
),
[Column.accessedByProjects]: () => (
<div className="flex items-center gap-2">
<SvgMask src={AccessedByProjectsIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{COLUMN_NAME[Column.accessedByProjects]}
</span>
</div>
),
[Column.accessedData]: () => (
<div className="flex items-center gap-2">
<SvgMask src={AccessedDataIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">{COLUMN_NAME[Column.accessedData]}</span>
</div>
),
[Column.docs]: () => (
<div className="flex items-center gap-2">
<SvgMask src={DocsIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">{COLUMN_NAME[Column.docs]}</span>
</div>
),
}
/** React components for every column except for the name column. */
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
export const COLUMN_RENDERER: Record<Column, (props: AssetColumnProps) => JSX.Element> = {
[Column.name]: AssetNameColumn,
[Column.modified]: LastModifiedColumn,
[Column.sharedWith]: SharedWithColumn,
[Column.labels]: LabelsColumn,
[Column.accessedByProjects]: PlaceholderColumn,
[Column.accessedData]: PlaceholderColumn,
[Column.docs]: DocsColumn,
}

View File

@ -1,59 +0,0 @@
/** @file A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind
* classes. */
import * as React from 'react'
// ===============
// === Spinner ===
// ===============
/** The state of the spinner. It should go from initial, to loading, to done. */
export enum SpinnerState {
initial = 'initial',
loadingSlow = 'loading-slow',
loadingMedium = 'loading-medium',
loadingFast = 'loading-fast',
done = 'done',
}
export const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
[SpinnerState.initial]: 'dasharray-5 ease-linear',
[SpinnerState.loadingSlow]: 'dasharray-75 duration-90000 ease-linear',
[SpinnerState.loadingMedium]: 'dasharray-75 duration-5000 ease-linear',
[SpinnerState.loadingFast]: 'dasharray-75 duration-1000 ease-linear',
[SpinnerState.done]: 'dasharray-100 duration-1000 ease-in',
} as const
/** Props for a {@link Spinner}. */
export interface SpinnerProps {
size: number
state: SpinnerState
}
/** A spinning arc that animates using the `dasharray-<percentage>` custom Tailwind classes. */
export default function Spinner(props: SpinnerProps) {
const { size, state } = props
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x={1.5}
y={1.5}
width={21}
height={21}
rx={10.5}
stroke="currentColor"
strokeLinecap="round"
strokeWidth={3}
className={
'animate-spin-ease origin-center transition-stroke-dasharray ' +
`pointer-events-none ${SPINNER_CSS_CLASSES[state]}`
}
/>
</svg>
)
}

View File

@ -1,28 +0,0 @@
/** @file A spinner that does not expose its {@link spinner.SpinnerState}. */
import * as React from 'react'
import Spinner, * as spinner from './spinner'
// ========================
// === StatelessSpinner ===
// ========================
// This is a re-export, so that the API of this module mirrors that of the `spinner` module.
// eslint-disable-next-line no-restricted-syntax
export { SpinnerState } from './spinner'
/** Props for a {@link StatelessSpinner}. */
export interface StatelessSpinnerProps extends spinner.SpinnerProps {}
/** A spinner that does not expose its {@link spinner.SpinnerState}. Instead, it begins at
* {@link spinner.SpinnerState.initial} and immediately changes to the given state. */
export default function StatelessSpinner(props: StatelessSpinnerProps) {
const { size, state: rawState } = props
const [state, setState] = React.useState(spinner.SpinnerState.initial)
React.useEffect(() => {
setState(rawState)
}, [/* should never change */ rawState])
return <Spinner size={size} state={state} />
}

View File

@ -1,7 +0,0 @@
/** @file Utility functions and constants for sets. */
// =================
// === Constants ===
// =================
export const EMPTY: ReadonlySet<never> = new Set<never>()

View File

@ -1,297 +0,0 @@
/** @file Module containing common custom React hooks used throughout out Dashboard. */
import * as React from 'react'
import * as router from 'react-router'
import * as toastify from 'react-toastify'
import * as detect from 'enso-common/src/detect'
import * as app from './components/app'
import * as auth from './authentication/providers/auth'
import * as errorModule from './error'
import * as loggerProvider from './providers/logger'
// ======================
// === useToastAndLog ===
// ======================
/** Return a function to send a toast with rendered error message. The same message is also logged
* as an error. */
export function useToastAndLog() {
const logger = loggerProvider.useLogger()
return React.useCallback(
<T>(
messagePrefix: string | null,
error?: errorModule.MustNotBeKnown<T>,
options?: toastify.ToastOptions
) => {
const message =
error == null
? `${messagePrefix ?? ''}.`
: // DO NOT explicitly pass the generic parameter anywhere else.
// It is only being used here because this function also checks for
// `MustNotBeKnown<T>`.
`${
messagePrefix != null ? messagePrefix + ': ' : ''
}${errorModule.getMessageOrToString<unknown>(error)}`
const id = toastify.toast.error(message, options)
logger.error(message)
return id
},
[/* should never change */ logger]
)
}
// ======================
// === useAsyncEffect ===
// ======================
/** A React hook for re-rendering a component once an asynchronous call is over.
*
*This hook will take care of setting an initial value for the component state (so that it can
*render immediately), updating the state once the asynchronous call is over (to re-render the
*component), and cancelling any in-progress asynchronous calls when the component is unmounted (to
*avoid race conditions where "update 1" starts, "update 2" starts and finishes, then "update 1"
*finishes and sets the state).
*
*For further details, see: https://devtrium.com/posts/async-functions-useeffect.
*Also see: https://stackoverflow.com/questions/61751728/asynchronous-calls-with-react-usememo.
* @param initialValue - The initial value of the state controlled by this hook.
* @param asyncEffect - The asynchronous function used to load the state controlled by this hook.
* @param deps - The list of dependencies that, when updated, trigger the asynchronous effect.
* @returns The current value of the state controlled by this hook. */
export function useAsyncEffect<T>(
initialValue: T,
asyncEffect: (signal: AbortSignal) => Promise<T>,
deps?: React.DependencyList
): T {
const toastAndLog = useToastAndLog()
const [value, setValue] = React.useState<T>(initialValue)
React.useEffect(() => {
const controller = new AbortController()
void (async () => {
try {
const result = await asyncEffect(controller.signal)
if (!controller.signal.aborted) {
setValue(result)
}
} catch (error) {
toastAndLog('Error while fetching data', error)
}
})()
/** Cancel any future `setValue` calls. */
return () => {
controller.abort()
}
// This is a wrapper function around `useEffect`, so it has its own `deps` array.
// `asyncEffect` is omitted as it always changes - this is intentional.
// `logger` is omitted as it should not trigger the effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps ?? [])
return value
}
// ===================
// === useNavigate ===
// ===================
/** A wrapper around {@link router.useNavigate} that goes into offline mode when
* offline. */
export function useNavigate() {
const { goOffline } = auth.useAuth()
// This function is a wrapper around `router.useNavigate`. It shouldbe the only place where
// `router.useNavigate` is used.
// eslint-disable-next-line no-restricted-properties
const originalNavigate = router.useNavigate()
const navigate: router.NavigateFunction = (...args: [unknown, unknown?]) => {
const isOnline = navigator.onLine
if (!isOnline) {
void goOffline()
originalNavigate(app.DASHBOARD_PATH)
} else {
// This is safe, because the arguments are being passed through transparently.
// eslint-disable-next-line no-restricted-syntax
originalNavigate(...(args as [never, never?]))
}
}
return navigate
}
// =======================
// === Reactive Events ===
// =======================
/** A map containing all known event types. Names MUST be chosen carefully to avoid conflicts.
* The simplest way to achieve this is by namespacing names using a prefix. */
export interface KnownEventsMap {}
/** A union of all known events. */
type KnownEvent = KnownEventsMap[keyof KnownEventsMap]
/** A wrapper around `useState` that calls `flushSync` after every `setState`.
* This is required so that no events are dropped. */
export function useEvent<T extends KnownEvent>(): [events: T[], dispatchEvent: (event: T) => void] {
const [events, setEvents] = React.useState<T[]>([])
React.useEffect(() => {
if (events.length !== 0) {
// This must run after the current render, but before the next.
queueMicrotask(() => {
setEvents([])
})
}
}, [events])
const dispatchEvent = React.useCallback((event: T) => {
setEvents(oldEvents => [...oldEvents, event])
}, [])
return [events, dispatchEvent]
}
/** A wrapper around `useEffect` that has `event` as its sole dependency. */
export function useEventHandler<T extends KnownEvent>(
events: T[],
effect: (event: T) => Promise<void> | void
) {
let hasEffectRun = false
React.useLayoutEffect(() => {
if (detect.IS_DEV_MODE) {
if (hasEffectRun) {
// This is the second time this event is being run in React Strict Mode.
// Event handlers are not supposed to be idempotent, so it is a mistake to execute it
// a second time.
// eslint-disable-next-line no-restricted-syntax
return
} else {
// eslint-disable-next-line react-hooks/exhaustive-deps
hasEffectRun = true
}
}
void (async () => {
for (const event of events) {
await effect(event)
}
})()
}, [events])
}
// =========================================
// === Debug wrappers for built-in hooks ===
// =========================================
// `console.*` is allowed because these are for debugging purposes only.
/* eslint-disable no-restricted-properties */
// === useDebugState ===
// `console.*` is allowed because this is for debugging purposes only.
/* eslint-disable no-restricted-properties */
/** A modified `useState` that logs the old and new values when `setState` is called. */
export function useDebugState<T>(
initialState: T | (() => T),
name?: string
): [state: T, setState: (valueOrUpdater: React.SetStateAction<T>, source?: string) => void] {
const [state, rawSetState] = React.useState(initialState)
const description = name != null ? `state for '${name}'` : 'state'
const setState = React.useCallback(
(valueOrUpdater: React.SetStateAction<T>, source?: string) => {
const fullDescription = `${description}${source != null ? ` from '${source}'` : ''}`
rawSetState(oldState => {
const newState =
typeof valueOrUpdater === 'function'
? // This is UNSAFE when `T` is itself a function type,
// however React makes the same assumption.
// eslint-disable-next-line no-restricted-syntax
(valueOrUpdater as (prevState: T) => T)(oldState)
: valueOrUpdater
if (!Object.is(oldState, newState)) {
console.group(description)
console.trace(description)
console.log(`Old ${fullDescription}:`, oldState)
console.log(`New ${fullDescription}:`, newState)
console.groupEnd()
}
return newState
})
},
[description]
)
return [state, setState]
}
// === useMonitorDependencies ===
/** A helper function to log the old and new values of changed dependencies. */
function useMonitorDependencies(
dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
const oldDependenciesRef = React.useRef(dependencies)
const indicesOfChangedDependencies = dependencies.flatMap((dep, i) =>
Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i]
)
if (indicesOfChangedDependencies.length !== 0) {
const descriptionText = description == null ? '' : `for '${description}'`
console.group(`dependencies changed${descriptionText}`)
for (const i of indicesOfChangedDependencies) {
console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`)
console.log('old value:', oldDependenciesRef.current[i])
console.log('new value:', dependencies[i])
console.groupEnd()
}
console.groupEnd()
}
oldDependenciesRef.current = dependencies
}
/* eslint-enable no-restricted-properties */
// === useDebugEffect ===
/** A modified `useEffect` that logs the old and new values of changed dependencies. */
export function useDebugEffect(
effect: React.EffectCallback,
deps: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(effect, deps)
}
// === useDebugMemo ===
/** A modified `useMemo` that logs the old and new values of changed dependencies. */
export function useDebugMemo<T>(
factory: () => T,
deps: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useMemo<T>(factory, deps)
}
// === useDebugCallback ===
/** A modified `useCallback` that logs the old and new values of changed dependencies. */
export function useDebugCallback<T extends (...args: never[]) => unknown>(
callback: T,
deps: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback<T>(callback, deps)
}
/* eslint-enable no-restricted-properties */

View File

@ -1,4 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["../../../types", "."]
}

View File

@ -73,25 +73,9 @@ export type AutocompleteProps<T> = (
/** A select menu with a dropdown. */
export default function Autocomplete<T>(props: AutocompleteProps<T>) {
const {
multiple,
type = 'text',
inputRef: rawInputRef,
placeholder,
values,
setValues,
text,
setText,
autoFocus,
items,
itemToKey,
itemToString,
itemsToString,
matches,
className,
inputClassName,
optionsClassName,
} = props
const { multiple, type = 'text', inputRef: rawInputRef, placeholder, values, setValues } = props
const { text, setText, autoFocus, items, itemToKey, itemToString, itemsToString } = props
const { matches, className, inputClassName, optionsClassName } = props
const [isDropdownVisible, setIsDropdownVisible] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState<number | null>(null)
const valuesSet = React.useMemo(() => new Set(values), [values])

View File

@ -1,6 +1,7 @@
/** @file A styled button. */
import * as React from 'react'
import SvgMask from '../../authentication/components/svgMask'
import SvgMask from '#/components/SvgMask'
/** Props for a {@link Button}. */
export interface ButtonProps {
@ -19,16 +20,8 @@ export interface ButtonProps {
/** A styled button. */
export default function Button(props: ButtonProps) {
const {
active = false,
disabled = false,
disabledOpacityClassName,
image,
alt,
error,
className,
onClick,
} = props
const { active = false, disabled = false, disabledOpacityClassName, image, alt, error } = props
const { className, onClick } = props
return (
<SvgMask

View File

@ -1,7 +1,7 @@
/** @file A color picker to select from a predetermined list of colors. */
import * as React from 'react'
import * as backend from '../backend'
import * as backend from '#/services/backend'
/** Props for a {@link ColorPicker}. */
export interface ColorPickerProps {

View File

@ -1,7 +1,7 @@
/** @file A context menu. */
import * as React from 'react'
import Modal from './modal'
import Modal from '#/components/Modal'
// ===================
// === ContextMenu ===

View File

@ -1,9 +1,9 @@
/** @file Modal for confirming delete of any type of asset. */
import * as React from 'react'
import * as modalProvider from '../../providers/modal'
import * as modalProvider from '#/providers/ModalProvider'
import Modal from './modal'
import Modal from '#/components/Modal'
// =================
// === Constants ===

View File

@ -4,8 +4,8 @@ import * as React from 'react'
import CrossIcon from 'enso-assets/cross.svg'
import TickIcon from 'enso-assets/tick.svg'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
import * as shortcutsModule from '#/utilities/shortcuts'
// ====================
// === EditableSpan ===
@ -35,7 +35,7 @@ export default function EditableSpan(props: EditableSpanProps) {
onCancel,
inputPattern,
inputTitle,
...passthroughProps
...passthrough
} = props
const { shortcuts } = shortcutsProvider.useShortcuts()
const [isSubmittable, setIsSubmittable] = React.useState(true)
@ -89,7 +89,7 @@ export default function EditableSpan(props: EditableSpanProps) {
setIsSubmittable(checkSubmittable(event.currentTarget.value))
},
})}
{...passthroughProps}
{...passthrough}
/>
{isSubmittable && (
<button type="submit" className="mx-0.5">
@ -109,6 +109,6 @@ export default function EditableSpan(props: EditableSpanProps) {
</form>
)
} else {
return <span {...passthroughProps}>{children}</span>
return <span {...passthrough}>{children}</span>
}
}

View File

@ -1,8 +1,8 @@
/** @file A fixed-size container for a {@link fontawesome.FontAwesomeIcon FontAwesomeIcon}. */
import * as React from 'react'
import * as fontawesome from '@fortawesome/react-fontawesome'
import type * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons'
import * as fontawesome from '@fortawesome/react-fontawesome'
// =======================
// === FontAwesomeIcon ===

View File

@ -4,9 +4,9 @@ import * as React from 'react'
import EyeCrossedIcon from 'enso-assets/eye_crossed.svg'
import EyeIcon from 'enso-assets/eye.svg'
import type * as controlledInput from './controlledInput'
import ControlledInput from './controlledInput'
import SvgIcon from './svgIcon'
import type * as controlledInput from '#/components/ControlledInput'
import ControlledInput from '#/components/ControlledInput'
import SvgIcon from '#/components/SvgIcon'
// =============
// === Input ===
@ -29,7 +29,6 @@ export default function Input(props: InputProps) {
<SvgIcon src={icon} />
<ControlledInput {...passthrough} type={isShowingPassword ? 'text' : props.type} />
{props.type === 'password' && allowShowingPassword && (
// FIXME:
<SvgIcon
src={isShowingPassword ? EyeIcon : EyeCrossedIcon}
className="cursor-pointer rounded-full"

View File

@ -1,8 +1,9 @@
/** @file A styled colored link with an icon. */
import * as React from 'react'
import * as router from 'react-router-dom'
import SvgMask from './svgMask'
import SvgMask from '#/components/SvgMask'
// ============
// === Link ===

View File

@ -1,11 +1,11 @@
/** @file An entry in a menu. */
import * as React from 'react'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
import * as shortcutsModule from '#/utilities/shortcuts'
import KeyboardShortcut from './keyboardShortcut'
import SvgMask from '../../authentication/components/svgMask'
import KeyboardShortcut from '#/components/dashboard/keyboardShortcut'
import SvgMask from '#/components/SvgMask'
// =================
// === MenuEntry ===

View File

@ -1,7 +1,7 @@
/** @file Base modal component that provides the full-screen element that blocks mouse events. */
import * as React from 'react'
import * as modalProvider from '../../providers/modal'
import * as modalProvider from '#/providers/ModalProvider'
// =================
// === Component ===

View File

@ -0,0 +1,10 @@
# Components
This folder contains reusable styled components.
Components designed to take up the full page are located in `pages/`; components
that are supposed to be used only once are located in `layouts/`.
Generic components usable anywhere belong directly in this folder; components
only relevant to a specific part of the app belong in a subfolder named after
the corresponding section.

View File

@ -1,7 +1,7 @@
/** @file A spinner that does not expose its {@link spinner.SpinnerState}. */
import * as React from 'react'
import Spinner, * as spinner from './spinner'
import Spinner, * as spinner from '#/components/Spinner'
// ========================
// === StatelessSpinner ===
@ -9,7 +9,7 @@ import Spinner, * as spinner from './spinner'
// This is a re-export, so that the API of this module mirrors that of the `spinner` module.
// eslint-disable-next-line no-restricted-syntax
export { SpinnerState } from './spinner'
export { SpinnerState } from './Spinner'
/** Props for a {@link StatelessSpinner}. */
export interface StatelessSpinnerProps extends spinner.SpinnerProps {}

View File

@ -1,7 +1,7 @@
/** @file A styled submit button. */
import * as React from 'react'
import SvgMask from './svgMask'
import SvgMask from '#/components/SvgMask'
// ====================
// === SubmitButton ===

View File

@ -1,7 +1,7 @@
/** @file Styled wrapper around SVG images. */
import * as React from 'react'
import SvgMask from './svgMask'
import SvgMask from '#/components/SvgMask'
// ===============
// === SvgIcon ===

View File

@ -3,14 +3,14 @@
* being used directly. */
import * as React from 'react'
import * as set from '../../set'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
import * as set from '#/utilities/set'
import * as shortcutsModule from '#/utilities/shortcuts'
import type * as tableColumn from './tableColumn'
import type * as tableRow from './tableRow'
import Spinner, * as spinner from './spinner'
import TableRow from './tableRow'
import Spinner, * as spinner from '#/components/Spinner'
import type * as tableColumn from '#/components/TableColumn'
import type * as tableRow from '#/components/TableRow'
import TableRow from '#/components/TableRow'
// =================
// === Constants ===
@ -95,29 +95,14 @@ export type TableProps<
export default function Table<T, State = never, RowState = never, Key extends string = string>(
props: TableProps<T, State, RowState, Key>
) {
const {
rowComponent: RowComponent = TableRow,
scrollContainerRef,
headerRowRef,
footer,
items,
filter,
getKey,
selectedKeys: rawSelectedKeys,
setSelectedKeys: rawSetSelectedKeys,
columns,
isLoading,
placeholder,
onContextMenu,
draggableRows,
onDragLeave,
onRowDragStart,
onRowDrag,
onRowDragOver,
onRowDragEnd,
onRowDrop,
...rowProps
} = props
const { rowComponent: RowComponent = TableRow, scrollContainerRef, headerRowRef } = props
const { footer, items, filter, getKey } = props
const { selectedKeys: rawSelectedKeys, setSelectedKeys: rawSetSelectedKeys, columns } = props
const { isLoading, placeholder, onContextMenu, draggableRows, onDragLeave } = props
const { onRowDragStart, onRowDrag, onRowDragOver, onRowDragEnd, onRowDrop } = props
const { className, initialRowState, state } = props
const rowProps = { className, initialRowState, state }
const { shortcuts } = shortcutsProvider.useShortcuts()
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
// This should not be made mutable for the sake of optimization, otherwise its value may

View File

@ -1,9 +1,9 @@
/** @file A row in a `Table`. */
import * as React from 'react'
import * as modalProvider from '../../providers/modal'
import * as modalProvider from '#/providers/ModalProvider'
import type * as tableColumn from './tableColumn'
import type * as tableColumn from '#/components/TableColumn'
// =============================
// === Partial `Props` types ===

View File

@ -4,10 +4,10 @@ import * as React from 'react'
import DocsIcon from 'enso-assets/docs.svg'
import SettingsIcon from 'enso-assets/settings.svg'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as backendProvider from '#/providers/BackendProvider'
import * as backendModule from '#/services/backend'
import Button from './button'
import Button from '#/components/Button'
/** Props for an {@link AssetInfoBar}. */
export interface AssetInfoBarProps {

View File

@ -1,13 +1,13 @@
/** @file The icon and name of an {@link backendModule.Asset}. */
import * as React from 'react'
import * as backendModule from '../backend'
import * as backendModule from '#/services/backend'
import type * as column from '../column'
import ConnectorNameColumn from './connectorNameColumn'
import DirectoryNameColumn from './directoryNameColumn'
import FileNameColumn from './fileNameColumn'
import ProjectNameColumn from './projectNameColumn'
import type * as column from '#/components/dashboard/column'
import ConnectorNameColumn from '#/components/dashboard/ConnectorNameColumn'
import DirectoryNameColumn from '#/components/dashboard/DirectoryNameColumn'
import FileNameColumn from '#/components/dashboard/FileNameColumn'
import ProjectNameColumn from '#/components/dashboard/ProjectNameColumn'
// =================
// === AssetName ===

View File

@ -3,29 +3,29 @@ import * as React from 'react'
import BlankIcon from 'enso-assets/blank.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as dateTime from '../dateTime'
import * as download from '../../download'
import * as drag from '../drag'
import * as errorModule from '../../error'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as modalProvider from '../../providers/modal'
import * as object from '../../object'
import * as permissions from '../permissions'
import * as set from '../set'
import * as visibilityModule from '../visibility'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as hooks from '#/hooks'
import AssetContextMenu from '#/layouts/dashboard/AssetContextMenu'
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as backendModule from '#/services/backend'
import * as assetTreeNode from '#/utilities/assetTreeNode'
import * as dateTime from '#/utilities/dateTime'
import * as download from '#/utilities/download'
import * as drag from '#/utilities/drag'
import * as errorModule from '#/utilities/error'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as set from '#/utilities/set'
import Visibility, * as visibilityModule from '#/utilities/visibility'
import * as assetsTable from './assetsTable'
import type * as tableRow from './tableRow'
import StatelessSpinner, * as statelessSpinner from './statelessSpinner'
import AssetContextMenu from './assetContextMenu'
import TableRow from './tableRow'
import StatelessSpinner, * as statelessSpinner from '#/components/StatelessSpinner'
import type * as tableRow from '#/components/TableRow'
import TableRow from '#/components/TableRow'
// =================
// === Constants ===
@ -35,6 +35,9 @@ import TableRow from './tableRow'
* to make a directory row expand. */
const DRAG_EXPAND_DELAY_MS = 500
/** Placeholder row for directories that are empty. */
const EMPTY_DIRECTORY_PLACEHOLDER = <span className="px-2 opacity-75">This folder is empty.</span>
// ================
// === AssetRow ===
// ================
@ -50,42 +53,21 @@ export interface AssetRowProps
/** A row containing an {@link backendModule.AnyAsset}. */
export default function AssetRow(props: AssetRowProps) {
const {
keyProp: key,
item: rawItem,
initialRowState,
hidden,
selected,
isSoleSelectedItem,
setSelected,
allowContextMenu,
onContextMenu,
state,
columns,
} = props
const {
visibilities,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
setAssetSettingsPanelProps,
doToggleDirectoryExpansion,
doCopy,
doCut,
doPaste,
} = state
const { organization } = authProvider.useNonPartialUserSession()
const { keyProp: key, item: rawItem, initialRowState, hidden, selected } = props
const { isSoleSelectedItem, setSelected, allowContextMenu, onContextMenu, state } = props
const { columns } = props
const { visibilities, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { setAssetSettingsPanelProps, doToggleDirectoryExpansion, doCopy, doCut, doPaste } = state
const { organization, user } = authProvider.useNonPartialUserSession()
const { backend } = backendProvider.useBackend()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { user } = authProvider.useNonPartialUserSession()
const toastAndLog = hooks.useToastAndLog()
const [isDraggedOver, setIsDraggedOver] = React.useState(false)
const [item, setItem] = React.useState(rawItem)
const dragOverTimeoutHandle = React.useRef<number | null>(null)
const asset = item.item
const [insertionVisibility, setInsertionVisibility] = React.useState(
visibilityModule.Visibility.visible
)
const [insertionVisibility, setInsertionVisibility] = React.useState(Visibility.visible)
const [rowState, setRowState] = React.useState<assetsTable.AssetRowState>(() =>
object.merge(initialRowState, { setVisibility: setInsertionVisibility })
)
@ -102,7 +84,7 @@ export default function AssetRow(props: AssetRowProps) {
const setAsset = assetTreeNode.useSetAsset(asset, setItem)
React.useEffect(() => {
if (selected && insertionVisibility !== visibilityModule.Visibility.visible) {
if (selected && insertionVisibility !== Visibility.visible) {
setSelected(false)
}
}, [selected, insertionVisibility, /* should never change */ setSelected])
@ -134,7 +116,7 @@ export default function AssetRow(props: AssetRowProps) {
toastAndLog(`Could not copy '${asset.title}'`, error)
// Delete the new component representing the asset that failed to insert.
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
}
@ -161,7 +143,7 @@ export default function AssetRow(props: AssetRowProps) {
const nonNullNewParentId = newParentId ?? rootDirectoryId
try {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.move,
type: AssetListEventType.move,
newParentKey: nonNullNewParentKey,
newParentId: nonNullNewParentId,
key: item.key,
@ -188,7 +170,7 @@ export default function AssetRow(props: AssetRowProps) {
)
// Move the asset back to its original position.
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.move,
type: AssetListEventType.move,
newParentKey: item.directoryKey,
newParentId: item.directoryId,
key: item.key,
@ -215,10 +197,10 @@ export default function AssetRow(props: AssetRowProps) {
}, [item, isSoleSelectedItem, /* should never change */ setAssetSettingsPanelProps])
const doDelete = React.useCallback(async () => {
setInsertionVisibility(visibilityModule.Visibility.hidden)
setInsertionVisibility(Visibility.hidden)
if (asset.type === backendModule.AssetType.directory) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.closeFolder,
type: AssetListEventType.closeFolder,
id: asset.id,
// This is SAFE, as this asset is already known to be a directory.
// eslint-disable-next-line no-restricted-syntax
@ -227,7 +209,7 @@ export default function AssetRow(props: AssetRowProps) {
}
try {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.willDelete,
type: AssetListEventType.willDelete,
key: item.key,
})
if (
@ -248,11 +230,11 @@ export default function AssetRow(props: AssetRowProps) {
}
await backend.deleteAsset(asset.id, asset.title)
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
} catch (error) {
setInsertionVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(Visibility.visible)
toastAndLog(
errorModule.tryGetMessage(error)?.slice(0, -1) ??
`Could not delete ${backendModule.ASSET_TYPE_NAME[asset.type]}`
@ -268,15 +250,15 @@ export default function AssetRow(props: AssetRowProps) {
const doRestore = React.useCallback(async () => {
// Visually, the asset is deleted from the Trash view.
setInsertionVisibility(visibilityModule.Visibility.hidden)
setInsertionVisibility(Visibility.hidden)
try {
await backend.undoDeleteAsset(asset.id, asset.title)
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
} catch (error) {
setInsertionVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(Visibility.visible)
toastAndLog(`Unable to restore ${backendModule.ASSET_TYPE_NAME[asset.type]}`, error)
}
}, [
@ -290,53 +272,53 @@ export default function AssetRow(props: AssetRowProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
// These events are handled in the specific `NameColumn` files.
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.newDataConnector:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.closeProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects: {
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.uploadFiles:
case AssetEventType.newDataConnector:
case AssetEventType.openProject:
case AssetEventType.closeProject:
case AssetEventType.cancelOpeningAllProjects: {
break
}
case assetEventModule.AssetEventType.copy: {
case AssetEventType.copy: {
if (event.ids.has(item.key)) {
await doCopyOnBackend(event.newParentId)
}
break
}
case assetEventModule.AssetEventType.cut: {
case AssetEventType.cut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(visibilityModule.Visibility.faded)
setInsertionVisibility(Visibility.faded)
}
break
}
case assetEventModule.AssetEventType.cancelCut: {
case AssetEventType.cancelCut: {
if (event.ids.has(item.key)) {
setInsertionVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(Visibility.visible)
}
break
}
case assetEventModule.AssetEventType.move: {
case AssetEventType.move: {
if (event.ids.has(item.key)) {
setInsertionVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(Visibility.visible)
await doMove(event.newParentKey, event.newParentId)
}
break
}
case assetEventModule.AssetEventType.delete: {
case AssetEventType.delete: {
if (event.ids.has(item.key)) {
await doDelete()
}
break
}
case assetEventModule.AssetEventType.restore: {
case AssetEventType.restore: {
if (event.ids.has(item.key)) {
await doRestore()
}
break
}
case assetEventModule.AssetEventType.download: {
case AssetEventType.download: {
if (event.ids.has(item.key)) {
download.download(
`./api/project-manager/projects/${asset.id}/enso-project`,
@ -345,7 +327,7 @@ export default function AssetRow(props: AssetRowProps) {
}
break
}
case assetEventModule.AssetEventType.downloadSelected: {
case AssetEventType.downloadSelected: {
if (selected) {
download.download(
`./api/project-manager/projects/${asset.id}/enso-project`,
@ -354,10 +336,10 @@ export default function AssetRow(props: AssetRowProps) {
}
break
}
case assetEventModule.AssetEventType.removeSelf: {
case AssetEventType.removeSelf: {
// This is not triggered from the asset list, so it uses `item.id` instead of `key`.
if (event.id === asset.id && user != null) {
setInsertionVisibility(visibilityModule.Visibility.hidden)
setInsertionVisibility(Visibility.hidden)
try {
await backend.createPermission({
action: null,
@ -365,17 +347,17 @@ export default function AssetRow(props: AssetRowProps) {
userSubjects: [user.id],
})
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
} catch (error) {
setInsertionVisibility(visibilityModule.Visibility.visible)
setInsertionVisibility(Visibility.visible)
toastAndLog(null, error)
}
}
break
}
case assetEventModule.AssetEventType.temporarilyAddLabels: {
case AssetEventType.temporarilyAddLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY
setRowState(oldRowState =>
oldRowState.temporarilyAddedLabels === labels &&
@ -388,7 +370,7 @@ export default function AssetRow(props: AssetRowProps) {
)
break
}
case assetEventModule.AssetEventType.temporarilyRemoveLabels: {
case AssetEventType.temporarilyRemoveLabels: {
const labels = event.ids.has(item.key) ? event.labelNames : set.EMPTY
setRowState(oldRowState =>
oldRowState.temporarilyAddedLabels === set.EMPTY &&
@ -401,7 +383,7 @@ export default function AssetRow(props: AssetRowProps) {
)
break
}
case assetEventModule.AssetEventType.addLabels: {
case AssetEventType.addLabels: {
setRowState(oldRowState =>
oldRowState.temporarilyAddedLabels === set.EMPTY
? oldRowState
@ -426,7 +408,7 @@ export default function AssetRow(props: AssetRowProps) {
}
break
}
case assetEventModule.AssetEventType.removeLabels: {
case AssetEventType.removeLabels: {
setRowState(oldRowState =>
oldRowState.temporarilyAddedLabels === set.EMPTY
? oldRowState
@ -449,7 +431,7 @@ export default function AssetRow(props: AssetRowProps) {
}
break
}
case assetEventModule.AssetEventType.deleteLabel: {
case AssetEventType.deleteLabel: {
setAsset(oldAsset => {
// The IIFE is required to prevent TypeScript from narrowing this value.
let found = (() => false)()
@ -502,9 +484,7 @@ export default function AssetRow(props: AssetRowProps) {
isDraggedOver ? 'selected' : ''
}`}
{...props}
hidden={
hidden || insertionVisibility === visibilityModule.Visibility.hidden
}
hidden={hidden || insertionVisibility === Visibility.hidden}
onContextMenu={(innerProps, event) => {
if (allowContextMenu) {
event.preventDefault()
@ -591,7 +571,7 @@ export default function AssetRow(props: AssetRowProps) {
true
)
dispatchAssetEvent({
type: assetEventModule.AssetEventType.move,
type: AssetEventType.move,
newParentKey: directoryKey,
newParentId: directoryId,
ids: new Set(payload.map(dragItem => dragItem.key)),
@ -599,30 +579,28 @@ export default function AssetRow(props: AssetRowProps) {
}
}}
/>
{selected &&
allowContextMenu &&
insertionVisibility !== visibilityModule.Visibility.hidden && (
// This is a copy of the context menu, since the context menu registers keyboard
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
// the entire context menu (once for the keyboard actions, once for the JSX).
<AssetContextMenu
hidden
innerProps={{
key,
item,
setItem,
state,
rowState,
setRowState,
}}
event={{ pageX: 0, pageY: 0 }}
eventTarget={null}
doCopy={doCopy}
doCut={doCut}
doPaste={doPaste}
doDelete={doDelete}
/>
)}
{selected && allowContextMenu && insertionVisibility !== Visibility.hidden && (
// This is a copy of the context menu, since the context menu registers keyboard
// shortcut handlers. This is a bit of a hack, however it is preferable to duplicating
// the entire context menu (once for the keyboard actions, once for the JSX).
<AssetContextMenu
hidden
innerProps={{
key,
item,
setItem,
state,
rowState,
setRowState,
}}
event={{ pageX: 0, pageY: 0 }}
eventTarget={null}
doCopy={doCopy}
doCut={doCut}
doPaste={doPaste}
doDelete={doDelete}
/>
)}
</>
)
}
@ -654,7 +632,7 @@ export default function AssetRow(props: AssetRowProps) {
)}`}
>
<img src={BlankIcon} />
{assetsTable.EMPTY_DIRECTORY_PLACEHOLDER}
{EMPTY_DIRECTORY_PLACEHOLDER}
</div>
</td>
</tr>

View File

@ -1,12 +1,13 @@
/** @file Modal for confirming delete of any type of asset. */
import * as React from 'react'
import * as toastify from 'react-toastify'
import * as errorModule from '../../error'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import * as loggerProvider from '#/providers/LoggerProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as errorModule from '#/utilities/error'
import Modal from './modal'
import Modal from '#/components/Modal'
// ==========================
// === ConfirmDeleteModal ===

View File

@ -3,21 +3,21 @@ import * as React from 'react'
import ConnectorIcon from 'enso-assets/connector.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as object from '../../object'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as visibility from '../visibility'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as hooks from '#/hooks'
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 Visibility from '#/utilities/visibility'
import type * as column from '../column'
import EditableSpan from './editableSpan'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
// =====================
// === ConnectorName ===
@ -30,14 +30,8 @@ export interface ConnectorNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.SecretAsset}.
* This should never happen. */
export default function ConnectorNameColumn(props: ConnectorNameColumnProps) {
const {
item,
setItem,
selected,
state: { assetEvents, dispatchAssetListEvent },
rowState,
setRowState,
} = props
const { item, setItem, selected, state, rowState, setRowState } = props
const { assetEvents, dispatchAssetListEvent } = state
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
@ -57,48 +51,48 @@ export default function ConnectorNameColumn(props: ConnectorNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.closeProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.copy:
case assetEventModule.AssetEventType.cut:
case assetEventModule.AssetEventType.cancelCut:
case assetEventModule.AssetEventType.move:
case assetEventModule.AssetEventType.delete:
case assetEventModule.AssetEventType.restore:
case assetEventModule.AssetEventType.download:
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf:
case assetEventModule.AssetEventType.temporarilyAddLabels:
case assetEventModule.AssetEventType.temporarilyRemoveLabels:
case assetEventModule.AssetEventType.addLabels:
case assetEventModule.AssetEventType.removeLabels:
case assetEventModule.AssetEventType.deleteLabel: {
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.uploadFiles:
case AssetEventType.openProject:
case AssetEventType.closeProject:
case AssetEventType.cancelOpeningAllProjects:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to secrets.
// `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.newDataConnector: {
case AssetEventType.newDataConnector: {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Data connectors cannot be created on the local backend')
} else {
rowState.setVisibility(visibility.Visibility.faded)
rowState.setVisibility(Visibility.faded)
try {
const createdSecret = await backend.createSecret({
parentDirectoryId: asset.parentId,
secretName: asset.title,
secretValue: event.value,
})
rowState.setVisibility(visibility.Visibility.visible)
rowState.setVisibility(Visibility.visible)
setAsset(object.merger({ id: createdSecret.id }))
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Error creating new data connector', error)

View File

@ -4,22 +4,22 @@ import * as React from 'react'
import FolderIcon from 'enso-assets/folder.svg'
import TriangleDownIcon from 'enso-assets/triangle_down.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import type * as column from '../column'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as object from '../../object'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as visibility from '../visibility'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as hooks from '#/hooks'
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 Visibility from '#/utilities/visibility'
import EditableSpan from './editableSpan'
import SvgMask from '../../authentication/components/svgMask'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask'
// =====================
// === DirectoryName ===
@ -32,21 +32,9 @@ export interface DirectoryNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.DirectoryAsset}.
* This should never happen. */
export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
const {
item,
setItem,
selected,
setSelected,
state: {
numberOfSelectedItems,
assetEvents,
dispatchAssetListEvent,
nodeMap,
doToggleDirectoryExpansion,
},
rowState,
setRowState,
} = props
const { item, setItem, selected, setSelected, state, rowState, setRowState } = props
const { numberOfSelectedItems, assetEvents, dispatchAssetListEvent, nodeMap } = state
const { doToggleDirectoryExpansion } = state
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
@ -71,47 +59,47 @@ export default function DirectoryNameColumn(props: DirectoryNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.newDataConnector:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.closeProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.copy:
case assetEventModule.AssetEventType.cut:
case assetEventModule.AssetEventType.cancelCut:
case assetEventModule.AssetEventType.move:
case assetEventModule.AssetEventType.delete:
case assetEventModule.AssetEventType.restore:
case assetEventModule.AssetEventType.download:
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf:
case assetEventModule.AssetEventType.temporarilyAddLabels:
case assetEventModule.AssetEventType.temporarilyRemoveLabels:
case assetEventModule.AssetEventType.addLabels:
case assetEventModule.AssetEventType.removeLabels:
case assetEventModule.AssetEventType.deleteLabel: {
case AssetEventType.newProject:
case AssetEventType.uploadFiles:
case AssetEventType.newDataConnector:
case AssetEventType.openProject:
case AssetEventType.closeProject:
case AssetEventType.cancelOpeningAllProjects:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to directories.
// `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.newFolder: {
case AssetEventType.newFolder: {
if (item.key === event.placeholderId) {
if (backend.type !== backendModule.BackendType.remote) {
toastAndLog('Cannot create folders on the local drive')
} else {
rowState.setVisibility(visibility.Visibility.faded)
rowState.setVisibility(Visibility.faded)
try {
const createdDirectory = await backend.createDirectory({
parentId: asset.parentId,
title: asset.title,
})
rowState.setVisibility(visibility.Visibility.visible)
rowState.setVisibility(Visibility.visible)
setAsset(object.merge(asset, createdDirectory))
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Could not create new folder', error)

View File

@ -1,23 +1,23 @@
/** @file The icon and name of a {@link backendModule.FileAsset}. */
import * as React from 'react'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as eventModule from '../event'
import * as fileIcon from '../../fileIcon'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as object from '../../object'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as visibility from '../visibility'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as hooks from '#/hooks'
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 '../column'
import EditableSpan from './editableSpan'
import SvgMask from '../../authentication/components/svgMask'
import type * as column from '#/components/dashboard/column'
import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask'
// ================
// === FileName ===
@ -30,14 +30,8 @@ export interface FileNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.FileAsset}.
* This should never happen. */
export default function FileNameColumn(props: FileNameColumnProps) {
const {
item,
setItem,
selected,
state: { assetEvents, dispatchAssetListEvent },
rowState,
setRowState,
} = props
const { item, setItem, selected, state, rowState, setRowState } = props
const { assetEvents, dispatchAssetListEvent } = state
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { shortcuts } = shortcutsProvider.useShortcuts()
@ -57,35 +51,35 @@ export default function FileNameColumn(props: FileNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.newProject:
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.newDataConnector:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.closeProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.copy:
case assetEventModule.AssetEventType.cut:
case assetEventModule.AssetEventType.cancelCut:
case assetEventModule.AssetEventType.move:
case assetEventModule.AssetEventType.delete:
case assetEventModule.AssetEventType.restore:
case assetEventModule.AssetEventType.download:
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf:
case assetEventModule.AssetEventType.temporarilyAddLabels:
case assetEventModule.AssetEventType.temporarilyRemoveLabels:
case assetEventModule.AssetEventType.addLabels:
case assetEventModule.AssetEventType.removeLabels:
case assetEventModule.AssetEventType.deleteLabel: {
case AssetEventType.newProject:
case AssetEventType.newFolder:
case AssetEventType.newDataConnector:
case AssetEventType.openProject:
case AssetEventType.closeProject:
case AssetEventType.cancelOpeningAllProjects:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. These events should all be unrelated to projects.
// `deleteMultiple`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.uploadFiles: {
case AssetEventType.uploadFiles: {
const file = event.files.get(item.key)
if (file != null) {
rowState.setVisibility(visibility.Visibility.faded)
rowState.setVisibility(Visibility.faded)
try {
const createdFile = await backend.uploadFile(
{
@ -95,11 +89,11 @@ export default function FileNameColumn(props: FileNameColumnProps) {
},
file
)
rowState.setVisibility(visibility.Visibility.visible)
rowState.setVisibility(Visibility.visible)
setAsset(object.merge(asset, { id: createdFile.id }))
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Could not upload file', error)

View File

@ -1,21 +1,7 @@
/** @file An label that can be applied to an asset. */
import * as React from 'react'
import * as backend from '../backend'
// =================
// === Constants ===
// =================
/** The default color for labels (Light blue). */
export const DEFAULT_LABEL_COLOR: backend.LChColor = {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
lightness: 100,
chroma: 0,
hue: 0,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
alpha: 70,
}
import * as backend from '#/services/backend'
// =============
// === Label ===

View File

@ -0,0 +1,16 @@
/** @file Constants related to labels. */
import type * as backend from '#/services/backend'
// =================
// === Constants ===
// =================
// The default color for labels (Light blue).
export const DEFAULT_LABEL_COLOR: backend.LChColor = {
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
lightness: 100,
chroma: 0,
hue: 0,
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
alpha: 70,
}

View File

@ -1,7 +1,7 @@
/** @file Colored border around icons and text indicating permissions. */
import * as React from 'react'
import * as permissionsModule from '../permissions'
import * as permissionsModule from '#/utilities/permissions'
// =================
// === Component ===

View File

@ -1,12 +1,12 @@
/** @file A selector for all possible permissions. */
import * as React from 'react'
import type * as backend from '../backend'
import type * as permissions from '../permissions'
import * as permissionsModule from '../permissions'
import type * as backend from '#/services/backend'
import type * as permissions from '#/utilities/permissions'
import * as permissionsModule from '#/utilities/permissions'
import Modal from './modal'
import PermissionTypeSelector from './permissionTypeSelector'
import PermissionTypeSelector from '#/components/dashboard/PermissionTypeSelector'
import Modal from '#/components/Modal'
// =================
// === Constants ===
@ -46,18 +46,8 @@ export interface PermissionSelectorProps {
/** A horizontal selector for all possible permissions. */
export default function PermissionSelector(props: PermissionSelectorProps) {
const {
showDelete = false,
disabled = false,
typeSelectorYOffsetPx,
error,
selfPermission,
action: actionRaw,
assetType,
className,
onChange,
doDelete,
} = props
const { showDelete = false, disabled = false, typeSelectorYOffsetPx, error } = props
const { selfPermission, action: actionRaw, assetType, className, onChange, doDelete } = props
const [action, setActionRaw] = React.useState(actionRaw)
const [TheChild, setTheChild] = React.useState<(() => JSX.Element) | null>()
const permission = permissionsModule.FROM_PERMISSION_ACTION[action]

View File

@ -1,8 +1,8 @@
/** @file A selector for all possible permission types. */
import * as React from 'react'
import * as backend from '../backend'
import * as permissions from '../permissions'
import * as backend from '#/services/backend'
import * as permissions from '#/utilities/permissions'
// =================
// === Constants ===

View File

@ -1,25 +1,27 @@
/** @file An interactive button indicating the status of a project. */
import * as React from 'react'
import * as toast from 'react-toastify'
import ArrowUpIcon from 'enso-assets/arrow_up.svg'
import PlayIcon from 'enso-assets/play.svg'
import StopIcon from 'enso-assets/stop.svg'
import * as assetEventModule from '../events/assetEvent'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as errorModule from '../../error'
import * as hooks from '../../hooks'
import * as localStorageModule from '../localStorage'
import * as localStorageProvider from '../../providers/localStorage'
import * as modalProvider from '../../providers/modal'
import * as object from '../../object'
import * as remoteBackend from '../remoteBackend'
import type * as assetEvent from '#/events/assetEvent'
import AssetEventType from '#/events/AssetEventType'
import * as hooks from '#/hooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as localStorageProvider from '#/providers/LocalStorageProvider'
import * as modalProvider from '#/providers/ModalProvider'
import * as backendModule from '#/services/backend'
import * as remoteBackend from '#/services/remoteBackend'
import * as errorModule from '#/utilities/error'
import * as localStorageModule from '#/utilities/localStorage'
import * as object from '#/utilities/object'
import Spinner, * as spinner from './spinner'
import SvgMask from '../../authentication/components/svgMask'
import Spinner, * as spinner from '#/components/Spinner'
import SvgMask from '#/components/SvgMask'
// =================
// === Constants ===
@ -65,7 +67,7 @@ export interface ProjectIconProps {
keyProp: string
item: backendModule.ProjectAsset
setItem: React.Dispatch<React.SetStateAction<backendModule.ProjectAsset>>
assetEvents: assetEventModule.AssetEvent[]
assetEvents: assetEvent.AssetEvent[]
/** Called when the project is opened via the {@link ProjectIcon}. */
doOpenManually: (projectId: backendModule.ProjectId) => void
onClose: () => void
@ -234,29 +236,29 @@ export default function ProjectIcon(props: ProjectIconProps) {
hooks.useEventHandler(assetEvents, event => {
switch (event.type) {
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.uploadFiles:
case assetEventModule.AssetEventType.newDataConnector:
case assetEventModule.AssetEventType.copy:
case assetEventModule.AssetEventType.cut:
case assetEventModule.AssetEventType.cancelCut:
case assetEventModule.AssetEventType.move:
case assetEventModule.AssetEventType.delete:
case assetEventModule.AssetEventType.restore:
case assetEventModule.AssetEventType.download:
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf:
case assetEventModule.AssetEventType.temporarilyAddLabels:
case assetEventModule.AssetEventType.temporarilyRemoveLabels:
case assetEventModule.AssetEventType.addLabels:
case assetEventModule.AssetEventType.removeLabels:
case assetEventModule.AssetEventType.deleteLabel: {
case AssetEventType.newFolder:
case AssetEventType.uploadFiles:
case AssetEventType.newDataConnector:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. Any missing project-related events should be handled by
// `ProjectNameColumn`. `deleteMultiple`, `restoreMultiple`, `download`,
// and `downloadSelected` are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.openProject: {
case AssetEventType.openProject: {
if (event.id !== item.id) {
if (!event.runInBackground && !isRunningInBackground) {
setShouldOpenWhenReady(false)
@ -272,14 +274,14 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
break
}
case assetEventModule.AssetEventType.closeProject: {
case AssetEventType.closeProject: {
if (event.id === item.id) {
setShouldOpenWhenReady(false)
void closeProject(false)
}
break
}
case assetEventModule.AssetEventType.cancelOpeningAllProjects: {
case AssetEventType.cancelOpeningAllProjects: {
if (!isRunningInBackground) {
setShouldOpenWhenReady(false)
onSpinnerStateChange?.(null)
@ -292,7 +294,7 @@ export default function ProjectIcon(props: ProjectIconProps) {
}
break
}
case assetEventModule.AssetEventType.newProject: {
case AssetEventType.newProject: {
if (event.placeholderId === key) {
setOnSpinnerStateChange(() => event.onSpinnerStateChange)
} else if (event.onSpinnerStateChange === onSpinnerStateChange) {

View File

@ -3,27 +3,27 @@ import * as React from 'react'
import NetworkIcon from 'enso-assets/network.svg'
import * as assetEventModule from '../events/assetEvent'
import * as assetListEventModule from '../events/assetListEvent'
import * as assetTreeNode from '../assetTreeNode'
import * as authProvider from '../../authentication/providers/auth'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as errorModule from '../../error'
import * as eventModule from '../event'
import * as hooks from '../../hooks'
import * as indent from '../indent'
import * as object from '../../object'
import * as permissions from '../permissions'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import * as validation from '../validation'
import * as visibility from '../visibility'
import AssetEventType from '#/events/AssetEventType'
import AssetListEventType from '#/events/AssetListEventType'
import * as hooks from '#/hooks'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
import * as backendModule from '#/services/backend'
import * as assetTreeNode from '#/utilities/assetTreeNode'
import * as errorModule from '#/utilities/error'
import * as eventModule from '#/utilities/event'
import * as indent from '#/utilities/indent'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as shortcutsModule from '#/utilities/shortcuts'
import * as validation from '#/utilities/validation'
import Visibility from '#/utilities/visibility'
import type * as column from '../column'
import EditableSpan from './editableSpan'
import ProjectIcon from './projectIcon'
import SvgMask from '../../authentication/components/svgMask'
import type * as column from '#/components/dashboard/column'
import ProjectIcon from '#/components/dashboard/ProjectIcon'
import EditableSpan from '#/components/EditableSpan'
import SvgMask from '#/components/SvgMask'
// ===================
// === ProjectName ===
@ -36,23 +36,9 @@ export interface ProjectNameColumnProps extends column.AssetColumnProps {}
* @throws {Error} when the asset is not a {@link backendModule.ProjectAsset}.
* This should never happen. */
export default function ProjectNameColumn(props: ProjectNameColumnProps) {
const {
item,
setItem,
selected,
rowState,
setRowState,
state: {
numberOfSelectedItems,
assetEvents,
dispatchAssetEvent,
dispatchAssetListEvent,
nodeMap,
doOpenManually,
doOpenIde,
doCloseIde,
},
} = props
const { item, setItem, selected, rowState, setRowState, state } = props
const { numberOfSelectedItems, assetEvents, dispatchAssetEvent, dispatchAssetListEvent } = state
const { nodeMap, doOpenManually, doOpenIde, doCloseIde } = state
const toastAndLog = hooks.useToastAndLog()
const { backend } = backendProvider.useBackend()
const { organization } = authProvider.useNonPartialUserSession()
@ -69,7 +55,9 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
// This is a workaround for a temporary bad state in the backend causing the `projectState` key
// to be absent.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const projectState = asset.projectState ?? { type: backendModule.ProjectState.closed }
const projectState = asset.projectState ?? {
type: backendModule.ProjectState.closed,
}
const isRunning = backendModule.DOES_PROJECT_STATE_INDICATE_VM_EXISTS[projectState.type]
const canExecute =
backend.type === backendModule.BackendType.local ||
@ -100,43 +88,43 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
hooks.useEventHandler(assetEvents, async event => {
switch (event.type) {
case assetEventModule.AssetEventType.newFolder:
case assetEventModule.AssetEventType.newDataConnector:
case assetEventModule.AssetEventType.openProject:
case assetEventModule.AssetEventType.closeProject:
case assetEventModule.AssetEventType.cancelOpeningAllProjects:
case assetEventModule.AssetEventType.copy:
case assetEventModule.AssetEventType.cut:
case assetEventModule.AssetEventType.cancelCut:
case assetEventModule.AssetEventType.move:
case assetEventModule.AssetEventType.delete:
case assetEventModule.AssetEventType.restore:
case assetEventModule.AssetEventType.download:
case assetEventModule.AssetEventType.downloadSelected:
case assetEventModule.AssetEventType.removeSelf:
case assetEventModule.AssetEventType.temporarilyAddLabels:
case assetEventModule.AssetEventType.temporarilyRemoveLabels:
case assetEventModule.AssetEventType.addLabels:
case assetEventModule.AssetEventType.removeLabels:
case assetEventModule.AssetEventType.deleteLabel: {
case AssetEventType.newFolder:
case AssetEventType.newDataConnector:
case AssetEventType.openProject:
case AssetEventType.closeProject:
case AssetEventType.cancelOpeningAllProjects:
case AssetEventType.copy:
case AssetEventType.cut:
case AssetEventType.cancelCut:
case AssetEventType.move:
case AssetEventType.delete:
case AssetEventType.restore:
case AssetEventType.download:
case AssetEventType.downloadSelected:
case AssetEventType.removeSelf:
case AssetEventType.temporarilyAddLabels:
case AssetEventType.temporarilyRemoveLabels:
case AssetEventType.addLabels:
case AssetEventType.removeLabels:
case AssetEventType.deleteLabel: {
// Ignored. Any missing project-related events should be handled by `ProjectIcon`.
// `deleteMultiple`, `restoreMultiple`, `download`, and `downloadSelected`
// are handled by `AssetRow`.
break
}
case assetEventModule.AssetEventType.newProject: {
case AssetEventType.newProject: {
// This should only run before this project gets replaced with the actual project
// by this event handler. In both cases `key` will match, so using `key` here
// is a mistake.
if (asset.id === event.placeholderId) {
rowState.setVisibility(visibility.Visibility.faded)
rowState.setVisibility(Visibility.faded)
try {
const createdProject = await backend.createProject({
parentDirectoryId: asset.parentId,
projectName: asset.title,
projectTemplateName: event.templateId,
})
rowState.setVisibility(visibility.Visibility.visible)
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
id: createdProject.projectId,
@ -146,14 +134,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
})
)
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
type: AssetEventType.openProject,
id: createdProject.projectId,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Error creating new project', error)
@ -161,10 +149,10 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
break
}
case assetEventModule.AssetEventType.uploadFiles: {
case AssetEventType.uploadFiles: {
const file = event.files.get(item.key)
if (file != null) {
rowState.setVisibility(visibility.Visibility.faded)
rowState.setVisibility(Visibility.faded)
try {
if (backend.type === backendModule.BackendType.local) {
let id: string
@ -190,7 +178,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
backendModule.ProjectId(id),
null
)
rowState.setVisibility(visibility.Visibility.visible)
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
title: listedProject.packageName,
@ -213,7 +201,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
if (project == null) {
throw new Error('The uploaded file was not a project.')
} else {
rowState.setVisibility(visibility.Visibility.visible)
rowState.setVisibility(Visibility.visible)
setAsset(
object.merge(asset, {
title,
@ -226,7 +214,7 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
}
} catch (error) {
dispatchAssetListEvent({
type: assetListEventModule.AssetListEventType.delete,
type: AssetListEventType.delete,
key: item.key,
})
toastAndLog('Could not upload project', error)
@ -253,14 +241,14 @@ export default function ProjectNameColumn(props: ProjectNameColumnProps) {
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.open, event)) {
// It is a double click; open the project.
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
type: AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: true,
runInBackground: false,
})
} else if (shortcuts.matchesMouseAction(shortcutsModule.MouseAction.run, event)) {
dispatchAssetEvent({
type: assetEventModule.AssetEventType.openProject,
type: AssetEventType.openProject,
id: asset.id,
shouldAutomaticallySwitchPage: false,
runInBackground: true,

View File

@ -1,7 +1,7 @@
/** @file A component that renders the modal instance from the modal React Context. */
import * as React from 'react'
import * as modalProvider from '../../providers/modal'
import * as modalProvider from '#/providers/ModalProvider'
// ================
// === TheModal ===

View File

@ -1,12 +1,12 @@
/** @file A user and their permissions for a specific asset. */
import * as React from 'react'
import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend'
import * as hooks from '../../hooks'
import * as object from '../../object'
import * as hooks from '#/hooks'
import * as backendProvider from '#/providers/BackendProvider'
import * as backendModule from '#/services/backend'
import * as object from '#/utilities/object'
import PermissionSelector from './permissionSelector'
import PermissionSelector from '#/components/dashboard/PermissionSelector'
/** Props for a {@link UserPermissions}. */
export interface UserPermissionsProps {
@ -20,14 +20,9 @@ export interface UserPermissionsProps {
/** A user and their permissions for a specific asset. */
export default function UserPermissions(props: UserPermissionsProps) {
const {
asset,
self,
isOnlyOwner,
userPermission: initialUserPermission,
setUserPermission: outerSetUserPermission,
doDelete,
} = props
const { asset, self, isOnlyOwner, doDelete } = props
const { userPermission: initialUserPermission, setUserPermission: outerSetUserPermission } =
props
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const [userPermissions, setUserPermissions] = React.useState(initialUserPermission)

View File

@ -0,0 +1,40 @@
/** @file Column types and column display modes. */
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
import type * as backendModule from '#/services/backend'
import type * as assetTreeNode from '#/utilities/assetTreeNode'
import AssetNameColumn from '#/components/dashboard/AssetNameColumn'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import DocsColumn from '#/components/dashboard/column/DocsColumn'
import LabelsColumn from '#/components/dashboard/column/LabelsColumn'
import LastModifiedColumn from '#/components/dashboard/column/LastModifiedColumn'
import PlaceholderColumn from '#/components/dashboard/column/PlaceholderColumn'
import SharedWithColumn from '#/components/dashboard/column/SharedWithColumn'
import type * as tableColumn from '#/components/TableColumn'
// ==========================
// === LastModifiedColumn ===
// ==========================
/** {@link tableColumn.TableColumnProps} for an unknown variant of {@link backendModule.Asset}. */
export type AssetColumnProps = tableColumn.TableColumnProps<
assetTreeNode.AssetTreeNode,
assetsTable.AssetsTableState,
assetsTable.AssetRowState,
backendModule.AssetId
>
/** React components for every column except for the name column. */
// This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
export const COLUMN_RENDERER: Record<columnUtils.Column, (props: AssetColumnProps) => JSX.Element> =
{
[columnUtils.Column.name]: AssetNameColumn,
[columnUtils.Column.modified]: LastModifiedColumn,
[columnUtils.Column.sharedWith]: SharedWithColumn,
[columnUtils.Column.labels]: LabelsColumn,
[columnUtils.Column.accessedByProjects]: PlaceholderColumn,
[columnUtils.Column.accessedData]: PlaceholderColumn,
[columnUtils.Column.docs]: DocsColumn,
}

View File

@ -0,0 +1,10 @@
/** @file A column listing the users with which this asset is shared. */
import * as React from 'react'
import type * as column from '#/components/dashboard/column'
/** A column listing the users with which this asset is shared. */
export default function DocsColumn(props: column.AssetColumnProps) {
const { item } = props
return <div className="flex items-center gap-1">{item.item.description}</div>
}

View File

@ -0,0 +1,172 @@
/** @file A column listing the labels on this asset. */
import * as React from 'react'
import Plus2Icon from 'enso-assets/plus2.svg'
import * as hooks from '#/hooks'
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
import ManageLabelsModal from '#/layouts/dashboard/ManageLabelsModal'
import * as authProvider from '#/providers/AuthProvider'
import * as backendProvider from '#/providers/BackendProvider'
import * as modalProvider from '#/providers/ModalProvider'
import type * as backendModule from '#/services/backend'
import * as assetQuery from '#/utilities/assetQuery'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as shortcuts from '#/utilities/shortcuts'
import * as uniqueString from '#/utilities/uniqueString'
import ContextMenu from '#/components/ContextMenu'
import ContextMenus from '#/components/ContextMenus'
import type * as column from '#/components/dashboard/column'
import Label from '#/components/dashboard/Label'
import * as labelUtils from '#/components/dashboard/Label/labelUtils'
import MenuEntry from '#/components/MenuEntry'
// ====================
// === LabelsColumn ===
// ====================
/** A column listing the labels on this asset. */
export default function LabelsColumn(props: column.AssetColumnProps) {
const { item, setItem, state, rowState } = props
const { category, labels, setQuery, deletedLabelNames, doCreateLabel } = state
const { temporarilyAddedLabels, temporarilyRemovedLabels } = rowState
const asset = item.item
const session = authProvider.useNonPartialUserSession()
const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend()
const toastAndLog = hooks.useToastAndLog()
const [isHovered, setIsHovered] = React.useState(false)
const self = asset.permissions?.find(
permission => permission.user.user_email === session.organization?.email
)
const managesThisAsset =
category !== Category.trash &&
(self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem(oldItem =>
object.merge(oldItem, {
item:
typeof valueOrUpdater !== 'function'
? valueOrUpdater
: valueOrUpdater(oldItem.item),
})
)
},
[/* should never change */ setItem]
)
return (
<div
className="flex items-center gap-1"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
{(asset.labels ?? [])
.filter(label => !deletedLabelNames.has(label))
.map(label => (
<Label
key={label}
title="Right click to remove label."
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
active={!temporarilyRemovedLabels.has(label)}
disabled={temporarilyRemovedLabels.has(label)}
negated={temporarilyRemovedLabels.has(label)}
className={
temporarilyRemovedLabels.has(label)
? 'relative before:absolute before:rounded-full before:border-2 before:border-delete before:inset-0 before:w-full before:h-full'
: ''
}
onContextMenu={event => {
event.preventDefault()
event.stopPropagation()
const doDelete = () => {
unsetModal()
setAsset(oldAsset => {
const newLabels =
oldAsset.labels?.filter(oldLabel => oldLabel !== label) ??
[]
void backend
.associateTag(asset.id, newLabels, asset.title)
.catch(error => {
toastAndLog(null, error)
setAsset(oldAsset2 =>
oldAsset2.labels?.some(
oldLabel => oldLabel === label
) === true
? oldAsset2
: object.merge(oldAsset2, {
labels: [
...(oldAsset2.labels ?? []),
label,
],
})
)
})
return object.merge(oldAsset, { labels: newLabels })
})
}
setModal(
<ContextMenus key={`label-${label}`} event={event}>
<ContextMenu>
<MenuEntry
action={shortcuts.KeyboardAction.delete}
doAction={doDelete}
/>
</ContextMenu>
</ContextMenus>
)
}}
onClick={event => {
event.preventDefault()
event.stopPropagation()
setQuery(oldQuery =>
assetQuery.toggleLabel(oldQuery, label, event.shiftKey)
)
}}
>
{label}
</Label>
))}
{...[...temporarilyAddedLabels]
.filter(label => asset.labels?.includes(label) !== true)
.map(label => (
<Label
disabled
key={label}
color={labels.get(label)?.color ?? labelUtils.DEFAULT_LABEL_COLOR}
className="pointer-events-none"
onClick={() => {}}
>
{label}
</Label>
))}
{managesThisAsset && (
<button
className={`h-4 w-4 ${isHovered ? '' : 'invisible pointer-events-none'}`}
onClick={event => {
event.stopPropagation()
setModal(
<ManageLabelsModal
key={uniqueString.uniqueString()}
item={asset}
setItem={setAsset}
allLabels={labels}
doCreateLabel={doCreateLabel}
eventTarget={event.currentTarget}
/>
)
}}
>
<img className="w-4.5 h-4.5" src={Plus2Icon} />
</button>
)}
</div>
)
}

View File

@ -0,0 +1,11 @@
/** @file A column displaying the time at which the asset was last modified. */
import * as React from 'react'
import * as dateTime from '#/utilities/dateTime'
import type * as column from '#/components/dashboard/column'
/** A column displaying the time at which the asset was last modified. */
export default function LastModifiedColumn(props: column.AssetColumnProps) {
return <>{dateTime.formatDateTime(new Date(props.item.item.modifiedAt))}</>
}

View File

@ -0,0 +1,11 @@
/** @file A placeholder component for columns which do not yet have corresponding data to display. */
import * as React from 'react'
// =========================
// === PlaceholderColumn ===
// =========================
/** A placeholder component for columns which do not yet have corresponding data to display. */
export default function PlaceholderColumn() {
return <></>
}

View File

@ -0,0 +1,95 @@
/** @file A column listing the users with which this asset is shared. */
import * as React from 'react'
import Plus2Icon from 'enso-assets/plus2.svg'
import AssetEventType from '#/events/AssetEventType'
import Category from '#/layouts/dashboard/CategorySwitcher/Category'
import ManagePermissionsModal from '#/layouts/dashboard/ManagePermissionsModal'
import * as authProvider from '#/providers/AuthProvider'
import * as modalProvider from '#/providers/ModalProvider'
import type * as backendModule from '#/services/backend'
import * as object from '#/utilities/object'
import * as permissions from '#/utilities/permissions'
import * as uniqueString from '#/utilities/uniqueString'
import type * as column from '#/components/dashboard/column'
import PermissionDisplay from '#/components/dashboard/PermissionDisplay'
// ========================
// === SharedWithColumn ===
// ========================
/** The type of the `state` prop of a {@link SharedWithColumn}. */
interface SharedWithColumnStateProp {
category: column.AssetColumnProps['state']['category']
dispatchAssetEvent: column.AssetColumnProps['state']['dispatchAssetEvent']
}
/** Props for a {@link SharedWithColumn}. */
interface SharedWithColumnPropsInternal extends Pick<column.AssetColumnProps, 'item' | 'setItem'> {
state: SharedWithColumnStateProp
}
/** A column listing the users with which this asset is shared. */
export default function SharedWithColumn(props: SharedWithColumnPropsInternal) {
const { item, setItem, state } = props
const { category, dispatchAssetEvent } = state
const asset = item.item
const session = authProvider.useNonPartialUserSession()
const { setModal } = modalProvider.useSetModal()
const self = asset.permissions?.find(
permission => permission.user.user_email === session.organization?.email
)
const managesThisAsset =
category !== Category.trash &&
(self?.permission === permissions.PermissionAction.own ||
self?.permission === permissions.PermissionAction.admin)
const setAsset = React.useCallback(
(valueOrUpdater: React.SetStateAction<backendModule.AnyAsset>) => {
setItem(oldItem =>
object.merge(oldItem, {
item:
typeof valueOrUpdater !== 'function'
? valueOrUpdater
: valueOrUpdater(oldItem.item),
})
)
},
[/* should never change */ setItem]
)
return (
<div className="flex items-center gap-1">
{(asset.permissions ?? []).map(user => (
<PermissionDisplay key={user.user.pk} action={user.permission}>
{user.user.user_name}
</PermissionDisplay>
))}
{managesThisAsset && (
<button
className="h-4 w-4 invisible pointer-events-none group-hover:visible group-hover:pointer-events-auto"
onClick={event => {
event.stopPropagation()
setModal(
<ManagePermissionsModal
key={uniqueString.uniqueString()}
item={asset}
setItem={setAsset}
self={self}
eventTarget={event.currentTarget}
doRemoveSelf={() => {
dispatchAssetEvent({
type: AssetEventType.removeSelf,
id: asset.id,
})
}}
/>
)
}}
>
<img className="w-4.5 h-4.5" src={Plus2Icon} />
</button>
)}
</div>
)
}

View File

@ -0,0 +1,121 @@
/** @file Types and constants related to `Column`s. */
import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg'
import AccessedDataIcon from 'enso-assets/accessed_data.svg'
import DocsIcon from 'enso-assets/docs.svg'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import SortDescendingIcon from 'enso-assets/sort_descending.svg'
import TagIcon from 'enso-assets/tag.svg'
import * as backend from '#/services/backend'
import * as sorting from '#/utilities/sorting'
// =============
// === Types ===
// =============
/** Determines which columns are visible. */
export enum ColumnDisplayMode {
/** Show only columns which are ready for release. */
release = 'release',
/** Show all columns. */
all = 'all',
/** Show only name and metadata. */
compact = 'compact',
/** Show only columns relevant to documentation editors. */
docs = 'docs',
/** Show only name, metadata, and configuration options. */
settings = 'settings',
}
/** Column type. */
export enum Column {
name = 'name',
modified = 'modified',
sharedWith = 'sharedWith',
labels = 'labels',
accessedByProjects = 'accessedByProjects',
accessedData = 'accessedData',
docs = 'docs',
}
/** Columns that can be toggled between visible and hidden. */
export type ExtraColumn = (typeof EXTRA_COLUMNS)[number]
/** Columns that can be used as a sort column. */
export type SortableColumn = Column.modified | Column.name
// =================
// === Constants ===
// =================
/** The list of extra columns, in order. */
// This MUST be `as const`, to generate the `ExtraColumn` type above.
// eslint-disable-next-line no-restricted-syntax
export const EXTRA_COLUMNS = [
Column.labels,
Column.accessedByProjects,
Column.accessedData,
Column.docs,
] as const
export const EXTRA_COLUMN_IMAGES: Record<ExtraColumn, string> = {
[Column.labels]: TagIcon,
[Column.accessedByProjects]: AccessedByProjectsIcon,
[Column.accessedData]: AccessedDataIcon,
[Column.docs]: DocsIcon,
}
/** English names for every column except for the name column. */
export const COLUMN_NAME: Record<Column, string> = {
[Column.name]: 'Name',
[Column.modified]: 'Modified',
[Column.sharedWith]: 'Shared with',
[Column.labels]: 'Labels',
[Column.accessedByProjects]: 'Accessed by projects',
[Column.accessedData]: 'Accessed data',
[Column.docs]: 'Docs',
} as const
const COLUMN_CSS_CLASSES =
'text-left bg-clip-padding border-transparent border-l-2 border-r-2 last:border-r-0'
const NORMAL_COLUMN_CSS_CLASSES = `px-2 last:rounded-r-full last:w-full ${COLUMN_CSS_CLASSES}`
/** CSS classes for every column. */
export const COLUMN_CSS_CLASS: Record<Column, string> = {
[Column.name]: `rounded-rows-skip-level min-w-61.25 p-0 border-l-0 ${COLUMN_CSS_CLASSES}`,
[Column.modified]: `min-w-33.25 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.sharedWith]: `min-w-40 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.labels]: `min-w-80 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedByProjects]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.accessedData]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
[Column.docs]: `min-w-96 ${NORMAL_COLUMN_CSS_CLASSES}`,
} as const
// =====================
// === getColumnList ===
// =====================
/** Return the full list of columns given the relevant current state. */
export function getColumnList(backendType: backend.BackendType, extraColumns: Set<ExtraColumn>) {
switch (backendType) {
case backend.BackendType.local: {
return [Column.name, Column.modified]
}
case backend.BackendType.remote: {
return [
Column.name,
Column.modified,
Column.sharedWith,
...EXTRA_COLUMNS.filter(column => extraColumns.has(column)),
]
}
}
} // =================
// === Constants ===
// =================
/** The corresponding icon URL for each {@link sorting.SortDirection}. */
export const SORT_ICON: Record<sorting.SortDirection, string> = {
[sorting.SortDirection.ascending]: SortAscendingIcon,
[sorting.SortDirection.descending]: SortDescendingIcon,
}

View File

@ -0,0 +1,26 @@
/** @file A lookup containing a component for the corresponding heading for each column type. */
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import AccessedByProjectsColumnHeading from '#/components/dashboard/columnHeading/AccessedByProjectsColumnHeading'
import AccessedDataColumnHeading from '#/components/dashboard/columnHeading/AccessedDataColumnHeading'
import DocsColumnHeading from '#/components/dashboard/columnHeading/DocsColumnHeading'
import LabelsColumnHeading from '#/components/dashboard/columnHeading/LabelsColumnHeading'
import ModifiedColumnHeading from '#/components/dashboard/columnHeading/ModifiedColumnHeading'
import NameColumnHeading from '#/components/dashboard/columnHeading/NameColumnHeading'
import SharedWithColumnHeading from '#/components/dashboard/columnHeading/SharedWithColumnHeading'
import type * as tableColumn from '#/components/TableColumn'
export const COLUMN_HEADING: Record<
columnUtils.Column,
(props: tableColumn.TableColumnHeadingProps<assetsTable.AssetsTableState>) => JSX.Element
> = {
[columnUtils.Column.name]: NameColumnHeading,
[columnUtils.Column.modified]: ModifiedColumnHeading,
[columnUtils.Column.sharedWith]: SharedWithColumnHeading,
[columnUtils.Column.labels]: LabelsColumnHeading,
[columnUtils.Column.accessedByProjects]: AccessedByProjectsColumnHeading,
[columnUtils.Column.accessedData]: AccessedDataColumnHeading,
[columnUtils.Column.docs]: DocsColumnHeading,
}

View File

@ -0,0 +1,19 @@
/** @file A heading for the "Accessed by projects" column. */
import * as React from 'react'
import AccessedByProjectsIcon from 'enso-assets/accessed_by_projects.svg'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
/** A heading for the "Accessed by projects" column. */
export default function AccessedByProjectsColumnHeading(): JSX.Element {
return (
<div className="flex items-center gap-2">
<SvgMask src={AccessedByProjectsIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.accessedByProjects]}
</span>
</div>
)
}

View File

@ -0,0 +1,19 @@
/** @file A heading for the "Accessed data" column. */
import * as React from 'react'
import AccessedDataIcon from 'enso-assets/accessed_data.svg'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
/** A heading for the "Accessed data" column. */
export default function AccessedDataColumnHeading(): JSX.Element {
return (
<div className="flex items-center gap-2">
<SvgMask src={AccessedDataIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.accessedData]}
</span>
</div>
)
}

View File

@ -0,0 +1,19 @@
/** @file A heading for the "Docs" column. */
import * as React from 'react'
import DocsIcon from 'enso-assets/docs.svg'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
/** A heading for the "Docs" column. */
export default function DocsColumnHeading(): JSX.Element {
return (
<div className="flex items-center gap-2">
<SvgMask src={DocsIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.docs]}
</span>
</div>
)
}

View File

@ -0,0 +1,19 @@
/** @file A heading for the "Labels" column. */
import * as React from 'react'
import TagIcon from 'enso-assets/tag.svg'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
/** A heading for the "Labels" column. */
export default function LabelsColumnHeading(): JSX.Element {
return (
<div className="flex items-center gap-2">
<SvgMask src={TagIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.labels]}
</span>
</div>
)
}

View File

@ -0,0 +1,51 @@
/** @file A heading for the "Modified" column. */
import * as React from 'react'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import TimeIcon from 'enso-assets/time.svg'
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
import * as sorting from '#/utilities/sorting'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
import type * as tableColumn from '#/components/TableColumn'
/** A heading for the "Modified" column. */
export default function ModifiedColumnHeading(
props: tableColumn.TableColumnHeadingProps<assetsTable.AssetsTableState>
): JSX.Element {
const { state } = props
const { sortColumn, setSortColumn, sortDirection, setSortDirection } = state
const [isHovered, setIsHovered] = React.useState(false)
const isSortActive = sortColumn === columnUtils.Column.modified && sortDirection != null
return (
<div
className="flex items-center cursor-pointer gap-2"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
onClick={event => {
event.stopPropagation()
if (sortColumn === columnUtils.Column.modified) {
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
} else {
setSortColumn(columnUtils.Column.modified)
setSortDirection(sorting.SortDirection.ascending)
}
}}
>
<SvgMask src={TimeIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.modified]}
</span>
<img
src={isSortActive ? columnUtils.SORT_ICON[sortDirection] : SortAscendingIcon}
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
/>
</div>
)
}

View File

@ -0,0 +1,48 @@
/** @file A heading for the "Name" column. */
import * as React from 'react'
import SortAscendingIcon from 'enso-assets/sort_ascending.svg'
import type * as assetsTable from '#/layouts/dashboard/AssetsTable'
import * as sorting from '#/utilities/sorting'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import type * as tableColumn from '#/components/TableColumn'
/** A heading for the "Name" column. */
export default function NameColumnHeading(
props: tableColumn.TableColumnHeadingProps<assetsTable.AssetsTableState>
): JSX.Element {
const { state } = props
const { sortColumn, setSortColumn, sortDirection, setSortDirection } = state
const [isHovered, setIsHovered] = React.useState(false)
const isSortActive = sortColumn === columnUtils.Column.name && sortDirection != null
return (
<div
className="flex items-center cursor-pointer gap-2 pt-1 pb-1.5"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
onClick={event => {
event.stopPropagation()
if (sortColumn === columnUtils.Column.name) {
setSortDirection(sorting.NEXT_SORT_DIRECTION[sortDirection ?? 'null'])
} else {
setSortColumn(columnUtils.Column.name)
setSortDirection(sorting.SortDirection.ascending)
}
}}
>
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.name]}
</span>
<img
src={isSortActive ? columnUtils.SORT_ICON[sortDirection] : SortAscendingIcon}
className={isSortActive ? '' : isHovered ? 'opacity-50' : 'opacity-0'}
/>
</div>
)
}

View File

@ -0,0 +1,19 @@
/** @file A heading for the "Shared with" column. */
import * as React from 'react'
import PeopleIcon from 'enso-assets/people.svg'
import * as columnUtils from '#/components/dashboard/column/columnUtils'
import SvgMask from '#/components/SvgMask'
/** A heading for the "Shared with" column. */
export default function SharedWithColumnHeading(): JSX.Element {
return (
<div className="flex items-center gap-2">
<SvgMask src={PeopleIcon} className="h-4 w-4" />
<span className="leading-144.5 h-6 py-0.5">
{columnUtils.COLUMN_NAME[columnUtils.Column.sharedWith]}
</span>
</div>
)
}

View File

@ -6,12 +6,12 @@ import CtrlKeyIcon from 'enso-assets/ctrl_key.svg'
import OptionKeyIcon from 'enso-assets/option_key.svg'
import ShiftKeyIcon from 'enso-assets/shift_key.svg'
import WindowsKeyIcon from 'enso-assets/windows_key.svg'
import * as detect from 'enso-common/src/detect'
import * as shortcutsModule from '../shortcuts'
import * as shortcutsProvider from '../../providers/shortcuts'
import SvgMask from '../../authentication/components/svgMask'
import * as shortcutsProvider from '#/providers/ShortcutsProvider'
import * as shortcutsModule from '#/utilities/shortcuts'
import SvgMask from '#/components/SvgMask'
// ========================
// === KeyboardShortcut ===

View File

@ -1,14 +1,13 @@
/** @file Entry point into the cloud dashboard. */
import * as authentication from 'enso-authentication'
import * as detect from 'enso-common/src/detect'
import './tailwind.css'
import '#/tailwind.css'
import * as authentication from '#/index'
// =================
// === Constants ===
// =================
/** Path to the SSE endpoint over which esbuild sends events. */
const ESBUILD_PATH = './esbuild'
/** SSE event indicating a build has finished. */

View File

@ -0,0 +1,34 @@
/** @file Possible types of asset state change. */
// ======================
// === AssetEventType ===
// ======================
/** Possible types of asset state change. */
enum AssetEventType {
newProject = 'new-project',
newFolder = 'new-folder',
uploadFiles = 'upload-files',
newDataConnector = 'new-data-connector',
openProject = 'open-project',
closeProject = 'close-project',
cancelOpeningAllProjects = 'cancel-opening-all-projects',
copy = 'copy',
cut = 'cut',
cancelCut = 'cancel-cut',
move = 'move',
delete = 'delete',
restore = 'restore',
download = 'download',
downloadSelected = 'download-selected',
removeSelf = 'remove-self',
temporarilyAddLabels = 'temporarily-add-labels',
temporarilyRemoveLabels = 'temporarily-remove-labels',
addLabels = 'add-labels',
removeLabels = 'remove-labels',
deleteLabel = 'delete-label',
}
// This is REQUIRED, as `export default enum` is invalid syntax.
// eslint-disable-next-line no-restricted-syntax
export default AssetEventType

View File

@ -0,0 +1,19 @@
/** @file Possible types of changes to the file list. */
/** Possible types of changes to the file list. */
enum AssetListEventType {
newFolder = 'new-folder',
newProject = 'new-project',
uploadFiles = 'upload-files',
newDataConnector = 'new-data-connector',
closeFolder = 'close-folder',
copy = 'copy',
move = 'move',
willDelete = 'will-delete',
delete = 'delete',
removeSelf = 'remove-self',
}
// This is REQUIRED, as `export default enum` is invalid syntax.
// eslint-disable-next-line no-restricted-syntax
export default AssetListEventType

View File

@ -1,11 +1,12 @@
/** @file Events related to changes in asset state. */
import type * as backendModule from '../backend'
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'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
declare module '#/hooks/eventHooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
assetEvent: AssetEvent
@ -16,31 +17,6 @@ declare module '../../hooks' {
// === AssetEvent ===
// ==================
/** Possible types of asset state change. */
export enum AssetEventType {
newProject = 'new-project',
newFolder = 'new-folder',
uploadFiles = 'upload-files',
newDataConnector = 'new-data-connector',
openProject = 'open-project',
closeProject = 'close-project',
cancelOpeningAllProjects = 'cancel-opening-all-projects',
copy = 'copy',
cut = 'cut',
cancelCut = 'cancel-cut',
move = 'move',
delete = 'delete',
restore = 'restore',
download = 'download',
downloadSelected = 'download-selected',
removeSelf = 'remove-self',
temporarilyAddLabels = 'temporarily-add-labels',
temporarilyRemoveLabels = 'temporarily-remove-labels',
addLabels = 'add-labels',
removeLabels = 'remove-labels',
deleteLabel = 'delete-label',
}
/** Properties common to all asset state change events. */
interface AssetBaseEvent<Type extends AssetEventType> {
type: Type

View File

@ -1,30 +1,21 @@
/** @file Events related to changes in the asset list. */
import type * as backend from '../backend'
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'
// This is required, to whitelist this event.
// eslint-disable-next-line no-restricted-syntax
declare module '../../hooks' {
declare module '#/hooks/eventHooks' {
/** A map containing all known event types. */
export interface KnownEventsMap {
assetListEvent: AssetListEvent
}
}
/** Possible changes to the file list. */
export enum AssetListEventType {
newFolder = 'new-folder',
newProject = 'new-project',
uploadFiles = 'upload-files',
newDataConnector = 'new-data-connector',
closeFolder = 'close-folder',
copy = 'copy',
move = 'move',
willDelete = 'will-delete',
delete = 'delete',
removeSelf = 'remove-self',
}
// ======================
// === AssetListEvent ===
// ======================
/** Properties common to all asset list events. */
interface AssetListBaseEvent<Type extends AssetListEventType> {

Some files were not shown because too many files have changed in this diff Show More