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

Implement Load More functionality on Project List

GitOrigin-RevId: 9981d5ef9d3b29683164152812f9315c74680c20
This commit is contained in:
Alexandre Bourdin 2022-09-26 10:57:09 +02:00 committed by Copybot
parent 53d2074315
commit fe164ec6fd
8 changed files with 299 additions and 5 deletions

View file

@ -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": "",

View file

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

View file

@ -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() {
</div>
</Col>
</Row>
<Row className="row-spaced">
<Col xs={12}>
<LoadMore />
</Col>
</Row>
</div>
</>
) : (

View file

@ -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: Project) => boolean) // eslint-disable-line no-unused-vars
@ -75,6 +77,10 @@ type ProjectListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
selectedProjects: Project[]
setSelectedProjects: React.Dispatch<React.SetStateAction<Project[]>>
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<Project[]>([])
const [visibleProjects, setVisibleProjects] = useState<Project[]>([])
const [hiddenProjects, setHiddenProjects] = useState<Project[]>([])
const [loadMoreCount, setLoadMoreCount] = useState<number>(0)
const [loadProgress, setLoadProgress] = useState(20)
const [totalProjectsCount, setTotalProjectsCount] = useState<number>(0)
const [sort, setSort] = useState<Sort>({
@ -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,
]
)

View file

@ -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;
}

View file

@ -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</0> 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."
}

View file

@ -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.`
)
})
})
})

View file

@ -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
}) => <ProjectListProvider {...contextProps}>{children}</ProjectListProvider>
}) => <ProjectListProvider>{children}</ProjectListProvider>
return render(component, {
wrapper: ProjectListProviderWrapper,