mirror of
https://github.com/enso-org/enso.git
synced 2024-11-22 03:32:23 +03:00
Make router work
This commit is contained in:
parent
eb54a45f8d
commit
3b4002237b
@ -38,88 +38,36 @@ import * as React from 'react'
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import * as router from 'react-router-dom'
|
||||
import * as toastify from 'react-toastify'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
import * as z from 'zod'
|
||||
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
|
||||
import AuthProvider, * as authProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import * as httpClientProvider from '#/providers/HttpClientProvider'
|
||||
import InputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import LocalStorageProvider, * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import LocalStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import type * as loggerProvider from '#/providers/LoggerProvider'
|
||||
import ModalProvider, * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
import ModalProvider from '#/providers/ModalProvider'
|
||||
|
||||
import ConfirmRegistration from '#/pages/authentication/ConfirmRegistration'
|
||||
import ForgotPassword from '#/pages/authentication/ForgotPassword'
|
||||
import Login from '#/pages/authentication/Login'
|
||||
import Registration from '#/pages/authentication/Registration'
|
||||
import ResetPassword from '#/pages/authentication/ResetPassword'
|
||||
import RestoreAccount from '#/pages/authentication/RestoreAccount'
|
||||
import SetUsername from '#/pages/authentication/SetUsername'
|
||||
import Dashboard from '#/pages/dashboard/Dashboard'
|
||||
import * as subscribe from '#/pages/subscribe/Subscribe'
|
||||
import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
|
||||
|
||||
import * as openAppWatcher from '#/layouts/OpenAppWatcher'
|
||||
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as offlineNotificationManager from '#/components/OfflineNotificationManager'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import * as rootComponent from '#/components/Root'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
import AboutModal from '#/modals/AboutModal'
|
||||
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
|
||||
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
|
||||
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import * as projectManager from '#/services/ProjectManager'
|
||||
import RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as appBaseUrl from '#/utilities/appBaseUrl'
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import type HttpClient from '#/utilities/HttpClient'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
|
||||
import type * as types from '../../types/types'
|
||||
import * as appRoutes from './Routes'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
const INPUT_BINDINGS_SCHEMA = z.record(z.array(z.string()).readonly()).or(z.null())
|
||||
|
||||
declare module '#/utilities/LocalStorage' {
|
||||
/** */
|
||||
interface LocalStorageData {
|
||||
readonly inputBindings: Readonly<Record<string, readonly string[]>>
|
||||
readonly inputBindings: z.infer<typeof INPUT_BINDINGS_SCHEMA>
|
||||
}
|
||||
}
|
||||
|
||||
LocalStorage.registerKey('inputBindings', {
|
||||
tryParse: value =>
|
||||
typeof value !== 'object' || value == null
|
||||
? null
|
||||
: Object.fromEntries(
|
||||
Object.entries<unknown>({ ...value }).flatMap(kv => {
|
||||
const [k, v] = kv
|
||||
return Array.isArray(v) && v.every((item): item is string => typeof item === 'string')
|
||||
? [[k, v]]
|
||||
: []
|
||||
})
|
||||
),
|
||||
})
|
||||
LocalStorage.registerKey('inputBindings', { isUserSpecific: true, schema: INPUT_BINDINGS_SCHEMA })
|
||||
|
||||
// ======================
|
||||
// === getMainPageUrl ===
|
||||
@ -147,8 +95,6 @@ export interface AppProps {
|
||||
/** Whether the application supports deep links. This is only true when using
|
||||
* the installed app on macOS and Windows. */
|
||||
readonly supportsDeepLinks: boolean
|
||||
/** Whether the dashboard should be rendered. */
|
||||
readonly shouldShowDashboard: boolean
|
||||
/** The name of the project to open on startup, if any. */
|
||||
readonly initialProjectName: string | null
|
||||
readonly onAuthenticated: (accessToken: string | null) => void
|
||||
@ -182,6 +128,8 @@ export default function App(props: AppProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
|
||||
// Both `BackendProvider` and `InputBindingsProvider` depend on `LocalStorageProvider`.
|
||||
// Note that the `Router` must be the parent of the `AuthProvider`, because the `AuthProvider`
|
||||
// will redirect the user between the login/register pages and the dashboard.
|
||||
@ -196,326 +144,16 @@ export default function App(props: AppProps) {
|
||||
transition={toastify.Zoom}
|
||||
limit={3}
|
||||
/>
|
||||
<router.BrowserRouter basename={getMainPageUrl().pathname}>
|
||||
<LocalStorageProvider>
|
||||
<ModalProvider>
|
||||
<AppRouter {...props} projectManagerRootDirectory={rootDirectoryPath} />
|
||||
</ModalProvider>
|
||||
</LocalStorageProvider>
|
||||
</router.BrowserRouter>
|
||||
<LocalStorageProvider>
|
||||
<ModalProvider>
|
||||
<appRoutes.Routes
|
||||
{...props}
|
||||
basename={mainPageUrl.pathname}
|
||||
projectManagerRootDirectory={rootDirectoryPath}
|
||||
mainPageUrl={mainPageUrl}
|
||||
/>
|
||||
</ModalProvider>
|
||||
</LocalStorageProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// =================
|
||||
// === AppRouter ===
|
||||
// =================
|
||||
|
||||
/** Props for an {@link AppRouter}. */
|
||||
export interface AppRouterProps extends AppProps {
|
||||
readonly projectManagerRootDirectory: projectManager.Path | null
|
||||
}
|
||||
|
||||
/** Router definition for the app.
|
||||
*
|
||||
* The only reason the {@link AppRouter} component is separate from the {@link App} component is
|
||||
* because the {@link AppRouter} relies on React hooks, which can't be used in the same React
|
||||
* component as the component that defines the provider. */
|
||||
function AppRouter(props: AppRouterProps) {
|
||||
const { logger, isAuthenticationDisabled, shouldShowDashboard, httpClient } = props
|
||||
const { onAuthenticated, projectManagerUrl, projectManagerRootDirectory } = props
|
||||
const { portalRoot } = props
|
||||
// `navigateHooks.useNavigate` cannot be used here as it relies on `AuthProvider`, which has not
|
||||
// yet been initialized at this point.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
const navigate = router.useNavigate()
|
||||
const { getText } = textProvider.useText()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
|
||||
const localBackend = React.useMemo(
|
||||
() =>
|
||||
projectManagerUrl != null && projectManagerRootDirectory != null
|
||||
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
|
||||
: null,
|
||||
[projectManagerUrl, projectManagerRootDirectory]
|
||||
)
|
||||
|
||||
const remoteBackend = React.useMemo(
|
||||
() => new RemoteBackend(httpClient, logger, getText),
|
||||
[httpClient, logger, getText]
|
||||
)
|
||||
|
||||
backendHooks.useObserveBackend(remoteBackend)
|
||||
backendHooks.useObserveBackend(localBackend)
|
||||
|
||||
if (detect.IS_DEV_MODE) {
|
||||
// @ts-expect-error This is used exclusively for debugging.
|
||||
window.navigate = navigate
|
||||
}
|
||||
|
||||
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
|
||||
|
||||
React.useEffect(() => {
|
||||
const savedInputBindings = localStorage.get('inputBindings')
|
||||
if (savedInputBindings != null) {
|
||||
const filteredInputBindings = object.mapEntries(
|
||||
inputBindingsRaw.metadata,
|
||||
k => savedInputBindings[k]
|
||||
)
|
||||
for (const [bindingKey, newBindings] of object.unsafeEntries(filteredInputBindings)) {
|
||||
for (const oldBinding of inputBindingsRaw.metadata[bindingKey].bindings) {
|
||||
inputBindingsRaw.delete(bindingKey, oldBinding)
|
||||
}
|
||||
for (const newBinding of newBindings ?? []) {
|
||||
inputBindingsRaw.add(bindingKey, newBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
const inputBindings = React.useMemo(() => {
|
||||
const updateLocalStorage = () => {
|
||||
localStorage.set(
|
||||
'inputBindings',
|
||||
Object.fromEntries(
|
||||
Object.entries(inputBindingsRaw.metadata).map(kv => {
|
||||
const [k, v] = kv
|
||||
return [k, v.bindings]
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
return {
|
||||
/** Transparently pass through `handler()`. */
|
||||
get handler() {
|
||||
return inputBindingsRaw.handler.bind(inputBindingsRaw)
|
||||
},
|
||||
/** Transparently pass through `attach()`. */
|
||||
get attach() {
|
||||
return inputBindingsRaw.attach.bind(inputBindingsRaw)
|
||||
},
|
||||
reset: (bindingKey: inputBindingsModule.DashboardBindingKey) => {
|
||||
inputBindingsRaw.reset(bindingKey)
|
||||
updateLocalStorage()
|
||||
},
|
||||
add: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
|
||||
inputBindingsRaw.add(bindingKey, binding)
|
||||
updateLocalStorage()
|
||||
},
|
||||
delete: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
|
||||
inputBindingsRaw.delete(bindingKey, binding)
|
||||
updateLocalStorage()
|
||||
},
|
||||
/** Transparently pass through `metadata`. */
|
||||
get metadata() {
|
||||
return inputBindingsRaw.metadata
|
||||
},
|
||||
/** Transparently pass through `register()`. */
|
||||
get register() {
|
||||
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
|
||||
},
|
||||
/** Transparently pass through `unregister()`. */
|
||||
get unregister() {
|
||||
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
|
||||
},
|
||||
}
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
const mainPageUrl = getMainPageUrl()
|
||||
|
||||
const authService = React.useMemo(() => {
|
||||
const authConfig = { navigate, ...props }
|
||||
return authServiceModule.initAuthService(authConfig)
|
||||
}, [props, navigate])
|
||||
|
||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||
const refreshUserSession =
|
||||
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null
|
||||
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
if ('menuApi' in window) {
|
||||
window.menuApi.setShowAboutModalHandler(() => {
|
||||
setModal(<AboutModal />)
|
||||
})
|
||||
}
|
||||
}, [setModal])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = navigator2D.onKeyDown.bind(navigator2D)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [navigator2D])
|
||||
|
||||
React.useEffect(() => {
|
||||
let isClick = false
|
||||
const onMouseDown = () => {
|
||||
isClick = true
|
||||
}
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
if (
|
||||
isClick &&
|
||||
!eventModule.isElementTextInput(event.target) &&
|
||||
!eventModule.isElementPartOfMonaco(event.target) &&
|
||||
!eventModule.isElementTextInput(document.activeElement)
|
||||
) {
|
||||
const selection = document.getSelection()
|
||||
const app = document.getElementById('app')
|
||||
const appContainsSelection =
|
||||
app != null &&
|
||||
selection != null &&
|
||||
selection.anchorNode != null &&
|
||||
app.contains(selection.anchorNode) &&
|
||||
selection.focusNode != null &&
|
||||
app.contains(selection.focusNode)
|
||||
if (!appContainsSelection) {
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
const onSelectStart = () => {
|
||||
isClick = false
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('selectstart', onSelectStart)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const routes = (
|
||||
<router.Routes>
|
||||
{/* Login & registration pages are visible to unauthenticated users. */}
|
||||
<router.Route element={<authProvider.GuestLayout />}>
|
||||
<router.Route path={appUtils.REGISTRATION_PATH} element={<Registration />} />
|
||||
<router.Route path={appUtils.LOGIN_PATH} element={<Login />} />
|
||||
</router.Route>
|
||||
|
||||
{/* Protected pages are visible to authenticated users. */}
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route element={<authProvider.ProtectedLayout />}>
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<router.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||
<router.Route element={<openAppWatcher.OpenAppWatcher />}>
|
||||
<router.Route
|
||||
path={appUtils.DASHBOARD_PATH}
|
||||
element={shouldShowDashboard && <Dashboard {...props} />}
|
||||
/>
|
||||
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribe.Subscribe />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
<router.Route
|
||||
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<suspense.Suspense>
|
||||
<subscribeSuccess.SubscribeSuccess />
|
||||
</suspense.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
<router.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
{/* Semi-protected pages are visible to users currently registering. */}
|
||||
<router.Route element={<authProvider.NotDeletedUserLayout />}>
|
||||
<router.Route element={<authProvider.SemiProtectedLayout />}>
|
||||
<router.Route path={appUtils.SET_USERNAME_PATH} element={<SetUsername />} />
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
{/* Other pages are visible to unauthenticated and authenticated users. */}
|
||||
<router.Route path={appUtils.CONFIRM_REGISTRATION_PATH} element={<ConfirmRegistration />} />
|
||||
<router.Route path={appUtils.FORGOT_PASSWORD_PATH} element={<ForgotPassword />} />
|
||||
<router.Route path={appUtils.RESET_PASSWORD_PATH} element={<ResetPassword />} />
|
||||
|
||||
{/* Soft-deleted user pages are visible to users who have been soft-deleted. */}
|
||||
<router.Route element={<authProvider.ProtectedLayout />}>
|
||||
<router.Route element={<authProvider.SoftDeletedUserLayout />}>
|
||||
<router.Route path={appUtils.RESTORE_USER_PATH} element={<RestoreAccount />} />
|
||||
</router.Route>
|
||||
</router.Route>
|
||||
|
||||
{/* 404 page */}
|
||||
<router.Route path="*" element={<router.Navigate to="/" replace />} />
|
||||
</router.Routes>
|
||||
)
|
||||
|
||||
let result = routes
|
||||
|
||||
if (detect.IS_DEV_MODE) {
|
||||
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
|
||||
}
|
||||
|
||||
result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary>
|
||||
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
|
||||
result = (
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
{result}
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
result = (
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
{result}
|
||||
</BackendProvider>
|
||||
)
|
||||
|
||||
result = (
|
||||
<SessionProvider
|
||||
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
refreshUserSession={refreshUserSession}
|
||||
>
|
||||
{result}
|
||||
</SessionProvider>
|
||||
)
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
result = (
|
||||
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
|
||||
{result}
|
||||
</rootComponent.Root>
|
||||
)
|
||||
result = (
|
||||
<offlineNotificationManager.OfflineNotificationManager>
|
||||
{result}
|
||||
</offlineNotificationManager.OfflineNotificationManager>
|
||||
)
|
||||
result = (
|
||||
<httpClientProvider.HttpClientProvider httpClient={httpClient}>
|
||||
{result}
|
||||
</httpClientProvider.HttpClientProvider>
|
||||
)
|
||||
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
|
||||
return result
|
||||
}
|
||||
|
283
app/ide-desktop/lib/dashboard/src/AppLayout.tsx
Normal file
283
app/ide-desktop/lib/dashboard/src/AppLayout.tsx
Normal file
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @file
|
||||
*
|
||||
* This file is the main layout for the application. It is responsible for setting up the providers and hooks that are used throughout the application.
|
||||
*/
|
||||
import * as React from 'react'
|
||||
|
||||
import * as router from 'react-router-dom'
|
||||
|
||||
import * as detect from 'enso-common/src/detect'
|
||||
|
||||
import * as inputBindingsModule from '#/configurations/inputBindings'
|
||||
|
||||
import * as backendHooks from '#/hooks/backendHooks'
|
||||
|
||||
import * as appProvider from '#/providers/AppProvider'
|
||||
import AuthProvider from '#/providers/AuthProvider'
|
||||
import BackendProvider from '#/providers/BackendProvider'
|
||||
import * as httpClientProvider from '#/providers/HttpClientProvider'
|
||||
import InputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
import * as localStorageProvider from '#/providers/LocalStorageProvider'
|
||||
import LoggerProvider from '#/providers/LoggerProvider'
|
||||
import * as modalProvider from '#/providers/ModalProvider'
|
||||
import * as navigator2DProvider from '#/providers/Navigator2DProvider'
|
||||
import SessionProvider from '#/providers/SessionProvider'
|
||||
import * as textProvider from '#/providers/TextProvider'
|
||||
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as offlineNotificationManager from '#/components/OfflineNotificationManager'
|
||||
import * as paywall from '#/components/Paywall'
|
||||
import * as rootComponent from '#/components/Root'
|
||||
|
||||
import AboutModal from '#/modals/AboutModal'
|
||||
|
||||
import LocalBackend from '#/services/LocalBackend'
|
||||
import type * as projectManager from '#/services/ProjectManager'
|
||||
import RemoteBackend from '#/services/RemoteBackend'
|
||||
|
||||
import * as eventModule from '#/utilities/event'
|
||||
import * as object from '#/utilities/object'
|
||||
|
||||
import * as authServiceModule from '#/authentication/service'
|
||||
|
||||
import type * as appComponent from './App'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
/** Props for an {@link AppLayout}. */
|
||||
export interface AppLayoutProps extends appComponent.AppProps {
|
||||
readonly projectManagerRootDirectory: projectManager.Path | null
|
||||
readonly mainPageUrl: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* Root Layout of the App. Provides core data and dependencies to
|
||||
* the nested elements
|
||||
*/
|
||||
export function AppLayout(props: AppLayoutProps) {
|
||||
const { logger, isAuthenticationDisabled, httpClient, mainPageUrl } = props
|
||||
const { onAuthenticated, projectManagerUrl, projectManagerRootDirectory } = props
|
||||
const { portalRoot } = props
|
||||
// `navigateHooks.useNavigate` cannot be used here as it relies on `AuthProvider`, which has not
|
||||
// yet been initialized at this point.
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
const navigate = router.useNavigate()
|
||||
const { getText } = textProvider.useText()
|
||||
const { localStorage } = localStorageProvider.useLocalStorage()
|
||||
const { setModal } = modalProvider.useSetModal()
|
||||
const navigator2D = navigator2DProvider.useNavigator2D()
|
||||
|
||||
const localBackend = React.useMemo(
|
||||
() =>
|
||||
projectManagerUrl != null && projectManagerRootDirectory != null
|
||||
? new LocalBackend(projectManagerUrl, projectManagerRootDirectory)
|
||||
: null,
|
||||
[projectManagerUrl, projectManagerRootDirectory]
|
||||
)
|
||||
|
||||
const remoteBackend = React.useMemo(
|
||||
() => new RemoteBackend(httpClient, logger, getText),
|
||||
[httpClient, logger, getText]
|
||||
)
|
||||
|
||||
backendHooks.useObserveBackend(remoteBackend)
|
||||
backendHooks.useObserveBackend(localBackend)
|
||||
|
||||
if (detect.IS_DEV_MODE) {
|
||||
// @ts-expect-error This is used exclusively for debugging.
|
||||
window.navigate = navigate
|
||||
}
|
||||
|
||||
const [inputBindingsRaw] = React.useState(() => inputBindingsModule.createBindings())
|
||||
|
||||
React.useEffect(() => {
|
||||
const savedInputBindings = localStorage.get('inputBindings')
|
||||
if (savedInputBindings != null) {
|
||||
const filteredInputBindings = object.mapEntries(
|
||||
inputBindingsRaw.metadata,
|
||||
k => savedInputBindings[k]
|
||||
)
|
||||
for (const [bindingKey, newBindings] of object.unsafeEntries(filteredInputBindings)) {
|
||||
for (const oldBinding of inputBindingsRaw.metadata[bindingKey].bindings) {
|
||||
inputBindingsRaw.delete(bindingKey, oldBinding)
|
||||
}
|
||||
for (const newBinding of newBindings ?? []) {
|
||||
inputBindingsRaw.add(bindingKey, newBinding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
const inputBindings = React.useMemo(() => {
|
||||
const updateLocalStorage = () => {
|
||||
localStorage.set(
|
||||
'inputBindings',
|
||||
Object.fromEntries(
|
||||
Object.entries(inputBindingsRaw.metadata).map(kv => {
|
||||
const [k, v] = kv
|
||||
return [k, v.bindings]
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
return {
|
||||
/** Transparently pass through `handler()`. */
|
||||
get handler() {
|
||||
return inputBindingsRaw.handler.bind(inputBindingsRaw)
|
||||
},
|
||||
/** Transparently pass through `attach()`. */
|
||||
get attach() {
|
||||
return inputBindingsRaw.attach.bind(inputBindingsRaw)
|
||||
},
|
||||
reset: (bindingKey: inputBindingsModule.DashboardBindingKey) => {
|
||||
inputBindingsRaw.reset(bindingKey)
|
||||
updateLocalStorage()
|
||||
},
|
||||
add: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
|
||||
inputBindingsRaw.add(bindingKey, binding)
|
||||
updateLocalStorage()
|
||||
},
|
||||
delete: (bindingKey: inputBindingsModule.DashboardBindingKey, binding: string) => {
|
||||
inputBindingsRaw.delete(bindingKey, binding)
|
||||
updateLocalStorage()
|
||||
},
|
||||
/** Transparently pass through `metadata`. */
|
||||
get metadata() {
|
||||
return inputBindingsRaw.metadata
|
||||
},
|
||||
/** Transparently pass through `register()`. */
|
||||
get register() {
|
||||
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
|
||||
},
|
||||
/** Transparently pass through `unregister()`. */
|
||||
get unregister() {
|
||||
return inputBindingsRaw.unregister.bind(inputBindingsRaw)
|
||||
},
|
||||
}
|
||||
}, [localStorage, inputBindingsRaw])
|
||||
|
||||
const authService = React.useMemo(() => {
|
||||
const authConfig = { navigate, ...props }
|
||||
return authServiceModule.initAuthService(authConfig)
|
||||
}, [props, navigate])
|
||||
|
||||
const userSession = authService?.cognito.userSession.bind(authService.cognito) ?? null
|
||||
const refreshUserSession =
|
||||
authService?.cognito.refreshUserSession.bind(authService.cognito) ?? null
|
||||
const registerAuthEventListener = authService?.registerAuthEventListener ?? null
|
||||
|
||||
React.useEffect(() => {
|
||||
if ('menuApi' in window) {
|
||||
window.menuApi.setShowAboutModalHandler(() => {
|
||||
setModal(<AboutModal />)
|
||||
})
|
||||
}
|
||||
}, [setModal])
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = navigator2D.onKeyDown.bind(navigator2D)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [navigator2D])
|
||||
|
||||
React.useEffect(() => {
|
||||
let isClick = false
|
||||
const onMouseDown = () => {
|
||||
isClick = true
|
||||
}
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
if (
|
||||
isClick &&
|
||||
!eventModule.isElementTextInput(event.target) &&
|
||||
!eventModule.isElementPartOfMonaco(event.target) &&
|
||||
!eventModule.isElementTextInput(document.activeElement)
|
||||
) {
|
||||
const selection = document.getSelection()
|
||||
const app = document.getElementById('app')
|
||||
const appContainsSelection =
|
||||
app != null &&
|
||||
selection != null &&
|
||||
selection.anchorNode != null &&
|
||||
app.contains(selection.anchorNode) &&
|
||||
selection.focusNode != null &&
|
||||
app.contains(selection.focusNode)
|
||||
if (!appContainsSelection) {
|
||||
selection?.removeAllRanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
const onSelectStart = () => {
|
||||
isClick = false
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', onMouseDown)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
document.addEventListener('selectstart', onSelectStart)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onMouseDown)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
document.removeEventListener('selectstart', onSelectStart)
|
||||
}
|
||||
}, [])
|
||||
|
||||
let result: React.JSX.Element | null = <router.Outlet />
|
||||
|
||||
if (detect.IS_DEV_MODE) {
|
||||
result = <paywall.PaywallDevtools>{result}</paywall.PaywallDevtools>
|
||||
}
|
||||
|
||||
result = <errorBoundary.ErrorBoundary>{result}</errorBoundary.ErrorBoundary>
|
||||
result = <InputBindingsProvider inputBindings={inputBindings}>{result}</InputBindingsProvider>
|
||||
result = (
|
||||
<AuthProvider
|
||||
shouldStartInOfflineMode={isAuthenticationDisabled}
|
||||
authService={authService}
|
||||
onAuthenticated={onAuthenticated}
|
||||
>
|
||||
{result}
|
||||
</AuthProvider>
|
||||
)
|
||||
|
||||
result = (
|
||||
<BackendProvider remoteBackend={remoteBackend} localBackend={localBackend}>
|
||||
{result}
|
||||
</BackendProvider>
|
||||
)
|
||||
|
||||
result = (
|
||||
<SessionProvider
|
||||
saveAccessToken={authService?.cognito.saveAccessToken.bind(authService.cognito) ?? null}
|
||||
mainPageUrl={mainPageUrl}
|
||||
userSession={userSession}
|
||||
registerAuthEventListener={registerAuthEventListener}
|
||||
refreshUserSession={refreshUserSession}
|
||||
>
|
||||
{result}
|
||||
</SessionProvider>
|
||||
)
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
result = (
|
||||
<rootComponent.Root navigate={navigate} portalRoot={portalRoot}>
|
||||
{result}
|
||||
</rootComponent.Root>
|
||||
)
|
||||
result = (
|
||||
<offlineNotificationManager.OfflineNotificationManager>
|
||||
{result}
|
||||
</offlineNotificationManager.OfflineNotificationManager>
|
||||
)
|
||||
result = (
|
||||
<httpClientProvider.HttpClientProvider httpClient={httpClient}>
|
||||
{result}
|
||||
</httpClientProvider.HttpClientProvider>
|
||||
)
|
||||
|
||||
result = <LoggerProvider logger={logger}>{result}</LoggerProvider>
|
||||
result = <appProvider.AppProvider {...props}>{result}</appProvider.AppProvider>
|
||||
|
||||
return result
|
||||
}
|
@ -7,6 +7,7 @@ import * as React from 'react'
|
||||
|
||||
import * as reactRouterDom from 'react-router-dom'
|
||||
|
||||
import * as appLayout from '#/AppLayout'
|
||||
import * as appUtils from '#/appUtils'
|
||||
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
@ -18,35 +19,36 @@ import Registration from '#/pages/authentication/Registration'
|
||||
import ResetPassword from '#/pages/authentication/ResetPassword'
|
||||
import RestoreAccount from '#/pages/authentication/RestoreAccount'
|
||||
import SetUsername from '#/pages/authentication/SetUsername'
|
||||
import dashboard from '#/pages/dashboard/Dashboard'
|
||||
import * as subscribe from '#/pages/subscribe/Subscribe'
|
||||
import * as subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
|
||||
import dashboard from '#/pages/dashboard'
|
||||
import subscribe from '#/pages/subscribe/Subscribe'
|
||||
import subscribeSuccess from '#/pages/subscribe/SubscribeSuccess'
|
||||
|
||||
import * as errorBoundary from '#/components/ErrorBoundary'
|
||||
import * as loader from '#/components/Loader'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
import * as setOrganizationNameModal from '#/modals/SetOrganizationNameModal'
|
||||
import * as termsOfServiceModal from '#/modals/TermsOfServiceModal'
|
||||
|
||||
import type * as app from './App'
|
||||
|
||||
/**
|
||||
* Props for the main router for the application.
|
||||
*/
|
||||
export interface RoutesProps extends app.AppRouterProps {
|
||||
readonly shouldShowDashboard: boolean
|
||||
export interface RoutesProps extends appLayout.AppLayoutProps {
|
||||
readonly fallback?: React.ReactNode
|
||||
readonly basename: string
|
||||
readonly mainPageUrl: URL
|
||||
}
|
||||
|
||||
/**
|
||||
* The main router for the application.
|
||||
*/
|
||||
export function Routes(props: RoutesProps) {
|
||||
const { shouldShowDashboard, fallback, basename } = props
|
||||
const { fallback = <suspense.Suspense />, basename } = props
|
||||
|
||||
const routes = (
|
||||
<>
|
||||
<reactRouterDom.Route
|
||||
element={<appLayout.AppLayout {...props} />}
|
||||
errorElement={<errorBoundary.ErrorBoundary />}
|
||||
>
|
||||
{/* Login & registration pages are visible to unauthenticated users. */}
|
||||
<reactRouterDom.Route element={<authProvider.GuestLayout />}>
|
||||
<reactRouterDom.Route path={appUtils.REGISTRATION_PATH} element={<Registration />} />
|
||||
@ -58,30 +60,13 @@ export function Routes(props: RoutesProps) {
|
||||
<reactRouterDom.Route element={<authProvider.ProtectedLayout />}>
|
||||
<reactRouterDom.Route element={<termsOfServiceModal.TermsOfServiceModal />}>
|
||||
<reactRouterDom.Route element={<setOrganizationNameModal.SetOrganizationNameModal />}>
|
||||
{shouldShowDashboard && <reactRouterDom.Route {...dashboard} />}
|
||||
<reactRouterDom.Route {...dashboard} />
|
||||
|
||||
<reactRouterDom.Route
|
||||
path={appUtils.SUBSCRIBE_PATH}
|
||||
errorElement={<errorBoundary.ErrorBoundary />}
|
||||
element={
|
||||
<React.Suspense fallback={<loader.Loader />}>
|
||||
<subscribe.Subscribe />
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
<reactRouterDom.Route {...subscribe} />
|
||||
</reactRouterDom.Route>
|
||||
</reactRouterDom.Route>
|
||||
|
||||
<reactRouterDom.Route
|
||||
path={appUtils.SUBSCRIBE_SUCCESS_PATH}
|
||||
element={
|
||||
<errorBoundary.ErrorBoundary>
|
||||
<React.Suspense fallback={<loader.Loader />}>
|
||||
<subscribeSuccess.SubscribeSuccess />
|
||||
</React.Suspense>
|
||||
</errorBoundary.ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<reactRouterDom.Route {...subscribeSuccess} />
|
||||
</reactRouterDom.Route>
|
||||
</reactRouterDom.Route>
|
||||
|
||||
@ -111,7 +96,7 @@ export function Routes(props: RoutesProps) {
|
||||
|
||||
{/* 404 page */}
|
||||
<reactRouterDom.Route path="*" element={<reactRouterDom.Navigate to="/" replace />} />
|
||||
</>
|
||||
</reactRouterDom.Route>
|
||||
)
|
||||
|
||||
const router = reactRouterDom.createBrowserRouter(
|
@ -7,6 +7,7 @@ import * as detect from 'enso-common/src/detect'
|
||||
import * as eventHooks from '#/hooks/eventHooks'
|
||||
import * as searchParamsState from '#/hooks/searchParamsStateHooks'
|
||||
|
||||
import * as appContext from '#/providers/AppProvider'
|
||||
import * as authProvider from '#/providers/AuthProvider'
|
||||
import * as backendProvider from '#/providers/BackendProvider'
|
||||
import * as inputBindingsProvider from '#/providers/InputBindingsProvider'
|
||||
@ -31,15 +32,12 @@ import Page from '#/components/Page'
|
||||
|
||||
import * as backendModule from '#/services/Backend'
|
||||
import type Backend from '#/services/Backend'
|
||||
import type * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import * as array from '#/utilities/array'
|
||||
import LocalStorage from '#/utilities/LocalStorage'
|
||||
import * as object from '#/utilities/object'
|
||||
import * as sanitizedEventTargets from '#/utilities/sanitizedEventTargets'
|
||||
|
||||
import type * as types from '../../../../types/types'
|
||||
|
||||
// ============================
|
||||
// === Global configuration ===
|
||||
// ============================
|
||||
@ -96,21 +94,11 @@ LocalStorage.registerKey('projectStartupInfo', {
|
||||
// === Dashboard ===
|
||||
// =================
|
||||
|
||||
/** Props for {@link Dashboard}s that are common to all platforms. */
|
||||
export interface DashboardProps {
|
||||
/** Whether the application may have the local backend running. */
|
||||
readonly supportsLocalBackend: boolean
|
||||
readonly appRunner: types.EditorRunner | null
|
||||
readonly initialProjectName: string | null
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
readonly projectManagerRootDirectory: projectManager.Path | null
|
||||
}
|
||||
|
||||
/** The component that contains the entire UI. */
|
||||
export default function Dashboard(props: DashboardProps) {
|
||||
const { appRunner, initialProjectName } = props
|
||||
const { ydocUrl, projectManagerUrl, projectManagerRootDirectory } = props
|
||||
export function Dashboard() {
|
||||
const { initialProjectName, projectManagerUrl, projectManagerRootDirectory, ydocUrl, appRunner } =
|
||||
appContext.useAppContext()
|
||||
|
||||
const session = authProvider.useNonPartialUserSession()
|
||||
const remoteBackend = backendProvider.useRemoteBackend()
|
||||
const localBackend = backendProvider.useLocalBackend()
|
||||
|
@ -1,12 +1,14 @@
|
||||
/**
|
||||
* @file Barrel import for Dashboard route.
|
||||
*/
|
||||
import Dashboard from './Dashboard'
|
||||
import defineRoute from '#/utilities/defineRoute'
|
||||
|
||||
import * as dashboard from './Dashboard'
|
||||
|
||||
/**
|
||||
* Dashboard route.
|
||||
*/
|
||||
export default {
|
||||
path: '/dashboard',
|
||||
element: Dashboard,
|
||||
}
|
||||
export default defineRoute({
|
||||
path: '/',
|
||||
element: dashboard.Dashboard,
|
||||
})
|
||||
|
@ -3,4 +3,11 @@
|
||||
*
|
||||
* Barrel file for Subscribe page.
|
||||
*/
|
||||
export * from './Subscribe'
|
||||
import defineRoute from '#/utilities/defineRoute'
|
||||
|
||||
import * as subscribe from './Subscribe'
|
||||
|
||||
export default defineRoute({
|
||||
path: '/subscribe',
|
||||
element: subscribe.Subscribe,
|
||||
})
|
||||
|
@ -0,0 +1,8 @@
|
||||
import defineRoute from '#/utilities/defineRoute'
|
||||
|
||||
import * as subscribeSuccess from './SubscribeSuccess'
|
||||
|
||||
export default defineRoute({
|
||||
path: '/subscribe/success',
|
||||
element: subscribeSuccess.SubscribeSuccess,
|
||||
})
|
46
app/ide-desktop/lib/dashboard/src/providers/AppProvider.tsx
Normal file
46
app/ide-desktop/lib/dashboard/src/providers/AppProvider.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import invariant from 'tiny-invariant'
|
||||
|
||||
import type * as projectManager from '#/services/ProjectManager'
|
||||
|
||||
import type * as types from '../../../types/types'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface AppContextType {
|
||||
readonly supportsLocalBackend: boolean
|
||||
readonly appRunner: types.EditorRunner | null
|
||||
readonly initialProjectName: string | null
|
||||
readonly projectManagerUrl: string | null
|
||||
readonly ydocUrl: string | null
|
||||
readonly projectManagerRootDirectory: projectManager.Path | null
|
||||
}
|
||||
|
||||
const AppContext = React.createContext<AppContextType | null>(null)
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface AppProviderProps extends React.PropsWithChildren, AppContextType {}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function AppProvider(props: AppProviderProps) {
|
||||
const { children, ...appContext } = props
|
||||
|
||||
return <AppContext.Provider value={appContext}>{children}</AppContext.Provider>
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export function useAppContext() {
|
||||
const context = React.useContext(AppContext)
|
||||
|
||||
invariant(context != null, 'Uh oh')
|
||||
|
||||
return context
|
||||
}
|
54
app/ide-desktop/lib/dashboard/src/utilities/defineRoute.tsx
Normal file
54
app/ide-desktop/lib/dashboard/src/utilities/defineRoute.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import * as reactQuery from '@tanstack/react-query'
|
||||
import type * as routerDom from 'react-router-dom'
|
||||
|
||||
import * as errorBoundaryComponent from '#/components/ErrorBoundary'
|
||||
import * as suspense from '#/components/Suspense'
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export interface Route {
|
||||
readonly path?: string
|
||||
readonly prefetchQueries?:
|
||||
| reactQuery.UseQueryOptions[]
|
||||
| ((request: Request, params: routerDom.Params) => reactQuery.UseQueryOptions[])
|
||||
readonly element?: React.ComponentType<Record<never, never>>
|
||||
readonly errorBoundary?: React.ComponentType
|
||||
readonly lazy?: routerDom.RouteProps['lazy']
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function defineRoute(route: Route): routerDom.RouteProps {
|
||||
const {
|
||||
prefetchQueries,
|
||||
path,
|
||||
element: Element,
|
||||
errorBoundary = errorBoundaryComponent.ErrorBoundary,
|
||||
...directRouteProps
|
||||
} = route
|
||||
|
||||
return {
|
||||
...directRouteProps,
|
||||
path,
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ErrorBoundary: errorBoundary,
|
||||
hydrateFallbackElement: <suspense.Suspense />,
|
||||
element: <suspense.Suspense>{Element != null ? <Element /> : null}</suspense.Suspense>,
|
||||
|
||||
loader: ({ request, params }) => {
|
||||
const client = reactQuery.useQueryClient()
|
||||
|
||||
const queries =
|
||||
typeof prefetchQueries === 'function'
|
||||
? prefetchQueries(request, params)
|
||||
: prefetchQueries ?? []
|
||||
|
||||
return Promise.allSettled(queries.map(query => client.prefetchQuery(query)))
|
||||
},
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user