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
+136
View File
@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ChangeListManager">
<list default="true" id="e601b4a6-af0f-4558-b267-11e152fb4728" name="Changes" comment="">
<change afterPath="$PROJECT_DIR$/client/src/components/SettingsButton.jsx" afterDir="false" />
<change afterPath="$PROJECT_DIR$/client/src/store/useDashboardStore.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/client/package-lock.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/client/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/App.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/App.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/AlertLevelWidget.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Widgets/AlertLevelWidget.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/FirstWidgetButton.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/FirstWidgetButton.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/SettingsButton.jsx" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/Widget.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Widgets/Widget.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/WidgetMenu.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Canvas/CanvasComponents/WidgetMenu.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/views/Admin.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Canvas/views/Admin.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/views/Home.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Canvas/views/Home.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/views/HomeTabs/MyDashboard.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Canvas/views/HomeTabs/MyDashboard.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/components/Canvas/views/Volcano.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/components/Canvas/views/Volcano.jsx" afterDir="false" />
<change beforePath="$PROJECT_DIR$/client/src/constants/widgetRegistry.jsx" beforeDir="false" afterPath="$PROJECT_DIR$/client/src/constants/widgetRegistry.jsx" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 7,
&quot;fromUser&quot;: false
}</component>
<component name="ProjectId" id="3Cm41UmuQbyMzWIumGVSngrYAr7" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;codeWithMe.voiceChat.enabledByDefault&quot;: &quot;false&quot;,
&quot;git-widget-placeholder&quot;: &quot;20260605__1123__Hight__updates&quot;,
&quot;javascript.preferred.runtime.type.id&quot;: &quot;node&quot;,
&quot;last_opened_file_path&quot;: &quot;/Users/chight/Documents/Development/website-framework&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;npm.build.executor&quot;: &quot;Run&quot;,
&quot;npm.dev.executor&quot;: &quot;Run&quot;,
&quot;ts.external.directory.path&quot;: &quot;/Users/chight/Applications/WebStorm.app/Contents/plugins/javascript-plugin/jsLanguageServicesImpl/external&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/client/src/components/Widgets" />
<recent name="$PROJECT_DIR$/client/src/components" />
</key>
</component>
<component name="RunManager">
<configuration name="dev" type="js.build_tools.npm" nameIsGenerated="true">
<package-json value="$PROJECT_DIR$/client/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-js-predefined-d6986cc7102b-3bd3a6803838-JavaScript-WS-261.22158.274" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="e601b4a6-af0f-4558-b267-11e152fb4728" name="Changes" comment="" />
<created>1776977196451</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1776977196451</updated>
<workItem from="1776977197454" duration="13121000" />
<workItem from="1777909177585" duration="1804000" />
<workItem from="1779808433040" duration="66743000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="Vcs.Log.Tabs.Properties">
<option name="RECENT_FILTERS">
<map>
<entry key="Branch">
<value>
<list>
<RecentGroup>
<option name="FILTER_VALUES">
<option value="authentication" />
</option>
</RecentGroup>
</list>
</value>
</entry>
</map>
</option>
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State>
<option name="FILTERS">
<map>
<entry key="branch">
<value>
<list>
<option value="authentication" />
</list>
</value>
</entry>
</map>
</option>
</State>
</value>
</entry>
</map>
</option>
</component>
</project>
+32 -2
View File
@@ -22,7 +22,8 @@
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^7.16.0", "react-router-dom": "^7.16.0",
"uuid": "^11.1.0" "uuid": "^11.1.0",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
@@ -1635,7 +1636,7 @@
"version": "19.1.8", "version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"dev": true, "devOptional": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -4541,6 +4542,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zustand": {
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }
+2 -1
View File
@@ -24,7 +24,8 @@
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"react-resizable": "^3.0.5", "react-resizable": "^3.0.5",
"react-router-dom": "^7.16.0", "react-router-dom": "^7.16.0",
"uuid": "^11.1.0" "uuid": "^11.1.0",
"zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
+3 -5
View File
@@ -11,6 +11,7 @@ import RegionalMap from "./components/Canvas/views/RegionalMap.jsx";
import Volcano from "@/components/Canvas/views/Volcano.jsx"; import Volcano from "@/components/Canvas/views/Volcano.jsx";
import GlobalMap from "./components/Canvas/views/GlobalMap.jsx"; import GlobalMap from "./components/Canvas/views/GlobalMap.jsx";
import Admin from "./components/Canvas/views/Admin.jsx"; import Admin from "./components/Canvas/views/Admin.jsx";
import MyDashboard from "./components/Canvas/views/HomeTabs/MyDashboard.jsx";
function AppFoundation() { function AppFoundation() {
const recipe = useRecipe({ key: "dashboard" }); const recipe = useRecipe({ key: "dashboard" });
@@ -50,9 +51,6 @@ const Placeholder = ({ name }) => (
</Flex> </Flex>
); );
function App() { function App() {
const sidebarContext = createContext(["Home", "Global Map", "Regional Map", "Volcano", "Admin"]);
// const [user, setUser] = useState(null);
return ( return (
<Routes> <Routes>
{/*Public Routes*/} {/*Public Routes*/}
@@ -63,8 +61,8 @@ function App() {
<Route path="/home" element={<Home />}> <Route path="/home" element={<Home />}>
{/* We use our "pass" component to fill the Outlet until the real pages are built */} {/* We use our "pass" component to fill the Outlet until the real pages are built */}
<Route index element={<Placeholder name="My" />} /> <Route index element={<MyDashboard />} />
<Route path="gas" element={<Placeholder name="Gas" />} /> <Route path="gas" element={<MyDashboard />} />
<Route path="seismic" element={<Placeholder name="Seismic" />} /> <Route path="seismic" element={<Placeholder name="Seismic" />} />
<Route path="remote" element={<Placeholder name="Remote Sensing" />} /> <Route path="remote" element={<Placeholder name="Remote Sensing" />} />
<Route path="daily" element={<Placeholder name="Daily Activity" />} /> <Route path="daily" element={<Placeholder name="Daily Activity" />} />
@@ -1,6 +1,6 @@
import { Box, Menu, Portal } from '@chakra-ui/react' import { Box, Menu, Portal } from '@chakra-ui/react'
import WidgetMenu from './WidgetMenu'; import WidgetMenu from './WidgetMenu';
import AlertLevel from "@/components/Canvas/CanvasComponents/AlertLevelWidget.jsx"; import AlertLevel from "@/components/Widgets/AlertLevelWidget.jsx";
export default function FirstWidgetButton({ appendWidget }) { export default function FirstWidgetButton({ appendWidget }) {
const triggerItem = ( 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 { Menu, Portal } from "@chakra-ui/react";
import AlertLevel from "./AlertLevelWidget"; import AlertLevel from "../../Widgets/AlertLevelWidget.jsx";
export default function WidgetMenu({appendWidget, triggerItem, positioning}) { export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
return ( return (
+1 -1
View File
@@ -4,7 +4,7 @@ import { ProjectCollector } from './AdminTabs/ProjectCollector';
import { NASApplication } from './AdminTabs/NASApplication'; import { NASApplication } from './AdminTabs/NASApplication';
import { IVANS } from './AdminTabs/IVANS' import { IVANS } from './AdminTabs/IVANS'
import { SearchBar } from "../CanvasComponents/SearchBar"; import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton"; import { SettingsButton } from "../../SettingsButton.jsx";
import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs'; import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs';
import { ADMIN_TABS } from '@/constants/viewKeys'; import { ADMIN_TABS } from '@/constants/viewKeys';
+2 -2
View File
@@ -1,13 +1,13 @@
import { HStack, Flex, Box } from "@chakra-ui/react"; import { HStack, Flex, Box } from "@chakra-ui/react";
import { NavLink, Outlet, useLocation } from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import { SearchBar } from "../CanvasComponents/SearchBar"; import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton"; import { SettingsButton } from "../../SettingsButton.jsx";
export default function Home() { export default function Home() {
const location = useLocation(); const location = useLocation();
const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/"; 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 = { const tabStyles = {
fontSize: "lg", fontSize: "lg",
fontWeight: "normal", fontWeight: "normal",
@@ -1,74 +1,70 @@
import GridLayout, { WidthProvider } from 'react-grid-layout'; import { Box, Text, Flex } from "@chakra-ui/react";
import { useSlotRecipe, chakra, Flex, SimpleGrid } from '@chakra-ui/react'; import { Responsive, WidthProvider } from "react-grid-layout";
import CustomResizeHandle from '../../CanvasComponents/CustomResizeHandle'; import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import FirstWidgetButton from '../../CanvasComponents/FirstWidgetButton';
export default function MyDashboard({layout, onLayoutChange, isEditable, isDelete, DeleteWidget, widgetArray, appendWidget}) { // React-Grid-Layout requires this wrapper to automatically calculate screen width
const recipe = useSlotRecipe({ key: "widget" }); const ResponsiveGridLayout = WidthProvider(Responsive);
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 = [];
if (widgetArray.length && layout.length) { export default function MyDashboard() {
const widgetMap = new Map(widgetArray.map(item => [item.i, item])); // 1. ☁️ Connect to the Cloud (Zustand)
merged = layout.map(item => { const widgets = useDashboardStore(state => state.widgets);
return { const isEditing = useDashboardStore(state => state.isEditing);
...item, const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
...widgetMap.get(item.i)
};
});
}
// Show large 'Add Widget' button when dashboard is empty // 2. 🛠️ Format the data for RGL
if ( !layout.length ) { // RGL expects a very specific array format for its layout prop
return ( const rglLayout = widgets.map(w => ({
<Flex i: w.id,
align="center" x: w.x,
justify="center" y: w.y,
h="100%" w: w.w,
w="100%" h: w.h,
bg="gray.150" isResizable: false // 🚫 The magic lock! No resizing allowed globally.
borderRadius="md" }));
>
<FirstWidgetButton appendWidget={ appendWidget } />
</Flex>
);
}
// Show responsive grid of widgets
return ( return (
<ResponsiveGridLayout <Box width="100%" height="100%">
useCSSTransforms={false} // remove the sliding animation of the widgets <ResponsiveGridLayout
className="layout" className="layout"
layout={layout} layouts={{ lg: rglLayout }} // We feed it the formatted layout map
cols={16} // Total columns in grid breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
rowHeight={40} // Height of a single row in px cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} // Our 12-column master grid
isBounded={true} // Total width of the grid rowHeight={30}
margin={[15, 5]} // [horizontal, vertical] gap isDraggable={isEditing} // Only allows dragging when your Settings tools toggle this
onLayoutChange={onLayoutChange} isResizable={false} // Double lock-down
draggableHandle=".widget-handle" // Make only the header draggable onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
onDragStart={handleDragStart} compactType="vertical" // Auto-packs widgets to the top
onDragStop={handleDragStop} >
position="relative" {/* 3. 🎨 Paint the Widgets */}
resizeHandle={<CustomResizeHandle isVisable={isEditable} />} {widgets.map(widget => (
> <Box
{ merged.map((item) => { key={widget.id}
const Widget = item.widget; bg="white"
return ( borderRadius="md"
<Flex key={item.i} css={styles.root} boxShadow="md" height="100%" > boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
<Widget border={isEditing ? "2px dashed gray" : "1px solid"}
id={item.i} borderColor={isEditing ? "gray.400" : "gray.200"}
isEditable={isEditable} overflow="hidden"
isDelete={isDelete} cursor={isEditing ? "grab" : "default"}
DeleteWidget={DeleteWidget} >
appendWidget={appendWidget} {/* 🚧 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> </Flex>
); )}
}) </Box>
}
</ResponsiveGridLayout>
); );
} }
@@ -8,7 +8,7 @@ import { Reports } from './VolcanoTabs/Reports';
import { OfficialInstitutions } from './VolcanoTabs/OfficialInstitutions'; import { OfficialInstitutions } from './VolcanoTabs/OfficialInstitutions';
import { SocialMedia } from './VolcanoTabs/SocialMedia'; import { SocialMedia } from './VolcanoTabs/SocialMedia';
import { SearchBar } from "../CanvasComponents/SearchBar"; import { SearchBar } from "../CanvasComponents/SearchBar";
import { SettingsButton } from "../CanvasComponents/SettingsButton"; import { SettingsButton } from "../../SettingsButton.jsx";
export default function Volcano() { export default function Volcano() {
return ( 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 { Fieldset, NativeSelect, For, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useState } from "react"; import { useState } from "react";
+5
View File
@@ -9,6 +9,7 @@
export const WIDGET_REGISTRY = { export const WIDGET_REGISTRY = {
"vaa_activity": { "vaa_activity": {
name: "VAA 7 Day Activity", name: "VAA 7 Day Activity",
category: "Aviation",
description: "Displays 7-day flight level timelines across multiple volcanoes.", description: "Displays 7-day flight level timelines across multiple volcanoes.",
allowedSizes: { allowedSizes: {
large: { w: 12, h: 8 }, // Spans the entire width of the screen large: { w: 12, h: 8 }, // Spans the entire width of the screen
@@ -17,6 +18,7 @@ export const WIDGET_REGISTRY = {
"db_cache_health": { "db_cache_health": {
name: "DB Cache Health", name: "DB Cache Health",
category: "Computer Science",
description: "Bar chart showing cache table operational status.", description: "Bar chart showing cache table operational status.",
allowedSizes: { allowedSizes: {
medium: { w: 4, h: 6 }, // 1/3 width medium: { w: 4, h: 6 }, // 1/3 width
@@ -26,6 +28,7 @@ export const WIDGET_REGISTRY = {
"ivans_alerts": { "ivans_alerts": {
name: "IVANS", name: "IVANS",
category: "Communications",
description: "Feed of recent IVANS reports and reviewer statuses.", description: "Feed of recent IVANS reports and reviewer statuses.",
allowedSizes: { allowedSizes: {
small: { w: 3, h: 6 }, // 1/4 width (tall and skinny) small: { w: 3, h: 6 }, // 1/4 width (tall and skinny)
@@ -35,6 +38,7 @@ export const WIDGET_REGISTRY = {
"remote_sensing": { "remote_sensing": {
name: "Remote Sensing", name: "Remote Sensing",
category: "Remote Sensing",
description: "Feed of recent remote sensing reports.", description: "Feed of recent remote sensing reports.",
allowedSizes: { allowedSizes: {
medium: { w: 4, h: 5 }, // 1/3 width medium: { w: 4, h: 5 }, // 1/3 width
@@ -43,6 +47,7 @@ export const WIDGET_REGISTRY = {
"alert_levels": { "alert_levels": {
name: "Alert Level Changes", name: "Alert Level Changes",
category: "Daily Alerts",
description: "List of 7-day alert level changes with trend arrows.", description: "List of 7-day alert level changes with trend arrows.",
allowedSizes: { allowedSizes: {
small: { w: 3, h: 5 }, small: { w: 3, h: 5 },
+78
View File
@@ -0,0 +1,78 @@
import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx';
export const useDashboardStore = create((set, get) => ({
// ==========================================
// 1. STATE (The Data)
// ==========================================
isEditing: false,
widgets: [], // The master array of what is currently on the screen
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
// ==========================================
// 2. ACTIONS (The Logic)
// ==========================================
enterEditMode: () => set({
isEditing: true,
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
}),
cancelEdit: () => set({
isEditing: false,
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
}),
saveEdit: async () => {
const payload = get().widgets;
// 💾 This is perfectly formatted for your backend DB right now
console.log("Saving dashboard to API:", JSON.stringify(payload, null, 2));
// Example: await fetch('/api/user/dashboard', { method: 'POST', body: JSON.stringify(payload) });
set({ isEditing: false, backupWidgets: [] });
},
addWidget: (widgetId, sizeKey) => {
// Look up the exact rules for this widget from your constant
const registryEntry = WIDGET_REGISTRY[widgetId];
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
return;
}
const dimensions = registryEntry.allowedSizes[sizeKey];
const newWidget = {
id: uuid(),
type: widgetId,
size: sizeKey,
x: 0,
y: Infinity, // RGL's auto-packer drops this exactly into the first available slot at the bottom
w: dimensions.w,
h: dimensions.h
};
set((state) => ({ widgets: [...state.widgets, newWidget] }));
},
removeWidget: (id) => set((state) => ({
widgets: state.widgets.filter(w => w.id !== id)
})),
// Called automatically by React-Grid-Layout when the user lets go of the mouse
updateLayoutPositions: (rglLayout) => {
set((state) => ({
widgets: state.widgets.map(widget => {
// Find where RGL says this widget currently is
const gridItem = rglLayout.find(l => l.i === widget.id);
// Only update X and Y. We completely ignore W and H so users can't resize!
return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget;
})
}));
}
}));