Admin tools: optimize user/project list performance

Add frontend pagination
Add search debouncing
Render modals conditionally
Remove unnecessary sorting
This commit is contained in:
yu-i-i
2026-02-10 17:59:52 +01:00
parent ab9550190a
commit 5aa6d8809a
46 changed files with 638 additions and 373 deletions

View File

@@ -1442,6 +1442,7 @@
"per_license": "",
"per_month": "",
"per_month_x_annually": "",
"per_page": "",
"per_year": "",
"percent_is_the_percentage_of_the_line_width": "",
"permanently_delete_accounts": "",
@@ -1840,7 +1841,6 @@
"sharing_permissions": "",
"shortcut_to_open_advanced_reference_search": "",
"show_all_projects": "",
"show_all_users": "",
"show_breadcrumbs": "",
"show_document_preamble": "",
"show_equation_preview": "",
@@ -1854,7 +1854,6 @@
"show_outline": "",
"show_version_history": "",
"show_x_more_projects": "",
"show_x_more_users": "",
"showing_1_result": "",
"showing_1_result_of_total": "",
"showing_pdf_preview_with_inverted_colors": "",

View File

@@ -0,0 +1,90 @@
import { useTranslation } from 'react-i18next'
interface PaginationProps {
currentPage: number
totalPages: number
onPageChange: (page: number) => void
}
export default function Pagination({
currentPage,
totalPages,
onPageChange,
}: PaginationProps) {
const { t } = useTranslation()
if (totalPages <= 1) return null
const pageNumbers: (number | "...")[] = []
let startPage = Math.max(1, currentPage - 4)
let endPage = Math.min(totalPages, currentPage + 4)
if (startPage > 1) {
pageNumbers.push(1)
if (startPage > 2) {
pageNumbers.push("...")
}
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i)
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
pageNumbers.push("...")
}
pageNumbers.push(totalPages)
}
return (
<nav role="navigation" aria-label={t('pagination_navigation')}>
<ul className="pagination">
{/*
{currentPage > 1 && (
<li>
<button aria-label={t('go_to_first_page')} onClick={() => onPageChange(1)}>
&lt;&lt; {t('first')}
</button>
</li>
)}
*/}
{currentPage > 1 && (
<li>
<button aria-label={t('go_prev_page')} onClick={() => onPageChange(currentPage - 1)}>
&lt; {t('prev')}
</button>
</li>
)}
{pageNumbers.map((page, index) => (
<li key={index} className={page === currentPage ? "active" : ""}>
{page === "..." ? (
<span aria-hidden="true">{page}</span>
) : page === currentPage ? (
<span aria-label={t('page_current', { page })} aria-current="true">{page}</span>
) : (
<button aria-label={t('go_page', { page })} onClick={() => onPageChange(page)}>
{page}
</button>
)}
</li>
))}
{currentPage < totalPages && (
<li>
<button aria-label={t('go_next_page')} onClick={() => onPageChange(currentPage + 1)}>
{t('next')} &gt;
</button>
</li>
)}
{/*
{currentPage < totalPages && (
<li>
<button aria-label={t('go_to_last_page')} onClick={() => onPageChange(totalPages)}>
{t('last')} &gt;&gt;
</button>
</li>
)}
*/}
</ul>
</nav>
)
}

View File

@@ -1159,6 +1159,7 @@
"pending": "Ausstehend",
"pending_additional_licenses": "Dein Abonnement wird geändert, um <0>__pendingAdditionalLicenses__</0> zusätzliche Lizenz(en) für insgesamt <1>__pendingTotalLicenses__</1> Lizenzen einzuschließen.",
"per_month": "pro Monat",
"per_page": "pro Seite",
"per_user_year": "pro Nutzer / Jahr",
"per_year": "pro Jahr",
"permanently_delete_accounts": "Konten endgültig löschen",
@@ -1401,14 +1402,12 @@
"shared_with_you": "Mit dir geteilt",
"sharelatex_beta_program": "__appName__ Beta-Programm",
"show_all_projects": "Alle Projekte anzeigen",
"show_all_users": "Alle Benutzer anzeigen",
"show_in_code": "Im Code anzeigen",
"show_in_pdf": "Im PDF anzeigen",
"show_less": "Weniger anzeigen",
"show_live_equation_previews_while_typing": "Live-Gleichungsvorschau beim Tippen anzeigen",
"show_outline": "Dateigliederung anzeigen",
"show_x_more_projects": "__x__ weitere Projekte anzeigen",
"show_x_more_users": "__x__ weitere Benutzer anzeigen",
"showing_1_result": "1 Ergebnis wird angezeigt",
"showing_1_result_of_total": "Zeige 1 Ergebnis von __total__",
"showing_x_out_of_n_projects": "Es werden __x__ von __n__ Projekten angezeigt.",

View File

@@ -1893,6 +1893,7 @@
"per_month": "per month",
"per_month_billed_annually": "per month, billed annually",
"per_month_x_annually": "per month, __price__ annually",
"per_page": "per page",
"per_user_year": "per user / year",
"per_year": "per year",
"percent_is_the_percentage_of_the_line_width": "% is the percentage of the line width",
@@ -2382,7 +2383,6 @@
"sharing_permissions": "Sharing permissions",
"shortcut_to_open_advanced_reference_search": "(<strong>__ctrlSpace__</strong> or <strong>__altSpace__</strong>)",
"show_all_projects": "Show all projects",
"show_all_users": "Show all users",
"show_breadcrumbs": "Show breadcrumbs",
"show_document_preamble": "Show document preamble",
"show_equation_preview": "Show equation preview",
@@ -2396,7 +2396,6 @@
"show_outline": "Show File outline",
"show_version_history": "Show version history",
"show_x_more_projects": "Show __x__ more projects",
"show_x_more_users": "Show __x__ more users",
"showing_1_result": "Showing 1 result",
"showing_1_result_of_total": "Showing 1 result of __total__",
"showing_pdf_preview_with_inverted_colors": "Showing PDF preview with inverted colors",

View File

@@ -325,6 +325,7 @@
"password_reset_token_expired": "Ваш код восстановления пароля истёк. Пожалуйста, запросите восстановление пароля по почте ещё раз и перейдите по ссылке в письме.",
"pdf_viewer": "Просмотрщик PDF",
"pending": "В ожидании",
"per_page": "на страницу",
"permanently_delete_accounts": "Окончательно удалить аккаунты",
"permanently_delete_projects": "Окончательно удалить проекты",
"personal": "Личный",
@@ -434,9 +435,7 @@
"share_project": "Открыть доступ к проекту",
"shared_with_you": "Доступные мне",
"show_all_projects": "Показать все проекты",
"show_all_users": "Показать всех пользователей",
"show_x_more_projects": "Показать ещё __x__ проектов",
"show_x_more_users": "Показать ещё __x__ пользователей",
"showing_x_out_of_n_projects": "Показано __x__ из __n__ проектов",
"showing_x_out_of_n_users": "Показано __x__ из __n__ пользователей",
"signed_up": "Зарегистрирован",

View File

@@ -323,8 +323,8 @@ function _matchesFilters(user, filters) {
if (
filters.search?.length &&
user.email.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
user.first_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
user.last_name.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
user.firstName?.toLowerCase().indexOf(filters.search.toLowerCase()) === -1 &&
user.lastName?.toLowerCase().indexOf(filters.search.toLowerCase()) === -1
) { return false }
// Deleted users only match the 'deleted' filter
if (user.deleted) return Boolean(filters.deleted)

View File

@@ -1,56 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context'
import OLButton from '@/shared/components/ol/ol-button'
export default function LoadMore() {
const {
visibleProjects,
hiddenProjectsCount,
loadMoreCount,
showAllProjects,
loadMoreProjects,
} = useProjectListContext()
const { t } = useTranslation()
return (
<div className="text-center">
{hiddenProjectsCount > 0 ? (
<>
<OLButton
variant="secondary"
className="project-list-load-more-button"
onClick={() => loadMoreProjects()}
>
{t('show_x_more_projects', { x: loadMoreCount })}
</OLButton>
</>
) : null}
<p>
{hiddenProjectsCount > 0 ? (
<>
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllProjects()}
className="btn-inline-link"
>
{t('show_all_projects')}
</OLButton>
</>
) : (
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length,
})}
</span>
)}
</p>
</div>
)
}

View File

@@ -13,7 +13,6 @@ import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormLabel from '@/shared/components/ol/ol-form-label'
import OLFormCheckbox from '@/shared/components/ol/ol-form-checkbox'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import sortUsers from '../../../user-list/util/sort-users'
type TransferProjectModalProps = Pick<
React.ComponentProps<typeof ProjectsActionModal>,
@@ -35,9 +34,8 @@ function TransferProjectModal({
const potentialOwners = useMemo(() => {
if (!loadedUsers) return null;
const sortedUsers = sortUsers(loadedUsers, { by: 'name', order: 'asc' })
const result: UserRef[] = []
for (const user of sortedUsers) {
for (const user of loadedUsers) {
if (!user.deleted && user.id !== projectsOwnerId) {
result.push({
id: user.id,

View File

@@ -18,6 +18,8 @@ import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import { getUserName } from '../util/user'
import { useProjectListContext } from '../context/project-list-context'
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
import Pagination from '@/shared/components/pagination-cep'
import ProjectListSummary from './project-list-summary'
export function ProjectListDsNav() {
@@ -32,6 +34,9 @@ export function ProjectListDsNav() {
selectedProjects,
filter,
projectsOwnerId,
currentPage,
setCurrentPage,
totalPages,
} = useProjectListContext()
const { getUserNameById } = useUserIdentityContext()
@@ -100,7 +105,10 @@ export function ProjectListDsNav() {
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
<ProjectListSummary />
</div>
<div className="mt-3">
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
</div>
</main>

View File

@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
import { useProjectListContext } from '../context/project-list-context'
export default function ProjectListSummary() {
const {
visibleProjects,
hiddenProjectsCount,
projectsPerPage,
setProjectsPerPage,
} = useProjectListContext()
const { t } = useTranslation()
return (
<div className="text-center">
<p>
<span aria-live="polite">
{t('showing_x_out_of_n_projects', {
x: visibleProjects.length,
n: visibleProjects.length + hiddenProjectsCount,
})}
</span>
<span className="mx-2">·</span>
<span className="d-inline-flex gap-1">
<OLFormSelect
name="projects_per_page"
value={projectsPerPage}
onChange={(e) => setProjectsPerPage(Number(e.target.value))}
style={{
width: 'auto',
border: '1px solid #ccc',
background: 'var(--green-10)',
padding: '0 0.2rem',
boxShadow: 'none',
cursor: 'pointer',
}}
>
<option value={20}>20</option>
<option value={40}>40</option>
<option value={80}>80</option>
</OLFormSelect>
<span>
{t('per_page')}
</span>
</span>
</p>
</div>
)
}

View File

@@ -1,13 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import * as eventTracking from '@/infrastructure/event-tracking'
import classnames from 'classnames'
import { MergeAndOverride } from '../../../../../../types/utils'
import * as eventTracking from '@/infrastructure/event-tracking'
import { isSmallDevice } from '@/infrastructure/event-tracking'
import OLCol from '@/shared/components/ol/ol-col'
import OLForm from '@/shared/components/ol/ol-form'
import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLCol from '@/shared/components/ol/ol-col'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { MergeAndOverride } from '../../../../../../types/utils'
type SearchFormOwnProps = {
inputValue: string
@@ -26,20 +27,39 @@ function SearchForm({
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholderMessage = t('search_projects')
const placeholder = `${placeholderMessage}`
const placeholder = t('search_projects')+'…'
const [localValue, setLocalValue] = useState(inputValue)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setLocalValue(inputValue)
}, [inputValue])
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
eventTracking.sendMB('admin-user-project-list-page-interaction', {
eventTracking.sendMB('admin-project-list-page-interaction', {
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
const value = e.target.value
setLocalValue(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
setInputValue(value)
}, 300)
}
const handleClear = () => setInputValue('')
const handleClear = () => {
setLocalValue('')
setInputValue('')
}
return (
<OLForm
@@ -51,14 +71,15 @@ function SearchForm({
<OLFormGroup>
<OLCol>
<OLFormControl
name="search"
type="text"
value={inputValue}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
localValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"

View File

@@ -47,12 +47,14 @@ function DeleteProjectButton({ project, children }: DeleteProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteProjectModal
projects={[project]}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -42,12 +42,14 @@ function PurgeProjectButton({ project, children }: PurgeProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<PurgeProjectModal
projects={[project]}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeProjectModal
projects={[project]}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -51,12 +51,14 @@ function RestoreProjectButton({
return (
<>
{children(text, handleOpenModal)}
<RestoreProjectModal
projects={[project]}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreProjectModal
projects={[project]}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -54,12 +54,14 @@ function TransferProjectButton({ project, children }: TransferProjectButtonProps
return (
<>
{children(text, handleOpenModal)}
<TransferProjectModal
projects={[project]}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TransferProjectModal
projects={[project]}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -47,12 +47,14 @@ function TrashProjectButton({ project, children }: TrashProjectButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TrashProjectModal
projects={[project]}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -18,6 +18,7 @@ export const ProjectCheckbox = memo<{ projectId: string; projectName: string }>(
return (
<OLFormCheckbox
id={`select_project_${projectId}`}
autoComplete="off"
onChange={handleCheckboxChange}
checked={selectedProjectIds.has(projectId)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect } from 'react'
import { useCallback, useRef, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ProjectListTableRow from './project-list-table-row'
import { useProjectListContext } from '../../context/project-list-context'
@@ -28,9 +28,12 @@ function ProjectListTable() {
const {
visibleProjects,
sort,
searchText,
selectedProjects,
selectOrUnselectAllProjects,
filter,
currentPage,
setCurrentPage,
} = useProjectListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)
@@ -49,6 +52,10 @@ function ProjectListTable() {
selectedProjects.length !== visibleProjects.length
}
}, [selectedProjects, visibleProjects])
const [lastNonSearchPage, setLastNonSearchPage] = useState(1)
const [isSearching, setIsSearching] = useState(false)
return (
<OLTable className="project-dash-table" container={false} hover>
<caption className="visually-hidden">{t('projects_list')}</caption>
@@ -59,6 +66,7 @@ function ProjectListTable() {
aria-label={t('select_projects')}
>
<OLFormCheckbox
name="select_all_projects"
autoComplete="off"
onChange={handleAllProjectsCheckboxChange}
checked={

View File

@@ -43,12 +43,14 @@ function DeleteProjectsButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteProjectModal
projects={selectedProjects}
actionHandler={handleDeleteProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -36,12 +36,14 @@ function PurgeProjectsButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('purge')}
</OLButton>
<PurgeProjectModal
projects={selectedProjects}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeProjectModal
projects={selectedProjects}
actionHandler={handlePurgeProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -47,12 +47,14 @@ function RestoreProjectsButton() {
<OLButton variant="primary" onClick={handleOpenModal}>
{t('restore')}
</OLButton>
<RestoreProjectModal
projects={selectedProjects}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreProjectModal
projects={selectedProjects}
actionHandler={handleRestoreProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -60,12 +60,14 @@ function TransferProjectsButton() {
icon="swap_horiz"
/>
</OLTooltip>
<TransferProjectModal
projects={selectedProjects}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TransferProjectModal
projects={selectedProjects}
actionHandler={handleTransferProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -52,12 +52,14 @@ function TrashProjectsButton() {
icon="delete"
/>
</OLTooltip>
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<TrashProjectModal
projects={selectedProjects}
actionHandler={handleTrashProject}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -21,8 +21,6 @@ import { getProjects } from '../util/api'
import { useUserIdentityContext } from '../../user-list/context/user-identity-context'
import sortProjects from '../util/sort-projects'
const MAX_PROJECT_PER_PAGE = 20
export type Filter = 'owned' | 'trashed' | 'deleted' | 'inactive'
type FilterMap = {
@@ -54,8 +52,6 @@ export type ProjectListContextValue = {
filter: Filter
hiddenProjectsCount: number
isLoading: ReturnType<typeof useAsync>['isLoading']
loadMoreCount: number
loadMoreProjects: () => void
loadProgress: number
removeProjectFromView: (project: Project) => void
selectFilter: (filter: Filter) => void
@@ -66,13 +62,17 @@ export type ProjectListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
setSelectedProjectIds: React.Dispatch<React.SetStateAction<Set<string>>>
setSort: React.Dispatch<React.SetStateAction<Sort>>
showAllProjects: () => void
sort: Sort
toggleSelectedProject: (projectId: string, selected?: boolean) => void
totalProjectsCount: number
projectsOwnerId: string | null
updateProjectViewData: (newProjectData: Project) => void
visibleProjects: Project[]
currentPage: number
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
totalPages: number
projectsPerPage: number,
setProjectsPerPage: React.Dispatch<React.SetStateAction<number>>,
}
export const ProjectListContext = createContext<
@@ -92,9 +92,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
prefetchedProjectsBlob?.projects ?? []
)
const [maxVisibleProjects, setMaxVisibleProjects] =
useState(MAX_PROJECT_PER_PAGE)
const [loadProgress, setLoadProgress] = useState(
prefetchedProjectsBlob ? 100 : 20
)
@@ -113,7 +110,32 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
})
const prevSortRef = useRef<Sort>(sort)
const [searchText, setSearchText] = useState('')
const [searchText, setSearchTextState] = useState('')
const lastNonSearchPageRef = useRef(1)
const isSearchingRef = useRef(false)
const [currentPage, setCurrentPage] = useState(1)
const setSearchText: React.Dispatch<React.SetStateAction<string>> = value => {
setSearchTextState(prev => {
const nextValue =
typeof value === 'function' ? value(prev) : value
const wasSearching = isSearchingRef.current
const willSearch = nextValue.length > 0
if (!wasSearching && willSearch) {
lastNonSearchPageRef.current = currentPage
isSearchingRef.current = true
setCurrentPage(1)
} else if (wasSearching && !willSearch) {
isSearchingRef.current = false
setCurrentPage(lastNonSearchPageRef.current)
}
return nextValue
})
}
const {
isLoading: loading,
@@ -131,7 +153,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
if (prefetchedProjectsBlob) return
setLoadProgress(40)
runAsync(getProjects({ userId: projectsOwnerId, by: 'lastUpdated', order: 'desc' }))
.then(data => {
setLoadedProjects(data.projects)
@@ -144,7 +165,12 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}, [projectsOwnerId, runAsync, prefetchedProjectsBlob])
const sortedProjects = useMemo(() => {
if (prevSortRef.current === sort) return loadedProjects
if (
prevSortRef.current.by === sort.by &&
prevSortRef.current.order === sort.order
) {
return loadedProjects
}
const sorted = sortProjects(loadedProjects, sort, getUserById)
prevSortRef.current = sort
@@ -152,40 +178,49 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}, [loadedProjects, sort, getUserById])
const filteredProjects = useMemo(() => {
let result = sortedProjects
const predicate = filters[filter]
const hasSearch = searchText.length > 0
const lower = hasSearch ? searchText.toLowerCase() : null
if (searchText.length) {
const lower = searchText.toLowerCase()
result = result.filter(project =>
project.name.toLowerCase().includes(lower)
)
}
return sortedProjects.filter(project => {
if (hasSearch) {
if (!project.name.toLowerCase().includes(lower!)) {
return false
}
}
return result.filter(filters[filter])
return typeof predicate === 'function'
? predicate(project)
: true
})
}, [sortedProjects, searchText, filter])
const [projectsPerPage, setProjectsPerPage] = useState(20)
const previousProjectsPerPageRef = useRef(projectsPerPage)
useEffect(() => {
const previousProjectsPerPage = previousProjectsPerPageRef.current
if (previousProjectsPerPage !== projectsPerPage) {
const oldStartIndex = (currentPage - 1) * previousProjectsPerPage
const newPage = Math.floor(oldStartIndex / projectsPerPage) + 1
setCurrentPage(newPage)
previousProjectsPerPageRef.current = projectsPerPage
}
}, [projectsPerPage])
const totalPages = Math.ceil(filteredProjects.length / projectsPerPage)
const startIndex = (currentPage - 1) * projectsPerPage
const visibleProjects = useMemo(() => {
return filteredProjects.slice(0, maxVisibleProjects)
}, [filteredProjects, maxVisibleProjects])
return filteredProjects.slice(startIndex, startIndex + projectsPerPage)
}, [filteredProjects, startIndex, projectsPerPage])
const hiddenProjectsCount = Math.max(
filteredProjects.length - visibleProjects.length,
0
)
const loadMoreCount = Math.min(
hiddenProjectsCount,
MAX_PROJECT_PER_PAGE
)
const showAllProjects = useCallback(() => {
setMaxVisibleProjects(v => v + hiddenProjectsCount)
}, [hiddenProjectsCount])
const loadMoreProjects = useCallback(() => {
setMaxVisibleProjects(v => v + loadMoreCount)
}, [loadMoreCount])
const [selectedProjectIds, setSelectedProjectIds] = useState(
() => new Set<string>()
)
@@ -233,6 +268,8 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
const selectFilter = useCallback(
(filter: Filter) => {
setFilter(filter)
selectOrUnselectAllProjects(false)
setCurrentPage(1)
setSort(prev => {
if (filter === 'deleted' && prev.by === 'lastUpdated') {
@@ -243,8 +280,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
}
return prev
})
selectOrUnselectAllProjects(false)
},
[selectOrUnselectAllProjects]
)
@@ -269,8 +304,6 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
filter,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromView,
selectFilter,
@@ -281,21 +314,23 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
setSearchText,
setSelectedProjectIds,
setSort,
showAllProjects,
sort,
toggleSelectedProject,
totalProjectsCount,
updateProjectViewData,
projectsOwnerId,
visibleProjects,
currentPage,
setCurrentPage,
totalPages,
projectsPerPage,
setProjectsPerPage
}),
[
error,
filter,
hiddenProjectsCount,
isLoading,
loadMoreCount,
loadMoreProjects,
loadProgress,
removeProjectFromView,
selectFilter,
@@ -306,13 +341,17 @@ export function ProjectListProvider({ projectsOwnerId, children }: ProjectListPr
setSearchText,
setSelectedProjectIds,
setSort,
showAllProjects,
sort,
toggleSelectedProject,
totalProjectsCount,
projectsOwnerId,
updateProjectViewData,
visibleProjects,
currentPage,
setCurrentPage,
totalPages,
projectsPerPage,
setProjectsPerPage
]
)

View File

@@ -7,9 +7,10 @@ const toggleSort = (order: SortingOrder): SortingOrder =>
order === 'asc' ? 'desc' : 'asc'
function useSort() {
const { filter, sort, setSort } = useProjectListContext()
const { filter, sort, setSort, setCurrentPage } = useProjectListContext()
const handleSort = (by: Sort['by']) => {
setCurrentPage(1)
setSort(prev => ({
by,
order: prev.by === by ? toggleSort(prev.order) : prev.order,
@@ -18,13 +19,15 @@ function useSort() {
useEffect(() => {
if (filter === 'deleted' && sort.by === 'lastUpdated') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'deletedAt' }))
}
if (filter !== 'deleted' && sort.by === 'deletedAt') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'lastUpdated' }))
}
}, [filter, sort.by, setSort])
}, [filter, sort.by, setSort, setCurrentPage])
return { handleSort }
}

View File

@@ -1,56 +0,0 @@
import { useTranslation } from 'react-i18next'
import { useUserListContext } from '../context/user-list-context'
import OLButton from '@/shared/components/ol/ol-button'
export default function LoadMore() {
const {
visibleUsers,
hiddenUsersCount,
loadMoreCount,
showAllUsers,
loadMoreUsers,
} = useUserListContext()
const { t } = useTranslation()
return (
<div className="text-center">
{hiddenUsersCount > 0 ? (
<>
<OLButton
variant="secondary"
className="user-list-load-more-button"
onClick={() => loadMoreUsers()}
>
{t('show_x_more_users', { x: loadMoreCount })}
</OLButton>
</>
) : null}
<p>
{hiddenUsersCount > 0 ? (
<>
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length + hiddenUsersCount,
})}
</span>{' '}
<OLButton
variant="link"
onClick={() => showAllUsers()}
className="btn-inline-link"
>
{t('show_all_users')}
</OLButton>
</>
) : (
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length,
})}
</span>
)}
</p>
</div>
)
}

View File

@@ -9,7 +9,6 @@ import OLForm from '@/shared/components/ol/ol-form'
import SelectOwnerForm from '../../../project-list/components/select-owner-form'
import { useUserListContext } from '../../../user-list/context/user-list-context'
import { UserRef } from '../../../../../types/project/api'
import sortUsers from '../../../user-list/util/sort-users'
type DeleteUserModalProps = Pick<
React.ComponentProps<typeof UsersActionModal>,
@@ -34,12 +33,10 @@ function DeleteUserModal({
const potentialOwners = useMemo(() => {
if (!loadedUsers) return []
const excludeIds = new Set(users.map(u => u.id))
const possibleUsers = loadedUsers.filter(
return loadedUsers.filter(
user => !user.deleted && !excludeIds.has(user.id)
)
return sortUsers(possibleUsers, { by: 'name', order: 'asc' })
}, [loadedUsers, users])
useEffect(() => {

View File

@@ -95,6 +95,7 @@ function ShowUserInfoModal({
<>
<Card.Header>{t('Account')}</Card.Header>
<Body>
<InfoRow label={'ID'} value={user.id} />
<InfoRow label={t('email_address')} value={user.email} />
<InfoRow label={t('first_name')} value={user.firstName || '—'} />
<InfoRow label={t('last_name')} value={user.lastName || '—'} />

View File

@@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
import * as eventTracking from '@/infrastructure/event-tracking'
@@ -8,12 +9,10 @@ import OLFormGroup from '@/shared/components/ol/ol-form-group'
import OLFormControl from '@/shared/components/ol/ol-form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { MergeAndOverride } from '../../../../../../types/utils'
import { Filter } from '../context/user-list-context'
type SearchFormOwnProps = {
inputValue: string
setInputValue: (input: string) => void
filter: Filter
}
type SearchFormProps = MergeAndOverride<
@@ -24,13 +23,19 @@ type SearchFormProps = MergeAndOverride<
function SearchForm({
inputValue,
setInputValue,
filter,
className,
...props
}: SearchFormProps) {
const { t } = useTranslation()
const placeholder = t('search')+'…'
const [localValue, setLocalValue] = useState(inputValue)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
setLocalValue(inputValue)
}, [inputValue])
const handleChange: React.ComponentProps<
typeof OLFormControl
>['onChange'] = e => {
@@ -38,10 +43,23 @@ function SearchForm({
action: 'search',
isSmallDevice,
})
setInputValue(e.target.value)
const value = e.target.value
setLocalValue(value)
if (debounceRef.current) {
clearTimeout(debounceRef.current)
}
debounceRef.current = setTimeout(() => {
setInputValue(value)
}, 300)
}
const handleClear = () => setInputValue('')
const handleClear = () => {
setLocalValue('')
setInputValue('')
}
return (
<OLForm
@@ -55,13 +73,13 @@ function SearchForm({
<OLFormControl
name="search"
type="text"
value={inputValue}
value={localValue}
onChange={handleChange}
placeholder={placeholder}
aria-label={placeholder}
prepend={<MaterialIcon type="search" />}
append={
inputValue.length > 0 && (
localValue.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"

View File

@@ -40,12 +40,14 @@ function DeleteUserButton({ user, children }: DeleteUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<DeleteUserModal
users={[user]}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteUserModal
users={[user]}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -43,13 +43,15 @@ function FlagUserButton({ user, action, children }: FlagUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<FlagUserModal
users={[user]}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<FlagUserModal
users={[user]}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -40,12 +40,14 @@ function PurgeUserButton({ user, children }: PurgeUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<PurgeUserModal
users={[user]}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeUserModal
users={[user]}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -40,12 +40,14 @@ function RestoreUserButton({ user, children }: RestoreUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<RestoreUserModal
users={[user]}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreUserModal
users={[user]}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -42,12 +42,14 @@ function SendRegEmailButton({ user, children }: SendRegEmailButtonProps) {
return (
<span style={isHidden ? { visibility: 'hidden' } : undefined }>
{children(text, handleOpenModal)}
<SendRegEmailModal
users={[user]}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<SendRegEmailModal
users={[user]}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</span>
)
}

View File

@@ -38,11 +38,13 @@ function ShowUserInfoButton({ user, children }: ShowUserInfoButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<ShowUserInfoModal
users={[user]}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<ShowUserInfoModal
users={[user]}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -40,12 +40,14 @@ function UpdateUserButton({ user, children }: UpdateUserButtonProps) {
return (
<>
{children(text, handleOpenModal)}
<UpdateUserModal
users={[user]}
actionHandler={handleUpdateUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<UpdateUserModal
users={[user]}
actionHandler={handleUpdateUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -28,10 +28,13 @@ function UserListTable() {
const {
visibleUsers,
sort,
searchText,
selectedUsers,
selectOrUnselectAllUsers,
selfVisibleCount,
filter,
currentPage,
setCurrentPage,
} = useUserListContext()
const { handleSort } = useSort()
const checkAllRef = useRef<HTMLInputElement>(null)

View File

@@ -39,12 +39,14 @@ function DeleteUsersButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('delete')}
</OLButton>
<DeleteUserModal
users={selectedUsers}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<DeleteUserModal
users={selectedUsers}
actionHandler={handleDeleteUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -70,13 +70,15 @@ function FlagUsersButton({ action }: { action: string }) {
unfilled={unfilled}
/>
</OLTooltip>
<FlagUserModal
users={selectedUsers}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<FlagUserModal
users={selectedUsers}
action={action}
actionHandler={handleFlagUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -33,13 +33,14 @@ function PurgeUsersButton() {
<OLButton variant="danger" onClick={handleOpenModal}>
{t('purge')}
</OLButton>
<PurgeUserModal
users={selectedUsers}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<PurgeUserModal
users={selectedUsers}
actionHandler={handlePurgeUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -33,13 +33,14 @@ function RestoreUsersButton() {
<OLButton variant="primary" onClick={handleOpenModal}>
{t('restore')}
</OLButton>
<RestoreUserModal
users={selectedUsers}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<RestoreUserModal
users={selectedUsers}
actionHandler={handleRestoreUser}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -47,12 +47,14 @@ function SendRegEmailsButton({ action }: { action: string }) {
unfilled={true}
/>
</OLTooltip>
<SendRegEmailModal
users={selectedUsers}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
{showModal && (
<SendRegEmailModal
users={selectedUsers}
actionHandler={handleSendRegEmail}
showModal={showModal}
handleCloseModal={handleCloseModal}
/>
)}
</>
)
}

View File

@@ -7,7 +7,7 @@ import UsersDropdown from './dropdown/users-dropdown'
import SortByDropdown from './dropdown/sort-by-dropdown'
import UserTools from './table/user-tools/user-tools'
import UserListTitle from './title/user-list-title'
import LoadMore from './load-more'
import CountUsers from './count-users'
import OLCol from '@/shared/components/ol/ol-col'
import OLRow from '@/shared/components/ol/ol-row'
import { TableContainer } from '@/shared/components/table'
@@ -18,6 +18,8 @@ import Footer from '@/shared/components/footer/footer'
import SidebarDsNav from './sidebar/sidebar-ds-nav'
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
import CookieBanner from '@/shared/components/cookie-banner'
import Pagination from '@/shared/components/pagination-cep'
import UserListSummary from './user-list-summary'
export function UserListDsNav() {
const navbarProps = getMeta('ol-navbar')
@@ -30,6 +32,9 @@ export function UserListDsNav() {
setSearchText,
selectedUsers,
filter,
currentPage,
setCurrentPage,
totalPages,
} = useUserListContext()
const tableTopArea = (
@@ -40,7 +45,6 @@ export function UserListDsNav() {
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
className="overflow-hidden flex-grow-1"
/>
</div>
@@ -77,7 +81,6 @@ export function UserListDsNav() {
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
/>
</OLCol>
</OLRow>
@@ -98,7 +101,10 @@ export function UserListDsNav() {
</TableContainer>
</div>
<div className="mt-3">
<LoadMore />
<UserListSummary />
</div>
<div className="mt-3">
<Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />
</div>
</div>
</main>

View File

@@ -0,0 +1,50 @@
import { useTranslation } from 'react-i18next'
import OLFormSelect from '@/shared/components/ol/ol-form-select'
import { useUserListContext } from '../context/user-list-context'
export default function UserListSummary() {
const {
visibleUsers,
hiddenUsersCount,
usersPerPage,
setUsersPerPage,
} = useUserListContext()
const { t } = useTranslation()
return (
<div className="text-center">
<p>
<span aria-live="polite">
{t('showing_x_out_of_n_users', {
x: visibleUsers.length,
n: visibleUsers.length + hiddenUsersCount,
})}
</span>
<span className="mx-2">·</span>
<span className="d-inline-flex gap-1">
<OLFormSelect
name="users_per_page"
value={usersPerPage}
onChange={(e) => setUsersPerPage(Number(e.target.value))}
style={{
width: 'auto',
border: '1px solid #ccc',
background: 'var(--green-10)',
padding: '0 0.2rem',
boxShadow: 'none',
cursor: 'pointer',
}}
>
<option value={20}>20</option>
<option value={40}>40</option>
<option value={80}>80</option>
</OLFormSelect>
<span>
{t('per_page')}
</span>
</span>
</p>
</div>
)
}

View File

@@ -6,6 +6,7 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from 'react'
import {
filter as arrayFilter,
@@ -25,8 +26,6 @@ import sortUsers from '../util/sort-users'
import { UserIdentityProvider } from './user-identity-context'
const MAX_USER_PER_PAGE = 10
type AuthMethods = 'local' | 'ldap' | 'saml' | 'oidc'
export type Filter = 'all' | 'admin' | 'suspended' | 'inactive' | AuthMethods | 'deleted'
@@ -68,8 +67,6 @@ export type UserListContextValue = {
filterTranslations: Map<Filter, string>
hiddenUsersCount: number
isLoading: ReturnType<typeof useAsync>['isLoading']
loadMoreCount: number
loadMoreUsers: () => void
loadProgress: number
removeUserFromView: (user: User) => void
searchText: string
@@ -81,13 +78,17 @@ export type UserListContextValue = {
setSearchText: React.Dispatch<React.SetStateAction<string>>
setSelectedUserIds: React.Dispatch<React.SetStateAction<Set<string>>>
setSort: React.Dispatch<React.SetStateAction<Sort>>
showAllUsers: () => void
sort: Sort
toggleSelectedUser: (userId: string, selected?: boolean) => void
totalUsersCount: number
updateUserViewData: (newUserData: User) => void
loadedUsers: User[]
visibleUsers: User[]
currentPage: number
setCurrentPage: React.Dispatch<React.SetStateAction<number>>
totalPages: number
usersPerPage: number,
setUsersPerPage: React.Dispatch<React.SetStateAction<number>>,
}
export const UserListContext = createContext<
@@ -104,9 +105,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
prefetchedUsersBlob?.users ?? []
)
const [maxVisibleUsers, setMaxVisibleUsers] =
useState(MAX_USER_PER_PAGE)
const [loadProgress, setLoadProgress] = useState(
prefetchedUsersBlob ? 100 : 20
)
@@ -130,7 +128,31 @@ export function UserListProvider({ children }: UserListProviderProps) {
'all'
)
const [searchText, setSearchText] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const [searchText, setSearchTextState] = useState('')
const lastNonSearchPageRef = useRef(1)
const isSearchingRef = useRef(false)
const setSearchText: React.Dispatch<React.SetStateAction<string>> = value => {
setSearchTextState(prev => {
const nextValue =
typeof value === 'function' ? value(prev) : value
const wasSearching = isSearchingRef.current
const willSearch = nextValue.length > 0
if (!wasSearching && willSearch) {
lastNonSearchPageRef.current = currentPage
isSearchingRef.current = true
setCurrentPage(1)
} else if (wasSearching && !willSearch) {
isSearchingRef.current = false
setCurrentPage(lastNonSearchPageRef.current)
}
return nextValue
})
}
const {
isLoading: loading,
@@ -143,10 +165,11 @@ export function UserListProvider({ children }: UserListProviderProps) {
})
const isLoading = isIdle ? true : loading
useEffect(() => {
if (prefetchedUsersBlob) return
setLoadProgress(40)
runAsync(getUsers({ by: 'signUpDate', order: 'desc' }))
runAsync(getUsers({ by: 'name', order: 'asc' }))
.then(data => {
setLoadedUsers(data.users)
setTotalUsersCount(data.totalSize)
@@ -161,7 +184,10 @@ export function UserListProvider({ children }: UserListProviderProps) {
setLoadedUsers(prev => [newUser as User, ...prev])
}, [])
const processedUsers = useMemo(() => {
const isDefaultSort =
sort.by === 'name' && sort.order === 'asc'
const filteredUsers = useMemo(() => {
let users = loadedUsers
if (searchText.length) {
@@ -175,35 +201,50 @@ export function UserListProvider({ children }: UserListProviderProps) {
users = arrayFilter(users, filters[filter])
return sortUsers(users, sort)
}, [loadedUsers, searchText, filter, sort])
return users
}, [loadedUsers, searchText, filter])
const processedUsers = useMemo(() => {
if (isDefaultSort) {
return filteredUsers
}
return sortUsers(filteredUsers, sort)
}, [filteredUsers, sort, isDefaultSort])
const [usersPerPage, setUsersPerPage] = useState(20)
const previousUsersPerPageRef = useRef(usersPerPage)
useEffect(() => {
const previousUsersPerPage = previousUsersPerPageRef.current
if (previousUsersPerPage !== usersPerPage) {
const oldStartIndex = (currentPage - 1) * previousUsersPerPage
const newPage = Math.floor(oldStartIndex / usersPerPage) + 1
setCurrentPage(newPage)
previousUsersPerPageRef.current = usersPerPage
}
}, [usersPerPage])
const totalPages = useMemo(
() => Math.ceil(processedUsers.length / usersPerPage),
[processedUsers.length, usersPerPage]
)
const startIndex = (currentPage - 1) * usersPerPage
const visibleUsers = useMemo(() => {
return processedUsers.slice(0, maxVisibleUsers)
}, [processedUsers, maxVisibleUsers])
return processedUsers.slice(startIndex, startIndex + usersPerPage)
}, [processedUsers, startIndex, usersPerPage])
const hiddenUsersCount = Math.max(
processedUsers.length - visibleUsers.length,
0
)
const loadMoreCount = Math.min(
hiddenUsersCount,
MAX_USER_PER_PAGE
)
const selfVisibleCount = useMemo(() => {
return visibleUsers.some(u => u.id === selfId) ? 1 : 0
}, [visibleUsers])
const showAllUsers = useCallback(() => {
setMaxVisibleUsers(maxVisibleUsers + hiddenUsersCount)
}, [hiddenUsersCount, maxVisibleUsers])
const loadMoreUsers = useCallback(() => {
setMaxVisibleUsers(maxVisibleUsers + loadMoreCount)
}, [maxVisibleUsers, loadMoreCount])
const [selectedUserIds, setSelectedUserIds] = useState(
() => new Set<string>()
)
@@ -266,10 +307,10 @@ export function UserListProvider({ children }: UserListProviderProps) {
return prev
})
const selected = false
selectOrUnselectAllUsers(selected)
selectOrUnselectAllUsers(false)
setCurrentPage(1)
},
[selectOrUnselectAllUsers, setFilter]
[selectOrUnselectAllUsers]
)
const updateUserViewData = useCallback((newUserData: User) => {
@@ -294,8 +335,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
filterTranslations,
hiddenUsersCount,
isLoading,
loadMoreCount,
loadMoreUsers,
loadProgress,
removeUserFromView,
searchText,
@@ -307,13 +346,17 @@ export function UserListProvider({ children }: UserListProviderProps) {
setSearchText,
setSelectedUserIds,
setSort,
showAllUsers,
sort,
toggleSelectedUser,
totalUsersCount,
updateUserViewData,
loadedUsers,
visibleUsers,
currentPage,
setCurrentPage,
totalPages,
usersPerPage,
setUsersPerPage,
}),
[
addUserToView,
@@ -322,8 +365,6 @@ export function UserListProvider({ children }: UserListProviderProps) {
filterTranslations,
hiddenUsersCount,
isLoading,
loadMoreCount,
loadMoreUsers,
loadProgress,
removeUserFromView,
searchText,
@@ -335,19 +376,21 @@ export function UserListProvider({ children }: UserListProviderProps) {
setSearchText,
setSelectedUserIds,
setSort,
showAllUsers,
sort,
toggleSelectedUser,
totalUsersCount,
updateUserViewData,
loadedUsers,
visibleUsers,
currentPage,
setCurrentPage,
totalPages,
usersPerPage,
setUsersPerPage,
]
)
if (!loadedUsers || loadedUsers.length === 0) {
return null
}
if (isLoading) return null
return (
<UserIdentityProvider users={loadedUsers}>

View File

@@ -8,9 +8,10 @@ const toggleSort = (order: SortingOrder): SortingOrder => {
}
function useSort() {
const { filter, sort, setSort } = useUserListContext()
const { filter, sort, setSort, setCurrentPage } = useUserListContext()
const handleSort = (by: Sort['by']) => {
setCurrentPage(1)
setSort(prev => ({
by,
order: prev.by === by ? toggleSort(sort.order) : sort.order,
@@ -19,13 +20,15 @@ function useSort() {
useEffect(() => {
if (filter === 'deleted' && sort.by === 'signUpDate') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'deletedAt' }))
}
if (filter !== 'deleted' && sort.by === 'deletedAt') {
setCurrentPage(1)
setSort(prev => ({ ...prev, by: 'signUpDate' }))
}
}, [filter, sort.by, setSort])
}, [filter, sort.by, setSort, setCurrentPage])
return { handleSort }
}