Просмотр исходного кода

Merge pull request #23743 from overleaf/ii-bs5-manage-group-members

[web] BS5 Group members management

GitOrigin-RevId: fab24ee6f6de07aa64887e123df930593fcec6a2
ilkin-overleaf 1 год назад
Родитель
Сommit
0783bf1dc7
27 измененных файлов с 1318 добавлено и 827 удалено
  1. 2 0
      services/web/app/src/Features/UserMembership/UserMembershipController.mjs
  2. 4 0
      services/web/app/views/user_membership/group-members-react.pug
  3. 34 0
      services/web/frontend/js/features/group-management/components/back-button.tsx
  4. 6 6
      services/web/frontend/js/features/group-management/components/error-alert.tsx
  5. 72 57
      services/web/frontend/js/features/group-management/components/group-members.tsx
  6. 117 47
      services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx
  7. 135 135
      services/web/frontend/js/features/group-management/components/members-table/list-alert.tsx
  8. 58 35
      services/web/frontend/js/features/group-management/components/members-table/member-row.tsx
  9. 57 68
      services/web/frontend/js/features/group-management/components/members-table/members-list.tsx
  10. 53 55
      services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx
  11. 24 13
      services/web/frontend/js/features/group-management/components/members-table/select-all-checkbox.tsx
  12. 25 14
      services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx
  13. 21 16
      services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx
  14. 19 0
      services/web/frontend/js/features/ui/components/ol/ol-card.tsx
  15. 3 0
      services/web/frontend/js/features/ui/components/ol/ol-table.tsx
  16. 5 1
      services/web/frontend/js/shared/components/notification-scrolled-to.tsx
  17. 19 0
      services/web/frontend/stylesheets/app/subscription.less
  18. 1 0
      services/web/frontend/stylesheets/bootstrap-5/components/all.scss
  19. 161 0
      services/web/frontend/stylesheets/bootstrap-5/components/group-members.scss
  20. 8 0
      services/web/frontend/stylesheets/bootstrap-5/components/table.scss
  21. 0 6
      services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss
  22. 2 1
      services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss
  23. 53 44
      services/web/frontend/stylesheets/components/group-members.less
  24. 173 139
      services/web/test/frontend/features/group-management/components/group-members.spec.tsx
  25. 108 88
      services/web/test/frontend/features/group-management/components/managed-group-members.spec.tsx
  26. 37 31
      services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx
  27. 121 71
      services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx

+ 2 - 0
services/web/app/src/Features/UserMembership/UserMembershipController.mjs

@@ -38,6 +38,8 @@ async function manageGroupMembers(req, res, next) {
     'flexible-group-licensing'
   )
 
+  await SplitTestHandler.promises.getAssignment(req, res, 'bootstrap-5-groups')
+
   const plan = PlansLocator.findLocalPlanInSettings(subscription.planCode)
   const userId = SessionManager.getLoggedInUserId(req.session)
   const isAdmin = subscription.admin_id.toString() === userId

+ 4 - 0
services/web/app/views/user_membership/group-members-react.pug

@@ -2,6 +2,10 @@ extends ../layout-marketing
 
 block entrypointVar
 	- entrypoint = 'pages/user/subscription/group-management/group-members'
+		
+block vars
+	- bootstrap5PageStatus = 'enabled' // One of 'disabled', 'enabled', and 'queryStringOnly'
+	- bootstrap5PageSplitTest = 'bootstrap-5-groups'
 
 block append meta
 	meta(name="ol-users", data-type="json", content=users)

+ 34 - 0
services/web/frontend/js/features/group-management/components/back-button.tsx

@@ -0,0 +1,34 @@
+import MaterialIcon from '@/shared/components/material-icon'
+import IconButton from '@/features/ui/components/bootstrap-5/icon-button'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+
+type BackButtonProps = {
+  href: string
+  accessibilityLabel: string
+}
+
+function BackButton({ href, accessibilityLabel }: BackButtonProps) {
+  return (
+    <BootstrapVersionSwitcher
+      bs3={
+        <a href={href} className="back-btn">
+          <MaterialIcon
+            type="arrow_back"
+            accessibilityLabel={accessibilityLabel}
+          />
+        </a>
+      }
+      bs5={
+        <IconButton
+          variant="ghost"
+          href={href}
+          size="lg"
+          icon="arrow_back"
+          accessibilityLabel={accessibilityLabel}
+        />
+      }
+    />
+  )
+}
+
+export default BackButton

+ 6 - 6
services/web/frontend/js/features/group-management/components/error-alert.tsx

@@ -1,4 +1,5 @@
 import { useTranslation } from 'react-i18next'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 export type APIError = {
   message?: string
@@ -17,15 +18,14 @@ export default function ErrorAlert({ error }: ErrorAlertProps) {
 
   if (error.message) {
     return (
-      <div className="alert alert-danger">
-        {t('error')}: {error.message}
-      </div>
+      <OLNotification
+        type="error"
+        content={`${t('error')}: ${error.message}`}
+      />
     )
   }
 
   return (
-    <div className="alert alert-danger">
-      {t('generic_something_went_wrong')}
-    </div>
+    <OLNotification type="error" content={t('generic_something_went_wrong')} />
   )
 }

+ 72 - 57
services/web/frontend/js/features/group-management/components/group-members.tsx

@@ -1,7 +1,5 @@
 import React, { useCallback, useState } from 'react'
-import { Button, Col, Form, FormControl, Row } from 'react-bootstrap'
 import { Trans, useTranslation } from 'react-i18next'
-import MaterialIcon from '../../../shared/components/material-icon'
 import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
 import getMeta from '../../../utils/meta'
 import { useGroupMembersContext } from '../context/group-members-context'
@@ -9,6 +7,13 @@ import ErrorAlert from './error-alert'
 import MembersList from './members-table/members-list'
 import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { sendMB } from '../../../infrastructure/event-tracking'
+import BackButton from '@/features/group-management/components/back-button'
+import OLRow from '@/features/ui/components/ol/ol-row'
+import OLCol from '@/features/ui/components/ol/ol-col'
+import OLCard from '@/features/ui/components/ol/ol-card'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLFormText from '@/features/ui/components/ol/ol-form-text'
 
 export default function GroupMembers() {
   const { isReady } = useWaitForI18n()
@@ -48,7 +53,7 @@ export default function GroupMembers() {
     return null
   }
 
-  const onAddMembersSubmit = (e: React.FormEvent<Form>) => {
+  const onAddMembersSubmit = (e: React.FormEvent) => {
     e.preventDefault()
     addMembers(emailString)
   }
@@ -98,36 +103,37 @@ export default function GroupMembers() {
 
   return (
     <div className="container">
-      <Row>
-        <Col md={10} mdOffset={1}>
-          <h1>
-            <a href="/user/subscription" className="back-btn">
-              <MaterialIcon
-                type="arrow_back"
-                accessibilityLabel={t('back_to_subscription')}
-              />
-            </a>{' '}
-            {groupName || t('group_subscription')}
-          </h1>
-          <div className="card">
-            <div className="page-header">
+      <OLRow>
+        <OLCol lg={{ span: 10, offset: 1 }}>
+          <div className="group-heading" data-testid="group-heading">
+            <BackButton
+              href="/user/subscription"
+              accessibilityLabel={t('back_to_subscription')}
+            />
+            <h1 className="heading">{groupName || t('group_subscription')}</h1>
+          </div>
+          <OLCard>
+            <div
+              className="page-header mb-4"
+              data-testid="page-header-members-details"
+            >
               <div className="pull-right">
                 {selectedUsers.length === 0 && groupSizeDetails()}
                 {removeMemberLoading ? (
-                  <Button bsStyle="danger" disabled>
+                  <OLButton variant="danger" disabled>
                     {t('removing')}&hellip;
-                  </Button>
+                  </OLButton>
                 ) : (
                   <>
                     {selectedUsers.length > 0 && (
-                      <Button bsStyle="danger" onClick={removeMembers}>
+                      <OLButton variant="danger" onClick={removeMembers}>
                         {t('remove_from_group')}
-                      </Button>
+                      </OLButton>
                     )}
                   </>
                 )}
               </div>
-              <h3>{t('members_management')}</h3>
+              <h2 className="h3 mt-0">{t('members_management')}</h2>
             </div>
             <div className="row-spaced-small">
               <ErrorAlert error={removeMemberError} />
@@ -145,58 +151,67 @@ export default function GroupMembers() {
                     : t('add_more_members')}
                 </p>
                 <ErrorAlert error={inviteError} />
-                <Form horizontal onSubmit={onAddMembersSubmit} className="form">
-                  <Row>
-                    <Col xs={6}>
-                      <FormControl
+                <form onSubmit={onAddMembersSubmit}>
+                  <OLRow>
+                    <OLCol xs={6}>
+                      <OLFormControl
                         type="input"
                         placeholder="jane@example.com, joe@example.com"
                         aria-describedby="add-members-description"
                         value={emailString}
                         onChange={handleEmailsChange}
                       />
-                    </Col>
-                    <Col xs={4}>
-                      {inviteMemberLoading ? (
-                        <Button bsStyle="primary" disabled>
-                          {isFlexibleGroupLicensing
-                            ? t('inviting')
-                            : t('adding')}
-                          &hellip;
-                        </Button>
-                      ) : (
-                        <Button bsStyle="primary" onClick={onAddMembersSubmit}>
-                          {isFlexibleGroupLicensing ? t('invite') : t('add')}
-                        </Button>
-                      )}
-                    </Col>
-                    <Col xs={2}>
+                    </OLCol>
+                    <OLCol xs={4}>
+                      <OLButton
+                        variant="primary"
+                        onClick={onAddMembersSubmit}
+                        isLoading={inviteMemberLoading}
+                        bs3Props={{
+                          loading: inviteMemberLoading ? (
+                            <>
+                              {isFlexibleGroupLicensing
+                                ? t('inviting')
+                                : t('adding')}
+                              &hellip;
+                            </>
+                          ) : isFlexibleGroupLicensing ? (
+                            t('invite')
+                          ) : (
+                            t('add')
+                          ),
+                        }}
+                      >
+                        {isFlexibleGroupLicensing ? t('invite') : t('add')}
+                      </OLButton>
+                    </OLCol>
+                    <OLCol xs={2}>
                       <a href={paths.exportMembers}>{t('export_csv')}</a>
-                    </Col>
-                  </Row>
-                  <Row>
-                    <Col xs={8}>
-                      <span className="help-block">
+                    </OLCol>
+                  </OLRow>
+                  <OLRow>
+                    <OLCol xs={8}>
+                      <OLFormText bs3Props={{ className: 'help-block' }}>
                         {t('add_comma_separated_emails_help')}
-                      </span>
-                    </Col>
-                  </Row>
-                </Form>
+                      </OLFormText>
+                    </OLCol>
+                  </OLRow>
+                </form>
               </div>
             )}
             {users.length >= groupSize && users.length > 0 && (
               <>
                 <ErrorAlert error={inviteError} />
-                <Row>
-                  <Col xs={2} xsOffset={10}>
+                <OLRow>
+                  <OLCol xs={{ span: 2, offset: 10 }}>
                     <a href={paths.exportMembers}>{t('export_csv')}</a>
-                  </Col>
-                </Row>
+                  </OLCol>
+                </OLRow>
               </>
             )}
-          </div>
-        </Col>
-      </Row>
+          </OLCard>
+        </OLCol>
+      </OLRow>
     </div>
   )
 }

+ 117 - 47
services/web/frontend/js/features/group-management/components/members-table/dropdown-button.tsx

@@ -6,7 +6,13 @@ import {
   type SetStateAction,
 } from 'react'
 import { useTranslation } from 'react-i18next'
-import { Dropdown, MenuItem } from 'react-bootstrap'
+import { Dropdown as BS3Dropdown, MenuItem } from 'react-bootstrap'
+import {
+  Dropdown,
+  DropdownItem,
+  DropdownMenu,
+  DropdownToggle,
+} from '@/features/ui/components/bootstrap-5/dropdown-menu'
 import { User } from '../../../../../../types/group-management/user'
 import useAsync from '@/shared/hooks/use-async'
 import { type FetchError, postJSON } from '@/infrastructure/fetch-json'
@@ -14,6 +20,10 @@ import Icon from '@/shared/components/icon'
 import { GroupUserAlert } from '../../utils/types'
 import { useGroupMembersContext } from '../../context/group-members-context'
 import getMeta from '@/utils/meta'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import MaterialIcon from '@/shared/components/material-icon'
+import DropdownListItem from '@/features/ui/components/bootstrap-5/dropdown-list-item'
+import { Spinner } from 'react-bootstrap-5'
 
 type resendInviteResponse = {
   success: boolean
@@ -191,12 +201,10 @@ export default function DropdownButton({
       <MenuItemButton
         onClick={onResendGroupInviteClick}
         key="resend-group-invite-action"
+        isLoading={isResendingGroupInvite}
         data-testid="resend-group-invite-action"
       >
         {t('resend_group_invite')}
-        {isResendingGroupInvite ? (
-          <Icon type="spinner" spin style={{ marginLeft: '5px' }} />
-        ) : null}
       </MenuItemButton>
     )
   }
@@ -205,12 +213,10 @@ export default function DropdownButton({
       <MenuItemButton
         onClick={onResendManagedUserInviteClick}
         key="resend-managed-user-invite-action"
+        isLoading={isResendingManagedUserInvite}
         data-testid="resend-managed-user-invite-action"
       >
         {t('resend_managed_user_invite')}
-        {isResendingManagedUserInvite ? (
-          <Icon type="spinner" spin style={{ marginLeft: '5px' }} />
-        ) : null}
       </MenuItemButton>
     )
   }
@@ -230,12 +236,10 @@ export default function DropdownButton({
       <MenuItemButton
         onClick={onResendSSOLinkInviteClick}
         key="resend-sso-link-invite-action"
+        isLoading={isResendingSSOLinkInvite}
         data-testid="resend-sso-link-invite-action"
       >
         {t('resend_link_sso')}
-        {isResendingSSOLinkInvite ? (
-          <Icon type="spinner" spin style={{ marginLeft: '5px' }} />
-        ) : null}
       </MenuItemButton>
     )
   }
@@ -257,6 +261,7 @@ export default function DropdownButton({
         data-testid="remove-user-action"
         onClick={onRemoveFromGroup}
         className="delete-user-action"
+        variant="danger"
       >
         {t('remove_from_group')}
       </MenuItemButton>
@@ -265,54 +270,119 @@ export default function DropdownButton({
 
   if (buttons.length === 0) {
     buttons.push(
-      <MenuItem key="no-actions-available" data-testid="no-actions-available">
-        <span className="text-muted">{t('no_actions')}</span>
-      </MenuItem>
+      <BootstrapVersionSwitcher
+        bs3={
+          <MenuItem
+            key="no-actions-available"
+            data-testid="no-actions-available"
+          >
+            <span className="text-muted">{t('no_actions')}</span>
+          </MenuItem>
+        }
+        bs5={
+          <DropdownListItem>
+            <DropdownItem
+              as="button"
+              tabIndex={-1}
+              data-testid="no-actions-available"
+              disabled
+            >
+              {t('no_actions')}
+            </DropdownItem>
+          </DropdownListItem>
+        }
+      />
     )
   }
 
   return (
-    <span className="managed-user-actions">
-      <Dropdown
-        id={`managed-user-dropdown-${user.email}`}
-        open={isOpened}
-        onToggle={open => setIsOpened(open)}
-      >
-        <Dropdown.Toggle
-          bsStyle={null}
-          className="btn btn-link action-btn"
-          noCaret
-        >
-          <i
-            className="fa fa-ellipsis-v"
-            aria-hidden="true"
-            aria-label={t('actions')}
-          />
-        </Dropdown.Toggle>
-        <Dropdown.Menu className="dropdown-menu-right managed-user-dropdown-menu">
-          {buttons}
-        </Dropdown.Menu>
-      </Dropdown>
-    </span>
+    <BootstrapVersionSwitcher
+      bs3={
+        <div className="managed-user-actions">
+          <BS3Dropdown
+            id={`managed-user-dropdown-${user.email}`}
+            open={isOpened}
+            onToggle={open => setIsOpened(open)}
+          >
+            <BS3Dropdown.Toggle
+              bsStyle={null}
+              className="btn btn-link action-btn"
+              noCaret
+            >
+              <Icon type="ellipsis-v" accessibilityLabel={t('actions')} />
+            </BS3Dropdown.Toggle>
+            <BS3Dropdown.Menu className="dropdown-menu-right managed-user-dropdown-menu">
+              {buttons}
+            </BS3Dropdown.Menu>
+          </BS3Dropdown>
+        </div>
+      }
+      bs5={
+        <Dropdown align="end">
+          <DropdownToggle
+            id={`managed-user-dropdown-${user.email}`}
+            bsPrefix="dropdown-table-button-toggle"
+          >
+            <MaterialIcon type="more_vert" accessibilityLabel={t('actions')} />
+          </DropdownToggle>
+          <DropdownMenu flip={false}>{buttons}</DropdownMenu>
+        </Dropdown>
+      }
+    />
   )
 }
 
+type MenuItemButtonProps = {
+  isLoading?: boolean
+  'data-testid'?: string
+} & Pick<ComponentProps<'button'>, 'children' | 'onClick' | 'className'> &
+  Pick<ComponentProps<typeof DropdownItem>, 'variant'>
+
 function MenuItemButton({
   children,
   onClick,
   className,
-  ...buttonProps
-}: ComponentProps<'button'>) {
+  isLoading,
+  variant,
+  'data-testid': dataTestId,
+}: MenuItemButtonProps) {
   return (
-    <li role="presentation" className={className}>
-      <button
-        className="managed-user-menu-item-button"
-        role="menuitem"
-        onClick={onClick}
-        {...buttonProps}
-      >
-        {children}
-      </button>
-    </li>
+    <BootstrapVersionSwitcher
+      bs3={
+        <li role="presentation" className={className}>
+          <button
+            className="managed-user-menu-item-button"
+            role="menuitem"
+            onClick={onClick}
+            data-testid={dataTestId}
+          >
+            {children}
+          </button>
+        </li>
+      }
+      bs5={
+        <DropdownListItem>
+          <DropdownItem
+            as="button"
+            tabIndex={-1}
+            onClick={onClick}
+            leadingIcon={
+              isLoading ? (
+                <Spinner
+                  animation="border"
+                  aria-hidden="true"
+                  size="sm"
+                  role="status"
+                />
+              ) : null
+            }
+            data-testid={dataTestId}
+            variant={variant}
+          >
+            {children}
+          </DropdownItem>
+        </DropdownListItem>
+      }
+    />
   )
 }

+ 135 - 135
services/web/frontend/js/features/group-management/components/members-table/list-alert.tsx

@@ -1,8 +1,7 @@
-import { type PropsWithChildren, useState } from 'react'
-import { Alert, type AlertProps } from 'react-bootstrap'
-import { Trans, useTranslation } from 'react-i18next'
+import { Trans } from 'react-i18next'
 import type { GroupUserAlertVariant } from '../../utils/types'
 import NotificationScrolledTo from '@/shared/components/notification-scrolled-to'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
 
 type GroupUsersListAlertProps = {
   variant: GroupUserAlertVariant
@@ -86,20 +85,25 @@ function ResendManagedUserInviteSuccess({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="success" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="managed_user_invite_has_been_sent_to_email"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
+    <OLNotification
+      type="success"
+      content={
+        <Trans
+          i18nKey="managed_user_invite_has_been_sent_to_email"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }
 
@@ -108,20 +112,25 @@ function ResendSSOLinkInviteSuccess({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="success" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="sso_link_invite_has_been_sent_to_email"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
+    <OLNotification
+      type="success"
+      content={
+        <Trans
+          i18nKey="sso_link_invite_has_been_sent_to_email"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }
 
@@ -130,20 +139,25 @@ function FailedToResendManagedInvite({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="danger" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="failed_to_send_managed_user_invite_to_email"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
+    <OLNotification
+      type="error"
+      content={
+        <Trans
+          i18nKey="failed_to_send_managed_user_invite_to_email"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }
 function FailedToResendSSOLink({
@@ -151,20 +165,25 @@ function FailedToResendSSOLink({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="danger" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="failed_to_send_sso_link_invite_to_email"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
+    <OLNotification
+      type="error"
+      content={
+        <Trans
+          i18nKey="failed_to_send_sso_link_invite_to_email"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }
 
@@ -173,20 +192,25 @@ function ResendGroupInviteSuccess({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="success" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="group_invite_has_been_sent_to_email"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
+    <OLNotification
+      type="success"
+      content={
+        <Trans
+          i18nKey="group_invite_has_been_sent_to_email"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }
 
@@ -195,20 +219,25 @@ function FailedToResendGroupInvite({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="danger" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="failed_to_send_group_invite_to_email"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
+    <OLNotification
+      type="error"
+      content={
+        <Trans
+          i18nKey="failed_to_send_group_invite_to_email"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }
 
@@ -217,53 +246,24 @@ function TooManyRequests({
   userEmail,
 }: GroupUsersListAlertComponentProps) {
   return (
-    <AlertComponent bsStyle="danger" onDismiss={onDismiss}>
-      <Trans
-        i18nKey="an_email_has_already_been_sent_to"
-        values={{
-          email: userEmail,
-        }}
-        shouldUnescape
-        tOptions={{ interpolation: { escapeValue: true } }}
-        components={[
-          // eslint-disable-next-line react/jsx-key
-          <strong />,
-        ]}
-      />
-    </AlertComponent>
-  )
-}
-
-type AlertComponentProps = PropsWithChildren<{
-  bsStyle: AlertProps['bsStyle']
-  onDismiss: AlertProps['onDismiss']
-}>
-
-function AlertComponent({ bsStyle, onDismiss, children }: AlertComponentProps) {
-  const [show, setShow] = useState(true)
-  const { t } = useTranslation()
-
-  const handleDismiss = () => {
-    if (onDismiss) {
-      onDismiss()
-    }
-
-    setShow(false)
-  }
-
-  if (!show) {
-    return null
-  }
-
-  return (
-    <Alert bsStyle={bsStyle} className="managed-users-list-alert">
-      <span>{children}</span>
-      <div className="managed-users-list-alert-close">
-        <button type="button" className="close" onClick={handleDismiss}>
-          <span aria-hidden="true">&times;</span>
-          <span className="sr-only">{t('close')}</span>
-        </button>
-      </div>
-    </Alert>
+    <OLNotification
+      type="error"
+      content={
+        <Trans
+          i18nKey="an_email_has_already_been_sent_to"
+          values={{
+            email: userEmail,
+          }}
+          shouldUnescape
+          tOptions={{ interpolation: { escapeValue: true } }}
+          components={[
+            // eslint-disable-next-line react/jsx-key
+            <strong />,
+          ]}
+        />
+      }
+      isDismissible
+      onDismiss={onDismiss}
+    />
   )
 }

+ 58 - 35
services/web/frontend/js/features/group-management/components/members-table/member-row.tsx

@@ -2,14 +2,18 @@ import moment from 'moment'
 import { type Dispatch, type SetStateAction } from 'react'
 import { useTranslation } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
-import Badge from '../../../../shared/components/badge'
-import Tooltip from '../../../../shared/components/tooltip'
 import type { GroupUserAlert } from '../../utils/types'
 import ManagedUserStatus from './managed-user-status'
 import SSOStatus from './sso-status'
 import DropdownButton from './dropdown-button'
 import SelectUserCheckbox from './select-user-checkbox'
 import getMeta from '@/utils/meta'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
+import OLTag from '@/features/ui/components/ol/ol-tag'
+import Icon from '@/shared/components/icon'
+import MaterialIcon from '@/shared/components/material-icon'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import classnames from 'classnames'
 
 type ManagedUserRowProps = {
   user: User
@@ -31,61 +35,80 @@ export default function MemberRow({
   const groupSSOActive = getMeta('ol-groupSSOActive')
 
   return (
-    <tr
-      key={`user-${user.email}`}
-      className={`managed-user-row ${user.invite ? 'text-muted' : ''}`}
-    >
+    <tr className="managed-user-row">
       <SelectUserCheckbox user={user} />
-      <td className="cell-email">
+      <td
+        className={classnames('cell-email', {
+          'text-muted': user.invite,
+        })}
+      >
         <span>
           {user.email}
-          {user.invite ? (
-            <span>
+          {user.invite && (
+            <>
               &nbsp;
-              <Tooltip
-                id={`pending-invite-symbol-${user._id}`}
+              <OLTooltip
+                id={`pending-invite-symbol-${user.email}`}
                 description={t('pending_invite')}
               >
-                <Badge
-                  bsStyle={null}
-                  className="badge-tag-bs3"
-                  aria-label={t('pending_invite')}
-                  data-testid="badge-pending-invite"
-                >
+                <OLTag data-testid="badge-pending-invite">
                   {t('pending_invite')}
-                </Badge>
-              </Tooltip>
-            </span>
-          ) : (
-            ''
+                </OLTag>
+              </OLTooltip>
+            </>
           )}
           {user.isEntityAdmin && (
-            <span>
+            <>
               &nbsp;
-              <Tooltip
-                id={`group-admin-symbol-${user._id}`}
+              <OLTooltip
+                id={`group-admin-symbol-${user.email}`}
                 description={t('group_admin')}
               >
-                <i
-                  className="fa fa-user-circle-o"
-                  aria-hidden="true"
-                  aria-label={t('group_admin')}
-                />
-              </Tooltip>
-            </span>
+                <span data-testid="group-admin-symbol">
+                  <BootstrapVersionSwitcher
+                    bs3={
+                      <Icon
+                        type="user-circle-o"
+                        fw
+                        accessibilityLabel={t('group_admin')}
+                      />
+                    }
+                    bs5={
+                      <MaterialIcon
+                        type="account_circle"
+                        accessibilityLabel={t('group_admin')}
+                        className="align-middle"
+                      />
+                    }
+                  />
+                </span>
+              </OLTooltip>
+            </>
           )}
         </span>
       </td>
-      <td className="cell-name">
+      <td
+        className={classnames('cell-name', {
+          'text-muted': user.invite,
+        })}
+      >
         {user.first_name} {user.last_name}
       </td>
-      <td className="cell-last-active">
+      <td
+        className={classnames('cell-last-active', {
+          'text-muted': user.invite,
+        })}
+      >
         {user.last_active_at
           ? moment(user.last_active_at).format('Do MMM YYYY')
           : 'N/A'}
       </td>
       {groupSSOActive && (
-        <td className="cell-security">
+        <td
+          className={classnames('cell-security', {
+            'text-muted': user.invite,
+          })}
+        >
           <div className="managed-user-security">
             <SSOStatus user={user} />
           </div>

+ 57 - 68
services/web/frontend/js/features/group-management/components/members-table/members-list.tsx

@@ -1,8 +1,6 @@
 import { useState } from 'react'
-import { Col, Row } from 'react-bootstrap'
 import { useTranslation } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
-import Tooltip from '@/shared/components/tooltip'
 import { useGroupMembersContext } from '../../context/group-members-context'
 import type { GroupUserAlert } from '../../utils/types'
 import MemberRow from './member-row'
@@ -12,6 +10,8 @@ import SelectAllCheckbox from './select-all-checkbox'
 import classNames from 'classnames'
 import getMeta from '@/utils/meta'
 import UnlinkUserModal from './unlink-user-modal'
+import OLTable from '@/features/ui/components/ol/ol-table'
+import OLTooltip from '@/features/ui/components/ol/ol-tooltip'
 
 type ManagedUsersListProps = {
   groupId: string
@@ -38,9 +38,9 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
           onDismiss={() => setGroupUserAlert(undefined)}
         />
       )}
-      <ul
+      <OLTable
         className={classNames(
-          'list-unstyled',
+          'managed-users-table',
           'structured-list',
           'managed-users-list',
           {
@@ -48,71 +48,60 @@ export default function MembersList({ groupId }: ManagedUsersListProps) {
             'group-sso-active': groupSSOActive,
           }
         )}
+        container={false}
+        hover
+        data-testid="managed-users-table"
       >
-        <li className="container-fluid">
-          <Row id="managed-users-list-headers">
-            <Col xs={12}>
-              <table className="managed-users-table">
-                <thead>
-                  <tr>
-                    <SelectAllCheckbox />
-                    <td className="cell-email">
-                      <span className="header">{t('email')}</span>
-                    </td>
-                    <td className="cell-name">
-                      <span className="header">{t('name')}</span>
-                    </td>
-                    <td className="cell-last-active">
-                      <Tooltip
-                        id="last-active-tooltip"
-                        description={t('last_active_description')}
-                        overlayProps={{
-                          placement: 'left',
-                        }}
-                      >
-                        <span className="header">
-                          {t('last_active')}
-                          <sup>(?)</sup>
-                        </span>
-                      </Tooltip>
-                    </td>
-                    {groupSSOActive && (
-                      <td className="cell-security">
-                        <span className="header">{t('security')}</span>
-                      </td>
-                    )}
-                    {managedUsersActive && (
-                      <td className="cell-managed">
-                        <span className="header">{t('managed')}</span>
-                      </td>
-                    )}
-                    <td />
-                  </tr>
-                </thead>
-                <tbody>
-                  {users.length === 0 && (
-                    <tr>
-                      <td className="text-center" colSpan={5}>
-                        <small>{t('no_members')}</small>
-                      </td>
-                    </tr>
-                  )}
-                  {users.map((user: any) => (
-                    <MemberRow
-                      key={user.email}
-                      user={user}
-                      openOffboardingModalForUser={setUserToOffboard}
-                      openUnlinkUserModal={setUserToUnlink}
-                      setGroupUserAlert={setGroupUserAlert}
-                      groupId={groupId}
-                    />
-                  ))}
-                </tbody>
-              </table>
-            </Col>
-          </Row>
-        </li>
-      </ul>
+        <thead>
+          <tr>
+            <th className="cell-checkbox">
+              <SelectAllCheckbox />
+            </th>
+            <th className="cell-email">{t('email')}</th>
+            <th className="cell-name">{t('name')}</th>
+            <th className="cell-last-active">
+              <OLTooltip
+                id="last-active-tooltip"
+                description={t('last_active_description')}
+                overlayProps={{
+                  placement: 'left',
+                }}
+              >
+                <span>
+                  {t('last_active')}
+                  <sup>(?)</sup>
+                </span>
+              </OLTooltip>
+            </th>
+            {groupSSOActive && (
+              <th className="cell-security">{t('security')}</th>
+            )}
+            {managedUsersActive && (
+              <th className="cell-managed">{t('managed')}</th>
+            )}
+            <th />
+          </tr>
+        </thead>
+        <tbody>
+          {users.length === 0 && (
+            <tr>
+              <td className="text-center" colSpan={5}>
+                <small>{t('no_members')}</small>
+              </td>
+            </tr>
+          )}
+          {users.map(user => (
+            <MemberRow
+              key={user.email}
+              user={user}
+              openOffboardingModalForUser={setUserToOffboard}
+              openUnlinkUserModal={setUserToUnlink}
+              setGroupUserAlert={setGroupUserAlert}
+              groupId={groupId}
+            />
+          ))}
+        </tbody>
+      </OLTable>
       {userToOffboard && (
         <OffboardManagedUserModal
           user={userToOffboard}

+ 53 - 55
services/web/frontend/js/features/group-management/components/members-table/offboard-managed-user-modal.tsx

@@ -1,14 +1,4 @@
 import { User } from '../../../../../../types/group-management/user'
-import {
-  Alert,
-  Button,
-  ControlLabel,
-  Form,
-  FormControl,
-  FormGroup,
-  Modal,
-} from 'react-bootstrap'
-import AccessibleModal from '@/shared/components/accessible-modal'
 import Icon from '@/shared/components/icon'
 import { useState } from 'react'
 import useAsync from '@/shared/hooks/use-async'
@@ -16,6 +6,18 @@ import { useTranslation } from 'react-i18next'
 import { useLocation } from '@/shared/hooks/use-location'
 import { FetchError, postJSON } from '@/infrastructure/fetch-json'
 import { debugConsole } from '@/utils/debugging'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLFormGroup from '@/features/ui/components/ol/ol-form-group'
+import OLButton from '@/features/ui/components/ol/ol-button'
+import OLNotification from '@/features/ui/components/ol/ol-notification'
+import OLFormControl from '@/features/ui/components/ol/ol-form-control'
+import OLFormLabel from '@/features/ui/components/ol/ol-form-label'
+import OLFormSelect from '@/features/ui/components/ol/ol-form-select'
 
 type OffboardManagedUserModalProps = {
   user: User
@@ -69,12 +71,12 @@ export default function OffboardManagedUserModal({
   }
 
   return (
-    <AccessibleModal id={`delete-user-modal-${user._id}`} show onHide={onClose}>
-      <Form id="delete-user-form" onSubmit={handleDeleteUserSubmit}>
-        <Modal.Header>
-          <Modal.Title>{t('delete_user')}</Modal.Title>
-        </Modal.Header>
-        <Modal.Body>
+    <OLModal id={`delete-user-modal-${user._id}`} show onHide={onClose}>
+      <form id="delete-user-form" onSubmit={handleDeleteUserSubmit}>
+        <OLModalHeader>
+          <OLModalTitle>{t('delete_user')}</OLModalTitle>
+        </OLModalHeader>
+        <OLModalBody>
           <p>
             {t('about_to_delete_user_preamble', {
               userName: userFullName,
@@ -95,21 +97,14 @@ export default function OffboardManagedUserModal({
           </p>
           <strong>{t('transfer_this_users_projects')}</strong>
           <p>{t('transfer_this_users_projects_description')}</p>
-          <FormGroup>
-            <ControlLabel htmlFor="recipient-select-input">
-              {t('select_a_new_owner_for_projects')}
-            </ControlLabel>
-            <FormControl
-              id="recipient-select-input"
-              className="form-control"
-              componentClass="select"
+          <OLFormGroup controlId="recipient-select-input">
+            <OLFormLabel>{t('select_a_new_owner_for_projects')}</OLFormLabel>
+            <OLFormSelect
               aria-label={t('select_user')}
               required
               placeholder={t('choose_from_group_members')}
               value={selectedRecipientId || ''}
-              onChange={(e: React.ChangeEvent<HTMLFormElement & FormControl>) =>
-                setSelectedRecipientId(e.target.value)
-              }
+              onChange={e => setSelectedRecipientId(e.target.value)}
             >
               <option hidden disabled value="">
                 {t('choose_from_group_members')}
@@ -119,47 +114,50 @@ export default function OffboardManagedUserModal({
                   {member.email}
                 </option>
               ))}
-            </FormControl>
-          </FormGroup>
+            </OLFormSelect>
+          </OLFormGroup>
           <p>
             <span>{t('all_projects_will_be_transferred_immediately')}</span>
           </p>
-          <FormGroup>
-            <ControlLabel htmlFor="supplied-email-input">
+          <OLFormGroup controlId="supplied-email-input">
+            <OLFormLabel>
               {t('confirm_delete_user_type_email_address', {
                 userName: userFullName,
               })}
-            </ControlLabel>
-            <FormControl
-              id="supplied-email-input"
+            </OLFormLabel>
+            <OLFormControl
               type="email"
               aria-label={t('email')}
-              onChange={(e: React.ChangeEvent<HTMLFormElement & FormControl>) =>
-                setSuppliedEmail(e.target.value)
-              }
+              onChange={e => setSuppliedEmail(e.target.value)}
             />
-          </FormGroup>
-          {error && <Alert bsStyle="danger">{error}</Alert>}
-        </Modal.Body>
-        <Modal.Footer>
-          <FormGroup>
-            <Button onClick={onClose}>{t('cancel')}</Button>
-            <Button
-              type="submit"
-              bsStyle="danger"
-              disabled={isLoading || isSuccess || !shouldEnableDeleteUserButton}
-            >
-              {isLoading ? (
+          </OLFormGroup>
+          {error && (
+            <OLNotification type="error" content={error} className="mb-0" />
+          )}
+        </OLModalBody>
+        <OLModalFooter>
+          <OLButton variant="secondary" onClick={onClose}>
+            {t('cancel')}
+          </OLButton>
+          <OLButton
+            type="submit"
+            variant="danger"
+            disabled={isLoading || isSuccess || !shouldEnableDeleteUserButton}
+            isLoading={isLoading}
+            bs3Props={{
+              loading: isLoading ? (
                 <>
                   <Icon type="refresh" fw spin /> {t('deleting')}…
                 </>
               ) : (
                 t('delete_user')
-              )}
-            </Button>
-          </FormGroup>
-        </Modal.Footer>
-      </Form>
-    </AccessibleModal>
+              ),
+            }}
+          >
+            {t('delete_user')}
+          </OLButton>
+        </OLModalFooter>
+      </form>
+    </OLModal>
   )
 }

+ 24 - 13
services/web/frontend/js/features/group-management/components/members-table/select-all-checkbox.tsx

@@ -1,6 +1,8 @@
 import React, { useCallback } from 'react'
 import { useTranslation } from 'react-i18next'
 import { useGroupMembersContext } from '../../context/group-members-context'
+import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 export default function SelectAllCheckbox() {
   const { t } = useTranslation()
@@ -28,18 +30,27 @@ export default function SelectAllCheckbox() {
   }
 
   return (
-    <td className="cell-checkbox">
-      <label htmlFor="select-all" className="sr-only">
-        {t('select_all')}
-      </label>
-      <input
-        className="select-all"
-        id="select-all"
-        type="checkbox"
-        autoComplete="off"
-        onChange={handleSelectAllNonManagedClick}
-        checked={selectedUsers.length === nonManagedUsers.length}
-      />
-    </td>
+    <BootstrapVersionSwitcher
+      bs3={
+        <input
+          className="select-all"
+          type="checkbox"
+          autoComplete="off"
+          onChange={handleSelectAllNonManagedClick}
+          checked={selectedUsers.length === nonManagedUsers.length}
+          aria-label={t('select_all')}
+          data-testid="select-all-checkbox"
+        />
+      }
+      bs5={
+        <OLFormCheckbox
+          autoComplete="off"
+          onChange={handleSelectAllNonManagedClick}
+          checked={selectedUsers.length === nonManagedUsers.length}
+          aria-label={t('select_all')}
+          data-testid="select-all-checkbox"
+        />
+      }
+    />
   )
 }

+ 25 - 14
services/web/frontend/js/features/group-management/components/members-table/select-user-checkbox.tsx

@@ -2,6 +2,8 @@ import { useTranslation } from 'react-i18next'
 import type { User } from '../../../../../../types/group-management/user'
 import { useGroupMembersContext } from '../../context/group-members-context'
 import { useCallback } from 'react'
+import OLFormCheckbox from '@/features/ui/components/ol/ol-form-checkbox'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
 
 type ManagedUsersSelectUserCheckboxProps = {
   user: User
@@ -39,21 +41,30 @@ export default function SelectUserCheckbox({
 
   return (
     <td className="cell-checkbox">
-      {/* the next check will hide the `checkbox` but still show the `td` */}
+      {/* the next check will hide the `checkbox` but still show the `th` */}
       {user.enrollment?.managedBy ? null : (
-        <>
-          <label htmlFor={`select-user-${user.email}`} className="sr-only">
-            {t('select_user')}
-          </label>
-          <input
-            className="select-item"
-            id={`select-user-${user.email}`}
-            type="checkbox"
-            autoComplete="off"
-            checked={selected}
-            onChange={e => handleSelectUser(e, user)}
-          />
-        </>
+        <BootstrapVersionSwitcher
+          bs3={
+            <input
+              className="select-item"
+              type="checkbox"
+              autoComplete="off"
+              checked={selected}
+              onChange={e => handleSelectUser(e, user)}
+              aria-label={t('select_user')}
+              data-testid="select-single-checkbox"
+            />
+          }
+          bs5={
+            <OLFormCheckbox
+              autoComplete="off"
+              checked={selected}
+              onChange={e => handleSelectUser(e, user)}
+              aria-label={t('select_user')}
+              data-testid="select-single-checkbox"
+            />
+          }
+        />
       )}
     </td>
   )

+ 21 - 16
services/web/frontend/js/features/group-management/components/members-table/unlink-user-modal.tsx

@@ -1,5 +1,3 @@
-import { Modal } from 'react-bootstrap'
-import AccessibleModal from '@/shared/components/accessible-modal'
 import { useTranslation, Trans } from 'react-i18next'
 import { User } from '../../../../../../types/group-management/user'
 import getMeta from '@/utils/meta'
@@ -10,6 +8,13 @@ import NotificationScrolledTo from '@/shared/components/notification-scrolled-to
 import { debugConsole } from '@/utils/debugging'
 import { GroupUserAlert } from '../../utils/types'
 import { useGroupMembersContext } from '../../context/group-members-context'
+import OLModal, {
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/features/ui/components/ol/ol-modal'
+import OLButton from '@/features/ui/components/ol/ol-button'
 
 export type UnlinkUserModalProps = {
   onClose: () => void
@@ -77,11 +82,11 @@ export default function UnlinkUserModal({
   )
 
   return (
-    <AccessibleModal show onHide={onClose}>
-      <Modal.Header>
-        <Modal.Title>{t('unlink_user')}</Modal.Title>
-      </Modal.Header>
-      <Modal.Body>
+    <OLModal show onHide={onClose}>
+      <OLModalHeader>
+        <OLModalTitle>{t('unlink_user')}</OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody>
         {hasError && (
           <div className="mb-3">
             <NotificationScrolledTo
@@ -101,19 +106,19 @@ export default function UnlinkUserModal({
             tOptions={{ interpolation: { escapeValue: true } }}
           />
         </p>
-      </Modal.Body>
-      <Modal.Footer>
-        <button className="btn btn-secondary" disabled={unlinkInFlight}>
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton variant="secondary" disabled={unlinkInFlight}>
           {t('cancel')}
-        </button>
-        <button
-          className="btn btn-danger"
+        </OLButton>
+        <OLButton
+          variant="danger"
           onClick={e => handleUnlink(e)}
           disabled={unlinkInFlight}
         >
           {t('unlink_user')}
-        </button>
-      </Modal.Footer>
-    </AccessibleModal>
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
   )
 }

+ 19 - 0
services/web/frontend/js/features/ui/components/ol/ol-card.tsx

@@ -0,0 +1,19 @@
+import { Card } from 'react-bootstrap-5'
+import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { FC } from 'react'
+import classNames from 'classnames'
+
+const OLCard: FC<{ className?: string }> = ({ children, className }) => {
+  return (
+    <BootstrapVersionSwitcher
+      bs3={<div className={classNames('card', className)}>{children}</div>}
+      bs5={
+        <Card className={className}>
+          <Card.Body>{children}</Card.Body>
+        </Card>
+      }
+    />
+  )
+}
+
+export default OLCard

+ 3 - 0
services/web/frontend/js/features/ui/components/ol/ol-table.tsx

@@ -1,6 +1,7 @@
 import Table from '@/features/ui/components/bootstrap-5/table'
 import { Table as BS3Table } from 'react-bootstrap'
 import BootstrapVersionSwitcher from '@/features/ui/components/bootstrap-5/bootstrap-version-switcher'
+import { getAriaAndDataProps } from '@/features/utils/bootstrap-5'
 
 type OLFormProps = React.ComponentProps<typeof Table> & {
   bs3Props?: React.ComponentProps<typeof BS3Table>
@@ -11,10 +12,12 @@ function OLTable(props: OLFormProps) {
 
   const bs3FormProps: React.ComponentProps<typeof BS3Table> = {
     bsClass: rest.className,
+    id: rest.id,
     condensed: rest.size === 'sm',
     children: rest.children,
     responsive:
       typeof rest.responsive !== 'string' ? rest.responsive : undefined,
+    ...getAriaAndDataProps(rest),
     ...bs3Props,
   }
 

+ 5 - 1
services/web/frontend/js/shared/components/notification-scrolled-to.tsx

@@ -41,6 +41,10 @@ function NotificationScrolledTo({ ...props }: NotificationProps) {
 
   notificationProps.className = `${notificationProps.className} notification-with-scroll-margin`
 
-  return <Notification {...notificationProps} />
+  return (
+    <div className="notification-list">
+      <Notification {...notificationProps} />
+    </div>
+  )
 }
 export default NotificationScrolledTo

+ 19 - 0
services/web/frontend/stylesheets/app/subscription.less

@@ -361,3 +361,22 @@
     }
   }
 }
+
+.group-heading {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-04);
+  margin-bottom: var(--spacing-06);
+
+  .heading {
+    margin: 0;
+  }
+
+  .back-btn {
+    display: flex;
+    align-items: center;
+    width: 42px;
+    height: 42px;
+    text-decoration: none;
+  }
+}

+ 1 - 0
services/web/frontend/stylesheets/bootstrap-5/components/all.scss

@@ -43,3 +43,4 @@
 @import 'invite';
 @import 'upgrade-prompt';
 @import 'integrations-panel';
+@import 'group-members';

+ 161 - 0
services/web/frontend/stylesheets/bootstrap-5/components/group-members.scss

@@ -0,0 +1,161 @@
+/* Styles for group-subscription members view */
+
+.structured-list.managed-users-list {
+  /* Override scrolling behaviour on structured-list */
+  overflow: initial;
+  overflow-y: initial;
+  overflow-x: initial;
+}
+
+.managed-users-list {
+  vertical-align: middle;
+
+  .security-state-invite-pending {
+    color: var(--content-disabled);
+  }
+
+  .security-state-managed {
+    color: var(--content-positive);
+  }
+
+  .security-state-not-managed {
+    color: var(--content-danger);
+  }
+
+  .managed-user-row {
+    overflow-wrap: break-word;
+  }
+
+  .managed-user-security {
+    display: flex;
+    justify-content: space-between;
+  }
+}
+
+.managed-users-table {
+  width: 100%;
+  table-layout: fixed;
+
+  @include media-breakpoint-up(sm) {
+    .cell-checkbox {
+      width: 5%;
+    }
+
+    .cell-email {
+      width: 30%;
+    }
+
+    &.group-sso-active .cell-email {
+      width: 29%;
+    }
+
+    .cell-name {
+      width: 20%;
+    }
+
+    .cell-last-active {
+      width: 20%;
+    }
+
+    .cell-security {
+      width: 12%;
+    }
+
+    .cell-managed {
+      width: 15%;
+    }
+
+    .cell-dropdown {
+      width: 26%;
+      min-width: 36px;
+      text-align: right;
+    }
+
+    &.managed-users-active {
+      .cell-email {
+        width: 30%;
+      }
+
+      &.group-sso-active {
+        .cell-checkbox {
+          width: 3%;
+        }
+
+        .cell-email {
+          width: 37%;
+        }
+
+        .cell-last-active {
+          width: 16%;
+        }
+
+        .cell-name {
+          width: 18%;
+        }
+      }
+    }
+  }
+
+  @include media-breakpoint-up(xl) {
+    .cell-checkbox {
+      width: 5%;
+    }
+
+    .cell-email {
+      width: 41%;
+    }
+
+    &.group-sso-active .cell-email {
+      width: 41%;
+    }
+
+    .cell-name {
+      width: 20%;
+    }
+
+    .cell-last-active {
+      width: 15%;
+    }
+
+    .cell-security {
+      width: 10%;
+    }
+
+    .cell-managed {
+      width: 13%;
+    }
+
+    .cell-dropdown {
+      width: 19%;
+      min-width: 36px;
+      text-align: right;
+    }
+
+    &.managed-users-active {
+      .cell-email {
+        width: 41%;
+      }
+
+      &.group-sso-active {
+        .cell-checkbox {
+          width: 3%;
+        }
+
+        .cell-email {
+          width: 36%;
+        }
+
+        .cell-name {
+          width: 18%;
+        }
+      }
+    }
+  }
+}
+
+.managed-user-security {
+  .material-symbols {
+    position: relative;
+    top: 4px;
+  }
+}

+ 8 - 0
services/web/frontend/stylesheets/bootstrap-5/components/table.scss

@@ -37,6 +37,14 @@
       }
     }
   }
+
+  .dropdown {
+    .dropdown-table-button-toggle {
+      @include action-button;
+
+      padding: var(--spacing-04);
+    }
+  }
 }
 
 .table-container-bordered {

+ 0 - 6
services/web/frontend/stylesheets/bootstrap-5/pages/project-list.scss

@@ -570,12 +570,6 @@
         right: var(--spacing-04);
         padding: 0 !important;
       }
-
-      .dropdown-table-button-toggle {
-        @include action-button;
-
-        padding: var(--spacing-04);
-      }
     }
   }
 }

+ 2 - 1
services/web/frontend/stylesheets/bootstrap-5/pages/subscription.scss

@@ -296,7 +296,8 @@
   gap: var(--spacing-04);
   margin-bottom: var(--spacing-06);
 
-  h2 {
+  h2,
+  .heading {
     @include heading-lg;
 
     margin: 0;

+ 53 - 44
services/web/frontend/stylesheets/components/group-members.less

@@ -61,19 +61,6 @@
   }
 }
 
-.managed-users-list-alert {
-  display: flex;
-  justify-content: space-between;
-  .managed-users-list-alert-close {
-    padding-left: @padding-sm;
-    text-align: right;
-    width: 10%;
-    @media (min-width: @screen-sm-min) {
-      width: auto;
-    }
-  }
-}
-
 .managed-users-table {
   width: 100%;
   table-layout: fixed;
@@ -106,36 +93,24 @@
   @media (min-width: @screen-xs) {
     .cell-checkbox {
       width: 5%;
-      .managed-users-active.group-sso-active & {
-        width: 3%;
-      }
     }
 
     .cell-email {
       width: 50%;
-      .managed-users-active & {
-        width: 35%;
-      }
-      .group-sso-active & {
+    }
+
+    &.group-sso-active {
+      .cell-email {
         width: 37%;
       }
-      .managed-users-active.group-sso-active & {
-        width: 29%;
-      }
     }
 
     .cell-name {
       width: 20%;
-      .managed-users-active.group-sso-active & {
-        width: 18%;
-      }
     }
 
     .cell-last-active {
       width: 20%;
-      .managed-users-active.group-sso-active & {
-        width: 16%;
-      }
     }
 
     .cell-security {
@@ -150,33 +125,47 @@
       width: 6%;
       min-width: 25px;
     }
+
+    &.managed-users-active {
+      .cell-email {
+        width: 35%;
+      }
+
+      &.group-sso-active {
+        .cell-checkbox {
+          width: 3%;
+        }
+
+        .cell-email {
+          width: 29%;
+        }
+
+        .cell-last-active {
+          width: 16%;
+        }
+
+        .cell-name {
+          width: 18%;
+        }
+      }
+    }
   }
 
   @media (min-width: @screen-lg) {
     .cell-checkbox {
       width: 5%;
-      .managed-users-active.group-sso-active & {
-        width: 3%;
-      }
     }
+
     .cell-email {
       width: 55%;
-      .managed-users-active & {
-        width: 42%;
-      }
-      .group-sso-active & {
-        width: 45%;
-      }
-      .managed-users-active.group-sso-active & {
-        width: 36%;
-      }
+    }
+
+    &.group-sso-active .cell-email {
+      width: 45%;
     }
 
     .cell-name {
       width: 20%;
-      .managed-users-active.group-sso-active & {
-        width: 18%;
-      }
     }
 
     .cell-last-active {
@@ -195,6 +184,26 @@
       width: 5%;
       min-width: 25px;
     }
+
+    &.managed-users-active {
+      .cell-email {
+        width: 42%;
+      }
+
+      &.group-sso-active {
+        .cell-checkbox {
+          width: 3%;
+        }
+
+        .cell-email {
+          width: 36%;
+        }
+
+        .cell-name {
+          width: 18%;
+        }
+      }
+    }
   }
 }
 

+ 173 - 139
services/web/test/frontend/features/group-management/components/group-members.spec.tsx

@@ -59,27 +59,31 @@ describe('GroupMembers', function () {
     })
 
     it('renders the group members page', function () {
-      cy.get('h1').contains('My Awesome Team')
-      cy.get('small').contains('You have added 2 of 10 available members')
-
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.contains('john.doe@test.com')
-          cy.contains('John Doe')
-          cy.contains('15th Jan 2023')
-          cy.findByTestId('badge-pending-invite').should(
-            'have.text',
-            'Pending invite'
-          )
-        })
+      cy.findByRole('heading', { name: /my awesome team/i, level: 1 })
+      cy.findByTestId('page-header-members-details').contains(
+        'You have added 2 of 10 available members'
+      )
 
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.contains('bobby.lapointe@test.com')
-          cy.contains('Bobby Lapointe')
-          cy.contains('2nd Jan 2023')
-          cy.findByTestId('badge-pending-invite').should('not.exist')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.contains('john.doe@test.com')
+            cy.contains('John Doe')
+            cy.contains('15th Jan 2023')
+            cy.findByTestId('badge-pending-invite').should(
+              'have.text',
+              'Pending invite'
+            )
+          })
+
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.contains('bobby.lapointe@test.com')
+            cy.contains('Bobby Lapointe')
+            cy.contains('2nd Jan 2023')
+            cy.findByTestId('badge-pending-invite').should('not.exist')
+          })
         })
-      })
     })
 
     it('sends an invite', function () {
@@ -96,16 +100,18 @@ describe('GroupMembers', function () {
       cy.get('.form-control').type('someone.else@test.com')
       cy.get('.add-more-members-form button').click()
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(3)').within(() => {
-          cy.contains('someone.else@test.com')
-          cy.contains('N/A')
-          cy.findByTestId('badge-pending-invite').should(
-            'have.text',
-            'Pending invite'
-          )
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(3)').within(() => {
+            cy.contains('someone.else@test.com')
+            cy.contains('N/A')
+            cy.findByTestId('badge-pending-invite').should(
+              'have.text',
+              'Pending invite'
+            )
+          })
         })
-      })
     })
 
     it('tries to send an invite and displays the error', function () {
@@ -124,25 +130,29 @@ describe('GroupMembers', function () {
     })
 
     it('checks the select all checkbox', function () {
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').should('not.be.checked')
-        })
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.get('.select-item').should('not.be.checked')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').should('not.be.checked')
+          })
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.get('.select-item').should('not.be.checked')
+          })
         })
-      })
 
       cy.get('.select-all').click()
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').should('be.checked')
-        })
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.get('.select-item').should('be.checked')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').should('be.checked')
+          })
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.get('.select-item').should('be.checked')
+          })
         })
-      })
     })
 
     it('remove a member', function () {
@@ -150,23 +160,27 @@ describe('GroupMembers', function () {
         statusCode: 200,
       })
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').check()
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').check()
+          })
         })
-      })
 
       cy.get('button').contains('Remove from group').click()
 
       cy.get('small').contains('You have added 1 of 10 available members')
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.contains('bobby.lapointe@test.com')
-          cy.contains('Bobby Lapointe')
-          cy.contains('2nd Jan 2023')
-          cy.contains('Pending invite').should('not.exist')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.contains('bobby.lapointe@test.com')
+            cy.contains('Bobby Lapointe')
+            cy.contains('2nd Jan 2023')
+            cy.contains('Pending invite').should('not.exist')
+          })
         })
-      })
     })
 
     it('tries to remove a user and displays the error', function () {
@@ -174,11 +188,13 @@ describe('GroupMembers', function () {
         statusCode: 500,
       })
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').check()
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').check()
+          })
         })
-      })
       cy.get('button').contains('Remove from group').click()
 
       cy.get('.alert').contains('Sorry, something went wrong')
@@ -241,35 +257,37 @@ describe('GroupMembers', function () {
       cy.get('h1').contains('My Awesome Team')
       cy.get('small').contains('You have added 3 of 10 available members')
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.contains('john.doe@test.com')
-          cy.contains('John Doe')
-          cy.contains('15th Jan 2023')
-          cy.get('.sr-only').contains('Pending invite')
-          cy.findByTestId('badge-pending-invite').should(
-            'have.text',
-            'Pending invite'
-          )
-          cy.get(`.security-state-invite-pending`).should('exist')
-        })
-
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.contains('bobby.lapointe@test.com')
-          cy.contains('Bobby Lapointe')
-          cy.contains('2nd Jan 2023')
-          cy.findByTestId('badge-pending-invite').should('not.exist')
-          cy.get('.sr-only').contains('Not managed')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.contains('john.doe@test.com')
+            cy.contains('John Doe')
+            cy.contains('15th Jan 2023')
+            cy.get('.sr-only').contains('Pending invite')
+            cy.findByTestId('badge-pending-invite').should(
+              'have.text',
+              'Pending invite'
+            )
+            cy.get(`.security-state-invite-pending`).should('exist')
+          })
+
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.contains('bobby.lapointe@test.com')
+            cy.contains('Bobby Lapointe')
+            cy.contains('2nd Jan 2023')
+            cy.findByTestId('badge-pending-invite').should('not.exist')
+            cy.get('.sr-only').contains('Not managed')
+          })
+
+          cy.get('tr:nth-child(3)').within(() => {
+            cy.contains('claire.jennings@test.com')
+            cy.contains('Claire Jennings')
+            cy.contains('3rd Jan 2023')
+            cy.findByTestId('badge-pending-invite').should('not.exist')
+            cy.get('.sr-only').contains('Managed')
+          })
         })
-
-        cy.get('tr:nth-child(3)').within(() => {
-          cy.contains('claire.jennings@test.com')
-          cy.contains('Claire Jennings')
-          cy.contains('3rd Jan 2023')
-          cy.findByTestId('badge-pending-invite').should('not.exist')
-          cy.get('.sr-only').contains('Managed')
-        })
-      })
     })
 
     it('sends an invite', function () {
@@ -286,18 +304,20 @@ describe('GroupMembers', function () {
       cy.get('.form-control').type('someone.else@test.com')
       cy.get('.add-more-members-form button').click()
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(4)').within(() => {
-          cy.contains('someone.else@test.com')
-          cy.contains('N/A')
-          cy.get('.sr-only').contains('Pending invite')
-          cy.findByTestId('badge-pending-invite').should(
-            'have.text',
-            'Pending invite'
-          )
-          cy.get(`.security-state-invite-pending`).should('exist')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(4)').within(() => {
+            cy.contains('someone.else@test.com')
+            cy.contains('N/A')
+            cy.get('.sr-only').contains('Pending invite')
+            cy.findByTestId('badge-pending-invite').should(
+              'have.text',
+              'Pending invite'
+            )
+            cy.get(`.security-state-invite-pending`).should('exist')
+          })
         })
-      })
     })
 
     it('tries to send an invite and displays the error', function () {
@@ -316,25 +336,29 @@ describe('GroupMembers', function () {
     })
 
     it('checks the select all checkbox', function () {
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').should('not.be.checked')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').should('not.be.checked')
+          })
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.get('.select-item').should('not.be.checked')
+          })
         })
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.get('.select-item').should('not.be.checked')
-        })
-      })
 
       cy.get('.select-all').click()
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').should('be.checked')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').should('be.checked')
+          })
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.get('.select-item').should('be.checked')
+          })
         })
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.get('.select-item').should('be.checked')
-        })
-      })
 
       cy.get('button').contains('Remove from group').click()
     })
@@ -344,22 +368,26 @@ describe('GroupMembers', function () {
         statusCode: 200,
       })
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').check()
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').check()
+          })
         })
-      })
 
       cy.get('button').contains('Remove from group').click()
 
       cy.get('small').contains('You have added 2 of 10 available members')
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.contains('bobby.lapointe@test.com')
-          cy.contains('Bobby Lapointe')
-          cy.contains('2nd Jan 2023')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.contains('bobby.lapointe@test.com')
+            cy.contains('Bobby Lapointe')
+            cy.contains('2nd Jan 2023')
+          })
         })
-      })
     })
 
     it('cannot remove a managed member', function () {
@@ -367,12 +395,14 @@ describe('GroupMembers', function () {
         statusCode: 200,
       })
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        // no checkbox should be shown for 'Claire Jennings', a managed user
-        cy.get('tr:nth-child(3)').within(() => {
-          cy.get('.select-item').should('not.exist')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          // no checkbox should be shown for 'Claire Jennings', a managed user
+          cy.get('tr:nth-child(3)').within(() => {
+            cy.get('.select-item').should('not.exist')
+          })
         })
-      })
     })
 
     it('tries to remove a user and displays the error', function () {
@@ -380,11 +410,13 @@ describe('GroupMembers', function () {
         statusCode: 500,
       })
 
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(1)').within(() => {
-          cy.get('.select-item').check()
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(1)').within(() => {
+            cy.get('.select-item').check()
+          })
         })
-      })
       cy.get('.page-header').within(() => {
         cy.get('button').contains('Remove from group').click()
       })
@@ -448,17 +480,19 @@ describe('GroupMembers', function () {
     })
 
     it('should display the Security column', function () {
-      cy.get('ul.managed-users-list table > tbody').within(() => {
-        cy.get('tr:nth-child(2)').within(() => {
-          cy.contains('bobby.lapointe@test.com')
-          cy.get('.sr-only').contains('SSO not active')
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.get('tr:nth-child(2)').within(() => {
+            cy.contains('bobby.lapointe@test.com')
+            cy.get('.sr-only').contains('SSO not active')
+          })
+
+          cy.get('tr:nth-child(3)').within(() => {
+            cy.contains('claire.jennings@test.com')
+            cy.get('.sr-only').contains('SSO active')
+          })
         })
-
-        cy.get('tr:nth-child(3)').within(() => {
-          cy.contains('claire.jennings@test.com')
-          cy.get('.sr-only').contains('SSO active')
-        })
-      })
     })
   })
 

+ 108 - 88
services/web/test/frontend/features/group-management/components/managed-group-members.spec.tsx

@@ -86,36 +86,38 @@ describe('group members, with managed users', function () {
     cy.get('h1').contains('My Awesome Team')
     cy.get('small').contains('You have added 3 of 10 available members')
 
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(1)').within(() => {
-        cy.contains('john.doe@test.com')
-        cy.contains('John Doe')
-        cy.contains('15th Jan 2023')
-        cy.get('.sr-only').contains('Pending invite')
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(1)').within(() => {
+          cy.contains('john.doe@test.com')
+          cy.contains('John Doe')
+          cy.contains('15th Jan 2023')
+          cy.get('.sr-only').contains('Pending invite')
 
-        cy.findByTestId('badge-pending-invite').should(
-          'have.text',
-          'Pending invite'
-        )
-        cy.get(`.security-state-invite-pending`).should('exist')
-      })
+          cy.findByTestId('badge-pending-invite').should(
+            'have.text',
+            'Pending invite'
+          )
+          cy.get(`.security-state-invite-pending`).should('exist')
+        })
 
-      cy.get('tr:nth-child(2)').within(() => {
-        cy.contains('bobby.lapointe@test.com')
-        cy.contains('Bobby Lapointe')
-        cy.contains('2nd Jan 2023')
-        cy.findByTestId('badge-pending-invite').should('not.exist')
-        cy.get('.sr-only').contains('Not managed')
-      })
+        cy.get('tr:nth-child(2)').within(() => {
+          cy.contains('bobby.lapointe@test.com')
+          cy.contains('Bobby Lapointe')
+          cy.contains('2nd Jan 2023')
+          cy.findByTestId('badge-pending-invite').should('not.exist')
+          cy.get('.sr-only').contains('Not managed')
+        })
 
-      cy.get('tr:nth-child(3)').within(() => {
-        cy.contains('claire.jennings@test.com')
-        cy.contains('Claire Jennings')
-        cy.contains('3rd Jan 2023')
-        cy.findByTestId('badge-pending-invite').should('not.exist')
-        cy.get('.sr-only').contains('Managed')
+        cy.get('tr:nth-child(3)').within(() => {
+          cy.contains('claire.jennings@test.com')
+          cy.contains('Claire Jennings')
+          cy.contains('3rd Jan 2023')
+          cy.findByTestId('badge-pending-invite').should('not.exist')
+          cy.get('.sr-only').contains('Managed')
+        })
       })
-    })
   })
 
   it('sends an invite', function () {
@@ -132,18 +134,20 @@ 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.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(4)').within(() => {
-        cy.contains('someone.else@test.com')
-        cy.contains('N/A')
-        cy.get('.sr-only').contains('Pending invite')
-        cy.findByTestId('badge-pending-invite').should(
-          'have.text',
-          'Pending invite'
-        )
-        cy.get(`.security-state-invite-pending`).should('exist')
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(4)').within(() => {
+          cy.contains('someone.else@test.com')
+          cy.contains('N/A')
+          cy.get('.sr-only').contains('Pending invite')
+          cy.findByTestId('badge-pending-invite').should(
+            'have.text',
+            'Pending invite'
+          )
+          cy.get(`.security-state-invite-pending`).should('exist')
+        })
       })
-    })
   })
 
   it('tries to send an invite and displays the error', function () {
@@ -162,25 +166,29 @@ describe('group members, with managed users', function () {
   })
 
   it('checks the select all checkbox', function () {
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(1)').within(() => {
-        cy.get('.select-item').should('not.be.checked')
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(1)').within(() => {
+          cy.get('.select-item').should('not.be.checked')
+        })
+        cy.get('tr:nth-child(2)').within(() => {
+          cy.get('.select-item').should('not.be.checked')
+        })
       })
-      cy.get('tr:nth-child(2)').within(() => {
-        cy.get('.select-item').should('not.be.checked')
-      })
-    })
 
     cy.get('.select-all').click()
 
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(1)').within(() => {
-        cy.get('.select-item').should('be.checked')
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(1)').within(() => {
+          cy.get('.select-item').should('be.checked')
+        })
+        cy.get('tr:nth-child(2)').within(() => {
+          cy.get('.select-item').should('be.checked')
+        })
       })
-      cy.get('tr:nth-child(2)').within(() => {
-        cy.get('.select-item').should('be.checked')
-      })
-    })
 
     cy.get('button').contains('Remove from group').click()
   })
@@ -190,22 +198,26 @@ describe('group members, with managed users', function () {
       statusCode: 200,
     })
 
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(1)').within(() => {
-        cy.get('.select-item').check()
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(1)').within(() => {
+          cy.get('.select-item').check()
+        })
       })
-    })
 
     cy.get('button').contains('Remove from group').click()
 
     cy.get('small').contains('You have added 2 of 10 available members')
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(1)').within(() => {
-        cy.contains('bobby.lapointe@test.com')
-        cy.contains('Bobby Lapointe')
-        cy.contains('2nd Jan 2023')
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(1)').within(() => {
+          cy.contains('bobby.lapointe@test.com')
+          cy.contains('Bobby Lapointe')
+          cy.contains('2nd Jan 2023')
+        })
       })
-    })
   })
 
   it('cannot remove a managed member', function () {
@@ -213,12 +225,14 @@ describe('group members, with managed users', function () {
       statusCode: 200,
     })
 
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      // no checkbox should be shown for 'Claire Jennings', a managed user
-      cy.get('tr:nth-child(3)').within(() => {
-        cy.get('.select-item').should('not.exist')
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        // no checkbox should be shown for 'Claire Jennings', a managed user
+        cy.get('tr:nth-child(3)').within(() => {
+          cy.get('.select-item').should('not.exist')
+        })
       })
-    })
   })
 
   it('tries to remove a user and displays the error', function () {
@@ -226,11 +240,13 @@ describe('group members, with managed users', function () {
       statusCode: 500,
     })
 
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(1)').within(() => {
-        cy.get('.select-item').check()
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(1)').within(() => {
+          cy.get('.select-item').check()
+        })
       })
-    })
     cy.get('.page-header').within(() => {
       cy.get('button').contains('Remove from group').click()
     })
@@ -259,17 +275,19 @@ describe('Group members when group SSO is enabled', function () {
       win.metaAttributesCache.set('ol-groupSSOActive', false)
     })
     mountGroupMembersProvider()
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(2)').within(() => {
-        cy.contains('bobby.lapointe@test.com')
-        cy.get('.sr-only').contains('SSO not active').should('not.exist')
-      })
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(2)').within(() => {
+          cy.contains('bobby.lapointe@test.com')
+          cy.get('.sr-only').contains('SSO not active').should('not.exist')
+        })
 
-      cy.get('tr:nth-child(3)').within(() => {
-        cy.contains('claire.jennings@test.com')
-        cy.get('.sr-only').contains('SSO active').should('not.exist')
+        cy.get('tr:nth-child(3)').within(() => {
+          cy.contains('claire.jennings@test.com')
+          cy.get('.sr-only').contains('SSO active').should('not.exist')
+        })
       })
-    })
   })
 
   it('should display SSO Column when Group SSO is enabled', function () {
@@ -277,16 +295,18 @@ describe('Group members when group SSO is enabled', function () {
       win.metaAttributesCache.set('ol-groupSSOActive', true)
     })
     mountGroupMembersProvider()
-    cy.get('ul.managed-users-list table > tbody').within(() => {
-      cy.get('tr:nth-child(2)').within(() => {
-        cy.contains('bobby.lapointe@test.com')
-        cy.get('.sr-only').contains('SSO not active')
-      })
+    cy.findByTestId('managed-users-table')
+      .find('tbody')
+      .within(() => {
+        cy.get('tr:nth-child(2)').within(() => {
+          cy.contains('bobby.lapointe@test.com')
+          cy.get('.sr-only').contains('SSO not active')
+        })
 
-      cy.get('tr:nth-child(3)').within(() => {
-        cy.contains('claire.jennings@test.com')
-        cy.get('.sr-only').contains('SSO active')
+        cy.get('tr:nth-child(3)').within(() => {
+          cy.contains('claire.jennings@test.com')
+          cy.get('.sr-only').contains('SSO active')
+        })
       })
-    })
   })
 })

+ 37 - 31
services/web/test/frontend/features/group-management/components/members-table/member-row.spec.tsx

@@ -40,9 +40,9 @@ describe('MemberRow', function () {
       })
 
       it('renders the row', function () {
-        cy.get('tr').should('exist')
+        cy.get('tr')
         // Checkbox
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
         // Email
         cy.get('tr').contains(user.email)
         // Name
@@ -131,7 +131,9 @@ describe('MemberRow', function () {
       })
 
       it('should render a "Group admin" symbol', function () {
-        cy.get('[aria-label="Group admin"].fa-user-circle-o').should('exist')
+        cy.findByTestId('group-admin-symbol').within(() => {
+          cy.findByText(/group admin/i)
+        })
       })
     })
 
@@ -167,11 +169,11 @@ describe('MemberRow', function () {
       })
 
       it('should select and unselect the user', function () {
-        cy.get('.select-item').should('not.be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
       })
     })
   })
@@ -217,7 +219,7 @@ describe('MemberRow', function () {
       it('renders the row', function () {
         cy.get('tr').should('exist')
         // Checkbox
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
         // Email
         cy.get('tr').contains(user.email)
         // Name
@@ -305,7 +307,9 @@ describe('MemberRow', function () {
       })
 
       it('should render a "Group admin" symbol', function () {
-        cy.get('[aria-label="Group admin"].fa-user-circle-o').should('exist')
+        cy.findByTestId('group-admin-symbol').within(() => {
+          cy.findByText(/group admin/i)
+        })
       })
     })
 
@@ -341,11 +345,11 @@ describe('MemberRow', function () {
       })
 
       it('should select and unselect the user', function () {
-        cy.get('.select-item').should('not.be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
       })
     })
   })
@@ -389,9 +393,8 @@ describe('MemberRow', function () {
       })
 
       it('renders the row', function () {
-        cy.get('tr').should('exist')
         // Checkbox
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
         // Email
         cy.get('tr').contains(user.email)
         // Name
@@ -481,7 +484,9 @@ describe('MemberRow', function () {
       })
 
       it('should render a "Group admin" symbol', function () {
-        cy.get('[aria-label="Group admin"].fa-user-circle-o').should('exist')
+        cy.findByTestId('group-admin-symbol').within(() => {
+          cy.findByText(/group admin/i)
+        })
       })
     })
 
@@ -517,11 +522,11 @@ describe('MemberRow', function () {
       })
 
       it('should select and unselect the user', function () {
-        cy.get('.select-item').should('not.be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
       })
     })
   })
@@ -566,9 +571,8 @@ describe('MemberRow', function () {
       })
 
       it('renders the row', function () {
-        cy.get('tr').should('exist')
         // Checkbox
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
         // Email
         cy.get('tr').contains(user.email)
         // Name
@@ -658,7 +662,9 @@ describe('MemberRow', function () {
       })
 
       it('should render a "Group admin" symbol', function () {
-        cy.get('[aria-label="Group admin"].fa-user-circle-o').should('exist')
+        cy.findByTestId('group-admin-symbol').within(() => {
+          cy.findByText(/group admin/i)
+        })
       })
     })
 
@@ -694,11 +700,11 @@ describe('MemberRow', function () {
       })
 
       it('should select and unselect the user', function () {
-        cy.get('.select-item').should('not.be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('be.checked')
-        cy.get('.select-item').click()
-        cy.get('.select-item').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('be.checked')
+        cy.findByTestId('select-single-checkbox').click()
+        cy.findByTestId('select-single-checkbox').should('not.be.checked')
       })
     })
   })

+ 121 - 71
services/web/test/frontend/features/group-management/components/members-table/members-list.spec.tsx

@@ -50,46 +50,74 @@ describe('MembersList', function () {
         win.metaAttributesCache.set('ol-groupSSOActive', false)
       })
       mountManagedUsersList()
-      cy.get('#managed-users-list-headers').should('exist')
 
       // Select-all checkbox
-      cy.get('#managed-users-list-headers .select-all').should('exist')
-
-      cy.get('#managed-users-list-headers').contains('Email')
-      cy.get('#managed-users-list-headers').contains('Name')
-      cy.get('#managed-users-list-headers').contains('Last Active')
-      cy.get('#managed-users-list-headers')
-        .contains('Security')
-        .should('not.exist')
+      cy.findByTestId('managed-users-table').within(() => {
+        cy.findByTestId('select-all-checkbox')
+      })
+      cy.findByTestId('managed-users-table').should('contain.text', 'Email')
+      cy.findByTestId('managed-users-table').should('contain.text', 'Name')
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        'Last Active'
+      )
+      cy.findByTestId('managed-users-table').should(
+        'not.contain.text',
+        'Security'
+      )
     })
     it('should render the table headers with SSO Column', function () {
       cy.window().then(win => {
         win.metaAttributesCache.set('ol-groupSSOActive', true)
       })
       mountManagedUsersList()
-      cy.get('#managed-users-list-headers').should('exist')
 
       // Select-all checkbox
-      cy.get('#managed-users-list-headers .select-all').should('exist')
+      cy.findByTestId('managed-users-table').within(() => {
+        cy.findByTestId('select-all-checkbox')
+      })
 
-      cy.get('#managed-users-list-headers').contains('Email')
-      cy.get('#managed-users-list-headers').contains('Name')
-      cy.get('#managed-users-list-headers').contains('Last Active')
-      cy.get('#managed-users-list-headers').contains('Security')
+      cy.findByTestId('managed-users-table').should('contain.text', 'Email')
+      cy.findByTestId('managed-users-table').should('contain.text', 'Name')
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        'Last Active'
+      )
+      cy.findByTestId('managed-users-table').should('contain.text', 'Security')
     })
 
     it('should render the list of users', function () {
-      cy.get('.managed-users-list')
-        .find('.managed-user-row')
-        .should('have.length', 2)
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.findAllByRole('row').should('have.length', 2)
+        })
       // First user
-      cy.get('.managed-users-list').contains(users[0].email)
-      cy.get('.managed-users-list').contains(users[0].first_name)
-      cy.get('.managed-users-list').contains(users[0].last_name)
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        users[0].email
+      )
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        users[0].first_name
+      )
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        users[0].last_name
+      )
       // Second user
-      cy.get('.managed-users-list').contains(users[1].email)
-      cy.get('.managed-users-list').contains(users[1].first_name)
-      cy.get('.managed-users-list').contains(users[1].last_name)
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        users[1].email
+      )
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        users[1].first_name
+      )
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        users[1].last_name
+      )
     })
   })
 
@@ -106,10 +134,17 @@ describe('MembersList', function () {
     })
 
     it('should render the list, with a "no members" message', function () {
-      cy.get('.managed-users-list').contains('No members')
-      cy.get('.managed-users-list')
-        .find('.managed-user-row')
-        .should('have.length', 0)
+      cy.findByTestId('managed-users-table').should(
+        'contain.text',
+        'No members'
+      )
+      cy.findByTestId('managed-users-table')
+        .find('tbody')
+        .within(() => {
+          cy.findAllByRole('row')
+            .should('have.length', 1)
+            .and('contain.text', 'No members')
+        })
     })
   })
 
@@ -188,27 +223,32 @@ describe('MembersList', function () {
     describe('unlinking user', function () {
       beforeEach(function () {
         mountManagedUsersList()
-        cy.get('ul.managed-users-list table > tbody').within(() => {
-          cy.get('tr:nth-child(3)').within(() => {
-            cy.get('.sr-only').contains('SSO active')
-            cy.get('.action-btn').click()
-            cy.findByTestId('unlink-user-action').click()
+        cy.findByTestId('managed-users-table')
+          .find('tbody')
+          .within(() => {
+            cy.get('tr:nth-child(3)').within(() => {
+              cy.findByText('SSO active')
+              cy.findByRole('button', { name: /actions/i }).click()
+              cy.findByTestId('unlink-user-action').click()
+            })
           })
-        })
       })
 
       it('should show successs notification and update the user row after unlinking', function () {
-        cy.get('.modal').within(() => {
-          cy.get('.btn-danger').click()
+        cy.findByRole('dialog').within(() => {
+          cy.findByRole('button', { name: /unlink user/i }).click()
         })
-        cy.get('.notification').contains(
+        cy.findByRole('alert').should(
+          'contain.text',
           `SSO reauthentication request has been sent to ${USER_LINKED.email}`
         )
-        cy.get('ul.managed-users-list table > tbody').within(() => {
-          cy.get('tr:nth-child(3)').within(() => {
-            cy.get('.sr-only').contains('SSO not active')
+        cy.findByTestId('managed-users-table')
+          .find('tbody')
+          .within(() => {
+            cy.get('tr:nth-child(3)').within(() => {
+              cy.findByText('SSO not active')
+            })
           })
-        })
       })
     })
 
@@ -222,57 +262,67 @@ describe('MembersList', function () {
 
       describe('when user is not managed', function () {
         beforeEach(function () {
-          cy.get('ul.managed-users-list table > tbody').within(() => {
-            cy.get('tr:nth-child(3)').within(() => {
-              cy.get('.sr-only').contains('SSO active')
-              cy.get('.sr-only').contains('Not managed')
-              cy.get('.action-btn').click()
-              cy.findByTestId('unlink-user-action').click()
+          cy.findByTestId('managed-users-table')
+            .find('tbody')
+            .within(() => {
+              cy.get('tr:nth-child(3)').within(() => {
+                cy.findByText('SSO active')
+                cy.findByText('Not managed')
+                cy.findByRole('button', { name: /actions/i }).click()
+                cy.findByTestId('unlink-user-action').click()
+              })
             })
-          })
         })
 
         it('should show successs notification and update the user row after unlinking', function () {
-          cy.get('.modal').within(() => {
-            cy.get('.btn-danger').click()
+          cy.findByRole('dialog').within(() => {
+            cy.findByRole('button', { name: /unlink user/i }).click()
           })
-          cy.get('.notification').contains(
+          cy.findByRole('alert').should(
+            'contain.text',
             `SSO reauthentication request has been sent to ${USER_LINKED.email}`
           )
-          cy.get('ul.managed-users-list table > tbody').within(() => {
-            cy.get('tr:nth-child(3)').within(() => {
-              cy.get('.sr-only').contains('SSO not active')
-              cy.get('.sr-only').contains('Not managed')
+          cy.findByTestId('managed-users-table')
+            .find('tbody')
+            .within(() => {
+              cy.get('tr:nth-child(3)').within(() => {
+                cy.findByText('SSO not active')
+                cy.findByText('Not managed')
+              })
             })
-          })
         })
       })
 
       describe('when user is managed', function () {
         beforeEach(function () {
-          cy.get('ul.managed-users-list table > tbody').within(() => {
-            cy.get('tr:nth-child(4)').within(() => {
-              cy.get('.sr-only').contains('SSO active')
-              cy.get('.sr-only').contains('Managed')
-              cy.get('.action-btn').click()
-              cy.findByTestId('unlink-user-action').click()
+          cy.findByTestId('managed-users-table')
+            .find('tbody')
+            .within(() => {
+              cy.get('tr:nth-child(4)').within(() => {
+                cy.findByText('SSO active')
+                cy.findAllByText('Managed')
+                cy.findByRole('button', { name: /actions/i }).click()
+                cy.findByTestId('unlink-user-action').click()
+              })
             })
-          })
         })
 
         it('should show successs notification and update the user row after unlinking', function () {
-          cy.get('.modal').within(() => {
-            cy.get('.btn-danger').click()
+          cy.findByRole('dialog').within(() => {
+            cy.findByRole('button', { name: /unlink user/i }).click()
           })
-          cy.get('.notification').contains(
+          cy.findByRole('alert').should(
+            'contain.text',
             `SSO reauthentication request has been sent to ${USER_LINKED_AND_MANAGED.email}`
           )
-          cy.get('ul.managed-users-list table > tbody').within(() => {
-            cy.get('tr:nth-child(4)').within(() => {
-              cy.get('.sr-only').contains('SSO not active')
-              cy.get('.sr-only').contains('Managed')
+          cy.findByTestId('managed-users-table')
+            .find('tbody')
+            .within(() => {
+              cy.get('tr:nth-child(4)').within(() => {
+                cy.findByText('SSO not active')
+                cy.findAllByText('Managed')
+              })
             })
-          })
         })
       })
     })