Enable React and strict-boolean-expressions lints (#7023)

* QoL improvements

* Enable react lints and `strict-boolean-expressions`

* Address review

* Minor bugfixes

---------

Co-authored-by: Paweł Buchowski <pawel.buchowski@enso.org>
This commit is contained in:
somebody1234 2023-07-06 21:52:32 +10:00 committed by GitHub
parent d11f09c192
commit c276bc035c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
44 changed files with 612 additions and 630 deletions

View File

@ -10,6 +10,8 @@ import * as url from 'node:url'
import eslintJs from '@eslint/js' import eslintJs from '@eslint/js'
import globals from 'globals' import globals from 'globals'
import jsdoc from 'eslint-plugin-jsdoc' import jsdoc from 'eslint-plugin-jsdoc'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import tsEslint from '@typescript-eslint/eslint-plugin' import tsEslint from '@typescript-eslint/eslint-plugin'
import tsEslintParser from '@typescript-eslint/parser' import tsEslintParser from '@typescript-eslint/parser'
/* eslint-enable no-restricted-syntax */ /* eslint-enable no-restricted-syntax */
@ -241,6 +243,8 @@ export default [
plugins: { plugins: {
jsdoc: jsdoc, jsdoc: jsdoc,
'@typescript-eslint': tsEslint, '@typescript-eslint': tsEslint,
react: react,
'react-hooks': reactHooks,
}, },
languageOptions: { languageOptions: {
parser: tsEslintParser, parser: tsEslintParser,
@ -259,6 +263,7 @@ export default [
...tsEslint.configs.recommended?.rules, ...tsEslint.configs.recommended?.rules,
...tsEslint.configs['recommended-requiring-type-checking']?.rules, ...tsEslint.configs['recommended-requiring-type-checking']?.rules,
...tsEslint.configs.strict?.rules, ...tsEslint.configs.strict?.rules,
...react.configs.recommended.rules,
eqeqeq: ['error', 'always', { null: 'never' }], eqeqeq: ['error', 'always', { null: 'never' }],
'jsdoc/require-jsdoc': [ 'jsdoc/require-jsdoc': [
'error', 'error',
@ -292,6 +297,10 @@ export default [
'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES], 'no-restricted-syntax': ['error', ...RESTRICTED_SYNTAXES],
'prefer-arrow-callback': 'error', 'prefer-arrow-callback': 'error',
'prefer-const': 'error', 'prefer-const': 'error',
// Not relevant because TypeScript checks types.
'react/prop-types': 'off',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'error',
// Prefer `interface` over `type`. // Prefer `interface` over `type`.
'@typescript-eslint/consistent-type-definitions': 'error', '@typescript-eslint/consistent-type-definitions': 'error',
'@typescript-eslint/consistent-type-imports': ['error', { prefer: 'no-type-imports' }], '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'no-type-imports' }],
@ -355,7 +364,7 @@ export default [
], ],
'@typescript-eslint/restrict-template-expressions': 'error', '@typescript-eslint/restrict-template-expressions': 'error',
'@typescript-eslint/sort-type-constituents': 'error', '@typescript-eslint/sort-type-constituents': 'error',
// '@typescript-eslint/strict-boolean-expressions': 'error', '@typescript-eslint/strict-boolean-expressions': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error', '@typescript-eslint/switch-exhaustiveness-check': 'error',
'default-param-last': 'off', 'default-param-last': 'off',
'@typescript-eslint/default-param-last': 'error', '@typescript-eslint/default-param-last': 'error',

View File

@ -111,7 +111,7 @@ interface AmplifyError extends Error {
/** Hint to TypeScript if we can safely cast an `unknown` error to an {@link AmplifyError}. */ /** Hint to TypeScript if we can safely cast an `unknown` error to an {@link AmplifyError}. */
function isAmplifyError(error: unknown): error is AmplifyError { function isAmplifyError(error: unknown): error is AmplifyError {
if (error && typeof error === 'object') { if (error != null && typeof error === 'object') {
return 'code' in error && 'message' in error && 'name' in error return 'code' in error && 'message' in error && 'name' in error
} else { } else {
return false return false
@ -141,7 +141,7 @@ interface AuthError {
/** Hint to TypeScript if we can safely cast an `unknown` error to an `AuthError`. */ /** Hint to TypeScript if we can safely cast an `unknown` error to an `AuthError`. */
function isAuthError(error: unknown): error is AuthError { function isAuthError(error: unknown): error is AuthError {
if (error && typeof error === 'object') { if (error != null && typeof error === 'object') {
return 'name' in error && 'log' in error return 'name' in error && 'log' in error
} else { } else {
return false return false
@ -523,7 +523,7 @@ async function signInWithGoogle(customState: string | null) {
const provider = amplify.CognitoHostedUIIdentityProvider.Google const provider = amplify.CognitoHostedUIIdentityProvider.Google
const options = { const options = {
provider, provider,
...(customState ? { customState } : {}), ...(customState != null ? { customState } : {}),
} }
await amplify.Auth.federatedSignIn(options) await amplify.Auth.federatedSignIn(options)
} }

View File

@ -1,6 +1,6 @@
/** @file Registration confirmation page for when a user clicks the confirmation link set to their /** @file Registration confirmation page for when a user clicks the confirmation link set to their
* email address. */ * email address. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@ -31,8 +31,10 @@ function ConfirmRegistration() {
const { verificationCode, email } = parseUrlSearchParams(location.search) const { verificationCode, email } = parseUrlSearchParams(location.search)
react.useEffect(() => { // No dependencies means this runs on every render, however this component should immediately
if (!email || !verificationCode) { // navigate away so it shouldn't exist for more than a few renders.
React.useEffect(() => {
if (email == null || verificationCode == null) {
navigate(app.LOGIN_PATH) navigate(app.LOGIN_PATH)
} else { } else {
void (async () => { void (async () => {
@ -48,7 +50,7 @@ function ConfirmRegistration() {
} }
})() })()
} }
}, []) })
return <></> return <></>
} }

View File

@ -1,4 +1,6 @@
/** @file Styled wrapper around FontAwesome icons. */ /** @file Styled wrapper around FontAwesome icons. */
import * as React from 'react'
import * as fontawesome from '@fortawesome/react-fontawesome' import * as fontawesome from '@fortawesome/react-fontawesome'
import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons' import * as fontawesomeIcons from '@fortawesome/free-brands-svg-icons'

View File

@ -1,6 +1,6 @@
/** @file Container responsible for rendering and interactions in first half of forgot password /** @file Container responsible for rendering and interactions in first half of forgot password
* flow. */ * flow. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import ArrowRightIcon from 'enso-assets/arrow_right.svg' import ArrowRightIcon from 'enso-assets/arrow_right.svg'
@ -22,7 +22,7 @@ import SvgIcon from './svgIcon'
function ForgotPassword() { function ForgotPassword() {
const { forgotPassword } = auth.useAuth() const { forgotPassword } = auth.useAuth()
const [email, setEmail] = react.useState('') const [email, setEmail] = React.useState('')
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">

View File

@ -1,12 +1,12 @@
/** @file Styled input element. */ /** @file Styled input element. */
import * as react from 'react' import * as React from 'react'
// ============= // =============
// === Input === // === Input ===
// ============= // =============
/** Props for an {@link Input}. */ /** Props for an {@link Input}. */
export interface InputProps extends react.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
value: string value: string
setValue: (value: string) => void setValue: (value: string) => void
} }

View File

@ -1,5 +1,5 @@
/** @file Login component responsible for rendering and interactions in sign in flow. */ /** @file Login component responsible for rendering and interactions in sign in flow. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import ArrowRightIcon from 'enso-assets/arrow_right.svg' import ArrowRightIcon from 'enso-assets/arrow_right.svg'
@ -36,8 +36,8 @@ function Login() {
const initialEmail = parseUrlSearchParams(search) const initialEmail = parseUrlSearchParams(search)
const [email, setEmail] = react.useState(initialEmail ?? '') const [email, setEmail] = React.useState(initialEmail ?? '')
const [password, setPassword] = react.useState('') const [password, setPassword] = React.useState('')
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">

View File

@ -1,5 +1,5 @@
/** @file Registration container responsible for rendering and interactions in sign up flow. */ /** @file Registration container responsible for rendering and interactions in sign up flow. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@ -32,9 +32,9 @@ const REGISTRATION_QUERY_PARAMS = {
function Registration() { function Registration() {
const auth = authModule.useAuth() const auth = authModule.useAuth()
const location = router.useLocation() const location = router.useLocation()
const [email, setEmail] = react.useState('') const [email, setEmail] = React.useState('')
const [password, setPassword] = react.useState('') const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = react.useState('') const [confirmPassword, setConfirmPassword] = React.useState('')
const { organizationId } = parseUrlSearchParams(location.search) const { organizationId } = parseUrlSearchParams(location.search)

View File

@ -1,6 +1,6 @@
/** @file Container responsible for rendering and interactions in second half of forgot password /** @file Container responsible for rendering and interactions in second half of forgot password
* flow. */ * flow. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@ -37,10 +37,10 @@ function ResetPassword() {
const { verificationCode: initialCode, email: initialEmail } = parseUrlSearchParams(search) const { verificationCode: initialCode, email: initialEmail } = parseUrlSearchParams(search)
const [email, setEmail] = react.useState(initialEmail ?? '') const [email, setEmail] = React.useState(initialEmail ?? '')
const [code, setCode] = react.useState(initialCode ?? '') const [code, setCode] = React.useState(initialCode ?? '')
const [newPassword, setNewPassword] = react.useState('') const [newPassword, setNewPassword] = React.useState('')
const [newPasswordConfirm, setNewPasswordConfirm] = react.useState('') const [newPasswordConfirm, setNewPasswordConfirm] = React.useState('')
const onSubmit = () => { const onSubmit = () => {
if (newPassword !== newPasswordConfirm) { if (newPassword !== newPasswordConfirm) {

View File

@ -1,6 +1,6 @@
/** @file Container responsible for rendering and interactions in setting username flow, after /** @file Container responsible for rendering and interactions in setting username flow, after
* registration. */ * registration. */
import * as react from 'react' import * as React from 'react'
import ArrowRightIcon from 'enso-assets/arrow_right.svg' import ArrowRightIcon from 'enso-assets/arrow_right.svg'
import AtIcon from 'enso-assets/at.svg' import AtIcon from 'enso-assets/at.svg'
@ -22,7 +22,7 @@ function SetUsername() {
const { email } = auth.usePartialUserSession() const { email } = auth.usePartialUserSession()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const [username, setUsername] = react.useState('') const [username, setUsername] = React.useState('')
return ( return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-300"> <div className="min-h-screen flex flex-col items-center justify-center bg-gray-300">

View File

@ -3,7 +3,7 @@
* Provides an `AuthProvider` component that wraps the entire application, and a `useAuth` hook that * Provides an `AuthProvider` component that wraps the entire application, and a `useAuth` hook that
* can be used from any React component to access the currently logged-in user's session data. The * can be used from any React component to access the currently logged-in user's session data. The
* hook also provides methods for registering a user, logging in, logging out, etc. */ * hook also provides methods for registering a user, logging in, logging out, etc. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
@ -154,7 +154,7 @@ interface AuthContextType {
* So changing the cast would provide no safety guarantees, and would require us to introduce null * So changing the cast would provide no safety guarantees, and would require us to introduce null
* checks everywhere we use the context. */ * checks everywhere we use the context. */
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const AuthContext = react.createContext<AuthContextType>({} as AuthContextType) const AuthContext = React.createContext<AuthContextType>({} as AuthContextType)
// ==================== // ====================
// === AuthProvider === // === AuthProvider ===
@ -167,7 +167,7 @@ export interface AuthProviderProps {
authService: authServiceModule.AuthService authService: authServiceModule.AuthService
/** Callback to execute once the user has authenticated successfully. */ /** Callback to execute once the user has authenticated successfully. */
onAuthenticated: () => void onAuthenticated: () => void
children: react.ReactNode children: React.ReactNode
} }
/** A React provider for the Cognito API. */ /** A React provider for the Cognito API. */
@ -187,25 +187,50 @@ export function AuthProvider(props: AuthProviderProps) {
// and the function call would error. // and the function call would error.
// eslint-disable-next-line no-restricted-properties // eslint-disable-next-line no-restricted-properties
const navigate = router.useNavigate() const navigate = router.useNavigate()
const [forceOfflineMode, setForceOfflineMode] = React.useState(shouldStartInOfflineMode)
const [initialized, setInitialized] = React.useState(false)
const [userSession, setUserSession] = React.useState<UserSession | null>(null)
const [forceOfflineMode, setForceOfflineMode] = react.useState(shouldStartInOfflineMode) const goOfflineInternal = React.useCallback(() => {
const [initialized, setInitialized] = react.useState(false) setInitialized(true)
const [userSession, setUserSession] = react.useState<UserSession | null>(null) setUserSession(OFFLINE_USER_SESSION)
if (supportsLocalBackend) {
setBackendWithoutSavingType(new localBackend.LocalBackend())
} else {
// Provide dummy headers to avoid errors. This `Backend` will never be called as
// the entire UI will be disabled.
const client = new http.Client(new Headers([['Authorization', '']]))
setBackendWithoutSavingType(new remoteBackend.RemoteBackend(client, logger))
}
}, [
/* should never change */ supportsLocalBackend,
/* should never change */ logger,
/* should never change */ setBackendWithoutSavingType,
])
const goOffline = React.useCallback(() => {
toast.error('You are offline, switching to offline mode.')
goOfflineInternal()
navigate(app.DASHBOARD_PATH)
return Promise.resolve(true)
}, [/* should never change */ goOfflineInternal, /* should never change */ navigate])
// This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible // This is identical to `hooks.useOnlineCheck`, however it is inline here to avoid any possible
// circular dependency. // circular dependency.
react.useEffect(() => { React.useEffect(() => {
// `navigator.onLine` is not a dependency so that the app doesn't make the remote backend
// completely unusable on unreliable connections.
if (!navigator.onLine) { if (!navigator.onLine) {
void goOffline() void goOffline()
} }
}, [navigator.onLine]) }, [goOffline])
/** Fetch the JWT access token from the session via the AWS Amplify library. /** Fetch the JWT access token from the session via the AWS Amplify library.
* *
* When invoked, retrieves the access token (if available) from the storage method chosen when * When invoked, retrieves the access token (if available) from the storage method chosen when
* Amplify was configured (e.g. local storage). If the token is not available, return `undefined`. * Amplify was configured (e.g. local storage). If the token is not available, return `undefined`.
* If the token has expired, automatically refreshes the token and returns the new token. */ * If the token has expired, automatically refreshes the token and returns the new token. */
react.useEffect(() => { React.useEffect(() => {
const fetchSession = async () => { const fetchSession = async () => {
if (!navigator.onLine || forceOfflineMode) { if (!navigator.onLine || forceOfflineMode) {
goOfflineInternal() goOfflineInternal()
@ -280,7 +305,18 @@ export function AuthProvider(props: AuthProviderProps) {
logger.error(error) logger.error(error)
} }
}) })
}, [session]) // `userSession` MUST NOT be a dependency as this effect always does a `setUserSession`,
// which would result in an infinite loop.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
cognito,
initialized,
logger,
onAuthenticated,
session,
/* should never change */ goOfflineInternal,
/* should never change */ setBackendWithoutSavingType,
])
/** Wrap a function returning a {@link Promise} to displays a loading toast notification /** Wrap a function returning a {@link Promise} to displays a loading toast notification
* until the returned {@link Promise} finishes loading. */ * until the returned {@link Promise} finishes loading. */
@ -297,26 +333,6 @@ export function AuthProvider(props: AuthProviderProps) {
return result return result
} }
const goOfflineInternal = () => {
setInitialized(true)
setUserSession(OFFLINE_USER_SESSION)
if (supportsLocalBackend) {
setBackendWithoutSavingType(new localBackend.LocalBackend())
} else {
// Provide dummy headers to avoid errors. This `Backend` will never be called as
// the entire UI will be disabled.
const client = new http.Client(new Headers([['Authorization', '']]))
setBackendWithoutSavingType(new remoteBackend.RemoteBackend(client, logger))
}
}
const goOffline = () => {
toast.error('You are offline, switching to offline mode.')
goOfflineInternal()
navigate(app.DASHBOARD_PATH)
return Promise.resolve(true)
}
const signUp = async (username: string, password: string, organizationId: string | null) => { const signUp = async (username: string, password: string, organizationId: string | null) => {
const result = await cognito.signUp(username, password, organizationId) const result = await cognito.signUp(username, password, organizationId)
if (result.ok) { if (result.ok) {
@ -492,7 +508,7 @@ function isUserFacingError(value: unknown): value is UserFacingError {
* Only the hook is exported, and not the context, because we only want to use the hook directly and * Only the hook is exported, and not the context, because we only want to use the hook directly and
* never the context component. */ * never the context component. */
export function useAuth() { export function useAuth() {
return react.useContext(AuthContext) return React.useContext(AuthContext)
} }
// =============================== // ===============================

View File

@ -1,6 +1,6 @@
/** @file Provider for the {@link SessionContextType}, which contains information about the /** @file Provider for the {@link SessionContextType}, which contains information about the
* currently authenticated user's session. */ * currently authenticated user's session. */
import * as react from 'react' import * as React from 'react'
import * as results from 'ts-results' import * as results from 'ts-results'
@ -21,7 +21,7 @@ interface SessionContextType {
} }
/** See `AuthContext` for safety details. */ /** See `AuthContext` for safety details. */
const SessionContext = react.createContext<SessionContextType>( const SessionContext = React.createContext<SessionContextType>(
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
{} as SessionContextType {} as SessionContextType
) )
@ -46,7 +46,7 @@ export interface SessionProviderProps {
mainPageUrl: URL mainPageUrl: URL
registerAuthEventListener: listen.ListenFunction registerAuthEventListener: listen.ListenFunction
userSession: () => Promise<results.Option<cognito.UserSession>> userSession: () => Promise<results.Option<cognito.UserSession>>
children: react.ReactNode children: React.ReactNode
} }
/** A React provider for the session of the authenticated user. */ /** A React provider for the session of the authenticated user. */
@ -57,7 +57,7 @@ export function SessionProvider(props: SessionProviderProps) {
/** Flag used to avoid rendering child components until we've fetched the user's session at least /** Flag used to avoid rendering child components until we've fetched the user's session at least
* once. Avoids flash of the login screen when the user is already logged in. */ * once. Avoids flash of the login screen when the user is already logged in. */
const [initialized, setInitialized] = react.useState(false) const [initialized, setInitialized] = React.useState(false)
/** Register an async effect that will fetch the user's session whenever the `refresh` state is /** Register an async effect that will fetch the user's session whenever the `refresh` state is
* set. This is useful when a user has just logged in (as their cached credentials are * set. This is useful when a user has just logged in (as their cached credentials are
@ -78,7 +78,7 @@ export function SessionProvider(props: SessionProviderProps) {
* *
* For example, if a user clicks the signout button, this will clear the user's session, which * 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). */ * means we want the login screen to render (which is a child of this provider). */
react.useEffect(() => { React.useEffect(() => {
/** Handle Cognito authentication events /** Handle Cognito authentication events
* @throws {error.UnreachableCaseError} Never. */ * @throws {error.UnreachableCaseError} Never. */
const listener: listen.ListenerCallback = event => { const listener: listen.ListenerCallback = event => {
@ -112,7 +112,7 @@ export function SessionProvider(props: SessionProviderProps) {
* cleaned up between renders. This must be done because the `useEffect` will be called * cleaned up between renders. This must be done because the `useEffect` will be called
* multiple times during the lifetime of the component. */ * multiple times during the lifetime of the component. */
return cancel return cancel
}, [registerAuthEventListener]) }, [doRefresh, mainPageUrl, registerAuthEventListener])
const deinitializeSession = () => { const deinitializeSession = () => {
setInitialized(false) setInitialized(false)
@ -131,5 +131,5 @@ export function SessionProvider(props: SessionProviderProps) {
/** React context hook returning the session of the authenticated user. */ /** React context hook returning the session of the authenticated user. */
export function useSession() { export function useSession() {
return react.useContext(SessionContext) return React.useContext(SessionContext)
} }

View File

@ -34,7 +34,7 @@
* {@link router.Route}s require fully authenticated users (c.f. * {@link router.Route}s require fully authenticated users (c.f.
* {@link authProvider.FullUserSession}). */ * {@link authProvider.FullUserSession}). */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router-dom' import * as router from 'react-router-dom'
import * as toast from 'react-hot-toast' import * as toast from 'react-hot-toast'
@ -149,7 +149,7 @@ function AppRouter(props: AppProps) {
window.navigate = navigate window.navigate = navigate
} }
const mainPageUrl = new URL(window.location.href) const mainPageUrl = new URL(window.location.href)
const authService = react.useMemo(() => { const authService = React.useMemo(() => {
const authConfig = { navigate, ...props } const authConfig = { navigate, ...props }
return authServiceModule.initAuthService(authConfig) return authServiceModule.initAuthService(authConfig)
}, [navigate, props]) }, [navigate, props])
@ -162,7 +162,7 @@ function AppRouter(props: AppProps) {
null! null!
const routes = ( const routes = (
<router.Routes> <router.Routes>
<react.Fragment> <React.Fragment>
{/* Login & registration pages are visible to unauthenticated users. */} {/* Login & registration pages are visible to unauthenticated users. */}
<router.Route element={<authProvider.GuestLayout />}> <router.Route element={<authProvider.GuestLayout />}>
<router.Route path={REGISTRATION_PATH} element={<Registration />} /> <router.Route path={REGISTRATION_PATH} element={<Registration />} />
@ -183,7 +183,7 @@ function AppRouter(props: AppProps) {
<router.Route path={CONFIRM_REGISTRATION_PATH} element={<ConfirmRegistration />} /> <router.Route path={CONFIRM_REGISTRATION_PATH} element={<ConfirmRegistration />} />
<router.Route path={FORGOT_PASSWORD_PATH} element={<ForgotPassword />} /> <router.Route path={FORGOT_PASSWORD_PATH} element={<ForgotPassword />} />
<router.Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} /> <router.Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</react.Fragment> </React.Fragment>
</router.Routes> </router.Routes>
) )
return ( return (

View File

@ -1,4 +1,6 @@
/** @file File containing SVG icon definitions. */ /** @file File containing SVG icon definitions. */
import * as React from 'react'
/** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/342 /** TODO [NP]: https://github.com/enso-org/cloud-v2/issues/342
* These should all be regular `.svg` files rather than React components, but React doesn't include * These should all be regular `.svg` files rather than React components, but React doesn't include
* the `svg` files when building for Electron. Once the build scripts have been adapted to allow for * the `svg` files when building for Electron. Once the build scripts have been adapted to allow for

View File

@ -1,5 +1,5 @@
/** @file Managing the logic and displaying the UI for the password change function. */ /** @file Managing the logic and displaying the UI for the password change function. */
import * as react from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import ArrowRightIcon from 'enso-assets/arrow_right.svg' import ArrowRightIcon from 'enso-assets/arrow_right.svg'
@ -23,9 +23,9 @@ function ChangePasswordModal() {
const { changePassword } = auth.useAuth() const { changePassword } = auth.useAuth()
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const [oldPassword, setOldPassword] = react.useState('') const [oldPassword, setOldPassword] = React.useState('')
const [newPassword, setNewPassword] = react.useState('') const [newPassword, setNewPassword] = React.useState('')
const [confirmNewPassword, setConfirmNewPassword] = react.useState('') const [confirmNewPassword, setConfirmNewPassword] = React.useState('')
const onSubmit = async () => { const onSubmit = async () => {
if (newPassword !== confirmNewPassword) { if (newPassword !== confirmNewPassword) {

View File

@ -1,4 +1,5 @@
/** @file Modal for confirming delete of any type of asset. */ /** @file Modal for confirming delete of any type of asset. */
import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import CloseIcon from 'enso-assets/close.svg' import CloseIcon from 'enso-assets/close.svg'
@ -61,7 +62,7 @@ function ConfirmDeleteModal(props: ConfirmDeleteModalProps) {
</button> </button>
</div> </div>
<div className="m-2"> <div className="m-2">
Are you sure you want to delete the {assetType} '{name}'? Are you sure you want to delete the {assetType} &lsquo;{name}&rsquo;?
</div> </div>
<div className="m-1"> <div className="m-1">
<button <button

View File

@ -1,6 +1,6 @@
/** @file A context menu. */ /** @file A context menu. */
import * as react from 'react' import * as React from 'react'
// ================= // =================
// === Constants === // === Constants ===
@ -18,18 +18,18 @@ export interface ContextMenuProps {
// `left: number` and `top: number` may be more correct, // `left: number` and `top: number` may be more correct,
// however passing an event eliminates the chance // however passing an event eliminates the chance
// of passing the wrong coordinates from the event. // of passing the wrong coordinates from the event.
event: react.MouseEvent event: React.MouseEvent
} }
/** A context menu that opens at the current mouse position. */ /** A context menu that opens at the current mouse position. */
function ContextMenu(props: react.PropsWithChildren<ContextMenuProps>) { function ContextMenu(props: React.PropsWithChildren<ContextMenuProps>) {
const { children, event } = props const { children, event } = props
const contextMenuRef = react.useRef<HTMLDivElement>(null) const contextMenuRef = React.useRef<HTMLDivElement>(null)
const [top, setTop] = react.useState(event.pageY) const [top, setTop] = React.useState(event.pageY)
// This must be the original height before the returned element affects the `scrollHeight`. // This must be the original height before the returned element affects the `scrollHeight`.
const [bodyHeight] = react.useState(document.body.scrollHeight) const [bodyHeight] = React.useState(document.body.scrollHeight)
react.useEffect(() => { React.useEffect(() => {
if (contextMenuRef.current != null) { if (contextMenuRef.current != null) {
setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight)) setTop(Math.min(top, bodyHeight - contextMenuRef.current.clientHeight))
const boundingBox = contextMenuRef.current.getBoundingClientRect() const boundingBox = contextMenuRef.current.getBoundingClientRect()
@ -38,7 +38,7 @@ function ContextMenu(props: react.PropsWithChildren<ContextMenuProps>) {
scroll(scrollX, scrollY + scrollBy) scroll(scrollX, scrollY + scrollBy)
} }
} }
}, [children]) }, [bodyHeight, children, top])
return ( return (
<div <div

View File

@ -1,5 +1,5 @@
/** @file An entry in a context menu. */ /** @file An entry in a context menu. */
import * as react from 'react' import * as React from 'react'
// ======================== // ========================
// === ContextMenuEntry === // === ContextMenuEntry ===
@ -9,13 +9,13 @@ import * as react from 'react'
export interface ContextMenuEntryProps { export interface ContextMenuEntryProps {
disabled?: boolean disabled?: boolean
title?: string title?: string
onClick: (event: react.MouseEvent<HTMLButtonElement>) => void onClick: (event: React.MouseEvent<HTMLButtonElement>) => void
} }
// This component MUST NOT use `useState` because it is not rendered directly. // This component MUST NOT use `useState` because it is not rendered directly.
/** An item in a `ContextMenu`. */ /** An item in a `ContextMenu`. */
function ContextMenuEntry(props: react.PropsWithChildren<ContextMenuEntryProps>) { function ContextMenuEntry(props: React.PropsWithChildren<ContextMenuEntryProps>) {
const { children, disabled, title, onClick } = props const { children, disabled = false, title, onClick } = props
return ( return (
<button <button
disabled={disabled} disabled={disabled}

View File

@ -2,7 +2,7 @@
* This should never be used directly, but instead should be wrapped in a component * This should never be used directly, but instead should be wrapped in a component
* that creates a specific asset type. */ * that creates a specific asset type. */
import * as react from 'react' import * as React from 'react'
import CloseIcon from 'enso-assets/close.svg' import CloseIcon from 'enso-assets/close.svg'
@ -21,9 +21,9 @@ export interface CreateFormPassthroughProps {
} }
/** `CreateFormPassthroughProps`, plus props that should be defined in the wrapper component. */ /** `CreateFormPassthroughProps`, plus props that should be defined in the wrapper component. */
export interface CreateFormProps extends CreateFormPassthroughProps, react.PropsWithChildren { export interface CreateFormProps extends CreateFormPassthroughProps, React.PropsWithChildren {
title: string title: string
onSubmit: (event: react.FormEvent) => Promise<void> onSubmit: (event: React.FormEvent) => Promise<void>
} }
/** A form to create an element. */ /** A form to create an element. */
@ -31,7 +31,7 @@ function CreateForm(props: CreateFormProps) {
const { title, left, top, children, onSubmit: innerOnSubmit } = props const { title, left, top, children, onSubmit: innerOnSubmit } = props
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const onSubmit = async (event: react.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
await innerOnSubmit(event) await innerOnSubmit(event)
} }

View File

@ -1,6 +1,6 @@
/** @file Main dashboard component, responsible for listing user's projects as well as other /** @file Main dashboard component, responsible for listing user's projects as well as other
* interactive components. */ * interactive components. */
import * as react from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import ArrowRightSmallIcon from 'enso-assets/arrow_right_small.svg' import ArrowRightSmallIcon from 'enso-assets/arrow_right_small.svg'
@ -279,7 +279,7 @@ function Dashboard(props: DashboardProps) {
const [refresh, doRefresh] = hooks.useRefresh() const [refresh, doRefresh] = hooks.useRefresh()
const [onDirectoryNextLoaded, setOnDirectoryNextLoaded] = react.useState< const [onDirectoryNextLoaded, setOnDirectoryNextLoaded] = React.useState<
((assets: backendModule.Asset[]) => void) | null ((assets: backendModule.Asset[]) => void) | null
>(() => >(() =>
initialProjectName != null initialProjectName != null
@ -299,66 +299,67 @@ function Dashboard(props: DashboardProps) {
: null : null
) )
const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] = const [nameOfProjectToImmediatelyOpen, setNameOfProjectToImmediatelyOpen] =
react.useState(initialProjectName) React.useState(initialProjectName)
const [projectEvent, setProjectEvent] = react.useState<projectActionButton.ProjectEvent | null>( const [projectEvent, setProjectEvent] = React.useState<projectActionButton.ProjectEvent | null>(
null null
) )
const [query, setQuery] = react.useState('') const [query, setQuery] = React.useState('')
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = react.useState(false) const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
const [directoryId, setDirectoryId] = react.useState( const [directoryId, setDirectoryId] = React.useState(
session.organization != null ? rootDirectoryId(session.organization.id) : null session.organization != null ? rootDirectoryId(session.organization.id) : null
) )
const [directoryStack, setDirectoryStack] = react.useState< const [directoryStack, setDirectoryStack] = React.useState<
backendModule.Asset<backendModule.AssetType.directory>[] backendModule.Asset<backendModule.AssetType.directory>[]
>([]) >([])
// Defined by the spec as `compact` by default, however it is not ready yet. // Defined by the spec as `compact` by default, however it is not ready yet.
const [columnDisplayMode, setColumnDisplayMode] = react.useState(ColumnDisplayMode.release) const [columnDisplayMode, setColumnDisplayMode] = React.useState(ColumnDisplayMode.release)
const [tab, setTab] = react.useState(Tab.dashboard) const [tab, setTab] = React.useState(Tab.dashboard)
const [project, setProject] = react.useState<backendModule.Project | null>(null) const [project, setProject] = React.useState<backendModule.Project | null>(null)
const [selectedAssets, setSelectedAssets] = react.useState<backendModule.Asset[]>([]) const [selectedAssets, setSelectedAssets] = React.useState<backendModule.Asset[]>([])
const [isFileBeingDragged, setIsFileBeingDragged] = react.useState(false) const [isFileBeingDragged, setIsFileBeingDragged] = React.useState(false)
const [isScrollBarVisible, setIsScrollBarVisible] = react.useState(false) const [isScrollBarVisible, setIsScrollBarVisible] = React.useState(false)
const [isLoadingAssets, setIsLoadingAssets] = react.useState(true) const [isLoadingAssets, setIsLoadingAssets] = React.useState(true)
const [projectAssets, setProjectAssetsRaw] = react.useState< const [projectAssets, setProjectAssetsRaw] = React.useState<
backendModule.Asset<backendModule.AssetType.project>[] backendModule.Asset<backendModule.AssetType.project>[]
>([]) >([])
const [directoryAssets, setDirectoryAssetsRaw] = react.useState< const [directoryAssets, setDirectoryAssetsRaw] = React.useState<
backendModule.Asset<backendModule.AssetType.directory>[] backendModule.Asset<backendModule.AssetType.directory>[]
>([]) >([])
const [secretAssets, setSecretAssetsRaw] = react.useState< const [secretAssets, setSecretAssetsRaw] = React.useState<
backendModule.Asset<backendModule.AssetType.secret>[] backendModule.Asset<backendModule.AssetType.secret>[]
>([]) >([])
const [fileAssets, setFileAssetsRaw] = react.useState< const [fileAssets, setFileAssetsRaw] = React.useState<
backendModule.Asset<backendModule.AssetType.file>[] backendModule.Asset<backendModule.AssetType.file>[]
>([]) >([])
const [visibleProjectAssets, setVisibleProjectAssets] = react.useState< const [visibleProjectAssets, setVisibleProjectAssets] = React.useState<
backendModule.Asset<backendModule.AssetType.project>[] backendModule.Asset<backendModule.AssetType.project>[]
>([]) >([])
const [visibleDirectoryAssets, setVisibleDirectoryAssets] = react.useState< const [visibleDirectoryAssets, setVisibleDirectoryAssets] = React.useState<
backendModule.Asset<backendModule.AssetType.directory>[] backendModule.Asset<backendModule.AssetType.directory>[]
>([]) >([])
const [visibleSecretAssets, setVisibleSecretAssets] = react.useState< const [visibleSecretAssets, setVisibleSecretAssets] = React.useState<
backendModule.Asset<backendModule.AssetType.secret>[] backendModule.Asset<backendModule.AssetType.secret>[]
>([]) >([])
const [visibleFileAssets, setVisibleFileAssets] = react.useState< const [visibleFileAssets, setVisibleFileAssets] = React.useState<
backendModule.Asset<backendModule.AssetType.file>[] backendModule.Asset<backendModule.AssetType.file>[]
>([]) >([])
const [projectDatas, setProjectDatas] = react.useState< const [projectDatas, setProjectDatas] = React.useState<
Record<backendModule.ProjectId, projectActionButton.ProjectData> Record<backendModule.ProjectId, projectActionButton.ProjectData>
>({}) >({})
const isListingLocalDirectoryAndWillFail = const isListingLocalDirectoryAndWillFail =
backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail
const isListingRemoteDirectoryAndWillFail = const isListingRemoteDirectoryAndWillFail =
backend.type === backendModule.BackendType.remote && !session.organization?.isEnabled backend.type === backendModule.BackendType.remote &&
session.organization?.isEnabled !== true
const isListingRemoteDirectoryWhileOffline = const isListingRemoteDirectoryWhileOffline =
session.type === authProvider.UserSessionType.offline && session.type === authProvider.UserSessionType.offline &&
backend.type === backendModule.BackendType.remote backend.type === backendModule.BackendType.remote
const directory = directoryStack[directoryStack.length - 1] const directory = directoryStack[directoryStack.length - 1]
const parentDirectory = directoryStack[directoryStack.length - 2] const parentDirectory = directoryStack[directoryStack.length - 2]
const switchToIdeTab = react.useCallback(() => { const switchToIdeTab = React.useCallback(() => {
setTab(Tab.ide) setTab(Tab.ide)
const ideElement = document.getElementById(IDE_ELEMENT_ID) const ideElement = document.getElementById(IDE_ELEMENT_ID)
if (ideElement) { if (ideElement) {
@ -367,7 +368,7 @@ function Dashboard(props: DashboardProps) {
} }
}, []) }, [])
const switchToDashboardTab = react.useCallback(() => { const switchToDashboardTab = React.useCallback(() => {
setTab(Tab.dashboard) setTab(Tab.dashboard)
doRefresh() doRefresh()
const ideElement = document.getElementById(IDE_ELEMENT_ID) const ideElement = document.getElementById(IDE_ELEMENT_ID)
@ -375,9 +376,9 @@ function Dashboard(props: DashboardProps) {
ideElement.style.top = '-100vh' ideElement.style.top = '-100vh'
ideElement.style.display = 'fixed' ideElement.style.display = 'fixed'
} }
}, []) }, [doRefresh])
react.useEffect(() => { React.useEffect(() => {
const onProjectManagerLoadingFailed = () => { const onProjectManagerLoadingFailed = () => {
setLoadingProjectManagerDidFail(true) setLoadingProjectManagerDidFail(true)
} }
@ -393,13 +394,13 @@ function Dashboard(props: DashboardProps) {
} }
}, []) }, [])
react.useEffect(() => { React.useEffect(() => {
if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) { if (backend.type === backendModule.BackendType.local && loadingProjectManagerDidFail) {
setIsLoadingAssets(false) setIsLoadingAssets(false)
} }
}, [isLoadingAssets, loadingProjectManagerDidFail, backend.type]) }, [isLoadingAssets, loadingProjectManagerDidFail, backend.type])
react.useEffect(() => { React.useEffect(() => {
if ( if (
supportsLocalBackend && supportsLocalBackend &&
localStorage.getItem(backendProvider.BACKEND_TYPE_KEY) !== localStorage.getItem(backendProvider.BACKEND_TYPE_KEY) !==
@ -407,16 +408,16 @@ function Dashboard(props: DashboardProps) {
) { ) {
setBackend(new localBackend.LocalBackend()) setBackend(new localBackend.LocalBackend())
} }
}, []) }, [setBackend, supportsLocalBackend])
react.useEffect(() => { React.useEffect(() => {
document.addEventListener('show-dashboard', switchToDashboardTab) document.addEventListener('show-dashboard', switchToDashboardTab)
return () => { return () => {
document.removeEventListener('show-dashboard', switchToDashboardTab) document.removeEventListener('show-dashboard', switchToDashboardTab)
} }
}, []) }, [switchToDashboardTab])
react.useEffect(() => { React.useEffect(() => {
if (projectEvent != null) { if (projectEvent != null) {
setProjectEvent(null) setProjectEvent(null)
} }
@ -500,9 +501,9 @@ function Dashboard(props: DashboardProps) {
setDirectoryStack([...directoryStack, directoryAsset]) setDirectoryStack([...directoryStack, directoryAsset])
} }
react.useEffect(() => { React.useEffect(() => {
const cachedDirectoryStackJson = localStorage.getItem(DIRECTORY_STACK_KEY) const cachedDirectoryStackJson = localStorage.getItem(DIRECTORY_STACK_KEY)
if (cachedDirectoryStackJson) { if (cachedDirectoryStackJson != null) {
// The JSON was inserted by the code below, so it will always have the right type. // 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const cachedDirectoryStack: backendModule.Asset<backendModule.AssetType.directory>[] = const cachedDirectoryStack: backendModule.Asset<backendModule.AssetType.directory>[] =
@ -515,7 +516,7 @@ function Dashboard(props: DashboardProps) {
} }
}, []) }, [])
react.useEffect(() => { React.useEffect(() => {
if ( if (
session.organization == null || session.organization == null ||
directoryId === rootDirectoryId(session.organization.id) directoryId === rootDirectoryId(session.organization.id)
@ -524,7 +525,7 @@ function Dashboard(props: DashboardProps) {
} else { } else {
localStorage.setItem(DIRECTORY_STACK_KEY, JSON.stringify(directoryStack)) localStorage.setItem(DIRECTORY_STACK_KEY, JSON.stringify(directoryStack))
} }
}, [directoryStack]) }, [directoryId, directoryStack, session.organization])
/** React components for the name column. */ /** React components for the name column. */
const nameRenderers: { const nameRenderers: {
@ -714,7 +715,7 @@ function Dashboard(props: DashboardProps) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const columnRenderer: Record< const columnRenderer: Record<
Exclude<Column, Column.name>, Exclude<Column, Column.name>,
(asset: backendModule.Asset) => react.ReactNode (asset: backendModule.Asset) => React.ReactNode
> = { > = {
[Column.lastModified]: asset => [Column.lastModified]: asset =>
asset.modifiedAt && <>{dateTime.formatDateTime(new Date(asset.modifiedAt))}</>, asset.modifiedAt && <>{dateTime.formatDateTime(new Date(asset.modifiedAt))}</>,
@ -734,7 +735,7 @@ function Dashboard(props: DashboardProps) {
[Column.labels]: () => { [Column.labels]: () => {
// This is not a React component even though it contains JSX. // This is not a React component even though it contains JSX.
// eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-unused-vars
const onContextMenu = (event: react.MouseEvent) => { const onContextMenu = (event: React.MouseEvent) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
setModal(() => ( setModal(() => (
@ -863,23 +864,30 @@ function Dashboard(props: DashboardProps) {
<>{COLUMN_NAME[column]}</> <>{COLUMN_NAME[column]}</>
) )
react.useEffect(() => { React.useEffect(() => {
const queryRegex = new RegExp(regexEscape(query), 'i') const queryRegex = new RegExp(regexEscape(query), 'i')
const doesItMatchQuery = (asset: backendModule.Asset) => queryRegex.test(asset.title) const doesItMatchQuery = (asset: backendModule.Asset) => queryRegex.test(asset.title)
setVisibleProjectAssets(projectAssets.filter(doesItMatchQuery)) setVisibleProjectAssets(projectAssets.filter(doesItMatchQuery))
setVisibleDirectoryAssets(directoryAssets.filter(doesItMatchQuery)) setVisibleDirectoryAssets(directoryAssets.filter(doesItMatchQuery))
setVisibleSecretAssets(secretAssets.filter(doesItMatchQuery)) setVisibleSecretAssets(secretAssets.filter(doesItMatchQuery))
setVisibleFileAssets(fileAssets.filter(doesItMatchQuery)) setVisibleFileAssets(fileAssets.filter(doesItMatchQuery))
}, [query]) }, [directoryAssets, fileAssets, projectAssets, query, secretAssets])
react.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (isLoadingAssets) { if (isLoadingAssets) {
document.body.style.overflowY = isScrollBarVisible ? 'scroll' : '' document.body.style.overflowY = isScrollBarVisible ? 'scroll' : ''
} else { } else {
document.body.style.overflowY = '' document.body.style.overflowY = ''
setIsScrollBarVisible(document.body.scrollHeight > document.body.clientHeight) setIsScrollBarVisible(document.body.scrollHeight > document.body.clientHeight)
} }
}, [isLoadingAssets, projectAssets, directoryAssets, secretAssets, fileAssets]) }, [
isLoadingAssets,
projectAssets,
directoryAssets,
secretAssets,
fileAssets,
isScrollBarVisible,
])
const setAssets = (assets: backendModule.Asset[]) => { const setAssets = (assets: backendModule.Asset[]) => {
const newProjectAssets = assets.filter( const newProjectAssets = assets.filter(
@ -947,7 +955,7 @@ function Dashboard(props: DashboardProps) {
[session.accessToken, directoryId, refresh, backend] [session.accessToken, directoryId, refresh, backend]
) )
react.useEffect(() => { React.useEffect(() => {
const onBlur = () => { const onBlur = () => {
setIsFileBeingDragged(false) setIsFileBeingDragged(false)
} }
@ -957,7 +965,7 @@ function Dashboard(props: DashboardProps) {
} }
}, []) }, [])
const handleEscapeKey = (event: react.KeyboardEvent<HTMLDivElement>) => { const handleEscapeKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
if ( if (
event.key === 'Escape' && event.key === 'Escape' &&
!event.ctrlKey && !event.ctrlKey &&
@ -972,7 +980,7 @@ function Dashboard(props: DashboardProps) {
} }
} }
const openDropZone = (event: react.DragEvent<HTMLDivElement>) => { const openDropZone = (event: React.DragEvent<HTMLDivElement>) => {
if (event.dataTransfer.types.includes('Files')) { if (event.dataTransfer.types.includes('Files')) {
setIsFileBeingDragged(true) setIsFileBeingDragged(true)
} }
@ -984,7 +992,7 @@ function Dashboard(props: DashboardProps) {
let highestProjectIndex = 0 let highestProjectIndex = 0
for (const projectAsset of projectAssets) { for (const projectAsset of projectAssets) {
const projectIndex = projectNameTemplate.exec(projectAsset.title)?.groups?.projectIndex const projectIndex = projectNameTemplate.exec(projectAsset.title)?.groups?.projectIndex
if (projectIndex) { if (projectIndex != null) {
highestProjectIndex = Math.max(highestProjectIndex, parseInt(projectIndex, 10)) highestProjectIndex = Math.max(highestProjectIndex, parseInt(projectIndex, 10))
} }
} }

View File

@ -1,5 +1,5 @@
/** @file Form to create a project. */ /** @file Form to create a project. */
import * as react from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import * as backendModule from '../backend' import * as backendModule from '../backend'
@ -23,13 +23,13 @@ function FileCreateForm(props: FileCreateFormProps) {
const { directoryId, onSuccess, ...passThrough } = props const { directoryId, onSuccess, ...passThrough } = props
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const [name, setName] = react.useState<string | null>(null) const [name, setName] = React.useState<string | null>(null)
const [file, setFile] = react.useState<File | null>(null) const [file, setFile] = React.useState<File | null>(null)
if (backend.type === backendModule.BackendType.local) { if (backend.type === backendModule.BackendType.local) {
return <></> return <></>
} else { } else {
const onSubmit = async (event: react.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
if (file == null) { if (file == null) {
// TODO[sb]: Uploading a file may be a mistake when creating a new file. // TODO[sb]: Uploading a file may be a mistake when creating a new file.

View File

@ -1,5 +1,5 @@
/** @file Container that launches the IDE. */ /** @file Container that launches the IDE. */
import * as react from 'react' import * as React from 'react'
import * as backendModule from '../backend' import * as backendModule from '../backend'
import * as backendProvider from '../../providers/backend' import * as backendProvider from '../../providers/backend'
@ -31,7 +31,7 @@ function Ide(props: IdeProps) {
const { project, appRunner } = props const { project, appRunner } = props
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
react.useEffect(() => { React.useEffect(() => {
void (async () => { void (async () => {
const ideVersion = const ideVersion =
project.ideVersion?.value ?? project.ideVersion?.value ??
@ -122,7 +122,10 @@ function Ide(props: IdeProps) {
} }
} }
})() })()
}, [project]) // The backend MUST NOT be a dependency, since the IDE should only be recreated when a new
// project is opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appRunner, project])
return <></> return <></>
} }

View File

@ -1,12 +1,12 @@
/** @file Input element with default event handlers. */ /** @file Input element with default event handlers. */
import * as react from 'react' import * as React from 'react'
// ============= // =============
// === Input === // === Input ===
// ============= // =============
/** Props for an {@link Input}. */ /** Props for an {@link Input}. */
export interface InputProps extends react.InputHTMLAttributes<HTMLInputElement> { export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
setValue: (value: string) => void setValue: (value: string) => void
} }

View File

@ -1,5 +1,5 @@
/** @file A label, which may be either user-defined, or a system warning message. */ /** @file A label, which may be either user-defined, or a system warning message. */
import * as react from 'react' import * as React from 'react'
import ExclamationIcon from 'enso-assets/exclamation.svg' import ExclamationIcon from 'enso-assets/exclamation.svg'
@ -47,11 +47,11 @@ const STATUS_ICON: Record<Status, JSX.Element | null> = {
/** Props for a {@link Label}. */ /** Props for a {@link Label}. */
export interface LabelProps { export interface LabelProps {
status?: Status status?: Status
onContextMenu?: react.MouseEventHandler<HTMLDivElement> onContextMenu?: React.MouseEventHandler<HTMLDivElement>
} }
/** A label, which may be either user-defined, or a system warning message. */ /** A label, which may be either user-defined, or a system warning message. */
function Label(props: react.PropsWithChildren<LabelProps>) { function Label(props: React.PropsWithChildren<LabelProps>) {
const { status = Status.none, children, onContextMenu } = props const { status = Status.none, children, onContextMenu } = props
return ( return (
<div <div

View File

@ -1,5 +1,5 @@
/** @file Base modal component that provides the full-screen element that blocks mouse events. */ /** @file Base modal component that provides the full-screen element that blocks mouse events. */
import * as react from 'react' import * as React from 'react'
import * as modalProvider from '../../providers/modal' import * as modalProvider from '../../providers/modal'
@ -8,7 +8,7 @@ import * as modalProvider from '../../providers/modal'
// ================= // =================
/** Props for a {@link Modal}. */ /** Props for a {@link Modal}. */
export interface ModalProps extends react.PropsWithChildren { export interface ModalProps extends React.PropsWithChildren {
centered?: boolean centered?: boolean
className?: string className?: string
} }
@ -18,7 +18,7 @@ export interface ModalProps extends react.PropsWithChildren {
* background transparency can be enabled with Tailwind's `bg-opacity` classes, * background transparency can be enabled with Tailwind's `bg-opacity` classes,
* like `className="bg-opacity-50"` */ * like `className="bg-opacity-50"` */
function Modal(props: ModalProps) { function Modal(props: ModalProps) {
const { children, centered, className } = props const { children, centered = false, className } = props
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
return ( return (

View File

@ -1,5 +1,5 @@
/** @file Colored border around icons and text indicating permissions. */ /** @file Colored border around icons and text indicating permissions. */
import * as react from 'react' import * as React from 'react'
// ============= // =============
// === Types === // === Types ===
@ -44,7 +44,7 @@ export type Permissions = AdminPermissions | OwnerPermissions | RegularPermissio
// ================= // =================
/** Props for a {@link PermissionDisplay}. */ /** Props for a {@link PermissionDisplay}. */
export interface PermissionDisplayProps extends react.PropsWithChildren { export interface PermissionDisplayProps extends React.PropsWithChildren {
permissions: Permissions permissions: Permissions
} }

View File

@ -1,5 +1,5 @@
/** @file An interactive button displaying the status of a project. */ /** @file An interactive button displaying the status of a project. */
import * as react from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import ArrowUpIcon from 'enso-assets/arrow_up.svg' import ArrowUpIcon from 'enso-assets/arrow_up.svg'
@ -93,7 +93,7 @@ const SPINNER_CSS_CLASSES: Record<SpinnerState, string> = {
export interface ProjectActionButtonProps { export interface ProjectActionButtonProps {
project: backendModule.Asset<backendModule.AssetType.project> project: backendModule.Asset<backendModule.AssetType.project>
projectData: ProjectData projectData: ProjectData
setProjectData: react.Dispatch<react.SetStateAction<ProjectData>> setProjectData: React.Dispatch<React.SetStateAction<ProjectData>>
appRunner: AppRunner | null appRunner: AppRunner | null
event: ProjectEvent | null event: ProjectEvent | null
/** Called when the project is opened via the {@link ProjectActionButton}. */ /** Called when the project is opened via the {@link ProjectActionButton}. */
@ -123,20 +123,20 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
(project.projectState.type === backendModule.ProjectState.opened || (project.projectState.type === backendModule.ProjectState.opened ||
project.projectState.type === backendModule.ProjectState.openInProgress) project.projectState.type === backendModule.ProjectState.openInProgress)
const [state, setState] = react.useState(() => { const [state, setState] = React.useState(() => {
if (shouldCheckIfActuallyOpen) { if (shouldCheckIfActuallyOpen) {
return backendModule.ProjectState.created return backendModule.ProjectState.created
} else { } else {
return project.projectState.type return project.projectState.type
} }
}) })
const [isCheckingStatus, setIsCheckingStatus] = react.useState(false) const [isCheckingStatus, setIsCheckingStatus] = React.useState(false)
const [isCheckingResources, setIsCheckingResources] = react.useState(false) const [isCheckingResources, setIsCheckingResources] = React.useState(false)
const [spinnerState, setSpinnerState] = react.useState(SPINNER_STATE[state]) const [spinnerState, setSpinnerState] = React.useState(SPINNER_STATE[state])
const [shouldOpenWhenReady, setShouldOpenWhenReady] = react.useState(false) const [shouldOpenWhenReady, setShouldOpenWhenReady] = React.useState(false)
const [toastId, setToastId] = react.useState<string | null>(null) const [toastId, setToastId] = React.useState<string | null>(null)
react.useEffect(() => { React.useEffect(() => {
if (toastId != null) { if (toastId != null) {
return () => { return () => {
toast.dismiss(toastId) toast.dismiss(toastId)
@ -146,27 +146,61 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
} }
}, [toastId]) }, [toastId])
react.useEffect(() => { React.useEffect(() => {
// Ensure that the previous spinner state is visible for at least one frame. // Ensure that the previous spinner state is visible for at least one frame.
requestAnimationFrame(() => { requestAnimationFrame(() => {
setSpinnerState(SPINNER_STATE[state]) setSpinnerState(SPINNER_STATE[state])
}) })
}, [state]) }, [state])
react.useEffect(() => { React.useEffect(() => {
if (toastId != null && state !== backendModule.ProjectState.openInProgress) { if (toastId != null && state !== backendModule.ProjectState.openInProgress) {
toast.dismiss(toastId) toast.dismiss(toastId)
} }
}, [state]) }, [state, toastId])
react.useEffect(() => { React.useEffect(() => {
if (shouldCheckIfActuallyOpen) { if (shouldCheckIfActuallyOpen) {
setState(backendModule.ProjectState.openInProgress) setState(backendModule.ProjectState.openInProgress)
setIsCheckingResources(true) setIsCheckingResources(true)
} }
}, []) }, [shouldCheckIfActuallyOpen])
react.useEffect(() => { 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) { if (event != null) {
switch (event.type) { switch (event.type) {
case ProjectEventType.open: { case ProjectEventType.open: {
@ -183,25 +217,27 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
} }
} }
} }
}, [event]) }, [event, openProject, project.id])
react.useEffect(() => { React.useEffect(() => {
if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) { if (shouldOpenWhenReady && state === backendModule.ProjectState.opened) {
openIde() openIde()
setShouldOpenWhenReady(false) setShouldOpenWhenReady(false)
} }
}, [shouldOpenWhenReady, state]) }, [openIde, shouldOpenWhenReady, state])
react.useEffect(() => { React.useEffect(() => {
if ( if (
backend.type === backendModule.BackendType.local && backend.type === backendModule.BackendType.local &&
project.id !== localBackend.LocalBackend.currentlyOpeningProjectId project.id !== localBackend.LocalBackend.currentlyOpeningProjectId
) { ) {
setState(backendModule.ProjectState.closed) setState(backendModule.ProjectState.closed)
} }
}, [project, state, localBackend.LocalBackend.currentlyOpeningProjectId]) // `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(() => { React.useEffect(() => {
if (!isCheckingStatus) { if (!isCheckingStatus) {
return return
} else { } else {
@ -240,9 +276,9 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
} }
} }
} }
}, [isCheckingStatus]) }, [backend, isCheckingStatus, project.id])
react.useEffect(() => { React.useEffect(() => {
if (!isCheckingResources) { if (!isCheckingResources) {
return return
} else { } else {
@ -285,7 +321,7 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
} }
} }
} }
}, [isCheckingResources]) }, [backend, isCheckingResources, project.id])
const closeProject = async () => { const closeProject = async () => {
onClose() onClose()
@ -303,40 +339,6 @@ function ProjectActionButton(props: ProjectActionButtonProps) {
} }
} }
const openProject = 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)
}
}
switch (state) { switch (state) {
case null: case null:
case backendModule.ProjectState.created: case backendModule.ProjectState.created:

View File

@ -1,5 +1,5 @@
/** @file Modal for confirming delete of any type of asset. */ /** @file Modal for confirming delete of any type of asset. */
import * as react from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import CloseIcon from 'enso-assets/close.svg' import CloseIcon from 'enso-assets/close.svg'
@ -28,7 +28,7 @@ function RenameModal(props: RenameModalProps) {
const { assetType, name, namePattern, title, doRename, onComplete } = props const { assetType, name, namePattern, title, doRename, onComplete } = props
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const [newName, setNewName] = react.useState<string | null>(null) const [newName, setNewName] = React.useState<string | null>(null)
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => { const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault() event.preventDefault()
@ -67,7 +67,7 @@ function RenameModal(props: RenameModalProps) {
</button> </button>
</div> </div>
<div className="m-2"> <div className="m-2">
What do you want to rename the {assetType} '{name}' to? What do you want to rename the {assetType} &lsquo;{name}&rsquo; to?
</div> </div>
<div className="m-2"> <div className="m-2">
<Input <Input

View File

@ -1,5 +1,5 @@
/** @file Table that projects an object into each column. */ /** @file Table that projects an object into each column. */
import * as react from 'react' import * as React from 'react'
import * as svg from '../../components/svg' import * as svg from '../../components/svg'
@ -22,7 +22,7 @@ const SPINNER_LOADING_CLASSES = 'grow dasharray-75 duration-1000 ease-linear'
export interface Column<T> { export interface Column<T> {
id: string id: string
heading: JSX.Element heading: JSX.Element
render: (item: T, index: number) => react.ReactNode render: (item: T, index: number) => React.ReactNode
} }
// ================= // =================
@ -36,14 +36,14 @@ export interface RowsProps<T> {
isLoading: boolean isLoading: boolean
placeholder: JSX.Element placeholder: JSX.Element
columns: Column<T>[] columns: Column<T>[]
onClick: (item: T, event: react.MouseEvent<HTMLTableRowElement>) => void onClick: (item: T, event: React.MouseEvent<HTMLTableRowElement>) => void
onContextMenu: (item: T, event: react.MouseEvent<HTMLTableRowElement>) => void onContextMenu: (item: T, event: React.MouseEvent<HTMLTableRowElement>) => void
} }
/** Table that projects an object into each column. */ /** Table that projects an object into each column. */
function Rows<T>(props: RowsProps<T>) { function Rows<T>(props: RowsProps<T>) {
const { columns, items, isLoading, getKey, placeholder, onClick, onContextMenu } = props const { columns, items, isLoading, getKey, placeholder, onClick, onContextMenu } = props
const [spinnerClasses, setSpinnerClasses] = react.useState(SPINNER_INITIAL_CLASSES) const [spinnerClasses, setSpinnerClasses] = React.useState(SPINNER_INITIAL_CLASSES)
const headerRow = ( const headerRow = (
<tr> <tr>
@ -58,7 +58,7 @@ function Rows<T>(props: RowsProps<T>) {
</tr> </tr>
) )
react.useEffect(() => { React.useEffect(() => {
if (isLoading) { if (isLoading) {
// Ensure the spinner stays in the "initial" state for at least one frame. // Ensure the spinner stays in the "initial" state for at least one frame.
requestAnimationFrame(() => { requestAnimationFrame(() => {

View File

@ -1,5 +1,5 @@
/** @file Form to create a project. */ /** @file Form to create a project. */
import * as react from 'react' import * as React from 'react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import * as backendModule from '../backend' import * as backendModule from '../backend'
@ -24,15 +24,15 @@ function SecretCreateForm(props: SecretCreateFormProps) {
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
const { unsetModal } = modalProvider.useSetModal() const { unsetModal } = modalProvider.useSetModal()
const [name, setName] = react.useState<string | null>(null) const [name, setName] = React.useState<string | null>(null)
const [value, setValue] = react.useState<string | null>(null) const [value, setValue] = React.useState<string | null>(null)
if (backend.type === backendModule.BackendType.local) { if (backend.type === backendModule.BackendType.local) {
return <></> return <></>
} else { } else {
const onSubmit = async (event: react.FormEvent) => { const onSubmit = async (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
if (!name) { if (name == null || name === '') {
toast.error('Please provide a secret name.') toast.error('Please provide a secret name.')
} else if (value == null) { } else if (value == null) {
// Secret value explicitly can be empty. // Secret value explicitly can be empty.

View File

@ -1,5 +1,5 @@
/** @file Renders the list of templates from which a project can be created. */ /** @file Renders the list of templates from which a project can be created. */
import * as react from 'react' import * as React from 'react'
import PlusCircledIcon from 'enso-assets/plus_circled.svg' import PlusCircledIcon from 'enso-assets/plus_circled.svg'
import RotatingArrowIcon from 'enso-assets/rotating_arrow.svg' import RotatingArrowIcon from 'enso-assets/rotating_arrow.svg'
@ -158,10 +158,10 @@ export interface TemplatesProps {
function Templates(props: TemplatesProps) { function Templates(props: TemplatesProps) {
const { onTemplateClick } = props const { onTemplateClick } = props
const [shadowClass, setShadowClass] = react.useState( const [shadowClass, setShadowClass] = React.useState(
window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? ShadowClass.bottom : ShadowClass.none window.innerWidth <= MAX_WIDTH_NEEDING_SCROLL ? ShadowClass.bottom : ShadowClass.none
) )
const [isOpen, setIsOpen] = react.useState(() => { const [isOpen, setIsOpen] = React.useState(() => {
/** This must not be in a `useEffect` as it would flash open for one frame. /** This must not be in a `useEffect` as it would flash open for one frame.
* It can be in a `useLayoutEffect` but as that needs to be checked every re-render, * It can be in a `useLayoutEffect` but as that needs to be checked every re-render,
* this is slightly more performant. */ * this is slightly more performant. */
@ -180,9 +180,9 @@ function Templates(props: TemplatesProps) {
// This is incorrect, but SAFE, as its value will always be assigned before any hooks are run. // This is incorrect, but SAFE, as its value will always be assigned before any hooks are run.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const containerRef = react.useRef<HTMLDivElement>(null!) const containerRef = React.useRef<HTMLDivElement>(null!)
const toggleIsOpen = react.useCallback(() => { const toggleIsOpen = React.useCallback(() => {
setIsOpen(oldIsOpen => !oldIsOpen) setIsOpen(oldIsOpen => !oldIsOpen)
}, []) }, [])
@ -210,14 +210,14 @@ function Templates(props: TemplatesProps) {
setShadowClass(newShadowClass) setShadowClass(newShadowClass)
} }
react.useEffect(() => { React.useEffect(() => {
window.addEventListener('resize', updateShadowClass) window.addEventListener('resize', updateShadowClass)
return () => { return () => {
window.removeEventListener('resize', updateShadowClass) window.removeEventListener('resize', updateShadowClass)
} }
}) })
react.useEffect(() => { React.useEffect(() => {
localStorage.setItem(IS_TEMPLATES_OPEN_KEY, JSON.stringify(isOpen)) localStorage.setItem(IS_TEMPLATES_OPEN_KEY, JSON.stringify(isOpen))
}, [isOpen]) }, [isOpen])

View File

@ -1,5 +1,5 @@
/** @file The top-bar of dashboard. */ /** @file The top-bar of dashboard. */
import * as react from 'react' import * as React from 'react'
import BarsIcon from 'enso-assets/bars.svg' import BarsIcon from 'enso-assets/bars.svg'
import CloudIcon from 'enso-assets/cloud.svg' import CloudIcon from 'enso-assets/cloud.svg'
@ -37,24 +37,24 @@ export interface TopBarProps {
function TopBar(props: TopBarProps) { function TopBar(props: TopBarProps) {
const { supportsLocalBackend, projectName, tab, toggleTab, setBackendType, query, setQuery } = const { supportsLocalBackend, projectName, tab, toggleTab, setBackendType, query, setQuery } =
props props
const [isUserMenuVisible, setIsUserMenuVisible] = react.useState(false) const [isUserMenuVisible, setIsUserMenuVisible] = React.useState(false)
const { modal } = modalProvider.useModal() const { modal } = modalProvider.useModal()
const { setModal, unsetModal } = modalProvider.useSetModal() const { setModal, unsetModal } = modalProvider.useSetModal()
const { backend } = backendProvider.useBackend() const { backend } = backendProvider.useBackend()
react.useEffect(() => { React.useEffect(() => {
if (!modal) { if (!modal) {
setIsUserMenuVisible(false) setIsUserMenuVisible(false)
} }
}, [modal]) }, [modal])
react.useEffect(() => { React.useEffect(() => {
if (isUserMenuVisible) { if (isUserMenuVisible) {
setModal(() => <UserMenu />) setModal(() => <UserMenu />)
} else { } else {
unsetModal() unsetModal()
} }
}, [isUserMenuVisible]) }, [isUserMenuVisible, setModal, unsetModal])
return ( return (
<div className="flex mx-2 h-8"> <div className="flex mx-2 h-8">
@ -87,8 +87,9 @@ function TopBar(props: TopBarProps) {
</div> </div>
)} )}
<div <div
className={`flex items-center bg-label rounded-full pl-1 className={`flex items-center bg-label rounded-full pl-1 pr-2.5 mx-2 ${
pr-2.5 mx-2 ${projectName ? 'cursor-pointer' : 'opacity-50'}`} projectName != null ? 'cursor-pointer' : 'opacity-50'
}`}
onClick={toggleTab} onClick={toggleTab}
> >
<span <span
@ -127,6 +128,7 @@ function TopBar(props: TopBarProps) {
<a <a
href="https://discord.gg/enso" href="https://discord.gg/enso"
target="_blank" target="_blank"
rel="noreferrer"
className="flex items-center bg-help rounded-full px-2.5 text-white mx-2" className="flex items-center bg-help rounded-full px-2.5 text-white mx-2"
> >
<span className="whitespace-nowrap">help chat</span> <span className="whitespace-nowrap">help chat</span>

View File

@ -1,5 +1,5 @@
/** @file The UserMenu component provides a dropdown menu of user actions and settings. */ /** @file The UserMenu component provides a dropdown menu of user actions and settings. */
import * as react from 'react' import * as React from 'react'
import * as app from '../../components/app' import * as app from '../../components/app'
import * as auth from '../../authentication/providers/auth' import * as auth from '../../authentication/providers/auth'
@ -20,8 +20,8 @@ export interface UserMenuItemProps {
} }
/** User menu item. */ /** User menu item. */
function UserMenuItem(props: react.PropsWithChildren<UserMenuItemProps>) { function UserMenuItem(props: React.PropsWithChildren<UserMenuItemProps>) {
const { children, disabled, onClick } = props const { children, disabled = false, onClick } = props
return ( return (
<div <div
@ -56,7 +56,7 @@ function UserMenu() {
const username: string | null = const username: string | null =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-non-null-assertion
accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null accessToken != null ? JSON.parse(atob(accessToken.split('.')[1]!)).username : null
const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : null const canChangePassword = username != null ? !/^Github_|^Google_/.test(username) : false
return ( return (
<div <div

View File

@ -34,6 +34,15 @@ export class LocalBackend implements Partial<backend.Backend> {
readonly type = backend.BackendType.local readonly type = backend.BackendType.local
private readonly projectManager = projectManager.ProjectManager.default() private readonly projectManager = projectManager.ProjectManager.default()
/** Create a {@link LocalBackend}. */
constructor() {
if (IS_DEV_MODE) {
// @ts-expect-error This exists only for debugging purposes. It does not have types
// because it MUST NOT be used in this codebase.
window.localBackend = this
}
}
/** Return a list of assets in a directory. /** Return a list of assets in a directory.
* *
* @throws An error if the JSON-RPC call fails. */ * @throws An error if the JSON-RPC call fails. */
@ -83,7 +92,9 @@ export class LocalBackend implements Partial<backend.Backend> {
async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> { async createProject(body: backend.CreateProjectRequestBody): Promise<backend.CreatedProject> {
const project = await this.projectManager.createProject({ const project = await this.projectManager.createProject({
name: newtype.asNewtype<projectManager.ProjectName>(body.projectName), name: newtype.asNewtype<projectManager.ProjectName>(body.projectName),
...(body.projectTemplateName ? { projectTemplate: body.projectTemplateName } : {}), ...(body.projectTemplateName != null
? { projectTemplate: body.projectTemplateName }
: {}),
missingComponentAction: projectManager.MissingComponentAction.install, missingComponentAction: projectManager.MissingComponentAction.install,
}) })
return { return {

View File

@ -161,6 +161,11 @@ export class RemoteBackend implements backend.Backend {
if (!this.client.defaultHeaders.has('Authorization')) { if (!this.client.defaultHeaders.has('Authorization')) {
return this.throw('Authorization header not set.') return this.throw('Authorization header not set.')
} else { } else {
if (IS_DEV_MODE) {
// @ts-expect-error This exists only for debugging purposes. It does not have types
// because it MUST NOT be used in this codebase.
window.remoteBackend = this
}
return return
} }
} }
@ -213,14 +218,14 @@ export class RemoteBackend implements backend.Backend {
'?' + '?' +
new URLSearchParams({ new URLSearchParams({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
...(query.parentId ? { parent_id: query.parentId } : {}), ...(query.parentId != null ? { parent_id: query.parentId } : {}),
}).toString() }).toString()
) )
if (!responseIsSuccessful(response)) { if (!responseIsSuccessful(response)) {
if (response.status === STATUS_SERVER_ERROR) { if (response.status === STATUS_SERVER_ERROR) {
// The directory is probably empty. // The directory is probably empty.
return [] return []
} else if (query.parentId) { } else if (query.parentId != null) {
return this.throw(`Unable to list directory with ID '${query.parentId}'.`) return this.throw(`Unable to list directory with ID '${query.parentId}'.`)
} else { } else {
return this.throw('Unable to list root directory.') return this.throw('Unable to list root directory.')
@ -393,8 +398,8 @@ export class RemoteBackend implements backend.Backend {
'?' + '?' +
new URLSearchParams({ new URLSearchParams({
/* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/naming-convention */
...(params.fileName ? { file_name: params.fileName } : {}), ...(params.fileName != null ? { file_name: params.fileName } : {}),
...(params.fileId ? { file_id: params.fileId } : {}), ...(params.fileId != null ? { file_id: params.fileId } : {}),
...(params.parentDirectoryId ...(params.parentDirectoryId
? { parent_directory_id: params.parentDirectoryId } ? { parent_directory_id: params.parentDirectoryId }
: {}), : {}),
@ -403,9 +408,9 @@ export class RemoteBackend implements backend.Backend {
body body
) )
if (!responseIsSuccessful(response)) { if (!responseIsSuccessful(response)) {
if (params.fileName) { if (params.fileName != null) {
return this.throw(`Unable to upload file with name '${params.fileName}'.`) return this.throw(`Unable to upload file with name '${params.fileName}'.`)
} else if (params.fileId) { } else if (params.fileId != null) {
return this.throw(`Unable to upload file with ID '${params.fileId}'.`) return this.throw(`Unable to upload file with ID '${params.fileId}'.`)
} else { } else {
return this.throw('Unable to upload file.') return this.throw('Unable to upload file.')

View File

@ -1,5 +1,5 @@
/** @file Module containing common custom React hooks used throughout out Dashboard. */ /** @file Module containing common custom React hooks used throughout out Dashboard. */
import * as react from 'react' import * as React from 'react'
import * as router from 'react-router' import * as router from 'react-router'
import * as app from './components/app' import * as app from './components/app'
@ -10,11 +10,14 @@ import * as loggerProvider from './providers/logger'
// === useRefresh === // === useRefresh ===
// ================== // ==================
/** An alias to make the purpose of the returned empty object clearer. */
export interface RefreshState {}
/** A hook that contains no state, and is used only to tell React when to re-render. */ /** A hook that contains no state, and is used only to tell React when to re-render. */
export function useRefresh() { export function useRefresh() {
// Uses an empty object literal because every distinct literal // Uses an empty object literal because every distinct literal
// is a new reference and therefore is not equal to any other object literal. // is a new reference and therefore is not equal to any other object literal.
return react.useReducer(() => ({}), {}) return React.useReducer((): RefreshState => ({}), {})
} }
// ====================== // ======================
@ -33,41 +36,38 @@ export function useRefresh() {
* Also see: https://stackoverflow.com/questions/61751728/asynchronous-calls-with-react-usememo. * Also see: https://stackoverflow.com/questions/61751728/asynchronous-calls-with-react-usememo.
* *
* @param initialValue - The initial value of the state controlled by this hook. * @param initialValue - The initial value of the state controlled by this hook.
* @param fetch - The asynchronous function used to load the state controlled by this hook. * @param asyncEffect - The asynchronous function used to load the state controlled by this hook.
* @param deps - The list of dependencies that, when updated, trigger the asynchronous fetch. * @param deps - The list of dependencies that, when updated, trigger the asynchronous effect.
* @returns The current value of the state controlled by this hook. */ * @returns The current value of the state controlled by this hook. */
export function useAsyncEffect<T>( export function useAsyncEffect<T>(
initialValue: T, initialValue: T,
fetch: (signal: AbortSignal) => Promise<T>, asyncEffect: (signal: AbortSignal) => Promise<T>,
deps?: react.DependencyList deps?: React.DependencyList
): T { ): T {
const logger = loggerProvider.useLogger() const logger = loggerProvider.useLogger()
const [value, setValue] = react.useState<T>(initialValue) const [value, setValue] = React.useState<T>(initialValue)
react.useEffect(() => { React.useEffect(() => {
const controller = new AbortController() const controller = new AbortController()
const { signal } = controller void asyncEffect(controller.signal).then(
result => {
/** Declare the async data fetching function. */ if (!controller.signal.aborted) {
const load = async () => { setValue(result)
const result = await fetch(signal) }
},
/** Set state with the result only if this effect has not been aborted. This prevents race error => {
* conditions by making it so that only the latest async fetch will update the state on logger.error('Error while fetching data:', error)
* completion. */
if (!signal.aborted) {
setValue(result)
} }
} )
load().catch(error => {
logger.error('Error while fetching data', error)
})
/** Cancel any future `setValue` calls. */ /** Cancel any future `setValue` calls. */
return () => { return () => {
controller.abort() controller.abort()
} }
}, deps) // This is a wrapper function around `useEffect`, so it has its own `deps` array.
// `asyncEffect` is omitted as it always changes - this is intentional.
// `logger` is omitted as it should not trigger the effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps ?? [])
return value return value
} }
@ -100,28 +100,48 @@ export function useNavigate() {
return navigate return navigate
} }
// ===================== // ================
// === useEvent ===
// ================
/** 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)
React.useEffect(() => {
if (event != null) {
setEvent(null)
}
}, [event])
return [event, setEvent]
}
// =========================================
// === Debug wrappers for built-in hooks ===
// =========================================
// `console.*` is allowed because these are for debugging purposes only.
/* eslint-disable no-restricted-properties */
// === useDebugState === // === useDebugState ===
// =====================
/** A modified `useState` that logs the old and new values when `setState` is called. */ /** A modified `useState` that logs the old and new values when `setState` is called. */
export function useDebugState<T>( export function useDebugState<T>(
initialState: T | (() => T), initialState: T | (() => T),
name?: string name?: string
): [state: T, setState: (valueOrUpdater: react.SetStateAction<T>, source?: string) => void] { ): [state: T, setState: (valueOrUpdater: React.SetStateAction<T>, source?: string) => void] {
const [state, rawSetState] = react.useState(initialState) const [state, rawSetState] = React.useState(initialState)
const description = name != null ? `state for '${name}'` : 'state' const description = name != null ? `state for '${name}'` : 'state'
const setState = react.useCallback( const setState = React.useCallback(
(valueOrUpdater: react.SetStateAction<T>, source?: string) => { (valueOrUpdater: React.SetStateAction<T>, source?: string) => {
const fullDescription = `${description}${source != null ? ` from '${source}'` : ''}` const fullDescription = `${description}${source != null ? ` from '${source}'` : ''}`
if (typeof valueOrUpdater === 'function') { if (typeof valueOrUpdater === 'function') {
// This is UNSAFE, however React makes the same assumption. // This is UNSAFE, however React makes the same assumption.
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const updater = valueOrUpdater as (prevState: T) => T const updater = valueOrUpdater as (prevState: T) => T
// `console.*` is allowed because this is for debugging purposes only.
/* eslint-disable no-restricted-properties */
rawSetState(oldState => { rawSetState(oldState => {
console.group(description) console.group(description)
console.log(`Old ${fullDescription}:`, oldState) console.log(`Old ${fullDescription}:`, oldState)
@ -140,11 +160,80 @@ export function useDebugState<T>(
} }
return valueOrUpdater return valueOrUpdater
}) })
/* eslint-enable no-restricted-properties */
} }
}, },
[] [description]
) )
return [state, setState] return [state, setState]
} }
// === useMonitorDependencies ===
/** A helper function to log the old and new values of changed dependencies. */
function useMonitorDependencies(
dependencies: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
const oldDependenciesRef = React.useRef(dependencies)
const indicesOfChangedDependencies = dependencies.flatMap((dep, i) =>
Object.is(dep, oldDependenciesRef.current[i]) ? [] : [i]
)
if (indicesOfChangedDependencies.length !== 0) {
const descriptionText = description == null ? '' : `for '${description}'`
console.group(`dependencies changed${descriptionText}`)
for (const i of indicesOfChangedDependencies) {
console.group(dependencyDescriptions?.[i] ?? `dependency #${i + 1}`)
console.log('old value:', oldDependenciesRef.current[i])
console.log('new value:', dependencies[i])
console.groupEnd()
}
console.groupEnd()
}
oldDependenciesRef.current = dependencies
}
// === useDebugEffect ===
/** A modified `useEffect` that logs the old and new values of changed dependencies. */
export function useDebugEffect(
effect: React.EffectCallback,
deps: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useEffect(effect, deps)
}
// === useDebugMemo ===
/** A modified `useMemo` that logs the old and new values of changed dependencies. */
export function useDebugMemo<T>(
factory: () => T,
deps: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useMemo<T>(factory, deps)
}
// === useDebugCallback ===
/** A modified `useCallback` that logs the old and new values of changed dependencies. */
export function useDebugCallback<T extends (...args: never[]) => unknown>(
callback: T,
deps: React.DependencyList,
description?: string,
dependencyDescriptions?: readonly string[]
) {
useMonitorDependencies(deps, description, dependencyDescriptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback<T>(callback, deps)
}
/* eslint-enable no-restricted-properties */

View File

@ -82,7 +82,7 @@ export class Client {
mimetype?: string mimetype?: string
) { ) {
const headers = new Headers(this.defaultHeaders) const headers = new Headers(this.defaultHeaders)
if (payload) { if (payload != null) {
const contentType = mimetype ?? 'application/json' const contentType = mimetype ?? 'application/json'
headers.set('Content-Type', contentType) headers.set('Content-Type', contentType)
} }
@ -98,7 +98,7 @@ export class Client {
return fetch(url, { return fetch(url, {
method, method,
headers, headers,
...(payload ? { body: payload } : {}), ...(payload != null ? { body: payload } : {}),
}) as Promise<ResponseWithTypedJson<T>> }) as Promise<ResponseWithTypedJson<T>>
} }
} }

View File

@ -1,14 +1,6 @@
/** @file Authentication module used by Enso IDE & Cloud. /** @file Authentication module used by Enso IDE & Cloud.
* *
* This module declares the main DOM structure for the authentication/dashboard app. */ * This module declares the main DOM structure for the authentication/dashboard app. */
/** This import is unused in this file, but React doesn't work without it, under Electron. This is
* probably because it gets tree-shaken out of the bundle, so we need to explicitly import it.
* Unlike all other imports in this project, this one is not `camelCase`. We use `React` instead of
* `react` here. This is because if the import is named any differently then React doesn't get
* included in the final bundle. */
// It is safe to disable `no-restricted-syntax` because the `PascalCase` naming is required
// as per the above comment.
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-restricted-syntax
import * as React from 'react' import * as React from 'react'
import * as reactDOM from 'react-dom/client' import * as reactDOM from 'react-dom/client'

View File

@ -1,6 +1,6 @@
/** @file Defines the React provider for the project manager `Backend`, along with hooks to use the /** @file Defines the React provider for the project manager `Backend`, along with hooks to use the
* provider via the shared React context. */ * provider via the shared React context. */
import * as react from 'react' import * as React from 'react'
import * as common from 'enso-common' import * as common from 'enso-common'
@ -34,7 +34,7 @@ export interface BackendContextType {
// @ts-expect-error The default value will never be exposed // @ts-expect-error The default value will never be exposed
// as `backend` will always be accessed using `useBackend`. // as `backend` will always be accessed using `useBackend`.
const BackendContext = react.createContext<BackendContextType>(null) const BackendContext = React.createContext<BackendContextType>(null)
/** Props for a {@link BackendProvider}. */ /** Props for a {@link BackendProvider}. */
export interface BackendProviderProps extends React.PropsWithChildren<object> { export interface BackendProviderProps extends React.PropsWithChildren<object> {
@ -48,14 +48,14 @@ export interface BackendProviderProps extends React.PropsWithChildren<object> {
/** A React Provider that lets components get and set the current backend. */ /** A React Provider that lets components get and set the current backend. */
export function BackendProvider(props: BackendProviderProps) { export function BackendProvider(props: BackendProviderProps) {
const { children } = props const { children } = props
const [backend, setBackendWithoutSavingType] = react.useState< const [backend, setBackendWithoutSavingType] = React.useState<
localBackend.LocalBackend | remoteBackend.RemoteBackend localBackend.LocalBackend | remoteBackend.RemoteBackend
// This default value is UNSAFE, but must neither be `LocalBackend`, which may not be // 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. // available, not `RemoteBackend`, which does not work when not yet logged in.
// Care must be taken to initialize the backend before its first usage. // Care must be taken to initialize the backend before its first usage.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
>(null!) >(null!)
const setBackend = react.useCallback((newBackend: AnyBackendAPI) => { const setBackend = React.useCallback((newBackend: AnyBackendAPI) => {
setBackendWithoutSavingType(newBackend) setBackendWithoutSavingType(newBackend)
localStorage.setItem(BACKEND_TYPE_KEY, newBackend.type) localStorage.setItem(BACKEND_TYPE_KEY, newBackend.type)
}, []) }, [])
@ -69,12 +69,12 @@ export function BackendProvider(props: BackendProviderProps) {
/** Exposes a property to get the current backend. */ /** Exposes a property to get the current backend. */
export function useBackend() { export function useBackend() {
const { backend } = react.useContext(BackendContext) const { backend } = React.useContext(BackendContext)
return { backend } return { backend }
} }
/** Exposes a property to set the current backend. */ /** Exposes a property to set the current backend. */
export function useSetBackend() { export function useSetBackend() {
const { setBackend, setBackendWithoutSavingType } = react.useContext(BackendContext) const { setBackend, setBackendWithoutSavingType } = React.useContext(BackendContext)
return { setBackend, setBackendWithoutSavingType } return { setBackend, setBackendWithoutSavingType }
} }

View File

@ -1,6 +1,6 @@
/** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the /** @file Defines the React provider for the {@link Logger} interface, along with a hook to use the
* provider via the shared React context. */ * provider via the shared React context. */
import * as react from 'react' import * as React from 'react'
// ============== // ==============
// === Logger === // === Logger ===
@ -23,7 +23,7 @@ export interface Logger {
/** See {@link AuthContext} for safety details. */ /** See {@link AuthContext} for safety details. */
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
const LoggerContext = react.createContext<Logger>({} as Logger) const LoggerContext = React.createContext<Logger>({} as Logger)
// ====================== // ======================
// === LoggerProvider === // === LoggerProvider ===
@ -31,7 +31,7 @@ const LoggerContext = react.createContext<Logger>({} as Logger)
/** Props for a {@link LoggerProvider}. */ /** Props for a {@link LoggerProvider}. */
export interface LoggerProviderProps { export interface LoggerProviderProps {
children: react.ReactNode children: React.ReactNode
logger: Logger logger: Logger
} }
@ -47,5 +47,5 @@ export function LoggerProvider(props: LoggerProviderProps) {
/** A React context hook exposing the diagnostic logger. */ /** A React context hook exposing the diagnostic logger. */
export function useLogger() { export function useLogger() {
return react.useContext(LoggerContext) return React.useContext(LoggerContext)
} }

View File

@ -1,5 +1,5 @@
/** @file */ /** @file */
import * as react from 'react' import * as React from 'react'
// ============= // =============
// === Modal === // === Modal ===
@ -14,7 +14,7 @@ interface ModalContextType {
setModal: (modal: Modal | null) => void setModal: (modal: Modal | null) => void
} }
const ModalContext = react.createContext<ModalContextType>({ const ModalContext = React.createContext<ModalContextType>({
modal: null, modal: null,
setModal: () => { setModal: () => {
// Ignored. This default value will never be used // Ignored. This default value will never be used
@ -28,19 +28,19 @@ export interface ModalProviderProps extends React.PropsWithChildren<object> {}
/** A React provider containing the currently active modal. */ /** A React provider containing the currently active modal. */
export function ModalProvider(props: ModalProviderProps) { export function ModalProvider(props: ModalProviderProps) {
const { children } = props const { children } = props
const [modal, setModal] = react.useState<Modal | null>(null) const [modal, setModal] = React.useState<Modal | null>(null)
return <ModalContext.Provider value={{ modal, setModal }}>{children}</ModalContext.Provider> return <ModalContext.Provider value={{ modal, setModal }}>{children}</ModalContext.Provider>
} }
/** A React context hook exposing the currently active modal, if one is currently visible. */ /** A React context hook exposing the currently active modal, if one is currently visible. */
export function useModal() { export function useModal() {
const { modal } = react.useContext(ModalContext) const { modal } = React.useContext(ModalContext)
return { modal } return { modal }
} }
/** A React context hook exposing functions to set and unset the currently active modal. */ /** A React context hook exposing functions to set and unset the currently active modal. */
export function useSetModal() { export function useSetModal() {
const { setModal: setModalRaw } = react.useContext(ModalContext) const { setModal: setModalRaw } = React.useContext(ModalContext)
const setModal = (modal: Modal) => { const setModal = (modal: Modal) => {
setModalRaw(modal) setModalRaw(modal)
} }

View File

@ -67,6 +67,82 @@ declare module 'eslint-plugin-jsdoc' {
export default DEFAULT export default DEFAULT
} }
declare module 'eslint-plugin-react' {
/** An ESLint configuration. */
interface Config {
plugins: string[]
rules: Record<string, number>
parserOptions: object
}
// The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
/** Configurations defined by this ESLint plugin. */
interface Configs {
recommended: Config
all: Config
'jsx-runtime': Config
}
/** Deprecated rules contained in this ESLint plugin. */
interface DeprecatedRules {
'jsx-sort-default-props': object
'jsx-space-before-closing': object
}
/* eslint-enable @typescript-eslint/naming-convention */
/** The default export of this ESLint plugin. */
interface Default {
rules: Record<string, object>
configs: Configs
deprecatedRules: DeprecatedRules
}
// The names come from a third-party API and cannot be changed.
// eslint-disable-next-line no-restricted-syntax
export const deprecatedRules: DeprecatedRules
const DEFAULT: Default
export default DEFAULT
}
declare module 'eslint-plugin-react-hooks' {
/** An ESLint configuration. */
interface Config {
plugins: string[]
rules: Record<string, string>
}
/** Configurations defined by this ESLint plugin. */
interface Configs {
recommended: Config
}
/** Rules defined by this ESLint plugin. */
interface ReactHooksRules {
// The names come from a third-party API and cannot be changed.
/* eslint-disable @typescript-eslint/naming-convention */
'rules-of-hooks': object
'exhaustive-deps': object
/* eslint-enable @typescript-eslint/naming-convention */
}
/** The default export of this ESLint plugin. */
interface Default {
configs: Configs
rules: ReactHooksRules
}
// The names come from a third-party API and cannot be changed.
/* eslint-disable no-restricted-syntax */
export const configs: Configs
export const rules: ReactHooksRules
/* eslint-enable no-restricted-syntax */
const DEFAULT: Default
export default DEFAULT
}
declare module 'esbuild-plugin-time' { declare module 'esbuild-plugin-time' {
import * as esbuild from 'esbuild' import * as esbuild from 'esbuild'

File diff suppressed because it is too large Load Diff

View File

@ -38,5 +38,9 @@
"watch-dashboard": "npm run watch --workspace enso-dashboard", "watch-dashboard": "npm run watch --workspace enso-dashboard",
"build-dashboard": "npm run build --workspace enso-dashboard", "build-dashboard": "npm run build --workspace enso-dashboard",
"typecheck": "npx tsc -p lib/types/tsconfig.json && npm run typecheck --workspace enso && npm run typecheck --workspace enso-content && npm run typecheck --workspace enso-dashboard && npm run typecheck --workspace enso-authentication" "typecheck": "npx tsc -p lib/types/tsconfig.json && npm run typecheck --workspace enso && npm run typecheck --workspace enso-content && npm run typecheck --workspace enso-dashboard && npm run typecheck --workspace enso-authentication"
},
"dependencies": {
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0"
} }
} }