From f3b1c89f270a5d8158f9f11762b3c623d84793ef Mon Sep 17 00:00:00 2001 From: Loris Van Katwijk Date: Thu, 25 Apr 2024 16:39:00 +0200 Subject: [PATCH] feat: Store filters in dashboard + auth redirection + scrollbar style --- src/app/(dashboard)/layout.tsx | 29 +++++----- src/app/(dashboard)/page.tsx | 18 ++++++- src/app/layout.tsx | 6 +-- .../applications/ApplicationList.ts | 4 +- src/components/applications/LoginForm.tsx | 13 ++++- src/components/layout/OIDCSecure.tsx | 5 +- src/components/ui/ApplicationDialog.tsx | 10 +--- src/components/ui/DashboardDrawer.tsx | 9 +--- src/components/ui/DataTable.tsx | 53 +++++++++++++++++-- src/components/ui/DrawerItem.tsx | 47 ++++++++++++---- src/components/ui/DrawerItemGroup.tsx | 5 +- src/contexts/ApplicationsProvider.tsx | 13 +++-- src/hooks/theme.tsx | 23 ++++++++ src/types/UserSection.ts | 2 +- test/unit-tests/Dashboard.test.tsx | 6 +++ test/unit-tests/JobDataTable.test.tsx | 13 +++-- test/unit-tests/LoginForm.test.tsx | 18 +++++-- 17 files changed, 201 insertions(+), 73 deletions(-) diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index c3252673..16520780 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -6,6 +6,7 @@ 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 JobMonitorLayout({ children, @@ -17,19 +18,21 @@ export default function JobMonitorLayout({ return (
- - - - - {children} - - - + + + + + + {children} + + + +
); diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 6d5d7447..3967341a 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -1,5 +1,21 @@ +"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() { - return ; + const searchParams = useSearchParams(); + const appId = searchParams.get("appId"); + const [sections] = React.useContext(ApplicationsContext); + + const appType = sections + .find((section) => section.items.some((item) => item.id === appId)) + ?.items.find((item) => item.id === appId)?.type; + + const Component = applicationList.find((app) => app.name === appType) + ?.component; + + return Component ? : ; } 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/components/applications/ApplicationList.ts b/src/components/applications/ApplicationList.ts index e2b06441..32d26fe5 100644 --- a/src/components/applications/ApplicationList.ts +++ b/src/components/applications/ApplicationList.ts @@ -3,16 +3,14 @@ import JobMonitor from "./JobMonitor"; import UserDashboard from "./UserDashboard"; export const applicationList = [ - { name: "Dashboard", path: "/", component: UserDashboard, icon: Dashboard }, + { name: "Dashboard", component: UserDashboard, icon: Dashboard }, { name: "Job Monitor", - path: "/jobmonitor", component: JobMonitor, icon: Monitor, }, { name: "File Catalog", - path: "/filecatalog", component: JobMonitor, icon: FolderCopy, }, 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/layout/OIDCSecure.tsx b/src/components/layout/OIDCSecure.tsx index 6e757b87..515ebd5c 100644 --- a/src/components/layout/OIDCSecure.tsx +++ b/src/components/layout/OIDCSecure.tsx @@ -21,7 +21,10 @@ export function OIDCSecure({ children }: OIDCProps) { useEffect(() => { // Redirect to login page if not authenticated if (!isAuthenticated) { - router.push("/auth"); + router.push( + "/auth?" + + new URLSearchParams({ redirect: window.location.href }).toString(), + ); } }, [isAuthenticated, router]); diff --git a/src/components/ui/ApplicationDialog.tsx b/src/components/ui/ApplicationDialog.tsx index 2314866f..0d15dddc 100644 --- a/src/components/ui/ApplicationDialog.tsx +++ b/src/components/ui/ApplicationDialog.tsx @@ -27,7 +27,7 @@ export default function AppDialog({ }: { appDialogOpen: boolean; setAppDialogOpen: React.Dispatch>; - handleCreateApp: (name: string, path: string, icon: ComponentType) => void; + handleCreateApp: (name: string, icon: ComponentType) => void; }) { const [appType, setAppType] = React.useState(""); return ( @@ -40,12 +40,6 @@ export default function AppDialog({ onSubmit: (event: React.FormEvent) => { event.preventDefault(); - const path = applicationList.find((app) => app.name === appType) - ?.path; - if (!path) { - console.error("Path not found for application type", appType); - return; - } const icon = applicationList.find((app) => app.name === appType) ?.icon; if (!icon) { @@ -53,7 +47,7 @@ export default function AppDialog({ return; } - handleCreateApp(appType, path, icon as React.ComponentType); + handleCreateApp(appType, icon as React.ComponentType); setAppDialogOpen(false); }, diff --git a/src/components/ui/DashboardDrawer.tsx b/src/components/ui/DashboardDrawer.tsx index 1b0bbeb5..41221a04 100644 --- a/src/components/ui/DashboardDrawer.tsx +++ b/src/components/ui/DashboardDrawer.tsx @@ -199,11 +199,7 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { * @param path - The path of the app. * @param icon - The icon component for the app. */ - const handleAppCreation = ( - appType: string, - path: string, - icon: ComponentType, - ) => { + const handleAppCreation = (appType: string, icon: ComponentType) => { let group = userSections[userSections.length - 1]; const empty = !group; if (empty) { @@ -217,7 +213,7 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { let title = `${appType} ${userSections.reduce( (sum, group) => - sum + group.items.filter((item) => item.icon === icon).length, + sum + group.items.filter((item) => item.type === appType).length, 1, )}`; while (group.items.some((item) => item.title === title)) { @@ -232,7 +228,6 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { )}`, type: appType, icon: icon, - path: path, }; group.items.push(newApp); if (empty) { diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx index 22e213eb..3bfb6560 100644 --- a/src/components/ui/DataTable.tsx +++ b/src/components/ui/DataTable.tsx @@ -26,7 +26,7 @@ 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"; @@ -346,7 +346,8 @@ export function DataTable(props: DataTableProps) { }>({ mouseX: null, mouseY: null, id: null }); // NextJS router and params const searchParams = useSearchParams(); - const { setParam } = useSearchParamsUtils(); + const { getParam, setParam } = useSearchParamsUtils(); + const appId = getParam("appId"); const updateFiltersAndUrl = React.useCallback( (newFilters: Filter[]) => { @@ -364,8 +365,28 @@ export function DataTable(props: DataTableProps) { const [sections, setSections] = React.useContext(ApplicationsContext); const updateSectionFilters = React.useCallback( - /* TODO */ (newFilters: Filter[]) => {}, - [], + (newFilters: Filter[]) => { + const appId = getParam("appId"); + + 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)), + ); + } + }, + [getParam, sections, setSections], ); // Handle the application of filters @@ -394,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(); @@ -406,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/DrawerItem.tsx b/src/components/ui/DrawerItem.tsx index ac70b15a..b2fff815 100644 --- a/src/components/ui/DrawerItem.tsx +++ b/src/components/ui/DrawerItem.tsx @@ -21,22 +21,23 @@ import { } 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 { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source"; import { ThemeProvider } from "@/contexts/ThemeProvider"; import { useMUITheme } from "@/hooks/theme"; +import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; export default function DrawerItem({ - item: { title, icon, path }, + item: { title, id, icon }, index, groupTitle, }: { - item: { title: string; icon: React.ComponentType; path: string }; + item: { title: string; id: string; icon: React.ComponentType }; index: number; groupTitle: string; }) { const dragRef = React.useRef(null); const handleRef = React.useRef(null); const theme = useMUITheme(); + const { setParam } = useSearchParamsUtils(); const [closestEdge, setClosestEdge]: any = useState(null); @@ -63,11 +64,7 @@ export default function DrawerItem({ width: source.element.getBoundingClientRect().width, }} > - + , @@ -125,15 +122,14 @@ export default function DrawerItem({ }, }), ); - }, [index, groupTitle, icon, path, theme, title]); + }, [index, groupTitle, icon, theme, title, id]); return ( <> setParam("appId", id)} sx={{ pl: 2, borderRadius: 2, pr: 1 }} ref={dragRef} > @@ -153,3 +149,32 @@ export default function DrawerItem({ ); } + +function ItemPreview({ + title, + icon, +}: { + title: string; + icon: React.ComponentType; +}) { + return ( + + + + + + + + + + ); +} diff --git a/src/components/ui/DrawerItemGroup.tsx b/src/components/ui/DrawerItemGroup.tsx index 126145a8..717caf83 100644 --- a/src/components/ui/DrawerItemGroup.tsx +++ b/src/components/ui/DrawerItemGroup.tsx @@ -24,7 +24,6 @@ export default function DrawerItemGroup({ title: string; id: string; icon: React.ComponentType; - path: string; }[]; }; setSections: React.Dispatch>; @@ -81,10 +80,10 @@ export default function DrawerItemGroup({ {/* Accordion details */} - {items.map(({ title, id, icon, path }, index) => ( + {items.map(({ title, id, icon }, index) => (
diff --git a/src/contexts/ApplicationsProvider.tsx b/src/contexts/ApplicationsProvider.tsx index 3860992f..9f9cc45b 100644 --- a/src/contexts/ApplicationsProvider.tsx +++ b/src/contexts/ApplicationsProvider.tsx @@ -1,9 +1,11 @@ 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< @@ -18,6 +20,9 @@ const ApplicationsProvider: React.FC<{ children: React.ReactNode }> = ({ const { getParam, setParam } = useSearchParamsUtils(); + const { configuration } = useOIDCContext(); + const { isAuthenticated } = useOidc(configuration?.scope); + useEffect(() => { // get user sections from searchParams const sectionsParams = getParam("sections"); @@ -52,14 +57,12 @@ const ApplicationsProvider: React.FC<{ children: React.ReactNode }> = ({ type: "Dashboard", id: "Dashboard0", icon: Dashboard, - path: "/", }, { title: "Job Monitor", type: "Job Monitor", id: "JobMonitor0", icon: Monitor, - path: "/jobmonitor", }, ], }, @@ -72,7 +75,6 @@ const ApplicationsProvider: React.FC<{ children: React.ReactNode }> = ({ type: "File Catalog", id: "FileCatatlog0", icon: FolderCopy, - path: "/filecatalog", }, ], }, @@ -81,6 +83,9 @@ const ApplicationsProvider: React.FC<{ children: React.ReactNode }> = ({ }, [getParam]); useEffect(() => { + if (!isAuthenticated) { + return; + } // save user sections to searchParams (but not icons) const newSections = userSections.map((section) => { return { @@ -94,7 +99,7 @@ const ApplicationsProvider: React.FC<{ children: React.ReactNode }> = ({ }; }); setParam("sections", JSONCrush.crush(JSON.stringify(newSections))); - }, [setParam, userSections]); + }, [isAuthenticated, setParam, userSections]); return ( diff --git a/src/hooks/theme.tsx b/src/hooks/theme.tsx index 10bd83ef..049d9bd1 100644 --- a/src/hooks/theme.tsx +++ b/src/hooks/theme.tsx @@ -37,6 +37,29 @@ export const useMUITheme = () => { }); muiTheme.components = { + MuiCssBaseline: { + styleOverrides: ` + ::-webkit-scrollbar { + width: 10px; + border-radius: 5px; + } + ::-webkit-scrollbar-track { + background: ${theme === "dark" ? "#333" : "#f1f1f1"}; + } + ::-webkit-scrollbar-thumb { + background: ${theme === "dark" ? "#888" : "#ccc"}; + border-radius: 5px; + } + ::-webkit-scrollbar-thumb:hover { + background: ${theme === "dark" ? "#555" : "#999"}; + } + @supports not selector(::-webkit-scrollbar) { + html { + scrollbar-color: ${theme === "dark" ? "#888 #333" : "#ccc #f1f1f1"}; + } + } + `, + }, MuiButton: { styleOverrides: { contained: { diff --git a/src/types/UserSection.ts b/src/types/UserSection.ts index 45334321..a5ac13be 100644 --- a/src/types/UserSection.ts +++ b/src/types/UserSection.ts @@ -7,6 +7,6 @@ export type UserSection = { type: string; id: string; icon: React.ComponentType; - path: string; + data?: any; }[]; }; diff --git a/test/unit-tests/Dashboard.test.tsx b/test/unit-tests/Dashboard.test.tsx index 39b4686c..22991083 100644 --- a/test/unit-tests/Dashboard.test.tsx +++ b/test/unit-tests/Dashboard.test.tsx @@ -10,6 +10,12 @@ jest.mock("@axa-fr/react-oidc", () => ({ useOidc: 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("", () => { beforeEach(() => { // Mock the return value for each test 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