Просмотр исходного кода

Library: Design Changes (#33933)

GitOrigin-RevId: b45ea92adc424e2864e952cd7f157509e10ffb7d
Tom Wells 2 месяцев назад
Родитель
Сommit
0565a778d8

+ 3 - 0
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": "",

+ 42 - 0
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 (
+    <>
+      <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

+ 64 - 18
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<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>
   )

+ 13 - 1
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}
         </DropdownMenu>
       </Dropdown>
-      <NewProjectButtonModal modal={modal} onHide={() => setModal(null)} />
+      <NewProjectButtonModal
+        modal={modal}
+        onHide={() => setModal(null)}
+        initialTags={initialTags}
+      />
     </>
   )
 }

+ 4 - 2
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 (
     <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>
   )
 }

+ 11 - 2
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 (
     <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>
   )
 }

+ 39 - 3
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<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>
 

+ 9 - 3
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<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} />}>

+ 10 - 5
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 = (
     <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"

+ 1 - 6
services/web/frontend/js/features/project-list/components/sidebar/sidebar-filters.tsx

@@ -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')} />

+ 12 - 8
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 && (
         <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

+ 15 - 9
services/web/frontend/stylesheets/pages/project-list-ds-nav.scss

@@ -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 - 0
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?",

+ 40 - 0
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('<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'