diff --git a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx index 3a38c414d8..e4fb5e946d 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx @@ -2,9 +2,10 @@ import { KeyboardButton } from "@/components/keyboard-button"; import { cn } from "@/lib/utils"; import { XMark } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { useCallback } from "react"; +import { type KeyboardEvent, useCallback, useEffect, useRef, useState } from "react"; +import type { FilterValue } from "../../filters.type"; +import { useFilters } from "../../hooks/use-filters"; import { useKeyboardShortcut } from "../../hooks/use-keyboard-shortcut"; -import { type FilterValue, useFilters } from "../../query-state"; const formatFieldName = (field: string): string => { switch (field) { @@ -17,7 +18,6 @@ const formatFieldName = (field: string): string => { case "requestId": return "Request ID"; default: - // Capitalize first letter return field.charAt(0).toUpperCase() + field.slice(1); } }; @@ -42,13 +42,31 @@ const formatValue = (value: string | number): string => { type ControlPillProps = { filter: FilterValue; onRemove: (id: string) => void; + isFocused?: boolean; + onFocus?: () => void; + index: number; }; -const ControlPill = ({ filter, onRemove }: ControlPillProps) => { +const ControlPill = ({ filter, onRemove, isFocused, onFocus, index }: ControlPillProps) => { const { field, operator, value, metadata } = filter; + const pillRef = useRef(null); + + useEffect(() => { + if (isFocused && pillRef.current) { + const button = pillRef.current.querySelector("button"); + button?.focus(); + } + }, [isFocused]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Backspace" || e.key === "Delete") { + e.preventDefault(); + onRemove(filter.id); + } + }; return ( -
+
{formatFieldName(field)}
@@ -62,18 +80,27 @@ const ControlPill = ({ filter, onRemove }: ControlPillProps) => { {metadata?.icon} {formatValue(value)}
- +
+ +
); }; export const ControlCloud = () => { const { filters, removeFilter, updateFilters } = useFilters(); + const [focusedIndex, setFocusedIndex] = useState(null); useKeyboardShortcut({ key: "d", meta: true }, () => { updateFilters([]); @@ -82,18 +109,101 @@ export const ControlCloud = () => { const handleRemoveFilter = useCallback( (id: string) => { removeFilter(id); + // Adjust focus after removal + if (focusedIndex !== null) { + if (focusedIndex >= filters.length - 1) { + setFocusedIndex(Math.max(filters.length - 2, 0)); + } + } }, - [removeFilter], + [removeFilter, filters.length, focusedIndex], ); + const handleKeyDown = (e: KeyboardEvent) => { + if (filters.length === 0) { + return; + } + + const findNextInDirection = (direction: "up" | "down") => { + if (focusedIndex === null) { + return 0; + } + + // Get all buttons + const buttons = document.querySelectorAll("[data-pill-index] button"); + const currentButton = buttons[focusedIndex] as HTMLElement; + if (!currentButton) { + return focusedIndex; + } + + const currentRect = currentButton.getBoundingClientRect(); + let closestDistance = Number.POSITIVE_INFINITY; + let closestIndex = focusedIndex; + + buttons.forEach((button, index) => { + const rect = button.getBoundingClientRect(); + + // Check if item is in the row above/below + const isAbove = direction === "up" && rect.bottom < currentRect.top; + const isBelow = direction === "down" && rect.top > currentRect.bottom; + + if (isAbove || isBelow) { + // Calculate horizontal distance + const horizontalDistance = Math.abs(rect.left - currentRect.left); + if (horizontalDistance < closestDistance) { + closestDistance = horizontalDistance; + closestIndex = index; + } + } + }); + + return closestIndex; + }; + + switch (e.key) { + case "ArrowRight": + case "l": + e.preventDefault(); + setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % filters.length)); + break; + case "ArrowLeft": + case "h": + e.preventDefault(); + setFocusedIndex((prev) => + prev === null ? filters.length - 1 : (prev - 1 + filters.length) % filters.length, + ); + break; + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex(findNextInDirection("down")); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex(findNextInDirection("up")); + break; + } + }; + if (filters.length === 0) { return null; } return ( -
- {filters.map((filter) => ( - +
+ {filters.map((filter, index) => ( + setFocusedIndex(index)} + index={index} + /> ))}
Clear filters diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx new file mode 100644 index 0000000000..7541df11ef --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filter-checkbox.tsx @@ -0,0 +1,121 @@ +import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; +import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; +import { Checkbox } from "@/components/ui/checkbox"; +import { cn } from "@/lib/utils"; +import { Button } from "@unkey/ui"; +import { useCallback } from "react"; +import { useCheckboxState } from "./hooks/use-checkbox-state"; + +export type BaseCheckboxOption = { + id: number; + checked: boolean; + [key: string]: any; +}; + +interface BaseCheckboxFilterProps { + options: TCheckbox[]; + filterField: "methods" | "paths" | "status"; + checkPath: string; + className?: string; + showScroll?: boolean; + scrollContainerRef?: React.RefObject; + renderBottomGradient?: () => React.ReactNode; + renderOptionContent?: (option: TCheckbox) => React.ReactNode; + createFilterValue: (option: TCheckbox) => Pick; +} + +export const FilterCheckbox = ({ + options, + filterField, + checkPath, + className, + showScroll = false, + renderOptionContent, + createFilterValue, + scrollContainerRef, + renderBottomGradient, +}: BaseCheckboxFilterProps) => { + const { filters, updateFilters } = useFilters(); + const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ + options, + filters, + filterField, + checkPath, + }); + + const handleApplyFilter = useCallback(() => { + const selectedValues = checkboxes.filter((c) => c.checked).map((c) => createFilterValue(c)); + + const otherFilters = filters.filter((f) => f.field !== filterField); + const newFilters: FilterValue[] = selectedValues.map((filterValue) => ({ + id: crypto.randomUUID(), + field: filterField, + operator: "is", + ...filterValue, + })); + + updateFilters([...otherFilters, ...newFilters]); + }, [checkboxes, filterField, filters, updateFilters, createFilterValue]); + + return ( +
+
+
+ +
+ {checkboxes.map((checkbox, index) => ( + + ))} +
+ + {renderBottomGradient?.()} + + {renderBottomGradient &&
} + +
+ ); +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx index fad443b711..50e5218796 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/filters-popover.tsx @@ -1,10 +1,10 @@ +import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; import { useKeyboardShortcut } from "@/app/(app)/logs-v2/hooks/use-keyboard-shortcut"; -import { useFilters } from "@/app/(app)/logs-v2/query-state"; import { KeyboardButton } from "@/components/keyboard-button"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { CaretRight } from "@unkey/icons"; import { Button } from "@unkey/ui"; -import { type PropsWithChildren, useState } from "react"; +import { type KeyboardEvent, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { MethodsFilter } from "./methods-filter"; import { PathsFilter } from "./paths-filter"; import { StatusFilter } from "./status-filter"; @@ -18,7 +18,7 @@ type FilterItemConfig = { const FILTER_ITEMS: FilterItemConfig[] = [ { - id: "responseStatus", + id: "status", label: "Status", shortcut: "s", component: , @@ -39,22 +39,76 @@ const FILTER_ITEMS: FilterItemConfig[] = [ export const FiltersPopover = ({ children }: PropsWithChildren) => { const [open, setOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(null); + const [activeFilter, setActiveFilter] = useState(null); useKeyboardShortcut("f", () => { setOpen((prev) => !prev); }); + const handleKeyDown = (e: KeyboardEvent) => { + if (!open) { + return; + } + + // If we have an active filter and press left, close it + if ((e.key === "ArrowLeft" || e.key === "h") && activeFilter) { + e.preventDefault(); + setActiveFilter(null); + return; + } + + switch (e.key) { + case "ArrowDown": + case "j": + e.preventDefault(); + setFocusedIndex((prev) => (prev === null ? 0 : (prev + 1) % FILTER_ITEMS.length)); + break; + case "ArrowUp": + case "k": + e.preventDefault(); + setFocusedIndex((prev) => + prev === null + ? FILTER_ITEMS.length - 1 + : (prev - 1 + FILTER_ITEMS.length) % FILTER_ITEMS.length, + ); + break; + case "Enter": + case "l": + case "ArrowRight": + e.preventDefault(); + if (focusedIndex !== null) { + const selectedFilter = FILTER_ITEMS[focusedIndex]; + if (selectedFilter) { + // Find the filterItem component and trigger its open state + const filterRefs = document.querySelectorAll("[data-filter-id]"); + const selectedRef = filterRefs[focusedIndex] as HTMLElement; + if (selectedRef) { + selectedRef.click(); + setActiveFilter(selectedFilter.id); + } + } + } + break; + case "h": + case "ArrowLeft": + // Don't handle left arrow in main popover - let it bubble to FilterItem + break; + } + }; + return ( {children}
- {FILTER_ITEMS.map((item) => ( - + {FILTER_ITEMS.map((item, index) => ( + ))}
@@ -71,11 +125,23 @@ const PopoverHeader = () => { ); }; -export const FilterItem = ({ label, shortcut, id, component }: FilterItemConfig) => { +type FilterItemProps = FilterItemConfig & { + isFocused?: boolean; +}; + +export const FilterItem = ({ label, shortcut, id, component, isFocused }: FilterItemProps) => { const { filters } = useFilters(); const [open, setOpen] = useState(false); + const itemRef = useRef(null); + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.key === "ArrowLeft" || e.key === "h") && open) { + e.preventDefault(); + setOpen(false); + itemRef.current?.focus(); + } + }; - // Add keyboard shortcut for each filter item when main filter is open useKeyboardShortcut( { key: shortcut || "", meta: true }, () => { @@ -84,10 +150,25 @@ export const FilterItem = ({ label, shortcut, id, component }: FilterItemConfig) { preventDefault: true }, ); + // Focus the element when isFocused changes + useEffect(() => { + if (isFocused && itemRef.current) { + itemRef.current.focus(); + } + }, [isFocused]); + return ( -
+
{shortcut && ( filter.field === id).length}
)} - @@ -118,6 +198,7 @@ export const FilterItem = ({ label, shortcut, id, component }: FilterItemConfig) side="right" align="start" sideOffset={12} + onKeyDown={handleKeyDown} > {component} diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts new file mode 100644 index 0000000000..939bf9644c --- /dev/null +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/hooks/use-checkbox-state.ts @@ -0,0 +1,98 @@ +import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; +import { useCallback, useState } from "react"; + +type UseCheckboxStateProps = { + options: Array<{ id: number } & TItem>; + filters: FilterValue[]; + filterField: string; + checkPath: keyof TItem; // Specify which field to get from checkbox item +}; + +export const useCheckboxState = >({ + options, + filters, + filterField, + checkPath, +}: UseCheckboxStateProps) => { + const [checkboxes, setCheckboxes] = useState(() => { + const activeFilters = filters + .filter((f) => f.field === filterField) + .map((f) => String(f.value)); + + return options.map((checkbox) => ({ + ...checkbox, + checked: activeFilters.includes(String(checkbox[checkPath])), + })); + }); + + const handleCheckboxChange = (index: number): void => { + setCheckboxes((prevCheckboxes) => { + const newCheckboxes = [...prevCheckboxes]; + newCheckboxes[index] = { + ...newCheckboxes[index], + checked: !newCheckboxes[index].checked, + }; + return newCheckboxes; + }); + }; + + const handleSelectAll = (): void => { + setCheckboxes((prevCheckboxes) => { + const allChecked = prevCheckboxes.every((checkbox) => checkbox.checked); + return prevCheckboxes.map((checkbox) => ({ + ...checkbox, + checked: !allChecked, + })); + }); + }; + + const handleToggle = useCallback( + (index?: number) => { + if (typeof index === "number") { + handleCheckboxChange(index); + } else { + handleSelectAll(); + } + }, + [handleCheckboxChange, handleSelectAll], + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent, index?: number) => { + // Handle checkbox toggle + if (event.key === " " || event.key === "Enter" || event.key === "h" || event.key === "l") { + event.preventDefault(); + handleToggle(index); + } + + // Handle navigation + if ( + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "j" || + event.key === "k" + ) { + event.preventDefault(); + const elements = document.querySelectorAll('label[role="checkbox"]'); + const currentIndex = Array.from(elements).findIndex((el) => el === event.currentTarget); + + let nextIndex: number; + if (event.key === "ArrowDown" || event.key === "j") { + nextIndex = currentIndex < elements.length - 1 ? currentIndex + 1 : 0; + } else { + nextIndex = currentIndex > 0 ? currentIndex - 1 : elements.length - 1; + } + + (elements[nextIndex] as HTMLElement).focus(); + } + }, + [handleToggle], + ); + + return { + checkboxes, + handleCheckboxChange, + handleSelectAll, + handleKeyDown, + }; +}; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/methods-filter.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/methods-filter.tsx index a082642193..a7419e240e 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/methods-filter.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/methods-filter.tsx @@ -1,15 +1,13 @@ -import { type FilterValue, type HttpMethod, useFilters } from "@/app/(app)/logs-v2/query-state"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Button } from "@unkey/ui"; -import { useCallback, useEffect, useState } from "react"; +import type { HttpMethod } from "@/app/(app)/logs-v2/filters.type"; +import { FilterCheckbox } from "./filter-checkbox"; -interface CheckboxOption { +type MethodOption = { id: number; method: HttpMethod; checked: boolean; -} +}; -const options: CheckboxOption[] = [ +const options: MethodOption[] = [ { id: 1, method: "GET", checked: false }, { id: 2, method: "POST", checked: false }, { id: 3, method: "PUT", checked: false }, @@ -18,92 +16,17 @@ const options: CheckboxOption[] = [ ] as const; export const MethodsFilter = () => { - const { filters, updateFilters } = useFilters(); - const [checkboxes, setCheckboxes] = useState(options); - - // Sync checkboxes with filters on mount and when filters change - useEffect(() => { - const methodFilters = filters - .filter((f) => f.field === "methods") - .map((f) => f.value as HttpMethod); - - setCheckboxes((prev) => - prev.map((checkbox) => ({ - ...checkbox, - checked: methodFilters.includes(checkbox.method), - })), - ); - }, [filters]); - - const handleCheckboxChange = (index: number): void => { - setCheckboxes((prevCheckboxes) => { - const newCheckboxes = [...prevCheckboxes]; - newCheckboxes[index] = { - ...newCheckboxes[index], - checked: !newCheckboxes[index].checked, - }; - return newCheckboxes; - }); - }; - - const handleSelectAll = (): void => { - setCheckboxes((prevCheckboxes) => { - const allChecked = prevCheckboxes.every((checkbox) => checkbox.checked); - return prevCheckboxes.map((checkbox) => ({ - ...checkbox, - checked: !allChecked, - })); - }); - }; - - const handleApplyFilter = useCallback(() => { - const selectedMethods = checkboxes.filter((c) => c.checked).map((c) => c.method); - - // Keep all non-method filters and add new method filters - const otherFilters = filters.filter((f) => f.field !== "methods"); - const methodFilters: FilterValue[] = selectedMethods.map((method) => ({ - id: crypto.randomUUID(), - field: "methods", - operator: "is", - value: method, - })); - - updateFilters([...otherFilters, ...methodFilters]); - }, [checkboxes, filters, updateFilters]); - return ( -
-
-
- -
- {checkboxes.map((checkbox, index) => ( - - ))} -
- -
+ ( +
{checkbox.method}
+ )} + createFilterValue={(option) => ({ + value: option.method, + })} + /> ); }; diff --git a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx index 769fe2856a..2b603a08fd 100644 --- a/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx +++ b/apps/dashboard/app/(app)/logs-v2/components/controls/components/logs-filters/components/paths-filter.tsx @@ -1,7 +1,9 @@ -import { type FilterValue, useFilters } from "@/app/(app)/logs-v2/query-state"; +import type { FilterValue } from "@/app/(app)/logs-v2/filters.type"; +import { useFilters } from "@/app/(app)/logs-v2/hooks/use-filters"; import { Checkbox } from "@/components/ui/checkbox"; import { Button } from "@unkey/ui"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useCheckboxState } from "./hooks/use-checkbox-state"; interface CheckboxOption { id: number; @@ -89,22 +91,15 @@ const options: CheckboxOption[] = [ export const PathsFilter = () => { const { filters, updateFilters } = useFilters(); - const [checkboxes, setCheckboxes] = useState(options); const [isAtBottom, setIsAtBottom] = useState(false); const scrollContainerRef = useRef(null); - // Sync checkboxes with filters on mount and when filters change - useEffect(() => { - const pathFilters = filters.filter((f) => f.field === "paths").map((f) => f.value as string); - - setCheckboxes((prev) => - prev.map((checkbox) => ({ - ...checkbox, - checked: pathFilters.includes(checkbox.path), - })), - ); - }, [filters]); - + const { checkboxes, handleCheckboxChange, handleSelectAll, handleKeyDown } = useCheckboxState({ + options, + filters, + filterField: "paths", + checkPath: "path", + }); const handleScroll = useCallback(() => { if (scrollContainerRef.current) { const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current; @@ -124,27 +119,6 @@ export const PathsFilter = () => { } }, [handleScroll]); - const handleCheckboxChange = (index: number): void => { - setCheckboxes((prevCheckboxes) => { - const newCheckboxes = [...prevCheckboxes]; - newCheckboxes[index] = { - ...newCheckboxes[index], - checked: !newCheckboxes[index].checked, - }; - return newCheckboxes; - }); - }; - - const handleSelectAll = (): void => { - setCheckboxes((prevCheckboxes) => { - const allChecked = prevCheckboxes.every((checkbox) => checkbox.checked); - return prevCheckboxes.map((checkbox) => ({ - ...checkbox, - checked: !allChecked, - })); - }); - }; - const handleApplyFilter = useCallback(() => { const selectedPaths = checkboxes.filter((c) => c.checked).map((c) => c.path); @@ -162,7 +136,13 @@ export const PathsFilter = () => { return (
-