From 306823a8a8d096ee83ff89d06708ce1575538d9e Mon Sep 17 00:00:00 2001 From: vkuchenik Date: Thu, 29 Oct 2026 13:34:53 -0700 Subject: [PATCH] add: data explorer page added to sidebar with wsu students code being rendered in said page --- client/src/App.jsx | 2 + client/src/components/layout/Sidebar.jsx | 7 +- .../src/pages/data_explorer/DataExplorer.jsx | 190 +++++++++++ client/src/pages/data_explorer/DataTable.jsx | 220 +++++++++++++ .../src/pages/data_explorer/QueryBuilder.jsx | 299 ++++++++++++++++++ client/src/theme.js | 8 +- 6 files changed, 719 insertions(+), 7 deletions(-) create mode 100644 client/src/pages/data_explorer/DataExplorer.jsx create mode 100644 client/src/pages/data_explorer/DataTable.jsx create mode 100644 client/src/pages/data_explorer/QueryBuilder.jsx diff --git a/client/src/App.jsx b/client/src/App.jsx index ca16750..7def33b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -20,6 +20,7 @@ import Admin from "./pages/admin/Admin.jsx"; import MyDashboard from "./pages/home/tabs/MyDashboard.jsx"; import Settings from "./pages/auth/Settings.jsx"; import WebAdmin from "./pages/auth/WebAdmin.jsx"; +import DataExplorer from "@/pages/data_explorer/DataExplorer.jsx"; export default function App() { // 1. Pull the initialization function from Zustand @@ -53,6 +54,7 @@ export default function App() { }/> }/> }/> + }/> diff --git a/client/src/components/layout/Sidebar.jsx b/client/src/components/layout/Sidebar.jsx index 9b3c49e..6f25147 100644 --- a/client/src/components/layout/Sidebar.jsx +++ b/client/src/components/layout/Sidebar.jsx @@ -1,4 +1,4 @@ -import {Mountain, House, Earth, MapPin, ShieldUser, PanelLeftClose, PanelLeftOpen} from 'lucide-react'; +import {Mountain, House, Earth, MapPin, ShieldUser, PanelLeftClose, PanelLeftOpen, ChartScatter, Telescope, Map, UserLock} from 'lucide-react'; import {useSlotRecipe, Box, Button, IconButton, Text, Flex} from "@chakra-ui/react"; import {ColorModeButton} from "@/components/ui/color-mode.jsx"; import {NavLink} from "react-router-dom"; @@ -7,9 +7,10 @@ import {useState} from "react"; const SIDEBAR_DEFAULT_BUTTONS = [ {icon: House, name: "Home", url: "/home"}, {icon: Earth, name: "Global Map", url: "/global-map"}, - {icon: MapPin, name: "Regional Map", url: "/regional-map"}, + {icon: Map, name: "Regional Map", url: "/regional-map"}, {icon: Mountain, name: "Volcano", url: "/volcano"}, - {icon: ShieldUser, name: "Admin", url: "/admin"} // TODO remove in the future as default should not have admin + {icon: Telescope, name: "Data Explorer", url: "/data-explorer"}, + {icon: UserLock, name: "Admin", url: "/admin"}, // TODO remove in the future as default should not have admin ]; export default function Sidebar() { diff --git a/client/src/pages/data_explorer/DataExplorer.jsx b/client/src/pages/data_explorer/DataExplorer.jsx new file mode 100644 index 0000000..6637773 --- /dev/null +++ b/client/src/pages/data_explorer/DataExplorer.jsx @@ -0,0 +1,190 @@ +import React, { useState, useRef, useEffect } from "react"; +import { Box, Stack, Separator, Center, Spinner, Alert, Flex } from "@chakra-ui/react"; +import { toaster } from "../../components/ui/toaster.jsx"; // Adjust path to your v3 toaster if needed + +// Import our newly consolidated engine and presentation components +import QueryBuilder from "./QueryBuilder.jsx"; +import DataTable from "./DataTable.jsx"; +// import { exportToCSV } from "../utils/csvExport.js"; + +export default function DataExplorer() { + // 1. Core State + const [results, setResults] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [allColumns, setAllColumns] = useState([]); + const isReadyRef = useRef(false); + + // 2. The Master Query State + const [url, setUrl] = useState({ + source: "", + page: 1, + columns: [], + bundles: [], + ordering: "id", + }); + + // 3. API & URL Logic + const buildUrlParams = () => { + const { source, page, columns, bundles, ordering } = url; + const params = new URLSearchParams(); + + if (bundles.length > 0) { + // Scrub out incomplete filters before sending to the backend + const cleanBundles = bundles.map(bundle => { + const validFilters = bundle.filters + .filter(f => f.column && f.type && f.value) + .map(({ id, ...rest }) => rest); + return { ...bundle, filters: validFilters }; + }).filter(bundle => bundle.filters.length > 0); // Only send bundles that actually have filters + + if (cleanBundles.length > 0) { + params.append("bundles", JSON.stringify(cleanBundles)); + } + } + + if (source) params.append("source", source); + if (columns?.length > 0) params.append("columns", columns.join(",")); + if (ordering) params.append("ordering", ordering); + if (page) params.append("page", page); + + return params.toString(); + }; + + const getColumns = async (endpoint) => { + setError(null); + isReadyRef.current = false; // does this need to be set to true at the end? + try { + // 🚨 Relative pathing so Traefik proxy handles it + const response = await fetch(endpoint); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const data = await response.json(); + setAllColumns(data); + setUrl({ ...url, columns: Object.keys(data.types), source: data.view }); // changed it from (prev => {...prev, columns: __, source: __ }) + } catch (e) { + setError(e.message); + } finally { + setIsLoading(false); + } + }; + + const pullTable = async () => { + setIsLoading(true); + setError(null); + + if (!url.source) { + setError("Please select a source (table) before fetching data."); + setIsLoading(false); + return; + } + + try { + // 🚨 Relative pathing for your production API + const response = await fetch(`/api/v2/table_view?${buildUrlParams()}`); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + + const data = await response.json(); + setResults(data); + } catch (e) { + setError(e.message); + } finally { + setIsLoading(false); + } + }; + + // Auto-fetch when pagination, columns, or sorting changes (but skip first render) + useEffect(() => { + if (! isReadyRef.current) { + isReadyRef.current = false; // why are we resetting the readyRef back to false since it already is false? + return; + } + pullTable(); + }, [url.page, url.columns, url.ordering]); + + // 4. Export Utility + const handleDownloadCSV = () => { + if (results?.results?.length > 0) { + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `${url.source}_${timestamp}.csv`; + // exportToCSV(results.results, filename); + toaster.create({ + title: "Dummy Success Toaster (csv download fn has been disabled for now)", + description: `Downloaded ${results.results.length} records as CSV`, + type: "success", + }); + } else { + toaster.create({ + title: "No Data", + description: "No results to download", + type: "error", + }); + } + }; + + // 5. Render Shell + return ( + // 🚨 Stripped hardcoded #FFFBFA background. + // This Flex container will perfectly fill your new Sidebar Route canvas. + + + + {/* The Engine */} + + + {results && } + + {/* Global Loading & Error States */} + {isLoading && ( +
+ +
+ )} + + {error && ( + + + + Error Fetching Data + {error} + + + )} + + {/* The Display (Table, Pagination, Download, Columns) */} + {results && !isLoading && !error && ( + + {results.results.length > 0 ? ( + + ) : ( + + + + No Results Found + Try loosening your filter criteria. + + + )} + + )} + +
+
+ ); +}; \ No newline at end of file diff --git a/client/src/pages/data_explorer/DataTable.jsx b/client/src/pages/data_explorer/DataTable.jsx new file mode 100644 index 0000000..361a949 --- /dev/null +++ b/client/src/pages/data_explorer/DataTable.jsx @@ -0,0 +1,220 @@ +import React from "react"; +import { + Button, + ButtonGroup, + Table, + HoverCard, + Text, + Flex, + Menu, + Portal, + useCheckboxGroup, + IconButton, + Pagination, +} from "@chakra-ui/react"; +import { ChevronDown, ChevronLeft, ChevronRight, Download, Files, ClipboardPaste } from 'lucide-react'; +// import { LuChevronDown, LuChevronLeft, LuChevronRight } from "react-icons/lu"; +// import { FiDownload, FiCopy, FiClipboard } from "react-icons/fi"; + +// ============================================================================ +// 1. COLUMN SELECTOR (Toggles visible columns) +// ============================================================================ +const ColumnSelector = ({ allColumns, url, setUrl, isLoading }) => { + const group = useCheckboxGroup({ defaultValue: url.columns || Object.keys(allColumns) }); + + return ( + + + + + + + + + Available Columns + {Object.keys(allColumns).map((colName) => ( + group.toggleValue(colName)} + color="text" + > + + {colName} + + ))} + + setUrl((prev) => ({ ...prev, columns: [...group.value] }))} + p="2" + > + + + + + + + ); +}; + +// ============================================================================ +// 2. PAGE FOOTER (Pagination & Query Saving) +// ============================================================================ +const PageFooter = ({ count, pageSize, url, setUrl }) => { + const page = url.page || 1; + + const handleSaveQuery = () => { + navigator.clipboard.writeText(JSON.stringify(url)).then(() => { + alert("Query copied to clipboard!"); // Optional: replace with your Toast + }); + }; + + const handlePasteQuery = async () => { + try { + const text = await navigator.clipboard.readText(); + const clipBoardText = JSON.parse(text); + setUrl(clipBoardText); + } catch (err) { + console.error("Invalid clipboard content", err); + alert("Failed to paste. Make sure you copied a valid query."); + } + }; + + return ( + + {/* Query Save/Paste Tools */} + + + + + + {/* Pagination Controls */} + + + + setUrl((prev) => ({ ...prev, page: page - 1 }))} isDisabled={page <= 1} color="text"> + + + + + ( + setUrl((prev) => ({ ...prev, page: pageItem.value }))} + > + {pageItem.value} + + )} + /> + + + setUrl((prev) => ({ ...prev, page: page + 1 }))} isDisabled={page * pageSize >= count} color="text"> + + + + + + + ); +}; + +// ============================================================================ +// 3. MAIN DATA TABLE (The Exported Presentation Wrapper) +// ============================================================================ +const DataTable = React.memo(({ data, count, url, setUrl, allColumns, isLoading, onDownload }) => { + const columns = Object.keys(data[0] || {}); + + const handleSort = (col) => { + let order = col; + if (url.ordering === col) { + order = `-${col}`; + } + setUrl((prev) => ({ ...prev, ordering: order })); + }; + + return ( + + {/* Top Table Toolbar */} + + {allColumns && ( + + )} + + + + {/* Main Scrollable Table */} + + + + + {columns.map((col) => ( + + + + ))} + + + + + {data.map((row, i) => ( + + {columns.map((col) => ( + + + + {row[col]} + + + + {row[col]} + + + + + ))} + + ))} + + + + + {/* Bottom Pagination Footer */} + + + ); +}); + +export default DataTable; \ No newline at end of file diff --git a/client/src/pages/data_explorer/QueryBuilder.jsx b/client/src/pages/data_explorer/QueryBuilder.jsx new file mode 100644 index 0000000..c7e9ec0 --- /dev/null +++ b/client/src/pages/data_explorer/QueryBuilder.jsx @@ -0,0 +1,299 @@ +import React, { useRef } from "react"; +import { Button, Stack, Flex, Field, NativeSelect, Input, HoverCard, Text, NumberInput, Box } from "@chakra-ui/react"; +import { Plus, CircleMinus, Files, ClipboardPaste, Trash } from 'lucide-react'; +// import { GrAdd, GrSubtractCircle } from "react-icons/gr"; +// import { FaCopy, FaPaste, FaTrash } from "react-icons/fa"; + +// ============================================================================ +// 1. FILTER ROW (The individual criteria inputs) +// ============================================================================ +const FilterRow = React.memo(({ filter, columns, setUrl, bundleId, copiedFilter }) => { + const types = { + AutoField: ["=", "<", ">", "<=", ">="], + IntegerField: ["=", "<", ">", "<=", ">="], + CharField: ["=", "contains"], + DateTimeField: ["range"], + }; + + const columnType = columns[filter.column]; + const operators = types[columnType] || ["="]; // default to equals if unknown + + const handleRemoveFilter = () => { + setUrl((prev) => ({ + ...prev, + bundles: prev.bundles.map((bundle) => + bundle.id === bundleId ? + { ...bundle, filters: bundle.filters.filter((fil) => fil.id !== filter.id) } + : bundle + ), + })); + }; + + const updateFilterField = (field, value) => { + setUrl((prev) => ({ + ...prev, + bundles: prev.bundles.map((bundle) => + bundle.id === bundleId + ? { + ...bundle, + filters: bundle.filters.map((fil) => + fil.id === filter.id ? { ...fil, [field]: value } : fil + ), + } + : bundle + ), + })); + }; + + const handleColumnChange = (newCol) => { + setUrl((prev) => ({ + ...prev, + bundles: prev.bundles.map((bundle) => + bundle.id === bundleId + ? { + ...bundle, + filters: bundle.filters.map((fil) => + fil.id === filter.id + ? { ...fil, column: newCol, type: columns[newCol], operator: null, value: null } + : fil + ), + } + : bundle + ), + })); + }; + + const handleDateChange = (type, newDate) => { + const currentValue = filter.value || { start: null, end: null }; + const updatedValue = { ...currentValue, [type]: newDate || null }; + updateFilterField("value", updatedValue); + updateFilterField("operator", "range"); + }; + + return ( + + {/* Remove button */} + + + + + + Remove Filter + + + + {/* Copy / Paste Buttons */} + + + + + + Copy Filter + + + + + + + + + Paste Filter + + + + {/* Column selector */} + + + handleColumnChange(e.target.value)} + > + + {Object.keys(columns).map((colName) => ( + + ))} + + + + + + {/* Operator selector */} + + {columnType === "DateTimeField" ? ( + handleDateChange("start", e.target.value)} /> + ) : ( + + updateFilterField("operator", e.target.value)}> + + {filter.type && operators.map((op) => )} + + + + )} + + + {/* Value input */} + + {columnType === "DateTimeField" ? ( + handleDateChange("end", e.target.value)} /> + ) : columnType === "IntegerField" || columnType === "AutoField" ? ( + updateFilterField("value", e.value)}> + + + ) : ( + updateFilterField("value", e.target.value)} /> + )} + + + ); +}); + +// ============================================================================ +// 2. FILTER BUNDLES (The AND logic groups) +// ============================================================================ +const FilterBundle = ({ bundle, columns, url, setUrl, copiedFilter }) => { + const handleRemoveBundle = () => { + setUrl((prev) => ({ ...prev, bundles: prev.bundles.filter((b) => b.id !== bundle.id) })); + }; + + const handleAddFilter = () => { + setUrl((prev) => ({ + ...prev, + bundles: prev.bundles.map((b) => + b.id === bundle.id + ? { ...b, filters: [...b.filters, { id: crypto.randomUUID(), column: null, type: null, operator: null, value: null }] } + : b + ), + })); + }; + + return ( + + + + + + + + {bundle.filters.map((filter) => ( + + ))} + + + + ); +}; + +// ============================================================================ +// 3. MAIN QUERY BUILDER (The Engine Wrapper) +// ============================================================================ +export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pullTable, isLoading, isReadyRef }) { + const copiedFilter = useRef(null); + + const handleAddBundle = () => { + setUrl((prev) => ({ + ...prev, + bundles: [ + ...prev.bundles, + { + id: crypto.randomUUID(), + filters: [{ id: crypto.randomUUID(), column: null, type: null, operator: null, value: null }], + }, + ], + })); + }; + + return ( + + {/* Top Controls: Source & Execute */} + + + Data Source + + { + const selectedView = e.target.value; + setUrl((prev) => ({ ...prev, source: selectedView })); + // 🚨 FIXED: Now uses relative pathing for the production API + const endpoint = "/api/v2/view_dash_event_search_columns"; + getColumns(endpoint); + }} + > + + + + + + + + + + + + {/* Filter Builder Engine */} + {url.source && allColumns?.types && ( + + Filters + + + + {url.bundles.map((bundle) => ( + + ))} + + + + + )} + + ); +} \ No newline at end of file diff --git a/client/src/theme.js b/client/src/theme.js index 517333b..3a4eab1 100644 --- a/client/src/theme.js +++ b/client/src/theme.js @@ -118,11 +118,11 @@ const sidebarRecipe = defineSlotRecipe({ _hover: { bg: "rgba(255, 255, 255, 0.2)", color: "{text}", - "& svg": { - strokeWidth: "3", - }, + // "& svg": { + // strokeWidth: "3", + // }, "& span": { - fontWeight: "700", + fontWeight: "800", }, },