Skip to content

Commit

Permalink
[botcom] fancypants routes (tldraw#5078)
Browse files Browse the repository at this point in the history
Some self-indulgent typescript shenanigans, as an end-of-week palette
cleanser πŸ’†β€β™‚οΈ

I found the route definitions kinda hard to read and add to, with all
the prefixes string interpolation and helper functions stuff. I wanted
to make it obvious how to add a new route and whether/how to add a
helper function for creating paths or urls for that route.

I remembered gary bernhardt came up with some clever typesafe router
thingy a few years ago after TS added template string types, and figured
I could do something similar that lets us specify the routes as normal
(uninterpolated, easy to scan) strings and then extract the param types,
and also to compile helper functions automatically.

That's what this PR does. it adds a routeDefs.ts file that lets you
quickly scan the list of routes on the site, and if you want to add a
new route you put it there and it automatically compiles a helper fn.
Then in routes.tsx we reference the route paths when constructing the
route component hierarchy.

I don't feel super strongly about whether or not to merge this, tbh it
was fine how it was 🀷🏼

### Change type

- [x] `other`
  • Loading branch information
ds300 authored Dec 9, 2024
1 parent 42070cd commit 2470bc0
Show file tree
Hide file tree
Showing 18 changed files with 151 additions and 117 deletions.
91 changes: 91 additions & 0 deletions apps/dotcom/client/src/routeDefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { assert } from 'tldraw'

export const ROUTES = {
legacyRoot: '/',
legacyNewPage: '/new',
legacyNewPage2: '/r',
legacyRoom: '/r/:roomId',
touchscreenSidePanel: '/ts-side',
legacyRoomHistory: '/r/:boardId/history',
legacyRoomHistorySnapshot: '/r/:boardId/history/:timestamp',
legacySnapshot: '/s/:roomId',
legacyReadonly: '/ro/:roomId',
legacyReadonlyOld: '/v/:roomId',

tlaRoot: `/q`,
tlaFile: `/q/f/:fileSlug`,
tlaLocalFile: `/q/lf/:fileSlug`,
tlaPlayground: `/q/playground`,
tlaPublish: `/q/p/:fileSlug`,
} as const

export const routes: {
[key in keyof typeof ROUTES]: PathFn<(typeof ROUTES)[key]>
} = Object.fromEntries(
Object.entries(ROUTES).map(([key, path]) => [
key,
((routeParamsOrOptions: any, options: any) => {
if (path.includes('/:')) {
return compilePath(path, routeParamsOrOptions, options)
} else {
return compilePath(path, null, routeParamsOrOptions)
}
}) satisfies PathFn<any>,
])
) as any

type ExtractParamNamesFromPath<route extends string> = route extends `/${infer path}`
? ExtractParamNamesFromPathSegments<SplitPath<path>>
: never

type SplitPath<path extends string> = path extends `${infer segment}/${infer rest}`
? segment | SplitPath<rest>
: path

type ExtractParamNamesFromPathSegments<segments extends string> = segments extends `:${infer param}`
? param
: never

interface PathOptions {
searchParams?: ConstructorParameters<typeof URLSearchParams>[0]
asUrl?: boolean
}

type PathFn<path extends `/${string}`> = path extends `${string}:${string}:${string}`
? // has at least two params
(
routeParams: Record<ExtractParamNamesFromPath<path>, string>,
searchParams?: PathOptions
) => string
: path extends `${string}:${string}`
? // only has one param, so we can have a single string
(param: string, searchParams?: PathOptions) => string
: (searchParams?: PathOptions) => string

function compilePath(
path: string,
routeParams: string | Record<string, string> | null,
options?: PathOptions
) {
const search = new URLSearchParams(options?.searchParams).toString()
if (!path.includes(':')) {
assert(
routeParams === null || Object.keys(routeParams).length === 0,
`Route params are not allowed for path ${path}`
)
return path + (search ? `?${search}` : '')
}
assert(routeParams !== null, `Route params are required for path ${path}`)

path =
path.replace(/:\w+/g, (match) =>
// if there's only one param, routeParams will be a string
typeof routeParams === 'string' ? routeParams : routeParams[match.slice(1)]
) + (search ? `?${search}` : '')

if (options?.asUrl) {
return `${window.location.origin}${path}`
}

return path
}
55 changes: 17 additions & 38 deletions apps/dotcom/client/src/routes.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import { captureException } from '@sentry/react'
import {
READ_ONLY_LEGACY_PREFIX,
READ_ONLY_PREFIX,
ROOM_PREFIX,
SNAPSHOT_PREFIX,
} from '@tldraw/dotcom-shared'
import { TLRemoteSyncError, TLSyncErrorCloseEventReason } from '@tldraw/sync-core'
import { Suspense, lazy, useEffect } from 'react'
import { Helmet } from 'react-helmet-async'
import { Outlet, Route, createRoutesFromElements, useRouteError } from 'react-router-dom'
import { DefaultErrorFallback } from './components/DefaultErrorFallback/DefaultErrorFallback'
import { ErrorPage } from './components/ErrorPage/ErrorPage'
import { notFound } from './pages/not-found'
import { ROUTES } from './routeDefs'
import { IntlProvider } from './tla/utils/i18n'
import { TlaNotFoundError } from './tla/utils/notFoundError'
import { PREFIX } from './tla/utils/urls'

const LoginRedirectPage = lazy(() => import('./components/LoginRedirectPage/LoginRedirectPage'))

Expand Down Expand Up @@ -71,55 +65,40 @@ export const router = createRoutesFromElements(
}}
>
<Route errorElement={<DefaultErrorFallback />}>
<Route path="/" lazy={() => import('./pages/root')} />
<Route path={ROUTES.legacyRoot} lazy={() => import('./pages/root')} />
{/* We don't want to index multiplayer rooms */}
<Route element={<NoIndex />}>
<Route element={<ShimIntlProvider />}>
<Route path={`/${ROOM_PREFIX}`} lazy={() => import('./pages/new')} />
<Route path="/new" lazy={() => import('./pages/new')} />
<Route path={`/ts-side`} lazy={() => import('./pages/public-touchscreen-side-panel')} />
<Route path={ROUTES.legacyNewPage} lazy={() => import('./pages/new')} />
<Route path={ROUTES.legacyNewPage2} lazy={() => import('./pages/new')} />
<Route
path={`/${ROOM_PREFIX}/:roomId`}
lazy={() => import('./pages/public-multiplayer')}
path={ROUTES.touchscreenSidePanel}
lazy={() => import('./pages/public-touchscreen-side-panel')}
/>
<Route path={`/${ROOM_PREFIX}/:boardId/history`} lazy={() => import('./pages/history')} />
<Route path={ROUTES.legacyRoom} lazy={() => import('./pages/public-multiplayer')} />
<Route path={ROUTES.legacyRoomHistory} lazy={() => import('./pages/history')} />
<Route
path={`/${ROOM_PREFIX}/:boardId/history/:timestamp`}
path={ROUTES.legacyRoomHistorySnapshot}
lazy={() => import('./pages/history-snapshot')}
/>
<Route path={ROUTES.legacySnapshot} lazy={() => import('./pages/public-snapshot')} />
<Route
path={`/${SNAPSHOT_PREFIX}/:roomId`}
lazy={() => import('./pages/public-snapshot')}
/>
<Route
path={`/${READ_ONLY_LEGACY_PREFIX}/:roomId`}
path={ROUTES.legacyReadonlyOld}
lazy={() => import('./pages/public-readonly-legacy')}
/>
<Route
path={`/${READ_ONLY_PREFIX}/:roomId`}
lazy={() => import('./pages/public-readonly')}
/>
<Route path={ROUTES.legacyReadonly} lazy={() => import('./pages/public-readonly')} />
</Route>
</Route>
</Route>
{/* begin tla */}
<Route element={<NoIndex />}>
<Route
path={`/${PREFIX.tla}/${PREFIX.localFile}/:fileSlug`}
lazy={() => import('./tla/pages/local-file')}
/>
<Route path={ROUTES.tlaLocalFile} lazy={() => import('./tla/pages/local-file')} />
<Route lazy={() => import('./tla/providers/TlaRootProviders')}>
<Route path={`/${PREFIX.tla}`} lazy={() => import('./tla/pages/local')} />
{/* <Route path={`/${PREFIX.tla}/playground`} lazy={() => import('./tla/pages/playground')} /> */}
<Route path={ROUTES.tlaRoot} lazy={() => import('./tla/pages/local')} />
{/* <Route path={ROUTES.tlaPlayground} lazy={() => import('./tla/pages/playground')} /> */}
{/* File view */}
<Route
path={`/${PREFIX.tla}/${PREFIX.file}/:fileSlug`}
lazy={() => import('./tla/pages/file')}
/>
<Route
path={`/${PREFIX.tla}/${PREFIX.publish}/:fileSlug`}
lazy={() => import('./tla/pages/publish')}
/>
<Route path={ROUTES.tlaFile} lazy={() => import('./tla/pages/file')} />
<Route path={ROUTES.tlaPublish} lazy={() => import('./tla/pages/publish')} />
{/* Views that require login */}
<Route lazy={() => import('./tla/providers/RequireSignedInUser')}></Route>
</Route>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import {
TldrawUiDialogHeader,
TldrawUiDialogTitle,
} from 'tldraw'
import { routes } from '../../../routeDefs'
import { F } from '../../utils/i18n'
import { getLocalFilePath } from '../../utils/urls'
import { ExternalLink } from '../ExternalLink/ExternalLink'

export function SlurpFailure({
Expand Down Expand Up @@ -46,7 +46,7 @@ export function SlurpFailure({
</p>
<ol>
<li>
<ExternalLink to={getLocalFilePath(slurpPersistenceKey)}>
<ExternalLink to={routes.tlaLocalFile(slurpPersistenceKey)}>
<F defaultMessage="Go here to see the content" />
</ExternalLink>
</li>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import classNames from 'classnames'
import { Link } from 'react-router-dom'
import { routes } from '../../../routeDefs'
import { F } from '../../utils/i18n'
import { getRootPath } from '../../utils/urls'
import styles from './error.module.css'

type TlaPageErrorType =
Expand All @@ -12,7 +12,7 @@ type TlaPageErrorType =

function ErrorLinkHome() {
return (
<Link className={classNames(styles.link, 'tla-text_ui__regular')} to={getRootPath()}>
<Link className={classNames(styles.link, 'tla-text_ui__regular')} to={routes.tlaRoot()}>
<F defaultMessage="Take me home" />
</Link>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ import {
useDialogs,
useToasts,
} from 'tldraw'
import { routes } from '../../../routeDefs'
import { TldrawApp } from '../../app/TldrawApp'
import { useApp } from '../../hooks/useAppState'
import { useIsFileOwner } from '../../hooks/useIsFileOwner'
import { TLAppUiEventSource, useTldrawAppUiEvents } from '../../utils/app-ui-events'
import { copyTextToClipboard } from '../../utils/copy'
import { defineMessages, useMsg } from '../../utils/i18n'
import { getFilePath, getShareableFileUrl } from '../../utils/urls'
import { TlaDeleteFileDialog } from '../dialogs/TlaDeleteFileDialog'

const messages = defineMessages({
Expand Down Expand Up @@ -92,7 +92,7 @@ function FileItems({
const isOwner = useIsFileOwner(fileId)

const handleCopyLinkClick = useCallback(() => {
const url = getShareableFileUrl(fileId)
const url = routes.tlaFile(fileId, { asUrl: true })
copyTextToClipboard(url)
addToast({
id: 'copied-link',
Expand All @@ -106,7 +106,9 @@ function FileItems({
const file = app.getFile(fileId)
if (!file) return
app.createFile({ id: newFileId, name: getDuplicateName(file, app) })
navigate(getFilePath(newFileId), { state: { mode: 'duplicate', duplicateId: fileId } })
navigate(routes.tlaFile(newFileId), {
state: { mode: 'duplicate', duplicateId: fileId },
})
}, [app, fileId, navigate])

const handleDeleteClick = useCallback(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { TlaFile } from '@tldraw/dotcom-shared'
import { useCallback } from 'react'
import { useEditor, useValue } from 'tldraw'
import { routes } from '../../../../routeDefs'
import { useApp } from '../../../hooks/useAppState'
import { useIsFileOwner } from '../../../hooks/useIsFileOwner'
import { useTldrawUser } from '../../../hooks/useUser'
import { useTldrawAppUiEvents } from '../../../utils/app-ui-events'
import { copyTextToClipboard } from '../../../utils/copy'
import { F, defineMessages, useMsg } from '../../../utils/i18n'
import { getShareableFileUrl } from '../../../utils/urls'
import { TlaSelect } from '../../TlaSelect/TlaSelect'
import { TlaSwitch } from '../../TlaSwitch/TlaSwitch'
import {
Expand Down Expand Up @@ -48,7 +48,7 @@ export function TlaInviteTab({ fileId }: { fileId: string }) {
</TlaMenuControlGroup>
)}
{isShared && <TlaCopyLinkButton isShared={isShared} fileId={fileId} />}
{isShared && <QrCode url={getShareableFileUrl(fileId)} />}
{isShared && <QrCode url={routes.tlaFile(fileId, { asUrl: true })} />}
</TlaMenuSection>
</>
)
Expand Down Expand Up @@ -140,7 +140,7 @@ function TlaCopyLinkButton({ fileId }: { isShared: boolean; fileId: string }) {
const trackEvent = useTldrawAppUiEvents()

const handleCopyLinkClick = useCallback(() => {
const url = getShareableFileUrl(fileId)
const url = routes.tlaFile(fileId, { asUrl: true })
copyTextToClipboard(editor.createDeepLink({ url }).toString())
// no toasts please
trackEvent('copy-share-link', { source: 'file-share-menu' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import { TlaFile } from '@tldraw/dotcom-shared'
import { useCallback, useState } from 'react'
import { useParams } from 'react-router-dom'
import { useEditor } from 'tldraw'
import { routes } from '../../../../routeDefs'
import { useApp } from '../../../hooks/useAppState'
import { useTldrawAppUiEvents } from '../../../utils/app-ui-events'
import { copyTextToClipboard } from '../../../utils/copy'
import { F, FormattedRelativeTime } from '../../../utils/i18n'
import { getShareablePublishUrl } from '../../../utils/urls'
import { TlaButton } from '../../TlaButton/TlaButton'
import { TlaSwitch } from '../../TlaSwitch/TlaSwitch'
import { TlaTabsPage } from '../../TlaTabs/TlaTabs'
Expand Down Expand Up @@ -67,7 +67,7 @@ export function TlaPublishTab({ file }: { file: TlaFile }) {
trackEvent('unpublish-file', { source: 'file-share-menu' })
}, [app, auth, file.id, isOwner, trackEvent])

const publishShareUrl = publishedSlug ? getShareablePublishUrl(publishedSlug) : null
const publishShareUrl = publishedSlug ? routes.tlaPublish(publishedSlug, { asUrl: true }) : null

const secondsSince = Math.min(0, Math.floor((Date.now() - file.lastPublished) / 1000))
const learnMoreUrl = 'https://tldraw.notion.site/Publishing-1283e4c324c08059a1a1d9ba9833ddc9'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useCallback, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { tltime } from 'tldraw'
import { routes } from '../../../../routeDefs'
import { useApp } from '../../../hooks/useAppState'
import { useTldrawAppUiEvents } from '../../../utils/app-ui-events'
import { useMsg } from '../../../utils/i18n'
import { getFilePath } from '../../../utils/urls'
import { TlaIcon } from '../../TlaIcon/TlaIcon'
import styles from '../sidebar.module.css'
import { messages } from './sidebar-shared'
Expand All @@ -22,7 +22,7 @@ export function TlaSidebarCreateFileButton() {
const res = app.createFile()
if (res.ok) {
const { file } = res.value
navigate(getFilePath(file.id), { state: { mode: 'create' } })
navigate(routes.tlaFile(file.id), { state: { mode: 'create' } })
trackEvent('create-file', { source: 'sidebar' })
rCanCreate.current = false
tltime.setTimeout('can create again', () => (rCanCreate.current = true), 1000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import classNames from 'classnames'
import { KeyboardEvent, useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { preventDefault, useContainer, useValue } from 'tldraw'
import { routes } from '../../../../routeDefs'
import { useApp } from '../../../hooks/useAppState'
import { useIsFileOwner } from '../../../hooks/useIsFileOwner'
import { useTldrawAppUiEvents } from '../../../utils/app-ui-events'
import { F } from '../../../utils/i18n'
import { getFilePath } from '../../../utils/urls'
import { TlaIcon } from '../../TlaIcon/TlaIcon'
import {
TlaTooltipArrow,
Expand Down Expand Up @@ -48,7 +48,7 @@ export function TlaSidebarFileLink({ item, testId }: { item: RecentFile; testId:
isActive={isActive}
isOwnFile={isOwnFile}
fileName={app.getFileName(fileId)}
href={getFilePath(fileId)}
href={routes.tlaFile(fileId)}
/>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import {
TldrawUiDialogHeader,
TldrawUiDialogTitle,
} from 'tldraw'
import { routes } from '../../../routeDefs'
import { useApp } from '../../hooks/useAppState'
import { useIsFileOwner } from '../../hooks/useIsFileOwner'
import { useTldrawAppUiEvents } from '../../utils/app-ui-events'
import { F } from '../../utils/i18n'
import { getFilePath } from '../../utils/urls'

export function TlaDeleteFileDialog({ fileId, onClose }: { fileId: string; onClose(): void }) {
const app = useApp()
Expand All @@ -32,11 +32,11 @@ export function TlaDeleteFileDialog({ fileId, onClose }: { fileId: string; onClo
if (recentFiles.length === 0) {
const result = app.createFile()
if (result.ok) {
navigate(getFilePath(result.value.file.id), { state: { mode: 'create' } })
navigate(routes.tlaFile(result.value.file.id), { state: { mode: 'create' } })
trackEvent('delete-file', { source: 'file-menu' })
}
} else {
navigate(getFilePath(recentFiles[0].fileId))
navigate(routes.tlaFile(recentFiles[0].fileId))
}
onClose()
}, [auth, app, fileId, onClose, navigate, trackEvent])
Expand Down
Loading

0 comments on commit 2470bc0

Please sign in to comment.