import { createContext, useContext, useState, useEffect, useRef, useCallback, } from "react"; import { useMediaQuery } from "react-responsive"; import useSessionStorage from "@/src/components/useSessionStorage"; import { cn } from "@/src/utils/tailwind"; import { compactNumberFormatter } from "@/src/utils/numbers"; import { Accordion } from "@/src/components/ui/accordion"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { ChevronDown } from "lucide-react"; import { Checkbox } from "@/src/components/ui/checkbox"; import { Button } from "@/src/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/src/components/ui/tooltip"; import { Slider } from "@/src/components/ui/slider"; import { Input } from "@/src/components/ui/input"; import { Label } from "@/src/components/ui/label"; import { Skeleton } from "@/src/components/ui/skeleton"; import { X as IconX, Search, WandSparkles } from "lucide-react"; import type { UIFilter, KeyValueFilterEntry, NumericKeyValueFilterEntry, StringKeyValueFilterEntry, TextFilterEntry, } from "@/src/features/filters/hooks/useSidebarFilterState"; import { KeyValueFilterBuilder } from "@/src/components/table/key-value-filter-builder"; import { Popover, PopoverContent, PopoverTrigger, } from "@/src/components/ui/popover"; import { DataTableAIFilters } from "@/src/components/table/data-table-ai-filters"; import { type FilterState } from "@langfuse/shared"; import { useLangfuseCloudRegion } from "@/src/features/organizations/hooks"; interface ControlsContextType { open: boolean; setOpen: React.Dispatch>; tableName?: string; } export const ControlsContext = createContext(null); export function DataTableControlsProvider({ children, tableName, defaultSidebarCollapsed, }: { children: React.ReactNode; tableName?: string; defaultSidebarCollapsed?: boolean; }) { const isDesktop = useMediaQuery({ query: "(min-width: 768px)" }); const storageKey = tableName ? `data-table-controls-${tableName}` : "data-table-controls"; const defaultOpen = isDesktop ? !defaultSidebarCollapsed : false; const [open, setOpen] = useSessionStorage(storageKey, defaultOpen); return (
{children}
); } export function useDataTableControls() { const context = useContext(ControlsContext); if (!context) { // Return default values when not in a provider (e.g., tables without the new sidebar) return { open: false, setOpen: () => {}, tableName: undefined }; } return context as ControlsContextType; } export interface QueryFilter { filters: UIFilter[]; expanded: string[]; onExpandedChange: (value: string[]) => void; clearAll: () => void; isFiltered: boolean; setFilterState: (filters: FilterState) => void; } interface DataTableControlsProps { queryFilter: QueryFilter; filterWithAI?: boolean; } export function DataTableControls({ queryFilter, filterWithAI, }: DataTableControlsProps) { const { isLangfuseCloud } = useLangfuseCloudRegion(); const [aiPopoverOpen, setAiPopoverOpen] = useState(false); const handleFiltersGenerated = useCallback( (filters: FilterState) => { // Apply filters queryFilter.setFilterState(filters); // Extract unique column names from filters const columnsToExpand = [...new Set(filters.map((f) => f.column))]; // Get current expanded state and merge with new columns const currentExpanded = queryFilter.expanded; const newExpanded = Array.from( new Set([...currentExpanded, ...columnsToExpand]), ); queryFilter.onExpandedChange(newExpanded); // Close popover setAiPopoverOpen(false); }, [queryFilter], ); return (
Filters
{queryFilter.isFiltered && ( Clear all filters )} {filterWithAI && isLangfuseCloud && ( Filter with AI )}
{queryFilter.filters.map((filter) => { if (filter.type === "categorical") { return ( ); } if (filter.type === "numeric") { return ( ); } if (filter.type === "string") { return ( ); } if (filter.type === "keyValue") { return ( ); } if (filter.type === "numericKeyValue") { return ( ); } if (filter.type === "stringKeyValue") { return ( ); } return null; })}
); } interface BaseFacetProps { label: string; children?: React.ReactNode; filterKey: string; filterKeyShort?: string | null; expanded?: boolean; loading?: boolean; isActive?: boolean; onReset?: () => void; } interface CategoricalFacetProps extends BaseFacetProps { options: string[]; counts: Map; value: string[]; onChange: (values: string[]) => void; onOnlyChange?: (value: string) => void; operator?: "any of" | "all of"; onOperatorChange?: (operator: "any of" | "all of") => void; textFilters?: TextFilterEntry[]; onTextFilterAdd?: ( operator: "contains" | "does not contain", value: string, ) => void; onTextFilterRemove?: ( operator: "contains" | "does not contain", value: string, ) => void; } interface NumericFacetProps extends BaseFacetProps { min: number; max: number; value: [number, number]; onChange: (value: [number, number]) => void; unit?: string; } interface StringFacetProps extends BaseFacetProps { value: string; onChange: (value: string) => void; } interface KeyValueFacetProps extends BaseFacetProps { keyOptions?: string[]; availableValues: Record; value: KeyValueFilterEntry[]; onChange: (filters: KeyValueFilterEntry[]) => void; keyPlaceholder?: string; } interface NumericKeyValueFacetProps extends BaseFacetProps { keyOptions?: string[]; value: NumericKeyValueFilterEntry[]; onChange: (filters: NumericKeyValueFilterEntry[]) => void; keyPlaceholder?: string; } interface StringKeyValueFacetProps extends BaseFacetProps { keyOptions?: string[]; value: StringKeyValueFilterEntry[]; onChange: (filters: StringKeyValueFilterEntry[]) => void; keyPlaceholder?: string; } // Non-animated accordion components for filters const FilterAccordionItemPrimitive = AccordionPrimitive.Item; const FilterAccordionTrigger = ({ className, children, ...props }: React.ComponentPropsWithoutRef) => ( svg]:rotate-180", className, )} {...props} > {children} ); const FilterAccordionContent = ({ className, children, ...props }: React.ComponentPropsWithoutRef) => (
{children}
); interface FilterAccordionItemProps { label: string; filterKey: string; filterKeyShort?: string | null; children: React.ReactNode; isActive?: boolean; onReset?: () => void; } export function FilterAccordionItem({ label, filterKey, filterKeyShort, children, isActive, onReset, }: FilterAccordionItemProps) { return (
{label} {filterKeyShort && ( {filterKeyShort} )} {isActive && onReset && (
{ e.stopPropagation(); onReset(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.stopPropagation(); e.preventDefault(); onReset(); } }} className="inline-flex h-5 cursor-pointer items-center gap-1 rounded-full border bg-background px-2 text-xs hover:bg-accent hover:text-accent-foreground" aria-label={`Clear ${label} filter`} > Clear
)}
{children}
); } export function CategoricalFacet({ label, filterKey, filterKeyShort, expanded, loading, options, counts, value, onChange, onOnlyChange, isActive, onReset, operator, onOperatorChange, textFilters, onTextFilterAdd, onTextFilterRemove, }: CategoricalFacetProps) { const [showAll, setShowAll] = useState(false); const [searchQuery, setSearchQuery] = useState(""); // Track which filter mode is active (select checkboxes vs text filters) const [filterMode, setFilterMode] = useState<"select" | "text">("select"); // Reset showAll and searchQuery state when accordion is collapsed useEffect(() => { if (!expanded) { setShowAll(false); setSearchQuery(""); } }, [expanded]); // Handle mode change with auto-clear of filters from the other mode const handleModeChange = useCallback( (newMode: "select" | "text") => { setFilterMode(newMode); // Clear filters from the other mode if (newMode === "select") { textFilters?.forEach((f) => onTextFilterRemove?.(f.operator, f.value)); } else { onChange([]); } }, [textFilters, onTextFilterRemove, onChange], ); const MAX_VISIBLE_OPTIONS = 12; const hasMoreOptions = options.length > MAX_VISIBLE_OPTIONS; // Filter options by search query const filteredOptions = searchQuery ? options.filter((option) => option.toLowerCase().includes(searchQuery.toLowerCase()), ) : options; const hasMoreFilteredOptions = filteredOptions.length > MAX_VISIBLE_OPTIONS; const visibleOptions = showAll ? filteredOptions : filteredOptions.slice(0, MAX_VISIBLE_OPTIONS); return (
{/* Tab switcher - only show when text filtering is supported */} {onTextFilterAdd && ( )} {/* SELECT MODE: Checkboxes with optional counts */} {filterMode === "select" && (
{/* SOME/ALL Operator Toggle for arrayOptions filters This toggle appears for multi-valued array columns (arrayOptions) like tags. It allows switching between OR and AND logic: - SOME: Match items with ANY selected value (OR logic) - ALL: Match items with ALL selected values (AND logic) The toggle is automatically enabled by useSidebarFilterState for any arrayOptions column when selections exist. Other filter types (stringOptions, boolean, numeric) don't get this toggle as "ALL" wouldn't be semantically meaningful. Currently enabled for: - Traces: tags - Sessions: userIds, tags - Prompts: labels, tags */} {onOperatorChange && value.length > 0 && (
Match:
)} {/* Loading / Empty / Options */} {loading ? ( <> {[1, 2].map((i) => (
))} ) : options.length === 0 ? (
No options found
) : ( <> {/* Search box for many options */} {hasMoreOptions && (
setSearchQuery(e.target.value)} className="h-8 pl-7 text-xs" />
)} {/* Checkbox list */} {filteredOptions.length === 0 ? (
No matches found
) : ( <> {visibleOptions.map((option: string) => ( { const newValues = checked ? [...value, option] : value.filter((v: string) => v !== option); onChange(newValues); }} onLabelClick={ onOnlyChange ? () => onOnlyChange(option) : undefined } totalSelected={value.length} /> ))} {hasMoreFilteredOptions && !showAll && (
)} )} )}
)} {/* TEXT MODE: Contains/Does Not Contain filters */} {filterMode === "text" && onTextFilterAdd && (
)}
); } export function NumericFacet({ label, filterKey, filterKeyShort, expanded: _expanded, loading, min, max, value, onChange, unit, isActive, onReset, }: NumericFacetProps) { const [localValue, setLocalValue] = useState<[number, number]>(value); const timeoutRef = useRef(null); useEffect(() => { setLocalValue(value); }, [value]); // Cleanup timeout on unmount useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const updateWithDebounce = (newValue: [number, number]) => { setLocalValue(newValue); // Clear existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // Set new timeout timeoutRef.current = setTimeout(() => { onChange(newValue); }, 120); }; const handleSliderChange = (values: number[]) => { if (values.length === 2) { const newValue: [number, number] = [values[0], values[1]]; updateWithDebounce(newValue); } }; const handleMinInputChange = (e: React.ChangeEvent) => { const inputValue = e.target.value; // If input is cleared, reset to default min if (inputValue === "") { const newValue: [number, number] = [min, localValue[1]]; updateWithDebounce(newValue); return; } const newMin = parseFloat(inputValue); if (isNaN(newMin)) return; const newValue: [number, number] = [newMin, localValue[1]]; updateWithDebounce(newValue); }; const handleMaxInputChange = (e: React.ChangeEvent) => { const inputValue = e.target.value; // If input is cleared, reset to default max if (inputValue === "") { const newValue: [number, number] = [localValue[0], max]; updateWithDebounce(newValue); return; } const newMax = parseFloat(inputValue); if (isNaN(newMax)) return; const newValue: [number, number] = [localValue[0], newMax]; updateWithDebounce(newValue); }; return (
{loading ? (
Loading...
) : (
{unit && ( {unit} )}
{unit && ( {unit} )}
)}
); } export function StringFacet({ label, filterKey, filterKeyShort, expanded: _expanded, loading, value, onChange, isActive, onReset, }: StringFacetProps) { const [localValue, setLocalValue] = useState(value); const timeoutRef = useRef(null); useEffect(() => { setLocalValue(value); }, [value]); // Cleanup timeout on unmount useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const updateWithDebounce = (newValue: string) => { setLocalValue(newValue); // Clear existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // Set new timeout timeoutRef.current = setTimeout(() => { onChange(newValue); }, 500); }; const handleInputChange = (e: React.ChangeEvent) => { updateWithDebounce(e.target.value); }; return (
{loading ? (
Loading...
) : ( )}
); } export function KeyValueFacet({ label, filterKey, filterKeyShort, expanded: _expanded, loading, keyOptions, availableValues, value, onChange, isActive, onReset, keyPlaceholder, }: KeyValueFacetProps) { return ( {loading ? (
Loading...
) : ( )}
); } export function NumericKeyValueFacet({ label, filterKey, filterKeyShort, expanded: _expanded, loading, keyOptions, value, onChange, isActive, onReset, keyPlaceholder, }: NumericKeyValueFacetProps) { return ( {loading ? (
Loading...
) : ( )}
); } export function StringKeyValueFacet({ label, filterKey, filterKeyShort, expanded: _expanded, loading, keyOptions, value, onChange, isActive, onReset, keyPlaceholder, }: StringKeyValueFacetProps) { return ( {loading ? (
Loading...
) : ( )}
); } // Filter mode tabs for switching between Select (checkboxes) and Text (contains) modes interface FilterModeTabsProps { mode: "select" | "text"; onModeChange: (mode: "select" | "text") => void; } function FilterModeTabs({ mode, onModeChange }: FilterModeTabsProps) { return (
Mode:
); } // Text filter section for categorical filters // Single input with DOES/DOES NOT toggle, allows adding multiple filters function TextFilterSection({ allFilters, onAdd, onRemove, }: { allFilters: TextFilterEntry[]; onAdd?: (op: "contains" | "does not contain", val: string) => void; onRemove?: (op: "contains" | "does not contain", val: string) => void; }) { const [inputValue, setInputValue] = useState(""); const [selectedOperator, setSelectedOperator] = useState< "contains" | "does not contain" >("contains"); const handleAdd = () => { // people have filtered for a single " ", e.g. does not contain " " on sessionID to get all traces with a session id if (inputValue.length > 0 && onAdd) { onAdd(selectedOperator, inputValue); setInputValue(""); } }; return (
{/* Operator toggle */}
{/* Input + Add button */}
setInputValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAdd(); } }} placeholder="Enter value..." className="h-7 flex-1 text-xs" />
{/* Active filters list */} {allFilters.length > 0 && (
{allFilters.map((f, idx) => (
{f.operator === "contains" ? "contains" : "does not contain"} {f.value}
))}
)}
); } interface FilterValueCheckboxProps { id: string; label: string; count: number; checked?: boolean; onCheckedChange?: (checked: boolean) => void; onLabelClick?: () => void; // For "only this" behavior totalSelected?: number; disabled?: boolean; } export function FilterValueCheckbox({ id, label, count, checked = false, onCheckedChange, onLabelClick, totalSelected, disabled = false, }: FilterValueCheckboxProps) { // Show "All" when clicking would reverse selection (only one item selected) const labelText = checked && totalSelected === 1 ? "All" : "Only"; // Display placeholder for empty strings to ensure clickable area const displayLabel = label === "" ? "(empty)" : label; const displayTitle = label === "" ? "(empty)" : label; return (
{/* Checkbox hover area */}
{/* Label hover area */}
{displayLabel} {/* "Only" or "All" indicator when hovering label */} {onLabelClick && !disabled && ( {labelText} )} {count > 0 ? ( {compactNumberFormatter(count, 0)} ) : null}
); } export function DataTableControlsSection({ title, children, }: { title: string; children: React.ReactNode; }) { return (

{title}

{children}
); }