mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
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:
parent
53d2074315
commit
fe164ec6fd
8 changed files with 299 additions and 5 deletions
|
@ -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": "",
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
)
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
|
|
|
@ -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.`
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue