Merge pull request #35534 from overleaf/cd-share-sidenav-bar-with-library

[web] Share the dashboard sidebar with the library page

GitOrigin-RevId: 5595746fef6698bee6a35ee7fc8be15743e2c620
This commit is contained in:
Chris Dryden
2026-09-17 11:37:57 +00:00
committed by Copybot
parent 6af1d79cd6
commit f81a7b2ff2
13 changed files with 177 additions and 274 deletions
+1
View File
@@ -1027,6 +1027,7 @@ module.exports = {
//
createFileModes: [],
devToolbar: [],
dsNavLibraryLink: [],
gitBridge: [],
publishModalDropdownButton: [],
publishModalToolbarButton: [],
@@ -1,8 +1,9 @@
import { useTranslation } from 'react-i18next'
import { OLDropdownItem } from '@/shared/components/ol/ol-dropdown-menu'
import { ActivePage } from '../../util/navigation-state'
type Props = {
activePage: 'library' | 'projects'
activePage: ActivePage
onProjectsClick?: () => void
}
@@ -34,7 +34,7 @@ export function Item({ filter, text, leadingIcon, onClick }: ItemProps) {
}
return (
<ProjectsFilterMenu filter={filter}>
<ProjectsFilterMenu filter={filter} activePage="projects">
{isActive => (
<OLDropdownItem
as="button"
@@ -95,7 +95,7 @@ export function ProjectListDsNav() {
showCloseIcon
/>
<div className="project-list-wrapper">
<SidebarDsNav />
<SidebarDsNav activePage="projects" />
<div className="project-ds-nav-content-and-messages">
<div className="project-ds-nav-content">
<div className="project-ds-nav-main">
@@ -1,13 +1,22 @@
import { Filter, useProjectListContext } from '../context/project-list-context'
import { ActivePage } from '../util/navigation-state'
type ProjectsMenuFilterType = {
children: (isActive: boolean) => React.ReactElement
filter: Filter
activePage: ActivePage
}
function ProjectsFilterMenu({ children, filter }: ProjectsMenuFilterType) {
function ProjectsFilterMenu({
children,
filter,
activePage,
}: ProjectsMenuFilterType) {
const { filter: activeFilter, selectedTagId } = useProjectListContext()
const isActive = selectedTagId === undefined && filter === activeFilter
const isActive =
selectedTagId === undefined &&
filter === activeFilter &&
activePage === 'projects'
return children(isActive)
}
@@ -1,3 +1,4 @@
import { JSXElementConstructor } from 'react'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import { Trash } from '@phosphor-icons/react'
@@ -9,15 +10,20 @@ import { useScrolled } from '@/features/project-list/components/sidebar/use-scro
import { SurveyWidgetDsNav } from '@/features/project-list/components/survey-widget-ds-nav'
import { SidebarLowerSection } from '@/shared/components/sidebar/sidebar-lower-section'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { DsNavPageSwitcher } from '@/shared/components/sidebar/ds-nav-page-switcher'
import { DsNavOverleafLogo } from '@/shared/components/sidebar/ds-nav-overleaf-logo'
import { useProjectListContext } from '@/features/project-list/context/project-list-context'
import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
import { ActivePage } from '../../util/navigation-state'
function SidebarDsNav() {
function SidebarDsNav({ activePage }: { activePage: ActivePage }) {
const { t } = useTranslation()
const { show: showAddAffiliationWidget } = useAddAffiliation()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const { filter, selectedTagId, selectFilter } = useProjectListContext()
const isTrashActive = selectedTagId === undefined && filter === 'trashed'
const [dsNavLibraryLinkModule] = importOverleafModules('dsNavLibraryLink')
const DsNavLibraryLink: JSXElementConstructor<{ active?: boolean }> =
dsNavLibraryLinkModule?.import.default
const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
name: 'project-sidebar',
})
@@ -32,16 +38,7 @@ function SidebarDsNav() {
},
})}
>
{isLibraryEnabled && (
<>
<DsNavPageSwitcher
activePage={isTrashActive ? undefined : 'projects'}
showLogo={false}
onProjectsClick={() => selectFilter('all')}
/>
<hr className="ds-nav-page-switcher-divider" />
</>
)}
{isLibraryEnabled && activePage === 'library' && <DsNavOverleafLogo />}
<nav
className="flex-grow flex-shrink"
aria-label={t('project_categories_tags')}
@@ -57,7 +54,7 @@ function SidebarDsNav() {
ref={containerRef}
data-testid="project-list-sidebar-scroll"
>
<SidebarFilters />
<SidebarFilters activePage={activePage} />
{showAddAffiliationWidget && <hr />}
<AddAffiliation />
</div>
@@ -70,17 +67,24 @@ function SidebarDsNav() {
>
<SidebarLowerSection showThemeToggle>
{isLibraryEnabled && (
<button
type="button"
className={classnames('ds-nav-page-switcher-item', {
active: isTrashActive,
})}
aria-current={isTrashActive ? 'page' : undefined}
onClick={() => selectFilter('trashed')}
>
<Trash size={24} />
<span>{t('trash')}</span>
</button>
<>
{DsNavLibraryLink && (
<DsNavLibraryLink active={activePage === 'library'} />
)}
<button
type="button"
className={classnames('ds-nav-page-switcher-item', {
active: isTrashActive,
})}
aria-current={isTrashActive ? 'page' : undefined}
onClick={() => selectFilter('trashed')}
>
<Trash size={24} />
<span className="ds-nav-page-switcher-item-label">
{t('trash')}
</span>
</button>
</>
)}
<div className="project-list-sidebar-survey-wrapper">
<SurveyWidgetDsNav />
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'
import classNames from 'classnames'
import {
Filter,
useProjectListContext,
@@ -6,20 +7,36 @@ import {
import TagsList from './tags-list'
import ProjectsFilterMenu from '../projects-filter-menu'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import { Folder } from '@phosphor-icons/react'
import { ActivePage } from '../../util/navigation-state'
type SidebarFilterProps = {
filter: Filter
text: React.ReactNode
activePage: ActivePage
icon?: React.ReactNode
}
export function SidebarFilter({ filter, text }: SidebarFilterProps) {
export function SidebarFilter({
filter,
text,
icon,
activePage,
}: SidebarFilterProps) {
const { selectFilter } = useProjectListContext()
return (
<ProjectsFilterMenu filter={filter}>
<ProjectsFilterMenu filter={filter} activePage={activePage}>
{isActive => (
<li className={isActive ? 'active' : ''}>
<button type="button" onClick={() => selectFilter(filter)}>
<button
className={classNames('sidebar-filter-button', {
'sidebar-filter-button-with-icon': !!icon,
})}
type="button"
onClick={() => selectFilter(filter)}
>
{icon && <span className="icon">{icon}</span>}
{text}
</button>
</li>
@@ -28,17 +45,42 @@ export function SidebarFilter({ filter, text }: SidebarFilterProps) {
)
}
export default function SidebarFilters() {
export default function SidebarFilters({
activePage,
}: {
activePage: ActivePage
}) {
const { t } = useTranslation()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
return (
<ul className="list-unstyled project-list-filters">
<SidebarFilter filter="all" text={t('all_projects')} />
<SidebarFilter filter="owned" text={t('your_projects')} />
<SidebarFilter filter="shared" text={t('shared_with_you')} />
<SidebarFilter filter="archived" text={t('archived_projects')} />
<SidebarFilter
activePage={activePage}
filter="all"
text={isLibraryEnabled ? t('projects') : t('all_projects')}
icon={isLibraryEnabled && <Folder size={24} />}
/>
<SidebarFilter
activePage={activePage}
filter="owned"
text={t('your_projects')}
/>
<SidebarFilter
activePage={activePage}
filter="shared"
text={t('shared_with_you')}
/>
<SidebarFilter
activePage={activePage}
filter="archived"
text={t('archived_projects')}
/>
{!isLibraryEnabled && (
<SidebarFilter filter="trashed" text={t('trashed_projects')} />
<SidebarFilter
activePage={activePage}
filter="trashed"
text={t('trashed_projects')}
/>
)}
<li aria-hidden="true">
<hr />
@@ -34,6 +34,7 @@ import {
getNavigationState,
getNavigationUrl,
migrateLegacyNavigationState,
NavigationState,
} from '../util/navigation-state'
import {
isArchivedOrTrashed,
@@ -41,6 +42,7 @@ import {
isLeavableProject,
} from '../util/project'
import { debugConsole } from '@/utils/debugging'
import { useLocation } from '@/shared/hooks/use-location'
const MAX_PROJECT_PER_PAGE = 20
@@ -116,11 +118,18 @@ export const ProjectListContext = createContext<
ProjectListContextValue | undefined
>(undefined)
export type NavigationMode = 'filter-projects' | 'redirect-to-filtered-projects'
type ProjectListProviderProps = {
children: ReactNode
navigationMode?: NavigationMode
}
export function ProjectListProvider({ children }: ProjectListProviderProps) {
export function ProjectListProvider({
children,
navigationMode = 'filter-projects',
}: ProjectListProviderProps) {
const location = useLocation()
const prefetchedProjectsBlob = getMeta('ol-prefetchedProjectsBlob')
const [loadedProjects, setLoadedProjects] = useState<Project[]>(
prefetchedProjectsBlob?.projects ?? []
@@ -140,7 +149,12 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
by: 'lastUpdated',
order: 'desc',
})
const [initialNavigationState] = useState(getInitialNavigationState)
const [initialNavigationState] = useState<NavigationState>(() => {
if (navigationMode !== 'filter-projects') {
return { type: 'filter', filter: 'all' }
}
return getInitialNavigationState()
})
const [filter, setFilter] = useState<Filter>(
initialNavigationState.type === 'filter'
? initialNavigationState.filter
@@ -265,6 +279,7 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}, [sort])
useEffect(() => {
if (navigationMode !== 'filter-projects') return
const handlePopState = () => {
const navigationState = getNavigationState(window.location.pathname)
if (navigationState.type === 'tag') {
@@ -278,13 +293,17 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
}
window.addEventListener('popstate', handlePopState)
return () => window.removeEventListener('popstate', handlePopState)
}, [])
}, [navigationMode])
// Temporary: migrate the legacy local-storage navigation state to the URL on
// first load. Safe to remove around September 2026 (see navigation-state.ts).
// Only in 'filter-projects' mode: migrateLegacyNavigationState() clears the
// stored state unconditionally, so running it on the library page would wipe
// the dashboard's remembered filter before the migration could apply it.
useEffect(() => {
if (navigationMode !== 'filter-projects') return
migrateLegacyNavigationState()
}, [])
}, [navigationMode])
const showAllProjects = useCallback(() => {
setLoadMoreCount(0)
@@ -361,30 +380,38 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
const selectFilter = useCallback(
(filter: Filter) => {
const url = getNavigationUrl({ type: 'filter', filter })
if (navigationMode === 'redirect-to-filtered-projects') {
location.assign(url)
return
}
setFilter(filter)
setSelectedTagId(undefined)
const selected = false
selectOrUnselectAllProjects(selected)
window.history.pushState(
null,
'',
getNavigationUrl({ type: 'filter', filter })
)
window.history.pushState(null, '', url)
},
[selectOrUnselectAllProjects, setFilter, setSelectedTagId]
[
navigationMode,
location,
selectOrUnselectAllProjects,
setFilter,
setSelectedTagId,
]
)
const selectTag = useCallback(
(tagId: string) => {
const url = getNavigationUrl({ type: 'tag', tag: tagId })
if (navigationMode === 'redirect-to-filtered-projects') {
location.assign(url)
return
}
setFilter('all')
setSelectedTagId(tagId)
window.history.pushState(
null,
'',
getNavigationUrl({ type: 'tag', tag: tagId })
)
window.history.pushState(null, '', url)
},
[setSelectedTagId, setFilter]
[navigationMode, location, setSelectedTagId, setFilter]
)
const addTag = useCallback((tag: Tag) => {
@@ -3,6 +3,8 @@ import customLocalStorage from '../../../infrastructure/local-storage'
export const DASHBOARD_PATH = '/project'
export type ActivePage = 'library' | 'projects'
export type NavigationState =
| { type: 'filter'; filter: Filter }
| { type: 'tag'; tag: string }
@@ -0,0 +1,22 @@
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import getMeta from '@/utils/meta'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
export function DsNavOverleafLogo() {
const appName = getMeta('ol-ExposedSettings')?.appName ?? 'Overleaf'
const activeOverallTheme = useActiveOverallTheme()
return (
<div className="ds-nav-page-switcher-logo">
<a href="/" aria-label={appName}>
<img
src={activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo}
alt="Overleaf, A Digital Science Solution"
height="59"
width="130"
/>
</a>
</div>
)
}
@@ -1,87 +0,0 @@
import { useTranslation } from 'react-i18next'
import { BookBookmark, Folder } from '@phosphor-icons/react'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import getMeta from '@/utils/meta'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
type ActivePage = 'library' | 'projects'
export function DsNavPageSwitcher({
activePage,
showLogo = true,
onLibraryClick,
onProjectsClick,
}: {
activePage: ActivePage | undefined
showLogo?: boolean
onLibraryClick?: React.MouseEventHandler
onProjectsClick?: React.MouseEventHandler
}) {
const { t } = useTranslation()
const appName = getMeta('ol-ExposedSettings')?.appName ?? 'Overleaf'
const activeOverallTheme = useActiveOverallTheme()
return (
<>
{showLogo && (
<div className="ds-nav-page-switcher-logo">
<a href="/" aria-label={appName}>
<img
src={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
alt="Overleaf, A Digital Science Solution"
height="59"
width="130"
/>
</a>
</div>
)}
<ul
className={`list-unstyled ds-nav-page-switcher-items${!showLogo ? ' ds-nav-page-switcher-items--no-logo' : ''}`}
>
<li>
<a
href="/library"
className={`ds-nav-page-switcher-item${activePage === 'library' ? ' active' : ''}`}
aria-current={activePage === 'library' ? 'page' : undefined}
onClick={
onLibraryClick
? e => {
e.preventDefault()
onLibraryClick(e)
}
: undefined
}
>
<BookBookmark size={24} />
<span className="ds-nav-page-switcher-item-label">
{t('library')}
</span>
</a>
</li>
<li>
<a
href="/project"
className={`ds-nav-page-switcher-item${activePage === 'projects' ? ' active' : ''}`}
aria-current={activePage === 'projects' ? 'page' : undefined}
onClick={
onProjectsClick
? e => {
e.preventDefault()
onProjectsClick(e)
}
: undefined
}
>
<Folder size={24} />
<span className="ds-nav-page-switcher-item-label">
{t('projects')}
</span>
</a>
</li>
</ul>
</>
)
}
@@ -114,20 +114,28 @@ body {
.project-list-wrapper .project-list-sidebar-wrapper-react {
padding-top: calc(var(--spacing-08) - 1px);
.ds-nav-page-switcher-divider {
margin: var(--spacing-05) 0;
}
.project-list-sidebar-scroll {
padding-top: 0;
padding-right: var(--spacing-05);
}
ul.project-list-filters {
> li > button {
margin-top: var(--spacing-05);
.sidebar-filter-button {
display: flex;
align-items: center;
padding-left: calc(var(--spacing-05) + 24px + var(--spacing-05));
}
.sidebar-filter-button-with-icon {
padding-left: var(--spacing-05);
svg {
margin-right: var(--spacing-05);
}
}
.dropdown-header {
padding-left: calc(var(--spacing-06) + 24px + var(--spacing-05));
}
@@ -212,7 +220,7 @@ body {
}
.ds-nav-page-switcher-logo {
padding: 0 var(--spacing-08) var(--spacing-07);
padding: 0 var(--spacing-08) var(--spacing-04);
img {
width: 130px;
@@ -220,24 +228,12 @@ body {
}
}
.ds-nav-page-switcher-items {
display: flex;
flex-direction: column;
gap: var(--spacing-02);
padding: 0 var(--spacing-05);
margin: 0;
&--no-logo {
padding-top: var(--spacing-05);
}
}
.ds-nav-page-switcher-item {
display: flex;
align-items: center;
width: 100%;
gap: var(--spacing-05);
padding: var(--spacing-05);
padding: var(--spacing-04) var(--spacing-05);
border: none;
border-radius: var(--border-radius-medium);
background: none;
@@ -1,114 +0,0 @@
import { DsNavPageSwitcher } from '../../../../../frontend/js/shared/components/sidebar/ds-nav-page-switcher'
import { SplitTestProvider } from '@/shared/context/split-test-context'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
function mountDsNavPageSwitcher(
props: React.ComponentProps<typeof DsNavPageSwitcher>
) {
cy.mount(
<SplitTestProvider>
<UserSettingsProvider>
<DsNavPageSwitcher {...props} />
</UserSettingsProvider>
</SplitTestProvider>
)
}
describe('<DsNavPageSwitcher />', function () {
describe('activePage="library"', function () {
beforeEach(function () {
mountDsNavPageSwitcher({ activePage: 'library' })
})
it('marks the Library link as active', function () {
cy.findByRole('link', { name: /library/i }).should('have.class', 'active')
})
it('does not mark the Projects link as active', function () {
cy.findByRole('link', { name: /projects/i }).should(
'not.have.class',
'active'
)
})
it('sets aria-current on the Library link', function () {
cy.findByRole('link', { name: /library/i }).should(
'have.attr',
'aria-current',
'page'
)
})
it('does not set aria-current on the Projects link', function () {
cy.findByRole('link', { name: /projects/i }).should(
'not.have.attr',
'aria-current'
)
})
it('renders the logo', function () {
cy.get('.ds-nav-page-switcher-logo img').should('exist')
})
})
describe('activePage="projects"', function () {
beforeEach(function () {
mountDsNavPageSwitcher({ activePage: 'projects' })
})
it('marks the Projects link as active', function () {
cy.findByRole('link', { name: /projects/i }).should(
'have.class',
'active'
)
})
it('does not mark the Library link as active', function () {
cy.findByRole('link', { name: /library/i }).should(
'not.have.class',
'active'
)
})
it('renders the logo', function () {
cy.get('.ds-nav-page-switcher-logo img').should('exist')
})
})
describe('link hrefs', function () {
beforeEach(function () {
mountDsNavPageSwitcher({ activePage: 'library' })
})
it('Library link points to /library', function () {
cy.findByRole('link', { name: /library/i }).should(
'have.attr',
'href',
'/library'
)
})
it('Projects link points to /project', function () {
cy.findByRole('link', { name: /projects/i }).should(
'have.attr',
'href',
'/project'
)
})
})
describe('showLogo={false}', function () {
beforeEach(function () {
mountDsNavPageSwitcher({ activePage: 'projects', showLogo: false })
})
it('does not render the logo', function () {
cy.get('.ds-nav-page-switcher-logo').should('not.exist')
})
it('still renders both nav links', function () {
cy.findByRole('link', { name: /library/i }).should('exist')
cy.findByRole('link', { name: /projects/i }).should('exist')
})
})
})