Merge pull request #35268 from overleaf/dp-frontend-routing

[web] store project dashboard navigation state in the URL

GitOrigin-RevId: 76d95c5706fc196dac7c362e186c66c5334f35ac
This commit is contained in:
David
2026-09-17 11:23:59 +00:00
committed by Copybot
parent bccef96581
commit f1e3093ae3
7 changed files with 443 additions and 18 deletions
+23 -8
View File
@@ -525,15 +525,30 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
ProjectController.projectEntitiesJson
)
webRouter.get(
'/project',
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiters.openDashboard),
AsyncLocalStorage.middleware,
await Modules.middleware('domainCaptureTestSession'),
PermissionsController.useCapabilities(),
ProjectListController.projectListPage
// All project dashboard navigation states render the same page. The active
// navigation item is derived from the URL on the frontend.
const domainCaptureTestSessionMiddleware = await Modules.middleware(
'domainCaptureTestSession'
)
for (const projectDashboardRoute of [
'/project',
'/project/owned',
'/project/shared',
'/project/archived',
'/project/trashed',
'/project/untagged',
'/project/tags/:tag',
]) {
webRouter.get(
projectDashboardRoute,
AuthenticationController.requireLogin(),
RateLimiterMiddleware.rateLimit(rateLimiters.openDashboard),
AsyncLocalStorage.middleware,
domainCaptureTestSessionMiddleware,
PermissionsController.useCapabilities(),
ProjectListController.projectListPage
)
}
webRouter.post(
'/project/new',
AuthenticationController.requireLogin(),
@@ -25,11 +25,16 @@ import {
Project,
Sort,
} from '../../../../../types/project/dashboard/api'
import usePersistedState from '../../../shared/hooks/use-persisted-state'
import getMeta from '../../../utils/meta'
import useAsync from '../../../shared/hooks/use-async'
import { getProjects } from '../util/api'
import sortProjects from '../util/sort-projects'
import {
getInitialNavigationState,
getNavigationState,
getNavigationUrl,
migrateLegacyNavigationState,
} from '../util/navigation-state'
import {
isArchivedOrTrashed,
isDeletableProject,
@@ -135,14 +140,18 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
by: 'lastUpdated',
order: 'desc',
})
const [filter, setFilter] = usePersistedState<Filter>(
'project-list-filter',
'all'
const [initialNavigationState] = useState(getInitialNavigationState)
const [filter, setFilter] = useState<Filter>(
initialNavigationState.type === 'filter'
? initialNavigationState.filter
: 'all'
)
const prevSortRef = useRef<Sort>(sort)
const [selectedTagId, setSelectedTagId] = usePersistedState<
string | undefined
>('project-list-selected-tag-id', undefined)
const [selectedTagId, setSelectedTagId] = useState<string | undefined>(
initialNavigationState.type === 'tag'
? initialNavigationState.tag
: undefined
)
const [showCustomPicker, setShowCustomPicker] = useState(false)
const olTags = getMeta('ol-tags') || []
@@ -255,6 +264,28 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
prevSortRef.current = sort
}, [sort])
useEffect(() => {
const handlePopState = () => {
const navigationState = getNavigationState(window.location.pathname)
if (navigationState.type === 'tag') {
setFilter('all')
setSelectedTagId(navigationState.tag)
} else {
setFilter(navigationState.filter)
setSelectedTagId(undefined)
}
setSelectedProjectIds(new Set<string>())
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
// Temporary: migrate the legacy local-storage navigation state to the URL on
// first load. Safe to remove around September 2026 (see navigation-state.ts).
useEffect(() => {
migrateLegacyNavigationState()
}, [])
const showAllProjects = useCallback(() => {
setLoadMoreCount(0)
setHiddenProjectsCount(0)
@@ -334,6 +365,11 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
setSelectedTagId(undefined)
const selected = false
selectOrUnselectAllProjects(selected)
window.history.pushState(
null,
'',
getNavigationUrl({ type: 'filter', filter })
)
},
[selectOrUnselectAllProjects, setFilter, setSelectedTagId]
)
@@ -342,6 +378,11 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
(tagId: string) => {
setFilter('all')
setSelectedTagId(tagId)
window.history.pushState(
null,
'',
getNavigationUrl({ type: 'tag', tag: tagId })
)
},
[setSelectedTagId, setFilter]
)
@@ -0,0 +1,116 @@
import { Filter, UNCATEGORIZED_KEY } from '../context/project-list-context'
import customLocalStorage from '../../../infrastructure/local-storage'
export const DASHBOARD_PATH = '/project'
export type NavigationState =
| { type: 'filter'; filter: Filter }
| { type: 'tag'; tag: string }
function isDashboardBasePath(pathname: string): boolean {
return pathname.replace(/\/$/, '') === DASHBOARD_PATH
}
// Each non-default filter maps to a URL segment of the same name (e.g.
// `owned` -> `/project/owned`); the `all` filter is the bare dashboard path.
const FILTER_SEGMENTS: Exclude<Filter, 'all'>[] = [
'owned',
'shared',
'archived',
'trashed',
]
function isFilterSegment(segment: string): segment is Exclude<Filter, 'all'> {
return (FILTER_SEGMENTS as string[]).includes(segment)
}
export function getNavigationState(pathname: string): NavigationState {
const path = pathname.replace(/\/$/, '')
const rest = path.slice(DASHBOARD_PATH.length).replace(/^\//, '')
if (rest === 'untagged') {
return { type: 'tag', tag: UNCATEGORIZED_KEY }
}
const tagMatch = rest.match(/^tags\/(.+)$/)
if (tagMatch) {
try {
return { type: 'tag', tag: decodeURIComponent(tagMatch[1]) }
} catch {
return { type: 'filter', filter: 'all' }
}
}
if (isFilterSegment(rest)) {
return { type: 'filter', filter: rest }
}
return { type: 'filter', filter: 'all' }
}
export function getNavigationUrl(state: NavigationState): string {
if (state.type === 'tag') {
if (state.tag === UNCATEGORIZED_KEY) {
return `${DASHBOARD_PATH}/untagged`
}
return `${DASHBOARD_PATH}/tags/${encodeURIComponent(state.tag)}`
}
if (state.filter !== 'all') {
return `${DASHBOARD_PATH}/${state.filter}`
}
return DASHBOARD_PATH
}
// --- Temporary migration code ---------------------------------------------
// The navigation state used to be persisted in local storage. To smooth the
// transition to URL-based navigation, when a user lands on the bare `/project`
// path we honour their stored state once and then delete it, so their first
// load after this change keeps them where they left off.
//
// This is one-time-per-user migration code. It can be removed (along with the
// `Legacy` helpers below) once the local-storage keys have aged out of active
// users — around September 2026. Introduced for #35214.
const LEGACY_FILTER_KEY = 'project-list-filter'
const LEGACY_SELECTED_TAG_ID_KEY = 'project-list-selected-tag-id'
function getLegacyNavigationState(): NavigationState | null {
const selectedTagId = customLocalStorage.getItem(LEGACY_SELECTED_TAG_ID_KEY)
if (typeof selectedTagId === 'string') {
return { type: 'tag', tag: selectedTagId }
}
const filter = customLocalStorage.getItem(LEGACY_FILTER_KEY)
if (typeof filter === 'string' && isFilterSegment(filter)) {
return { type: 'filter', filter }
}
return null
}
function clearLegacyNavigationState(): void {
customLocalStorage.removeItem(LEGACY_FILTER_KEY)
customLocalStorage.removeItem(LEGACY_SELECTED_TAG_ID_KEY)
}
// Resolve the initial navigation state, honouring the legacy local-storage
// state once on the bare dashboard path. Read-only: the stored state is not
// cleared here (see migrateLegacyNavigationState).
export function getInitialNavigationState(): NavigationState {
if (isDashboardBasePath(window.location.pathname)) {
const legacyState = getLegacyNavigationState()
if (legacyState) {
return legacyState
}
}
return getNavigationState(window.location.pathname)
}
// Reflect any legacy state in the URL (via replaceState, so the back button is
// not affected) and then delete it. Call once on mount.
export function migrateLegacyNavigationState(): void {
if (isDashboardBasePath(window.location.pathname)) {
const legacyState = getLegacyNavigationState()
if (legacyState) {
window.history.replaceState(null, '', getNavigationUrl(legacyState))
}
}
clearLegacyNavigationState()
}
@@ -94,6 +94,26 @@ describe('Project CRUD', function () {
})
})
describe('dashboard navigation-state routes', function () {
const dashboardRoutes = [
'/project',
'/project/owned',
'/project/shared',
'/project/archived',
'/project/trashed',
'/project/untagged',
'/project/tags/aaaaaaaaaaaaaaaaaaaaaaaa',
]
for (const route of dashboardRoutes) {
it(`should render the project dashboard for ${route}`, async function () {
const { response, body } = await this.user.doRequest('GET', route)
expect(response.statusCode).to.equal(200)
expect(body).to.include('name="ol-prefetchedProjectsBlob"')
})
}
})
describe("when project doesn't exist", function () {
it('should return 404', async function () {
const { response } = await this.user.doRequest(
+10
View File
@@ -122,3 +122,13 @@ Object.defineProperty(navigator, 'onLine', {
configurable: true,
get: () => true,
})
// Reset the URL after each test. Some features (e.g. the project dashboard)
// push navigation state into the URL via history.pushState; because the jsdom
// window is shared across the whole run, a dirty path would otherwise leak into
// the next test's initial state and into the `page` field of analytics events.
exports.mochaHooks = {
afterEach() {
window.history.replaceState(null, '', '/')
},
}
@@ -881,7 +881,7 @@ describe('<ProjectListRoot />', function () {
'project-list-page-interaction',
{
action: 'rename',
page: '/',
page: '/project',
projectId: copyableProject.id,
isSmallDevice: true,
}
@@ -1022,7 +1022,7 @@ describe('<ProjectListRoot />', function () {
'project-list-page-interaction',
{
action: 'clone',
page: '/',
page: '/project',
projectId: archiveableProject.id,
isSmallDevice: true,
}
@@ -1187,7 +1187,7 @@ describe('<ProjectListRoot />', function () {
'project-list-page-interaction',
{
action: 'clone',
page: '/',
page: '/project/shared',
projectId: archiveableProject.id,
isSmallDevice: true,
}
@@ -0,0 +1,223 @@
import { expect } from 'chai'
import {
getInitialNavigationState,
getNavigationState,
getNavigationUrl,
migrateLegacyNavigationState,
} from '../../../../../frontend/js/features/project-list/util/navigation-state'
import { UNCATEGORIZED_KEY } from '../../../../../frontend/js/features/project-list/context/project-list-context'
const LEGACY_FILTER_KEY = 'project-list-filter'
const LEGACY_SELECTED_TAG_ID_KEY = 'project-list-selected-tag-id'
describe('navigation-state', function () {
describe('getNavigationState', function () {
it('maps the bare dashboard path to the default filter', function () {
expect(getNavigationState('/project')).to.deep.equal({
type: 'filter',
filter: 'all',
})
})
it('ignores a trailing slash', function () {
expect(getNavigationState('/project/')).to.deep.equal({
type: 'filter',
filter: 'all',
})
})
it('maps filter segments to filters', function () {
expect(getNavigationState('/project/owned')).to.deep.equal({
type: 'filter',
filter: 'owned',
})
expect(getNavigationState('/project/shared')).to.deep.equal({
type: 'filter',
filter: 'shared',
})
expect(getNavigationState('/project/archived')).to.deep.equal({
type: 'filter',
filter: 'archived',
})
expect(getNavigationState('/project/trashed')).to.deep.equal({
type: 'filter',
filter: 'trashed',
})
})
it('maps the untagged path to the uncategorized tag', function () {
expect(getNavigationState('/project/untagged')).to.deep.equal({
type: 'tag',
tag: UNCATEGORIZED_KEY,
})
})
it('maps a tag path to the tag id', function () {
expect(getNavigationState('/project/tags/abc123')).to.deep.equal({
type: 'tag',
tag: 'abc123',
})
})
it('decodes the tag id', function () {
expect(getNavigationState('/project/tags/a%2Fb')).to.deep.equal({
type: 'tag',
tag: 'a/b',
})
})
it('falls back to the default filter for unknown paths', function () {
expect(getNavigationState('/project/nonsense')).to.deep.equal({
type: 'filter',
filter: 'all',
})
})
})
describe('getNavigationUrl', function () {
it('maps the default filter to the bare dashboard path', function () {
expect(getNavigationUrl({ type: 'filter', filter: 'all' })).to.equal(
'/project'
)
})
it('maps filters to filter segments', function () {
expect(getNavigationUrl({ type: 'filter', filter: 'owned' })).to.equal(
'/project/owned'
)
expect(getNavigationUrl({ type: 'filter', filter: 'shared' })).to.equal(
'/project/shared'
)
expect(getNavigationUrl({ type: 'filter', filter: 'archived' })).to.equal(
'/project/archived'
)
expect(getNavigationUrl({ type: 'filter', filter: 'trashed' })).to.equal(
'/project/trashed'
)
})
it('maps the uncategorized tag to the untagged path', function () {
expect(
getNavigationUrl({ type: 'tag', tag: UNCATEGORIZED_KEY })
).to.equal('/project/untagged')
})
it('maps a tag id to a tag path', function () {
expect(getNavigationUrl({ type: 'tag', tag: 'abc123' })).to.equal(
'/project/tags/abc123'
)
})
it('encodes the tag id', function () {
expect(getNavigationUrl({ type: 'tag', tag: 'a/b' })).to.equal(
'/project/tags/a%2Fb'
)
})
})
describe('legacy local-storage migration', function () {
const setLegacyFilter = (value: string) =>
localStorage.setItem(LEGACY_FILTER_KEY, JSON.stringify(value))
const setLegacyTag = (value: string) =>
localStorage.setItem(LEGACY_SELECTED_TAG_ID_KEY, JSON.stringify(value))
beforeEach(function () {
localStorage.clear()
window.history.replaceState(null, '', '/project')
})
afterEach(function () {
localStorage.clear()
window.history.replaceState(null, '', '/project')
})
describe('getInitialNavigationState', function () {
it('uses the URL state when there is no legacy state', function () {
window.history.replaceState(null, '', '/project/owned')
expect(getInitialNavigationState()).to.deep.equal({
type: 'filter',
filter: 'owned',
})
})
it('restores a legacy filter on the bare dashboard path', function () {
setLegacyFilter('trashed')
expect(getInitialNavigationState()).to.deep.equal({
type: 'filter',
filter: 'trashed',
})
})
it('restores a legacy selected tag on the bare dashboard path', function () {
setLegacyTag('abc123')
expect(getInitialNavigationState()).to.deep.equal({
type: 'tag',
tag: 'abc123',
})
})
it('prefers a legacy tag over a legacy filter', function () {
setLegacyFilter('trashed')
setLegacyTag(UNCATEGORIZED_KEY)
expect(getInitialNavigationState()).to.deep.equal({
type: 'tag',
tag: UNCATEGORIZED_KEY,
})
})
it('ignores legacy state when the URL already has a navigation state', function () {
window.history.replaceState(null, '', '/project/shared')
setLegacyFilter('trashed')
expect(getInitialNavigationState()).to.deep.equal({
type: 'filter',
filter: 'shared',
})
})
it('ignores an invalid legacy filter', function () {
setLegacyFilter('nonsense')
expect(getInitialNavigationState()).to.deep.equal({
type: 'filter',
filter: 'all',
})
})
it('does not mutate the URL or clear the legacy state', function () {
setLegacyFilter('trashed')
getInitialNavigationState()
expect(window.location.pathname).to.equal('/project')
expect(localStorage.getItem(LEGACY_FILTER_KEY)).to.not.equal(null)
})
})
describe('migrateLegacyNavigationState', function () {
it('rewrites the URL to the legacy state and clears it', function () {
setLegacyFilter('trashed')
migrateLegacyNavigationState()
expect(window.location.pathname).to.equal('/project/trashed')
expect(localStorage.getItem(LEGACY_FILTER_KEY)).to.equal(null)
expect(localStorage.getItem(LEGACY_SELECTED_TAG_ID_KEY)).to.equal(null)
})
it('rewrites the URL to a legacy tag state', function () {
setLegacyTag('abc123')
migrateLegacyNavigationState()
expect(window.location.pathname).to.equal('/project/tags/abc123')
expect(localStorage.getItem(LEGACY_SELECTED_TAG_ID_KEY)).to.equal(null)
})
it('clears legacy state without rewriting on a non-bare path', function () {
window.history.replaceState(null, '', '/project/owned')
setLegacyFilter('trashed')
migrateLegacyNavigationState()
expect(window.location.pathname).to.equal('/project/owned')
expect(localStorage.getItem(LEGACY_FILTER_KEY)).to.equal(null)
})
it('leaves the URL unchanged when there is no legacy state', function () {
migrateLegacyNavigationState()
expect(window.location.pathname).to.equal('/project')
})
})
})
})