From 0565a778d868efd77ae0874ec9bee255803e5323 Mon Sep 17 00:00:00 2001 From: Tom Wells Date: Tue, 26 May 2026 14:18:59 +0100 Subject: [PATCH] Library: Design Changes (#33933) GitOrigin-RevId: b45ea92adc424e2864e952cd7f157509e10ffb7d --- .../web/frontend/extracted-translations.json | 3 + .../dropdown/mobile-page-switcher-items.tsx | 42 ++++++++++ .../components/dropdown/projects-dropdown.tsx | 82 +++++++++++++++---- .../components/new-project-button.tsx | 14 +++- .../blank-project-modal.tsx | 6 +- .../example-project-modal.tsx | 13 ++- .../modal-content-new-project-form.tsx | 42 +++++++++- .../new-project-button-modal.tsx | 12 ++- .../components/project-list-ds-nav.tsx | 15 ++-- .../components/sidebar/sidebar-filters.tsx | 7 +- .../sidebar/ds-nav-page-switcher.tsx | 20 +++-- .../pages/project-list-ds-nav.scss | 24 ++++-- services/web/locales/en.json | 3 + .../modal-content-new-project-form.test.tsx | 40 +++++++++ 14 files changed, 266 insertions(+), 57 deletions(-) create mode 100644 services/web/frontend/js/features/project-list/components/dropdown/mobile-page-switcher-items.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 293df8843f..8a0dc6128e 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/project-list/components/dropdown/mobile-page-switcher-items.tsx b/services/web/frontend/js/features/project-list/components/dropdown/mobile-page-switcher-items.tsx new file mode 100644 index 0000000000..959822ae64 --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/dropdown/mobile-page-switcher-items.tsx @@ -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 ( + <> +
  • + + {t('library')} + +
  • +
  • + {onProjectsClick ? ( + { + e.stopPropagation() + onProjectsClick() + }} + > + {t('projects')} + + ) : ( + + {t('projects')} + + )} +
  • + + ) +} + +export default MobilePageSwitcherItems diff --git a/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx b/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx index 273c50ced6..571eb1dab3 100644 --- a/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx +++ b/services/web/frontend/js/features/project-list/components/dropdown/projects-dropdown.tsx @@ -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>({ all: t('all_projects'), owned: t('your_projects'), @@ -73,8 +78,40 @@ function ProjectsDropdown() { } }, [filter, tags, selectedTagId, t]) + const submenuItems = ( + <> +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • + {t('tags')}: + + + ) + return ( - + { + if (!show) { + setView('submenu') + } + } + : undefined + } + > -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • -
  • - -
  • - {t('tags')}: - + {!isLibraryEnabled && submenuItems} + {isLibraryEnabled && view === 'submenu' && ( + <> +
  • + } + aria-label={t('back')} + onClick={e => { + e.stopPropagation() + setView('top') + }} + > + {t('projects')} + +
  • + {submenuItems} + + )} + {isLibraryEnabled && view === 'top' && ( + setView('submenu')} + /> + )}
    ) diff --git a/services/web/frontend/js/features/project-list/components/new-project-button.tsx b/services/web/frontend/js/features/project-list/components/new-project-button.tsx index 86731f1df7..6f34d3049e 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button.tsx @@ -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}
    - setModal(null)} /> + setModal(null)} + initialTags={initialTags} + /> ) } diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx index 5943bf7baf..dd1ae3ca0e 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/blank-project-modal.tsx @@ -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 ( - + ) } diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx index aacd9c05d2..3989883b6b 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/example-project-modal.tsx @@ -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 ( - + ) } diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx index 048016c3bc..db191eacb6 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/modal-content-new-project-form.tsx @@ -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() const [projectName, setProjectName] = useState('') + const [projectTags, setProjectTags] = useState(initialTags) const [redirecting, setRedirecting] = useState(false) const { isLoading, isError, error, runAsync } = useAsync() 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} /> + + {projectTags.length > 0 && ( + + {t('tags')}: +
    + {projectTags.map(tag => ( + + ))} +
    +
    + )} diff --git a/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx b/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx index bfac2356c1..6f9f273a3a 100644 --- a/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/new-project-button/new-project-button-modal.tsx @@ -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 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 + return case 'example_project': - return + return case 'upload_project': return ( }> diff --git a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx index b0d7e02f06..a558bdd6ac 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-ds-nav.tsx @@ -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 = (
    @@ -54,10 +57,12 @@ export function ProjectListDsNav() { selectedTag={selectedTag} className="overflow-hidden flex-grow-1" /> - + {showNewProjectButton && ( + + )} ) : ( <> @@ -127,7 +132,7 @@ export function ProjectListDsNav() { selectedTag={selectedTag} /> - {isLibraryEnabled && ( + {isLibraryEnabled && showNewProjectButton && ( - {!isLibraryEnabled && ( - - )} + diff --git a/services/web/frontend/js/shared/components/sidebar/ds-nav-page-switcher.tsx b/services/web/frontend/js/shared/components/sidebar/ds-nav-page-switcher.tsx index 30b60cc3bf..2623dc9863 100644 --- a/services/web/frontend/js/shared/components/sidebar/ds-nav-page-switcher.tsx +++ b/services/web/frontend/js/shared/components/sidebar/ds-nav-page-switcher.tsx @@ -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 && (
    - Overleaf, A Digital Science Solution + + Overleaf, A Digital Science Solution +
    )}
      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)); + } } } } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index bda2e42504..04103df886 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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?", diff --git a/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx b/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx index e1c97bb914..252877e772 100644 --- a/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx +++ b/services/web/test/frontend/features/project-list/components/new-project-button/modal-content-new-project-form.test.tsx @@ -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('', function () { beforeEach(function () { @@ -51,6 +52,45 @@ describe('', 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( + {}} + 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'