Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7c49bd1
feat: new filter logic
ogzhanolguncu Jan 10, 2025
23b7393
fix: remove ununused hook
ogzhanolguncu Jan 10, 2025
feaa801
Merge branch 'main' into logs-v2-filter-cloud
ogzhanolguncu Jan 13, 2025
92c1c99
fix: parsing logic
ogzhanolguncu Jan 13, 2025
61c8abb
Merge branch 'logs-v2-filter-cloud' of github.com:unkeyed/unkey into …
ogzhanolguncu Jan 13, 2025
fd36900
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 13, 2025
1cd7e17
chore: remove log
ogzhanolguncu Jan 13, 2025
89281b2
Merge branch 'logs-v2-filter-cloud' of github.com:unkeyed/unkey into …
ogzhanolguncu Jan 13, 2025
723738f
chore: revert flag
ogzhanolguncu Jan 13, 2025
0c1b297
feat: add ui for logs search
ogzhanolguncu Jan 13, 2025
e9afe8e
fix: input for search
ogzhanolguncu Jan 13, 2025
2dac542
feat: add structured query parsing
ogzhanolguncu Jan 13, 2025
33116a9
feat: allow parsing multiple search
ogzhanolguncu Jan 13, 2025
6beefba
chore: run formatter
ogzhanolguncu Jan 13, 2025
964a99f
Merge branch 'main' of github.com:unkeyed/unkey into logs-v2-search
ogzhanolguncu Jan 13, 2025
df0903a
[autofix.ci] apply automated fixes
autofix-ci[bot] Jan 13, 2025
5182ba5
chore: fix build issue
ogzhanolguncu Jan 13, 2025
48b3bec
Merge branch 'logs-v2-search' of github.com:unkeyed/unkey into logs-v…
ogzhanolguncu Jan 13, 2025
bd6d6cb
fix: build issue
ogzhanolguncu Jan 13, 2025
55f8301
fix: build error
ogzhanolguncu Jan 13, 2025
e0e4884
Merge branch 'main' into logs-v2-search
ogzhanolguncu Jan 14, 2025
bc967e3
feat: give preselected ai queries for users
ogzhanolguncu Jan 14, 2025
ffbd76a
refactor: common functions into a hook
ogzhanolguncu Jan 14, 2025
1a6120e
refactor: checkbox component and selection logic
ogzhanolguncu Jan 14, 2025
cff30d0
refactor: allow easier filter navigation
ogzhanolguncu Jan 14, 2025
8d4c067
refactor: add full keyboard navigation for selected filters
ogzhanolguncu Jan 14, 2025
48881ec
fix: import path
ogzhanolguncu Jan 14, 2025
39e2e62
Merge branch 'main' into logs-v2-search
ogzhanolguncu Jan 14, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 125 additions & 15 deletions apps/dashboard/app/(app)/logs-v2/components/control-cloud/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
};
Expand All @@ -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<HTMLDivElement>(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 (
<div className="flex gap-0.5 font-mono">
<div className="flex gap-0.5 font-mono" data-pill-index={index}>
<div className="bg-gray-3 px-2 rounded-l-md text-accent-12 font-medium py-[2px]">
{formatFieldName(field)}
</div>
Expand All @@ -62,18 +80,27 @@ const ControlPill = ({ filter, onRemove }: ControlPillProps) => {
{metadata?.icon}
<span className="text-accent-12 text-xs font-mono">{formatValue(value)}</span>
</div>
<Button
onClick={() => onRemove(filter.id)}
className="bg-gray-3 rounded-none rounded-r-md py-[2px] px-2 [&_svg]:stroke-[2px] [&_svg]:size-3 flex items-center border-none h-auto"
>
<XMark className="text-gray-9" />
</Button>
<div ref={pillRef} className="contents">
<Button
onClick={() => onRemove(filter.id)}
onFocus={onFocus}
onKeyDown={handleKeyDown}
tabIndex={0}
className={cn(
"bg-gray-3 rounded-none rounded-r-md py-[2px] px-2 [&_svg]:stroke-[2px] [&_svg]:size-3 flex items-center border-none h-auto focus:ring-2 focus:ring-accent-7 focus:outline-none",
isFocused && "bg-gray-4",
)}
>
<XMark className={cn("text-gray-9", isFocused && "text-gray-11")} />
</Button>
</div>
</div>
);
};

export const ControlCloud = () => {
const { filters, removeFilter, updateFilters } = useFilters();
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);

useKeyboardShortcut({ key: "d", meta: true }, () => {
updateFilters([]);
Expand All @@ -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 (
<div className="px-3 py-2 w-full flex items-center min-h-10 border-b border-gray-4 gap-2 text-xs flex-wrap">
{filters.map((filter) => (
<ControlPill key={filter.id} filter={filter} onRemove={handleRemoveFilter} />
<div
className="px-3 py-2 w-full flex items-center min-h-10 border-b border-gray-4 gap-2 text-xs flex-wrap"
onKeyDown={handleKeyDown}
>
{filters.map((filter, index) => (
<ControlPill
key={filter.id}
filter={filter}
onRemove={handleRemoveFilter}
isFocused={focusedIndex === index}
onFocus={() => setFocusedIndex(index)}
index={index}
/>
))}
<div className="flex items-center px-2 py-1 gap-1 ml-auto">
<span className="text-gray-9 text-[13px]">Clear filters</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TCheckbox extends BaseCheckboxOption> {
options: TCheckbox[];
filterField: "methods" | "paths" | "status";
checkPath: string;
className?: string;
showScroll?: boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement>;
renderBottomGradient?: () => React.ReactNode;
renderOptionContent?: (option: TCheckbox) => React.ReactNode;
createFilterValue: (option: TCheckbox) => Pick<FilterValue, "value" | "metadata">;
}

export const FilterCheckbox = <TCheckbox extends BaseCheckboxOption>({
options,
filterField,
checkPath,
className,
showScroll = false,
renderOptionContent,
createFilterValue,
scrollContainerRef,
renderBottomGradient,
}: BaseCheckboxFilterProps<TCheckbox>) => {
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 (
<div className={cn("flex flex-col p-2", className)}>
<div
className={cn(
"flex flex-col gap-2 font-mono px-2 py-2",
showScroll &&
"max-h-64 overflow-auto [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]",
)}
ref={scrollContainerRef}
>
<div className="flex justify-between items-center">
<label
className="flex items-center gap-2 cursor-pointer"
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: its okay
role="checkbox"
aria-checked={checkboxes.every((checkbox) => checkbox.checked)}
onKeyDown={handleKeyDown}
>
<Checkbox
tabIndex={0}
checked={checkboxes.every((checkbox) => checkbox.checked)}
className="size-[14px] rounded border-gray-4 [&_svg]:size-3"
onClick={handleSelectAll}
/>
<span className="text-xs text-accent-12 ml-2">
{checkboxes.every((checkbox) => checkbox.checked) ? "Unselect All" : "Select All"}
</span>
</label>
</div>
{checkboxes.map((checkbox, index) => (
<label
key={checkbox.id}
className="flex gap-4 items-center py-1 cursor-pointer"
// biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: its okay
role="checkbox"
aria-checked={checkbox.checked}
onKeyDown={(e) => handleKeyDown(e, index)}
>
<Checkbox
tabIndex={0}
checked={checkbox.checked}
className="size-[14px] rounded border-gray-4 [&_svg]:size-3"
onClick={() => handleCheckboxChange(index)}
/>
{renderOptionContent ? renderOptionContent(checkbox) : null}
</label>
))}
</div>

{renderBottomGradient?.()}

{renderBottomGradient && <div className="border-t border-gray-4" />}
<Button
variant="primary"
className="font-sans mt-2 w-full h-9 rounded-md"
onClick={handleApplyFilter}
>
Apply Filter
</Button>
</div>
);
};
Loading
Loading