Add DS nav page switcher behind overleaf-library flag (#33112)

* Add DS nav page switcher behind overleaf-library flag

- Add shared DsNavPageSwitcher component (Library/Projects nav links + logo)
- Show page switcher in projects sidebar when overleaf-library flag enabled
- Hide 'All projects' filter and sidebar New Project button behind flag
- Move New Project button to content area header when flag enabled
- Prevent full page reload when clicking active nav item
- Change Upgrade button to premium variant when flag enabled
- Add overleaf-library split test to ProjectListController
- Add library-page class to remove rounded corner on /library
- Add Cypress component tests for DsNavPageSwitcher

Closes #33092

GitOrigin-RevId: 2e348da8307bf944d481b54b3a2bcc2eb319e18e
This commit is contained in:
Tom Wells
2026-04-27 10:42:54 +01:00
committed by Copybot
parent 816f8c45eb
commit 73cc1b571b
8 changed files with 342 additions and 20 deletions

View File

@@ -530,6 +530,7 @@ async function projectListPage(req, res, next) {
const splitTests = [
// Split tests that will be made available to the frontend
'import-docx',
'overleaf-library',
].filter(Boolean)
await Promise.all(

View File

@@ -4,10 +4,12 @@ import OLTooltip from '@/shared/components/ol/ol-tooltip'
import MaterialIcon from '@/shared/components/material-icon'
import { FreePlanSubscription } from '../../../../../../types/project/dashboard/subscription'
import * as eventTracking from '../../../../infrastructure/event-tracking'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
type FreePlanProps = Pick<FreePlanSubscription, 'featuresPageURL'>
function FreePlan({ featuresPageURL }: FreePlanProps) {
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const { t } = useTranslation()
const currentPlanLabel = (
<Trans
@@ -43,7 +45,8 @@ function FreePlan({ featuresPageURL }: FreePlanProps) {
</OLTooltip>{' '}
<span className="d-none d-md-inline-block">
<OLButton
variant="primary"
variant={isLibraryEnabled ? 'premium' : 'primary'}
size={isLibraryEnabled ? 'sm' : undefined}
href="/user/subscription/plans"
onClick={handleClick}
>

View File

@@ -23,6 +23,7 @@ import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
import CookieBanner from '@/shared/components/cookie-banner'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
export function ProjectListDsNav() {
const navbarProps = getMeta('ol-navbar')
@@ -38,27 +39,48 @@ export function ProjectListDsNav() {
selectedTagId,
} = useProjectListContext()
const activeOverallTheme = useActiveOverallTheme()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
{isLibraryEnabled ? (
<>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
</>
) : (
<>
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
</>
)}
</div>
)
return (
<div className="project-ds-nav-page website-redesign">
<div
className={`project-ds-nav-page website-redesign${isLibraryEnabled ? ' library-enabled' : ''}`}
>
<SystemMessages />
<DefaultNavbar
{...navbarProps}
@@ -96,8 +118,8 @@ export function ProjectListDsNav() {
</div>
</div>
<div className="project-ds-nav-project-list">
<OLRow className="d-none d-md-block">
<OLCol lg={7}>
<OLRow className="d-none d-md-flex align-items-center">
<OLCol md={isLibraryEnabled ? 8 : undefined} lg={7}>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
@@ -105,6 +127,14 @@ export function ProjectListDsNav() {
selectedTag={selectedTag}
/>
</OLCol>
{isLibraryEnabled && (
<OLCol className="ms-auto" xs="auto">
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
</OLCol>
)}
</OLRow>
<div className="project-list-sidebar-survey-wrapper d-md-none">
{/* Omit the survey card in mobile view for now */}

View File

@@ -7,10 +7,15 @@ import { usePersistedResize } from '@/shared/hooks/use-resize'
import { useScrolled } from '@/features/project-list/components/sidebar/use-scroll'
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 { useProjectListContext } from '@/features/project-list/context/project-list-context'
function SidebarDsNav() {
const { t } = useTranslation()
const { show: showAddAffiliationWidget } = useAddAffiliation()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const { selectFilter } = useProjectListContext()
const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
name: 'project-sidebar',
})
@@ -25,14 +30,26 @@ function SidebarDsNav() {
},
})}
>
{isLibraryEnabled && (
<>
<DsNavPageSwitcher
activePage="projects"
showLogo={false}
onProjectsClick={() => selectFilter('all')}
/>
<hr className="ds-nav-page-switcher-divider" />
</>
)}
<nav
className="flex-grow flex-shrink"
aria-label={t('project_categories_tags')}
>
<NewProjectButton
id="new-project-button-sidebar"
className={scrolledDown ? 'show-shadow' : undefined}
/>
{!isLibraryEnabled && (
<NewProjectButton
id="new-project-button-sidebar"
className={scrolledDown ? 'show-shadow' : undefined}
/>
)}
<div
className="project-list-sidebar-scroll"
ref={containerRef}

View File

@@ -5,6 +5,7 @@ import {
} from '../../context/project-list-context'
import TagsList from './tags-list'
import ProjectsFilterMenu from '../projects-filter-menu'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
type SidebarFilterProps = {
filter: Filter
@@ -29,10 +30,13 @@ export function SidebarFilter({ filter, text }: SidebarFilterProps) {
export default function SidebarFilters() {
const { t } = useTranslation()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
return (
<ul className="list-unstyled project-list-filters">
<SidebarFilter filter="all" text={t('all_projects')} />
{!isLibraryEnabled && (
<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')} />

View File

@@ -0,0 +1,79 @@
import { useTranslation } from 'react-i18next'
import { BookBookmark, Folder } from '@phosphor-icons/react'
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
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
showLogo?: boolean
onLibraryClick?: React.MouseEventHandler
onProjectsClick?: React.MouseEventHandler
}) {
const { t } = useTranslation()
const activeOverallTheme = useActiveOverallTheme()
return (
<>
{showLogo && (
<div className="ds-nav-page-switcher-logo">
<img
src={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
alt="Overleaf, A Digital Science Solution"
height="59"
width="130"
/>
</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>{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>{t('projects')}</span>
</a>
</li>
</ul>
</>
)
}

View File

@@ -103,6 +103,36 @@ body {
}
}
&.library-enabled {
.navbar-default .navbar-header .navbar-logo {
@include media-breakpoint-up(md) {
top: calc(var(--spacing-05) * 2);
left: var(--spacing-05);
}
}
.project-list-wrapper .project-list-sidebar-wrapper-react {
.ds-nav-page-switcher-divider {
margin: var(--spacing-05) 0;
}
.project-list-sidebar-scroll {
padding-top: 0;
}
}
}
&.library-page {
.project-list-wrapper
.project-ds-nav-content-and-messages
.project-ds-nav-content {
@include media-breakpoint-up(md) {
border-top-left-radius: 0;
border-top: none;
}
}
}
.navbar-nav > li > .dropdown-toggle::after {
display: none;
}
@@ -156,6 +186,50 @@ body {
var(--spacing-05);
}
.ds-nav-page-switcher-logo {
padding: 0 var(--spacing-08) var(--spacing-07);
img {
width: 130px;
height: auto;
}
}
.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;
gap: var(--spacing-05);
padding: var(--spacing-05);
border-radius: var(--border-radius-medium);
color: var(--ds-nav-color);
font-size: var(--font-size-03);
text-decoration: none;
&:hover {
background-color: var(--ds-nav-hover-bg);
color: var(--ds-nav-color);
text-decoration: none;
}
&.active {
background-color: var(--ds-nav-active-bg);
color: var(--ds-nav-active-color);
font-weight: bold;
}
}
.ds-nav-sidebar-lower {
padding: var(--spacing-05) var(--spacing-08) 0 var(--spacing-05);
border-top: solid 1px transparent;

View File

@@ -0,0 +1,114 @@
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')
})
})
})