From 2f62cdc220f99a38fb8b12699594b3a2b5829983 Mon Sep 17 00:00:00 2001 From: Tulio Calil Date: Fri, 25 Jul 2025 17:41:39 -0300 Subject: [PATCH 1/3] feat: add centralized message management and delete functionality - Centralize message fetching to prevent component remounting - Implement message deletion with SQS API integration - Add HTML entity decoding for message display - Include comprehensive tests and performance optimizations - Update component architecture for --- ui/package.json | 4 +- ui/src/Main/Main.tsx | 15 +- ui/src/Queues/NewMessageModal.tsx | 97 ++++++ ui/src/Queues/QueueMessageData.ts | 44 ++- ui/src/Queues/QueueMessagesList.test.tsx | 111 +++++++ ui/src/Queues/QueueMessagesList.tsx | 353 +++++++++++++++++++++ ui/src/Queues/QueueRow.test.tsx | 122 ++++---- ui/src/Queues/QueueRow.tsx | 104 +++++-- ui/src/Queues/QueueRowDetails.tsx | 169 +++++++--- ui/src/Queues/QueuesTable.test.tsx | 220 ++++++------- ui/src/Queues/QueuesTable.tsx | 64 ++-- ui/src/Queues/RefreshQueuesData.ts | 227 ++++++++++---- ui/src/services/QueueService.test.ts | 375 ++++++++++++----------- ui/src/services/QueueService.ts | 244 ++++++++++++--- ui/src/setupTests.js | 7 +- ui/yarn.lock | 18 ++ 16 files changed, 1623 insertions(+), 551 deletions(-) create mode 100644 ui/src/Queues/NewMessageModal.tsx create mode 100644 ui/src/Queues/QueueMessagesList.test.tsx create mode 100644 ui/src/Queues/QueueMessagesList.tsx diff --git a/ui/package.json b/ui/package.json index 89eb7b5ce..12e91208d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,6 +15,7 @@ "@types/yup": "^0.29.8", "axios": "^0.21.2", "jest-environment-jsdom-sixteen": "^1.0.3", + "notistack": "^3.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-scripts": "3.4.0", @@ -44,5 +45,6 @@ "last 1 firefox version", "last 1 safari version" ] - } + }, + "proxy": "http://localhost:9324" } diff --git a/ui/src/Main/Main.tsx b/ui/src/Main/Main.tsx index 4630f6a23..cb4b547fa 100644 --- a/ui/src/Main/Main.tsx +++ b/ui/src/Main/Main.tsx @@ -1,14 +1,15 @@ import React from "react"; import NavBar from "../NavBar/NavBar"; import QueuesTable from "../Queues/QueuesTable"; +import { SnackbarProvider } from "notistack"; const Main: React.FC = () => { - return ( - <> - - - - ); + return ( + + + + + ); }; -export default Main; \ No newline at end of file +export default Main; diff --git a/ui/src/Queues/NewMessageModal.tsx b/ui/src/Queues/NewMessageModal.tsx new file mode 100644 index 000000000..7ecb510ba --- /dev/null +++ b/ui/src/Queues/NewMessageModal.tsx @@ -0,0 +1,97 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, + Button, +} from "@material-ui/core"; +import { useSnackbar } from "notistack"; + +import QueueService from "../services/QueueService"; + +interface NewMessageModalProps { + open: boolean; + onClose: () => void; + queueName: string; +} + +const NewMessageModal: React.FC = ({ + open, + onClose, + queueName, +}) => { + const [messageBody, setMessageBody] = useState(""); + const [loading, setLoading] = useState(false); + const { enqueueSnackbar } = useSnackbar(); + + const handleSendMessage = async () => { + if (!messageBody.trim()) { + enqueueSnackbar("Message body cannot be empty", { + variant: "error", + }); + return; + } + + setLoading(true); + try { + await QueueService.sendMessage(queueName, messageBody); + enqueueSnackbar("Message sent successfully!", { + variant: "success", + }); + setMessageBody(""); + onClose(); + } catch (error) { + enqueueSnackbar("Failed to send message", { + variant: "error", + }); + console.error("Error sending message:", error); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setMessageBody(""); + onClose(); + }; + + return ( + <> + + New Message - {queueName} + + setMessageBody(e.target.value)} + placeholder="Enter your message body here..." + /> + + + + + + + + ); +}; + +export default NewMessageModal; diff --git a/ui/src/Queues/QueueMessageData.ts b/ui/src/Queues/QueueMessageData.ts index b818eb0c2..7aabafaab 100644 --- a/ui/src/Queues/QueueMessageData.ts +++ b/ui/src/Queues/QueueMessageData.ts @@ -1,25 +1,43 @@ interface QueueMessagesData { - queueName: string; - currentMessagesNumber: number; - delayedMessagesNumber: number; - notVisibleMessagesNumber: number; - isOpened: boolean + queueName: string; + currentMessagesNumber: number; + delayedMessagesNumber: number; + notVisibleMessagesNumber: number; + isOpened: boolean; + messages?: QueueMessage[]; + messagesLoading?: boolean; + messagesError?: string | null; } interface QueueStatistic { - name: string; - statistics: Statistics; + name: string; + statistics: Statistics; } interface Statistics { - approximateNumberOfVisibleMessages: number; - approximateNumberOfMessagesDelayed: number; - approximateNumberOfInvisibleMessages: number; + approximateNumberOfVisibleMessages: number; + approximateNumberOfMessagesDelayed: number; + approximateNumberOfInvisibleMessages: number; } interface QueueRedrivePolicyAttribute { - deadLetterTargetArn: string, - maxReceiveCount: number + deadLetterTargetArn: string; + maxReceiveCount: number; } -export type {QueueMessagesData, QueueStatistic, QueueRedrivePolicyAttribute} \ No newline at end of file +interface QueueMessage { + messageId: string; + body: string; + sentTimestamp: string; + receiptHandle?: string; + attributes?: { [key: string]: string }; + messageAttributes?: { [key: string]: any }; + isExpanded?: boolean; +} + +export type { + QueueMessagesData, + QueueStatistic, + QueueRedrivePolicyAttribute, + QueueMessage, +}; diff --git a/ui/src/Queues/QueueMessagesList.test.tsx b/ui/src/Queues/QueueMessagesList.test.tsx new file mode 100644 index 000000000..29aba8d8c --- /dev/null +++ b/ui/src/Queues/QueueMessagesList.test.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import QueueMessagesList from "./QueueMessagesList"; +import { QueueMessage } from "./QueueMessageData"; + +const mockUpdateMessageExpandedState = jest.fn(); + +describe(" - New Features", () => { + describe("HTML Entity Decoding (New Feature)", () => { + test("decodes HTML entities in message body preview", () => { + const messageWithEntities: QueueMessage[] = [ + { + messageId: "msg-html-entities", + body: ""organizationId":"test"", + sentTimestamp: "1609459200000", + }, + ]; + + render( + + ); + + // Should display decoded version in preview + expect(screen.getByText('"organizationId":"test"')).toBeInTheDocument(); + }); + + test("decodes HTML entities in full message body when expanded", () => { + const messageWithEntities: QueueMessage[] = [ + { + messageId: "msg-html-entities", + body: ""data":"value"&<test>", + sentTimestamp: "1609459200000", + }, + ]; + + render( + + ); + + // Should display decoded version in preview (truncated) + expect(screen.getByText('"data":"value"&')).toBeInTheDocument(); + }); + }); + + describe("Props-based State Management (New Feature)", () => { + test("uses messages from props when provided", () => { + const propsMessages: QueueMessage[] = [ + { + messageId: "props-msg-1", + body: "Message from props", + sentTimestamp: "1609459200000", + }, + ]; + + render( + + ); + + expect(screen.getByText("Messages (1)")).toBeInTheDocument(); + expect(screen.getByText("Message from props")).toBeInTheDocument(); + }); + + test("shows loading state from props", () => { + render( + + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.getByRole("progressbar")).toBeInTheDocument(); + }); + + test("shows error state from props", () => { + const errorMessage = "Failed to fetch messages from parent"; + render( + + ); + + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/src/Queues/QueueMessagesList.tsx b/ui/src/Queues/QueueMessagesList.tsx new file mode 100644 index 000000000..f90f2d6b3 --- /dev/null +++ b/ui/src/Queues/QueueMessagesList.tsx @@ -0,0 +1,353 @@ +import React, { useMemo } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, + Box, + Button, + Collapse, + IconButton, + Chip, + CircularProgress, +} from "@material-ui/core"; +import { Refresh, ExpandMore, ExpandLess, Delete } from "@material-ui/icons"; +import { useSnackbar } from "notistack"; +import { QueueMessage } from "./QueueMessageData"; + +interface QueueMessagesListProps { + queueName: string; + messages?: QueueMessage[]; + loading?: boolean; + error?: string | null; + onRefreshMessages?: (queueName: string) => void; + onDeleteMessage?: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +} + +const QueueMessagesList: React.FC = ({ + queueName, + messages = [], + loading = false, + error = null, + onRefreshMessages, + onDeleteMessage, + updateMessageExpandedState, +}) => { + const { enqueueSnackbar } = useSnackbar(); + + const formatDate = useMemo( + () => (timestamp: string) => { + try { + const date = new Date(timestamp); + return date.toLocaleString(); + } catch { + return timestamp; + } + }, + [] + ); + + const toggleMessageExpansion = (messageId: string) => { + updateMessageExpandedState(queueName, messageId); + }; + + const truncateText = useMemo( + () => + (text: string, maxLength: number = 100) => { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + "..."; + }, + [] + ); + + const decodeHtmlEntities = useMemo( + () => (text: string) => { + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "text/html"); + return doc.documentElement.textContent || text; + }, + [] + ); + + const handleDeleteMessage = async ( + messageId: string, + receiptHandle?: string + ) => { + if (!receiptHandle) { + enqueueSnackbar("Cannot delete message without receiptHandle", { + variant: "warning", + }); + return; + } + + if (onDeleteMessage) { + try { + await onDeleteMessage(queueName, messageId, receiptHandle); + enqueueSnackbar("Message deleted successfully", { + variant: "success", + }); + } catch (error) { + enqueueSnackbar("Failed to delete message", { + variant: "error", + }); + console.error("Failed to delete message:", error); + } + } + }; + + return ( + + + + + Messages ({messages?.length || 0}) + + {loading && } + + + + + {error && ( + + + {error} + + + )} + + {(!messages || messages.length === 0) && !loading ? ( + + No messages found in this queue. + + ) : ( + + + + + Message ID + Body Preview + Sent Time + Attributes + Actions + + + + {messages?.map((message) => ( + + + + toggleMessageExpansion(message.messageId)} + > + {message.isExpanded ? : } + + + + + {message.messageId.substring(0, 20)}... + + + + + {truncateText(decodeHtmlEntities(message.body))} + + + + + {formatDate(message.sentTimestamp)} + + + + {message.attributes && + Object.keys(message.attributes).length > 0 ? ( + + ) : ( + + None + + )} + + + + handleDeleteMessage( + message.messageId, + message.receiptHandle + ) + } + disabled={!message.receiptHandle || loading} + title="Delete message" + > + + + + + + + + + + Full Message Details: + + + + Message ID: + + + {message.messageId} + + + + + Body: + + + {decodeHtmlEntities(message.body)} + + + {message.attributes && + Object.keys(message.attributes).length > 0 && ( + + + Attributes: + +
+ + {Object.entries(message.attributes).map( + ([key, value]) => ( + + + {key} + + + {value} + + + ) + )} + +
+
+ )} + {message.messageAttributes && + Object.keys(message.messageAttributes).length > 0 && ( + + + Message Attributes: + + + + {Object.entries( + message.messageAttributes + ).map(([key, value]) => ( + + + {key} + + + {JSON.stringify(value)} + + + ))} + +
+
+ )} + + + + + + ))} + + + )} + + ); +}; + +export default React.memo(QueueMessagesList); diff --git a/ui/src/Queues/QueueRow.test.tsx b/ui/src/Queues/QueueRow.test.tsx index e0c4b919c..031373f2f 100644 --- a/ui/src/Queues/QueueRow.test.tsx +++ b/ui/src/Queues/QueueRow.test.tsx @@ -1,69 +1,87 @@ import React from "react"; import axios from "axios"; -import {act, fireEvent, render, screen} from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import QueueTableRow from "./QueueRow"; -import {TableBody} from "@material-ui/core"; +import { TableBody } from "@material-ui/core"; import Table from "@material-ui/core/Table"; jest.mock("axios"); +const mockFetchQueueMessages = jest.fn(); +const mockDeleteMessage = jest.fn(); +const mockUpdateMessageExpandedState = jest.fn(); + beforeEach(() => { - jest.clearAllMocks(); -}) + jest.clearAllMocks(); + mockFetchQueueMessages.mockClear(); + mockDeleteMessage.mockClear(); + mockUpdateMessageExpandedState.mockClear(); +}); describe("", () => { - const queue1 = { - queueName: "queueName1", - currentMessagesNumber: 1, - delayedMessagesNumber: 2, - notVisibleMessagesNumber: 3, - isOpened: false - } - - test("renders cell values", () => { - render( - - - - -
- ) + const queue1 = { + queueName: "queueName1", + currentMessagesNumber: 1, + delayedMessagesNumber: 2, + notVisibleMessagesNumber: 3, + isOpened: false, + }; - expect(screen.queryByText("queueName1")).toBeInTheDocument(); - expect(screen.queryByText("1")).toBeInTheDocument(); - expect(screen.queryByText("2")).toBeInTheDocument(); - expect(screen.queryByText("3")).toBeInTheDocument(); - expect(screen.queryByRole("button")).toBeInTheDocument() - }); + test("renders cell values", () => { + render( + + + + +
+ ); - test("clicking button should expand queue attributes section", async () => { - const data = { - name: "queueName1", - attributes: { - attribute1: "value1", - attribute2: "value2" - } - }; - (axios.get as jest.Mock).mockResolvedValueOnce({data, status: 200}) + expect(screen.queryByText("queueName1")).toBeInTheDocument(); + expect(screen.queryByText("1")).toBeInTheDocument(); + expect(screen.queryByText("2")).toBeInTheDocument(); + expect(screen.queryByText("3")).toBeInTheDocument(); + expect(screen.queryByLabelText("open-details")).toBeInTheDocument(); + expect(screen.queryByTitle("New message")).toBeInTheDocument(); + }); - render( - - - - -
- ) + test("clicking button should expand queue attributes section", async () => { + const data = { + name: "queueName1", + attributes: { + attribute1: "value1", + attribute2: "value2", + }, + }; + (axios.get as jest.Mock).mockResolvedValueOnce({ data, status: 200 }); - expect(screen.queryByText("Queue attributes")).not.toBeInTheDocument() + render( + + + + +
+ ); - await act(async () => { - fireEvent.click(await screen.findByRole("button")) - }) + expect(screen.queryByText("Queue attributes")).not.toBeInTheDocument(); - expect(screen.queryByText("Queue attributes")).toBeInTheDocument() - expect(screen.queryByText("attribute1")) - expect(screen.queryByText("value1")) - expect(screen.queryByText("attribute2")) - expect(screen.queryByText("value1")) + await act(async () => { + fireEvent.click(await screen.findByLabelText("open-details")); }); -}); \ No newline at end of file + + expect(screen.queryByText("Queue attributes")).toBeInTheDocument(); + expect(screen.queryByText("attribute1")); + expect(screen.queryByText("value1")); + expect(screen.queryByText("attribute2")); + expect(screen.queryByText("value1")); + }); +}); diff --git a/ui/src/Queues/QueueRow.tsx b/ui/src/Queues/QueueRow.tsx index 6cd54db82..4aca0f6c2 100644 --- a/ui/src/Queues/QueueRow.tsx +++ b/ui/src/Queues/QueueRow.tsx @@ -1,37 +1,85 @@ import TableRow from "@material-ui/core/TableRow"; import TableCell from "@material-ui/core/TableCell"; import IconButton from "@material-ui/core/IconButton"; -import {KeyboardArrowDown, KeyboardArrowRight} from "@material-ui/icons"; -import React, {useState} from "react"; -import {QueueMessagesData} from "./QueueMessageData"; +import { + AddComment, + KeyboardArrowDown, + KeyboardArrowRight, +} from "@material-ui/icons"; +import React, { useState } from "react"; +import { QueueMessagesData } from "./QueueMessageData"; import RowDetails from "./QueueRowDetails"; +import NewMessageModal from "./NewMessageModal"; -function QueueTableRow(props: { row: QueueMessagesData }) { +function QueueTableRow(props: { + row: QueueMessagesData; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); - const [isExpanded, setIsExpanded] = useState(false); - - function ExpandableArrowButton(props: { isExpanded: boolean }) { - return setIsExpanded(prevState => !prevState)}> - {props.isExpanded ? : } - - } - - const {row} = props + function ExpandableArrowButton(props: { isExpanded: boolean }) { return ( - <> - - - - - {row.queueName} - {row.currentMessagesNumber} - {row.delayedMessagesNumber} - {row.notVisibleMessagesNumber} - - - - ) + setIsExpanded((prevState) => !prevState)} + > + {props.isExpanded ? : } + + ); + } + + const { row, fetchQueueMessages } = props; + return ( + <> + + + + + + {row.queueName} + + {row.currentMessagesNumber} + {row.delayedMessagesNumber} + {row.notVisibleMessagesNumber} + + setIsModalOpen(true)} + title="New message" + > + + + + + + setIsModalOpen(false)} + queueName={row.queueName} + /> + + ); } -export default QueueTableRow \ No newline at end of file +export default QueueTableRow; diff --git a/ui/src/Queues/QueueRowDetails.tsx b/ui/src/Queues/QueueRowDetails.tsx index e844e7f27..fc7d924db 100644 --- a/ui/src/Queues/QueueRowDetails.tsx +++ b/ui/src/Queues/QueueRowDetails.tsx @@ -5,49 +5,138 @@ import Box from "@material-ui/core/Box"; import Typography from "@material-ui/core/Typography"; import Table from "@material-ui/core/Table"; import TableHead from "@material-ui/core/TableHead"; -import {TableBody} from "@material-ui/core"; -import React, {useState} from "react"; +import { TableBody, Tabs, Tab } from "@material-ui/core"; +import React, { useState } from "react"; import QueueService from "../services/QueueService"; +import QueueMessagesList from "./QueueMessagesList"; +import { QueueMessagesData } from "./QueueMessageData"; -const RowDetails: React.FC<{ props: { isExpanded: boolean, queueName: string } }> = ({props}) => { +const RowDetails: React.FC<{ + props: { + isExpanded: boolean; + queueName: string; + queueData: QueueMessagesData; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; + }; +}> = ({ props }) => { + const [attributes, setAttributes] = useState>>([]); + const [activeTab, setActiveTab] = useState(0); - const [attributes, setAttributes] = useState>>([]); + function getQueueAttributes() { + QueueService.getQueueAttributes(props.queueName).then((attributes) => + setAttributes(attributes) + ); + } - function getQueueAttributes() { - QueueService.getQueueAttributes(props.queueName).then(attributes => setAttributes(attributes)) - } + const handleTabChange = (event: React.ChangeEvent<{}>, newValue: number) => { + setActiveTab(newValue); + }; + + interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; + } + + function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; return ( - - - getQueueAttributes()}> - - - Queue attributes - - - - - - Attribute Name - Attribute Value - - - - {attributes.map((attribute) => - ( - - {attribute[0]} - {attribute[1]} - - ) - )} - -
-
-
-
- ) -} - -export default RowDetails \ No newline at end of file + + ); + } + + return ( + + + getQueueAttributes()} + > + + + + + + + + + + Queue attributes + + + + + Attribute Name + Attribute Value + + + + {attributes.map((attribute) => ( + + + {attribute[0]} + + {attribute[1]} + + ))} + +
+
+
+ + + + + + +
+
+
+
+ ); +}; + +export default RowDetails; diff --git a/ui/src/Queues/QueuesTable.test.tsx b/ui/src/Queues/QueuesTable.test.tsx index 6885254a8..4c248618d 100644 --- a/ui/src/Queues/QueuesTable.test.tsx +++ b/ui/src/Queues/QueuesTable.test.tsx @@ -1,135 +1,139 @@ import React from "react"; -import {act, render, screen, waitFor} from '@testing-library/react' +import { act, render, screen, waitFor } from "@testing-library/react"; import QueuesTable from "./QueuesTable"; import axios from "axios"; -import '@testing-library/jest-dom' +import "@testing-library/jest-dom"; jest.mock("axios"); -const initialData = +const initialData = { + data: [ { - data: [ - { - name: "queueName1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - }, - { - name: "queueName2", - statistics: { - approximateNumberOfVisibleMessages: 1, - approximateNumberOfMessagesDelayed: 3, - approximateNumberOfInvisibleMessages: 7 - } - } - ] - }; + name: "queueName1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + { + name: "queueName2", + statistics: { + approximateNumberOfVisibleMessages: 1, + approximateNumberOfMessagesDelayed: 3, + approximateNumberOfInvisibleMessages: 7, + }, + }, + ], +}; beforeEach(() => { - jest.useFakeTimers(); -}) + jest.useFakeTimers(); +}); afterEach(() => { - jest.clearAllMocks(); - jest.clearAllTimers() -}) + jest.clearAllMocks(); + jest.clearAllTimers(); +}); describe("", () => { - test("Basic information about queues should be retrieved for first time without waiting for interval", async () => { - (axios.get as jest.Mock).mockResolvedValueOnce(initialData) + test("Basic information about queues should be retrieved for first time without waiting for interval", async () => { + (axios.get as jest.Mock).mockResolvedValueOnce(initialData); + + render(); + + await waitFor(() => screen.findByText("queueName1")); - render(); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("5")).toBeInTheDocument(); + expect(await screen.findByText("8")).toBeInTheDocument(); + expect(await screen.findByText("10")).toBeInTheDocument(); - await waitFor(() => screen.findByText("queueName1")) + expect(await screen.findByText("queueName2")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + expect(await screen.findByText("7")).toBeInTheDocument(); + }); - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("5")).toBeInTheDocument(); - expect(await screen.findByText("8")).toBeInTheDocument(); - expect(await screen.findByText("10")).toBeInTheDocument(); + test("Each second statistics for queue should be updated if there were updates on Backend side", async () => { + const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); + const firstUpdate = createResponseDataForQueue("queueName1", 4, 5, 6); + const secondUpdate = createResponseDataForQueue("queueName1", 7, 8, 9); - expect(await screen.findByText("queueName2")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); - expect(await screen.findByText("7")).toBeInTheDocument(); + (axios.get as jest.Mock) + .mockResolvedValueOnce(initialData) + .mockResolvedValueOnce(firstUpdate) + .mockResolvedValue(secondUpdate); + + render(); + + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); }); - test("Each second statistics for queue should be updated if there were updates on Backend side", async () => { - const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); - const firstUpdate = createResponseDataForQueue("queueName1", 4, 5, 6); - const secondUpdate = createResponseDataForQueue("queueName1", 7, 8, 9); - - (axios.get as jest.Mock) - .mockResolvedValueOnce(initialData) - .mockResolvedValueOnce(firstUpdate) - .mockResolvedValue(secondUpdate) - - render() - - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(1000) - }); - - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("4")).toBeInTheDocument(); - expect(await screen.findByText("5")).toBeInTheDocument(); - expect(await screen.findByText("6")).toBeInTheDocument(); - expect(screen.queryByText("1")).not.toBeInTheDocument(); - expect(screen.queryByText("2")).not.toBeInTheDocument(); - expect(screen.queryByText("3")).not.toBeInTheDocument(); - - act(() => { - jest.advanceTimersByTime(1000) - }); - - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("7")).toBeInTheDocument(); - expect(await screen.findByText("8")).toBeInTheDocument(); - expect(await screen.findByText("9")).toBeInTheDocument(); - expect(screen.queryByText("4")).not.toBeInTheDocument(); - expect(screen.queryByText("5")).not.toBeInTheDocument(); - expect(screen.queryByText("6")).not.toBeInTheDocument(); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("4")).toBeInTheDocument(); + expect(await screen.findByText("5")).toBeInTheDocument(); + expect(await screen.findByText("6")).toBeInTheDocument(); + expect(screen.queryByText("1")).not.toBeInTheDocument(); + expect(screen.queryByText("2")).not.toBeInTheDocument(); + expect(screen.queryByText("3")).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1000); }); - test("Statistics should not change if retrieved data has not been changed", async () => { - const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); - (axios.get as jest.Mock).mockResolvedValue(initialData); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("7")).toBeInTheDocument(); + expect(await screen.findByText("8")).toBeInTheDocument(); + expect(await screen.findByText("9")).toBeInTheDocument(); + expect(screen.queryByText("4")).not.toBeInTheDocument(); + expect(screen.queryByText("5")).not.toBeInTheDocument(); + expect(screen.queryByText("6")).not.toBeInTheDocument(); + }); - render() + test("Statistics should not change if retrieved data has not been changed", async () => { + const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); + (axios.get as jest.Mock).mockResolvedValue(initialData); - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); + render(); - act(() => { - jest.advanceTimersByTime(1000) - }); + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); - expect(await screen.findByText("queueName1")).toBeInTheDocument(); - expect(await screen.findByText("1")).toBeInTheDocument(); - expect(await screen.findByText("2")).toBeInTheDocument(); - expect(await screen.findByText("3")).toBeInTheDocument(); + act(() => { + jest.advanceTimersByTime(1000); }); + + expect(await screen.findByText("queueName1")).toBeInTheDocument(); + expect(await screen.findByText("1")).toBeInTheDocument(); + expect(await screen.findByText("2")).toBeInTheDocument(); + expect(await screen.findByText("3")).toBeInTheDocument(); + }); }); -function createResponseDataForQueue(queueName: string, numberOfVisibleMessages: number, numberOfDelayedMessages: number, numberOfInvisibleMessages: number) { - return { - data: [ - { - name: queueName, - statistics: { - approximateNumberOfVisibleMessages: numberOfVisibleMessages, - approximateNumberOfMessagesDelayed: numberOfDelayedMessages, - approximateNumberOfInvisibleMessages: numberOfInvisibleMessages - } - } - ] - } -} \ No newline at end of file +function createResponseDataForQueue( + queueName: string, + numberOfVisibleMessages: number, + numberOfDelayedMessages: number, + numberOfInvisibleMessages: number +) { + return { + data: [ + { + name: queueName, + statistics: { + approximateNumberOfVisibleMessages: numberOfVisibleMessages, + approximateNumberOfMessagesDelayed: numberOfDelayedMessages, + approximateNumberOfInvisibleMessages: numberOfInvisibleMessages, + }, + }, + ], + }; +} diff --git a/ui/src/Queues/QueuesTable.tsx b/ui/src/Queues/QueuesTable.tsx index 72d4cfe51..6ad3fc494 100644 --- a/ui/src/Queues/QueuesTable.tsx +++ b/ui/src/Queues/QueuesTable.tsx @@ -5,34 +5,50 @@ import Table from "@material-ui/core/Table"; import TableHead from "@material-ui/core/TableHead"; import TableRow from "@material-ui/core/TableRow"; import TableCell from "@material-ui/core/TableCell"; -import {TableBody} from "@material-ui/core"; +import { TableBody } from "@material-ui/core"; import "../styles/queue.css"; import QueueTableRow from "./QueueRow"; import useRefreshedQueueStatistics from "./RefreshQueuesData"; const QueuesTable: React.FC = () => { - const queuesOverallData = useRefreshedQueueStatistics(); + const { + queuesData, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, + } = useRefreshedQueueStatistics(); - return ( - - - - - - Name - Approximate number of messages - Approximate number of delayed messages - Approximate number of not visible Messages - - - - {queuesOverallData.map((row) => ( - - ))} - -
-
- ) -} + return ( + + + + + + Name + Approximate number of messages + + Approximate number of delayed messages + + + Approximate number of not visible Messages + + Actions + + + + {queuesData.map((row) => ( + + ))} + +
+
+ ); +}; -export default QueuesTable; \ No newline at end of file +export default QueuesTable; diff --git a/ui/src/Queues/RefreshQueuesData.ts b/ui/src/Queues/RefreshQueuesData.ts index 7b89aa6a2..7d7a31241 100644 --- a/ui/src/Queues/RefreshQueuesData.ts +++ b/ui/src/Queues/RefreshQueuesData.ts @@ -1,73 +1,184 @@ -import {useEffect, useState} from "react"; +import { useEffect, useState, useCallback } from "react"; import QueueService from "../services/QueueService"; -import {QueueMessagesData, QueueStatistic} from "./QueueMessageData"; +import { QueueMessagesData, QueueStatistic } from "./QueueMessageData"; -function useRefreshedQueueStatistics(): QueueMessagesData[] { - function convertQueueStatisticsToNewQueueData(newQuery: QueueStatistic) { - return { - queueName: newQuery.name, - currentMessagesNumber: newQuery.statistics.approximateNumberOfVisibleMessages, - delayedMessagesNumber: newQuery.statistics.approximateNumberOfMessagesDelayed, - notVisibleMessagesNumber: newQuery.statistics.approximateNumberOfInvisibleMessages, - isOpened: false - } as QueueMessagesData +function useRefreshedQueueStatistics(): { + queuesData: QueueMessagesData[]; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +} { + function convertQueueStatisticsToNewQueueData(newQuery: QueueStatistic) { + return { + queueName: newQuery.name, + currentMessagesNumber: + newQuery.statistics.approximateNumberOfVisibleMessages, + delayedMessagesNumber: + newQuery.statistics.approximateNumberOfMessagesDelayed, + notVisibleMessagesNumber: + newQuery.statistics.approximateNumberOfInvisibleMessages, + isOpened: false, + messages: [], + messagesLoading: false, + messagesError: null, + } as QueueMessagesData; + } + + function updateNumberOfMessagesInQueue( + newStatistics: QueueStatistic, + knownQuery: QueueMessagesData + ) { + return { + ...knownQuery, + currentMessagesNumber: + newStatistics.statistics.approximateNumberOfVisibleMessages, + delayedMessagesNumber: + newStatistics.statistics.approximateNumberOfMessagesDelayed, + notVisibleMessagesNumber: + newStatistics.statistics.approximateNumberOfInvisibleMessages, + }; + } + + const [queuesOverallData, setQueuesOverallData] = useState< + QueueMessagesData[] + >([]); + + const fetchQueueMessages = useCallback(async (queueName: string) => { + const updateQueue = ( + updater: (queue: QueueMessagesData) => QueueMessagesData + ) => { + setQueuesOverallData((prevQueues) => + prevQueues.map((queue) => + queue.queueName === queueName ? updater(queue) : queue + ) + ); + }; + + updateQueue((queue) => ({ + ...queue, + messagesLoading: true, + messagesError: null, + })); + + try { + const messages = await QueueService.getQueueMessages(queueName, 10); + + updateQueue((queue) => ({ + ...queue, + messages, + messagesLoading: false, + messagesError: null, + })); + } catch (error) { + console.error("Error fetching messages:", error); + + updateQueue((queue) => ({ + ...queue, + messagesLoading: false, + messagesError: + error instanceof Error + ? error.message + : "Failed to fetch messages. Please try again.", + })); } + }, []); + + const updateMessageExpandedState = ( + queueName: string, + messageId: string | null + ) => { + setQueuesOverallData((prevQueues) => + prevQueues.map((queue) => { + if (queue.queueName !== queueName) return queue; - function updateNumberOfMessagesInQueue(newStatistics: QueueStatistic, knownQuery: QueueMessagesData) { return { - ...knownQuery, - currentMessagesNumber: newStatistics.statistics.approximateNumberOfVisibleMessages, - delayedMessagesNumber: newStatistics.statistics.approximateNumberOfMessagesDelayed, - notVisibleMessagesNumber: newStatistics.statistics.approximateNumberOfInvisibleMessages, - } + ...queue, + messages: queue.messages?.map((msg) => ({ + ...msg, + isExpanded: + msg.messageId === messageId ? !msg.isExpanded : msg.isExpanded, + })), + }; + }) + ); + }; + + const deleteMessage = useCallback( + async (queueName: string, messageId: string, receiptHandle: string) => { + try { + await QueueService.deleteMessage(queueName, messageId, receiptHandle); + + await fetchQueueMessages(queueName); + } catch (error) { + console.error("Erro ao deletar mensagem:", error); + } + }, + [] + ); + + useEffect(() => { + function obtainInitialStatistics() { + return QueueService.getQueueListWithCorrelatedMessages().then( + (queuesStatistics) => + queuesStatistics.map(convertQueueStatisticsToNewQueueData) + ); } - const [queuesOverallData, setQueuesOverallData] = useState([]); - useEffect(() => { - function obtainInitialStatistics() { - return QueueService.getQueueListWithCorrelatedMessages().then(queuesStatistics => - queuesStatistics.map(convertQueueStatisticsToNewQueueData) + function getQueuesListWithMessages() { + QueueService.getQueueListWithCorrelatedMessages().then((statistics) => { + setQueuesOverallData((prevState) => { + return statistics.map((queueStatistics) => { + const maybeKnownQuery = prevState.find( + (queueMessageData) => + queueMessageData.queueName === queueStatistics.name ); - } - - function getQueuesListWithMessages() { - QueueService.getQueueListWithCorrelatedMessages() - .then(statistics => { - setQueuesOverallData((prevState) => { - return statistics.map(queueStatistics => { - const maybeKnownQuery = prevState.find(queueMessageData => queueMessageData.queueName === queueStatistics.name) - if (maybeKnownQuery === undefined) { - return convertQueueStatisticsToNewQueueData(queueStatistics) - } else { - return updateNumberOfMessagesInQueue(queueStatistics, maybeKnownQuery) - } - }) - }) - }) - } + if (maybeKnownQuery === undefined) { + return convertQueueStatisticsToNewQueueData(queueStatistics); + } else { + return updateNumberOfMessagesInQueue( + queueStatistics, + maybeKnownQuery + ); + } + }); + }); + }); + } - const fetchInitialStatistics = async () => { - const initialStatistics = await obtainInitialStatistics() - setQueuesOverallData((prevState) => { - if (prevState.length === 0) { - return initialStatistics - } else { - return prevState; - } - }) + const fetchInitialStatistics = async () => { + const initialStatistics = await obtainInitialStatistics(); + setQueuesOverallData((prevState) => { + if (prevState.length === 0) { + return initialStatistics; + } else { + return prevState; } + }); + }; - fetchInitialStatistics() + fetchInitialStatistics(); - const interval = setInterval(() => { - getQueuesListWithMessages() - }, 1000); - return () => { - clearInterval(interval); - }; - }, []); + const interval = setInterval(() => { + getQueuesListWithMessages(); + }, 1000); + return () => { + clearInterval(interval); + }; + }, []); - return queuesOverallData; + return { + queuesData: queuesOverallData, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, + }; } -export default useRefreshedQueueStatistics; \ No newline at end of file +export default useRefreshedQueueStatistics; diff --git a/ui/src/services/QueueService.test.ts b/ui/src/services/QueueService.test.ts index b896554a6..a4df68a9e 100644 --- a/ui/src/services/QueueService.test.ts +++ b/ui/src/services/QueueService.test.ts @@ -4,203 +4,226 @@ import QueueService from "./QueueService"; jest.mock("axios"); afterEach(() => { - jest.clearAllMocks(); -}) + jest.clearAllMocks(); +}); test("Get queue list with correlated messages should return basic information about messages in queues", async () => { - const data = - [ - { - name: "queueName1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - }, - { - name: "queueName2", - statistics: { - approximateNumberOfVisibleMessages: 1, - approximateNumberOfMessagesDelayed: 3, - approximateNumberOfInvisibleMessages: 7 - } - } - ]; - - (axios.get as jest.Mock).mockResolvedValueOnce({data}) - - await expect(QueueService.getQueueListWithCorrelatedMessages()).resolves.toEqual(data) - expect(axios.get).toBeCalledWith("statistics/queues") -}) + const data = [ + { + name: "queueName1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + { + name: "queueName2", + statistics: { + approximateNumberOfVisibleMessages: 1, + approximateNumberOfMessagesDelayed: 3, + approximateNumberOfInvisibleMessages: 7, + }, + }, + ]; + + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + await expect( + QueueService.getQueueListWithCorrelatedMessages() + ).resolves.toEqual(data); + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return empty array if response does not contain queue info", async () => { - const data: Array = []; + const data: Array = []; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); - await expect(QueueService.getQueueListWithCorrelatedMessages()).resolves.toEqual(data); - expect(axios.get).toBeCalledWith("statistics/queues") -}) + await expect( + QueueService.getQueueListWithCorrelatedMessages() + ).resolves.toEqual(data); + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing name property", async () => { - expect.assertions(2); - - const data = - [ - { - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required queueName" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await QueueService.getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required queueName"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing approximate number of visible messages property", async () => { - expect.assertions(2); - - const data = - [ - { - queueName: "name1", - statistics: { - approximateNumberOfMessagesDelayed: 8, - approximateNumberOfInvisibleMessages: 10 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required approximateNumberOfVisibleMessages" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + queueName: "name1", + statistics: { + approximateNumberOfMessagesDelayed: 8, + approximateNumberOfInvisibleMessages: 10, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await QueueService.getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required approximateNumberOfVisibleMessages"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing approximate number of delayed messages property", async () => { - expect.assertions(2); - - const data = - [ - { - queueName: "name1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfInvisibleMessages: 10 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required approximateNumberOfMessagesDelayed" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + queueName: "name1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfInvisibleMessages: 10, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await QueueService.getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required approximateNumberOfMessagesDelayed"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Get queue list with correlated messages should return validation error if queue is missing approximate number of invisible messages property", async () => { - expect.assertions(2); - - const data = - [ - { - queueName: "name1", - statistics: { - approximateNumberOfVisibleMessages: 5, - approximateNumberOfMessagesDelayed: 8 - } - } - ]; - (axios.get as jest.Mock).mockResolvedValueOnce({data}); - - try { - await QueueService.getQueueListWithCorrelatedMessages(); - } catch (e) { - expect(e.errors).toEqual([ - "Required approximateNumberOfInvisibleMessages" - ]); - } - expect(axios.get).toBeCalledWith("statistics/queues") -}) + expect.assertions(2); + + const data = [ + { + queueName: "name1", + statistics: { + approximateNumberOfVisibleMessages: 5, + approximateNumberOfMessagesDelayed: 8, + }, + }, + ]; + (axios.get as jest.Mock).mockResolvedValueOnce({ data }); + + try { + await QueueService.getQueueListWithCorrelatedMessages(); + } catch (e) { + expect(e.errors).toEqual(["Required approximateNumberOfInvisibleMessages"]); + } + expect(axios.get).toBeCalledWith("statistics/queues"); +}); test("Getting queue attributes should return empty array if it can't be found", async () => { - expect.assertions(2); + expect.assertions(2); - (axios.get as jest.Mock).mockResolvedValueOnce({status: 404}) + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 404 }); - await expect(QueueService.getQueueAttributes("queueName")).resolves.toEqual([]); - expect(axios.get).toBeCalledWith("statistics/queues/queueName"); -}) + await expect(QueueService.getQueueAttributes("queueName")).resolves.toEqual( + [] + ); + expect(axios.get).toBeCalledWith("statistics/queues/queueName"); +}); test("Timestamp related attributes should be converted to human readable dates", async () => { - const data = { - name: "QueueName", - attributes: { - CreatedTimestamp: "1605539328", - LastModifiedTimestamp: "1605539300" - } - }; - - (axios.get as jest.Mock).mockResolvedValueOnce({status: 200, data: data}) - - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ - ["CreatedTimestamp", "2020-11-16T15:08:48.000Z"], - ["LastModifiedTimestamp", "2020-11-16T15:08:20.000Z"] - ]) - expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); -}) + const data = { + name: "QueueName", + attributes: { + CreatedTimestamp: "1605539328", + LastModifiedTimestamp: "1605539300", + }, + }; + + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); + + await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ + ["CreatedTimestamp", "2020-11-16T15:08:48.000Z"], + ["LastModifiedTimestamp", "2020-11-16T15:08:20.000Z"], + ]); + expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); +}); test("RedrivePolicy attribute should be converted to easier to read format", async () => { - const data = { - name: "QueueName", - attributes: { - RedrivePolicy: "{\"deadLetterTargetArn\": \"targetArn\", \"maxReceiveCount\": 10}" - } - }; - - (axios.get as jest.Mock).mockResolvedValueOnce({status: 200, data: data}) - - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ - ["RedrivePolicy", "DeadLetterTargetArn: targetArn, MaxReceiveCount: 10"] - ]) - expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); -}) + const data = { + name: "QueueName", + attributes: { + RedrivePolicy: + '{"deadLetterTargetArn": "targetArn", "maxReceiveCount": 10}', + }, + }; + + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); + + await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ + ["RedrivePolicy", "DeadLetterTargetArn: targetArn, MaxReceiveCount: 10"], + ]); + expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); +}); test("Attributes related to amount of messages should be filtered out", async () => { - const data = { - name: "QueueName", - attributes: { - ApproximateNumberOfMessages: 10, - ApproximateNumberOfMessagesNotVisible: 5, - ApproximateNumberOfMessagesDelayed: 8, - RandomAttribute: "09203" - } - }; - - (axios.get as jest.Mock).mockResolvedValueOnce({status: 200, data: data}) - - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ - ["RandomAttribute", "09203"] - ]) - expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); -}) \ No newline at end of file + const data = { + name: "QueueName", + attributes: { + ApproximateNumberOfMessages: 10, + ApproximateNumberOfMessagesNotVisible: 5, + ApproximateNumberOfMessagesDelayed: 8, + RandomAttribute: "09203", + }, + }; + + (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); + + await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ + ["RandomAttribute", "09203"], + ]); + expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); +}); + +test("Delete message should call SQS DeleteMessage action", async () => { + const queueName = "test-queue"; + const messageId = "msg-123"; + const receiptHandle = "receipt-handle-456"; + + (axios.post as jest.Mock).mockResolvedValueOnce({ + status: 200, + data: '', + }); + + await QueueService.deleteMessage(queueName, messageId, receiptHandle); + + expect(axios.post).toHaveBeenCalledWith( + `queue/${queueName}`, + expect.any(URLSearchParams), + expect.objectContaining({ + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }) + ); + + const callArgs = (axios.post as jest.Mock).mock.calls[0]; + const params = callArgs[1] as URLSearchParams; + + expect(params.get("Action")).toBe("DeleteMessage"); + expect(params.get("ReceiptHandle")).toBe(receiptHandle); +}); diff --git a/ui/src/services/QueueService.ts b/ui/src/services/QueueService.ts index a94bd521d..e05544e2f 100644 --- a/ui/src/services/QueueService.ts +++ b/ui/src/services/QueueService.ts @@ -1,70 +1,228 @@ import * as Yup from "yup"; -import {QueueRedrivePolicyAttribute, QueueStatistic} from "../Queues/QueueMessageData"; +import { + QueueRedrivePolicyAttribute, + QueueStatistic, + QueueMessage, +} from "../Queues/QueueMessageData"; import axios from "axios"; -const instance = (!process.env.NODE_ENV || process.env.NODE_ENV === 'development') ? axios.create({baseURL: "http://localhost:9325/"}) : axios; +const instance = + !process.env.NODE_ENV || process.env.NODE_ENV === "development" + ? axios.create({ baseURL: "http://localhost:9325/" }) + : axios; -const queuesBasicInformationSchema: Yup.NotRequiredArraySchema = Yup.array().of( - Yup.object().required().shape({ - name: Yup.string().required("Required queueName"), - statistics: Yup.object().required("Statistics are required").shape({ - approximateNumberOfVisibleMessages: Yup.number().required("Required approximateNumberOfVisibleMessages"), - approximateNumberOfMessagesDelayed: Yup.number().required("Required approximateNumberOfMessagesDelayed"), - approximateNumberOfInvisibleMessages: Yup.number().required("Required approximateNumberOfInvisibleMessages") - }) +const sqsInstance = + !process.env.NODE_ENV || process.env.NODE_ENV === "development" + ? axios.create({ baseURL: "/" }) + : axios; + +const queuesBasicInformationSchema = Yup.array().of( + Yup.object() + .required() + .shape({ + name: Yup.string().required("Required queueName"), + statistics: Yup.object() + .required("Statistics are required") + .shape({ + approximateNumberOfVisibleMessages: Yup.number().required( + "Required approximateNumberOfVisibleMessages" + ), + approximateNumberOfMessagesDelayed: Yup.number().required( + "Required approximateNumberOfMessagesDelayed" + ), + approximateNumberOfInvisibleMessages: Yup.number().required( + "Required approximateNumberOfInvisibleMessages" + ), + }), }) ); async function getQueueListWithCorrelatedMessages(): Promise { - const response = await instance.get(`statistics/queues`) - const result = queuesBasicInformationSchema.validateSync(response.data) - return result === undefined ? [] : result; + const response = await instance.get(`statistics/queues`); + const result = queuesBasicInformationSchema.validateSync(response.data); + return result === undefined ? [] : (result as QueueStatistic[]); } const numberOfMessagesRelatedAttributes = [ - "ApproximateNumberOfMessages", - "ApproximateNumberOfMessagesNotVisible", - "ApproximateNumberOfMessagesDelayed", -] + "ApproximateNumberOfMessages", + "ApproximateNumberOfMessagesNotVisible", + "ApproximateNumberOfMessagesDelayed", +]; interface QueueAttributes { - attributes: AttributeNameValue, - name: string + attributes: AttributeNameValue; + name: string; } interface AttributeNameValue { - [name: string]: string; + [name: string]: string; } async function getQueueAttributes(queueName: string) { - const response = await instance.get(`statistics/queues/${queueName}`) + const response = await instance.get(`statistics/queues/${queueName}`); + if (response.status !== 200) { + console.log( + "Can't obtain attributes of " + + queueName + + " queue because of " + + response.statusText + ); + return []; + } + const data: QueueAttributes = response.data as QueueAttributes; + return Object.entries(data.attributes) + .filter( + ([attributeKey, _]) => + !numberOfMessagesRelatedAttributes.includes(attributeKey) + ) + .map(([attributeKey, attributeValue]) => [ + attributeKey, + trimAttributeValue(attributeKey, attributeValue), + ]); +} + +function trimAttributeValue(attributeName: string, attributeValue: string) { + switch (attributeName) { + case "CreatedTimestamp": + case "LastModifiedTimestamp": + return new Date(parseInt(attributeValue) * 1000).toISOString(); + case "RedrivePolicy": + const redriveAttributeValue: QueueRedrivePolicyAttribute = + JSON.parse(attributeValue); + const deadLetterTargetArn = + "DeadLetterTargetArn: " + redriveAttributeValue.deadLetterTargetArn; + const maxReceiveCount = + "MaxReceiveCount: " + redriveAttributeValue.maxReceiveCount; + return deadLetterTargetArn + ", " + maxReceiveCount; + default: + return attributeValue; + } +} + +async function sendMessage( + queueName: string, + messageBody: string +): Promise { + const params = new URLSearchParams(); + params.append("Action", "SendMessage"); + params.append("MessageBody", messageBody); + + const response = await sqsInstance.post(`queue/${queueName}`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (response.status !== 200 && response.status !== 201) { + throw new Error(`Failed to send message: ${response.statusText}`); + } +} + +async function getQueueMessages( + queueName: string, + maxResults: number = 10 +): Promise { + try { + const params = new URLSearchParams(); + params.append("Action", "ReceiveMessage"); + params.append("MaxNumberOfMessages", maxResults.toString()); + params.append("VisibilityTimeout", "0"); + params.append("WaitTimeSeconds", "0"); + + const response = await sqsInstance.post(`queue/${queueName}`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + if (response.status !== 200) { - console.log("Can't obtain attributes of " + queueName + " queue because of " + response.statusText) - return []; + console.log( + `Can't obtain messages from ${queueName} queue: ${response.statusText}` + ); + return []; } - const data: QueueAttributes = response.data as QueueAttributes - return Object.entries(data.attributes) - .filter(([attributeKey, _]) => !numberOfMessagesRelatedAttributes.includes(attributeKey)) - .map(([attributeKey, attributeValue]) => [ - attributeKey, - trimAttributeValue(attributeKey, attributeValue) - ]); + + const xmlData = response.data; + return parseReceiveMessageResponse(xmlData); + } catch (error) { + console.log(`Error fetching messages from ${queueName}:`, error); + return []; + } } -function trimAttributeValue(attributeName: string, attributeValue: string) { - switch (attributeName) { - case "CreatedTimestamp": - case "LastModifiedTimestamp": - return new Date(parseInt(attributeValue) * 1000).toISOString(); - case "RedrivePolicy": - const redriveAttributeValue: QueueRedrivePolicyAttribute = JSON.parse(attributeValue) - const deadLetterTargetArn = "DeadLetterTargetArn: " + redriveAttributeValue.deadLetterTargetArn - const maxReceiveCount = "MaxReceiveCount: " + redriveAttributeValue.maxReceiveCount - return deadLetterTargetArn + ", " + maxReceiveCount - default: - return attributeValue; +function parseReceiveMessageResponse(xmlData: string): QueueMessage[] { + try { + const messages: QueueMessage[] = []; + + const messageRegex = /([\s\S]*?)<\/Message>/g; + const messageIdRegex = /([\s\S]*?)<\/MessageId>/; + const receiptHandleRegex = /([\s\S]*?)<\/ReceiptHandle>/; + const bodyRegex = /([\s\S]*?)<\/Body>/; + const sentTimestampRegex = + /SentTimestamp<\/Name>\s*([\s\S]*?)<\/Value>/; + + let match; + while ((match = messageRegex.exec(xmlData)) !== null) { + const messageXml = match[1]; + + const messageIdMatch = messageIdRegex.exec(messageXml); + const receiptHandleMatch = receiptHandleRegex.exec(messageXml); + const bodyMatch = bodyRegex.exec(messageXml); + const sentTimestampMatch = sentTimestampRegex.exec(messageXml); + + if (messageIdMatch && bodyMatch) { + messages.push({ + messageId: messageIdMatch[1], + receiptHandle: receiptHandleMatch ? receiptHandleMatch[1] : undefined, + body: bodyMatch[1], + sentTimestamp: sentTimestampMatch + ? sentTimestampMatch[1] + : new Date().toISOString(), + attributes: {}, + messageAttributes: {}, + }); + } + } + + return messages; + } catch (error) { + console.error("Error parsing XML response:", error); + return []; + } +} +async function deleteMessage( + queueName: string, + messageId: string, + receiptHandle: string +): Promise { + try { + const params = new URLSearchParams(); + params.append("Action", "DeleteMessage"); + params.append("ReceiptHandle", receiptHandle); + + const response = await sqsInstance.post(`queue/${queueName}`, params, { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + + if (response.status !== 200) { + throw new Error(`Failed to delete message: ${response.statusText}`); } + } catch (error) { + console.error( + `Error deleting message ${messageId} from ${queueName}:`, + error + ); + throw error; + } } -export default {getQueueListWithCorrelatedMessages, getQueueAttributes} \ No newline at end of file +export default { + getQueueListWithCorrelatedMessages, + getQueueAttributes, + sendMessage, + getQueueMessages, + deleteMessage, +}; diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index 8f2609b7b..e66e614e3 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -2,4 +2,9 @@ // allows you to do things like: // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom -import '@testing-library/jest-dom'; +import "@testing-library/jest-dom"; + +jest.mock("goober", () => ({ + css: () => "", + setup: jest.fn(), +})); diff --git a/ui/yarn.lock b/ui/yarn.lock index 2da83bbd4..e22520f62 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -3433,6 +3433,11 @@ clsx@^1.0.4: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== +clsx@^1.1.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -5551,6 +5556,11 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" +goober@^2.0.33: + version "2.1.16" + resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.16.tgz#7d548eb9b83ff0988d102be71f271ca8f9c82a95" + integrity sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g== + gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -8003,6 +8013,14 @@ normalize-url@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== +notistack@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/notistack/-/notistack-3.0.2.tgz#009799c3fccddeffac58565ba1657d27616dfabd" + integrity sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA== + dependencies: + clsx "^1.1.0" + goober "^2.0.33" + npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" From 7e281aa54b93a45fef9873c2c0f311eee56ea774 Mon Sep 17 00:00:00 2001 From: Tulio Calil Date: Tue, 5 Aug 2025 12:01:38 -0300 Subject: [PATCH 2/3] Refactor Queue components and integrate Snackbar context for notifications - Replaced `notistack` with a custom Snackbar context for better control over notifications. - Simplified the QueueMessagesList component by removing unnecessary useMemo hooks and directly using utility functions for formatting and truncating text. - Updated QueueRow and QueueRowDetails components to streamline props and improve readability. - Enhanced error handling in message deletion and fetching functions with a centralized error message utility. - Added unit tests for new Snackbar context and refactored QueueService tests to align with the new structure. - Introduced utility functions for decoding HTML entities, formatting dates, and truncating text. - Cleaned up unused dependencies and ensured consistent code style across components. --- ui/package.json | 1 - ui/src/App.tsx | 7 +- ui/src/Main/Main.tsx | 5 +- ui/src/Queues/NewMessageModal.tsx | 87 ++++----- ui/src/Queues/QueueMessageData.ts | 4 +- ui/src/Queues/QueueMessagesList.test.tsx | 17 +- ui/src/Queues/QueueMessagesList.tsx | 79 +++----- ui/src/Queues/QueueRow.test.tsx | 9 +- ui/src/Queues/QueueRow.tsx | 28 +-- ui/src/Queues/QueueRowDetails.tsx | 68 ++++--- ui/src/Queues/QueuesTable.test.tsx | 11 +- ui/src/Queues/RefreshQueuesData.ts | 239 ++++++++++++----------- ui/src/context/SnackbarContext.tsx | 57 ++++++ ui/src/services/QueueService.test.ts | 230 ++++++++++++++++++++-- ui/src/services/QueueService.ts | 43 ++-- ui/src/setupTests.js | 5 - ui/src/utils/decodeHtml.ts | 5 + ui/src/utils/formatDate.ts | 8 + ui/src/utils/getErrorMessage.ts | 7 + ui/src/utils/truncateText.ts | 4 + ui/yarn.lock | 18 -- 21 files changed, 584 insertions(+), 348 deletions(-) create mode 100644 ui/src/context/SnackbarContext.tsx create mode 100644 ui/src/utils/decodeHtml.ts create mode 100644 ui/src/utils/formatDate.ts create mode 100644 ui/src/utils/getErrorMessage.ts create mode 100644 ui/src/utils/truncateText.ts diff --git a/ui/package.json b/ui/package.json index 12e91208d..f4acd4021 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,7 +15,6 @@ "@types/yup": "^0.29.8", "axios": "^0.21.2", "jest-environment-jsdom-sixteen": "^1.0.3", - "notistack": "^3.0.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-scripts": "3.4.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 5c14e2810..5fd7ddbf6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,8 +1,13 @@ import React from "react"; import Main from "./Main/Main"; +import { SnackbarProvider } from "./context/SnackbarContext"; function App() { - return
+ return ( + +
+ + ); } export default App; diff --git a/ui/src/Main/Main.tsx b/ui/src/Main/Main.tsx index cb4b547fa..75e923176 100644 --- a/ui/src/Main/Main.tsx +++ b/ui/src/Main/Main.tsx @@ -1,14 +1,13 @@ import React from "react"; import NavBar from "../NavBar/NavBar"; import QueuesTable from "../Queues/QueuesTable"; -import { SnackbarProvider } from "notistack"; const Main: React.FC = () => { return ( - + <> - + ); }; diff --git a/ui/src/Queues/NewMessageModal.tsx b/ui/src/Queues/NewMessageModal.tsx index 7ecb510ba..653d48bd4 100644 --- a/ui/src/Queues/NewMessageModal.tsx +++ b/ui/src/Queues/NewMessageModal.tsx @@ -7,9 +7,10 @@ import { TextField, Button, } from "@material-ui/core"; -import { useSnackbar } from "notistack"; +import { useSnackbar } from "../context/SnackbarContext"; -import QueueService from "../services/QueueService"; +import { sendMessage } from "../services/QueueService"; +import getErrorMessage from "../utils/getErrorMessage"; interface NewMessageModalProps { open: boolean; @@ -24,29 +25,23 @@ const NewMessageModal: React.FC = ({ }) => { const [messageBody, setMessageBody] = useState(""); const [loading, setLoading] = useState(false); - const { enqueueSnackbar } = useSnackbar(); + const { showSnackbar } = useSnackbar(); const handleSendMessage = async () => { if (!messageBody.trim()) { - enqueueSnackbar("Message body cannot be empty", { - variant: "error", - }); + showSnackbar("Message body cannot be empty"); return; } setLoading(true); try { - await QueueService.sendMessage(queueName, messageBody); - enqueueSnackbar("Message sent successfully!", { - variant: "success", - }); + await sendMessage(queueName, messageBody); + showSnackbar("Message sent successfully!"); setMessageBody(""); onClose(); } catch (error) { - enqueueSnackbar("Failed to send message", { - variant: "error", - }); - console.error("Error sending message:", error); + const errorMessage = getErrorMessage(error); + showSnackbar(`Failed to send message: ${errorMessage}`); } finally { setLoading(false); } @@ -58,39 +53,37 @@ const NewMessageModal: React.FC = ({ }; return ( - <> - - New Message - {queueName} - - setMessageBody(e.target.value)} - placeholder="Enter your message body here..." - /> - - - - - - - + + New Message - {queueName} + + setMessageBody(e.target.value)} + placeholder="Enter your message body here..." + /> + + + + + + ); }; diff --git a/ui/src/Queues/QueueMessageData.ts b/ui/src/Queues/QueueMessageData.ts index 7aabafaab..b77c35d64 100644 --- a/ui/src/Queues/QueueMessageData.ts +++ b/ui/src/Queues/QueueMessageData.ts @@ -30,8 +30,8 @@ interface QueueMessage { body: string; sentTimestamp: string; receiptHandle?: string; - attributes?: { [key: string]: string }; - messageAttributes?: { [key: string]: any }; + attributes?: Record; + messageAttributes?: Record; isExpanded?: boolean; } diff --git a/ui/src/Queues/QueueMessagesList.test.tsx b/ui/src/Queues/QueueMessagesList.test.tsx index 29aba8d8c..b56534c15 100644 --- a/ui/src/Queues/QueueMessagesList.test.tsx +++ b/ui/src/Queues/QueueMessagesList.test.tsx @@ -2,8 +2,14 @@ import React from "react"; import { render, screen } from "@testing-library/react"; import QueueMessagesList from "./QueueMessagesList"; import { QueueMessage } from "./QueueMessageData"; +import { SnackbarProvider } from "../context/SnackbarContext"; const mockUpdateMessageExpandedState = jest.fn(); +const mockOnRefreshMessages = jest.fn(); + +const renderWithSnackbarProvider = (component: React.ReactElement) => { + return render({component}); +}; describe(" - New Features", () => { describe("HTML Entity Decoding (New Feature)", () => { @@ -16,7 +22,7 @@ describe(" - New Features", () => { }, ]; - render( + renderWithSnackbarProvider( - New Features", () => { }, ]; - render( + renderWithSnackbarProvider( - New Features", () => { }, ]; - render( + renderWithSnackbarProvider( - New Features", () => { }); test("shows loading state from props", () => { - render( + renderWithSnackbarProvider( ); @@ -95,7 +102,7 @@ describe(" - New Features", () => { test("shows error state from props", () => { const errorMessage = "Failed to fetch messages from parent"; - render( + renderWithSnackbarProvider( = ({ onDeleteMessage, updateMessageExpandedState, }) => { - const { enqueueSnackbar } = useSnackbar(); - - const formatDate = useMemo( - () => (timestamp: string) => { - try { - const date = new Date(timestamp); - return date.toLocaleString(); - } catch { - return timestamp; - } - }, - [] - ); + const { showSnackbar } = useSnackbar(); const toggleMessageExpansion = (messageId: string) => { updateMessageExpandedState(queueName, messageId); }; - const truncateText = useMemo( - () => - (text: string, maxLength: number = 100) => { - if (text.length <= maxLength) return text; - return text.substring(0, maxLength) + "..."; - }, - [] - ); - - const decodeHtmlEntities = useMemo( - () => (text: string) => { - const parser = new DOMParser(); - const doc = parser.parseFromString(text, "text/html"); - return doc.documentElement.textContent || text; - }, - [] - ); - const handleDeleteMessage = async ( messageId: string, receiptHandle?: string ) => { if (!receiptHandle) { - enqueueSnackbar("Cannot delete message without receiptHandle", { - variant: "warning", - }); + showSnackbar("Cannot delete message without receiptHandle"); return; } if (onDeleteMessage) { try { await onDeleteMessage(queueName, messageId, receiptHandle); - enqueueSnackbar("Message deleted successfully", { - variant: "success", - }); + showSnackbar("Message deleted successfully"); } catch (error) { - enqueueSnackbar("Failed to delete message", { - variant: "error", - }); - console.error("Failed to delete message:", error); + const errorMessage = getErrorMessage(error); + showSnackbar(`Failed to delete message: ${errorMessage}`); } } }; @@ -117,24 +85,23 @@ const QueueMessagesList: React.FC = ({ Messages ({messages?.length || 0}) {loading && } - + {onRefreshMessages && ( + + )} {error && ( @@ -329,7 +296,7 @@ const QueueMessagesList: React.FC = ({ - {JSON.stringify(value)} + {JSON.stringify(value, null, 2)} ))} diff --git a/ui/src/Queues/QueueRow.test.tsx b/ui/src/Queues/QueueRow.test.tsx index 031373f2f..6b4dd9562 100644 --- a/ui/src/Queues/QueueRow.test.tsx +++ b/ui/src/Queues/QueueRow.test.tsx @@ -4,6 +4,7 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import QueueTableRow from "./QueueRow"; import { TableBody } from "@material-ui/core"; import Table from "@material-ui/core/Table"; +import { SnackbarProvider } from "../context/SnackbarContext"; jest.mock("axios"); @@ -11,6 +12,10 @@ const mockFetchQueueMessages = jest.fn(); const mockDeleteMessage = jest.fn(); const mockUpdateMessageExpandedState = jest.fn(); +const renderWithSnackbarProvider = (component: React.ReactElement) => { + return render({component}); +}; + beforeEach(() => { jest.clearAllMocks(); mockFetchQueueMessages.mockClear(); @@ -28,7 +33,7 @@ describe("", () => { }; test("renders cell values", () => { - render( + renderWithSnackbarProvider( ", () => { }; (axios.get as jest.Mock).mockResolvedValueOnce({ data, status: 200 }); - render( + renderWithSnackbarProvider(
Promise; deleteMessage: ( @@ -27,22 +32,21 @@ function QueueTableRow(props: { const [isExpanded, setIsExpanded] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); - function ExpandableArrowButton(props: { isExpanded: boolean }) { + function ExpandableArrowButton({ isExpanded }: { isExpanded: boolean }) { return ( setIsExpanded((prevState) => !prevState)} > - {props.isExpanded ? : } + {isExpanded ? : } ); } - const { row, fetchQueueMessages } = props; return ( <> - + @@ -64,14 +68,12 @@ function QueueTableRow(props: { Promise; - deleteMessage: ( - queueName: string, - messageId: string, - receiptHandle: string - ) => Promise; - updateMessageExpandedState: ( - queueName: string, - messageId: string | null - ) => void; - }; -}> = ({ props }) => { + isExpanded: boolean; + queueName: string; + queueData: QueueMessagesData; + fetchQueueMessages: (queueName: string) => Promise; + deleteMessage: ( + queueName: string, + messageId: string, + receiptHandle: string + ) => Promise; + updateMessageExpandedState: ( + queueName: string, + messageId: string | null + ) => void; +}> = ({ + isExpanded, + queueName, + queueData, + fetchQueueMessages, + deleteMessage, + updateMessageExpandedState, +}) => { const [attributes, setAttributes] = useState>>([]); const [activeTab, setActiveTab] = useState(0); - function getQueueAttributes() { - QueueService.getQueueAttributes(props.queueName).then((attributes) => - setAttributes(attributes) - ); + async function fetchQueueAttributes() { + const attributes = await getQueueAttributes(queueName); + setAttributes(attributes); } - const handleTabChange = (event: React.ChangeEvent<{}>, newValue: number) => { + const handleTabChange = (_event: React.ChangeEvent<{}>, newValue: number) => { setActiveTab(newValue); }; @@ -65,15 +69,15 @@ const RowDetails: React.FC<{ return ( getQueueAttributes()} + onEnter={fetchQueueAttributes} > diff --git a/ui/src/Queues/QueuesTable.test.tsx b/ui/src/Queues/QueuesTable.test.tsx index 4c248618d..be3b12bdd 100644 --- a/ui/src/Queues/QueuesTable.test.tsx +++ b/ui/src/Queues/QueuesTable.test.tsx @@ -3,6 +3,7 @@ import { act, render, screen, waitFor } from "@testing-library/react"; import QueuesTable from "./QueuesTable"; import axios from "axios"; import "@testing-library/jest-dom"; +import { SnackbarProvider } from "../context/SnackbarContext"; jest.mock("axios"); const initialData = { @@ -26,6 +27,10 @@ const initialData = { ], }; +const renderWithSnackbarProvider = (component: React.ReactElement) => { + return render({component}); +}; + beforeEach(() => { jest.useFakeTimers(); }); @@ -39,7 +44,7 @@ describe("", () => { test("Basic information about queues should be retrieved for first time without waiting for interval", async () => { (axios.get as jest.Mock).mockResolvedValueOnce(initialData); - render(); + renderWithSnackbarProvider(); await waitFor(() => screen.findByText("queueName1")); @@ -64,7 +69,7 @@ describe("", () => { .mockResolvedValueOnce(firstUpdate) .mockResolvedValue(secondUpdate); - render(); + renderWithSnackbarProvider(); expect(await screen.findByText("queueName1")).toBeInTheDocument(); expect(await screen.findByText("1")).toBeInTheDocument(); @@ -100,7 +105,7 @@ describe("", () => { const initialData = createResponseDataForQueue("queueName1", 1, 2, 3); (axios.get as jest.Mock).mockResolvedValue(initialData); - render(); + renderWithSnackbarProvider(); expect(await screen.findByText("queueName1")).toBeInTheDocument(); expect(await screen.findByText("1")).toBeInTheDocument(); diff --git a/ui/src/Queues/RefreshQueuesData.ts b/ui/src/Queues/RefreshQueuesData.ts index 7d7a31241..9b2ddc040 100644 --- a/ui/src/Queues/RefreshQueuesData.ts +++ b/ui/src/Queues/RefreshQueuesData.ts @@ -1,8 +1,13 @@ import { useEffect, useState, useCallback } from "react"; -import QueueService from "../services/QueueService"; +import { + getQueueMessages, + getQueueListWithCorrelatedMessages, + deleteMessage as deleteMessageService, +} from "../services/QueueService"; import { QueueMessagesData, QueueStatistic } from "./QueueMessageData"; +import getErrorMessage from "../utils/getErrorMessage"; -function useRefreshedQueueStatistics(): { +export default function useRefreshedQueueStatistics(): { queuesData: QueueMessagesData[]; fetchQueueMessages: (queueName: string) => Promise; deleteMessage: ( @@ -15,154 +20,125 @@ function useRefreshedQueueStatistics(): { messageId: string | null ) => void; } { - function convertQueueStatisticsToNewQueueData(newQuery: QueueStatistic) { - return { - queueName: newQuery.name, - currentMessagesNumber: - newQuery.statistics.approximateNumberOfVisibleMessages, - delayedMessagesNumber: - newQuery.statistics.approximateNumberOfMessagesDelayed, - notVisibleMessagesNumber: - newQuery.statistics.approximateNumberOfInvisibleMessages, - isOpened: false, - messages: [], - messagesLoading: false, - messagesError: null, - } as QueueMessagesData; - } - - function updateNumberOfMessagesInQueue( - newStatistics: QueueStatistic, - knownQuery: QueueMessagesData - ) { - return { - ...knownQuery, - currentMessagesNumber: - newStatistics.statistics.approximateNumberOfVisibleMessages, - delayedMessagesNumber: - newStatistics.statistics.approximateNumberOfMessagesDelayed, - notVisibleMessagesNumber: - newStatistics.statistics.approximateNumberOfInvisibleMessages, - }; - } - const [queuesOverallData, setQueuesOverallData] = useState< QueueMessagesData[] >([]); - const fetchQueueMessages = useCallback(async (queueName: string) => { - const updateQueue = ( + const updateQueue = useCallback( + ( + name: string, updater: (queue: QueueMessagesData) => QueueMessagesData ) => { setQueuesOverallData((prevQueues) => prevQueues.map((queue) => - queue.queueName === queueName ? updater(queue) : queue + queue.queueName === name ? updater(queue) : queue ) ); - }; - - updateQueue((queue) => ({ - ...queue, - messagesLoading: true, - messagesError: null, - })); - - try { - const messages = await QueueService.getQueueMessages(queueName, 10); + }, + [] + ); - updateQueue((queue) => ({ + const fetchQueueMessages = useCallback( + async (queueName: string) => { + updateQueue(queueName, (queue) => ({ ...queue, - messages, - messagesLoading: false, + messagesLoading: true, messagesError: null, })); - } catch (error) { - console.error("Error fetching messages:", error); - updateQueue((queue) => ({ - ...queue, - messagesLoading: false, - messagesError: - error instanceof Error - ? error.message - : "Failed to fetch messages. Please try again.", - })); - } - }, []); + try { + const messages = await getQueueMessages(queueName, 10); - const updateMessageExpandedState = ( - queueName: string, - messageId: string | null - ) => { - setQueuesOverallData((prevQueues) => - prevQueues.map((queue) => { - if (queue.queueName !== queueName) return queue; + updateQueue(queueName, (queue) => ({ + ...queue, + messages, + messagesLoading: false, + messagesError: null, + })); + } catch (error) { + const messagesError = getErrorMessage(error); - return { + updateQueue(queueName, (queue) => ({ ...queue, - messages: queue.messages?.map((msg) => ({ - ...msg, - isExpanded: - msg.messageId === messageId ? !msg.isExpanded : msg.isExpanded, - })), - }; - }) - ); - }; + messagesLoading: false, + messagesError, + })); + } + }, + [updateQueue] + ); + + const updateMessageExpandedState = useCallback( + (queueName: string, messageId: string | null) => { + setQueuesOverallData((prevQueues) => + prevQueues.map((queue) => { + if (queue.queueName !== queueName) return queue; + + return { + ...queue, + messages: queue.messages?.map((msg) => ({ + ...msg, + isExpanded: + msg.messageId === messageId ? !msg.isExpanded : msg.isExpanded, + })), + }; + }) + ); + }, + [] + ); const deleteMessage = useCallback( async (queueName: string, messageId: string, receiptHandle: string) => { try { - await QueueService.deleteMessage(queueName, messageId, receiptHandle); + await deleteMessageService(queueName, messageId, receiptHandle); await fetchQueueMessages(queueName); } catch (error) { console.error("Erro ao deletar mensagem:", error); } }, - [] + [fetchQueueMessages] ); - useEffect(() => { - function obtainInitialStatistics() { - return QueueService.getQueueListWithCorrelatedMessages().then( - (queuesStatistics) => - queuesStatistics.map(convertQueueStatisticsToNewQueueData) - ); - } - - function getQueuesListWithMessages() { - QueueService.getQueueListWithCorrelatedMessages().then((statistics) => { - setQueuesOverallData((prevState) => { - return statistics.map((queueStatistics) => { - const maybeKnownQuery = prevState.find( - (queueMessageData) => - queueMessageData.queueName === queueStatistics.name - ); - if (maybeKnownQuery === undefined) { - return convertQueueStatisticsToNewQueueData(queueStatistics); - } else { - return updateNumberOfMessagesInQueue( - queueStatistics, - maybeKnownQuery - ); - } - }); - }); - }); - } + async function obtainInitialStatistics() { + const statistics = await getQueueListWithCorrelatedMessages(); + return statistics.map(convertQueueStatisticsToNewQueueData); + } - const fetchInitialStatistics = async () => { - const initialStatistics = await obtainInitialStatistics(); - setQueuesOverallData((prevState) => { - if (prevState.length === 0) { - return initialStatistics; + async function getQueuesListWithMessages() { + const messages = await getQueueListWithCorrelatedMessages(); + + setQueuesOverallData((prevState) => { + return messages.map((queueStatistics) => { + const maybeKnownQuery = prevState.find( + (queueMessageData) => + queueMessageData.queueName === queueStatistics.name + ); + if (maybeKnownQuery === undefined) { + return convertQueueStatisticsToNewQueueData(queueStatistics); } else { - return prevState; + return updateNumberOfMessagesInQueue( + queueStatistics, + maybeKnownQuery + ); } }); - }; + }); + } + const fetchInitialStatistics = async () => { + const initialStatistics = await obtainInitialStatistics(); + setQueuesOverallData((prevState) => { + if (prevState.length === 0) { + return initialStatistics; + } else { + return prevState; + } + }); + }; + + useEffect(() => { fetchInitialStatistics(); const interval = setInterval(() => { @@ -181,4 +157,35 @@ function useRefreshedQueueStatistics(): { }; } -export default useRefreshedQueueStatistics; +function convertQueueStatisticsToNewQueueData( + newQuery: QueueStatistic +): QueueMessagesData { + return { + queueName: newQuery.name, + currentMessagesNumber: + newQuery.statistics.approximateNumberOfVisibleMessages, + delayedMessagesNumber: + newQuery.statistics.approximateNumberOfMessagesDelayed, + notVisibleMessagesNumber: + newQuery.statistics.approximateNumberOfInvisibleMessages, + isOpened: false, + messages: [], + messagesLoading: false, + messagesError: null, + }; +} + +function updateNumberOfMessagesInQueue( + newStatistics: QueueStatistic, + knownQuery: QueueMessagesData +) { + return { + ...knownQuery, + currentMessagesNumber: + newStatistics.statistics.approximateNumberOfVisibleMessages, + delayedMessagesNumber: + newStatistics.statistics.approximateNumberOfMessagesDelayed, + notVisibleMessagesNumber: + newStatistics.statistics.approximateNumberOfInvisibleMessages, + }; +} diff --git a/ui/src/context/SnackbarContext.tsx b/ui/src/context/SnackbarContext.tsx new file mode 100644 index 000000000..bbdc11031 --- /dev/null +++ b/ui/src/context/SnackbarContext.tsx @@ -0,0 +1,57 @@ +import React, { createContext, useContext, useState, ReactNode } from "react"; +import { Snackbar } from "@material-ui/core"; + +interface SnackbarState { + open: boolean; + message: string; +} + +interface SnackbarContextType { + showSnackbar: (message: string) => void; +} + +const SnackbarContext = createContext( + undefined +); + +interface SnackbarProviderProps { + children: ReactNode; +} + +export const SnackbarProvider: React.FC = ({ + children, +}) => { + const [snackbar, setSnackbar] = useState({ + open: false, + message: "", + }); + + const showSnackbar = (message: string) => { + setSnackbar({ open: true, message }); + }; + + const handleClose = () => { + setSnackbar((prev) => ({ ...prev, open: false })); + }; + + return ( + + {children} + + + ); +}; + +export const useSnackbar = (): SnackbarContextType => { + const context = useContext(SnackbarContext); + if (!context) { + throw new Error("useSnackbar must be used within a SnackbarProvider"); + } + return context; +}; diff --git a/ui/src/services/QueueService.test.ts b/ui/src/services/QueueService.test.ts index a4df68a9e..72d36ebfe 100644 --- a/ui/src/services/QueueService.test.ts +++ b/ui/src/services/QueueService.test.ts @@ -1,5 +1,10 @@ import axios from "axios"; -import QueueService from "./QueueService"; +import { + getQueueListWithCorrelatedMessages, + getQueueAttributes, + deleteMessage, + parseReceiveMessageResponse, +} from "./QueueService"; jest.mock("axios"); @@ -29,9 +34,7 @@ test("Get queue list with correlated messages should return basic information ab (axios.get as jest.Mock).mockResolvedValueOnce({ data }); - await expect( - QueueService.getQueueListWithCorrelatedMessages() - ).resolves.toEqual(data); + await expect(getQueueListWithCorrelatedMessages()).resolves.toEqual(data); expect(axios.get).toBeCalledWith("statistics/queues"); }); @@ -40,9 +43,7 @@ test("Get queue list with correlated messages should return empty array if respo (axios.get as jest.Mock).mockResolvedValueOnce({ data }); - await expect( - QueueService.getQueueListWithCorrelatedMessages() - ).resolves.toEqual(data); + await expect(getQueueListWithCorrelatedMessages()).resolves.toEqual(data); expect(axios.get).toBeCalledWith("statistics/queues"); }); @@ -61,7 +62,7 @@ test("Get queue list with correlated messages should return validation error if (axios.get as jest.Mock).mockResolvedValueOnce({ data }); try { - await QueueService.getQueueListWithCorrelatedMessages(); + await getQueueListWithCorrelatedMessages(); } catch (e) { expect(e.errors).toEqual(["Required queueName"]); } @@ -83,7 +84,7 @@ test("Get queue list with correlated messages should return validation error if (axios.get as jest.Mock).mockResolvedValueOnce({ data }); try { - await QueueService.getQueueListWithCorrelatedMessages(); + await getQueueListWithCorrelatedMessages(); } catch (e) { expect(e.errors).toEqual(["Required approximateNumberOfVisibleMessages"]); } @@ -105,7 +106,7 @@ test("Get queue list with correlated messages should return validation error if (axios.get as jest.Mock).mockResolvedValueOnce({ data }); try { - await QueueService.getQueueListWithCorrelatedMessages(); + await getQueueListWithCorrelatedMessages(); } catch (e) { expect(e.errors).toEqual(["Required approximateNumberOfMessagesDelayed"]); } @@ -127,7 +128,7 @@ test("Get queue list with correlated messages should return validation error if (axios.get as jest.Mock).mockResolvedValueOnce({ data }); try { - await QueueService.getQueueListWithCorrelatedMessages(); + await getQueueListWithCorrelatedMessages(); } catch (e) { expect(e.errors).toEqual(["Required approximateNumberOfInvisibleMessages"]); } @@ -139,9 +140,7 @@ test("Getting queue attributes should return empty array if it can't be found", (axios.get as jest.Mock).mockResolvedValueOnce({ status: 404 }); - await expect(QueueService.getQueueAttributes("queueName")).resolves.toEqual( - [] - ); + await expect(getQueueAttributes("queueName")).resolves.toEqual([]); expect(axios.get).toBeCalledWith("statistics/queues/queueName"); }); @@ -156,7 +155,7 @@ test("Timestamp related attributes should be converted to human readable dates", (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ + await expect(getQueueAttributes("QueueName")).resolves.toEqual([ ["CreatedTimestamp", "2020-11-16T15:08:48.000Z"], ["LastModifiedTimestamp", "2020-11-16T15:08:20.000Z"], ]); @@ -174,7 +173,7 @@ test("RedrivePolicy attribute should be converted to easier to read format", asy (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ + await expect(getQueueAttributes("QueueName")).resolves.toEqual([ ["RedrivePolicy", "DeadLetterTargetArn: targetArn, MaxReceiveCount: 10"], ]); expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); @@ -193,7 +192,7 @@ test("Attributes related to amount of messages should be filtered out", async () (axios.get as jest.Mock).mockResolvedValueOnce({ status: 200, data: data }); - await expect(QueueService.getQueueAttributes("QueueName")).resolves.toEqual([ + await expect(getQueueAttributes("QueueName")).resolves.toEqual([ ["RandomAttribute", "09203"], ]); expect(axios.get).toBeCalledWith("statistics/queues/QueueName"); @@ -209,7 +208,7 @@ test("Delete message should call SQS DeleteMessage action", async () => { data: '', }); - await QueueService.deleteMessage(queueName, messageId, receiptHandle); + await deleteMessage(queueName, messageId, receiptHandle); expect(axios.post).toHaveBeenCalledWith( `queue/${queueName}`, @@ -227,3 +226,198 @@ test("Delete message should call SQS DeleteMessage action", async () => { expect(params.get("Action")).toBe("DeleteMessage"); expect(params.get("ReceiptHandle")).toBe(receiptHandle); }); + +test("parseReceiveMessageResponse should parse XML response with single message correctly", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + Test message body + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + messageId: "msg-123", + receiptHandle: "receipt-handle-456", + body: "Test message body", + sentTimestamp: "1609459200000", + attributes: {}, + messageAttributes: {}, + }); +}); + +test("parseReceiveMessageResponse should parse XML response with multiple messages correctly", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + First message body + + SentTimestamp + 1609459200000 + + + + msg-456 + receipt-handle-789 + Second message body + + SentTimestamp + 1609459300000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + messageId: "msg-123", + receiptHandle: "receipt-handle-456", + body: "First message body", + sentTimestamp: "1609459200000", + attributes: {}, + messageAttributes: {}, + }); + expect(result[1]).toEqual({ + messageId: "msg-456", + receiptHandle: "receipt-handle-789", + body: "Second message body", + sentTimestamp: "1609459300000", + attributes: {}, + messageAttributes: {}, + }); +}); + +test("parseReceiveMessageResponse should handle message without receipt handle", () => { + const xmlResponse = ` + + + + msg-123 + Test message body + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + messageId: "msg-123", + receiptHandle: undefined, + body: "Test message body", + sentTimestamp: "1609459200000", + attributes: {}, + messageAttributes: {}, + }); +}); + +test("parseReceiveMessageResponse should handle message without sent timestamp", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + Test message body + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + const { + messageId, + receiptHandle, + body, + sentTimestamp, + attributes, + messageAttributes, + } = result[0]; + + expect(result).toHaveLength(1); + expect(messageId).toBe("msg-123"); + expect(receiptHandle).toBe("receipt-handle-456"); + expect(body).toBe("Test message body"); + expect(sentTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + expect(attributes).toEqual({}); + expect(messageAttributes).toEqual({}); +}); + +test("parseReceiveMessageResponse should return empty array for empty XML response", () => { + const xmlResponse = ` + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); + +test("parseReceiveMessageResponse should return empty array for invalid XML", () => { + const xmlResponse = "invalid xml content"; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); + +test("parseReceiveMessageResponse should handle message with missing messageId gracefully", () => { + const xmlResponse = ` + + + + receipt-handle-456 + Test message body + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); + +test("parseReceiveMessageResponse should handle message with missing body gracefully", () => { + const xmlResponse = ` + + + + msg-123 + receipt-handle-456 + + SentTimestamp + 1609459200000 + + + + `; + + const result = parseReceiveMessageResponse(xmlResponse); + + expect(result).toEqual([]); +}); diff --git a/ui/src/services/QueueService.ts b/ui/src/services/QueueService.ts index e05544e2f..9b34b7486 100644 --- a/ui/src/services/QueueService.ts +++ b/ui/src/services/QueueService.ts @@ -37,7 +37,9 @@ const queuesBasicInformationSchema = Yup.array().of( }) ); -async function getQueueListWithCorrelatedMessages(): Promise { +export async function getQueueListWithCorrelatedMessages(): Promise< + QueueStatistic[] +> { const response = await instance.get(`statistics/queues`); const result = queuesBasicInformationSchema.validateSync(response.data); return result === undefined ? [] : (result as QueueStatistic[]); @@ -58,15 +60,9 @@ interface AttributeNameValue { [name: string]: string; } -async function getQueueAttributes(queueName: string) { +export async function getQueueAttributes(queueName: string) { const response = await instance.get(`statistics/queues/${queueName}`); if (response.status !== 200) { - console.log( - "Can't obtain attributes of " + - queueName + - " queue because of " + - response.statusText - ); return []; } const data: QueueAttributes = response.data as QueueAttributes; @@ -81,7 +77,10 @@ async function getQueueAttributes(queueName: string) { ]); } -function trimAttributeValue(attributeName: string, attributeValue: string) { +export function trimAttributeValue( + attributeName: string, + attributeValue: string +) { switch (attributeName) { case "CreatedTimestamp": case "LastModifiedTimestamp": @@ -89,17 +88,17 @@ function trimAttributeValue(attributeName: string, attributeValue: string) { case "RedrivePolicy": const redriveAttributeValue: QueueRedrivePolicyAttribute = JSON.parse(attributeValue); - const deadLetterTargetArn = - "DeadLetterTargetArn: " + redriveAttributeValue.deadLetterTargetArn; - const maxReceiveCount = - "MaxReceiveCount: " + redriveAttributeValue.maxReceiveCount; - return deadLetterTargetArn + ", " + maxReceiveCount; + + const deadLetterTargetArn = `DeadLetterTargetArn: ${redriveAttributeValue.deadLetterTargetArn}`; + const maxReceiveCount = `MaxReceiveCount: ${redriveAttributeValue.maxReceiveCount}`; + + return `${deadLetterTargetArn}, ${maxReceiveCount}`; default: return attributeValue; } } -async function sendMessage( +export async function sendMessage( queueName: string, messageBody: string ): Promise { @@ -118,7 +117,7 @@ async function sendMessage( } } -async function getQueueMessages( +export async function getQueueMessages( queueName: string, maxResults: number = 10 ): Promise { @@ -150,7 +149,7 @@ async function getQueueMessages( } } -function parseReceiveMessageResponse(xmlData: string): QueueMessage[] { +export function parseReceiveMessageResponse(xmlData: string): QueueMessage[] { try { const messages: QueueMessage[] = []; @@ -191,7 +190,7 @@ function parseReceiveMessageResponse(xmlData: string): QueueMessage[] { } } -async function deleteMessage( +export async function deleteMessage( queueName: string, messageId: string, receiptHandle: string @@ -218,11 +217,3 @@ async function deleteMessage( throw error; } } - -export default { - getQueueListWithCorrelatedMessages, - getQueueAttributes, - sendMessage, - getQueueMessages, - deleteMessage, -}; diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js index e66e614e3..1dd407a63 100644 --- a/ui/src/setupTests.js +++ b/ui/src/setupTests.js @@ -3,8 +3,3 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import "@testing-library/jest-dom"; - -jest.mock("goober", () => ({ - css: () => "", - setup: jest.fn(), -})); diff --git a/ui/src/utils/decodeHtml.ts b/ui/src/utils/decodeHtml.ts new file mode 100644 index 000000000..84d4cac14 --- /dev/null +++ b/ui/src/utils/decodeHtml.ts @@ -0,0 +1,5 @@ +export default function decodeHtmlEntities(text: string) { + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "text/html"); + return doc.documentElement.textContent || text; +} diff --git a/ui/src/utils/formatDate.ts b/ui/src/utils/formatDate.ts new file mode 100644 index 000000000..43d106944 --- /dev/null +++ b/ui/src/utils/formatDate.ts @@ -0,0 +1,8 @@ +export default function formatDate(timestamp: string) { + try { + const date = new Date(timestamp); + return date.toLocaleString(); + } catch { + return timestamp; + } +} diff --git a/ui/src/utils/getErrorMessage.ts b/ui/src/utils/getErrorMessage.ts new file mode 100644 index 000000000..5ba42ba4b --- /dev/null +++ b/ui/src/utils/getErrorMessage.ts @@ -0,0 +1,7 @@ +export default function getErrorMessage(error: unknown): string { + console.error(error); + + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "An unknown error occurred"; +} diff --git a/ui/src/utils/truncateText.ts b/ui/src/utils/truncateText.ts new file mode 100644 index 000000000..d7aa58cf7 --- /dev/null +++ b/ui/src/utils/truncateText.ts @@ -0,0 +1,4 @@ +export default function truncateText(text: string, maxLength: number = 100) { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + "..."; +} diff --git a/ui/yarn.lock b/ui/yarn.lock index e22520f62..2da83bbd4 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -3433,11 +3433,6 @@ clsx@^1.0.4: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== -clsx@^1.1.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" - integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== - co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -5556,11 +5551,6 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" -goober@^2.0.33: - version "2.1.16" - resolved "https://registry.yarnpkg.com/goober/-/goober-2.1.16.tgz#7d548eb9b83ff0988d102be71f271ca8f9c82a95" - integrity sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g== - gopd@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" @@ -8013,14 +8003,6 @@ normalize-url@^3.0.0: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559" integrity sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg== -notistack@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/notistack/-/notistack-3.0.2.tgz#009799c3fccddeffac58565ba1657d27616dfabd" - integrity sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA== - dependencies: - clsx "^1.1.0" - goober "^2.0.33" - npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" From cd94a046f012ff4986c669397343403a60d2bad9 Mon Sep 17 00:00:00 2001 From: Tulio Calil Date: Wed, 27 Aug 2025 00:02:05 -0300 Subject: [PATCH 3/3] refactor: add HTML entity decoding utility and tests This commit includes: - Created decodeHtml utility function to handle HTML entity decoding - Added comprehensive unit tests for the decodeHtml utility - Created test utility module for consistent rendering with SnackbarProvider - Refactored component interfaces and nested functions to improve code structure - Updated test files to use shared renderWithSnackbarProvider utility - Applied HTML entity decoding to message display in QueueMessagesList - Fixed error message translation and improved code organization --- ui/src/Queues/QueueMessagesList.test.tsx | 8 +-- ui/src/Queues/QueueRow.test.tsx | 8 +-- ui/src/Queues/QueueRow.tsx | 20 +++---- ui/src/Queues/QueueRowDetails.tsx | 50 ++++++++--------- ui/src/Queues/QueuesTable.test.tsx | 8 +-- ui/src/Queues/RefreshQueuesData.ts | 69 ++++++++++++------------ ui/src/tests/utils.tsx | 7 +++ ui/src/utils/decodeHtml.test.ts | 41 ++++++++++++++ 8 files changed, 121 insertions(+), 90 deletions(-) create mode 100644 ui/src/tests/utils.tsx create mode 100644 ui/src/utils/decodeHtml.test.ts diff --git a/ui/src/Queues/QueueMessagesList.test.tsx b/ui/src/Queues/QueueMessagesList.test.tsx index b56534c15..ca66d38ba 100644 --- a/ui/src/Queues/QueueMessagesList.test.tsx +++ b/ui/src/Queues/QueueMessagesList.test.tsx @@ -1,16 +1,12 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import QueueMessagesList from "./QueueMessagesList"; import { QueueMessage } from "./QueueMessageData"; -import { SnackbarProvider } from "../context/SnackbarContext"; +import { renderWithSnackbarProvider } from "../tests/utils"; const mockUpdateMessageExpandedState = jest.fn(); const mockOnRefreshMessages = jest.fn(); -const renderWithSnackbarProvider = (component: React.ReactElement) => { - return render({component}); -}; - describe(" - New Features", () => { describe("HTML Entity Decoding (New Feature)", () => { test("decodes HTML entities in message body preview", () => { diff --git a/ui/src/Queues/QueueRow.test.tsx b/ui/src/Queues/QueueRow.test.tsx index 6b4dd9562..8d141e644 100644 --- a/ui/src/Queues/QueueRow.test.tsx +++ b/ui/src/Queues/QueueRow.test.tsx @@ -1,10 +1,10 @@ import React from "react"; import axios from "axios"; -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { act, fireEvent, screen } from "@testing-library/react"; import QueueTableRow from "./QueueRow"; import { TableBody } from "@material-ui/core"; import Table from "@material-ui/core/Table"; -import { SnackbarProvider } from "../context/SnackbarContext"; +import { renderWithSnackbarProvider } from "../tests/utils"; jest.mock("axios"); @@ -12,10 +12,6 @@ const mockFetchQueueMessages = jest.fn(); const mockDeleteMessage = jest.fn(); const mockUpdateMessageExpandedState = jest.fn(); -const renderWithSnackbarProvider = (component: React.ReactElement) => { - return render({component}); -}; - beforeEach(() => { jest.clearAllMocks(); mockFetchQueueMessages.mockClear(); diff --git a/ui/src/Queues/QueueRow.tsx b/ui/src/Queues/QueueRow.tsx index 9907ffd81..cba35d2d6 100644 --- a/ui/src/Queues/QueueRow.tsx +++ b/ui/src/Queues/QueueRow.tsx @@ -32,23 +32,17 @@ function QueueTableRow({ const [isExpanded, setIsExpanded] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false); - function ExpandableArrowButton({ isExpanded }: { isExpanded: boolean }) { - return ( - setIsExpanded((prevState) => !prevState)} - > - {isExpanded ? : } - - ); - } - return ( <> - + setIsExpanded((prevState) => !prevState)} + > + {isExpanded ? : } + {row.queueName} diff --git a/ui/src/Queues/QueueRowDetails.tsx b/ui/src/Queues/QueueRowDetails.tsx index 5ad8ab850..be1a4a4d7 100644 --- a/ui/src/Queues/QueueRowDetails.tsx +++ b/ui/src/Queues/QueueRowDetails.tsx @@ -11,7 +11,7 @@ import { getQueueAttributes } from "../services/QueueService"; import QueueMessagesList from "./QueueMessagesList"; import { QueueMessagesData } from "./QueueMessageData"; -const RowDetails: React.FC<{ +interface RowDetailsProps { isExpanded: boolean; queueName: string; queueData: QueueMessagesData; @@ -25,7 +25,9 @@ const RowDetails: React.FC<{ queueName: string, messageId: string | null ) => void; -}> = ({ +} + +const RowDetails: React.FC = ({ isExpanded, queueName, queueData, @@ -45,28 +47,6 @@ const RowDetails: React.FC<{ setActiveTab(newValue); }; - interface TabPanelProps { - children?: React.ReactNode; - index: number; - value: number; - } - - function TabPanel(props: TabPanelProps) { - const { children, value, index, ...other } = props; - - return ( - - ); - } - return (