Merge pull request #27464 from overleaf/slg-filter-group-member-list

add user filter to group member list

GitOrigin-RevId: 0a65ca377979de706e2e43ee024d2cee9261a121
This commit is contained in:
Simon Gardner
2025-08-05 12:50:01 +01:00
committed by Copybot
parent 58b8e36739
commit 203e2e914b
10 changed files with 179 additions and 18 deletions
@@ -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": "",
@@ -159,6 +159,7 @@ export default function GroupMembers() {
aria-describedby="add-members-description"
value={emailString}
onChange={handleEmailsChange}
data-testid="add-members-input"
/>
</OLCol>
<OLCol xs={4}>
@@ -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<User | undefined>(
@@ -34,17 +53,40 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
const managedUsersActive = getMeta('ol-managedUsersActive')
const groupSSOActive = getMeta('ol-groupSSOActive')
const tHeadRowRef = useRef<HTMLTableRowElement>(null)
const [userSearchString, setUserSearchString] = useState('')
const userSearchRef = useRef<HTMLInputElement>(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<HTMLInputElement>
) => {
setUserSearchString(e.target.value)
}
const handleClearUserSearchString = () => {
setUserSearchString('')
if (userSearchRef.current) {
userSearchRef.current.focus()
}
}
const handlePageClick = (
_e: React.MouseEvent<HTMLButtonElement>,
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 (
<div>
@@ -68,6 +110,35 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
onDismiss={() => setGroupUserAlert(undefined)}
/>
)}
<OLForm role="search" onSubmit={e => e.preventDefault()}>
<OLFormGroup>
<OLRow>
<OLCol lg={7}>
<OLFormControl
ref={userSearchRef}
placeholder={t('search_members')}
aria-label={t('search_members')}
prepend={<MaterialIcon type="search" />}
append={
userSearchString.length > 0 && (
<button
type="button"
className="form-control-search-clear-btn"
aria-label={t('clear_search')}
onClick={handleClearUserSearchString}
>
<MaterialIcon type="clear" />
</button>
)
}
value={userSearchString}
onChange={handleUserSearchStringChange}
data-testid="search-members-input"
/>
</OLCol>
</OLRow>
</OLFormGroup>
</OLForm>
<OLTable
className={classNames(
'managed-entities-table',
@@ -111,7 +182,7 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
</tr>
</thead>
<tbody>
{users.length === 0 && (
{filteredUsers.length === 0 && (
<tr>
<td
className="text-center"
@@ -136,6 +207,19 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
))}
</tbody>
</OLTable>
<div className="mt-3">
<div className="text-center">
<p>
<span aria-live="polite" data-testid="x-of-n-users">
{t('showing_x_out_of_n_users', {
x: usersForCurrentPage.length,
n: filteredUsers.length,
})}
</span>
</p>
</div>
</div>
{pagination.totalPages > 1 && (
<div className="d-flex justify-content-center">
<Pagination
+2
View File
@@ -1972,6 +1972,7 @@
"search_in_trashed_projects": "Search in trashed projects",
"search_in_your_projects": "Search in your projects",
"search_match_case": "Match case",
"search_members": "Search members",
"search_next": "next",
"search_only_the_bib_files_in_your_project_only_by_citekeys": "Search only the .bib files in your project, only by citekeys.",
"search_previous": "previous",
@@ -2076,6 +2077,7 @@
"showing_1_result": "Showing 1 result",
"showing_1_result_of_total": "Showing 1 result of __total__",
"showing_x_out_of_n_projects": "Showing __x__ out of __n__ projects.",
"showing_x_out_of_n_users": "Showing __x__ out of __n__ users",
"showing_x_results": "Showing __x__ results",
"showing_x_results_of_total": "Showing __x__ results of __total__",
"sign_up": "Sign up",
@@ -91,8 +91,8 @@ describe('GroupMembers', function () {
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('add-members-input').type('someone.else@test.com')
cy.findByRole('button', { name: /invite/i }).click()
cy.findByTestId('managed-entities-table')
.find('tbody')
@@ -118,8 +118,8 @@ describe('GroupMembers', function () {
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('add-members-input').type('someone.else@test.com')
cy.findByRole('button', { name: /invite/i }).click()
cy.findByRole('alert').contains('Error: User already added')
})
@@ -295,8 +295,8 @@ describe('GroupMembers', function () {
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('add-members-input').type('someone.else@test.com')
cy.findByRole('button', { name: /invite/i }).click()
cy.findByTestId('managed-entities-table')
.find('tbody')
@@ -324,8 +324,8 @@ describe('GroupMembers', function () {
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('add-members-input').type('someone.else@test.com')
cy.findByRole('button', { name: /invite/i }).click()
cy.findByRole('alert').contains('Error: User already added')
})
@@ -130,8 +130,8 @@ describe('group members, with managed users', function () {
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('add-members-input').type('someone.else@test.com')
cy.findByRole('button', { name: /invite/i }).click()
cy.findByTestId('managed-entities-table')
.find('tbody')
@@ -159,8 +159,8 @@ describe('group members, with managed users', function () {
},
})
cy.get('.form-control').type('someone.else@test.com')
cy.get('.add-more-members-form button').click()
cy.findByTestId('add-members-input').type('someone.else@test.com')
cy.findByRole('button', { name: /invite/i }).click()
cy.findByRole('alert').contains('Error: User already added')
})
@@ -131,6 +131,32 @@ describe('MembersList', function () {
mountManagedUsersList()
cy.findByRole('navigation', { name: /pagination navigation/i })
})
it('should show the user count', function () {
cy.findByTestId('x-of-n-users').should(
'contain.text',
'Showing 2 out of 2 users'
)
})
it('should filter users based on case-insensitive search string', function () {
cy.window().then(win => {
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 () {
+4 -1
View File
@@ -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"]
},
+42
View File
@@ -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<string, 'NonEmptyString'>
/**
* 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
}
+1
View File
@@ -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'],