diff --git a/packages/root-config/src/microfrontend-layout.html b/packages/root-config/src/microfrontend-layout.html index 20dcf9fc..a038df33 100644 --- a/packages/root-config/src/microfrontend-layout.html +++ b/packages/root-config/src/microfrontend-layout.html @@ -7,6 +7,10 @@ + + + + @@ -50,7 +54,7 @@ - +
diff --git a/packages/transaction-log/jest.config.js b/packages/transaction-log/jest.config.js index 20529f31..0bb11750 100644 --- a/packages/transaction-log/jest.config.js +++ b/packages/transaction-log/jest.config.js @@ -1,3 +1,5 @@ +const path = require('path') + module.exports = { rootDir: 'src', testEnvironment: 'jsdom', @@ -6,7 +8,11 @@ module.exports = { }, moduleNameMapper: { '\\.(css)$': 'identity-obj-proxy', - 'single-spa-react/parcel': 'single-spa-react/lib/cjs/parcel.cjs' + 'single-spa-react/parcel': 'single-spa-react/lib/cjs/parcel.cjs', + '^@jembi/openhim-core-api$': path.resolve( + __dirname, + '../openhim-core-api/src/jembi-openhim-core-api.ts' + ) }, setupFilesAfterEnv: ['@testing-library/jest-dom'] } diff --git a/packages/transaction-log/src/components/buttons/status.button.component.tsx b/packages/transaction-log/src/components/buttons/status.button.component.tsx new file mode 100644 index 00000000..23bfde11 --- /dev/null +++ b/packages/transaction-log/src/components/buttons/status.button.component.tsx @@ -0,0 +1,26 @@ +import React from 'react' +import {Button} from '@mui/material' +import {StatusButtonProps} from '../../interfaces/index.interface' + +const StatusButton: React.FC = ({status, buttonText}) => { + const buttonColor = + status === 'Processing' + ? 'info' + : status === 'Pending Async' + ? 'info' + : status === 'Successful' + ? 'success' + : status === 'Completed' + ? 'warning' + : status === 'Completed with error(s)' + ? 'warning' + : 'error' + + return ( + + ) +} + +export default StatusButton diff --git a/packages/transaction-log/src/components/common/app.main.component.tsx b/packages/transaction-log/src/components/common/app.main.component.tsx index 5d5471a1..296cc018 100644 --- a/packages/transaction-log/src/components/common/app.main.component.tsx +++ b/packages/transaction-log/src/components/common/app.main.component.tsx @@ -41,6 +41,8 @@ const App: React.FC = () => { const [client, setClient] = useState(NO_FILTER) const [method, setMethod] = useState(NO_FILTER) const [clients, setClients] = useState([]) + const [loading, setLoading] = useState(false) + const [timestampFilter, setTimestampFilter] = useState(null) const fetchTransactionLogs = useCallback( async (timestampFilter?: string) => { @@ -112,17 +114,20 @@ const App: React.FC = () => { fetchParams.filterPage = 0 } - const transactions = await getTransactions(fetchParams); + const newTransactions = await getTransactions(fetchParams) - const transactionsWithChannelDetails = await Promise.all( - transactions.map(async transaction => { + const newTransactionsWithChannelDetails = await Promise.all( + newTransactions.map(async transaction => { const channelName = await fetchChannelDetails(transaction.channelID) const clientName = await fetchClientDetails(transaction.clientID) return {...transaction, channelName, clientName} }) ) - setTransactions(transactionsWithChannelDetails) + setTransactions(prevTransactions => [ + ...newTransactionsWithChannelDetails, + ...prevTransactions + ]) } catch (error) { console.error('Error fetching logs:', error) } @@ -140,7 +145,8 @@ const App: React.FC = () => { path, param, client, - method + method, + loading ] ) @@ -165,6 +171,21 @@ const App: React.FC = () => { fetchAvailableChannels(), fetchAvailableClients() }, [fetchTransactionLogs, fetchAvailableChannels, fetchAvailableClients]) + useEffect(() => { + const interval = setInterval(() => { + const currentTimestamp = new Date().toISOString() + setTimestampFilter(currentTimestamp) + }, 5000) + + return () => clearInterval(interval) + }, []) + + useEffect(() => { + if (timestampFilter) { + fetchTransactionLogs(timestampFilter) + } + }, [timestampFilter, fetchTransactionLogs]) + const handleTabChange = (event: React.ChangeEvent<{}>, newValue: number) => { setTabValue(newValue) } @@ -191,8 +212,16 @@ const App: React.FC = () => { } } - const loadMore = () => { - setLimit(prevLimit => prevLimit + 20) + const loadMore = async () => { + setLoading(true) + try { + setLimit(prevLimit => prevLimit + 20) + + await fetchTransactionLogs() + } catch (error) { + } finally { + setLoading(false) + } } const filteredTransactions = transactions.filter(transaction => { @@ -208,8 +237,8 @@ const App: React.FC = () => { ].some(field => field?.toLowerCase().includes(searchTerm)) }) - const handleRowClick = (transaction) => { - console.log('Transaction clicked:', transaction); + const handleRowClick = transaction => { + console.log('Transaction clicked:', transaction) } return ( @@ -311,6 +340,7 @@ const App: React.FC = () => { diff --git a/packages/transaction-log/src/components/common/transactionlog.datatable.component.tsx b/packages/transaction-log/src/components/common/transactionlog.datatable.component.tsx index 6dc8cba5..3ff6b7fd 100644 --- a/packages/transaction-log/src/components/common/transactionlog.datatable.component.tsx +++ b/packages/transaction-log/src/components/common/transactionlog.datatable.component.tsx @@ -11,25 +11,68 @@ import { Typography, Button, IconButton, - TableFooter + TableFooter, + CircularProgress } from '@mui/material' import SettingsIcon from '@mui/icons-material/Settings' import SettingsDialog from '../dialogs/settings.dialog.component' import {ChevronRight} from '@mui/icons-material' +import LockIcon from '@mui/icons-material/Lock' +import convertTimestampFormat from '../helpers/timestampformat.helper.component' +import StatusButton from '../buttons/status.button.component' const TransactionLogTable: React.FC<{ transactions: any[] loadMore: () => void - onRowClick: (transaction) => void -}> = ({transactions, loadMore, onRowClick}) => { + loading: boolean + onRowClick: (transaction: any) => void +}> = ({transactions, loadMore, onRowClick, loading}) => { const [settingsOpen, setSettingsOpen] = useState(false) const [openInNewTab, setOpenInNewTab] = useState(false) const [autoUpdate, setAutoUpdate] = useState(false) + const [selectedRows, setSelectedRows] = useState>(new Set()) + const [selectAll, setSelectAll] = useState(false) const handleSettingsApply = () => { setSettingsOpen(false) } + const handleRowClick = (event: React.MouseEvent, transaction: {_id: any}) => { + const nonClickableColumnClass = 'non-clickable-column' + if ((event.target as HTMLElement).closest(`.${nonClickableColumnClass}`)) { + return + } + const transactionDetailsUrl = `/#!/transactions/${transaction._id}` + + if (openInNewTab) { + window.open(transactionDetailsUrl, '_blank') + } else { + window.location.href = transactionDetailsUrl + } + } + + const handleRowSelect = (rowIndex: number) => { + setSelectedRows(prevSelectedRows => { + const newSelectedRows = new Set(prevSelectedRows) + if (newSelectedRows.has(rowIndex)) { + newSelectedRows.delete(rowIndex) + } else { + newSelectedRows.add(rowIndex) + } + return newSelectedRows + }) + } + + const handleSelectAll = () => { + if (selectAll) { + setSelectedRows(new Set()) + } else { + const allRowIndexes = transactions.map((_, index) => index) + setSelectedRows(new Set(allRowIndexes)) + } + setSelectAll(!selectAll) + } + return ( - + Type Method @@ -65,29 +108,38 @@ const TransactionLogTable: React.FC<{ Params Channel Client + Status Time {transactions.map((transaction, index) => ( - onRowClick(transaction)}> - - + handleRowClick(event, transaction)} + > + + handleRowSelect(index)} + /> - - - - + + {transaction.request.method} {transaction.request.host} @@ -96,15 +148,31 @@ const TransactionLogTable: React.FC<{ {transaction.request.params} {transaction.channelName} {transaction.clientName} - {transaction.request.timestamp} + + + + + {convertTimestampFormat(transaction.request.timestamp)} + ))} - - diff --git a/packages/transaction-log/src/components/dialogs/reruntransactions.dialog.component.tsx b/packages/transaction-log/src/components/dialogs/reruntransactions.dialog.component.tsx new file mode 100644 index 00000000..1022cdfe --- /dev/null +++ b/packages/transaction-log/src/components/dialogs/reruntransactions.dialog.component.tsx @@ -0,0 +1,184 @@ +import React from 'react' +import { + Modal, + Button, + Typography, + Box, + FormControl, + InputLabel, + Select, + MenuItem, + Checkbox, + FormControlLabel, + Alert +} from '@mui/material' + +interface Batch { + value: number + label: string +} + +interface Props { + open: boolean + handleClose: () => void + rerunSuccess: boolean | undefined + transactionsSelected: number[] + transactionsCount: number + alerts: {rerun: {type?: string; msg: string}[]} + bulkRerunActive: boolean + batchSizes: Batch[] + taskSetup: {batchSize: number; paused: boolean} + setTaskSetup: React.Dispatch< + React.SetStateAction<{batchSize: number; paused: boolean}> + > + confirmRerun: () => void +} + +const RerunTransactionsConfirmationModal: React.FC = ({ + open, + handleClose, + rerunSuccess, + transactionsSelected, + transactionsCount, + alerts, + bulkRerunActive, + batchSizes, + taskSetup, + setTaskSetup, + confirmRerun +}) => { + const handleBatchSizeChange = ( + event: React.ChangeEvent<{value: unknown}> + ) => { + setTaskSetup(prevState => ({ + ...prevState, + batchSize: event.target.value as number + })) + } + + const handlePausedChange = (event: React.ChangeEvent) => { + setTaskSetup(prevState => ({ + ...prevState, + paused: event.target.checked + })) + } + + return ( + + + + + +   Transactions Rerun Confirmation + + + + + {!rerunSuccess ? ( + <> + {transactionsSelected.length === 0 && transactionsCount === 0 ? ( + + You have not selected any transactions to be rerun.
+
+ Please select the transactions you wish to rerun by ticking + the boxes on the left and try again +
+ ) : ( + + {transactionsSelected.length === 1 && ( +
+ You have selected that transaction{' '} + #{transactionsSelected[0]} should be + rerun. +
+ )} + {(transactionsSelected.length > 1 || transactionsCount) && ( +
+ You have selected a total of{' '} + + {bulkRerunActive + ? transactionsCount + : transactionsSelected.length} + {' '} + transactions to be rerun. +
+ )} + {alerts.rerun.map((alert, index) => ( + + {alert.msg} + + ))} +
+ Are you sure you wish to proceed with this operation? +
+
+ )} + + ) : ( + + {transactionsSelected.length === 1 + ? 'Your selected transaction has been added to the queue to be rerun.' + : 'Your selected transactions have been added to the queue to be rerun.'} + + )} +
+ {!rerunSuccess && + (transactionsSelected.length > 1 || transactionsCount > 0) && ( + + + Batch size + + + + } + label="Paused" + sx={{ml: 2}} + /> + + )} + + + {transactionsSelected.length > 0 || transactionsCount > 0 ? ( + + ) : null} + +
+
+ ) +} + +export default RerunTransactionsConfirmationModal diff --git a/packages/transaction-log/src/components/helpers/loader.helper.component.tsx b/packages/transaction-log/src/components/helpers/loader.helper.component.tsx new file mode 100644 index 00000000..00f0c3a2 --- /dev/null +++ b/packages/transaction-log/src/components/helpers/loader.helper.component.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import {CircularProgress, Box} from '@mui/material' + +const Loader: React.FC = () => { + return ( + + + + ) +} + +export default Loader diff --git a/packages/transaction-log/src/components/helpers/timestampformat.helper.component.tsx b/packages/transaction-log/src/components/helpers/timestampformat.helper.component.tsx new file mode 100644 index 00000000..6fda0d02 --- /dev/null +++ b/packages/transaction-log/src/components/helpers/timestampformat.helper.component.tsx @@ -0,0 +1,28 @@ +const convertTimestampFormat = (isoStringDate: Date) => { + const date = new Date(isoStringDate) + + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZoneName: 'short' + } + const formattedDate = date.toLocaleString('en-CA', options) + const [datePart, timePart] = formattedDate.split(/, | /) + + if (isoStringDate instanceof Date === false) isoStringDate = new Date() + const off = isoStringDate.getTimezoneOffset() * -1 + isoStringDate = new Date(isoStringDate.getTime() + off * 60000) + + return ( + `${datePart.replace(/\//g, '-')} ${timePart}` + + (off < 0 ? '-' : ' +') + + ('0' + Math.abs(Math.floor(off / 60))).substr(-2) + + ('0' + Math.abs(off % 60)).substr(-2) + ) +} + +export default convertTimestampFormat diff --git a/packages/transaction-log/src/declarations.d.ts b/packages/transaction-log/src/declarations.d.ts index 9a7c00a3..e135b4ab 100644 --- a/packages/transaction-log/src/declarations.d.ts +++ b/packages/transaction-log/src/declarations.d.ts @@ -40,3 +40,4 @@ declare module '*.svg' { declare module '@jembi/openhim-core-api' declare module '@jembi/openhim-theme' +declare module '@jembi/legacy-app' diff --git a/packages/transaction-log/src/interfaces/index.interface.ts b/packages/transaction-log/src/interfaces/index.interface.ts index aba50437..3435e6d6 100644 --- a/packages/transaction-log/src/interfaces/index.interface.ts +++ b/packages/transaction-log/src/interfaces/index.interface.ts @@ -68,3 +68,8 @@ export interface SettingsDialogProps { autoUpdate: boolean setAutoUpdate: (value: boolean) => void } + +export interface StatusButtonProps { + status: string + buttonText: string +} diff --git a/packages/transaction-log/src/root.component.test.tsx b/packages/transaction-log/src/root.component.test.tsx index 0b4f8eca..e776319e 100644 --- a/packages/transaction-log/src/root.component.test.tsx +++ b/packages/transaction-log/src/root.component.test.tsx @@ -3,5 +3,5 @@ import TransactionsLogRootApp from './root.component' import React from 'react' describe('Root component', () => { - it.skip('should be in the document', () => {}) + it.skip('should be in the document', () => {}) }) diff --git a/packages/transaction-log/src/services/api.service.spec.ts b/packages/transaction-log/src/services/api.service.spec.ts new file mode 100644 index 00000000..a169956b --- /dev/null +++ b/packages/transaction-log/src/services/api.service.spec.ts @@ -0,0 +1,145 @@ +import { + getClients, + getClientById, + getChannels, + getChannelById, + getTransactions +} from './api.service' +import { + fetchClients, + fetchClientById, + fetchChannels, + fetchChannelById, + fetchTransactions +} from '@jembi/openhim-core-api' +import {Client, Channel} from '../types' + +jest.mock('@jembi/openhim-core-api', () => ({ + fetchClients: jest.fn(), + fetchClientById: jest.fn(), + fetchChannels: jest.fn(), + fetchChannelById: jest.fn(), + fetchTransactions: jest.fn() +})) + +describe('API Service Tests', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe('getClients', () => { + it('should return a list of clients', async () => { + const mockClients: Client[] = [ + {_id: '1', name: 'Client One'}, + {_id: '2', name: 'Client Two'} + ] + + ;(fetchClients as jest.Mock).mockResolvedValue(mockClients) + + const clients = await getClients() + expect(clients).toEqual(mockClients) + expect(fetchClients).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if fetchClients fails', async () => { + const errorMessage = 'Failed to fetch clients' + ;(fetchClients as jest.Mock).mockRejectedValue(new Error(errorMessage)) + + await expect(getClients()).rejects.toThrow(errorMessage) + expect(fetchClients).toHaveBeenCalledTimes(1) + }) + }) + + describe('getClientById', () => { + it('should return a client by ID', async () => { + const mockClient: Client = {_id: '1', name: 'Client One'} + + ;(fetchClientById as jest.Mock).mockResolvedValue(mockClient) + + const client = await getClientById('1') + expect(client).toEqual(mockClient) + expect(fetchClientById).toHaveBeenCalledWith('1') + }) + + it('should throw an error if fetchClientById fails', async () => { + const errorMessage = 'Failed to fetch client' + ;(fetchClientById as jest.Mock).mockRejectedValue(new Error(errorMessage)) + + await expect(getClientById('1')).rejects.toThrow(errorMessage) + expect(fetchClientById).toHaveBeenCalledWith('1') + }) + }) + + describe('getChannels', () => { + it('should return a list of channels', async () => { + const mockChannels: Channel[] = [ + {_id: '1', name: 'Channel One'}, + {_id: '2', name: 'Channel Two'} + ] + + ;(fetchChannels as jest.Mock).mockResolvedValue(mockChannels) + + const channels = await getChannels() + expect(channels).toEqual(mockChannels) + expect(fetchChannels).toHaveBeenCalledTimes(1) + }) + + it('should throw an error if fetchChannels fails', async () => { + const errorMessage = 'Failed to fetch channels' + ;(fetchChannels as jest.Mock).mockRejectedValue(new Error(errorMessage)) + + await expect(getChannels()).rejects.toThrow(errorMessage) + expect(fetchChannels).toHaveBeenCalledTimes(1) + }) + }) + + describe('getChannelById', () => { + it('should return a channel by ID', async () => { + const mockChannel: Channel = {_id: '1', name: 'Channel One'} + + ;(fetchChannelById as jest.Mock).mockResolvedValue(mockChannel) + + const channel = await getChannelById('1') + expect(channel).toEqual(mockChannel) + expect(fetchChannelById).toHaveBeenCalledWith('1') + }) + + it('should throw an error if fetchChannelById fails', async () => { + const errorMessage = 'Failed to fetch channel' + ;(fetchChannelById as jest.Mock).mockRejectedValue( + new Error(errorMessage) + ) + + await expect(getChannelById('1')).rejects.toThrow(errorMessage) + expect(fetchChannelById).toHaveBeenCalledWith('1') + }) + }) + + describe('getTransactions', () => { + it('should return a list of transactions', async () => { + const mockTransactions = [ + {id: '1', type: 'Transaction One'}, + {id: '2', type: 'Transaction Two'} + ] + + ;(fetchTransactions as jest.Mock).mockResolvedValue(mockTransactions) + + const transactions = await getTransactions({}) + expect(transactions).toEqual(mockTransactions) + + expect(fetchTransactions).toHaveBeenCalledWith({}) + }) + + it('should throw an error if fetchTransactions fails', async () => { + const errorMessage = 'Failed to fetch transactions' + + ;(fetchTransactions as jest.Mock).mockRejectedValue( + new Error(errorMessage) + ) + + await expect(getTransactions({})).rejects.toThrow(errorMessage) + + expect(fetchTransactions).toHaveBeenCalledWith({}) + }) + }) +}) diff --git a/packages/transaction-log/src/services/api.service.ts b/packages/transaction-log/src/services/api.service.ts index ac3c713e..259d3cda 100644 --- a/packages/transaction-log/src/services/api.service.ts +++ b/packages/transaction-log/src/services/api.service.ts @@ -47,13 +47,9 @@ export async function getChannelById(id: String): Promise { } } -export async function getTransactions( - filters: {} -): Promise { +export async function getTransactions(filters: {}): Promise { try { - const transactions = await fetchTransactions( - filters - ) + const transactions = await fetchTransactions(filters) return transactions } catch (error) {