소스 검색

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

add user filter to group member list

GitOrigin-RevId: 0a65ca377979de706e2e43ee024d2cee9261a121
Simon Gardner 1 년 전
부모
커밋
203e2e914b

+ 2 - 0
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": "",

+ 1 - 0
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"
                       />
                     </OLCol>
                     <OLCol xs={4}>

+ 89 - 5
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<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 - 0
services/web/locales/en.json

@@ -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
services/web/test/frontend/features/group-management/components/group-members.spec.tsx

@@ -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
services/web/test/frontend/features/group-management/components/managed-group-members.spec.tsx

@@ -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 - 0
services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx

@@ -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
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"]
   },

+ 42 - 0
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<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 - 0
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'],