From 5aa6d8809a2c1f0f372fc0779974c7c9c6165c17 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Tue, 10 Feb 2026 17:59:52 +0100 Subject: [PATCH] Admin tools: optimize user/project list performance Add frontend pagination Add search debouncing Render modals conditionally Remove unnecessary sorting --- .../web/frontend/extracted-translations.json | 3 +- .../js/shared/components/pagination-cep.tsx | 90 +++++++++++++ services/web/locales/de.json | 3 +- services/web/locales/en.json | 3 +- services/web/locales/ru.json | 3 +- .../app/src/UserListController.mjs | 4 +- .../js/project-list/components/load-more.tsx | 56 -------- .../modals/transfer-project-modal.tsx | 4 +- .../components/project-list-ds-nav.tsx | 10 +- .../components/project-list-summary.tsx | 50 +++++++ .../project-list/components/search-form.tsx | 41 ++++-- .../action-buttons/delete-project-button.tsx | 14 +- .../action-buttons/purge-project-button.tsx | 14 +- .../action-buttons/restore-project-button.tsx | 14 +- .../transfer-project-button.tsx | 14 +- .../action-buttons/trash-project-button.tsx | 14 +- .../components/table/project-checkbox.tsx | 1 + .../components/table/project-list-table.tsx | 10 +- .../buttons/delete-projects-button.tsx | 14 +- .../buttons/purge-projects-button.tsx | 14 +- .../buttons/restore-projects-button.tsx | 14 +- .../buttons/transfer-projects-button.tsx | 14 +- .../buttons/trash-projects-button.tsx | 14 +- .../context/project-list-context.tsx | 123 ++++++++++++------ .../js/project-list/hooks/use-sort.ts | 7 +- .../js/user-list/components/load-more.tsx | 56 -------- .../components/modals/delete-user-modal.tsx | 5 +- .../modals/show-user-info-modal.tsx | 1 + .../js/user-list/components/search-form.tsx | 32 ++++- .../action-buttons/delete-user-button.tsx | 14 +- .../cells/action-buttons/flag-user-button.tsx | 16 ++- .../action-buttons/purge-user-button.tsx | 14 +- .../action-buttons/restore-user-button.tsx | 14 +- .../action-buttons/send-reg-email-button.tsx | 14 +- .../action-buttons/show-user-info-button.tsx | 12 +- .../action-buttons/update-user-button.tsx | 14 +- .../components/table/user-list-table.tsx | 3 + .../buttons/delete-users-button.tsx | 14 +- .../user-tools/buttons/flag-users-button.tsx | 16 ++- .../user-tools/buttons/purge-users-button.tsx | 15 ++- .../buttons/restore-users-button.tsx | 15 ++- .../buttons/send-reg-emails-button.tsx | 14 +- .../user-list/components/user-list-ds-nav.tsx | 14 +- .../components/user-list-summary.tsx | 50 +++++++ .../user-list/context/user-list-context.tsx | 123 ++++++++++++------ .../frontend/js/user-list/hooks/use-sort.ts | 7 +- 46 files changed, 638 insertions(+), 373 deletions(-) create mode 100644 services/web/frontend/js/shared/components/pagination-cep.tsx delete mode 100644 services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx create mode 100644 services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx delete mode 100644 services/web/modules/admin-tools/frontend/js/user-list/components/load-more.tsx create mode 100644 services/web/modules/admin-tools/frontend/js/user-list/components/user-list-summary.tsx diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4439651822..8cda295760 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/shared/components/pagination-cep.tsx b/services/web/frontend/js/shared/components/pagination-cep.tsx new file mode 100644 index 0000000000..86521b2f99 --- /dev/null +++ b/services/web/frontend/js/shared/components/pagination-cep.tsx @@ -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 ( + + ) +} diff --git a/services/web/locales/de.json b/services/web/locales/de.json index 1e6860cbaa..7ed4436cc9 100644 --- a/services/web/locales/de.json +++ b/services/web/locales/de.json @@ -1159,6 +1159,7 @@ "pending": "Ausstehend", "pending_additional_licenses": "Dein Abonnement wird geändert, um <0>__pendingAdditionalLicenses__ zusätzliche Lizenz(en) für insgesamt <1>__pendingTotalLicenses__ 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.", diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 6fb43afd73..61ca041b13 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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": "(__ctrlSpace__ or __altSpace__)", "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", diff --git a/services/web/locales/ru.json b/services/web/locales/ru.json index 04373b1214..e94c4c7f31 100644 --- a/services/web/locales/ru.json +++ b/services/web/locales/ru.json @@ -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": "Зарегистрирован", diff --git a/services/web/modules/admin-tools/app/src/UserListController.mjs b/services/web/modules/admin-tools/app/src/UserListController.mjs index 0805d3480b..7b70dc217a 100644 --- a/services/web/modules/admin-tools/app/src/UserListController.mjs +++ b/services/web/modules/admin-tools/app/src/UserListController.mjs @@ -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) diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx deleted file mode 100644 index 3843a89a19..0000000000 --- a/services/web/modules/admin-tools/frontend/js/project-list/components/load-more.tsx +++ /dev/null @@ -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 ( -
- {hiddenProjectsCount > 0 ? ( - <> - loadMoreProjects()} - > - {t('show_x_more_projects', { x: loadMoreCount })} - - - ) : null} -

- {hiddenProjectsCount > 0 ? ( - <> - - {t('showing_x_out_of_n_projects', { - x: visibleProjects.length, - n: visibleProjects.length + hiddenProjectsCount, - })} - {' '} - showAllProjects()} - className="btn-inline-link" - > - {t('show_all_projects')} - - - ) : ( - - {t('showing_x_out_of_n_projects', { - x: visibleProjects.length, - n: visibleProjects.length, - })} - - )} -

-
- ) -} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx index 3602d06664..384ac88442 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/modals/transfer-project-modal.tsx @@ -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, @@ -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, diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx index e6f0a7e0c7..6b3e84c128 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-ds-nav.tsx @@ -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() {
- + +
+
+
diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx new file mode 100644 index 0000000000..156e5a2059 --- /dev/null +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/project-list-summary.tsx @@ -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 ( +
+

+ + {t('showing_x_out_of_n_projects', { + x: visibleProjects.length, + n: visibleProjects.length + hiddenProjectsCount, + })} + + · + + setProjectsPerPage(Number(e.target.value))} + style={{ + width: 'auto', + border: '1px solid #ccc', + background: 'var(--green-10)', + padding: '0 0.2rem', + boxShadow: 'none', + cursor: 'pointer', + }} + > + + + + + + {t('per_page')} + + +

+
+ ) +} diff --git a/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx b/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx index 5eaddcd26c..085a77203a 100644 --- a/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx +++ b/services/web/modules/admin-tools/frontend/js/project-list/components/search-form.tsx @@ -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 | 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 ( } append={ - inputValue.length > 0 && ( + localValue.length > 0 && (