diff --git a/apps/backend/src/mutations/StatusActionsLogsMutations.spec.ts b/apps/backend/src/mutations/StatusActionsLogsMutations.spec.ts index 22e5bf0a6d..3379f4f389 100644 --- a/apps/backend/src/mutations/StatusActionsLogsMutations.spec.ts +++ b/apps/backend/src/mutations/StatusActionsLogsMutations.spec.ts @@ -3,7 +3,10 @@ import { container } from 'tsyringe'; import StatusActionsLogsMutations from './StatusActionsLogsMutations'; import { Tokens } from '../config/Tokens'; -import { dummyStatusActionsLog } from '../datasources/mockups/StatusActionsLogsDataSource'; +import { + dummyStatusActionsLog, + dummyStatusActionsLogReplay, +} from '../datasources/mockups/StatusActionsLogsDataSource'; import { dummyUserOfficerWithRole } from '../datasources/mockups/UserDataSource'; import { StatusActionsDataSource } from '../datasources/StatusActionsDataSource'; import { Rejection } from '../models/Rejection'; @@ -39,4 +42,66 @@ describe('Test Status Actions Logs Mutations', () => { getConnectionStatusActionSpy.mockRestore(); }); + + test('A logged in user officer should be able to replay multiple status actions logs successfully', async () => { + const result = await statusActionsLogsMutations.replayStatusActionsLogs( + dummyUserOfficerWithRole, + [ + dummyStatusActionsLog.statusActionsLogId, + dummyStatusActionsLogReplay.statusActionsLogId, + ] + ); + + expect(result.totalRequested).toBe(2); + expect(result.successful.sort()).toEqual( + [ + dummyStatusActionsLog.statusActionsLogId, + dummyStatusActionsLogReplay.statusActionsLogId, + ].sort() + ); + expect(result.failed).toHaveLength(0); + }); + + test('Replaying multiple status actions logs should report partial failures without failing the whole request', async () => { + const statusActionsDataSource = container.resolve( + Tokens.StatusActionsDataSource + ); + const getConnectionStatusActionSpy = jest + .spyOn(statusActionsDataSource, 'getConnectionStatusAction') + .mockResolvedValueOnce(null); + + const result = await statusActionsLogsMutations.replayStatusActionsLogs( + dummyUserOfficerWithRole, + [ + dummyStatusActionsLog.statusActionsLogId, + dummyStatusActionsLogReplay.statusActionsLogId, + ] + ); + + expect(result.totalRequested).toBe(2); + expect(result.successful).toEqual([ + dummyStatusActionsLogReplay.statusActionsLogId, + ]); + expect(result.failed).toEqual([ + { + logId: dummyStatusActionsLog.statusActionsLogId, + error: expect.any(String), + }, + ]); + + getConnectionStatusActionSpy.mockRestore(); + }); + + test('Replaying an empty list of status actions logs should return zero counts', async () => { + const result = await statusActionsLogsMutations.replayStatusActionsLogs( + dummyUserOfficerWithRole, + [] + ); + + expect(result).toEqual({ + totalRequested: 0, + successful: [], + failed: [], + }); + }); }); diff --git a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts index 972822b63a..7ac35c29cd 100644 --- a/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts +++ b/apps/e2e/cypress/e2e/pregeneratedPdfs.cy.ts @@ -718,11 +718,21 @@ context('Pregenerated PDF tests', () => { cy.navigateToStatusActionLogsSubmenu('Proposal Download'); - cy.get('[data-cy="replay_status_action_icon"]') - .last() - .click({ force: true }); + cy.finishedLoading(); - cy.contains('unexpected behaviour').should('exist'); + cy.get('[data-cy="status-actions-logs-table"]') + .contains(proposalId1) + .parents('tr') + .find('input[type="checkbox"]') + .check(); + + cy.get('[data-cy="replay_all_status_action_icon"]').click({ + force: true, + }); + + cy.contains( + 'Any selected status action log(s) that can no longer be replayed will be skipped.' + ).should('exist'); cy.get('[data-cy="confirm-ok"]').click(); diff --git a/apps/e2e/cypress/e2e/statusActions.cy.ts b/apps/e2e/cypress/e2e/statusActions.cy.ts index c006b86ef9..66d7111630 100644 --- a/apps/e2e/cypress/e2e/statusActions.cy.ts +++ b/apps/e2e/cypress/e2e/statusActions.cy.ts @@ -840,17 +840,26 @@ context('Status actions tests', () => { cy.wait(2000); // wait until status actions are executed }); - it('User Officer should be able to view and replay email status actions', () => { + it('User Officer should be able to select and replay an email status action', () => { cy.login('officer'); cy.visit('/'); cy.navigateToStatusActionLogsSubmenu('Email'); - cy.get('[data-cy="replay_status_action_icon"]') + cy.finishedLoading(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') .first() - .click({ force: true }); + .find('input[type="checkbox"]') + .check(); + + cy.get('[data-cy="replay_all_status_action_icon"]').click({ + force: true, + }); - cy.contains('duplicate emails').should('exist'); + cy.contains( + 'Any selected status action log(s) that can no longer be replayed will be skipped.' + ).should('exist'); cy.get('[data-cy="confirm-ok"]').click(); @@ -860,17 +869,26 @@ context('Status actions tests', () => { }); }); - it('User Officer should be able to view and replay proposal download status actions', () => { + it('User Officer should be able to select and replay a proposal download status action(s)', () => { cy.login('officer'); cy.visit('/'); cy.navigateToStatusActionLogsSubmenu('Proposal Download'); - cy.get('[data-cy="replay_status_action_icon"]') + cy.finishedLoading(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') .first() - .click({ force: true }); + .find('input[type="checkbox"]') + .check(); + + cy.get('[data-cy="replay_all_status_action_icon"]').click({ + force: true, + }); - cy.contains('unexpected behaviour').should('exist'); + cy.contains( + 'Any selected status action log(s) that can no longer be replayed will be skipped.' + ).should('exist'); cy.get('[data-cy="confirm-ok"]').click(); @@ -880,7 +898,7 @@ context('Status actions tests', () => { }); }); - it('User Officer should see status actions logs whose connection configuration was later removed, with replay disabled', () => { + it('User Officer should see status actions logs whose connection configuration was later removed, with selection disabled', () => { cy.addConnectionStatusActions({ actions: [], connectionId: statusActionsConnectionId, @@ -899,11 +917,19 @@ context('Status actions tests', () => { .filter(':contains("SUCCESSFUL")') .should('have.length.greaterThan', 0); - cy.get('[data-cy="replay_status_action_icon"]') + cy.get('[data-cy="status-actions-logs-table"] tbody tr') .first() - .click({ force: true }); + .find('input[type="checkbox"]') + .should('be.disabled'); - cy.get('[data-cy="confirm-ok"]').should('not.exist'); + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('.MuiCheckbox-root') + .should( + 'have.attr', + 'title', + 'This status action can no longer be replayed' + ); }); it('User Officer should be able to replay all email status actions in a call', () => { @@ -1153,6 +1179,112 @@ context('Status actions tests', () => { assertProposalPresentInTable(String(proposal2Id)); }); + it('User Officer should be able to persist row selection across page changes', () => { + cy.login('officer'); + cy.visit('/EmailStatusActionsLogs?pageSize=2'); + + cy.finishedLoading(); + + cy.get('[data-cy="replay_all_status_action_icon"]').should('not.exist'); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('input[type="checkbox"]') + .check(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('input[type="checkbox"]') + .should('be.checked'); + + cy.get('button[aria-label="Next Page"]').click(); + + cy.finishedLoading(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('input[type="checkbox"]') + .should('not.be.checked'); + + cy.get('[data-cy="replay_all_status_action_icon"]').should('exist'); + cy.get('[data-cy="replay_selected_status_actions_count"]') + .find('.MuiBadge-badge') + .should('have.text', '1'); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('input[type="checkbox"]') + .check(); + + cy.get('[data-cy="replay_selected_status_actions_count"]') + .find('.MuiBadge-badge') + .should('have.text', '2'); + + cy.get('button[aria-label="Previous Page"]').click(); + + cy.finishedLoading(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('input[type="checkbox"]') + .should('be.checked'); + + cy.get('[data-cy="replay_all_status_action_icon"]').should('exist'); + cy.get('[data-cy="replay_selected_status_actions_count"]') + .find('.MuiBadge-badge') + .should('have.text', '2'); + + cy.get('button[aria-label="Next Page"]').click(); + + cy.finishedLoading(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .first() + .find('input[type="checkbox"]') + .should('be.checked'); + }); + + it('User Officer should be able to replay multiple selected email status actions', () => { + cy.login('officer'); + cy.visit('/EmailStatusActionsLogs'); + + cy.finishedLoading(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .eq(0) + .find('input[type="checkbox"]') + .check(); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .eq(1) + .find('input[type="checkbox"]') + .check(); + + cy.contains('2 row(s) selected').should('exist'); + + cy.get('[data-cy="replay_all_status_action_icon"]').click({ + force: true, + }); + + cy.contains( + 'You are about to send a status action replay request for 2 selected status action log(s).' + ).should('exist'); + + cy.get('[data-cy="confirm-ok"]').click(); + + cy.notification({ + variant: 'success', + text: 'Status action replay successfully sent.', + }); + + cy.contains('row(s) selected').should('not.exist'); + + cy.get('[data-cy="status-actions-logs-table"] tbody tr') + .eq(0) + .find('input[type="checkbox"]') + .should('not.be.checked'); + }); + it('User Officer should be able to access the proposal from the link in status actions logs', () => { cy.createProposal({ callId: initialDBData.call.id }).then((result) => { const proposal = result.createProposal; diff --git a/apps/frontend/src/components/statusActionsLogs/StatusActionsLogsTable.tsx b/apps/frontend/src/components/statusActionsLogs/StatusActionsLogsTable.tsx index 53a91d1140..667c06b7be 100644 --- a/apps/frontend/src/components/statusActionsLogs/StatusActionsLogsTable.tsx +++ b/apps/frontend/src/components/statusActionsLogs/StatusActionsLogsTable.tsx @@ -1,12 +1,13 @@ import MaterialTableCore, { + Action, Column, OrderByCollection, Query, QueryResult, } from '@material-table/core'; -import { Replay, Refresh } from '@mui/icons-material'; +import { Refresh } from '@mui/icons-material'; import ReplayCircleFilledIcon from '@mui/icons-material/ReplayCircleFilled'; -import { Grid, Typography, useTheme } from '@mui/material'; +import { Badge, Grid, Typography, useTheme } from '@mui/material'; import React, { useEffect, useState } from 'react'; import { Link as ReactRouterLink, useSearchParams } from 'react-router-dom'; @@ -52,8 +53,7 @@ const StatusActionsLogsTable = ({ ); const RefreshIcon = (): JSX.Element => ; - const [selectedStatusActionsLog, setStatusActionsLog] = - useState(null); + const [currentPageLogIds, setCurrentPageLogIds] = useState([]); const [searchParams, setSearchParams] = useSearchParams({ statusActionsLogStatus: StatusActionsLogStatus.ALL, }); @@ -64,6 +64,7 @@ const StatusActionsLogsTable = ({ const search = searchParams.get('search'); const page = searchParams.get('page'); const pageSize = searchParams.get('pageSize'); + const selectedStatusActionsLogIds = searchParams.getAll('selection'); let columns: Column[] = [ ...(statusActionType === StatusActionType.EMAIL ? [ @@ -193,6 +194,7 @@ const StatusActionsLogsTable = ({ offset: tableQuery.page * tableQuery.pageSize, }) .then((results) => { + const selection = new Set(searchParams.getAll('selection')); const data: StatusActionsLog[] = results.statusActionsLogs?.statusActionsLogs.map( (statusActionsLog) => { @@ -201,9 +203,19 @@ const StatusActionsLogsTable = ({ statusActionsTstamp: toFormattedDateTime( statusActionsLog.statusActionsTstamp ), - } as StatusActionsLog; + tableData: { + checked: selection.has( + statusActionsLog.statusActionsLogId.toString() + ), + }, + } as unknown as StatusActionsLog; } ) || []; + setCurrentPageLogIds( + data.map((statusActionsLog) => + statusActionsLog.statusActionsLogId.toString() + ) + ); resolve({ data: data, page: tableQuery.page, @@ -263,6 +275,64 @@ const StatusActionsLogsTable = ({ ); }; + const handleBulkReplayStatusActionsLogs = (): void => { + if (!selectedStatusActionsLogIds.length) { + return; + } + + confirm( + () => + api({ + toastSuccessMessage: 'Status action replay successfully sent.', + }) + .replayStatusActionsLogs({ + statusActionsLogIds: selectedStatusActionsLogIds.map( + (logId) => +logId + ), + }) + .then(() => { + setSearchParams((searchParams) => { + searchParams.delete('selection'); + + return searchParams; + }); + tableRef.current && tableRef.current.onQueryChange({}); + }), + { + title: 'Are you sure?', + description: `You are about to send a status action replay request for ${selectedStatusActionsLogIds.length} selected status action log(s).`, + alertText: + 'Any selected status action log(s) that can no longer be replayed will be skipped.', + confirmationText: 'Replay', + shouldEnableOKWithAlert: true, + } + )(); + }; + + const bulkReplayAction = { + icon: () => ( + + + + ), + tooltip: `Replay ${selectedStatusActionsLogIds.length} selected status action(s)`, + onClick: handleBulkReplayStatusActionsLogs, + hidden: selectedStatusActionsLogIds.length === 0, + }; + + // material-table only ever renders 'toolbar' actions OR 'toolbarOnSelect' + // actions, switching based on whether the current page has rows checked, + // so this action is registered under both positions to stay visible + // regardless of whether anything is checked on the currently viewed page. + const tableActions: Action[] = [ + { ...bulkReplayAction, isFreeAction: true, position: 'toolbar' }, + { ...bulkReplayAction, position: 'toolbarOnSelect' }, + ]; + return ( <> @@ -305,106 +375,33 @@ const StatusActionsLogsTable = ({ })()} } - onRowClick={() => { - setStatusActionsLog(null); - }} columns={columns} data={fetchStatusActionsLogsData} options={{ search: true, - selection: false, + selection: true, searchText: search || undefined, debounceInterval: 600, columnsButton: true, pageSize: pageSize ? +pageSize : 20, initialPage: page ? +page : 0, idSynonym: 'statusActionsLogId', - rowStyle: (rowdata: StatusActionsLog): React.CSSProperties => { - const style = rowdata.statusActionsSuccessful + rowStyle: (rowdata: StatusActionsLog): React.CSSProperties => + rowdata.statusActionsSuccessful ? { color: theme.palette.success.main } - : { color: theme.palette.error.main }; - - if ( - selectedStatusActionsLog && - rowdata.statusActionsLogId === - selectedStatusActionsLog.statusActionsLogId - ) { - return { - ...style, - backgroundColor: theme.palette.grey[100], - }; - } - - return style; - }, - }} - actions={[ - (rowData: StatusActionsLog) => ({ - icon: () => ( - ({ - opacity: rowData.connectionStatusAction - ? 1 - : theme.palette.action.disabledOpacity, - color: rowData.connectionStatusAction - ? undefined - : theme.palette.action.disabled, - cursor: rowData.connectionStatusAction - ? 'pointer' - : 'not-allowed', - })} - /> - ), - tooltip: rowData.connectionStatusAction - ? 'Replay status action' + : { color: theme.palette.error.main }, + selectionProps: (rowData: StatusActionsLog) => ({ + disabled: !rowData.connectionStatusAction, + title: rowData.connectionStatusAction + ? undefined : 'This status action can no longer be replayed', - onClick: (_event: unknown, rowData: unknown): void => { - const statusActionsLog = rowData as StatusActionsLog; - if (!statusActionsLog.connectionStatusAction) { - return; - } - if (statusActionsLog.statusActionsLogId) { - confirm( - () => - api({ - toastSuccessMessage: - 'Status action replay successfully sent.', - }) - .replayStatusActionsLog({ - statusActionsLogId: - statusActionsLog.statusActionsLogId, - }) - .then((result) => { - if (result.replayStatusActionsLog) { - setStatusActionsLog(statusActionsLog); - } - }), - { - title: 'Are you sure?', - description: `You are about to send a status action replay request.`, - alertText: (() => { - if (!statusActionsLog.statusActionsSuccessful) { - return ''; - } - - if ( - statusActionsLog?.connectionStatusAction?.action.name - .toLowerCase() - .includes('email') - ) { - return 'This email status action was already successful. Replaying it will lead to duplicate emails being sent.'; - } else { - return 'This status action was already successful. Replaying it might lead to unexpected behaviour or redundant processes.'; - } - })(), - confirmationText: 'Replay', - shouldEnableOKWithAlert: true, - } - )(); - } - }, + sx: rowData.connectionStatusAction + ? undefined + : { pointerEvents: 'auto !important' }, }), + }} + actions={[ + ...tableActions, { icon: RefreshIcon, tooltip: 'Refresh status actions log data', @@ -449,6 +446,29 @@ const StatusActionsLogsTable = ({ }, }, ]} + localization={{ + toolbar: { + nRowsSelected: () => + `${selectedStatusActionsLogIds.length} row(s) selected`, + }, + }} + onSelectionChange={(selectedItems: StatusActionsLog[]) => { + setSearchParams((searchParams) => { + const otherPagesSelection = searchParams + .getAll('selection') + .filter((logId) => !currentPageLogIds.includes(logId)); + const currentPageSelection = selectedItems.map((selectedItem) => + selectedItem.statusActionsLogId.toString() + ); + + searchParams.delete('selection'); + [...otherPagesSelection, ...currentPageSelection].forEach( + (logId) => searchParams.append('selection', logId) + ); + + return searchParams; + }); + }} onChangeColumnHidden={(columnChange) => { const proposalColumns = columns.map( (statusActionLogsColumn: Column) => ({ diff --git a/apps/frontend/src/graphql/statusActionsLogs/replayStatusActionsLog.graphql b/apps/frontend/src/graphql/statusActionsLogs/replayStatusActionsLog.graphql deleted file mode 100644 index 1cfd8c33c5..0000000000 --- a/apps/frontend/src/graphql/statusActionsLogs/replayStatusActionsLog.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation replayStatusActionsLog( - $statusActionsLogId: Int! -) { - replayStatusActionsLog(statusActionsLogId: $statusActionsLogId) -} diff --git a/apps/frontend/src/graphql/statusActionsLogs/replayStatusActionLogs.graphql b/apps/frontend/src/graphql/statusActionsLogs/replayStatusActionsLogs.graphql similarity index 100% rename from apps/frontend/src/graphql/statusActionsLogs/replayStatusActionLogs.graphql rename to apps/frontend/src/graphql/statusActionsLogs/replayStatusActionsLogs.graphql