From d672d5021ba5a612217204f37796d412a87982f8 Mon Sep 17 00:00:00 2001 From: somebody1234 Date: Wed, 26 Apr 2023 19:52:13 +1000 Subject: [PATCH] Dashboard directory interactivity (#6279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * turn object into var * add todo * fix style * fixes * remove forgot password + reset password * remove signout * remove setusername * remove login * remove registration * fix comments * re-enable flag * rename div * add comment * fix lints * remove tailwind conf * Revert "remove registration" This reverts commit 02439c9b709f5f7bdab72171d2706bec45cdfe6e. * Revert "remove login" This reverts commit 8e6f9c11122142c41faddef8a857cd3546d81fab. * Revert "remove setusername" This reverts commit 84721bcccd12b83c05f79505a5e7b444505e7917. * Revert "remove signout" This reverts commit 08a96d3796108f32d3f26317b01248bdfe59c5fa. * Revert "remove forgot password + reset password" This reverts commit e52f51a76253894041c32f9bff4e33690b354176. * remove opener * move opener * tmp * prettier * expand docs * tmp * replace react-scripts with craco * add tailwindcss * switch to brands * tmp * tmp * tmp * fixmes * fixmes * fixmes * fixmes * fixmes * fixes for e-hern's comments * use abortcontroller * add docs * fixes * revert craco, fix windows build * remove from gitignore * remove unnecessary check * tmp * augment window * tmptmp * split errors back up * tmp * tmp * prettier * fix * Fix lints * Prepare for addition for `as T` lint * Add lint for early returns * Address review issues * Fix lints * remove withrouter * fix file length * fixes * fixes * remove dashboard * fix * use switch * prettier * fixes * prettier * fixes * run prettier * run prettier * run prettier * fix main page url * allow node.js debugging * fix lints * change not equal * prettier * Remove references to withRouter; fix lints * Run prettier * Add cloud endpoints * Add JSON-RPC endpoints * Add dashboard skeleton * Add components and edit dashboard * Run prettier * (WIP) Add cloud endpoints * Add rpc endpoints * Address review issues * Formatting and minor fixes for `newtype.ts` * Address review issues * Rename `Brand` to `NewtypeVariant` * Rename `Brand` to `NewtypeVariant` * Fix formatting in `newtype.ts` * Switch dashboard to esbuild * Minor fixes; move Tailwind generation into esbuild-config * Fix watching `content/` and `client/` * Bump esbuild binary versions; minor dependency list fixes * Add dashboard skeleton * Run prettier * Fixes; rename "npm run dev" to "npm run watch-dashboard" * Avoid writing esbuild outputs to disk for `dashboard/` * Convert watch-dashboard to be fully in-memory; rebuild css files on change * Remove obsolete FIXME * Remove unused constants * Run prettier * add missing styles * Fixes * Fix the fixes * Run prettier * Fixes; use nesting plugin to wrap tailwind preflight * Remove testing flag from client/watch * Minor fixes * Run prettier * Export newtypes * Make css rebuild when tailwind config changes * Fix endpoints * Finish copying changes over * Remove duplicate type definitions * Fix bundling for dashboard * Fix dashboard/bundle.ts erroring when build directory does not exist * Move CSS to Tailwind config * Run prettier * Update endpoints * Fix esbuild binary package names * Remove redundant "npx" prefix from build scripts * Remove unused dependency * Begin adding interactivity * workaround for mac freeze * Fix modal bugs * Begin implementing forms, split forms and modals into new files * Get form UI working * add missing sections * Minor fixes, save current directory to localStorage * Fixes for drop-to-upload Note: currently it is opening in a new tab instead of actually uploading * Address review issue * Fix prettier config; run prettier * Fix live-reload of `npm run watch-dashboard` * (WIP) * Fix service worker for client-side routing * Add close button to asset creation forms; fix saving directory to localStorage * Remove workaround for backend bug when listing directories * Fix drop-to-upload * Fix sizing * Fix spacing, add fixed paths * WIP: fix toast notification styles, begin adding context menu * WIP: Add context menu, minor fixes * Fix authentication on desktop IDE * Allow unused locals and parameters in tsconfig.json * Run prettier * Fix TypeScript errors * Fix modals; minor refactor * Implement context menus for labels; fixes * Add modal provider and switch all modals to provider; fixes * Fix modals and user icon size * Fixes * Remove obsolete files from incorrect merge * Address review issues * Fix type error * Stop removing `#root` * Fixes for cloud * Implement search on frontend side * Fix race condition related to `directoryId` * Hide directories, files and secrets tables on desktop IDE * Fix lint errors * Properly update visible projects when a project is created * Pass directory id to create project * Hide column display switcher; remove placeholder column data --------- Co-authored-by: Nikita Pekin Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Paweł Buchowski --- app/ide-desktop/eslint.config.js | 1 + .../lib/client/src/authentication.ts | 3 - .../components/forgotPassword.tsx | 1 + .../src/authentication/components/login.tsx | 8 +- .../src/authentication/providers/session.tsx | 29 +- .../src/authentication/src/components/svg.tsx | 26 +- .../components/confirmDeleteModal.tsx | 62 ++ .../src/dashboard/components/contextMenu.tsx | 28 + .../dashboard/components/contextMenuEntry.tsx | 29 + .../src/dashboard/components/createForm.tsx | 58 ++ .../src/dashboard/components/dashboard.tsx | 915 +++++++++++++----- .../components/directoryCreateForm.tsx | 63 ++ .../dashboard/components/fileCreateForm.tsx | 90 ++ .../src/dashboard/components/ide.tsx | 18 +- .../src/dashboard/components/label.tsx | 5 +- .../components/permissionDisplay.tsx | 2 +- .../components/projectCreateForm.tsx | 82 ++ .../src/dashboard/components/renameModal.tsx | 79 ++ .../src/dashboard/components/rows.tsx | 25 +- .../dashboard/components/secretCreateForm.tsx | 83 ++ .../src/dashboard/components/templates.tsx | 6 +- .../src/dashboard/components/topBar.tsx | 12 +- .../dashboard/components/uploadFileModal.tsx | 117 +++ .../authentication/src/dashboard/service.ts | 16 +- .../dashboard/src/authentication/src/error.ts | 22 + .../src/authentication/src/fileInfo.ts | 29 + .../src/authentication/src/hooks.tsx | 11 + .../authentication/src/uploadMultipleFiles.ts | 57 ++ .../lib/dashboard/src/tailwind.css | 3 +- .../lib/dashboard/tailwind.config.ts | 3 + app/ide-desktop/package-lock.json | 105 +- app/ide-desktop/package.json | 2 +- 32 files changed, 1637 insertions(+), 353 deletions(-) create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/uploadFileModal.tsx create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts create mode 100644 app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index c4c4d2c70a9f5..f3453bc427a3a 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -269,6 +269,7 @@ export default [ }, ], '@typescript-eslint/no-confusing-void-expression': 'error', + '@typescript-eslint/no-empty-interface': 'off', '@typescript-eslint/no-extraneous-class': 'error', '@typescript-eslint/no-invalid-void-type': ['error', { allowAsThisParameter: true }], // React 17 and later supports async functions as event handlers, so we need to disable this diff --git a/app/ide-desktop/lib/client/src/authentication.ts b/app/ide-desktop/lib/client/src/authentication.ts index 59b49c2d21ea9..2e83ff6dd8daa 100644 --- a/app/ide-desktop/lib/client/src/authentication.ts +++ b/app/ide-desktop/lib/client/src/authentication.ts @@ -75,9 +75,6 @@ import * as fs from 'node:fs' import * as os from 'node:os' import * as path from 'node:path' -import opener from 'opener' - -import * as electron from 'electron' import * as electron from 'electron' import opener from 'opener' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx index 424076f51eccf..077e7301310e4 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/forgotPassword.tsx @@ -6,6 +6,7 @@ import * as router from 'react-router-dom' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' + import Input from './input' import SvgIcon from './svgIcon' diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx index cf69cca78f532..734b6e06af486 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/components/login.tsx @@ -7,6 +7,7 @@ import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' import * as app from '../../components/app' import * as auth from '../providers/auth' import * as svg from '../../components/svg' + import FontAwesomeIcon from './fontAwesomeIcon' import Input from './input' import SvgIcon from './svgIcon' @@ -15,9 +16,6 @@ import SvgIcon from './svgIcon' // === Constants === // ================= -const BUTTON_CLASS_NAME = - 'relative mt-6 border rounded-md py-2 text-sm text-gray-800 bg-gray-100 hover:bg-gray-200' - const LOGIN_QUERY_PARAMS = { email: 'email', } as const @@ -51,7 +49,7 @@ function Login() { event.preventDefault() await signInWithGoogle() }} - className={BUTTON_CLASS_NAME} + className="relative mt-6 border rounded-md py-2 text-sm text-gray-800 bg-gray-100 hover:bg-gray-200" > Login with Google @@ -61,7 +59,7 @@ function Login() { event.preventDefault() await signInWithGitHub() }} - className={BUTTON_CLASS_NAME} + className="relative mt-6 border rounded-md py-2 text-sm text-gray-800 bg-gray-100 hover:bg-gray-200" > Login with Github diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx index be1fbd181bb25..516f38dbe232c 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/authentication/providers/session.tsx @@ -48,31 +48,12 @@ interface SessionProviderProps { export function SessionProvider(props: SessionProviderProps) { const { mainPageUrl, children, userSession, registerAuthEventListener } = props + const [refresh, doRefresh] = hooks.useRefresh() + /** Flag used to avoid rendering child components until we've fetched the user's session at least * once. Avoids flash of the login screen when the user is already logged in. */ const [initialized, setInitialized] = react.useState(false) - /** Produces a new object every time. - * This is not equal to any other empty object because objects are compared by reference. - * Because it is not equal to the old value, React re-renders the component. */ - function newRefresh() { - return {} - } - - /** State that, when set, forces a refresh of the user session. This is useful when a - * user has just logged in (so their cached credentials are out of date). Should be used via the - * `refreshSession` function. */ - const [refresh, setRefresh] = react.useState(newRefresh()) - - /** Forces a refresh of the user session. - * - * Should be called after any operation that **will** (not **might**) change the user's session. - * For example, this should be called after signing out. Calling this will result in a re-render - * of the whole page, which is why it should only be done when necessary. */ - const refreshSession = () => { - setRefresh(newRefresh()) - } - /** Register an async effect that will fetch the user's session whenever the `refresh` state is * incremented. This is useful when a user has just logged in (as their cached credentials are * out of date, so this will update them). */ @@ -83,7 +64,7 @@ export function SessionProvider(props: SessionProviderProps) { setInitialized(true) return innerSession }, - [refresh, userSession] + [userSession, refresh] ) /** Register an effect that will listen for authentication events. When the event occurs, we @@ -97,7 +78,7 @@ export function SessionProvider(props: SessionProviderProps) { switch (event) { case listen.AuthEvent.signIn: case listen.AuthEvent.signOut: { - refreshSession() + doRefresh() break } case listen.AuthEvent.customOAuthState: @@ -110,7 +91,7 @@ export function SessionProvider(props: SessionProviderProps) { * See: * https://github.com/aws-amplify/amplify-js/issues/3391#issuecomment-756473970 */ window.history.replaceState({}, '', mainPageUrl) - refreshSession() + doRefresh() break } default: { diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx index 7a62b529d5b21..c1ecda9868dba 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/components/svg.tsx @@ -101,7 +101,7 @@ export const SECRET_ICON = ( ) @@ -164,6 +164,17 @@ export const ARROW_UP_ICON = ( ) +/** `+`-shaped icon representing creation of an item. */ +export const ADD_ICON = ( + + + + + + + +) + /** An icon representing creation of an item. */ export const CIRCLED_PLUS_ICON = ( - + ) @@ -213,6 +224,17 @@ export const SPEECH_BUBBLE_ICON = ( ) +/** `x`-shaped icon representing the closing of a window. */ +export const CLOSE_ICON = ( + + + + + + + +) + // =========== // === Svg === // =========== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx new file mode 100644 index 0000000000000..02a0140f444a4 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/confirmDeleteModal.tsx @@ -0,0 +1,62 @@ +/** @file Modal for confirming delete of any type of asset. */ +import toast from 'react-hot-toast' + +import * as modalProvider from '../../providers/modal' +import * as svg from '../../components/svg' + +import Modal from './modal' + +// ================= +// === Component === +// ================= + +export interface ConfirmDeleteModalProps { + assetType: string + name: string + doDelete: () => Promise + onSuccess: () => void +} + +function ConfirmDeleteModal(props: ConfirmDeleteModalProps) { + const { assetType, name, doDelete, onSuccess } = props + const { unsetModal } = modalProvider.useSetModal() + return ( + +
{ + event.stopPropagation() + }} + > + + Are you sure you want to delete the {assetType} '{name}'? +
+
{ + unsetModal() + await toast.promise(doDelete(), { + loading: `Deleting ${assetType}...`, + success: `Deleted ${assetType}.`, + error: `Could not delete ${assetType}.`, + }) + onSuccess() + }} + > + Delete +
+
+ Cancel +
+
+
+
+ ) +} + +export default ConfirmDeleteModal diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx new file mode 100644 index 0000000000000..eb5855bfcf93d --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenu.tsx @@ -0,0 +1,28 @@ +/** @file A context menu. */ + +import * as react from 'react' + +// ================= +// === Component === +// ================= + +export interface ContextMenuProps { + // `left: number` and `top: number` may be more correct, + // however passing an event eliminates the chance + // of passing the wrong coordinates from the event. + event: react.MouseEvent +} + +function ContextMenu(props: react.PropsWithChildren) { + const { children, event } = props + return ( +
+ {children} +
+ ) +} + +export default ContextMenu diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx new file mode 100644 index 0000000000000..26fdc3695c00e --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/contextMenuEntry.tsx @@ -0,0 +1,29 @@ +/** @file An entry in a context menu. */ + +import * as react from 'react' + +export interface ContextMenuEntryProps { + disabled?: boolean + onClick: (event: react.MouseEvent) => void +} + +// This component MUST NOT use `useState` because it is not rendered directly. +function ContextMenuEntry(props: react.PropsWithChildren) { + const { children, disabled, onClick } = props + return ( + + ) +} + +export default ContextMenuEntry diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx new file mode 100644 index 0000000000000..b99273ee142a5 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/createForm.tsx @@ -0,0 +1,58 @@ +/** @file Base form to create an asset. + * This should never be used directly, but instead should be wrapped in a component + * that creates a specific asset type. */ + +import * as react from 'react' + +import * as modalProvider from '../../providers/modal' +import * as svg from '../../components/svg' + +import Modal from './modal' + +/** The props that should also be in the wrapper component. */ +export interface CreateFormPassthroughProps { + left: number + top: number +} + +/** `CreateFormPassthroughProps`, plus props that should be defined in the wrapper component. */ +export interface CreateFormProps extends CreateFormPassthroughProps, react.PropsWithChildren { + title: string + onSubmit: (event: react.FormEvent) => Promise +} + +function CreateForm(props: CreateFormProps) { + const { title, left, top, children, onSubmit: wrapperOnSubmit } = props + const { unsetModal } = modalProvider.useSetModal() + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + await wrapperOnSubmit(event) + } + + return ( + +
{ + event.stopPropagation() + }} + > + +

{title}

+ {children} + +
+
+ ) +} + +export default CreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx index 80ef19287bbe1..9619f1b9b31ae 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/dashboard.tsx @@ -1,26 +1,38 @@ /** @file Main dashboard component, responsible for listing user's projects as well as other * interactive components. */ import * as react from 'react' -import * as reactDom from 'react-dom' import * as projectManagerModule from 'enso-content/src/project_manager' import * as auth from '../../authentication/providers/auth' import * as backend from '../service' +import * as fileInfo from '../../fileInfo' +import * as hooks from '../../hooks' import * as loggerProvider from '../../providers/logger' import * as modalProvider from '../../providers/modal' import * as newtype from '../../newtype' import * as platformModule from '../../platform' import * as svg from '../../components/svg' +import * as uploadMultipleFiles from '../../uploadMultipleFiles' -import Label, * as label from './label' import PermissionDisplay, * as permissionDisplay from './permissionDisplay' +import ContextMenu from './contextMenu' +import ContextMenuEntry from './contextMenuEntry' import Ide from './ide' import ProjectActionButton from './projectActionButton' import Rows from './rows' import Templates from './templates' import TopBar from './topBar' +import ConfirmDeleteModal from './confirmDeleteModal' +import RenameModal from './renameModal' +import UploadFileModal from './uploadFileModal' + +import DirectoryCreateForm from './directoryCreateForm' +import FileCreateForm from './fileCreateForm' +import ProjectCreateForm from './projectCreateForm' +import SecretCreateForm from './secretCreateForm' + // ============= // === Types === // ============= @@ -32,9 +44,15 @@ export enum Tab { } 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', } @@ -51,10 +69,29 @@ enum Column { ide = 'ide', } +/** Values provided to form creation dialogs. */ +export interface CreateFormProps { + left: number + top: number + backend: backend.Backend + directoryId: backend.DirectoryId + onSuccess: () => void +} + // ================= // === Constants === // ================= +/** Enables features which are not ready for release, + * and so are intentionally disabled for release builds. */ +// This type annotation is explicit to undo TypeScript narrowing to `false`, +// which result in errors about unused code. +// eslint-disable-next-line @typescript-eslint/no-inferrable-types +const EXPERIMENTAL: boolean = true + +/** The `localStorage` key under which the ID of the current directory is stored. */ +const DIRECTORY_STACK_KEY = 'enso-dashboard-directory-stack' + /** English names for the name column. */ const ASSET_TYPE_NAME: Record = { [backend.AssetType.project]: 'Projects', @@ -63,6 +100,14 @@ const ASSET_TYPE_NAME: Record = { [backend.AssetType.directory]: 'Folders', } as const +/** Forms to create each asset type. */ +const ASSET_TYPE_CREATE_FORM: Record JSX.Element> = { + [backend.AssetType.project]: ProjectCreateForm, + [backend.AssetType.file]: FileCreateForm, + [backend.AssetType.secret]: SecretCreateForm, + [backend.AssetType.directory]: DirectoryCreateForm, +} + /** English names for every column except for the name column. */ const COLUMN_NAME: Record, string> = { [Column.lastModified]: 'Last modified', @@ -75,8 +120,35 @@ const COLUMN_NAME: Record, string> = { [Column.ide]: 'IDE', } as const +/** The corresponding `Permissions` for each backend `PermissionAction`. */ +const PERMISSION: Record = { + [backend.PermissionAction.own]: { type: permissionDisplay.Permission.owner }, + [backend.PermissionAction.execute]: { + type: permissionDisplay.Permission.regular, + read: false, + write: false, + docsWrite: false, + exec: true, + }, + [backend.PermissionAction.edit]: { + type: permissionDisplay.Permission.regular, + read: false, + write: true, + docsWrite: false, + exec: false, + }, + [backend.PermissionAction.read]: { + type: permissionDisplay.Permission.regular, + read: true, + write: false, + docsWrite: false, + exec: false, + }, +} + /** The list of columns displayed on each `ColumnDisplayMode`. */ const COLUMNS_FOR: Record = { + [ColumnDisplayMode.release]: [Column.name, Column.lastModified, Column.sharedWith], [ColumnDisplayMode.all]: [ Column.name, Column.lastModified, @@ -104,64 +176,10 @@ const COLUMNS_FOR: Record = { ], } -/** React components for every column except for the name column. */ -const COLUMN_RENDERER: Record< - Exclude, - (project: backend.Asset) => JSX.Element -> = { - [Column.lastModified]: () => <>aa, - [Column.sharedWith]: () => <>aa, - [Column.docs]: () => <>aa, - [Column.labels]: () => ( - <> - - - - - ), - [Column.dataAccess]: () => ( - <> - - ./user_data - - - this folder - - - no access - - - ), - [Column.usagePlan]: () => <>aa, - [Column.engine]: () => <>aa, - [Column.ide]: () => <>aa, -} - // ======================== // === Helper functions === // ======================== -/** English names for every column. */ -function columnName(column: Column, assetType: backend.AssetType) { - return column === Column.name ? ASSET_TYPE_NAME[assetType] : COLUMN_NAME[column] -} - /** Returns the id of the root directory for a user or organization. */ function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { return newtype.asNewtype( @@ -169,16 +187,6 @@ function rootDirectoryId(userOrOrganizationId: backend.UserOrOrganizationId) { ) } -/** Returns the file extension of a file name. */ -function fileExtension(fileName: string) { - return fileName.match(/\.(.+?)$/)?.[1] ?? '' -} - -/** Returns the appropriate icon for a specific file extension. */ -function fileIcon(_extension: string) { - return svg.FILE_ICON -} - // ================= // === Dashboard === // ================= @@ -199,46 +207,135 @@ interface OtherDashboardProps extends BaseDashboardProps { export type DashboardProps = DesktopDashboardProps | OtherDashboardProps +// TODO[sb]: Implement rename when clicking name of a selected row. +// There is currently no way to tell whether a row is selected from a column. + function Dashboard(props: DashboardProps) { const { logger, platform } = props const { accessToken, organization } = auth.useFullUserSession() const backendService = backend.createBackend(accessToken, logger) - const { modal } = modalProvider.useModal() - const { unsetModal } = modalProvider.useSetModal() + const { setModal, unsetModal } = modalProvider.useSetModal() + + const [refresh, doRefresh] = hooks.useRefresh() - const [searchVal, setSearchVal] = react.useState('') + const [query, setQuery] = react.useState('') const [directoryId, setDirectoryId] = react.useState(rootDirectoryId(organization.id)) const [directoryStack, setDirectoryStack] = react.useState< backend.Asset[] >([]) - const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.compact) - const [selectedAssets, setSelectedAssets] = react.useState([]) + // Defined by the spec as `compact` by default, however it is not ready yet. + const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.release) - const [projectAssets, setProjectAssets] = react.useState< + const [projectAssets, setProjectAssetsRaw] = react.useState< + backend.Asset[] + >([]) + const [directoryAssets, setDirectoryAssetsRaw] = react.useState< + backend.Asset[] + >([]) + const [secretAssets, setSecretAssetsRaw] = react.useState< + backend.Asset[] + >([]) + const [fileAssets, setFileAssetsRaw] = react.useState[]>( + [] + ) + const [visibleProjectAssets, setVisibleProjectAssets] = react.useState< backend.Asset[] >([]) - const [directoryAssets, setDirectoryAssets] = react.useState< + const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState< backend.Asset[] >([]) - const [secretAssets, setSecretAssets] = react.useState< + const [visibleSecretAssets, setVisibleSecretAssets] = react.useState< backend.Asset[] >([]) - const [fileAssets, setFileAssets] = react.useState[]>([]) + const [visibleFileAssets, setVisibleFileAssets] = react.useState< + backend.Asset[] + >([]) const [tab, setTab] = react.useState(Tab.dashboard) const [project, setProject] = react.useState(null) + const [selectedAssets, setSelectedAssets] = react.useState([]) + const [isFileBeingDragged, setIsFileBeingDragged] = react.useState(false) + const directory = directoryStack[directoryStack.length - 1] const parentDirectory = directoryStack[directoryStack.length - 2] + function setProjectAssets(newProjectAssets: backend.Asset[]) { + setProjectAssetsRaw(newProjectAssets) + setVisibleProjectAssets(newProjectAssets.filter(asset => asset.title.includes(query))) + } + function setDirectoryAssets(newDirectoryAssets: backend.Asset[]) { + setDirectoryAssetsRaw(newDirectoryAssets) + setVisibleDirectoryAssets(newDirectoryAssets.filter(asset => asset.title.includes(query))) + } + function setSecretAssets(newSecretAssets: backend.Asset[]) { + setSecretAssetsRaw(newSecretAssets) + setVisibleSecretAssets(newSecretAssets.filter(asset => asset.title.includes(query))) + } + function setFileAssets(newFileAssets: backend.Asset[]) { + setFileAssetsRaw(newFileAssets) + setVisibleFileAssets(newFileAssets.filter(asset => asset.title.includes(query))) + } + + function exitDirectory() { + setDirectoryId(parentDirectory?.id ?? rootDirectoryId(organization.id)) + setDirectoryStack( + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + directoryStack.slice(0, -1) + ) + } + + function enterDirectory(directoryAsset: backend.Asset) { + setDirectoryId(directoryAsset.id) + setDirectoryStack([...directoryStack, directoryAsset]) + } + + react.useEffect(() => { + const cachedDirectoryStackJson = localStorage.getItem(DIRECTORY_STACK_KEY) + if (cachedDirectoryStackJson) { + // The JSON was inserted by the code below, so it will always have the right type. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const cachedDirectoryStack: backend.Asset[] = + JSON.parse(cachedDirectoryStackJson) + setDirectoryStack(cachedDirectoryStack) + const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id + if (cachedDirectoryId) { + setDirectoryId(cachedDirectoryId) + } + } + }, []) + + react.useEffect(() => { + if (directoryId === rootDirectoryId(organization.id)) { + localStorage.removeItem(DIRECTORY_STACK_KEY) + } else { + localStorage.setItem(DIRECTORY_STACK_KEY, JSON.stringify(directoryStack)) + } + }, [directoryStack]) + /** React components for the name column. */ const nameRenderers: { [Type in backend.AssetType]: (asset: backend.Asset) => JSX.Element } = { [backend.AssetType.project]: projectAsset => ( -
+
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} + > { @@ -252,43 +349,185 @@ function Dashboard(props: DashboardProps) { [backend.AssetType.directory]: directoryAsset => (
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} onDoubleClick={() => { - setDirectoryId(directoryAsset.id) - setDirectoryStack([...directoryStack, directoryAsset]) + enterDirectory(directoryAsset) }} > {svg.DIRECTORY_ICON} {directoryAsset.title}
), [backend.AssetType.secret]: secret => ( -
+
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} + > {svg.SECRET_ICON} {secret.title}
), [backend.AssetType.file]: file => ( -
- {fileIcon(fileExtension(file.title))} {file.title} +
{ + if (event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey) { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + }} + > + {fileInfo.fileIcon(fileInfo.fileExtension(file.title))}{' '} + {file.title}
), } - const renderer = (column: Column, assetType: Type) => { + /** React components for every column except for the name column. */ + const columnRenderer: Record< + Exclude, + (asset: backend.Asset) => JSX.Element + > = { + [Column.lastModified]: () => <>, + [Column.sharedWith]: asset => ( + <> + {(asset.permissions ?? []).map(user => ( + + + + ))} + + ), + [Column.docs]: () => <>, + [Column.labels]: () => { + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function onContextMenu(event: react.MouseEvent) { + event.preventDefault() + event.stopPropagation() + setModal(() => ( + + { + // TODO: Wait for backend implementation. + }} + > + Rename label + + + )) + } + return <> + }, + [Column.dataAccess]: () => <>, + [Column.usagePlan]: () => <>, + [Column.engine]: () => <>, + [Column.ide]: () => <>, + } + + function renderer(column: Column, assetType: Type) { return column === Column.name ? // This is type-safe only if we pass enum literals as `assetType`. + // eslint-disable-next-line no-restricted-syntax (nameRenderers[assetType] as (asset: backend.Asset) => JSX.Element) - : COLUMN_RENDERER[column] + : columnRenderer[column] + } + + /** Heading element for every column. */ + function ColumnHeading(column: Column, assetType: backend.AssetType) { + return column === Column.name ? ( +
+ {ASSET_TYPE_NAME[assetType]} + +
+ ) : ( + <>{COLUMN_NAME[column]} + ) } // The purpose of this effect is to enable search action. react.useEffect(() => { - return () => { - // TODO - } - }, [searchVal]) + setVisibleProjectAssets(projectAssets.filter(asset => asset.title.includes(query))) + setVisibleDirectoryAssets(directoryAssets.filter(asset => asset.title.includes(query))) + setVisibleSecretAssets(secretAssets.filter(asset => asset.title.includes(query))) + setVisibleFileAssets(fileAssets.filter(asset => asset.title.includes(query))) + }, [query]) - react.useEffect(() => { - void (async (): Promise => { + function setAssets(assets: backend.Asset[]) { + const newProjectAssets = assets.filter(backend.assetIsType(backend.AssetType.project)) + const newDirectoryAssets = assets.filter(backend.assetIsType(backend.AssetType.directory)) + const newSecretAssets = assets.filter(backend.assetIsType(backend.AssetType.secret)) + const newFileAssets = assets.filter(backend.assetIsType(backend.AssetType.file)) + setProjectAssets(newProjectAssets) + setDirectoryAssets(newDirectoryAssets) + setSecretAssets(newSecretAssets) + setFileAssets(newFileAssets) + } + + hooks.useAsyncEffect( + null, + async signal => { let assets: backend.Asset[] switch (platform) { @@ -308,22 +547,53 @@ function Dashboard(props: DashboardProps) { title: localProject.name, id: localProject.id, parentId: '', - permissions: [], + permissions: null, }) } break } } - reactDom.unstable_batchedUpdates(() => { - setProjectAssets(assets.filter(backend.assetIsType(backend.AssetType.project))) - setDirectoryAssets(assets.filter(backend.assetIsType(backend.AssetType.directory))) - setSecretAssets(assets.filter(backend.assetIsType(backend.AssetType.secret))) - setFileAssets(assets.filter(backend.assetIsType(backend.AssetType.file))) - }) - })() - }, [accessToken, directoryId]) - - const getNewProjectName = (templateName?: string | null): string => { + if (!signal.aborted) { + setAssets(assets) + } + }, + [accessToken, directoryId, refresh] + ) + + react.useEffect(() => { + function onBlur() { + setIsFileBeingDragged(false) + } + + window.addEventListener('blur', onBlur) + + return () => { + window.removeEventListener('blur', onBlur) + } + }, []) + + function handleEscapeKey(event: react.KeyboardEvent) { + if ( + event.key === 'Escape' && + !event.ctrlKey && + !event.shiftKey && + !event.altKey && + !event.metaKey + ) { + if (modal) { + event.preventDefault() + unsetModal() + } + } + } + + function openDropZone(event: react.DragEvent) { + if (event.dataTransfer.types.includes('Files')) { + setIsFileBeingDragged(true) + } + } + + function getNewProjectName(templateName?: string | null): string { const prefix = `${templateName ?? 'New_Project'}_` const projectNameTemplate = new RegExp(`^${prefix}(?\\d+)$`) let highestProjectIndex = 0 @@ -336,19 +606,22 @@ function Dashboard(props: DashboardProps) { return `${prefix}${highestProjectIndex + 1}` } - const handleCreateProject = async (templateName?: string | null) => { + async function handleCreateProject(templateName: string | null) { const projectName = getNewProjectName(templateName) switch (platform) { case platformModule.Platform.cloud: { const body: backend.CreateProjectRequestBody = { projectName, + projectTemplateName: + templateName?.replace(/_/g, '').toLocaleLowerCase() ?? null, + parentDirectoryId: directoryId, } if (templateName) { body.projectTemplateName = templateName.replace(/_/g, '').toLocaleLowerCase() } const projectAsset = await backendService.createProject(body) - setProjectAssets(oldProjectAssets => [ - ...oldProjectAssets, + setProjectAssets([ + ...projectAssets, { type: backend.AssetType.project, title: projectAsset.name, @@ -365,8 +638,8 @@ function Dashboard(props: DashboardProps) { ...(templateName ? { projectTemplate: templateName } : {}), }) const newProject = result.result - setProjectAssets(oldProjectAssets => [ - ...oldProjectAssets, + setProjectAssets([ + ...projectAssets, { type: backend.AssetType.project, title: projectName, @@ -381,8 +654,15 @@ function Dashboard(props: DashboardProps) { } return ( -
-
+
+

Drive

- {/* FIXME[sb]: Remove `|| true` when UI to create directory is implemented. */} - {/* eslint-disable-next-line no-constant-condition, @typescript-eslint/no-unnecessary-condition */} - {directory || true ? ( + {directory && ( <> - {svg.SMALL_RIGHT_ARROW_ICON} - ) : null} - {directory?.title ?? '~'} + )} + {directory?.title ?? '/'}
Shared with
@@ -435,11 +701,18 @@ function Dashboard(props: DashboardProps) {
-
+
-
- - - - -
+ {EXPERIMENTAL && ( + <> +
+ + + + +
+ + )}
- - + +
+ + > - items={projectAssets} + items={visibleProjectAssets} getKey={proj => proj.id} placeholder={ - <> + You have no project yet. Go ahead and create one using the form above. - + } columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ id: column, - name: columnName(column, backend.AssetType.project), + heading: ColumnHeading(column, backend.AssetType.project), render: renderer(column, backend.AssetType.project), }))} + onClick={projectAsset => { + setSelectedAssets([projectAsset]) + }} + onContextMenu={(projectAsset, event) => { + event.preventDefault() + event.stopPropagation() + function doOpenForEditing() { + // FIXME[sb]: Switch to IDE tab + // once merged with `show-and-open-workspace` branch. + } + function doOpenAsFolder() { + // FIXME[sb]: Uncomment once backend support + // is in place. + // The following code does not typecheck + // since `ProjectId`s are not `DirectoryId`s. + // enterDirectory(projectAsset) + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doRename() { + setModal(() => ( + Promise.resolve()} + onSuccess={doRefresh} + /> + )) + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doDelete() { + setModal(() => ( + + backendService.deleteProject(projectAsset.id) + } + onSuccess={doRefresh} + /> + )) + } + setModal(() => ( + + + Open for editing + + + Open as folder + + + Rename + + + Delete + + + )) + }} /> - - > - items={directoryAssets} - getKey={proj => proj.id} - placeholder={<>This directory does not contain any subdirectories.} - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - name: columnName(column, backend.AssetType.directory), - render: renderer(column, backend.AssetType.directory), - }))} - /> - - > - items={secretAssets} - getKey={proj => proj.id} - placeholder={<>This directory does not contain any secrets.} - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - name: columnName(column, backend.AssetType.secret), - render: renderer(column, backend.AssetType.secret), - }))} - /> - - > - items={fileAssets} - getKey={proj => proj.id} - placeholder={<>This directory does not contain any files.} - columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ - id: column, - name: columnName(column, backend.AssetType.file), - render: renderer(column, backend.AssetType.file), - }))} - /> -
-
-
- {project ? : <>} -
+ {platform === platformModule.Platform.cloud && ( + <> + + > + items={visibleDirectoryAssets} + getKey={dir => dir.id} + placeholder={ + + This directory does not contain any subdirectories + {query ? ' matching your query' : ''}. + + } + columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + id: column, + heading: ColumnHeading(column, backend.AssetType.directory), + render: renderer(column, backend.AssetType.directory), + }))} + onClick={directoryAsset => { + setSelectedAssets([directoryAsset]) + }} + onContextMenu={(_directory, event) => { + event.preventDefault() + event.stopPropagation() + setModal(() => ) + }} + /> + + > + items={visibleSecretAssets} + getKey={secret => secret.id} + placeholder={ + + This directory does not contain any secrets + {query ? ' matching your query' : ''}. + + } + columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + id: column, + heading: ColumnHeading(column, backend.AssetType.secret), + render: renderer(column, backend.AssetType.secret), + }))} + onClick={secret => { + setSelectedAssets([secret]) + }} + onContextMenu={(secret, event) => { + event.preventDefault() + event.stopPropagation() + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doDelete() { + setModal(() => ( + + backendService.deleteSecret(secret.id) + } + onSuccess={doRefresh} + /> + )) + } + setModal(() => ( + + + Delete + + + )) + }} + /> + + > + items={visibleFileAssets} + getKey={file => file.id} + placeholder={ + + This directory does not contain any files + {query ? ' matching your query' : ''}. + + } + columns={COLUMNS_FOR[columnDisplayMode].map(column => ({ + id: column, + heading: ColumnHeading(column, backend.AssetType.file), + render: renderer(column, backend.AssetType.file), + }))} + onClick={file => { + setSelectedAssets([file]) + }} + onContextMenu={(file, event) => { + event.preventDefault() + event.stopPropagation() + function doCopy() { + /** TODO: Call endpoint for copying file. */ + } + function doCut() { + /** TODO: Call endpoint for downloading file. */ + } + // This is not a React component even though it contains JSX. + // eslint-disable-next-line no-restricted-syntax + function doDelete() { + setModal(() => ( + backendService.deleteFile(file.id)} + onSuccess={doRefresh} + /> + )) + } + function doDownload() { + /** TODO: Call endpoint for downloading file. */ + } + setModal(() => ( + + + Copy + + + Cut + + + Delete + + + Download + + + )) + }} + /> + + )} + + + {isFileBeingDragged ? ( +
{ + setIsFileBeingDragged(false) + }} + onDragOver={event => { + event.preventDefault() + }} + onDrop={async event => { + event.preventDefault() + setIsFileBeingDragged(false) + await uploadMultipleFiles.uploadMultipleFiles( + backendService, + directoryId, + Array.from(event.dataTransfer.files) + ) + doRefresh() + }} + > + Drop to upload files. +
+ ) : null} + {/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */} + {project && } {modal && <>{modal}}
) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx new file mode 100644 index 0000000000000..8f13d6e4da19a --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/directoryCreateForm.tsx @@ -0,0 +1,63 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface DirectoryCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +function DirectoryCreateForm(props: DirectoryCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + const [name, setName] = react.useState(null) + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + if (name == null) { + toast.error('Please provide a directory name.') + } else { + unsetModal() + await toast + .promise( + backend.createDirectory({ + parentId: directoryId, + title: name, + }), + { + loading: 'Creating directory...', + success: 'Sucessfully created directory.', + error: error.unsafeIntoErrorMessage, + } + ) + .then(onSuccess) + } + } + + return ( + +
+ + { + setName(event.target.value) + }} + /> +
+
+ ) +} + +export default DirectoryCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx new file mode 100644 index 0000000000000..20ae40b26c9b8 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/fileCreateForm.tsx @@ -0,0 +1,90 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +function FileCreateForm(props: FileCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + const [name, setName] = react.useState(null) + const [file, setFile] = react.useState(null) + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + if (file == null) { + // TODO[sb]: Uploading a file may be a mistake when creating a new file. + toast.error('Please select a file to upload.') + } else { + unsetModal() + await toast + .promise( + backend.uploadFile( + { + parentDirectoryId: directoryId, + fileName: name ?? file.name, + }, + file + ), + { + loading: 'Uploading file...', + success: 'Sucessfully uploaded file.', + error: error.unsafeIntoErrorMessage, + } + ) + .then(onSuccess) + } + } + + return ( + +
+ + { + setName(event.target.value) + }} + defaultValue={name ?? file?.name ?? ''} + /> +
+
+
File
+
+ + { + setFile(event.target.files?.[0] ?? null) + }} + /> +
+
+
+ ) +} + +export default FileCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx index a149bf25b7bd0..d453f5b67fb7a 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/ide.tsx @@ -7,6 +7,8 @@ import * as service from '../service' // === Constants === // ================= +/** The `id` attribute of the element that the IDE will be rendered into. */ +const IDE_ELEMENT_ID = 'root' const IDE_CDN_URL = 'https://ensocdn.s3.us-west-1.amazonaws.com/ide' // ================= @@ -19,7 +21,9 @@ interface Props { } /** Container that launches the IDE. */ -function Ide({ project, backendService }: Props) { +function Ide(props: Props) { + const { project, backendService } = props + const [ideElement] = react.useState(() => document.querySelector(IDE_ELEMENT_ID)) const [[loaded, resolveLoaded]] = react.useState((): [Promise, () => void] => { let resolve!: () => void const promise = new Promise(innerResolve => { @@ -28,6 +32,13 @@ function Ide({ project, backendService }: Props) { return [promise, resolve] }) + react.useEffect(() => { + document.getElementById(IDE_ELEMENT_ID)?.classList.remove('hidden') + return () => { + document.getElementById(IDE_ELEMENT_ID)?.classList.add('hidden') + } + }, []) + react.useEffect(() => { void (async () => { const ideVersion = ( @@ -53,6 +64,9 @@ function Ide({ project, backendService }: Props) { react.useEffect(() => { void (async () => { + while (ideElement?.firstChild) { + ideElement.removeChild(ideElement.firstChild) + } const ideVersion = ( await backendService.listVersions({ versionType: service.VersionType.ide, @@ -86,7 +100,7 @@ function Ide({ project, backendService }: Props) { })() }, [project]) - return
+ return <> } export default Ide diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx index df1facc5da2af..d471866412fa6 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/label.tsx @@ -38,13 +38,16 @@ const STATUS_ICON: Record = { export interface LabelProps { status?: Status + onContextMenu?: react.MouseEventHandler } /** A label, which may be either user-defined, or a system warning message. */ -function Label({ status = Status.none, children }: react.PropsWithChildren) { +function Label(props: react.PropsWithChildren) { + const { status = Status.none, children, onContextMenu } = props return (
{STATUS_ICON[status]}
{children}
diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx index f1c978bbabb3d..a130b81a93a61 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/permissionDisplay.tsx @@ -92,7 +92,7 @@ function PermissionDisplay(props: react.PropsWithChildren {permissionBorder} -
{children}
+
{children}
) } diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx new file mode 100644 index 0000000000000..12b6a0ee486b7 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/projectCreateForm.tsx @@ -0,0 +1,82 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface ProjectCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +// FIXME[sb]: Extract shared shape to a common component. +function ProjectCreateForm(props: ProjectCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + + const [name, setName] = react.useState(null) + const [template, setTemplate] = react.useState(null) + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + if (name == null) { + toast.error('Please provide a project name.') + } else { + unsetModal() + await toast + .promise( + backend.createProject({ + parentDirectoryId: directoryId, + projectName: name, + projectTemplateName: template, + }), + { + loading: 'Creating project...', + success: 'Sucessfully created project.', + error: error.unsafeIntoErrorMessage, + } + ) + .then(onSuccess) + } + } + + return ( + +
+ + { + setName(event.target.value) + }} + /> +
+
+ {/* FIXME[sb]: Use the array of templates in a dropdown when it becomes available. */} + + { + setTemplate(event.target.value) + }} + /> +
+
+ ) +} + +export default ProjectCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx new file mode 100644 index 0000000000000..41fed6f6a6607 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/renameModal.tsx @@ -0,0 +1,79 @@ +/** @file Modal for confirming delete of any type of asset. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as modalProvider from '../../providers/modal' +import * as svg from '../../components/svg' + +import Modal from './modal' + +export interface RenameModalProps { + assetType: string + name: string + doRename: (newName: string) => Promise + onSuccess: () => void +} + +function RenameModal(props: RenameModalProps) { + const { assetType, name, doRename, onSuccess } = props + const { unsetModal } = modalProvider.useSetModal() + const [newName, setNewName] = react.useState(null) + return ( + +
{ + event.stopPropagation() + }} + > + + What do you want to rename the {assetType} '{name}' to? +
+ + { + setNewName(event.target.value) + }} + defaultValue={newName ?? ''} + /> +
+
+
{ + if (newName == null) { + toast.error('Please provide a new name.') + } else { + unsetModal() + await toast.promise(doRename(newName), { + loading: `Deleting ${assetType}...`, + success: `Deleted ${assetType}.`, + error: `Could not delete ${assetType}.`, + }) + onSuccess() + } + }} + > + Rename +
+
+ Cancel +
+
+
+
+ ) +} + +export default RenameModal diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx index d6e06a472d056..867bf6764a903 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/rows.tsx @@ -1,4 +1,5 @@ /** @file Table that projects an object into each column. */ +import * as react from 'react' // ============= // === Types === @@ -7,7 +8,7 @@ /** Metadata describing how to render a column of the table. */ export interface Column { id: string - name: string + heading: JSX.Element render: (item: T, index: number) => JSX.Element } @@ -16,32 +17,42 @@ export interface Column { // ================= interface Props { - columns: Column[] items: T[] getKey: (item: T) => string placeholder: JSX.Element + columns: Column[] + onClick: (item: T, event: react.MouseEvent) => void + onContextMenu: (item: T, event: react.MouseEvent) => void } /** Table that projects an object into each column. */ -function Rows({ columns, items, getKey, placeholder }: Props) { - const headerRow = columns.map(({ name }, index) => ( +function Rows(props: Props) { + const { columns, items, getKey, placeholder, onClick, onContextMenu } = props + const headerRow = columns.map(({ heading }, index) => ( - {name} + {heading} )) const itemRows = items.length === 0 ? ( - + {placeholder} ) : ( items.map((item, index) => ( { + onClick(item, event) + }} + onContextMenu={event => { + onContextMenu(item, event) + }} + className="h-10 transition duration-300 ease-in-out hover:bg-gray-100 focus:bg-gray-200" > {columns.map(({ id, render }) => ( diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx new file mode 100644 index 0000000000000..d6994a085a010 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/secretCreateForm.tsx @@ -0,0 +1,83 @@ +/** @file Form to create a project. */ +import * as react from 'react' +import toast from 'react-hot-toast' + +import * as backendModule from '../service' +import * as error from '../../error' +import * as modalProvider from '../../providers/modal' +import CreateForm, * as createForm from './createForm' + +export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps { + backend: backendModule.Backend + directoryId: backendModule.DirectoryId + onSuccess: () => void +} + +function SecretCreateForm(props: SecretCreateFormProps) { + const { backend, directoryId, onSuccess, ...passThrough } = props + const { unsetModal } = modalProvider.useSetModal() + + const [name, setName] = react.useState(null) + const [value, setValue] = react.useState(null) + + async function onSubmit(event: react.FormEvent) { + event.preventDefault() + if (!name) { + toast.error('Please provide a secret name.') + } else if (value == null) { + // Secret value explicitly can be empty. + toast.error('Please provide a secret value.') + } else { + unsetModal() + await toast + .promise( + backend.createSecret({ + parentDirectoryId: directoryId, + secretName: name, + secretValue: value, + }), + { + loading: 'Creating secret...', + success: 'Sucessfully created secret.', + error: error.unsafeIntoErrorMessage, + } + ) + .then(onSuccess) + } + } + + return ( + +
+ + { + setName(event.target.value) + }} + /> +
+
+ + { + setValue(event.target.value) + }} + /> +
+
+ ) +} + +export default SecretCreateForm diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx index 01cb40de01ddb..d34a67373f3c5 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/templates.tsx @@ -61,7 +61,7 @@ const TEMPLATES: Template[] = [ interface TemplatesRenderProps { // Later this data may be requested and therefore needs to be passed dynamically. templates: Template[] - onTemplateClick: (name?: string | null) => void + onTemplateClick: (name: string | null) => void } function TemplatesRender(props: TemplatesRenderProps) { @@ -71,7 +71,7 @@ function TemplatesRender(props: TemplatesRenderProps) { const CreateEmptyTemplate = ( +
+ + { + setName(event.target.value) + }} + defaultValue={name ?? file?.name ?? ''} + /> +
+
+ +
+
+ { + setFile(event.target.files?.[0] ?? null) + }} + /> +
+
+
{file?.name ?? 'No file selected'}
+
+ {file ? fileInfo.toReadableSize(file.size) : '\u00a0'} +
+
+
+ {file ? fileInfo.fileIcon(fileInfo.fileExtension(file.name)) : <>} +
+
+
+
+
+ Upload +
+
+ Cancel +
+
+ + + ) +} + +export default UploadFileModal diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts index 24fc9f476f918..e0727a5c75f69 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/service.ts @@ -303,7 +303,7 @@ export interface UserPermission { } /** Metadata uniquely identifying a directory entry. - * Thes can be Projects, Files, Secrets, or other directories. */ + * These can be Projects, Files, Secrets, or other directories. */ interface BaseAsset { title: string id: string @@ -326,15 +326,13 @@ export interface IdType { } /** Metadata uniquely identifying a directory entry. - * Thes can be Projects, Files, Secrets, or other directories. */ + * These can be Projects, Files, Secrets, or other directories. */ export interface Asset extends BaseAsset { type: Type id: IdType[Type] } -// This is an alias. -// It should be a separate type because it is the return value of one of the APIs. -// eslint-disable-next-line @typescript-eslint/no-empty-interface +/** The type returned from the "create directory" endpoint. */ export interface Directory extends Asset {} // ================= @@ -350,14 +348,14 @@ export interface CreateUserRequestBody { /** HTTP request body for the "create directory" endpoint. */ export interface CreateDirectoryRequestBody { title: string - parentId?: DirectoryId + parentId: DirectoryId | null } /** HTTP request body for the "create project" endpoint. */ export interface CreateProjectRequestBody { projectName: string - projectTemplateName?: string - parentDirectoryId?: DirectoryId + projectTemplateName: string | null + parentDirectoryId: DirectoryId | null } /** @@ -379,7 +377,7 @@ export interface OpenProjectRequestBody { export interface CreateSecretRequestBody { secretName: string secretValue: string - parentDirectoryId?: DirectoryId + parentDirectoryId: DirectoryId | null } /** HTTP request body for the "create tag" endpoint. */ diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts index a9c35c2ec44ef..d3782264e599f 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/error.ts @@ -1,5 +1,27 @@ /** @file Contains useful error types common across the module. */ +// ================================ +// === Type assertions (unsafe) === +// ================================ + +type MustBeAny = never extends T ? (T & 1 extends 0 ? T : never) : never + +export function unsafeAsError(error: MustBeAny) { + // This is UNSAFE - errors can be any value. + // Usually they *do* extend `Error`, + // however great care must be taken when deciding to use this. + // eslint-disable-next-line no-restricted-syntax + return error as Error +} + +export function unsafeIntoErrorMessage(error: MustBeAny) { + return unsafeAsError(error).message +} + +// ============================ +// === UnreachableCaseError === +// ============================ + /** An error used to indicate when an unreachable case is hit in a `switch` or `if` statement. * * TypeScript is sometimes unable to determine if we're exhaustively matching in a `switch` or `if` diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts new file mode 100644 index 0000000000000..1febb6bad2713 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/fileInfo.ts @@ -0,0 +1,29 @@ +/** @file Utility functions for extracting and manipulating file information. */ + +import * as svg from './components/svg' + +/** Returns the file extension of a file name. */ +export function fileExtension(fileName: string) { + return fileName.match(/\.(.+?)$/)?.[1] ?? '' +} + +/** Returns the appropriate icon for a specific file extension. */ +export function fileIcon(_extension: string) { + return svg.FILE_ICON +} + +export function toReadableSize(size: number) { + /* eslint-disable @typescript-eslint/no-magic-numbers */ + if (size < 2 ** 10) { + return String(size) + ' B' + } else if (size < 2 ** 20) { + return (size / 2 ** 10).toFixed(2) + ' kiB' + } else if (size < 2 ** 30) { + return (size / 2 ** 30).toFixed(2) + ' MiB' + } else if (size < 2 ** 40) { + return (size / 2 ** 40).toFixed(2) + ' GiB' + } else { + return (size / 2 ** 50).toFixed(2) + ' TiB' + } + /* eslint-enable @typescript-eslint/no-magic-numbers */ +} diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx index fc27e3716895a..ea635c2c53c02 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/hooks.tsx @@ -3,6 +3,17 @@ import * as react from 'react' import * as loggerProvider from './providers/logger' +// ================== +// === useRefresh === +// ================== + +/** A hook that contains no state, and is used only to tell React when to re-render. */ +export function useRefresh() { + // Uses an empty object literal because every distinct literal + // is a new reference and therefore is not equal to any other object literal. + return react.useReducer(() => ({}), {}) +} + // ====================== // === useAsyncEffect === // ====================== diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts new file mode 100644 index 0000000000000..31bca4b839147 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/uploadMultipleFiles.ts @@ -0,0 +1,57 @@ +/** @file Helper function to upload multiple files, + * with progress being reported by a continually updating toast notification. */ + +import toast from 'react-hot-toast' + +import * as backend from './dashboard/service' + +export async function uploadMultipleFiles( + backendService: backend.Backend, + directoryId: backend.DirectoryId, + files: File[] +) { + const fileCount = files.length + if (fileCount === 0) { + toast.error('No files were dropped.') + return [] + } else { + let successfulUploadCount = 0 + let completedUploads = 0 + /** "file" or "files", whicheven is appropriate. */ + const filesWord = fileCount === 1 ? 'file' : 'files' + const toastId = toast.loading(`Uploading ${fileCount} ${filesWord}.`) + return await Promise.allSettled( + files.map(file => + backendService + .uploadFile( + { + fileName: file.name, + parentDirectoryId: directoryId, + }, + file + ) + .then(() => { + successfulUploadCount += 1 + }) + .catch(() => { + toast.error(`Could not upload file '${file.name}'.`) + }) + .finally(() => { + completedUploads += 1 + if (completedUploads === fileCount) { + const progress = + successfulUploadCount === fileCount + ? fileCount + : `${successfulUploadCount}/${fileCount}` + toast.success(`${progress} ${filesWord} uploaded.`, { id: toastId }) + } else { + toast.loading( + `${successfulUploadCount}/${fileCount} ${filesWord} uploaded.`, + { id: toastId } + ) + } + }) + ) + ) + } +} diff --git a/app/ide-desktop/lib/dashboard/src/tailwind.css b/app/ide-desktop/lib/dashboard/src/tailwind.css index 6d55769518cd8..669f6d8c92115 100644 --- a/app/ide-desktop/lib/dashboard/src/tailwind.css +++ b/app/ide-desktop/lib/dashboard/src/tailwind.css @@ -4,7 +4,8 @@ body { margin: 0; } -/* These styles MUST still be copied as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */ +/* These styles MUST still be copied + * as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */ .enso-dashboard { line-height: 1.5; -webkit-text-size-adjust: 100%; diff --git a/app/ide-desktop/lib/dashboard/tailwind.config.ts b/app/ide-desktop/lib/dashboard/tailwind.config.ts index 738b17988a245..5e3b13e3d127b 100644 --- a/app/ide-desktop/lib/dashboard/tailwind.config.ts +++ b/app/ide-desktop/lib/dashboard/tailwind.config.ts @@ -35,6 +35,9 @@ export const theme = { // Should be `#3e515f14`, but `bg-opacity` does not work with RGBA. 'perm-none': '#f0f1f3', }, + flexGrow: { + 2: '2', + }, fontSize: { vs: '0.8125rem', }, diff --git a/app/ide-desktop/package-lock.json b/app/ide-desktop/package-lock.json index 63dc601457f65..fc0bb568cfa3e 100644 --- a/app/ide-desktop/package-lock.json +++ b/app/ide-desktop/package-lock.json @@ -413,15 +413,6 @@ "name": "enso-content-config", "version": "1.0.0" }, - "lib/copy-plugin": { - "name": "enso-copy-plugin", - "version": "1.0.0", - "extraneous": true, - "license": "Apache-2.0", - "devDependencies": { - "typescript": "^4.9.3" - } - }, "lib/dashboard": { "name": "enso-dashboard", "version": "0.1.0", @@ -3571,9 +3562,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", - "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "version": "0.17.16", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.16.tgz", + "integrity": "sha512-SzBQtCV3Pdc9kyizh36Ol+dNVhkDyIrGb/JXZqFq8WL37LIyrXU0gUpADcNV311sCOhvY+f2ivMhb5Tuv8nMOQ==", "cpu": [ "x64" ], @@ -3737,11 +3728,10 @@ }, "node_modules/@esbuild/linux-x64": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz", - "integrity": "sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==", "cpu": [ "x64" ], + "license": "MIT", "optional": true, "os": [ "linux" @@ -4999,8 +4989,7 @@ }, "node_modules/@types/tar": { "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.4.tgz", - "integrity": "sha512-Cp4oxpfIzWt7mr2pbhHT2OTXGMAL0szYCzuf8lRWyIMCgsx6/Hfc3ubztuhvzXHXgraTQxyOCmmg7TDGIMIJJQ==", + "license": "MIT", "dependencies": { "@types/node": "*", "minipass": "^4.0.0" @@ -7636,9 +7625,8 @@ }, "node_modules/esbuild": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz", - "integrity": "sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==", "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -7695,6 +7683,21 @@ "js-yaml": "^4.0.0" } }, + "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { + "version": "0.17.15", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", + "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/escalade": { "version": "3.1.1", "license": "MIT", @@ -8725,8 +8728,7 @@ }, "node_modules/fs-minipass": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -8736,8 +8738,7 @@ }, "node_modules/fs-minipass/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -11763,16 +11764,14 @@ }, "node_modules/minipass": { "version": "4.2.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz", - "integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==", + "license": "ISC", "engines": { "node": ">=8" } }, "node_modules/minizlib": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -11783,8 +11782,7 @@ }, "node_modules/minizlib/node_modules/minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -14934,8 +14932,7 @@ }, "node_modules/tar": { "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", + "license": "ISC", "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -14976,16 +14973,14 @@ }, "node_modules/tar/node_modules/chownr": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", "engines": { "node": ">=10" } }, "node_modules/tar/node_modules/mkdirp": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", "bin": { "mkdirp": "bin/cmd.js" }, @@ -18145,9 +18140,9 @@ "optional": true }, "@esbuild/darwin-x64": { - "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", - "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "version": "0.17.16", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.16.tgz", + "integrity": "sha512-SzBQtCV3Pdc9kyizh36Ol+dNVhkDyIrGb/JXZqFq8WL37LIyrXU0gUpADcNV311sCOhvY+f2ivMhb5Tuv8nMOQ==", "optional": true }, "@esbuild/freebsd-arm64": { @@ -18212,8 +18207,6 @@ }, "@esbuild/linux-x64": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.15.tgz", - "integrity": "sha512-JsdS0EgEViwuKsw5tiJQo9UdQdUJYuB+Mf6HxtJSPN35vez1hlrNb1KajvKWF5Sa35j17+rW1ECEO9iNrIXbNg==", "optional": true }, "@esbuild/netbsd-x64": { @@ -19104,8 +19097,6 @@ }, "@types/tar": { "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/tar/-/tar-6.1.4.tgz", - "integrity": "sha512-Cp4oxpfIzWt7mr2pbhHT2OTXGMAL0szYCzuf8lRWyIMCgsx6/Hfc3ubztuhvzXHXgraTQxyOCmmg7TDGIMIJJQ==", "requires": { "@types/node": "*", "minipass": "^4.0.0" @@ -21143,8 +21134,6 @@ }, "esbuild": { "version": "0.17.15", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz", - "integrity": "sha512-LBUV2VsUIc/iD9ME75qhT4aJj0r75abCVS0jakhFzOtR7TQsqQA5w0tZ+KTKnwl3kXE0MhskNdHDh/I5aCR1Zw==", "requires": { "@esbuild/android-arm": "0.17.15", "@esbuild/android-arm64": "0.17.15", @@ -21168,6 +21157,14 @@ "@esbuild/win32-arm64": "0.17.15", "@esbuild/win32-ia32": "0.17.15", "@esbuild/win32-x64": "0.17.15" + }, + "dependencies": { + "@esbuild/darwin-x64": { + "version": "0.17.15", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.15.tgz", + "integrity": "sha512-NbImBas2rXwYI52BOKTW342Tm3LTeVlaOQ4QPZ7XuWNKiO226DisFk/RyPk3T0CKZkKMuU69yOvlapJEmax7cg==", + "optional": true + } } }, "esbuild-plugin-alias": { @@ -21869,16 +21866,12 @@ }, "fs-minipass": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", "requires": { "minipass": "^3.0.0" }, "dependencies": { "minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "requires": { "yallist": "^4.0.0" } @@ -23935,14 +23928,10 @@ "version": "1.2.7" }, "minipass": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-4.2.5.tgz", - "integrity": "sha512-+yQl7SX3bIT83Lhb4BVorMAHVuqsskxRdlmO9kTpyukp8vsm2Sn/fUOV9xlnG8/a5JsypJzap21lz/y3FBMJ8Q==" + "version": "4.2.5" }, "minizlib": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", "requires": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -23950,8 +23939,6 @@ "dependencies": { "minipass": { "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "requires": { "yallist": "^4.0.0" } @@ -25978,8 +25965,6 @@ }, "tar": { "version": "6.1.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.13.tgz", - "integrity": "sha512-jdIBIN6LTIe2jqzay/2vtYLlBHa3JF42ot3h1dW8Q0PaAG4v8rm0cvpVePtau5C6OKXGGcgO9q2AMNSWxiLqKw==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -25990,14 +25975,10 @@ }, "dependencies": { "chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==" + "version": "2.0.0" }, "mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" + "version": "1.0.4" } } }, diff --git a/app/ide-desktop/package.json b/app/ide-desktop/package.json index 5f49cb41c7778..f9a45a0a4fed9 100644 --- a/app/ide-desktop/package.json +++ b/app/ide-desktop/package.json @@ -36,6 +36,6 @@ "watch": "npm run watch --workspace enso-content", "watch-dashboard": "npm run watch --workspace enso-dashboard", "build-dashboard": "npm run build --workspace enso-dashboard", - "typecheck": "npm run typecheck --workspace enso; npm run typecheck --workspace enso-content; npm run typecheck --workspace enso-dashboard; npm run typecheck --workspace enso-authentication" + "typecheck": "npm run typecheck --workspace enso && npm run typecheck --workspace enso-content && npm run typecheck --workspace enso-dashboard && npm run typecheck --workspace enso-authentication" } }