Revert "Merge branch 'feature/gas-filter' into 'main'"

This commit is contained in:
Dan Hansen
2026-01-30 19:11:11 +00:00
parent 2a4139ab31
commit 946dbb499e
51 changed files with 174 additions and 4999 deletions
@@ -1,14 +1,11 @@
import Widget from "./Widget";
import { Stack, Box, Field, Input, Button, ButtonGroup, Text, Flex } from "@chakra-ui/react";
import { Stack, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { LuAsterisk } from "react-icons/lu";
import { CancelButton } from "./CancelButton.jsx";
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
const { register, handleSubmit, reset, formState: {errors} } = useForm();
const onSubmit = (data) => {
console.log("data: ", data);
if (data.volcanoname === '') {
@@ -17,10 +14,6 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
setIsSubmit(true);
reset();
}
const onCancel = () => {
reset();
setIsSubmit(false);
}
const [isSubmit, setIsSubmit] = useState(false);
// Prop Forwarding
@@ -39,7 +32,7 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
pr={3}
display="flex"
flexDir="column"
gap={12}
gap="10"
>
<Field.Root orientation="horizontal">
<Stack direction="column" gap="3" width="100%">
@@ -63,12 +56,7 @@ export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
</Flex>
</Stack>
</Field.Root>
<Flex justify ="flex-end" gap = {2}>
<ButtonGroup size="sm" variant="solid">
<Button type="submit">Submit</Button>
<CancelButton onCancel ={onCancel}/>
</ButtonGroup>
</Flex>
<Button type="submit" size="sm">Submit</Button>
</Box>
):(
// DISPLAYING ALERT LEVEL DATA
@@ -2,6 +2,6 @@ import { Button } from "@chakra-ui/react";
export function CancelButton ({onCancel}) {
return (
<Button onClick={onCancel} type={"button"}>Cancel</Button>
<Button onClick={onCancel} borderRadius="md">Cancel</Button>
)
}
@@ -1,7 +1,7 @@
import { Dialog, Portal, CloseButton, Button } from "@chakra-ui/react";
export default function DialogPopup({dialog}) {
//console.log("3. dialog popup")
console.log("3. dialog popup")
return(
<Dialog.RootProvider value={dialog}>
<Portal>
@@ -1,7 +1,7 @@
import { Box, Menu, Portal } from '@chakra-ui/react'
import WidgetMenu from './WidgetMenu';
export default function FirstWidgetButton({ view, appendWidget }) {
export default function FirstWidgetButton({ appendWidget }) {
const triggerItem = (<>
<Menu.Trigger asChild>
<Box
@@ -16,11 +16,10 @@ export default function FirstWidgetButton({ view, appendWidget }) {
boxShadow="md"
border="2px dashed"
borderColor="gray.300"
bg={{base:"white",_dark:"gray"}}
color={{base:"black",_dark:"white"}}
bg="gray.50"
cursor="pointer"
_hover = {{
bg: {base:"gray.100", _dark:"gray.600"},
bg: "gray.100",
borderColor: "gray.400",
boxShadow: "lg",
}}
@@ -30,7 +29,7 @@ export default function FirstWidgetButton({ view, appendWidget }) {
</Menu.Trigger>
</>);
const positioning = { placement: "bottom-middle" };
const WidgetMenuProps = { view, appendWidget, triggerItem, positioning };
const WidgetMenuProps = { appendWidget, triggerItem, positioning };
return (
<WidgetMenu {...WidgetMenuProps} />
);
@@ -1,232 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
Box, Button, Checkbox, CheckboxGroup, Field, HStack,
Input, Spinner, Stack, Text }
from "@chakra-ui/react";
// DEBUG
console.log({
Field,
Checkbox,
CheckboxGroup,
});
async function fetchJson(url, { signal } = {}) {
const res = await fetch(url, { signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
}
return res.json();
}
function buildGasQuery(filters) {
const params = new URLSearchParams();
// Multi-select fields ~ Add more once working
for (const v of filters.volcanoname ?? []) params.append("volcanoname", v);
for (const c of filters.compoundname ?? []) params.append("compoundname", c);
for (const s of filters.sitename ?? []) params.append("sitename", s);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
return params.toString();
}
/**
* Filters a list of values using a case-insensitive substring search.
*
* If `searchText` is empty, null, or undefined, the original list is returned.
* The function is defensive against null/undefined inputs and will never throw.
*
* @param {Array<any> | null | undefined} list
* The list of items to filter. Items are converted to strings before
* comparison. If null or undefined, an empty array is returned.
*
* @param {string | null | undefined} searchText
* The text to search for within each list item. Leading/trailing whitespace
* is ignored and matching is case-insensitive.
*/
function filterList(list, searchText) {
const q = (searchText ?? "").trim().toLowerCase();
if (!q) return list ?? [];
return (list ?? []).filter((x) => String(x).toLowerCase().includes(q));
}
export default function GasFilter({ apiBase, onChange }) {
const [options, setOptions] = useState(null);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(true);
// filter state
const [filters, setFilters] = useState({
volcanoname: [],
compoundname: [],
sitename: [],
date_from: "",
date_to: "",
});
// local search inputs
const [volcanoSearch, setVolcanoSearch] = useState("");
const [compoundSearch, setCompoundSearch] = useState("");
useEffect(() => {
const ac = new AbortController();
setLoading(true);
setErr(null);
// TODO - Add options end point to populate dropdowns
fetchJson(`${apiBase}/options`, { signal: ac.signal })
.then(setOptions)
.catch((e) => e.name !== "AbortError" && setErr(e))
.finally(() => setLoading(false));
return () => ac.abort();
}, [apiBase]);
// Emit query string whenever filters change
useEffect(() => {
const qs = buildGasQuery(filters);
onChange?.(qs, filters);
}, [filters, onChange]);
const volcanoList = useMemo(
() => filterList(options?.volcanoname, volcanoSearch),
[options, volcanoSearch]
);
const compoundList = useMemo(
() => filterList(options?.compoundname, compoundSearch),
[options, compoundSearch]
);
if (loading) {
return (
<HStack p={2}>
<Spinner size="sm" />
<Text fontSize="sm">Loading Filters</Text>
</HStack>
);
}
if (err) {
return (
<Box p={2}>
<Text fontWeight="bold">Couldn't load filter options</Text>
<Text fontSize="sm" opacity={0.8}>
{String(err.message ?? err)}
</Text>
</Box>
);
}
return (
<Box p={2} borderWidth="1px" borderRadius="md">
<HStack justify="space-between" mb={2}>
<Text fontWeight="bold">Gas Filters</Text>
<Button
size="sm"
variant="outline"
onClick={() =>
setFilters({
volcanoname: [],
compoundname: [],
sitename: [],
date_from: "",
date_to: "",
})
}
>
Clear
</Button>
</HStack>
<Stack spacing={4}>
{/*Volcano multi-select*/}
<Field.Root>
<Field.Label fontSize="sm">Volcano</Field.Label>
<Input
size="sm"
placeholder="Search Volcano"
value={volcanoSearch}
onChange={(e) => setVolcanoSearch(e.target.value)}
mb={2}
/>
<CheckboxGroup
value={filters.volcanoname}
onChange={(vals) =>
setFilters((f) => ({ ...f, volcanoname: vals }))
}
>
<Stack maxH="140px" overflowY="auto" spacing={1}>
{volcanoList.map((v) => (
<Checkbox.Root key={v} value={v}>
{v}
</Checkbox.Root>
))}
</Stack>
</CheckboxGroup>
</Field.Root>
{/* Compound multi-select */}
<Field.Root>
<Field.Label fontSize="sm">Compound</Field.Label>
<Input
size="sm"
placeholder="Search Compound"
value={compoundSearch}
onChange={(e) => setCompoundSearch(e.target.value)}
mb={2}
/>
<CheckboxGroup
value={filters.compoundname}
onChange={(vals) =>
setFilters((f) => ({...f, compoundname: vals}))
}
>
<Stack maxH="140px" overflowY="auto" spacing={1}>
{compoundList.map((c) => (
<Checkbox.Root key={c} value={c}>
{c}
</Checkbox.Root>
))}
</Stack>
</CheckboxGroup>
</Field.Root>
{/* Date Range */}
<HStack align="start" spacing={3}>
<Field.Root>
<Field.Label fontSize="sm">From</Field.Label>
<Input
size="sm"
type="date"
value={filters.date_from}
min={options?.date_min ?? undefined}
max={options?.date_max ?? undefined}
onChange={(e) =>
setFilters((f) => ({...f, date_from: e.target.value }))
}
/>
</Field.Root>
<Field.Root>
<Field.Label fontSize="sm">To</Field.Label>
<Input
size="sm"
type="date"
value={filters.date_to}
min={options?.date_min ?? undefined}
max={options?.date_max ?? undefined}
onChange={(e) =>
setFilters((f) => ({...f, date_to: e.target.value }))
}
/>
</Field.Root>
</HStack>
</Stack>
</Box>
);
}
@@ -1,264 +0,0 @@
/**
* Gas Measurements Widget
*
* Renders a Plotly time-series chart of gas measurement values returned from the API.
* Fetches rows from the backend, normalizes them into plottable points, groups points by compound,
* and displays one trace per compound.
*/
import { useEffect, useMemo, useState } from "react";
import Plot from "react-plotly.js";
import { Box, Center, Spinner, Text } from "@chakra-ui/react";
import Widget from "./Widget";
// import GasFilter from "./GasFilter";
const API_BASE = "http://localhost:8000"; // Possibly import.meta.env.VITE_API_BASE_URL
/**
* Fetches JSON from a URL and throws a helpful error if the response is not OK.
*
* @param {string} url URL to request
* @param {object} [options] Optional options object
* @param {AbortSignal} [options.signal] Abort signal used to cancel the request
* @returns {Promise<any>} Parsed JSON response body
* @throws {Error} If the HTTP response is not OK (non-2xx)
*/
async function fetchJson(url, { signal } = {}) {
const res = await fetch(url, { signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
}
return res.json();
}
/**
* Safely converts an input value into a finite number; otherwise returns null.
* Useful for filtering out non-numeric measurement values before plotting.
*
* @param {any} value Input value to check/convert
* @param {any} [rawvalue] Optional original/raw value (currently unused)
* @returns {number|null} Finite numeric value, or null if invalid
*/
function toNumberOrNull(value, rawvalue) {
if (typeof value === "number" && Number.isFinite(value))
return value;
else
return null;
}
/**
* Groups an array of items into a Map based on the provided key function.
*
* @template T
* @param {T[]} arr Array of items to group
* @param {(item: T) => any} keyFn Function that returns the grouping key for an item
* @returns {Map<any, T[]>} Map from key -> array of items for that key
*/
function groupBy(arr, keyFn) {
const m = new Map();
for (const item of arr) {
const k = keyFn(item);
if (!m.has(k)) m.set(k, []);
m.get(k).push(item);
}
return m;
}
/**
* Normalizes raw API rows into Plotly-friendly point objects.
* Filters out rows missing a valid numeric value or sample date, and sorts by date ascending.
*
* @param {Array<object>} rows Raw rows returned by the API
* @returns {Array<object>} Normalized points with x/y and helpful metadata for hover tooltips
*/
function normalizeRowsToPoints(rows) {
const points = [];
for (const r of rows ?? []) {
const y = toNumberOrNull(r.value);
if (y === null) continue;
if (!r.sampledate) continue;
points.push({
x: r.sampledate,
y,
compound: r.compoundname ?? "unknown",
volcanoname: r.volcanoname ?? "",
sitename: r.sitename ?? "",
meta: r,
});
}
points.sort((a, b) => (a.x < b.x ? -1 : a.x > b.x ? 1 : 0));
return points;
}
/**
* GasWidget React component.
*
* Fetches gas measurement rows from the backend and renders a multi-trace Plotly time series.
* Each trace represents a different compound (e.g., SO2, CO2), drawn as lines+markers.
*
* @param {object} props Component props
* @param {string} props.id Widget instance id (used by the Widget wrapper)
* @param {boolean} props.isEditable Whether the dashboard is in edit mode
* @param {boolean} props.isDelete Whether the dashboard is in delete mode
* @param {(id: string) => void} props.DeleteWidget Callback to delete this widget
* @returns {JSX.Element} Rendered widget UI
*/
export default function GasWidget({ id, config, isEditable, isDelete, DeleteWidget }) {
const [rows, setRows] = useState([]);
const [error, setError] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const volcano = config?.volcano_name ?? null;
const site = config?.site_name ?? null;
const compounds = Array.isArray(config?.compound_name) ? config.compound_name: [];
const start_date = config?.start_date ?? null;
const end_date = config?.end_date ?? null;
const compoundsKey = useMemo(() => compounds.slice().sort().join("|"), [compounds]);
const endpoint = useMemo(() => {
const params = new URLSearchParams();
params.set("include_measurements", "true");
params.set("include_personnel", "false");
// Supported /search filters
if (volcano) params.set("volcano_name", volcano);
if (site) params.set("site_name", site);
for (const c of compounds) params.append("compound_name", c);
if (start_date) params.set("start_date", start_date);
if (end_date) params.set("end_date", end_date);
return `${API_BASE}/search/?${params.toString()}`;
}, [volcano, site, compoundsKey, start_date, end_date]);
// DEBUG
useEffect(() => {
console.log("[GasWidget] endpoint:", endpoint)
}, [endpoint]);
useEffect(() => {
console.log("[GasWidget] volcano_name:", volcano);
}, [volcano]);
/**
* Fetch rows on mount (and when the endpoint changes).
* Uses AbortController to cancel in-flight requests on unmount or dependency change.
*/
useEffect(() => {
const ac = new AbortController();
setIsLoading(true);
setError(null);
fetchJson(endpoint, { signal: ac.signal })
.then(setRows)
.catch((e) => e.name !== "AbortError" && setError(e))
.finally(() => setIsLoading(false));
return () => ac.abort();
}, [endpoint]);
/**
* Normalized point data derived from raw rows.
* Memoized to avoid recomputation unless rows change.
*/
const points = useMemo(() => normalizeRowsToPoints(rows), [rows]);
/**
* Plotly trace data (one trace per compound).
* Groups points by compound name and creates a scatter trace with lines+markers.
*/
const plotData = useMemo(() => {
const byCompound = groupBy(points, (p) => p.compound);
const traces = [];
for (const [compound, pts] of byCompound.entries()) {
traces.push({
x: pts.map((p) => p.x),
y: pts.map((p) => p.y),
name: compound,
type: "scatter",
mode: "lines+markers",
text: pts.map((p) => `${p.volcanoname} - ${p.sitename}`),
hovertemplate:
"Date: %{x}<br>Value: %{y}<br>%{text}<extra>" + compound + "</extra>",
});
}
traces.sort((a, b) => a.name.localeCompare(b.name));
return traces;
}, [points]);
/**
* Plotly layout configuration for axis, margins, and legend placement.
* Memoized once since it is static.
*/
const layout = useMemo(
() => ({
// title: { text: "Gas Measurements" }, // This is redundant
autosize: true,
margin: { l: 55, r: 20, t: 20, b: 30 },
xaxis: { title: "Sample Date", type: "date" },
yaxis: { title: "Value" },
legend: {
orientation: "h",
x: 0,
y: -0.25, // push legend below plot area
xanchor: "left",
yanchor: "top",
font: { size: 10 },
itemwidth: 80, // helps wrap/pack items tighter
},
}),
[]
);
return (
<Widget
title={`${volcano} - Gas Compound Measurements`}
id={id}
isEditable={isEditable}
isDelete={isDelete}
DeleteWidget={DeleteWidget}
>
<Box height="100%" width="100%" p={2}>
{/* <GasFilter
apiBase={API_BASE}
onChange={(qs) => setQueryString(qs)}
/> */}
{isLoading && (
<Center height="100%">
<Spinner />
</Center>
)}
{!isLoading && error && (
<Center height="100%" flexDir="column" gap={2}>
<Text fontWeight="bold">Couldn't load data</Text>
<Text fontSize="sm" opacity={0.8}>
{String(error.message ?? error)}
</Text>
</Center>
)}
{!isLoading && !error && points.length === 0 && (
<Center height="100%">
<Text opacity={0.8}>No numeric values found</Text>
</Center>
)}
{!isLoading && !error && points.length > 0 && (
<Box height="100%" width="100%">
<Plot
data={plotData}
layout={layout}
config={{ responsive: true, displayModeBar: false }}
style={{ width: "100%", height: "100%" }}
/>
</Box>
)}
</Box>
</Widget>
);
}
@@ -38,7 +38,7 @@ export default function MapButtonControls({settingsBtn, position = "topright"})
return () => {
control.remove();
};
}, [map, position]);
}, [map, position, settingsBtn ]);
return container ? createPortal(
settingsBtn,
@@ -1,130 +0,0 @@
import { Box, Checkbox, CheckboxGroup, Separator , chakra, CloseButton, Text, IconButton, Accordion, RadioGroup, } from "@chakra-ui/react";
import { useState } from "react";
import { CiFilter } from "react-icons/ci";
function GenerateFilters ({config, selected, setFilterValue, toggleInArray}){
return (
<Accordion.Root collapsible defaultValue={[]}>
{config.map((f) => {
if (f.type === "checkbox") {
const current = Array.isArray(selected?.[f.id]) ? selected[f.id] : [];
return (
<Accordion.Item key={f.id} value={f.id}>
<Accordion.ItemTrigger>
<Accordion.ItemIndicator />
<Text fontWeight="semibold">
{f.label}
{current.length ? ` (${current.length})` : ""}
</Text>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Box display="flex" flexDir="column" gap="2" pt="2">
{f.options.map((opt) => {
const checked = current.includes(opt.value);
return (
<Checkbox.Root
key={opt.value}
checked={checked}
onCheckedChange={() => toggleInArray(f.id, opt.value)}
>
<Checkbox.HiddenInput />
<Checkbox.Control />
<Checkbox.Label>{opt.label}</Checkbox.Label>
</Checkbox.Root>
);
})}
</Box>
</Accordion.ItemContent>
</Accordion.Item>
);
}
if (f.type === "radio") {
const current = selected?.[f.id] ?? "";
return (
<Accordion.Item key={f.id} value={f.id}>
<Accordion.ItemTrigger>
<Accordion.ItemIndicator />
<Text fontWeight="semibold">
{f.label}
{current ? ` (${current})` : ""}
</Text>
</Accordion.ItemTrigger>
<Accordion.ItemContent>
<Box pt="2">
<RadioGroup.Root
value={current}
onValueChange={(details) => setFilterValue(f.id, details.value)}
>
<Box display="flex" flexDir="column" gap="2">
{f.options.map((opt) => (
<RadioGroup.Item key={opt.value} value={opt.value}>
<RadioGroup.ItemHiddenInput />
<RadioGroup.ItemIndicator />
<RadioGroup.ItemText>{opt.label}</RadioGroup.ItemText>
</RadioGroup.Item>
))}
</Box>
</RadioGroup.Root>
</Box>
</Accordion.ItemContent>
</Accordion.Item>
);
}
return null;
})}
</Accordion.Root>
);
}
function Header(props) {
const { setOpen } = props;
return (
<chakra.div display="flex" dir="row" alignItems="center" justifyContent="space-between" pl="12">
<Text textStyle="lg">Filters</Text>
<CloseButton size="sm" colorPalette='red' onClick={() => setOpen(false)}/>
</chakra.div>
);
}
export default function MapFilter({config, selected, setFilterValue, toggleInArray}) {
const [open, setOpen] = useState(false);
return open ? (
<Box
width="240px"
display="flex"
flexDir="column"
gap="2"
p="2"
bg="white"
borderRadius="5px"
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
>
<Header {...{ open, setOpen }} />
<Separator />
<GenerateFilters config={config}
selected={selected}
setFilterValue={setFilterValue}
toggleInArray={toggleInArray}
/>
</Box>
) : (
<IconButton
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
size="sm"
variant="subtle"
onClick={() => setOpen(true)}
>
<CiFilter />
</IconButton>
);
}
@@ -1,49 +0,0 @@
import {Menu, MenuTrigger, MenuContent, MenuPositioner, Portal, chakra, Text, CloseButton, Box, Separator} from "@chakra-ui/react";
import { IconButton } from "@chakra-ui/react";
import {useState} from "react";
import { IoIosInformationCircleOutline } from "react-icons/io";
function Header(props) {
const { setOpen } = props;
return (
<chakra.div display="flex" dir="row" alignItems="center" justifyContent="space-between" pl="12">
<Text textStyle="lg">Legend</Text>
<CloseButton size="sm" colorPalette='red' onClick={() => setOpen(false)}/>
</chakra.div>
);
}
export default function MapLegend({legend}) {
const [open, setOpen] = useState(false);
return open ? (
<Box
width="240px"
display="flex"
flexDir="column"
gap="2"
p="2"
bg="white"
borderRadius="5px"
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
>
<Header {...{ open, setOpen }} />
{legend}
<Separator />
</Box>
) : (
<IconButton
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
size="sm"
variant="subtle"
onClick={() => setOpen(true)}
>
<IoIosInformationCircleOutline />
</IconButton>
);
}
@@ -1,4 +1,4 @@
import { Box, Checkbox, Separator , CloseButton, Text, Field, chakra, IconButton, Icon, Input, Fieldset, Flex } from "@chakra-ui/react";
import { Box, Checkbox, Separator , CloseButton, Text, chakra, Field, IconButton, Icon, Input, Fieldset, Flex } from "@chakra-ui/react";
import { useState } from "react";
import { FiSettings } from "react-icons/fi";
import { IoIosArrowDown } from "react-icons/io";
@@ -1,33 +0,0 @@
import Widget from "src/components/Canvas/CanvasComponents/Widget.jsx";
import { Box, Text } from "@chakra-ui/react";
export default function PlaceholderWidget({
title = "Widget",
id,
isEditable,
isDelete,
DeleteWidget
}) { return (
<Widget
title={title}
id={id}
isEditable={isEditable}
isDelete={isDelete}
DeleteWidget={DeleteWidget}
>
<Box
h="100%"
w="100%"
display="flex"
alignItems="center"
justifyContent="center"
opacity={0.6}
textAlign="center"
>
<Text fontSize="sm">
{title} widget coming soon
</Text>
</Box>
</Widget>
);
}
@@ -3,16 +3,9 @@ import { IoIosArrowBack } from "react-icons/io";
import { FiSettings } from "react-icons/fi";
import WidgetMenu from "./WidgetMenu";
export function SettingsButton({ view, onEditWidgets, appendWidget}) {
const triggerItem = (
<>
<Menu.TriggerItem>
<IoIosArrowBack /> Add Widget
</Menu.TriggerItem>
</>);
const WidgetMenuProps = { view, appendWidget, triggerItem }
export function SettingsButton({ onEditWidgets, appendWidget}) {
const triggerItem = (<> <Menu.TriggerItem> <IoIosArrowBack /> Add Widget</Menu.TriggerItem> </>);
const WidgetMenuProps = { appendWidget, triggerItem }
return (
<Menu.Root>
<Menu.Trigger asChild >
@@ -20,12 +13,11 @@ export function SettingsButton({ view, onEditWidgets, appendWidget}) {
<FiSettings />
</IconButton>
</Menu.Trigger>
<Portal>
<Menu.Positioner>
<Menu.Content>
<WidgetMenu {...WidgetMenuProps}/>
<Menu.Item value="edit" onClick={onEditWidgets} >
<Menu.Item value="eidt" onClick={onEditWidgets} >
Edit Widgets
</Menu.Item>
</Menu.Content>
@@ -1,529 +0,0 @@
import {
Box,
CloseButton,
Text,
Input,
Button,
Stack,
HStack,
Select,
RadioGroup,
Checkbox,
CheckboxGroup,
Code,
Field,
Fieldset,
createListCollection,
} from "@chakra-ui/react";
import { useEffect, useMemo, useState } from "react";
import { WIDGET_INSPECTOR_REGISTRY } from "@/constants/widgetInspectorRegistry";
const API_BASE = "http://localhost:8000";
async function fetchJson(url, { signal } = {}) {
const res = await fetch(url, { signal });
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
}
return res.json();
}
function getByPath(obj, path) {
if (!obj || !path) return undefined;
return path.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), obj);
}
function buildQueryString(draft) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(draft)) {
if (value == null) continue;
// array (checkbox group)
if (Array.isArray(value)) {
if (value.length === 0) continue;
value.forEach(v => params.append(key, v));
continue;
}
// scalar
if (value === "") continue;
params.append(key, value);
}
return params.toString();
}
export default function WidgetInspector({
open,
onClose,
target,
widgetArray,
updateWidgetConfig
}) {
if (!open || !target) return null;
const { id, widgetKey } = target;
const widget = widgetArray.find(w => w.i === id);
const def = WIDGET_INSPECTOR_REGISTRY[widgetKey];
if (!widget || !def) return null;
// current saved config
const cfg = widget.config ?? {};
// local draft config (only applied on Submit)
const initialDraft = useMemo(() => {
const base = def?.defaultConfig ?? {};
return {...base, ...cfg,};
}, [def, cfg]);
const [draft, setDraft] = useState(initialDraft);
// reset draft when switching widgets or repoening
useEffect(() => setDraft(initialDraft), [initialDraft]);
// source payloads (e.g. { options: { volcanoname:[], ... } })
// const [options, setOptions] = useState({
// volcanoname: [],
// compoundname: [],
// sitename: [],
// date_min: null,
// date_max: null,
// });
const [sources, setSources] = useState({});
const [optError, setOptError] = useState(null);
const [optLoading, setOptLoading] = useState(false);
useEffect(() => {
if (!open) return;
const ac = new AbortController();
const srcDefs = def.optionSources ?? {};
const entries = Object.entries(srcDefs);
if (entries.length === 0) {
setSources({});
setOptLoading(false);
setOptError(null);
return;
}
setOptLoading(true);
setOptError(null);
Promise.all(
entries.map(async ([name, src]) => {
const url =
typeof src.url === "function"
? src.url({ draft })
: src.url;
// DEBUG
console.log("Fetching options source:", name, `${API_BASE}${url}`)
const data = await fetchJson(`${API_BASE}${url}`, { signal: ac.signal });
return [name, data];
})
)
.then((pairs) => setSources(Object.fromEntries(pairs)))
.catch((e) => e.name !== "AbortError" && setOptError(e))
.finally(() => setOptLoading(false));
return () => ac.abort();
}, [open, def, draft?.volcano_name]);
// DEBUG
useEffect(() => {
if (open) console.log("sources", sources);
}, [open, sources]);
const patchDraft = (partial) => setDraft((d) => ({ ...d, ...partial }));
const validationError = useMemo(() => {
for (const f of def.fields ?? []) {
if (typeof f.validate === "function") {
const msg = f.validate(draft, sources);
if (msg) return msg;
}
}
return null;
}, [def, draft, sources]);
const onSubmit = () => {
if (validationError) return;
const keys = def.submitKeys ?? Object.keys(def.defaultConfig ?? {});
const patch = {};
for (const k of keys) patch[k] = draft[k] ?? null;
// DEBUG
console.log("submit keys:", keys);
console.log("draft:", draft);
console.log("patch:", patch);
// Build and log query string for debugging
const queryString = buildQueryString(patch);
console.log("Query string:", `?${queryString}`);
// Full URL
console.log("Full URL:", `${API_BASE}/search?${queryString}`);
updateWidgetConfig(id, patch);
onClose();
};
const onReset = () => {
setDraft(def?.defaultConfig ?? {});
};
// --- Field renderers i.e. Radio, Checkbox, Select, Search ---
// 'Select' Field Handler
const renderField = (field) => {
if (field.type === "select") {
const srcObj = sources[field.optionsFrom?.source];
const raw = getByPath(srcObj, field.optionsFrom?.path) ?? [];
// DEV WARNING
if (process.env.NODE_ENV !== "production" && raw != null && !Array.isArray(raw)) {
console.warn(
`[WidgetInspector] optionsFrom path "${field.optionsFrom.path}" did not resolve to an array`,
{
widget: widgetKey,
field: field.key,
source: field.optionsFrom.source,
path: field.optionsFrom.path,
resolvedValue: raw,
}
);
}
const items = [
{ label: field.placeholder ?? "(all)", value: "" },
...raw.map((v) => ({ label: String(v), value: String(v) })),
];
const collection = createListCollection({ items });
const current = draft[field.key] ?? "";
const value = current ? [current] : []; // empty means (all)
return (
<Field.Root key={field.key}>
<Field.Label fontSize="sm">{field.label}</Field.Label>
<Select.Root
collection={collection}
value={value}
onValueChange={(e) => {
const next = e.value?.[0] ?? ""; // "" is "(all)"
const clears = field.clearOnChange ?? [];
const partial = { [field.key]: next || null };
for (const c of clears) partial[c] = null;
patchDraft(partial);
}}
disabled = { optLoading || items.length <= 1 } // only "(all)" exists
// size="sm"
// placeholder={optLoading ? "Loading…" : field.placeholder ?? "(all)"}
// value={draft[field.key] ?? ""}
// isDisabled={optLoading || opts.length === 0}
// onChange={(e) => {
// const v = e.target.value || null;
// // handle clears on change
// const clears = field.clearOnChange ?? [];
// const partial = { [field.key]: v };
// for (const c of clears) partial[c] = null;
// patchDraft(partial);
// }}
>
<Select.Trigger>
<Select.ValueText placeholder={optLoading ? "Loading..." : (field.placeholder ?? "(all)")} />
</Select.Trigger>
<Select.Content>
{items.map((it) => (
<Select.Item key={it.value} item={it}>
<Select.ItemText>{it.label}</Select.ItemText>
<Select.ItemIndicator />
</Select.Item>
))}
</Select.Content>
{/* {opts.map((x) => (
<option key={x} value={x}>
{x}
</option>
))} */}
</Select.Root>
</Field.Root>
);
}
// 'Radio Button' Field Handler
if (field.type === "radio") {
const srcObj = sources[field.optionsFrom?.source];
const raw = getByPath(srcObj, field.optionsFrom?.path);
if (process.env.NODE_ENV !== "production" && raw != null && !Array.isArray(raw)) {
console.warn(
`[WidgetInspector] optionsFrom path "${field.optionsFrom.path}" did not resolve to an array`,
{
widget: widgetKey,
field: field.key,
source: field.optionsFrom.source,
path: field.optionsFrom.path,
resolvedValue: raw,
}
);
}
const options = Array.isArray(raw) ? raw : [];
const isMulti = field.mode === "multiple";
const value = isMulti
? Array.isArray(draft[field.key]) ? draft[field.key] : []
: draft[field.key] ?? "";
return (
<Field.Root key={field.key}>
<Field.Label fontSize="sm">{field.label}</Field.Label>
<RadioGroup.Root
value={isMulti ? value : [value].filter(Boolean)}
onValueChange={(e) => {
if (isMulti) {
patchDraft({ [field.key]: e.value });
} else {
patchDraft({ [field.key]: e.value[0] ?? null });
}
}}
>
<Stack spacing={1}>
{options.map((opt) => (
<RadioGroup.Item key={opt} value={opt}>
<RadioGroup.ItemHiddenInput />
<RadioGroup.ItemIndicator />
<RadioGroup.ItemText>{opt}</RadioGroup.ItemText>
</RadioGroup.Item>
))}
</Stack>
</RadioGroup.Root>
</Field.Root>
);
}
// 'Checkbox' Field Handler
if (field.type === "checkbox") {
const srcObj = sources[field.optionsFrom?.source];
const raw = getByPath(srcObj, field.optionsFrom?.path);
if (process.env.NODE_ENV !== "production" && raw != null && !Array.isArray(raw)) {
console.warn(
`[WidgetInspector] optionsFrom path "${field.optionsFrom.path}" did not resolve to an array`,
{
widget: widgetKey,
field: field.key,
source: field.optionsFrom.source,
path: field.optionsFrom.path,
resolvedValue: raw,
}
);
}
// const options = Array.isArray(raw) ? raw.map(String) : [];
// items: [{ label, value }]
const items = (Array.isArray(raw) ? raw : []).map((x) => ({
label: String(x),
value: String(x),
}));
// const selected = Array.isArray(draft[field.key]) ? draft[field.key] : [];
const value = Array.isArray(draft[field.key]) ? draft[field.key].map(String) : [];
// Can be used to validate fields ~ optional
const invalid = typeof field.validate === "function" ? !!field.validate(draft, sources) : false;
return (
<Fieldset.Root key={field.key} invalid={invalid}>
<Fieldset.Legend fontSize="sm">{field.label}</Fieldset.Legend>
<CheckboxGroup
name={`wi-${widgetKey}-${field.key}`}
value={value}
onValueChange={(next) => {
console.log("group next:", next)
patchDraft({ [field.key]: next })
}}
>
<Stack
spacing={1}
maxH="160px"
overflow="auto"
border="1px solid"
borderColor="gray.200"
borderRadius="md"
p="2"
>
{items.map((item) => (
<Checkbox.Root key={item.value} value={item.value}>
<Checkbox.HiddenInput value={item.value} />
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
<Checkbox.Label>{item.label}</Checkbox.Label>
</Checkbox.Root>
))}
</Stack>
</CheckboxGroup>
<HStack mt="1" justify="space-between">
<Fieldset.HelperText>{value.length} selected</Fieldset.HelperText>
<Button size="xs" variant="ghost" onClick={() => patchDraft({ [field.key]: [] })}>
Clear
</Button>
</HStack>
{invalid && field.errorText && (
<Fieldset.ErrorText>{field.errorText}</Fieldset.ErrorText>
)}
</Fieldset.Root>
);
}
// Date Range Handler
if (field.type === "dateRange") {
const srcObj = sources[field.minFrom?.source];
const min = getByPath(srcObj, field.minFrom?.path) ?? undefined;
const max = getByPath(srcObj, field.maxFrom?.path) ?? undefined;
const startKey = field.startKey;
const endKey = field.endKey;
return (
<Field.Root key={field.key}>
<Field.Label fontSize="sm">{field.label}</Field.Label>
<HStack>
<Input
size="sm"
type="date"
min={min}
max={max}
value={draft[startKey] ?? ""}
onChange={(e) => patchDraft({ [startKey]: e.target.value || null })}
/>
<Text fontSize="sm" opacity={0.7}>
to
</Text>
<Input
size="sm"
type="date"
min={min}
max={max}
value={draft[endKey] ?? ""}
onChange={(e) => patchDraft({ [endKey]: e.target.value || null })}
/>
</HStack>
{!!validationError && (
<Text mt="1" fontSize="xs" color="red.500">
{validationError}
</Text>
)}
<HStack mt="2" justify="flex-end">
{(field.presets ?? []).map((p) => {
const variant = p.variant ?? "outline";
return (
<Button
key={p.label}
size="xs"
variant={variant}
onClick={() => {
if (p.startFrom && p.endFrom) {
// use values from options payload (e.g. date_min/date_max)
const src = sources[field.minFrom?.source];
patchDraft({
[startKey]: src?.[p.startFrom] ?? null,
[endKey]: src?.[p.endFrom] ?? null,
});
return;
}
patchDraft({ [startKey]: p.start ?? null, [endKey]: p.end ?? null });
}}
isDisabled={p.startFrom && (!min || !max)}
>
{p.label}
</Button>
);
})}
</HStack>
</Field.Root>
);
}
// fallback (future types)
return null;
};
return (
<Box
position="fixed"
top="84px"
right="12px"
width="320px"
maxH="calc(100vh - 96px)"
overflow="auto"
bg="white"
borderRadius="5px"
p="2"
zIndex="overlay"
boxShadow="0 0 0 2px rgba(119, 119, 119, 0.4)"
>
<Box display="flex" alignItems="center" justifyContent="space-between" px="2" py="1">
<Text textStyle="lg">{def.title ?? widgetKey}</Text>
<CloseButton size="sm" onClick={onClose} />
</Box>
{/* Options loading/error */}
<Box mt="2" px="2" fontSize="xs" opacity={0.8}>
{optLoading && <Text>Loading options</Text>}
{!optLoading && optError && (
<Text color="red.500">Options failed: {String(optError.message ?? optError)}</Text>
)}
</Box>
{/* Debug */}
{/* <Box mt="2" p="2" bg="gray.50" borderRadius="md" fontSize="xs" whiteSpace="pre-wrap">
<Text fontWeight="bold" mb="1">
Saved config
</Text>
{JSON.stringify(cfg ?? {}, null, 2)}
<Text fontWeight="bold" mt="2" mb="1">
Draft (pending)
</Text>
{JSON.stringify(draft ?? {}, null, 2)}
</Box> */}
{/* Fields */}
<Stack mt="3" spacing="3" p="2">
{(def.fields ?? []).map(renderField)}
{/* Actions */}
<HStack justify="flex-end">
<Button size="sm" variant="outline" onClick={onReset}>
Reset
</Button>
<Button
size="sm"
colorScheme="blue"
onClick={onSubmit}
isDisabled={optLoading || !!validationError}
>
Submit
</Button>
</HStack>
</Stack>
</Box>
);
}
@@ -1,25 +1,23 @@
import { Menu, Portal } from "@chakra-ui/react";
import { getWidgetsForView } from "@/constants/widgetRegistry";
// import AlertLevel from "./AlertLevelWidget";
import AlertLevel from "./AlertLevelWidget";
export default function WidgetMenu({view, appendWidget, triggerItem, positioning}) {
const entries = getWidgetsForView(view);
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
return (
<Menu.Root positioning={positioning}>
{triggerItem}
<Portal>
<Menu.Positioner>
<Menu.Content>
{entries.map((w) => (
<Menu.Item
key={w.key}
value={w.key}
onClick={() => appendWidget(w.key)}
<Menu.Item
value="AlertLevel"
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
>
{w.label}
Alert Level
</Menu.Item>
))}
<Menu.Item value="CO2">CO2</Menu.Item>
<Menu.Item value="Ivans">IVANS</Menu.Item>
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>