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();
});
});