diff --git a/src/components/applications/JobMonitor.tsx b/src/components/applications/JobMonitor.tsx index 615511d1..9b3945e9 100644 --- a/src/components/applications/JobMonitor.tsx +++ b/src/components/applications/JobMonitor.tsx @@ -4,7 +4,7 @@ import CssBaseline from "@mui/material/CssBaseline"; import { Box } from "@mui/material"; import { useMUITheme } from "@/hooks/theme"; import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { JobDataGrid } from "../ui/JobDataGrid"; +import { JobDataTable } from "../ui/JobDataTable"; /** * Build the Job Monitor application @@ -24,7 +24,7 @@ export default function JobMonitor() { }} >

Job Monitor

- + diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx new file mode 100644 index 00000000..aec2bcbe --- /dev/null +++ b/src/components/ui/DataTable.tsx @@ -0,0 +1,328 @@ +import * as React from "react"; +import Box from "@mui/material/Box"; +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableHead from "@mui/material/TableHead"; +import TablePagination from "@mui/material/TablePagination"; +import TableRow from "@mui/material/TableRow"; +import TableSortLabel from "@mui/material/TableSortLabel"; +import Paper from "@mui/material/Paper"; +import Checkbox from "@mui/material/Checkbox"; +import { visuallyHidden } from "@mui/utils"; + +/** + * Descending comparator function + * @param a - the first value to compare + * @param b - the second value to compare + * @param orderBy - the key to compare + * @returns -1 if b is less than a, 1 if b is greater than a, 0 if they are equal + * @template T - the type of the values to compare + */ +function descendingComparator(a: T, b: T, orderBy: keyof T) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +type Order = "asc" | "desc"; + +/** + * Get the comparator function for a given key and order + * @param order - the order to sort by + * @param orderBy - the key to sort by + * @returns a comparator function + * @template Key - the type of the key to sort by + */ +function getComparator( + order: Order, + orderBy: Key, +): ( + a: { [key in Key]: number | string }, + b: { [key in Key]: number | string }, +) => number { + return order === "desc" + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +/** + * Stable sort function + * @param array - the array to sort + * @param comparator - the comparator function + * @returns the sorted array + * @template T - the type of the array to sort + */ +function stableSort( + array: readonly T[], + comparator: (a: T, b: T) => number, +) { + const stabilizedThis = array.map((el, index) => [el, index] as [T, number]); + stabilizedThis.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) return order; + return a[1] - b[1]; + }); + return stabilizedThis.map((el) => el[0]); +} + +/** + * The head cells for the table. + * Components using this table should provide a list of head cells. + * @property {number | string} id - the id of the cell + * @property {string} label - the label of the cell + */ +export interface HeadCell { + id: number | string; + label: string; + render?: ((value: any) => JSX.Element) | null; +} + +/** + * Enhanced table props + * @property {HeadCell[]} headCells - the head cells for the table + * @property {number} numSelected - the number of selected rows + * @property {function} onRequestSort - the function to call when sorting is requested + * @property {function} onSelectAllClick - the function to call when all rows are selected + * @property {Order} order - the order to sort by + * @property {string} orderBy - the key to sort by + * @property {number} rowCount - the number of rows + */ +interface EnhancedTableProps { + headCells: HeadCell[]; + numSelected: number; + onRequestSort: ( + event: React.MouseEvent, + property: string | number, + ) => void; + onSelectAllClick: ( + event: React.ChangeEvent, + checked: boolean, + ) => void; + order: Order; + orderBy: string; + rowCount: number; +} + +/** + * Data table head component + * @param {EnhancedTableProps} props - the props for the component + */ +function DataTableHead(props: EnhancedTableProps) { + const { + headCells, + onSelectAllClick, + order, + orderBy, + numSelected, + rowCount, + onRequestSort, + } = props; + const createSortHandler = + (property: string | number) => (event: React.MouseEvent) => { + onRequestSort(event, property); + }; + + return ( + + + + 0 && numSelected < rowCount} + checked={rowCount > 0 && numSelected === rowCount} + onChange={onSelectAllClick} + inputProps={{ "aria-label": "select all items" }} + /> + + {headCells.map((headCell) => ( + + + {headCell.label} + {orderBy === headCell.id ? ( + + {order === "desc" ? "sorted descending" : "sorted ascending"} + + ) : null} + + + ))} + + + ); +} + +/** + * Data table props + * @property {HeadCell[]} columns - the columns for the table + * @property {any[]} rows - the rows for the table + */ +interface DataTableProps { + columns: HeadCell[]; + rows: any[]; + rowIdentifier: string; + isMobile: boolean; +} + +/** + * Data table component + * @param {DataTableProps} props - the props for the component + * @returns a DataTable component + */ +export function DataTable(props: DataTableProps) { + const { columns, rows, rowIdentifier, isMobile } = 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); + + const handleRequestSort = ( + event: React.MouseEvent, + property: string | number, + ) => { + const isAsc = orderBy === property && order === "asc"; + setOrder(isAsc ? "desc" : "asc"); + setOrderBy(property); + }; + + const handleSelectAllClick = (event: React.ChangeEvent) => { + if (event.target.checked) { + const newSelected = rows.map((n: any) => n[rowIdentifier]); + setSelected(newSelected); + return; + } + setSelected([]); + }; + + const handleClick = (event: React.MouseEvent, id: number) => { + const selectedIndex = selected.indexOf(id); + let newSelected: readonly number[] = []; + + if (selectedIndex === -1) { + newSelected = newSelected.concat(selected, id); + } else if (selectedIndex === 0) { + newSelected = newSelected.concat(selected.slice(1)); + } else if (selectedIndex === selected.length - 1) { + newSelected = newSelected.concat(selected.slice(0, -1)); + } else if (selectedIndex > 0) { + newSelected = newSelected.concat( + selected.slice(0, selectedIndex), + selected.slice(selectedIndex + 1), + ); + } + + setSelected(newSelected); + }; + + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = ( + event: React.ChangeEvent, + ) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const isSelected = (name: number) => selected.indexOf(name) !== -1; + + // Calculate the number of empty rows needed to fill the space + const emptyRows = + rowsPerPage - Math.min(rowsPerPage, rows.length - page * rowsPerPage); + + return ( + + + + + + + {stableSort(rows, getComparator(order, orderBy)) + .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) + .map((row, index) => { + const isItemSelected = isSelected( + row[rowIdentifier] as number, + ); + const labelId = `enhanced-table-checkbox-${index}`; + + return ( + + handleClick(event, row[rowIdentifier] as number) + } + role="checkbox" + aria-checked={isItemSelected} + tabIndex={-1} + key={row[rowIdentifier]} + selected={isItemSelected} + > + + + + {columns.map((column) => { + const cellValue = row[column.id]; + return ( + + {column.render + ? column.render(cellValue) + : cellValue} + + ); + })} + + ); + })} + {emptyRows > 0 && ( + + + + )} + +
+
+ +
+
+ ); +} diff --git a/src/components/ui/JobDataGrid.tsx b/src/components/ui/JobDataGrid.tsx deleted file mode 100644 index 48c21e3b..00000000 --- a/src/components/ui/JobDataGrid.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; -import { DataGrid } from "@mui/x-data-grid"; -import { useJobs } from "@/hooks/jobs"; - -const columns = [ - { field: "JobID", headerName: "Job ID", width: 90 }, - { field: "JobName", headerName: "Job Name", flex: 1 }, - { field: "Status", headerName: "Status", flex: 1 }, - { field: "MinorStatus", headerName: "Minor Status", flex: 1 }, - { field: "SubmissionTime", headerName: "Submission Time", flex: 1 }, -]; - -/** - * It gets rows from diracx and build the data grid - * - * @returns a DataGrid displaying details about jobs - */ -export function JobDataGrid() { - const { data, error, isLoading } = useJobs(); - - if (isLoading) { - return
Loading...
; - } - - if (error) { - return
An error occurred while fetching jobs.
; - } - - if (!data || data.length === 0) { - return
No job submitted.
; - } - - return ( - row.JobID} - rows={data} - columns={columns} - initialState={{ - pagination: { - paginationModel: { page: 0, pageSize: 5 }, - }, - }} - autoHeight - pageSizeOptions={[5, 10, 50, 100, 500, 1000]} - checkboxSelection - /> - ); -} diff --git a/src/components/ui/JobDataTable.tsx b/src/components/ui/JobDataTable.tsx new file mode 100644 index 00000000..62d157ae --- /dev/null +++ b/src/components/ui/JobDataTable.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; +import { useJobs } from "@/hooks/jobs"; +import { DataTable, HeadCell } from "./DataTable"; +import Box from "@mui/material/Box"; +import { + blue, + orange, + grey, + green, + red, + lightBlue, + purple, + teal, + blueGrey, + lime, + amber, +} from "@mui/material/colors"; +import { useMediaQuery, useTheme } from "@mui/material"; + +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 + }; + + return ( + + {status} + + ); +}; + +const headCells: HeadCell[] = [ + { id: "JobID", label: "Job ID" }, + { id: "JobName", label: "Job Name" }, + { id: "Status", label: "Status", render: renderStatusCell }, + { + id: "MinorStatus", + label: "Minor Status", + }, + { + id: "SubmissionTime", + label: "Submission Time", + }, +]; + +const mobileHeadCells: HeadCell[] = [ + { id: "JobID", label: "Job ID" }, + { id: "JobName", label: "Job Name" }, + { id: "Status", label: "Status", render: renderStatusCell }, +]; + +/** + * 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 columns = isMobile ? mobileHeadCells : headCells; + + if (isLoading) return
Loading...
; + if (error) return
An error occurred while fetching jobs
; + if (!rows || rows.length === 0) return
No job submitted.
; + + return ( + + ); +} diff --git a/src/hooks/theme.tsx b/src/hooks/theme.tsx index 0f3b1b21..3638bc56 100644 --- a/src/hooks/theme.tsx +++ b/src/hooks/theme.tsx @@ -1,8 +1,7 @@ import { ThemeContext } from "@/contexts/ThemeProvider"; import { PaletteMode } from "@mui/material"; -import deepOrange from "@mui/material/colors/deepOrange"; -import lightGreen from "@mui/material/colors/lightGreen"; -import { createTheme } from "@mui/material/styles"; +import { deepOrange, grey, lightGreen } from "@mui/material/colors"; +import { createTheme, darken, lighten } from "@mui/material/styles"; import { useContext } from "react"; /** @@ -35,32 +34,73 @@ export const useMUITheme = () => { main: "#ffffff", }, }, - components: { - MuiButton: { - styleOverrides: { - contained: { - // Target the 'contained' variant + }); + + muiTheme.components = { + MuiButton: { + styleOverrides: { + contained: { + // Target the 'contained' variant + color: "white", + backgroundColor: lightGreen[700], + "&:hover": { color: "white", - backgroundColor: lightGreen[700], - "&:hover": { - color: "white", - backgroundColor: deepOrange[500], - }, + backgroundColor: deepOrange[500], + }, + }, + outlined: { + // Target the 'outlined' variant + color: lightGreen[700], + borderColor: lightGreen[700], + "&:hover": { + color: deepOrange[500], + borderColor: deepOrange[500], + backgroundColor: "transparent", + }, + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + "&.Mui-selected, &.Mui-selected:hover": { + backgroundColor: + muiTheme.palette.mode === "light" + ? lighten(grey[200], 0.2) + : darken(grey[800], 0.2), + }, + }, + }, + }, + MuiTableCell: { + styleOverrides: { + head: { + borderRight: `1px solid ${muiTheme.palette.divider}`, + borderColor: "divider", + // Remove the border for the last cell + "&:last-child": { + borderRight: 0, }, - outlined: { - // Target the 'outlined' variant + textAlign: "left", + }, + body: { + textAlign: "left", + }, + }, + }, + MuiCheckbox: { + styleOverrides: { + root: { + "&.Mui-checked": { color: lightGreen[700], - borderColor: lightGreen[700], - "&:hover": { - color: deepOrange[500], - borderColor: deepOrange[500], - backgroundColor: "transparent", - }, + }, + "&.MuiCheckbox-indeterminate": { + color: deepOrange[500], }, }, }, }, - }); + }; return muiTheme; }; diff --git a/test/unit-tests/JobDataGrid.test.tsx b/test/unit-tests/JobDataTable.test.tsx similarity index 74% rename from test/unit-tests/JobDataGrid.test.tsx rename to test/unit-tests/JobDataTable.test.tsx index 6784d3fd..9ce3117e 100644 --- a/test/unit-tests/JobDataGrid.test.tsx +++ b/test/unit-tests/JobDataTable.test.tsx @@ -1,32 +1,32 @@ import React from "react"; import { render } from "@testing-library/react"; -import { JobDataGrid } from "@/components/ui/JobDataGrid"; +import { JobDataTable } from "@/components/ui/JobDataTable"; import { useJobs } from "@/hooks/jobs"; // Mocking the useJobs hook jest.mock("../../src/hooks/jobs"); -describe("", () => { +describe("", () => { it("displays loading state", () => { (useJobs as jest.Mock).mockReturnValue({ isLoading: true }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText("Loading...")).toBeInTheDocument(); }); it("displays error state", () => { (useJobs as jest.Mock).mockReturnValue({ error: true }); - const { getByText } = render(); + const { getByText } = render(); expect( - getByText("An error occurred while fetching jobs."), + getByText("An error occurred while fetching jobs"), ).toBeInTheDocument(); }); it("displays no jobs data state", () => { (useJobs as jest.Mock).mockReturnValue({ data: [] }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText("No job submitted.")).toBeInTheDocument(); }); @@ -42,7 +42,7 @@ describe("", () => { ]; (useJobs as jest.Mock).mockReturnValue({ data: mockData }); - const { getByText } = render(); + const { getByText } = render(); expect(getByText("TestJob1")).toBeInTheDocument(); }); });