mirror of
https://github.com/enso-org/enso.git
synced 2024-12-22 21:01:37 +03:00
Split dashboard.tsx
into smaller components (#6546)
* Consistent order for statements in `dashboard.tsx`; change functions back to lambdas * Create convenience aliases for each asset type * Remove obsolete FIXME * Refactor out column renderers into components * Enable `prefer-const` lint * Remove hardcoded product name * Add fixme * Enable `react-hooks` lints (not working for some reason) * Consistent messages for naming-convention lint overrides * Enable `react` lints * Extract out tables for each asset type * Refactor out column display mode switcher to a file * Switch VM check state to use an enum * Fix lint errors * Minor section change * Fix position of create forms * Fix bugs; improve debugging QoL * Add documentation for new components * Refactor out drive bar * Refactor out event handlers to variables * Minor clarifications * Refactor out directory view; some fixes; improve React DX There are still many issues when switching backends * Add `assert` * Use `backend.platform` instead of checking for properties * Fix errors when switching backend * Minor style changes; fix lint errors * Fix assert behavior * Change `Rows` to `Table` * Fixes * Fix lint errors * Fix "show dashboard" button * Implement click to rename * Fix lint errors * Fix lint errors (along with a bug in `devServiceWorker`) * Enable dev-mode on `ide watch` * Fix bug in `useAsyncEffect` introduced during merge * More fixes; new debug hooks; fix infinite loop in `auth.tsx` * Inline Cognito methods * Remove redundant `Promise.resolve`s * Fix column display * Fixes * Simplify modal type * Fix bug when opening IDE * Shift+click to select a range of table items * Implement delete multiple * Fixes * Tick and cross for rename input; fixes * Implement rename and delete directory and multi-delete directory; fixes * Optimize modal re-rendering * Make some internal `Props` private * Remove old asset selection code * Eliminate re-renders when clicking document body * Fix name flickering when renaming * Use static placeholders * Avoid refreshing entire directory on rename * Use asset name instead of ID in error messages * QoL improvements * Enable react lints and `strict-boolean-expressions` * Extract dashboard feature flags to its own module * Feature flag to show more toasts; minimize calls to `listDirectory` * Deselect selection on delete; hide unused features; add exception to PascalCase lint * Fix projects disappearing after being created * Fix name of `projectEvent` module imports * Re-disable delete when project is being closed * Fix assets refreshing when adding new projects * Refactor row state into `Table`; fix delete not being disabled again * Address review * Implement shortcut registry * Fix stop icon spinning when switching backends (ported from #6919) * Give columns names * Immediately show project as opening * Replace `asNewtype` with constructor functions * Address review * Minor bugfixes * Prepare for optimistically updated tables * wip 2 * Fix type errors * Remove indirect usages of `doRefresh` Updating the lists of items will need to be re-added later * Remove `toastPromise` * Fix `New_Directory_-Infinity` bug * wip * WIP: Begin restoring functionality to rows * Fix most issues with DirectoriesTable * Port optimistic UI from `DirectoriesTable` to all other asset tables * Fix bugs in item list events for asset tables * Merge `projectActionButton` into `projectsTable` * Remove `RenameModal`; minor context menu bugfixes * Fix bugs * Remove small default user icon * Fix more bugs * Fix bugs * Fix type error * Address review and QA * Fix optimistic UI for "manage permissions" modal * Fix "share with" modal * Fix template spinner disappearing * Allow multiple projects to be opened on local backend; fix version lifecycle returned by local backend * Fix minor bug when closing local project --------- Co-authored-by: Paweł Buchowski <pawel.buchowski@enso.org>
This commit is contained in:
parent
a5ec6a9e51
commit
ce33af82f7
@ -38,7 +38,7 @@ const RELATIVE_MODULES =
|
||||
'bin\\u002Fproject-manager|bin\\u002Fserver|config\\u002Fparser|authentication|config|debug|file-associations|index|ipc|log|naming|paths|preload|security|url-associations'
|
||||
const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)'
|
||||
const JSX = ':matches(JSXElement, JSXFragment)'
|
||||
const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/'
|
||||
const NOT_PASCAL_CASE = '/^(?!do[A-Z])(?!_?([A-Z][a-z0-9]*)+$)/'
|
||||
const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/'
|
||||
const WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+'
|
||||
const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/`
|
||||
@ -103,7 +103,8 @@ const RESTRICTED_SYNTAXES = [
|
||||
message: 'Use `for (const x of xs)`, not `for (let x of xs)`',
|
||||
},
|
||||
{
|
||||
selector: 'TSTypeAliasDeclaration > TSTypeReference > Identifier',
|
||||
selector:
|
||||
'TSTypeAliasDeclaration > TSTypeReference:not(:has(.typeParameters)) > Identifier',
|
||||
message: 'No renamed types',
|
||||
},
|
||||
{
|
||||
@ -133,7 +134,7 @@ const RESTRICTED_SYNTAXES = [
|
||||
},
|
||||
{
|
||||
// Matches non-functions.
|
||||
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:has(:matches(ArrowFunctionExpression)))`,
|
||||
selector: `:matches(Program, ExportNamedDeclaration, TSModuleBlock) > VariableDeclaration[kind=const] > VariableDeclarator[id.name=${NOT_CONSTANT_CASE}]:not(:matches(:has(ArrowFunctionExpression), :has(CallExpression[callee.object.name=newtype][callee.property.name=newtypeConstructor])))`,
|
||||
message: 'Use `CONSTANT_CASE` for top-level constants that are not functions',
|
||||
},
|
||||
{
|
||||
@ -225,6 +226,10 @@ const RESTRICTED_SYNTAXES = [
|
||||
selector: 'IfStatement > ExpressionStatement',
|
||||
message: 'Wrap `if` branches in `{}`',
|
||||
},
|
||||
{
|
||||
selector: ':matches(ForStatement[test=null], ForStatement[test.value=true])',
|
||||
message: 'Use `while (true)` instead of `for (;;)`',
|
||||
},
|
||||
{
|
||||
selector: 'VariableDeclarator[id.name=ENVIRONMENT][init.value!=production]',
|
||||
message: "Environment must be 'production' when committing",
|
||||
@ -290,6 +295,7 @@ export default [
|
||||
},
|
||||
],
|
||||
'sort-imports': ['error', { allowSeparatedGroups: true }],
|
||||
'no-constant-condition': ['error', { checkLoops: false }],
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
@ -357,7 +363,10 @@ export default [
|
||||
{ checksVoidReturn: { attributes: false } },
|
||||
],
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': [
|
||||
'error',
|
||||
{ allowConstantLoopConditions: true },
|
||||
],
|
||||
'@typescript-eslint/no-useless-empty-export': 'error',
|
||||
'@typescript-eslint/parameter-properties': ['error', { prefer: 'parameter-property' }],
|
||||
'@typescript-eslint/prefer-enum-initializers': 'error',
|
||||
|
6
app/ide-desktop/lib/assets/cross.svg
Normal file
6
app/ide-desktop/lib/assets/cross.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="8" fill="#3e515fe5" fill-opacity="0.1" />
|
||||
<path
|
||||
d="M4.8 6.2L6.6 8 4.8 9.8A.5.5 0 1 0 6.2 11.2L8 9.4 9.8 11.2A.5.5 0 1 0 11.2 9.8L9.4 8 11.2 6.2A.5.5 0 1 0 9.8 4.8L8 6.6 6.2 4.8A.5.5 0 1 0 4.8 6.2Z"
|
||||
fill="#3e515fe5" />
|
||||
</svg>
|
After Width: | Height: | Size: 357 B |
5
app/ide-desktop/lib/assets/tick.svg
Normal file
5
app/ide-desktop/lib/assets/tick.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg height="16" width="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="8" fill="#3e515fe5" fill-opacity="0.1" />
|
||||
<path d="M3.4 9.2L5.7 11.5A1 1 0 0 0 7.3 11.5L12.6 6.2A.5.5 0 1 0 11.2 4.8L6.5 9.5 4.8 7.8A.5.5 0 1 0 3.4 9.2"
|
||||
fill="#3e515fe5" />
|
||||
</svg>
|
After Width: | Height: | Size: 304 B |
@ -77,6 +77,8 @@ electron.contextBridge.exposeInMainWorld('enso_console', {
|
||||
// === Authentication API ===
|
||||
// ==========================
|
||||
|
||||
let currentDeepLinkHandler: ((event: Electron.IpcRendererEvent, url: string) => void) | null = null
|
||||
|
||||
/** Object exposed on the Electron main window; provides proxy functions to:
|
||||
* - open OAuth flows in the system browser, and
|
||||
* - handle deep links from the system browser or email client to the dashboard.
|
||||
@ -102,10 +104,15 @@ const AUTHENTICATION_API = {
|
||||
* `enso://authentication/register?code=...&state=...` from external sources like the user's
|
||||
* system browser or email client. Handling the links involves resuming whatever flow was in
|
||||
* progress when the link was opened (e.g., an OAuth registration flow). */
|
||||
setDeepLinkHandler: (callback: (url: string) => void) =>
|
||||
electron.ipcRenderer.on(ipc.Channel.openDeepLink, (_event, url: string) => {
|
||||
setDeepLinkHandler: (callback: (url: string) => void) => {
|
||||
if (currentDeepLinkHandler != null) {
|
||||
electron.ipcRenderer.off(ipc.Channel.openDeepLink, currentDeepLinkHandler)
|
||||
}
|
||||
currentDeepLinkHandler = (_event, url: string) => {
|
||||
callback(url)
|
||||
}),
|
||||
}
|
||||
electron.ipcRenderer.on(ipc.Channel.openDeepLink, currentDeepLinkHandler)
|
||||
},
|
||||
/** Save the access token to a credentials file.
|
||||
*
|
||||
* The backend doesn't have access to Electron's `localStorage` so we need to save access token
|
||||
|
@ -231,7 +231,8 @@ export function generateDirectoryName(name: string): string {
|
||||
|
||||
// If the name already consists a suffix, reuse it.
|
||||
const matches = name.match(/^(.*)_(\d+)$/)
|
||||
let suffix = 0
|
||||
const initialSuffix = -1
|
||||
let suffix = initialSuffix
|
||||
// Matches start with the whole match, so we need to skip it. Then come our two capture groups.
|
||||
const [matchedName, matchedSuffix] = matches?.slice(1) ?? []
|
||||
if (typeof matchedName !== 'undefined' && typeof matchedSuffix !== 'undefined') {
|
||||
@ -240,7 +241,8 @@ export function generateDirectoryName(name: string): string {
|
||||
}
|
||||
|
||||
const projectsDirectory = getProjectsDirectory()
|
||||
for (; ; suffix++) {
|
||||
while (true) {
|
||||
suffix++
|
||||
const candidatePath = pathModule.join(
|
||||
projectsDirectory,
|
||||
`${name}${suffix === 0 ? '' : `_${suffix}`}`
|
||||
|
@ -142,7 +142,7 @@ process.on('SIGINT', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
for (;;) {
|
||||
while (true) {
|
||||
console.log('Spawning Electron process.')
|
||||
const electronProcess = childProcess.spawn('electron', ELECTRON_ARGS, {
|
||||
stdio: 'inherit',
|
||||
|
@ -11,3 +11,29 @@
|
||||
export function isRunningInElectron() {
|
||||
return /electron/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
// ================
|
||||
// === Platform ===
|
||||
// ================
|
||||
|
||||
/** Possible platforms that the app may run on. */
|
||||
export enum Platform {
|
||||
unknown = 'Unknown platform',
|
||||
windows = 'Windows',
|
||||
macOS = 'macOS',
|
||||
linux = 'Linux',
|
||||
}
|
||||
|
||||
/** Returns the platform the app is currently running on.
|
||||
* This is used to determine whether `metaKey` or `ctrlKey` is used in shortcuts. */
|
||||
export function platform(): Platform {
|
||||
if (/windows/i.test(navigator.userAgent)) {
|
||||
return Platform.windows
|
||||
} else if (/mac os/i.test(navigator.userAgent)) {
|
||||
return Platform.macOS
|
||||
} else if (/linux/i.test(navigator.userAgent)) {
|
||||
return Platform.linux
|
||||
} else {
|
||||
return Platform.unknown
|
||||
}
|
||||
}
|
||||
|
@ -33,11 +33,11 @@
|
||||
user-scalable = no"
|
||||
/>
|
||||
<title>Enso</title>
|
||||
<link rel="stylesheet" href="/tailwind.css" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/docsStyle.css" />
|
||||
<script type="module" src="/index.js" defer></script>
|
||||
<script type="module" src="/run.js" defer></script>
|
||||
<link rel="stylesheet" href="./tailwind.css" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<link rel="stylesheet" href="./docsStyle.css" />
|
||||
<script type="module" src="./index.js" defer></script>
|
||||
<script type="module" src="./run.js" defer></script>
|
||||
<script
|
||||
src="https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.2/dist/twemoji.min.js"
|
||||
integrity="sha384-D6GSzpW7fMH86ilu73eB95ipkfeXcMPoOGVst/L04yqSSe+RTUY0jXcuEIZk0wrT"
|
||||
|
@ -22,14 +22,14 @@ const logger = app.log.logger
|
||||
/** The name of the `localStorage` key storing the initial URL of the app. */
|
||||
const INITIAL_URL_KEY = `${common.PRODUCT_NAME.toLowerCase()}-initial-url`
|
||||
/** Path to the SSE endpoint over which esbuild sends events. */
|
||||
const ESBUILD_PATH = '/esbuild'
|
||||
const ESBUILD_PATH = './esbuild'
|
||||
/** SSE event indicating a build has finished. */
|
||||
const ESBUILD_EVENT_NAME = 'change'
|
||||
/** Path to the serice worker that caches assets for offline usage.
|
||||
* In development, it also resolves all extensionless paths to `/index.html`.
|
||||
* This is required for client-side routing to work when doing `./run gui watch`.
|
||||
*/
|
||||
const SERVICE_WORKER_PATH = '/serviceWorker.js'
|
||||
const SERVICE_WORKER_PATH = './serviceWorker.js'
|
||||
/** One second in milliseconds. */
|
||||
const SECOND = 1000
|
||||
/** Time in seconds after which a `fetchTimeout` ends. */
|
||||
|
@ -191,8 +191,10 @@ export class Cognito {
|
||||
/** Return the current {@link UserSession}, or `None` if the user is not logged in.
|
||||
*
|
||||
* Will refresh the {@link UserSession} if it has expired. */
|
||||
userSession() {
|
||||
return userSession()
|
||||
async userSession() {
|
||||
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
|
||||
const amplifySession = currentSession.mapErr(intoCurrentSessionErrorKind)
|
||||
return amplifySession.map(parseUserSession).unwrapOr(null)
|
||||
}
|
||||
|
||||
/** Returns the associated organization ID of the current user, which is passed during signup,
|
||||
@ -207,8 +209,17 @@ export class Cognito {
|
||||
/** Sign up with username and password.
|
||||
*
|
||||
* Does not rely on federated identity providers (e.g., Google or GitHub). */
|
||||
signUp(username: string, password: string, organizationId: string | null) {
|
||||
return signUp(this.supportsDeepLinks, username, password, organizationId)
|
||||
async signUp(username: string, password: string, organizationId: string | null) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
const params = intoSignUpParams(
|
||||
this.supportsDeepLinks,
|
||||
username,
|
||||
password,
|
||||
organizationId
|
||||
)
|
||||
await amplify.Auth.signUp(params)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Send the email address verification code.
|
||||
@ -217,8 +228,11 @@ export class Cognito {
|
||||
* verification page. The email verification page will parse the verification code from the URL.
|
||||
* If the verification code matches, the email address is marked as verified. Once the email
|
||||
* address is verified, the user can sign in. */
|
||||
confirmSignUp(email: string, code: string) {
|
||||
return confirmSignUp(email, code)
|
||||
async confirmSignUp(email: string, code: string) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.confirmSignUp(email, code)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoConfirmSignUpErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Sign in via the Google federated identity provider.
|
||||
@ -226,8 +240,13 @@ export class Cognito {
|
||||
* This function will open the Google authentication page in the user's browser. The user will
|
||||
* be asked to log in to their Google account, and then to grant access to the application.
|
||||
* After the user has granted access, the browser will be redirected to the application. */
|
||||
signInWithGoogle() {
|
||||
return signInWithGoogle(this.customState())
|
||||
async signInWithGoogle() {
|
||||
const customState = this.customState()
|
||||
const provider = amplify.CognitoHostedUIIdentityProvider.Google
|
||||
await amplify.Auth.federatedSignIn({
|
||||
provider,
|
||||
...(customState != null ? { customState } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Sign in via the GitHub federated identity provider.
|
||||
@ -235,20 +254,40 @@ export class Cognito {
|
||||
* This function will open the GitHub authentication page in the user's browser. The user will
|
||||
* be asked to log in to their GitHub account, and then to grant access to the application.
|
||||
* After the user has granted access, the browser will be redirected to the application. */
|
||||
signInWithGitHub() {
|
||||
return signInWithGitHub()
|
||||
async signInWithGitHub() {
|
||||
await amplify.Auth.federatedSignIn({
|
||||
customProvider: GITHUB_PROVIDER,
|
||||
})
|
||||
}
|
||||
|
||||
/** Sign in with the given username and password.
|
||||
*
|
||||
* Does not rely on external identity providers (e.g., Google or GitHub). */
|
||||
signInWithPassword(username: string, password: string) {
|
||||
return signInWithPassword(username, password)
|
||||
async signInWithPassword(username: string, password: string) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.signIn(username, password)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Sign out the current user. */
|
||||
signOut() {
|
||||
return signOut(this.logger)
|
||||
async signOut() {
|
||||
// FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/341
|
||||
// For some reason, the redirect back to the IDE from the browser doesn't work correctly so this
|
||||
// `await` throws a timeout error. As a workaround, we catch this error and force a refresh of
|
||||
// the session manually by running the `signOut` again. This works because Amplify will see that
|
||||
// we've already signed out and clear the cache accordingly. Ideally we should figure out how
|
||||
// to fix the redirect and remove this `catch`. This has the unintended consequence of catching
|
||||
// any other errors that might occur during sign out, that we really shouldn't be catching. This
|
||||
// also has the unintended consequence of delaying the sign out process by a few seconds (until
|
||||
// the timeout occurs).
|
||||
try {
|
||||
await amplify.Auth.signOut()
|
||||
} catch (error) {
|
||||
this.logger.error('Sign out failed', error)
|
||||
} finally {
|
||||
await amplify.Auth.signOut()
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a password reset email.
|
||||
@ -256,8 +295,11 @@ export class Cognito {
|
||||
* The user will be able to reset their password by following the link in the email, which takes
|
||||
* them to the "reset password" page of the application. The verification code will be filled in
|
||||
* automatically. */
|
||||
forgotPassword(email: string) {
|
||||
return forgotPassword(email)
|
||||
async forgotPassword(email: string) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.forgotPassword(email)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoForgotPasswordErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Submit a new password for the given email address.
|
||||
@ -265,8 +307,11 @@ export class Cognito {
|
||||
* The user will have received a verification code in an email, which they will have entered on
|
||||
* the "reset password" page of the application. This function will submit the new password
|
||||
* along with the verification code, changing the user's password. */
|
||||
forgotPasswordSubmit(email: string, code: string, password: string) {
|
||||
return forgotPasswordSubmit(email, code, password)
|
||||
async forgotPasswordSubmit(email: string, code: string, password: string) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.forgotPasswordSubmit(email, code, password)
|
||||
})
|
||||
return result.mapErr(intoForgotPasswordSubmitErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Change a password for current authenticated user.
|
||||
@ -275,8 +320,17 @@ export class Cognito {
|
||||
* password, new password, and repeat new password to change their old password to the new
|
||||
* one. The validation of the repeated new password is handled by the `changePasswordModel`
|
||||
* component. */
|
||||
changePassword(oldPassword: string, newPassword: string) {
|
||||
return changePassword(oldPassword, newPassword)
|
||||
async changePassword(oldPassword: string, newPassword: string) {
|
||||
const cognitoUserResult = await currentAuthenticatedUser()
|
||||
if (cognitoUserResult.ok) {
|
||||
const cognitoUser = cognitoUserResult.unwrap()
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.changePassword(cognitoUser, oldPassword, newPassword)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||
} else {
|
||||
return results.Err(cognitoUserResult.val)
|
||||
}
|
||||
}
|
||||
|
||||
/** We want to signal to Amplify to fire a "custom state change" event when the user is
|
||||
@ -320,21 +374,6 @@ export interface UserSession {
|
||||
accessToken: string
|
||||
}
|
||||
|
||||
/** Return the current `CognitoUserSession`, if one exists. */
|
||||
async function userSession() {
|
||||
const amplifySession = await getAmplifyCurrentSession()
|
||||
return amplifySession.map(parseUserSession).toOption()
|
||||
}
|
||||
|
||||
/** Return the current `CognitoUserSession` if the user is logged in, or `CurrentSessionErrorKind`
|
||||
* otherwise.
|
||||
*
|
||||
* Will refresh the session if it has expired. */
|
||||
async function getAmplifyCurrentSession() {
|
||||
const currentSession = await results.Result.wrapAsync(() => amplify.Auth.currentSession())
|
||||
return currentSession.mapErr(intoCurrentSessionErrorKind)
|
||||
}
|
||||
|
||||
/** Parse a `CognitoUserSession` into a {@link UserSession}.
|
||||
* @throws If the `email` field of the payload is not a string. */
|
||||
function parseUserSession(session: cognito.CognitoUserSession): UserSession {
|
||||
@ -372,21 +411,6 @@ function intoCurrentSessionErrorKind(error: unknown): CurrentSessionErrorKind {
|
||||
// === SignUp ===
|
||||
// ==============
|
||||
|
||||
/** A wrapper around the Amplify "sign up" endpoint that converts known errors
|
||||
* to {@link SignUpError}s. */
|
||||
async function signUp(
|
||||
supportsDeepLinks: boolean,
|
||||
username: string,
|
||||
password: string,
|
||||
organizationId: string | null
|
||||
) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
const params = intoSignUpParams(supportsDeepLinks, username, password, organizationId)
|
||||
await amplify.Auth.signUp(params)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignUpErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Format a username and password as an {@link amplify.SignUpParams}. */
|
||||
function intoSignUpParams(
|
||||
supportsDeepLinks: boolean,
|
||||
@ -471,14 +495,6 @@ function intoSignUpErrorOrThrow(error: AmplifyError): SignUpError {
|
||||
// === ConfirmSignUp ===
|
||||
// =====================
|
||||
|
||||
/** A wrapper around the Amplify "confirm sign up" endpoint that converts known errors
|
||||
* to {@link ConfirmSignUpError}s. */
|
||||
async function confirmSignUp(email: string, code: string) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.confirmSignUp(email, code)
|
||||
}).then(result => result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoConfirmSignUpErrorOrThrow))
|
||||
}
|
||||
|
||||
const CONFIRM_SIGN_UP_USER_ALREADY_CONFIRMED_ERROR = {
|
||||
internalCode: 'NotAuthorizedException',
|
||||
internalMessage: 'User cannot be confirmed. Current status is CONFIRMED',
|
||||
@ -514,44 +530,10 @@ function intoConfirmSignUpErrorOrThrow(error: AmplifyError): ConfirmSignUpError
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === SignInWithGoogle ===
|
||||
// ========================
|
||||
|
||||
/** A wrapper around the Amplify "sign in with Google" endpoint. */
|
||||
async function signInWithGoogle(customState: string | null) {
|
||||
const provider = amplify.CognitoHostedUIIdentityProvider.Google
|
||||
const options = {
|
||||
provider,
|
||||
...(customState != null ? { customState } : {}),
|
||||
}
|
||||
await amplify.Auth.federatedSignIn(options)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === SignInWithGoogle ===
|
||||
// ========================
|
||||
|
||||
/** A wrapper around the Amplify confirm "sign in with GitHub" endpoint. */
|
||||
async function signInWithGitHub() {
|
||||
await amplify.Auth.federatedSignIn({
|
||||
customProvider: GITHUB_PROVIDER,
|
||||
})
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === SignInWithPassword ===
|
||||
// ==========================
|
||||
|
||||
/** A wrapper around the Amplify "sign in with password" endpoint that converts known errors
|
||||
* to {@link SignInWithPasswordError}s. */
|
||||
async function signInWithPassword(username: string, password: string) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.signIn(username, password)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoSignInWithPasswordErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when signing in with a password. */
|
||||
type SignInWithPasswordErrorKind = 'NotAuthorized' | 'UserNotConfirmed' | 'UserNotFound'
|
||||
|
||||
@ -589,11 +571,12 @@ function intoSignInWithPasswordErrorOrThrow(error: AmplifyError): SignInWithPass
|
||||
// ======================
|
||||
// === ForgotPassword ===
|
||||
// ======================
|
||||
|
||||
const FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR = {
|
||||
internalCode: 'InvalidParameterException',
|
||||
kind: 'UserNotConfirmed',
|
||||
message: `Cannot reset password for the user as there is no registered/verified email or \
|
||||
phone_number`,
|
||||
message: `Cannot reset password for the user as there is no registered/verified email \
|
||||
or phone_number`,
|
||||
} as const
|
||||
|
||||
const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = {
|
||||
@ -601,14 +584,6 @@ const FORGOT_PASSWORD_USER_NOT_FOUND_ERROR = {
|
||||
kind: 'UserNotFound',
|
||||
} as const
|
||||
|
||||
/** A wrapper around the Amplify "forgot password" endpoint that converts known errors
|
||||
* to {@link ForgotPasswordError}s. */
|
||||
async function forgotPassword(email: string) {
|
||||
return results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.forgotPassword(email)
|
||||
}).then(result => result.mapErr(intoAmplifyErrorOrThrow).mapErr(intoForgotPasswordErrorOrThrow))
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when requesting a password reset. */
|
||||
type ForgotPasswordErrorKind =
|
||||
| (typeof FORGOT_PASSWORD_USER_NOT_CONFIRMED_ERROR)['kind']
|
||||
@ -646,15 +621,6 @@ function intoForgotPasswordErrorOrThrow(error: AmplifyError): ForgotPasswordErro
|
||||
// === ForgotPasswordSubmit ===
|
||||
// ============================
|
||||
|
||||
/** A wrapper around the Amplify "forgot password submit" endpoint that converts known errors
|
||||
* to {@link ForgotPasswordSubmitError}s. */
|
||||
async function forgotPasswordSubmit(email: string, code: string, password: string) {
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.forgotPasswordSubmit(email, code, password)
|
||||
})
|
||||
return result.mapErr(intoForgotPasswordSubmitErrorOrThrow)
|
||||
}
|
||||
|
||||
/** Internal IDs of errors that may occur when resetting a password. */
|
||||
type ForgotPasswordSubmitErrorKind = 'AmplifyError' | 'AuthError'
|
||||
|
||||
@ -683,30 +649,6 @@ function intoForgotPasswordSubmitErrorOrThrow(error: unknown): ForgotPasswordSub
|
||||
}
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === SignOut ===
|
||||
// ===============
|
||||
|
||||
/** A wrapper around the Amplify "sign out" endpoint. */
|
||||
async function signOut(logger: loggerProvider.Logger) {
|
||||
// FIXME [NP]: https://github.com/enso-org/cloud-v2/issues/341
|
||||
// For some reason, the redirect back to the IDE from the browser doesn't work correctly so this
|
||||
// `await` throws a timeout error. As a workaround, we catch this error and force a refresh of
|
||||
// the session manually by running the `signOut` again. This works because Amplify will see that
|
||||
// we've already signed out and clear the cache accordingly. Ideally we should figure out how
|
||||
// to fix the redirect and remove this `catch`. This has the unintended consequence of catching
|
||||
// any other errors that might occur during sign out, that we really shouldn't be catching. This
|
||||
// also has the unintended consequence of delaying the sign out process by a few seconds (until
|
||||
// the timeout occurs).
|
||||
try {
|
||||
await amplify.Auth.signOut()
|
||||
} catch (error) {
|
||||
logger.error('Sign out failed', error)
|
||||
} finally {
|
||||
await amplify.Auth.signOut()
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === ChangePassword ===
|
||||
// ======================
|
||||
@ -724,18 +666,3 @@ async function currentAuthenticatedUser() {
|
||||
)
|
||||
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||
}
|
||||
|
||||
/** A wrapper around the Amplify "change password submit" endpoint that converts known errors
|
||||
* to {@link AmplifyError}s. */
|
||||
async function changePassword(oldPassword: string, newPassword: string) {
|
||||
const cognitoUserResult = await currentAuthenticatedUser()
|
||||
if (cognitoUserResult.ok) {
|
||||
const cognitoUser = cognitoUserResult.unwrap()
|
||||
const result = await results.Result.wrapAsync(async () => {
|
||||
await amplify.Auth.changePassword(cognitoUser, oldPassword, newPassword)
|
||||
})
|
||||
return result.mapErr(intoAmplifyErrorOrThrow)
|
||||
} else {
|
||||
return results.Err(cognitoUserResult.val)
|
||||
}
|
||||
}
|
||||
|
@ -31,8 +31,8 @@ function ConfirmRegistration() {
|
||||
|
||||
const { verificationCode, email } = parseUrlSearchParams(location.search)
|
||||
|
||||
// No dependencies means this runs on every render, however this component should immediately
|
||||
// navigate away so it shouldn't exist for more than a few renders.
|
||||
// No dependencies means this runs on every render, however this component immediately
|
||||
// navigates away so it should not exist for more than a few renders.
|
||||
React.useEffect(() => {
|
||||
if (email == null || verificationCode == null) {
|
||||
navigate(app.LOGIN_PATH)
|
||||
|
@ -12,54 +12,58 @@
|
||||
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** AWS region in which our Cognito pool is located. */
|
||||
export const AWS_REGION = newtype.asNewtype<AwsRegion>('eu-west-1')
|
||||
/** Complete list of OAuth scopes used by the app. */
|
||||
export const OAUTH_SCOPES = [
|
||||
newtype.asNewtype<OAuthScope>('email'),
|
||||
newtype.asNewtype<OAuthScope>('openid'),
|
||||
]
|
||||
/** OAuth response type used in the OAuth flows. */
|
||||
export const OAUTH_RESPONSE_TYPE = newtype.asNewtype<OAuthResponseType>('code')
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
// These are constructor functions that construct values of the type they are named after.
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
/** The AWS region in which our Cognito pool is located. This is always set to `eu-west-1` because
|
||||
* that is the only region in which our Cognito pools are currently available in. */
|
||||
type AwsRegion = newtype.Newtype<string, 'AwsRegion'>
|
||||
/** Create an {@link AwsRegion}. */
|
||||
const AwsRegion = newtype.newtypeConstructor<AwsRegion>()
|
||||
/** ID of the "Cognito user pool" that contains authentication & identity data of our users.
|
||||
*
|
||||
* This is created automatically by our Terraform scripts when the backend infrastructure is
|
||||
* created. Look in the `enso-org/cloud-v2` repo for details. */
|
||||
export type UserPoolId = newtype.Newtype<string, 'UserPoolId'>
|
||||
/** Create a {@link UserPoolId}. */
|
||||
export const UserPoolId = newtype.newtypeConstructor<UserPoolId>()
|
||||
/** ID of an OAuth client authorized to interact with the Cognito user pool specified by the
|
||||
* {@link UserPoolId}.
|
||||
*
|
||||
* This is created automatically by our Terraform scripts when the backend infrastructure is
|
||||
* created. Look in the `enso-org/cloud-v2` repo for details. */
|
||||
export type UserPoolWebClientId = newtype.Newtype<string, 'UserPoolWebClientId'>
|
||||
/** Create a {@link UserPoolWebClientId}. */
|
||||
export const UserPoolWebClientId = newtype.newtypeConstructor<UserPoolWebClientId>()
|
||||
/** Domain of the Cognito user pool used for authenticating/identifying the user.
|
||||
*
|
||||
* This must correspond to the public-facing domain name of the Cognito pool identified by the
|
||||
* {@link UserPoolId}, and must not contain an HTTP scheme, or a pathname. */
|
||||
export type OAuthDomain = newtype.Newtype<string, 'OAuthDomain'>
|
||||
/** Create a {@link OAuthDomain}. */
|
||||
export const OAuthDomain = newtype.newtypeConstructor<OAuthDomain>()
|
||||
/** Possible OAuth scopes to request from the federated identity provider during OAuth sign-in. */
|
||||
type OAuthScope = newtype.Newtype<string, 'OAuthScope'>
|
||||
/** Create an {@link OAuthScope}. */
|
||||
const OAuthScope = newtype.newtypeConstructor<OAuthScope>()
|
||||
/** The response type used to complete the OAuth flow. "code" means that the federated identity
|
||||
* provider will return an authorization code that can be exchanged for an access token. The
|
||||
* authorization code will be provided as a query parameter of the redirect URL. */
|
||||
type OAuthResponseType = newtype.Newtype<string, 'OAuthResponseType'>
|
||||
/** Create an {@link OAuthResponseType}. */
|
||||
const OAuthResponseType = newtype.newtypeConstructor<OAuthResponseType>()
|
||||
/** The URL used as a redirect (minus query parameters like `code` which get appended later), once
|
||||
* an OAuth flow (e.g., sign-in or sign-out) has completed. These must match the values set in the
|
||||
* Cognito pool and during the creation of the OAuth client. See the `enso-org/cloud-v2` repo for
|
||||
* details. */
|
||||
export type OAuthRedirect = newtype.Newtype<string, 'OAuthRedirect'>
|
||||
/** Create a {@link OAuthRedirect}. */
|
||||
export const OAuthRedirect = newtype.newtypeConstructor<OAuthRedirect>()
|
||||
/* eslint-enable @typescript-eslint/no-redeclare */
|
||||
/** Callback used to open URLs for the OAuth flow. This is only used in the desktop app (i.e. not in
|
||||
* the cloud). This is because in the cloud we just keep the user in their browser, but in the app
|
||||
* we want to open OAuth URLs in the system browser. This is because the user can't be expected to
|
||||
@ -73,6 +77,17 @@ export type AccessTokenSaver = (accessToken: string) => void
|
||||
* user is redirected back to the app from the system browser, after completing an OAuth flow. */
|
||||
export type RegisterOpenAuthenticationUrlCallbackFn = () => void
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** AWS region in which our Cognito pool is located. */
|
||||
export const AWS_REGION = AwsRegion('eu-west-1')
|
||||
/** Complete list of OAuth scopes used by the app. */
|
||||
export const OAUTH_SCOPES = [OAuthScope('email'), OAuthScope('openid')]
|
||||
/** OAuth response type used in the OAuth flows. */
|
||||
export const OAUTH_RESPONSE_TYPE = OAuthResponseType('code')
|
||||
|
||||
// =====================
|
||||
// === AmplifyConfig ===
|
||||
// =====================
|
||||
|
@ -15,7 +15,6 @@ import * as errorModule from '../../error'
|
||||
import * as http from '../../http'
|
||||
import * as localBackend from '../../dashboard/localBackend'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as newtype from '../../newtype'
|
||||
import * as remoteBackend from '../../dashboard/remoteBackend'
|
||||
import * as sessionProvider from './session'
|
||||
|
||||
@ -37,7 +36,7 @@ const MESSAGES = {
|
||||
resetPasswordSuccess: 'Successfully reset password!',
|
||||
signOutLoading: 'Logging out...',
|
||||
signOutSuccess: 'Successfully logged out!',
|
||||
signOutError: 'Error logging out, please try again.',
|
||||
signOutError: 'Could not log out, please try again.',
|
||||
pleaseWait: 'Please wait...',
|
||||
} as const
|
||||
|
||||
@ -194,6 +193,7 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
const goOfflineInternal = React.useCallback(() => {
|
||||
setInitialized(true)
|
||||
setUserSession(OFFLINE_USER_SESSION)
|
||||
setBackendWithoutSavingType(new localBackend.LocalBackend())
|
||||
if (supportsLocalBackend) {
|
||||
setBackendWithoutSavingType(new localBackend.LocalBackend())
|
||||
} else {
|
||||
@ -218,8 +218,9 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
|
||||
// circular dependency.
|
||||
React.useEffect(() => {
|
||||
// `navigator.onLine` is not a dependency so that the app doesn't make the remote backend
|
||||
// completely unusable on unreliable connections.
|
||||
// `navigator.onLine` is not a dependency of this `useEffect` (so this effect is not called
|
||||
// when `navigator.onLine` changes) - the internet being down should not immediately disable
|
||||
// the remote backend.
|
||||
if (!navigator.onLine) {
|
||||
void goOffline()
|
||||
}
|
||||
@ -235,12 +236,11 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
if (!navigator.onLine || forceOfflineMode) {
|
||||
goOfflineInternal()
|
||||
setForceOfflineMode(false)
|
||||
} else if (session.none) {
|
||||
} else if (session == null) {
|
||||
setInitialized(true)
|
||||
setUserSession(null)
|
||||
} else {
|
||||
const { accessToken, email } = session.val
|
||||
const headers = new Headers([['Authorization', `Bearer ${accessToken}`]])
|
||||
const headers = new Headers([['Authorization', `Bearer ${session.accessToken}`]])
|
||||
const client = new http.Client(headers)
|
||||
const backend = new remoteBackend.RemoteBackend(client, logger)
|
||||
// The backend MUST be the remote backend before login is finished.
|
||||
@ -252,11 +252,11 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
) {
|
||||
setBackendWithoutSavingType(backend)
|
||||
}
|
||||
let organization
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
while (organization === undefined) {
|
||||
let organization: backendModule.UserOrOrganization | null
|
||||
while (true) {
|
||||
try {
|
||||
organization = await backend.usersMe()
|
||||
break
|
||||
} catch {
|
||||
// The value may have changed after the `await`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
@ -277,25 +277,24 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
history.replaceState(null, '', url.toString())
|
||||
}
|
||||
let newUserSession: UserSession
|
||||
const sharedSessionData = { email, accessToken }
|
||||
if (!organization) {
|
||||
if (organization == null) {
|
||||
newUserSession = {
|
||||
type: UserSessionType.partial,
|
||||
...sharedSessionData,
|
||||
...session,
|
||||
}
|
||||
} else {
|
||||
newUserSession = {
|
||||
type: UserSessionType.full,
|
||||
...sharedSessionData,
|
||||
...session,
|
||||
organization,
|
||||
}
|
||||
|
||||
/** Save access token so can be reused by Enso backend. */
|
||||
cognito.saveAccessToken(accessToken)
|
||||
cognito.saveAccessToken(session.accessToken)
|
||||
|
||||
/** Execute the callback that should inform the Electron app that the user has logged in.
|
||||
* This is done to transition the app from the authentication/dashboard view to the IDE. */
|
||||
onAuthenticated(accessToken)
|
||||
onAuthenticated(session.accessToken)
|
||||
}
|
||||
|
||||
setUserSession(newUserSession)
|
||||
@ -306,12 +305,14 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
fetchSession().catch(error => {
|
||||
if (isUserFacingError(error)) {
|
||||
toast.error(error.message)
|
||||
logger.error(error.message)
|
||||
} else {
|
||||
logger.error(error)
|
||||
}
|
||||
})
|
||||
// `userSession` MUST NOT be a dependency as this effect always does a `setUserSession`,
|
||||
// which would result in an infinite loop.
|
||||
// `userSession` MUST NOT be a dependency as `setUserSession` is called every time
|
||||
// by this effect. Because it is an object literal, it will never be equal to the previous
|
||||
// value.
|
||||
// `initialized` MUST NOT be a dependency as it breaks offline mode.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
@ -390,15 +391,15 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
} else {
|
||||
try {
|
||||
const organizationId = await authService.cognito.organizationId()
|
||||
// This should not omit success and error toasts as it is not possible
|
||||
// to render this optimistically.
|
||||
await toast.promise(
|
||||
backend.createUser({
|
||||
userName: username,
|
||||
userEmail: newtype.asNewtype<backendModule.EmailAddress>(email),
|
||||
userEmail: backendModule.EmailAddress(email),
|
||||
organizationId:
|
||||
organizationId != null
|
||||
? newtype.asNewtype<backendModule.UserOrOrganizationId>(
|
||||
organizationId
|
||||
)
|
||||
? backendModule.UserOrOrganizationId(organizationId)
|
||||
: null,
|
||||
}),
|
||||
{
|
||||
@ -451,6 +452,8 @@ export function AuthProvider(props: AuthProviderProps) {
|
||||
deinitializeSession()
|
||||
setInitialized(false)
|
||||
setUserSession(null)
|
||||
// This should not omit success and error toasts as it is not possible
|
||||
// to render this optimistically.
|
||||
await toast.promise(cognito.signOut(), {
|
||||
success: MESSAGES.signOutSuccess,
|
||||
error: MESSAGES.signOutError,
|
||||
@ -535,7 +538,7 @@ export function ProtectedLayout() {
|
||||
const { session } = useAuth()
|
||||
const shouldPreventNavigation = getShouldPreventNavigation()
|
||||
|
||||
if (!shouldPreventNavigation && !session) {
|
||||
if (!shouldPreventNavigation && session == null) {
|
||||
return <router.Navigate to={app.LOGIN_PATH} />
|
||||
} else if (!shouldPreventNavigation && session?.type === UserSessionType.partial) {
|
||||
return <router.Navigate to={app.SET_USERNAME_PATH} />
|
||||
|
@ -2,8 +2,6 @@
|
||||
* currently authenticated user's session. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as results from 'ts-results'
|
||||
|
||||
import * as cognito from '../cognito'
|
||||
import * as error from '../../error'
|
||||
import * as hooks from '../../hooks'
|
||||
@ -15,12 +13,12 @@ import * as listen from '../listen'
|
||||
|
||||
/** State contained in a {@link SessionContext}. */
|
||||
interface SessionContextType {
|
||||
session: results.Option<cognito.UserSession>
|
||||
session: cognito.UserSession | null
|
||||
/** Set `initialized` to false. Must be called when logging out. */
|
||||
deinitializeSession: () => void
|
||||
}
|
||||
|
||||
/** See `AuthContext` for safety details. */
|
||||
/** See {@link AuthContext} for safety details. */
|
||||
const SessionContext = React.createContext<SessionContextType>(
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
{} as SessionContextType
|
||||
@ -45,7 +43,7 @@ export interface SessionProviderProps {
|
||||
* is initially served. */
|
||||
mainPageUrl: URL
|
||||
registerAuthEventListener: listen.ListenFunction
|
||||
userSession: () => Promise<results.Option<cognito.UserSession>>
|
||||
userSession: () => Promise<cognito.UserSession | null>
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
@ -63,13 +61,13 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
* set. This is useful when a user has just logged in (as their cached credentials are
|
||||
* out of date, so this will update them). */
|
||||
const session = hooks.useAsyncEffect(
|
||||
results.None,
|
||||
null,
|
||||
async () => {
|
||||
const innerSession = await userSession()
|
||||
setInitialized(true)
|
||||
return innerSession
|
||||
},
|
||||
[userSession, refresh]
|
||||
[refresh]
|
||||
)
|
||||
|
||||
/** Register an effect that will listen for authentication events. When the event occurs, we
|
||||
@ -79,8 +77,6 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
* For example, if a user clicks the signout button, this will clear the user's session, which
|
||||
* means we want the login screen to render (which is a child of this provider). */
|
||||
React.useEffect(() => {
|
||||
/** Handle Cognito authentication events
|
||||
* @throws {error.UnreachableCaseError} Never. */
|
||||
const listener: listen.ListenerCallback = event => {
|
||||
switch (event) {
|
||||
case listen.AuthEvent.signIn:
|
||||
@ -112,7 +108,7 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
* cleaned up between renders. This must be done because the `useEffect` will be called
|
||||
* multiple times during the lifetime of the component. */
|
||||
return cancel
|
||||
}, [doRefresh, mainPageUrl, registerAuthEventListener])
|
||||
}, [doRefresh, registerAuthEventListener, mainPageUrl])
|
||||
|
||||
const deinitializeSession = () => {
|
||||
setInitialized(false)
|
||||
|
@ -11,7 +11,6 @@ import * as cognito from './cognito'
|
||||
import * as config from '../config'
|
||||
import * as listen from './listen'
|
||||
import * as loggerProvider from '../providers/logger'
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
@ -40,9 +39,7 @@ const LOGIN_PATHNAME = '//auth/login'
|
||||
const REGISTRATION_PATHNAME = '//auth/registration'
|
||||
|
||||
/** URI used as the OAuth redirect when deep links are supported. */
|
||||
const DEEP_LINK_REDIRECT = newtype.asNewtype<auth.OAuthRedirect>(
|
||||
`${common.DEEP_LINK_SCHEME}://auth`
|
||||
)
|
||||
const DEEP_LINK_REDIRECT = auth.OAuthRedirect(`${common.DEEP_LINK_SCHEME}://auth`)
|
||||
/** OAuth redirect URLs for the electron app. */
|
||||
const DEEP_LINK_REDIRECTS: AmplifyRedirects = {
|
||||
redirectSignIn: DEEP_LINK_REDIRECT,
|
||||
@ -64,35 +61,23 @@ const BASE_AMPLIFY_CONFIG = {
|
||||
const AMPLIFY_CONFIGS = {
|
||||
/** Configuration for @indiv0's Cognito user pool. */
|
||||
npekin: {
|
||||
userPoolId: newtype.asNewtype<auth.UserPoolId>('eu-west-1_AXX1gMvpx'),
|
||||
userPoolWebClientId: newtype.asNewtype<auth.UserPoolWebClientId>(
|
||||
'1rpnb2n1ijn6o5529a7ob017o'
|
||||
),
|
||||
domain: newtype.asNewtype<auth.OAuthDomain>(
|
||||
'npekin-enso-domain.auth.eu-west-1.amazoncognito.com'
|
||||
),
|
||||
userPoolId: auth.UserPoolId('eu-west-1_AXX1gMvpx'),
|
||||
userPoolWebClientId: auth.UserPoolWebClientId('1rpnb2n1ijn6o5529a7ob017o'),
|
||||
domain: auth.OAuthDomain('npekin-enso-domain.auth.eu-west-1.amazoncognito.com'),
|
||||
...BASE_AMPLIFY_CONFIG,
|
||||
} satisfies Partial<auth.AmplifyConfig>,
|
||||
/** Configuration for @pbuchu's Cognito user pool. */
|
||||
pbuchu: {
|
||||
userPoolId: newtype.asNewtype<auth.UserPoolId>('eu-west-1_jSF1RbgPK'),
|
||||
userPoolWebClientId: newtype.asNewtype<auth.UserPoolWebClientId>(
|
||||
'1bnib0jfon3aqc5g3lkia2infr'
|
||||
),
|
||||
domain: newtype.asNewtype<auth.OAuthDomain>(
|
||||
'pb-enso-domain.auth.eu-west-1.amazoncognito.com'
|
||||
),
|
||||
userPoolId: auth.UserPoolId('eu-west-1_jSF1RbgPK'),
|
||||
userPoolWebClientId: auth.UserPoolWebClientId('1bnib0jfon3aqc5g3lkia2infr'),
|
||||
domain: auth.OAuthDomain('pb-enso-domain.auth.eu-west-1.amazoncognito.com'),
|
||||
...BASE_AMPLIFY_CONFIG,
|
||||
} satisfies Partial<auth.AmplifyConfig>,
|
||||
/** Configuration for the production Cognito user pool. */
|
||||
production: {
|
||||
userPoolId: newtype.asNewtype<auth.UserPoolId>('eu-west-1_9Kycu2SbD'),
|
||||
userPoolWebClientId: newtype.asNewtype<auth.UserPoolWebClientId>(
|
||||
'4j9bfs8e7415erf82l129v0qhe'
|
||||
),
|
||||
domain: newtype.asNewtype<auth.OAuthDomain>(
|
||||
'production-enso-domain.auth.eu-west-1.amazoncognito.com'
|
||||
),
|
||||
userPoolId: auth.UserPoolId('eu-west-1_9Kycu2SbD'),
|
||||
userPoolWebClientId: auth.UserPoolWebClientId('4j9bfs8e7415erf82l129v0qhe'),
|
||||
domain: auth.OAuthDomain('production-enso-domain.auth.eu-west-1.amazoncognito.com'),
|
||||
...BASE_AMPLIFY_CONFIG,
|
||||
} satisfies Partial<auth.AmplifyConfig>,
|
||||
}
|
||||
|
@ -76,6 +76,22 @@ export const FORGOT_PASSWORD_PATH = '/forgot-password'
|
||||
export const RESET_PASSWORD_PATH = '/password-reset'
|
||||
/** Path to the set username page. */
|
||||
export const SET_USERNAME_PATH = '/set-username'
|
||||
/** 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})$`
|
||||
)
|
||||
|
||||
// ======================
|
||||
// === getMainPageUrl ===
|
||||
// ======================
|
||||
|
||||
/** 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, '')
|
||||
return mainPageUrl
|
||||
}
|
||||
|
||||
// ===========
|
||||
// === App ===
|
||||
@ -112,12 +128,8 @@ function App(props: AppProps) {
|
||||
* will redirect the user between the login/register pages and the dashboard. */
|
||||
return (
|
||||
<>
|
||||
<toast.Toaster
|
||||
toastOptions={{ style: { maxWidth: '100%' } }}
|
||||
position="top-center"
|
||||
reverseOrder={false}
|
||||
/>
|
||||
<Router>
|
||||
<toast.Toaster toastOptions={{ style: { maxWidth: '100%' } }} position="top-center" />
|
||||
<Router basename={getMainPageUrl().pathname}>
|
||||
<AppRouter {...props} />
|
||||
</Router>
|
||||
</>
|
||||
@ -142,13 +154,11 @@ function AppRouter(props: AppProps) {
|
||||
onAuthenticated,
|
||||
} = props
|
||||
const navigate = hooks.useNavigate()
|
||||
// FIXME[sb]: After platform detection for Electron is merged in, `IS_DEV_MODE` should be
|
||||
// set to true on `ide watch`.
|
||||
if (IS_DEV_MODE) {
|
||||
// @ts-expect-error This is used exclusively for debugging.
|
||||
window.navigate = navigate
|
||||
}
|
||||
const mainPageUrl = new URL(window.location.href)
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
const authService = React.useMemo(() => {
|
||||
const authConfig = { navigate, ...props }
|
||||
return authServiceModule.initAuthService(authConfig)
|
||||
|
@ -3,6 +3,24 @@
|
||||
import * as auth from './authentication/config'
|
||||
import * as newtype from './newtype'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Base URL for requests to our Cloud API backend. */
|
||||
type ApiUrl = newtype.Newtype<`http://${string}` | `https://${string}`, 'ApiUrl'>
|
||||
/** Create an {@link ApiUrl}. */
|
||||
// This is a constructor function that constructs values of the type after which it is named.
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const ApiUrl = newtype.newtypeConstructor<ApiUrl>()
|
||||
|
||||
/** URL to the websocket endpoint of the Help Chat. */
|
||||
type ChatUrl = newtype.Newtype<`ws://${string}` | `wss://${string}`, 'ChatUrl'>
|
||||
|
||||
// This is a constructor function that constructs values of the type after which it is named.
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const ChatUrl = newtype.newtypeConstructor<ChatUrl>()
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -19,15 +37,15 @@ const CLOUD_REDIRECTS = {
|
||||
* The redirect URL must be known ahead of time because it is registered with the OAuth provider
|
||||
* when it is created. In the native app, the port is unpredictable, but this is not a problem
|
||||
* because the native app does not use port-based redirects, but deep links. */
|
||||
development: newtype.asNewtype<auth.OAuthRedirect>('http://localhost:8080'),
|
||||
production: newtype.asNewtype<auth.OAuthRedirect>(REDIRECT_OVERRIDE ?? CLOUD_DOMAIN),
|
||||
development: auth.OAuthRedirect('http://localhost:8080'),
|
||||
production: auth.OAuthRedirect(REDIRECT_OVERRIDE ?? CLOUD_DOMAIN),
|
||||
}
|
||||
|
||||
/** All possible API URLs, sorted by environment. */
|
||||
const API_URLS = {
|
||||
pbuchu: newtype.asNewtype<ApiUrl>('https://xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com'),
|
||||
npekin: newtype.asNewtype<ApiUrl>('https://s02ejyepk1.execute-api.eu-west-1.amazonaws.com'),
|
||||
production: newtype.asNewtype<ApiUrl>('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'),
|
||||
pbuchu: ApiUrl('https://xw0g8j3tsb.execute-api.eu-west-1.amazonaws.com'),
|
||||
npekin: ApiUrl('https://s02ejyepk1.execute-api.eu-west-1.amazonaws.com'),
|
||||
production: ApiUrl('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'),
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,9 +54,8 @@ const API_URLS = {
|
||||
* In development mode, the chat bot will need to be run locally:
|
||||
* https://github.com/enso-org/enso-bot */
|
||||
const CHAT_URLS = {
|
||||
development: newtype.asNewtype<ChatUrl>('ws://localhost:8082'),
|
||||
// TODO[sb]: Insert the actual URL of the production chat bot here.
|
||||
production: newtype.asNewtype<ChatUrl>('wss://chat.cloud.enso.org'),
|
||||
development: ChatUrl('ws://localhost:8082'),
|
||||
production: ChatUrl('wss://chat.cloud.enso.org'),
|
||||
}
|
||||
|
||||
/** All possible configuration options, sorted by environment. */
|
||||
@ -89,9 +106,3 @@ export type Environment = 'npekin' | 'pbuchu' | 'production'
|
||||
// ===========
|
||||
// === API ===
|
||||
// ===========
|
||||
|
||||
/** Base URL for requests to our Cloud API backend. */
|
||||
type ApiUrl = newtype.Newtype<`http://${string}` | `https://${string}`, 'ApiUrl'>
|
||||
|
||||
/** URL to the websocket endpoint of the Help Chat. */
|
||||
type ChatUrl = newtype.Newtype<`ws://${string}` | `wss://${string}`, 'ChatUrl'>
|
||||
|
@ -13,41 +13,68 @@ export enum BackendType {
|
||||
remote = 'remote',
|
||||
}
|
||||
|
||||
// These are constructor functions that construct values of the type they are named after.
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
/** Unique identifier for a user/organization. */
|
||||
export type UserOrOrganizationId = newtype.Newtype<string, 'UserOrOrganizationId'>
|
||||
/** Create a {@link UserOrOrganizationId}. */
|
||||
export const UserOrOrganizationId = newtype.newtypeConstructor<UserOrOrganizationId>()
|
||||
|
||||
/** Unique identifier for a directory. */
|
||||
export type DirectoryId = newtype.Newtype<string, 'DirectoryId'>
|
||||
/** Create a {@link DirectoryId}. */
|
||||
export const DirectoryId = newtype.newtypeConstructor<DirectoryId>()
|
||||
|
||||
/** Unique identifier for a user's project. */
|
||||
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
|
||||
/** Create a {@link ProjectId}. */
|
||||
export const ProjectId = newtype.newtypeConstructor<ProjectId>()
|
||||
|
||||
/** Unique identifier for an uploaded file. */
|
||||
export type FileId = newtype.Newtype<string, 'FileId'>
|
||||
/** Create a {@link FileId}. */
|
||||
export const FileId = newtype.newtypeConstructor<FileId>()
|
||||
|
||||
/** Unique identifier for a secret environment variable. */
|
||||
export type SecretId = newtype.Newtype<string, 'SecretId'>
|
||||
/** Create a {@link SecretId}. */
|
||||
export const SecretId = newtype.newtypeConstructor<SecretId>()
|
||||
|
||||
/** Unique identifier for an arbitrary asset */
|
||||
export type AssetId = DirectoryId | FileId | ProjectId | SecretId
|
||||
|
||||
/** Unique identifier for a file tag or project tag. */
|
||||
export type TagId = newtype.Newtype<string, 'TagId'>
|
||||
/** Create a {@link TagId}. */
|
||||
export const TagId = newtype.newtypeConstructor<TagId>()
|
||||
|
||||
/** A URL. */
|
||||
export type Address = newtype.Newtype<string, 'Address'>
|
||||
/** Create an {@link Address}. */
|
||||
export const Address = newtype.newtypeConstructor<Address>()
|
||||
|
||||
/** An email address. */
|
||||
export type EmailAddress = newtype.Newtype<string, 'EmailAddress'>
|
||||
/** Create an {@link EmailAddress}. */
|
||||
export const EmailAddress = newtype.newtypeConstructor<EmailAddress>()
|
||||
|
||||
/** An AWS S3 file path. */
|
||||
export type S3FilePath = newtype.Newtype<string, 'S3FilePath'>
|
||||
/** Create an {@link S3FilePath}. */
|
||||
export const S3FilePath = newtype.newtypeConstructor<S3FilePath>()
|
||||
|
||||
/** An AWS machine configuration. */
|
||||
export type Ami = newtype.Newtype<string, 'Ami'>
|
||||
/** Create an {@link Ami}. */
|
||||
export const Ami = newtype.newtypeConstructor<Ami>()
|
||||
|
||||
/** An AWS user ID. */
|
||||
export type Subject = newtype.Newtype<string, 'Subject'>
|
||||
/** Create a {@link Subject}. */
|
||||
export const Subject = newtype.newtypeConstructor<Subject>()
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-redeclare */
|
||||
|
||||
/** A user/organization in the application. These are the primary owners of a project. */
|
||||
export interface UserOrOrganization {
|
||||
@ -59,6 +86,13 @@ export interface UserOrOrganization {
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
/** A `Directory` returned by `createDirectory`. */
|
||||
export interface CreatedDirectory {
|
||||
id: DirectoryId
|
||||
parentId: DirectoryId
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Possible states that a project can be in. */
|
||||
export enum ProjectState {
|
||||
created = 'Created',
|
||||
@ -261,13 +295,22 @@ export interface UserPermissions {
|
||||
permissions: PermissionAction[]
|
||||
}
|
||||
|
||||
/** The type returned from the "update directory" endpoint. */
|
||||
export interface UpdatedDirectory {
|
||||
id: DirectoryId
|
||||
parentId: DirectoryId
|
||||
title: string
|
||||
}
|
||||
|
||||
/** Metadata uniquely identifying a directory entry.
|
||||
* These can be Projects, Files, Secrets, or other directories. */
|
||||
export interface BaseAsset {
|
||||
id: AssetId
|
||||
title: string
|
||||
modifiedAt: dateTime.Rfc3339DateTime | null
|
||||
parentId: AssetId
|
||||
/** This is defined as a generic {@link AssetId} in the backend, however it is more convenient
|
||||
* (and currently safe) to assume it is always a {@link DirectoryId}. */
|
||||
parentId: DirectoryId
|
||||
permissions: UserPermission[] | null
|
||||
}
|
||||
|
||||
@ -295,8 +338,20 @@ export interface Asset<Type extends AssetType = AssetType> extends BaseAsset {
|
||||
projectState: Type extends AssetType.project ? ProjectStateType : null
|
||||
}
|
||||
|
||||
/** A convenience alias for {@link Asset}<{@link AssetType.project}>. */
|
||||
export interface ProjectAsset extends Asset<AssetType.project> {}
|
||||
|
||||
/** A convenience alias for {@link Asset}<{@link AssetType.directory}>. */
|
||||
export interface DirectoryAsset extends Asset<AssetType.directory> {}
|
||||
|
||||
/** A convenience alias for {@link Asset}<{@link AssetType.secret}>. */
|
||||
export interface SecretAsset extends Asset<AssetType.secret> {}
|
||||
|
||||
/** A convenience alias for {@link Asset}<{@link AssetType.file}>. */
|
||||
export interface FileAsset extends Asset<AssetType.file> {}
|
||||
|
||||
/** The type returned from the "create directory" endpoint. */
|
||||
export interface Directory extends Asset<AssetType.directory> {}
|
||||
export interface Directory extends DirectoryAsset {}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -309,6 +364,23 @@ export const ASSET_TYPE_NAME: Record<AssetType, string> = {
|
||||
[AssetType.file]: 'file',
|
||||
} as const
|
||||
|
||||
// ==============================
|
||||
// === detectVersionLifecycle ===
|
||||
// ==============================
|
||||
|
||||
/** Extract the {@link VersionLifecycle} from a version string. */
|
||||
export function detectVersionLifecycle(version: string) {
|
||||
if (/rc/i.test(version)) {
|
||||
return VersionLifecycle.releaseCandidate
|
||||
} else if (/\bnightly\b/i.test(version)) {
|
||||
return VersionLifecycle.nightly
|
||||
} else if (/\bdev\b|\balpha\b/i.test(version)) {
|
||||
return VersionLifecycle.development
|
||||
} else {
|
||||
return VersionLifecycle.stable
|
||||
}
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Endpoints ===
|
||||
// =================
|
||||
@ -339,6 +411,11 @@ export interface CreateDirectoryRequestBody {
|
||||
parentId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** HTTP request body for the "update directory" endpoint. */
|
||||
export interface UpdateDirectoryRequestBody {
|
||||
title: string
|
||||
}
|
||||
|
||||
/** HTTP request body for the "create project" endpoint. */
|
||||
export interface CreateProjectRequestBody {
|
||||
projectName: string
|
||||
@ -376,14 +453,14 @@ export interface CreateTagRequestBody {
|
||||
|
||||
/** URL query string parameters for the "list directory" endpoint. */
|
||||
export interface ListDirectoryRequestParams {
|
||||
parentId?: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "upload file" endpoint. */
|
||||
export interface UploadFileRequestParams {
|
||||
fileId?: string
|
||||
fileName?: string
|
||||
parentDirectoryId?: DirectoryId
|
||||
fileId: string | null
|
||||
fileName: string | null
|
||||
parentDirectoryId: DirectoryId | null
|
||||
}
|
||||
|
||||
/** URL query string parameters for the "list tags" endpoint. */
|
||||
@ -406,6 +483,37 @@ export function assetIsType<Type extends AssetType>(type: Type) {
|
||||
return (asset: Asset): asset is Asset<Type> => asset.type === type
|
||||
}
|
||||
|
||||
// These are functions, and so their names should be camelCase.
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/** A type guard that returns whether an {@link Asset} is a {@link ProjectAsset}. */
|
||||
export const assetIsProject = assetIsType(AssetType.project)
|
||||
/** A type guard that returns whether an {@link Asset} is a {@link DirectoryAsset}. */
|
||||
export const assetIsDirectory = assetIsType(AssetType.directory)
|
||||
/** A type guard that returns whether an {@link Asset} is a {@link SecretAsset}. */
|
||||
export const assetIsSecret = assetIsType(AssetType.secret)
|
||||
/** A type guard that returns whether an {@link Asset} is a {@link FileAsset}. */
|
||||
export const assetIsFile = assetIsType(AssetType.file)
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
|
||||
// =======================
|
||||
// === rootDirectoryId ===
|
||||
// =======================
|
||||
|
||||
/** Return the id of the root directory for a user or organization. */
|
||||
export function rootDirectoryId(userOrOrganizationId: UserOrOrganizationId) {
|
||||
return DirectoryId(userOrOrganizationId.replace(/^organization-/, `${AssetType.directory}-`))
|
||||
}
|
||||
|
||||
// ==================
|
||||
// === getAssetId ===
|
||||
// ==================
|
||||
|
||||
/** A convenience function to get the `id` of an {@link Asset}.
|
||||
* This is useful to avoid React re-renders as it is not re-created on each function call. */
|
||||
export function getAssetId<Type extends AssetType>(asset: Asset<Type>) {
|
||||
return asset.id
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// === groupPermissionsByUser ===
|
||||
// ==============================
|
||||
@ -449,38 +557,54 @@ export interface Backend {
|
||||
/** Return user details for the current user. */
|
||||
usersMe: () => Promise<UserOrOrganization | null>
|
||||
/** Return a list of assets in a directory. */
|
||||
listDirectory: (query: ListDirectoryRequestParams) => Promise<Asset[]>
|
||||
listDirectory: (query: ListDirectoryRequestParams, title: string | null) => Promise<Asset[]>
|
||||
/** Create a directory. */
|
||||
createDirectory: (body: CreateDirectoryRequestBody) => Promise<Directory>
|
||||
createDirectory: (body: CreateDirectoryRequestBody) => Promise<CreatedDirectory>
|
||||
/** Change the name of a directory. */
|
||||
updateDirectory: (
|
||||
directoryId: DirectoryId,
|
||||
body: UpdateDirectoryRequestBody,
|
||||
title: string | null
|
||||
) => Promise<UpdatedDirectory>
|
||||
/** Delete a directory. */
|
||||
deleteDirectory: (directoryId: DirectoryId, title: string | null) => Promise<void>
|
||||
/** Return a list of projects belonging to the current user. */
|
||||
listProjects: () => Promise<ListedProject[]>
|
||||
/** Create a project for the current user. */
|
||||
createProject: (body: CreateProjectRequestBody) => Promise<CreatedProject>
|
||||
/** Close the project identified by the given project ID. */
|
||||
closeProject: (projectId: ProjectId) => Promise<void>
|
||||
closeProject: (projectId: ProjectId, title: string | null) => Promise<void>
|
||||
/** Return project details for the specified project ID. */
|
||||
getProjectDetails: (projectId: ProjectId) => Promise<Project>
|
||||
getProjectDetails: (projectId: ProjectId, title: string | null) => Promise<Project>
|
||||
/** Set a project to an open state. */
|
||||
openProject: (projectId: ProjectId, body: OpenProjectRequestBody) => Promise<void>
|
||||
projectUpdate: (projectId: ProjectId, body: ProjectUpdateRequestBody) => Promise<UpdatedProject>
|
||||
openProject: (
|
||||
projectId: ProjectId,
|
||||
body: OpenProjectRequestBody | null,
|
||||
title: string | null
|
||||
) => Promise<void>
|
||||
projectUpdate: (
|
||||
projectId: ProjectId,
|
||||
body: ProjectUpdateRequestBody,
|
||||
title: string | null
|
||||
) => Promise<UpdatedProject>
|
||||
/** Delete a project. */
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>
|
||||
deleteProject: (projectId: ProjectId, title: string | null) => Promise<void>
|
||||
/** Return project memory, processor and storage usage. */
|
||||
checkResources: (projectId: ProjectId) => Promise<ResourceUsage>
|
||||
checkResources: (projectId: ProjectId, title: string | null) => Promise<ResourceUsage>
|
||||
/** Return a list of files accessible by the current user. */
|
||||
listFiles: () => Promise<File[]>
|
||||
/** Upload a file. */
|
||||
uploadFile: (params: UploadFileRequestParams, body: Blob) => Promise<FileInfo>
|
||||
/** Delete a file. */
|
||||
deleteFile: (fileId: FileId) => Promise<void>
|
||||
deleteFile: (fileId: FileId, title: string | null) => Promise<void>
|
||||
/** Create a secret environment variable. */
|
||||
createSecret: (body: CreateSecretRequestBody) => Promise<SecretAndInfo>
|
||||
/** Return a secret environment variable. */
|
||||
getSecret: (secretId: SecretId) => Promise<Secret>
|
||||
getSecret: (secretId: SecretId, title: string | null) => Promise<Secret>
|
||||
/** Return the secret environment variables accessible by the user. */
|
||||
listSecrets: () => Promise<SecretInfo[]>
|
||||
/** Delete a secret environment variable. */
|
||||
deleteSecret: (secretId: SecretId) => Promise<void>
|
||||
deleteSecret: (secretId: SecretId, title: string | null) => Promise<void>
|
||||
/** Create a file tag or project tag. */
|
||||
createTag: (body: CreateTagRequestBody) => Promise<TagInfo>
|
||||
/** Return file tags or project tags accessible by the user. */
|
||||
|
@ -0,0 +1,344 @@
|
||||
/** @file Column types and column display modes. */
|
||||
import * as React from 'react'
|
||||
|
||||
import DefaultUserIcon from 'enso-assets/default_user.svg'
|
||||
import PlusIcon from 'enso-assets/plus.svg'
|
||||
|
||||
import * as authProvider from '../authentication/providers/auth'
|
||||
import * as backend from './backend'
|
||||
import * as dateTime from './dateTime'
|
||||
import * as modalProvider from '../providers/modal'
|
||||
import * as tableColumn from './components/tableColumn'
|
||||
|
||||
import PermissionDisplay, * as permissionDisplay from './components/permissionDisplay'
|
||||
import ManagePermissionsModal from './components/managePermissionsModal'
|
||||
|
||||
// =============
|
||||
// === 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',
|
||||
lastModified = 'last-modified',
|
||||
sharedWith = 'shared-with',
|
||||
docs = 'docs',
|
||||
labels = 'labels',
|
||||
dataAccess = 'data-access',
|
||||
usagePlan = 'usage-plan',
|
||||
engine = 'engine',
|
||||
ide = 'ide',
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** An immutable empty array, useful as a React prop. */
|
||||
const EMPTY_ARRAY: never[] = []
|
||||
|
||||
/** English names for every column except for the name column. */
|
||||
export const COLUMN_NAME: Record<Exclude<Column, Column.name>, string> = {
|
||||
[Column.lastModified]: 'Last modified',
|
||||
[Column.sharedWith]: 'Shared with',
|
||||
[Column.docs]: 'Docs',
|
||||
[Column.labels]: 'Labels',
|
||||
[Column.dataAccess]: 'Data access',
|
||||
[Column.usagePlan]: 'Usage plan',
|
||||
[Column.engine]: 'Engine',
|
||||
[Column.ide]: 'IDE',
|
||||
} as const
|
||||
|
||||
/** CSS classes for every column. Currently only used to set the widths. */
|
||||
export const COLUMN_CSS_CLASS: Record<Column, string> = {
|
||||
[Column.name]: 'w-60',
|
||||
[Column.lastModified]: 'w-40',
|
||||
[Column.sharedWith]: 'w-36',
|
||||
[Column.docs]: 'w-96',
|
||||
[Column.labels]: 'w-80',
|
||||
[Column.dataAccess]: 'w-96',
|
||||
[Column.usagePlan]: 'w-40',
|
||||
[Column.engine]: 'w-20',
|
||||
[Column.ide]: 'w-20',
|
||||
} as const
|
||||
|
||||
/** A list of column display modes and names, in order. */
|
||||
export const COLUMN_DISPLAY_MODES_AND_NAMES: [ColumnDisplayMode, string][] = [
|
||||
[ColumnDisplayMode.all, 'All'],
|
||||
[ColumnDisplayMode.compact, 'Compact'],
|
||||
[ColumnDisplayMode.docs, 'Docs'],
|
||||
[ColumnDisplayMode.settings, 'Settings'],
|
||||
]
|
||||
|
||||
/** {@link table.ColumnProps} for an unknown variant of {@link backend.Asset}. */
|
||||
type AnyAssetColumnProps = Omit<
|
||||
tableColumn.TableColumnProps<backend.Asset>,
|
||||
'rowState' | 'setItem' | 'setRowState' | 'state'
|
||||
>
|
||||
|
||||
/** A column displaying the time at which the asset was last modified. */
|
||||
function LastModifiedColumn(props: AnyAssetColumnProps) {
|
||||
return <>{props.item.modifiedAt && dateTime.formatDateTime(new Date(props.item.modifiedAt))}</>
|
||||
}
|
||||
|
||||
/** Props for a {@link UserPermissionDisplay}. */
|
||||
interface InternalUserPermissionDisplayProps {
|
||||
user: backend.UserPermissions
|
||||
item: backend.Asset
|
||||
emailsOfUsersWithPermission: Set<backend.EmailAddress>
|
||||
ownsThisAsset: boolean
|
||||
onDelete: () => void
|
||||
onPermissionsChange: (permissions: backend.PermissionAction[]) => void
|
||||
}
|
||||
|
||||
/** Displays permissions for a user on a specific asset. */
|
||||
function UserPermissionDisplay(props: InternalUserPermissionDisplayProps) {
|
||||
const {
|
||||
user,
|
||||
item,
|
||||
emailsOfUsersWithPermission,
|
||||
ownsThisAsset,
|
||||
onDelete,
|
||||
onPermissionsChange,
|
||||
} = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [permissions, setPermissions] = React.useState(user.permissions)
|
||||
const [oldPermissions, setOldPermissions] = React.useState(user.permissions)
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
const [isDeleting, setIsDeleting] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
setPermissions(user.permissions)
|
||||
}, [user.permissions])
|
||||
|
||||
return isDeleting ? null : (
|
||||
<PermissionDisplay
|
||||
key={user.user.pk}
|
||||
permissions={permissionDisplay.permissionActionsToPermissions(permissions)}
|
||||
className={`border-2 rounded-full -ml-5 first:ml-0 ${
|
||||
ownsThisAsset ? 'cursor-pointer hover:shadow-soft hover:z-10' : ''
|
||||
}`}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
if (ownsThisAsset) {
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
key={Number(new Date())}
|
||||
user={user.user}
|
||||
initialPermissions={user.permissions}
|
||||
asset={item}
|
||||
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
|
||||
eventTarget={event.currentTarget}
|
||||
onSubmit={(_users, newPermissions) => {
|
||||
if (newPermissions.length === 0) {
|
||||
setIsDeleting(true)
|
||||
} else {
|
||||
setOldPermissions(permissions)
|
||||
setPermissions(newPermissions)
|
||||
onPermissionsChange(newPermissions)
|
||||
}
|
||||
}}
|
||||
onSuccess={(_users, newPermissions) => {
|
||||
if (newPermissions.length === 0) {
|
||||
onDelete()
|
||||
}
|
||||
}}
|
||||
onFailure={() => {
|
||||
setIsDeleting(false)
|
||||
setPermissions(oldPermissions)
|
||||
onPermissionsChange(oldPermissions)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
setIsHovered(true)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setIsHovered(false)
|
||||
}}
|
||||
>
|
||||
{isHovered && (
|
||||
<div className="relative">
|
||||
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full shadow-soft bg-white px-2 py-1">
|
||||
{user.user.user_email}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<img src={DefaultUserIcon} height={24} width={24} />
|
||||
</PermissionDisplay>
|
||||
)
|
||||
}
|
||||
|
||||
/** A column listing the users with which this asset is shared. */
|
||||
function SharedWithColumn(props: AnyAssetColumnProps) {
|
||||
const { item } = props
|
||||
const session = authProvider.useNonPartialUserSession()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [permissions, setPermissions] = React.useState(() =>
|
||||
backend.groupPermissionsByUser(item.permissions ?? [])
|
||||
)
|
||||
const [oldPermissions, setOldPermissions] = React.useState(permissions)
|
||||
const emailsOfUsersWithPermission = React.useMemo(
|
||||
() => new Set(permissions.map(permission => permission.user.user_email)),
|
||||
[permissions]
|
||||
)
|
||||
const selfPermission = item.permissions?.find(
|
||||
permission => permission.user.user_email === session.organization?.email
|
||||
)?.permission
|
||||
const ownsThisAsset = selfPermission === backend.PermissionAction.own
|
||||
return (
|
||||
<div className="flex">
|
||||
{permissions.map(user => (
|
||||
<UserPermissionDisplay
|
||||
key={user.user.user_email}
|
||||
user={user}
|
||||
item={item}
|
||||
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
|
||||
ownsThisAsset={ownsThisAsset}
|
||||
onDelete={() => {
|
||||
setPermissions(
|
||||
permissions.filter(
|
||||
permission => permission.user.user_email !== user.user.user_email
|
||||
)
|
||||
)
|
||||
}}
|
||||
onPermissionsChange={newPermissions => {
|
||||
setPermissions(
|
||||
permissions.map(permission =>
|
||||
permission.user.user_email === user.user.user_email
|
||||
? { user: user.user, permissions: newPermissions }
|
||||
: permission
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{ownsThisAsset && (
|
||||
<button
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<ManagePermissionsModal
|
||||
key={Number(new Date())}
|
||||
asset={item}
|
||||
initialPermissions={EMPTY_ARRAY}
|
||||
emailsOfUsersWithPermission={emailsOfUsersWithPermission}
|
||||
eventTarget={event.currentTarget}
|
||||
onSubmit={(users, newPermissions) => {
|
||||
setOldPermissions(permissions)
|
||||
setPermissions([
|
||||
...permissions,
|
||||
...users.map(user => {
|
||||
const userPermissions: backend.UserPermissions = {
|
||||
user: {
|
||||
pk: user.id,
|
||||
// The names come from a third-party API
|
||||
// and cannot be changed.
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
user_name: user.name,
|
||||
user_email: user.email,
|
||||
/** {@link SharedWithColumn} is only accessible
|
||||
* if `session.organization` is not `null`. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
organization_id: session.organization!.id,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
permissions: newPermissions,
|
||||
}
|
||||
return userPermissions
|
||||
}),
|
||||
])
|
||||
}}
|
||||
onFailure={() => {
|
||||
setPermissions(oldPermissions)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img src={PlusIcon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A placeholder component for columns which do not yet have corresponding data to display. */
|
||||
function PlaceholderColumn() {
|
||||
return <></>
|
||||
}
|
||||
|
||||
/** 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<
|
||||
Exclude<Column, Column.name>,
|
||||
(props: AnyAssetColumnProps) => JSX.Element
|
||||
> = {
|
||||
[Column.lastModified]: LastModifiedColumn,
|
||||
[Column.sharedWith]: SharedWithColumn,
|
||||
[Column.docs]: PlaceholderColumn,
|
||||
[Column.labels]: PlaceholderColumn,
|
||||
[Column.dataAccess]: PlaceholderColumn,
|
||||
[Column.usagePlan]: PlaceholderColumn,
|
||||
[Column.engine]: PlaceholderColumn,
|
||||
[Column.ide]: PlaceholderColumn,
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === Helper functions ===
|
||||
// ========================
|
||||
|
||||
/** The list of columns displayed on each `ColumnDisplayMode`. */
|
||||
const COLUMNS_FOR: Record<ColumnDisplayMode, Column[]> = {
|
||||
[ColumnDisplayMode.release]: [Column.name, Column.lastModified, Column.sharedWith],
|
||||
[ColumnDisplayMode.all]: [
|
||||
Column.name,
|
||||
Column.lastModified,
|
||||
Column.sharedWith,
|
||||
Column.labels,
|
||||
Column.dataAccess,
|
||||
Column.usagePlan,
|
||||
Column.engine,
|
||||
Column.ide,
|
||||
],
|
||||
[ColumnDisplayMode.compact]: [
|
||||
Column.name,
|
||||
Column.lastModified,
|
||||
Column.sharedWith,
|
||||
Column.labels,
|
||||
Column.dataAccess,
|
||||
],
|
||||
[ColumnDisplayMode.docs]: [Column.name, Column.lastModified, Column.docs],
|
||||
[ColumnDisplayMode.settings]: [
|
||||
Column.name,
|
||||
Column.lastModified,
|
||||
Column.usagePlan,
|
||||
Column.engine,
|
||||
Column.ide,
|
||||
],
|
||||
}
|
||||
|
||||
/** Returns the list of columns to be displayed. */
|
||||
export function columnsFor(displayMode: ColumnDisplayMode, backendType: backend.BackendType) {
|
||||
const columns = COLUMNS_FOR[displayMode]
|
||||
return backendType === backend.BackendType.local
|
||||
? columns.filter(column => column !== Column.sharedWith)
|
||||
: columns
|
||||
}
|
@ -212,6 +212,7 @@ function Autocomplete(props: InternalMultipleAutocompleteProps | InternalSingleA
|
||||
ref={inputRef}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
size={1}
|
||||
className={`grow bg-gray-200 rounded-xl px-2 py-1 ${
|
||||
disabled ? 'pointer-events-none opacity-70' : ''
|
||||
} ${className ?? ''}`}
|
||||
|
@ -18,6 +18,13 @@ import * as newtype from '../../newtype'
|
||||
|
||||
import Twemoji from './twemoji'
|
||||
|
||||
// ================
|
||||
// === Newtypes ===
|
||||
// ================
|
||||
|
||||
/** Create a {@link chat.MessageId} */
|
||||
const MessageId = newtype.newtypeConstructor<chat.MessageId>()
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
@ -628,7 +635,7 @@ function Chat(props: ChatProps) {
|
||||
element.style.height = `${element.scrollHeight}px`
|
||||
const newMessage: ChatDisplayMessage = {
|
||||
// This MUST be unique.
|
||||
id: newtype.asNewtype<chat.MessageId>(String(Number(new Date()))),
|
||||
id: MessageId(String(Number(new Date()))),
|
||||
isStaffMessage: false,
|
||||
avatar: null,
|
||||
name: 'Me',
|
||||
|
@ -0,0 +1,42 @@
|
||||
/** @file Switcher for choosing which columns are shown. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as column from '../column'
|
||||
|
||||
// =================================
|
||||
// === ColumnDisplayModeSwitcher ===
|
||||
// =================================
|
||||
|
||||
/** Props for a {@link ColumnDisplayModeSwitcher}. */
|
||||
export interface ColumnDisplayModeSwitcherProps {
|
||||
columnDisplayMode: column.ColumnDisplayMode
|
||||
setColumnDisplayMode: (columnDisplayMode: column.ColumnDisplayMode) => void
|
||||
}
|
||||
|
||||
/** A selector that lets the user choose between pre-defined sets of visible columns. */
|
||||
function ColumnDisplayModeSwitcher(props: ColumnDisplayModeSwitcherProps) {
|
||||
const { columnDisplayMode, setColumnDisplayMode } = props
|
||||
|
||||
return (
|
||||
<>
|
||||
{column.COLUMN_DISPLAY_MODES_AND_NAMES.map(modeAndName => {
|
||||
const [mode, name] = modeAndName
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
className={`${
|
||||
columnDisplayMode === mode ? 'bg-white shadow-soft' : 'opacity-50'
|
||||
} rounded-full px-1.5`}
|
||||
onClick={() => {
|
||||
setColumnDisplayMode(mode)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColumnDisplayModeSwitcher
|
@ -4,6 +4,8 @@ import toast from 'react-hot-toast'
|
||||
|
||||
import CloseIcon from 'enso-assets/close.svg'
|
||||
|
||||
import * as errorModule from '../../error'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import Modal from './modal'
|
||||
@ -14,29 +16,27 @@ import Modal from './modal'
|
||||
|
||||
/** Props for a {@link ConfirmDeleteModal}. */
|
||||
export interface ConfirmDeleteModalProps {
|
||||
assetType: string
|
||||
name: string
|
||||
doDelete: () => Promise<void>
|
||||
onComplete: () => void
|
||||
/** Must fit in the sentence "Are you sure you want to delete <description>"? */
|
||||
description: string
|
||||
doDelete: () => void
|
||||
}
|
||||
|
||||
/** A modal for confirming the deletion of an asset. */
|
||||
function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||
const { assetType, name, doDelete, onComplete } = props
|
||||
const { description, doDelete } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const onSubmit = async () => {
|
||||
const onSubmit = () => {
|
||||
unsetModal()
|
||||
try {
|
||||
await toast.promise(doDelete(), {
|
||||
loading: `Deleting ${assetType} '${name}'...`,
|
||||
success: `Deleted ${assetType} '${name}'.`,
|
||||
// This is UNSAFE, as the original function's parameter is of type `any`.
|
||||
error: (promiseError: Error) =>
|
||||
`Error deleting ${assetType} '${name}': ${promiseError.message}`,
|
||||
})
|
||||
} finally {
|
||||
onComplete()
|
||||
doDelete()
|
||||
} catch (error) {
|
||||
const message = `Could not delete ${description}: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,24 +46,26 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onSubmit={async event => {
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
// Consider not calling `onSubmit()` here to make it harder to accidentally
|
||||
// delete an important asset.
|
||||
await onSubmit()
|
||||
onSubmit()
|
||||
}}
|
||||
className="relative bg-white shadow-soft rounded-lg w-96 p-2"
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Padding. */}
|
||||
<div className="grow" />
|
||||
<button type="button" onClick={unsetModal}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0 top-0 m-2"
|
||||
onClick={unsetModal}
|
||||
>
|
||||
<img src={CloseIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="m-2">
|
||||
Are you sure you want to delete the {assetType} ‘{name}’?
|
||||
</div>
|
||||
<div className="m-2">Are you sure you want to delete {description}?</div>
|
||||
<div className="m-1">
|
||||
<button
|
||||
type="submit"
|
||||
|
@ -14,7 +14,8 @@ const SCROLL_MARGIN = 12
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link ContextMenu}. */
|
||||
export interface ContextMenuProps {
|
||||
export interface ContextMenuProps extends React.PropsWithChildren {
|
||||
key: string
|
||||
// `left: number` and `top: number` may be more correct,
|
||||
// however passing an event eliminates the chance
|
||||
// of passing the wrong coordinates from the event.
|
||||
@ -22,7 +23,7 @@ export interface ContextMenuProps {
|
||||
}
|
||||
|
||||
/** A context menu that opens at the current mouse position. */
|
||||
function ContextMenu(props: React.PropsWithChildren<ContextMenuProps>) {
|
||||
function ContextMenu(props: ContextMenuProps) {
|
||||
const { children, event } = props
|
||||
const contextMenuRef = React.useRef<HTMLDivElement>(null)
|
||||
const [top, setTop] = React.useState(event.pageY)
|
||||
@ -46,6 +47,9 @@ function ContextMenu(props: React.PropsWithChildren<ContextMenuProps>) {
|
||||
// The location must be offset by -0.5rem to balance out the `m-2`.
|
||||
style={{ left: `calc(${event.pageX}px - 0.5rem)`, top: `calc(${top}px - 0.5rem)` }}
|
||||
className="absolute bg-white rounded-lg shadow-soft flex flex-col flex-nowrap m-2"
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -12,7 +12,6 @@ export interface ContextMenuEntryProps {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}
|
||||
|
||||
// This component MUST NOT use `useState` because it is not rendered directly.
|
||||
/** An item in a `ContextMenu`. */
|
||||
function ContextMenuEntry(props: React.PropsWithChildren<ContextMenuEntryProps>) {
|
||||
const { children, disabled = false, title, onClick } = props
|
||||
|
@ -23,7 +23,7 @@ export interface CreateFormPassthroughProps {
|
||||
/** `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<void>
|
||||
onSubmit: (event: React.FormEvent) => void
|
||||
}
|
||||
|
||||
/** A form to create an element. */
|
||||
@ -31,9 +31,9 @@ function CreateForm(props: CreateFormProps) {
|
||||
const { title, left, top, children, onSubmit: innerOnSubmit } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
const onSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
await innerOnSubmit(event)
|
||||
innerOnSubmit(event)
|
||||
}
|
||||
|
||||
return (
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,578 @@
|
||||
/** @file Table displaying a list of directories. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import DirectoryIcon from 'enso-assets/directory.svg'
|
||||
import PlusIcon from 'enso-assets/plus.svg'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as columnModule from '../column'
|
||||
import * as dateTime from '../dateTime'
|
||||
import * as directoryEventModule from '../events/directoryEvent'
|
||||
import * as directoryListEventModule from '../events/directoryListEvent'
|
||||
import * as errorModule from '../../error'
|
||||
import * as eventModule from '../event'
|
||||
import * as hooks from '../../hooks'
|
||||
import * as permissions from '../permissions'
|
||||
import * as presence from '../presence'
|
||||
import * as shortcuts from '../shortcuts'
|
||||
import * as string from '../../string'
|
||||
import * as uniqueString from '../../uniqueString'
|
||||
|
||||
import * as authProvider from '../../authentication/providers/auth'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import * as tableColumn from './tableColumn'
|
||||
import TableRow, * as tableRow from './tableRow'
|
||||
import ConfirmDeleteModal from './confirmDeleteModal'
|
||||
import ContextMenu from './contextMenu'
|
||||
import ContextMenuEntry from './contextMenuEntry'
|
||||
import EditableSpan from './editableSpan'
|
||||
import Table from './table'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The {@link RegExp} matching a directory name following the default naming convention. */
|
||||
const DIRECTORY_NAME_REGEX = /^New_Folder_(?<directoryIndex>\d+)$/
|
||||
/** The default prefix of an automatically generated directory. */
|
||||
const DIRECTORY_NAME_DEFAULT_PREFIX = 'New_Folder_'
|
||||
/** The user-facing name of this asset type. */
|
||||
const ASSET_TYPE_NAME = 'folder'
|
||||
/** The user-facing plural name of this asset type. */
|
||||
const ASSET_TYPE_NAME_PLURAL = 'folders'
|
||||
// This is a function, even though it is not syntactically a function.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL)
|
||||
/** Placeholder row when the search query is not empty. */
|
||||
const PLACEHOLDER_WITH_FILTER = (
|
||||
<span className="opacity-75">
|
||||
This folder does not contain any sub{ASSET_TYPE_NAME_PLURAL} matching your query.
|
||||
</span>
|
||||
)
|
||||
/** Placeholder row when the search query is empty. */
|
||||
const PLACEHOLDER_WITHOUT_FILTER = (
|
||||
<span className="opacity-75">
|
||||
This folder does not contain any sub{ASSET_TYPE_NAME_PLURAL}.
|
||||
</span>
|
||||
)
|
||||
|
||||
// ============================
|
||||
// === DirectoryNameHeading ===
|
||||
// ============================
|
||||
|
||||
/** Props for a {@link DirectoryNameHeading}. */
|
||||
interface InternalDirectoryNameHeadingProps {
|
||||
doCreateDirectory: () => void
|
||||
}
|
||||
|
||||
/** The column header for the "name" column for the table of directory assets. */
|
||||
function DirectoryNameHeading(props: InternalDirectoryNameHeadingProps) {
|
||||
const { doCreateDirectory } = props
|
||||
|
||||
return (
|
||||
<div className="inline-flex">
|
||||
{string.capitalizeFirst(ASSET_TYPE_NAME_PLURAL)}
|
||||
<button
|
||||
className="mx-1"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
doCreateDirectory()
|
||||
}}
|
||||
>
|
||||
<img src={PlusIcon} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === DirectoryName ===
|
||||
// =====================
|
||||
|
||||
/** Props for a {@link DirectoryName}. */
|
||||
interface InternalDirectoryNameProps
|
||||
extends tableColumn.TableColumnProps<
|
||||
backendModule.DirectoryAsset,
|
||||
DirectoriesTableState,
|
||||
DirectoryRowState
|
||||
> {}
|
||||
|
||||
/** The icon and name of a specific directory asset. */
|
||||
function DirectoryName(props: InternalDirectoryNameProps) {
|
||||
const {
|
||||
item,
|
||||
setItem,
|
||||
selected,
|
||||
state: { enterDirectory },
|
||||
rowState,
|
||||
setRowState,
|
||||
} = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
const doRename = async (newName: string) => {
|
||||
if (backend.type !== backendModule.BackendType.local) {
|
||||
try {
|
||||
await backend.updateDirectory(item.id, { title: newName }, item.title)
|
||||
return
|
||||
} catch (error) {
|
||||
const message = `Error renaming folder: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex text-left items-center align-middle whitespace-nowrap"
|
||||
onClick={event => {
|
||||
if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
(selected ||
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.editName,
|
||||
event
|
||||
))
|
||||
) {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: true,
|
||||
}))
|
||||
} else if (eventModule.isDoubleClick(event)) {
|
||||
enterDirectory(item)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={DirectoryIcon} />
|
||||
<EditableSpan
|
||||
editable={rowState.isEditingName}
|
||||
onSubmit={async newTitle => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: false,
|
||||
}))
|
||||
if (newTitle !== item.title) {
|
||||
const oldTitle = item.title
|
||||
setItem(oldItem => ({ ...oldItem, title: newTitle }))
|
||||
try {
|
||||
await doRename(newTitle)
|
||||
} catch {
|
||||
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: false,
|
||||
}))
|
||||
}}
|
||||
className="cursor-pointer bg-transparent grow px-2"
|
||||
>
|
||||
{item.title}
|
||||
</EditableSpan>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// === DirectoryRowContextMenu ===
|
||||
// ===============================
|
||||
|
||||
/** Props for a {@link DirectoryRowContextMenu}. */
|
||||
interface InternalDirectoryRowContextMenuProps {
|
||||
innerProps: tableRow.TableRowInnerProps<
|
||||
backendModule.DirectoryAsset,
|
||||
backendModule.DirectoryId,
|
||||
DirectoryRowState
|
||||
>
|
||||
event: React.MouseEvent
|
||||
doDelete: () => Promise<void>
|
||||
}
|
||||
|
||||
/** The context menu for a row of a {@link DirectorysTable}. */
|
||||
function DirectoryRowContextMenu(props: InternalDirectoryRowContextMenuProps) {
|
||||
const {
|
||||
innerProps: { item, setRowState },
|
||||
event,
|
||||
doDelete,
|
||||
} = props
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const doRename = () => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: true,
|
||||
}))
|
||||
unsetModal()
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.id} event={event}>
|
||||
<ContextMenuEntry onClick={doRename}>Rename</ContextMenuEntry>
|
||||
<ContextMenuEntry
|
||||
onClick={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<span className="text-red-700">Delete</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === DirectoryRow ===
|
||||
// ====================
|
||||
|
||||
/** A row in a {@link DirectoriesTable}. */
|
||||
function DirectoryRow(
|
||||
props: tableRow.TableRowProps<
|
||||
backendModule.DirectoryAsset,
|
||||
backendModule.DirectoryId,
|
||||
DirectoriesTableState,
|
||||
DirectoryRowState
|
||||
>
|
||||
) {
|
||||
const {
|
||||
keyProp: key,
|
||||
item: rawItem,
|
||||
state: { directoryEvent, dispatchDirectoryListEvent, markItemAsHidden, markItemAsVisible },
|
||||
} = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const [status, setStatus] = React.useState(presence.Presence.present)
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(rawItem)
|
||||
}, [rawItem])
|
||||
|
||||
const doDelete = async () => {
|
||||
if (backend.type !== backendModule.BackendType.local) {
|
||||
setStatus(presence.Presence.deleting)
|
||||
markItemAsHidden(key)
|
||||
try {
|
||||
await backend.deleteDirectory(item.id, item.title)
|
||||
dispatchDirectoryListEvent({
|
||||
type: directoryListEventModule.DirectoryListEventType.delete,
|
||||
directoryId: key,
|
||||
})
|
||||
} catch (error) {
|
||||
setStatus(presence.Presence.present)
|
||||
markItemAsVisible(key)
|
||||
const message = `Unable to delete directory: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hooks.useEventHandler(directoryEvent, async event => {
|
||||
switch (event.type) {
|
||||
case directoryEventModule.DirectoryEventType.create: {
|
||||
if (key === event.placeholderId) {
|
||||
if (backend.type !== backendModule.BackendType.remote) {
|
||||
const message = 'Folders cannot be created on the local backend.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else {
|
||||
setStatus(presence.Presence.inserting)
|
||||
try {
|
||||
const createdDirectory = await backend.createDirectory({
|
||||
parentId: item.parentId,
|
||||
title: item.title,
|
||||
})
|
||||
setStatus(presence.Presence.present)
|
||||
const newItem: backendModule.DirectoryAsset = {
|
||||
...item,
|
||||
...createdDirectory,
|
||||
}
|
||||
setItem(newItem)
|
||||
} catch (error) {
|
||||
dispatchDirectoryListEvent({
|
||||
type: directoryListEventModule.DirectoryListEventType.delete,
|
||||
directoryId: key,
|
||||
})
|
||||
const message = `Error creating new folder: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case directoryEventModule.DirectoryEventType.deleteMultiple: {
|
||||
if (event.directoryIds.has(key)) {
|
||||
await doDelete()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={presence.CLASS_NAME[status]}
|
||||
{...props}
|
||||
onContextMenu={(innerProps, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<DirectoryRowContextMenu
|
||||
innerProps={innerProps}
|
||||
event={event}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === DirectoriesTable ===
|
||||
// ========================
|
||||
|
||||
/** State passed through from a {@link DirectoriesTable} to every cell. */
|
||||
interface DirectoriesTableState {
|
||||
directoryEvent: directoryEventModule.DirectoryEvent | null
|
||||
enterDirectory: (directory: backendModule.DirectoryAsset) => void
|
||||
dispatchDirectoryListEvent: (event: directoryListEventModule.DirectoryListEvent) => void
|
||||
markItemAsHidden: (key: string) => void
|
||||
markItemAsVisible: (key: string) => void
|
||||
}
|
||||
|
||||
/** Data associated with a {@link DirectoryRow}, used for rendering. */
|
||||
export interface DirectoryRowState {
|
||||
isEditingName: boolean
|
||||
}
|
||||
|
||||
/** The default {@link DirectoryRowState} associated with a {@link DirectoryRow}. */
|
||||
export const INITIAL_ROW_STATE: DirectoryRowState = Object.freeze({
|
||||
isEditingName: false,
|
||||
})
|
||||
|
||||
/** Props for a {@link DirectoriesTable}. */
|
||||
export interface DirectoriesTableProps {
|
||||
directoryId: backendModule.DirectoryId | null
|
||||
items: backendModule.DirectoryAsset[]
|
||||
filter: ((item: backendModule.DirectoryAsset) => boolean) | null
|
||||
isLoading: boolean
|
||||
columnDisplayMode: columnModule.ColumnDisplayMode
|
||||
enterDirectory: (directory: backendModule.DirectoryAsset) => void
|
||||
}
|
||||
|
||||
/** The table of directory assets. */
|
||||
function DirectoriesTable(props: DirectoriesTableProps) {
|
||||
const {
|
||||
directoryId,
|
||||
items: rawItems,
|
||||
filter,
|
||||
isLoading,
|
||||
columnDisplayMode,
|
||||
enterDirectory,
|
||||
} = props
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [items, setItems] = React.useState(rawItems)
|
||||
const [directoryEvent, dispatchDirectoryEvent] =
|
||||
hooks.useEvent<directoryEventModule.DirectoryEvent>()
|
||||
const [directoryListEvent, dispatchDirectoryListEvent] =
|
||||
hooks.useEvent<directoryListEventModule.DirectoryListEvent>()
|
||||
|
||||
React.useEffect(() => {
|
||||
setItems(rawItems)
|
||||
}, [rawItems])
|
||||
|
||||
const visibleItems = React.useMemo(
|
||||
() => (filter != null ? items.filter(filter) : items),
|
||||
[items, filter]
|
||||
)
|
||||
|
||||
// === Tracking number of visually hidden items ===
|
||||
|
||||
const [shouldForceShowPlaceholder, setShouldForceShowPlaceholder] = React.useState(false)
|
||||
const keysOfHiddenItemsRef = React.useRef(new Set<string>())
|
||||
|
||||
const updateShouldForceShowPlaceholder = React.useCallback(() => {
|
||||
setShouldForceShowPlaceholder(
|
||||
visibleItems.every(item => keysOfHiddenItemsRef.current.has(item.id))
|
||||
)
|
||||
}, [visibleItems])
|
||||
|
||||
React.useEffect(updateShouldForceShowPlaceholder, [updateShouldForceShowPlaceholder])
|
||||
|
||||
React.useEffect(() => {
|
||||
const oldKeys = keysOfHiddenItemsRef.current
|
||||
keysOfHiddenItemsRef.current = new Set(
|
||||
items.map(backendModule.getAssetId).filter(key => oldKeys.has(key))
|
||||
)
|
||||
}, [items])
|
||||
|
||||
const markItemAsHidden = React.useCallback(
|
||||
(key: string) => {
|
||||
keysOfHiddenItemsRef.current.add(key)
|
||||
updateShouldForceShowPlaceholder()
|
||||
},
|
||||
[updateShouldForceShowPlaceholder]
|
||||
)
|
||||
|
||||
const markItemAsVisible = React.useCallback(
|
||||
(key: string) => {
|
||||
keysOfHiddenItemsRef.current.delete(key)
|
||||
updateShouldForceShowPlaceholder()
|
||||
},
|
||||
[updateShouldForceShowPlaceholder]
|
||||
)
|
||||
|
||||
// === End tracking number of visually hidden items ===
|
||||
|
||||
const createNewDirectory = React.useCallback(() => {
|
||||
dispatchDirectoryListEvent({
|
||||
type: directoryListEventModule.DirectoryListEventType.create,
|
||||
})
|
||||
}, [/* should never change */ dispatchDirectoryListEvent])
|
||||
|
||||
hooks.useEventHandler(directoryListEvent, event => {
|
||||
switch (event.type) {
|
||||
case directoryListEventModule.DirectoryListEventType.create: {
|
||||
const directoryIndices = items
|
||||
.map(item => DIRECTORY_NAME_REGEX.exec(item.title))
|
||||
.map(match => match?.groups?.directoryIndex)
|
||||
.map(maybeIndex => (maybeIndex != null ? parseInt(maybeIndex, 10) : 0))
|
||||
const title = `${DIRECTORY_NAME_DEFAULT_PREFIX}${
|
||||
Math.max(0, ...directoryIndices) + 1
|
||||
}`
|
||||
const placeholderItem: backendModule.DirectoryAsset = {
|
||||
id: backendModule.DirectoryId(uniqueString.uniqueString()),
|
||||
title,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
parentId: directoryId ?? backendModule.DirectoryId(''),
|
||||
permissions: permissions.tryGetSingletonOwnerPermission(organization),
|
||||
projectState: null,
|
||||
type: backendModule.AssetType.directory,
|
||||
}
|
||||
setItems(oldItems => [placeholderItem, ...oldItems])
|
||||
dispatchDirectoryEvent({
|
||||
type: directoryEventModule.DirectoryEventType.create,
|
||||
placeholderId: placeholderItem.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
case directoryListEventModule.DirectoryListEventType.delete: {
|
||||
setItems(oldItems => oldItems.filter(item => item.id !== event.directoryId))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const state = React.useMemo(
|
||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||
(): DirectoriesTableState => ({
|
||||
directoryEvent,
|
||||
enterDirectory,
|
||||
dispatchDirectoryListEvent,
|
||||
markItemAsHidden,
|
||||
markItemAsVisible,
|
||||
}),
|
||||
[
|
||||
directoryEvent,
|
||||
enterDirectory,
|
||||
dispatchDirectoryListEvent,
|
||||
markItemAsHidden,
|
||||
markItemAsVisible,
|
||||
]
|
||||
)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
return <></>
|
||||
} else {
|
||||
return (
|
||||
<Table<
|
||||
backendModule.DirectoryAsset,
|
||||
backendModule.DirectoryId,
|
||||
DirectoriesTableState,
|
||||
DirectoryRowState
|
||||
>
|
||||
rowComponent={DirectoryRow}
|
||||
items={visibleItems}
|
||||
isLoading={isLoading}
|
||||
state={state}
|
||||
initialRowState={INITIAL_ROW_STATE}
|
||||
getKey={backendModule.getAssetId}
|
||||
placeholder={filter != null ? PLACEHOLDER_WITH_FILTER : PLACEHOLDER_WITHOUT_FILTER}
|
||||
forceShowPlaceholder={shouldForceShowPlaceholder}
|
||||
columns={columnModule.columnsFor(columnDisplayMode, backend.type).map(column =>
|
||||
column === columnModule.Column.name
|
||||
? {
|
||||
id: column,
|
||||
className: columnModule.COLUMN_CSS_CLASS[column],
|
||||
heading: (
|
||||
<DirectoryNameHeading doCreateDirectory={createNewDirectory} />
|
||||
),
|
||||
render: DirectoryName,
|
||||
}
|
||||
: {
|
||||
id: column,
|
||||
className: columnModule.COLUMN_CSS_CLASS[column],
|
||||
heading: <>{columnModule.COLUMN_NAME[column]}</>,
|
||||
render: columnModule.COLUMN_RENDERER[column],
|
||||
}
|
||||
)}
|
||||
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doDeleteAll = () => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={
|
||||
`${selectedKeys.size} selected ` + ASSET_TYPE_NAME_PLURAL
|
||||
}
|
||||
doDelete={() => {
|
||||
dispatchDirectoryEvent({
|
||||
type: directoryEventModule.DirectoryEventType
|
||||
.deleteMultiple,
|
||||
directoryIds: selectedKeys,
|
||||
})
|
||||
setSelectedKeys(new Set())
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const pluralized = pluralize(selectedKeys.size)
|
||||
setModal(
|
||||
<ContextMenu key={uniqueString.uniqueString()} event={event}>
|
||||
<ContextMenuEntry onClick={doDeleteAll}>
|
||||
<span className="text-red-700">
|
||||
Delete {selectedKeys.size} {pluralized}
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DirectoriesTable
|
@ -0,0 +1,375 @@
|
||||
/** @file The directory header bar and directory item listing. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as common from 'enso-common'
|
||||
|
||||
import * as authProvider from '../../authentication/providers/auth'
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as columnModule from '../column'
|
||||
import * as hooks from '../../hooks'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as tabModule from '../tab'
|
||||
|
||||
import * as fileListEventModule from '../events/fileListEvent'
|
||||
import * as projectEventModule from '../events/projectEvent'
|
||||
import * as projectListEventModule from '../events/projectListEvent'
|
||||
|
||||
import DirectoriesTable from './directoriesTable'
|
||||
import DriveBar from './driveBar'
|
||||
import FilesTable from './filesTable'
|
||||
import ProjectsTable from './projectsTable'
|
||||
import SecretsTable from './secretsTable'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The `localStorage` key under which the ID of the current directory is stored. */
|
||||
const DIRECTORY_STACK_KEY = `${common.PRODUCT_NAME.toLowerCase()}-dashboard-directory-stack`
|
||||
|
||||
// ========================
|
||||
// === Helper functions ===
|
||||
// ========================
|
||||
|
||||
/** Sanitizes a string for use as a regex. */
|
||||
function regexEscape(string: string) {
|
||||
return string.replace(/[\\^$.|?*+()[{]/g, '\\$&')
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === DirectoryView ===
|
||||
// =====================
|
||||
|
||||
/** Props for a {@link DirectoryView}. */
|
||||
export interface DirectoryViewProps {
|
||||
tab: tabModule.Tab
|
||||
initialProjectName: string | null
|
||||
nameOfProjectToImmediatelyOpen: string | null
|
||||
setNameOfProjectToImmediatelyOpen: (nameOfProjectToImmediatelyOpen: string | null) => void
|
||||
directoryId: backendModule.DirectoryId | null
|
||||
setDirectoryId: (directoryId: backendModule.DirectoryId | null) => void
|
||||
projectListEvent: projectListEventModule.ProjectListEvent | null
|
||||
dispatchProjectListEvent: (directoryEvent: projectListEventModule.ProjectListEvent) => void
|
||||
query: string
|
||||
onOpenIde: (project: backendModule.ProjectAsset) => void
|
||||
onCloseIde: () => void
|
||||
appRunner: AppRunner | null
|
||||
loadingProjectManagerDidFail: boolean
|
||||
isListingRemoteDirectoryWhileOffline: boolean
|
||||
isListingLocalDirectoryAndWillFail: boolean
|
||||
isListingRemoteDirectoryAndWillFail: boolean
|
||||
}
|
||||
|
||||
/** Contains directory path and directory contents (projects, folders, secrets and files). */
|
||||
function DirectoryView(props: DirectoryViewProps) {
|
||||
const {
|
||||
tab,
|
||||
initialProjectName,
|
||||
nameOfProjectToImmediatelyOpen,
|
||||
setNameOfProjectToImmediatelyOpen,
|
||||
directoryId,
|
||||
setDirectoryId,
|
||||
query,
|
||||
projectListEvent,
|
||||
dispatchProjectListEvent,
|
||||
onOpenIde,
|
||||
onCloseIde,
|
||||
appRunner,
|
||||
loadingProjectManagerDidFail,
|
||||
isListingRemoteDirectoryWhileOffline,
|
||||
isListingLocalDirectoryAndWillFail,
|
||||
isListingRemoteDirectoryAndWillFail,
|
||||
} = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { organization, accessToken } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
|
||||
const [directoryStack, setDirectoryStack] = React.useState<backendModule.DirectoryAsset[]>([])
|
||||
// Defined by the spec as `compact` by default, however some columns lack an implementation
|
||||
// in the remote (cloud) backend and will therefore be empty.
|
||||
const [columnDisplayMode, setColumnDisplayMode] = React.useState(
|
||||
columnModule.ColumnDisplayMode.release
|
||||
)
|
||||
const [isFileBeingDragged, setIsFileBeingDragged] = React.useState(false)
|
||||
|
||||
const [projectAssets, setProjectAssets] = React.useState<backendModule.ProjectAsset[]>([])
|
||||
const [directoryAssets, setDirectoryAssets] = React.useState<backendModule.DirectoryAsset[]>([])
|
||||
const [secretAssets, setSecretAssets] = React.useState<backendModule.SecretAsset[]>([])
|
||||
const [fileAssets, setFileAssets] = React.useState<backendModule.FileAsset[]>([])
|
||||
const [projectEvent, dispatchProjectEvent] =
|
||||
React.useState<projectEventModule.ProjectEvent | null>(null)
|
||||
const [fileListEvent, dispatchFileListEvent] =
|
||||
React.useState<fileListEventModule.FileListEvent | null>(null)
|
||||
|
||||
const assetFilter = React.useMemo(() => {
|
||||
if (query === '') {
|
||||
return null
|
||||
} else {
|
||||
const regex = new RegExp(regexEscape(query), 'i')
|
||||
return (asset: backendModule.Asset) => regex.test(asset.title)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
const directory = directoryStack[directoryStack.length - 1] ?? null
|
||||
const parentDirectory = directoryStack[directoryStack.length - 2] ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
const onBlur = () => {
|
||||
setIsFileBeingDragged(false)
|
||||
}
|
||||
window.addEventListener('blur', onBlur)
|
||||
return () => {
|
||||
window.removeEventListener('blur', onBlur)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoadingAssets(true)
|
||||
setProjectAssets([])
|
||||
setDirectoryAssets([])
|
||||
setSecretAssets([])
|
||||
setFileAssets([])
|
||||
}, [backend, directoryId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) {
|
||||
setIsLoadingAssets(false)
|
||||
}
|
||||
}, [loadingProjectManagerDidFail, backend.type])
|
||||
|
||||
React.useEffect(() => {
|
||||
const cachedDirectoryStackJson = localStorage.getItem(DIRECTORY_STACK_KEY)
|
||||
if (cachedDirectoryStackJson != null) {
|
||||
// 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: backendModule.DirectoryAsset[] =
|
||||
JSON.parse(cachedDirectoryStackJson)
|
||||
setDirectoryStack(cachedDirectoryStack)
|
||||
const cachedDirectoryId = cachedDirectoryStack[cachedDirectoryStack.length - 1]?.id
|
||||
if (cachedDirectoryId) {
|
||||
setDirectoryId(cachedDirectoryId)
|
||||
}
|
||||
}
|
||||
}, [setDirectoryId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
organization != null &&
|
||||
directoryId === backendModule.rootDirectoryId(organization.id)
|
||||
) {
|
||||
localStorage.removeItem(DIRECTORY_STACK_KEY)
|
||||
} else {
|
||||
localStorage.setItem(DIRECTORY_STACK_KEY, JSON.stringify(directoryStack))
|
||||
}
|
||||
}, [directoryStack, directoryId, organization])
|
||||
|
||||
const assets = hooks.useAsyncEffect(
|
||||
[],
|
||||
async signal => {
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.local: {
|
||||
if (!isListingLocalDirectoryAndWillFail) {
|
||||
const newAssets = await backend.listDirectory()
|
||||
if (!signal.aborted) {
|
||||
setIsLoadingAssets(false)
|
||||
}
|
||||
return newAssets
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
}
|
||||
case backendModule.BackendType.remote: {
|
||||
if (
|
||||
!isListingRemoteDirectoryAndWillFail &&
|
||||
!isListingRemoteDirectoryWhileOffline &&
|
||||
directoryId != null
|
||||
) {
|
||||
const newAssets = await backend.listDirectory(
|
||||
{ parentId: directoryId },
|
||||
directory?.title ?? null
|
||||
)
|
||||
if (!signal.aborted) {
|
||||
setIsLoadingAssets(false)
|
||||
}
|
||||
return newAssets
|
||||
} else {
|
||||
setIsLoadingAssets(false)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[accessToken, directoryId, backend]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
const newProjectAssets = assets.filter(backendModule.assetIsProject)
|
||||
setProjectAssets(newProjectAssets)
|
||||
setDirectoryAssets(assets.filter(backendModule.assetIsDirectory))
|
||||
setSecretAssets(assets.filter(backendModule.assetIsSecret))
|
||||
setFileAssets(assets.filter(backendModule.assetIsFile))
|
||||
if (nameOfProjectToImmediatelyOpen != null) {
|
||||
const projectToLoad = newProjectAssets.find(
|
||||
projectAsset => projectAsset.title === nameOfProjectToImmediatelyOpen
|
||||
)
|
||||
if (projectToLoad != null) {
|
||||
dispatchProjectEvent({
|
||||
type: projectEventModule.ProjectEventType.open,
|
||||
projectId: projectToLoad.id,
|
||||
})
|
||||
}
|
||||
setNameOfProjectToImmediatelyOpen(null)
|
||||
}
|
||||
if (!initialized && initialProjectName != null) {
|
||||
setInitialized(true)
|
||||
if (!newProjectAssets.some(asset => asset.title === initialProjectName)) {
|
||||
const errorMessage = `No project named '${initialProjectName}' was found.`
|
||||
toast.error(errorMessage)
|
||||
logger.error(`Error opening project on startup: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
// `nameOfProjectToImmediatelyOpen` must NOT trigger this effect.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
assets,
|
||||
initialized,
|
||||
initialProjectName,
|
||||
logger,
|
||||
/* should never change */ setNameOfProjectToImmediatelyOpen,
|
||||
/* should never change */ dispatchProjectEvent,
|
||||
])
|
||||
|
||||
const doCreateProject = React.useCallback(() => {
|
||||
dispatchProjectListEvent({
|
||||
type: projectListEventModule.ProjectListEventType.create,
|
||||
templateId: null,
|
||||
onSpinnerStateChange: null,
|
||||
})
|
||||
}, [/* should never change */ dispatchProjectListEvent])
|
||||
|
||||
const enterDirectory = React.useCallback(
|
||||
(directoryAsset: backendModule.DirectoryAsset) => {
|
||||
setDirectoryId(directoryAsset.id)
|
||||
setDirectoryStack([...directoryStack, directoryAsset])
|
||||
},
|
||||
[directoryStack, setDirectoryId]
|
||||
)
|
||||
|
||||
const exitDirectory = React.useCallback(() => {
|
||||
setDirectoryId(
|
||||
parentDirectory?.id ??
|
||||
(organization != null ? backendModule.rootDirectoryId(organization.id) : null)
|
||||
)
|
||||
setDirectoryStack(
|
||||
// eslint-disable-next-line @typescript-eslint/no-magic-numbers
|
||||
directoryStack.slice(0, -1)
|
||||
)
|
||||
}, [directoryStack, organization, parentDirectory?.id, setDirectoryId])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDragEnter = (event: DragEvent) => {
|
||||
if (
|
||||
tab === tabModule.Tab.dashboard &&
|
||||
event.dataTransfer?.types.includes('Files') === true
|
||||
) {
|
||||
setIsFileBeingDragged(true)
|
||||
}
|
||||
}
|
||||
document.body.addEventListener('dragenter', onDragEnter)
|
||||
return () => {
|
||||
document.body.removeEventListener('dragenter', onDragEnter)
|
||||
}
|
||||
}, [tab])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DriveBar
|
||||
directoryId={directoryId}
|
||||
directory={directory}
|
||||
parentDirectory={parentDirectory}
|
||||
columnDisplayMode={columnDisplayMode}
|
||||
setColumnDisplayMode={setColumnDisplayMode}
|
||||
exitDirectory={exitDirectory}
|
||||
dispatchFileListEvent={dispatchFileListEvent}
|
||||
/>
|
||||
{/* Padding. */}
|
||||
<div className="h-6 mx-2" />
|
||||
<div className="flex-1 overflow-auto mx-2">
|
||||
<ProjectsTable
|
||||
appRunner={appRunner}
|
||||
directoryId={directoryId}
|
||||
items={projectAssets}
|
||||
filter={assetFilter}
|
||||
isLoading={isLoadingAssets}
|
||||
columnDisplayMode={columnDisplayMode}
|
||||
projectEvent={projectEvent}
|
||||
dispatchProjectEvent={dispatchProjectEvent}
|
||||
projectListEvent={projectListEvent}
|
||||
dispatchProjectListEvent={dispatchProjectListEvent}
|
||||
doCreateProject={doCreateProject}
|
||||
doOpenIde={onOpenIde}
|
||||
doCloseIde={onCloseIde}
|
||||
/>
|
||||
{/* Padding. */}
|
||||
<div className="h-10" />
|
||||
<DirectoriesTable
|
||||
directoryId={directoryId}
|
||||
items={directoryAssets}
|
||||
filter={assetFilter}
|
||||
isLoading={isLoadingAssets}
|
||||
columnDisplayMode={columnDisplayMode}
|
||||
enterDirectory={enterDirectory}
|
||||
/>
|
||||
{/* Padding. */}
|
||||
<div className="h-10" />
|
||||
<SecretsTable
|
||||
directoryId={directoryId}
|
||||
items={secretAssets}
|
||||
filter={assetFilter}
|
||||
isLoading={isLoadingAssets}
|
||||
columnDisplayMode={columnDisplayMode}
|
||||
/>
|
||||
{/* Padding. */}
|
||||
<div className="h-10" />
|
||||
<FilesTable
|
||||
directoryId={directoryId}
|
||||
items={fileAssets}
|
||||
filter={assetFilter}
|
||||
isLoading={isLoadingAssets}
|
||||
columnDisplayMode={columnDisplayMode}
|
||||
fileListEvent={fileListEvent}
|
||||
dispatchFileListEvent={dispatchFileListEvent}
|
||||
/>
|
||||
</div>
|
||||
{isFileBeingDragged &&
|
||||
directoryId != null &&
|
||||
backend.type === backendModule.BackendType.remote ? (
|
||||
<div
|
||||
className="text-white text-lg fixed w-screen h-screen inset-0 bg-primary grid place-items-center"
|
||||
onDragLeave={() => {
|
||||
setIsFileBeingDragged(false)
|
||||
}}
|
||||
onDragOver={event => {
|
||||
event.preventDefault()
|
||||
}}
|
||||
onDrop={event => {
|
||||
event.preventDefault()
|
||||
setIsFileBeingDragged(false)
|
||||
dispatchFileListEvent({
|
||||
type: fileListEventModule.FileListEventType.uploadMultiple,
|
||||
files: event.dataTransfer.files,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Drop to upload files.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DirectoryView
|
@ -0,0 +1,144 @@
|
||||
/** @file Header menubar for the directory listing, containing information about
|
||||
* the current directory and some configuration options. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ArrowRightSmallIcon from 'enso-assets/arrow_right_small.svg'
|
||||
import DownloadIcon from 'enso-assets/download.svg'
|
||||
import UploadIcon from 'enso-assets/upload.svg'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as column from '../column'
|
||||
import * as featureFlags from '../featureFlags'
|
||||
import * as fileListEventModule from '../events/fileListEvent'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
|
||||
import ColumnDisplayModeSwitcher from './columnDisplayModeSwitcher'
|
||||
|
||||
// ================
|
||||
// === DriveBar ===
|
||||
// ================
|
||||
|
||||
/** Props for a {@link DriveBar}. */
|
||||
export interface DriveBarProps {
|
||||
directoryId: backendModule.DirectoryId | null
|
||||
directory: backendModule.DirectoryAsset | null
|
||||
parentDirectory: backendModule.DirectoryAsset | null
|
||||
columnDisplayMode: column.ColumnDisplayMode
|
||||
setColumnDisplayMode: (columnDisplayMode: column.ColumnDisplayMode) => void
|
||||
dispatchFileListEvent: (fileListEvent: fileListEventModule.FileListEvent) => void
|
||||
exitDirectory: () => void
|
||||
}
|
||||
|
||||
/** Displays the current directory path and permissions, upload and download buttons,
|
||||
* and a column display mode switcher. */
|
||||
function DriveBar(props: DriveBarProps) {
|
||||
const {
|
||||
directoryId,
|
||||
directory,
|
||||
parentDirectory,
|
||||
columnDisplayMode,
|
||||
setColumnDisplayMode,
|
||||
dispatchFileListEvent,
|
||||
exitDirectory,
|
||||
} = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
const uploadFiles = React.useCallback(
|
||||
(event: React.FormEvent<HTMLInputElement>) => {
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
// TODO[sb]: Allow uploading `.enso-project`s
|
||||
// https://github.com/enso-org/cloud-v2/issues/510
|
||||
const message = 'Files cannot be uploaded to the local backend.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else if (
|
||||
event.currentTarget.files == null ||
|
||||
event.currentTarget.files.length === 0
|
||||
) {
|
||||
toast.success('No files selected to upload.')
|
||||
} else if (directoryId == null) {
|
||||
// This should never happen, however display a nice error message in case
|
||||
// it somehow does.
|
||||
const message = 'Files cannot be uploaded while offline.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else {
|
||||
dispatchFileListEvent({
|
||||
type: fileListEventModule.FileListEventType.uploadMultiple,
|
||||
files: event.currentTarget.files,
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
backend.type,
|
||||
directoryId,
|
||||
/* should not change */ logger,
|
||||
/* should never change */ dispatchFileListEvent,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-nowrap my-2">
|
||||
<h1 className="text-xl font-bold mx-4 self-center">Drive</h1>
|
||||
<div className="flex flex-row flex-nowrap mx-4">
|
||||
<div className="bg-gray-100 rounded-l-full flex flex-row flex-nowrap items-center p-1 mx-0.5">
|
||||
{directory && (
|
||||
<>
|
||||
<button className="mx-2" onClick={exitDirectory}>
|
||||
{parentDirectory?.title ?? '/'}
|
||||
</button>
|
||||
<img src={ArrowRightSmallIcon} />
|
||||
</>
|
||||
)}
|
||||
<span className="mx-2">{directory?.title ?? '/'}</span>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-r-full flex flex-row flex-nowrap items-center mx-0.5">
|
||||
<div className="m-2">Shared with</div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div className="bg-gray-100 rounded-full flex flex-row flex-nowrap px-1.5 py-1 mx-4">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
disabled={backend.type === backendModule.BackendType.local}
|
||||
id="upload_files_input"
|
||||
name="upload_files_input"
|
||||
className="w-0 h-0"
|
||||
onInput={uploadFiles}
|
||||
/>
|
||||
<label
|
||||
htmlFor="upload_files_input"
|
||||
className={`mx-1 ${
|
||||
backend.type === backendModule.BackendType.local
|
||||
? 'opacity-50'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img src={UploadIcon} />
|
||||
</label>
|
||||
<button
|
||||
className={`mx-1 opacity-50`}
|
||||
disabled={true}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
/* TODO */
|
||||
}}
|
||||
>
|
||||
<img src={DownloadIcon} />
|
||||
</button>
|
||||
</div>
|
||||
{featureFlags.FEATURE_FLAGS.columnDisplayModeSwitcher && (
|
||||
<ColumnDisplayModeSwitcher
|
||||
columnDisplayMode={columnDisplayMode}
|
||||
setColumnDisplayMode={setColumnDisplayMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DriveBar
|
@ -0,0 +1,91 @@
|
||||
/** @file A text `<span>` which turns into an `input` when desired. */
|
||||
import * as React from 'react'
|
||||
|
||||
import CrossIcon from 'enso-assets/cross.svg'
|
||||
import TickIcon from 'enso-assets/tick.svg'
|
||||
|
||||
import * as shortcuts from '../shortcuts'
|
||||
|
||||
// ====================
|
||||
// === EditableSpan ===
|
||||
// ====================
|
||||
|
||||
/** Props of an {@link EditableSpan} that are passed through to the base element. */
|
||||
type EditableSpanPassthroughProps = JSX.IntrinsicElements['input'] & JSX.IntrinsicElements['span']
|
||||
|
||||
/** Props for an {@link EditableSpan}. */
|
||||
export interface EditableSpanProps extends Omit<EditableSpanPassthroughProps, 'onSubmit'> {
|
||||
editable?: boolean
|
||||
onSubmit: (value: string) => void
|
||||
onCancel: () => void
|
||||
inputPattern?: string
|
||||
inputTitle?: string
|
||||
children: string
|
||||
}
|
||||
|
||||
/** A `<span>` that can turn into an `<input type="text">`. */
|
||||
function EditableSpan(props: EditableSpanProps) {
|
||||
const {
|
||||
editable = false,
|
||||
children,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
inputPattern,
|
||||
inputTitle,
|
||||
...passthroughProps
|
||||
} = props
|
||||
|
||||
// This is incorrect, but SAFE, as the value is always set by the time it is used.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const inputRef = React.useRef<HTMLInputElement>(null!)
|
||||
|
||||
if (editable) {
|
||||
return (
|
||||
<form
|
||||
className="flex grow"
|
||||
onSubmit={event => {
|
||||
event.preventDefault()
|
||||
onSubmit(inputRef.current.value)
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
size={1}
|
||||
defaultValue={children}
|
||||
onBlur={event => event.currentTarget.form?.requestSubmit()}
|
||||
onKeyUp={event => {
|
||||
if (
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesKeyboardAction(
|
||||
shortcuts.KeyboardAction.cancelEditName,
|
||||
event
|
||||
)
|
||||
) {
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
{...(inputPattern != null ? { pattern: inputPattern } : {})}
|
||||
{...(inputTitle != null ? { title: inputTitle } : {})}
|
||||
{...passthroughProps}
|
||||
/>
|
||||
<button type="submit" className="mx-0.5">
|
||||
<img src={TickIcon} />
|
||||
</button>
|
||||
<button
|
||||
className="mx-0.5"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
}}
|
||||
>
|
||||
<img src={CrossIcon} />
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
} else {
|
||||
return <span {...passthroughProps}>{children}</span>
|
||||
}
|
||||
}
|
||||
|
||||
export default EditableSpan
|
@ -1,101 +0,0 @@
|
||||
/** @file Form to create a project. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
// ======================
|
||||
// === FileCreateForm ===
|
||||
// ======================
|
||||
|
||||
/** Props for a {@link FileCreateForm}. */
|
||||
export interface FileCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A form to create a file. */
|
||||
function FileCreateForm(props: FileCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
const [name, setName] = React.useState<string | null>(null)
|
||||
const [file, setFile] = React.useState<File | null>(null)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (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 (
|
||||
<CreateForm title="New File" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="file_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="file_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
defaultValue={name ?? file?.name ?? ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<div className="inline-block flex-1 grow m-1">File</div>
|
||||
<div className="inline-block bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1">
|
||||
<label className="bg-transparent rounded-full w-full" htmlFor="file_file">
|
||||
<div className="inline-block bg-gray-300 hover:bg-gray-400 rounded-l-full px-2 -ml-2">
|
||||
<u>𐌣</u>
|
||||
</div>
|
||||
<div className="inline-block px-2 -mr-2">
|
||||
{file?.name ?? 'No file chosen'}
|
||||
</div>
|
||||
</label>
|
||||
<input
|
||||
id="file_file"
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={event => {
|
||||
setFile(event.target.files?.[0] ?? null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileCreateForm
|
@ -0,0 +1,580 @@
|
||||
/** @file Table displaying a list of files. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import PlusIcon from 'enso-assets/plus.svg'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as columnModule from '../column'
|
||||
import * as dateTime from '../dateTime'
|
||||
import * as errorModule from '../../error'
|
||||
import * as eventModule from '../event'
|
||||
import * as fileEventModule from '../events/fileEvent'
|
||||
import * as fileInfo from '../../fileInfo'
|
||||
import * as fileListEventModule from '../events/fileListEvent'
|
||||
import * as hooks from '../../hooks'
|
||||
import * as permissions from '../permissions'
|
||||
import * as presence from '../presence'
|
||||
import * as shortcuts from '../shortcuts'
|
||||
import * as string from '../../string'
|
||||
import * as uniqueString from '../../uniqueString'
|
||||
|
||||
import * as authProvider from '../../authentication/providers/auth'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import * as tableColumn from './tableColumn'
|
||||
import TableRow, * as tableRow from './tableRow'
|
||||
import ConfirmDeleteModal from './confirmDeleteModal'
|
||||
import ContextMenu from './contextMenu'
|
||||
import ContextMenuEntry from './contextMenuEntry'
|
||||
import EditableSpan from './editableSpan'
|
||||
import Table from './table'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The user-facing name of this asset type. */
|
||||
const ASSET_TYPE_NAME = 'file'
|
||||
/** The user-facing plural name of this asset type. */
|
||||
const ASSET_TYPE_NAME_PLURAL = 'files'
|
||||
// This is a function, even though it is not syntactically a function.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL)
|
||||
/** Placeholder row when the search query is not empty. */
|
||||
const PLACEHOLDER_WITH_QUERY = (
|
||||
<span className="opacity-75">
|
||||
This folder does not contain any {ASSET_TYPE_NAME_PLURAL} matching your query.
|
||||
</span>
|
||||
)
|
||||
/** Placeholder row when the search query is empty. */
|
||||
const PLACEHOLDER_WITHOUT_QUERY = (
|
||||
<span className="opacity-75">This folder does not contain any {ASSET_TYPE_NAME_PLURAL}.</span>
|
||||
)
|
||||
|
||||
// =======================
|
||||
// === FileNameHeading ===
|
||||
// =======================
|
||||
|
||||
/** Props for a {@link FileNameHeading}. */
|
||||
interface InternalFileNameHeadingProps {
|
||||
directoryId: backendModule.DirectoryId | null
|
||||
dispatchFileListEvent: (fileListEvent: fileListEventModule.FileListEvent) => void
|
||||
}
|
||||
|
||||
/** The column header for the "name" column for the table of file assets. */
|
||||
function FileNameHeading(props: InternalFileNameHeadingProps) {
|
||||
const { directoryId, dispatchFileListEvent } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
const uploadFiles = React.useCallback(
|
||||
(event: React.FormEvent<HTMLInputElement>) => {
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
// TODO[sb]: Allow uploading `.enso-project`s
|
||||
// https://github.com/enso-org/cloud-v2/issues/510
|
||||
const message = 'Files cannot be uploaded to the local backend.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else if (
|
||||
event.currentTarget.files == null ||
|
||||
event.currentTarget.files.length === 0
|
||||
) {
|
||||
toast.success('No files selected to upload.')
|
||||
} else if (directoryId == null) {
|
||||
// This should never happen, however display a nice error message in case
|
||||
// it somehow does.
|
||||
const message = 'Files cannot be uploaded while offline.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else {
|
||||
dispatchFileListEvent({
|
||||
type: fileListEventModule.FileListEventType.uploadMultiple,
|
||||
files: event.currentTarget.files,
|
||||
})
|
||||
}
|
||||
},
|
||||
[
|
||||
backend.type,
|
||||
directoryId,
|
||||
/* should not change */ logger,
|
||||
/* should never change */ dispatchFileListEvent,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="inline-flex">
|
||||
{string.capitalizeFirst(ASSET_TYPE_NAME_PLURAL)}
|
||||
<input
|
||||
type="file"
|
||||
id="files_table_upload_files_input"
|
||||
name="files_table_upload_files_input"
|
||||
multiple
|
||||
className="w-0 h-0"
|
||||
onInput={uploadFiles}
|
||||
/>
|
||||
<label htmlFor="files_table_upload_files_input" className="cursor-pointer mx-1">
|
||||
<img src={PlusIcon} />
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ================
|
||||
// === FileName ===
|
||||
// ================
|
||||
|
||||
/** Props for a {@link FileName}. */
|
||||
interface InternalFileNameProps
|
||||
extends tableColumn.TableColumnProps<backendModule.FileAsset, FilesTableState, FileRowState> {}
|
||||
|
||||
/** The icon and name of a specific file asset. */
|
||||
function FileName(props: InternalFileNameProps) {
|
||||
const { item, setItem, selected, setRowState } = props
|
||||
|
||||
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
|
||||
// context menu entry should be re-added.
|
||||
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
|
||||
const doRename = async () => {
|
||||
return await Promise.resolve(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex text-left items-center align-middle whitespace-nowrap"
|
||||
onClick={event => {
|
||||
if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
(selected ||
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.editName,
|
||||
event
|
||||
))
|
||||
) {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: true,
|
||||
}))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={fileInfo.fileIcon()} />
|
||||
<EditableSpan
|
||||
editable={false}
|
||||
onSubmit={async newTitle => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: false,
|
||||
}))
|
||||
if (newTitle !== item.title) {
|
||||
const oldTitle = item.title
|
||||
setItem(oldItem => ({ ...oldItem, title: newTitle }))
|
||||
try {
|
||||
await doRename(/* newTitle */)
|
||||
} catch {
|
||||
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: false,
|
||||
}))
|
||||
}}
|
||||
className="bg-transparent grow px-2"
|
||||
>
|
||||
{item.title}
|
||||
</EditableSpan>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === FileRowContextMenu ===
|
||||
// ==========================
|
||||
|
||||
/** Props for a {@link FileRowContextMenu}. */
|
||||
interface InternalDirectoryRowContextMenuProps {
|
||||
innerProps: tableRow.TableRowInnerProps<
|
||||
backendModule.FileAsset,
|
||||
backendModule.FileId,
|
||||
FileRowState
|
||||
>
|
||||
event: React.MouseEvent
|
||||
doDelete: () => Promise<void>
|
||||
}
|
||||
|
||||
/** The context menu for a row of a {@link FilesTable}. */
|
||||
function FileRowContextMenu(props: InternalDirectoryRowContextMenuProps) {
|
||||
const {
|
||||
innerProps: { item },
|
||||
event,
|
||||
doDelete,
|
||||
} = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
<ContextMenu key={item.id} event={event}>
|
||||
{/*<ContextMenuEntry disabled onClick={doCopy}>
|
||||
Copy
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry disabled onClick={doCut}>
|
||||
Cut
|
||||
</ContextMenuEntry>*/}
|
||||
<ContextMenuEntry
|
||||
onClick={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<span className="text-red-700">Delete</span>
|
||||
</ContextMenuEntry>
|
||||
{/*<ContextMenuEntry disabled onClick={doDownload}>
|
||||
Download
|
||||
</ContextMenuEntry>*/}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// ===============
|
||||
// === FileRow ===
|
||||
// ===============
|
||||
|
||||
/** A row in a {@link FilesTable}. */
|
||||
function FileRow(
|
||||
props: tableRow.TableRowProps<
|
||||
backendModule.FileAsset,
|
||||
backendModule.FileId,
|
||||
FilesTableState,
|
||||
FileRowState
|
||||
>
|
||||
) {
|
||||
const {
|
||||
keyProp: key,
|
||||
item: rawItem,
|
||||
state: { fileEvent, dispatchFileListEvent, markItemAsHidden, markItemAsVisible },
|
||||
} = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const [status, setStatus] = React.useState(presence.Presence.present)
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(rawItem)
|
||||
}, [rawItem])
|
||||
|
||||
const doDelete = async () => {
|
||||
if (backend.type !== backendModule.BackendType.local) {
|
||||
setStatus(presence.Presence.deleting)
|
||||
markItemAsHidden(key)
|
||||
try {
|
||||
await backend.deleteFile(item.id, item.title)
|
||||
dispatchFileListEvent({
|
||||
type: fileListEventModule.FileListEventType.delete,
|
||||
fileId: key,
|
||||
})
|
||||
} catch (error) {
|
||||
setStatus(presence.Presence.present)
|
||||
markItemAsVisible(key)
|
||||
const message = `Unable to delete file: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hooks.useEventHandler(fileEvent, async event => {
|
||||
switch (event.type) {
|
||||
case fileEventModule.FileEventType.createMultiple: {
|
||||
const file = event.files.get(key)
|
||||
if (file != null) {
|
||||
if (backend.type !== backendModule.BackendType.remote) {
|
||||
const message = 'Files cannot be uploaded on the local backend.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else {
|
||||
setStatus(presence.Presence.inserting)
|
||||
try {
|
||||
const createdFile = await backend.uploadFile(
|
||||
{
|
||||
fileId: null,
|
||||
fileName: item.title,
|
||||
parentDirectoryId: item.parentId,
|
||||
},
|
||||
file
|
||||
)
|
||||
setStatus(presence.Presence.present)
|
||||
const newItem: backendModule.FileAsset = {
|
||||
...item,
|
||||
...createdFile,
|
||||
}
|
||||
setItem(newItem)
|
||||
} catch (error) {
|
||||
dispatchFileListEvent({
|
||||
type: fileListEventModule.FileListEventType.delete,
|
||||
fileId: key,
|
||||
})
|
||||
const message = `Error creating new file: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case fileEventModule.FileEventType.deleteMultiple: {
|
||||
if (event.fileIds.has(key)) {
|
||||
await doDelete()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={presence.CLASS_NAME[status]}
|
||||
{...props}
|
||||
onContextMenu={(innerProps, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<FileRowContextMenu innerProps={innerProps} event={event} doDelete={doDelete} />
|
||||
)
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================
|
||||
// === FilesTable ===
|
||||
// ==================
|
||||
|
||||
/** State passed through from a {@link FilesTable} to every cell. */
|
||||
interface FilesTableState {
|
||||
fileEvent: fileEventModule.FileEvent | null
|
||||
dispatchFileListEvent: (event: fileListEventModule.FileListEvent) => void
|
||||
markItemAsHidden: (key: string) => void
|
||||
markItemAsVisible: (key: string) => void
|
||||
}
|
||||
|
||||
/** Data associated with a {@link FileRow}, used for rendering. */
|
||||
interface FileRowState {
|
||||
isEditingName: boolean
|
||||
}
|
||||
|
||||
/** The default {@link FileRowState} associated with a {@link FileRow}. */
|
||||
const INITIAL_ROW_STATE: FileRowState = Object.freeze({
|
||||
isEditingName: false,
|
||||
})
|
||||
|
||||
/** Props for a {@link FilesTable}. */
|
||||
export interface FilesTableProps {
|
||||
directoryId: backendModule.DirectoryId | null
|
||||
items: backendModule.FileAsset[]
|
||||
filter: ((item: backendModule.FileAsset) => boolean) | null
|
||||
isLoading: boolean
|
||||
columnDisplayMode: columnModule.ColumnDisplayMode
|
||||
fileListEvent: fileListEventModule.FileListEvent | null
|
||||
dispatchFileListEvent: (projectListEvent: fileListEventModule.FileListEvent) => void
|
||||
}
|
||||
|
||||
/** The table of file assets. */
|
||||
function FilesTable(props: FilesTableProps) {
|
||||
const {
|
||||
directoryId,
|
||||
items: rawItems,
|
||||
filter,
|
||||
isLoading,
|
||||
columnDisplayMode,
|
||||
fileListEvent,
|
||||
dispatchFileListEvent,
|
||||
} = props
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [items, setItems] = React.useState(rawItems)
|
||||
const [fileEvent, dispatchFileEvent] = hooks.useEvent<fileEventModule.FileEvent>()
|
||||
|
||||
React.useEffect(() => {
|
||||
setItems(rawItems)
|
||||
}, [rawItems])
|
||||
|
||||
const visibleItems = React.useMemo(
|
||||
() => (filter != null ? items.filter(filter) : items),
|
||||
[items, filter]
|
||||
)
|
||||
|
||||
// === Tracking number of visually hidden items ===
|
||||
|
||||
const [shouldForceShowPlaceholder, setShouldForceShowPlaceholder] = React.useState(false)
|
||||
const keysOfHiddenItemsRef = React.useRef(new Set<string>())
|
||||
|
||||
const updateShouldForceShowPlaceholder = React.useCallback(() => {
|
||||
setShouldForceShowPlaceholder(
|
||||
visibleItems.every(item => keysOfHiddenItemsRef.current.has(item.id))
|
||||
)
|
||||
}, [visibleItems])
|
||||
|
||||
React.useEffect(updateShouldForceShowPlaceholder, [updateShouldForceShowPlaceholder])
|
||||
|
||||
React.useEffect(() => {
|
||||
const oldKeys = keysOfHiddenItemsRef.current
|
||||
keysOfHiddenItemsRef.current = new Set(
|
||||
items.map(backendModule.getAssetId).filter(key => oldKeys.has(key))
|
||||
)
|
||||
}, [items])
|
||||
|
||||
const markItemAsHidden = React.useCallback(
|
||||
(key: string) => {
|
||||
keysOfHiddenItemsRef.current.add(key)
|
||||
updateShouldForceShowPlaceholder()
|
||||
},
|
||||
[updateShouldForceShowPlaceholder]
|
||||
)
|
||||
|
||||
const markItemAsVisible = React.useCallback(
|
||||
(key: string) => {
|
||||
keysOfHiddenItemsRef.current.delete(key)
|
||||
updateShouldForceShowPlaceholder()
|
||||
},
|
||||
[updateShouldForceShowPlaceholder]
|
||||
)
|
||||
|
||||
// === End tracking number of visually hidden items ===
|
||||
|
||||
hooks.useEventHandler(fileListEvent, event => {
|
||||
switch (event.type) {
|
||||
case fileListEventModule.FileListEventType.uploadMultiple: {
|
||||
const placeholderItems: backendModule.FileAsset[] = Array.from(event.files)
|
||||
.reverse()
|
||||
.map(file => ({
|
||||
type: backendModule.AssetType.file,
|
||||
id: backendModule.FileId(uniqueString.uniqueString()),
|
||||
title: file.name,
|
||||
parentId: directoryId ?? backendModule.DirectoryId(''),
|
||||
permissions: permissions.tryGetSingletonOwnerPermission(organization),
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
projectState: null,
|
||||
}))
|
||||
setItems(oldItems => [...placeholderItems, ...oldItems])
|
||||
dispatchFileEvent({
|
||||
type: fileEventModule.FileEventType.createMultiple,
|
||||
files: new Map(
|
||||
placeholderItems.map((placeholderItem, i) => [
|
||||
placeholderItem.id,
|
||||
// This is SAFE, as `placeholderItems` is created using a map on
|
||||
// `event.files`.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
event.files[i]!,
|
||||
])
|
||||
),
|
||||
})
|
||||
break
|
||||
}
|
||||
case fileListEventModule.FileListEventType.delete: {
|
||||
setItems(oldItems => oldItems.filter(item => item.id !== event.fileId))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const state = React.useMemo(
|
||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||
(): FilesTableState => ({
|
||||
fileEvent,
|
||||
dispatchFileListEvent,
|
||||
markItemAsHidden,
|
||||
markItemAsVisible,
|
||||
}),
|
||||
[fileEvent, dispatchFileListEvent, markItemAsHidden, markItemAsVisible]
|
||||
)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
return <></>
|
||||
} else {
|
||||
return (
|
||||
<Table<backendModule.FileAsset, backendModule.FileId, FilesTableState, FileRowState>
|
||||
rowComponent={FileRow}
|
||||
items={visibleItems}
|
||||
isLoading={isLoading}
|
||||
state={state}
|
||||
initialRowState={INITIAL_ROW_STATE}
|
||||
getKey={backendModule.getAssetId}
|
||||
placeholder={filter != null ? PLACEHOLDER_WITH_QUERY : PLACEHOLDER_WITHOUT_QUERY}
|
||||
forceShowPlaceholder={shouldForceShowPlaceholder}
|
||||
columns={columnModule.columnsFor(columnDisplayMode, backend.type).map(column =>
|
||||
column === columnModule.Column.name
|
||||
? {
|
||||
id: column,
|
||||
className: columnModule.COLUMN_CSS_CLASS[column],
|
||||
heading: (
|
||||
<FileNameHeading
|
||||
directoryId={directoryId}
|
||||
dispatchFileListEvent={dispatchFileListEvent}
|
||||
/>
|
||||
),
|
||||
render: FileName,
|
||||
}
|
||||
: {
|
||||
id: column,
|
||||
className: columnModule.COLUMN_CSS_CLASS[column],
|
||||
heading: <>{columnModule.COLUMN_NAME[column]}</>,
|
||||
render: columnModule.COLUMN_RENDERER[column],
|
||||
}
|
||||
)}
|
||||
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doDeleteAll = () => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={
|
||||
`${selectedKeys.size} selected ` + ASSET_TYPE_NAME_PLURAL
|
||||
}
|
||||
doDelete={() => {
|
||||
dispatchFileEvent({
|
||||
type: fileEventModule.FileEventType.deleteMultiple,
|
||||
fileIds: selectedKeys,
|
||||
})
|
||||
setSelectedKeys(new Set())
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const pluralized = pluralize(selectedKeys.size)
|
||||
setModal(
|
||||
<ContextMenu key={uniqueString.uniqueString()} event={event}>
|
||||
{/*<ContextMenuEntry disabled onClick={doCopyAll}>
|
||||
Copy {files.size} {pluralized}
|
||||
</ContextMenuEntry>
|
||||
<ContextMenuEntry disabled onClick={doCutAll}>
|
||||
Cut {files.size} {pluralized}
|
||||
</ContextMenuEntry>*/}
|
||||
<ContextMenuEntry onClick={doDeleteAll}>
|
||||
<span className="text-red-700">
|
||||
Delete {selectedKeys.size} {pluralized}
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default FilesTable
|
@ -33,11 +33,22 @@ function Ide(props: IdeProps) {
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { accessToken } = auth.useNonPartialUserSession()
|
||||
|
||||
let hasEffectRun = false
|
||||
|
||||
React.useEffect(() => {
|
||||
// This is a hack to work around the IDE WASM not playing nicely with React Strict Mode.
|
||||
// This is unavoidable as the WASM must fully set up to be able to properly drop its assets,
|
||||
// but React re-executes this side-effect faster tha the WASM can do so.
|
||||
if (hasEffectRun) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
hasEffectRun = true
|
||||
void (async () => {
|
||||
const ideVersion =
|
||||
project.ideVersion?.value ??
|
||||
('listVersions' in backend
|
||||
(backend.type === backendModule.BackendType.remote
|
||||
? await backend.listVersions({
|
||||
versionType: backendModule.VersionType.ide,
|
||||
default: true,
|
||||
@ -45,7 +56,7 @@ function Ide(props: IdeProps) {
|
||||
: null)?.[0].number.value
|
||||
const engineVersion =
|
||||
project.engineVersion?.value ??
|
||||
('listVersions' in backend
|
||||
(backend.type === backendModule.BackendType.remote
|
||||
? await backend.listVersions({
|
||||
versionType: backendModule.VersionType.backend,
|
||||
default: true,
|
||||
@ -62,14 +73,17 @@ function Ide(props: IdeProps) {
|
||||
} else if (binaryAddress == null) {
|
||||
throw new Error("Could not get the address of the project's binary endpoint.")
|
||||
} else {
|
||||
const assetsRoot = (() => {
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.remote:
|
||||
return `${IDE_CDN_URL}/${ideVersion}/`
|
||||
case backendModule.BackendType.local:
|
||||
return ''
|
||||
let assetsRoot: string
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.remote: {
|
||||
assetsRoot = `${IDE_CDN_URL}/${ideVersion}/`
|
||||
break
|
||||
}
|
||||
})()
|
||||
case backendModule.BackendType.local: {
|
||||
assetsRoot = ''
|
||||
break
|
||||
}
|
||||
}
|
||||
const runNewProject = async () => {
|
||||
const engineConfig =
|
||||
backend.type === backendModule.BackendType.remote
|
||||
@ -130,9 +144,9 @@ function Ide(props: IdeProps) {
|
||||
}
|
||||
})()
|
||||
// The backend MUST NOT be a dependency, since the IDE should only be recreated when a new
|
||||
// project is opened.
|
||||
// project is opened, and a local project does not exist on the cloud and vice versa.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [appRunner, project])
|
||||
}, [project, /* should never change */ appRunner])
|
||||
|
||||
return <></>
|
||||
}
|
||||
|
@ -5,17 +5,20 @@ import * as React from 'react'
|
||||
// === Input ===
|
||||
// =============
|
||||
|
||||
/** Props for an `<input>` HTML element/ */
|
||||
type InputAttributes = JSX.IntrinsicElements['input']
|
||||
|
||||
/** Props for an {@link Input}. */
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
export interface InputProps extends InputAttributes {
|
||||
setValue: (value: string) => void
|
||||
}
|
||||
|
||||
/** A component for authentication from inputs, with preset styles. */
|
||||
function Input(props: InputProps) {
|
||||
const { setValue, ...passThrough } = props
|
||||
const { setValue, ...passthroughProps } = props
|
||||
return (
|
||||
<input
|
||||
{...passThrough}
|
||||
{...passthroughProps}
|
||||
onChange={event => {
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
|
@ -9,7 +9,6 @@ import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as errorModule from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as newtype from '../../newtype'
|
||||
|
||||
import Autocomplete from './autocomplete'
|
||||
import Modal from './modal'
|
||||
@ -62,9 +61,22 @@ const ACTION_CSS_CLASS: Record<ManagePermissionsAction, string> = {
|
||||
/** Props for a {@link ManagePermissionsModal}. */
|
||||
export interface ManagePermissionsModalProps {
|
||||
asset: backendModule.Asset
|
||||
initialPermissions: backendModule.PermissionAction[]
|
||||
emailsOfUsersWithPermission: Set<backendModule.EmailAddress>
|
||||
/* If present, the user cannot be changed. */
|
||||
user?: backendModule.User
|
||||
onSuccess: () => void
|
||||
onSubmit: (
|
||||
users: backendModule.SimpleUser[],
|
||||
permissions: backendModule.PermissionAction[]
|
||||
) => void
|
||||
onSuccess?: (
|
||||
users: backendModule.SimpleUser[],
|
||||
permissions: backendModule.PermissionAction[]
|
||||
) => void
|
||||
onFailure?: (
|
||||
users: backendModule.SimpleUser[],
|
||||
permissions: backendModule.PermissionAction[]
|
||||
) => void
|
||||
eventTarget: HTMLElement
|
||||
}
|
||||
|
||||
@ -72,7 +84,16 @@ export interface ManagePermissionsModalProps {
|
||||
* @throws {Error} when the current backend is the local backend, or when the user is offline.
|
||||
* This should never happen, as this modal should not be accessible in either case. */
|
||||
export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
|
||||
const { asset, user: rawUser, onSuccess, eventTarget } = props
|
||||
const {
|
||||
asset,
|
||||
initialPermissions,
|
||||
emailsOfUsersWithPermission,
|
||||
user: rawUser,
|
||||
onSubmit: rawOnSubmit,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
eventTarget,
|
||||
} = props
|
||||
const { organization } = auth.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
@ -90,12 +111,13 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
|
||||
|
||||
const user = React.useMemo(() => {
|
||||
const firstEmail = emails[0]
|
||||
if (firstEmail == null || emails.length !== 1) {
|
||||
if (rawUser != null) {
|
||||
return rawUser
|
||||
} else if (firstEmail != null && emails.length === 1) {
|
||||
return asset.permissions?.find(permission => permission.user.user_email === firstEmail)
|
||||
?.user
|
||||
} else {
|
||||
return (asset.permissions ?? []).find(
|
||||
permission => permission.user.user_email === firstEmail
|
||||
)?.user
|
||||
return null
|
||||
}
|
||||
}, [rawUser, emails, /* should never change */ asset.permissions])
|
||||
|
||||
@ -108,32 +130,6 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
|
||||
? ManagePermissionsAction.inviteToOrganization
|
||||
: ManagePermissionsAction.share
|
||||
|
||||
/** Overridden by the user's permissions only if it is not empty. */
|
||||
const initialPermissions = React.useMemo(
|
||||
() =>
|
||||
permissions.size !== 0
|
||||
? null
|
||||
: user != null
|
||||
? new Set(
|
||||
(asset.permissions ?? [])
|
||||
.filter(
|
||||
assetPermission => assetPermission.user.user_email === user.user_email
|
||||
)
|
||||
.map(userPermission => userPermission.permission)
|
||||
)
|
||||
: null,
|
||||
// `permissions` is NOT a dependency; this is an expensive computation so it is only used
|
||||
// to determine whether the computation should be avoided completely.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[user, /* should never change */ asset.permissions]
|
||||
)
|
||||
|
||||
const emailsOfUsersWithPermission = React.useMemo(
|
||||
() =>
|
||||
new Set(asset.permissions?.map(userPermission => userPermission.user.user_email) ?? []),
|
||||
[/* should never change */ asset.permissions]
|
||||
)
|
||||
|
||||
const userEmailRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local || organization == null) {
|
||||
@ -176,7 +172,7 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
|
||||
setEmails(newEmails)
|
||||
const lowercaseEmail =
|
||||
newEmails[0] != null
|
||||
? newtype.asNewtype<backendModule.EmailAddress>(newEmails[0].toLowerCase())
|
||||
? backendModule.EmailAddress(newEmails[0].toLowerCase())
|
||||
: null
|
||||
if (
|
||||
userEmailRef.current?.validity.valid === true &&
|
||||
@ -235,21 +231,24 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
|
||||
try {
|
||||
await backend.inviteUser({
|
||||
organizationId: organization.id,
|
||||
userEmail: newtype.asNewtype<backendModule.EmailAddress>(firstEmail),
|
||||
userEmail: backendModule.EmailAddress(firstEmail),
|
||||
})
|
||||
} catch (error) {
|
||||
toast.error(errorModule.tryGetMessage(error) ?? 'Unknown error.')
|
||||
}
|
||||
} else if (finalUsers.length !== 0) {
|
||||
unsetModal()
|
||||
const permissionsArray = [...permissions]
|
||||
try {
|
||||
rawOnSubmit(finalUsers, permissionsArray)
|
||||
await backend.createPermission({
|
||||
userSubjects: finalUsers.map(finalUser => finalUser.id),
|
||||
resourceId: asset.id,
|
||||
actions: [...permissions],
|
||||
actions: permissionsArray,
|
||||
})
|
||||
onSuccess()
|
||||
onSuccess?.(finalUsers, permissionsArray)
|
||||
} catch {
|
||||
onFailure?.(finalUsers, permissionsArray)
|
||||
const finalUserEmails = finalUsers.map(finalUser => `'${finalUser.email}'`)
|
||||
toast.error(`Unable to set permissions of ${finalUserEmails.join(', ')}.`)
|
||||
}
|
||||
@ -263,7 +262,9 @@ export function ManagePermissionsModal(props: ManagePermissionsModalProps) {
|
||||
organization.id,
|
||||
users,
|
||||
user,
|
||||
rawOnSubmit,
|
||||
onSuccess,
|
||||
onFailure,
|
||||
/* should never change */ unsetModal,
|
||||
/* should never change */ backend,
|
||||
/* should never change */ rawUser,
|
||||
|
@ -30,7 +30,7 @@ const PERMISSIONS = [
|
||||
/** Props for a {@link PermissionSelector}. */
|
||||
export interface PermissionSelectorProps {
|
||||
/** If this prop changes, the internal state will be updated too. */
|
||||
initialPermissions?: Set<backend.PermissionAction> | null
|
||||
initialPermissions?: backend.PermissionAction[] | null
|
||||
className?: string
|
||||
permissionClassName?: string
|
||||
onChange: (permissions: Set<backend.PermissionAction>) => void
|
||||
@ -38,22 +38,18 @@ export interface PermissionSelectorProps {
|
||||
|
||||
/** A horizontal selector for all possible permissions. */
|
||||
function PermissionSelector(props: PermissionSelectorProps) {
|
||||
const {
|
||||
initialPermissions: rawInitialPermissions,
|
||||
className,
|
||||
permissionClassName,
|
||||
onChange,
|
||||
} = props
|
||||
const { initialPermissions, className, permissionClassName, onChange } = props
|
||||
const [permissions, setPermissions] = React.useState(() => new Set<backend.PermissionAction>())
|
||||
|
||||
React.useEffect(() => {
|
||||
if (rawInitialPermissions != null) {
|
||||
setPermissions(rawInitialPermissions)
|
||||
onChange(rawInitialPermissions)
|
||||
if (initialPermissions != null) {
|
||||
const initialPermissionsSet = new Set(initialPermissions)
|
||||
setPermissions(initialPermissionsSet)
|
||||
onChange(initialPermissionsSet)
|
||||
}
|
||||
// `onChange` is NOT a dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [rawInitialPermissions])
|
||||
}, [initialPermissions])
|
||||
|
||||
return (
|
||||
<div className={`flex justify-items-center ${className ?? ''}`}>
|
||||
|
@ -1,424 +0,0 @@
|
||||
/** @file An interactive button displaying the status of a project. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import ArrowUpIcon from 'enso-assets/arrow_up.svg'
|
||||
import PlayIcon from 'enso-assets/play.svg'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as localBackend from '../localBackend'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import * as svg from '../../components/svg'
|
||||
|
||||
import * as spinner from './spinner'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Data associated with a project, used for rendering.
|
||||
* FIXME[sb]: This is a hack that is required because each row does not carry its own extra state.
|
||||
* It will be obsoleted by the implementation in https://github.com/enso-org/enso/pull/6546. */
|
||||
export interface ProjectData {
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
/** Possible types of project state change. */
|
||||
export enum ProjectEventType {
|
||||
open = 'open',
|
||||
cancelOpeningAll = 'cancelOpeningAll',
|
||||
}
|
||||
|
||||
/** Properties common to all project state change events. */
|
||||
interface ProjectBaseEvent<Type extends ProjectEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** Requests the specified project to be opened. */
|
||||
export interface ProjectOpenEvent extends ProjectBaseEvent<ProjectEventType.open> {
|
||||
/** This must be a name because it may be specified by name on the command line.
|
||||
* Note that this will not work properly with the cloud backend if there are multiple projects
|
||||
* with the same name. */
|
||||
projectId: backendModule.ProjectId
|
||||
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
|
||||
}
|
||||
|
||||
/** Requests the specified project to be opened. */
|
||||
export interface ProjectCancelOpeningAllEvent
|
||||
extends ProjectBaseEvent<ProjectEventType.cancelOpeningAll> {}
|
||||
|
||||
/** Every possible type of project event. */
|
||||
export type ProjectEvent = ProjectCancelOpeningAllEvent | ProjectOpenEvent
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The default {@link ProjectData} associated with a {@link backendModule.Project}. */
|
||||
export const DEFAULT_PROJECT_DATA: ProjectData = Object.freeze({
|
||||
isRunning: false,
|
||||
})
|
||||
const LOADING_MESSAGE =
|
||||
'Your environment is being created. It will take some time, please be patient.'
|
||||
/** The interval between requests checking whether the IDE is ready. */
|
||||
const CHECK_STATUS_INTERVAL_MS = 5000
|
||||
/** The interval between requests checking whether the VM is ready. */
|
||||
const CHECK_RESOURCES_INTERVAL_MS = 1000
|
||||
/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
|
||||
* when using the remote backend. */
|
||||
const REMOTE_SPINNER_STATE: Record<backendModule.ProjectState, spinner.SpinnerState> = {
|
||||
[backendModule.ProjectState.closed]: spinner.SpinnerState.initial,
|
||||
[backendModule.ProjectState.created]: spinner.SpinnerState.initial,
|
||||
[backendModule.ProjectState.new]: spinner.SpinnerState.initial,
|
||||
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingSlow,
|
||||
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
|
||||
}
|
||||
/** The corresponding {@link SpinnerState} for each {@link backendModule.ProjectState},
|
||||
* when using the local backend. */
|
||||
const LOCAL_SPINNER_STATE: Record<backendModule.ProjectState, spinner.SpinnerState> = {
|
||||
[backendModule.ProjectState.closed]: spinner.SpinnerState.initial,
|
||||
[backendModule.ProjectState.created]: spinner.SpinnerState.initial,
|
||||
[backendModule.ProjectState.new]: spinner.SpinnerState.initial,
|
||||
[backendModule.ProjectState.openInProgress]: spinner.SpinnerState.loadingMedium,
|
||||
[backendModule.ProjectState.opened]: spinner.SpinnerState.done,
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link ProjectActionButton}. */
|
||||
export interface ProjectActionButtonProps {
|
||||
project: backendModule.Asset<backendModule.AssetType.project>
|
||||
projectData: ProjectData
|
||||
setProjectData: React.Dispatch<React.SetStateAction<ProjectData>>
|
||||
appRunner: AppRunner | null
|
||||
event: ProjectEvent | null
|
||||
/** Called when the project is opened via the {@link ProjectActionButton}. */
|
||||
doOpenManually: () => void
|
||||
onClose: () => void
|
||||
openIde: () => void
|
||||
doRefresh: () => void
|
||||
}
|
||||
|
||||
/** An interactive button displaying the status of a project. */
|
||||
function ProjectActionButton(props: ProjectActionButtonProps) {
|
||||
const {
|
||||
project,
|
||||
setProjectData,
|
||||
event,
|
||||
appRunner,
|
||||
doOpenManually,
|
||||
onClose,
|
||||
openIde,
|
||||
doRefresh,
|
||||
} = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const shouldCheckIfActuallyOpen =
|
||||
backend.type === backendModule.BackendType.remote &&
|
||||
(project.projectState.type === backendModule.ProjectState.opened ||
|
||||
project.projectState.type === backendModule.ProjectState.openInProgress)
|
||||
|
||||
const [state, setState] = React.useState(() => {
|
||||
if (shouldCheckIfActuallyOpen) {
|
||||
return backendModule.ProjectState.created
|
||||
} else {
|
||||
return project.projectState.type
|
||||
}
|
||||
})
|
||||
const [isCheckingStatus, setIsCheckingStatus] = React.useState(false)
|
||||
const [isCheckingResources, setIsCheckingResources] = React.useState(false)
|
||||
const [spinnerState, setSpinnerState] = React.useState(REMOTE_SPINNER_STATE[state])
|
||||
const [onSpinnerStateChange, setOnSpinnerStateChange] = React.useState<
|
||||
((state: spinner.SpinnerState | null) => void) | null
|
||||
>(null)
|
||||
const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
|
||||
const [toastId, setToastId] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toastId != null) {
|
||||
return () => {
|
||||
toast.dismiss(toastId)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}, [toastId])
|
||||
|
||||
React.useEffect(() => {
|
||||
// Ensure that the previous spinner state is visible for at least one frame.
|
||||
requestAnimationFrame(() => {
|
||||
const newSpinnerState =
|
||||
backend.type === backendModule.BackendType.remote
|
||||
? REMOTE_SPINNER_STATE[state]
|
||||
: LOCAL_SPINNER_STATE[state]
|
||||
setSpinnerState(newSpinnerState)
|
||||
onSpinnerStateChange?.(
|
||||
state === backendModule.ProjectState.closed ? null : newSpinnerState
|
||||
)
|
||||
})
|
||||
}, [state, onSpinnerStateChange, backend.type])
|
||||
|
||||
React.useEffect(() => {
|
||||
onSpinnerStateChange?.(spinner.SpinnerState.initial)
|
||||
}, [onSpinnerStateChange])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toastId != null && state !== backendModule.ProjectState.openInProgress) {
|
||||
toast.dismiss(toastId)
|
||||
}
|
||||
}, [state, toastId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldCheckIfActuallyOpen) {
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
setIsCheckingResources(true)
|
||||
}
|
||||
}, [shouldCheckIfActuallyOpen])
|
||||
|
||||
const openProject = React.useCallback(async () => {
|
||||
setState(backendModule.ProjectState.openInProgress)
|
||||
try {
|
||||
switch (backend.type) {
|
||||
case backendModule.BackendType.remote:
|
||||
setToastId(toast.loading(LOADING_MESSAGE))
|
||||
await backend.openProject(project.id)
|
||||
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true }))
|
||||
doRefresh()
|
||||
setIsCheckingStatus(true)
|
||||
break
|
||||
case backendModule.BackendType.local:
|
||||
await backend.openProject(project.id)
|
||||
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: true }))
|
||||
setState(oldState => {
|
||||
if (oldState === backendModule.ProjectState.openInProgress) {
|
||||
setTimeout(() => {
|
||||
doRefresh()
|
||||
}, 0)
|
||||
return backendModule.ProjectState.opened
|
||||
} else {
|
||||
return oldState
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
setIsCheckingStatus(false)
|
||||
setIsCheckingResources(false)
|
||||
toast.error(`Error opening project '${project.title}'.`)
|
||||
setState(backendModule.ProjectState.closed)
|
||||
}
|
||||
}, [backend, doRefresh, project.id, project.title, setProjectData])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (event != null) {
|
||||
switch (event.type) {
|
||||
case ProjectEventType.open: {
|
||||
if (event.projectId !== project.id) {
|
||||
setShouldOpenWhenReady(false)
|
||||
if (onSpinnerStateChange === event.onSpinnerStateChange) {
|
||||
setOnSpinnerStateChange(null)
|
||||
}
|
||||
} else {
|
||||
setShouldOpenWhenReady(true)
|
||||
setOnSpinnerStateChange(() => event.onSpinnerStateChange)
|
||||
void openProject()
|
||||
}
|
||||
break
|
||||
}
|
||||
case ProjectEventType.cancelOpeningAll: {
|
||||
setShouldOpenWhenReady(false)
|
||||
onSpinnerStateChange?.(null)
|
||||
setOnSpinnerStateChange(null)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// This effect MUST run if and only if `event` changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [event])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) {
|
||||
openIde()
|
||||
setShouldOpenWhenReady(false)
|
||||
}
|
||||
}, [openIde, shouldOpenWhenReady, state])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
backend.type === backendModule.BackendType.local &&
|
||||
project.id !== localBackend.LocalBackend.currentlyOpeningProjectId
|
||||
) {
|
||||
setState(backendModule.ProjectState.closed)
|
||||
}
|
||||
// `localBackend.LocalBackend.currentlyOpeningProjectId` is a mutable outer scope value.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [project, state, backend.type, localBackend.LocalBackend.currentlyOpeningProjectId])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCheckingStatus) {
|
||||
return
|
||||
} else {
|
||||
let handle: number | null = null
|
||||
let continuePolling = true
|
||||
let previousTimestamp = 0
|
||||
const checkProjectStatus = async () => {
|
||||
try {
|
||||
const response = await backend.getProjectDetails(project.id)
|
||||
handle = null
|
||||
if (
|
||||
continuePolling &&
|
||||
response.state.type === backendModule.ProjectState.opened
|
||||
) {
|
||||
continuePolling = false
|
||||
setIsCheckingStatus(false)
|
||||
setIsCheckingResources(true)
|
||||
}
|
||||
} finally {
|
||||
if (continuePolling) {
|
||||
const nowTimestamp = Number(new Date())
|
||||
const delay = CHECK_STATUS_INTERVAL_MS - (nowTimestamp - previousTimestamp)
|
||||
previousTimestamp = nowTimestamp
|
||||
handle = window.setTimeout(
|
||||
() => void checkProjectStatus(),
|
||||
Math.max(0, delay)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
void checkProjectStatus()
|
||||
return () => {
|
||||
continuePolling = false
|
||||
if (handle != null) {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [backend, isCheckingStatus, project.id])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCheckingResources) {
|
||||
return
|
||||
} else {
|
||||
let handle: number | null = null
|
||||
let continuePolling = true
|
||||
let previousTimestamp = 0
|
||||
const checkProjectResources = async () => {
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setIsCheckingResources(false)
|
||||
} else {
|
||||
try {
|
||||
// This call will error if the VM is not ready yet.
|
||||
await backend.checkResources(project.id)
|
||||
handle = null
|
||||
if (continuePolling) {
|
||||
continuePolling = false
|
||||
setState(backendModule.ProjectState.opened)
|
||||
setIsCheckingResources(false)
|
||||
}
|
||||
} catch {
|
||||
if (continuePolling) {
|
||||
const nowTimestamp = Number(new Date())
|
||||
const delay =
|
||||
CHECK_RESOURCES_INTERVAL_MS - (nowTimestamp - previousTimestamp)
|
||||
previousTimestamp = nowTimestamp
|
||||
handle = window.setTimeout(
|
||||
() => void checkProjectResources(),
|
||||
Math.max(0, delay)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void checkProjectResources()
|
||||
return () => {
|
||||
continuePolling = false
|
||||
if (handle != null) {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [backend, isCheckingResources, project.id])
|
||||
|
||||
const closeProject = async () => {
|
||||
onClose()
|
||||
setShouldOpenWhenReady(false)
|
||||
setState(backendModule.ProjectState.closed)
|
||||
onSpinnerStateChange?.(null)
|
||||
setOnSpinnerStateChange(null)
|
||||
appRunner?.stopApp()
|
||||
setIsCheckingStatus(false)
|
||||
setIsCheckingResources(false)
|
||||
try {
|
||||
await backend.closeProject(project.id)
|
||||
} finally {
|
||||
// This is not 100% correct, but it is better than never setting `isRunning` to `false`,
|
||||
// which would prevent the project from ever being deleted.
|
||||
setProjectData(oldProjectData => ({ ...oldProjectData, isRunning: false }))
|
||||
}
|
||||
}
|
||||
|
||||
switch (state) {
|
||||
case null:
|
||||
case backendModule.ProjectState.created:
|
||||
case backendModule.ProjectState.new:
|
||||
case backendModule.ProjectState.closed:
|
||||
return (
|
||||
<button
|
||||
className="w-6"
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
doOpenManually()
|
||||
}}
|
||||
>
|
||||
<img src={PlayIcon} />
|
||||
</button>
|
||||
)
|
||||
case backendModule.ProjectState.openInProgress:
|
||||
return (
|
||||
<button
|
||||
className="w-6"
|
||||
onClick={async clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
await closeProject()
|
||||
}}
|
||||
>
|
||||
<svg.StopIcon className={spinner.SPINNER_CSS_CLASSES[spinnerState]} />
|
||||
</button>
|
||||
)
|
||||
case backendModule.ProjectState.opened:
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="w-6"
|
||||
onClick={async clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
await closeProject()
|
||||
}}
|
||||
>
|
||||
<svg.StopIcon className={spinner.SPINNER_CSS_CLASSES[spinnerState]} />
|
||||
</button>
|
||||
<button
|
||||
className="w-6"
|
||||
onClick={clickEvent => {
|
||||
clickEvent.stopPropagation()
|
||||
unsetModal()
|
||||
openIde()
|
||||
}}
|
||||
>
|
||||
<img src={ArrowUpIcon} />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default ProjectActionButton
|
File diff suppressed because it is too large
Load Diff
@ -1,106 +0,0 @@
|
||||
/** @file Modal for confirming delete of any type of asset. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import CloseIcon from 'enso-assets/close.svg'
|
||||
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import Input from './input'
|
||||
import Modal from './modal'
|
||||
|
||||
// ===================
|
||||
// === RenameModal ===
|
||||
// ===================
|
||||
|
||||
/** Props for a {@link RenameModal}. */
|
||||
export interface RenameModalProps {
|
||||
assetType: string
|
||||
name: string
|
||||
namePattern?: string
|
||||
title?: string
|
||||
doRename: (newName: string) => Promise<void>
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
/** A modal for renaming an asset. */
|
||||
function RenameModal(props: RenameModalProps) {
|
||||
const { assetType, name, namePattern, title, doRename, onComplete } = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [newName, setNewName] = React.useState<string | null>(null)
|
||||
|
||||
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (newName == null) {
|
||||
toast.error('Please provide a new name.')
|
||||
} else {
|
||||
unsetModal()
|
||||
try {
|
||||
await toast.promise(doRename(newName), {
|
||||
loading: `Renaming ${assetType} '${name}' to '${newName}'...`,
|
||||
success: `Renamed ${assetType} '${name}' to '${newName}'.`,
|
||||
// This is UNSAFE, as the original function's parameter is of type `any`.
|
||||
error: (promiseError: Error) =>
|
||||
`Error renaming ${assetType} '${name}' to '${newName}': ${promiseError.message}`,
|
||||
})
|
||||
} finally {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal centered className="bg-opacity-90">
|
||||
<form
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
className="relative bg-white shadow-soft rounded-lg w-96 p-2"
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Padding. */}
|
||||
<div className="grow" />
|
||||
<button type="button" onClick={unsetModal}>
|
||||
<img src={CloseIcon} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="m-2">
|
||||
What do you want to rename the {assetType} ‘{name}’ to?
|
||||
</div>
|
||||
<div className="m-2">
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
// Never disabled, as disabling unfocuses the input.
|
||||
id="renamed_asset_name"
|
||||
type="text"
|
||||
pattern={namePattern}
|
||||
title={title}
|
||||
className="border-primary bg-gray-200 rounded-full w-full px-2"
|
||||
defaultValue={newName ?? name}
|
||||
setValue={setNewName}
|
||||
/>
|
||||
</div>
|
||||
<div className="m-1">
|
||||
<button
|
||||
type="submit"
|
||||
className="hover:cursor-pointer inline-block text-white bg-blue-600 rounded-full px-4 py-1 m-1"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:cursor-pointer inline-block bg-gray-200 rounded-full px-4 py-1 m-1"
|
||||
onClick={unsetModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default RenameModal
|
@ -1,109 +0,0 @@
|
||||
/** @file Table that projects an object into each column. */
|
||||
import * as React from 'react'
|
||||
|
||||
import Spinner, * as spinner from './spinner'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The size of the loading spinner. */
|
||||
const LOADING_SPINNER_SIZE = 36
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Metadata describing how to render a column of the table. */
|
||||
export interface Column<T> {
|
||||
id: string
|
||||
heading: JSX.Element
|
||||
render: (item: T, index: number) => React.ReactNode
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Component ===
|
||||
// =================
|
||||
|
||||
/** Props for a {@link Rows}. */
|
||||
export interface RowsProps<T> {
|
||||
items: T[]
|
||||
getKey: (item: T) => string
|
||||
isLoading: boolean
|
||||
placeholder: JSX.Element
|
||||
columns: Column<T>[]
|
||||
onClick: (item: T, event: React.MouseEvent<HTMLTableRowElement>) => void
|
||||
onContextMenu: (item: T, event: React.MouseEvent<HTMLTableRowElement>) => void
|
||||
}
|
||||
|
||||
/** Table that projects an object into each column. */
|
||||
function Rows<T>(props: RowsProps<T>) {
|
||||
const { columns, items, isLoading, getKey, placeholder, onClick, onContextMenu } = props
|
||||
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
|
||||
|
||||
const headerRow = (
|
||||
<tr>
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={column.id}
|
||||
className="text-vs px-4 align-middle py-1 border-0 border-r whitespace-nowrap font-semibold text-left"
|
||||
>
|
||||
{column.heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
// Ensure the spinner stays in the "initial" state for at least one frame.
|
||||
requestAnimationFrame(() => {
|
||||
setSpinnerState(spinner.SpinnerState.loadingFast)
|
||||
})
|
||||
} else {
|
||||
setSpinnerState(spinner.SpinnerState.initial)
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
const itemRows = isLoading ? (
|
||||
<tr className="h-10">
|
||||
<td colSpan={columns.length}>
|
||||
<div className="grid justify-around w-full">
|
||||
<Spinner size={LOADING_SPINNER_SIZE} state={spinnerState} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 ? (
|
||||
<tr className="h-10">
|
||||
<td colSpan={columns.length}>{placeholder}</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<tr
|
||||
key={getKey(item)}
|
||||
tabIndex={-1}
|
||||
onClick={event => {
|
||||
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(column => (
|
||||
<td key={column.id} className="px-4 border-0 border-r vertical-align-middle">
|
||||
{column.render(item, index)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{headerRow}
|
||||
{itemRows}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Rows
|
@ -1,94 +0,0 @@
|
||||
/** @file Form to create a project. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as error from '../../error'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
|
||||
// ========================
|
||||
// === SecretCreateForm ===
|
||||
// ========================
|
||||
|
||||
/** Props for a {@link SecretCreateForm}. */
|
||||
export interface SecretCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
directoryId: backendModule.DirectoryId
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
/** A form to create a secret. */
|
||||
function SecretCreateForm(props: SecretCreateFormProps) {
|
||||
const { directoryId, onSuccess, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [name, setName] = React.useState<string | null>(null)
|
||||
const [value, setValue] = React.useState<string | null>(null)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (name == null || 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 (
|
||||
<CreateForm title="New Secret" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="project_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="secret_value">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id="secret_value"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SecretCreateForm
|
@ -0,0 +1,606 @@
|
||||
/** @file Table displaying a list of secrets. */
|
||||
import * as React from 'react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import PlusIcon from 'enso-assets/plus.svg'
|
||||
import SecretIcon from 'enso-assets/secret.svg'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as columnModule from '../column'
|
||||
import * as dateTime from '../dateTime'
|
||||
import * as errorModule from '../../error'
|
||||
import * as eventModule from '../event'
|
||||
import * as hooks from '../../hooks'
|
||||
import * as permissions from '../permissions'
|
||||
import * as presence from '../presence'
|
||||
import * as secretEventModule from '../events/secretEvent'
|
||||
import * as secretListEventModule from '../events/secretListEvent'
|
||||
import * as shortcuts from '../shortcuts'
|
||||
import * as string from '../../string'
|
||||
import * as uniqueString from '../../uniqueString'
|
||||
|
||||
import * as authProvider from '../../authentication/providers/auth'
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as loggerProvider from '../../providers/logger'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import * as tableColumn from './tableColumn'
|
||||
import CreateForm, * as createForm from './createForm'
|
||||
import TableRow, * as tableRow from './tableRow'
|
||||
import ConfirmDeleteModal from './confirmDeleteModal'
|
||||
import ContextMenu from './contextMenu'
|
||||
import ContextMenuEntry from './contextMenuEntry'
|
||||
import EditableSpan from './editableSpan'
|
||||
import Table from './table'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The user-facing name of this asset type. */
|
||||
const ASSET_TYPE_NAME = 'secret'
|
||||
/** The user-facing plural name of this asset type. */
|
||||
const ASSET_TYPE_NAME_PLURAL = 'secrets'
|
||||
// This is a function, even though it is not syntactically a function.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const pluralize = string.makePluralize(ASSET_TYPE_NAME, ASSET_TYPE_NAME_PLURAL)
|
||||
/** Placeholder row when the search query is not empty. */
|
||||
const PLACEHOLDER_WITH_QUERY = (
|
||||
<span className="opacity-75">
|
||||
This folder does not contain any secrets matching your query.
|
||||
</span>
|
||||
)
|
||||
/** Placeholder row when the search query is empty. */
|
||||
const PLACEHOLDER_WITHOUT_QUERY = (
|
||||
<span className="opacity-75">This folder does not contain any secrets.</span>
|
||||
)
|
||||
|
||||
// ========================
|
||||
// === SecretCreateForm ===
|
||||
// ========================
|
||||
|
||||
/** Props for a {@link SecretCreateForm}. */
|
||||
interface InternalSecretCreateFormProps extends createForm.CreateFormPassthroughProps {
|
||||
dispatchSecretListEvent: (event: secretListEventModule.SecretListEvent) => void
|
||||
}
|
||||
|
||||
/** A form to create a new secret asset. */
|
||||
function SecretCreateForm(props: InternalSecretCreateFormProps) {
|
||||
const { dispatchSecretListEvent, ...passThrough } = props
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
const [name, setName] = React.useState<string | null>(null)
|
||||
const [value, setValue] = React.useState<string | null>(null)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
return <></>
|
||||
} else {
|
||||
const onSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (name == null) {
|
||||
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()
|
||||
dispatchSecretListEvent({
|
||||
type: secretListEventModule.SecretListEventType.create,
|
||||
name: name,
|
||||
value: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateForm title="New Secret" onSubmit={onSubmit} {...passThrough}>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="project_name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="project_name"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setName(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row flex-nowrap m-1">
|
||||
<label className="inline-block flex-1 grow m-1" htmlFor="secret_value">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
id="secret_value"
|
||||
type="text"
|
||||
size={1}
|
||||
className="bg-gray-200 rounded-full flex-1 grow-2 px-2 m-1"
|
||||
onChange={event => {
|
||||
setValue(event.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CreateForm>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// =========================
|
||||
// === SecretNameHeading ===
|
||||
// =========================
|
||||
|
||||
/** Props for a {@link SecretNameHeading}. */
|
||||
interface InternalSecretNameHeadingProps {
|
||||
dispatchSecretListEvent: (event: secretListEventModule.SecretListEvent) => void
|
||||
}
|
||||
|
||||
/** The column header for the "name" column for the table of secret assets. */
|
||||
function SecretNameHeading(props: InternalSecretNameHeadingProps) {
|
||||
const { dispatchSecretListEvent } = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
<div className="inline-flex">
|
||||
{string.capitalizeFirst(ASSET_TYPE_NAME_PLURAL)}
|
||||
<button
|
||||
className="mx-1"
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
const buttonPosition = event.currentTarget.getBoundingClientRect()
|
||||
setModal(
|
||||
<SecretCreateForm
|
||||
left={buttonPosition.left + window.scrollX}
|
||||
top={buttonPosition.top + window.scrollY}
|
||||
dispatchSecretListEvent={dispatchSecretListEvent}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<img src={PlusIcon} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================
|
||||
// === SecretName ===
|
||||
// ==================
|
||||
|
||||
/** Props for a {@link SecretName}. */
|
||||
interface InternalSecretNameProps
|
||||
extends tableColumn.TableColumnProps<
|
||||
backendModule.SecretAsset,
|
||||
SecretsTableState,
|
||||
SecretRowState
|
||||
> {}
|
||||
|
||||
/** The icon and name of a specific secret asset. */
|
||||
function SecretName(props: InternalSecretNameProps) {
|
||||
const { item, setItem, selected, setRowState } = props
|
||||
|
||||
// TODO[sb]: Wait for backend implementation. `editable` should also be re-enabled, and the
|
||||
// context menu entry should be re-added.
|
||||
// Backend implementation is tracked here: https://github.com/enso-org/cloud-v2/issues/505.
|
||||
const doRename = async (/* _newName: string */) => {
|
||||
await Promise.resolve(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex text-left items-center align-middle whitespace-nowrap"
|
||||
onClick={event => {
|
||||
if (
|
||||
eventModule.isSingleClick(event) &&
|
||||
(selected ||
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.editName,
|
||||
event
|
||||
))
|
||||
) {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: true,
|
||||
}))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img src={SecretIcon} />{' '}
|
||||
<EditableSpan
|
||||
editable={false}
|
||||
onSubmit={async newTitle => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: false,
|
||||
}))
|
||||
if (newTitle !== item.title) {
|
||||
const oldTitle = item.title
|
||||
setItem(oldItem => ({ ...oldItem, title: newTitle }))
|
||||
try {
|
||||
await doRename(/* newTitle */)
|
||||
} catch {
|
||||
setItem(oldItem => ({ ...oldItem, title: oldTitle }))
|
||||
}
|
||||
}
|
||||
}}
|
||||
onCancel={() => {
|
||||
setRowState(oldRowState => ({
|
||||
...oldRowState,
|
||||
isEditingName: false,
|
||||
}))
|
||||
}}
|
||||
className="bg-transparent grow px-2"
|
||||
>
|
||||
{item.title}
|
||||
</EditableSpan>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === SecretRowContextMenu ===
|
||||
// ============================
|
||||
|
||||
/** Props for a {@link SecretRowContextMenu}. */
|
||||
interface InternalDirectoryRowContextMenuProps {
|
||||
innerProps: tableRow.TableRowInnerProps<
|
||||
backendModule.SecretAsset,
|
||||
backendModule.SecretId,
|
||||
SecretRowState
|
||||
>
|
||||
event: React.MouseEvent
|
||||
doDelete: () => Promise<void>
|
||||
}
|
||||
|
||||
/** The context menu for a row of a {@link SecretsTable}. */
|
||||
function SecretRowContextMenu(props: InternalDirectoryRowContextMenuProps) {
|
||||
const {
|
||||
innerProps: { item },
|
||||
event,
|
||||
doDelete,
|
||||
} = props
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
<ContextMenu key={item.id} event={event}>
|
||||
<ContextMenuEntry
|
||||
onClick={() => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={`the ${ASSET_TYPE_NAME} '${item.title}'`}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<span className="text-red-700">Delete</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
// =================
|
||||
// === SecretRow ===
|
||||
// =================
|
||||
|
||||
/** A row in a {@link SecretsTable}. */
|
||||
function SecretRow(
|
||||
props: tableRow.TableRowProps<
|
||||
backendModule.SecretAsset,
|
||||
backendModule.SecretId,
|
||||
SecretsTableState,
|
||||
SecretRowState
|
||||
>
|
||||
) {
|
||||
const {
|
||||
keyProp: key,
|
||||
item: rawItem,
|
||||
state: { secretEvent, dispatchSecretListEvent, markItemAsHidden, markItemAsVisible },
|
||||
} = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
const [status, setStatus] = React.useState(presence.Presence.present)
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(rawItem)
|
||||
}, [rawItem])
|
||||
|
||||
const doDelete = async () => {
|
||||
if (backend.type !== backendModule.BackendType.local) {
|
||||
setStatus(presence.Presence.deleting)
|
||||
markItemAsHidden(key)
|
||||
try {
|
||||
await backend.deleteSecret(item.id, item.title)
|
||||
dispatchSecretListEvent({
|
||||
type: secretListEventModule.SecretListEventType.delete,
|
||||
secretId: key,
|
||||
})
|
||||
} catch (error) {
|
||||
setStatus(presence.Presence.present)
|
||||
markItemAsVisible(key)
|
||||
const message = `Unable to delete secret: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hooks.useEventHandler(secretEvent, async event => {
|
||||
switch (event.type) {
|
||||
case secretEventModule.SecretEventType.create: {
|
||||
if (key === event.placeholderId) {
|
||||
if (backend.type !== backendModule.BackendType.remote) {
|
||||
const message = 'Secrets cannot be created on the local backend.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else {
|
||||
setStatus(presence.Presence.inserting)
|
||||
try {
|
||||
const createdSecret = await backend.createSecret({
|
||||
parentDirectoryId: item.parentId,
|
||||
secretName: item.title,
|
||||
secretValue: event.value,
|
||||
})
|
||||
setStatus(presence.Presence.present)
|
||||
const newItem: backendModule.SecretAsset = {
|
||||
...item,
|
||||
...createdSecret,
|
||||
}
|
||||
setItem(newItem)
|
||||
} catch (error) {
|
||||
dispatchSecretListEvent({
|
||||
type: secretListEventModule.SecretListEventType.delete,
|
||||
secretId: key,
|
||||
})
|
||||
const message = `Error creating new secret: ${
|
||||
errorModule.tryGetMessage(error) ?? 'unknown error.'
|
||||
}`
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case secretEventModule.SecretEventType.deleteMultiple: {
|
||||
if (event.secretIds.has(key)) {
|
||||
await doDelete()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
className={presence.CLASS_NAME[status]}
|
||||
{...props}
|
||||
onContextMenu={(innerProps, event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setModal(
|
||||
<SecretRowContextMenu
|
||||
innerProps={innerProps}
|
||||
event={event}
|
||||
doDelete={doDelete}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
item={item}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === SecretsTable ===
|
||||
// ====================
|
||||
|
||||
/** State passed through from a {@link SecretsTable} to every cell. */
|
||||
interface SecretsTableState {
|
||||
secretEvent: secretEventModule.SecretEvent | null
|
||||
dispatchSecretListEvent: (event: secretListEventModule.SecretListEvent) => void
|
||||
markItemAsHidden: (key: string) => void
|
||||
markItemAsVisible: (key: string) => void
|
||||
}
|
||||
|
||||
/** Data associated with a {@link SecretRow}, used for rendering. */
|
||||
export interface SecretRowState {
|
||||
isEditingName: boolean
|
||||
}
|
||||
|
||||
/** The default {@link SecretRowState} associated with a {@link SecretRow}. */
|
||||
export const INITIAL_ROW_STATE: SecretRowState = Object.freeze({
|
||||
isEditingName: false,
|
||||
})
|
||||
|
||||
/** Props for a {@link SecretsTable}. */
|
||||
export interface SecretsTableProps {
|
||||
directoryId: backendModule.DirectoryId | null
|
||||
items: backendModule.SecretAsset[]
|
||||
filter: ((item: backendModule.SecretAsset) => boolean) | null
|
||||
isLoading: boolean
|
||||
columnDisplayMode: columnModule.ColumnDisplayMode
|
||||
}
|
||||
|
||||
/** The table of secret assets. */
|
||||
function SecretsTable(props: SecretsTableProps) {
|
||||
const { directoryId, items: rawItems, filter, isLoading, columnDisplayMode } = props
|
||||
const logger = loggerProvider.useLogger()
|
||||
const { organization } = authProvider.useNonPartialUserSession()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const [items, setItems] = React.useState(rawItems)
|
||||
const [secretEvent, dispatchSecretEvent] = hooks.useEvent<secretEventModule.SecretEvent>()
|
||||
const [secretListEvent, dispatchSecretListEvent] =
|
||||
hooks.useEvent<secretListEventModule.SecretListEvent>()
|
||||
|
||||
React.useEffect(() => {
|
||||
setItems(rawItems)
|
||||
}, [rawItems])
|
||||
|
||||
const visibleItems = React.useMemo(
|
||||
() => (filter != null ? items.filter(filter) : items),
|
||||
[items, filter]
|
||||
)
|
||||
|
||||
// === Tracking number of visually hidden items ===
|
||||
|
||||
const [shouldForceShowPlaceholder, setShouldForceShowPlaceholder] = React.useState(false)
|
||||
const keysOfHiddenItemsRef = React.useRef(new Set<string>())
|
||||
|
||||
const updateShouldForceShowPlaceholder = React.useCallback(() => {
|
||||
setShouldForceShowPlaceholder(
|
||||
visibleItems.every(item => keysOfHiddenItemsRef.current.has(item.id))
|
||||
)
|
||||
}, [visibleItems])
|
||||
|
||||
React.useEffect(updateShouldForceShowPlaceholder, [updateShouldForceShowPlaceholder])
|
||||
|
||||
React.useEffect(() => {
|
||||
const oldKeys = keysOfHiddenItemsRef.current
|
||||
keysOfHiddenItemsRef.current = new Set(
|
||||
items.map(backendModule.getAssetId).filter(key => oldKeys.has(key))
|
||||
)
|
||||
}, [items])
|
||||
|
||||
const markItemAsHidden = React.useCallback(
|
||||
(key: string) => {
|
||||
keysOfHiddenItemsRef.current.add(key)
|
||||
updateShouldForceShowPlaceholder()
|
||||
},
|
||||
[updateShouldForceShowPlaceholder]
|
||||
)
|
||||
|
||||
const markItemAsVisible = React.useCallback(
|
||||
(key: string) => {
|
||||
keysOfHiddenItemsRef.current.delete(key)
|
||||
updateShouldForceShowPlaceholder()
|
||||
},
|
||||
[updateShouldForceShowPlaceholder]
|
||||
)
|
||||
|
||||
// === End tracking number of visually hidden items ===
|
||||
|
||||
hooks.useEventHandler(secretListEvent, event => {
|
||||
switch (event.type) {
|
||||
case secretListEventModule.SecretListEventType.create: {
|
||||
if (backend.type !== backendModule.BackendType.remote) {
|
||||
const message = 'Secrets cannot be created on the local backend.'
|
||||
toast.error(message)
|
||||
logger.error(message)
|
||||
} else {
|
||||
const placeholderItem: backendModule.SecretAsset = {
|
||||
id: backendModule.SecretId(uniqueString.uniqueString()),
|
||||
title: event.name,
|
||||
modifiedAt: dateTime.toRfc3339(new Date()),
|
||||
parentId: directoryId ?? backendModule.DirectoryId(''),
|
||||
permissions: permissions.tryGetSingletonOwnerPermission(organization),
|
||||
projectState: null,
|
||||
type: backendModule.AssetType.secret,
|
||||
}
|
||||
setItems(oldItems => [placeholderItem, ...oldItems])
|
||||
dispatchSecretEvent({
|
||||
type: secretEventModule.SecretEventType.create,
|
||||
placeholderId: placeholderItem.id,
|
||||
value: event.value,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case secretListEventModule.SecretListEventType.delete: {
|
||||
setItems(oldItems => oldItems.filter(item => item.id !== event.secretId))
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const state = React.useMemo(
|
||||
// The type MUST be here to trigger excess property errors at typecheck time.
|
||||
(): SecretsTableState => ({
|
||||
secretEvent,
|
||||
dispatchSecretListEvent,
|
||||
markItemAsHidden,
|
||||
markItemAsVisible,
|
||||
}),
|
||||
[secretEvent, dispatchSecretListEvent, markItemAsHidden, markItemAsVisible]
|
||||
)
|
||||
|
||||
if (backend.type === backendModule.BackendType.local) {
|
||||
return <></>
|
||||
} else {
|
||||
return (
|
||||
<Table<
|
||||
backendModule.SecretAsset,
|
||||
backendModule.SecretId,
|
||||
SecretsTableState,
|
||||
SecretRowState
|
||||
>
|
||||
rowComponent={SecretRow}
|
||||
items={visibleItems}
|
||||
isLoading={isLoading}
|
||||
state={state}
|
||||
initialRowState={INITIAL_ROW_STATE}
|
||||
getKey={backendModule.getAssetId}
|
||||
placeholder={filter != null ? PLACEHOLDER_WITH_QUERY : PLACEHOLDER_WITHOUT_QUERY}
|
||||
forceShowPlaceholder={shouldForceShowPlaceholder}
|
||||
columns={columnModule.columnsFor(columnDisplayMode, backend.type).map(column =>
|
||||
column === columnModule.Column.name
|
||||
? {
|
||||
id: column,
|
||||
className: columnModule.COLUMN_CSS_CLASS[column],
|
||||
heading: (
|
||||
<SecretNameHeading
|
||||
dispatchSecretListEvent={dispatchSecretListEvent}
|
||||
/>
|
||||
),
|
||||
render: SecretName,
|
||||
}
|
||||
: {
|
||||
id: column,
|
||||
className: columnModule.COLUMN_CSS_CLASS[column],
|
||||
heading: <>{columnModule.COLUMN_NAME[column]}</>,
|
||||
render: columnModule.COLUMN_RENDERER[column],
|
||||
}
|
||||
)}
|
||||
onContextMenu={(selectedKeys, event, setSelectedKeys) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const doDeleteAll = () => {
|
||||
setModal(
|
||||
<ConfirmDeleteModal
|
||||
description={
|
||||
`${selectedKeys.size} selected ` + ASSET_TYPE_NAME_PLURAL
|
||||
}
|
||||
doDelete={() => {
|
||||
dispatchSecretEvent({
|
||||
type: secretEventModule.SecretEventType.deleteMultiple,
|
||||
secretIds: selectedKeys,
|
||||
})
|
||||
setSelectedKeys(new Set())
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
const pluralized = pluralize(selectedKeys.size)
|
||||
setModal(
|
||||
<ContextMenu key={uniqueString.uniqueString()} event={event}>
|
||||
<ContextMenuEntry onClick={doDeleteAll}>
|
||||
<span className="text-red-700">
|
||||
Delete {selectedKeys.size} {pluralized}
|
||||
</span>
|
||||
</ContextMenuEntry>
|
||||
</ContextMenu>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default SecretsTable
|
@ -0,0 +1,246 @@
|
||||
/** @file A table that projects an object into each column.
|
||||
* This is intended to be specialized into components for specific item types, rather than
|
||||
* being used directly. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as shortcuts from '../shortcuts'
|
||||
|
||||
import * as tableColumn from './tableColumn'
|
||||
import Spinner, * as spinner from './spinner'
|
||||
import TableRow, * as tableRow from './tableRow'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The size of the loading spinner. */
|
||||
const LOADING_SPINNER_SIZE = 36
|
||||
|
||||
// =============================
|
||||
// === Partial `Props` types ===
|
||||
// =============================
|
||||
|
||||
/** `state: State`. */
|
||||
interface StateProp<State> {
|
||||
state: State
|
||||
}
|
||||
|
||||
/** `initialRowState: RowState`. */
|
||||
interface InitialRowStateProp<RowState> {
|
||||
initialRowState: RowState
|
||||
}
|
||||
|
||||
// =============
|
||||
// === Table ===
|
||||
// =============
|
||||
|
||||
/** Props for a {@link Table}. */
|
||||
interface InternalTableProps<T, Key extends string = string, State = never, RowState = never> {
|
||||
rowComponent?: (props: tableRow.TableRowProps<T, Key, State, RowState>) => JSX.Element
|
||||
items: T[]
|
||||
state?: State
|
||||
initialRowState?: RowState
|
||||
getKey: (item: T) => Key
|
||||
columns: tableColumn.TableColumn<T, State, RowState>[]
|
||||
isLoading: boolean
|
||||
placeholder: JSX.Element
|
||||
forceShowPlaceholder?: boolean
|
||||
onContextMenu: (
|
||||
selectedKeys: Set<Key>,
|
||||
event: React.MouseEvent<HTMLTableElement>,
|
||||
setSelectedKeys: (items: Set<Key>) => void
|
||||
) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link Table}. */
|
||||
export type TableProps<
|
||||
T,
|
||||
Key extends string = string,
|
||||
State = never,
|
||||
RowState = never
|
||||
> = InternalTableProps<T, Key, State, RowState> &
|
||||
([RowState] extends [never] ? unknown : InitialRowStateProp<RowState>) &
|
||||
([State] extends [never] ? unknown : StateProp<State>)
|
||||
|
||||
/** Table that projects an object into each column. */
|
||||
function Table<T, Key extends string = string, State = never, RowState = never>(
|
||||
props: TableProps<T, Key, State, RowState>
|
||||
) {
|
||||
const {
|
||||
rowComponent: RowComponent = TableRow,
|
||||
items,
|
||||
getKey,
|
||||
columns,
|
||||
isLoading,
|
||||
placeholder,
|
||||
forceShowPlaceholder = false,
|
||||
onContextMenu,
|
||||
...rowProps
|
||||
} = props
|
||||
|
||||
const [spinnerState, setSpinnerState] = React.useState(spinner.SpinnerState.initial)
|
||||
// This should not be made mutable for the sake of optimization, otherwise its value may
|
||||
// be different after `await`ing an I/O operation.
|
||||
const [selectedKeys, setSelectedKeys] = React.useState(() => new Set<Key>())
|
||||
const [previouslySelectedKey, setPreviouslySelectedKey] = React.useState<Key | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
const onDocumentClick = (event: MouseEvent) => {
|
||||
if (
|
||||
!shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.selectAdditional,
|
||||
event
|
||||
) &&
|
||||
!shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.selectAdditionalRange,
|
||||
event
|
||||
) &&
|
||||
selectedKeys.size !== 0
|
||||
) {
|
||||
setSelectedKeys(new Set())
|
||||
}
|
||||
}
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
return () => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
}
|
||||
}, [selectedKeys])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isLoading) {
|
||||
// Ensure the spinner stays in the "initial" state for at least one frame,
|
||||
// to ensure the CSS animation begins at the initial state.
|
||||
requestAnimationFrame(() => {
|
||||
setSpinnerState(spinner.SpinnerState.loadingFast)
|
||||
})
|
||||
} else {
|
||||
setSpinnerState(spinner.SpinnerState.initial)
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
const onRowClick = React.useCallback(
|
||||
(innerRowProps: tableRow.TableRowInnerProps<T, Key, RowState>, event: React.MouseEvent) => {
|
||||
const { key } = innerRowProps
|
||||
event.stopPropagation()
|
||||
const getNewlySelectedKeys = () => {
|
||||
if (previouslySelectedKey == null) {
|
||||
return [key]
|
||||
} else {
|
||||
const index1 = items.findIndex(
|
||||
innerItem => getKey(innerItem) === previouslySelectedKey
|
||||
)
|
||||
const index2 = items.findIndex(innerItem => getKey(innerItem) === key)
|
||||
const selectedItems =
|
||||
index1 <= index2
|
||||
? items.slice(index1, index2 + 1)
|
||||
: items.slice(index2, index1 + 1)
|
||||
return selectedItems.map(getKey)
|
||||
}
|
||||
}
|
||||
if (
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.selectRange,
|
||||
event
|
||||
)
|
||||
) {
|
||||
setSelectedKeys(new Set(getNewlySelectedKeys()))
|
||||
} else if (
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.selectAdditionalRange,
|
||||
event
|
||||
)
|
||||
) {
|
||||
setSelectedKeys(
|
||||
oldSelectedItems => new Set([...oldSelectedItems, ...getNewlySelectedKeys()])
|
||||
)
|
||||
} else if (
|
||||
shortcuts.SHORTCUT_REGISTRY.matchesMouseAction(
|
||||
shortcuts.MouseAction.selectAdditional,
|
||||
event
|
||||
)
|
||||
) {
|
||||
setSelectedKeys(oldSelectedItems => {
|
||||
const newItems = new Set(oldSelectedItems)
|
||||
if (oldSelectedItems.has(key)) {
|
||||
newItems.delete(key)
|
||||
} else {
|
||||
newItems.add(key)
|
||||
}
|
||||
return newItems
|
||||
})
|
||||
} else {
|
||||
setSelectedKeys(new Set([key]))
|
||||
}
|
||||
setPreviouslySelectedKey(key)
|
||||
},
|
||||
[items, previouslySelectedKey, /* should never change */ getKey]
|
||||
)
|
||||
|
||||
const headerRow = (
|
||||
<tr>
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={column.id}
|
||||
className={`text-vs px-4 align-middle py-1 border-0 border-r whitespace-nowrap font-semibold text-left ${
|
||||
column.className ?? ''
|
||||
}`}
|
||||
>
|
||||
{column.heading}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
|
||||
const itemRows = isLoading ? (
|
||||
<tr className="h-10">
|
||||
<td colSpan={columns.length}>
|
||||
<div className="grid justify-around w-full">
|
||||
<Spinner size={LOADING_SPINNER_SIZE} state={spinnerState} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : items.length === 0 || forceShowPlaceholder ? (
|
||||
<tr className="h-10">
|
||||
<td colSpan={columns.length}>{placeholder}</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map(item => {
|
||||
const key = getKey(item)
|
||||
return (
|
||||
<RowComponent
|
||||
{...rowProps}
|
||||
columns={columns}
|
||||
// The following two lines are safe; the type error occurs because a property
|
||||
// with a conditional type is being destructured.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
state={rowProps.state as never}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
initialRowState={rowProps.initialRowState as never}
|
||||
key={key}
|
||||
keyProp={key}
|
||||
item={item}
|
||||
selected={selectedKeys.has(key)}
|
||||
allowContextMenu={
|
||||
selectedKeys.size === 0 ||
|
||||
(selectedKeys.size === 1 && selectedKeys.has(key))
|
||||
}
|
||||
onClick={onRowClick}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
return (
|
||||
<table
|
||||
className="table-fixed items-center border-collapse w-0 mt-2"
|
||||
onContextMenu={event => {
|
||||
onContextMenu(selectedKeys, event, setSelectedKeys)
|
||||
}}
|
||||
>
|
||||
<thead>{headerRow}</thead>
|
||||
<tbody>{itemRows}</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
export default Table
|
@ -0,0 +1,23 @@
|
||||
/** @file Types for columns in a `Table`. */
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Props for a {@link Column}. */
|
||||
export interface TableColumnProps<T, State = never, RowState = never> {
|
||||
item: T
|
||||
setItem: React.Dispatch<React.SetStateAction<T>>
|
||||
selected: boolean
|
||||
state: State
|
||||
rowState: RowState
|
||||
setRowState: React.Dispatch<React.SetStateAction<RowState>>
|
||||
}
|
||||
|
||||
/** Metadata describing how to render a column of the table. */
|
||||
export interface TableColumn<T, State = never, RowState = never> {
|
||||
id: string
|
||||
className?: string
|
||||
heading: JSX.Element
|
||||
render: (props: TableColumnProps<T, State, RowState>) => JSX.Element
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
/** @file A row in a `Table`. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
import * as tableColumn from './tableColumn'
|
||||
|
||||
// =============================
|
||||
// === Partial `Props` types ===
|
||||
// =============================
|
||||
|
||||
/** `state: State`. */
|
||||
interface StateProp<State> {
|
||||
state: State
|
||||
}
|
||||
|
||||
/** `tablerowState` and `setTableRowState` */
|
||||
interface InternalTableRowStateProps<TableRowState> {
|
||||
rowState: TableRowState
|
||||
setRowState: React.Dispatch<React.SetStateAction<TableRowState>>
|
||||
}
|
||||
|
||||
/** `initialRowState: RowState`. */
|
||||
interface InitialRowStateProp<RowState> {
|
||||
initialRowState: RowState
|
||||
}
|
||||
|
||||
// ================
|
||||
// === TableRow ===
|
||||
// ================
|
||||
|
||||
/** Common properties for state and setters passed to event handlers on a {@link TableRow}. */
|
||||
interface InternalTableRowInnerProps<T, Key extends string = string> {
|
||||
key: Key
|
||||
item: T
|
||||
setItem: (newItem: T) => void
|
||||
}
|
||||
|
||||
/** State and setters passed to event handlers on a {@link TableRow}. */
|
||||
export type TableRowInnerProps<
|
||||
T,
|
||||
Key extends string = string,
|
||||
TableRowState = never
|
||||
> = InternalTableRowInnerProps<T, Key> &
|
||||
([TableRowState] extends never ? unknown : InternalTableRowStateProps<TableRowState>)
|
||||
|
||||
/** Props for a {@link TableRow}. */
|
||||
interface InternalBaseTableRowProps<
|
||||
T,
|
||||
Key extends string = string,
|
||||
State = never,
|
||||
TableRowState = never
|
||||
> extends Omit<JSX.IntrinsicElements['tr'], 'onClick' | 'onContextMenu'> {
|
||||
keyProp: Key
|
||||
item: T
|
||||
state?: State
|
||||
initialRowState?: TableRowState
|
||||
columns: tableColumn.TableColumn<T, State, TableRowState>[]
|
||||
selected: boolean
|
||||
allowContextMenu: boolean
|
||||
onClick: (props: TableRowInnerProps<T, Key, TableRowState>, event: React.MouseEvent) => void
|
||||
onContextMenu?: (
|
||||
props: TableRowInnerProps<T, Key, TableRowState>,
|
||||
event: React.MouseEvent<HTMLTableRowElement>
|
||||
) => void
|
||||
}
|
||||
|
||||
/** Props for a {@link TableRow}. */
|
||||
export type TableRowProps<
|
||||
T,
|
||||
Key extends string = string,
|
||||
State = never,
|
||||
TableRowState = never
|
||||
> = InternalBaseTableRowProps<T, Key, State, TableRowState> &
|
||||
([State] extends [never] ? unknown : StateProp<State>) &
|
||||
([TableRowState] extends [never] ? unknown : InitialRowStateProp<TableRowState>)
|
||||
|
||||
/** A row of a table. This is required because each row may store its own state. */
|
||||
function TableRow<T, Key extends string = string, State = never, RowState = never>(
|
||||
props: TableRowProps<T, Key, State, RowState>
|
||||
) {
|
||||
const {
|
||||
keyProp: key,
|
||||
item: rawItem,
|
||||
state,
|
||||
initialRowState,
|
||||
columns,
|
||||
selected,
|
||||
allowContextMenu,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
className,
|
||||
...passthrough
|
||||
} = props
|
||||
const { unsetModal } = modalProvider.useSetModal()
|
||||
|
||||
/** The internal state for this row. This may change as backend requests are sent. */
|
||||
const [item, setItem] = React.useState(rawItem)
|
||||
/** This is SAFE, as the type is defined such that they MUST be present when `RowState` is not
|
||||
* `never`.
|
||||
* See the type definitions of {@link TableRowProps} and `TableProps`. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const [rowState, setRowState] = React.useState<RowState>(initialRowState!)
|
||||
|
||||
React.useEffect(() => {
|
||||
setItem(rawItem)
|
||||
}, [rawItem])
|
||||
|
||||
const innerProps: TableRowInnerProps<T, Key, RowState> = {
|
||||
key,
|
||||
item,
|
||||
setItem,
|
||||
rowState,
|
||||
setRowState,
|
||||
}
|
||||
|
||||
return (
|
||||
<tr
|
||||
tabIndex={-1}
|
||||
onClick={event => {
|
||||
unsetModal()
|
||||
onClick(innerProps, event)
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
if (allowContextMenu) {
|
||||
onContextMenu?.(innerProps, event)
|
||||
}
|
||||
}}
|
||||
className={`h-10 transition duration-300 ease-in-out hover:bg-gray-100 ${
|
||||
className ?? ''
|
||||
} ${selected ? 'bg-gray-200' : ''}`}
|
||||
{...passthrough}
|
||||
>
|
||||
{columns.map(column => {
|
||||
// This is a React component even though it does not contain JSX.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const Render = column.render
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={`px-4 border-0 border-r vertical-align-middle ${
|
||||
column.className ?? ''
|
||||
}`}
|
||||
>
|
||||
<Render
|
||||
item={item}
|
||||
setItem={setItem}
|
||||
selected={selected}
|
||||
/** This is SAFE, as the type is defined such that they MUST be
|
||||
* present if it is specified as a generic parameter.
|
||||
* See the type definitions of {@link TableRowProps} and {@link TableProps}.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
state={state!}
|
||||
rowState={rowState}
|
||||
setRowState={setRowState}
|
||||
/>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export default TableRow
|
@ -37,9 +37,9 @@ enum ShadowClass {
|
||||
both = 'shadow-inset-v-lg',
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Templates ===
|
||||
// =================
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** Template metadata. */
|
||||
export interface Template {
|
||||
@ -49,6 +49,10 @@ export interface Template {
|
||||
background: string
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The full list of templates. */
|
||||
export const TEMPLATES: [Template, ...Template[]] = [
|
||||
{
|
||||
@ -73,19 +77,19 @@ export const TEMPLATES: [Template, ...Template[]] = [
|
||||
title: 'Combine spreadsheets',
|
||||
id: 'Orders',
|
||||
description: 'Glue multiple spreadsheets together to analyse all your data at once.',
|
||||
background: 'url("/spreadsheets.png") 50% 20% / 80% no-repeat, #479366',
|
||||
background: 'url("./spreadsheets.png") 50% 20% / 80% no-repeat, #479366',
|
||||
},
|
||||
{
|
||||
title: 'Geospatial analysis',
|
||||
id: 'Restaurants',
|
||||
description: 'Learn where to open a coffee shop to maximize your income.',
|
||||
background: 'url("/geo.png") center / cover',
|
||||
background: 'url("./geo.png") center / cover, #6b7280',
|
||||
},
|
||||
{
|
||||
title: 'Analyze GitHub stars',
|
||||
id: 'Stargazers',
|
||||
description: "Find out which of Enso's repositories are most popular over time.",
|
||||
background: 'url("/visualize.png") center / cover',
|
||||
background: 'url("./visualize.png") center / cover, #6b7280',
|
||||
},
|
||||
]
|
||||
|
||||
@ -154,6 +158,17 @@ interface InternalTemplateButtonProps {
|
||||
function TemplateButton(props: InternalTemplateButtonProps) {
|
||||
const { template, onTemplateClick } = props
|
||||
const [spinnerState, setSpinnerState] = React.useState<spinner.SpinnerState | null>(null)
|
||||
const onSpinnerStateChange = React.useCallback(
|
||||
(newSpinnerState: spinner.SpinnerState | null) => {
|
||||
setSpinnerState(newSpinnerState)
|
||||
if (newSpinnerState === spinner.SpinnerState.done) {
|
||||
setTimeout(() => {
|
||||
setSpinnerState(null)
|
||||
}, SPINNER_DONE_DURATION_MS)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -161,14 +176,7 @@ function TemplateButton(props: InternalTemplateButtonProps) {
|
||||
className="h-40 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSpinnerState(spinner.SpinnerState.initial)
|
||||
onTemplateClick(template.id, newSpinnerState => {
|
||||
setSpinnerState(newSpinnerState)
|
||||
if (newSpinnerState === spinner.SpinnerState.done) {
|
||||
setTimeout(() => {
|
||||
setSpinnerState(null)
|
||||
}, SPINNER_DONE_DURATION_MS)
|
||||
}
|
||||
})
|
||||
onTemplateClick(template.id, onSpinnerStateChange)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
@ -0,0 +1,17 @@
|
||||
/** @file A component that renders the modal instance from the modal React Context. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
|
||||
// ================
|
||||
// === TheModal ===
|
||||
// ================
|
||||
|
||||
/** Renders the modal instance from the modal React Context (if any). */
|
||||
function TheModal() {
|
||||
const { modal } = modalProvider.useModal()
|
||||
|
||||
return <>{modal}</>
|
||||
}
|
||||
|
||||
export default TheModal
|
@ -9,7 +9,7 @@ import MagnifyingGlassIcon from 'enso-assets/magnifying_glass.svg'
|
||||
import SpeechBubbleIcon from 'enso-assets/speech_bubble.svg'
|
||||
|
||||
import * as backendModule from '../backend'
|
||||
import * as dashboard from './dashboard'
|
||||
import * as tabModule from '../tab'
|
||||
|
||||
import * as backendProvider from '../../providers/backend'
|
||||
import * as modalProvider from '../../providers/modal'
|
||||
@ -25,7 +25,7 @@ export interface TopBarProps {
|
||||
/** Whether the application may have the local backend running. */
|
||||
supportsLocalBackend: boolean
|
||||
projectName: string | null
|
||||
tab: dashboard.Tab
|
||||
tab: tabModule.Tab
|
||||
toggleTab: () => void
|
||||
setBackendType: (backendType: backendModule.BackendType) => void
|
||||
isHelpChatOpen: boolean
|
||||
@ -48,24 +48,8 @@ function TopBar(props: TopBarProps) {
|
||||
query,
|
||||
setQuery,
|
||||
} = props
|
||||
const [isUserMenuVisible, setIsUserMenuVisible] = React.useState(false)
|
||||
const { modal } = modalProvider.useModal()
|
||||
const { setModal, unsetModal } = modalProvider.useSetModal()
|
||||
const { backend } = backendProvider.useBackend()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!modal) {
|
||||
setIsUserMenuVisible(false)
|
||||
}
|
||||
}, [modal])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUserMenuVisible) {
|
||||
setModal(() => <UserMenu />)
|
||||
} else {
|
||||
unsetModal()
|
||||
}
|
||||
}, [isUserMenuVisible, setModal, unsetModal])
|
||||
const { updateModal } = modalProvider.useSetModal()
|
||||
|
||||
return (
|
||||
<div className="flex mx-2 h-8">
|
||||
@ -105,7 +89,7 @@ function TopBar(props: TopBarProps) {
|
||||
>
|
||||
<span
|
||||
className={`opacity-50 overflow-hidden transition-width nowrap ${
|
||||
tab === dashboard.Tab.dashboard ? 'm-2 w-16' : 'w-0'
|
||||
tab === tabModule.Tab.dashboard ? 'm-2 w-16' : 'w-0'
|
||||
}`}
|
||||
>
|
||||
{projectName ?? 'Dashboard'}
|
||||
@ -115,7 +99,7 @@ function TopBar(props: TopBarProps) {
|
||||
</div>
|
||||
<span
|
||||
className={`opacity-50 overflow-hidden transition-width nowrap ${
|
||||
tab === dashboard.Tab.ide ? 'm-2 w-16' : 'w-0'
|
||||
tab === tabModule.Tab.ide ? 'm-2 w-16' : 'w-0'
|
||||
}`}
|
||||
>
|
||||
{projectName ?? 'No project open'}
|
||||
@ -155,7 +139,7 @@ function TopBar(props: TopBarProps) {
|
||||
<div
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setIsUserMenuVisible(!isUserMenuVisible)
|
||||
updateModal(oldModal => (oldModal?.type === UserMenu ? null : <UserMenu />))
|
||||
}}
|
||||
className="rounded-full w-8 h-8 bg-cover cursor-pointer"
|
||||
>
|
||||
|
@ -27,7 +27,7 @@ function UserMenuItem(props: React.PropsWithChildren<UserMenuItemProps>) {
|
||||
<div
|
||||
className={`whitespace-nowrap px-4 py-2 ${disabled ? 'opacity-50' : ''} ${
|
||||
onClick ? 'hover:bg-blue-500 hover:text-white' : ''
|
||||
} ${onClick && !disabled ? 'cursor-pointer' : ''}`}
|
||||
} ${onClick != null && !disabled ? 'cursor-pointer' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
@ -76,7 +76,7 @@ function UserMenu() {
|
||||
{canChangePassword && (
|
||||
<UserMenuItem
|
||||
onClick={() => {
|
||||
setModal(() => <ChangePasswordModal />)
|
||||
setModal(<ChangePasswordModal />)
|
||||
}}
|
||||
>
|
||||
Change your password
|
||||
|
@ -14,6 +14,10 @@ const HALF_DAY_HOURS = 12
|
||||
|
||||
/** A string with date and time, following the RFC3339 specification. */
|
||||
export type Rfc3339DateTime = newtype.Newtype<string, 'Rfc3339DateTime'>
|
||||
/** Create a {@link Rfc3339DateTime}. */
|
||||
// This is a constructor function that constructs values of the type it is named after.
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const Rfc3339DateTime = newtype.newtypeConstructor<Rfc3339DateTime>()
|
||||
|
||||
/** Formats date time into the preferred format: `YYYY-MM-DD, hh:mm`. */
|
||||
export function formatDateTime(date: Date) {
|
||||
@ -44,5 +48,5 @@ export function formatDateTimeChatFriendly(date: Date) {
|
||||
|
||||
/** Formats a {@link Date} as a {@link Rfc3339DateTime} */
|
||||
export function toRfc3339(date: Date) {
|
||||
return newtype.asNewtype<Rfc3339DateTime>(date.toISOString())
|
||||
return Rfc3339DateTime(date.toISOString())
|
||||
}
|
||||
|
@ -0,0 +1,17 @@
|
||||
/** @file Utility functions related to event handling. */
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
// =============================
|
||||
// === Mouse event utilities ===
|
||||
// =============================
|
||||
|
||||
/** Returns `true` if and only if the event is a single click event. */
|
||||
export function isSingleClick(event: React.MouseEvent) {
|
||||
return event.detail === 1
|
||||
}
|
||||
|
||||
/** Returns `true` if and only if the event is a double click event. */
|
||||
export function isDoubleClick(event: React.MouseEvent) {
|
||||
return event.detail === 2
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
/** @file Events related to changes in directory state. */
|
||||
import * as backendModule from '../backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
directoryEvent: DirectoryEvent
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// === DirectoryEvent ===
|
||||
// ======================
|
||||
|
||||
/** Possible types of directory state change. */
|
||||
export enum DirectoryEventType {
|
||||
create = 'create',
|
||||
deleteMultiple = 'delete-multiple',
|
||||
}
|
||||
|
||||
/** Properties common to all directory state change events. */
|
||||
interface DirectoryBaseEvent<Type extends DirectoryEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create a directory. */
|
||||
export interface DirectoryCreateEvent extends DirectoryBaseEvent<DirectoryEventType.create> {
|
||||
placeholderId: backendModule.DirectoryId
|
||||
}
|
||||
|
||||
/** A signal to delete multiple directories. */
|
||||
export interface DirectoryDeleteMultipleEvent
|
||||
extends DirectoryBaseEvent<DirectoryEventType.deleteMultiple> {
|
||||
directoryIds: Set<backendModule.DirectoryId>
|
||||
}
|
||||
|
||||
/** Every possible type of directory event. */
|
||||
export type DirectoryEvent = DirectoryCreateEvent | DirectoryDeleteMultipleEvent
|
@ -0,0 +1,36 @@
|
||||
/** @file Events related to changes in the directory list. */
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
directoryListEvent: DirectoryListEvent
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// === DirectoryListEvent ===
|
||||
// ==========================
|
||||
|
||||
/** Possible changes to the directory list. */
|
||||
export enum DirectoryListEventType {
|
||||
create = 'create',
|
||||
delete = 'delete',
|
||||
}
|
||||
|
||||
/** Properties common to all directory list change events. */
|
||||
interface DirectoryListBaseEvent<Type extends DirectoryListEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create a new directory. */
|
||||
interface DirectoryListCreateEvent extends DirectoryListBaseEvent<DirectoryListEventType.create> {}
|
||||
|
||||
/** A signal to delete a directory. */
|
||||
interface DirectoryListDeleteEvent extends DirectoryListBaseEvent<DirectoryListEventType.delete> {
|
||||
directoryId: string
|
||||
}
|
||||
|
||||
/** Every possible type of directory list event. */
|
||||
export type DirectoryListEvent = DirectoryListCreateEvent | DirectoryListDeleteEvent
|
@ -0,0 +1,39 @@
|
||||
/** @file Events related to changes in file state. */
|
||||
import * as backendModule from '../backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
fileEvent: FileEvent
|
||||
}
|
||||
}
|
||||
|
||||
// =================
|
||||
// === FileEvent ===
|
||||
// =================
|
||||
|
||||
/** Possible types of file state change. */
|
||||
export enum FileEventType {
|
||||
createMultiple = 'create-multiple',
|
||||
deleteMultiple = 'delete-multiple',
|
||||
}
|
||||
|
||||
/** Properties common to all file state change events. */
|
||||
interface FileBaseEvent<Type extends FileEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create multiple files. */
|
||||
export interface FileCreateMultipleEvent extends FileBaseEvent<FileEventType.createMultiple> {
|
||||
files: Map<backendModule.FileId, File>
|
||||
}
|
||||
|
||||
/** A signal to delete multiple files. */
|
||||
export interface FileDeleteMultipleEvent extends FileBaseEvent<FileEventType.deleteMultiple> {
|
||||
fileIds: Set<backendModule.FileId>
|
||||
}
|
||||
|
||||
/** Every possible type of file event. */
|
||||
export type FileEvent = FileCreateMultipleEvent | FileDeleteMultipleEvent
|
@ -0,0 +1,38 @@
|
||||
/** @file Events related to changes in the file list. */
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
fileListEvent: FileListEvent
|
||||
}
|
||||
}
|
||||
|
||||
// =====================
|
||||
// === FileListEvent ===
|
||||
// =====================
|
||||
|
||||
/** Possible changes to the file list. */
|
||||
export enum FileListEventType {
|
||||
uploadMultiple = 'upload-multiple',
|
||||
delete = 'delete',
|
||||
}
|
||||
|
||||
/** Properties common to all file list change events. */
|
||||
interface FileListBaseEvent<Type extends FileListEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to upload multiple files. */
|
||||
interface FileListUploadMultipleEvent extends FileListBaseEvent<FileListEventType.uploadMultiple> {
|
||||
files: FileList
|
||||
}
|
||||
|
||||
/** A signal to delete a file. */
|
||||
interface FileListDeleteEvent extends FileListBaseEvent<FileListEventType.delete> {
|
||||
fileId: string
|
||||
}
|
||||
|
||||
/** Every possible type of file list event. */
|
||||
export type FileListEvent = FileListDeleteEvent | FileListUploadMultipleEvent
|
@ -0,0 +1,67 @@
|
||||
/** @file Events related to changes in project state. */
|
||||
import * as backendModule from '../backend'
|
||||
import * as spinner from '../components/spinner'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
projectEvent: ProjectEvent
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === ProjectEvent ===
|
||||
// ====================
|
||||
|
||||
/** Possible types of project state change. */
|
||||
export enum ProjectEventType {
|
||||
create = 'create',
|
||||
open = 'open',
|
||||
showAsOpening = 'show-as-opening',
|
||||
cancelOpeningAll = 'cancel-opening-all',
|
||||
deleteMultiple = 'delete-multiple',
|
||||
}
|
||||
|
||||
/** Properties common to all project state change events. */
|
||||
interface ProjectBaseEvent<Type extends ProjectEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create a project. */
|
||||
export interface ProjectCreateEvent extends ProjectBaseEvent<ProjectEventType.create> {
|
||||
placeholderId: backendModule.ProjectId
|
||||
templateId: string | null
|
||||
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
|
||||
}
|
||||
|
||||
/** A signal to open the specified project. */
|
||||
export interface ProjectOpenEvent extends ProjectBaseEvent<ProjectEventType.open> {
|
||||
projectId: backendModule.ProjectId
|
||||
}
|
||||
|
||||
/** A signal to display the specified project as opening, but not actually send the call to the
|
||||
* backend. */
|
||||
export interface ProjectShowAsOpeningEvent
|
||||
extends ProjectBaseEvent<ProjectEventType.showAsOpening> {
|
||||
projectId: backendModule.ProjectId
|
||||
}
|
||||
|
||||
/** A signal to stop automatically opening any project that is currently opening. */
|
||||
export interface ProjectCancelOpeningAllEvent
|
||||
extends ProjectBaseEvent<ProjectEventType.cancelOpeningAll> {}
|
||||
|
||||
/** A signal to delete multiple projects. */
|
||||
export interface ProjectDeleteMultipleEvent
|
||||
extends ProjectBaseEvent<ProjectEventType.deleteMultiple> {
|
||||
projectIds: Set<backendModule.ProjectId>
|
||||
}
|
||||
|
||||
/** Every possible type of project event. */
|
||||
export type ProjectEvent =
|
||||
| ProjectCancelOpeningAllEvent
|
||||
| ProjectCreateEvent
|
||||
| ProjectDeleteMultipleEvent
|
||||
| ProjectOpenEvent
|
||||
| ProjectShowAsOpeningEvent
|
@ -0,0 +1,40 @@
|
||||
/** @file Events related to changes in the project list. */
|
||||
import * as spinner from '../components/spinner'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
projectListEvent: ProjectListEvent
|
||||
}
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === ProjectListEvent ===
|
||||
// ========================
|
||||
|
||||
/** Possible changes to the project list. */
|
||||
export enum ProjectListEventType {
|
||||
create = 'create',
|
||||
delete = 'delete',
|
||||
}
|
||||
|
||||
/** Properties common to all project list events. */
|
||||
interface ProjectListBaseEvent<Type extends ProjectListEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create a new project. */
|
||||
interface ProjectListCreateEvent extends ProjectListBaseEvent<ProjectListEventType.create> {
|
||||
templateId: string | null
|
||||
onSpinnerStateChange: ((state: spinner.SpinnerState) => void) | null
|
||||
}
|
||||
|
||||
/** A signal to delete a project. */
|
||||
interface ProjectListDeleteEvent extends ProjectListBaseEvent<ProjectListEventType.delete> {
|
||||
projectId: string
|
||||
}
|
||||
|
||||
/** Every possible type of project list event. */
|
||||
export type ProjectListEvent = ProjectListCreateEvent | ProjectListDeleteEvent
|
@ -0,0 +1,40 @@
|
||||
/** @file Events related to changes in secret state. */
|
||||
import * as backendModule from '../backend'
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
secretEvent: SecretEvent
|
||||
}
|
||||
}
|
||||
|
||||
// ===================
|
||||
// === SecretEvent ===
|
||||
// ===================
|
||||
|
||||
/** Possible types of secret state change. */
|
||||
export enum SecretEventType {
|
||||
create = 'create',
|
||||
deleteMultiple = 'delete-multiple',
|
||||
}
|
||||
|
||||
/** Properties common to all secret state change events. */
|
||||
interface SecretBaseEvent<Type extends SecretEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create a secret. */
|
||||
export interface SecretCreateEvent extends SecretBaseEvent<SecretEventType.create> {
|
||||
placeholderId: backendModule.SecretId
|
||||
value: string
|
||||
}
|
||||
|
||||
/** A signal to delete multiple secrets. */
|
||||
export interface SecretDeleteMultipleEvent extends SecretBaseEvent<SecretEventType.deleteMultiple> {
|
||||
secretIds: Set<backendModule.SecretId>
|
||||
}
|
||||
|
||||
/** Every possible type of secret event. */
|
||||
export type SecretEvent = SecretCreateEvent | SecretDeleteMultipleEvent
|
@ -0,0 +1,39 @@
|
||||
/** @file Events related to changes in the secret list. */
|
||||
|
||||
// This is required, to whitelist this event.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
declare module '../../hooks' {
|
||||
/** A map containing all known event types. */
|
||||
export interface KnownEventsMap {
|
||||
secretListEvent: SecretListEvent
|
||||
}
|
||||
}
|
||||
|
||||
// =======================
|
||||
// === SecretListEvent ===
|
||||
// =======================
|
||||
|
||||
/** Possible changes to the secret list. */
|
||||
export enum SecretListEventType {
|
||||
create = 'create',
|
||||
delete = 'delete',
|
||||
}
|
||||
|
||||
/** Properties common to all secret list change events. */
|
||||
interface SecretListBaseEvent<Type extends SecretListEventType> {
|
||||
type: Type
|
||||
}
|
||||
|
||||
/** A signal to create a new secret. */
|
||||
interface SecretListCreateEvent extends SecretListBaseEvent<SecretListEventType.create> {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** A signal to delete a secret. */
|
||||
interface SecretListDeleteEvent extends SecretListBaseEvent<SecretListEventType.delete> {
|
||||
secretId: string
|
||||
}
|
||||
|
||||
/** Every possible type of secret list event. */
|
||||
export type SecretListEvent = SecretListCreateEvent | SecretListDeleteEvent
|
@ -0,0 +1,12 @@
|
||||
/** @file Flags for features that are implemented, but disabled either because
|
||||
* they are not production-ready, or because it is unknown whether they are still needed. */
|
||||
|
||||
export const FEATURE_FLAGS = {
|
||||
/** A selector that lets the user choose between pre-defined sets of visible columns. */
|
||||
columnDisplayModeSwitcher: false,
|
||||
}
|
||||
|
||||
if (IS_DEV_MODE) {
|
||||
// @ts-expect-error This is exposed for development purposes only.
|
||||
window.featureFlags = FEATURE_FLAGS
|
||||
}
|
@ -4,7 +4,7 @@
|
||||
* The functions are asynchronous and return a {@link Promise} that resolves to the response from
|
||||
* the API. */
|
||||
import * as backend from './backend'
|
||||
import * as newtype from '../newtype'
|
||||
import * as errorModule from '../error'
|
||||
import * as projectManager from './projectManager'
|
||||
|
||||
// ========================
|
||||
@ -13,24 +13,18 @@ import * as projectManager from './projectManager'
|
||||
|
||||
/** Convert a {@link projectManager.IpWithSocket} to a {@link backend.Address}. */
|
||||
function ipWithSocketToAddress(ipWithSocket: projectManager.IpWithSocket) {
|
||||
return newtype.asNewtype<backend.Address>(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
|
||||
return backend.Address(`ws://${ipWithSocket.host}:${ipWithSocket.port}`)
|
||||
}
|
||||
|
||||
// ====================
|
||||
// === LocalBackend ===
|
||||
// ====================
|
||||
|
||||
/** The currently open project and its ID. */
|
||||
interface CurrentlyOpenProjectInfo {
|
||||
id: projectManager.ProjectId
|
||||
project: projectManager.OpenProject
|
||||
}
|
||||
|
||||
/** Class for sending requests to the Project Manager API endpoints.
|
||||
* This is used instead of the cloud backend API when managing local projects from the dashboard. */
|
||||
export class LocalBackend implements Partial<backend.Backend> {
|
||||
static currentlyOpeningProjectId: backend.ProjectId | null = null
|
||||
static currentlyOpenProject: CurrentlyOpenProjectInfo | null = null
|
||||
static currentlyOpenProjects = new Map<projectManager.ProjectId, projectManager.OpenProject>()
|
||||
readonly type = backend.BackendType.local
|
||||
private readonly projectManager = projectManager.ProjectManager.default()
|
||||
|
||||
@ -53,17 +47,16 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
id: project.id,
|
||||
title: project.name,
|
||||
modifiedAt: project.lastOpened ?? project.created,
|
||||
parentId: newtype.asNewtype<backend.AssetId>(''),
|
||||
parentId: backend.DirectoryId(''),
|
||||
permissions: [],
|
||||
projectState: {
|
||||
type:
|
||||
project.id === LocalBackend.currentlyOpenProject?.id
|
||||
? backend.ProjectState.opened
|
||||
: project.id === LocalBackend.currentlyOpeningProjectId
|
||||
? backend.ProjectState.openInProgress
|
||||
: project.lastOpened != null
|
||||
? backend.ProjectState.closed
|
||||
: backend.ProjectState.created,
|
||||
type: LocalBackend.currentlyOpenProjects.has(project.id)
|
||||
? backend.ProjectState.opened
|
||||
: project.id === LocalBackend.currentlyOpeningProjectId
|
||||
? backend.ProjectState.openInProgress
|
||||
: project.lastOpened != null
|
||||
? backend.ProjectState.closed
|
||||
: backend.ProjectState.created,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@ -91,7 +84,7 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
|
||||
const project = await this.projectManager.createProject({
|
||||
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
|
||||
name: projectManager.ProjectName(body.projectName),
|
||||
...(body.projectTemplateName != null
|
||||
? { projectTemplate: body.projectTemplateName }
|
||||
: {}),
|
||||
@ -111,37 +104,45 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
/** Close the project identified by the given project ID.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async closeProject(projectId: backend.ProjectId): Promise<void> {
|
||||
async closeProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
|
||||
if (LocalBackend.currentlyOpeningProjectId === projectId) {
|
||||
LocalBackend.currentlyOpeningProjectId = null
|
||||
}
|
||||
if (LocalBackend.currentlyOpenProject?.id === projectId) {
|
||||
LocalBackend.currentlyOpenProject = null
|
||||
LocalBackend.currentlyOpenProjects.delete(projectId)
|
||||
try {
|
||||
await this.projectManager.closeProject({ projectId })
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to close project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
|
||||
)
|
||||
}
|
||||
await this.projectManager.closeProject({ projectId })
|
||||
}
|
||||
|
||||
/** Close the project identified by the given project ID.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
|
||||
if (projectId !== LocalBackend.currentlyOpenProject?.id) {
|
||||
const cachedProject = LocalBackend.currentlyOpenProjects.get(projectId)
|
||||
if (cachedProject == null) {
|
||||
const result = await this.projectManager.listProjects({})
|
||||
const project = result.projects.find(listedProject => listedProject.id === projectId)
|
||||
const engineVersion = project?.engineVersion
|
||||
if (project == null) {
|
||||
throw new Error(`The project ID '${projectId}' is invalid.`)
|
||||
} else if (engineVersion == null) {
|
||||
throw new Error(`The project '${projectId}' does not have an engine version.`)
|
||||
throw new Error(`The project '${project.name}' does not have an engine version.`)
|
||||
} else {
|
||||
return Promise.resolve<backend.Project>({
|
||||
return {
|
||||
name: project.name,
|
||||
engineVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
lifecycle: backend.detectVersionLifecycle(engineVersion),
|
||||
value: engineVersion,
|
||||
},
|
||||
ideVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
lifecycle: backend.detectVersionLifecycle(engineVersion),
|
||||
value: engineVersion,
|
||||
},
|
||||
jsonAddress: null,
|
||||
@ -157,43 +158,55 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
? backend.ProjectState.closed
|
||||
: backend.ProjectState.created,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const project = LocalBackend.currentlyOpenProject.project
|
||||
return Promise.resolve<backend.Project>({
|
||||
name: project.projectName,
|
||||
return {
|
||||
name: cachedProject.projectName,
|
||||
engineVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
value: project.engineVersion,
|
||||
lifecycle: backend.detectVersionLifecycle(cachedProject.engineVersion),
|
||||
value: cachedProject.engineVersion,
|
||||
},
|
||||
ideVersion: {
|
||||
lifecycle: backend.VersionLifecycle.stable,
|
||||
value: project.engineVersion,
|
||||
lifecycle: backend.detectVersionLifecycle(cachedProject.engineVersion),
|
||||
value: cachedProject.engineVersion,
|
||||
},
|
||||
jsonAddress: ipWithSocketToAddress(project.languageServerJsonAddress),
|
||||
binaryAddress: ipWithSocketToAddress(project.languageServerBinaryAddress),
|
||||
jsonAddress: ipWithSocketToAddress(cachedProject.languageServerJsonAddress),
|
||||
binaryAddress: ipWithSocketToAddress(cachedProject.languageServerBinaryAddress),
|
||||
organizationId: '',
|
||||
packageName: project.projectName,
|
||||
packageName: cachedProject.projectName,
|
||||
projectId,
|
||||
state: {
|
||||
type: backend.ProjectState.opened,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare a project for execution.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async openProject(projectId: backend.ProjectId): Promise<void> {
|
||||
async openProject(
|
||||
projectId: backend.ProjectId,
|
||||
_body: backend.OpenProjectRequestBody | null,
|
||||
title: string | null
|
||||
): Promise<void> {
|
||||
LocalBackend.currentlyOpeningProjectId = projectId
|
||||
const project = await this.projectManager.openProject({
|
||||
projectId,
|
||||
missingComponentAction: projectManager.MissingComponentAction.install,
|
||||
})
|
||||
if (LocalBackend.currentlyOpeningProjectId === projectId) {
|
||||
LocalBackend.currentlyOpenProject = { id: projectId, project }
|
||||
if (!LocalBackend.currentlyOpenProjects.has(projectId)) {
|
||||
try {
|
||||
const project = await this.projectManager.openProject({
|
||||
projectId,
|
||||
missingComponentAction: projectManager.MissingComponentAction.install,
|
||||
})
|
||||
LocalBackend.currentlyOpenProjects.set(projectId, project)
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to open project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -210,7 +223,7 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
if (body.projectName != null) {
|
||||
await this.projectManager.renameProject({
|
||||
projectId,
|
||||
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
|
||||
name: projectManager.ProjectName(body.projectName),
|
||||
})
|
||||
}
|
||||
const result = await this.projectManager.listProjects({})
|
||||
@ -219,7 +232,7 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
if (project == null) {
|
||||
throw new Error(`The project ID '${projectId}' is invalid.`)
|
||||
} else if (engineVersion == null) {
|
||||
throw new Error(`The project '${projectId}' does not have an engine version.`)
|
||||
throw new Error(`The project '${project.name}' does not have an engine version.`)
|
||||
} else {
|
||||
return {
|
||||
ami: null,
|
||||
@ -242,13 +255,20 @@ export class LocalBackend implements Partial<backend.Backend> {
|
||||
/** Delete a project.
|
||||
*
|
||||
* @throws An error if the JSON-RPC call fails. */
|
||||
async deleteProject(projectId: backend.ProjectId): Promise<void> {
|
||||
async deleteProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
|
||||
if (LocalBackend.currentlyOpeningProjectId === projectId) {
|
||||
LocalBackend.currentlyOpeningProjectId = null
|
||||
}
|
||||
if (LocalBackend.currentlyOpenProject?.id === projectId) {
|
||||
LocalBackend.currentlyOpenProject = null
|
||||
LocalBackend.currentlyOpenProjects.delete(projectId)
|
||||
try {
|
||||
await this.projectManager.deleteProject({ projectId })
|
||||
return
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Unable to delete project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}: ${errorModule.tryGetMessage(error) ?? 'unknown error'}.`
|
||||
)
|
||||
}
|
||||
await this.projectManager.deleteProject({ projectId })
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,23 @@
|
||||
/** @file Utilities for working with permissions. */
|
||||
import * as backend from './backend'
|
||||
|
||||
/** Returns an array containing the owner permission if `owner` is not `null`;
|
||||
* else returns an empty array (`[]`). */
|
||||
export function tryGetSingletonOwnerPermission(owner: backend.UserOrOrganization | null) {
|
||||
return owner != null
|
||||
? [
|
||||
{
|
||||
user: {
|
||||
// The names are defined by the backend and cannot be changed.
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
pk: backend.Subject(''),
|
||||
organization_id: owner.id,
|
||||
user_email: owner.email,
|
||||
user_name: owner.name,
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
},
|
||||
permission: backend.PermissionAction.own,
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
/** @file Types and functions for representing optimistic state.
|
||||
* Optimistic UI is when UI updates are applied immediately with the expected result, instead of
|
||||
* waiting for a server response, based on the assumption that virtually all requests succeed.
|
||||
* In our case it is MANDATORY because immediate user feedback is important. */
|
||||
|
||||
// ================
|
||||
// === Presence ===
|
||||
// ================
|
||||
|
||||
/** The state of an item being synced to a remote server. */
|
||||
export enum Presence {
|
||||
/** The item is present. */
|
||||
present = 'present',
|
||||
/** The item will be inserted, but the backend request has not yet finished. */
|
||||
inserting = 'inserting',
|
||||
/** The item will be deleted, but the backend request has not yet finished. */
|
||||
deleting = 'deleting',
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The corresponding CSS classes for table rows, for each {@link Presence}. */
|
||||
export const CLASS_NAME: Record<Presence, string> = {
|
||||
[Presence.present]: '',
|
||||
[Presence.deleting]: 'hidden',
|
||||
[Presence.inserting]: 'opacity-50 pointer-events-none-recursive',
|
||||
} as const
|
@ -53,14 +53,25 @@ interface JSONRPCErrorResponse extends JSONRPCBaseResponse {
|
||||
/** The return value of a JSON-RPC call. */
|
||||
type JSONRPCResponse<T> = JSONRPCErrorResponse | JSONRPCSuccessResponse<T>
|
||||
|
||||
// These are constructor functions that construct values of the type they are named after.
|
||||
/* eslint-disable @typescript-eslint/no-redeclare */
|
||||
|
||||
// This intentionally has the same brand as in the cloud backend API.
|
||||
/** An ID of a project. */
|
||||
export type ProjectId = newtype.Newtype<string, 'ProjectId'>
|
||||
/** Create a {@link ProjectId}. */
|
||||
export const ProjectId = newtype.newtypeConstructor<ProjectId>()
|
||||
/** A name of a project. */
|
||||
export type ProjectName = newtype.Newtype<string, 'ProjectName'>
|
||||
/** Create a {@link ProjectName}. */
|
||||
export const ProjectName = newtype.newtypeConstructor<ProjectName>()
|
||||
/** The newtype's `TypeName` is intentionally different from the name of this type alias,
|
||||
* to match the backend's newtype. */
|
||||
export type UTCDateTime = dateTime.Rfc3339DateTime
|
||||
/** Create a {@link UTCDateTime}. */
|
||||
export const UTCDateTime = newtype.newtypeConstructor<UTCDateTime>()
|
||||
|
||||
/* eslint-enable @typescript-eslint/no-redeclare */
|
||||
|
||||
/** Details for a project. */
|
||||
export interface ProjectMetadata {
|
||||
|
@ -7,7 +7,6 @@ import * as backend from './backend'
|
||||
import * as config from '../config'
|
||||
import * as http from '../http'
|
||||
import * as loggerProvider from '../providers/logger'
|
||||
import * as newtype from '../newtype'
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
@ -70,6 +69,14 @@ const CREATE_TAG_PATH = 'tags'
|
||||
const LIST_TAGS_PATH = 'tags'
|
||||
/** Relative HTTP path to the "list versions" endpoint of the Cloud backend API. */
|
||||
const LIST_VERSIONS_PATH = 'versions'
|
||||
/** Relative HTTP path to the "update directory" endpoint of the Cloud backend API. */
|
||||
function updateDirectoryPath(directoryId: backend.DirectoryId) {
|
||||
return `directories/${directoryId}`
|
||||
}
|
||||
/** Relative HTTP path to the "delete directory" endpoint of the Cloud backend API. */
|
||||
function deleteDirectoryPath(directoryId: backend.DirectoryId) {
|
||||
return `directories/${directoryId}`
|
||||
}
|
||||
/** Relative HTTP path to the "close project" endpoint of the Cloud backend API. */
|
||||
function closeProjectPath(projectId: backend.ProjectId) {
|
||||
return `projects/${projectId}/close`
|
||||
@ -228,7 +235,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return organization info for the current user.
|
||||
*
|
||||
* @returns `null` if any status code other than 200 OK was received. */
|
||||
* @returns `null` if a non-successful status code (not 200-299) was received. */
|
||||
async usersMe(): Promise<backend.UserOrOrganization | null> {
|
||||
const response = await this.get<backend.UserOrOrganization>(USERS_ME_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -240,8 +247,11 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return a list of assets in a directory.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async listDirectory(query: backend.ListDirectoryRequestParams): Promise<backend.Asset[]> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async listDirectory(
|
||||
query: backend.ListDirectoryRequestParams,
|
||||
title: string | null
|
||||
): Promise<backend.Asset[]> {
|
||||
const response = await this.get<ListDirectoryResponseBody>(
|
||||
LIST_DIRECTORY_PATH +
|
||||
'?' +
|
||||
@ -255,7 +265,11 @@ export class RemoteBackend implements backend.Backend {
|
||||
// The directory is probably empty.
|
||||
return []
|
||||
} else if (query.parentId != null) {
|
||||
return this.throw(`Unable to list directory with ID '${query.parentId}'.`)
|
||||
return this.throw(
|
||||
`Unable to list directory ${
|
||||
title != null ? `'${title}'` : `with ID '${query.parentId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return this.throw('Unable to list root directory.')
|
||||
}
|
||||
@ -270,9 +284,11 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Create a directory.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async createDirectory(body: backend.CreateDirectoryRequestBody): Promise<backend.Directory> {
|
||||
const response = await this.post<backend.Directory>(CREATE_DIRECTORY_PATH, body)
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async createDirectory(
|
||||
body: backend.CreateDirectoryRequestBody
|
||||
): Promise<backend.CreatedDirectory> {
|
||||
const response = await this.post<backend.CreatedDirectory>(CREATE_DIRECTORY_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to create directory with name '${body.title}'.`)
|
||||
} else {
|
||||
@ -280,9 +296,48 @@ export class RemoteBackend implements backend.Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the name of a directory.
|
||||
*
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async updateDirectory(
|
||||
directoryId: backend.DirectoryId,
|
||||
body: backend.UpdateDirectoryRequestBody,
|
||||
title: string | null
|
||||
) {
|
||||
const response = await this.put<backend.UpdatedDirectory>(
|
||||
updateDirectoryPath(directoryId),
|
||||
body
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to update directory ${
|
||||
title != null ? `'${title}'` : `with ID '${directoryId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
/** Change the name of a directory.
|
||||
*
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async deleteDirectory(directoryId: backend.DirectoryId, title: string | null) {
|
||||
const response = await this.delete(deleteDirectoryPath(directoryId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(
|
||||
`Unable to delete directory ${
|
||||
title != null ? `'${title}'` : `with ID '${directoryId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a list of projects belonging to the current user.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async listProjects(): Promise<backend.ListedProject[]> {
|
||||
const response = await this.get<ListProjectsResponseBody>(LIST_PROJECTS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -291,20 +346,16 @@ export class RemoteBackend implements backend.Backend {
|
||||
return (await response.json()).projects.map(project => ({
|
||||
...project,
|
||||
jsonAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}json`)
|
||||
: null,
|
||||
project.address != null ? backend.Address(`${project.address}json`) : null,
|
||||
binaryAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}binary`)
|
||||
: null,
|
||||
project.address != null ? backend.Address(`${project.address}binary`) : null,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
|
||||
const response = await this.post<backend.CreatedProject>(CREATE_PROJECT_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -316,11 +367,15 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Close a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async closeProject(projectId: backend.ProjectId): Promise<void> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async closeProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
|
||||
const response = await this.post(closeProjectPath(projectId), {})
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to close project with ID '${projectId}'.`)
|
||||
return this.throw(
|
||||
`Unable to close project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -328,37 +383,46 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return details for a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async getProjectDetails(projectId: backend.ProjectId): Promise<backend.Project> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async getProjectDetails(
|
||||
projectId: backend.ProjectId,
|
||||
title: string | null
|
||||
): Promise<backend.Project> {
|
||||
const response = await this.get<backend.ProjectRaw>(getProjectDetailsPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to get details of project with ID '${projectId}'.`)
|
||||
return this.throw(
|
||||
`Unable to get details of project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
const project = await response.json()
|
||||
return {
|
||||
...project,
|
||||
jsonAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}json`)
|
||||
: null,
|
||||
project.address != null ? backend.Address(`${project.address}json`) : null,
|
||||
binaryAddress:
|
||||
project.address != null
|
||||
? newtype.asNewtype<backend.Address>(`${project.address}binary`)
|
||||
: null,
|
||||
project.address != null ? backend.Address(`${project.address}binary`) : null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepare a project for execution.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async openProject(
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.OpenProjectRequestBody = DEFAULT_OPEN_PROJECT_BODY
|
||||
body: backend.OpenProjectRequestBody | null,
|
||||
title: string | null
|
||||
): Promise<void> {
|
||||
const response = await this.post(openProjectPath(projectId), body)
|
||||
const response = await this.post(
|
||||
openProjectPath(projectId),
|
||||
body ?? DEFAULT_OPEN_PROJECT_BODY
|
||||
)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to open project with ID '${projectId}'.`)
|
||||
return this.throw(
|
||||
`Unable to open project ${title != null ? `'${title}'` : `with ID '${projectId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -366,14 +430,19 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Update the name or AMI of a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async projectUpdate(
|
||||
projectId: backend.ProjectId,
|
||||
body: backend.ProjectUpdateRequestBody
|
||||
body: backend.ProjectUpdateRequestBody,
|
||||
title: string | null
|
||||
): Promise<backend.UpdatedProject> {
|
||||
const response = await this.put<backend.UpdatedProject>(projectUpdatePath(projectId), body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to update project with ID '${projectId}'.`)
|
||||
return this.throw(
|
||||
`Unable to update project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -381,11 +450,15 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Delete a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteProject(projectId: backend.ProjectId): Promise<void> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async deleteProject(projectId: backend.ProjectId, title: string | null): Promise<void> {
|
||||
const response = await this.delete(deleteProjectPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to delete project with ID '${projectId}'.`)
|
||||
return this.throw(
|
||||
`Unable to delete project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -393,11 +466,18 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return the resource usage of a project.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async checkResources(projectId: backend.ProjectId): Promise<backend.ResourceUsage> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async checkResources(
|
||||
projectId: backend.ProjectId,
|
||||
title: string | null
|
||||
): Promise<backend.ResourceUsage> {
|
||||
const response = await this.get<backend.ResourceUsage>(checkResourcesPath(projectId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to get resource usage for project with ID '${projectId}'.`)
|
||||
return this.throw(
|
||||
`Unable to get resource usage for project ${
|
||||
title != null ? `'${title}'` : `with ID '${projectId}'`
|
||||
}.`
|
||||
)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -405,7 +485,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return a list of files accessible by the current user.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async listFiles(): Promise<backend.File[]> {
|
||||
const response = await this.get<ListFilesResponseBody>(LIST_FILES_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -417,7 +497,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Upload a file.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async uploadFile(
|
||||
params: backend.UploadFileRequestParams,
|
||||
body: Blob
|
||||
@ -451,11 +531,13 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Delete a file.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteFile(fileId: backend.FileId): Promise<void> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async deleteFile(fileId: backend.FileId, title: string | null): Promise<void> {
|
||||
const response = await this.delete(deleteFilePath(fileId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to delete file with ID '${fileId}'.`)
|
||||
return this.throw(
|
||||
`Unable to delete file ${title != null ? `'${title}'` : `with ID '${fileId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -463,7 +545,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Create a secret environment variable.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async createSecret(body: backend.CreateSecretRequestBody): Promise<backend.SecretAndInfo> {
|
||||
const response = await this.post<backend.SecretAndInfo>(CREATE_SECRET_PATH, body)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -475,11 +557,13 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return a secret environment variable.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async getSecret(secretId: backend.SecretId): Promise<backend.Secret> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async getSecret(secretId: backend.SecretId, title: string | null): Promise<backend.Secret> {
|
||||
const response = await this.get<backend.Secret>(getSecretPath(secretId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to get secret with ID '${secretId}'.`)
|
||||
return this.throw(
|
||||
`Unable to get secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return await response.json()
|
||||
}
|
||||
@ -487,7 +571,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return the secret environment variables accessible by the user.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async listSecrets(): Promise<backend.SecretInfo[]> {
|
||||
const response = await this.get<ListSecretsResponseBody>(LIST_SECRETS_PATH)
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -499,11 +583,13 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Delete a secret environment variable.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
async deleteSecret(secretId: backend.SecretId): Promise<void> {
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async deleteSecret(secretId: backend.SecretId, title: string | null): Promise<void> {
|
||||
const response = await this.delete(deleteSecretPath(secretId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
return this.throw(`Unable to delete secret with ID '${secretId}'.`)
|
||||
return this.throw(
|
||||
`Unable to delete secret ${title != null ? `'${title}'` : `with ID '${secretId}'`}.`
|
||||
)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
@ -511,7 +597,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Create a file tag or project tag.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async createTag(body: backend.CreateTagRequestBody): Promise<backend.TagInfo> {
|
||||
const response = await this.post<backend.TagInfo>(CREATE_TAG_PATH, {
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
@ -530,7 +616,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return file tags or project tags accessible by the user.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async listTags(params: backend.ListTagsRequestParams): Promise<backend.Tag[]> {
|
||||
const response = await this.get<ListTagsResponseBody>(
|
||||
LIST_TAGS_PATH +
|
||||
@ -549,7 +635,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Delete a secret environment variable.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async deleteTag(tagId: backend.TagId): Promise<void> {
|
||||
const response = await this.delete(deleteTagPath(tagId))
|
||||
if (!responseIsSuccessful(response)) {
|
||||
@ -561,7 +647,7 @@ export class RemoteBackend implements backend.Backend {
|
||||
|
||||
/** Return list of backend or IDE versions.
|
||||
*
|
||||
* @throws An error if any status code other than 200 OK was received. */
|
||||
* @throws An error if a non-successful status code (not 200-299) was received. */
|
||||
async listVersions(
|
||||
params: backend.ListVersionsRequestParams
|
||||
): Promise<[backend.Version, ...backend.Version[]]> {
|
||||
|
@ -0,0 +1,152 @@
|
||||
/** @file A registry for keyboard and mouse shortcuts. */
|
||||
import * as React from 'react'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
// =============
|
||||
// === Types ===
|
||||
// =============
|
||||
|
||||
/** All possible mouse actions for which shortcuts can be registered. */
|
||||
export enum MouseAction {
|
||||
editName = 'edit-name',
|
||||
selectAdditional = 'select-additional',
|
||||
selectRange = 'select-range',
|
||||
selectAdditionalRange = 'select-additional-range',
|
||||
}
|
||||
|
||||
/** All possible keyboard actions for which shortcuts can be registered. */
|
||||
export enum KeyboardAction {
|
||||
closeModal = 'close-modal',
|
||||
cancelEditName = 'cancel-edit-name',
|
||||
}
|
||||
|
||||
/** Valid mouse buttons. The values of each enum member is its corresponding value of
|
||||
* `MouseEvent.button`. */
|
||||
export enum MouseButton {
|
||||
left = 0,
|
||||
middle = 1,
|
||||
right = 2,
|
||||
back = 3,
|
||||
forward = 4,
|
||||
}
|
||||
|
||||
/** Restrictions on modifier keys that can trigger a shortcut.
|
||||
*
|
||||
* If a key is omitted, the shortcut will be triggered regardless of its value in the event. */
|
||||
interface Modifiers {
|
||||
ctrl?: boolean
|
||||
alt?: boolean
|
||||
shift?: boolean
|
||||
meta?: boolean
|
||||
}
|
||||
|
||||
/** A keyboard shortcut. */
|
||||
export interface KeyboardShortcut extends Modifiers {
|
||||
// Every printable character is a valid value for `key`, so unions and enums are both
|
||||
// not an option here.
|
||||
key: string
|
||||
}
|
||||
|
||||
/** A mouse shortcut. If a key is omitted, that means its value does not matter. */
|
||||
export interface MouseShortcut extends Modifiers {
|
||||
button: MouseButton
|
||||
}
|
||||
|
||||
/** All possible modifier keys. */
|
||||
export type ModifierKey = 'Alt' | 'Ctrl' | 'Meta' | 'Shift'
|
||||
|
||||
// ===========================
|
||||
// === modifiersMatchEvent ===
|
||||
// ===========================
|
||||
|
||||
/** Return `true` if and only if the modifiers match the evenet's modifier key states. */
|
||||
function modifiersMatchEvent(
|
||||
modifiers: Modifiers,
|
||||
event: KeyboardEvent | MouseEvent | React.KeyboardEvent | React.MouseEvent
|
||||
) {
|
||||
return (
|
||||
('ctrl' in modifiers ? event.ctrlKey === modifiers.ctrl : true) &&
|
||||
('alt' in modifiers ? event.altKey === modifiers.alt : true) &&
|
||||
('shift' in modifiers ? event.shiftKey === modifiers.shift : true) &&
|
||||
('meta' in modifiers ? event.metaKey === modifiers.meta : true)
|
||||
)
|
||||
}
|
||||
|
||||
// ========================
|
||||
// === ShortcutRegistry ===
|
||||
// ========================
|
||||
|
||||
/** Holds all keyboard and mouse shortcuts, and provides functions to detect them. */
|
||||
export class ShortcutRegistry {
|
||||
/** Create a {@link ShortcutRegistry}. */
|
||||
constructor(
|
||||
public keyboardShortcuts: Record<KeyboardAction, KeyboardShortcut[]>,
|
||||
public mouseShortcuts: Record<MouseAction, MouseShortcut[]>
|
||||
) {}
|
||||
|
||||
/** Return `true` if the specified action is being triggered by the given event. */
|
||||
matchesKeyboardAction(action: KeyboardAction, event: KeyboardEvent | React.KeyboardEvent) {
|
||||
return this.keyboardShortcuts[action].some(
|
||||
shortcut => shortcut.key === event.key && modifiersMatchEvent(shortcut, event)
|
||||
)
|
||||
}
|
||||
|
||||
/** Return `true` if the specified action is being triggered by the given event. */
|
||||
matchesMouseAction(action: MouseAction, event: MouseEvent | React.MouseEvent) {
|
||||
return this.mouseShortcuts[action].some(
|
||||
shortcut => shortcut.button === event.button && modifiersMatchEvent(shortcut, event)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A shorthand for creating a {@link KeyboardShortcut}. Should only be used in
|
||||
* {@link DEFAULT_KEYBOARD_SHORTCUTS}. */
|
||||
function keybind(modifiers: ModifierKey[], key: string): KeyboardShortcut {
|
||||
return {
|
||||
key: key,
|
||||
ctrl: modifiers.includes('Ctrl'),
|
||||
alt: modifiers.includes('Alt'),
|
||||
shift: modifiers.includes('Shift'),
|
||||
meta: modifiers.includes('Meta'),
|
||||
}
|
||||
}
|
||||
|
||||
/** A shorthand for creating a {@link MouseShortcut}. Should only be used in
|
||||
* {@link DEFAULT_MOUSE_SHORTCUTS}. */
|
||||
function mousebind(modifiers: ModifierKey[], button: MouseButton): MouseShortcut {
|
||||
return {
|
||||
button: button,
|
||||
ctrl: modifiers.includes('Ctrl'),
|
||||
alt: modifiers.includes('Alt'),
|
||||
shift: modifiers.includes('Shift'),
|
||||
meta: modifiers.includes('Meta'),
|
||||
}
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Constants ===
|
||||
// =================
|
||||
|
||||
/** The equivalent of the `Control` key for the current platform. */
|
||||
const CTRL = detect.platform() === detect.Platform.macOS ? 'Meta' : 'Ctrl'
|
||||
|
||||
/** The default keyboard shortcuts. */
|
||||
const DEFAULT_KEYBOARD_SHORTCUTS: Record<KeyboardAction, KeyboardShortcut[]> = {
|
||||
[KeyboardAction.closeModal]: [keybind([], 'Escape')],
|
||||
[KeyboardAction.cancelEditName]: [keybind([], 'Escape')],
|
||||
}
|
||||
|
||||
/** The default mouse shortcuts. */
|
||||
const DEFAULT_MOUSE_SHORTCUTS: Record<MouseAction, MouseShortcut[]> = {
|
||||
[MouseAction.editName]: [mousebind([CTRL], MouseButton.left)],
|
||||
[MouseAction.selectAdditional]: [mousebind([CTRL], MouseButton.left)],
|
||||
[MouseAction.selectRange]: [mousebind(['Shift'], MouseButton.left)],
|
||||
[MouseAction.selectAdditionalRange]: [mousebind([CTRL, 'Shift'], MouseButton.left)],
|
||||
}
|
||||
|
||||
/** The global instance of the shortcut registry. */
|
||||
export const SHORTCUT_REGISTRY = new ShortcutRegistry(
|
||||
DEFAULT_KEYBOARD_SHORTCUTS,
|
||||
DEFAULT_MOUSE_SHORTCUTS
|
||||
)
|
@ -0,0 +1,12 @@
|
||||
/** @file An enum specifying the main content of the screen. Only one should be visible
|
||||
* at a time. */
|
||||
|
||||
// ===========
|
||||
// === Tab ===
|
||||
// ===========
|
||||
|
||||
/** Main content of the screen. Only one should be visible at a time. */
|
||||
export enum Tab {
|
||||
dashboard = 'dashboard',
|
||||
ide = 'ide',
|
||||
}
|
@ -16,3 +16,12 @@ export const PREVIOUS_PASSWORD_PATTERN = '^[\\S]+.*[\\S]+$'
|
||||
export const PREVIOUS_PASSWORD_TITLE =
|
||||
'Your password must neither start nor end with whitespace, and must contain ' +
|
||||
'at least two characters.'
|
||||
|
||||
// The Project Manager has restrictions on names of projects.
|
||||
/** Regex pattern for valid names for local projects.
|
||||
* @see https://github.com/enso-org/enso/blob/72ec775d8cf46b1862884fe7905477354943f0a5/lib/scala/pkg/src/main/scala/org/enso/pkg/validation/NameValidation.scala#L37
|
||||
*/
|
||||
export const LOCAL_PROJECT_NAME_PATTERN = '[A-Z]+[a-z]*(?:_\\d+|_[A-Z]+[a-z]*)*'
|
||||
/** Human readable explanation of project name restrictions for local projects. */
|
||||
export const LOCAL_PROJECT_NAME_TITLE =
|
||||
'Project names must be in Upper_Snake_Case. (Numbers (_0, _1) are also allowed.)'
|
||||
|
@ -29,24 +29,6 @@ export function tryGetMessage(error: unknown): string | null {
|
||||
: null
|
||||
}
|
||||
|
||||
// ================================
|
||||
// === Type assertions (unsafe) ===
|
||||
// ================================
|
||||
|
||||
/** Assumes an unknown value is an {@link Error}. */
|
||||
export function unsafeAsError<T>(error: MustBeAny<T>) {
|
||||
// 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
|
||||
}
|
||||
|
||||
/** Extracts the `message` property of a value, by first assuming it is an {@link Error}. */
|
||||
export function unsafeIntoErrorMessage<T>(error: MustBeAny<T>) {
|
||||
return unsafeAsError(error).message
|
||||
}
|
||||
|
||||
// ============================
|
||||
// === UnreachableCaseError ===
|
||||
// ============================
|
||||
@ -64,3 +46,10 @@ export class UnreachableCaseError extends Error {
|
||||
super(`Unreachable case: ${JSON.stringify(value)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A function that throws an {@link UnreachableCaseError} so that it can be used
|
||||
* in an expresison.
|
||||
* @throws {UnreachableCaseError} Always. */
|
||||
export function unreachable(value: never): never {
|
||||
throw new UnreachableCaseError(value)
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
/** @file Module containing common custom React hooks used throughout out Dashboard. */
|
||||
import * as React from 'react'
|
||||
import * as reactDom from 'react-dom'
|
||||
import * as router from 'react-router'
|
||||
|
||||
import * as app from './components/app'
|
||||
@ -100,21 +101,61 @@ export function useNavigate() {
|
||||
return navigate
|
||||
}
|
||||
|
||||
// ================
|
||||
// === useEvent ===
|
||||
// ================
|
||||
// =======================
|
||||
// === Reactive Events ===
|
||||
// =======================
|
||||
|
||||
/** A wrapper around `useState` that sets its value to `null` after the current render. */
|
||||
export function useEvent<T>(): [event: T | null, dispatchEvent: (value: T | null) => void] {
|
||||
const [event, setEvent] = React.useState<T | null>(null)
|
||||
/** 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 {}
|
||||
|
||||
React.useEffect(() => {
|
||||
/** 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>(): [
|
||||
event: T | null,
|
||||
dispatchEvent: (event: T) => void
|
||||
] {
|
||||
const [event, rawDispatchEvent] = React.useState<T | null>(null)
|
||||
const dispatchEvent = React.useCallback(
|
||||
(innerEvent: T) => {
|
||||
setTimeout(() => {
|
||||
reactDom.flushSync(() => {
|
||||
rawDispatchEvent(innerEvent)
|
||||
})
|
||||
}, 0)
|
||||
},
|
||||
|
||||
[rawDispatchEvent]
|
||||
)
|
||||
return [event, dispatchEvent]
|
||||
}
|
||||
|
||||
/** A wrapper around `useEffect` that has `event` as its sole dependency. */
|
||||
export function useEventHandler<T extends KnownEvent>(
|
||||
event: T | null,
|
||||
effect: (event: T) => Promise<void> | void
|
||||
) {
|
||||
let hasEffectRun = false
|
||||
React.useLayoutEffect(() => {
|
||||
if (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
|
||||
}
|
||||
}
|
||||
if (event != null) {
|
||||
setEvent(null)
|
||||
void effect(event)
|
||||
}
|
||||
}, [event])
|
||||
|
||||
return [event, setEvent]
|
||||
}
|
||||
|
||||
// =========================================
|
||||
|
@ -45,9 +45,15 @@ function run(props: app.AppProps) {
|
||||
// `supportsDeepLinks` will be incorrect when accessing the installed Electron app's pages
|
||||
// via the browser.
|
||||
const actuallySupportsDeepLinks = supportsDeepLinks && detect.isRunningInElectron()
|
||||
reactDOM
|
||||
.createRoot(root)
|
||||
.render(<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />)
|
||||
reactDOM.createRoot(root).render(
|
||||
IS_DEV_MODE ? (
|
||||
<React.StrictMode>
|
||||
<App {...props} />
|
||||
</React.StrictMode>
|
||||
) : (
|
||||
<App {...props} supportsDeepLinks={actuallySupportsDeepLinks} />
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,8 +37,10 @@ interface NotNewtype {
|
||||
_$type?: never
|
||||
}
|
||||
|
||||
/** Converts a value that is not a newtype, to a value that is a newtype. */
|
||||
export function asNewtype<T extends Newtype<unknown, string>>(s: UnNewtype<T>): T {
|
||||
/** Converts a value that is not a newtype, to a value that is a newtype.
|
||||
* This function intentionally returns another function, to ensure that each function instance
|
||||
* is only used for one type, avoiding the de-optimization caused by polymorphic functions. */
|
||||
export function newtypeConstructor<T extends Newtype<unknown, string>>() {
|
||||
// This cast is unsafe.
|
||||
// `T` has an extra property `_$type` which is used purely for typechecking
|
||||
// and does not exist at runtime.
|
||||
@ -46,5 +48,5 @@ export function asNewtype<T extends Newtype<unknown, string>>(s: UnNewtype<T>):
|
||||
// The property name is specifically chosen to trigger eslint's `naming-convention` lint,
|
||||
// so it should not be possible to accidentally create a value with such a type.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
return s as unknown as T
|
||||
return (s: NotNewtype & UnNewtype<T>) => s as unknown as T
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
/** @file Defines the React provider for the project manager `Backend`, along with hooks to use the
|
||||
/** @file The React provider for the project manager `Backend`, along with hooks to use the
|
||||
* provider via the shared React context. */
|
||||
import * as React from 'react'
|
||||
|
||||
@ -47,14 +47,10 @@ export interface BackendProviderProps extends React.PropsWithChildren<object> {
|
||||
|
||||
/** A React Provider that lets components get and set the current backend. */
|
||||
export function BackendProvider(props: BackendProviderProps) {
|
||||
const { children } = props
|
||||
const { initialBackend, children } = props
|
||||
const [backend, setBackendWithoutSavingType] = React.useState<
|
||||
localBackend.LocalBackend | remoteBackend.RemoteBackend
|
||||
// This default value is UNSAFE, but must neither be `LocalBackend`, which may not be
|
||||
// available, not `RemoteBackend`, which does not work when not yet logged in.
|
||||
// Care must be taken to initialize the backend before its first usage.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
>(null!)
|
||||
>(initialBackend)
|
||||
const setBackend = React.useCallback((newBackend: AnyBackendAPI) => {
|
||||
setBackendWithoutSavingType(newBackend)
|
||||
localStorage.setItem(BACKEND_TYPE_KEY, newBackend.type)
|
||||
|
@ -1,4 +1,4 @@
|
||||
/** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the
|
||||
/** @file The React provider for the {@link Logger} interface, along with a hook to use the
|
||||
* provider via the shared React context. */
|
||||
import * as React from 'react'
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
/** @file */
|
||||
/** @file The React provider for modals, along with hooks to use the provider via
|
||||
* the shared React context. */
|
||||
import * as React from 'react'
|
||||
|
||||
// =============
|
||||
@ -6,16 +7,23 @@ import * as React from 'react'
|
||||
// =============
|
||||
|
||||
/** The type of a modal. */
|
||||
export type Modal = () => JSX.Element
|
||||
export type Modal = JSX.Element
|
||||
|
||||
/** State contained in a `SetModalContext`. */
|
||||
interface SetModalContextType {
|
||||
setModal: (modal: React.SetStateAction<Modal | null>) => void
|
||||
}
|
||||
|
||||
/** State contained in a `ModalContext`. */
|
||||
interface ModalContextType {
|
||||
modal: Modal | null
|
||||
setModal: (modal: Modal | null) => void
|
||||
}
|
||||
|
||||
const ModalContext = React.createContext<ModalContextType>({
|
||||
modal: null,
|
||||
})
|
||||
|
||||
const SetModalContext = React.createContext<SetModalContextType>({
|
||||
setModal: () => {
|
||||
// Ignored. This default value will never be used
|
||||
// as `ModalProvider` always provides its own value.
|
||||
@ -23,13 +31,31 @@ const ModalContext = React.createContext<ModalContextType>({
|
||||
})
|
||||
|
||||
/** Props for a {@link ModalProvider}. */
|
||||
export interface ModalProviderProps extends React.PropsWithChildren<object> {}
|
||||
export interface ModalProviderProps extends React.PropsWithChildren {}
|
||||
|
||||
/** A React provider containing the currently active modal. */
|
||||
export function ModalProvider(props: ModalProviderProps) {
|
||||
const { children } = props
|
||||
const [modal, setModal] = React.useState<Modal | null>(null)
|
||||
return <ModalContext.Provider value={{ modal, setModal }}>{children}</ModalContext.Provider>
|
||||
// This is NOT for optimization purposes - this is for debugging purposes,
|
||||
// so that a change of `modal` does not trigger VDOM changes everywhere in the page.
|
||||
const setModalProvider = React.useMemo(
|
||||
() => <SetModalProvider setModal={setModal}>{children}</SetModalProvider>,
|
||||
[children]
|
||||
)
|
||||
return <ModalContext.Provider value={{ modal }}>{setModalProvider}</ModalContext.Provider>
|
||||
}
|
||||
|
||||
/** Props for a {@link ModalProvider}. */
|
||||
interface InternalSetModalProviderProps extends React.PropsWithChildren {
|
||||
setModal: (modal: React.SetStateAction<Modal | null>) => void
|
||||
}
|
||||
|
||||
/** A React provider containing a function to set the currently active modal. */
|
||||
function SetModalProvider(props: InternalSetModalProviderProps) {
|
||||
const { setModal, children } = props
|
||||
|
||||
return <SetModalContext.Provider value={{ setModal }}>{children}</SetModalContext.Provider>
|
||||
}
|
||||
|
||||
/** A React context hook exposing the currently active modal, if one is currently visible. */
|
||||
@ -40,10 +66,11 @@ export function useModal() {
|
||||
|
||||
/** A React context hook exposing functions to set and unset the currently active modal. */
|
||||
export function useSetModal() {
|
||||
const { setModal: setModalRaw } = React.useContext(ModalContext)
|
||||
const { setModal: setModalRaw } = React.useContext(SetModalContext)
|
||||
const setModal: (modal: Modal) => void = setModalRaw
|
||||
const updateModal: (updater: (modal: Modal | null) => Modal | null) => void = setModalRaw
|
||||
const unsetModal = React.useCallback(() => {
|
||||
setModalRaw(null)
|
||||
}, [/* should never change */ setModalRaw])
|
||||
return { setModal, unsetModal }
|
||||
return { setModal, updateModal, unsetModal }
|
||||
}
|
||||
|
@ -0,0 +1,12 @@
|
||||
/** @file Utilities for manipulating strings. */
|
||||
|
||||
/** Return a function returning the singular or plural form of a word depending on the count of
|
||||
* items. */
|
||||
export function makePluralize(singular: string, plural: string) {
|
||||
return (count: number) => (count === 1 ? singular : plural)
|
||||
}
|
||||
|
||||
/** Return the given string, but with the first letter uppercased. */
|
||||
export function capitalizeFirst(string: string) {
|
||||
return string.replace(/^./, match => match.toUpperCase())
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
/** @file A function that generates a unique string. */
|
||||
|
||||
// ====================
|
||||
// === uniqueString ===
|
||||
// ====================
|
||||
|
||||
// This is initialized to an unusual number, to minimize the chances of collision.
|
||||
let counter = Number(new Date()) >> 2
|
||||
|
||||
/** Returns a new, mostly unique string. */
|
||||
export function uniqueString(): string {
|
||||
counter += 1
|
||||
return counter.toString()
|
||||
}
|
@ -1,62 +0,0 @@
|
||||
/** @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/backend'
|
||||
import * as remoteBackend from './dashboard/remoteBackend'
|
||||
|
||||
// ===========================
|
||||
// === uploadMultipleFiles ===
|
||||
// ===========================
|
||||
|
||||
/** Uploads multiple files to the backend, showing a continuously updated toast notification. */
|
||||
export async function uploadMultipleFiles(
|
||||
backendService: remoteBackend.RemoteBackend,
|
||||
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 }
|
||||
)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@ -6,12 +6,12 @@ import * as authentication from 'enso-authentication'
|
||||
// =================
|
||||
|
||||
/** Path to the SSE endpoint over which esbuild sends events. */
|
||||
const ESBUILD_PATH = '/esbuild'
|
||||
const ESBUILD_PATH = './esbuild'
|
||||
/** SSE event indicating a build has finished. */
|
||||
const ESBUILD_EVENT_NAME = 'change'
|
||||
/** Path to the service worker that resolves all extensionless paths to `/index.html`.
|
||||
* This service worker is required for client-side routing to work when doing local development. */
|
||||
const SERVICE_WORKER_PATH = '/serviceWorker.js'
|
||||
const SERVICE_WORKER_PATH = './serviceWorker.js'
|
||||
|
||||
// ===================
|
||||
// === Live reload ===
|
||||
|
@ -153,4 +153,9 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pointer-events-none-recursive,
|
||||
.pointer-events-none-recursive * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
374
app/ide-desktop/package-lock.json
generated
374
app/ide-desktop/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -31,7 +31,9 @@
|
||||
"@typescript-eslint/parser": "^5.55.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-plugin-jsdoc": "^40.0.2"
|
||||
"eslint-plugin-jsdoc": "^40.0.2",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"watch": "npm run watch --workspace enso-content",
|
||||
|
Loading…
Reference in New Issue
Block a user