Library: Design Changes (#33933)

GitOrigin-RevId: b45ea92adc424e2864e952cd7f157509e10ffb7d
This commit is contained in:
Tom Wells
2026-05-26 14:18:59 +01:00
committed by Copybot
parent 577ff63eca
commit 0565a778d8
14 changed files with 266 additions and 57 deletions
@@ -96,6 +96,7 @@
"add_ons": "",
"add_or_remove_project_from_tag": "",
"add_reference": "",
"add_references": "",
"add_role_and_department": "",
"add_to_dictionary": "",
"add_to_tag": "",
@@ -1157,6 +1158,7 @@
"manage_template": "",
"manage_users_subtext": "",
"manage_your_ai_assist_add_on": "",
"manage_your_references_across_projects": "",
"managed": "",
"managed_user_accounts": "",
"managed_user_invite_has_been_sent_to_email": "",
@@ -1910,6 +1912,7 @@
"start_typing_find_your_company": "",
"start_typing_find_your_organization": "",
"start_typing_find_your_university": "",
"start_your_collection_of_most_used_references": "",
"stop": "",
"stop_compile": "",
"stop_on_first_error": "",
@@ -0,0 +1,42 @@
import { useTranslation } from 'react-i18next'
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
type Props = {
activePage: 'library' | 'projects'
onProjectsClick?: () => void
}
function MobilePageSwitcherItems({ activePage, onProjectsClick }: Props) {
const { t } = useTranslation()
return (
<>
<li role="none">
<DropdownItem href="/library" active={activePage === 'library'}>
{t('library')}
</DropdownItem>
</li>
<li role="none">
{onProjectsClick ? (
<DropdownItem
as="button"
tabIndex={-1}
trailingIcon="chevron_right"
onClick={e => {
e.stopPropagation()
onProjectsClick()
}}
>
{t('projects')}
</DropdownItem>
) : (
<DropdownItem href="/project" active={activePage === 'projects'}>
{t('projects')}
</DropdownItem>
)}
</li>
</>
)
}
export default MobilePageSwitcherItems
@@ -12,8 +12,11 @@ import {
DropdownMenu,
DropdownToggle,
} from '@/shared/components/dropdown/dropdown-menu'
import MaterialIcon from '@/shared/components/material-icon'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
import ProjectsFilterMenu from '../projects-filter-menu'
import TagsList from '../tags-list'
import MobilePageSwitcherItems from './mobile-page-switcher-items'
type ItemProps = {
filter: Filter
@@ -48,7 +51,9 @@ export function Item({ filter, text, onClick }: ItemProps) {
function ProjectsDropdown() {
const { t } = useTranslation()
const [title, setTitle] = useState(() => t('all_projects'))
const [view, setView] = useState<'top' | 'submenu'>('submenu')
const { filter, selectedTagId, tags } = useProjectListContext()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const filterTranslations = useRef<Record<Filter, string>>({
all: t('all_projects'),
owned: t('your_projects'),
@@ -73,8 +78,40 @@ function ProjectsDropdown() {
}
}, [filter, tags, selectedTagId, t])
const submenuItems = (
<>
<li role="none">
<Item filter="all" text={t('all_projects')} />
</li>
<li role="none">
<Item filter="owned" text={t('your_projects')} />
</li>
<li role="none">
<Item filter="shared" text={t('shared_with_you')} />
</li>
<li role="none">
<Item filter="archived" text={t('archived_projects')} />
</li>
<li role="none">
<Item filter="trashed" text={t('trashed_projects')} />
</li>
<DropdownHeader className="text-uppercase">{t('tags')}:</DropdownHeader>
<TagsList />
</>
)
return (
<Dropdown>
<Dropdown
onToggle={
isLibraryEnabled
? show => {
if (!show) {
setView('submenu')
}
}
: undefined
}
>
<DropdownToggle
id="projects-types-dropdown-toggle-btn"
className="ps-0 mb-0 btn-transparent h3"
@@ -86,23 +123,32 @@ function ProjectsDropdown() {
</span>
</DropdownToggle>
<DropdownMenu flip={false}>
<li role="none">
<Item filter="all" text={t('all_projects')} />
</li>
<li role="none">
<Item filter="owned" text={t('your_projects')} />
</li>
<li role="none">
<Item filter="shared" text={t('shared_with_you')} />
</li>
<li role="none">
<Item filter="archived" text={t('archived_projects')} />
</li>
<li role="none">
<Item filter="trashed" text={t('trashed_projects')} />
</li>
<DropdownHeader className="text-uppercase">{t('tags')}:</DropdownHeader>
<TagsList />
{!isLibraryEnabled && submenuItems}
{isLibraryEnabled && view === 'submenu' && (
<>
<li role="none">
<DropdownItem
as="button"
tabIndex={-1}
leadingIcon={<MaterialIcon type="chevron_left" />}
aria-label={t('back')}
onClick={e => {
e.stopPropagation()
setView('top')
}}
>
{t('projects')}
</DropdownItem>
</li>
{submenuItems}
</>
)}
{isLibraryEnabled && view === 'top' && (
<MobilePageSwitcherItems
activePage="projects"
onProjectsClick={() => setView('submenu')}
/>
)}
</DropdownMenu>
</Dropdown>
)
@@ -21,6 +21,8 @@ import { useSendProjectListMB } from '@/features/project-list/components/project
import type { PortalTemplate } from '../../../../../types/portal-template'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import MaterialIcon from '@/shared/components/material-icon'
import { useProjectListContext } from '@/features/project-list/context/project-list-context'
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
type SendTrackingEvent = {
dropdownMenu: string
@@ -65,6 +67,12 @@ function NewProjectButton({
const markdownImportEnabled =
useFeatureFlag('import-markdown') &&
getMeta('ol-ExposedSettings').enablePandocConversions
const { selectedTagId, tags } = useProjectListContext()
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const initialTags =
isLibraryEnabled && selectedTagId
? tags.filter(tag => tag._id === selectedTagId)
: []
const sendTrackingEvent = useCallback(
({
dropdownMenu,
@@ -310,7 +318,11 @@ function NewProjectButton({
) : null}
</DropdownMenu>
</Dropdown>
<NewProjectButtonModal modal={modal} onHide={() => setModal(null)} />
<NewProjectButtonModal
modal={modal}
onHide={() => setModal(null)}
initialTags={initialTags}
/>
</>
)
}
@@ -1,11 +1,13 @@
import ModalContentNewProjectForm from './modal-content-new-project-form'
import { OLModal } from '@/shared/components/ol/ol-modal'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
type BlankProjectModalProps = {
onHide: () => void
initialTags?: Tag[]
}
function BlankProjectModal({ onHide }: BlankProjectModalProps) {
function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
return (
<OLModal
show
@@ -14,7 +16,7 @@ function BlankProjectModal({ onHide }: BlankProjectModalProps) {
id="blank-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} />
<ModalContentNewProjectForm onCancel={onHide} initialTags={initialTags} />
</OLModal>
)
}
@@ -1,11 +1,16 @@
import { OLModal } from '@/shared/components/ol/ol-modal'
import ModalContentNewProjectForm from './modal-content-new-project-form'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
type ExampleProjectModalProps = {
onHide: () => void
initialTags?: Tag[]
}
function ExampleProjectModal({ onHide }: ExampleProjectModalProps) {
function ExampleProjectModal({
onHide,
initialTags,
}: ExampleProjectModalProps) {
return (
<OLModal
show
@@ -14,7 +19,11 @@ function ExampleProjectModal({ onHide }: ExampleProjectModalProps) {
id="example-project-modal"
backdrop="static"
>
<ModalContentNewProjectForm onCancel={onHide} template="example" />
<ModalContentNewProjectForm
onCancel={onHide}
template="example"
initialTags={initialTags}
/>
</OLModal>
)
}
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useAsync from '../../../../shared/hooks/use-async'
import {
@@ -19,6 +19,10 @@ import OLButton from '@/shared/components/ol/ol-button'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import { CloneProjectTag } from '@/features/clone-project-modal/components/clone-project-tag'
import { addProjectsToTag } from '@/features/project-list/util/api'
import { captureException } from '@/infrastructure/error-reporter'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
type NewProjectData = {
project_id: string
@@ -34,16 +38,26 @@ type NewProjectData = {
type Props = {
onCancel: () => void
template?: string
initialTags?: Tag[]
}
function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
function ModalContentNewProjectForm({
onCancel,
template = 'none',
initialTags = [],
}: Props) {
const { t } = useTranslation()
const { autoFocusedRef } = useRefWithAutoFocus<HTMLInputElement>()
const [projectName, setProjectName] = useState('')
const [projectTags, setProjectTags] = useState<Tag[]>(initialTags)
const [redirecting, setRedirecting] = useState(false)
const { isLoading, isError, error, runAsync } = useAsync<NewProjectData>()
const location = useLocation()
const removeTag = useCallback((tag: Tag) => {
setProjectTags(value => value.filter(item => item._id !== tag._id))
}, [])
const createNewProject = () => {
runAsync(
postJSON('/project/new', {
@@ -53,10 +67,17 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
},
})
)
.then(data => {
.then(async data => {
if (data.project_id) {
// prevents clicking on create again between async load of next page and pending state being finished
setRedirecting(true)
for (const tag of projectTags) {
try {
await addProjectsToTag(tag._id, [data.project_id])
} catch (err) {
captureException(err as Error)
}
}
location.assign(`/project/${data.project_id}`)
}
})
@@ -97,6 +118,21 @@ function ModalContentNewProjectForm({ onCancel, template = 'none' }: Props) {
value={projectName}
/>
</OLFormGroup>
{projectTags.length > 0 && (
<OLFormGroup controlId="new-project-tags-list">
<OLFormLabel>{t('tags')}: </OLFormLabel>
<div role="listbox" id="new-project-tags-list">
{projectTags.map(tag => (
<CloneProjectTag
key={tag._id}
tag={tag}
removeTag={removeTag}
/>
))}
</div>
</OLFormGroup>
)}
</OLForm>
</OLModalBody>
@@ -5,6 +5,7 @@ import { JSXElementConstructor, lazy, Suspense, useCallback } from 'react'
import { Nullable } from '../../../../../../types/utils'
import { FullSizeLoadingSpinner } from '@/shared/components/loading-spinner'
import { useLocation } from '@/shared/hooks/use-location'
import { Tag } from '../../../../../../app/src/Features/Tags/types'
const UploadProjectModal = lazy(() => import('./upload-project-modal'))
const ImportDocumentModal = lazy(() => import('./import-document-modal'))
@@ -20,9 +21,14 @@ export type NewProjectButtonModalVariant =
type NewProjectButtonModalProps = {
modal: Nullable<NewProjectButtonModalVariant>
onHide: () => void
initialTags?: Tag[]
}
function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
function NewProjectButtonModal({
modal,
onHide,
initialTags,
}: NewProjectButtonModalProps) {
const [importProjectFromGithubModalWrapper] = importOverleafModules(
'importProjectFromGithubModalWrapper'
)
@@ -45,9 +51,9 @@ function NewProjectButtonModal({ modal, onHide }: NewProjectButtonModalProps) {
switch (modal) {
case 'blank_project':
return <BlankProjectModal onHide={onHide} />
return <BlankProjectModal onHide={onHide} initialTags={initialTags} />
case 'example_project':
return <ExampleProjectModal onHide={onHide} />
return <ExampleProjectModal onHide={onHide} initialTags={initialTags} />
case 'upload_project':
return (
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
@@ -42,6 +42,9 @@ export function ProjectListDsNav() {
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
const selectedTag = tags.find(tag => tag._id === selectedTagId)
const showNewProjectButton =
!isLibraryEnabled ||
(filter !== 'shared' && filter !== 'archived' && filter !== 'trashed')
const tableTopArea = (
<div className="pt-2 pb-3 d-md-none d-flex gap-2">
@@ -54,10 +57,12 @@ export function ProjectListDsNav() {
selectedTag={selectedTag}
className="overflow-hidden flex-grow-1"
/>
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
{showNewProjectButton && (
<NewProjectButton
id="new-project-button-projects-table"
showAddAffiliationWidget
/>
)}
</>
) : (
<>
@@ -127,7 +132,7 @@ export function ProjectListDsNav() {
selectedTag={selectedTag}
/>
</OLCol>
{isLibraryEnabled && (
{isLibraryEnabled && showNewProjectButton && (
<OLCol className="ms-auto" xs="auto">
<NewProjectButton
id="new-project-button-projects-table"
@@ -5,7 +5,6 @@ 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
@@ -30,13 +29,9 @@ 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">
{!isLibraryEnabled && (
<SidebarFilter filter="all" text={t('all_projects')} />
)}
<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')} />
@@ -1,6 +1,7 @@
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'
@@ -18,20 +19,23 @@ export function DsNavPageSwitcher({
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">
<img
src={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
alt="Overleaf, A Digital Science Solution"
height="59"
width="130"
/>
<a href="/" aria-label={appName}>
<img
src={
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
}
alt="Overleaf, A Digital Science Solution"
height="59"
width="130"
/>
</a>
</div>
)}
<ul
@@ -112,23 +112,29 @@ 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);
}
}
}
&.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;
ul.project-list-filters {
> li > button {
padding-left: calc(var(--spacing-05) + 24px + var(--spacing-05));
}
.dropdown-header {
padding-left: calc(var(--spacing-06) + 24px + var(--spacing-05));
}
> li.tag button.tag-name {
padding-left: calc(var(--spacing-06) + 24px + var(--spacing-05));
}
}
}
}
+3
View File
@@ -113,6 +113,7 @@
"add_ons_for_any_plan_subheading": "Buy add-ons for any Overleaf plan (including the free plan) to unlock additional features.",
"add_or_remove_project_from_tag": "Add or remove project from tag __tagName__",
"add_reference": "Add reference",
"add_references": "Add references",
"add_role_and_department": "Add role and department",
"add_to_dictionary": "Add to dictionary",
"add_to_tag": "Add to tag",
@@ -1519,6 +1520,7 @@
"manage_template": "Manage template",
"manage_users_subtext": "Add or remove members and assign roles",
"manage_your_ai_assist_add_on": "Manage your AI Assist add-on",
"manage_your_references_across_projects": "Manage your references across projects.",
"managed": "Managed",
"managed_user_accounts": "Managed user accounts",
"managed_user_accounts_explanation": "Provides tighter management of user access and deletion and allows organizations to keep control of projects when someone leaves the organization",
@@ -2477,6 +2479,7 @@
"start_typing_find_your_company": " Start typing to find your company",
"start_typing_find_your_organization": "Start typing to find your organization",
"start_typing_find_your_university": "Start typing to find your university",
"start_your_collection_of_most_used_references": "Start your collection of most-used references in the Library, so you can easily add them to any project.",
"state": "State",
"status_checks": "Status Checks",
"still_have_questions": "Still have questions?",
@@ -4,6 +4,7 @@ import fetchMock from 'fetch-mock'
import sinon from 'sinon'
import ModalContentNewProjectForm from '../../../../../../frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form'
import { location } from '@/shared/components/location'
import * as projectListApi from '@/features/project-list/util/api'
describe('<ModalContentNewProjectForm />', function () {
beforeEach(function () {
@@ -51,6 +52,45 @@ describe('<ModalContentNewProjectForm />', function () {
sinon.assert.calledWith(assignStub, `/project/${projectId}`)
})
it('redirects even when adding tags fails', async function () {
const projectId = 'ab123'
fetchMock.post('/project/new', {
status: 200,
body: {
project_id: projectId,
},
})
const addProjectsToTagStub = this.locationWrapperSandbox
.stub(projectListApi, 'addProjectsToTag')
.rejects(new Error('tag add failed'))
render(
<ModalContentNewProjectForm
onCancel={() => {}}
initialTags={[{ _id: 'tag-1', user_id: 'user-1', name: 'Tag 1' }]}
/>
)
fireEvent.change(screen.getByLabelText('Project name'), {
target: { value: 'Test Name' },
})
fireEvent.click(
screen.getByRole('button', {
name: 'Create',
})
)
const assignStub = this.locationWrapperStub.assign
await waitFor(() => {
sinon.assert.calledOnce(assignStub)
})
sinon.assert.calledWith(assignStub, `/project/${projectId}`)
sinon.assert.calledWith(addProjectsToTagStub, 'tag-1', [projectId])
})
it('shows error when project name contains "/"', async function () {
const errorMessage = 'Project name cannot contain / characters'