diff --git a/cypress/e2e/roles.cy.ts b/cypress/e2e/roles.cy.ts index dc58e3339..893e7bd29 100644 --- a/cypress/e2e/roles.cy.ts +++ b/cypress/e2e/roles.cy.ts @@ -33,9 +33,109 @@ describe('Roles page', () => { external_role_id: null, external_tenant: null, }, + { + uuid: '00000-00000-1111-1111-111111', + name: 'A_Test_00000-00000', + display_name: 'A Test', + description: 'Test role A', + created: '2024-05-17T05:03:15.684013Z', + modified: '2024-05-17T05:03:15.709410Z', + policyCount: 2, + accessCount: 2, + applications: [], + system: false, + platform_default: false, + admin_default: false, + external_role_id: null, + external_tenant: null, + }, + ], + meta: { + count: 3, + limit: 20, + offset: 0, + }, + }; + + const sortedRoles = { + data: [ + { + uuid: '00000-00000-1111-1111-111111', + name: 'A_Test_00000-00000', + display_name: 'A Test', + description: 'Test role A', + created: '2024-05-17T05:03:15.684013Z', + modified: '2024-05-17T05:03:15.709410Z', + policyCount: 2, + accessCount: 2, + applications: [], + system: false, + platform_default: false, + admin_default: false, + external_role_id: null, + external_tenant: null, + }, + { + uuid: '00000-0000-0000-0000-00000000', + name: 'Test_1_00000-00000', + display_name: 'Test 1', + description: 'Test role1', + created: '2024-05-17T05:03:15.684013Z', + modified: '2024-05-17T05:03:15.709410Z', + policyCount: 2, + accessCount: 2, + applications: [], + system: false, + platform_default: false, + admin_default: false, + external_role_id: null, + external_tenant: null, + }, + { + uuid: '00000-11111-1111-1111-111111', + name: 'Test_2_00000-00000', + display_name: 'Test 2', + description: 'Test role2', + created: '2024-05-17T05:03:15.684013Z', + modified: '2024-05-17T05:03:15.709410Z', + policyCount: 2, + accessCount: 2, + applications: [], + system: false, + platform_default: false, + admin_default: false, + external_role_id: null, + external_tenant: null, + }, ], meta: { - count: 2, + count: 3, + limit: 20, + offset: 0, + }, + }; + + const filteredRoles = { + data: [ + { + uuid: '00000-00000-1111-1111-111111', + name: 'A_Test_00000-00000', + display_name: 'A Test', + description: 'Test role A', + created: '2024-05-17T05:03:15.684013Z', + modified: '2024-05-17T05:03:15.709410Z', + policyCount: 2, + accessCount: 2, + applications: [], + system: false, + platform_default: false, + admin_default: false, + external_role_id: null, + external_tenant: null, + }, + ], + meta: { + count: 1, limit: 20, offset: 0, }, @@ -85,4 +185,24 @@ describe('Roles page', () => { cy.get('[data-ouia-component-id^="RolesTable-assigned-groups-tab"]').first().click(); cy.get('[data-ouia-component-id^="assigned-usergroups-table-stack"]').should('be.visible'); }); + + it('should sort roles', () => { + cy.intercept('GET', '**/api/rbac/v1/roles/?limit=20&display_name=&scope=org_id&order_by=display_name*', { + statusCode: 200, + body: sortedRoles, + }).as('sortRoles'); + cy.get('[data-ouia-component-id="RolesTable-table-th-0"]').click(); + cy.wait('@sortRoles', { timeout: 15000 }); + cy.get('[data-ouia-component-id="RolesTable-table-td-0-0"]').should('contain.text', 'A Test'); + }); + + it('should filter roles by name', () => { + cy.intercept('GET', '**/api/rbac/v1/roles/?limit=20&display_name=A*', { + statusCode: 200, + body: filteredRoles, + }).as('filterRoles'); + cy.get('[data-ouia-component-id="RolesTable-name-filter-input"]').type('A'); + cy.wait('@filterRoles', { timeout: 15000 }); + cy.get('table tbody tr').should('have.length', 1); + }); }); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 9511a2497..8f3587232 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -47,6 +47,10 @@ Cypress.Commands.add('login', (enableWorkspaces = false) => { // This JS file causes randomly an uncaught exception on login page which blocks the tests // Cannot read properties of undefined (reading 'setAttribute') cy.intercept({ url: 'https://sso.stage.redhat.com/auth/resources/0833r/login/rhd-theme/dist/pfelements/bundle.js' }, {}); + Cypress.on('uncaught:exception', (err) => { + console.log(err); + return false; + }); cy.visit('/'); // disable analytics integrations cy.setLocalStorage('chrome:analytics:disable', 'true'); diff --git a/package-lock.json b/package-lock.json index dabf22968..9414c46a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@patternfly/quickstarts": "^5.1.0", "@patternfly/react-component-groups": "^5.5.5", "@patternfly/react-core": "^5.1.1", - "@patternfly/react-data-view": "^5.7.0", + "@patternfly/react-data-view": "^5.7.1", "@patternfly/react-icons": "^5.1.1", "@patternfly/react-table": "^5.1.1", "@patternfly/react-tokens": "^5.1.1", @@ -4372,10 +4372,9 @@ } }, "node_modules/@patternfly/react-data-view": { - "version": "5.7.0", - "resolved": "https://registry.npmjs.org/@patternfly/react-data-view/-/react-data-view-5.7.0.tgz", - "integrity": "sha512-EcCy8+5xdD48AvZyONfO8pZ7LM53eIkvT23guQ1QkmI253tUQYk16TlfJQMBC8aBZztu7wd0fWjDNHKVvgJA9Q==", - "license": "MIT", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@patternfly/react-data-view/-/react-data-view-5.8.0.tgz", + "integrity": "sha512-+GDaWmr19t71q83yiamFPnyxeak0I70gTxWmkD/tQEnE/m6+5MZy+jtJ1lsT5BFPXNvwweEwfaXwiFzPTxTV3w==", "dependencies": { "@patternfly/react-component-groups": "^5.5.2", "@patternfly/react-core": "^5.4.1", diff --git a/package.json b/package.json index 5068f6263..8f2a776e4 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "@patternfly/quickstarts": "^5.1.0", "@patternfly/react-component-groups": "^5.5.5", "@patternfly/react-core": "^5.1.1", - "@patternfly/react-data-view": "^5.7.0", + "@patternfly/react-data-view": "^5.7.1", "@patternfly/react-icons": "^5.1.1", "@patternfly/react-table": "^5.1.1", "@patternfly/react-tokens": "^5.1.1", diff --git a/src/Messages.js b/src/Messages.js index 48447be0d..9150d8a69 100644 --- a/src/Messages.js +++ b/src/Messages.js @@ -2518,6 +2518,11 @@ export default defineMessages({ description: 'confirm button for deleting role', defaultMessage: 'Delete role', }, + nameFilterPlaceholder: { + id: 'nameFilterPlaceholder', + description: 'placeholder for name filter', + defaultMessage: 'Filter by name', + }, deleteRolesAction: { id: 'deleteRolesAction', description: 'delete roles', diff --git a/src/smart-components/access-management/UserGroupsTable.tsx b/src/smart-components/access-management/UserGroupsTable.tsx index 83a7650e8..ec383ade1 100644 --- a/src/smart-components/access-management/UserGroupsTable.tsx +++ b/src/smart-components/access-management/UserGroupsTable.tsx @@ -17,7 +17,7 @@ import messages from '../../Messages'; import { Group } from '../../redux/reducers/group-reducer'; import { DataViewTrObject, DataViewState, EventTypes, useDataViewEventsContext } from '@patternfly/react-data-view'; import { SearchIcon } from '@patternfly/react-icons'; -import { ResponsiveAction, ResponsiveActions, SkeletonTableBody, WarningModal } from '@patternfly/react-component-groups'; +import { ResponsiveAction, ResponsiveActions, SkeletonTableBody, SkeletonTableHead, WarningModal } from '@patternfly/react-component-groups'; import AddGroupWizard from '../group/add-group/add-group-wizard'; const COLUMNS: string[] = ['User group name', 'Description', 'Users', 'Service accounts', 'Roles', 'Workspaces', 'Last modified']; @@ -30,6 +30,25 @@ const PER_PAGE_OPTIONS = [ { title: '100', value: 100 }, ]; +const EmptyTable: React.FunctionComponent<{ titleText: string }> = ({ titleText }) => { + return ( + + } /> + + , + }} + /> + + + ); +}; + +const loadingHeader = ; +const loadingBody = ; + interface UserGroupsTableProps { defaultPerPage?: number; useUrlParams?: boolean; @@ -207,26 +226,6 @@ const UserGroupsTable: React.FunctionComponent = ({ /> ); - const empty = ( - - } - /> - - , - }} - /> - - - ); - - const loading = ; - return ( {isAddGroupWizardOpen && ( @@ -302,7 +301,8 @@ const UserGroupsTable: React.FunctionComponent = ({ ouiaId={`${ouiaId}-table`} columns={COLUMNS} rows={rows} - bodyStates={{ empty, loading }} + headStates={{ loading: loadingHeader }} + bodyStates={{ loading: loadingBody, empty: }} /> diff --git a/src/smart-components/access-management/UsersTable.tsx b/src/smart-components/access-management/UsersTable.tsx index ddfe1958b..857c43243 100644 --- a/src/smart-components/access-management/UsersTable.tsx +++ b/src/smart-components/access-management/UsersTable.tsx @@ -16,7 +16,7 @@ import { User } from '../../redux/reducers/user-reducer'; import { FormattedMessage, useIntl } from 'react-intl'; import messages from '../../Messages'; import { Outlet, useSearchParams } from 'react-router-dom'; -import { SkeletonTableBody, WarningModal } from '@patternfly/react-component-groups'; +import { SkeletonTableBody, SkeletonTableHead, WarningModal } from '@patternfly/react-component-groups'; import paths from '../../utilities/pathnames'; import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; import useAppNavigate from '../../hooks/useAppNavigate'; @@ -33,6 +33,25 @@ const PER_PAGE_OPTIONS = [ { title: '100', value: 100 }, ]; +const EmptyTable: React.FunctionComponent<{ titleText: string }> = ({ titleText }) => { + return ( + + } /> + + , + }} + /> + + + ); +}; + +const loadingHeader = ; +const loadingBody = ; + const OUIA_ID = 'iam-users-table'; interface UsersTableProps { @@ -159,22 +178,6 @@ const UsersTable: React.FunctionComponent = ({ onAddUserClick, /> ); - const empty = ( - - } /> - - , - }} - /> - - - ); - - const loading = ; - return ( {isDeleteModalOpen && ( @@ -239,7 +242,8 @@ const UsersTable: React.FunctionComponent = ({ onAddUserClick, ouiaId={`${OUIA_ID}-table`} columns={COLUMNS} rows={rows} - bodyStates={{ empty, loading }} + headStates={{ loading: loadingHeader }} + bodyStates={{ loading: loadingBody, empty: }} /> diff --git a/src/smart-components/role/RolesTable.tsx b/src/smart-components/role/RolesTable.tsx index 257d675de..d6f152c2d 100644 --- a/src/smart-components/role/RolesTable.tsx +++ b/src/smart-components/role/RolesTable.tsx @@ -6,19 +6,20 @@ import { DataView } from '@patternfly/react-data-view/dist/dynamic/DataView'; import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; import { DataViewTable } from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; import { DataViewEventsProvider, EventTypes, useDataViewEventsContext } from '@patternfly/react-data-view/dist/dynamic/DataViewEventsContext'; +import { useDataViewSort } from '@patternfly/react-data-view/dist/dynamic/Hooks'; import { ButtonVariant, Drawer, DrawerContent, DrawerContentBody, PageSection, Pagination } from '@patternfly/react-core'; -import { ActionsColumn } from '@patternfly/react-table'; +import { ActionsColumn, ThProps } from '@patternfly/react-table'; import ContentHeader from '@patternfly/react-component-groups/dist/esm/ContentHeader'; import { fetchRolesWithPolicies, removeRole } from '../../redux/actions/role-actions'; import { FormattedMessage, useIntl } from 'react-intl'; import messages from '../../Messages'; -import { mappedProps } from '../../helpers/shared/helpers'; +import { debouncedFetch, mappedProps } from '../../helpers/shared/helpers'; import { Role } from '../../redux/reducers/role-reducer'; import { RBACStore } from '../../redux/store'; import { useSearchParams } from 'react-router-dom'; import RolesDetails from './RolesTableDetails'; import { ResponsiveAction, ResponsiveActions, WarningModal } from '@patternfly/react-component-groups'; -import { DataViewTrObject } from '@patternfly/react-data-view'; +import { DataViewTextFilter, DataViewTh, DataViewTr, DataViewTrObject, useDataViewFilters } from '@patternfly/react-data-view'; const PER_PAGE = [ { title: '5', value: 5 }, @@ -28,6 +29,10 @@ const PER_PAGE = [ { title: '100', value: 100 }, ]; +interface RoleFilters { + display_name: string; +} + const ouiaId = 'RolesTable'; interface RolesTableProps { @@ -51,17 +56,25 @@ const RolesTable: React.FunctionComponent = ({ selectedRole }) setIsDeleteModalOpen(!isDeleteModalOpen); }; - const COLUMNS: string[] = [ - intl.formatMessage(messages.name), - intl.formatMessage(messages.description), - intl.formatMessage(messages.permissions), - intl.formatMessage(messages.workspaces), - intl.formatMessage(messages.userGroups), - intl.formatMessage(messages.lastModified), + const COLUMNHEADERS = [ + { label: intl.formatMessage(messages.name), key: 'display_name', index: 0, isSortable: true }, + { label: intl.formatMessage(messages.description), key: 'description', index: 1, isSortable: false }, + { label: intl.formatMessage(messages.permissions), key: 'accessCount', index: 2, isSortable: false }, + { label: intl.formatMessage(messages.workspaces), key: 'workspaces', index: 3, isSortable: false }, + { label: intl.formatMessage(messages.userGroups), key: 'user_groups', index: 4, isSortable: false }, + { label: intl.formatMessage(messages.lastModified), key: 'modified', index: 5, isSortable: true }, ]; const dispatch = useDispatch(); const [searchParams, setSearchParams] = useSearchParams(); + const { filters, onSetFilters, clearAllFilters } = useDataViewFilters({ + initialFilters: { display_name: '' }, + searchParams, + setSearchParams, + }); + + const { sortBy, direction, onSort } = useDataViewSort({ searchParams, setSearchParams }); + const sortByIndex = useMemo(() => COLUMNHEADERS.findIndex((item) => (item.isSortable ? item.key === sortBy : '')), [sortBy]); const pagination = useDataViewPagination({ perPage: 20, searchParams, setSearchParams }); const { page, perPage, onSetPage, onPerPageSelect } = pagination; @@ -70,9 +83,9 @@ const RolesTable: React.FunctionComponent = ({ selectedRole }) const { selected, onSelect, isSelected } = selection; const fetchData = useCallback( - (apiProps: { count: number; limit: number; offset: number; orderBy: string }) => { - const { count, limit, offset, orderBy } = apiProps; - dispatch(fetchRolesWithPolicies({ ...mappedProps({ count, limit, offset, orderBy }) })); + (apiProps: { limit: number; offset: number; orderBy: string; filters: RoleFilters }) => { + const { limit, offset, orderBy, filters } = apiProps; + dispatch(fetchRolesWithPolicies({ ...mappedProps({ limit, offset, orderBy, filters }) })); }, [dispatch] ); @@ -81,12 +94,43 @@ const RolesTable: React.FunctionComponent = ({ selectedRole }) fetchData({ limit: perPage, offset: (page - 1) * perPage, - orderBy: 'display_name', - count: totalCount || 0, + orderBy: `${direction === 'desc' ? '-' : ''}${sortBy}`, + filters: filters, }); - }, [fetchData, page, perPage]); + }, [fetchData, page, perPage, sortBy, direction]); + + useEffect(() => { + debouncedFetch( + () => + fetchData({ + limit: perPage, + offset: (page - 1) * perPage, + orderBy: `${direction === 'desc' ? '-' : ''}${sortBy}`, + filters: filters, + }), + 800 + ); + }, [debouncedFetch, filters, onSetFilters]); + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: sortByIndex, + direction, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + onSort(_event, COLUMNHEADERS[index].key, direction); + onSetPage(undefined, 1); + }, + columnIndex, + }); + + const columns: DataViewTh[] = COLUMNHEADERS.map((column, index) => ({ + cell: column.label, + props: column.isSortable ? { sort: getSortParams(index) } : {}, + })); - const rows = useMemo(() => { + const rows: DataViewTr[] = useMemo(() => { const handleRowClick = (event: any, role: Role | undefined) => { (event.target.matches('td') || event.target.matches('tr')) && trigger(EventTypes.rowClick, role); }; @@ -122,7 +166,7 @@ const RolesTable: React.FunctionComponent = ({ selectedRole }) isRowSelected: selectedRole?.name === role.name, }, })); - }, [roles, handleModalToggle, trigger, selectedRole, selectedRole?.display_name]); + }, [roles, handleModalToggle, trigger, selectedRole, selectedRole?.display_name, sortBy, onSort, direction, filters, onSetFilters]); const handleBulkSelect = (value: BulkSelectValue) => { value === BulkSelectValue.none && onSelect(false); @@ -174,6 +218,7 @@ const RolesTable: React.FunctionComponent = ({ selectedRole }) = ({ selectedRole }) onPerPageSelect={onPerPageSelect} /> } + filters={ + { + onSetFilters({ display_name: value }); + onSetPage(undefined, 1); + }} + value={filters['display_name']} + /> + } /> - +