diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx index aec2bcbe..15d5dd6d 100644 --- a/src/components/ui/DataTable.tsx +++ b/src/components/ui/DataTable.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { alpha } from "@mui/material/styles"; import Box from "@mui/material/Box"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -11,6 +12,14 @@ import TableSortLabel from "@mui/material/TableSortLabel"; import Paper from "@mui/material/Paper"; import Checkbox from "@mui/material/Checkbox"; import { visuallyHidden } from "@mui/utils"; +import Toolbar from "@mui/material/Toolbar"; +import Typography from "@mui/material/Typography"; +import Tooltip from "@mui/material/Tooltip"; +import IconButton from "@mui/material/IconButton"; +import FilterListIcon from "@mui/icons-material/FilterList"; +import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted"; +import { Snackbar, Stack } from "@mui/material"; +import { deepOrange } from "@mui/material/colors"; /** * Descending comparator function @@ -164,16 +173,107 @@ function DataTableHead(props: EnhancedTableProps) { ); } +/** + * Data table toolbar props + * @property {string} title - the title of the table + * @property {number} numSelected - the number of selected rows + * @property {number[]} selectedIds - the ids of the selected rows + * @property {function} clearSelected - the function to call when the selected rows are cleared + */ +interface DataTableToolbarProps { + title: string; + numSelected: number; + selectedIds: readonly number[]; + toolbarComponents: JSX.Element; +} + +/** + * Data table toolbar component + * @param {DataTableToolbarProps} props - the props for the component + */ +function DataTableToolbar(props: DataTableToolbarProps) { + const { title, numSelected, selectedIds, toolbarComponents } = props; + const [snackbarOpen, setSnackbarOpen] = React.useState(false); + + const handleCopyIDs = () => { + navigator.clipboard.writeText(JSON.stringify(selectedIds)).then( + () => { + setSnackbarOpen(true); // Open the snackbar on successful copy + }, + (err) => { + console.error("Could not copy text: ", err); + }, + ); + }; + + return ( + 0 && { + bgcolor: (theme) => + alpha(deepOrange[500], theme.palette.action.activatedOpacity), + }), + }} + > + {numSelected > 0 ? ( + + {numSelected} selected + + ) : ( + + {title} + + )} + {numSelected > 0 ? ( + + + + + + + setSnackbarOpen(false)} + message="Job IDs copied to clipboard" + /> + {toolbarComponents} + + ) : ( + + + + + + )} + + ); +} + /** * Data table props * @property {HeadCell[]} columns - the columns for the table * @property {any[]} rows - the rows for the table */ interface DataTableProps { + title: string; + selected: readonly number[]; + setSelected: React.Dispatch>; columns: HeadCell[]; rows: any[]; rowIdentifier: string; isMobile: boolean; + toolbarComponents: JSX.Element; } /** @@ -182,10 +282,18 @@ interface DataTableProps { * @returns a DataTable component */ export function DataTable(props: DataTableProps) { - const { columns, rows, rowIdentifier, isMobile } = props; + const { + title, + selected, + setSelected, + columns, + rows, + rowIdentifier, + isMobile, + toolbarComponents, + } = props; const [order, setOrder] = React.useState("asc"); const [orderBy, setOrderBy] = React.useState(rowIdentifier); - const [selected, setSelected] = React.useState([]); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(25); @@ -247,10 +355,16 @@ export function DataTable(props: DataTableProps) { return ( - + + diff --git a/src/components/ui/JobDataTable.tsx b/src/components/ui/JobDataTable.tsx index 62d157ae..1bd5d9e9 100644 --- a/src/components/ui/JobDataTable.tsx +++ b/src/components/ui/JobDataTable.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { useJobs } from "@/hooks/jobs"; import { DataTable, HeadCell } from "./DataTable"; import Box from "@mui/material/Box"; import { @@ -15,25 +14,41 @@ import { lime, amber, } from "@mui/material/colors"; -import { useMediaQuery, useTheme } from "@mui/material"; +import { + Alert, + AlertColor, + IconButton, + Tooltip, + useMediaQuery, + useTheme, +} from "@mui/material"; +import { useOidcAccessToken } from "@axa-fr/react-oidc"; +import { useOIDCContext } from "../../hooks/oidcConfiguration"; +import DeleteIcon from "@mui/icons-material/Delete"; +import ClearIcon from "@mui/icons-material/Clear"; +import ReplayIcon from "@mui/icons-material/Replay"; +import { Backdrop, CircularProgress, Snackbar, Stack } from "@mui/material"; +import { mutate } from "swr"; +import useSWR from "swr"; +import { fetcher } from "../../hooks/utils"; const renderStatusCell = (status: string) => { const statusColors: { [key: string]: string } = { - Submitting: purple[500], // Starting process - Purple suggests something 'in progress' - Received: blueGrey[500], // Neutral informative color - Checking: teal[500], // Indicates a process in action, teal is less 'active' than blue but still indicates movement - Staging: lightBlue[500], // Light blue is calm, implying readiness and preparation - Waiting: amber[600], // Amber signals a pause or that something is on hold - Matched: blue[300], // A lighter blue indicating a successful match but still in an intermediate stage - Running: blue[900], // Dark blue suggests a deeper level of operation - Rescheduled: lime[700], // Lime is bright and eye-catching, good for something that has been reset - Completing: orange[500], // Orange is often associated with the transition, appropriate for a state leading to completion - Completed: green[300], // Light green signifies near success - Done: green[500], // Green represents success and completion - Failed: red[500], // Red is commonly associated with failure - Stalled: amber[900], // Darker amber implies a more critical waiting or hold state - Killed: red[900], // A darker red to indicate an intentional stop with a more critical connotation than 'Failed' - Deleted: grey[500], // Grey denotes deactivation or removal, neutral and final + Submitting: purple[500], + Received: blueGrey[500], + Checking: teal[500], + Staging: lightBlue[500], + Waiting: amber[600], + Matched: blue[300], + Running: blue[900], + Rescheduled: lime[700], + Completing: orange[500], + Completed: green[300], + Done: green[500], + Failed: red[500], + Stalled: amber[900], + Killed: red[900], + Deleted: grey[500], }; return ( @@ -76,22 +91,203 @@ const mobileHeadCells: HeadCell[] = [ * The data grid for the jobs */ export function JobDataTable() { - const { data: rows, isLoading, error } = useJobs(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const [selected, setSelected] = React.useState([]); - const columns = isMobile ? mobileHeadCells : headCells; + const { configuration } = useOIDCContext(); + const { accessToken } = useOidcAccessToken(configuration?.scope); + const [backdropOpen, setBackdropOpen] = React.useState(false); + const [snackbarInfo, setSnackbarInfo] = React.useState({ + open: false, + message: "", + severity: "success", + }); - if (isLoading) return
Loading...
; + /** + * Fetches the jobs from the /api/jobs/search endpoint + */ + const urlGetJobs = `/api/jobs/search?page=0&per_page=100`; + const { data, error } = useSWR([urlGetJobs, accessToken, "POST"], fetcher); + + if (!data && !error) return
Loading...
; if (error) return
An error occurred while fetching jobs
; - if (!rows || rows.length === 0) return
No job submitted.
; + if (!data || data.length === 0) return
No job submitted.
; + + const columns = isMobile ? mobileHeadCells : headCells; + const clearSelected = () => setSelected([]); + + /** + * Handle the deletion of the selected jobs + */ + const handleDelete = async (selectedIds: readonly number[]) => { + const queryString = selectedIds.map((id) => `job_ids=${id}`).join("&"); + const deleteUrl = `/api/jobs/?${queryString}`; + const requestOptions = { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, // Use the access token for authorization + }, + }; + + setBackdropOpen(true); + try { + const response = await fetch(deleteUrl, requestOptions); + if (!response.ok) + throw new Error("An error occurred while deleting jobs."); + const data = await response.json(); + setBackdropOpen(false); + mutate([urlGetJobs, accessToken, "POST"]); + clearSelected(); + setSnackbarInfo({ + open: true, + message: "Deleted successfully", + severity: "success", + }); + } catch (error: any) { + setSnackbarInfo({ + open: true, + message: "Delete failed: " + error.message, + severity: "error", + }); + } finally { + setBackdropOpen(false); + } + }; + + /** + * Handle the killing of the selected jobs + */ + const handleKill = async (selectedIds: readonly number[]) => { + const queryString = selectedIds.map((id) => `job_ids=${id}`).join("&"); + const killUrl = `/api/jobs/kill?${queryString}`; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, // Use the access token for authorization + }, + }; + + setBackdropOpen(true); + try { + const response = await fetch(killUrl, requestOptions); + if (!response.ok) + throw new Error("An error occurred while deleting jobs."); + const data = await response.json(); + setBackdropOpen(false); + mutate([urlGetJobs, accessToken, "POST"]); + clearSelected(); + setSnackbarInfo({ + open: true, + message: "Killed successfully", + severity: "success", + }); + } catch (error: any) { + setSnackbarInfo({ + open: true, + message: "Kill failed: " + error.message, + severity: "error", + }); + } finally { + setBackdropOpen(false); + } + }; + + /** + * Handle the rescheduling of the selected jobs + */ + const handleReschedule = async (selectedIds: readonly number[]) => { + const queryString = selectedIds.map((id) => `job_ids=${id}`).join("&"); + const rescheduleUrl = `/api/jobs/reschedule?${queryString}`; + const requestOptions = { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, // Use the access token for authorization + }, + }; + + setBackdropOpen(true); + try { + const response = await fetch(rescheduleUrl, requestOptions); + if (!response.ok) + throw new Error("An error occurred while deleting jobs."); + const data = await response.json(); + setBackdropOpen(false); + mutate([urlGetJobs, accessToken, "POST"]); + clearSelected(); + setSnackbarInfo({ + open: true, + message: "Rescheduled successfully", + severity: "success", + }); + } catch (error: any) { + setSnackbarInfo({ + open: true, + message: "Reschedule failed: " + error.message, + severity: "error", + }); + } finally { + setBackdropOpen(false); + } + }; + + /** + * The toolbar components for the data grid + */ + const toolbarComponents = ( + <> + + handleReschedule(selected)}> + + + + + handleKill(selected)}> + + + + + handleDelete(selected)}> + + + + + ); return ( - + <> + + setSnackbarInfo((old) => ({ ...old, open: false }))} + > + setSnackbarInfo((old) => ({ ...old, open: false }))} + severity={snackbarInfo.severity as AlertColor} + sx={{ width: "100%" }} + > + {snackbarInfo.message} + + + theme.zIndex.drawer + 1 }} + open={backdropOpen} + > + + + ); } diff --git a/src/hooks/jobs.tsx b/src/hooks/jobs.tsx deleted file mode 100644 index 872991b4..00000000 --- a/src/hooks/jobs.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { useOidcAccessToken } from "@axa-fr/react-oidc"; -import useSWR from "swr"; -import { useDiracxUrl, fetcher } from "./utils"; -import { useOIDCContext } from "./oidcConfiguration"; - -/** - * Fetches the jobs from the /api/jobs/search endpoint - * @returns the jobs - */ -export function useJobs() { - const { configuration } = useOIDCContext(); - const diracxUrl = useDiracxUrl(); - const { accessToken } = useOidcAccessToken(configuration?.scope); - - const url = `${diracxUrl}/api/jobs/search?page=0&per_page=100`; - const { data, error } = useSWR([url, accessToken, "POST"], fetcher); - - return { - data, - error, - isLoading: !data && !error, - }; -} diff --git a/test/unit-tests/JobDataTable.test.tsx b/test/unit-tests/JobDataTable.test.tsx index 9ce3117e..7c0e7c47 100644 --- a/test/unit-tests/JobDataTable.test.tsx +++ b/test/unit-tests/JobDataTable.test.tsx @@ -1,21 +1,27 @@ import React from "react"; import { render } from "@testing-library/react"; import { JobDataTable } from "@/components/ui/JobDataTable"; -import { useJobs } from "@/hooks/jobs"; +import useSWR from "swr"; +import { useOidcAccessToken } from "@axa-fr/react-oidc"; -// Mocking the useJobs hook -jest.mock("../../src/hooks/jobs"); +// Mock the module +jest.mock("@axa-fr/react-oidc", () => ({ + useOidcAccessToken: jest.fn(), +})); + +jest.mock("swr", () => jest.fn()); describe("", () => { it("displays loading state", () => { - (useJobs as jest.Mock).mockReturnValue({ isLoading: true }); + (useSWR as jest.Mock).mockReturnValue({ data: null, error: null }); + (useOidcAccessToken as jest.Mock).mockReturnValue("1234"); const { getByText } = render(); expect(getByText("Loading...")).toBeInTheDocument(); }); it("displays error state", () => { - (useJobs as jest.Mock).mockReturnValue({ error: true }); + (useSWR as jest.Mock).mockReturnValue({ error: true }); const { getByText } = render(); expect( @@ -24,7 +30,7 @@ describe("", () => { }); it("displays no jobs data state", () => { - (useJobs as jest.Mock).mockReturnValue({ data: [] }); + (useSWR as jest.Mock).mockReturnValue({ data: [] }); const { getByText } = render(); expect(getByText("No job submitted.")).toBeInTheDocument(); @@ -40,7 +46,7 @@ describe("", () => { SubmissionTime: "2023-10-13", }, ]; - (useJobs as jest.Mock).mockReturnValue({ data: mockData }); + (useSWR as jest.Mock).mockReturnValue({ data: mockData }); const { getByText } = render(); expect(getByText("TestJob1")).toBeInTheDocument();