diff --git a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.jsx b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.jsx deleted file mode 100644 index f8e6bf3b36..0000000000 --- a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.jsx +++ /dev/null @@ -1,75 +0,0 @@ -import { policy } from "lib/policy" -import { PrettyDate } from "lib/components/pretty_date" -import React from "react" - -const Row = ({ label, value, children }) => { - return ( - - {label} - {value || children} - - ) -} - -export default class ErrorMessageItem extends React.Component { - state = { showDetails: false } - - toggleDetails = () => this.setState({ showDetails: !this.state.showDetails }) - - detailsView = () => ( - - - {[ - "project_id", - "id", - "resource_type", - "resource_id", - "detail_id", - "action_id", - "request_id", - "created_at", - "expires_at", - ].map((key, index) => ( - - - - - ))} - -
{key}{this.props.errorMessage[key]}
- ) - - render() { - const { errorMessage } = this.props - return ( - <> - - - { - e.preventDefault() - this.toggleDetails() - }} - > - {this.state.showDetails ? ( - - ) : ( - - )} - - {errorMessage.message_level} - - {errorMessage.user_message} - - - - - {this.state.showDetails && ( - - {this.detailsView()} - - )} - - ) - } -} diff --git a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.test.tsx b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.test.tsx new file mode 100644 index 0000000000..82117aac78 --- /dev/null +++ b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.test.tsx @@ -0,0 +1,162 @@ +import { describe, it, expect, vi } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import "@testing-library/jest-dom/vitest" +import ErrorMessageItem from "./item" + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("lib/components/pretty_date", () => ({ + PrettyDate: ({ date }: any) => {date}, +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockErrorMessage = { + id: "em-1", + project_id: "proj-1", + resource_type: "share", + resource_id: "res-1", + detail_id: "detail-1", + action_id: "action-1", + request_id: "req-1", + created_at: "2024-01-01T00:00:00Z", + expires_at: "2025-01-01T00:00:00Z", + message_level: "ERROR", + user_message: "Something went wrong", +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ErrorMessageItem", () => { + // ── Row rendering ────────────────────────────────────────────────────────── + + describe("Row rendering", () => { + it("renders message_level in first column", () => { + render( + + + + +
+ ) + expect(screen.getByText("ERROR")).toBeInTheDocument() + }) + + it("renders user_message in second column", () => { + render( + + + + +
+ ) + expect(screen.getByText("Something went wrong")).toBeInTheDocument() + }) + + it("renders PrettyDate with created_at in third column", () => { + render( + + + + +
+ ) + expect(screen.getByTestId("pretty-date")).toHaveTextContent("2024-01-01T00:00:00Z") + }) + + it("renders a toggle icon (caret-right) when details are hidden", () => { + render( + + + + +
+ ) + expect(document.querySelector(".fa-caret-right")).toBeInTheDocument() + expect(document.querySelector(".fa-caret-down")).not.toBeInTheDocument() + }) + }) + + // ── Toggle details ───────────────────────────────────────────────────────── + + describe("Toggle details", () => { + it("shows details row when toggle is clicked", () => { + render( + + + + +
+ ) + const toggle = document.querySelector("a")! + fireEvent.click(toggle) + expect(document.querySelector("tr.details")).toBeInTheDocument() + }) + + it("switches icon to caret-down when expanded", () => { + render( + + + + +
+ ) + fireEvent.click(document.querySelector("a")!) + expect(document.querySelector(".fa-caret-down")).toBeInTheDocument() + expect(document.querySelector(".fa-caret-right")).not.toBeInTheDocument() + }) + + it("hides details row when toggled twice", () => { + render( + + + + +
+ ) + const toggle = document.querySelector("a")! + fireEvent.click(toggle) + fireEvent.click(toggle) + expect(document.querySelector("tr.details")).not.toBeInTheDocument() + }) + + it("renders all detail fields in the details table", () => { + render( + + + + +
+ ) + fireEvent.click(document.querySelector("a")!) + + const expectedFields = [ + "project_id", + "id", + "resource_type", + "resource_id", + "detail_id", + "action_id", + "request_id", + "created_at", + "expires_at", + ] + for (const field of expectedFields) { + expect(screen.getByText(field)).toBeInTheDocument() + } + }) + + it("renders detail values in the details table", () => { + render( + + + + +
+ ) + fireEvent.click(document.querySelector("a")!) + expect(screen.getByText("proj-1")).toBeInTheDocument() + expect(screen.getByText("res-1")).toBeInTheDocument() + }) + }) +}) diff --git a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.tsx b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.tsx new file mode 100644 index 0000000000..2070c892b5 --- /dev/null +++ b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/item.tsx @@ -0,0 +1,86 @@ +import React, { useState } from "react" +import { PrettyDate } from "lib/components/pretty_date" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ErrorMessage { + id?: string + project_id?: string + resource_type?: string + resource_id?: string + detail_id?: string + action_id?: string + request_id?: string + created_at?: string + expires_at?: string + message_level?: string + user_message?: string +} + +interface ErrorMessageItemProps { + errorMessage: ErrorMessage +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const DETAIL_FIELDS: Array = [ + "project_id", + "id", + "resource_type", + "resource_id", + "detail_id", + "action_id", + "request_id", + "created_at", + "expires_at", +] + +const ErrorMessageItem: React.FC = ({ errorMessage }) => { + const [showDetails, setShowDetails] = useState(false) + + const toggleDetails = () => setShowDetails((prev) => !prev) + + return ( + <> + + + { + e.preventDefault() + toggleDetails() + }} + > + {showDetails ? ( + + ) : ( + + )} + + {errorMessage.message_level} + + {errorMessage.user_message} + + + + + {showDetails && ( + + + + + {DETAIL_FIELDS.map((key) => ( + + + + + ))} + +
{key}{errorMessage[key]}
+ + + )} + + ) +} + +export default ErrorMessageItem diff --git a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.jsx b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.jsx deleted file mode 100644 index a9b5eb5060..0000000000 --- a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.jsx +++ /dev/null @@ -1,78 +0,0 @@ -import { DefeatableLink } from "lib/components/defeatable_link" -import { policy } from "lib/policy" -import { Modal, Button } from "react-bootstrap" -import ErrorMessageItem from "./item" -import React from "react" - -export default class ErrorMessageList extends React.Component { - state = { show: true } - - restoreUrl = (e) => { - const type = this.props.match && this.props.match.params.type - if (!this.state.show) this.props.history.replace(type || "shares") - } - - hide = (e) => { - if (e) e.stopPropagation() - this.setState({ show: false }) - } - - loadDependencies = (props) => props.loadErrorMessagesOnce() - - componentDidMount() { - this.loadDependencies(this.props) - } - - UNSAFE_componentWillReceiveProps(nextProps) { - this.loadDependencies(nextProps) - } - - render() { - let { errorMessages } = this.props - - return ( - - - Error Log - - - {!errorMessages || errorMessages.isFetching ? ( -
- - Loading... -
- ) : ( - - - - - - - - - - {errorMessages.items.length == 0 && ( - - - - )} - {errorMessages.items.map((errorMessage, index) => ( - - ))} - -
LevelErrorCreated
No errors found.
- )} -
- - - -
- ) - } -} diff --git a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.test.tsx b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.test.tsx new file mode 100644 index 0000000000..f86e8a1692 --- /dev/null +++ b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.test.tsx @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import "@testing-library/jest-dom/vitest" +import ErrorMessageList from "./list" + +// ─── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock("react-bootstrap", () => ({ + Modal: Object.assign( + ({ show, onExited, onHide, children }: any) => ( +
+
+ ), + { + Header: ({ children }: any) =>
{children}
, + Title: ({ children }: any) =>

{children}

, + Body: ({ children }: any) =>
{children}
, + Footer: ({ children }: any) =>
{children}
, + } + ), + Button: ({ children, onClick }: any) => ( + + ), +})) + +vi.mock("./item", () => ({ + default: ({ errorMessage }: any) => ( + + {errorMessage.message_level} + {errorMessage.user_message} + + ), +})) + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const mockErrorMessages = { + isFetching: false, + items: [ + { id: "em-1", message_level: "ERROR", user_message: "Something went wrong", created_at: "2024-01-01T00:00:00Z" }, + { id: "em-2", message_level: "WARNING", user_message: "Low disk space", created_at: "2024-01-02T00:00:00Z" }, + ], +} + +const mockHistory = { replace: vi.fn() } +const mockMatch = { params: { type: "shares" } } +const mockLoadErrorMessagesOnce = vi.fn() + +const defaultProps = { + errorMessages: mockErrorMessages, + loadErrorMessagesOnce: mockLoadErrorMessagesOnce, + history: mockHistory, + match: mockMatch, +} + +const renderComponent = (props = {}) => render() + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe("ErrorMessageList", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ── Initial rendering ────────────────────────────────────────────────────── + + describe("Initial rendering", () => { + it("renders the modal", () => { + renderComponent() + expect(screen.getByTestId("modal")).toBeInTheDocument() + expect(screen.getByTestId("modal")).toHaveAttribute("data-show", "true") + }) + + it("renders the modal title", () => { + renderComponent() + expect(screen.getByTestId("modal-title")).toHaveTextContent("Error Log") + }) + + it("renders the Close button", () => { + renderComponent() + expect(screen.getByTestId("close-btn")).toBeInTheDocument() + }) + + it("calls loadErrorMessagesOnce on mount", () => { + renderComponent() + expect(mockLoadErrorMessagesOnce).toHaveBeenCalledTimes(1) + }) + }) + + // ── Loading state ────────────────────────────────────────────────────────── + + describe("Loading state", () => { + it("shows spinner when errorMessages is undefined", () => { + renderComponent({ errorMessages: undefined }) + expect(document.querySelector(".spinner")).toBeInTheDocument() + expect(screen.getByText("Loading...")).toBeInTheDocument() + }) + + it("shows spinner when isFetching is true", () => { + renderComponent({ errorMessages: { isFetching: true, items: [] } }) + expect(document.querySelector(".spinner")).toBeInTheDocument() + expect(screen.getByText("Loading...")).toBeInTheDocument() + }) + + it("shows table when errorMessages are loaded", () => { + renderComponent() + expect(document.querySelector("table.table.error-messages")).toBeInTheDocument() + }) + }) + + // ── Table content ────────────────────────────────────────────────────────── + + describe("Table content", () => { + it("renders table headers: Level, Error, Created", () => { + renderComponent() + expect(screen.getByText("Level")).toBeInTheDocument() + expect(screen.getByText("Error")).toBeInTheDocument() + expect(screen.getByText("Created")).toBeInTheDocument() + }) + + it("renders one ErrorMessageItem per error message", () => { + renderComponent() + const items = screen.getAllByTestId("error-message-item") + expect(items).toHaveLength(2) + }) + + it("shows 'No errors found.' when items list is empty", () => { + renderComponent({ errorMessages: { isFetching: false, items: [] } }) + expect(screen.getByText("No errors found.")).toBeInTheDocument() + }) + }) + + // ── Close behaviour ──────────────────────────────────────────────────────── + + describe("Close behaviour", () => { + it("hides modal when Close button is clicked", () => { + renderComponent() + fireEvent.click(screen.getByTestId("close-btn")) + expect(screen.getByTestId("modal")).toHaveAttribute("data-show", "false") + }) + + it("navigates to match.params.type on close", () => { + renderComponent() + fireEvent.click(screen.getByTestId("close-btn")) + // Simulate onExited (modal transition end) + fireEvent.transitionEnd(screen.getByTestId("modal")) + expect(mockHistory.replace).toHaveBeenCalledWith("shares") + }) + + it("navigates to 'shares' fallback when match.params.type is undefined", () => { + renderComponent({ match: { params: {} } }) + fireEvent.click(screen.getByTestId("close-btn")) + fireEvent.transitionEnd(screen.getByTestId("modal")) + expect(mockHistory.replace).toHaveBeenCalledWith("shares") + }) + + it("does not navigate when modal is still visible", () => { + renderComponent() + // Fire transition without closing first + fireEvent.transitionEnd(screen.getByTestId("modal")) + expect(mockHistory.replace).not.toHaveBeenCalled() + }) + }) +}) diff --git a/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.tsx b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.tsx new file mode 100644 index 0000000000..f2511cf223 --- /dev/null +++ b/plugins/shared_filesystem_storage/app/javascript/widgets/app/components/error_messages/list.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from "react" +import { Modal, Button } from "react-bootstrap" +import ErrorMessageItem from "./item" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ErrorMessage { + id?: string + message_level?: string + user_message?: string + created_at?: string +} + +interface ErrorMessagesState { + isFetching: boolean + items: ErrorMessage[] +} + +interface Match { + params: { type?: string } +} + +interface History { + replace: (path: string) => void +} + +interface ErrorMessageListProps { + errorMessages?: ErrorMessagesState + loadErrorMessagesOnce: () => void + match?: Match + history: History +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +const ErrorMessageList: React.FC = ({ + errorMessages, + loadErrorMessagesOnce, + match, + history, +}) => { + const [show, setShow] = useState(true) + + useEffect(() => { + loadErrorMessagesOnce() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const hide = (e?: React.MouseEvent) => { + if (e) e.stopPropagation() + setShow(false) + } + + const restoreUrl = () => { + if (!show) { + const type = match?.params?.type + history.replace(type || "shares") + } + } + + return ( + + + Error Log + + + {!errorMessages || errorMessages.isFetching ? ( +
+ + Loading... +
+ ) : ( + + + + + + + + + + {errorMessages.items.length === 0 && ( + + + + )} + {errorMessages.items.map((errorMessage, index) => ( + + ))} + +
LevelErrorCreated
No errors found.
+ )} +
+ + + +
+ ) +} + +export default ErrorMessageList