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 features 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,