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:
@@ -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}>
|
||||
|
||||
+89
-5
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+8
-8
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
+4
-4
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
+26
@@ -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 () {
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user