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

Merge pull request #9545 from overleaf/mf-project-list-load-more

Implement Load More functionality on Project List

GitOrigin-RevId: 9981d5ef9d3b29683164152812f9315c74680c20
Alexandre Bourdin 3 лет назад
Родитель
Сommit
fe164ec6fd

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -561,11 +561,16 @@
   "share_with_your_collabs": "",
   "share_with_your_collabs": "",
   "shared_with_you": "",
   "shared_with_you": "",
   "sharelatex_beta_program": "",
   "sharelatex_beta_program": "",
+  "show_all_projects": "",
+  "show_all_uppercase": "",
   "show_in_code": "",
   "show_in_code": "",
   "show_in_pdf": "",
   "show_in_pdf": "",
   "show_outline": "",
   "show_outline": "",
+  "show_x_more": "",
+  "show_x_more_projects": "",
   "showing_1_result": "",
   "showing_1_result": "",
   "showing_1_result_of_total": "",
   "showing_1_result_of_total": "",
+  "showing_x_out_of_n_projects": "",
   "showing_x_results": "",
   "showing_x_results": "",
   "showing_x_results_of_total": "",
   "showing_x_results_of_total": "",
   "something_went_wrong_loading_pdf_viewer": "",
   "something_went_wrong_loading_pdf_viewer": "",

+ 57 - 0
services/web/frontend/js/features/project-list/components/load-more.tsx

@@ -0,0 +1,57 @@
+import { Button } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import { useProjectListContext } from '../context/project-list-context'
+
+export default function LoadMore() {
+  const {
+    visibleProjects,
+    hiddenProjects,
+    loadMoreCount,
+    showAllProjects,
+    loadMoreProjects,
+  } = useProjectListContext()
+  const { t } = useTranslation()
+
+  return (
+    <div className="text-centered">
+      {hiddenProjects.length > 0 ? (
+        <Button
+          bsStyle="info"
+          className="project-list-load-more-button"
+          onClick={() => loadMoreProjects()}
+          aria-label={t('show_x_more_projects', { x: loadMoreCount })}
+        >
+          {t('show_x_more', { x: loadMoreCount })}
+        </Button>
+      ) : null}
+      <p>
+        {hiddenProjects.length > 0 ? (
+          <>
+            <span aria-live="polite">
+              {t('showing_x_out_of_n_projects', {
+                x: visibleProjects.length,
+                n: visibleProjects.length + hiddenProjects.length,
+              })}
+            </span>{' '}
+            <button
+              type="button"
+              onClick={() => showAllProjects()}
+              style={{ padding: 0 }}
+              className="btn-link"
+              aria-label={t('show_all_projects')}
+            >
+              {t('show_all_uppercase')}
+            </button>
+          </>
+        ) : (
+          <span aria-live="polite">
+            {t('showing_x_out_of_n_projects', {
+              x: visibleProjects.length,
+              n: visibleProjects.length,
+            })}
+          </span>
+        )}
+      </p>
+    </div>
+  )
+}

+ 6 - 0
services/web/frontend/js/features/project-list/components/project-list-root.tsx

@@ -17,6 +17,7 @@ import SearchForm from './search-form'
 import ProjectsDropdown from './dropdown/projects-dropdown'
 import ProjectsDropdown from './dropdown/projects-dropdown'
 import SortByDropdown from './dropdown/sort-by-dropdown'
 import SortByDropdown from './dropdown/sort-by-dropdown'
 import ProjectTools from './table/project-tools/project-tools'
 import ProjectTools from './table/project-tools/project-tools'
+import LoadMore from './load-more'
 
 
 function ProjectListRoot() {
 function ProjectListRoot() {
   const { isReady } = useWaitForI18n()
   const { isReady } = useWaitForI18n()
@@ -111,6 +112,11 @@ function ProjectListPageContent() {
                   </div>
                   </div>
                 </Col>
                 </Col>
               </Row>
               </Row>
+              <Row className="row-spaced">
+                <Col xs={12}>
+                  <LoadMore />
+                </Col>
+              </Row>
             </div>
             </div>
           </>
           </>
         ) : (
         ) : (

+ 57 - 1
services/web/frontend/js/features/project-list/context/project-list-context.tsx

@@ -21,6 +21,8 @@ import useAsync from '../../../shared/hooks/use-async'
 import { getProjects } from '../util/api'
 import { getProjects } from '../util/api'
 import sortProjects from '../util/sort-projects'
 import sortProjects from '../util/sort-projects'
 
 
+const MAX_PROJECT_PER_PAGE = 20
+
 export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
 export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
 type FilterMap = {
 type FilterMap = {
   [key in Filter]: Partial<Project> | ((project: Project) => boolean) // eslint-disable-line no-unused-vars
   [key in Filter]: Partial<Project> | ((project: Project) => boolean) // eslint-disable-line no-unused-vars
@@ -75,6 +77,10 @@ type ProjectListContextValue = {
   setSearchText: React.Dispatch<React.SetStateAction<string>>
   setSearchText: React.Dispatch<React.SetStateAction<string>>
   selectedProjects: Project[]
   selectedProjects: Project[]
   setSelectedProjects: React.Dispatch<React.SetStateAction<Project[]>>
   setSelectedProjects: React.Dispatch<React.SetStateAction<Project[]>>
+  hiddenProjects: Project[]
+  loadMoreCount: number
+  showAllProjects: () => void
+  loadMoreProjects: () => void
 }
 }
 
 
 export const ProjectListContext = createContext<
 export const ProjectListContext = createContext<
@@ -88,6 +94,8 @@ type ProjectListProviderProps = {
 export function ProjectListProvider({ children }: ProjectListProviderProps) {
 export function ProjectListProvider({ children }: ProjectListProviderProps) {
   const [loadedProjects, setLoadedProjects] = useState<Project[]>([])
   const [loadedProjects, setLoadedProjects] = useState<Project[]>([])
   const [visibleProjects, setVisibleProjects] = useState<Project[]>([])
   const [visibleProjects, setVisibleProjects] = useState<Project[]>([])
+  const [hiddenProjects, setHiddenProjects] = useState<Project[]>([])
+  const [loadMoreCount, setLoadMoreCount] = useState<number>(0)
   const [loadProgress, setLoadProgress] = useState(20)
   const [loadProgress, setLoadProgress] = useState(20)
   const [totalProjectsCount, setTotalProjectsCount] = useState<number>(0)
   const [totalProjectsCount, setTotalProjectsCount] = useState<number>(0)
   const [sort, setSort] = useState<Sort>({
   const [sort, setSort] = useState<Sort>({
@@ -168,7 +176,27 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       setLoadedProjects(loadedProjectsSorted)
       setLoadedProjects(loadedProjectsSorted)
     }
     }
 
 
-    setVisibleProjects(filteredProjects)
+    if (filteredProjects.length > MAX_PROJECT_PER_PAGE) {
+      const visibleFilteredProjects = filteredProjects.slice(
+        0,
+        MAX_PROJECT_PER_PAGE
+      )
+      setVisibleProjects(visibleFilteredProjects)
+
+      const hiddenFilteredProjects =
+        filteredProjects.slice(MAX_PROJECT_PER_PAGE)
+      setHiddenProjects(hiddenFilteredProjects)
+
+      if (hiddenFilteredProjects.length > MAX_PROJECT_PER_PAGE) {
+        setLoadMoreCount(MAX_PROJECT_PER_PAGE)
+      } else {
+        setLoadMoreCount(hiddenFilteredProjects.length)
+      }
+    } else {
+      setHiddenProjects([])
+      setVisibleProjects(filteredProjects)
+      setLoadMoreCount(0)
+    }
   }, [
   }, [
     loadedProjects,
     loadedProjects,
     tags,
     tags,
@@ -184,6 +212,26 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
     prevSortRef.current = sort
     prevSortRef.current = sort
   }, [sort])
   }, [sort])
 
 
+  const showAllProjects = useCallback(() => {
+    setLoadMoreCount(0)
+    setVisibleProjects([...visibleProjects, ...hiddenProjects])
+    setHiddenProjects([])
+  }, [hiddenProjects, visibleProjects])
+
+  const loadMoreProjects = useCallback(() => {
+    const newVisibleProjects = [
+      ...visibleProjects,
+      ...hiddenProjects.slice(0, loadMoreCount),
+    ]
+    const newHiddenProjects = hiddenProjects.slice(loadMoreCount)
+
+    setVisibleProjects(newVisibleProjects)
+    setHiddenProjects(newHiddenProjects)
+    if (newHiddenProjects.length < MAX_PROJECT_PER_PAGE) {
+      setLoadMoreCount(newHiddenProjects.length)
+    }
+  }, [visibleProjects, hiddenProjects, loadMoreCount])
+
   const untaggedProjectsCount = useMemo(() => {
   const untaggedProjectsCount = useMemo(() => {
     const taggedProjectIds = _.uniq(_.flatten(tags.map(tag => tag.project_ids)))
     const taggedProjectIds = _.uniq(_.flatten(tags.map(tag => tag.project_ids)))
     return loadedProjects.filter(
     return loadedProjects.filter(
@@ -304,6 +352,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       updateProjectViewData,
       updateProjectViewData,
       visibleProjects,
       visibleProjects,
       removeProjectFromView,
       removeProjectFromView,
+      hiddenProjects,
+      loadMoreCount,
+      showAllProjects,
+      loadMoreProjects,
     }),
     }),
     [
     [
       addTag,
       addTag,
@@ -329,6 +381,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
       updateProjectViewData,
       updateProjectViewData,
       visibleProjects,
       visibleProjects,
       removeProjectFromView,
       removeProjectFromView,
+      hiddenProjects,
+      loadMoreCount,
+      showAllProjects,
+      loadMoreProjects,
     ]
     ]
   )
   )
 
 

+ 11 - 0
services/web/frontend/stylesheets/app/project-list-react.less

@@ -652,3 +652,14 @@
     transform: translateY(-50%);
     transform: translateY(-50%);
   }
   }
 }
 }
+
+.project-list-load-more {
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+}
+
+.project-list-load-more-button {
+  margin-bottom: @margin-sm;
+}

+ 6 - 1
services/web/locales/en.json

@@ -663,6 +663,8 @@
   "tracked_change_added": "Added",
   "tracked_change_added": "Added",
   "tracked_change_deleted": "Deleted",
   "tracked_change_deleted": "Deleted",
   "show_all": "show all",
   "show_all": "show all",
+  "show_all_uppercase": "Show all",
+  "show_all_projects": "Show all projects",
   "show_less": "show less",
   "show_less": "show less",
   "dropbox_sync_error": "Sorry, there was a problem checking our Dropbox service. Please try again in a few moments.",
   "dropbox_sync_error": "Sorry, there was a problem checking our Dropbox service. Please try again in a few moments.",
   "send": "Send",
   "send": "Send",
@@ -1899,5 +1901,8 @@
   "thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features</0> and help us explore innovative ideas that help you work more quickly and effectively",
   "thank_you_for_being_part_of_our_labs_program": "Thank you for being part of our Labs program, where you can have <0>early access to experimental features</0> and help us explore innovative ideas that help you work more quickly and effectively",
   "manage_labs_program_membership": "Manage Labs Program Membership",
   "manage_labs_program_membership": "Manage Labs Program Membership",
   "current_experiments": "Current Experiments",
   "current_experiments": "Current Experiments",
-  "overleaf_labs": "Overleaf Labs"
+  "overleaf_labs": "Overleaf Labs",
+  "show_x_more": "Show __x__ more",
+  "show_x_more_projects": "Show __x__ more projects",
+  "showing_x_out_of_n_projects": "Showing __x__ out of __n__ projects."
 }
 }

+ 141 - 0
services/web/test/frontend/features/project-list/components/load-more.test.tsx

@@ -0,0 +1,141 @@
+import { fireEvent, screen, waitFor } from '@testing-library/dom'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import LoadMore from '../../../../../frontend/js/features/project-list/components/load-more'
+import { Project } from '../../../../../types/project/dashboard/api'
+import { projectsData } from '../fixtures/projects-data'
+import { renderWithProjectListContext } from '../helpers/render-with-context'
+
+describe('<LoadMore />', function () {
+  afterEach(function () {
+    fetchMock.reset()
+  })
+
+  it('renders on a project list longer than 40', async function () {
+    // archived and trashed projects are currently not shown
+    const filteredProjects = projectsData.filter(
+      ({ archived, trashed }) => !archived && !trashed
+    )
+    let longProjectsData: Project[] = filteredProjects
+    const MULTIPLY_FACTOR = 10
+    // longProjectsData.length = 55
+    for (let i = 0; i < MULTIPLY_FACTOR; i++) {
+      longProjectsData = [...longProjectsData, ...filteredProjects]
+    }
+
+    renderWithProjectListContext(<LoadMore />, { projects: longProjectsData })
+
+    await screen.findByRole('button', {
+      name: /Show 20 more projects/i,
+    })
+
+    await screen.findByText(
+      `Showing 20 out of ${longProjectsData.length} projects.`
+    )
+
+    await screen.findByRole('button', {
+      name: /Show all projects/i,
+    })
+  })
+
+  it('renders on a project list longer than 20 and shorter than 40', async function () {
+    // archived and trashed projects are currently not shown
+    const filteredProjects = projectsData.filter(
+      ({ archived, trashed }) => !archived && !trashed
+    )
+    let longProjectsData: Project[] = filteredProjects
+    const MULTIPLY_FACTOR = 5
+    // longProjectsData.length = 30
+    for (let i = 0; i < MULTIPLY_FACTOR; i++) {
+      longProjectsData = [...longProjectsData, ...filteredProjects]
+    }
+
+    renderWithProjectListContext(<LoadMore />, { projects: longProjectsData })
+
+    await screen.findByRole('button', {
+      name: new RegExp(
+        `Show ${longProjectsData.length - 20} more projects`,
+        'i'
+      ),
+    })
+
+    await screen.findByText(
+      `Showing 20 out of ${longProjectsData.length} projects.`
+    )
+
+    await screen.findByRole('button', {
+      name: /Show all projects/i,
+    })
+  })
+
+  it('renders on a project list shorter than 20', async function () {
+    // archived and trashed projects are currently not shown
+    // filteredProjects.length = 5
+    const filteredProjects = projectsData.filter(
+      ({ archived, trashed }) => !archived && !trashed
+    )
+
+    renderWithProjectListContext(<LoadMore />, { projects: filteredProjects })
+
+    await waitFor(() => {
+      expect(screen.queryByRole('button', { name: 'Show all' })).to.not.exist
+      screen.getByText(
+        `Showing ${filteredProjects.length} out of ${filteredProjects.length} projects.`
+      )
+    })
+  })
+
+  it('change text when pressing the "Show 20 more" once for project list longer than 40', async function () {
+    // archived and trashed projects are currently not shown
+    const filteredProjects = projectsData.filter(
+      ({ archived, trashed }) => !archived && !trashed
+    )
+    let longProjectsData: Project[] = filteredProjects
+    const MULTIPLY_FACTOR = 10
+    // longProjectsData.length = 55
+    for (let i = 0; i < MULTIPLY_FACTOR; i++) {
+      longProjectsData = [...longProjectsData, ...filteredProjects]
+    }
+
+    renderWithProjectListContext(<LoadMore />, { projects: longProjectsData })
+
+    await waitFor(() => {
+      const showMoreBtn = screen.getByRole('button', {
+        name: /Show 20 more projects/i,
+      })
+      fireEvent.click(showMoreBtn)
+
+      screen.getByRole('button', {
+        name: /Show 15 more/i,
+      })
+      screen.getByText(`Showing 40 out of ${longProjectsData.length} projects.`)
+    })
+  })
+
+  it('change text when pressing the "Show 20 more" once for project list longer than 20 and shorter than 40', async function () {
+    // archived and trashed projects are currently not shown
+    const filteredProjects = projectsData.filter(
+      ({ archived, trashed }) => !archived && !trashed
+    )
+    let longProjectsData: Project[] = filteredProjects
+    const MULTIPLY_FACTOR = 5
+    // longProjectsData.length = 30
+    for (let i = 0; i < MULTIPLY_FACTOR; i++) {
+      longProjectsData = [...longProjectsData, ...filteredProjects]
+    }
+
+    renderWithProjectListContext(<LoadMore />, { projects: longProjectsData })
+
+    await waitFor(() => {
+      const showMoreBtn = screen.getByRole('button', {
+        name: /Show 10 more projects/i,
+      })
+      fireEvent.click(showMoreBtn)
+
+      expect(screen.queryByRole('button', { name: /Show/ })).to.not.exist
+      screen.getByText(
+        `Showing ${longProjectsData.length} out of ${longProjectsData.length} projects.`
+      )
+    })
+  })
+})

+ 16 - 3
services/web/test/frontend/features/project-list/helpers/render-with-context.tsx

@@ -1,21 +1,34 @@
 import { render } from '@testing-library/react'
 import { render } from '@testing-library/react'
 import fetchMock from 'fetch-mock'
 import fetchMock from 'fetch-mock'
+import React from 'react'
 import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
 import { ProjectListProvider } from '../../../../../frontend/js/features/project-list/context/project-list-context'
+import { Project } from '../../../../../types/project/dashboard/api'
 import { projectsData } from '../fixtures/projects-data'
 import { projectsData } from '../fixtures/projects-data'
 
 
+type Options = {
+  projects?: Project[]
+}
+
 export function renderWithProjectListContext(
 export function renderWithProjectListContext(
   component: React.ReactElement,
   component: React.ReactElement,
-  contextProps = {}
+  options: Options = {}
 ) {
 ) {
+  let { projects } = options
+
+  if (!projects) {
+    projects = projectsData
+  }
+
   fetchMock.post('express:/api/project', {
   fetchMock.post('express:/api/project', {
     status: 200,
     status: 200,
-    body: { projects: projectsData, totalSize: projectsData.length },
+    body: { projects, totalSize: projects.length },
   })
   })
+
   const ProjectListProviderWrapper = ({
   const ProjectListProviderWrapper = ({
     children,
     children,
   }: {
   }: {
     children: React.ReactNode
     children: React.ReactNode
-  }) => <ProjectListProvider {...contextProps}>{children}</ProjectListProvider>
+  }) => <ProjectListProvider>{children}</ProjectListProvider>
 
 
   return render(component, {
   return render(component, {
     wrapper: ProjectListProviderWrapper,
     wrapper: ProjectListProviderWrapper,