diff --git a/app/components/Reporting/ManageReports.tsx b/app/components/Reporting/ManageReports.tsx new file mode 100644 index 0000000000..4ac2f663ab --- /dev/null +++ b/app/components/Reporting/ManageReports.tsx @@ -0,0 +1,108 @@ +import styled from 'styled-components'; +import { useToast } from 'components/AppProvider'; +import { useState } from 'react'; +import { DateTime } from 'luxon'; +import { useArchiveReportingGcpeMutation } from 'schema/mutations/reporting/archiveReportingGcpeMutation'; +import { ConnectionHandler } from 'relay-runtime'; +import * as Sentry from '@sentry/nextjs'; +import ReportRow from './ReportRow'; + +const StyledH2 = styled.h2` + margin-top: 12px; +`; + +const ManageReports = ({ reportList, connectionId }) => { + const { showToast, hideToast } = useToast(); + const [updateReportingGcpeByRowId] = useArchiveReportingGcpeMutation(); + + const [isLoading, setIsLoading] = useState(false); + + const handleArchive = (report) => { + const { __id: reportConnectionId, rowId, createdAt } = report; + const formattedFileName = `Generated ${DateTime.fromISO(createdAt) + .setZone('America/Los_Angeles') + .toLocaleString(DateTime.DATETIME_FULL)}`; + hideToast(); + setIsLoading(true); + const variables = { + input: { + reportingGcpePatch: { + archivedAt: new Date().toISOString(), + }, + rowId, + }, + }; + updateReportingGcpeByRowId({ + variables, + updater: (store) => { + const connection = store.get(connectionId); + store.delete(reportConnectionId); + ConnectionHandler.deleteNode(connection, reportConnectionId); + }, + onError: (response) => { + setIsLoading(false); + showToast( + 'Error archiving GCPE file. Please try again later.', + 'error', + 5000 + ); + Sentry.captureException({ + name: `Error archiving GCPE file: ${formattedFileName}`, + message: response, + }); + }, + onCompleted: () => { + setIsLoading(false); + showToast('GCPE file archived successfully', 'success', 5000); + }, + }); + }; + + const handleDownload = async (report: any) => { + const { rowId, createdAt } = report; + await fetch('/api/reporting/gcpe/regenerate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ rowId }), + }) + .then((response) => { + response.blob().then((blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${DateTime.fromISO(createdAt) + .setZone('America/Los_Angeles') + .toLocaleString( + DateTime.DATETIME_FULL + )}_Connectivity_Projects_GCPE_List.xlsx`; + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); + }); + }) + .catch((error) => { + showToast('Error downloading file. Please try again later.', error); + }); + }; + + return ( + <> + Manage My Reports + {reportList.map((edge) => ( + handleDownload(edge.node)} + onArchive={() => handleArchive(edge.node)} + isLoading={isLoading} + reportType="GCPE" + /> + ))} + + ); +}; + +export default ManageReports; diff --git a/app/components/Reporting/ReportRow.tsx b/app/components/Reporting/ReportRow.tsx new file mode 100644 index 0000000000..17da79eccb --- /dev/null +++ b/app/components/Reporting/ReportRow.tsx @@ -0,0 +1,87 @@ +import { faTrash } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import useModal from 'lib/helpers/useModal'; +import GenericConfirmationModal from 'lib/theme/widgets/GenericConfirmationModal'; +import { DateTime } from 'luxon'; +import styled from 'styled-components'; + +const StyledFileDiv = styled('div')` + display: flex; + flex-direction: row; + align-items: center; + word-break: break-word; + margin-left: 16px; + margin-top: 10px; + & svg { + margin: 0px 8px; + } +`; + +const StyledDeleteBtn = styled('button')` + &:hover { + opacity: 0.6; + } +`; + +const StyledLink = styled.button` + width: 380px; + color: ${(props) => props.theme.color.links}; + text-decoration-line: underline; + text-align: left; +`; + +const ReportRow = ({ + report, + onDownload, + onArchive, + isLoading, + reportType, +}) => { + const { id, createdAt } = report; + + const deleteConfirmationModal = useModal(); + + const formattedFileName = `Generated ${DateTime.fromISO(createdAt) + .setZone('America/Los_Angeles') + .toLocaleString(DateTime.DATETIME_FULL)}`; + + return ( + <> + + { + e.preventDefault(); + onDownload(); + }} + > + {formattedFileName} + + ) => { + e.preventDefault(); + deleteConfirmationModal.open(); + }} + disabled={isLoading} + > + + + + { + onArchive(); + deleteConfirmationModal.close(); + }} + {...deleteConfirmationModal} + /> + + ); +}; + +export default ReportRow; diff --git a/app/components/Reporting/Tabs.tsx b/app/components/Reporting/Tabs.tsx index 4ee3a86ae2..7ada9fe983 100644 --- a/app/components/Reporting/Tabs.tsx +++ b/app/components/Reporting/Tabs.tsx @@ -10,12 +10,19 @@ const StyledNav = styled.nav` const Tabs = () => { const router = useRouter(); const gcpeHref = '/analyst/reporting/gcpe'; + const manageReportsHref = '/analyst/reporting/manage-reports'; // this a bare page to handle any future reporting tabs return ( GCPE + + Manage Reports + ); }; diff --git a/app/pages/analyst/reporting/manage-reports.tsx b/app/pages/analyst/reporting/manage-reports.tsx new file mode 100644 index 0000000000..9ad7d3f861 --- /dev/null +++ b/app/pages/analyst/reporting/manage-reports.tsx @@ -0,0 +1,79 @@ +import { Layout } from 'components'; +import { DashboardTabs } from 'components/AnalystDashboard'; +import { graphql } from 'react-relay'; +import { usePreloadedQuery } from 'react-relay/hooks'; +import { withRelay, RelayProps } from 'relay-nextjs'; +import styled from 'styled-components'; +import defaultRelayOptions from 'lib/relay/withRelayOptions'; +import Tabs from 'components/Reporting/Tabs'; +import ManageReports from 'components/Reporting/ManageReports'; +import { manageReportsQuery } from '__generated__/manageReportsQuery.graphql'; + +const getManageReportingQuery = graphql` + query manageReportsQuery { + allReportingGcpes( + first: 9999 + condition: { archivedAt: null } + orderBy: ID_DESC + ) @connection(key: "ManageReporting_allReportingGcpes") { + __id + edges { + node { + __id + id + rowId + createdAt + createdBy + ccbcUserByCreatedBy { + id + sessionSub + } + } + } + } + session { + sub + ...DashboardTabs_query + } + } +`; + +const StyledContainer = styled.div` + width: 100%; + height: 100%; +`; + +const ManageReporting = ({ + preloadedQuery, +}: RelayProps, manageReportsQuery>) => { + const query = usePreloadedQuery(getManageReportingQuery, preloadedQuery); + const { allReportingGcpes, session } = query; + + const reportList = + allReportingGcpes && + [...allReportingGcpes.edges].filter((data: any) => { + return ( + data.node !== null && + data.node?.ccbcUserByCreatedBy.sessionSub === session?.sub + ); + }); + + return ( + + + + + + + + ); +}; + +export default withRelay( + ManageReporting, + getManageReportingQuery, + defaultRelayOptions +); diff --git a/app/schema/mutations/reporting/archiveReportingGcpeMutation.ts b/app/schema/mutations/reporting/archiveReportingGcpeMutation.ts new file mode 100644 index 0000000000..67b23f2eae --- /dev/null +++ b/app/schema/mutations/reporting/archiveReportingGcpeMutation.ts @@ -0,0 +1,25 @@ +import { graphql } from 'react-relay'; +import { archiveReportingGcpeMutation } from '__generated__/archiveReportingGcpeMutation.graphql'; +import useMutationWithErrorMessage from '../useMutationWithErrorMessage'; + +const mutation = graphql` + mutation archiveReportingGcpeMutation( + $input: UpdateReportingGcpeByRowIdInput! + ) { + updateReportingGcpeByRowId(input: $input) { + reportingGcpe { + id + archivedAt + createdAt + } + } + } +`; + +const useArchiveReportingGcpeMutation = () => + useMutationWithErrorMessage( + mutation, + () => 'An error occured while attempting to archive the intake' + ); + +export { mutation, useArchiveReportingGcpeMutation }; diff --git a/app/tests/pages/analyst/reporting/manage-reports.test.tsx b/app/tests/pages/analyst/reporting/manage-reports.test.tsx new file mode 100644 index 0000000000..8861656581 --- /dev/null +++ b/app/tests/pages/analyst/reporting/manage-reports.test.tsx @@ -0,0 +1,204 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import compiledManageReportingQuery, { + manageReportsQuery, +} from '__generated__/manageReportsQuery.graphql'; +import ManageReporting from 'pages/analyst/reporting/manage-reports'; +import PageTestingHelper from '../../../utils/pageTestingHelper'; + +const mockQueryPayload = { + Query() { + return { + session: { + sub: '4e0ac88c-bf05-49ac-948f-7fd53c7a9fd6', + authRole: 'cbc_admin', + }, + allReportingGcpes: { + edges: [ + { + node: { + rowId: 1, + createdAt: '2024-06-28T20:05:52.383864+00:00', + ccbcUserByCreatedBy: { + sessionSub: '4e0ac88c-bf05-49ac-948f-7fd53c7a9fd6', + }, + }, + }, + { + node: { + rowId: 2, + createdAt: '2024-06-27T01:50:51.270249+00:00', + ccbcUserByCreatedBy: { + sessionSub: '500ac88c-bf05-49ac-948f-7fd53c7a9fd6', + }, + }, + }, + { + node: { + rowId: 3, + createdAt: '2024-06-26T01:51:05.63794+00:00', + ccbcUserByCreatedBy: { + sessionSub: '4e0ac88c-bf05-49ac-948f-7fd53c7a9fd6', + }, + }, + }, + { + node: { + rowId: 4, + createdAt: '2024-06-25T01:53:22.963979+00:00', + ccbcUserByCreatedBy: { + sessionSub: '500ac88c-bf05-49ac-948f-7fd53c7a9fd6', + }, + }, + }, + { + node: { + rowId: 5, + createdAt: '2024-06-24T01:55:04.948302+00:00', + ccbcUserByCreatedBy: { + sessionSub: '500ac88c-bf05-49ac-948f-7fd53c7a9fd6', + }, + }, + }, + ], + }, + }; + }, +}; + +jest.mock('@bcgov-cas/sso-express/dist/helpers'); + +global.fetch = jest.fn(() => + Promise.resolve({ + blob: () => + Promise.resolve( + new Blob(['test content'], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }) + ), + headers: { + get: (header) => { + const headers = { + rowId: '1', + }; + return headers[header]; + }, + }, + }) +) as jest.Mock; + +// Mock the window.URL functions +window.URL.createObjectURL = jest.fn(); +window.URL.revokeObjectURL = jest.fn(); + +const pageTestingHelper = new PageTestingHelper({ + pageComponent: ManageReporting, + compiledQuery: compiledManageReportingQuery, + defaultQueryResolver: mockQueryPayload, + defaultQueryVariables: {}, +}); + +describe('The Gcpe reporting page', () => { + beforeEach(() => { + pageTestingHelper.reinit(); + pageTestingHelper.setMockRouterValues({ + pathname: '/analyst/reporting/manage-reports', + }); + }); + + it('renders correct headings', async () => { + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + expect(screen.getByText('Manage My Reports')).toBeInTheDocument(); + }); + + it('load correct reports belong to user', async () => { + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + expect(screen.getAllByTestId('file-download-link')).toHaveLength(2); + expect( + screen.getByText(/Generated June 28, 2024 at 1:05 p.m. PDT/) + ).toBeInTheDocument(); + expect( + screen.getByText(/Generated June 25, 2024 at 6:51 p.m. PDT/) + ).toBeInTheDocument(); + }); + + it('downloads an existing report', async () => { + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + const firstReport = screen.getAllByTestId('file-download-link')[0]; + + await act(async () => { + fireEvent.click(firstReport); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('delete report raises a confirmation dialog', async () => { + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + const deleteButton = screen.getAllByTestId('file-delete-btn')[0]; + + fireEvent.click(deleteButton); + + expect( + screen.getByText(/Are you sure you want to delete this GCPE file/) + ).toBeInTheDocument(); + }); + + it('delete report calls correct mutation', async () => { + pageTestingHelper.loadQuery(); + pageTestingHelper.renderPage(); + + expect( + screen.getByText(/Generated June 28, 2024 at 1:05 p.m. PDT/) + ).toBeInTheDocument(); + + const deleteButton = screen.getAllByTestId('file-delete-btn')[0]; + + await act(async () => { + fireEvent.click(deleteButton); + }); + + const confirmButton = screen.getByRole('button', { + name: 'Delete', + }); + + expect(confirmButton).toBeInTheDocument(); + fireEvent.click(confirmButton); + + pageTestingHelper.expectMutationToBeCalled('archiveReportingGcpeMutation', { + input: { + reportingGcpePatch: expect.anything(), + rowId: 1, + }, + }); + + pageTestingHelper.environment.mock.resolveMostRecentOperation({ + data: { + updateReportingGcpeByRowId: { + reportingGcpe: { + rowId: 1, + id: 'string', + }, + }, + }, + }); + + await waitFor(() => { + expect( + screen.getByText(/GCPE file archived successfully/) + ).toBeInTheDocument(); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); +});