From 203e2e914b55261febad05bb2a68dfc282ce6709 Mon Sep 17 00:00:00 2001 From: Simon Gardner Date: Tue, 5 Aug 2025 12:50:01 +0100 Subject: [PATCH] Merge pull request #27464 from overleaf/slg-filter-group-member-list add user filter to group member list GitOrigin-RevId: 0a65ca377979de706e2e43ee024d2cee9261a121 --- .../web/frontend/extracted-translations.json | 2 + .../components/group-members.tsx | 1 + .../components/members-table/members-list.tsx | 94 ++++++++++++++++++- services/web/locales/en.json | 2 + .../components/group-members.spec.tsx | 16 ++-- .../components/managed-group-members.spec.tsx | 8 +- .../members-table/members-list.spec.tsx | 26 +++++ services/web/tsconfig.json | 5 +- services/web/types/helpers/string.ts | 42 +++++++++ services/web/webpack.config.js | 1 + 10 files changed, 179 insertions(+), 18 deletions(-) create mode 100644 services/web/types/helpers/string.ts diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 299f5d97d9..ac33005ac7 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -1514,6 +1514,7 @@ "search_in_trashed_projects": "", "search_in_your_projects": "", "search_match_case": "", + "search_members": "", "search_next": "", "search_only_the_bib_files_in_your_project_only_by_citekeys": "", "search_previous": "", @@ -1608,6 +1609,7 @@ "showing_1_result": "", "showing_1_result_of_total": "", "showing_x_out_of_n_projects": "", + "showing_x_out_of_n_users": "", "showing_x_results": "", "showing_x_results_of_total": "", "sign_up": "", diff --git a/services/web/frontend/js/features/group-management/components/group-members.tsx b/services/web/frontend/js/features/group-management/components/group-members.tsx index 4e94645c94..d775e70511 100644 --- a/services/web/frontend/js/features/group-management/components/group-members.tsx +++ b/services/web/frontend/js/features/group-management/components/group-members.tsx @@ -159,6 +159,7 @@ export default function GroupMembers() { aria-describedby="add-members-description" value={emailString} onChange={handleEmailsChange} + data-testid="add-members-input" /> diff --git a/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx b/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx index 8a46a225bb..415fa1c56e 100644 --- a/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx +++ b/services/web/frontend/js/features/group-management/components/members-table/members-list.tsx @@ -14,6 +14,13 @@ import UnlinkUserModal from './unlink-user-modal' import OLTable from '@/features/ui/components/ol/ol-table' import OLTooltip from '@/features/ui/components/ol/ol-tooltip' import Pagination from '@/shared/components/pagination' +import OLFormControl from '@/features/ui/components/ol/ol-form-control' +import OLForm from '@/features/ui/components/ol/ol-form' +import OLFormGroup from '@/features/ui/components/ol/ol-form-group' +import OLCol from '@/features/ui/components/ol/ol-col' +import MaterialIcon from '@/shared/components/material-icon' +import OLRow from '@/features/ui/components/ol/ol-row' +import { isNonEmptyString, NonEmptyString } from '@ol-types/helpers/string' const USERS_DISPLAY_LIMIT = 50 @@ -21,6 +28,18 @@ type ManagedUsersListProps = { groupId: string } +function isUserSearchMatch(user: User, search: NonEmptyString): boolean { + const lowercaseSearch = search.toLowerCase() + + return Boolean( + [user.email, user.first_name, user.last_name].find( + fieldValue => + // if the field is null treat it as a match + fieldValue == null || fieldValue.toLowerCase().includes(lowercaseSearch) + ) + ) +} + export default function MembersList({ groupId }: ManagedUsersListProps) { const { t } = useTranslation() const [userToOffboard, setUserToOffboard] = useState( @@ -34,17 +53,40 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { const managedUsersActive = getMeta('ol-managedUsersActive') const groupSSOActive = getMeta('ol-groupSSOActive') const tHeadRowRef = useRef(null) + const [userSearchString, setUserSearchString] = useState('') + const userSearchRef = useRef(null) const [pagination, setPagination] = useState({ currPage: 1, totalPages: 1 }) + const filteredUsers = useMemo( + () => + isNonEmptyString(userSearchString) + ? users.filter(user => isUserSearchMatch(user, userSearchString)) + : users, + [users, userSearchString] + ) + const usersForCurrentPage = useMemo( () => - users.slice( + filteredUsers.slice( (pagination.currPage - 1) * USERS_DISPLAY_LIMIT, pagination.currPage * USERS_DISPLAY_LIMIT ), - [users, pagination.currPage] + [filteredUsers, pagination.currPage] ) + const handleUserSearchStringChange = ( + e: React.ChangeEvent + ) => { + setUserSearchString(e.target.value) + } + + const handleClearUserSearchString = () => { + setUserSearchString('') + if (userSearchRef.current) { + userSearchRef.current.focus() + } + } + const handlePageClick = ( _e: React.MouseEvent, page: number @@ -55,9 +97,9 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { useEffect(() => { setPagination(p => ({ ...p, - totalPages: Math.ceil(users.length / USERS_DISPLAY_LIMIT), + totalPages: Math.ceil(filteredUsers.length / USERS_DISPLAY_LIMIT), })) - }, [users.length]) + }, [filteredUsers.length]) return (
@@ -68,6 +110,35 @@ export default function MembersList({ groupId }: ManagedUsersListProps) { onDismiss={() => setGroupUserAlert(undefined)} /> )} + e.preventDefault()}> + + + + } + append={ + userSearchString.length > 0 && ( + + ) + } + value={userSearchString} + onChange={handleUserSearchStringChange} + data-testid="search-members-input" + /> + + + + - {users.length === 0 && ( + {filteredUsers.length === 0 && ( +
+
+

+ + {t('showing_x_out_of_n_users', { + x: usersForCurrentPage.length, + n: filteredUsers.length, + })} + +

+
+
+ {pagination.totalPages > 1 && (
{ + win.metaAttributesCache.set( + 'ol-users', + Array.from({ length: 50 }) + .flatMap(() => users.flat()) + .map((user, i) => ({ + ...user, + // create more than one page of users with same name + first_name: i < 75 ? 'Julie' : 'David', + })) + ) + }) + mountManagedUsersList() + cy.findByTestId('search-members-input').type('jul') + cy.findByTestId('x-of-n-users').should( + 'contain.text', + 'Showing 50 out of 75 users' + ) + }) }) describe('empty user list', function () { diff --git a/services/web/tsconfig.json b/services/web/tsconfig.json index 63922ae9a7..57f1469a9e 100644 --- a/services/web/tsconfig.json +++ b/services/web/tsconfig.json @@ -15,7 +15,10 @@ "paths": { "@/*": ["./frontend/js/*"], "@overleaf/o-error": ["../../libraries/o-error"], - "@overleaf/ranges-tracker": ["../../libraries/ranges-tracker"] + "@overleaf/ranges-tracker": ["../../libraries/ranges-tracker"], + "@ol-types/*": [ + "./types/*" + ] /* can't make this entry @types because that conflicts with the "types" entry below */ }, "types": ["cypress", "@testing-library/cypress"] }, diff --git a/services/web/types/helpers/string.ts b/services/web/types/helpers/string.ts new file mode 100644 index 0000000000..d334e7b380 --- /dev/null +++ b/services/web/types/helpers/string.ts @@ -0,0 +1,42 @@ +import { Brand } from './brand' + +/** + * A branded type representing a string that is guaranteed to be non-empty. + * This is a compile-time-only type and does not add any runtime overhead. + * It's created using a type guard and is a safer alternative to a simple `string` + * when you need to ensure the value is not an empty string (''). + * + * The compiler will prevent a regular `string` or an empty string literal + * from being assigned to this type without a type assertion. The recommended way + * to create an instance of this type is by using the {@link isNonEmptyString} + * type guard. + * + * @example + * // A function that requires a non-empty string as input + * function greetUser(name: NonEmptyString): void { + * console.log(`Hello, ${name}!`); + * } + * + * // Use the type guard to safely call the function + * if (isNonEmptyString("Alice")) { + * greetUser(userInput); // This call is valid + * } else { + * console.error("Error: User name cannot be empty."); + * } + */ +export type NonEmptyString = Brand + +/** + * A type guard to check if a string is non-empty. + * + * This function performs a runtime check and, if the condition is met, + * tells the TypeScript compiler to treat the string as a {@link NonEmptyString}. + * This is the only way to safely transition from a `string` to a `NonEmptyString` + * type without a type assertion. + * + * @param {string} str - The string to check. + * @returns {str is NonEmptyString} - A boolean indicating if the string is non-empty. + */ +export function isNonEmptyString(str: string): str is NonEmptyString { + return str.length > 0 +} diff --git a/services/web/webpack.config.js b/services/web/webpack.config.js index 94144eeea2..077ff41fb1 100644 --- a/services/web/webpack.config.js +++ b/services/web/webpack.config.js @@ -288,6 +288,7 @@ module.exports = { alias: { // custom prefixes for import paths '@': path.resolve(__dirname, './frontend/js/'), + '@ol-types': path.resolve(__dirname, './types/'), }, // symlinks: false, // enable this while using `npm link` extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.json'],