Refactor updating how widgets work and removed prop drilling

This commit is contained in:
2026-06-09 14:06:01 -07:00
parent 7744d6b8ce
commit 305ee4e757
16 changed files with 474 additions and 110 deletions
@@ -1,6 +1,6 @@
import { Box, Menu, Portal } from '@chakra-ui/react'
import WidgetMenu from './WidgetMenu';
import AlertLevel from "@/components/Canvas/CanvasComponents/AlertLevelWidget.jsx";
import AlertLevel from "@/components/Widgets/AlertLevelWidget.jsx";
export default function FirstWidgetButton({ appendWidget }) {
const triggerItem = (
@@ -1,28 +0,0 @@
import { IconButton, Menu, Portal } from "@chakra-ui/react";
import { ArrowLeft } from 'lucide-react';
import { Settings } from 'lucide-react';
import WidgetMenu from "./WidgetMenu";
export function SettingsButton({ onEditWidgets, appendWidget}) {
const triggerItem = (<> <Menu.TriggerItem> <ArrowLeft /> Add Widget</Menu.TriggerItem> </>);
const WidgetMenuProps = { appendWidget, triggerItem }
return (
<Menu.Root>
<Menu.Trigger asChild >
<IconButton variant="ghost" size="md" borderRadius="xl">
<Settings />
</IconButton>
</Menu.Trigger>
<Portal>
<Menu.Positioner>
<Menu.Content>
<WidgetMenu {...WidgetMenuProps}/>
<Menu.Item value="eidt" onClick={onEditWidgets} >
Edit Widgets
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}
@@ -1,5 +1,5 @@
import { Menu, Portal } from "@chakra-ui/react";
import AlertLevel from "./AlertLevelWidget";
import AlertLevel from "../../Widgets/AlertLevelWidget.jsx";
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
return (
+1 -1
View File
@@ -4,7 +4,7 @@ import { ProjectCollector } from './AdminTabs/ProjectCollector';
import { NASApplication } from './AdminTabs/NASApplication';
import { IVANS } from './AdminTabs/IVANS'
import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton";
import { SettingsButton } from "../../SettingsButton.jsx";
import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs';
import { ADMIN_TABS } from '@/constants/viewKeys';
+2 -2
View File
@@ -1,13 +1,13 @@
import { HStack, Flex, Box } from "@chakra-ui/react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton";
import { SettingsButton } from "../../SettingsButton.jsx";
export default function Home() {
const location = useLocation();
const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/";
// 1. 🎨 CSS Magic: Replicates Chakra's `<Tabs variant="line">` exactly
// 1. CSS making this look like how we want it, TODO turn to theme?
const tabStyles = {
fontSize: "lg",
fontWeight: "normal",
@@ -1,74 +1,70 @@
import GridLayout, { WidthProvider } from 'react-grid-layout';
import { useSlotRecipe, chakra, Flex, SimpleGrid } from '@chakra-ui/react';
import CustomResizeHandle from '../../CanvasComponents/CustomResizeHandle';
import FirstWidgetButton from '../../CanvasComponents/FirstWidgetButton';
import { Box, Text, Flex } from "@chakra-ui/react";
import { Responsive, WidthProvider } from "react-grid-layout";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
export default function MyDashboard({layout, onLayoutChange, isEditable, isDelete, DeleteWidget, widgetArray, appendWidget}) {
const recipe = useSlotRecipe({ key: "widget" });
const styles = recipe(); // using root recipe for widget below
const ResponsiveGridLayout = WidthProvider(GridLayout); // canvas width now adjusts to window size
const handleDragStart = () => { document.body.style.userSelect = "none"; }; // don't allow text highlighting when dragging
const handleDragStop = () => { document.body.style.userSelect = ""; }; // allow text highlighting all other times
let merged = [];
// React-Grid-Layout requires this wrapper to automatically calculate screen width
const ResponsiveGridLayout = WidthProvider(Responsive);
if (widgetArray.length && layout.length) {
const widgetMap = new Map(widgetArray.map(item => [item.i, item]));
merged = layout.map(item => {
return {
...item,
...widgetMap.get(item.i)
};
});
}
export default function MyDashboard() {
// 1. ☁️ Connect to the Cloud (Zustand)
const widgets = useDashboardStore(state => state.widgets);
const isEditing = useDashboardStore(state => state.isEditing);
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
// Show large 'Add Widget' button when dashboard is empty
if ( !layout.length ) {
return (
<Flex
align="center"
justify="center"
h="100%"
w="100%"
bg="gray.150"
borderRadius="md"
>
<FirstWidgetButton appendWidget={ appendWidget } />
</Flex>
);
}
// 2. 🛠️ Format the data for RGL
// RGL expects a very specific array format for its layout prop
const rglLayout = widgets.map(w => ({
i: w.id,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
}));
// Show responsive grid of widgets
return (
<ResponsiveGridLayout
useCSSTransforms={false} // remove the sliding animation of the widgets
className="layout"
layout={layout}
cols={16} // Total columns in grid
rowHeight={40} // Height of a single row in px
isBounded={true} // Total width of the grid
margin={[15, 5]} // [horizontal, vertical] gap
onLayoutChange={onLayoutChange}
draggableHandle=".widget-handle" // Make only the header draggable
onDragStart={handleDragStart}
onDragStop={handleDragStop}
position="relative"
resizeHandle={<CustomResizeHandle isVisable={isEditable} />}
>
{ merged.map((item) => {
const Widget = item.widget;
return (
<Flex key={item.i} css={styles.root} boxShadow="md" height="100%" >
<Widget
id={item.i}
isEditable={isEditable}
isDelete={isDelete}
DeleteWidget={DeleteWidget}
appendWidget={appendWidget}
/>
<Box width="100%" height="100%">
<ResponsiveGridLayout
className="layout"
layouts={{ lg: rglLayout }} // We feed it the formatted layout map
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} // Our 12-column master grid
rowHeight={30}
isDraggable={isEditing} // Only allows dragging when your Settings tools toggle this
isResizable={false} // Double lock-down
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
compactType="vertical" // Auto-packs widgets to the top
>
{/* 3. 🎨 Paint the Widgets */}
{widgets.map(widget => (
<Box
key={widget.id}
bg="white"
borderRadius="md"
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
border={isEditing ? "2px dashed gray" : "1px solid"}
borderColor={isEditing ? "gray.400" : "gray.200"}
overflow="hidden"
cursor={isEditing ? "grab" : "default"}
>
{/* 🚧 Temporary Placeholder!
Eventually, we will swap this block out for a dynamic component loader
that looks at widget.type and loads the correct graph or table.
*/}
<Flex width="100%" height="100%" align="center" justify="center" direction="column">
<Text fontWeight="bold" color="gray.600">{widget.type}</Text>
<Text fontSize="sm" color="gray.400">Size: {widget.size}</Text>
</Flex>
</Box>
))}
</ResponsiveGridLayout>
{/* Empty State Helper */}
{widgets.length === 0 && (
<Flex width="100%" height="200px" align="center" justify="center">
<Text color="gray.400">Your dashboard is empty. Click Settings to add widgets.</Text>
</Flex>
);
})
}
</ResponsiveGridLayout>
)}
</Box>
);
}
}
@@ -8,7 +8,7 @@ import { Reports } from './VolcanoTabs/Reports';
import { OfficialInstitutions } from './VolcanoTabs/OfficialInstitutions';
import { SocialMedia } from './VolcanoTabs/SocialMedia';
import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton";
import { SettingsButton } from "../../SettingsButton.jsx";
export default function Volcano() {
return (
+148
View File
@@ -0,0 +1,148 @@
import { useState, useMemo } from "react";
import {
Button,
IconButton,
Box,
Text,
Flex,
Grid,
Dialog,
Tabs,
Badge,
Menu,
HStack
} from "@chakra-ui/react";
import { Settings, Plus, LayoutGrid, Move, Save, X } from 'lucide-react';
import { useDashboardStore } from "../store/useDashboardStore.jsx";
import { WIDGET_REGISTRY } from "../constants/widgetRegistry.jsx";
export function SettingsButton() {
// 1. ☁️ Store connections
const isEditing = useDashboardStore(state => state.isEditing);
const enterEditMode = useDashboardStore(state => state.enterEditMode);
const saveEdit = useDashboardStore(state => state.saveEdit);
const cancelEdit = useDashboardStore(state => state.cancelEdit);
const addWidget = useDashboardStore(state => state.addWidget);
// 2. 🎛️ Local state to control the Widget Library popup independently of the dropdown
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
// 3. 🧠 Categorize widgets for the tabs
const categorizedWidgets = useMemo(() => {
return Object.entries(WIDGET_REGISTRY).reduce((acc, [id, widget]) => {
const cat = widget.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push({ id, ...widget });
return acc;
}, {});
}, []);
const categories = Object.keys(categorizedWidgets);
return (
<>
{/* THE STATIC DROPDOWN MENU */}
<Menu.Root>
<Menu.Trigger asChild>
<IconButton
aria-label="Settings"
bg="vdap.darkGreen" // Or #1a2e24
color="white"
size="sm"
borderRadius="md"
_hover={{ bg: "vdap.darkGreenHover" }} // Or #111f18
>
<Settings size={18} />
</IconButton>
</Menu.Trigger>
<Menu.Content bg="white" boxShadow="lg" borderRadius="md" p={1} zIndex="dropdown">
{/* Dynamic Menu Options: What shows up depends on isEditing */}
{isEditing ? (
<>
<Menu.Item onClick={saveEdit} color="green.600" fontWeight="bold" cursor="pointer" _hover={{ bg: "green.50" }}>
<Save size={16} style={{ marginRight: '8px' }} /> Save Layout
</Menu.Item>
<Menu.Item onClick={cancelEdit} color="red.500" cursor="pointer" _hover={{ bg: "red.50" }}>
<X size={16} style={{ marginRight: '8px' }} /> Discard Changes
</Menu.Item>
</>
) : (
<Menu.Item onClick={enterEditMode} cursor="pointer" _hover={{ bg: "gray.100" }}>
<Move size={16} style={{ marginRight: '8px' }} /> Edit Layout
</Menu.Item>
)}
<Menu.Separator my={1} borderColor="gray.200" />
<Menu.Item onClick={() => setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
<LayoutGrid size={16} style={{ marginRight: '8px' }} /> Widget Library
</Menu.Item>
</Menu.Content>
</Menu.Root>
{/* THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
<Dialog.Root open={isLibraryOpen} onOpenChange={(e) => setIsLibraryOpen(e.open)} size="xl" placement="center">
<Dialog.Backdrop bg="blackAlpha.600" />
<Dialog.Positioner>
<Dialog.Content bg="gray.50" borderRadius="xl" overflow="hidden" boxShadow="xl" maxW="800px">
<Dialog.Header bg="vdap.darkGreen" color="white" py={4}>
<Dialog.Title fontSize="xl" fontWeight="bold">Add Dashboard Widgets</Dialog.Title>
</Dialog.Header>
<Dialog.Body p={0}>
<Tabs.Root defaultValue={categories[0]} variant="enclosed" colorScheme="blue">
<Tabs.List bg="white" px={4} pt={4} borderBottom="1px solid" borderColor="gray.200">
{categories.map(cat => (
<Tabs.Trigger key={cat} value={cat} pb={2}>{cat}</Tabs.Trigger>
))}
</Tabs.List>
{categories.map(cat => (
<Tabs.Content key={cat} value={cat} p={6}>
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
{categorizedWidgets[cat].map(widget => (
<Flex key={widget.id} direction="column" bg="white" p={4} borderRadius="md" border="1px solid" borderColor="gray.200" boxShadow="sm">
<Text fontWeight="bold" fontSize="md" mb={1}>{widget.name}</Text>
<Text fontSize="sm" color="gray.500" mb={4} flex="1">{widget.description}</Text>
<Box borderTop="1px solid" borderColor="gray.100" pt={3}>
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase" mb={2}>Add to Grid:</Text>
<HStack gap={2} flexWrap="wrap">
{Object.keys(widget.allowedSizes).map(sizeKey => (
<Badge
key={sizeKey}
as="button"
onClick={() => addWidget(widget.id, sizeKey)}
colorScheme="gray"
variant="subtle"
px={3}
py={1}
borderRadius="full"
cursor="pointer"
_hover={{ bg: "blue.50", color: "blue.600", transform: "scale(1.05)" }}
transition="all 0.2s"
>
<Plus size={12} style={{ display: 'inline', marginRight: '4px' }}/>
{sizeKey.charAt(0).toUpperCase() + sizeKey.slice(1)}
</Badge>
))}
</HStack>
</Box>
</Flex>
))}
</Grid>
</Tabs.Content>
))}
</Tabs.Root>
</Dialog.Body>
<Dialog.CloseTrigger position="absolute" top={3} right={3} color="white" />
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
}
@@ -1,4 +1,4 @@
import Widget from "./Widget";
import Widget from "./Widget.jsx";
import { Fieldset, NativeSelect, For, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useState } from "react";