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": "",
|
"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": "",
|
||||||
|
|
|
@ -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 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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -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,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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."
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { 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,
|
||||||
|
|
Loading…
Reference in a new issue