From 594b21ab92f3f859789c583b4442f9ce4c2bd325 Mon Sep 17 00:00:00 2001 From: aldbr Date: Thu, 17 Oct 2024 12:59:07 +0200 Subject: [PATCH] feat(DataTable): resize, hide/show columns and more features... --- .gitignore | 5 +- package-lock.json | 40 +- .../components/BaseApp/BaseApp.stories.tsx | 19 +- .../components/BaseApp/BaseApp.tsx | 9 +- .../ApplicationDialog.stories.tsx | 6 +- .../components/DashboardLayout/Dashboard.tsx | 159 ++-- .../DashboardDrawer.stories.tsx | 8 +- .../DashboardLayout/DashboardDrawer.tsx | 27 +- .../DashboardLayout/DrawerItem.stories.tsx | 8 +- .../components/DashboardLayout/DrawerItem.tsx | 93 ++- .../DrawerItemGroup.stories.tsx | 8 +- .../DashboardLayout/DrawerItemGroup.tsx | 64 +- .../DashboardLayout/ProfileButton.stories.tsx | 8 +- .../DashboardLayout/ProfileButton.tsx | 6 +- .../ThemeToggleButton.stories.tsx | 8 +- .../JobMonitor/JobDataTable.stories.tsx | 8 +- .../components/JobMonitor/JobDataTable.tsx | 468 ++++++++---- .../JobMonitor/JobHistoryDialog.stories.tsx | 7 +- .../JobMonitor/JobHistoryDialog.tsx | 138 +++- .../JobMonitor/JobMonitor.stories.tsx | 10 +- .../components/JobMonitor/JobMonitor.tsx | 22 +- .../components/Login/LoginForm.stories.tsx | 7 +- .../components/Login/LoginForm.tsx | 189 +++-- .../shared/ApplicationHeader.stories.tsx | 75 -- .../components/shared/ApplicationHeader.tsx | 48 -- .../components/shared/DataTable.stories.tsx | 150 ++-- .../components/shared/DataTable.tsx | 706 +++++++++--------- .../components/shared/FilterForm.stories.tsx | 74 +- .../components/shared/FilterForm.tsx | 150 ++-- .../shared/FilterToolbar.stories.tsx | 84 ++- .../components/shared/FilterToolbar.tsx | 65 +- .../components/shared/index.ts | 1 - .../contexts/DiracXWebProviders.tsx | 32 +- .../contexts/ThemeProvider.tsx | 265 ++++++- packages/diracx-web-components/global.d.ts | 12 + .../hooks/application.tsx | 22 + .../diracx-web-components/hooks/theme.tsx | 204 ----- .../lint-staged.config.js | 5 + packages/diracx-web-components/package.json | 9 +- .../test/unit-tests/Dashboard.test.tsx | 86 +-- .../test/unit-tests/DashboardDrawer.test.tsx | 80 ++ .../test/unit-tests/FilterForm.test.tsx | 194 ++--- .../test/unit-tests/FilterToolbar.test.tsx | 127 ++-- .../test/unit-tests/JobDataTable.test.tsx | 4 +- .../test/unit-tests/JobHistoryDialog.test.tsx | 6 +- .../unit-tests/ThemeToggleButton.test.tsx | 53 +- packages/diracx-web-components/tsconfig.json | 2 +- packages/diracx-web-components/tsup.config.ts | 6 +- .../diracx-web-components/types/Column.ts | 13 - packages/diracx-web-components/types/Job.ts | 7 +- packages/diracx-web-components/types/index.ts | 1 - .../diracx-web/src/app/(dashboard)/layout.tsx | 8 +- packages/diracx-web/test/e2e/dashboard.cy.ts | 48 +- packages/diracx-web/test/e2e/jobMonitor.cy.ts | 372 ++++++++- .../extensions/src/app/(dashboard)/layout.tsx | 8 +- .../gubbins/components/TestApp/testApp.tsx | 2 - 56 files changed, 2517 insertions(+), 1719 deletions(-) delete mode 100644 packages/diracx-web-components/components/shared/ApplicationHeader.stories.tsx delete mode 100644 packages/diracx-web-components/components/shared/ApplicationHeader.tsx create mode 100644 packages/diracx-web-components/global.d.ts create mode 100644 packages/diracx-web-components/lint-staged.config.js create mode 100644 packages/diracx-web-components/test/unit-tests/DashboardDrawer.test.tsx delete mode 100644 packages/diracx-web-components/types/Column.ts diff --git a/.gitignore b/.gitignore index cdf90783..b69d2af1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,7 @@ yarn-error.log* next-env.d.ts # eslint -.eslintcache \ No newline at end of file +.eslintcache + +# storybook +**/.cache diff --git a/package-lock.json b/package-lock.json index eacea84a..e1de4292 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9026,6 +9026,37 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tanstack/react-table": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", + "integrity": "sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==", + "dependencies": { + "@tanstack/table-core": "8.20.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.20.5", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.20.5.tgz", + "integrity": "sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.2.0.tgz", @@ -24497,10 +24528,9 @@ } }, "node_modules/react-virtuoso": { - "version": "4.7.11", - "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.7.11.tgz", - "integrity": "sha512-Kdn9qEtQI2ulEuBMzW2BTkDsfijB05QUd6lpZ1K36oyA3k65Cz4lG4EKrh2pCfUafX4C2uMSZOwzMOhbrMOTFA==", - "license": "MIT", + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.12.0.tgz", + "integrity": "sha512-oHrKlU7xHsrnBQ89ecZoMPAK0tHnI9s1hsFW3KKg5ZGeZ5SWvbGhg/QFJFY4XETAzoCUeu+Xaxn1OUb/PGtPlA==", "engines": { "node": ">=10" }, @@ -29560,6 +29590,7 @@ "@mui/material": "^5.15.18", "@mui/utils": "^5.15.14", "@mui/x-date-pickers": "^7.14.0", + "@tanstack/react-table": "^8.20.5", "@types/node": "20.11.30", "@types/react": "18.3.3", "@types/react-dom": "18.3.0", @@ -29603,7 +29634,6 @@ "react-test-renderer": "^18.3.1", "storybook": "^8.2.9", "ts-jest": "^29.1.2", - "tsc-files": "^1.1.4", "tsup": "^8.0.2", "typescript": "^5.4.5" } diff --git a/packages/diracx-web-components/components/BaseApp/BaseApp.stories.tsx b/packages/diracx-web-components/components/BaseApp/BaseApp.stories.tsx index ff68e576..62582a64 100644 --- a/packages/diracx-web-components/components/BaseApp/BaseApp.stories.tsx +++ b/packages/diracx-web-components/components/BaseApp/BaseApp.stories.tsx @@ -1,12 +1,11 @@ import React from "react"; import { StoryObj, Meta } from "@storybook/react"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; import { Paper } from "@mui/material"; import { Apps } from "@mui/icons-material"; import { useOidcAccessToken } from "../../mocks/react-oidc.mock"; -import { useMUITheme } from "../../hooks/theme"; import { ApplicationsContext } from "../../contexts/ApplicationsProvider"; import { NavigationProvider } from "../../contexts/NavigationProvider"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import BaseApp from "./BaseApp"; const meta = { @@ -16,18 +15,14 @@ const meta = { layout: "centered", }, tags: ["autodocs"], - argTypes: { - headerSize: { control: "radio" }, - }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, (Story) => ( @@ -76,9 +71,7 @@ export default meta; type Story = StoryObj; export const LoggedIn: Story = { - args: { - headerSize: undefined, - }, + args: {}, render: (props) => { useOidcAccessToken.mockReturnValue({ accessTokenPayload: { preferred_username: "John Doe" }, @@ -88,9 +81,7 @@ export const LoggedIn: Story = { }; export const LoggedOff: Story = { - args: { - headerSize: undefined, - }, + args: {}, render: (props) => { useOidcAccessToken.mockReturnValue({ accessTokenPayload: null, diff --git a/packages/diracx-web-components/components/BaseApp/BaseApp.tsx b/packages/diracx-web-components/components/BaseApp/BaseApp.tsx index 49c7a8f0..ae8ef5c0 100644 --- a/packages/diracx-web-components/components/BaseApp/BaseApp.tsx +++ b/packages/diracx-web-components/components/BaseApp/BaseApp.tsx @@ -1,19 +1,13 @@ "use client"; import { useOidcAccessToken } from "@axa-fr/react-oidc/"; import { useOIDCContext } from "@/hooks/oidcConfiguration"; -import ApplicationHeader from "@/components/shared/ApplicationHeader"; /** * Build the User Dashboard page * * @returns User Dashboard content */ -export default function BaseApplication({ - headerSize, -}: { - /** The size of the header, optional, will default to h4 */ - headerSize?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -}) { +export default function BaseApplication() { const { configuration } = useOIDCContext(); const { accessTokenPayload } = useOidcAccessToken(configuration?.scope); @@ -23,7 +17,6 @@ export default function BaseApplication({ return (
-

Hello {accessTokenPayload["preferred_username"]}

To start with, select an application in the side bar

diff --git a/packages/diracx-web-components/components/DashboardLayout/ApplicationDialog.stories.tsx b/packages/diracx-web-components/components/DashboardLayout/ApplicationDialog.stories.tsx index f1a6320e..9931a9b6 100644 --- a/packages/diracx-web-components/components/DashboardLayout/ApplicationDialog.stories.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/ApplicationDialog.stories.tsx @@ -1,13 +1,12 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react"; -import { ThemeProvider } from "@mui/material"; import { useArgs } from "@storybook/core/preview-api"; import { ApplicationsContext } from "../../contexts/ApplicationsProvider"; import { useOidcAccessToken } from "../../mocks/react-oidc.mock"; import { applicationList } from "../ApplicationList"; -import { useMUITheme } from "../../hooks/theme"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import ApplicationDialog from "./ApplicationDialog"; const meta = { @@ -31,9 +30,8 @@ const meta = { ); }, (Story) => { - const theme = useMUITheme(); return ( - + ); diff --git a/packages/diracx-web-components/components/DashboardLayout/Dashboard.tsx b/packages/diracx-web-components/components/DashboardLayout/Dashboard.tsx index 3949dfba..6b561739 100644 --- a/packages/diracx-web-components/components/DashboardLayout/Dashboard.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/Dashboard.tsx @@ -2,16 +2,15 @@ import React from "react"; import AppBar from "@mui/material/AppBar"; import Box from "@mui/material/Box"; -import CssBaseline from "@mui/material/CssBaseline"; import IconButton from "@mui/material/IconButton"; import { Menu } from "@mui/icons-material"; import Toolbar from "@mui/material/Toolbar"; import Stack from "@mui/material/Stack"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; +import { Typography, useMediaQuery, useTheme } from "@mui/material"; import { ProfileButton } from "./ProfileButton"; import { ThemeToggleButton } from "./ThemeToggleButton"; import DashboardDrawer from "./DashboardDrawer"; -import { useMUITheme } from "@/hooks/theme"; +import { useApplicationTitle, useApplicationType } from "@/hooks/application"; interface DashboardProps { /** The content to be displayed in the main area */ @@ -30,85 +29,123 @@ interface DashboardProps { * @return an dashboard layout */ export default function Dashboard(props: DashboardProps) { - const theme = useMUITheme(); + const { children, drawerWidth = 240, logoURL } = props; + + const appTitle = useApplicationTitle(); + const appType = useApplicationType(); + + /** Theme and media query */ + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + /** State management for mobile drawer */ const [mobileOpen, setMobileOpen] = React.useState(false); const handleDrawerToggle = () => { setMobileOpen(!mobileOpen); }; - /** Drawer width */ - const drawerWidth = props.drawerWidth || 240; - return ( - - - - - + + + + {isMobile && ( - + -
- - - - - + {appTitle} + + + {appType} + - - - {/* 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} - - + + + + + + + + + + + + + + + {children} + ); } diff --git a/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.stories.tsx b/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.stories.tsx index 7d1f633d..f9f02092 100644 --- a/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.stories.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.stories.tsx @@ -3,12 +3,11 @@ import type { Meta, StoryObj } from "@storybook/react"; import { Box } from "@mui/material"; import { Dashboard } from "@mui/icons-material"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { useMUITheme } from "../../hooks/theme"; import { useOidc, useOidcAccessToken } from "../../mocks/react-oidc.mock"; import { ApplicationsContext } from "../../contexts/ApplicationsProvider"; import { applicationList } from "../ApplicationList"; import { DashboardGroup } from "../../types"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import DashboardDrawer from "./DashboardDrawer"; const meta = { @@ -20,7 +19,6 @@ const meta = { tags: ["autodocs"], decorators: [ (Story) => { - const theme = useMUITheme(); const [userDashboard, setUserDashboard] = React.useState< DashboardGroup[] >([ @@ -41,11 +39,11 @@ const meta = { - + - + ); }, diff --git a/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.tsx b/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.tsx index 6edcb0e0..d49b86b1 100644 --- a/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/DashboardDrawer.tsx @@ -12,6 +12,7 @@ import { Popover, TextField, Toolbar, + useTheme, } from "@mui/material"; import { MenuBook, Add, SvgIconComponent } from "@mui/icons-material"; import React, { @@ -25,7 +26,6 @@ import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/clo import DrawerItemGroup from "./DrawerItemGroup"; import AppDialog from "./ApplicationDialog"; import { ApplicationsContext } from "@/contexts/ApplicationsProvider"; -import { useMUITheme } from "@/hooks/theme"; import { DashboardGroup } from "@/types"; interface DashboardDrawerProps { @@ -70,6 +70,8 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { const [popAnchorEl, setPopAnchorEl] = React.useState( null, ); + const [renamingItemId, setRenamingItemId] = useState(null); + const [renamingGroupId, setRenamingGroupId] = useState(null); const [renameValue, setRenameValue] = React.useState(""); // Define the applications that are accessible to users. @@ -78,7 +80,7 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { const logoURL = props.logoURL || "/DIRAC-logo.png"; - const theme = useMUITheme(); + const theme = useTheme(); useEffect(() => { // Handle changes to app instances when drag and drop occurs. @@ -306,8 +308,14 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { handleCloseContextMenu(); }; - const handleRenameClick = (event: React.MouseEvent) => { - setPopAnchorEl(event.currentTarget); + const handleRenameClick = () => { + if (contextState.type === "group") { + setRenamingGroupId(contextState.id); + } else if (contextState.type === "item") { + setRenamingItemId(contextState.id); + } + setRenameValue(""); + handleCloseContextMenu(); }; const popClose = () => { @@ -394,6 +402,12 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { group={group} setUserDashboard={setUserDashboard} handleContextMenu={handleContextMenu} + renamingGroupId={renamingGroupId} + setRenamingGroupId={setRenamingGroupId} + renamingItemId={renamingItemId} + setRenamingItemId={setRenamingItemId} + renameValue={renameValue} + setRenameValue={setRenameValue} /> ))} @@ -443,8 +457,9 @@ export default function DashboardDrawer(props: DashboardDrawerProps) { {contextState.type && ( Delete )} - - New Group + {contextState.type === null && ( + New Group + )}
{ - const theme = useMUITheme(); return ( - + - +
); }, ], diff --git a/packages/diracx-web-components/components/DashboardLayout/DrawerItem.tsx b/packages/diracx-web-components/components/DashboardLayout/DrawerItem.tsx index 994d4aa2..4782a9e3 100644 --- a/packages/diracx-web-components/components/DashboardLayout/DrawerItem.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/DrawerItem.tsx @@ -5,6 +5,8 @@ import { ListItemIcon, Icon, ListItemText, + useTheme, + TextField, } from "@mui/material"; import { DragIndicator } from "@mui/icons-material"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; @@ -19,11 +21,10 @@ import { 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"; import { useApplicationId } from "@/hooks/application"; +import { DashboardGroup } from "@/types"; /** * Represents a drawer item component. @@ -34,6 +35,11 @@ export default function DrawerItem({ item: { title, id, icon }, index, groupTitle, + renamingItemId, + setRenamingItemId, + renameValue, + setRenameValue, + setUserDashboard, }: { /** The item object containing the title, id, and icon. */ item: { title: string; id: string; icon: React.ComponentType }; @@ -41,12 +47,22 @@ export default function DrawerItem({ index: number; /** The title of the group. */ groupTitle: string; + /** The ID of the item being renamed. */ + renamingItemId: string | null; + /** The function to set the renaming item ID. */ + setRenamingItemId: React.Dispatch>; + /** The value of the rename input. */ + renameValue: string; + /** The function to set the rename input value. */ + setRenameValue: React.Dispatch>; + /** The function to set the user dashboard state. */ + setUserDashboard: React.Dispatch>; }) { // 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 theme = useTheme(); const { setParam } = useSearchParamsUtils(); // Represents the closest edge to the mouse cursor const [closestEdge, setClosestEdge] = useState(null); @@ -75,15 +91,13 @@ export default function DrawerItem({ // 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(); @@ -143,6 +157,26 @@ export default function DrawerItem({ ); }, [index, groupTitle, icon, theme, title, id]); + // Handle renaming of the item + const handleItemRename = () => { + if (renameValue.trim() === "") return; + setUserDashboard((groups) => + groups.map((group) => { + if (group.title === groupTitle) { + return { + ...group, + items: group.items.map((item) => + item.id === id ? { ...item, title: renameValue } : item, + ), + }; + } + return group; + }), + ); + setRenamingItemId(null); + setRenameValue(""); + }; + return ( <> - + {renamingItemId === id ? ( + setRenameValue(e.target.value)} + onBlur={handleItemRename} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleItemRename(); + } else if (e.key === "Escape") { + setRenamingItemId(null); + } + }} + autoFocus + size="small" + /> + ) : ( + + )} - + diff --git a/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.stories.tsx b/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.stories.tsx index 798d2fbf..112fe598 100644 --- a/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.stories.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.stories.tsx @@ -2,11 +2,10 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { useArgs } from "@storybook/core/preview-api"; import { Paper } from "@mui/material"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; import { Dashboard } from "@mui/icons-material"; -import { useMUITheme } from "../../hooks/theme"; import { useOidc, useOidcAccessToken } from "../../mocks/react-oidc.mock"; import { DashboardGroup } from "../../types/DashboardGroup"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import DrawerItemGroup from "./DrawerItemGroup"; const meta = { @@ -18,13 +17,12 @@ const meta = { tags: ["autodocs"], decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, ], diff --git a/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.tsx b/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.tsx index a2b07184..a918e3c8 100644 --- a/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/DrawerItemGroup.tsx @@ -1,5 +1,10 @@ "use client"; -import { Accordion, AccordionDetails, AccordionSummary } from "@mui/material"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + TextField, +} from "@mui/material"; import { ExpandMore, Apps } from "@mui/icons-material"; import React, { useEffect } from "react"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; @@ -17,6 +22,12 @@ export default function DrawerItemGroup({ group: { title, extended: expanded, items }, setUserDashboard, handleContextMenu, + renamingGroupId, + setRenamingGroupId, + renamingItemId, + setRenamingItemId, + renameValue, + setRenameValue, }: { /** The group object containing the title, expanded state, and items. */ group: DashboardGroup; @@ -27,6 +38,18 @@ export default function DrawerItemGroup({ type: "group" | "item" | null, id: string | null, ) => (event: React.MouseEvent) => void; + /** The ID of the group being renamed. */ + renamingGroupId: string | null; + /** The function to set the renaming group ID. */ + setRenamingGroupId: React.Dispatch>; + /** The ID of the item being renamed. */ + renamingItemId: string | null; + /** The function to set the renaming item ID. */ + setRenamingItemId: React.Dispatch>; + /** The value of the rename input. */ + renameValue: string; + /** The function to set the rename input value. */ + setRenameValue: React.Dispatch>; }) { // Ref to use for the drag and drop target const dropRef = React.useRef(null); @@ -61,6 +84,19 @@ export default function DrawerItemGroup({ ), ); }; + + // Handle renaming of the group + const handleGroupRename = () => { + if (renameValue.trim() === "") return; + setUserDashboard((groups) => + groups.map((group) => + group.title === title ? { ...group, title: renameValue } : group, + ), + ); + setRenamingGroupId(null); + setRenameValue(""); + }; + return ( {/* Accordion summary */} - }>{title} + }> + {renamingGroupId === title ? ( + setRenameValue(e.target.value)} + onBlur={handleGroupRename} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleGroupRename(); + } else if (e.key === "Escape") { + setRenamingGroupId(null); + } + }} + autoFocus + size="small" + /> + ) : ( +
{title}
+ )} +
{/* Accordion details */} {items.map(({ title: itemTitle, id, icon }, index) => ( @@ -82,6 +137,11 @@ export default function DrawerItemGroup({ item={{ title: itemTitle, id, icon: icon || Apps }} index={index} groupTitle={title} + renamingItemId={renamingItemId} + setRenamingItemId={setRenamingItemId} + renameValue={renameValue} + setRenameValue={setRenameValue} + setUserDashboard={setUserDashboard} />
))} diff --git a/packages/diracx-web-components/components/DashboardLayout/ProfileButton.stories.tsx b/packages/diracx-web-components/components/DashboardLayout/ProfileButton.stories.tsx index 31f54922..8b919929 100644 --- a/packages/diracx-web-components/components/DashboardLayout/ProfileButton.stories.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/ProfileButton.stories.tsx @@ -2,9 +2,8 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { Paper } from "@mui/material"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { useMUITheme } from "../../hooks/theme"; import { useOidc, useOidcAccessToken } from "../../mocks/react-oidc.mock"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import { ProfileButton } from "./ProfileButton"; const meta = { @@ -16,13 +15,12 @@ const meta = { tags: ["autodocs"], decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, ], diff --git a/packages/diracx-web-components/components/DashboardLayout/ProfileButton.tsx b/packages/diracx-web-components/components/DashboardLayout/ProfileButton.tsx index f6b55313..154e3340 100644 --- a/packages/diracx-web-components/components/DashboardLayout/ProfileButton.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/ProfileButton.tsx @@ -25,8 +25,8 @@ import { Stack, Tooltip, Typography, + useTheme, } from "@mui/material"; -import { cyan } from "@mui/material/colors"; import React from "react"; import { useOIDCContext } from "@/hooks/oidcConfiguration"; @@ -35,6 +35,8 @@ import { useOIDCContext } from "@/hooks/oidcConfiguration"; * @returns a Button */ export function ProfileButton() { + const theme = useTheme(); + const { configuration, setConfiguration } = useOIDCContext(); const { accessTokenPayload } = useOidcAccessToken(configuration?.scope); const { logout, isAuthenticated } = useOidc(configuration?.scope); @@ -76,7 +78,7 @@ export function ProfileButton() { aria-haspopup="true" aria-expanded={open ? "true" : undefined} > - + {accessTokenPayload["preferred_username"][0]} diff --git a/packages/diracx-web-components/components/DashboardLayout/ThemeToggleButton.stories.tsx b/packages/diracx-web-components/components/DashboardLayout/ThemeToggleButton.stories.tsx index 74b59232..c49e98c9 100644 --- a/packages/diracx-web-components/components/DashboardLayout/ThemeToggleButton.stories.tsx +++ b/packages/diracx-web-components/components/DashboardLayout/ThemeToggleButton.stories.tsx @@ -2,8 +2,7 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react"; import { Paper } from "@mui/material"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { useMUITheme } from "../../hooks/theme"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import { ThemeToggleButton } from "./ThemeToggleButton"; const meta = { @@ -15,13 +14,12 @@ const meta = { tags: ["autodocs"], decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, ], diff --git a/packages/diracx-web-components/components/JobMonitor/JobDataTable.stories.tsx b/packages/diracx-web-components/components/JobMonitor/JobDataTable.stories.tsx index 870dc92d..b85e4466 100644 --- a/packages/diracx-web-components/components/JobMonitor/JobDataTable.stories.tsx +++ b/packages/diracx-web-components/components/JobMonitor/JobDataTable.stories.tsx @@ -1,9 +1,8 @@ import React from "react"; import { StoryObj, Meta } from "@storybook/react"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { useMUITheme } from "../../hooks/theme"; import { useJobs } from "../../mocks/JobDataService.mock"; import { useOidcAccessToken } from "../../mocks/react-oidc.mock"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import { JobDataTable } from "./JobDataTable"; const meta = { @@ -25,11 +24,10 @@ const meta = { }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, ], diff --git a/packages/diracx-web-components/components/JobMonitor/JobDataTable.tsx b/packages/diracx-web-components/components/JobMonitor/JobDataTable.tsx index 0e0ea3b8..9e80174c 100644 --- a/packages/diracx-web-components/components/JobMonitor/JobDataTable.tsx +++ b/packages/diracx-web-components/components/JobMonitor/JobDataTable.tsx @@ -18,14 +18,23 @@ import { AlertColor, IconButton, Tooltip, - useMediaQuery, - useTheme, Backdrop, CircularProgress, Snackbar, + lighten, + darken, + useTheme, } from "@mui/material"; import { useOidcAccessToken } from "@axa-fr/react-oidc"; import { Delete, Clear, Replay } from "@mui/icons-material"; +import { + createColumnHelper, + ColumnPinningState, + RowSelectionState, + useReactTable, + getCoreRowModel, + VisibilityState, +} from "@tanstack/react-table"; import { useOIDCContext } from "../../hooks/oidcConfiguration"; import { DataTable, MenuItem } from "../shared/DataTable"; import { JobHistoryDialog } from "./JobHistoryDialog"; @@ -37,129 +46,191 @@ import { rescheduleJobs, useJobs, } from "./JobDataService"; -import { Column } from "@/types/Column"; -import { InternalFilter } from "@/types/Filter"; import { JobHistory } from "@/types/JobHistory"; import { Job, SearchBody } from "@/types"; -const statusColors: { [key: string]: string } = { - Submitting: purple[500], - Received: blueGrey[500], - Checking: teal[500], - Staging: lightBlue[500], - Waiting: amber[600], - Matched: blue[300], - Running: blue[900], - Rescheduled: lime[700], - Completing: orange[500], - Completed: green[300], - Done: green[500], - Failed: red[500], - Stalled: amber[900], - Killed: red[900], - Deleted: grey[500], -}; - -/** - * Renders the status cell with colors - */ -const renderStatusCell = (status: unknown) => { - if (typeof status !== "string") { - return null; // or handle other types as needed - } - return ( - - {status} - - ); -}; - -/** - * The head cells for the data grid (desktop version) - */ -const headCells: Column[] = [ - { id: "JobID", label: "Job ID", type: "number" }, - { id: "JobName", label: "Job Name" }, - { id: "Site", label: "Site" }, - { - id: "Status", - label: "Status", - render: renderStatusCell, - type: Object.keys(statusColors).sort(), - }, - { - id: "MinorStatus", - label: "Minor Status", - }, - { - id: "SubmissionTime", - label: "Submission Time", - type: "DateTime", - }, -]; - -/** - * The head cells for the data grid (mobile version) - */ -const mobileHeadCells: Column[] = [ - { id: "JobID", label: "Job ID" }, - { id: "JobName", label: "Job Name" }, - { id: "Status", label: "Status", render: renderStatusCell }, -]; - -type Order = "asc" | "desc"; - /** * The data grid for the jobs */ export function JobDataTable() { const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("sm")); - const [selected, setSelected] = React.useState([]); + // Authentication const { configuration } = useOIDCContext(); const { accessToken } = useOidcAccessToken(configuration?.scope); + + // State for loading elements const [backdropOpen, setBackdropOpen] = React.useState(false); const [snackbarInfo, setSnackbarInfo] = React.useState({ open: false, message: "", severity: "success", }); - // State for sorting - const [order, setOrder] = React.useState("asc"); - const [orderBy, setOrderBy] = React.useState("JobID"); - // State for pagination - const [page, setPage] = React.useState(0); - const [rowsPerPage, setRowsPerPage] = React.useState(25); - // State for filters - const [filters, setFilters] = React.useState([]); + + // States for table settings + const [columnVisibility, setColumnVisibility] = + React.useState({ + JobGroup: false, + JobType: false, + Owner: false, + OwnerGroup: false, + VO: false, + StartExecTime: false, + EndExecTime: false, + UserPriority: false, + }); + const [columnPinning, setColumnPinning] = React.useState({ + left: ["JobID"], // Pin JobID column by default + }); + const [rowSelection, setRowSelection] = React.useState({}); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 25, + }); + // State for search body - const [searchBody, setSearchBody] = React.useState({}); + const [searchBody, setSearchBody] = React.useState({ + sort: [{ parameter: "JobID", direction: "asc" }], + }); + + // State for selected job + const [selectedJobId, setSelectedJobId] = React.useState(null); + // State for job history const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false); const [jobHistoryData, setJobHistoryData] = React.useState([]); + // Status colors + const statusColors: Record = React.useMemo( + () => ({ + Submitting: purple[500], + Received: blueGrey[500], + Checking: teal[500], + Staging: lightBlue[500], + Waiting: amber[600], + Matched: blue[300], + Running: blue[900], + Rescheduled: lime[700], + Completing: orange[500], + Completed: green[300], + Done: green[500], + Failed: red[500], + Stalled: amber[900], + Killed: red[900], + Deleted: grey[500], + }), + [], + ); + + /** + * Renders the status cell with colors + */ + const renderStatusCell = React.useCallback( + (status: string) => { + return ( + + {status} + + ); + }, + [theme, statusColors], + ); + + const columnHelper = React.useMemo(() => createColumnHelper(), []); + + /** + * The head cells for the data grid (desktop version) + */ + const columns = React.useMemo( + () => [ + columnHelper.accessor("JobID", { + header: "ID", + meta: { type: "number" }, + }), + columnHelper.accessor("Status", { + header: "Status", + cell: (info) => renderStatusCell(info.getValue()), + meta: { type: "category", values: Object.keys(statusColors).sort() }, + }), + columnHelper.accessor("MinorStatus", { + header: "Minor Status", + }), + columnHelper.accessor("ApplicationStatus", { + header: "Application Status", + }), + columnHelper.accessor("Site", { + header: "Site", + }), + columnHelper.accessor("JobName", { + header: "Name", + }), + columnHelper.accessor("JobGroup", { + header: "Job Group", + }), + columnHelper.accessor("JobType", { + header: "Type", + }), + columnHelper.accessor("LastUpdateTime", { + header: "Last Update Time", + meta: { type: "date" }, + }), + columnHelper.accessor("HeartBeatTime", { + header: "Last Sign of Life", + meta: { type: "date" }, + }), + columnHelper.accessor("SubmissionTime", { + header: "Submission Time", + meta: { type: "date" }, + }), + columnHelper.accessor("Owner", { + header: "Owner", + }), + columnHelper.accessor("OwnerGroup", { + header: "Owner Group", + }), + columnHelper.accessor("VO", { + header: "VO", + }), + columnHelper.accessor("StartExecTime", { + header: "Start Execution Time", + meta: { type: "date" }, + }), + columnHelper.accessor("EndExecTime", { + header: "End Execution Time", + meta: { type: "date" }, + }), + columnHelper.accessor("UserPriority", { + header: "User Priority", + meta: { type: "number" }, + }), + ], + [columnHelper, renderStatusCell, statusColors], + ); + /** * Fetches the jobs from the /api/jobs/search endpoint */ const { data, error, isLoading, isValidating } = useJobs( accessToken, searchBody, - page, - rowsPerPage, + pagination.pageIndex, + pagination.pageSize, ); const dataHeader = data?.headers; - const results = data?.data || []; + const results = React.useMemo(() => data?.data || [], [data?.data]); // Parse the headers to get the first item, last item and number of items const contentRange = dataHeader?.get("content-range"); @@ -174,18 +245,23 @@ export function JobDataTable() { totalJobs = results.length; } - const columns = isMobile ? mobileHeadCells : headCells; - const clearSelected = () => setSelected([]); + const clearSelected = () => setRowSelection({}); /** * Handle the deletion of the selected jobs */ - const handleDelete = async (selectedIds: readonly number[]) => { + const handleDelete = React.useCallback(async () => { setBackdropOpen(true); try { + const selectedIds = Object.keys(rowSelection).map(Number); await deleteJobs(selectedIds, accessToken); setBackdropOpen(false); - refreshJobs(accessToken, searchBody, page, rowsPerPage); + refreshJobs( + accessToken, + searchBody, + pagination.pageIndex, + pagination.pageSize, + ); clearSelected(); setSnackbarInfo({ open: true, @@ -206,17 +282,29 @@ export function JobDataTable() { } finally { setBackdropOpen(false); } - }; + }, [ + accessToken, + rowSelection, + searchBody, + pagination.pageIndex, + pagination.pageSize, + ]); /** * Handle the killing of the selected jobs */ - const handleKill = async (selectedIds: readonly number[]) => { + const handleKill = React.useCallback(async () => { setBackdropOpen(true); try { + const selectedIds = Object.keys(rowSelection).map(Number); await killJobs(selectedIds, accessToken); setBackdropOpen(false); - refreshJobs(accessToken, searchBody, page, rowsPerPage); + refreshJobs( + accessToken, + searchBody, + pagination.pageIndex, + pagination.pageSize, + ); clearSelected(); setSnackbarInfo({ open: true, @@ -237,17 +325,29 @@ export function JobDataTable() { } finally { setBackdropOpen(false); } - }; + }, [ + accessToken, + rowSelection, + searchBody, + pagination.pageIndex, + pagination.pageSize, + ]); /** * Handle the rescheduling of the selected jobs */ - const handleReschedule = async (selectedIds: readonly number[]) => { + const handleReschedule = React.useCallback(async () => { setBackdropOpen(true); try { + const selectedIds = Object.keys(rowSelection).map(Number); await rescheduleJobs(selectedIds, accessToken); setBackdropOpen(false); - refreshJobs(accessToken, searchBody, page, rowsPerPage); + refreshJobs( + accessToken, + searchBody, + pagination.pageIndex, + pagination.pageSize, + ); clearSelected(); setSnackbarInfo({ open: true, @@ -268,35 +368,45 @@ export function JobDataTable() { } finally { setBackdropOpen(false); } - }; + }, [ + accessToken, + rowSelection, + searchBody, + pagination.pageIndex, + pagination.pageSize, + ]); /** * Handle the history of the selected job */ - const handleHistory = async (selectedId: number | null) => { - if (!selectedId) return; - setBackdropOpen(true); - try { - const { data } = await getJobHistory(selectedId, accessToken); - setBackdropOpen(false); - // Show the history - setJobHistoryData(data[selectedId]); - setIsHistoryDialogOpen(true); - } catch (error: unknown) { - let errorMessage = "An unknown error occurred"; + const handleHistory = React.useCallback( + async (selectedId: number | null) => { + if (!selectedId) return; + setBackdropOpen(true); + setSelectedJobId(selectedId); + try { + const { data } = await getJobHistory(selectedId, accessToken); + setBackdropOpen(false); + // Show the history + setJobHistoryData(data[selectedId]); + setIsHistoryDialogOpen(true); + } catch (error: unknown) { + let errorMessage = "An unknown error occurred"; - if (error instanceof Error) { - errorMessage = error.message; + if (error instanceof Error) { + errorMessage = error.message; + } + setSnackbarInfo({ + open: true, + message: "Fetching history failed: " + errorMessage, + severity: "error", + }); + } finally { + setBackdropOpen(false); } - setSnackbarInfo({ - open: true, - message: "Fetching history failed: " + errorMessage, - severity: "error", - }); - } finally { - setBackdropOpen(false); - } - }; + }, + [accessToken], + ); const handleHistoryClose = () => { setIsHistoryDialogOpen(false); @@ -305,62 +415,87 @@ export function JobDataTable() { /** * The toolbar components for the data grid */ - const toolbarComponents = ( - <> - - handleReschedule(selected)}> - - - - - handleKill(selected)}> - - - - - handleDelete(selected)}> - - - - + const toolbarComponents = React.useMemo( + () => ( + <> + + handleReschedule()}> + + + + + handleKill()}> + + + + + handleDelete()}> + + + + + ), + [handleReschedule, handleKill, handleDelete], ); /** * The menu items */ - const menuItems: MenuItem[] = [ - { label: "Get history", onClick: (id: number | null) => handleHistory(id) }, - ]; + const menuItems: MenuItem[] = React.useMemo( + () => [ + { + label: "Get history", + onClick: (id: number | null) => handleHistory(id), + }, + ], + [handleHistory], + ); /** - * The main component + * Table instance */ + const table = useReactTable({ + data: results, + columns, + state: { + columnVisibility, + columnPinning, + rowSelection, + pagination, + }, + getRowId: (row) => row.JobID.toString(), + onColumnVisibilityChange: setColumnVisibility, + onColumnPinningChange: setColumnPinning, + onRowSelectionChange: setRowSelection, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + enablePinning: true, + enableColumnResizing: true, + columnResizeMode: "onChange", + manualPagination: true, + }); + /** + * The main component + */ return ( - + title="List of Jobs" - page={page} - setPage={setPage} - rowsPerPage={rowsPerPage} - setRowsPerPage={setRowsPerPage} - order={order} - setOrder={setOrder} - orderBy={orderBy} - setOrderBy={setOrderBy} + table={table} totalRows={totalJobs} - selected={selected} - setSelected={setSelected} - filters={filters} - setFilters={setFilters} + searchBody={searchBody} setSearchBody={setSearchBody} - columns={columns} - rows={results} error={error} isLoading={isLoading} isValidating={isValidating} - rowIdentifier="JobID" - isMobile={isMobile} toolbarComponents={toolbarComponents} menuItems={menuItems} /> @@ -387,6 +522,7 @@ export function JobDataTable() { open={isHistoryDialogOpen} onClose={handleHistoryClose} historyData={jobHistoryData} + jobId={selectedJobId ?? 0} /> ); diff --git a/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.stories.tsx b/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.stories.tsx index 3b162dc0..d3c08c6f 100644 --- a/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.stories.tsx +++ b/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.stories.tsx @@ -1,8 +1,7 @@ import React from "react"; import { StoryObj, Meta } from "@storybook/react"; import { useArgs } from "@storybook/core/preview-api"; -import { ThemeProvider } from "@mui/material"; -import { useMUITheme } from "../../hooks/theme"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import { JobHistoryDialog } from "./JobHistoryDialog"; const meta = { @@ -19,9 +18,8 @@ const meta = { }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + ); @@ -45,6 +43,7 @@ export const Default: Story = { ], open: true, onClose: () => {}, + jobId: 1234, }, render: (props) => { const [, updateArgs] = useArgs(); diff --git a/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.tsx b/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.tsx index 96ea10b0..5b1139f0 100644 --- a/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.tsx +++ b/packages/diracx-web-components/components/JobMonitor/JobHistoryDialog.tsx @@ -8,8 +8,17 @@ import { TableCell, TableHead, TableRow, + TableContainer, + useTheme, } from "@mui/material"; import { Close } from "@mui/icons-material"; +import React from "react"; +import { + useReactTable, + createColumnHelper, + getCoreRowModel, + flexRender, +} from "@tanstack/react-table"; import { JobHistory } from "@/types/JobHistory"; interface JobHistoryDialogProps { @@ -17,10 +26,10 @@ interface JobHistoryDialogProps { open: boolean; /** The function to close the dialog */ onClose: () => void; - /** - * The data for the job history dialog - */ + /** The data for the job history dialog */ historyData: JobHistory[]; + /** The job ID */ + jobId: number; } /** @@ -29,10 +38,47 @@ interface JobHistoryDialogProps { * @returns The rendered JobHistoryDialog component. */ export function JobHistoryDialog(props: JobHistoryDialogProps) { - const { open, onClose, historyData } = props; + const { open, onClose, historyData, jobId } = props; + const theme = useTheme(); + + // Create column helper + const columnHelper = createColumnHelper(); + + // Define columns + const columns = React.useMemo( + () => [ + columnHelper.accessor("Status", { + header: "Status", + }), + columnHelper.accessor("MinorStatus", { + header: "Minor Status", + }), + columnHelper.accessor("ApplicationStatus", { + header: "Application Status", + }), + columnHelper.accessor("StatusTime", { + header: "Status Time", + }), + columnHelper.accessor("Source", { + header: "Source", + }), + ], + [columnHelper], + ); + + // Create table instance + const table = useReactTable({ + data: historyData, + columns, + getCoreRowModel: getCoreRowModel(), + state: {}, + enableColumnResizing: true, // Enable column resizing + columnResizeMode: "onChange", // Column resize mode + }); + return ( - Job History + Job History: {jobId} theme.palette.grey[500], + color: theme.palette.grey[500], }} > + - - - - Status - Minor Status - Application Status - Status Time - Source - - - - {historyData.map((history, index) => ( - - {history.Status} - {history.MinorStatus} - {history.ApplicationStatus} - {history.StatusTime} - {history.Source} - - ))} - -
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : ( + <> + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {header.column.getCanResize() && ( +
+ )} + + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
+
); diff --git a/packages/diracx-web-components/components/JobMonitor/JobMonitor.stories.tsx b/packages/diracx-web-components/components/JobMonitor/JobMonitor.stories.tsx index 1920bbf0..16a91474 100644 --- a/packages/diracx-web-components/components/JobMonitor/JobMonitor.stories.tsx +++ b/packages/diracx-web-components/components/JobMonitor/JobMonitor.stories.tsx @@ -1,11 +1,11 @@ import React from "react"; import { StoryObj, Meta } from "@storybook/react"; -import { Paper, ThemeProvider } from "@mui/material"; +import { Paper } from "@mui/material"; import { useJobs } from "../../mocks/JobDataService.mock"; import { useOidcAccessToken } from "../../mocks/react-oidc.mock"; import { ApplicationsContext } from "../../contexts/ApplicationsProvider"; import { NavigationProvider } from "../../contexts/NavigationProvider"; -import { useMUITheme } from "../../hooks/theme"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import JobMonitor from "./JobMonitor"; const meta = { @@ -15,14 +15,10 @@ const meta = { layout: "centered", }, tags: ["autodocs"], - argTypes: { - headerSize: { control: "select" }, - }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + diff --git a/packages/diracx-web-components/components/JobMonitor/JobMonitor.tsx b/packages/diracx-web-components/components/JobMonitor/JobMonitor.tsx index 6967f527..d080ae1c 100644 --- a/packages/diracx-web-components/components/JobMonitor/JobMonitor.tsx +++ b/packages/diracx-web-components/components/JobMonitor/JobMonitor.tsx @@ -1,23 +1,23 @@ "use client"; -import React from "react"; +import { Box } from "@mui/material"; import { JobDataTable } from "./JobDataTable"; -import ApplicationHeader from "@/components/shared/ApplicationHeader"; /** * Build the Job Monitor application * * @returns Job Monitor content */ -export default function JobMonitor({ - headerSize, -}: { - /** The size of the header, optional */ - headerSize?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -}) { +export default function JobMonitor() { return ( -
- + -
+
); } diff --git a/packages/diracx-web-components/components/Login/LoginForm.stories.tsx b/packages/diracx-web-components/components/Login/LoginForm.stories.tsx index 5ceb9ad0..d593efe5 100644 --- a/packages/diracx-web-components/components/Login/LoginForm.stories.tsx +++ b/packages/diracx-web-components/components/Login/LoginForm.stories.tsx @@ -1,9 +1,9 @@ import React from "react"; import { StoryObj, Meta } from "@storybook/react"; -import { Paper, ThemeProvider } from "@mui/material"; +import { Paper } from "@mui/material"; import { useMetadata } from "../../mocks/metadata.mock"; import { useOidc } from "../../mocks/react-oidc.mock"; -import { useMUITheme } from "../../hooks/theme"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; import { LoginForm } from "./LoginForm"; const meta = { @@ -24,9 +24,8 @@ const meta = { }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + diff --git a/packages/diracx-web-components/components/Login/LoginForm.tsx b/packages/diracx-web-components/components/Login/LoginForm.tsx index 13f5dc4d..237c9b54 100644 --- a/packages/diracx-web-components/components/Login/LoginForm.tsx +++ b/packages/diracx-web-components/components/Login/LoginForm.tsx @@ -10,12 +10,10 @@ import MenuItem from "@mui/material/MenuItem"; import Button from "@mui/material/Button"; import Autocomplete from "@mui/material/Autocomplete"; import TextField from "@mui/material/TextField"; -import { CssBaseline, Stack } from "@mui/material"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; +import { Stack } from "@mui/material"; import { useOidc } from "@axa-fr/react-oidc"; import { useMetadata, Metadata } from "../../hooks/metadata"; import { useOIDCContext } from "@/hooks/oidcConfiguration"; -import { useMUITheme } from "@/hooks/theme"; import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; import { NavigationContext } from "@/contexts/NavigationProvider"; @@ -31,7 +29,6 @@ export function LoginForm({ /** The URL of the logo, optional */ logoURL?: string; }) { - const theme = useMUITheme(); const { setPath } = React.useContext(NavigationContext); const { metadata, error, isLoading } = useMetadata(); const [selectedVO, setSelectedVO] = useState(null); @@ -129,109 +126,105 @@ export function LoginForm({ return ( - - - + - + + {singleVO ? ( + + {selectedVO} + + ) : ( + option} + renderInput={(params) => ( + + )} + value={selectedVO} + onChange={handleVOChange} sx={{ - width: "100%", - display: "flex", - justifyContent: "center", - paddingTop: "10%", - paddingBottom: "10%", + "& .MuiAutocomplete-root": { + // Style changes when an option is selected + opacity: selectedVO ? 0.5 : 1, + }, }} - > - DIRAC logo - - {singleVO ? ( + /> + )} + {selectedVO && ( + + + Select a Group + + + + + + - {selectedVO} + Need help?{" "} + {metadata.virtual_organizations[selectedVO].support.message} - ) : ( - option} - renderInput={(params) => ( - - )} - value={selectedVO} - onChange={handleVOChange} - sx={{ - "& .MuiAutocomplete-root": { - // Style changes when an option is selected - opacity: selectedVO ? 0.5 : 1, - }, - }} - /> - )} - {selectedVO && ( - - - Select a Group - - - - - - - - Need help?{" "} - {metadata.virtual_organizations[selectedVO].support.message} - - - )} - - + + )} + ); } diff --git a/packages/diracx-web-components/components/shared/ApplicationHeader.stories.tsx b/packages/diracx-web-components/components/shared/ApplicationHeader.stories.tsx deleted file mode 100644 index 004af940..00000000 --- a/packages/diracx-web-components/components/shared/ApplicationHeader.stories.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import React from "react"; -import { StoryObj, Meta } from "@storybook/react"; -import { Paper, ThemeProvider } from "@mui/material"; -import { useMUITheme } from "../../hooks/theme"; -import { ApplicationsContext } from "../../contexts/ApplicationsProvider"; -import { NavigationProvider } from "../../contexts/NavigationProvider"; -import ApplicationHeader from "./ApplicationHeader"; - -const meta = { - title: "shared/ApplicationHeader", - component: ApplicationHeader, - parameters: { - layout: "centered", - }, - tags: ["autodocs"], - argTypes: { - type: { control: "text" }, - size: { options: ["h1", "h2", "h3", "h4", "h5", "h6"] }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = { - args: { - type: "App Type", - }, - decorators: [ - (Story) => { - const theme = useMUITheme(); - return ( - - - - - - ); - }, - (Story) => ( - "/"} - setPath={() => {}} - getSearchParams={() => { - const url = new URLSearchParams(); - url.append("appId", "example"); - return url; - }} - > - {}, - [], - ]} - > - - - - ), - ], -}; diff --git a/packages/diracx-web-components/components/shared/ApplicationHeader.tsx b/packages/diracx-web-components/components/shared/ApplicationHeader.tsx deleted file mode 100644 index 3a1bc15b..00000000 --- a/packages/diracx-web-components/components/shared/ApplicationHeader.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from "react"; -import { Stack, Typography, useMediaQuery, useTheme } from "@mui/material"; -import { useApplicationTitle } from "@/hooks/application"; - -export interface ApplicationHeaderProps { - /** The type of the application. */ - type: string; - /** The size of the header, default is h4 */ - size?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; -} - -/** - * Application Header component with the application title and type - * - * @returns The Application Header component - */ -export default function ApplicationHeader({ - type, - size = "h5", -}: ApplicationHeaderProps) { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down("sm")); - const appTitle = useApplicationTitle(); - - return ( -
- - {appTitle && ( - - {appTitle} - - )} - - {type} - - -
- ); -} diff --git a/packages/diracx-web-components/components/shared/DataTable.stories.tsx b/packages/diracx-web-components/components/shared/DataTable.stories.tsx index 4db01c0f..207b03a8 100644 --- a/packages/diracx-web-components/components/shared/DataTable.stories.tsx +++ b/packages/diracx-web-components/components/shared/DataTable.stories.tsx @@ -1,122 +1,96 @@ import React from "react"; -import { StoryObj, Meta } from "@storybook/react"; -import { useArgs } from "@storybook/core/preview-api"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; -import { useMUITheme } from "../../hooks/theme"; -import { DataTable } from "./DataTable"; +import { Meta, StoryObj } from "@storybook/react"; +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; +import { DataTable, DataTableProps } from "./DataTable"; -const meta = { +interface SimpleItem extends Record { + id: number; + name: string; + email: string; +} + +const columnHelper = createColumnHelper(); + +const columnDefs = [ + columnHelper.accessor("id", { + header: "ID", + meta: { type: "number" }, + }), + columnHelper.accessor("name", { + header: "Name", + meta: { type: "string" }, + }), + columnHelper.accessor("email", { + header: "Email", + meta: { type: "string" }, + }), +]; + +const data: SimpleItem[] = [ + { id: 1, name: "John Doe", email: "john@example.com" }, +]; + +// Wrapper component to initialize the table +const DataTableWrapper: React.FC, "table">> = ( + props, +) => { + const table = useReactTable({ + data, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + }); + + return {...props} table={table} />; +}; + +const meta: Meta = { title: "shared/DataTable", - component: DataTable, + component: DataTableWrapper, parameters: { layout: "centered", }, tags: ["autodocs"], argTypes: { title: { control: "text" }, - page: { control: "number" }, - setPage: { control: false }, - rowsPerPage: { control: "number" }, - setRowsPerPage: { control: false }, - order: { control: "radio" }, - setOrder: { control: false }, - orderBy: { control: "text" }, - setOrderBy: { control: false }, + table: { control: false, description: "tan stack `Table`", required: true }, totalRows: { control: "number" }, - selected: { control: "object" }, - setSelected: { control: false }, - filters: { control: "object" }, - setFilters: { control: false }, + searchBody: { control: false }, setSearchBody: { control: false }, - columns: { control: "object" }, - rows: { control: "object" }, error: { control: "text" }, isValidating: { control: "boolean" }, isLoading: { control: "boolean" }, - rowIdentifier: { control: "text" }, - isMobile: { control: "boolean" }, toolbarComponents: { control: false }, menuItems: { control: "object" }, }, - args: {}, decorators: [ - (Story) => { - const theme = useMUITheme(); - return ( - + (Story) => ( + +
- - ); - }, +
+
+ ), ], -} satisfies Meta; +}; export default meta; type Story = StoryObj; export const Default: Story = { - decorators: [ - (Story) => ( -
- -
- ), - ], args: { title: "Data Table", - page: 0, - setPage: () => {}, - rowsPerPage: 25, - setRowsPerPage: () => {}, - order: "asc", - setOrder: () => {}, - orderBy: "id", - setOrderBy: () => {}, - totalRows: 1, - selected: [], - setSelected: () => {}, - filters: [], - setFilters: () => {}, + totalRows: data.length, + searchBody: { sort: [{ parameter: "id", direction: "asc" }] }, setSearchBody: () => {}, - columns: [ - { id: "id", label: "ID" }, - { id: "name", label: "Name" }, - { id: "email", label: "Email" }, - ], - rows: [{ id: 1, name: "John Doe", email: "john@example.com" }], - error: "", + error: null, isValidating: false, isLoading: false, - rowIdentifier: "id", - isMobile: false, toolbarComponents: <>, menuItems: [{ label: "Edit", onClick: () => {} }], }, - render: (props) => { - const [, updateArgs] = useArgs(); - props.setPage = (newPage) => { - if (typeof newPage === "function") newPage = newPage(props.page); - updateArgs({ page: newPage }); - }; - props.setRowsPerPage = (newRowsPerPage) => { - if (typeof newRowsPerPage === "function") - newRowsPerPage = newRowsPerPage(props.rowsPerPage); - updateArgs({ rowsPerPage: newRowsPerPage }); - }; - props.setOrder = (newOrder) => { - if (typeof newOrder === "function") newOrder = newOrder(props.order); - updateArgs({ order: newOrder }); - }; - props.setOrderBy = (newOrderBy) => { - if (typeof newOrderBy === "function") - newOrderBy = newOrderBy(props.orderBy); - updateArgs({ orderBy: newOrderBy }); - }; - props.setSelected = (newSelected) => { - if (typeof newSelected === "function") - newSelected = newSelected(props.selected); - updateArgs({ selected: newSelected }); - }; - return ; - }, }; diff --git a/packages/diracx-web-components/components/shared/DataTable.tsx b/packages/diracx-web-components/components/shared/DataTable.tsx index 05611e83..d085e0d0 100644 --- a/packages/diracx-web-components/components/shared/DataTable.tsx +++ b/packages/diracx-web-components/components/shared/DataTable.tsx @@ -12,25 +12,27 @@ import TableRow from "@mui/material/TableRow"; import TableSortLabel from "@mui/material/TableSortLabel"; import Paper from "@mui/material/Paper"; import Checkbox from "@mui/material/Checkbox"; -import { visuallyHidden } from "@mui/utils"; import Toolbar from "@mui/material/Toolbar"; import Typography from "@mui/material/Typography"; import Tooltip from "@mui/material/Tooltip"; import IconButton from "@mui/material/IconButton"; -import { FormatListBulleted } from "@mui/icons-material"; +import { FormatListBulleted, Visibility } from "@mui/icons-material"; import { Alert, Menu, MenuItem, + Popover, Skeleton, Snackbar, Stack, + Switch, + useMediaQuery, + useTheme, } from "@mui/material"; -import { cyan } from "@mui/material/colors"; +import { flexRender, Row, Table as TanstackTable } from "@tanstack/react-table"; import { TableComponents, TableVirtuoso } from "react-virtuoso"; import { FilterToolbar } from "./FilterToolbar"; import { InternalFilter } from "@/types/Filter"; -import { Column } from "@/types/Column"; import { useSearchParamsUtils } from "@/hooks/searchParamsUtils"; import { ApplicationsContext } from "@/contexts/ApplicationsProvider"; import { DashboardGroup, SearchBody } from "@/types"; @@ -50,8 +52,9 @@ export interface MenuItem { * @property {number[]} selectedIds - the ids of the selected rows * @property {function} clearSelected - the function to call when the selected rows are cleared */ -interface DataTableToolbarProps { +interface DataTableToolbarProps> { title: string; + table: TanstackTable; numSelected: number; selectedIds: readonly number[]; toolbarComponents: JSX.Element; @@ -61,9 +64,23 @@ interface DataTableToolbarProps { * Data table toolbar component * @param {DataTableToolbarProps} props - the props for the component */ -function DataTableToolbar(props: DataTableToolbarProps) { - const { title, numSelected, selectedIds, toolbarComponents } = props; +function DataTableToolbar>( + props: DataTableToolbarProps, +) { + const { title, table, numSelected, selectedIds, toolbarComponents } = props; const [snackbarOpen, setSnackbarOpen] = React.useState(false); + const [anchorEl, setAnchorEl] = React.useState(null); + + const handleVisibilityClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handlePopoverClose = () => { + setAnchorEl(null); + }; + + const open = Boolean(anchorEl); + const id = open ? "simple-popover" : undefined; /** * Handle the copy of the selected IDs @@ -87,7 +104,10 @@ function DataTableToolbar(props: DataTableToolbarProps) { sx={{ ...(numSelected > 0 && { bgcolor: (theme) => - alpha(cyan[500], theme.palette.action.activatedOpacity), + alpha( + theme.palette.secondary.main, + theme.palette.action.activatedOpacity, + ), }), }} > @@ -121,12 +141,51 @@ function DataTableToolbar(props: DataTableToolbarProps) { open={snackbarOpen} autoHideDuration={6000} onClose={() => setSnackbarOpen(false)} - message="Job IDs copied to clipboard" + message="IDs copied to clipboard" /> {toolbarComponents} ) : ( - <> + + + + + + + + + + + + {table.getAllLeafColumns().map((column) => ( + + + {String(column.columnDef.header)} + + ))} + + + + + )} ); @@ -135,68 +194,33 @@ function DataTableToolbar(props: DataTableToolbarProps) { /** * Data table props * @property {string} title - the title of the table - * @property {number} page - the current page - * @property {function} setPage - the function to call when the page changes - * @property {number} rowsPerPage - the number of rows per page - * @property {function} setRowsPerPage - the function to call when the rows per page change - * @property {number[]} selected - the selected rows - * @property {function} setSelected - the function to call when the selected rows change - * @property {Filter[]} filters - the filters to apply - * @property {function} setFilters - the function to call when the filters change + * @property {TanstackTable} table - the table + * @property {number} totalRows - the total number of rows + * @property {number} searchBody - the search body to send along with the request * @property {function} setSearchBody - the function to call when the search body changes - * @property {Column[]} columns - the columns of the table + * @property {Column[]} columns - the columns of the table * @property {T[]} rows - the rows of the table * @property {string | null} error - the error message - * @property {string} rowIdentifier - the identifier for the rows - * @property {boolean} isMobile - whether the table is displayed on a mobile device * @property {JSX.Element} toolbarComponents - the components to display in the toolbar * @property {MenuItem[]} menuItems - the menu items */ -interface DataTableProps> { +export interface DataTableProps> { /** The title of the table */ title: string; - /** The current page */ - page: number; - /** The function to call when the page changes */ - setPage: React.Dispatch>; - /** The number of rows per page */ - rowsPerPage: number; - /** The function to call when the rows per page change */ - setRowsPerPage: React.Dispatch>; - /** The order of the table, either "asc" or "desc" */ - order: "asc" | "desc"; - /** The function to call when the order changes */ - setOrder: React.Dispatch>; - /** The column to order by */ - orderBy: string | number; - /** The function to call when the order by changes */ - setOrderBy: React.Dispatch>; + /** The table */ + table: TanstackTable; /** The total number of rows */ totalRows: number; - /** The selected rows */ - selected: readonly number[]; - /** The function to call when the selected rows change */ - setSelected: React.Dispatch>; - /** The filters to apply */ - filters: InternalFilter[]; - /** The function to call when the filters change */ - setFilters: React.Dispatch>; + /** The search body to send along with the request */ + searchBody: SearchBody; /** The function to call when the search body changes */ setSearchBody: React.Dispatch>; - /** The columns of the table */ - columns: Column[]; - /** The rows of the table */ - rows: T[]; /** The error message */ error: string | null; /** Whether the table is validating */ isValidating: boolean; /** Whether the table is loading */ isLoading: boolean; - /** The identifier for the rows */ - rowIdentifier: keyof T; - /** Whether the table is displayed on a mobile device */ - isMobile: boolean; /** The components to display in the toolbar */ toolbarComponents: JSX.Element; /** The context menu items */ @@ -213,39 +237,32 @@ export function DataTable>( ) { const { title, - page, - setPage, - rowsPerPage, - setRowsPerPage, - order, - setOrder, - orderBy, - setOrderBy, + table, totalRows, - selected, - setSelected, - filters, - setFilters, + searchBody, setSearchBody, - columns, - rows, error, isLoading, isValidating, - rowIdentifier, - isMobile, toolbarComponents, menuItems, } = props; + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + // State for the context menu const [contextMenu, setContextMenu] = React.useState<{ mouseX: number | null; mouseY: number | null; id: number | null; }>({ mouseX: null, mouseY: null, id: null }); + + // State for the search parameters const { getParam, setParam } = useSearchParamsUtils(); const appId = getParam("appId"); + // State for filters + const [filters, setFilters] = React.useState([]); const [appliedFilters, setAppliedFilters] = React.useState(filters); @@ -263,6 +280,7 @@ export function DataTable>( [setParam], ); + // State for the user dashboard const [userDashboard, setUserDashboard] = React.useContext(ApplicationsContext); const updateGroupFilters = React.useCallback( @@ -299,16 +317,29 @@ export function DataTable>( value: filter.value, values: filter.values, })); - setSearchBody({ search: jsonFilters }); - setPage(0); + setSearchBody((prevState) => ({ + ...prevState, + search: jsonFilters, + })); + table.setPageIndex(0); setAppliedFilters(filters); - // Update the filters in the URL updateFiltersAndUrl(filters); - // Update the filters in the groups updateGroupFilters(filters); }; + const handleRemoveAllFilters = React.useCallback(() => { + setSearchBody((prevState) => ({ + ...prevState, + search: [], + })); + table.setPageIndex(0); + setAppliedFilters([]); + + updateFiltersAndUrl([]); + updateGroupFilters([]); + }, [setFilters]); + const DashboardItem = React.useMemo( () => userDashboard @@ -327,337 +358,346 @@ export function DataTable>( value: filter.value, values: filter.values, })); - setSearchBody({ search: jsonFilters }); + setSearchBody((prevState) => ({ + ...prevState, + search: jsonFilters, + })); } else { setFilters([]); - setSearchBody({ search: [] }); + setSearchBody((prevState) => ({ + ...prevState, + search: [], + })); } }, [DashboardItem?.data, setFilters, setSearchBody]); // Manage sorting - const handleRequestSort = ( - event: React.MouseEvent, - property: string, - ) => { - const isAsc = orderBy === property && order === "asc"; - setOrder(isAsc ? "desc" : "asc"); - setOrderBy(property); - setSearchBody((prevState: SearchBody) => ({ - ...prevState, - sort: [{ parameter: property, direction: isAsc ? "desc" : "asc" }], - })); - }; - - // Manage selection - const handleSelectAllClick = (event: React.ChangeEvent) => { - if (event.target.checked) { - const newSelected = rows.map((n: T) => n[rowIdentifier] as number); - setSelected(newSelected); - return; - } - setSelected([]); - }; - - const handleClick = (event: React.MouseEvent, id: number) => { - const selectedIndex = selected.indexOf(id); - let newSelected: readonly number[] = []; - - if (selectedIndex === -1) { - newSelected = newSelected.concat(selected, id); - } else if (selectedIndex === 0) { - newSelected = newSelected.concat(selected.slice(1)); - } else if (selectedIndex === selected.length - 1) { - newSelected = newSelected.concat(selected.slice(0, -1)); - } else if (selectedIndex > 0) { - newSelected = newSelected.concat( - selected.slice(0, selectedIndex), - selected.slice(selectedIndex + 1), - ); - } - - setSelected(newSelected); - }; + const handleRequestSort = React.useCallback( + (_event: React.MouseEvent, property: string) => { + const isAsc = + searchBody.sort && + searchBody.sort[0]?.parameter === property && + searchBody.sort[0]?.direction === "asc"; + setSearchBody((prevState: SearchBody) => ({ + ...prevState, + sort: [{ parameter: property, direction: isAsc ? "desc" : "asc" }], + })); + }, + [searchBody, setSearchBody], + ); // Manage pagination - const handleChangePage = (event: unknown, newPage: number) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = ( - event: React.ChangeEvent, - ) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; + const handleChangePage = React.useCallback( + (_event: unknown, newPage: number) => { + table.setPageIndex(newPage); + }, + [table], + ); - const isSelected = (name: number) => selected.indexOf(name) !== -1; + const handleChangeRowsPerPage = React.useCallback( + (event: React.ChangeEvent) => { + table.setPageSize(Number(event.target.value)); + table.setPageIndex(0); + }, + [table], + ); // Manage context menu - const handleContextMenu = (event: React.MouseEvent, id: number) => { - event.preventDefault(); // Prevent default context menu - setContextMenu({ - mouseX: event.clientX - 2, - mouseY: event.clientY - 4, - id, - }); - }; + const handleContextMenu = React.useCallback( + (event: React.MouseEvent, id: number) => { + event.preventDefault(); + setContextMenu({ + mouseX: event.clientX - 2, + mouseY: event.clientY - 4, + id, + }); + }, + [], + ); - const handleCloseContextMenu = () => { + const handleCloseContextMenu = React.useCallback(() => { setContextMenu({ mouseX: null, mouseY: null, id: null }); - }; - - // Virtuoso table components: https://virtuoso.dev/ - // Used to render large tables with virtualization, which improves performance - interface TableContextProps { - rowIdentifier: keyof T; - handleClick: (event: React.MouseEvent, id: number) => void; - handleContextMenu: (event: React.MouseEvent, id: number) => void; - isSelected: (id: number) => boolean; - isMobile: boolean; - } - - interface TableRowProps { - item: T; - context?: TableContextProps; - "data-index"?: number; - } - - const VirtuosoTableComponents: TableComponents = { - Scroller: React.forwardRef(function Scroller(props, ref) { - return ; - }), - Table: function VirtuosoTable(props) { - const { isMobile } = props.context as TableContextProps; - return ( + }, []); + + // Virtualizer + const VirtuosoTableComponents: TableComponents< + Row, + unknown + > = React.useMemo( + () => ({ + Scroller: React.forwardRef(function Scroller(props, ref) { + return ; + }), + Table: (props) => ( - ); - }, - TableHead: React.forwardRef( - function VirtuosoTableHead(props, ref) { - return ; - }, - ), - TableRow: function VirtuosoTableRow(props: TableRowProps) { - const { item, context } = props; - - if (!context) { - return ; - } - - const { rowIdentifier, handleClick, handleContextMenu, isSelected } = - context || {}; - - const itemId = item[rowIdentifier]; - if (typeof itemId !== "number") { - return ; - } - - return ( + ), + TableHead: React.forwardRef( + function TableHeadRef(props, ref) { + return ; + }, + ), + TableRow: ({ item, ...props }) => ( handleClick(event, itemId)} - role="checkbox" - aria-checked={isSelected(itemId)} - tabIndex={-1} - key={itemId} - selected={isSelected(itemId)} - onContextMenu={(event) => handleContextMenu(event, itemId)} + key={item.id} + onClick={() => item.toggleSelected()} style={{ cursor: "context-menu" }} + onContextMenu={(event) => handleContextMenu(event, Number(item.id))} + {...props} /> - ); - }, - TableBody: React.forwardRef( - function VirtuosoTableBody(props, ref) { - return ; - }, - ), - }; + ), + TableBody: React.forwardRef( + function TableBodyRef(props, ref) { + return ; + }, + ), + }), + [handleContextMenu], + ); // Wait for the data to load - if (isValidating || isLoading) { - return ( - <> - - - - - - ); - } - - // Handle errors - if (error) { - return ( - <> - - - - An error occurred while fetching data. Reload the page. - - - - ); - } + const rows = table.getRowModel().rows; // Handle no data - if (!rows || rows.length === 0) { + const noData = !rows || rows.length === 0; + + if (isValidating || isLoading || error || noData) { return ( <> - - No data or no results match your filters. - + {isValidating || isLoading ? ( + + ) : error ? ( + + An error occurred while fetching data. Reload the page. + + ) : ( + + No data or no results match your filters. + + )} ); } + const checkboxWidth = 50; + return ( - + - + - - - data={rows} - components={VirtuosoTableComponents} - context={{ - rowIdentifier, - handleClick, - handleContextMenu, - isSelected, - isMobile, - }} - fixedHeaderContent={() => { - const createSortHandler = - (property: string) => (event: React.MouseEvent) => { - handleRequestSort(event, property); - }; - - return ( - - + ( + <> + {table.getHeaderGroups().map((headerGroup) => ( + + 0 && selected.length < rows.length - } - checked={ - rows.length > 0 && selected.length === rows.length + table.getIsSomeRowsSelected() && + !table.getIsAllRowsSelected() } - onChange={handleSelectAllClick} - inputProps={{ "aria-label": "select all items" }} + checked={table.getIsAllRowsSelected()} + onChange={table.getToggleAllRowsSelectedHandler()} /> - {columns.map((headCell) => ( + {headerGroup.headers.map((header) => ( - - {headCell.label} - {orderBy === headCell.id ? ( - - {order === "desc" - ? "sorted descending" - : "sorted ascending"} - - ) : null} - + {header.isPlaceholder ? null : ( + + handleRequestSort(event, header.id) + } + data-testid={`sort-${header.id}`} + > + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + )} + {header.column.getCanResize() && ( + + )} ))} - ); - }} - itemContent={(index: number, row: T) => { - const isItemSelected = isSelected(row[rowIdentifier] as number); - const labelId = `enhanced-table-checkbox-${index}`; - - return ( - <> - - - - {columns.map((column) => { - const cellValue = row[column.id]; - return ( - - {column.render - ? column.render(cellValue) - : String(cellValue)} - - ); - })} - - ); - }} - /> - + ))} + + )} + itemContent={(_index, row: Row) => ( + <> + + + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )} + /> { + id: number; + name: string; + email: string; +} + +const columnHelper = createColumnHelper(); + +const columnDefs = [ + columnHelper.accessor("id", { + header: "ID", + meta: { type: "number" }, + }), + columnHelper.accessor("name", { + header: "Name", + meta: { type: "string" }, + }), + columnHelper.accessor("email", { + header: "Email", + meta: { type: "string" }, + }), +]; + +const data: SimpleItem[] = [ + { id: 1, name: "John Doe", email: "john@example.com" }, +]; + +// Wrapper component to initialize the table +const FilterFormWrapper: React.FC< + Omit, "columns"> +> = (props) => { + const table = useReactTable({ + data, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + }); + + return {...props} columns={table.getAllColumns()} />; +}; const meta = { title: "shared/FilterForm", - component: FilterForm, + component: FilterFormWrapper, parameters: { layout: "centered", }, tags: ["autodocs"], argTypes: { - columns: { control: "object" }, + columns: { + control: false, + description: "`array` of tan stack `Column`", + required: true, + }, filters: { control: "object" }, setFilters: { control: "object" }, handleFilterChange: { control: "object" }, @@ -22,29 +70,23 @@ const meta = { }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, ], -} satisfies Meta; +}; export default meta; type Story = StoryObj; export const Default: Story = { args: { - columns: [ - { id: "id", label: "ID" }, - { id: "name", label: "Name" }, - { id: "age", label: "Age" }, - ], - filters: [{ id: 0, column: "id", operator: "eq", value: "1" }], + filters: [{ id: 0, parameter: "id", operator: "eq", value: "1" }], setFilters: () => {}, handleFilterChange: () => {}, handleFilterMenuClose: () => {}, diff --git a/packages/diracx-web-components/components/shared/FilterForm.tsx b/packages/diracx-web-components/components/shared/FilterForm.tsx index 4899d509..29861638 100644 --- a/packages/diracx-web-components/components/shared/FilterForm.tsx +++ b/packages/diracx-web-components/components/shared/FilterForm.tsx @@ -1,36 +1,32 @@ import React from "react"; -import { CheckCircle } from "@mui/icons-material"; import { - Box, + Button, FormControl, - IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, Stack, TextField, - Tooltip, - Typography, } from "@mui/material"; import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { Column } from "@tanstack/react-table"; import dayjs from "dayjs"; -import { Column } from "@/types/Column"; import { InternalFilter } from "@/types/Filter"; import "dayjs/locale/en-gb"; // needed by LocalizationProvider to format Dates to dd-mm-yyyy /** * Filter form props - * @property {Column[]} columns - the columns on which to filter + * @property {AccessorKeyColumnDef[]} columns - the columns on which to filter * @property {function} handleFilterChange - the function to call when a filter is changed * @property {function} handleFilterMenuClose - the function to call when the filter menu is closed * @property {InternalFilter[]} filters - the filters for the table * @property {number} selectedFilterId - the id of the selected filter */ -interface FilterFormProps { +export interface FilterFormProps> { /** The columns of the data table */ - columns: Column[]; + columns: Column[]; /** The function to call when a filter is changed */ handleFilterChange: (index: number, tempFilter: InternalFilter) => void; /** The function to call when the filter menu is closed */ @@ -48,7 +44,9 @@ interface FilterFormProps { * * @returns a FilterForm component */ -export function FilterForm(props: FilterFormProps) { +export function FilterForm>( + props: FilterFormProps, +) { const { columns, filters, @@ -60,7 +58,6 @@ export function FilterForm(props: FilterFormProps) { const [tempFilter, setTempFilter] = React.useState( null, ); - // Find the index using the filter ID const filterIndex = filters.findIndex((f) => f.id === selectedFilterId); @@ -105,20 +102,20 @@ export function FilterForm(props: FilterFormProps) { const selectedColumn = columns.find((c) => c.id == tempFilter.parameter); - const columnType = selectedColumn?.type; - const isCategory = Array.isArray(columnType); - const isDateTime = columnType === "DateTime"; + const columnType = selectedColumn?.columnDef.meta?.type || "default"; + const isCategory = Array.isArray(selectedColumn?.columnDef.meta?.values); + const isDateTime = columnType === "date"; const isNumber = columnType === "number"; const operatorOptions = { - DateTime: ["last", "gt", "lt"], + date: ["last", "gt", "lt"], category: ["eq", "neq", "in", "not in", "like"], number: ["eq", "neq", "gt", "lt", "in", "not in", "like"], default: ["eq", "neq", "gt", "lt", "like"], }; const defaultOperators = { - DateTime: "last", + date: "last", category: "eq", number: "eq", default: "eq", @@ -136,18 +133,17 @@ export function FilterForm(props: FilterFormProps) { }; const getOperatorType = () => { - if (isDateTime) return "DateTime"; + if (isDateTime) return "date"; if (isCategory) return "category"; if (isNumber) return "number"; return "default"; }; const operatorType = getOperatorType(); - const operators = operatorOptions[operatorType]; const operatorSelector = ( - + Operator + Value ); @@ -258,7 +253,7 @@ export function FilterForm(props: FilterFormProps) { if (isNumber) { if (!["in", "not in", "like"].includes(tempFilter.operator)) { return ( - + + + - - - Edit Filter - - - - Parameter - { + const parameter = e.target.value; + onChange("parameter", parameter); - const column = columns.find((v) => v.id === parameter); - const colType = column?.type; - const typeKey = - colType === "DateTime" - ? "DateTime" - : Array.isArray(colType) - ? "category" - : colType === "number" - ? "number" - : "default"; + const column = columns.find((v) => v.id === parameter); + const colType = column?.columnDef.meta?.type || "default"; + const typeKey = + colType === "date" + ? "date" + : Array.isArray(colType) + ? "category" + : colType === "number" + ? "number" + : "default"; - const defaultOp = defaultOperators[typeKey]; - onChange("operator", defaultOp); - }} - label="Parameter" - labelId="parameter" - data-testid="filter-form-select-parameter" - sx={{ minWidth: 120 }} + const defaultOp = defaultOperators[typeKey]; + onChange("operator", defaultOp); + onChange("value", ""); + }} + label="Parameter" + labelId="parameter" + data-testid="filter-form-select-parameter" + > + {columns.map((column) => ( + - {columns.map((column) => ( - - {column.label} - - ))} - - + {column.columnDef.header?.toString()} + + ))} + + - {operatorSelector} + {operatorSelector} - {valueSelector()} + {valueSelector()} - - applyChanges()} color="success"> - - - - - - + + ); } diff --git a/packages/diracx-web-components/components/shared/FilterToolbar.stories.tsx b/packages/diracx-web-components/components/shared/FilterToolbar.stories.tsx index 9d90c997..3d551f14 100644 --- a/packages/diracx-web-components/components/shared/FilterToolbar.stories.tsx +++ b/packages/diracx-web-components/components/shared/FilterToolbar.stories.tsx @@ -1,60 +1,106 @@ import React from "react"; -import { StoryObj, Meta } from "@storybook/react"; +import { StoryObj } from "@storybook/react"; import { useArgs } from "@storybook/core/preview-api"; -import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles"; import { Paper } from "@mui/material"; -import { useMUITheme } from "../../hooks/theme"; -import { FilterToolbar } from "./FilterToolbar"; +import { + createColumnHelper, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ThemeProvider } from "../../contexts/ThemeProvider"; +import { FilterToolbar, FilterToolbarProps } from "./FilterToolbar"; + +interface SimpleItem extends Record { + id: number; + name: string; + email: string; +} + +const columnHelper = createColumnHelper(); + +const columnDefs = [ + columnHelper.accessor("id", { + header: "ID", + meta: { type: "number" }, + }), + columnHelper.accessor("name", { + header: "Name", + meta: { type: "string" }, + }), + columnHelper.accessor("email", { + header: "Email", + meta: { type: "string" }, + }), +]; + +const data: SimpleItem[] = [ + { id: 1, name: "John Doe", email: "john@example.com" }, +]; + +// Wrapper component to initialize the table +const FilterToolbarWrapper: React.FC< + Omit, "columns"> +> = (props) => { + const table = useReactTable({ + data, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + }); + + return ( + {...props} columns={table.getAllColumns()} /> + ); +}; const meta = { title: "shared/FilterToolbar", - component: FilterToolbar, + component: FilterToolbarWrapper, parameters: { layout: "centered", }, tags: ["autodocs"], argTypes: { - columns: { control: "object" }, + columns: { + control: false, + description: "`array` of tan stack `Column`", + required: true, + }, filters: { control: "object" }, setFilters: { control: "object" }, handleApplyFilters: { control: "object" }, + handleClearFilters: { control: "object" }, }, decorators: [ (Story) => { - const theme = useMUITheme(); return ( - + - + ); }, ], -} satisfies Meta; +}; export default meta; type Story = StoryObj; export const Default: Story = { args: { - columns: [ - { id: "id", label: "ID" }, - { id: "name", label: "Name" }, - { id: "age", label: "Age" }, - ], filters: [ - { id: 0, column: "id", operator: "eq", value: "1" }, - { id: 1, column: "id", operator: "neq", value: "2" }, + { id: 0, parameter: "id", operator: "eq", value: "1" }, + { id: 1, parameter: "id", operator: "neq", value: "2" }, ], setFilters: () => {}, handleApplyFilters: () => {}, - appliedFilters: [{ id: 0, column: "id", operator: "eq", value: "1" }], + handleClearFilters: () => {}, + appliedFilters: [{ id: 0, parameter: "id", operator: "eq", value: "1" }], }, render: (props) => { const [{ filters }, updateArgs] = useArgs(); props.setFilters = (filters) => updateArgs({ filters }); props.handleApplyFilters = () => updateArgs({ appliedFilters: filters }); - return ; + return ; }, }; diff --git a/packages/diracx-web-components/components/shared/FilterToolbar.tsx b/packages/diracx-web-components/components/shared/FilterToolbar.tsx index 6c29d14d..076c705e 100644 --- a/packages/diracx-web-components/components/shared/FilterToolbar.tsx +++ b/packages/diracx-web-components/components/shared/FilterToolbar.tsx @@ -1,20 +1,21 @@ import React from "react"; -import { FilterList, Delete, Send } from "@mui/icons-material"; +import { grey } from "@mui/material/colors"; +import { FilterList, Delete, Send, Refresh } from "@mui/icons-material"; import Chip from "@mui/material/Chip"; import Button from "@mui/material/Button"; -import { Alert, Popover, Stack, Tooltip } from "@mui/material"; +import { Alert, Box, Popover, Stack, Tooltip } from "@mui/material"; +import { Column } from "@tanstack/react-table"; import { FilterForm } from "./FilterForm"; import { InternalFilter } from "@/types/Filter"; -import { Column } from "@/types/Column"; import "@/hooks/theme"; /** * Filter toolbar component * @param {FilterToolbarProps} props - the props for the component */ -interface FilterToolbarProps { +export interface FilterToolbarProps> { /** The columns of the data table */ - columns: Column[]; + columns: Column[]; /** The filters to apply */ filters: InternalFilter[]; /** The function to set the filters */ @@ -23,6 +24,8 @@ interface FilterToolbarProps { appliedFilters: InternalFilter[]; /** The function to apply the filters */ handleApplyFilters: () => void; + /** The function to remove all filters */ + handleClearFilters: () => void; } /** @@ -30,9 +33,17 @@ interface FilterToolbarProps { * * @returns a FilterToolbar component */ -export function FilterToolbar(props: FilterToolbarProps) { - const { columns, filters, setFilters, appliedFilters, handleApplyFilters } = - props; +export function FilterToolbar>( + props: FilterToolbarProps, +) { + const { + columns, + filters, + setFilters, + appliedFilters, + handleApplyFilters, + handleClearFilters, + } = props; const [anchorEl, setAnchorEl] = React.useState(null); const [selectedFilter, setSelectedFilter] = React.useState(null); @@ -52,10 +63,6 @@ export function FilterToolbar(props: FilterToolbarProps) { setAnchorEl(addFilterButtonRef.current); }, [setSelectedFilter, setAnchorEl]); - const handleRemoveAllFilters = React.useCallback(() => { - setFilters([]); - }, [setFilters]); - const handleFilterChange = (index: number, newFilter: InternalFilter) => { const updatedFilters = filters.map((filter, i) => i === index ? newFilter : filter, @@ -126,7 +133,7 @@ export function FilterToolbar(props: FilterToolbarProps) { case "c": event.preventDefault(); event.stopPropagation(); - handleRemoveAllFilters(); + handleClearFilters(); break; default: break; @@ -144,7 +151,7 @@ export function FilterToolbar(props: FilterToolbarProps) { return () => { window.removeEventListener("keydown", debouncedHandleKeyPress); }; - }, [handleAddFilter, handleApplyFilters, handleRemoveAllFilters]); + }, [handleAddFilter, handleApplyFilters, handleClearFilters]); return ( <> @@ -165,11 +172,12 @@ export function FilterToolbar(props: FilterToolbarProps) { @@ -178,7 +186,7 @@ export function FilterToolbar(props: FilterToolbarProps) {