diff --git a/docs/developer/contribute.md b/docs/developer/contribute.md index bf6841ea..ebd7f46d 100644 --- a/docs/developer/contribute.md +++ b/docs/developer/contribute.md @@ -20,11 +20,10 @@ - **Testing**: - - **Component Testing**: Write tests for your components to ensure they work as expected. Use [Jest](https://jestjs.io/) for unit testing and snapshot testing of your React components. + - **Component Testing**: Write tests for your stories to ensure they work as expected. Use [Jest](https://jestjs.io/) for unit testing and snapshot testing of your React components. - **Application Testing**: Use [Cypress](https://www.cypress.io/) for end-to-end testing to simulate real user interactions and ensure your application behaves correctly. - **Test Coverage**: Maintain good test coverage to ensure that your critical features are well-protected during updates. Tools like Jest provide [coverage reports](https://jestjs.io/docs/code-coverage) that help you identify untested parts of your code. - - **Accessibility**: Make your application accessible to all users. Use semantic HTML, ARIA attributes, and test your application with different screen sizes and assistive technologies. By following these practices, you'll ensure that your codebase remains robust, secure, and maintainable. diff --git a/packages/diracx-web-components/.storybook/main.ts b/packages/diracx-web-components/.storybook/main.ts index cba601a3..e2990ab9 100644 --- a/packages/diracx-web-components/.storybook/main.ts +++ b/packages/diracx-web-components/.storybook/main.ts @@ -37,20 +37,13 @@ const config: StorybookConfig = { config.resolve.alias = { ...config.resolve.alias, "@axa-fr/react-oidc": require.resolve( - "../stories/mocks/react-oidc.mock.ts", + "../stories/mocks/react-oidc.mock.tsx", ), - "@actual/react-oidc": require.resolve("@axa-fr/react-oidc"), - - "@actual/hooks/metadata$": require.resolve("../src/hooks/metadata"), "../../hooks/metadata": require.resolve( - "../stories/mocks/metadata.mock.ts", - ), - - "@actual/components/JobMonitor/JobDataService$": require.resolve( - "../src/components/JobMonitor/JobDataService.ts", + "../stories/mocks/metadata.mock.tsx", ), "./JobDataService": require.resolve( - "../stories/mocks/JobDataService.mock.ts", + "../stories/mocks/JobDataService.mock.tsx", ), }; return config; diff --git a/packages/diracx-web-components/jest.config.js b/packages/diracx-web-components/jest.config.js index 273bf428..b1dd0865 100644 --- a/packages/diracx-web-components/jest.config.js +++ b/packages/diracx-web-components/jest.config.js @@ -9,6 +9,12 @@ const config = { // The test environment that will be used for testing testEnvironment: "jest-environment-jsdom", + + moduleNameMapper: { + "^@axa-fr/react-oidc$": "/stories/mocks/react-oidc.mock.tsx", + "^../../hooks/metadata$": "/stories/mocks/metadata.mock.tsx", + "^./JobDataService$": "/stories/mocks/JobDataService.mock.tsx", + }, }; export default config; diff --git a/packages/diracx-web-components/jest.setup.ts b/packages/diracx-web-components/jest.setup.ts index d0de870d..d35fa5bc 100644 --- a/packages/diracx-web-components/jest.setup.ts +++ b/packages/diracx-web-components/jest.setup.ts @@ -1 +1,3 @@ import "@testing-library/jest-dom"; + +jest.mock("@axa-fr/react-oidc"); diff --git a/packages/diracx-web-components/src/components/BaseApp/BaseApp.tsx b/packages/diracx-web-components/src/components/BaseApp/BaseApp.tsx index bbc33568..b76bc363 100644 --- a/packages/diracx-web-components/src/components/BaseApp/BaseApp.tsx +++ b/packages/diracx-web-components/src/components/BaseApp/BaseApp.tsx @@ -1,6 +1,6 @@ "use client"; -import { useOidcAccessToken } from "@axa-fr/react-oidc/"; +import { useOidcAccessToken } from "@axa-fr/react-oidc"; import { useOIDCContext } from "../../hooks/oidcConfiguration"; /** diff --git a/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx b/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx index 3e7ab95f..dc0c3516 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/Dashboard.tsx @@ -15,7 +15,7 @@ import { import { ProfileButton } from "./ProfileButton"; import { ThemeToggleButton } from "./ThemeToggleButton"; import DashboardDrawer from "./DashboardDrawer"; -import { ShareButton } from "./ShareButton"; +import { ExportButton } from "./ExportButton"; import { ImportButton } from "./ImportButton"; interface DashboardProps { @@ -124,7 +124,7 @@ export default function Dashboard({ > - + diff --git a/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx b/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx index 675a54ea..0f02090f 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/DashboardDrawer.tsx @@ -434,7 +434,10 @@ export default function DashboardDrawer({ }} > - setAppDialogOpen(true)}> + setAppDialogOpen(true)} + data-testid="add-application-button" + > {} diff --git a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx index 7a42e1ee..af1cca20 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/DrawerItem.tsx @@ -10,7 +10,7 @@ import { useTheme, TextField, } from "@mui/material"; -import { DragIndicator } from "@mui/icons-material"; +import { DragIndicator, SvgIconComponent } from "@mui/icons-material"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { draggable, @@ -23,14 +23,15 @@ import { extractClosestEdge, } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview"; +import EggIcon from "@mui/icons-material/Egg"; import { ThemeProvider } from "../../contexts/ThemeProvider"; import { useSearchParamsUtils } from "../../hooks/searchParamsUtils"; import { useApplicationId } from "../../hooks/application"; -import { DashboardGroup } from "../../types"; +import { DashboardGroup, DashboardItem } from "../../types"; interface DrawerItemProps { /** The item object containing the title, id, and icon. */ - item: { title: string; id: string; icon: React.ComponentType }; + item: DashboardItem; /** The index of the item. */ index: number; /** The title of the group. */ @@ -53,7 +54,7 @@ interface DrawerItemProps { * @returns The rendered JSX for the drawer item. */ export default function DrawerItem({ - item: { title, id, icon }, + item, index, groupTitle, renamingItemId, @@ -100,7 +101,7 @@ export default function DrawerItem({ width: source.element.getBoundingClientRect().width, }} > - + , ); @@ -136,9 +137,9 @@ export default function DrawerItem({ const sourceIndex = source.data.index; if (typeof sourceIndex === "number") { const isItemBeforeSource = - index === sourceIndex - 1 && source.data.title === title; + index === sourceIndex - 1 && source.data.title === item.title; const isItemAfterSource = - index === sourceIndex + 1 && source.data.title === title; + index === sourceIndex + 1 && source.data.title === item.title; const isDropIndicatorHidden = (isItemBeforeSource && closestEdge === "bottom") || @@ -159,7 +160,7 @@ export default function DrawerItem({ }, }), ); - }, [index, groupTitle, icon, theme, title, id]); + }, [index, groupTitle, item, theme]); // Handle renaming of the item const handleItemRename = () => { @@ -169,8 +170,8 @@ export default function DrawerItem({ if (group.title === groupTitle) { return { ...group, - items: group.items.map((item) => - item.id === id ? { ...item, title: renameValue } : item, + items: group.items.map((i) => + i.id === item.id ? { ...item, title: renameValue } : i, ), }; } @@ -185,16 +186,16 @@ export default function DrawerItem({ <> setParam("appId", id)} + key={item.title} + onClick={() => setParam("appId", item.id)} sx={{ pl: 2, borderRadius: 2, pr: 1 }} ref={dragRef} - selected={appId === id} + selected={appId === item.id} > - + - {renamingItemId === id ? ( + {renamingItemId === item.id ? ( setRenameValue(e.target.value)} @@ -211,7 +212,7 @@ export default function DrawerItem({ /> ) : ( - + {/* Accordion details */} - {items.map(({ title: itemTitle, id, icon }, index) => ( -
+ {items.map((item, index) => ( +
void; state: string; } -function ShareDialog({ open, onClose, state }: ShareDialogProps) { +function ExportDialog({ open, onClose, state }: ExportDialogProps) { const theme = useTheme(); const handleCopy = () => { navigator.clipboard.writeText(state); @@ -57,11 +57,14 @@ function ShareDialog({ open, onClose, state }: ShareDialogProps) { - + @@ -71,10 +74,10 @@ function ShareDialog({ open, onClose, state }: ShareDialogProps) { } /** - * ShareButton component allows users to share the state of selected applications. + * ExportButton component allows users to share the state of selected applications. * It provides a menu with checkboxes for each application and a dialog to display the state. */ -export function ShareButton() { +export function ExportButton() { const [anchorEl, setAnchorEl] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [selectedApps, setSelectedApps] = useState([]); @@ -103,7 +106,7 @@ export function ShareButton() { // Function to handle the share action // It collects the state of selected applications and opens the dialog - const handleShare = () => { + const handleExport = () => { const states = selectedApps.map((appId) => { const app = groups.flatMap((g) => g.items).find((a) => a.id === appId); if (!app) return null; @@ -123,8 +126,8 @@ export function ShareButton() { return ( <> - - + + @@ -188,16 +191,16 @@ export function ShareButton() { )} - setDialogOpen(false)} state={selectedState} diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx index a29dac2d..ea7daf0b 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ImportButton.tsx @@ -58,11 +58,14 @@ function ImportDialog({ open, onClose, onImport }: ImportDialogProps) { /> - + @@ -120,7 +123,10 @@ export function ImportButton() { return ( <> - setDialogOpen(true)}> + setDialogOpen(true)} + data-testid="import-button" + > diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx index 4815329a..84613bad 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ProfileButton.tsx @@ -40,7 +40,7 @@ export function ProfileButton() { const { configuration, setConfiguration } = useOIDCContext(); const { accessTokenPayload } = useOidcAccessToken(configuration?.scope); - const { logout, isAuthenticated } = useOidc(configuration?.scope); + const { logout } = useOidc(configuration?.scope); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -62,9 +62,14 @@ export function ProfileButton() { logout(); }; - if (!isAuthenticated) { + if (!accessTokenPayload) { return ( - ); @@ -78,6 +83,7 @@ export function ProfileButton() { aria-controls={open ? "account-menu" : undefined} aria-haspopup="true" aria-expanded={open ? "true" : undefined} + data-testid="profile-button" > {accessTokenPayload["preferred_username"][0]} @@ -190,6 +196,7 @@ export function ProfileButton() { handleClose(); handleLogout(); }} + data-testid="logout-button" > diff --git a/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx b/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx index 1121167e..902486ae 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx +++ b/packages/diracx-web-components/src/components/DashboardLayout/ThemeToggleButton.tsx @@ -12,7 +12,7 @@ export function ThemeToggleButton() { const { theme, toggleTheme } = useTheme(); return ( - + {theme === "light" ? ( ) : ( diff --git a/packages/diracx-web-components/src/components/DashboardLayout/index.ts b/packages/diracx-web-components/src/components/DashboardLayout/index.ts index 7b42e03c..01f9aff0 100644 --- a/packages/diracx-web-components/src/components/DashboardLayout/index.ts +++ b/packages/diracx-web-components/src/components/DashboardLayout/index.ts @@ -1,9 +1 @@ -export { default as ApplicationDialog } from "./ApplicationDialog"; export { default as Dashboard } from "./Dashboard"; -export { default as DashboardDrawer } from "./DashboardDrawer"; -export { default as DrawerItem } from "./DrawerItem"; -export { default as DrawerItemGroup } from "./DrawerItemGroup"; -export { ProfileButton } from "./ProfileButton"; -export { ThemeToggleButton } from "./ThemeToggleButton"; -export { ShareButton } from "./ShareButton"; -export { ImportButton } from "./ImportButton"; diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx index b720ecda..4e8248e3 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobDataTable.tsx @@ -66,6 +66,8 @@ interface JobDataTableProps { columnPinning: ColumnPinningState; /** Set column pinning */ setColumnPinning: React.Dispatch>; + /** Status Colors */ + statusColors: Record; } /** @@ -83,6 +85,7 @@ export function JobDataTable({ setColumnVisibility, columnPinning, setColumnPinning, + statusColors, }: JobDataTableProps) { // Authentication const { configuration } = useOIDCContext(); @@ -470,6 +473,7 @@ export function JobDataTable({ onClose={handleHistoryClose} historyData={jobHistoryData} jobId={selectedJobId ?? 0} + statusColors={statusColors} /> ); diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobHistoryDialog.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobHistoryDialog.tsx index 9c024cfb..a11aff72 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobHistoryDialog.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobHistoryDialog.tsx @@ -1,91 +1,100 @@ -"use client"; - import { Dialog, DialogContent, DialogTitle, IconButton, - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TableContainer, + Stepper, + Step, + StepLabel, + StepContent, + Typography, useTheme, } from "@mui/material"; import { Close } from "@mui/icons-material"; -import React, { useMemo } from "react"; -import { - useReactTable, - createColumnHelper, - getCoreRowModel, - flexRender, -} from "@tanstack/react-table"; import { JobHistory } from "../../types/JobHistory"; interface JobHistoryDialogProps { - /** Whether the Dialog is open */ + /** Whether the dialog is open or not */ open: boolean; - /** The function to close the dialog */ + /** Function to close the dialog */ onClose: () => void; - /** The data for the job history dialog */ + /** Job history data */ historyData: JobHistory[]; - /** The job ID */ + /** Job ID */ jobId: number; + /** Status colors */ + statusColors: Record; +} + +// Helper to group consecutive entries by Status +function groupByConsecutiveStatus(history: JobHistory[]) { + const groups: { status: string; entries: JobHistory[] }[] = []; + let lastStatus: string | null = null; + let currentGroup: JobHistory[] = []; + for (const entry of history) { + if (entry.Status !== lastStatus) { + if (currentGroup.length > 0) { + groups.push({ status: lastStatus!, entries: currentGroup }); + } + lastStatus = entry.Status; + currentGroup = [entry]; + } else { + currentGroup.push(entry); + } + } + if (currentGroup.length > 0) { + groups.push({ status: lastStatus!, entries: currentGroup }); + } + return groups; } -/** - * Renders a dialog component that displays the job history. - * - * @returns The rendered JobHistoryDialog component. - */ export function JobHistoryDialog({ open, onClose, historyData, jobId, + statusColors, }: JobHistoryDialogProps) { const theme = useTheme(); - // Create column helper - const columnHelper = createColumnHelper(); + // Reverse the history so the most recent is first + const reversedHistory = [...historyData].reverse(); - // Define columns - const columns = useMemo( - () => [ - columnHelper.accessor("Status", { - header: "Status", - }), - columnHelper.accessor("MinorStatus", { - header: "Minor Status", - }), - columnHelper.accessor("ApplicationStatus", { - header: "Application Status", - }), - columnHelper.accessor("StatusTime", { - header: "Status Time", - }), - columnHelper.accessor("Source", { - header: "Source", - }), - ], - [columnHelper], - ); + // Group consecutive entries by Status + const grouped = groupByConsecutiveStatus(reversedHistory); - // Create table instance - const table = useReactTable({ - data: historyData, - columns, - getCoreRowModel: getCoreRowModel(), - state: {}, - enableColumnResizing: true, // Enable column resizing - columnResizeMode: "onChange", // Column resize mode - }); + // Custom StepIcon for color logic + const CustomStepIcon = (props: { + active?: boolean; + completed?: boolean; + className?: string; + }) => { + const { active, completed, className } = props; + let color = theme.palette.grey[400]; + if (active) { + color = statusColors[grouped[0].status] || theme.palette.primary.main; + } else if (completed) { + color = theme.palette.grey[400]; + } + return ( + + ); + }; return ( Job History: {jobId} - + + + {grouped.map((group, idx) => { + const isActive = idx === 0; - - - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder ? null : ( - <> - {flexRender( - header.column.columnDef.header, - header.getContext(), - )} - {header.column.getCanResize() && ( -
- )} - - )} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - + let labelColor: string = theme.palette.grey[400]; + if (isActive) { + labelColor = + statusColors[group.status] || theme.palette.primary.main; + } + + return ( + + + + {group.status} + + + + {group.entries.map((entry, i) => ( +
+ + {entry.MinorStatus} + + + {entry.ApplicationStatus} + + + {new Date(entry.StatusTime).toLocaleString("en-GB", { + timeZone: "UTC", + })}{" "} + UTC +
+ {entry.Source} +
+
))} -
- ))} -
-
-
+ + + ); + })} +
); diff --git a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx index dceaf94a..e13448a3 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx +++ b/packages/diracx-web-components/src/components/JobMonitor/JobMonitor.tsx @@ -318,6 +318,7 @@ export default function JobMonitor() { setColumnPinning={setColumnPinning} rowSelection={rowSelection} setRowSelection={setRowSelection} + statusColors={statusColors} /> ); diff --git a/packages/diracx-web-components/src/components/JobMonitor/index.ts b/packages/diracx-web-components/src/components/JobMonitor/index.ts index 6dfddd03..0c8b0b08 100644 --- a/packages/diracx-web-components/src/components/JobMonitor/index.ts +++ b/packages/diracx-web-components/src/components/JobMonitor/index.ts @@ -1,3 +1 @@ -export { JobDataTable } from "./JobDataTable"; -export { JobHistoryDialog } from "./JobHistoryDialog"; export { default as JobMonitor } from "./JobMonitor"; diff --git a/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx b/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx index 2e327dbe..34bb43a2 100644 --- a/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx +++ b/packages/diracx-web-components/src/components/shared/FilterToolbar.tsx @@ -155,6 +155,7 @@ export function FilterToolbar>({ startIcon={} onClick={handleAddFilter} ref={addFilterButtonRef} + data-testid="add-filter-button" > Add filter @@ -166,6 +167,7 @@ export function FilterToolbar>({ variant="text" startIcon={changesUnapplied() ? : } onClick={() => handleApplyFilters()} + data-testid="apply-filters-button" > {changesUnapplied() ? "Apply filters" : "Refresh page"} @@ -180,6 +182,7 @@ export function FilterToolbar>({ startIcon={} onClick={handleClearFilters} disabled={filters.length === 0} + data-testid="clear-filters-button" > Clear all filters diff --git a/packages/diracx-web-components/src/contexts/ThemeProvider.tsx b/packages/diracx-web-components/src/contexts/ThemeProvider.tsx index 209ba16c..ff85e5b3 100644 --- a/packages/diracx-web-components/src/contexts/ThemeProvider.tsx +++ b/packages/diracx-web-components/src/contexts/ThemeProvider.tsx @@ -61,20 +61,20 @@ export const ThemeProvider = ({ children }: ThemeProviderProps) => { const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); useEffect(() => { - const storedTheme = localStorage.getItem("theme"); + const storedTheme = sessionStorage.getItem("theme"); if (storedTheme) { setTheme(storedTheme); } else { const defaultTheme = prefersDarkMode ? "dark" : "light"; setTheme(defaultTheme); - localStorage.setItem("theme", defaultTheme); + sessionStorage.setItem("theme", defaultTheme); } }, [prefersDarkMode]); const toggleTheme = () => { setTheme((prevTheme) => { const newTheme = prevTheme === "light" ? "dark" : "light"; - localStorage.setItem("theme", newTheme); + sessionStorage.setItem("theme", newTheme); return newTheme; }); }; diff --git a/packages/diracx-web-components/stories/ApplicationDialog.stories.tsx b/packages/diracx-web-components/stories/ApplicationDialog.stories.tsx deleted file mode 100644 index 72533b4c..00000000 --- a/packages/diracx-web-components/stories/ApplicationDialog.stories.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { useArgs } from "@storybook/core/preview-api"; - -import { ApplicationsContext } from "../src/contexts/ApplicationsProvider"; -import { applicationList } from "../src/components/ApplicationList"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import ApplicationDialog from "../src/components/DashboardLayout/ApplicationDialog"; -import { useOidcAccessToken } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Dashboard Layout/ApplicationDialog", - component: ApplicationDialog, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: { - appDialogOpen: { control: "boolean" }, - setAppDialogOpen: { control: false }, - handleCreateApp: { control: false }, - }, - decorators: [ - (Story) => { - return ( - {}, applicationList]}> - - - ); - }, - (Story) => { - return ( - - - - ); - }, - ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - accessTokenPayload: { preferred_username: "John Doe" }, - }); - return () => useOidcAccessToken.mockReset(); - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - appDialogOpen: true, - setAppDialogOpen: () => {}, - handleCreateApp: () => {}, - }, - render: (props) => { - const [, updateArgs] = useArgs(); - props.setAppDialogOpen = (open) => updateArgs({ appDialogOpen: open }); - return ; - }, -}; diff --git a/packages/diracx-web-components/stories/BaseApp.stories.tsx b/packages/diracx-web-components/stories/BaseApp.stories.tsx index 3e4fb8e8..7ac69b0b 100644 --- a/packages/diracx-web-components/stories/BaseApp.stories.tsx +++ b/packages/diracx-web-components/stories/BaseApp.stories.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { StoryObj, Meta } from "@storybook/react"; import { Paper } from "@mui/material"; import { Apps } from "@mui/icons-material"; @@ -6,7 +5,6 @@ import { ApplicationsContext } from "../src/contexts/ApplicationsProvider"; import { NavigationProvider } from "../src/contexts/NavigationProvider"; import { ThemeProvider } from "../src/contexts/ThemeProvider"; import BaseApp from "../src/components/BaseApp/BaseApp"; -import { useOidcAccessToken } from "./mocks/react-oidc.mock"; const meta = { title: "Base Application", @@ -60,32 +58,15 @@ const meta = { ), ], - async beforeEach() { - return () => { - useOidcAccessToken.mockRestore(); - }; - }, + async beforeEach() {}, } satisfies Meta; export default meta; type Story = StoryObj; -export const LoggedIn: Story = { - args: {}, - render: (props) => { - useOidcAccessToken.mockReturnValue({ - accessTokenPayload: { preferred_username: "John Doe" }, - }); - return ; - }, -}; - -export const LoggedOff: Story = { +export const Default: Story = { args: {}, render: (props) => { - useOidcAccessToken.mockReturnValue({ - accessTokenPayload: null, - }); return ; }, }; diff --git a/packages/diracx-web-components/stories/Dashboard.stories.tsx b/packages/diracx-web-components/stories/Dashboard.stories.tsx index b7be5c26..47c9336c 100644 --- a/packages/diracx-web-components/stories/Dashboard.stories.tsx +++ b/packages/diracx-web-components/stories/Dashboard.stories.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { Dashboard as DashboardIcon } from "@mui/icons-material"; @@ -8,7 +8,7 @@ import { NavigationProvider } from "../src/contexts/NavigationProvider"; import { applicationList } from "../src/components/ApplicationList"; import { DashboardGroup } from "../src/types/DashboardGroup"; import Dashboard from "../src/components/DashboardLayout/Dashboard"; -import { useOidc, useOidcAccessToken } from "./mocks/react-oidc.mock"; +import { ThemeProvider } from "../src/contexts/ThemeProvider"; const meta = { title: "Dashboard Layout/Dashboard", @@ -50,21 +50,17 @@ const meta = { - - - + + + + + ); }, ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - accessTokenPayload: { preferred_username: "John Doe" }, - }); - return () => useOidcAccessToken.mockReset(); - }, + async beforeEach() {}, } satisfies Meta; export default meta; @@ -76,13 +72,11 @@ export const Default: Story = { children:
, logoURL: process.env.STORYBOOK_DEV ? undefined - : "/diracx-web/DIRAC-logo.png", // we need to add "/diracx-web" at the start of the url in production because of the repo name in the github pages url + : // we need to add "/diracx-web" at the start of the url in production + // because of the repo name in the github pages url + "/diracx-web/DIRAC-logo.png", }, render: (props) => { - useOidc.mockReturnValue({ - login: () => {}, - isAuthenticated: true, - }); return ; }, }; diff --git a/packages/diracx-web-components/stories/DashboardDrawer.stories.tsx b/packages/diracx-web-components/stories/DashboardDrawer.stories.tsx deleted file mode 100644 index 5409a1c2..00000000 --- a/packages/diracx-web-components/stories/DashboardDrawer.stories.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useState } from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { Box } from "@mui/material"; -import { Dashboard } from "@mui/icons-material"; -import { ApplicationsContext } from "../src/contexts/ApplicationsProvider"; -import { applicationList } from "../src/components/ApplicationList"; -import { DashboardGroup } from "../src/types"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import DashboardDrawer from "../src/components/DashboardLayout/DashboardDrawer"; -import { useOidc, useOidcAccessToken } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Dashboard Layout/DashboardDrawer", - component: DashboardDrawer, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - const [userDashboard, setUserDashboard] = useState([ - { - title: "Group Title", - extended: true, - items: [ - { - id: "example", - title: "App Name", - icon: Dashboard, - type: "test", - }, - ], - }, - ]); - return ( - - - - - - - - ); - }, - ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - accessTokenPayload: { preferred_username: "John Doe" }, - }); - useOidc.mockReturnValue({ - login: () => {}, - isAuthenticated: true, - }); - return () => useOidcAccessToken.mockReset(); - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - variant: "permanent", - mobileOpen: false, - handleDrawerToggle: () => {}, - width: 240, - logoURL: process.env.STORYBOOK_DEV - ? undefined - : "/diracx-web/DIRAC-logo.png", // we need to add "/diracx-web" at the start of the url in production because of the repo name in the github pages url - }, -}; diff --git a/packages/diracx-web-components/stories/DataTable.stories.tsx b/packages/diracx-web-components/stories/DataTable.stories.tsx index 430bcb1f..fca78756 100644 --- a/packages/diracx-web-components/stories/DataTable.stories.tsx +++ b/packages/diracx-web-components/stories/DataTable.stories.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Meta, StoryObj } from "@storybook/react"; import { createColumnHelper, @@ -35,22 +34,9 @@ const data: SimpleItem[] = [ { id: 1, name: "John Doe", email: "john@example.com" }, ]; -// Wrapper component to initialize the table -const DataTableWrapper: React.FC, "table">> = ( - props, -) => { - const table = useReactTable({ - data, - columns: columnDefs, - getCoreRowModel: getCoreRowModel(), - }); - - return {...props} table={table} />; -}; - -const meta: Meta = { +const meta: Meta> = { title: "shared/DataTable", - component: DataTableWrapper, + component: DataTable, parameters: { layout: "centered", }, @@ -70,7 +56,7 @@ const meta: Meta = { decorators: [ (Story) => ( -
+
@@ -79,7 +65,7 @@ const meta: Meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj>; export const Default: Story = { args: { @@ -93,4 +79,18 @@ export const Default: Story = { toolbarComponents: <>, menuItems: [{ label: "Edit", onClick: () => {} }], }, + render: (args) => { + const table = useReactTable({ + data, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + state: { + pagination: { + pageIndex: 0, + pageSize: 25, + }, + }, + }); + return {...args} table={table} />; + }, }; diff --git a/packages/diracx-web-components/stories/DrawerItem.stories.tsx b/packages/diracx-web-components/stories/DrawerItem.stories.tsx deleted file mode 100644 index 2c3745ac..00000000 --- a/packages/diracx-web-components/stories/DrawerItem.stories.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { Paper } from "@mui/material"; -import { Dashboard } from "@mui/icons-material"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import DrawerItem from "../src/components/DashboardLayout/DrawerItem"; -import { useOidc, useOidcAccessToken } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Dashboard Layout/DrawerItem", - component: DrawerItem, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - accessTokenPayload: { preferred_username: "John Doe" }, - }); - useOidc.mockReturnValue({ - login: () => {}, - isAuthenticated: true, - }); - return () => useOidcAccessToken.mockReset(); - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - groupTitle: "Default Applications", - index: 0, - item: { - title: "Dashboard", - id: "Dashboard 1", - icon: Dashboard, - }, - }, -}; diff --git a/packages/diracx-web-components/stories/DrawerItemGroup.stories.tsx b/packages/diracx-web-components/stories/DrawerItemGroup.stories.tsx deleted file mode 100644 index 31a42961..00000000 --- a/packages/diracx-web-components/stories/DrawerItemGroup.stories.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; -import { useArgs } from "@storybook/core/preview-api"; -import { Paper } from "@mui/material"; -import { Dashboard } from "@mui/icons-material"; -import { DashboardGroup } from "../src/types/DashboardGroup"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import DrawerItemGroup from "../src/components/DashboardLayout/DrawerItemGroup"; -import { useOidc, useOidcAccessToken } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Dashboard Layout/DrawerItemGroup", - component: DrawerItemGroup, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - accessTokenPayload: { preferred_username: "John Doe" }, - }); - useOidc.mockReturnValue({ - login: () => {}, - isAuthenticated: true, - }); - return () => useOidcAccessToken.mockReset(); - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - group: { - title: "Group Title", - extended: true, - items: [ - { - title: "Dashboard", - id: "Dashboard 1", - type: "Dashboard", - icon: Dashboard, - }, - ], - }, - handleContextMenu: () => () => {}, - setUserDashboard: () => {}, - }, - render: (props) => { - const [, updateArgs] = useArgs(); - const updateGroups = (groups: React.SetStateAction) => { - if (typeof groups === "function") { - groups = groups([props.group]); - } - updateArgs({ group: groups[0] }); - }; - props.setUserDashboard = updateGroups; - return ; - }, -}; diff --git a/packages/diracx-web-components/stories/ErrorBox.stories.tsx b/packages/diracx-web-components/stories/ErrorBox.stories.tsx index cd315fe3..436da7b2 100644 --- a/packages/diracx-web-components/stories/ErrorBox.stories.tsx +++ b/packages/diracx-web-components/stories/ErrorBox.stories.tsx @@ -1,4 +1,3 @@ -import React from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { ErrorBox } from "../src/components/shared/ErrorBox"; diff --git a/packages/diracx-web-components/stories/FilterForm.stories.tsx b/packages/diracx-web-components/stories/FilterForm.stories.tsx index 2b859aa1..c987fb00 100644 --- a/packages/diracx-web-components/stories/FilterForm.stories.tsx +++ b/packages/diracx-web-components/stories/FilterForm.stories.tsx @@ -1,11 +1,6 @@ -import React from "react"; -import { StoryObj } from "@storybook/react"; +import { Meta, StoryObj } from "@storybook/react"; import { Paper } from "@mui/material"; -import { - createColumnHelper, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; +import { createColumnHelper } from "@tanstack/react-table"; import { ThemeProvider } from "../src/contexts/ThemeProvider"; import { FilterForm, @@ -23,53 +18,39 @@ const columnHelper = createColumnHelper(); const columnDefs = [ columnHelper.accessor("id", { header: "ID", + id: "id", meta: { type: "number" }, }), columnHelper.accessor("name", { header: "Name", + id: "name", meta: { type: "string" }, }), columnHelper.accessor("email", { header: "Email", + id: "email", meta: { type: "string" }, }), ]; -const data: SimpleItem[] = [ - { id: 1, name: "John Doe", email: "john@example.com" }, -]; - -// Wrapper component to initialize the table -const FilterFormWrapper: React.FC< - Omit, "columns"> -> = (props) => { - const table = useReactTable({ - data, - columns: columnDefs, - getCoreRowModel: getCoreRowModel(), - }); - - return {...props} columns={table.getAllColumns()} />; -}; - -const meta = { +const meta: Meta> = { title: "shared/FilterForm", - component: FilterFormWrapper, + component: FilterForm, parameters: { layout: "centered", }, tags: ["autodocs"], argTypes: { columns: { - control: false, + control: { disable: true }, description: "`array` of tan stack `Column`", required: true, }, - filters: { control: "object" }, - setFilters: { control: "object" }, - handleFilterChange: { control: "object" }, - handleFilterMenuClose: { control: "object" }, - selectedFilterId: { control: "number" }, + filters: { control: { disable: true } }, + setFilters: { control: { disable: true } }, + handleFilterChange: { control: { disable: true } }, + handleFilterMenuClose: { control: { disable: true } }, + selectedFilterId: { control: { disable: true } }, }, decorators: [ (Story) => { @@ -89,7 +70,10 @@ type Story = StoryObj; export const Default: Story = { args: { - filters: [{ id: 0, parameter: "id", operator: "eq", value: "1" }], + columns: columnDefs, + filters: [ + { id: 0, parameter: "id", operator: "eq", value: "1", isApplied: false }, + ], setFilters: () => {}, handleFilterChange: () => {}, handleFilterMenuClose: () => {}, diff --git a/packages/diracx-web-components/stories/FilterToolbar.stories.tsx b/packages/diracx-web-components/stories/FilterToolbar.stories.tsx index e6af465d..a1b8882d 100644 --- a/packages/diracx-web-components/stories/FilterToolbar.stories.tsx +++ b/packages/diracx-web-components/stories/FilterToolbar.stories.tsx @@ -1,12 +1,7 @@ -import React from "react"; -import { StoryObj } from "@storybook/react"; +import { Meta, StoryObj } from "@storybook/react"; import { useArgs } from "@storybook/core/preview-api"; import { Paper } from "@mui/material"; -import { - createColumnHelper, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; +import { createColumnHelper } from "@tanstack/react-table"; import { ThemeProvider } from "../src/contexts/ThemeProvider"; import { FilterToolbar, @@ -23,55 +18,39 @@ const columnHelper = createColumnHelper(); const columnDefs = [ columnHelper.accessor("id", { + id: "id", header: "ID", meta: { type: "number" }, }), columnHelper.accessor("name", { + id: "name", header: "Name", meta: { type: "string" }, }), columnHelper.accessor("email", { + id: "email", header: "Email", meta: { type: "string" }, }), ]; -const data: SimpleItem[] = [ - { id: 1, name: "John Doe", email: "john@example.com" }, -]; - -// Wrapper component to initialize the table -const FilterToolbarWrapper: React.FC< - Omit, "columns"> -> = (props) => { - const table = useReactTable({ - data, - columns: columnDefs, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - {...props} columns={table.getAllColumns()} /> - ); -}; - -const meta = { +const meta: Meta> = { title: "shared/FilterToolbar", - component: FilterToolbarWrapper, + component: FilterToolbar, parameters: { layout: "centered", }, tags: ["autodocs"], argTypes: { columns: { - control: false, + control: { disable: true }, description: "`array` of tan stack `Column`", required: true, }, - filters: { control: "object" }, - setFilters: { control: "object" }, - handleApplyFilters: { control: "object" }, - handleClearFilters: { control: "object" }, + filters: { control: { disable: true } }, + setFilters: { control: { disable: true } }, + handleApplyFilters: { control: { disable: true } }, + handleClearFilters: { control: { disable: true } }, }, decorators: [ (Story) => { @@ -87,23 +66,23 @@ const meta = { }; export default meta; -type Story = StoryObj; +type Story = StoryObj>; export const Default: Story = { args: { + columns: columnDefs, filters: [ - { id: 0, parameter: "id", operator: "eq", value: "1" }, - { id: 1, parameter: "id", operator: "neq", value: "2" }, + { id: 0, parameter: "id", operator: "eq", value: "1", isApplied: true }, + { id: 1, parameter: "id", operator: "neq", value: "2", isApplied: false }, ], setFilters: () => {}, handleApplyFilters: () => {}, handleClearFilters: () => {}, - appliedFilters: [{ id: 0, parameter: "id", operator: "eq", value: "1" }], }, render: (props) => { const [{ filters }, updateArgs] = useArgs(); props.setFilters = (filters) => updateArgs({ filters }); props.handleApplyFilters = () => updateArgs({ appliedFilters: filters }); - return ; + return {...props} />; }, }; diff --git a/packages/diracx-web-components/stories/ImportButton.stories.tsx b/packages/diracx-web-components/stories/ImportButton.stories.tsx deleted file mode 100644 index d81cad1d..00000000 --- a/packages/diracx-web-components/stories/ImportButton.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { Paper } from "@mui/material"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { ImportButton } from "../src/components/DashboardLayout/ImportButton"; - -const meta = { - title: "Dashboard Layout/ImportButton", - component: ImportButton, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/diracx-web-components/stories/JobDataTable.stories.tsx b/packages/diracx-web-components/stories/JobDataTable.stories.tsx deleted file mode 100644 index b868f332..00000000 --- a/packages/diracx-web-components/stories/JobDataTable.stories.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from "react"; -import { StoryObj, Meta } from "@storybook/react"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { JobDataTable } from "../src/components/JobMonitor/JobDataTable"; -import { useJobs } from "./mocks/JobDataService.mock"; -import { useOidcAccessToken } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Job Monitor/JobDataTable", - component: JobDataTable, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: {}, - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - }); - - return () => { - useOidcAccessToken.mockRestore(); - }; - }, - decorators: [ - (Story) => { - return ( - - - - ); - }, - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: {}, - render() { - useJobs.mockReturnValue({ - data: { - data: [ - { - JobID: 1, - JobName: "Job 1", - Site: "ANY", - Status: "Received", - MinorStatus: "Job accepted", - SubmissionTime: "2024-01-01T12:00:30", - }, - { - JobID: 2, - JobName: "Job 2", - Site: "ANY", - Status: "Received", - MinorStatus: "Job accepted", - SubmissionTime: "2024-01-01T12:00:00", - }, - ], - }, - }); - return JobDataTable(); - }, -}; - -export const Empty: Story = { - args: {}, - render() { - useJobs.mockReturnValue({ - data: { - data: [], - }, - }); - return JobDataTable(); - }, -}; diff --git a/packages/diracx-web-components/stories/JobHistoryDialog.stories.tsx b/packages/diracx-web-components/stories/JobHistoryDialog.stories.tsx deleted file mode 100644 index b4ceb2b0..00000000 --- a/packages/diracx-web-components/stories/JobHistoryDialog.stories.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react"; -import { StoryObj, Meta } from "@storybook/react"; -import { useArgs } from "@storybook/core/preview-api"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { JobHistoryDialog } from "../src/components/JobMonitor/JobHistoryDialog"; - -const meta = { - title: "Job Monitor/JobHistoryDialog", - component: JobHistoryDialog, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: { - historyData: { control: "object" }, - open: { control: "boolean" }, - onClose: { action: "onClose" }, - }, - decorators: [ - (Story) => { - return ( - - - - ); - }, - ], -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - historyData: [ - { - Status: "Success", - MinorStatus: "Success", - ApplicationStatus: "Success", - StatusTime: "2024-07-04T13:00:00", - Source: "Test", - }, - ], - open: true, - onClose: () => {}, - jobId: 1234, - }, - render: (props) => { - const [, updateArgs] = useArgs(); - props.onClose = () => updateArgs({ open: false }); - return ; - }, -}; diff --git a/packages/diracx-web-components/stories/JobMonitor.stories.tsx b/packages/diracx-web-components/stories/JobMonitor.stories.tsx index 0ed44a7c..404ecb1a 100644 --- a/packages/diracx-web-components/stories/JobMonitor.stories.tsx +++ b/packages/diracx-web-components/stories/JobMonitor.stories.tsx @@ -1,12 +1,10 @@ -import React from "react"; import { StoryObj, Meta } from "@storybook/react"; import { Paper } from "@mui/material"; import { ApplicationsContext } from "../src/contexts/ApplicationsProvider"; import { NavigationProvider } from "../src/contexts/NavigationProvider"; import { ThemeProvider } from "../src/contexts/ThemeProvider"; import JobMonitor from "../src/components/JobMonitor/JobMonitor"; -import { useOidcAccessToken } from "./mocks/react-oidc.mock"; -import { useJobs } from "./mocks/JobDataService.mock"; +import { setJobsMock, setJobHistoryMock } from "./mocks/JobDataService.mock"; const meta = { title: "Job Monitor/JobMonitor", @@ -45,7 +43,7 @@ const meta = { { id: "example", title: "App Name", - icon: React.Component, + icon: null, type: "test", }, ], @@ -60,42 +58,175 @@ const meta = { ), ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - }); - return () => useOidcAccessToken.mockReset(); - }, + async beforeEach() {}, } satisfies Meta; export default meta; type Story = StoryObj; +const jobs = [ + { + JobID: 1, + JobName: "Job 1", + Site: "ANY", + Status: "Running", + MinorStatus: "Job running", + SubmissionTime: new Date("2024-01-01T12:00:30"), + JobGroup: "Group A", + JobType: "Type A", + Owner: "Owner A", + OwnerGroup: "OwnerGroup A", + VO: "VO A", + ApplicationStatus: "Initializing", + RescheduleTime: new Date("2024-01-01T13:00:00"), + LastUpdateTime: new Date("2024-01-01T12:30:00"), + StartExecTime: new Date("2024-01-01T12:05:00"), + HeartBeatTime: new Date("2024-01-01T12:10:00"), + EndExecTime: null, + UserPriority: 1, + RescheduleCounter: 0, + VerifiedFlag: true, + AccountedFlag: "Yes", + }, + { + JobID: 2, + JobName: "Job 2", + Site: "ANY", + Status: "Received", + MinorStatus: "Job accepted", + SubmissionTime: new Date("2024-01-01T12:00:00"), + JobGroup: "Group B", + JobType: "Type B", + Owner: "Owner B", + OwnerGroup: "OwnerGroup B", + VO: "VO B", + ApplicationStatus: "Pending", + RescheduleTime: new Date("2024-01-01T13:30:00"), + LastUpdateTime: new Date("2024-01-01T12:45:00"), + StartExecTime: null, + HeartBeatTime: null, + EndExecTime: null, + UserPriority: 2, + RescheduleCounter: 1, + VerifiedFlag: false, + AccountedFlag: "No", + }, +]; + +const jobHistory = [ + { + Status: "Submitted", + MinorStatus: "", + ApplicationStatus: "", + StatusTime: "2024-07-04T13:00:00", + Source: "Storybook", + }, + { + Status: "Received", + MinorStatus: "Job accepted", + ApplicationStatus: "", + StatusTime: "2024-07-04T13:00:03", + Source: "Storybook", + }, + { + Status: "Running", + MinorStatus: "Job initializing", + ApplicationStatus: "Unknown", + StatusTime: "2024-07-04T13:00:10", + Source: "Storybook", + }, + { + Status: "Running", + MinorStatus: "Job running", + ApplicationStatus: "Unknown", + StatusTime: "2024-07-04T13:00:10", + Source: "Storybook", + }, +]; + +export const Loading: Story = { + args: {}, + decorators: [ + (Story) => { + setJobsMock({ + jobs: null, + error: null, + isLoading: true, + }); + + setJobHistoryMock({ + jobHistory: jobHistory, + error: null, + isLoading: false, + }); + + return ; + }, + ], +}; + +export const Error: Story = { + args: {}, + decorators: [ + (Story) => { + setJobsMock({ + jobs: null, + error: { + message: "Error loading jobs", + name: "Error", + }, + isLoading: false, + }); + + setJobHistoryMock({ + jobHistory: jobHistory, + error: null, + isLoading: false, + }); + + return ; + }, + ], +}; + +export const Empty: Story = { + args: {}, + decorators: [ + (Story) => { + setJobsMock({ + jobs: [], + error: null, + isLoading: false, + }); + + setJobHistoryMock({ + jobHistory: [], + error: null, + isLoading: false, + }); + + return ; + }, + ], +}; + export const Default: Story = { args: {}, - render(args) { - useJobs.mockReturnValue({ - data: { - data: [ - { - JobID: 1, - JobName: "Job 1", - Site: "ANY", - Status: "Received", - MinorStatus: "Job accepted", - SubmissionTime: "2024-01-01T12:00:30", - }, - { - JobID: 2, - JobName: "Job 2", - Site: "ANY", - Status: "Received", - MinorStatus: "Job accepted", - SubmissionTime: "2024-01-01T12:00:00", - }, - ], - }, - }); - return ; - }, + decorators: [ + (Story) => { + setJobsMock({ + jobs: jobs, + error: null, + isLoading: false, + }); + + setJobHistoryMock({ + jobHistory: jobHistory, + error: null, + isLoading: false, + }); + + return ; + }, + ], }; diff --git a/packages/diracx-web-components/stories/LoginForm.stories.tsx b/packages/diracx-web-components/stories/LoginForm.stories.tsx index ac8c4bb0..8e88738f 100644 --- a/packages/diracx-web-components/stories/LoginForm.stories.tsx +++ b/packages/diracx-web-components/stories/LoginForm.stories.tsx @@ -1,47 +1,8 @@ -import React from "react"; import { StoryObj, Meta } from "@storybook/react"; import { Paper } from "@mui/material"; import { ThemeProvider } from "../src/contexts/ThemeProvider"; import { LoginForm } from "../src/components/Login/LoginForm"; -import { useMetadata } from "./mocks/metadata.mock"; -import { useOidc } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Login/LoginForm", - component: LoginForm, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: { - logoURL: { control: "text" }, - }, - beforeEach: async () => { - useOidc.mockReturnValue({ - login: () => {}, - isAuthenticated: false, - }); - }, - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], - args: { - logoURL: process.env.STORYBOOK_DEV - ? undefined - : "/diracx-web/DIRAC-logo-minimal.png", // we need to add "/diracx-web" at the start of the url in production because of the repo name in the github pages url - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; +import { setMetadataMock } from "./mocks/metadata.mock"; const singleVOMetadata = { virtual_organizations: { @@ -99,28 +60,92 @@ const multiVOMetadata = { }, }; -export const SingleVO: Story = { - render(props) { - useMetadata.mockReturnValue({ - metadata: singleVOMetadata, - error: null, - isLoading: false, - }); - return ; +const meta = { + title: "Login/LoginForm", + component: LoginForm, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + logoURL: { control: "text" }, + }, + decorators: [ + (Story) => { + return ( + + + + + + ); + }, + ], + args: { + logoURL: process.env.STORYBOOK_DEV + ? undefined + : "/diracx-web/DIRAC-logo-minimal.png", // we need to add "/diracx-web" at the start of the url in production because of the repo name in the github pages url }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = { + decorators: [ + (Story) => { + // Set the mock data before rendering + setMetadataMock({ + metadata: null, + error: null, + isLoading: true, + }); + return ; + }, + ], +}; + +export const Error: Story = { + decorators: [ + (Story) => { + // Set the mock data before rendering + setMetadataMock({ + metadata: null, + error: { + message: "Error loading metadata", + name: "Error", + }, + isLoading: false, + }); + return ; + }, + ], +}; + +export const SingleVO: Story = { + decorators: [ + (Story) => { + // Set the mock data before rendering + setMetadataMock({ + metadata: singleVOMetadata, + error: null, + isLoading: false, + }); + return ; + }, + ], }; export const MultiVO: Story = { - args: { - logoURL: - "https://raw.githubusercontent.com/DIRACGrid/management/master/branding/diracx/svg/diracx-logo-square-minimal.svg", - }, - render(props) { - useMetadata.mockReturnValue({ - metadata: multiVOMetadata, - error: null, - isLoading: false, - }); - return ; - }, + decorators: [ + (Story) => { + // Set the mock data before rendering + setMetadataMock({ + metadata: multiVOMetadata, + error: null, + isLoading: false, + }); + return ; + }, + ], }; diff --git a/packages/diracx-web-components/stories/ProfileButton.stories.tsx b/packages/diracx-web-components/stories/ProfileButton.stories.tsx deleted file mode 100644 index 657ddcf1..00000000 --- a/packages/diracx-web-components/stories/ProfileButton.stories.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { Paper } from "@mui/material"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { ProfileButton } from "../src/components/DashboardLayout/ProfileButton"; -import { useOidc, useOidcAccessToken } from "./mocks/react-oidc.mock"; - -const meta = { - title: "Dashboard Layout/ProfileButton", - component: ProfileButton, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], - async beforeEach() { - useOidcAccessToken.mockReturnValue({ - accessToken: "123456789", - accessTokenPayload: { - preferred_username: "John Doe", - vo: "dirac", - dirac_group: "dirac_user", - dirac_properties: ["NormalUser", "AnotherProperty"], - }, - }); - useOidc.mockReturnValue({ - login: () => {}, - isAuthenticated: true, - }); - return () => useOidcAccessToken.mockReset(); - }, -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/diracx-web-components/stories/ShareButton.stories.tsx b/packages/diracx-web-components/stories/ShareButton.stories.tsx deleted file mode 100644 index 96070b46..00000000 --- a/packages/diracx-web-components/stories/ShareButton.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { Paper } from "@mui/material"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { ShareButton } from "../src/components/DashboardLayout/ShareButton"; - -const meta = { - title: "Dashboard Layout/ShareButton", - component: ShareButton, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/diracx-web-components/stories/ThemeToggleButton.stories.tsx b/packages/diracx-web-components/stories/ThemeToggleButton.stories.tsx deleted file mode 100644 index d631bb5d..00000000 --- a/packages/diracx-web-components/stories/ThemeToggleButton.stories.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from "react"; -import type { Meta, StoryObj } from "@storybook/react"; - -import { Paper } from "@mui/material"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { ThemeToggleButton } from "../src/components/DashboardLayout/ThemeToggleButton"; - -const meta = { - title: "Dashboard Layout/ThemeToggleButton", - component: ThemeToggleButton, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - decorators: [ - (Story) => { - return ( - - - - - - ); - }, - ], -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/diracx-web-components/stories/mocks/JobDataService.mock.ts b/packages/diracx-web-components/stories/mocks/JobDataService.mock.ts deleted file mode 100644 index bae2c107..00000000 --- a/packages/diracx-web-components/stories/mocks/JobDataService.mock.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { fn } from "@storybook/test"; -// Aliased '@/components/JobMonitor/JobDataService' as '@actual/components/JobMonitor/JobDataService' in the Storybook config to prevent the mock from importing itself. -// @ts-expect-error: Cannot find module '@actual/components/JobMonitor/JobDataService' -import * as actual from "@actual/components/JobMonitor/JobDataService"; - -export const useJobs = fn(actual.useJobs); -export const refreshJobs = fn(actual.refreshJobs); -export const deleteJobs = fn(actual.deleteJobs); -export const killJobs = fn(actual.killJobs); -export const rescheduleJobs = fn(actual.rescheduleJobs); -export const getJobHistory = fn(actual.getJobHistory); diff --git a/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx b/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx new file mode 100644 index 00000000..eec4eb9a --- /dev/null +++ b/packages/diracx-web-components/stories/mocks/JobDataService.mock.tsx @@ -0,0 +1,157 @@ +/* eslint-disable */ +import { Job, JobHistory, SearchBody } from "../../src/types"; + +// Mock data store for jobs +let mockJobsResponse: { + jobs: Job[] | null; + error: Error | null; + isLoading: boolean; +} = { + jobs: null, + error: null, + isLoading: false, +}; + +// Mock data store for job history +let mockJobHistoryResponse: { + jobHistory: JobHistory[] | null; + error: Error | null; + isLoading: boolean; +} = { + jobHistory: null, + error: null, + isLoading: false, +}; + +// Function to set mock jobs data +export function setJobsMock(data: { + jobs: Job[] | null; + error: Error | null; + isLoading: boolean; +}) { + mockJobsResponse = data; +} + +// Function to set mock job history data +export function setJobHistoryMock(data: { + jobHistory: JobHistory[] | null; + error: Error | null; + isLoading: boolean; +}) { + mockJobHistoryResponse = data; +} + +// Mock implementation of `useJobs` +export const useJobs = ( + diracxUrl: string | null, + accessToken: string, + searchBody: any, + page: number, + rowsPerPage: number, +) => { + if (mockJobsResponse.error) { + return { + data: undefined, + error: mockJobsResponse.error, + isLoading: mockJobsResponse.isLoading, + isValidating: false, + }; + } + + // Create headers with content-range for pagination + const headers = new Headers(); + headers.append( + "content-range", + `jobs 0-${mockJobsResponse.jobs?.length || 0}/${mockJobsResponse.jobs?.length || 0}`, + ); + + return { + data: mockJobsResponse.jobs + ? { + headers, + data: mockJobsResponse.jobs, + } + : undefined, + error: null, + isLoading: mockJobsResponse.isLoading, + isValidating: false, + }; +}; + +// Mock implementation of `getJobHistory` +export const getJobHistory = async ( + diracxUrl: string | null, + jobId: number, + accessToken: string, +): Promise<{ data: JobHistory[] }> => { + if (mockJobHistoryResponse.error) { + throw mockJobHistoryResponse.error; + } + return { data: mockJobHistoryResponse.jobHistory || [] }; +}; + +// Mock implementation of refreshJobs +export const refreshJobs = ( + diracxUrl: string | null, + accessToken: string, + searchBody: SearchBody, + page: number, + rowsPerPage: number, +) => { + // Just a mock, doesn't need to do anything + return Promise.resolve(); +}; + +// Mock implementation of deleteJobs +export function deleteJobs( + diracxUrl: string | null, + selectedIds: readonly number[], + accessToken: string, +): Promise<{ headers: Headers; data: any }> { + return Promise.resolve({ + headers: new Headers(), + data: { success: true }, + }); +} + +// Mock implementation of killJobs +export function killJobs( + diracxUrl: string | null, + selectedIds: readonly number[], + accessToken: string, +): Promise<{ headers: Headers; data: any }> { + return Promise.resolve({ + headers: new Headers(), + data: { + success: selectedIds.reduce( + (acc, id) => { + acc[id] = {}; + return acc; + }, + {} as Record, + ), + failed: {}, + }, + }); +} + +// Mock implementation of rescheduleJobs +export function rescheduleJobs( + diracxUrl: string | null, + selectedIds: readonly number[], + accessToken: string, +): Promise<{ headers: Headers; data: any }> { + return Promise.resolve({ + headers: new Headers(), + data: { + success: selectedIds.reduce( + (acc, id) => { + acc[id] = {}; + return acc; + }, + {} as Record, + ), + failed: {}, + }, + }); +} diff --git a/packages/diracx-web-components/stories/mocks/metadata.mock.ts b/packages/diracx-web-components/stories/mocks/metadata.mock.ts deleted file mode 100644 index a1045f56..00000000 --- a/packages/diracx-web-components/stories/mocks/metadata.mock.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { fn } from "@storybook/test"; -// Aliased '@/hooks/metadata' as '@actual/hooks/metadata' in the Storybook config to prevent the mock from importing itself. -// @ts-expect-error: Cannot find module '@actual/hooks/metadata' -import * as actual from "@actual/hooks/metadata"; - -export const useMetadata = fn(actual.useMetadata); -export type Metadata = actual.Metadata; diff --git a/packages/diracx-web-components/stories/mocks/metadata.mock.tsx b/packages/diracx-web-components/stories/mocks/metadata.mock.tsx new file mode 100644 index 00000000..37504d88 --- /dev/null +++ b/packages/diracx-web-components/stories/mocks/metadata.mock.tsx @@ -0,0 +1,26 @@ +import { Metadata } from "../../src/hooks/metadata"; + +export { Metadata } from "../../src/hooks/metadata"; + +// Create a store for mock data +let mockMetadataResponse: { + metadata: Metadata | null; + error: Error | null; + isLoading: boolean; +} = { + metadata: null, + error: null, + isLoading: false, +}; + +// Function to set mock data +export function setMetadataMock(data: { + metadata: Metadata | null; + error: Error | null; + isLoading: boolean; +}) { + mockMetadataResponse = data; +} + +// Mock hook that returns the stored mock data +export const useMetadata = () => mockMetadataResponse; diff --git a/packages/diracx-web-components/stories/mocks/react-oidc.mock.ts b/packages/diracx-web-components/stories/mocks/react-oidc.mock.ts deleted file mode 100644 index 90606cc0..00000000 --- a/packages/diracx-web-components/stories/mocks/react-oidc.mock.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { fn } from "@storybook/test"; -// Aliased the '@axa-fr/react-oidc' library as '@actual/react-oidc' in the Storybook config to prevent the mock from importing itself. -// @ts-expect-error: Cannot find module '@actual/react-oidc' -import * as actual from "@actual/react-oidc"; - -export const useOidc = fn(actual.useOidc); -export const useOidcAccessToken = fn(actual.useOidcAccessToken); -export const OidcProvider = actual.OidcProvider; diff --git a/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx b/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx new file mode 100644 index 00000000..6c8a0786 --- /dev/null +++ b/packages/diracx-web-components/stories/mocks/react-oidc.mock.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; + +const jestFn = + // Storybook runs in the browser – `jest` is not defined there + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof jest !== "undefined" ? jest.fn : (fn: any) => fn; + +const noop = () => {}; + +/* ---------- API surface --------------------------------- */ +export const useOidc = jestFn(() => ({ login: noop, isAuthenticated: false })); +export const useOidcAccessToken = jestFn(() => ({ + accessToken: "123456789", + accessTokenPayload: { + preferred_username: "John Doe", + vo: "dirac", + dirac_group: "dirac_user", + dirac_properties: ["NormalUser", "AnotherProperty"], + }, +})); + +export const OidcProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) =>
{children}
; +/* -------------------------------------------------------- */ diff --git a/packages/diracx-web-components/test/BaseApp.test.tsx b/packages/diracx-web-components/test/BaseApp.test.tsx index 61b202ec..550b51da 100644 --- a/packages/diracx-web-components/test/BaseApp.test.tsx +++ b/packages/diracx-web-components/test/BaseApp.test.tsx @@ -1,46 +1,18 @@ -import React from "react"; -import { render } from "@testing-library/react"; -import { useOidcAccessToken, useOidc } from "@axa-fr/react-oidc"; -import BaseApp from "../src/components/BaseApp/BaseApp"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; +import { render, screen } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import * as stories from "../stories/BaseApp.stories"; -// Mock the modules -jest.mock("@axa-fr/react-oidc", () => ({ - useOidc: jest.fn(), - useOidcAccessToken: jest.fn(), -})); +// Compose all the stories +const { Default } = composeStories(stories); jest.mock("jsoncrush", () => ({ crush: jest.fn().mockImplementation((data) => `crushed-${data}`), uncrush: jest.fn().mockImplementation((data) => data.replace("crushed-", "")), })); -describe("", () => { - it("renders not authenticated message when accessTokenPayload is not defined", () => { - (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: false }); - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessTokenPayload: null, - }); - - const { getByText } = render( - - - , - ); - expect(getByText("Not authenticated")).toBeInTheDocument(); - }); - - it("renders welcome message when accessTokenPayload is defined", () => { - (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: true }); - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessTokenPayload: { preferred_username: "TestUser" }, - }); - - const { getByText } = render( - - - , - ); - expect(getByText("Hello TestUser")).toBeInTheDocument(); +describe("BaseApp", () => { + it("renders the LoggedIn story", () => { + render(); + expect(screen.getByText("Hello John Doe")).toBeInTheDocument(); }); }); diff --git a/packages/diracx-web-components/test/Dashboard.test.tsx b/packages/diracx-web-components/test/Dashboard.test.tsx index 15d05af1..e70b8f6c 100644 --- a/packages/diracx-web-components/test/Dashboard.test.tsx +++ b/packages/diracx-web-components/test/Dashboard.test.tsx @@ -1,17 +1,12 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; +import { render, fireEvent, waitFor } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; import { useOidc, useOidcAccessToken } from "@axa-fr/react-oidc"; import { useMediaQuery } from "@mui/material"; -import Dashboard from "../src/components/DashboardLayout/Dashboard"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; +import * as stories from "../stories/Dashboard.stories"; -// Mock the useOidcAccessToken and useOidc hooks -jest.mock("@axa-fr/react-oidc", () => ({ - useOidcAccessToken: jest.fn(), - useOidc: jest.fn(), -})); +// Compose your Storybook stories (this will include all decorators/args) +const { Default } = composeStories(stories); -// Mock the useMediaQuery hook to control the return value jest.mock("@mui/material", () => ({ ...jest.requireActual("@mui/material"), useMediaQuery: jest.fn(), @@ -22,62 +17,348 @@ jest.mock("jsoncrush", () => ({ uncrush: jest.fn().mockImplementation((data) => data.replace("crushed-", "")), })); -describe("", () => { - beforeEach(() => { - // Mock the return value for each test - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessTokenPayload: { - test: "test", +describe("Dashboard", () => { + it("renders in desktop mode", () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); // desktop + + const { getByTestId } = render(); + + // On desktop, the permanent drawer should be visible + expect(getByTestId("drawer-permanent")).toBeVisible(); + + // The temporary drawer (for mobile) should not be rendered + expect(() => getByTestId("drawer-temporary")).toThrow(); + }); + + it("renders in mobile mode and opens drawer after toggle", () => { + (useMediaQuery as jest.Mock).mockReturnValue(true); // mobile + + const { getByTestId } = render(); + const toggleButton = getByTestId("drawer-toggle-button"); + + // Initially, the temporary drawer is not visible + expect(() => getByTestId("drawer-temporary")).toThrow(); + + // Simulate user opening the drawer + fireEvent.click(toggleButton); + + // Now, the temporary drawer should be visible + expect(getByTestId("drawer-temporary")).toBeVisible(); + }); +}); + +describe("DashboardDrawer", () => { + it("renders the app title from the context", () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); // desktop + + const { getByRole } = render(); + expect(getByRole("heading", { name: /App Name/i })).toBeInTheDocument(); + }); + + it("shows the context menu when right-clicking on an app item", () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); // desktop + + const { getByRole, getByTestId } = render(); + fireEvent.contextMenu(getByRole("button", { name: /App Name/i })); + expect(getByTestId("context-menu")).toBeInTheDocument(); + }); +}); + +describe("ApplicationDialog", () => { + it("renders the Default story with the dialog open", () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); // desktop + + const { getByTestId, getByRole, getByText } = render(); + + // Click on "Add Application" button to open the dialog + const addAppButton = getByTestId("add-application-button"); + fireEvent.click(addAppButton); + // Check if the dialog is open + expect(getByRole("dialog")).toBeInTheDocument(); + expect(getByText("Available applications")).toBeInTheDocument(); + }); +}); + +describe("DrawerItem", () => { + it("renders with the default props", () => { + const { getByRole } = render(); + + // Checks for item title + expect(getByRole("button", { name: /App Name/i })).toBeInTheDocument(); + }); +}); + +describe("DrawerItemGroup", () => { + it("renders group title", () => { + const { getByText } = render(); + + // Check if the group title is rendered + expect(getByText("Group Title")).toBeInTheDocument(); + }); +}); + +describe("ImportButton", () => { + it("renders the import button", () => { + const { getByTestId } = render(); + expect(getByTestId("import-button")).toBeInTheDocument(); + }); + + it("opens and closes the import dialog", async () => { + const { getByTestId, queryByTestId } = render(); + + expect(queryByTestId("import-menu")).not.toBeInTheDocument(); + + fireEvent.click(getByTestId("import-button")); + expect(getByTestId("import-menu")).toBeInTheDocument(); + + fireEvent.click(getByTestId("cancel-import-button")); + await waitFor(() => { + expect(queryByTestId("import-menu")).not.toBeInTheDocument(); + }); + }); + + it("shows error if JSON is invalid", async () => { + const { getByTestId, getByPlaceholderText, findByText } = render( + , + ); + fireEvent.click(getByTestId("import-button")); + + const textarea = getByPlaceholderText(/paste your application state/i); + fireEvent.change(textarea, { target: { value: "not valid json" } }); + const importBtn = getByTestId("validate-import-button"); + fireEvent.click(importBtn); + expect(await findByText(/invalid json format/i)).toBeInTheDocument(); + + // Still open + expect(getByTestId("import-menu")).toBeInTheDocument(); + }); + + it("calls onImport and closes dialog when JSON is valid", async () => { + const { getByTestId, getByPlaceholderText, queryByTestId } = render( + , + ); + fireEvent.click(getByTestId("import-button")); + const textarea = getByPlaceholderText(/paste your application state/i); + // Use valid JSON for ApplicationState + fireEvent.change(textarea, { + target: { + value: JSON.stringify([ + { appType: "test", appName: "test", state: "{}" }, + ]), }, }); - (useOidc as jest.Mock).mockReturnValue({ - isAuthenticated: false, + // Import should be enabled + const importBtn = getByTestId("validate-import-button"); + expect(importBtn).not.toBeDisabled(); + fireEvent.click(importBtn); + + // Dialog should close after success + await waitFor(() => { + expect(queryByTestId("import-menu")).not.toBeInTheDocument(); }); }); - afterEach(() => { - jest.clearAllMocks(); + it("disables import button when textarea is empty", () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId("import-button")); + expect(getByTestId("validate-import-button")).toBeDisabled(); }); +}); - // Normal case - it("renders on desktop screen", () => { - (useMediaQuery as jest.Mock).mockReturnValue(false); // e.g., desktop screen +describe("ExportButton", () => { + // Prepare a fake sessionStorage for app state + beforeEach(() => { + sessionStorage.clear(); + }); - const { getByTestId } = render( - - -

Test

-
-
, - ); + it("renders the export button with tooltip", () => { + const { getByTestId } = render(); + expect(getByTestId("export-button")).toBeInTheDocument(); + }); - // `drawer-temporary` should not even be in the DOM for desktop screen sizes - expect(() => getByTestId("drawer-temporary")).toThrow(); - // Expect `drawer-permanent` to now be visible - expect(getByTestId("drawer-permanent")).toBeVisible(); + it("opens export menu when button is clicked", () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId("export-button")); + expect(getByTestId("export-menu")).toBeInTheDocument(); }); - // Testing a hypothetical toggle button for the drawer - it("renders on mobile screen", () => { - (useMediaQuery as jest.Mock).mockReturnValue(true); // e.g., mobile screen + it("shows app checkboxes and enables export", () => { + const { getByTestId, getByLabelText, getByRole } = render(); + fireEvent.click(getByTestId("export-button")); + // You should see app/group checkboxes (adjust label as needed) + expect(getByLabelText(/group title/i)).toBeInTheDocument(); + expect(getByLabelText(/app name/i)).toBeInTheDocument(); + + // Select app + const appCheckbox = getByTestId("checkbox-example"); + fireEvent.click(appCheckbox); + + // Export button should now appear + expect( + getByRole("button", { name: /export 1 selected/i }), + ).toBeInTheDocument(); + }); - const { getByTestId } = render( - - -

Test

-
-
, + it("shows dialog with exported state when exporting", async () => { + const { getByTestId, getByRole, getByText, findByRole } = render( + , ); - const toggleButton = getByTestId("drawer-toggle-button"); + fireEvent.click(getByTestId("export-button")); - // Assuming the drawer is initially closed - // `drawer-temporary` should not even be in the DOM initially - expect(() => getByTestId("drawer-temporary")).toThrow(); + // Select app + const appCheckbox = getByTestId("checkbox-example"); + fireEvent.click(appCheckbox); - // Simulate a button click - fireEvent.click(toggleButton); + fireEvent.click(getByRole("button", { name: /export 1 selected/i })); - // Expect the drawer to now be visible - expect(getByTestId("drawer-temporary")).toBeVisible(); + // Dialog with Application State should open + expect(await findByRole("dialog")).toBeInTheDocument(); + expect(getByText(/application state/i)).toBeInTheDocument(); + + // State JSON should be visible + expect(getByText(/"App Name"/)).toBeInTheDocument(); + }); + + it("copies to clipboard and closes dialog", async () => { + const { getByTestId, getByRole, queryByRole } = render(); + fireEvent.click(getByTestId("export-button")); + const appCheckbox = getByTestId("checkbox-example"); + fireEvent.click(appCheckbox); + fireEvent.click(getByRole("button", { name: /export 1 selected/i })); + + // Mock clipboard + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + + // Click copy + const copyBtn = await getByTestId("validate-export-button"); + fireEvent.click(copyBtn); + + // Dialog should close + await waitFor(() => expect(queryByRole("dialog")).not.toBeInTheDocument()); + // Clipboard should have been called + expect(navigator.clipboard.writeText).toHaveBeenCalled(); + }); + + it("can close dialog with cancel", async () => { + const { getByTestId, getByRole, queryByRole } = render(); + fireEvent.click(getByTestId("export-button")); + const appCheckbox = getByTestId("checkbox-example"); + fireEvent.click(appCheckbox); + fireEvent.click(getByRole("button", { name: /export 1 selected/i })); + + const cancelBtn = await getByTestId("cancel-export-button"); + fireEvent.click(cancelBtn); + + await waitFor(() => expect(queryByRole("dialog")).not.toBeInTheDocument()); + }); +}); + +describe("ThemeToggleButton", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it("toggles theme and updates the icon accordingly", () => { + const { getByTestId, queryByTestId } = render(); + expect(getByTestId("dark-mode")).toBeInTheDocument(); + expect(queryByTestId("light-mode")).not.toBeInTheDocument(); + + fireEvent.click(getByTestId("theme-toggle-button")); + + expect(getByTestId("light-mode")).toBeInTheDocument(); + expect(queryByTestId("dark-mode")).not.toBeInTheDocument(); + }); + + it("renders the correct icon based on theme from sessionStorage", () => { + // Simulate theme stored in localStorage + sessionStorage.setItem("theme", "dark"); + + const { getByTestId } = render(); + expect(getByTestId("light-mode")).toBeInTheDocument(); + + fireEvent.click(getByTestId("theme-toggle-button")); + + expect(getByTestId("dark-mode")).toBeInTheDocument(); + }); + + it("uses system light mode preference when no theme in sessionStorage", () => { + // Simulate system preference = light mode + (useMediaQuery as jest.Mock).mockReturnValue(false); + + const { getByTestId, queryByTestId } = render(); + + // When system prefers light mode, "dark-mode" icon should be shown + expect(getByTestId("dark-mode")).toBeInTheDocument(); + expect(queryByTestId("light-mode")).not.toBeInTheDocument(); + + // Verify sessionStorage was updated to match system preference + expect(sessionStorage.getItem("theme")).toBe("light"); + }); + + it("prioritizes sessionStorage theme over system preference", () => { + // System prefers light mode + (useMediaQuery as jest.Mock).mockReturnValue(false); + + // But sessionStorage has dark theme + sessionStorage.setItem("theme", "dark"); + + const { getByTestId } = render(); + + // Should show light-mode icon (for dark theme) despite system preferring light + expect(getByTestId("light-mode")).toBeInTheDocument(); + }); +}); + +describe("ProfileButton", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders "Login" button when not authenticated', () => { + (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: false }); + (useOidcAccessToken as jest.Mock).mockReturnValue({}); + + const { getByTestId } = render(); + expect(getByTestId("login-button")).toBeInTheDocument(); + }); + + it("renders user avatar with initial and opens menu when authenticated", () => { + (useOidc as jest.Mock).mockReturnValue({ + isAuthenticated: true, + logout: jest.fn(), + }); + (useOidcAccessToken as jest.Mock).mockReturnValue({ + accessToken: "mockAccessToken", + accessTokenPayload: { + preferred_username: "Maria", + vo: "dirac", + dirac_group: "group1", + dirac_properties: ["Prop1", "Prop2"], + }, + }); + + const { getByText, getByTestId } = render(); + // Avatar shows first letter + expect(getByText("M")).toBeInTheDocument(); + + // Open the menu + fireEvent.click(getByTestId("profile-button")); + + // All profile fields should be visible + expect(getByText("Maria")).toBeInTheDocument(); + expect(getByText("dirac")).toBeInTheDocument(); + expect(getByText("group1")).toBeInTheDocument(); + expect(getByText("Properties")).toBeInTheDocument(); + expect(getByText("About")).toBeInTheDocument(); + expect(getByText("Logout")).toBeInTheDocument(); + + // Expand properties + fireEvent.click(getByText("Properties")); + expect(getByText("Prop1")).toBeInTheDocument(); + expect(getByText("Prop2")).toBeInTheDocument(); }); }); diff --git a/packages/diracx-web-components/test/DashboardDrawer.test.tsx b/packages/diracx-web-components/test/DashboardDrawer.test.tsx deleted file mode 100644 index 3ab7a949..00000000 --- a/packages/diracx-web-components/test/DashboardDrawer.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; -import { Dashboard as DashboardIcon } from "@mui/icons-material"; -import DashboardDrawer from "../src/components/DashboardLayout/DashboardDrawer"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { ApplicationsContext } from "../src/contexts/ApplicationsProvider"; -import { DashboardGroup } from "../src/types/DashboardGroup"; -import { applicationList } from "../src/components/ApplicationList"; - -jest.mock("jsoncrush", () => ({ - crush: jest.fn().mockImplementation((data) => `crushed-${data}`), - uncrush: jest.fn().mockImplementation((data) => data.replace("crushed-", "")), -})); - -const mockSections: DashboardGroup[] = [ - { - title: "Group 1", - extended: true, - items: [ - { - title: "App 1", - id: "app1", - type: "Dashboard", - icon: DashboardIcon, - }, - ], - }, -]; - -const MockApplicationProvider: React.FC<{ children: React.ReactNode }> = ({ - children, -}): JSX.Element => ( - {}, applicationList]} - > - {children} - -); - -describe("", () => { - it("renders correctly", () => { - const { getByText } = render( - - - - - , - ); - - expect(getByText("App 1")).toBeInTheDocument(); - }); - - it("handles context menu", () => { - const { getByText, getByTestId } = render( - - - - - , - ); - - fireEvent.contextMenu(getByText("App 1")); - - expect(getByTestId("context-menu")).toBeInTheDocument(); - }); -}); diff --git a/packages/diracx-web-components/test/DataTable.test.tsx b/packages/diracx-web-components/test/DataTable.test.tsx new file mode 100644 index 00000000..1260b9f5 --- /dev/null +++ b/packages/diracx-web-components/test/DataTable.test.tsx @@ -0,0 +1,34 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import * as stories from "../stories/DataTable.stories"; + +// Compose the stories to get actual Storybook behavior (decorators, args, etc) +const { Default } = composeStories(stories); + +describe("DataTable", () => { + it("renders table title", () => { + render(); + expect(screen.getByText("Data Table")).toBeInTheDocument(); + }); + + it("renders column headers", () => { + render(); + expect(screen.getByText("ID")).toBeInTheDocument(); + expect(screen.getByText("Name")).toBeInTheDocument(); + expect(screen.getByText("Email")).toBeInTheDocument(); + }); + + it("renders Edit menu item if menu is opened", () => { + render(); + const moreButtons = screen.queryAllByRole("button"); + const menuButton = moreButtons.find((btn) => + /menu|action|more/i.test( + btn.textContent || btn.getAttribute("aria-label") || "", + ), + ); + if (menuButton) { + fireEvent.click(menuButton); + expect(screen.getByText("Edit")).toBeInTheDocument(); + } + }); +}); diff --git a/packages/diracx-web-components/test/ErrorBox.test.tsx b/packages/diracx-web-components/test/ErrorBox.test.tsx new file mode 100644 index 00000000..54e432d4 --- /dev/null +++ b/packages/diracx-web-components/test/ErrorBox.test.tsx @@ -0,0 +1,33 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import * as stories from "../stories/ErrorBox.stories"; + +// Compose the stories to get actual Storybook behavior (decorators, args, etc) +const { Default } = composeStories(stories); + +describe("ErrorBox", () => { + it("renders default error message", () => { + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("renders custom error message", () => { + render(); + expect(screen.getByText("Custom error occurred")).toBeInTheDocument(); + }); + + it("renders reset button if reset prop is provided and calls it on click", () => { + const resetMock = jest.fn(); + render(); + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(resetMock).toHaveBeenCalled(); + }); + + it("does not render reset button if reset prop is not provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); +}); diff --git a/packages/diracx-web-components/test/FilterForm.test.tsx b/packages/diracx-web-components/test/FilterForm.test.tsx index 9356581f..7b3c0c53 100644 --- a/packages/diracx-web-components/test/FilterForm.test.tsx +++ b/packages/diracx-web-components/test/FilterForm.test.tsx @@ -1,239 +1,45 @@ -import React from "react"; import { render, screen, fireEvent, within } from "@testing-library/react"; -import { createColumnHelper } from "@tanstack/react-table"; -import { FilterForm } from "../src/components/shared/FilterForm"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; +import { composeStories } from "@storybook/react"; +import * as stories from "../stories/FilterForm.stories"; -// Define the item type for the table -interface SimpleItem extends Record { - id: number; - name: string; - date: Date; - category: string; -} +// Compose the stories to get actual Storybook behavior (decorators, args, etc) +const { Default } = composeStories(stories); describe("FilterForm", () => { - // Define the columns for the table - const columnHelper = createColumnHelper(); - - const columnDefs = [ - columnHelper.accessor("id", { - id: "id", - header: "ID", - meta: { type: "number" }, - }), - columnHelper.accessor("name", { - id: "name", - header: "Name", - meta: { type: "string" }, - }), - columnHelper.accessor("category", { - id: "category", - header: "Category", - meta: { type: "category", values: ["A", "B", "C"] }, // Example of a category column - }), - columnHelper.accessor("date", { - id: "date", - header: "Date", - meta: { type: "date" }, // Example of a DateTime column - }), - ]; - - // Mock filters - const filters = [ - { id: 1, parameter: "id", operator: "eq", value: "4", isApplied: false }, - { - id: 2, - parameter: "name", - operator: "neq", - value: "value2", - isApplied: false, - }, - ]; - const setFilters = jest.fn(); - const handleFilterChange = jest.fn(); - const handleFilterMenuClose = jest.fn(); - - // Wrapper component to initialize the table - interface FilterFormWrapperProps { - selectedFilterId: number | undefined; - } - - const FilterFormWrapper: React.FC = ({ - selectedFilterId, - }) => { - return ( - - - columns={columnDefs} - filters={filters} - setFilters={setFilters} - handleFilterChange={handleFilterChange} - handleFilterMenuClose={handleFilterMenuClose} - selectedFilterId={selectedFilterId} - /> - - ); - }; - it("renders the filter form with correct initial values", () => { - render(); - - const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const operatorSelect = screen.getByTestId("filter-form-select-operator"); - const valueInput = screen.getByLabelText("Value") as HTMLInputElement; - - expect(columnSelect).not.toHaveTextContent("ID"); - expect(operatorSelect).toHaveTextContent("equals to"); - expect(valueInput.value).not.toBe("value1"); + render(); + // By default: ID = 1, operator = "equals to", value = "1" + expect( + screen.getByTestId("filter-form-select-parameter"), + ).toHaveTextContent("ID"); + expect(screen.getByTestId("filter-form-select-operator")).toHaveTextContent( + "equals to", + ); + expect(screen.getByLabelText("Value")).toHaveValue(1); }); - it("renders the filter form with correct initial values when a filter is selected", () => { - render(); - - const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const operatorSelect = screen.getByTestId("filter-form-select-operator"); - const valueInput = screen.getByLabelText("Value") as HTMLInputElement; - - expect(columnSelect).toHaveTextContent("ID"); - expect(operatorSelect).toHaveTextContent("equals to"); - expect(valueInput.value).toBe("4"); + it("allows changing the value", () => { + render(); + const valueInput = screen.getByLabelText("Value"); + fireEvent.change(valueInput, { target: { value: "42" } }); + expect(valueInput).toHaveValue(42); }); - it("updates the selected filter when fields are changed", () => { - render(); - + it("allows changing the parameter (column)", () => { + render(); const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const operatorSelect = screen.getByTestId("filter-form-select-operator"); - const valueInput = screen.getByLabelText("Value") as HTMLInputElement; - + const button = within(columnSelect).getByRole("combobox"); + fireEvent.mouseDown(button); + fireEvent.click(screen.getByText("Name")); expect(columnSelect).toHaveTextContent("Name"); - expect(operatorSelect).toHaveTextContent("not equals to"); - expect(valueInput.value).toBe("value2"); - - // Simulate a click event on the column Select element - const columnButton = within(columnSelect).getByRole("combobox"); - fireEvent.mouseDown(columnButton); - - // Select the desired option from the dropdown list - const columnOption = screen.getByText("ID"); - fireEvent.click(columnOption); - - // Simulate a click event on the operator Select element - const operatorButton = within(operatorSelect).getByRole("combobox"); - fireEvent.mouseDown(operatorButton); - - // Select the desired option from the dropdown list - const operatorOption = screen.getByText("is greater than"); - fireEvent.click(operatorOption); - - // Simulate a change event on the value input element - fireEvent.change(valueInput, { target: { value: "5" } }); - - expect(columnSelect).toHaveTextContent("ID"); - expect(operatorSelect).toHaveTextContent("is greater than"); - expect(valueInput.value).toBe("5"); - }); - - it("calls setFilters when applyChanges is clicked with a new filter", () => { - render(); - - const applyChangesButton = screen.getByText("Add"); - - fireEvent.click(applyChangesButton); - - expect(setFilters).toHaveBeenCalledWith([ - ...filters, - { - id: expect.any(Number), - parameter: "", - operator: "eq", - value: "", - isApplied: false, - }, - ]); - expect(handleFilterChange).not.toHaveBeenCalled(); - expect(handleFilterMenuClose).toHaveBeenCalled(); - }); - - it("calls handleFilterChange when applyChanges is clicked with an existing filter", () => { - render(); - - const applyChangesButton = screen.getByText("Add"); - - // Simulate a click event on the column Select element - const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const columnButton = within(columnSelect).getByRole("combobox"); - fireEvent.mouseDown(columnButton); - - // Select the desired option from the dropdown list - const columnOption = screen.getByText("Category"); - fireEvent.click(columnOption); - - fireEvent.click(applyChangesButton); - - expect(setFilters).toHaveBeenCalled(); - expect(handleFilterChange).toHaveBeenCalledWith(0, { - id: 1, - parameter: "category", - operator: "eq", - value: "", - isApplied: false, - }); - expect(handleFilterMenuClose).toHaveBeenCalled(); - }); - - it("renders the correct input for DateTime column type", () => { - render(); - - const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const columnButton = within(columnSelect).getByRole("combobox"); - fireEvent.mouseDown(columnButton); - const columnOption = screen.getByText("Date"); - fireEvent.click(columnOption); - - const operatorSelect = screen.getByTestId("filter-form-select-operator"); - expect(operatorSelect).toHaveTextContent("in the last"); - - const dateTimeInput = screen.getByLabelText("Value"); - - expect(dateTimeInput).toHaveRole("combobox"); - - // Simulate a click event on the operator Select element - const operatorButton = within(operatorSelect).getByRole("combobox"); - fireEvent.mouseDown(operatorButton); - - // Select the desired option from the dropdown list - const operatorOption = screen.getByText("is greater than"); - fireEvent.click(operatorOption); - - expect(screen.getByTestId("CalendarIcon")).toBeInTheDocument(); }); - it("handles 'in' and 'not in' operators for category columns", () => { - render(); - - const columnSelect = screen.getByTestId("filter-form-select-parameter"); - const columnButton = within(columnSelect).getByRole("combobox"); - fireEvent.mouseDown(columnButton); - const columnOption = screen.getByText("Category"); - fireEvent.click(columnOption); - + it("allows changing the operator", () => { + render(); const operatorSelect = screen.getByTestId("filter-form-select-operator"); - const operatorButton = within(operatorSelect).getByRole("combobox"); - fireEvent.mouseDown(operatorButton); - const operatorOption = screen.getByText("is in"); - fireEvent.click(operatorOption); - - const valueSelect = screen.getByLabelText("Value"); - expect(valueSelect).toHaveRole("combobox"); - fireEvent.mouseDown(valueSelect); - - const valueOption1 = screen.getByText("A"); - fireEvent.click(valueOption1); - const valueOption2 = screen.getByText("B"); - fireEvent.click(valueOption2); - - expect(valueSelect).toHaveTextContent("A, B"); + const button = within(operatorSelect).getByRole("combobox"); + fireEvent.mouseDown(button); + fireEvent.click(screen.getByText("not equals to")); + expect(operatorSelect).toHaveTextContent("not equals to"); }); }); diff --git a/packages/diracx-web-components/test/FilterToolbar.test.tsx b/packages/diracx-web-components/test/FilterToolbar.test.tsx index 8097859a..740a77ce 100644 --- a/packages/diracx-web-components/test/FilterToolbar.test.tsx +++ b/packages/diracx-web-components/test/FilterToolbar.test.tsx @@ -1,151 +1,43 @@ -import React from "react"; -import { render, screen, fireEvent, cleanup } from "@testing-library/react"; -import { - createColumnHelper, - getCoreRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { FilterToolbar } from "../src/components/shared/FilterToolbar"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import * as stories from "../stories/FilterToolbar.stories"; -// Define the item type for the table -interface SimpleItem extends Record { - id: number; - name: string; - description: string; -} +const { Default } = composeStories(stories); describe("FilterToolbar", () => { - // Define the columns for the table - const columnHelper = createColumnHelper(); - - const columnDefs = [ - columnHelper.accessor("id", { - header: "ID", - meta: { type: "number" }, - }), - columnHelper.accessor("name", { - header: "Name", - meta: { type: "string" }, - }), - columnHelper.accessor("description", { - header: "Description", - meta: { type: "string" }, - }), - ]; - - // Create mock data for the table - const data: SimpleItem[] = [ - { id: 1, name: "Item 1", description: "Description 1" }, - { id: 2, name: "Item 2", description: "Description 2" }, - ]; - - // Create mock filters - const filters = [ - { - id: 1, - parameter: "id", - operator: "eq", - value: "value1", - isApplied: true, - }, - { - id: 2, - parameter: "name", - operator: "neq", - value: "value2", - isApplied: false, - }, - ]; - - const setFilters = jest.fn(); - const handleApplyFilters = jest.fn(); - const handleClearFilters = jest.fn(); - - // Wrapper component to initialize the table - const FilterToolbarWrapper: React.FC = () => { - const table = useReactTable({ - data, - columns: columnDefs, - getCoreRowModel: getCoreRowModel(), - }); - - return ( - - - columns={table.getAllColumns()} - filters={filters} - setFilters={setFilters} - handleApplyFilters={handleApplyFilters} - handleClearFilters={handleClearFilters} - /> - - ); - }; - - beforeEach(() => { - render(); - }); - - it("renders the filter toolbar with correct buttons", () => { - const addFilterButton = screen.getByText("Add filter"); - const applyFiltersButton = screen.getByText("Apply filters"); - const clearAllFiltersButton = screen.getByText("Clear all filters"); - - expect(addFilterButton).toBeInTheDocument(); - expect(applyFiltersButton).toBeInTheDocument(); - expect(clearAllFiltersButton).toBeInTheDocument(); - - const idFilter = screen.getByText("id eq value1").closest("div"); - const nameFilter = screen.getByText("name neq value2").closest("div"); - - expect(idFilter).toBeInTheDocument(); - expect(nameFilter).toBeInTheDocument(); - - expect(idFilter).toHaveClass("chip-filter-applied"); - expect(nameFilter).toHaveClass("chip-filter-unapplied"); + it("shows the three main buttons", () => { + render(); + expect( + screen.getByRole("button", { name: /add filter/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /apply filters/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /clear all filters/i }), + ).toBeInTheDocument(); }); - it("renders the warning when there are unapplied filters", () => { - const warningMessage = screen.getByText( - 'Some filter changes have not been applied. Please click on "Apply filters" to update your results.', - ); + it("renders filter chips with correct applied / unapplied classes", () => { + render(); + // chip text is rendered inside a div/span, we search the chip root by nearest div + const appliedChip = screen.getByText("id eq 1").closest("div"); + const unappliedChip = screen.getByText("id neq 2").closest("div"); // story renamed automaticly - expect(warningMessage).toBeInTheDocument(); - - filters[1].isApplied = true; - - cleanup(); - - render(); - - expect(warningMessage).not.toBeInTheDocument(); - filters[1].isApplied = false; + expect(appliedChip).toHaveClass("chip-filter-applied"); + expect(unappliedChip).toHaveClass("chip-filter-unapplied"); }); - it("opens the filter form when 'Add filter' button is clicked", () => { - const addFilterButton = screen.getByText("Add filter"); - - fireEvent.click(addFilterButton); - - const filterForm = screen.getByRole("presentation"); - - expect(filterForm).toBeInTheDocument(); + it("warns the user about unapplied filters", () => { + render(); + expect( + screen.getByText(/Some filter changes have not been applied/i), + ).toBeInTheDocument(); }); - it("applies filters when 'Apply filters' button is clicked", async () => { - const applyFiltersButton = screen.getByText("Apply filters"); - - fireEvent.click(applyFiltersButton); - - expect(handleApplyFilters).toHaveBeenCalled(); - }); - - it("removes a filter when the corresponding 'Delete' button is clicked", () => { - const deleteFilterButton = screen.getAllByTestId("CancelIcon")[0]; - - fireEvent.click(deleteFilterButton); - - expect(setFilters).toHaveBeenCalledWith([filters[1]]); + it("opens the filter form popper when *Add filter* is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add filter/i })); + expect(screen.getByRole("presentation")).toBeInTheDocument(); // the MUI Popper }); }); diff --git a/packages/diracx-web-components/test/JobDataTable.test.tsx b/packages/diracx-web-components/test/JobDataTable.test.tsx deleted file mode 100644 index 4b8f1e18..00000000 --- a/packages/diracx-web-components/test/JobDataTable.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import React from "react"; -import { render } from "@testing-library/react"; -import { VirtuosoMockContext } from "react-virtuoso"; -import { useOidcAccessToken } from "@axa-fr/react-oidc"; -import { createColumnHelper } from "@tanstack/react-table"; -import { JobDataTable } from "../src/components/JobMonitor/JobDataTable"; -import { useJobs } from "../src/components/JobMonitor/JobDataService"; -import { Job } from "../src/types"; - -// ——— Mock out OIDC + DataService hooks/funcs ——— -jest.mock("@axa-fr/react-oidc", () => ({ - useOidcAccessToken: jest.fn(), -})); - -jest.mock("../src/components/JobMonitor/JobDataService", () => ({ - useJobs: jest.fn(), - deleteJobs: jest.fn(), - killJobs: jest.fn(), - rescheduleJobs: jest.fn(), - refreshJobs: jest.fn(), - getJobHistory: jest.fn(), -})); - -const columnHelper = createColumnHelper(); - -const columnDefs = [ - columnHelper.accessor("JobID", { - id: "JobID", - header: "Job ID", - meta: { type: "string" }, - }), - columnHelper.accessor("JobName", { - id: "JobName", - header: "Job Name", - meta: { type: "string" }, - }), - columnHelper.accessor("Status", { - id: "Status", - header: "Status", - meta: { type: "string" }, - }), - columnHelper.accessor("MinorStatus", { - id: "MinorStatus", - header: "Minor Status", - meta: { type: "string" }, - }), - columnHelper.accessor("SubmissionTime", { - id: "SubmissionTime", - header: "Submission Time", - meta: { type: "date" }, - }), -]; - -const defaultProps = { - searchBody: { - search: [], - sort: [{ parameter: "JobID", direction: "desc" as const }], - }, - setSearchBody: jest.fn(), - columns: columnDefs, - pagination: { pageIndex: 0, pageSize: 25 }, - setPagination: jest.fn(), - rowSelection: {}, - setRowSelection: jest.fn(), - columnVisibility: {}, - setColumnVisibility: jest.fn(), - columnPinning: { left: [] }, - setColumnPinning: jest.fn(), -}; - -describe("", () => { - beforeEach(() => { - jest.resetAllMocks(); - // return shape matching: const { accessToken } = useOidcAccessToken(...) - (useOidcAccessToken as jest.Mock).mockReturnValue({ accessToken: "1234" }); - }); - - function renderWithProps(overrides = {}) { - return render( - - - , - ); - } - - it("displays the skeleton when `isValidating` is true", () => { - (useJobs as jest.Mock).mockReturnValue({ - data: undefined, - error: null, - isValidating: true, - isLoading: false, - }); - - const { getByTestId } = renderWithProps(); - expect(getByTestId("loading-skeleton")).toBeVisible(); - }); - - it("displays the skeleton when `isLoading` is true", () => { - (useJobs as jest.Mock).mockReturnValue({ - data: undefined, - error: null, - isValidating: false, - isLoading: true, - }); - - const { getByTestId } = renderWithProps(); - expect(getByTestId("loading-skeleton")).toBeVisible(); - }); - - it("displays the error message", () => { - (useJobs as jest.Mock).mockReturnValue({ - data: undefined, - error: new Error("fetch-fail"), - isValidating: false, - isLoading: false, - }); - - const { getByText } = renderWithProps(); - expect( - getByText("An error occurred while fetching data. Reload the page."), - ).toBeInTheDocument(); - }); - - it("displays the no-data message when `data.data` is empty", () => { - (useJobs as jest.Mock).mockReturnValue({ - data: { headers: new Headers(), data: [] }, - error: null, - isValidating: false, - isLoading: false, - }); - - const { getByText } = renderWithProps(); - expect( - getByText("No data or no results match your filters."), - ).toBeInTheDocument(); - }); - - it("renders rows when there is job data", () => { - const headers = new Headers({ "content-range": "jobs 0-0/1" }); - const fakeData = { - headers, - data: [ - { - JobID: "1", - JobName: "TestJob1", - Status: "Running", - MinorStatus: "Processing", - SubmissionTime: "2023-10-13", - }, - ], - }; - - (useJobs as jest.Mock).mockReturnValue({ - data: fakeData, - error: null, - isValidating: false, - isLoading: false, - }); - - const { getByText } = renderWithProps(); - expect(getByText("TestJob1")).toBeInTheDocument(); - }); -}); diff --git a/packages/diracx-web-components/test/JobHistoryDialog.test.tsx b/packages/diracx-web-components/test/JobHistoryDialog.test.tsx deleted file mode 100644 index c515e3b8..00000000 --- a/packages/diracx-web-components/test/JobHistoryDialog.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from "react"; -import { render, screen } from "@testing-library/react"; -import { JobHistoryDialog } from "../src/components/JobMonitor/JobHistoryDialog"; - -describe("JobHistoryDialog", () => { - const historyData = [ - { - Status: "Received", - MinorStatus: "", - ApplicationStatus: "", - StatusTime: "2022-01-01", - Source: "Local", - }, - { - Status: "Killed", - MinorStatus: "Job was killed", - ApplicationStatus: "64", - StatusTime: "2022-01-02", - Source: "Site1", - }, - ]; - - it("renders the dialog with correct data", () => { - render( - , - ); - - // Dialog title - const dialogTitle = screen.getByText("Job History: 1"); - expect(dialogTitle).toBeInTheDocument(); - - // Table headers - const statusHeader = screen.getByText("Status"); - const minorStatusHeader = screen.getByText("Minor Status"); - const applicationStatusHeader = screen.getByText("Application Status"); - const statusTimeHeader = screen.getByText("Status Time"); - const sourceHeader = screen.getByText("Source"); - - expect(statusHeader).toBeInTheDocument(); - expect(minorStatusHeader).toBeInTheDocument(); - expect(applicationStatusHeader).toBeInTheDocument(); - expect(statusTimeHeader).toBeInTheDocument(); - expect(sourceHeader).toBeInTheDocument(); - - // History - const statusCell = screen.getByText("Killed"); - const minorStatusCell = screen.getByText("Job was killed"); - const applicationStatusCell = screen.getByText("64"); - const statusTimeCell = screen.getByText("2022-01-01"); - const sourceCell = screen.getByText("Local"); - - expect(statusCell).toBeInTheDocument(); - expect(minorStatusCell).toBeInTheDocument(); - expect(applicationStatusCell).toBeInTheDocument(); - expect(statusTimeCell).toBeInTheDocument(); - expect(sourceCell).toBeInTheDocument(); - }); - - it("does not render the dialog because dialog is closed", () => { - render( - , - ); - - // Dialog title - const dialogTitle = screen.queryByText("Job History: 1050"); - expect(dialogTitle).not.toBeInTheDocument(); - - // Table headers - const statusHeader = screen.queryByText("Status"); - const minorStatusHeader = screen.queryByText("Minor Status"); - const applicationStatusHeader = screen.queryByText("Application Status"); - const statusTimeHeader = screen.queryByText("Status Time"); - const sourceHeader = screen.queryByText("Source"); - - expect(statusHeader).not.toBeInTheDocument(); - expect(minorStatusHeader).not.toBeInTheDocument(); - expect(applicationStatusHeader).not.toBeInTheDocument(); - expect(statusTimeHeader).not.toBeInTheDocument(); - expect(sourceHeader).not.toBeInTheDocument(); - - // History - const statusCell = screen.queryByText("Killed"); - const minorStatusCell = screen.queryByText("Job was killed"); - const applicationStatusCell = screen.queryByText("64"); - const statusTimeCell = screen.queryByText("2022-01-01"); - const sourceCell = screen.queryByText("Local"); - - expect(statusCell).not.toBeInTheDocument(); - expect(minorStatusCell).not.toBeInTheDocument(); - expect(applicationStatusCell).not.toBeInTheDocument(); - expect(statusTimeCell).not.toBeInTheDocument(); - expect(sourceCell).not.toBeInTheDocument(); - }); -}); diff --git a/packages/diracx-web-components/test/JobMonitor.test.tsx b/packages/diracx-web-components/test/JobMonitor.test.tsx new file mode 100644 index 00000000..8a091efd --- /dev/null +++ b/packages/diracx-web-components/test/JobMonitor.test.tsx @@ -0,0 +1,109 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import { VirtuosoMockContext } from "react-virtuoso"; +import * as stories from "../stories/JobMonitor.stories"; + +// Compose Storybook stories (includes all decorators/args) +const { Default, Loading, Empty, Error } = composeStories(stories); + +jest.mock("jsoncrush", () => ({ + crush: jest.fn().mockImplementation((data) => `crushed-${data}`), + uncrush: jest.fn().mockImplementation((data) => data.replace("crushed-", "")), +})); + +describe("JobMonitor", () => { + it("renders the job monitor component", async () => { + const { getByTestId, getByText } = render(); + + expect(getByTestId("add-filter-button")).toBeInTheDocument(); + expect(getByTestId("apply-filters-button")).toBeInTheDocument(); + expect(getByTestId("clear-filters-button")).toBeInTheDocument(); + + // Verify job data is displayed + await waitFor(() => { + expect(getByText("List of Jobs")).toBeInTheDocument(); + }); + }); + + it("renders loading state while fetching data", () => { + const { getByTestId } = render(); + + // Verify loading state + expect(getByTestId("loading-skeleton")).toBeInTheDocument(); + }); + + it("renders error state when data fetch fails", () => { + const { getByText } = render(); + + // Verify error message + expect( + getByText("An error occurred while fetching data. Reload the page."), + ).toBeInTheDocument(); + }); + + it("renders empty state when no jobs are found", () => { + const { getByText } = render(); + + // Verify empty state message + expect( + getByText("No data or no results match your filters."), + ).toBeInTheDocument(); + }); +}); + +describe("JobDataTable", () => { + it("displays job data with correct columns", async () => { + const { getByText } = render( + + + , + ); + + // Verify table headers + expect(getByText("ID")).toBeInTheDocument(); + expect(getByText("Status")).toBeInTheDocument(); + expect(getByText("Name")).toBeInTheDocument(); + + // Verify job data is displayed + await waitFor(() => { + expect(getByText("Job 1")).toBeInTheDocument(); + expect(getByText("Job accepted")).toBeInTheDocument(); + }); + }); +}); + +describe("JobHistoryDialog", () => { + it("renders the dialog with correct data", async () => { + const { getByText } = render( + + + , + ); + + await act(async () => { + fireEvent.contextMenu(getByText("Job 1")); + }); + + // Now wait for the context menu to appear and click Get history + await act(async () => { + fireEvent.click(getByText("Get history")); + // Allow time for state updates to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + // Now check for the dialog + await waitFor(() => { + expect(screen.getByText(/Job History:/)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/diracx-web-components/test/LoginForm.test.tsx b/packages/diracx-web-components/test/LoginForm.test.tsx index ab4f1075..3498a5d8 100644 --- a/packages/diracx-web-components/test/LoginForm.test.tsx +++ b/packages/diracx-web-components/test/LoginForm.test.tsx @@ -1,134 +1,57 @@ -import { render, fireEvent, screen } from "@testing-library/react"; -import React from "react"; -import { LoginForm } from "../src/components/Login/LoginForm"; -import { ThemeProvider } from "../src/contexts/ThemeProvider"; -import { useMetadata } from "../src/hooks/metadata"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { composeStories } from "@storybook/react"; +import * as stories from "../stories/LoginForm.stories"; +import { useOidc } from "../stories/mocks/react-oidc.mock"; -const singleVOMetadata = { - virtual_organizations: { - DTeam: { - groups: { - admin: { - properties: ["AdminUser"], - }, - user: { - properties: ["NormalUser"], - }, - }, - support: { - message: "Please contact system administrator", - webpage: null, - email: null, - }, - default_group: "user", - }, - }, -}; +const { SingleVO, MultiVO, Error, Loading } = composeStories(stories); -const multiVOMetadata = { - virtual_organizations: { - LHCp: { - groups: { - user: { - properties: ["NormalUser"], - }, - admin: { - properties: ["AdminUser"], - }, - }, - support: { - message: "Please contact the system administrator", - webpage: null, - email: null, - }, - default_group: "admin", - }, - PridGG: { - groups: { - admin: { - properties: ["NormalUser"], - }, - }, - support: { - message: - "Please restart your machine, if it still does not work, please try again later", - webpage: null, - email: null, - }, - default_group: "admin", - }, - }, -}; - -// Mock the necessary hooks and external modules -jest.mock("../src/hooks/metadata"); - -jest.mock("../src/hooks/utils", () => ({ - useDiracxUrl: () => "https://example.com", -})); +describe("LoginForm", () => { + beforeEach(() => { + // ensure OIDC is always logged out by default + useOidc.mockReturnValue({ login: () => {}, isAuthenticated: false }); + }); -jest.mock("@axa-fr/react-oidc", () => ({ - useOidc: () => ({ - login: jest.fn(), - isAuthenticated: false, - }), -})); + it("works for the SingleVO story", () => { + render(); -describe("LoginForm", () => { - // Should render a text field to select the VO - it("renders correctly multiple VOs", () => { - (useMetadata as jest.Mock).mockReturnValue({ metadata: multiVOMetadata }); + // now immediately rendered + expect(screen.getByTestId("h3-vo-name")).toBeInTheDocument(); + expect(screen.queryByTestId("autocomplete-vo-select")).toBeNull(); + expect(screen.getByTestId("select-group")).toBeInTheDocument(); + expect(screen.getByTestId("button-login")).toBeInTheDocument(); + }); - render( - - - , - ); + it("works for the MultiVO story", () => { + render(); - // Check the presence of the VO select field (it should not presented as a title since there are multiple VOs) - // Check the presence of the login button (it should not be present as we have not selected a VO) const input = screen .getByTestId("autocomplete-vo-select") - .querySelector("input"); - expect(() => screen.getByTestId("h3-vo-name")).toThrow(); - expect(() => screen.getByTestId("button-login")).toThrow(); + .querySelector("input") as HTMLInputElement; - // Simulate typing into the input field (the VO selected does not exist) - // Check the presence of the login button (it should not be present as the VO does not exist) - fireEvent.change(input, { target: { value: "Does not exist" } }); - expect(() => screen.getByTestId("button-login")).toThrow(); + // before selection + expect(screen.queryByTestId("button-login")).toBeNull(); - // Simulate typing into the input field (partial VO name) - // Check the presence of the login button (it should be present as the VO exists) + // pick “LHCp” fireEvent.change(input, { target: { value: "LHC" } }); fireEvent.click(screen.getByText("LHCp")); - // Check the presence of the group selector expect(screen.getByTestId("select-group")).toBeInTheDocument(); - - // Check the presence of the login button (it should be present as the VO exists) expect(screen.getByTestId("button-login")).toBeInTheDocument(); }); - // Should render a title with the VO name - it("renders correctly single VO", () => { - (useMetadata as jest.Mock).mockReturnValue({ metadata: singleVOMetadata }); + it("works for the Error story", () => { + render(); - render( - - - , - ); - - // Check the presence of the VO title - // The select field should not be presented as a title since there is only one VO - expect(screen.getByTestId("h3-vo-name")).toBeInTheDocument(); - expect(() => screen.getByTestId("autocomplete-vo-select")).toThrow(); + expect( + screen.getByText("An error occurred while fetching metadata."), + ).toBeInTheDocument(); + expect(screen.queryByTestId("h3-vo-name")).toBeNull(); + }); - // Check the presence of the group selector - expect(screen.getByTestId("select-group")).toBeInTheDocument(); + it("works for the Loading story", () => { + render(); - // Check the presence of the login button (it should be present as the VO exists) - expect(screen.getByTestId("button-login")).toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByTestId("h3-vo-name")).toBeNull(); }); }); diff --git a/packages/diracx-web-components/test/ProfileButton.test.tsx b/packages/diracx-web-components/test/ProfileButton.test.tsx deleted file mode 100644 index a92223d7..00000000 --- a/packages/diracx-web-components/test/ProfileButton.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; -import { - useOidcAccessToken, - useOidc, - OidcConfiguration, -} from "@axa-fr/react-oidc"; -import { ProfileButton } from "../src/components/DashboardLayout/ProfileButton"; -import { OIDCConfigurationContext } from "../src/contexts/OIDCConfigurationProvider"; - -// Mocking the hooks -jest.mock("@axa-fr/react-oidc"); - -beforeEach(() => { - jest.resetAllMocks(); -}); - -describe("", () => { - it('displays the "Login" button when not authenticated', () => { - (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: false }); - (useOidcAccessToken as jest.Mock).mockReturnValue({}); - - const { getByText } = render(); - expect(getByText("Login")).toBeInTheDocument(); - }); - - it("displays the user avatar when authenticated", () => { - (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: true }); - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessToken: "mockAccessToken", - accessTokenPayload: { preferred_username: "John" }, - }); - - const { getByText } = render(); - expect(getByText("J")).toBeInTheDocument(); // Assuming 'John' is the preferred username and 'J' is the first letter. - }); - - it("opens the menu when avatar is clicked", () => { - (useOidc as jest.Mock).mockReturnValue({ isAuthenticated: true }); - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessToken: "mockAccessToken", - accessTokenPayload: { - preferred_username: "John", - vo: "DiracVO", - dirac_group: "dirac_user", - dirac_properties: ["NormalUser"], - }, - }); - - const { getByText, queryByText } = render(); - fireEvent.click(getByText("J")); - - expect(queryByText("John")).toBeInTheDocument(); - expect(queryByText("DiracVO")).toBeInTheDocument(); - expect(queryByText("dirac_user")).toBeInTheDocument(); - expect(queryByText("Properties")).toBeInTheDocument(); - expect(queryByText("About")).toBeInTheDocument(); - expect(queryByText("Logout")).toBeInTheDocument(); - - // Open the "Properties" section - fireEvent.click(getByText("Properties")); - - // Ensure the "NormalUser" property is displayed within the "Properties" section - expect(queryByText("NormalUser")).toBeInTheDocument(); - }); - - it('calls the logout function when "Logout" is clicked', () => { - const mockLogout = jest.fn(); - - (useOidc as jest.Mock).mockReturnValue({ - isAuthenticated: true, - logout: mockLogout, - }); - (useOidcAccessToken as jest.Mock).mockReturnValue({ - accessTokenPayload: { preferred_username: "John" }, - }); - - // Mock context value - const mockContextValue = { - configuration: { - scope: "fake_scope", - client_id: "fake_id", - redirect_uri: "fake_uri", - authority: "fake_authority", - } as OidcConfiguration, - setConfiguration: jest.fn(), - }; - - const { getByText } = render( - - - , - ); - - // Open the menu by clicking the avatar - fireEvent.click(getByText("J")); - - // Click the "Logout" option - fireEvent.click(getByText("Logout")); - - // Ensure the mockLogout function was called - expect(mockLogout).toHaveBeenCalled(); - }); -}); diff --git a/packages/diracx-web-components/test/ThemeToggleButton.test.tsx b/packages/diracx-web-components/test/ThemeToggleButton.test.tsx deleted file mode 100644 index ee8c0648..00000000 --- a/packages/diracx-web-components/test/ThemeToggleButton.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// ThemeToggleButton.test.tsx -import React from "react"; -import { render, fireEvent } from "@testing-library/react"; -import { ThemeToggleButton } from "../src/components/DashboardLayout/ThemeToggleButton"; -import { useTheme } from "../src/hooks/theme"; - -// Mock the useTheme hook -jest.mock("../src/hooks/theme", () => ({ - useTheme: jest.fn(), -})); - -describe("", () => { - let mockToggleTheme: jest.Mock; - - beforeEach(() => { - mockToggleTheme = jest.fn(); - (useTheme as jest.Mock).mockReturnValue({ - theme: "light", - toggleTheme: mockToggleTheme, - }); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders the DarkModeIcon when theme is "light"', () => { - const { getByTestId, queryByTestId } = render(); - expect(getByTestId("dark-mode")).toBeInTheDocument(); - expect(queryByTestId("light-mode")).not.toBeInTheDocument(); - }); - - it('renders the LightModeIcon when theme is "dark"', () => { - (useTheme as jest.Mock).mockReturnValue({ - theme: "dark", - toggleTheme: mockToggleTheme, - }); - - const { getByTestId, queryByTestId } = render(); - expect(getByTestId("light-mode")).toBeInTheDocument(); - expect(queryByTestId("dark-mode")).not.toBeInTheDocument(); - }); - - it("calls toggleTheme function when button is clicked", () => { - const { getByRole } = render(); - const button = getByRole("button"); - - fireEvent.click(button); - expect(mockToggleTheme).toHaveBeenCalledTimes(1); - }); - - it("renders the correct icon based on theme from localStorage", () => { - // Simulate theme stored in localStorage - localStorage.setItem("theme", "dark"); - - // Mock useTheme to read from localStorage - (useTheme as jest.Mock).mockReturnValue({ - theme: "dark", - toggleTheme: mockToggleTheme, - }); - - const { getByTestId } = render(); - expect(getByTestId("light-mode")).toBeInTheDocument(); - }); - - it("toggles theme and updates the icon accordingly", () => { - (useTheme as jest.Mock).mockReturnValue({ - theme: "light", - toggleTheme: mockToggleTheme, - }); - - const { getByTestId, queryByTestId, rerender } = render( - , - ); - expect(getByTestId("dark-mode")).toBeInTheDocument(); - - fireEvent.click(getByTestId("dark-mode")); - expect(mockToggleTheme).toHaveBeenCalledTimes(1); - - (useTheme as jest.Mock).mockReturnValue({ - theme: "dark", - toggleTheme: mockToggleTheme, - }); - - rerender(); - expect(getByTestId("light-mode")).toBeInTheDocument(); - expect(queryByTestId("dark-mode")).not.toBeInTheDocument(); - }); -});