diff --git a/src/app/dashboard/jobmonitor/page.tsx b/src/app/dashboard/jobmonitor/page.tsx
index 6e35f4b1..54fc0ed6 100644
--- a/src/app/dashboard/jobmonitor/page.tsx
+++ b/src/app/dashboard/jobmonitor/page.tsx
@@ -1,10 +1,5 @@
-import { JobDataGrid } from "@/components/data/JobDataGrid";
+import JobMonitor from "@/components/applications/JobMonitor";
export default async function Page() {
- return (
-
-
Job Monitor
-
-
- );
+ return ;
}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index f171f6c0..6d5d7447 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,7 +1,5 @@
+import UserDashboard from "@/components/applications/UserDashboard";
+
export default function Page() {
- return (
-
- Hello User
-
- );
+ return ;
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 05c1ecd9..e69de29b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,23 +0,0 @@
-:root {
- --foreground-rgb: 0, 0, 0;
- --background-start-rgb: 214, 219, 220;
- --background-end-rgb: 255, 255, 255;
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --foreground-rgb: 0, 0, 0;
- --background-start-rgb: 255, 255, 255;
- --background-end-rgb: 255, 255, 255;
- }
-}
-
-body {
- color: rgb(var(--foreground-rgb));
- background: linear-gradient(
- to bottom,
- transparent,
- rgb(var(--background-end-rgb))
- )
- rgb(var(--background-start-rgb));
-}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index e4b1ce0a..657ccd2e 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,6 +1,6 @@
-import "./globals.css";
import { Inter } from "next/font/google";
import { OIDCProvider } from "@/components/auth/OIDCUtils";
+import { ThemeProvider } from "@/contexts/ThemeProvider";
const inter = Inter({ subsets: ["latin"] });
@@ -21,7 +21,9 @@ export default function RootLayout({
return (
- {children}
+
+ {children}
+
);
diff --git a/src/components/applications/JobMonitor.tsx b/src/components/applications/JobMonitor.tsx
new file mode 100644
index 00000000..615511d1
--- /dev/null
+++ b/src/components/applications/JobMonitor.tsx
@@ -0,0 +1,32 @@
+"use client";
+import * as React from "react";
+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";
+
+/**
+ * Build the Job Monitor application
+ * @returns Job Monitor content
+ */
+export default function JobMonitor() {
+ const theme = useMUITheme();
+
+ return (
+
+
+
+
+ Job Monitor
+
+
+
+
+ );
+}
diff --git a/src/components/applications/UserDashboard.tsx b/src/components/applications/UserDashboard.tsx
new file mode 100644
index 00000000..4cc07f79
--- /dev/null
+++ b/src/components/applications/UserDashboard.tsx
@@ -0,0 +1,32 @@
+"use client";
+import * as React from "react";
+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 { useOidcAccessToken } from "@axa-fr/react-oidc";
+
+/**
+ * Build the User Dashboard page
+ * @returns User Dashboard content
+ */
+export default function UserDashboard() {
+ const theme = useMUITheme();
+ const { accessTokenPayload } = useOidcAccessToken();
+
+ return (
+
+
+
+
+ Hello {accessTokenPayload["preferred_username"]}
+
+
+
+ );
+}
diff --git a/src/components/layout/Dashboard.tsx b/src/components/layout/Dashboard.tsx
index 22b13d08..76e8efe3 100644
--- a/src/components/layout/Dashboard.tsx
+++ b/src/components/layout/Dashboard.tsx
@@ -9,6 +9,9 @@ import Toolbar from "@mui/material/Toolbar";
import Stack from "@mui/material/Stack";
import { LoginButton } from "../ui/LoginButton";
import DashboardDrawer from "../ui/DashboardDrawer";
+import { useMUITheme } from "@/hooks/theme";
+import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles";
+import { ThemeToggleButton } from "../ui/ThemeToggleButton";
interface DashboardProps {
children: React.ReactNode;
@@ -22,6 +25,7 @@ interface DashboardProps {
* @return an dashboard layout
*/
export default function Dashboard(props: DashboardProps) {
+ const theme = useMUITheme();
/** State management for mobile drawer */
const [mobileOpen, setMobileOpen] = React.useState(false);
const handleDrawerToggle = () => {
@@ -34,66 +38,70 @@ export default function Dashboard(props: DashboardProps) {
return (
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- {/* Here two types of drawers are rendered:
+
+
+
+
+
+
+
+
+
+ {/* Here two types of drawers are rendered:
1. Temporary drawer: Visible on small screens (xs) and is collapsible.
2. Permanent drawer: Visible on larger screens (sm) and stays fixed.
Depending on the screen size, only one will be visible at a time. */}
-
-
-
-
- {props.children}
-
+
+
+
+
+ {props.children}
+
+
);
}
diff --git a/src/components/layout/Showcase.tsx b/src/components/layout/Showcase.tsx
index 3e6737a8..c6090e82 100644
--- a/src/components/layout/Showcase.tsx
+++ b/src/components/layout/Showcase.tsx
@@ -11,6 +11,9 @@ import { LoginButton } from "../ui/LoginButton";
import { Box, Stack } from "@mui/material";
import { DashboardButton } from "../ui/DashboardButton";
import Image from "next/image";
+import { ThemeToggleButton } from "../ui/ThemeToggleButton";
+import { useMUITheme } from "@/hooks/theme";
+import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles";
/**
* Build the showcase content with an AppBar
@@ -18,6 +21,8 @@ import Image from "next/image";
* @returns showcase content
*/
export default function Showcase() {
+ const theme = useMUITheme();
+
const Item = styled(Paper)(({ theme }) => ({
padding: theme.spacing(6),
elevation: 0,
@@ -25,73 +30,76 @@ export default function Showcase() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
- The DIRAC interware is a software framework that enables
- communities to interact with distributed computing resources.
- DIRAC forms a layer between users and resources, hiding
- diversities across computing and storage resources.
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
- DIRAC has been adopted by several HEP and non-HEP experiments
- communities, with different goals, intents, resources and
- workflows: it is experiment agnostic, extensible, and flexible.
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ The DIRAC interware is a software framework that enables
+ communities to interact with distributed computing resources.
+ DIRAC forms a layer between users and resources, hiding
+ diversities across computing and storage resources.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ -
+ DIRAC has been adopted by several HEP and non-HEP experiments
+ communities, with different goals, intents, resources and
+ workflows: it is experiment agnostic, extensible, and flexible.
+
+
-
-
+
+
);
}
diff --git a/src/components/ui/DashboardButton.tsx b/src/components/ui/DashboardButton.tsx
index f29ff92a..a226b418 100644
--- a/src/components/ui/DashboardButton.tsx
+++ b/src/components/ui/DashboardButton.tsx
@@ -1,5 +1,6 @@
import { useOidcAccessToken } from "@axa-fr/react-oidc";
import { Button } from "@mui/material";
+import { deepOrange, lightGreen } from "@mui/material/colors";
import Link from "next/link";
/**
@@ -14,7 +15,14 @@ export function DashboardButton() {
}
return (
-
+
Dashboard
);
diff --git a/src/components/data/JobDataGrid.tsx b/src/components/ui/JobDataGrid.tsx
similarity index 100%
rename from src/components/data/JobDataGrid.tsx
rename to src/components/ui/JobDataGrid.tsx
diff --git a/src/components/ui/LoginButton.tsx b/src/components/ui/LoginButton.tsx
index 4af5ffc7..4b6b29a0 100644
--- a/src/components/ui/LoginButton.tsx
+++ b/src/components/ui/LoginButton.tsx
@@ -12,7 +12,6 @@ import {
Tooltip,
} from "@mui/material";
import { deepOrange, lightGreen } from "@mui/material/colors";
-import NextLink from "next/link";
import React from "react";
/**
diff --git a/src/components/ui/ThemeToggleButton.tsx b/src/components/ui/ThemeToggleButton.tsx
new file mode 100644
index 00000000..cdd9185d
--- /dev/null
+++ b/src/components/ui/ThemeToggleButton.tsx
@@ -0,0 +1,22 @@
+import { IconButton } from "@mui/material";
+import DarkModeIcon from "@mui/icons-material/DarkMode";
+import LightModeIcon from "@mui/icons-material/LightMode";
+import { useTheme } from "@/hooks/theme";
+
+/**
+ * Toggle button for switching between light and dark themes.
+ * @returns an IconButton
+ */
+export function ThemeToggleButton() {
+ const { theme, toggleTheme } = useTheme();
+
+ return (
+
+ {theme === "light" ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/contexts/ThemeProvider.tsx b/src/contexts/ThemeProvider.tsx
new file mode 100644
index 00000000..f0cf2918
--- /dev/null
+++ b/src/contexts/ThemeProvider.tsx
@@ -0,0 +1,35 @@
+"use client";
+import { useMediaQuery } from "@mui/material";
+import { createContext, useState } from "react";
+
+type ThemeContextType = {
+ theme: "light" | "dark";
+ toggleTheme: () => void;
+};
+
+export type ThemeProviderProps = {
+ children: React.ReactNode;
+};
+
+export const ThemeContext = createContext(
+ undefined,
+);
+
+// ThemeProvider component to provide the theme context to its children
+export const ThemeProvider = ({ children }: ThemeProviderProps) => {
+ // State to manage the current theme mode
+ const [theme, setTheme] = useState<"light" | "dark">(
+ useMediaQuery("(prefers-color-scheme: dark)") ? "dark" : "light",
+ );
+
+ // Function to toggle the theme mode
+ const toggleTheme = () => {
+ setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/hooks/jobs.tsx b/src/hooks/jobs.tsx
index d511273a..37f06dd0 100644
--- a/src/hooks/jobs.tsx
+++ b/src/hooks/jobs.tsx
@@ -17,8 +17,11 @@ const fetcher = (args: any[]) => {
export function useJobs() {
const diracxUrl = useDiracxUrl();
const { accessToken } = useOidcAccessToken();
- const url = `${diracxUrl}/api/jobs/search?page=0&per_page=100`;
- const { data, error } = useSWR([url, accessToken], fetcher);
+ const url = diracxUrl
+ ? `${diracxUrl}/api/jobs/search?page=0&per_page=100`
+ : null;
+
+ const { data, error } = useSWR(url ? [url, accessToken] : null, fetcher);
if (diracxUrl === null) {
return { data: null, error: "diracxUrl is null", isLoading: false };
diff --git a/src/hooks/theme.tsx b/src/hooks/theme.tsx
new file mode 100644
index 00000000..9831b2e5
--- /dev/null
+++ b/src/hooks/theme.tsx
@@ -0,0 +1,29 @@
+import { ThemeContext } from "@/contexts/ThemeProvider";
+import { createTheme } from "@mui/material/styles";
+import { useContext } from "react";
+
+// Custom hook to access the theme context
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (!context) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+};
+
+// Custom hook to generate and return the Material-UI theme based on the current mode
+export const useMUITheme = () => {
+ const { theme } = useTheme();
+
+ // Creating a Material-UI theme based on the current mode
+ const muiTheme = createTheme({
+ palette: {
+ mode: theme,
+ primary: {
+ main: "#ffffff",
+ },
+ },
+ });
+
+ return muiTheme;
+};
diff --git a/test/DashboardAppBar.test.tsx b/test/unit-tests/Dashboard.test.tsx
similarity index 86%
rename from test/DashboardAppBar.test.tsx
rename to test/unit-tests/Dashboard.test.tsx
index 0c0e6e0a..542aece6 100644
--- a/test/DashboardAppBar.test.tsx
+++ b/test/unit-tests/Dashboard.test.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import Dashboard from "@/components/layout/Dashboard";
+import { ThemeProvider } from "@/contexts/ThemeProvider";
import { useOidc, useOidcAccessToken } from "@axa-fr/react-oidc";
// Mock the module
@@ -29,9 +30,11 @@ describe("", () => {
// Normal case
it("renders on desktop screen", () => {
const { getByTestId } = render(
-
- Test
- ,
+
+
+ Test
+
+ ,
);
// `drawer-temporary` should not even be in the DOM for desktop screen sizes
@@ -46,9 +49,11 @@ describe("", () => {
global.dispatchEvent(new Event("resize"));
const { getByTestId } = render(
-
- Test
- ,
+
+
+ Test
+
+ ,
);
const toggleButton = getByTestId("drawer-toggle-button");
diff --git a/test/unit-tests/DashboardButton.test.tsx b/test/unit-tests/DashboardButton.test.tsx
new file mode 100644
index 00000000..e16f40a5
--- /dev/null
+++ b/test/unit-tests/DashboardButton.test.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import { DashboardButton } from "@/components/ui/DashboardButton";
+import { useOidcAccessToken } from "@axa-fr/react-oidc";
+
+// Mocking the useOidcAccessToken hook
+jest.mock("@axa-fr/react-oidc", () => ({
+ useOidcAccessToken: jest.fn(),
+}));
+
+describe(" ", () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the button when user is connected (has accessToken)", () => {
+ // Mocking the return value of useOidcAccessToken to simulate a user with an accessToken
+ (useOidcAccessToken as jest.Mock).mockReturnValue({
+ accessToken: "mocked_token",
+ });
+
+ const { getByText } = render( );
+ const button = getByText("Dashboard");
+
+ expect(button).toBeInTheDocument();
+ });
+
+ it("does not render the button when user is not connected (no accessToken)", () => {
+ // Mocking the return value of useOidcAccessToken to simulate a user without an accessToken
+ (useOidcAccessToken as jest.Mock).mockReturnValue({ accessToken: null });
+
+ const { queryByText } = render( );
+ const button = queryByText("Dashboard");
+
+ expect(button).not.toBeInTheDocument();
+ });
+});
diff --git a/test/unit-tests/DiracLogo.test.tsx b/test/unit-tests/DiracLogo.test.tsx
new file mode 100644
index 00000000..9f2a9985
--- /dev/null
+++ b/test/unit-tests/DiracLogo.test.tsx
@@ -0,0 +1,22 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import { DiracLogo } from "@/components/ui/DiracLogo";
+
+describe(" ", () => {
+ it("renders the logo image with correct attributes", () => {
+ const { getByAltText } = render( );
+
+ // Check if the image is rendered with the correct alt text
+ const logoImage = getByAltText("DIRAC logo");
+ expect(logoImage).toBeInTheDocument();
+ });
+
+ it("renders the link that redirects to the root page", () => {
+ const { getByRole } = render( );
+
+ // Check if the link is rendered and points to the root page
+ const link = getByRole("link");
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute("href", "/");
+ });
+});
diff --git a/test/unit-tests/JobDataGrid.test.tsx b/test/unit-tests/JobDataGrid.test.tsx
new file mode 100644
index 00000000..6784d3fd
--- /dev/null
+++ b/test/unit-tests/JobDataGrid.test.tsx
@@ -0,0 +1,48 @@
+import React from "react";
+import { render } from "@testing-library/react";
+import { JobDataGrid } from "@/components/ui/JobDataGrid";
+import { useJobs } from "@/hooks/jobs";
+
+// Mocking the useJobs hook
+jest.mock("../../src/hooks/jobs");
+
+describe(" ", () => {
+ it("displays loading state", () => {
+ (useJobs as jest.Mock).mockReturnValue({ isLoading: true });
+
+ const { getByText } = render( );
+ expect(getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("displays error state", () => {
+ (useJobs as jest.Mock).mockReturnValue({ error: true });
+
+ const { getByText } = render( );
+ expect(
+ getByText("An error occurred while fetching jobs."),
+ ).toBeInTheDocument();
+ });
+
+ it("displays no jobs data state", () => {
+ (useJobs as jest.Mock).mockReturnValue({ data: [] });
+
+ const { getByText } = render( );
+ expect(getByText("No job submitted.")).toBeInTheDocument();
+ });
+
+ it("displays jobs data in the grid", () => {
+ const mockData = [
+ {
+ JobID: "1",
+ JobName: "TestJob1",
+ Status: "Running",
+ MinorStatus: "Processing",
+ SubmissionTime: "2023-10-13",
+ },
+ ];
+ (useJobs as jest.Mock).mockReturnValue({ data: mockData });
+
+ const { getByText } = render( );
+ expect(getByText("TestJob1")).toBeInTheDocument();
+ });
+});
diff --git a/test/unit-tests/LoginButton.test.tsx b/test/unit-tests/LoginButton.test.tsx
new file mode 100644
index 00000000..0efcdf38
--- /dev/null
+++ b/test/unit-tests/LoginButton.test.tsx
@@ -0,0 +1,69 @@
+import React from "react";
+import { render, fireEvent } from "@testing-library/react";
+import { LoginButton } from "@/components/ui/LoginButton";
+import { useOidcAccessToken, useOidc } from "@axa-fr/react-oidc";
+
+// 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" },
+ });
+
+ const { getByText, queryByText } = render( );
+ fireEvent.click(getByText("J"));
+ expect(queryByText("Profile")).toBeInTheDocument();
+ expect(queryByText("Logout")).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({
+ accessToken: "mockAccessToken",
+ accessTokenPayload: { preferred_username: "John" },
+ });
+
+ 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/test/unit-tests/ThemeToggleButton.test.tsx b/test/unit-tests/ThemeToggleButton.test.tsx
new file mode 100644
index 00000000..faea8258
--- /dev/null
+++ b/test/unit-tests/ThemeToggleButton.test.tsx
@@ -0,0 +1,56 @@
+import React from "react";
+import { render, fireEvent } from "@testing-library/react";
+import { ThemeToggleButton } from "@/components/ui/ThemeToggleButton";
+import { useTheme } from "@/hooks/theme";
+
+// Mocking the useTheme hook
+jest.mock("../../src/hooks/theme", () => ({
+ useTheme: jest.fn(),
+}));
+
+describe(" ", () => {
+ let mockToggleTheme: jest.Mock;
+
+ beforeEach(() => {
+ mockToggleTheme = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the DarkModeIcon when theme is "light"', () => {
+ (useTheme as jest.Mock).mockReturnValue({
+ theme: "light",
+ toggleTheme: mockToggleTheme,
+ });
+
+ 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", () => {
+ (useTheme as jest.Mock).mockReturnValue({
+ theme: "light",
+ toggleTheme: mockToggleTheme,
+ });
+
+ const { getByRole } = render( );
+ const button = getByRole("button");
+
+ fireEvent.click(button);
+ expect(mockToggleTheme).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
index 0914988a..be05ba1f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,5 +24,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules", "test/*.ts", "test/*.tsx", "jest.setup.ts"]
+ "exclude": ["node_modules", "test/*/*.ts", "test/*/*.tsx", "jest.setup.ts"]
}