);
}
diff --git a/src/components/applications/LoginForm.tsx b/src/components/applications/LoginForm.tsx
index ca15aadc..02a14743 100644
--- a/src/components/applications/LoginForm.tsx
+++ b/src/components/applications/LoginForm.tsx
@@ -20,6 +20,8 @@ import { useOIDCContext } from "@/hooks/oidcConfiguration";
import { useMUITheme } from "@/hooks/theme";
import { useMetadata, Metadata } from "@/hooks/metadata";
+import { useSearchParamsUtils } from "@/hooks/searchParamsUtils";
+
/**
* Login form
* @returns a form
@@ -33,6 +35,8 @@ export function LoginForm() {
const { configuration, setConfiguration } = useOIDCContext();
const { isAuthenticated, login } = useOidc(configuration?.scope);
+ const { getParam } = useSearchParamsUtils();
+
// Login if not authenticated
useEffect(() => {
if (configuration && configuration.scope && isAuthenticated === false) {
@@ -44,9 +48,14 @@ export function LoginForm() {
useEffect(() => {
// Redirect to dashboard if already authenticated
if (isAuthenticated) {
- router.push("/");
+ const redirect = getParam("redirect");
+ if (redirect) {
+ router.push(redirect);
+ } else {
+ router.push("/");
+ }
}
- }, [isAuthenticated, router]);
+ }, [getParam, isAuthenticated, router]);
// Get default group
const getDefaultGroup = (data: Metadata | undefined, vo: string): string => {
diff --git a/src/components/applications/UserDashboard.tsx b/src/components/applications/UserDashboard.tsx
index 15a701a9..57b93774 100644
--- a/src/components/applications/UserDashboard.tsx
+++ b/src/components/applications/UserDashboard.tsx
@@ -13,7 +13,6 @@ import { useMUITheme } from "@/hooks/theme";
* @returns User Dashboard content
*/
export default function UserDashboard() {
- const theme = useMUITheme();
const { configuration } = useOIDCContext();
const { accessTokenPayload } = useOidcAccessToken(configuration?.scope);
@@ -22,20 +21,10 @@ export default function UserDashboard() {
}
return (
-
-
-
-
-
Hello {accessTokenPayload["preferred_username"]}
+
+
Hello {accessTokenPayload["preferred_username"]}
-
To start with, select an application in the side bar
-
-
-
+
To start with, select an application in the side bar
+
);
}
diff --git a/src/components/layout/OIDCSecure.tsx b/src/components/layout/OIDCSecure.tsx
index 6e757b87..bc6f9adb 100644
--- a/src/components/layout/OIDCSecure.tsx
+++ b/src/components/layout/OIDCSecure.tsx
@@ -21,7 +21,11 @@ export function OIDCSecure({ children }: OIDCProps) {
useEffect(() => {
// Redirect to login page if not authenticated
if (!isAuthenticated) {
- router.push("/auth");
+ router.push(
+ "/auth?" +
+ // URLSearchParams to ensure that auth redirects users to the URL they came from
+ new URLSearchParams({ redirect: window.location.href }).toString(),
+ );
}
}, [isAuthenticated, router]);
diff --git a/src/components/ui/ApplicationDialog.tsx b/src/components/ui/ApplicationDialog.tsx
new file mode 100644
index 00000000..58864845
--- /dev/null
+++ b/src/components/ui/ApplicationDialog.tsx
@@ -0,0 +1,101 @@
+import React, { ComponentType } from "react";
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogContentText,
+ DialogActions,
+ Button,
+ Grid,
+ Icon,
+ IconButton,
+} from "@mui/material";
+import { Close } from "@mui/icons-material";
+import { applicationList } from "../applications/ApplicationList";
+
+/**
+ * Renders a dialog component for creating a new application.
+ *
+ * @param {Object} props - The component props.
+ * @param {boolean} props.appDialogOpen - Determines whether the dialog is open or not.
+ * @param {React.Dispatch>} props.setAppDialogOpen - Function to set the open state of the dialog.
+ * @param {(name: string, path: string, icon: ComponentType) => void} props.handleCreateApp - Function to handle the creation of a new application.
+ * @returns {JSX.Element} The rendered dialog component.
+ */
+export default function AppDialog({
+ appDialogOpen,
+ setAppDialogOpen,
+ handleCreateApp,
+}: {
+ appDialogOpen: boolean;
+ setAppDialogOpen: React.Dispatch>;
+ handleCreateApp: (name: string, icon: ComponentType) => void;
+}) {
+ const [appType, setAppType] = React.useState("");
+ return (
+
+ );
+}
diff --git a/src/components/ui/DashboardDrawer.tsx b/src/components/ui/DashboardDrawer.tsx
index ccc641e1..637e36c8 100644
--- a/src/components/ui/DashboardDrawer.tsx
+++ b/src/components/ui/DashboardDrawer.tsx
@@ -1,31 +1,34 @@
-import { usePathname } from "next/navigation";
-import NextLink from "next/link";
import {
+ Box,
+ Button,
Drawer,
- Icon,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
+ Menu,
+ MenuItem,
+ Popover,
+ TextField,
Toolbar,
} from "@mui/material";
-import { Dashboard, FolderCopy } from "@mui/icons-material";
-import MonitorIcon from "@mui/icons-material/Monitor";
import MenuBookIcon from "@mui/icons-material/MenuBook";
-import { ReactEventHandler } from "react";
-import { DiracLogo } from "./DiracLogo";
-
-// Define the sections that are accessible to users.
-// Each section has an associated icon and path.
-const userSections: Record<
- string,
- { icon: React.ComponentType; path: string }
-> = {
- Dashboard: { icon: Dashboard, path: "/" },
- "Job Monitor": { icon: MonitorIcon, path: "/jobmonitor" },
- "File Catalog": { icon: FolderCopy, path: "/filecatalog" },
-};
+import AddIcon from "@mui/icons-material/Add";
+import React, {
+ ComponentType,
+ ReactEventHandler,
+ useContext,
+ useEffect,
+ useState,
+} from "react";
+import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
+import Image from "next/image";
+import DrawerItemGroup from "./DrawerItemGroup";
+import AppDialog from "./ApplicationDialog";
+import { ApplicationsContext } from "@/contexts/ApplicationsProvider";
+import { useMUITheme } from "@/hooks/theme";
interface DashboardDrawerProps {
variant: "permanent" | "temporary";
@@ -34,70 +37,452 @@ interface DashboardDrawerProps {
handleDrawerToggle: ReactEventHandler;
}
+/**
+ * Represents a drawer component used in the dashboard.
+ *
+ * @component
+ * @param {DashboardDrawerProps} props - The props for the DashboardDrawer component.
+ * @returns {JSX.Element} The rendered DashboardDrawer component.
+ */
export default function DashboardDrawer(props: DashboardDrawerProps) {
- // Get the current URL
- const pathname = usePathname();
// Determine the container for the Drawer based on whether the window object exists.
const container =
window !== undefined ? () => window.document.body : undefined;
// Check if the drawer is in "temporary" mode.
const isTemporary = props.variant === "temporary";
+ // Whether the modal for Application Creation is open
+ const [appDialogOpen, setAppDialogOpen] = useState(false);
+
+ const [contextMenu, setContextMenu] = React.useState<{
+ mouseX: number;
+ mouseY: number;
+ } | null>(null);
+
+ const [contextState, setContextState] = useState<{
+ type: string | null;
+ id: string | null;
+ }>({ type: null, id: null });
+
+ const [popAnchorEl, setPopAnchorEl] = React.useState(null);
+ const [renameValue, setRenameValue] = React.useState("");
+
+ // Define the sections that are accessible to users.
+ // Each section has an associated icon and path.
+ const [userSections, setSections] = useContext(ApplicationsContext);
+
+ const theme = useMUITheme();
+
+ useEffect(() => {
+ // Handle changes to sections when drag and drop occurs.
+ return monitorForElements({
+ onDrop({ source, location }) {
+ const target = location.current.dropTargets[0];
+ if (!target) {
+ return;
+ }
+ const sourceData = source.data;
+ const targetData = target.data;
+
+ if (location.current.dropTargets.length == 2) {
+ // If the target is an item
+ const groupTitle = targetData.title;
+ const closestEdgeOfTarget = extractClosestEdge(targetData);
+ const targetIndex = targetData.index as number;
+ const sourceGroup = userSections.find(
+ (group) => group.title == sourceData.title,
+ );
+ const targetGroup = userSections.find(
+ (group) => group.title == groupTitle,
+ );
+ const sourceIndex = sourceData.index as number;
+ const destinationIndex = (
+ closestEdgeOfTarget === "top" ? targetIndex : targetIndex + 1
+ ) as number;
+
+ reorderSections(
+ sourceGroup,
+ targetGroup,
+ sourceIndex,
+ destinationIndex,
+ );
+ } else {
+ // If the target is a group
+ const groupTitle = targetData.title;
+ const sourceGroup = userSections.find(
+ (group) => group.title == sourceData.title,
+ );
+ const targetGroup = userSections.find(
+ (group) => group.title == groupTitle,
+ );
+ const sourceIndex = sourceData.index as number;
+
+ reorderSections(sourceGroup, targetGroup, sourceIndex);
+ }
+ },
+ });
+
+ /**
+ * Reorders sections within a group or between different groups.
+ *
+ * @param sourceGroup - The source group from which the section is being moved.
+ * @param destinationGroup - The destination group where the section is being moved to.
+ * @param sourceIndex - The index of the section within the source group.
+ * @param destinationIndex - The index where the section should be placed in the destination group.
+ * If null, the section will be placed at the end of the destination group.
+ */
+ function reorderSections(
+ sourceGroup: any,
+ destinationGroup: any,
+ sourceIndex: number,
+ destinationIndex: number | null = null,
+ ) {
+ if (sourceGroup && destinationGroup) {
+ if (
+ sourceGroup.title === destinationGroup.title &&
+ destinationIndex &&
+ sourceIndex < destinationIndex
+ ) {
+ destinationIndex -= 1; // Corrects the index within the same group if needed
+ }
+ if (
+ sourceGroup.title === destinationGroup.title &&
+ (destinationIndex == null || sourceIndex === destinationIndex)
+ ) {
+ return; // Nothing to do
+ }
+
+ if (sourceGroup.title === destinationGroup.title) {
+ const sourceItems = [...sourceGroup.items];
+
+ const [removed] = sourceItems.splice(sourceIndex, 1);
+
+ if (destinationIndex === null) {
+ destinationIndex = sourceItems.length;
+ }
+ sourceItems.splice(destinationIndex, 0, removed);
+
+ setSections((sections) =>
+ sections.map((section) =>
+ section.title === sourceGroup.title
+ ? { ...section, items: sourceItems }
+ : section,
+ ),
+ );
+ } else {
+ const sourceItems = [...sourceGroup.items];
+
+ const [removed] = sourceItems.splice(sourceIndex, 1);
+
+ const destinationItems = [...destinationGroup.items];
+
+ if (destinationIndex === null) {
+ destinationIndex = destinationItems.length;
+ }
+ destinationItems.splice(destinationIndex, 0, removed);
+
+ setSections((sections) =>
+ sections.map((section) =>
+ section.title === sourceGroup.title
+ ? { ...section, items: sourceItems }
+ : section.title === destinationGroup.title
+ ? { ...section, items: destinationItems }
+ : section,
+ ),
+ );
+ }
+ }
+ }
+ }, [setSections, userSections]);
+
+ /**
+ * Handles the creation of a new app in the dashboard drawer.
+ *
+ * @param appType - The type of the app to be created.
+ * @param icon - The icon component for the app.
+ */
+ const handleAppCreation = (appType: string, icon: ComponentType) => {
+ let group = userSections[userSections.length - 1];
+ const empty = !group;
+ if (empty) {
+ //create a new group if there is no group
+ group = {
+ title: `Group ${userSections.length + 1}`,
+ extended: false,
+ items: [],
+ };
+ }
+
+ let title = `${appType} ${userSections.reduce(
+ (sum, group) =>
+ sum + group.items.filter((item) => item.type === appType).length,
+ 1,
+ )}`;
+ while (group.items.some((item) => item.title === title)) {
+ title = `${appType} ${parseInt(title.split(" ")[1]) + 1}`;
+ }
+
+ const newApp = {
+ title,
+ id: `${title}${userSections.reduce(
+ (sum, group) => sum + group.items.length,
+ 0,
+ )}`,
+ type: appType,
+ icon: icon,
+ };
+ group.items.push(newApp);
+ if (empty) {
+ setSections([...userSections, group]);
+ } else {
+ setSections(
+ userSections.map((g) => (g.title === group.title ? group : g)),
+ );
+ }
+ };
+
+ let isContextStateStable = true;
+
+ const handleContextMenu =
+ (type: "group" | "item" | null = null, id: string | null = null) =>
+ (event: React.MouseEvent) => {
+ event.preventDefault();
+ if (contextMenu !== null) {
+ handleCloseContextMenu();
+ return;
+ }
+ setContextMenu({
+ mouseX: event.clientX + 2,
+ mouseY: event.clientY - 6,
+ });
+ if (isContextStateStable) {
+ setContextState({ type, id });
+ isContextStateStable = false;
+ }
+ };
+
+ const handleCloseContextMenu = () => {
+ setContextMenu(null);
+ setContextState({ type: null, id: null });
+ isContextStateStable = true;
+ };
+
+ const handleNewGroup = () => {
+ const newGroup = {
+ title: `Group ${userSections.length + 1}`,
+ extended: false,
+ items: [],
+ };
+ while (userSections.some((group) => group.title === newGroup.title)) {
+ newGroup.title = `Group ${parseInt(newGroup.title.split(" ")[1]) + 1}`;
+ }
+
+ setSections([...userSections, newGroup]);
+ handleCloseContextMenu();
+ };
+
+ const handleDelete = () => {
+ if (contextState.type === "group") {
+ const newSections = userSections.filter(
+ (group) => group.title !== contextState.id,
+ );
+ setSections(newSections);
+ } else if (contextState.type === "item") {
+ const newSections = userSections.map((group) => {
+ const newItems = group.items.filter(
+ (item) => item.id !== contextState.id,
+ );
+ return { ...group, items: newItems };
+ });
+ setSections(newSections);
+ }
+ handleCloseContextMenu();
+ };
+
+ const handleRenameClick = (event: any) => {
+ setPopAnchorEl(event.currentTarget);
+ };
+
+ const popClose = () => {
+ setRenameValue("");
+ setPopAnchorEl(null);
+ };
+
+ const handleRename = () => {
+ if (contextState.type === "group") {
+ //check if the name is already taken
+ if (userSections.some((group) => group.title === renameValue)) {
+ return;
+ }
+ //rename the group
+ const newSections = userSections.map((group) => {
+ if (group.title === contextState.id) {
+ return { ...group, title: renameValue };
+ }
+ return group;
+ });
+ setSections(newSections);
+ } else if (contextState.type === "item") {
+ const newSections = userSections.map((group) => {
+ const newItems = group.items.map((item) => {
+ if (item.id === contextState.id) {
+ return { ...item, title: renameValue };
+ }
+ return item;
+ });
+ return { ...group, items: newItems };
+ });
+ setSections(newSections);
+ }
+
+ popClose();
+ handleCloseContextMenu();
+ };
return (
-
-
- {/* Display the logo in the toolbar section of the drawer. */}
-
-
-
- {/* Map over user sections and render them as list items in the drawer. */}
-
- {Object.keys(userSections).map((title: string) => (
-
+ <>
+
+
+ {/* Display the logo in the toolbar section of the drawer. */}
+
+
+
+ {/* Map over user sections and render them as list items in the drawer. */}
+
+ {userSections.map((group) => (
+
+
+
+ ))}
+
+
+ {/* Render a link to documentation and a button to add applications, positioned at the bottom of the drawer. */}
+
+
+ setAppDialogOpen(true)}>
+
+
+
+
+
+
+
-
+
-
+
- ))}
-
- {/* Render a link to documentation, positioned at the bottom of the drawer. */}
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ >
);
}
diff --git a/src/components/ui/DataTable.tsx b/src/components/ui/DataTable.tsx
index 802084f0..3bfb6560 100644
--- a/src/components/ui/DataTable.tsx
+++ b/src/components/ui/DataTable.tsx
@@ -26,10 +26,12 @@ import {
Stack,
} from "@mui/material";
import { deepOrange } from "@mui/material/colors";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useSearchParams } from "next/navigation";
import { FilterToolbar } from "./FilterToolbar";
import { Filter } from "@/types/Filter";
import { Column } from "@/types/Column";
+import { useSearchParamsUtils } from "@/hooks/searchParamsUtils";
+import { ApplicationsContext } from "@/contexts/ApplicationsProvider";
/**
* Descending comparator function
@@ -343,39 +345,48 @@ export function DataTable(props: DataTableProps) {
id: number | null;
}>({ mouseX: null, mouseY: null, id: null });
// NextJS router and params
- const router = useRouter();
- const pathname = usePathname();
const searchParams = useSearchParams();
+ const { getParam, setParam } = useSearchParamsUtils();
+ const appId = getParam("appId");
- // Manage URL search params
- const createQueryString = React.useCallback(
- (filters: Filter[]) => {
- const params = new URLSearchParams(searchParams.toString());
- // Clear existing filters
- params.delete("filter");
-
- // Append new filters
- filters.forEach((filter) => {
- params.append(
- "filter",
- `${filter.id}_${filter.column}_${filter.operator}_${filter.value}`,
- );
- });
-
- return params.toString();
+ const updateFiltersAndUrl = React.useCallback(
+ (newFilters: Filter[]) => {
+ // Update the filters in the URL using the setParam function
+ setParam(
+ "filter",
+ newFilters.map(
+ (filter) =>
+ `${filter.id}_${filter.column}_${filter.operator}_${filter.value}`,
+ ),
+ );
},
- [searchParams],
+ [setParam],
);
- const updateFiltersAndUrl = React.useCallback(
+ const [sections, setSections] = React.useContext(ApplicationsContext);
+ const updateSectionFilters = React.useCallback(
(newFilters: Filter[]) => {
- // Generate the new query string with all filters
- const queryString = createQueryString(newFilters);
+ const appId = getParam("appId");
- // Push new URL to history without reloading the page
- router.push(pathname + "?" + queryString);
+ const section = sections.find((section) =>
+ section.items.some((item) => item.id === appId),
+ );
+ if (section) {
+ const newSection = {
+ ...section,
+ items: section.items.map((item) => {
+ if (item.id === appId) {
+ return { ...item, data: { filters: newFilters } };
+ }
+ return item;
+ }),
+ };
+ setSections((sections) =>
+ sections.map((s) => (s.title === section.title ? newSection : s)),
+ );
+ }
},
- [createQueryString, pathname, router],
+ [getParam, sections, setSections],
);
// Handle the application of filters
@@ -390,6 +401,8 @@ export function DataTable(props: DataTableProps) {
// Update the filters in the URL
updateFiltersAndUrl(filters);
+ // Update the filters in the sections
+ updateSectionFilters(filters);
};
React.useEffect(() => {
@@ -402,6 +415,10 @@ export function DataTable(props: DataTableProps) {
});
};
+ const item = sections
+ .find((section) => section.items.some((item) => item.id === appId))
+ ?.items.find((item) => item.id === appId);
+
if (searchParams.has("filter")) {
// Parse the filters when the component mounts or when the searchParams change
const initialFilters = parseFiltersFromUrl();
@@ -414,8 +431,26 @@ export function DataTable(props: DataTableProps) {
value: filter.value,
}));
setSearchBody({ search: jsonFilters });
+ } else if (item?.data?.filters) {
+ setFilters(item.data.filters);
+ const jsonFilters = item.data.filters.map(
+ (filter: {
+ id: number;
+ column: string;
+ operator: string;
+ value: string;
+ }) => ({
+ parameter: filter.column,
+ operator: filter.operator,
+ value: filter.value,
+ }),
+ );
+ setSearchBody({ search: jsonFilters });
+ } else {
+ setFilters([]);
+ setSearchBody({ search: [] });
}
- }, [searchParams, setFilters, setSearchBody]);
+ }, [appId, searchParams, sections, setFilters, setSearchBody]);
// Manage sorting
const handleRequestSort = (
diff --git a/src/components/ui/DiracLogo.tsx b/src/components/ui/DiracLogo.tsx
deleted file mode 100644
index cff07b6f..00000000
--- a/src/components/ui/DiracLogo.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import NextLink from "next/link";
-import Image from "next/image";
-
-/**
- * Logo of the DIRAC interware redirecting to the root page
- * @returns a NextLink embedding an Image
- */
-export function DiracLogo() {
- return (
- <>
-
-
-
- >
- );
-}
diff --git a/src/components/ui/DrawerItem.tsx b/src/components/ui/DrawerItem.tsx
new file mode 100644
index 00000000..71f11bb8
--- /dev/null
+++ b/src/components/ui/DrawerItem.tsx
@@ -0,0 +1,205 @@
+import React, { useEffect, useState } from "react";
+import { createRoot } from "react-dom/client";
+import {
+ ListItemButton,
+ ListItemIcon,
+ Icon,
+ ListItemText,
+} from "@mui/material";
+import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
+import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
+import {
+ draggable,
+ dropTargetForElements,
+} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
+import {
+ Edge,
+ attachClosestEdge,
+ extractClosestEdge,
+} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
+import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
+import { ThemeProvider as MUIThemeProvider } from "@mui/material/styles";
+import { ThemeProvider } from "@/contexts/ThemeProvider";
+import { useMUITheme } from "@/hooks/theme";
+import { useSearchParamsUtils } from "@/hooks/searchParamsUtils";
+
+/**
+ * Represents a drawer item component.
+ *
+ * @param item - The item object containing the title, id, and icon.
+ * @param index - The index of the item.
+ * @param groupTitle - The title of the group.
+ * @returns The rendered JSX for the drawer item.
+ */
+export default function DrawerItem({
+ item: { title, id, icon },
+ index,
+ groupTitle,
+}: {
+ item: { title: string; id: string; icon: React.ComponentType };
+ index: number;
+ groupTitle: string;
+}) {
+ // Ref to use for the draggable element
+ const dragRef = React.useRef(null);
+ // Ref to use for the handle of the draggable element, must be a child of the draggable element
+ const handleRef = React.useRef(null);
+ const theme = useMUITheme();
+ const { setParam } = useSearchParamsUtils();
+ // Represents the closest edge to the mouse cursor
+ const [closestEdge, setClosestEdge]: any = useState(null);
+
+ useEffect(() => {
+ if (!dragRef.current || !handleRef.current) return;
+ const element = dragRef.current;
+ const handleItem = handleRef.current;
+
+ return combine(
+ // makes the item draggable
+ draggable({
+ element: element,
+ dragHandle: handleItem,
+ // Sets the initial data for the drag and drop interaction
+ getInitialData: () => ({ index, title: groupTitle }),
+ // Sets a lightweight version of the real item as a preview
+ onGenerateDragPreview: ({ nativeSetDragImage, source, location }) => {
+ setCustomNativeDragPreview({
+ nativeSetDragImage,
+ render: ({ container }) => {
+ const root = createRoot(container);
+ root.render(
+ // Wraps the preview in the theme provider to ensure the correct theme is applied
+ // This is necessary because the preview is rendered outside the main app
+
+
+
+
+
+
+ ,
+ );
+ return () => root.unmount();
+ },
+ // Seamless transition between the preview and the real element
+ getOffset: ({ container }) => {
+ const elementPos = source.element.getBoundingClientRect();
+ const x = location.current.input.pageX - elementPos.x;
+ const y = location.current.input.pageY - elementPos.y;
+ return { x, y };
+ },
+ });
+ },
+ }),
+ // Makes the item a target for dragged elements. Attach the closest edge data and highlight the destination when hovering over the item
+ dropTargetForElements({
+ element: element,
+ getData: ({ input, element }) => {
+ return attachClosestEdge(
+ { index, title: groupTitle },
+ { input, element, allowedEdges: ["top", "bottom"] },
+ );
+ },
+ onDrag({ self, source }) {
+ const isSource = source.element === element;
+ if (isSource) {
+ setClosestEdge(null);
+ return;
+ }
+ const closestEdge = extractClosestEdge(self.data);
+
+ const sourceIndex = source.data.index;
+ if (typeof sourceIndex === "number") {
+ const isItemBeforeSource =
+ index === sourceIndex - 1 && source.data.title === title;
+ const isItemAfterSource =
+ index === sourceIndex + 1 && source.data.title === title;
+
+ const isDropIndicatorHidden =
+ (isItemBeforeSource && closestEdge === "bottom") ||
+ (isItemAfterSource && closestEdge === "top");
+
+ if (isDropIndicatorHidden) {
+ setClosestEdge(null);
+ return;
+ }
+ }
+ setClosestEdge(closestEdge);
+ },
+ onDragLeave() {
+ setClosestEdge(null);
+ },
+ onDrop: () => {
+ setClosestEdge(null);
+ },
+ }),
+ );
+ }, [index, groupTitle, icon, theme, title, id]);
+
+ return (
+ <>
+ setParam("appId", id)}
+ sx={{ pl: 2, borderRadius: 2, pr: 1 }}
+ ref={dragRef}
+ >
+
+
+
+
+
+
+
+ {closestEdge && }
+
+ >
+ );
+}
+
+/**
+ * Lightweight preview of an item in the drawer.
+ * Used when dragging an item to give a visual representation of it with minimal resources.
+ *
+ * @param {Object} props - The component props.
+ * @param {string} props.title - The title of the item.
+ * @param {React.ComponentType} props.icon - The icon component for the item.
+ * @returns {JSX.Element} The rendered item preview.
+ */
+function ItemPreview({
+ title,
+ icon,
+}: {
+ title: string;
+ icon: React.ComponentType;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/DrawerItemGroup.tsx b/src/components/ui/DrawerItemGroup.tsx
new file mode 100644
index 00000000..f70f7fd8
--- /dev/null
+++ b/src/components/ui/DrawerItemGroup.tsx
@@ -0,0 +1,96 @@
+import { Accordion, AccordionDetails, AccordionSummary } from "@mui/material";
+import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
+import React, { useEffect } from "react";
+import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
+import DrawerItem from "./DrawerItem";
+import { UserSection } from "@/types/UserSection";
+
+/**
+ * Represents a group of items in a drawer.
+ *
+ * @component
+ * @param {Object} props - The component props.
+ * @param {Object} props.group - The group object containing the title, expanded state, and items.
+ * @param {string} props.group.title - The title of the group.
+ * @param {boolean} props.group.extended - The expanded state of the group.
+ * @param {Array} props.group.items - The array of items in the group.
+ * @param {Function} props.setSections - The function to set the sections state.
+ * @param {Function} props.handleContextMenu - The function to handle the context menu.
+ * @returns {JSX.Element} The rendered DrawerItemGroup component.
+ */
+export default function DrawerItemGroup({
+ group: { title, extended: expanded, items },
+ setSections,
+ handleContextMenu,
+}: {
+ group: UserSection;
+ setSections: React.Dispatch>;
+ handleContextMenu: (
+ type: "group" | "item" | null,
+ id: string | null,
+ ) => (event: React.MouseEvent) => void;
+}) {
+ // Ref to use for the drag and drop target
+ const dropRef = React.useRef(null);
+ // State to track whether the user is hovering over the item during a drag operation
+ const [hovered, setHovered] = React.useState(false);
+
+ useEffect(() => {
+ if (!dropRef.current) return;
+ const dropItem = dropRef.current;
+
+ // Makes the element a valid drop target, sets up the data transfer and manage the hovered state
+ return dropTargetForElements({
+ element: dropItem,
+ getData: () => ({ title }),
+ onDragStart: () => setHovered(true),
+ onDrop: () => {
+ setHovered(false);
+ handleChange(title)(null, true);
+ },
+ onDragEnter: () => setHovered(true),
+ onDragLeave: () => setHovered(false),
+ });
+ });
+
+ // Handles expansion of the accordion group
+ const handleChange = (title: string) => (event: any, isExpanded: any) => {
+ // Set the extended state of the accordion group.
+ setSections((sections) =>
+ sections.map((section) =>
+ section.title === title
+ ? { ...section, extended: isExpanded }
+ : section,
+ ),
+ );
+ };
+ return (
+
+ {/* Accordion summary */}
+ }>
+ {title}
+
+ {/* Accordion details */}
+
+ {items.map(({ title: itemTitle, id, icon }, index) => (
+