diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 80cd045a94..029a52849e 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -561,11 +561,16 @@
"share_with_your_collabs": "",
"shared_with_you": "",
"sharelatex_beta_program": "",
+ "show_all_projects": "",
+ "show_all_uppercase": "",
"show_in_code": "",
"show_in_pdf": "",
"show_outline": "",
+ "show_x_more": "",
+ "show_x_more_projects": "",
"showing_1_result": "",
"showing_1_result_of_total": "",
+ "showing_x_out_of_n_projects": "",
"showing_x_results": "",
"showing_x_results_of_total": "",
"something_went_wrong_loading_pdf_viewer": "",
diff --git a/services/web/frontend/js/features/project-list/components/load-more.tsx b/services/web/frontend/js/features/project-list/components/load-more.tsx
new file mode 100644
index 0000000000..6353eafb44
--- /dev/null
+++ b/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 (
+
+ {hiddenProjects.length > 0 ? (
+
+ ) : null}
+
+ {hiddenProjects.length > 0 ? (
+ <>
+
+ {t('showing_x_out_of_n_projects', {
+ x: visibleProjects.length,
+ n: visibleProjects.length + hiddenProjects.length,
+ })}
+ {' '}
+
+ >
+ ) : (
+
+ {t('showing_x_out_of_n_projects', {
+ x: visibleProjects.length,
+ n: visibleProjects.length,
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
index 58956b1123..70ad27ea7a 100644
--- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx
+++ b/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 SortByDropdown from './dropdown/sort-by-dropdown'
import ProjectTools from './table/project-tools/project-tools'
+import LoadMore from './load-more'
function ProjectListRoot() {
const { isReady } = useWaitForI18n()
@@ -111,6 +112,11 @@ function ProjectListPageContent() {
+
+
+
+
+
>
) : (
diff --git a/services/web/frontend/js/features/project-list/context/project-list-context.tsx b/services/web/frontend/js/features/project-list/context/project-list-context.tsx
index fa99cb826d..ea3e4af251 100644
--- a/services/web/frontend/js/features/project-list/context/project-list-context.tsx
+++ b/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 sortProjects from '../util/sort-projects'
+const MAX_PROJECT_PER_PAGE = 20
+
export type Filter = 'all' | 'owned' | 'shared' | 'archived' | 'trashed'
type FilterMap = {
[key in Filter]: Partial | ((project: Project) => boolean) // eslint-disable-line no-unused-vars
@@ -75,6 +77,10 @@ type ProjectListContextValue = {
setSearchText: React.Dispatch>
selectedProjects: Project[]
setSelectedProjects: React.Dispatch>
+ hiddenProjects: Project[]
+ loadMoreCount: number
+ showAllProjects: () => void
+ loadMoreProjects: () => void
}
export const ProjectListContext = createContext<
@@ -88,6 +94,8 @@ type ProjectListProviderProps = {
export function ProjectListProvider({ children }: ProjectListProviderProps) {
const [loadedProjects, setLoadedProjects] = useState([])
const [visibleProjects, setVisibleProjects] = useState([])
+ const [hiddenProjects, setHiddenProjects] = useState([])
+ const [loadMoreCount, setLoadMoreCount] = useState(0)
const [loadProgress, setLoadProgress] = useState(20)
const [totalProjectsCount, setTotalProjectsCount] = useState(0)
const [sort, setSort] = useState({
@@ -168,7 +176,27 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
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,
tags,
@@ -184,6 +212,26 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
prevSortRef.current = 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 taggedProjectIds = _.uniq(_.flatten(tags.map(tag => tag.project_ids)))
return loadedProjects.filter(
@@ -304,6 +352,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
updateProjectViewData,
visibleProjects,
removeProjectFromView,
+ hiddenProjects,
+ loadMoreCount,
+ showAllProjects,
+ loadMoreProjects,
}),
[
addTag,
@@ -329,6 +381,10 @@ export function ProjectListProvider({ children }: ProjectListProviderProps) {
updateProjectViewData,
visibleProjects,
removeProjectFromView,
+ hiddenProjects,
+ loadMoreCount,
+ showAllProjects,
+ loadMoreProjects,
]
)
diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less
index ffe9c7968a..95f662bb45 100644
--- a/services/web/frontend/stylesheets/app/project-list-react.less
+++ b/services/web/frontend/stylesheets/app/project-list-react.less
@@ -652,3 +652,14 @@
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;
+}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index ed4d18adda..34970659f4 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -663,6 +663,8 @@
"tracked_change_added": "Added",
"tracked_change_deleted": "Deleted",
"show_all": "show all",
+ "show_all_uppercase": "Show all",
+ "show_all_projects": "Show all projects",
"show_less": "show less",
"dropbox_sync_error": "Sorry, there was a problem checking our Dropbox service. Please try again in a few moments.",
"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 features0> and help us explore innovative ideas that help you work more quickly and effectively",
"manage_labs_program_membership": "Manage Labs Program Membership",
"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."
}
diff --git a/services/web/test/frontend/features/project-list/components/load-more.test.tsx b/services/web/test/frontend/features/project-list/components/load-more.test.tsx
new file mode 100644
index 0000000000..b44ae8be12
--- /dev/null
+++ b/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('', 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(, { 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(, { 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(, { 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(, { 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(, { 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.`
+ )
+ })
+ })
+})
diff --git a/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
index 4f7146e007..65c419e4c1 100644
--- a/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
+++ b/services/web/test/frontend/features/project-list/helpers/render-with-context.tsx
@@ -1,21 +1,34 @@
import { render } from '@testing-library/react'
import fetchMock from 'fetch-mock'
+import React from 'react'
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'
+type Options = {
+ projects?: Project[]
+}
+
export function renderWithProjectListContext(
component: React.ReactElement,
- contextProps = {}
+ options: Options = {}
) {
+ let { projects } = options
+
+ if (!projects) {
+ projects = projectsData
+ }
+
fetchMock.post('express:/api/project', {
status: 200,
- body: { projects: projectsData, totalSize: projectsData.length },
+ body: { projects, totalSize: projects.length },
})
+
const ProjectListProviderWrapper = ({
children,
}: {
children: React.ReactNode
- }) => {children}
+ }) => {children}
return render(component, {
wrapper: ProjectListProviderWrapper,