diff --git a/package-lock.json b/package-lock.json index 0b69c2a0..472f19d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.3", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^1.1.0", "@axa-fr/react-oidc": "^7.21.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", @@ -18,6 +21,7 @@ "@types/react": "18.2.48", "@types/react-dom": "18.2.25", "autoprefixer": "10.4.19", + "jsoncrush": "^1.1.8", "next": "^14.1.4", "postcss": "8.4.38", "react": "^18.2.0", @@ -74,6 +78,76 @@ "node": ">=6.0.0" } }, + "node_modules/@atlaskit/platform-feature-flags": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@atlaskit/platform-feature-flags/-/platform-feature-flags-0.2.5.tgz", + "integrity": "sha512-0fD2aDxn2mE59D4acUhVib+YF2HDYuuPH50aYwpQdcV/CsVkAaJsMKy8WhWSulcRFeMYp72kfIfdy0qGdRB7Uw==", + "dependencies": { + "@babel/runtime": "^7.0.0" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.1.3.tgz", + "integrity": "sha512-lx6ZMPSU8zPhUfAkdKajNAFWDDIqdtM8eQzCsqCRalXWumpclcvqeN8VCLkmclcQDEUhV8c2utKbcuhm7hvRIw==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^2.1.1", + "raf-schd": "^4.0.3" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-hitbox": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-hitbox/-/pragmatic-drag-and-drop-hitbox-1.0.3.tgz", + "integrity": "sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", + "@babel/runtime": "^7.0.0" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/-/pragmatic-drag-and-drop-react-drop-indicator-1.1.0.tgz", + "integrity": "sha512-h6TClbK1axZflyQL17mDZO44psPyk3i7P7xn/lSzPfMEXdUuGHPsGBr5jH0QyxJ+flHA9GlfNDqggJK3L/HkFg==", + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.0", + "@atlaskit/tokens": "^1.43.0", + "@babel/runtime": "^7.0.0", + "@emotion/react": "^11.7.1" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/node_modules/@atlaskit/tokens": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@atlaskit/tokens/-/tokens-1.43.0.tgz", + "integrity": "sha512-3rRxGRnJGQBVKGqNqy+Zuad3xuDZ7uD+aFGRcU2OpLuIpiFLX95agDZ9w0HGzNiDw9eWi2f1j8Uzq06AyaRqTw==", + "dependencies": { + "@atlaskit/ds-lib": "^2.2.0", + "@atlaskit/platform-feature-flags": "^0.2.0", + "@babel/runtime": "^7.0.0", + "@babel/traverse": "^7.23.2", + "@babel/types": "^7.20.0", + "bind-event-listener": "^2.1.1" + }, + "peerDependencies": { + "react": "^16.8.0" + } + }, + "node_modules/@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/node_modules/@atlaskit/tokens/node_modules/@atlaskit/ds-lib": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@atlaskit/ds-lib/-/ds-lib-2.2.5.tgz", + "integrity": "sha512-fE7uEuB4uAJvNGncY5k+ofUYmJmVcS43MDChvhz1qG2dweYx4hktFVOowiDjt7+RtLQOXuvs7WTn3wIy25er3w==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^2.1.1" + }, + "peerDependencies": { + "react": "^16.8.0" + } + }, "node_modules/@axa-fr/oidc-client": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/@axa-fr/oidc-client/-/oidc-client-7.21.0.tgz", @@ -235,7 +309,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.4.tgz", "integrity": "sha512-esuS49Cga3HcThFNebGhlgsrVLkvhqvYDTzgjfFFlHJcIfLe5jFmRRfCQ1KuBfc4Jrtn3ndLgKWAKjBE+IraYQ==", - "dev": true, "dependencies": { "@babel/types": "^7.23.4", "@jridgewell/gen-mapping": "^0.3.2", @@ -275,7 +348,6 @@ "version": "7.22.20", "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, "engines": { "node": ">=6.9.0" } @@ -284,7 +356,6 @@ "version": "7.23.0", "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, "dependencies": { "@babel/template": "^7.22.15", "@babel/types": "^7.23.0" @@ -297,7 +368,6 @@ "version": "7.22.5", "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, "dependencies": { "@babel/types": "^7.22.5" }, @@ -360,7 +430,6 @@ "version": "7.22.6", "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", - "dev": true, "dependencies": { "@babel/types": "^7.22.5" }, @@ -488,7 +557,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz", "integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==", - "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -688,7 +756,6 @@ "version": "7.22.15", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.22.13", "@babel/parser": "^7.22.15", @@ -702,7 +769,6 @@ "version": "7.23.4", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.4.tgz", "integrity": "sha512-IYM8wSUwunWTB6tFC2dkKZhxbIjHoWemdK+3f8/wq8aKhbUscxD5MX72ubd90fxvFknaLPeGw5ycU84V1obHJg==", - "dev": true, "dependencies": { "@babel/code-frame": "^7.23.4", "@babel/generator": "^7.23.4", @@ -723,7 +789,6 @@ "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, "engines": { "node": ">=4" } @@ -2107,7 +2172,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -2121,7 +2185,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2130,7 +2193,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -2138,14 +2200,12 @@ "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.19", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -3797,6 +3857,11 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bind-event-listener": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-2.1.1.tgz", + "integrity": "sha512-O+a5c0D2se/u2VlBJmPRn45IB6R4mYMh1ok3dWxrIZ2pmLqzggBhb875mbq73508ylzofc0+hT9W41x4Y2s8lg==" + }, "node_modules/blob-util": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", @@ -4788,7 +4853,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -8372,7 +8436,6 @@ "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, "bin": { "jsesc": "bin/jsesc" }, @@ -8427,6 +8490,11 @@ "node": ">=6" } }, + "node_modules/jsoncrush": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/jsoncrush/-/jsoncrush-1.1.8.tgz", + "integrity": "sha512-lvIMGzMUA0fjuqwNcxlTNRq2bibPZ9auqT/LyGdlR5hvydJtA/BasSgkx4qclqTKVeTidrJvsS/oVjlTCPQ4Nw==" + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -9101,8 +9169,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/nanoid": { "version": "3.3.7", @@ -9932,6 +9999,11 @@ } ] }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", diff --git a/package.json b/package.json index 0c69c21e..712d2e94 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,9 @@ "prepare": "husky" }, "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.1.3", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^1.1.0", "@axa-fr/react-oidc": "^7.21.0", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", @@ -22,6 +25,7 @@ "@types/react": "18.2.48", "@types/react-dom": "18.2.25", "autoprefixer": "10.4.19", + "jsoncrush": "^1.1.8", "next": "^14.1.4", "postcss": "8.4.38", "react": "^18.2.0", diff --git a/src/app/error.tsx b/src/app/(dashboard)/error.tsx similarity index 100% rename from src/app/error.tsx rename to src/app/(dashboard)/error.tsx diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 00000000..eb79c070 --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -0,0 +1,39 @@ +"use client"; +import * as React from "react"; +import CssBaseline from "@mui/material/CssBaseline"; +import { Box } from "@mui/material"; +import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; +import { useMUITheme } from "@/hooks/theme"; +import Dashboard from "@/components/layout/Dashboard"; +import ApplicationsProvider from "@/contexts/ApplicationsProvider"; +import { OIDCSecure } from "@/components/layout/OIDCSecure"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + const theme = useMUITheme(); + + return ( +
+ + + + + + + {children} + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx new file mode 100644 index 00000000..ed193572 --- /dev/null +++ b/src/app/(dashboard)/page.tsx @@ -0,0 +1,25 @@ +"use client"; +import React from "react"; +import { useSearchParams } from "next/navigation"; +import UserDashboard from "@/components/applications/UserDashboard"; +import { ApplicationsContext } from "@/contexts/ApplicationsProvider"; +import { applicationList } from "@/components/applications/ApplicationList"; + +export default function Page() { + const searchParams = useSearchParams(); + const appId = searchParams.get("appId"); + const [sections] = React.useContext(ApplicationsContext); + + const appType = React.useMemo(() => { + const section = sections.find((section) => + section.items.some((item) => item.id === appId), + ); + return section?.items.find((item) => item.id === appId)?.type; + }, [sections, appId]); + + const Component = React.useMemo(() => { + return applicationList.find((app) => app.name === appType)?.component; + }, [appType]); + + return Component ? : ; +} diff --git a/src/app/jobmonitor/error.tsx b/src/app/jobmonitor/error.tsx deleted file mode 100644 index 68cea11a..00000000 --- a/src/app/jobmonitor/error.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { useEffect } from "react"; - -export default function Error({ - error, - reset, -}: { - error: Error; - reset: () => void; -}) { - useEffect(() => { - console.error(error); - }, [error]); - - return ( -
-

Something went wrong!

- -
- ); -} diff --git a/src/app/jobmonitor/layout.tsx b/src/app/jobmonitor/layout.tsx deleted file mode 100644 index 1f26a53c..00000000 --- a/src/app/jobmonitor/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function JobMonitorLayout({ - children, -}: { - children: React.ReactNode; -}) { - return
{children}
; -} diff --git a/src/app/jobmonitor/page.tsx b/src/app/jobmonitor/page.tsx deleted file mode 100644 index 54fc0ed6..00000000 --- a/src/app/jobmonitor/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import JobMonitor from "@/components/applications/JobMonitor"; - -export default async function Page() { - return ; -} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9bab2c39..d6f170ac 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,8 +1,6 @@ import { Inter } from "next/font/google"; import { OIDCConfigurationProvider } from "@/contexts/OIDCConfigurationProvider"; import { ThemeProvider } from "@/contexts/ThemeProvider"; -import Dashboard from "@/components/layout/Dashboard"; -import { OIDCSecure } from "@/components/layout/OIDCSecure"; const inter = Inter({ subsets: ["latin"] }); @@ -24,9 +22,7 @@ export default function RootLayout({ - - {children} - + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 6d5d7447..00000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import UserDashboard from "@/components/applications/UserDashboard"; - -export default function Page() { - return ; -} diff --git a/src/components/applications/ApplicationList.ts b/src/components/applications/ApplicationList.ts new file mode 100644 index 00000000..7bda2436 --- /dev/null +++ b/src/components/applications/ApplicationList.ts @@ -0,0 +1,18 @@ +import { Dashboard, FolderCopy, Monitor } from "@mui/icons-material"; +import JobMonitor from "./JobMonitor"; +import UserDashboard from "./UserDashboard"; +import ApplicationConfig from "@/types/ApplicationConfig"; + +export const applicationList: ApplicationConfig[] = [ + { name: "Dashboard", component: UserDashboard, icon: Dashboard }, + { + name: "Job Monitor", + component: JobMonitor, + icon: Monitor, + }, + { + name: "File Catalog", + component: JobMonitor, + icon: FolderCopy, + }, +]; diff --git a/src/components/applications/JobMonitor.tsx b/src/components/applications/JobMonitor.tsx index e4224299..09c2fa6f 100644 --- a/src/components/applications/JobMonitor.tsx +++ b/src/components/applications/JobMonitor.tsx @@ -15,19 +15,9 @@ export default function JobMonitor() { const theme = useMUITheme(); return ( - - - - -

Job Monitor

- -
-
-
+
+

Job Monitor

+ +
); } diff --git a/src/components/applications/LoginForm.tsx b/src/components/applications/LoginForm.tsx index ca15aadc..02a14743 100644 --- a/src/components/applications/LoginForm.tsx +++ b/src/components/applications/LoginForm.tsx @@ -20,6 +20,8 @@ import { useOIDCContext } from "@/hooks/oidcConfiguration"; import { useMUITheme } from "@/hooks/theme"; import { useMetadata, Metadata } from "@/hooks/metadata"; +import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; + /** * Login form * @returns a form @@ -33,6 +35,8 @@ export function LoginForm() { const { configuration, setConfiguration } = useOIDCContext(); const { isAuthenticated, login } = useOidc(configuration?.scope); + const { getParam } = useSearchParamsUtils(); + // Login if not authenticated useEffect(() => { if (configuration && configuration.scope && isAuthenticated === false) { @@ -44,9 +48,14 @@ export function LoginForm() { useEffect(() => { // Redirect to dashboard if already authenticated if (isAuthenticated) { - router.push("/"); + const redirect = getParam("redirect"); + if (redirect) { + router.push(redirect); + } else { + router.push("/"); + } } - }, [isAuthenticated, router]); + }, [getParam, isAuthenticated, router]); // Get default group const getDefaultGroup = (data: Metadata | undefined, vo: string): string => { diff --git a/src/components/applications/UserDashboard.tsx b/src/components/applications/UserDashboard.tsx index 15a701a9..57b93774 100644 --- a/src/components/applications/UserDashboard.tsx +++ b/src/components/applications/UserDashboard.tsx @@ -13,7 +13,6 @@ import { useMUITheme } from "@/hooks/theme"; * @returns User Dashboard content */ export default function UserDashboard() { - const theme = useMUITheme(); const { configuration } = useOIDCContext(); const { accessTokenPayload } = useOidcAccessToken(configuration?.scope); @@ -22,20 +21,10 @@ export default function UserDashboard() { } return ( - - - - -

Hello {accessTokenPayload["preferred_username"]}

+
+

Hello {accessTokenPayload["preferred_username"]}

-

To start with, select an application in the side bar

- - - +

To start with, select an application in the side bar

+
); } diff --git a/src/components/layout/OIDCSecure.tsx b/src/components/layout/OIDCSecure.tsx index 6e757b87..bc6f9adb 100644 --- a/src/components/layout/OIDCSecure.tsx +++ b/src/components/layout/OIDCSecure.tsx @@ -21,7 +21,11 @@ export function OIDCSecure({ children }: OIDCProps) { useEffect(() => { // Redirect to login page if not authenticated if (!isAuthenticated) { - router.push("/auth"); + router.push( + "/auth?" + + // URLSearchParams to ensure that auth redirects users to the URL they came from + new URLSearchParams({ redirect: window.location.href }).toString(), + ); } }, [isAuthenticated, router]); diff --git a/src/components/ui/ApplicationDialog.tsx b/src/components/ui/ApplicationDialog.tsx new file mode 100644 index 00000000..58864845 --- /dev/null +++ b/src/components/ui/ApplicationDialog.tsx @@ -0,0 +1,101 @@ +import React, { ComponentType } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogContentText, + DialogActions, + Button, + Grid, + Icon, + IconButton, +} from "@mui/material"; +import { Close } from "@mui/icons-material"; +import { applicationList } from "../applications/ApplicationList"; + +/** + * Renders a dialog component for creating a new application. + * + * @param {Object} props - The component props. + * @param {boolean} props.appDialogOpen - Determines whether the dialog is open or not. + * @param {React.Dispatch>} props.setAppDialogOpen - Function to set the open state of the dialog. + * @param {(name: string, path: string, icon: ComponentType) => void} props.handleCreateApp - Function to handle the creation of a new application. + * @returns {JSX.Element} The rendered dialog component. + */ +export default function AppDialog({ + appDialogOpen, + setAppDialogOpen, + handleCreateApp, +}: { + appDialogOpen: boolean; + setAppDialogOpen: React.Dispatch>; + handleCreateApp: (name: string, icon: ComponentType) => void; +}) { + const [appType, setAppType] = React.useState(""); + return ( + setAppDialogOpen(false)} + aria-labelledby="application-dialog-label" + PaperProps={{ + component: "form", + onSubmit: (event: React.FormEvent) => { + event.preventDefault(); + + const icon = applicationList.find((app) => app.name === appType) + ?.icon; + if (!icon) { + console.error("Icon not found for application type", appType); + return; + } + + handleCreateApp(appType, icon as React.ComponentType); + + setAppDialogOpen(false); + }, + }} + fullWidth + maxWidth="sm" + > + + Available applications + + setAppDialogOpen(false)} + sx={{ + position: "absolute", + right: 8, + top: 8, + color: (theme) => theme.palette.grey[500], + }} + > + + + + + Click on any application to open it in a new instance in the drawer. + Multiple instances of the same application can be opened + simultaneously. + + + {applicationList.map((app) => ( + + + + ))} + + + + ); +} diff --git a/src/components/ui/DashboardDrawer.tsx b/src/components/ui/DashboardDrawer.tsx index ccc641e1..637e36c8 100644 --- a/src/components/ui/DashboardDrawer.tsx +++ b/src/components/ui/DashboardDrawer.tsx @@ -1,31 +1,34 @@ -import { usePathname } from "next/navigation"; -import NextLink from "next/link"; import { + Box, + Button, Drawer, - Icon, List, ListItem, ListItemButton, ListItemIcon, ListItemText, + Menu, + MenuItem, + Popover, + TextField, Toolbar, } from "@mui/material"; -import { Dashboard, FolderCopy } from "@mui/icons-material"; -import MonitorIcon from "@mui/icons-material/Monitor"; import MenuBookIcon from "@mui/icons-material/MenuBook"; -import { ReactEventHandler } from "react"; -import { DiracLogo } from "./DiracLogo"; - -// Define the sections that are accessible to users. -// Each section has an associated icon and path. -const userSections: Record< - string, - { icon: React.ComponentType; path: string } -> = { - Dashboard: { icon: Dashboard, path: "/" }, - "Job Monitor": { icon: MonitorIcon, path: "/jobmonitor" }, - "File Catalog": { icon: FolderCopy, path: "/filecatalog" }, -}; +import AddIcon from "@mui/icons-material/Add"; +import React, { + ComponentType, + ReactEventHandler, + useContext, + useEffect, + useState, +} from "react"; +import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import Image from "next/image"; +import DrawerItemGroup from "./DrawerItemGroup"; +import AppDialog from "./ApplicationDialog"; +import { ApplicationsContext } from "@/contexts/ApplicationsProvider"; +import { useMUITheme } from "@/hooks/theme"; interface DashboardDrawerProps { variant: "permanent" | "temporary"; @@ -34,70 +37,452 @@ interface DashboardDrawerProps { handleDrawerToggle: ReactEventHandler; } +/** + * Represents a drawer component used in the dashboard. + * + * @component + * @param {DashboardDrawerProps} props - The props for the DashboardDrawer component. + * @returns {JSX.Element} The rendered DashboardDrawer component. + */ export default function DashboardDrawer(props: DashboardDrawerProps) { - // Get the current URL - const pathname = usePathname(); // Determine the container for the Drawer based on whether the window object exists. const container = window !== undefined ? () => window.document.body : undefined; // Check if the drawer is in "temporary" mode. const isTemporary = props.variant === "temporary"; + // Whether the modal for Application Creation is open + const [appDialogOpen, setAppDialogOpen] = useState(false); + + const [contextMenu, setContextMenu] = React.useState<{ + mouseX: number; + mouseY: number; + } | null>(null); + + const [contextState, setContextState] = useState<{ + type: string | null; + id: string | null; + }>({ type: null, id: null }); + + const [popAnchorEl, setPopAnchorEl] = React.useState(null); + const [renameValue, setRenameValue] = React.useState(""); + + // Define the sections that are accessible to users. + // Each section has an associated icon and path. + const [userSections, setSections] = useContext(ApplicationsContext); + + const theme = useMUITheme(); + + useEffect(() => { + // Handle changes to sections when drag and drop occurs. + return monitorForElements({ + onDrop({ source, location }) { + const target = location.current.dropTargets[0]; + if (!target) { + return; + } + const sourceData = source.data; + const targetData = target.data; + + if (location.current.dropTargets.length == 2) { + // If the target is an item + const groupTitle = targetData.title; + const closestEdgeOfTarget = extractClosestEdge(targetData); + const targetIndex = targetData.index as number; + const sourceGroup = userSections.find( + (group) => group.title == sourceData.title, + ); + const targetGroup = userSections.find( + (group) => group.title == groupTitle, + ); + const sourceIndex = sourceData.index as number; + const destinationIndex = ( + closestEdgeOfTarget === "top" ? targetIndex : targetIndex + 1 + ) as number; + + reorderSections( + sourceGroup, + targetGroup, + sourceIndex, + destinationIndex, + ); + } else { + // If the target is a group + const groupTitle = targetData.title; + const sourceGroup = userSections.find( + (group) => group.title == sourceData.title, + ); + const targetGroup = userSections.find( + (group) => group.title == groupTitle, + ); + const sourceIndex = sourceData.index as number; + + reorderSections(sourceGroup, targetGroup, sourceIndex); + } + }, + }); + + /** + * Reorders sections within a group or between different groups. + * + * @param sourceGroup - The source group from which the section is being moved. + * @param destinationGroup - The destination group where the section is being moved to. + * @param sourceIndex - The index of the section within the source group. + * @param destinationIndex - The index where the section should be placed in the destination group. + * If null, the section will be placed at the end of the destination group. + */ + function reorderSections( + sourceGroup: any, + destinationGroup: any, + sourceIndex: number, + destinationIndex: number | null = null, + ) { + if (sourceGroup && destinationGroup) { + if ( + sourceGroup.title === destinationGroup.title && + destinationIndex && + sourceIndex < destinationIndex + ) { + destinationIndex -= 1; // Corrects the index within the same group if needed + } + if ( + sourceGroup.title === destinationGroup.title && + (destinationIndex == null || sourceIndex === destinationIndex) + ) { + return; // Nothing to do + } + + if (sourceGroup.title === destinationGroup.title) { + const sourceItems = [...sourceGroup.items]; + + const [removed] = sourceItems.splice(sourceIndex, 1); + + if (destinationIndex === null) { + destinationIndex = sourceItems.length; + } + sourceItems.splice(destinationIndex, 0, removed); + + setSections((sections) => + sections.map((section) => + section.title === sourceGroup.title + ? { ...section, items: sourceItems } + : section, + ), + ); + } else { + const sourceItems = [...sourceGroup.items]; + + const [removed] = sourceItems.splice(sourceIndex, 1); + + const destinationItems = [...destinationGroup.items]; + + if (destinationIndex === null) { + destinationIndex = destinationItems.length; + } + destinationItems.splice(destinationIndex, 0, removed); + + setSections((sections) => + sections.map((section) => + section.title === sourceGroup.title + ? { ...section, items: sourceItems } + : section.title === destinationGroup.title + ? { ...section, items: destinationItems } + : section, + ), + ); + } + } + } + }, [setSections, userSections]); + + /** + * Handles the creation of a new app in the dashboard drawer. + * + * @param appType - The type of the app to be created. + * @param icon - The icon component for the app. + */ + const handleAppCreation = (appType: string, icon: ComponentType) => { + let group = userSections[userSections.length - 1]; + const empty = !group; + if (empty) { + //create a new group if there is no group + group = { + title: `Group ${userSections.length + 1}`, + extended: false, + items: [], + }; + } + + let title = `${appType} ${userSections.reduce( + (sum, group) => + sum + group.items.filter((item) => item.type === appType).length, + 1, + )}`; + while (group.items.some((item) => item.title === title)) { + title = `${appType} ${parseInt(title.split(" ")[1]) + 1}`; + } + + const newApp = { + title, + id: `${title}${userSections.reduce( + (sum, group) => sum + group.items.length, + 0, + )}`, + type: appType, + icon: icon, + }; + group.items.push(newApp); + if (empty) { + setSections([...userSections, group]); + } else { + setSections( + userSections.map((g) => (g.title === group.title ? group : g)), + ); + } + }; + + let isContextStateStable = true; + + const handleContextMenu = + (type: "group" | "item" | null = null, id: string | null = null) => + (event: React.MouseEvent) => { + event.preventDefault(); + if (contextMenu !== null) { + handleCloseContextMenu(); + return; + } + setContextMenu({ + mouseX: event.clientX + 2, + mouseY: event.clientY - 6, + }); + if (isContextStateStable) { + setContextState({ type, id }); + isContextStateStable = false; + } + }; + + const handleCloseContextMenu = () => { + setContextMenu(null); + setContextState({ type: null, id: null }); + isContextStateStable = true; + }; + + const handleNewGroup = () => { + const newGroup = { + title: `Group ${userSections.length + 1}`, + extended: false, + items: [], + }; + while (userSections.some((group) => group.title === newGroup.title)) { + newGroup.title = `Group ${parseInt(newGroup.title.split(" ")[1]) + 1}`; + } + + setSections([...userSections, newGroup]); + handleCloseContextMenu(); + }; + + const handleDelete = () => { + if (contextState.type === "group") { + const newSections = userSections.filter( + (group) => group.title !== contextState.id, + ); + setSections(newSections); + } else if (contextState.type === "item") { + const newSections = userSections.map((group) => { + const newItems = group.items.filter( + (item) => item.id !== contextState.id, + ); + return { ...group, items: newItems }; + }); + setSections(newSections); + } + handleCloseContextMenu(); + }; + + const handleRenameClick = (event: any) => { + setPopAnchorEl(event.currentTarget); + }; + + const popClose = () => { + setRenameValue(""); + setPopAnchorEl(null); + }; + + const handleRename = () => { + if (contextState.type === "group") { + //check if the name is already taken + if (userSections.some((group) => group.title === renameValue)) { + return; + } + //rename the group + const newSections = userSections.map((group) => { + if (group.title === contextState.id) { + return { ...group, title: renameValue }; + } + return group; + }); + setSections(newSections); + } else if (contextState.type === "item") { + const newSections = userSections.map((group) => { + const newItems = group.items.map((item) => { + if (item.id === contextState.id) { + return { ...item, title: renameValue }; + } + return item; + }); + return { ...group, items: newItems }; + }); + setSections(newSections); + } + + popClose(); + handleCloseContextMenu(); + }; return ( - -
- {/* Display the logo in the toolbar section of the drawer. */} - - - - {/* Map over user sections and render them as list items in the drawer. */} - - {Object.keys(userSections).map((title: string) => ( - + <> + +
+ {/* Display the logo in the toolbar section of the drawer. */} + + DIRAC logo + + {/* Map over user sections and render them as list items in the drawer. */} + + {userSections.map((group) => ( + + + + ))} + + + {/* Render a link to documentation and a button to add applications, positioned at the bottom of the drawer. */} + + + setAppDialogOpen(true)}> + + + + + + + - + - + - ))} - - {/* Render a link to documentation, positioned at the bottom of the drawer. */} - - - - - - - - - - -
-
+
+
+ + {contextState.type && ( + Rename + )} + {contextState.type && ( + Delete + )} + + New Group + + +
{ + e.preventDefault(); + handleRename(); + }} + > + + setRenameValue(e.target.value)} + /> + + +
+
+
+ + ); } diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx index 802084f0..3bfb6560 100644 --- a/src/components/ui/DataTable.tsx +++ b/src/components/ui/DataTable.tsx @@ -26,10 +26,12 @@ import { Stack, } from "@mui/material"; import { deepOrange } from "@mui/material/colors"; -import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { FilterToolbar } from "./FilterToolbar"; import { Filter } from "@/types/Filter"; import { Column } from "@/types/Column"; +import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; +import { ApplicationsContext } from "@/contexts/ApplicationsProvider"; /** * Descending comparator function @@ -343,39 +345,48 @@ export function DataTable(props: DataTableProps) { id: number | null; }>({ mouseX: null, mouseY: null, id: null }); // NextJS router and params - const router = useRouter(); - const pathname = usePathname(); const searchParams = useSearchParams(); + const { getParam, setParam } = useSearchParamsUtils(); + const appId = getParam("appId"); - // Manage URL search params - const createQueryString = React.useCallback( - (filters: Filter[]) => { - const params = new URLSearchParams(searchParams.toString()); - // Clear existing filters - params.delete("filter"); - - // Append new filters - filters.forEach((filter) => { - params.append( - "filter", - `${filter.id}_${filter.column}_${filter.operator}_${filter.value}`, - ); - }); - - return params.toString(); + const updateFiltersAndUrl = React.useCallback( + (newFilters: Filter[]) => { + // Update the filters in the URL using the setParam function + setParam( + "filter", + newFilters.map( + (filter) => + `${filter.id}_${filter.column}_${filter.operator}_${filter.value}`, + ), + ); }, - [searchParams], + [setParam], ); - const updateFiltersAndUrl = React.useCallback( + const [sections, setSections] = React.useContext(ApplicationsContext); + const updateSectionFilters = React.useCallback( (newFilters: Filter[]) => { - // Generate the new query string with all filters - const queryString = createQueryString(newFilters); + const appId = getParam("appId"); - // Push new URL to history without reloading the page - router.push(pathname + "?" + queryString); + const section = sections.find((section) => + section.items.some((item) => item.id === appId), + ); + if (section) { + const newSection = { + ...section, + items: section.items.map((item) => { + if (item.id === appId) { + return { ...item, data: { filters: newFilters } }; + } + return item; + }), + }; + setSections((sections) => + sections.map((s) => (s.title === section.title ? newSection : s)), + ); + } }, - [createQueryString, pathname, router], + [getParam, sections, setSections], ); // Handle the application of filters @@ -390,6 +401,8 @@ export function DataTable(props: DataTableProps) { // Update the filters in the URL updateFiltersAndUrl(filters); + // Update the filters in the sections + updateSectionFilters(filters); }; React.useEffect(() => { @@ -402,6 +415,10 @@ export function DataTable(props: DataTableProps) { }); }; + const item = sections + .find((section) => section.items.some((item) => item.id === appId)) + ?.items.find((item) => item.id === appId); + if (searchParams.has("filter")) { // Parse the filters when the component mounts or when the searchParams change const initialFilters = parseFiltersFromUrl(); @@ -414,8 +431,26 @@ export function DataTable(props: DataTableProps) { value: filter.value, })); setSearchBody({ search: jsonFilters }); + } else if (item?.data?.filters) { + setFilters(item.data.filters); + const jsonFilters = item.data.filters.map( + (filter: { + id: number; + column: string; + operator: string; + value: string; + }) => ({ + parameter: filter.column, + operator: filter.operator, + value: filter.value, + }), + ); + setSearchBody({ search: jsonFilters }); + } else { + setFilters([]); + setSearchBody({ search: [] }); } - }, [searchParams, setFilters, setSearchBody]); + }, [appId, searchParams, sections, setFilters, setSearchBody]); // Manage sorting const handleRequestSort = ( diff --git a/src/components/ui/DiracLogo.tsx b/src/components/ui/DiracLogo.tsx deleted file mode 100644 index cff07b6f..00000000 --- a/src/components/ui/DiracLogo.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import NextLink from "next/link"; -import Image from "next/image"; - -/** - * Logo of the DIRAC interware redirecting to the root page - * @returns a NextLink embedding an Image - */ -export function DiracLogo() { - return ( - <> - - DIRAC logo - - - ); -} diff --git a/src/components/ui/DrawerItem.tsx b/src/components/ui/DrawerItem.tsx new file mode 100644 index 00000000..71f11bb8 --- /dev/null +++ b/src/components/ui/DrawerItem.tsx @@ -0,0 +1,205 @@ +import React, { useEffect, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { + ListItemButton, + ListItemIcon, + Icon, + ListItemText, +} from "@mui/material"; +import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box"; +import { + Edge, + attachClosestEdge, + 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 { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; +import { ThemeProvider } from "@/contexts/ThemeProvider"; +import { useMUITheme } from "@/hooks/theme"; +import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; + +/** + * Represents a drawer item component. + * + * @param item - The item object containing the title, id, and icon. + * @param index - The index of the item. + * @param groupTitle - The title of the group. + * @returns The rendered JSX for the drawer item. + */ +export default function DrawerItem({ + item: { title, id, icon }, + index, + groupTitle, +}: { + item: { title: string; id: string; icon: React.ComponentType }; + index: number; + groupTitle: string; +}) { + // Ref to use for the draggable element + const dragRef = React.useRef(null); + // Ref to use for the handle of the draggable element, must be a child of the draggable element + const handleRef = React.useRef(null); + const theme = useMUITheme(); + const { setParam } = useSearchParamsUtils(); + // Represents the closest edge to the mouse cursor + const [closestEdge, setClosestEdge]: any = useState(null); + + useEffect(() => { + if (!dragRef.current || !handleRef.current) return; + const element = dragRef.current; + const handleItem = handleRef.current; + + return combine( + // makes the item draggable + draggable({ + element: element, + dragHandle: handleItem, + // Sets the initial data for the drag and drop interaction + getInitialData: () => ({ index, title: groupTitle }), + // Sets a lightweight version of the real item as a preview + onGenerateDragPreview: ({ nativeSetDragImage, source, location }) => { + setCustomNativeDragPreview({ + nativeSetDragImage, + render: ({ container }) => { + const root = createRoot(container); + root.render( + // Wraps the preview in the theme provider to ensure the correct theme is applied + // This is necessary because the preview is rendered outside the main app + + +
+ +
+
+
, + ); + return () => root.unmount(); + }, + // Seamless transition between the preview and the real element + getOffset: ({ container }) => { + const elementPos = source.element.getBoundingClientRect(); + const x = location.current.input.pageX - elementPos.x; + const y = location.current.input.pageY - elementPos.y; + return { x, y }; + }, + }); + }, + }), + // Makes the item a target for dragged elements. Attach the closest edge data and highlight the destination when hovering over the item + dropTargetForElements({ + element: element, + getData: ({ input, element }) => { + return attachClosestEdge( + { index, title: groupTitle }, + { input, element, allowedEdges: ["top", "bottom"] }, + ); + }, + onDrag({ self, source }) { + const isSource = source.element === element; + if (isSource) { + setClosestEdge(null); + return; + } + const closestEdge = extractClosestEdge(self.data); + + const sourceIndex = source.data.index; + if (typeof sourceIndex === "number") { + const isItemBeforeSource = + index === sourceIndex - 1 && source.data.title === title; + const isItemAfterSource = + index === sourceIndex + 1 && source.data.title === title; + + const isDropIndicatorHidden = + (isItemBeforeSource && closestEdge === "bottom") || + (isItemAfterSource && closestEdge === "top"); + + if (isDropIndicatorHidden) { + setClosestEdge(null); + return; + } + } + setClosestEdge(closestEdge); + }, + onDragLeave() { + setClosestEdge(null); + }, + onDrop: () => { + setClosestEdge(null); + }, + }), + ); + }, [index, groupTitle, icon, theme, title, id]); + + return ( + <> + setParam("appId", id)} + sx={{ pl: 2, borderRadius: 2, pr: 1 }} + ref={dragRef} + > + + + + + + + + {closestEdge && } + + + ); +} + +/** + * Lightweight preview of an item in the drawer. + * Used when dragging an item to give a visual representation of it with minimal resources. + * + * @param {Object} props - The component props. + * @param {string} props.title - The title of the item. + * @param {React.ComponentType} props.icon - The icon component for the item. + * @returns {JSX.Element} The rendered item preview. + */ +function ItemPreview({ + title, + icon, +}: { + title: string; + icon: React.ComponentType; +}) { + return ( + + + + + + + + + + ); +} diff --git a/src/components/ui/DrawerItemGroup.tsx b/src/components/ui/DrawerItemGroup.tsx new file mode 100644 index 00000000..f70f7fd8 --- /dev/null +++ b/src/components/ui/DrawerItemGroup.tsx @@ -0,0 +1,96 @@ +import { Accordion, AccordionDetails, AccordionSummary } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import React, { useEffect } from "react"; +import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import DrawerItem from "./DrawerItem"; +import { UserSection } from "@/types/UserSection"; + +/** + * Represents a group of items in a drawer. + * + * @component + * @param {Object} props - The component props. + * @param {Object} props.group - The group object containing the title, expanded state, and items. + * @param {string} props.group.title - The title of the group. + * @param {boolean} props.group.extended - The expanded state of the group. + * @param {Array} props.group.items - The array of items in the group. + * @param {Function} props.setSections - The function to set the sections state. + * @param {Function} props.handleContextMenu - The function to handle the context menu. + * @returns {JSX.Element} The rendered DrawerItemGroup component. + */ +export default function DrawerItemGroup({ + group: { title, extended: expanded, items }, + setSections, + handleContextMenu, +}: { + group: UserSection; + setSections: React.Dispatch>; + handleContextMenu: ( + type: "group" | "item" | null, + id: string | null, + ) => (event: React.MouseEvent) => void; +}) { + // Ref to use for the drag and drop target + const dropRef = React.useRef(null); + // State to track whether the user is hovering over the item during a drag operation + const [hovered, setHovered] = React.useState(false); + + useEffect(() => { + if (!dropRef.current) return; + const dropItem = dropRef.current; + + // Makes the element a valid drop target, sets up the data transfer and manage the hovered state + return dropTargetForElements({ + element: dropItem, + getData: () => ({ title }), + onDragStart: () => setHovered(true), + onDrop: () => { + setHovered(false); + handleChange(title)(null, true); + }, + onDragEnter: () => setHovered(true), + onDragLeave: () => setHovered(false), + }); + }); + + // Handles expansion of the accordion group + const handleChange = (title: string) => (event: any, isExpanded: any) => { + // Set the extended state of the accordion group. + setSections((sections) => + sections.map((section) => + section.title === title + ? { ...section, extended: isExpanded } + : section, + ), + ); + }; + return ( + + {/* Accordion summary */} + }> + {title} + + {/* Accordion details */} + + {items.map(({ title: itemTitle, id, icon }, index) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/src/contexts/ApplicationsProvider.tsx b/src/contexts/ApplicationsProvider.tsx new file mode 100644 index 00000000..2b346688 --- /dev/null +++ b/src/contexts/ApplicationsProvider.tsx @@ -0,0 +1,109 @@ +import { Dashboard, FolderCopy, Monitor } from "@mui/icons-material"; +import React, { createContext, useEffect, useState } from "react"; +import JSONCrush from "jsoncrush"; +import { useOidc } from "@axa-fr/react-oidc"; +import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; +import { applicationList } from "@/components/applications/ApplicationList"; +import { UserSection } from "@/types/UserSection"; +import { useOIDCContext } from "@/hooks/oidcConfiguration"; + +// Create a context for the userSections state +export const ApplicationsContext = createContext< + [UserSection[], React.Dispatch>] +>([[], () => {}]); + +// Create the ApplicationsProvider component +const ApplicationsProvider = ({ children }: { children: React.ReactNode }) => { + const [userSections, setSections] = useState([]); + + const { getParam, setParam } = useSearchParamsUtils(); + + const { configuration } = useOIDCContext(); + const { isAuthenticated } = useOidc(configuration?.scope); + + useEffect(() => { + // get user sections from searchParams + const sectionsParams = getParam("sections"); + if (sectionsParams) { + const uncrushed = JSONCrush.uncrush(sectionsParams); + try { + const newSections = JSON.parse(uncrushed).map( + (section: { items: any[] }) => { + section.items = section.items.map((item: any) => { + return { + ...item, + //get icon from ApplicationList + icon: applicationList.find((app) => app.name === item.type) + ?.icon, + }; + }); + return section; + }, + ); + setSections(newSections); + } catch (e) { + console.error("Error parsing user sections : ", uncrushed, e); + } + } else { + setSections([ + { + title: "Dashboard", + extended: true, + items: [ + { + title: "Dashboard", + type: "Dashboard", + id: "Dashboard0", + icon: Dashboard, + }, + { + title: "Job Monitor", + type: "Job Monitor", + id: "JobMonitor0", + icon: Monitor, + }, + ], + }, + { + title: "Other", + extended: false, + items: [ + { + title: "File Catalog", + type: "File Catalog", + id: "FileCatatlog0", + icon: FolderCopy, + }, + ], + }, + ]); + } + }, [getParam]); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + // save user sections to searchParams (but not icons) + const newSections = userSections.map((section) => { + return { + ...section, + items: section.items.map((item) => { + return { + ...item, + icon: () => null, + }; + }), + }; + }); + setParam("sections", JSONCrush.crush(JSON.stringify(newSections))); + }, [isAuthenticated, setParam, userSections]); + + return ( + + {children} + + ); +}; + +export default ApplicationsProvider; diff --git a/src/hooks/searchParamsUtils.tsx b/src/hooks/searchParamsUtils.tsx new file mode 100644 index 00000000..7712d09c --- /dev/null +++ b/src/hooks/searchParamsUtils.tsx @@ -0,0 +1,63 @@ +import { useRouter, useSearchParams, usePathname } from "next/navigation"; +import { useCallback } from "react"; + +/** + * Custom hook for managing search parameters in the URL. + * Provides functions to get, set, and remove search parameters. + * + * @returns An object containing the `getParam`, `setParam`, and `removeParam` functions. + */ +export function useSearchParamsUtils() { + const router = useRouter(); + const searchParams = useSearchParams(); + const pathname = usePathname(); + + const getParams = useCallback(() => { + return new URLSearchParams(searchParams); + }, [searchParams]); + + const getAllParam = useCallback( + (key: string) => { + return searchParams.getAll(key); + }, + [searchParams], + ); + + const getParam = useCallback( + (key: string) => { + return searchParams.get(key); + }, + [searchParams], + ); + + const setParam = useCallback( + (key: string, value: string | string[]) => { + const params = getParams(); + if (Array.isArray(value)) { + params.delete(key); + value.forEach((v) => params.append(key, v)); + params.sort(); + router.push(`${pathname}?${params.toString()}`); + } else { + params.set(key, value); + params.sort(); + router.push(`${pathname}?${params.toString()}`); + } + }, + [getParams, pathname, router], + ); + + const removeParam = useCallback( + (key: string) => { + const params = getParams(); + params.delete(key); + + router.push( + `${pathname}?${params.toString() === "" ? "" : params.toString()}`, + ); + }, + [getParams, pathname, router], + ); + + return { getParam, setParam, removeParam, getAllParam }; +} diff --git a/src/hooks/theme.tsx b/src/hooks/theme.tsx index 10bd83ef..03ce9a0d 100644 --- a/src/hooks/theme.tsx +++ b/src/hooks/theme.tsx @@ -36,7 +36,35 @@ export const useMUITheme = () => { }, }); + const scrollbarBackground = theme === "dark" ? "#333" : "#f1f1f1"; + const scrollbarThumbBackground = theme === "dark" ? "#888" : "#ccc"; + const scrollbarThumbHoverBackground = theme === "dark" ? "#555" : "#999"; + const scrollbarColor = `${scrollbarThumbBackground} ${scrollbarBackground}`; + muiTheme.components = { + MuiCssBaseline: { + styleOverrides: ` + ::-webkit-scrollbar { + width: 10px; + border-radius: 5px; + } + ::-webkit-scrollbar-track { + background: ${scrollbarBackground}; + } + ::-webkit-scrollbar-thumb { + background: ${scrollbarThumbBackground}; + border-radius: 5px; + } + ::-webkit-scrollbar-thumb:hover { + background: ${scrollbarThumbHoverBackground}; + } + @supports not selector(::-webkit-scrollbar) { + html { + scrollbar-color: ${scrollbarColor}; + } + } + `, + }, MuiButton: { styleOverrides: { contained: { diff --git a/src/types/ApplicationConfig.ts b/src/types/ApplicationConfig.ts new file mode 100644 index 00000000..324a53d8 --- /dev/null +++ b/src/types/ApplicationConfig.ts @@ -0,0 +1,8 @@ +import { SvgIconComponent } from "@mui/icons-material"; +import { ElementType } from "react"; + +export default interface ApplicationConfig { + name: string; + component: ElementType; + icon: SvgIconComponent; +} diff --git a/src/types/UserSection.ts b/src/types/UserSection.ts new file mode 100644 index 00000000..a5ac13be --- /dev/null +++ b/src/types/UserSection.ts @@ -0,0 +1,12 @@ +// Define the type for the userSections state +export type UserSection = { + title: string; + extended: boolean; + items: { + title: string; + type: string; + id: string; + icon: React.ComponentType; + data?: any; + }[]; +}; diff --git a/test/e2e/dashboard.cy.ts b/test/e2e/dashboard.cy.ts new file mode 100644 index 00000000..299156ba --- /dev/null +++ b/test/e2e/dashboard.cy.ts @@ -0,0 +1,242 @@ +/// + +describe("DashboardDrawer", { retries: { runMode: 5, openMode: 3 } }, () => { + beforeEach(() => { + cy.session("login", () => { + // Visit the page where the DashboardDrawer is rendered + cy.visit("/"); + + //login + cy.contains("Login through your Identity Provider").click(); + cy.get("#login").type("admin@example.com"); + cy.get("#password").type("password"); + + // Find the login button and click on it + cy.get("button").click(); + // Grant access + cy.get(":nth-child(1) > form > .dex-btn").click(); + cy.url().should("include", "/auth"); + }); + cy.visit("/"); + }); + + it("should render the drawer", () => { + // Check if the drawer is rendered + cy.contains("Job Monitor").should("be.visible"); + }); + + it("should toggle the group on button click", () => { + cy.contains("Dashboard").click(); + // Check if the drawer is not visible after clicking the toggle button + cy.contains("Job Monitor").should("not.be.visible"); + }); + + it("should handle application addition", () => { + cy.contains("Add application").click(); + + cy.get("button").contains("Dashboard").click().click(); + + cy.contains("Other").click(); + // Check if the application is added + cy.contains("Dashboard 2").should("be.visible"); + }); + + it("should handle application deletion", () => { + cy.get(".MuiListItemButton-root").contains("Dashboard").rightclick(); + cy.contains("Delete").click(); + + // Check if the application is deleted + cy.get(".MuiListItemButton-root").contains("Dashboard").should("not.exist"); + }); + + it("should handle application renaming", () => { + cy.get(".MuiListItemButton-root").contains("Dashboard").rightclick(); + cy.contains("Rename").click(); + + cy.get("input").type("Dashboard 1"); + cy.get("button").contains("Rename").click(); + + // Check if the application is renamed + cy.get(".MuiListItemButton-root") + .contains("Dashboard 1") + .should("be.visible"); + }); + + it("should handle group creation", () => { + cy.contains("Dashboard").rightclick(); + cy.contains("New Group").click(); + + cy.contains("Group 3").should("be.visible"); + }); + + it("should handle group deletion", () => { + cy.contains("Other").rightclick(); + cy.contains("Delete").click(); + + // Check if the group is deleted + cy.contains("Other").should("not.exist"); + }); + + it("should handle group renaming", () => { + cy.contains("Other").rightclick(); + cy.contains("Rename").click(); + + cy.get("input").type("Other 1"); + cy.get("button").contains("Rename").click(); + + // Check if the group is renamed + cy.contains("Other 1").should("be.visible"); + }); + + it("should create a the app in a new group if there is no group", () => { + cy.contains("Dashboard").rightclick(); + cy.contains("Delete").click(); + cy.contains("Other").rightclick(); + cy.contains("Delete").click(); + + cy.contains("Add application").click(); + cy.get("button").contains("Job Monitor").click().click(); + + cy.contains("Group 1").should("be.visible"); + + cy.contains("Group 1").click(); + + cy.contains("Job Monitor 1").should("be.visible"); + }); + + it("should persist the state of the drawer", () => { + cy.contains("Dashboard").click(); + + // Check if the drawer is not visible before reloading + cy.contains("Job Monitor").should("not.be.visible"); + cy.url().should("include", "sections="); + + cy.reload(); + // Check if the drawer is still not visible after reloading + cy.contains("Job Monitor").should("not.be.visible"); + }); + + it("should load the state of the drawer from url", () => { + cy.visit( + "/?sections=%5B%28%27title%21%27Test+Value%27~extended%21true~items%21%5B%5D%29%5D_", + ); + + // Check if there is a group with the title "Test Value" + cy.contains("Test Value").should("be.visible"); + }); + + it("should navigate to the application on click", () => { + cy.contains("Job Monitor").click(); + + // Check if the application is navigated to + cy.url().should("include", "JobMonitor0"); + }); + + it("should handle drag and drop for items", () => { + dragAndDrop( + cy.contains("[draggable=true]", "Job Monitor").first(), + cy.contains("[data-drop-target-for-element=true]", "Other").first(), + cy.get( + '.MuiAccordionDetails-root > :nth-child(2) > .MuiButtonBase-root .css-1blhdvq-MuiListItemIcon-root > [data-testid="DragIndicatorIcon"]', + ), + ); + + // Check if the application is dropped + cy.get(":nth-child(2) > .MuiPaper-root") + .contains("Job Monitor") + .should("be.visible"); + }); +}); + +function dragAndDrop( + source: Cypress.Chainable>, + target: Cypress.Chainable>, + handle?: Cypress.Chainable>, +) { + target.then(($target) => { + const target = $target[0]; + const dataTransfer = new DataTransfer(); + const rect = target.getBoundingClientRect(); + const targetPos = { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + pageX: rect.left + rect.width / 2, + pageY: rect.top + rect.height / 2, + }; + + source.first().then(($src) => { + const src = $src[0]; + const rect = src.getBoundingClientRect(); + const srcPos = { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + pageX: rect.left + rect.width / 2, + pageY: rect.top + rect.height / 2, + }; + + // use the handle if provided + if (handle) { + handle.first().then(($handle) => { + const handle = $handle[0]; + const handleRect = handle.getBoundingClientRect(); + const handlePos = { + clientX: handleRect.left + handleRect.width / 2, + clientY: handleRect.top + handleRect.height / 2, + pageX: handleRect.left + handleRect.width / 2, + pageY: handleRect.top + handleRect.height / 2, + }; + + // pick up + src.dispatchEvent( + new DragEvent("dragstart", { + dataTransfer, + bubbles: true, + ...handlePos, + }), + ); + + //drag + target.dispatchEvent( + new DragEvent("dragover", { + dataTransfer, + bubbles: true, + ...targetPos, + }), + ); + + // drop + target.dispatchEvent( + new DragEvent("drop", { + dataTransfer, + bubbles: true, + ...targetPos, + }), + ); + }); + } else { + // pick up + src.dispatchEvent( + new DragEvent("dragstart", { + dataTransfer, + bubbles: true, + ...srcPos, + }), + ); + + //drag + target.dispatchEvent( + new DragEvent("dragover", { + dataTransfer, + bubbles: true, + ...targetPos, + }), + ); + + // drop + target.dispatchEvent( + new DragEvent("drop", { dataTransfer, bubbles: true, ...targetPos }), + ); + } + }); + }); +} diff --git a/test/e2e/jobMonitor.cy.ts b/test/e2e/jobMonitor.cy.ts new file mode 100644 index 00000000..6ca25e6f --- /dev/null +++ b/test/e2e/jobMonitor.cy.ts @@ -0,0 +1,160 @@ +/// + +describe("Job Monitor", () => { + beforeEach(() => { + cy.session("login", () => { + cy.visit("/"); + //login + cy.contains("Login through your Identity Provider").click(); + cy.get("#login").type("admin@example.com"); + cy.get("#password").type("password"); + + // Find the login button and click on it + cy.get("button").click(); + // Grant access + cy.get(":nth-child(1) > form > .dex-btn").click(); + cy.url().should("include", "/auth"); + }); + + // Visit the page where the Job Monitor is rendered + cy.visit( + "/?appId=JobMonitor0§ions=6%21%27Test+Group%27~extended%21true~items%216*.~id430%27%29%2C-5%27~id51.%29%5D%29%5D*4+3-%28%27title.%27~type*%273Monitor4%21%27Job5*+26%5B-%016543.-*_", + ); + }); + + it("should render the drawer", () => { + cy.get("h2").contains("Job Monitor").should("be.visible"); + }); + + it("should handle filter addition", () => { + cy.get("button").contains("Add filter").click(); + + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + }); + + it("should handle filter deletion", () => { + cy.get("button").contains("Add filter").click(); + + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + + cy.get('[data-testid="CancelIcon"]').click(); + cy.get(".MuiChip-label").should("not.exist"); + }); + + it("should handle filter editing", () => { + cy.get("button").contains("Add filter").click(); + + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + + cy.get(".MuiChip-label").click(); + cy.get("#value").clear().type("test2"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").contains("test2").should("be.visible"); + }); + + it("should handle filter clear", () => { + cy.get("button").contains("Add filter").click(); + + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + + cy.get("button").contains("Add filter").click(); + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test2"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + + cy.get("button").contains("Clear all").click(); + + cy.get(".MuiChip-label").should("not.exist"); + }); + + it("should handle filter apply and persist", () => { + cy.get("button").contains("Add filter").click(); + + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + + cy.get("button").contains("Apply").click(); + cy.wait(1000); + cy.reload(); + + cy.get(".MuiChip-label").should("be.visible"); + }); + + it("should handle filter apply and save filters in dashboard", () => { + cy.get("button").contains("Add filter").click(); + + cy.get( + '[data-testid="filter-form-select-column"] > .MuiSelect-select', + ).click(); + cy.get('[data-value="JobName"]').click(); + cy.get("#value").type("test"); + cy.get( + ".css-1x33toh-MuiStack-root > .MuiStack-root > .MuiButtonBase-root", + ).click(); + + cy.get(".MuiChip-label").should("be.visible"); + + cy.get("button").contains("Apply").click(); + cy.wait(1000); + cy.get(".MuiButtonBase-root").contains("Job Monitor 2").click(); + + cy.get(".MuiChip-label").should("not.exist"); + + cy.get(".MuiButtonBase-root").contains("Job Monitor").click(); + cy.get(".MuiChip-label").should("be.visible"); + }); +}); diff --git a/test/unit-tests/Dashboard.test.tsx b/test/unit-tests/Dashboard.test.tsx index 39b4686c..f6551ffd 100644 --- a/test/unit-tests/Dashboard.test.tsx +++ b/test/unit-tests/Dashboard.test.tsx @@ -2,7 +2,10 @@ import React from "react"; import { render, fireEvent } from "@testing-library/react"; import { useOidc, useOidcAccessToken } from "@axa-fr/react-oidc"; import Dashboard from "@/components/layout/Dashboard"; +import DashboardDrawer from "@/components/ui/DashboardDrawer"; import { ThemeProvider } from "@/contexts/ThemeProvider"; +import { ApplicationsContext } from "@/contexts/ApplicationsProvider"; +import { UserSection } from "@/types/UserSection"; // Mock the module jest.mock("@axa-fr/react-oidc", () => ({ @@ -10,6 +13,55 @@ jest.mock("@axa-fr/react-oidc", () => ({ useOidc: jest.fn(), })); +jest.mock("jsoncrush", () => ({ + crush: jest.fn().mockImplementation((data) => `crushed-${data}`), + uncrush: jest.fn().mockImplementation((data) => data.replace("crushed-", "")), +})); + +let mockSections: UserSection[] = [ + { + title: "Group 1", + extended: true, + items: [ + { + title: "App 1", + id: "app1", + type: "Dashboard", + icon: () =>
App 1 Icon
, + }, + ], + }, +]; + +const params = new URLSearchParams(); + +jest.mock("next/navigation", () => { + return { + usePathname: () => ({ + pathname: "", + }), + useRouter: () => ({ + push: jest.fn(), + }), + useSearchParams: () => params, + }; +}); + +const MockApplicationProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}): JSX.Element => ( + { + mockSections = test(); + }), + ]} + > + {children} + +); + describe("", () => { beforeEach(() => { // Mock the return value for each test @@ -68,3 +120,31 @@ describe("", () => { expect(getByTestId("drawer-temporary")).toBeVisible(); }); }); + +describe("", () => { + it("renders correctly", () => { + const { getByText } = render( + + + + + , + ); + + expect(getByText("App 1 Icon")).toBeInTheDocument(); + }); + + it("handles context menu", () => { + const { getByText, getByTestId } = render( + + + + + , + ); + + fireEvent.contextMenu(getByText("App 1")); + + expect(getByTestId("context-menu")).toBeInTheDocument(); + }); +}); diff --git a/test/unit-tests/DiracLogo.test.tsx b/test/unit-tests/DiracLogo.test.tsx deleted file mode 100644 index 9f2a9985..00000000 --- a/test/unit-tests/DiracLogo.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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/JobDataTable.test.tsx b/test/unit-tests/JobDataTable.test.tsx index 815875a6..cdef924d 100644 --- a/test/unit-tests/JobDataTable.test.tsx +++ b/test/unit-tests/JobDataTable.test.tsx @@ -9,6 +9,8 @@ jest.mock("@axa-fr/react-oidc", () => ({ useOidcAccessToken: jest.fn(), })); +const params = new URLSearchParams(); + jest.mock("next/navigation", () => { return { usePathname: () => ({ @@ -17,15 +19,18 @@ jest.mock("next/navigation", () => { useRouter: () => ({ push: jest.fn(), }), - useSearchParams: () => ({ - has: () => false, - getAll: () => [], - }), + useSearchParams: () => params, }; }); jest.mock("swr", () => jest.fn()); +// In your test file or a Jest setup file +jest.mock("jsoncrush", () => ({ + crush: jest.fn().mockImplementation((data) => `crushed-${data}`), + uncrush: jest.fn().mockImplementation((data) => data.replace("crushed-", "")), +})); + describe("", () => { it("displays loading state", () => { (useSWR as jest.Mock).mockReturnValue({ data: null, error: null }); diff --git a/test/unit-tests/LoginForm.test.tsx b/test/unit-tests/LoginForm.test.tsx index 26a69ff0..3e255844 100644 --- a/test/unit-tests/LoginForm.test.tsx +++ b/test/unit-tests/LoginForm.test.tsx @@ -73,11 +73,19 @@ jest.mock("@axa-fr/react-oidc", () => ({ }), })); -jest.mock("next/navigation", () => ({ - useRouter: () => ({ - push: jest.fn(), - }), -})); +const params = new URLSearchParams(); + +jest.mock("next/navigation", () => { + return { + usePathname: () => ({ + pathname: "", + }), + useRouter: () => ({ + push: jest.fn(), + }), + useSearchParams: () => params, + }; +}); describe("LoginForm", () => { // Should render a text field to select the VO