diff --git a/libs/cypress/e2e/devices/approveEnrollmentRequest.cy.ts b/libs/cypress/e2e/devices/approveEnrollmentRequest.cy.ts index 7c65670781..233c006c48 100644 --- a/libs/cypress/e2e/devices/approveEnrollmentRequest.cy.ts +++ b/libs/cypress/e2e/devices/approveEnrollmentRequest.cy.ts @@ -41,4 +41,21 @@ describe('Enrollment requests approval', () => { // NOTE: The ER will still appear in the device list as such. // To mock it properly, we'd need to remove it from the ER list, and add its equivalent item to the Device list. }); + + it('Pending enrollment requests remain visible while clearing an empty search result', () => { + cy.wait('@all-enrollment-requests'); + devicesPage.firstEnrollmentRequestRow.should('be.visible'); + + devicesPage.enrollmentRequestSearchInput.type('fake'); + cy.wait('@all-enrollment-requests'); + devicesPage.pendingEnrollmentRequestsNoResults.should('be.visible'); + devicesPage.firstEnrollmentRequestRow.should('not.exist'); + + devicesPage.enrollmentRequestSearchInput.clear(); + devicesPage.pendingEnrollmentRequestsSection.should('be.visible'); + devicesPage.pendingEnrollmentRequestsLoading.should('be.visible'); + + cy.wait('@all-enrollment-requests'); + devicesPage.firstEnrollmentRequestRow.scrollIntoView().should('be.visible'); + }); }); diff --git a/libs/cypress/fixtures/enrollmentRequests/index.ts b/libs/cypress/fixtures/enrollmentRequests/index.ts index f5c6845462..1e42ddd497 100644 --- a/libs/cypress/fixtures/enrollmentRequests/index.ts +++ b/libs/cypress/fixtures/enrollmentRequests/index.ts @@ -1,4 +1,4 @@ -import { ConditionStatus, ConditionType } from '@flightctl/types'; +import { ConditionStatus, ConditionType, type EnrollmentRequest } from '@flightctl/types'; import { API_VERSION } from '../../support/constants'; const approvedErStatus = { @@ -20,7 +20,7 @@ const approvedErStatus = { ], }; -const getErList = (onlyPending: boolean) => +const getErList = (onlyPending: boolean): EnrollmentRequest[] => [ { apiVersion: API_VERSION, @@ -75,7 +75,7 @@ const getErList = (onlyPending: boolean) => status: { conditions: [] }, }, ].filter((er) => { - return onlyPending ? er.status.conditions.length === 0 : true; + return onlyPending ? er.status?.conditions?.length === 0 : true; }); export { getErList }; diff --git a/libs/cypress/pages/DevicesPage.ts b/libs/cypress/pages/DevicesPage.ts index 6f6214e95d..c4ff050f3a 100644 --- a/libs/cypress/pages/DevicesPage.ts +++ b/libs/cypress/pages/DevicesPage.ts @@ -19,6 +19,26 @@ export class DevicesPage { return cy.get(`[data-testid=enrollment-request-0] button[aria-label="Kebab toggle"]`); } + get enrollmentRequestSearchInput() { + return cy.get('[data-testid="pending-enrollment-request-search-input"]'); + } + + get pendingEnrollmentRequestsSection() { + return cy.get('[data-testid="pending-enrollment-requests-section"]'); + } + + get pendingEnrollmentRequestsLoading() { + return cy.get('[data-testid="pending-enrollment-requests-loading"]'); + } + + get pendingEnrollmentRequestsNoResults() { + return this.pendingEnrollmentRequestsSection.contains('No results found'); + } + + get firstEnrollmentRequestRow() { + return cy.get('[data-testid="enrollment-request-0"]'); + } + enrollmentRequestKebabMenuAction(actionName: string) { return cy.get('[role="menuitem"]').contains(actionName); } diff --git a/libs/cypress/support/interceptors/enrollmentRequests.ts b/libs/cypress/support/interceptors/enrollmentRequests.ts index 1f113d9f41..c2bc57ccc2 100644 --- a/libs/cypress/support/interceptors/enrollmentRequests.ts +++ b/libs/cypress/support/interceptors/enrollmentRequests.ts @@ -1,21 +1,44 @@ import { getErList } from '../../fixtures'; -import { EnrollmentRequest } from '@flightctl/types'; +import type { EnrollmentRequest, EnrollmentRequestList } from '@flightctl/types'; import { API_VERSION } from '../constants'; import { createListMatcher } from './matchers'; -const buildErResponse = (enrollmentRequests: EnrollmentRequest[]) => ({ +const UNFILTERED_RESPONSE_DELAY_MS = 1000; + +const buildErResponse = (enrollmentRequests: EnrollmentRequest[]): EnrollmentRequestList => ({ apiVersion: API_VERSION, items: enrollmentRequests, kind: 'EnrollmentRequestList', metadata: {}, }); +let shouldDelayNextUnfilteredPendingEnrollmentResponse = false; + const loadInterceptors = () => { cy.intercept('GET', createListMatcher('enrollmentrequests'), (req) => { - const hasFieldSelector = req.url.includes('fieldSelector='); - req.reply({ - body: buildErResponse(getErList(hasFieldSelector)), - }); + const requestUrl = new URL(req.url); + const fieldSelector = requestUrl.searchParams.get('fieldSelector') || ''; + const hasFieldSelector = !!fieldSelector; + const enrollmentRequests = filterEnrollmentRequests(getErList(hasFieldSelector), fieldSelector); + const body = buildErResponse(enrollmentRequests); + + if ( + shouldDelayNextUnfilteredPendingEnrollmentResponse && + isUnfilteredPendingEnrollmentRequest(requestUrl, fieldSelector) + ) { + shouldDelayNextUnfilteredPendingEnrollmentResponse = false; + req.reply({ + body, + delayMs: UNFILTERED_RESPONSE_DELAY_MS, + }); + return; + } + + if (getNameSearch(fieldSelector) && enrollmentRequests.length === 0) { + shouldDelayNextUnfilteredPendingEnrollmentResponse = true; + } + + req.reply({ body }); }).as('all-enrollment-requests'); cy.intercept('PUT', '/api/flightctl/api/v1/enrollmentrequests/*/approval', (req) => { @@ -25,4 +48,29 @@ const loadInterceptors = () => { }).as('approve-enrollment-request'); }; +const filterEnrollmentRequests = ( + enrollmentRequests: EnrollmentRequest[], + fieldSelector: string, +): EnrollmentRequest[] => { + const nameSearch = getNameSearch(fieldSelector); + if (!nameSearch) { + return enrollmentRequests; + } + return enrollmentRequests.filter((er) => er.metadata.name?.includes(nameSearch)); +}; + +const getNameSearch = (fieldSelector: string): string | undefined => { + for (const selector of fieldSelector.split(',')) { + const match = selector.match(/^metadata\.name contains ([^,]+)$/); + if (match) { + return match[1]; + } + } + return undefined; +}; + +const isUnfilteredPendingEnrollmentRequest = (requestUrl: URL, fieldSelector: string): boolean => { + return fieldSelector === '!status.approval.approved' && requestUrl.searchParams.get('limit') === '15'; +}; + export { loadInterceptors }; diff --git a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx index fe369cf946..e2b945258b 100644 --- a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx +++ b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestList.tsx @@ -80,8 +80,10 @@ const EnrollmentRequestList = ({ refetchDevices, isStandalone }: EnrollmentReque }, }); - // In non-standalone mode, hide the entire component when the search result is empty (and not due to filtering) - const isLastUnfilteredListEmpty = !search && itemCount === 0; + const isInitialUnfilteredLoad = !search && itemCount === 0 && isLoading; + + // In non-standalone mode, hide the entire component when the unfiltered list is empty. + const isLastUnfilteredListEmpty = !search && itemCount === 0 && !isLoading; if (!isStandalone && isLastUnfilteredListEmpty) { return null; } @@ -93,8 +95,13 @@ const EnrollmentRequestList = ({ refetchDevices, isStandalone }: EnrollmentReque title={t('Devices pending approval')} headingLevel="h2" description={t('Review and approve devices requesting to join your environment.')} + testId="pending-enrollment-requests-section" > - + {(canApprove || canDelete) && ( @@ -113,7 +120,7 @@ const EnrollmentRequestList = ({ refetchDevices, isStandalone }: EnrollmentReque setSearch('')} diff --git a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestTableToolbar.tsx b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestTableToolbar.tsx index 8d44c7c174..de691206c5 100644 --- a/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestTableToolbar.tsx +++ b/libs/ui-components/src/components/EnrollmentRequest/EnrollmentRequestTableToolbar.tsx @@ -24,7 +24,12 @@ const EnrollmentRequestTableToolbar = ({ - + {children} diff --git a/libs/ui-components/src/components/EnrollmentRequest/useEnrollmentRequests.ts b/libs/ui-components/src/components/EnrollmentRequest/useEnrollmentRequests.ts index 3fb99ced1c..d7208a59df 100644 --- a/libs/ui-components/src/components/EnrollmentRequest/useEnrollmentRequests.ts +++ b/libs/ui-components/src/components/EnrollmentRequest/useEnrollmentRequests.ts @@ -52,7 +52,7 @@ export const usePendingEnrollments = ( useTablePagination(); const [pendingErEndpoint, isDebouncing] = useEnrollmentRequestsEndpoint({ search, nextContinue }); - const [erList, isLoading, error, refetch] = useFetchPeriodically( + const [erList, isLoading, error, refetch, updating] = useFetchPeriodically( { endpoint: pendingErEndpoint, }, @@ -68,5 +68,5 @@ export const usePendingEnrollments = ( [currentPage, setCurrentPage, itemCount], ); - return [erList?.items || [], isLoading || isDebouncing, error, refetch, pagination]; + return [erList?.items || [], isLoading || isDebouncing || updating, error, refetch, pagination]; }; diff --git a/libs/ui-components/src/components/ListPage/ListPage.tsx b/libs/ui-components/src/components/ListPage/ListPage.tsx index e0df64392e..2266e2ff30 100644 --- a/libs/ui-components/src/components/ListPage/ListPage.tsx +++ b/libs/ui-components/src/components/ListPage/ListPage.tsx @@ -7,11 +7,12 @@ type ListPageProps = { description?: string; headingLevel?: TitleProps['headingLevel']; children: React.ReactNode; + testId?: string; }; -const ListPage: React.FC = ({ title, description, headingLevel = 'h1', children }) => { +const ListPage: React.FC = ({ title, description, headingLevel = 'h1', children, testId }) => { return ( - + diff --git a/libs/ui-components/src/components/ListPage/ListPageBody.tsx b/libs/ui-components/src/components/ListPage/ListPageBody.tsx index 0ba5d434dc..3aefb2b378 100644 --- a/libs/ui-components/src/components/ListPage/ListPageBody.tsx +++ b/libs/ui-components/src/components/ListPage/ListPageBody.tsx @@ -9,9 +9,10 @@ type ListPageBodyProps = { error: unknown; loading: boolean; children: React.ReactNode; + loadingTestId?: string; }; -const ListPageBody: React.FC<ListPageBodyProps> = ({ error, loading, children }) => { +const ListPageBody: React.FC<ListPageBodyProps> = ({ error, loading, children, loadingTestId }) => { const { t } = useTranslation(); if (error) { return ( @@ -24,7 +25,7 @@ const ListPageBody: React.FC<ListPageBodyProps> = ({ error, loading, children }) if (loading) { return ( <Bullseye> - <Spinner /> + <Spinner data-testid={loadingTestId} /> </Bullseye> ); }