refactor: now using MyQueryBuilder (temp name) in Data Explorer pg as it implements zustand state management to aid the transition from retriggering an api call on every new filter added
This commit is contained in:
@@ -1,9 +1,11 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { Box, Stack, Separator, Center, Spinner, Alert, Flex } from "@chakra-ui/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 { toaster } from "../../components/ui/toaster.jsx"; // Adjust path to your v3 toaster if needed
|
||||||
|
import VdapApi from "../../hooks/useVdapApi.js";
|
||||||
|
|
||||||
// Import our newly consolidated engine and presentation components
|
// Import our newly consolidated engine and presentation components
|
||||||
import QueryBuilder from "./QueryBuilder.jsx";
|
import QueryBuilder from "./QueryBuilder.jsx";
|
||||||
|
import MyQueryBuilder from "./MyQueryBuilder.jsx";
|
||||||
import DataTable from "./DataTable.jsx";
|
import DataTable from "./DataTable.jsx";
|
||||||
// import { exportToCSV } from "../utils/csvExport.js";
|
// import { exportToCSV } from "../utils/csvExport.js";
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ export default function DataExplorer() {
|
|||||||
return params.toString();
|
return params.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// pulls list of column names and datatypes of said columns
|
||||||
const getColumns = async (endpoint) => {
|
const getColumns = async (endpoint) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
isReadyRef.current = false; // does this need to be set to true at the end?
|
isReadyRef.current = false; // does this need to be set to true at the end?
|
||||||
@@ -69,6 +72,8 @@ export default function DataExplorer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// pulls the data
|
||||||
const pullTable = async () => {
|
const pullTable = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -80,7 +85,6 @@ export default function DataExplorer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🚨 Relative pathing for your production API
|
|
||||||
const response = await fetch(`/api/v3/table_view?${buildUrlParams()}`);
|
const response = await fetch(`/api/v3/table_view?${buildUrlParams()}`);
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
|
||||||
@@ -95,10 +99,11 @@ export default function DataExplorer() {
|
|||||||
|
|
||||||
// Auto-fetch when pagination, columns, or sorting changes (but skip first render)
|
// Auto-fetch when pagination, columns, or sorting changes (but skip first render)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (! isReadyRef.current) {
|
if (! isReadyRef.current) return;
|
||||||
isReadyRef.current = false; // why are we resetting the readyRef back to false since it already is false?
|
// {
|
||||||
return;
|
// isReadyRef.current = false; // why are we resetting the readyRef back to false since it already is false?
|
||||||
}
|
// return;
|
||||||
|
// }
|
||||||
pullTable();
|
pullTable();
|
||||||
}, [url.page, url.columns, url.ordering]);
|
}, [url.page, url.columns, url.ordering]);
|
||||||
|
|
||||||
@@ -122,69 +127,62 @@ export default function DataExplorer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 5. Render Shell
|
|
||||||
return (
|
return (
|
||||||
// 🚨 Stripped hardcoded #FFFBFA background.
|
|
||||||
// This Flex container will perfectly fill your new Sidebar Route canvas.
|
// This Flex container will perfectly fill your new Sidebar Route canvas.
|
||||||
<Flex p={8} flex="1" direction="column" overflowY="auto" bg="transparent" h="100%">
|
<Flex direction="column" p={2} gap={3} bg="transparent">
|
||||||
<Stack direction="column" gap={6} align="stretch" w="100%">
|
{/* The Engine */}
|
||||||
|
<MyQueryBuilder
|
||||||
|
url={url}
|
||||||
|
setUrl={setUrl}
|
||||||
|
allColumns={allColumns}
|
||||||
|
getColumns={getColumns}
|
||||||
|
pullTable={pullTable}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isReadyRef={isReadyRef}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* The Engine */}
|
<Separator borderColor="widgetBadgeBg" />
|
||||||
<QueryBuilder
|
|
||||||
url={url}
|
|
||||||
setUrl={setUrl}
|
|
||||||
allColumns={allColumns}
|
|
||||||
getColumns={getColumns}
|
|
||||||
pullTable={pullTable}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isReadyRef={isReadyRef}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{results && <Separator borderColor="widgetBadgeBg" />}
|
{isLoading && (
|
||||||
|
<Center p={10}>
|
||||||
|
<Spinner color="blue.500" size="xl" thickness="4px" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Global Loading & Error States */}
|
{error && (
|
||||||
{isLoading && (
|
<Alert.Root status="error" mt={4} bg="red.900" color="white">
|
||||||
<Center p={10}>
|
<Alert.Indicator />
|
||||||
<Spinner color="blue.500" size="xl" thickness="4px" />
|
<Alert.Content>
|
||||||
</Center>
|
<Alert.Title>Error Fetching Data</Alert.Title>
|
||||||
)}
|
<Alert.Description>{error}</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{/* The Display (Table, Pagination, Download, Columns) */}
|
||||||
<Alert.Root status="error" mt={4} bg="red.900" color="white">
|
{results && (
|
||||||
<Alert.Indicator />
|
<Box w="100%">
|
||||||
<Alert.Content>
|
{results.results.length > 0 ? (
|
||||||
<Alert.Title>Error Fetching Data</Alert.Title>
|
<DataTable
|
||||||
<Alert.Description>{error}</Alert.Description>
|
data={results.results}
|
||||||
</Alert.Content>
|
count={results.count}
|
||||||
</Alert.Root>
|
url={url}
|
||||||
)}
|
setUrl={setUrl}
|
||||||
|
allColumns={allColumns.types}
|
||||||
{/* The Display (Table, Pagination, Download, Columns) */}
|
isLoading={isLoading}
|
||||||
{results && !isLoading && !error && (
|
onDownload={handleDownloadCSV}
|
||||||
<Box w="100%">
|
/>
|
||||||
{results.results.length > 0 ? (
|
) : (
|
||||||
<DataTable
|
<Alert.Root status="warning" mt={4} bg="widgetItemExpandedBg" color="text">
|
||||||
data={results.results}
|
<Alert.Indicator />
|
||||||
count={results.count}
|
<Alert.Content>
|
||||||
url={url}
|
<Alert.Title>No Results Found</Alert.Title>
|
||||||
setUrl={setUrl}
|
<Alert.Description>Try loosening your filter criteria.</Alert.Description>
|
||||||
allColumns={allColumns.types}
|
</Alert.Content>
|
||||||
isLoading={isLoading}
|
</Alert.Root>
|
||||||
onDownload={handleDownloadCSV}
|
)}
|
||||||
/>
|
</Box>
|
||||||
) : (
|
)}
|
||||||
<Alert.Root status="warning" mt={4} bg="widgetItemExpandedBg" color="text">
|
|
||||||
<Alert.Indicator />
|
|
||||||
<Alert.Content>
|
|
||||||
<Alert.Title>No Results Found</Alert.Title>
|
|
||||||
<Alert.Description>Try loosening your filter criteria.</Alert.Description>
|
|
||||||
</Alert.Content>
|
|
||||||
</Alert.Root>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</Stack>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -31,7 +31,7 @@ const ColumnSelector = ({ allColumns, url, setUrl, isLoading }) => {
|
|||||||
</Menu.Trigger>
|
</Menu.Trigger>
|
||||||
<Portal>
|
<Portal>
|
||||||
<Menu.Positioner>
|
<Menu.Positioner>
|
||||||
<Menu.Content bg="widgetItemBg" borderColor="widgetBadgeBg">
|
<Menu.Content bg="white" borderColor="widgetBadgeBg">
|
||||||
<Menu.ItemGroup>
|
<Menu.ItemGroup>
|
||||||
<Menu.ItemGroupLabel color="widgetTextMuted">Available Columns</Menu.ItemGroupLabel>
|
<Menu.ItemGroupLabel color="widgetTextMuted">Available Columns</Menu.ItemGroupLabel>
|
||||||
{Object.keys(allColumns).map((colName) => (
|
{Object.keys(allColumns).map((colName) => (
|
||||||
@@ -161,7 +161,7 @@ const DataTable = React.memo(({ data, count, url, setUrl, allColumns, isLoading,
|
|||||||
<Table.ScrollArea borderWidth="1px" borderColor="widgetBadgeBg" rounded="md" maxH="65vh" bg="widgetItemBg">
|
<Table.ScrollArea borderWidth="1px" borderColor="widgetBadgeBg" rounded="md" maxH="65vh" bg="widgetItemBg">
|
||||||
<Table.Root stickyHeader showColumnBorder size="sm">
|
<Table.Root stickyHeader showColumnBorder size="sm">
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row bg="widgetItemExpandedBg">
|
<Table.Row bg="white">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<Table.ColumnHeader key={col} color="text" py="2">
|
<Table.ColumnHeader key={col} color="text" py="2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// CHAKRA COMPONENTS
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Flex,
|
||||||
|
Field,
|
||||||
|
NativeSelect,
|
||||||
|
Input,
|
||||||
|
HoverCard,
|
||||||
|
Text,
|
||||||
|
NumberInput,
|
||||||
|
Box,
|
||||||
|
useSlotRecipe,
|
||||||
|
Combobox,
|
||||||
|
Portal,
|
||||||
|
useFilter,
|
||||||
|
useListCollection
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
// ICONS
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
CircleMinus,
|
||||||
|
Files,
|
||||||
|
ClipboardPaste,
|
||||||
|
Trash
|
||||||
|
} from 'lucide-react';
|
||||||
|
// OTHER
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useDataFilterStore from '../../store/useDataFilterStore.jsx';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 1. MAIN QUERY BUILDER (The Engine Wrapper)
|
||||||
|
// ============================================================================
|
||||||
|
export default function MyQueryBuilder({ url, setUrl, allColumns, getColumns, pullTable, isLoading, isReadyRef }) {
|
||||||
|
// STYLING
|
||||||
|
const dataExplorerRecipe = useSlotRecipe({ key: "dataExplorer" });
|
||||||
|
const dataExplorerStyle = dataExplorerRecipe();
|
||||||
|
|
||||||
|
// THE SELECT DROPDOWN MENU
|
||||||
|
const { contains } = useFilter({ sensitivity: "base" })
|
||||||
|
|
||||||
|
const { collection, filter } = useListCollection({
|
||||||
|
initialItems: tables,
|
||||||
|
filter: contains,
|
||||||
|
})
|
||||||
|
|
||||||
|
// STATE MANAGEMENT
|
||||||
|
const [value, setValue] = useState([]);
|
||||||
|
const addNewGroup = useDataFilterStore(state => state.addNewGroup);
|
||||||
|
const filterGroups = useDataFilterStore(state => state.filterGroups);
|
||||||
|
|
||||||
|
console.log("filter groups", filterGroups);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={2} w="100%" bg="transparent" css={dataExplorerStyle.query_builder}>
|
||||||
|
{/* Top Controls: Source & Execute */}
|
||||||
|
<Flex gap={2} align="flex-end">
|
||||||
|
<Combobox.Root
|
||||||
|
collection={collection}
|
||||||
|
onInputValueChange={(e) => filter(e.inputValue)}
|
||||||
|
value={value}
|
||||||
|
onValueChange={(e) => {
|
||||||
|
setValue(e.value);
|
||||||
|
const selectedView = e.value;
|
||||||
|
setUrl(prev => ({ ...prev, source: selectedView }));
|
||||||
|
// need to get all columns as default in param building
|
||||||
|
const endpoint = "/api/v3/view_dash_event_search_columns";
|
||||||
|
getColumns(endpoint);
|
||||||
|
}}
|
||||||
|
width="320px"
|
||||||
|
>
|
||||||
|
<Combobox.Label>Select Table</Combobox.Label>
|
||||||
|
<Combobox.Control>
|
||||||
|
<Combobox.Input placeholder="Type to search" />
|
||||||
|
<Combobox.IndicatorGroup>
|
||||||
|
<Combobox.ClearTrigger />
|
||||||
|
<Combobox.Trigger />
|
||||||
|
</Combobox.IndicatorGroup>
|
||||||
|
</Combobox.Control>
|
||||||
|
<Portal>
|
||||||
|
<Combobox.Positioner>
|
||||||
|
<Combobox.Content>
|
||||||
|
<Combobox.Empty>No items found</Combobox.Empty>
|
||||||
|
{collection.items.map((item) => (
|
||||||
|
<Combobox.Item item={item} key={item.value}>
|
||||||
|
{item.label}
|
||||||
|
<Combobox.ItemIndicator />
|
||||||
|
</Combobox.Item>
|
||||||
|
))}
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Positioner>
|
||||||
|
</Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={isLoading}
|
||||||
|
onClick={() => {
|
||||||
|
isReadyRef.current = true;
|
||||||
|
pullTable();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Execute Search
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* Filter Builder Engine */}
|
||||||
|
{url.source && allColumns?.types && (
|
||||||
|
<Stack w="100%" gap="2">
|
||||||
|
<Text fontSize="sm" fontWeight="bold" color="text">Filters</Text>
|
||||||
|
|
||||||
|
<Box w="100%" maxH="400px" overflow="auto">
|
||||||
|
<Stack w="100%" gap="2" pr="4">
|
||||||
|
{filterGroups.map((group) => (
|
||||||
|
<FilterGroup key={group.id} groupID={group.id} filters={group.filters} columns={allColumns.types} />
|
||||||
|
))}
|
||||||
|
<Button onClick={() => addNewGroup()} variant="outline" borderColor="widgetBadgeBg" color="text" _hover={{ bg: "widgetBadgeBg" }}>
|
||||||
|
New Filter Group <Plus />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIST OF TABLES USER CAN EXPLORE
|
||||||
|
const tables = [
|
||||||
|
{ label: "Events Table", value: "ViewDashEventSearch" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 2. FILTERS GROUPS (The AND logic groups)
|
||||||
|
// ============================================================================
|
||||||
|
function FilterGroup({ groupID, filters, columns }) {
|
||||||
|
const removeGroup = useDataFilterStore(state => state.removeGroup);
|
||||||
|
const addFilter = useDataFilterStore(state => state.addFilter);
|
||||||
|
|
||||||
|
console.log("in filter group. GroupID: ", groupID, "filters: ", filters);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack bg="widgetItemBg" borderWidth="1px" borderColor="widgetBadgeBg" borderRadius="lg" p="3" spacing="3">
|
||||||
|
<Flex justify="space-between" align="center">
|
||||||
|
<Input placeholder="Group Name (Optional)" maxW="200px" size="sm" variant="flushed" color="text" />
|
||||||
|
<Button onClick={() => removeGroup(groupID)} variant="ghost" size="sm" color="red.400">
|
||||||
|
<Trash />
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Stack spacing="2">
|
||||||
|
{filters?.map((filter) => (
|
||||||
|
<FilterRow key={filter.id} groupID={groupID} filterID={filter.id} filter={filter} columns={columns} setUrl={setUrl} copiedFilter={copiedFilter} />
|
||||||
|
))}
|
||||||
|
<Button onClick={() => addFilter(groupID)} variant="outline" size="sm" color="text" borderColor="widgetBadgeBg" _hover={{ bg: "widgetBadgeBg" }}>
|
||||||
|
Add Filter <Plus />
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 3. FILTER ROW (The individual criteria inputs)
|
||||||
|
// ============================================================================
|
||||||
|
function FilterRow({ groupID, filterID, filter, columns }) {
|
||||||
|
// STATE MANAGEMENT
|
||||||
|
const removeFilter = useDataFilterStore(state => state.removeFilter);
|
||||||
|
const updateFilterColumn = useDataFilterStore(state => state.updateFilterColumn);
|
||||||
|
const updateFilterOp = useDataFilterStore(state => state.updateFilterOps);
|
||||||
|
const updateFilterValue = useDataFilterStore(state => state.updateFilterValue);
|
||||||
|
const copyFilter = useDataFilterStore(state => state.copyFilter);
|
||||||
|
const pasteFilter = useDataFilterStore(state => state.pasteFilter);
|
||||||
|
|
||||||
|
// CONSTANTS
|
||||||
|
const types = {
|
||||||
|
AutoField: ["=", "<", ">", "<=", ">="],
|
||||||
|
IntegerField: ["=", "<", ">", "<=", ">="],
|
||||||
|
CharField: ["=", "contains"],
|
||||||
|
DateTimeField: ["range"],
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="2" align="center">
|
||||||
|
{/* Remove button */}
|
||||||
|
<HoverCard.Root positioning={{ placement: "top" }}>
|
||||||
|
<HoverCard.Trigger asChild>
|
||||||
|
<Button onClick={() => removeFilter(groupID, filterID)} variant="ghost" size="sm" color="red.400">
|
||||||
|
<CircleMinus />
|
||||||
|
</Button>
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
<HoverCard.Positioner>
|
||||||
|
<HoverCard.Content p="2" bg="white"><Text fontSize="sm">Remove Filter</Text></HoverCard.Content>
|
||||||
|
</HoverCard.Positioner>
|
||||||
|
</HoverCard.Root>
|
||||||
|
|
||||||
|
{/* Copy Button */}
|
||||||
|
<HoverCard.Root positioning={{ placement: "top" }}>
|
||||||
|
<HoverCard.Trigger asChild>
|
||||||
|
<Button onClick={() => copyFilter(filter)} variant="ghost" size="sm" color="text">
|
||||||
|
<Files />
|
||||||
|
</Button>
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
<HoverCard.Positioner>
|
||||||
|
<HoverCard.Content p="2" bg="white"><Text fontSize="sm">Copy Filter</Text></HoverCard.Content>
|
||||||
|
</HoverCard.Positioner>
|
||||||
|
</HoverCard.Root>
|
||||||
|
|
||||||
|
{/* paste button */}
|
||||||
|
<HoverCard.Root positioning={{ placement: "top" }}>
|
||||||
|
<HoverCard.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
onClick={() => pasteFilter(groupID)}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
color="text"
|
||||||
|
>
|
||||||
|
<ClipboardPaste />
|
||||||
|
</Button>
|
||||||
|
</HoverCard.Trigger>
|
||||||
|
<HoverCard.Positioner>
|
||||||
|
<HoverCard.Content p="2" bg="white"><Text fontSize="sm">Paste Filter</Text></HoverCard.Content>
|
||||||
|
</HoverCard.Positioner>
|
||||||
|
</HoverCard.Root>
|
||||||
|
|
||||||
|
{/* Column selector */}
|
||||||
|
<Field.Root w={filter?.type === "DateTimeField" ? "30%" : "30%"}>
|
||||||
|
<NativeSelect.Root>
|
||||||
|
<NativeSelect.Field
|
||||||
|
bg="widgetItemBg"
|
||||||
|
color="text"
|
||||||
|
value={filter.col ?? ""}
|
||||||
|
onChange={(e) => updateFilterColumn(groupID, filterID, e.target.value, columns[e.target.value], null, null)} // e.target.value is the new column
|
||||||
|
>
|
||||||
|
<option value="" disabled hidden>Select Column</option>
|
||||||
|
{Object.keys(columns).map((colName) => (
|
||||||
|
<option key={colName} value={colName}>{colName}</option>
|
||||||
|
))}
|
||||||
|
</NativeSelect.Field>
|
||||||
|
<NativeSelect.Indicator />
|
||||||
|
</NativeSelect.Root>
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Operator selector or start date for range */}
|
||||||
|
<Field.Root w={filter?.type === "DateTimeField" ? "20%" : "20%"}>
|
||||||
|
{filter?.type === "DateTimeField" ? (
|
||||||
|
<Input type="date" bg="widgetItemBg" color="text" value={filter.value?.start || ""} onChange={(e) => updateFilterValue(groupID, filterID, e.target.value, "s")} />
|
||||||
|
) : (
|
||||||
|
<NativeSelect.Root>
|
||||||
|
<NativeSelect.Field bg="widgetItemBg" color="text" value={filter.op ?? ""} onChange={(e) => updateFilterOp(groupID, filterID, e.target.value)}>
|
||||||
|
<option value="" disabled hidden>Operator</option>
|
||||||
|
{filter?.type && types[filter?.type].map((op) => <option key={op} value={op}>{op}</option>)} {/* maps over the 'filter.type' corresponding ops from the types dictionary */}
|
||||||
|
</NativeSelect.Field>
|
||||||
|
<NativeSelect.Indicator />
|
||||||
|
</NativeSelect.Root>
|
||||||
|
)}
|
||||||
|
</Field.Root>
|
||||||
|
|
||||||
|
{/* Value input or end date for range */}
|
||||||
|
<Field.Root w={filter.type === "DateTimeField" ? "20%" : "50%"}>
|
||||||
|
{filter?.type === "DateTimeField" ? (
|
||||||
|
<Input type="date" bg="widgetItemBg" color="text" value={filter.value?.end || ""} onChange={(e) => updateFilterValue(groupID, filterID, e.target.value, "e")} />
|
||||||
|
) : filter?.type === "IntegerField" || filter?.type === "AutoField" ? (
|
||||||
|
<NumberInput.Root value={filter.value ?? ""} onValueChange={(e) => updateFilterValue(groupID, filterID, e.value)}>
|
||||||
|
<NumberInput.Input placeholder="Value" bg="widgetItemBg" color="text" />
|
||||||
|
</NumberInput.Root>
|
||||||
|
) : (
|
||||||
|
<Input placeholder="Value" bg="widgetItemBg" color="text" value={filter.value ?? ""} onChange={(e) => updateFilterValue(groupID, filterID, e.target.value)} />
|
||||||
|
)}
|
||||||
|
</Field.Root>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,24 @@
|
|||||||
import React, { useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
import { Button, Stack, Flex, Field, NativeSelect, Input, HoverCard, Text, NumberInput, Box } from "@chakra-ui/react";
|
import {
|
||||||
|
Button,
|
||||||
|
Stack,
|
||||||
|
Flex,
|
||||||
|
Field,
|
||||||
|
NativeSelect,
|
||||||
|
Input,
|
||||||
|
HoverCard,
|
||||||
|
Text,
|
||||||
|
NumberInput,
|
||||||
|
Box,
|
||||||
|
useSlotRecipe,
|
||||||
|
Combobox,
|
||||||
|
Portal,
|
||||||
|
useFilter,
|
||||||
|
useListCollection
|
||||||
|
} from "@chakra-ui/react";
|
||||||
import { Plus, CircleMinus, Files, ClipboardPaste, Trash } from 'lucide-react';
|
import { Plus, CircleMinus, Files, ClipboardPaste, Trash } from 'lucide-react';
|
||||||
// import { GrAdd, GrSubtractCircle } from "react-icons/gr";
|
import { useState } from 'react';
|
||||||
// import { FaCopy, FaPaste, FaTrash } from "react-icons/fa";
|
import useDataFilterStore from '../../store/useDataFilterStore.jsx';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 1. FILTER ROW (The individual criteria inputs)
|
// 1. FILTER ROW (The individual criteria inputs)
|
||||||
@@ -80,7 +96,7 @@ const FilterRow = React.memo(({ filter, columns, setUrl, bundleId, copiedFilter
|
|||||||
</Button>
|
</Button>
|
||||||
</HoverCard.Trigger>
|
</HoverCard.Trigger>
|
||||||
<HoverCard.Positioner>
|
<HoverCard.Positioner>
|
||||||
<HoverCard.Content p="2" bg="widgetItemExpandedBg"><Text fontSize="sm">Remove Filter</Text></HoverCard.Content>
|
<HoverCard.Content p="2" bg="white"><Text fontSize="sm">Remove Filter</Text></HoverCard.Content>
|
||||||
</HoverCard.Positioner>
|
</HoverCard.Positioner>
|
||||||
</HoverCard.Root>
|
</HoverCard.Root>
|
||||||
|
|
||||||
@@ -92,7 +108,7 @@ const FilterRow = React.memo(({ filter, columns, setUrl, bundleId, copiedFilter
|
|||||||
</Button>
|
</Button>
|
||||||
</HoverCard.Trigger>
|
</HoverCard.Trigger>
|
||||||
<HoverCard.Positioner>
|
<HoverCard.Positioner>
|
||||||
<HoverCard.Content p="2" bg="widgetItemExpandedBg"><Text fontSize="sm">Copy Filter</Text></HoverCard.Content>
|
<HoverCard.Content p="2" bg="white"><Text fontSize="sm">Copy Filter</Text></HoverCard.Content>
|
||||||
</HoverCard.Positioner>
|
</HoverCard.Positioner>
|
||||||
</HoverCard.Root>
|
</HoverCard.Root>
|
||||||
|
|
||||||
@@ -124,7 +140,7 @@ const FilterRow = React.memo(({ filter, columns, setUrl, bundleId, copiedFilter
|
|||||||
</Button>
|
</Button>
|
||||||
</HoverCard.Trigger>
|
</HoverCard.Trigger>
|
||||||
<HoverCard.Positioner>
|
<HoverCard.Positioner>
|
||||||
<HoverCard.Content p="2" bg="widgetItemExpandedBg"><Text fontSize="sm">Paste Filter</Text></HoverCard.Content>
|
<HoverCard.Content p="2" bg="white"><Text fontSize="sm">Paste Filter</Text></HoverCard.Content>
|
||||||
</HoverCard.Positioner>
|
</HoverCard.Positioner>
|
||||||
</HoverCard.Root>
|
</HoverCard.Root>
|
||||||
|
|
||||||
@@ -207,6 +223,8 @@ const FilterBundle = ({ bundle, columns, url, setUrl, copiedFilter }) => {
|
|||||||
}) : (b)
|
}) : (b)
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -234,8 +252,25 @@ const FilterBundle = ({ bundle, columns, url, setUrl, copiedFilter }) => {
|
|||||||
// 3. MAIN QUERY BUILDER (The Engine Wrapper)
|
// 3. MAIN QUERY BUILDER (The Engine Wrapper)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pullTable, isLoading, isReadyRef }) {
|
export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pullTable, isLoading, isReadyRef }) {
|
||||||
const copiedFilter = useRef(null);
|
// STYLING
|
||||||
|
const dataExplorerRecipe = useSlotRecipe({ key: "dataExplorer" });
|
||||||
|
const dataExplorerStyle = dataExplorerRecipe();
|
||||||
|
|
||||||
|
// SELECT DROPDOWN MENU
|
||||||
|
const { contains } = useFilter({ sensitivity: "base" })
|
||||||
|
|
||||||
|
const { collection, filter } = useListCollection({
|
||||||
|
initialItems: tables,
|
||||||
|
filter: contains,
|
||||||
|
})
|
||||||
|
|
||||||
|
// STATE MANAGEMENT
|
||||||
|
const copiedFilter = useRef(null);
|
||||||
|
const [value, setValue] = useState([]);
|
||||||
|
const addNewGroup = useDataFilterStore(state => state.addNewGroup);
|
||||||
|
const filterGroups = useDataFilterStore(state => state.filterGroups);
|
||||||
|
|
||||||
|
// FUNCTIONS
|
||||||
const handleAddBundle = () => {
|
const handleAddBundle = () => {
|
||||||
setUrl(prev => ({
|
setUrl(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -253,39 +288,56 @@ export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pull
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// adding zustand state management as a test here
|
||||||
|
addNewGroup();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("filter groups", filterGroups);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="column" gap="4" w="100%" p="4" bg="transparent">
|
<Flex direction="column" gap={2} w="100%" bg="transparent" css={dataExplorerStyle.query_builder}>
|
||||||
{/* Top Controls: Source & Execute */}
|
{/* Top Controls: Source & Execute */}
|
||||||
<Flex gap="4" align="flex-end">
|
<Flex gap={2} align="flex-end">
|
||||||
<Field.Root maxW="300px">
|
<Combobox.Root
|
||||||
<Field.Label color="text">Data Source</Field.Label>
|
collection={collection}
|
||||||
<NativeSelect.Root>
|
onInputValueChange={(e) => filter(e.inputValue)}
|
||||||
<NativeSelect.Field
|
value={value}
|
||||||
bg="widgetItemBg"
|
onValueChange={(e) => {
|
||||||
color="text"
|
setValue(e.value);
|
||||||
value={url.source || ""}
|
const selectedView = e.value;
|
||||||
onChange={(e) => {
|
setUrl(prev => ({ ...prev, source: selectedView }));
|
||||||
const selectedView = e.target.value;
|
// need to get all columns as default in param building
|
||||||
setUrl(prev => ({ ...prev, source: selectedView }));
|
const endpoint = "/api/v3/view_dash_event_search_columns";
|
||||||
// 🚨 FIXED: Now uses relative pathing for the production API
|
getColumns(endpoint);
|
||||||
const endpoint = "/api/v3/view_dash_event_search_columns";
|
}}
|
||||||
getColumns(endpoint);
|
width="320px"
|
||||||
}}
|
>
|
||||||
>
|
<Combobox.Label>Select Table</Combobox.Label>
|
||||||
<option value="" disabled hidden>Select View</option>
|
<Combobox.Control>
|
||||||
<option value="main">Main View</option>
|
<Combobox.Input placeholder="Type to search" />
|
||||||
{/*<option value="test">Test View</option>*/}
|
<Combobox.IndicatorGroup>
|
||||||
</NativeSelect.Field>
|
<Combobox.ClearTrigger />
|
||||||
<NativeSelect.Indicator />
|
<Combobox.Trigger />
|
||||||
</NativeSelect.Root>
|
</Combobox.IndicatorGroup>
|
||||||
</Field.Root>
|
</Combobox.Control>
|
||||||
|
<Portal>
|
||||||
|
<Combobox.Positioner>
|
||||||
|
<Combobox.Content>
|
||||||
|
<Combobox.Empty>No items found</Combobox.Empty>
|
||||||
|
{collection.items.map((item) => (
|
||||||
|
<Combobox.Item item={item} key={item.value}>
|
||||||
|
{item.label}
|
||||||
|
<Combobox.ItemIndicator />
|
||||||
|
</Combobox.Item>
|
||||||
|
))}
|
||||||
|
</Combobox.Content>
|
||||||
|
</Combobox.Positioner>
|
||||||
|
</Portal>
|
||||||
|
</Combobox.Root>
|
||||||
|
{/*</Field.Root>*/}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
bg="blue.600"
|
|
||||||
color="white"
|
|
||||||
_hover={{ bg: "blue.500" }}
|
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
isReadyRef.current = true;
|
isReadyRef.current = true;
|
||||||
@@ -298,11 +350,11 @@ export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pull
|
|||||||
|
|
||||||
{/* Filter Builder Engine */}
|
{/* Filter Builder Engine */}
|
||||||
{url.source && allColumns?.types && (
|
{url.source && allColumns?.types && (
|
||||||
<Stack w="100%" gap="4">
|
<Stack w="100%" gap="2">
|
||||||
<Text fontSize="lg" fontWeight="bold" color="text">Filters</Text>
|
<Text fontSize="sm" fontWeight="bold" color="text">Filters</Text>
|
||||||
|
|
||||||
<Box w="100%" maxH="400px" overflow="auto">
|
<Box w="100%" maxH="400px" overflow="auto">
|
||||||
<Stack w="100%" gap="4" pr="4">
|
<Stack w="100%" gap="2" pr="4">
|
||||||
{url.bundles.map((bundle) => (
|
{url.bundles.map((bundle) => (
|
||||||
<FilterBundle key={bundle.id} bundle={bundle} columns={allColumns.types} url={url} setUrl={setUrl} copiedFilter={copiedFilter} />
|
<FilterBundle key={bundle.id} bundle={bundle} columns={allColumns.types} url={url} setUrl={setUrl} copiedFilter={copiedFilter} />
|
||||||
))}
|
))}
|
||||||
@@ -316,3 +368,8 @@ export default function QueryBuilder({ url, setUrl, allColumns, getColumns, pull
|
|||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LIST OF TABLES USER CAN EXPLORE
|
||||||
|
const tables = [
|
||||||
|
{ label: "Events Table", value: "ViewDashEventSearch" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
const useDataFilterStore = create((set, get) => ({
|
||||||
|
filterGroups: [],
|
||||||
|
copiedFilter: {},
|
||||||
|
|
||||||
|
addNewGroup: () => {
|
||||||
|
const newGroup = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
filters: []
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({filterGroups: [...state.filterGroups, newGroup]}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeGroup: (groupID) => set((state) => ({
|
||||||
|
filterGroups: state.filterGroups.filter((group) => group.id !== groupID),
|
||||||
|
})),
|
||||||
|
|
||||||
|
addFilter: (groupID) => set((state) => {
|
||||||
|
return {
|
||||||
|
filterGroups: state.filterGroups.map(filterGroup => {
|
||||||
|
return filterGroup.id === groupID ?
|
||||||
|
{
|
||||||
|
...filterGroup,
|
||||||
|
filters: [
|
||||||
|
...filterGroup.filters,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
col: null,
|
||||||
|
type: null,
|
||||||
|
op: null,
|
||||||
|
value: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: filterGroup
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateFilterColumn: (groupID, filterID, col, type, op, value) => set((state) => {
|
||||||
|
return {
|
||||||
|
filterGroups: state.filterGroups.map((group) => {
|
||||||
|
return groupID === group.id ?
|
||||||
|
{
|
||||||
|
...group,
|
||||||
|
filters: group.filters.map(filter => {
|
||||||
|
return filterID === filter.id ?
|
||||||
|
{
|
||||||
|
...filter,
|
||||||
|
col: col,
|
||||||
|
type: type,
|
||||||
|
op: op,
|
||||||
|
value: value
|
||||||
|
}
|
||||||
|
: filter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: group
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateFilterOps: (groupID, filterID, op) => set((state) => {
|
||||||
|
return {
|
||||||
|
filterGroups: state.filterGroups.map((group) => {
|
||||||
|
return groupID === group.id ?
|
||||||
|
{
|
||||||
|
...group,
|
||||||
|
filters: group.filters.map(filter => {
|
||||||
|
return filterID === filter.id ?
|
||||||
|
{
|
||||||
|
...filter,
|
||||||
|
op: op,
|
||||||
|
}
|
||||||
|
: filter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: group
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateFilterValue: (groupID, filterID, value, dateType = undefined) => set((state) => {
|
||||||
|
return {
|
||||||
|
filterGroups: state.filterGroups.map((group) => {
|
||||||
|
return groupID === group.id ?
|
||||||
|
{
|
||||||
|
...group,
|
||||||
|
filters: group.filters.map(filter => {
|
||||||
|
return filterID === filter.id ?
|
||||||
|
{
|
||||||
|
...filter,
|
||||||
|
value: (dateType === "s") ? {...filter.value, start: value} : (dateType === "e") ? {...filter.value, end: value } : value
|
||||||
|
}
|
||||||
|
: filter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
: group
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
removeFilter: (groupID, filterID) => set((state) => ({
|
||||||
|
filterGroups: state.filterGroups.map((group) => {
|
||||||
|
return group.id === groupID ?
|
||||||
|
{
|
||||||
|
...group,
|
||||||
|
filters: group.filters.filter((f) => f.id !== filterID)
|
||||||
|
}
|
||||||
|
: group
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
copyFilter: (filter) => set((state) => ({
|
||||||
|
copiedFilter: state.copiedFilter = {
|
||||||
|
col: filter.col,
|
||||||
|
type: filter.type,
|
||||||
|
op: filter.op,
|
||||||
|
value: filter.value
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
|
||||||
|
pasteFilter: (groupID) => set((state) => {
|
||||||
|
state.filterGroups.map((group) => {
|
||||||
|
return groupID === group.id ?
|
||||||
|
{
|
||||||
|
...group,
|
||||||
|
filters: [
|
||||||
|
...group.filters,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
col: state.copiedFilter.col,
|
||||||
|
type: state.copiedFilter.type,
|
||||||
|
op: state.copiedFilter.op,
|
||||||
|
value: state.copiedFilter.value,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
: group
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default useDataFilterStore;
|
||||||
|
|
||||||
Reference in New Issue
Block a user