Make router work

This commit is contained in:
Sergey Garin 2024-06-04 22:57:47 +03:00
parent eb54a45f8d
commit 3b4002237b
10 changed files with 446 additions and 435 deletions

View File

@ -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
}

View 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
}

View File

@ -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(

View File

@ -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()

View File

@ -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,
})

View File

@ -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,
})

View File

@ -0,0 +1,8 @@
import defineRoute from '#/utilities/defineRoute'
import * as subscribeSuccess from './SubscribeSuccess'
export default defineRoute({
path: '/subscribe/success',
element: subscribeSuccess.SubscribeSuccess,
})

View 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
}

View 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)))
},
}
}