diff --git a/e2e-tests/bun.lockb b/e2e-tests/bun.lockb index 87f5a3e..b9fb6d4 100755 Binary files a/e2e-tests/bun.lockb and b/e2e-tests/bun.lockb differ diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 43666fb..1aa0c83 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -4,7 +4,7 @@ "author": "", "main": "index.js", "devDependencies": { - "@playwright/test": "^1.42.1", + "@playwright/test": "^1.49.1", "@types/bun": "^1.0.11", "@types/node": "^20.11.30" }, @@ -23,6 +23,7 @@ "license": "ISC", "private": true, "dependencies": { - "find-free-ports": "^3.1.1" + "find-free-ports": "^3.1.1", + "playwright": "^1.49.1" } } diff --git a/frontend/bun.lockb b/frontend/bun.lockb index 81ec563..bc7f718 100755 Binary files a/frontend/bun.lockb and b/frontend/bun.lockb differ diff --git a/frontend/package.json b/frontend/package.json index e82c779..248925b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toggle-group": "^1.1.0", + "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-tooltip": "^1.0.7", "@tanstack/react-table": "^8.12.0", "class-variance-authority": "^0.7.0", @@ -21,12 +23,14 @@ "fast-equals": "^5.0.1", "history": "^5.3.0", "lucide-react": "^0.338.0", + "mark.js": "^8.11.1", "prism-react-renderer": "^2.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.3.0", "react-json-tree": "^0.18.0", "react-json-view": "^1.21.3", + "react-mark.js": "^9.0.7", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "tracethat.dev": "../reporters/javascript/tracethat.dev", diff --git a/frontend/src/components/ui/toggle-group.tsx b/frontend/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..5b6729b --- /dev/null +++ b/frontend/src/components/ui/toggle-group.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group" +import { type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps +>({ + size: "default", + variant: "default", +}) + +const ToggleGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, children, ...props }, ref) => ( + + + {children} + + +)) + +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName + +const ToggleGroupItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, children, variant, size, ...props }, ref) => { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +}) + +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName + +export { ToggleGroup, ToggleGroupItem } diff --git a/frontend/src/components/ui/toggle.tsx b/frontend/src/components/ui/toggle.tsx new file mode 100644 index 0000000..e81dd24 --- /dev/null +++ b/frontend/src/components/ui/toggle.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import * as TogglePrimitive from "@radix-ui/react-toggle" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const toggleVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground", + { + variants: { + variant: { + default: "bg-transparent", + outline: + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground", + }, + size: { + default: "h-10 px-3", + sm: "h-9 px-2.5", + lg: "h-11 px-5", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +const Toggle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, size, ...props }, ref) => ( + +)) + +Toggle.displayName = TogglePrimitive.Root.displayName + +export { Toggle, toggleVariants } diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 0000000..7d8ac98 --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,19 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +export default useDebounce; \ No newline at end of file diff --git a/frontend/src/hooks/usePrevious.ts b/frontend/src/hooks/usePrevious.ts new file mode 100644 index 0000000..bdf4323 --- /dev/null +++ b/frontend/src/hooks/usePrevious.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export function usePrevious(value: T) { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +} diff --git a/frontend/src/hooks/useRemount.ts b/frontend/src/hooks/useRemount.ts new file mode 100644 index 0000000..25bc5e0 --- /dev/null +++ b/frontend/src/hooks/useRemount.ts @@ -0,0 +1,15 @@ +import { useState } from "react"; + +export function useRemount() { + const [mounted, setMounted] = useState(true); + + return { + mounted, + remount: () => { + setMounted(false); + setTimeout(() => { + setMounted(true); + }, 0); + }, + }; +} diff --git a/frontend/src/layouts/EventViewer/EventViewer.tsx b/frontend/src/layouts/EventViewer/EventViewer.tsx index cbb0659..30f8232 100644 --- a/frontend/src/layouts/EventViewer/EventViewer.tsx +++ b/frontend/src/layouts/EventViewer/EventViewer.tsx @@ -10,14 +10,55 @@ import { TraceEvent } from "@/validators/TraceEvent"; import { Loader2, X } from "lucide-react"; import { ReactNode, useMemo } from "react"; import ReactJson, { ThemeObject } from "react-json-view"; +import { Marker } from "react-mark.js"; +import { useRef, useEffect, useState } from "react"; +import { SearchBy } from "../EventsSearch/EventsSearch"; interface EventViewerProps { events: TraceEvent[]; selectedEventCallId: string | null; onEventClose: () => void; viewerPlaceholder: ReactNode; + searchValue: string; + searchBy: SearchBy; } -export const EventViewer = ({ events, selectedEventCallId, onEventClose, viewerPlaceholder }: EventViewerProps) => { + +export const EventViewer = ({ + events, + selectedEventCallId, + onEventClose, + viewerPlaceholder, + searchValue, + searchBy, +}: EventViewerProps) => { + const [searchValueMarker, setSearchValueMarker] = useState({ searchValue }); + + useEffect(() => { + setSearchValueMarker({ searchValue: searchValue }); + }, [searchValue, searchBy]); + + const jsonViewerRef = useRef(null); + useEffect(() => { + // New properties might appear in the JSON + // and we need to remount the higlighter so it picks up the changes + const observer = new MutationObserver((mutationsList) => { + if ((mutationsList[0].target as HTMLElement).className === "icon-container") { + setSearchValueMarker({ searchValue: searchValue }); + } + }); + + if (jsonViewerRef.current) { + observer.observe(jsonViewerRef.current, { + childList: true, + subtree: true, + }); + } + + return () => { + observer.disconnect(); + }; + }, [events, selectedEventCallId, onEventClose, viewerPlaceholder, searchValue, searchBy]); + const selectedEvent = useMemo(() => { if (selectedEventCallId == null) { return undefined; @@ -84,19 +125,31 @@ export const EventViewer = ({ events, selectedEventCallId, onEventClose, viewerP
- name !== "root"} - /> +
+ + { + if (searchBy === "eventName" || searchValue === "") { + return name !== "root"; + } + + const isSearchedDataHere = JSON.stringify(src).toLowerCase().includes(searchValue.toLowerCase()); + + return !isSearchedDataHere; + }} + /> + +
diff --git a/frontend/src/layouts/EventsList/EventsList.tsx b/frontend/src/layouts/EventsList/EventsList.tsx index 013dd96..a984e07 100644 --- a/frontend/src/layouts/EventsList/EventsList.tsx +++ b/frontend/src/layouts/EventsList/EventsList.tsx @@ -3,39 +3,121 @@ import { EventsTable } from "../EventsTable/EventsTable"; import { cn } from "@/lib/utils"; import styles from "./EventsList.module.css"; import { TraceEvent } from "@/validators/TraceEvent"; -import { ReactNode } from "react"; +import { ReactNode, useState, useMemo, useEffect, useCallback } from "react"; import { traceThat } from "tracethat.dev"; +import { useDebounce } from "@/hooks/useDebounce"; +import { EventsSearch, SearchBy } from "@/layouts/EventsSearch/EventsSearch"; +import { usePrevious } from "@/hooks/usePrevious"; +import { useRemount } from "@/hooks/useRemount"; interface EventsListProps { data: TraceEvent[]; - selectedEventCallId: string | null; viewerPlaceholder?: ReactNode; - onEventClose: () => void; - setSelectedEventCallId: (value: string | null) => void; + isSearchBarHidden?: boolean; } -export function EventsList({ - data, - selectedEventCallId, - setSelectedEventCallId, - onEventClose, - viewerPlaceholder, -}: EventsListProps) { +export function EventsList({ data, viewerPlaceholder, isSearchBarHidden = false }: EventsListProps) { + const [selectedEventCallId, setSelectedEventCallId] = useState(null); + + const onEventClose = useCallback(() => { + setSelectedEventCallId(null); + }, [setSelectedEventCallId]); + + const [rawSearchValue, setSearchValue] = useState(""); + const [searchBy, setSearchBy] = useState("all"); + const searchValue = useDebounce(rawSearchValue, 200); + + const filteredData = useMemo(() => { + if (!searchValue) { + return data; + } + + return data.filter((item) => { + const searchInName = () => { + return item.name.toLowerCase().includes(searchValue.toLowerCase()); + }; + + const searchInDetails = () => { + return JSON.stringify(item.details).toLowerCase().includes(searchValue.toLowerCase()); + }; + + if (searchBy === "eventName") { + return searchInName(); + } else if (searchBy === "eventDetails") { + return searchInDetails(); + } + return searchInName() || searchInDetails(); + }); + }, [searchValue, searchBy, data]); + + const searchValueByName = searchBy !== "eventDetails" ? searchValue : ""; + const searchValueByDetails = searchBy !== "eventName" ? searchValue : ""; + + const isSearchNotFound = searchValue && filteredData.length === 0; + + const previousFirst = usePrevious(filteredData[0]); + const previousSearchValue = usePrevious(searchValue); + + useEffect(() => { + if (!filteredData[0]) return; + + // auto select the first event if the search changed + if (filteredData[0] != previousFirst || previousSearchValue != searchValue) { + setSelectedEventCallId(filteredData[0].callId); + } + }, [filteredData[0], previousFirst, searchValue, previousSearchValue]); + + const { mounted: viewerMounted, remount: remountViewer } = useRemount(); + + useEffect(() => { + remountViewer(); + }, [searchValue]); + + const onEventSelected = useCallback( + (value: string | null) => { + if (searchValue) { + remountViewer(); + } + setSelectedEventCallId(value); + }, + [searchValue], + ); + return (
-
- + {!isSearchBarHidden && } + + {!isSearchNotFound && ( + + )} + + {isSearchNotFound && ( +
+
+

No Results Found

+

+ No results for: {searchValue} +

+
+
+ )} +
+ {viewerMounted && ( + -
- + )}
); diff --git a/frontend/src/layouts/EventsSearch/EventsSearch.tsx b/frontend/src/layouts/EventsSearch/EventsSearch.tsx new file mode 100644 index 0000000..cab9f6c --- /dev/null +++ b/frontend/src/layouts/EventsSearch/EventsSearch.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { Input } from "@/components/ui/input/input"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; + +export type SearchBy = "eventName" | "eventDetails" | "all"; + +interface EventsSearchProps { + setSearchValue: React.Dispatch>; + setSearchBy: React.Dispatch>; +} + +export function EventsSearch({ setSearchValue, setSearchBy }: EventsSearchProps) { + const [toggleGroupValue, setToggleGroupValue] = useState("all"); + + const handleSearchByValue = (eventValue: SearchBy) => { + if (eventValue) { + setToggleGroupValue(eventValue); + setSearchBy(eventValue); + } + }; + + return ( +
+ { + setSearchValue(event.target.value); + }} + /> +
+ + + name + + + details + + + all + + +
+
+ ); +} diff --git a/frontend/src/layouts/EventsTable/EventsTable.tsx b/frontend/src/layouts/EventsTable/EventsTable.tsx index 027d121..ca587d2 100644 --- a/frontend/src/layouts/EventsTable/EventsTable.tsx +++ b/frontend/src/layouts/EventsTable/EventsTable.tsx @@ -5,101 +5,105 @@ import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@ import { cn } from "@/lib/utils"; import { getColor } from "@/utils/colors"; import { useHandleTableScroll } from "./EventsTable.hooks"; +import { Marker } from "react-mark.js"; interface EventViewerProps { events: TraceEvent[]; selectedEventCallId: string | null; setSelectedEventCallId?: (callId: string | null) => void; + searchValue: string; } -export const EventsTable = ({ events, selectedEventCallId, setSelectedEventCallId }: EventViewerProps) => { + +export const EventsTable = ({ events, selectedEventCallId, setSelectedEventCallId, searchValue }: EventViewerProps) => { const table = useReactTable({ data: events, columns, getCoreRowModel: getCoreRowModel(), getRowId: (row) => row.callId, }); - const iconColumnClassNames = "pr-0"; const { tableRef, tableWrapperRef } = useHandleTableScroll(); return (
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const color = getColor(row.getValue("name")); + return ( - - {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - - ); - })} - - ))} - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => { - const color = getColor(row.getValue("name")); + { + if (setSelectedEventCallId == null) { + return; + } - return ( - { - if (setSelectedEventCallId == null) { - return; - } + if (row.id === selectedEventCallId) { + return setSelectedEventCallId(null); + } - if (row.id === selectedEventCallId) { - return setSelectedEventCallId(null); + return setSelectedEventCallId(row.id); + }} + style={ + { + "--row-base": color.base, + "--row-rest": color.rest, + "--row-hover": color.hover, + } as React.CSSProperties } + className={cn("bg-[--row-rest]", { + ["hover:bg-[--row-hover] cursor-pointer"]: setSelectedEventCallId != null, + })} + > + {row.getVisibleCells().map((cell, i) => { + const isSelected = i === 0 && row.id === selectedEventCallId; - return setSelectedEventCallId(row.id); - }} - style={ - { - "--row-base": color.base, - "--row-rest": color.rest, - "--row-hover": color.hover, - } as React.CSSProperties - } - className={cn("bg-[--row-rest]", { - ["hover:bg-[--row-hover] cursor-pointer"]: setSelectedEventCallId != null, - })} - > - {row.getVisibleCells().map((cell, i) => { - const isSelected = i === 0 && row.id === selectedEventCallId; - - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ); - }) - ) : ( - - - No events yet - - - )} - -
-
+ return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + }) + ) : ( + + + No events yet + + + )} + + +
+
); }; diff --git a/frontend/src/views/Landing/Landing.tsx b/frontend/src/views/Landing/Landing.tsx index 93fb17f..3a04950 100644 --- a/frontend/src/views/Landing/Landing.tsx +++ b/frontend/src/views/Landing/Landing.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui/button/button"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { Input } from "@/components/ui/input/input"; import { getRandomId } from "@/utils/getRandomId"; import { Header } from "@/components/Header"; @@ -20,11 +20,6 @@ export const Landing = () => { const [, setToken] = useToken(); const { data } = useEventsList(LANDING_TOKEN); - const [selectedEventCallId, setSelectedEventCallId] = useState(null); - const onEventClose = useCallback(() => { - setSelectedEventCallId(null); - }, [setSelectedEventCallId]); - const onSubmit = (event: React.FormEvent) => { event.preventDefault(); goToEvents(customToken); @@ -45,12 +40,7 @@ export const Landing = () => {
- +
diff --git a/frontend/src/views/TraceWithToken/TraceWithToken.tsx b/frontend/src/views/TraceWithToken/TraceWithToken.tsx index 96562a3..5b12d61 100644 --- a/frontend/src/views/TraceWithToken/TraceWithToken.tsx +++ b/frontend/src/views/TraceWithToken/TraceWithToken.tsx @@ -1,7 +1,6 @@ import { Header } from "@/components/Header"; import { useEventsList } from "@/hooks/useEventsList"; import { EventsList } from "@/layouts/EventsList/EventsList"; -import { useState, useEffect, useCallback } from "react"; import { Snippet } from "@/components/Snippet/Snippet"; import { useToken } from "@/hooks/useToken"; @@ -9,27 +8,12 @@ export function TraceWithToken() { const [token] = useToken(); const { data, clearData } = useEventsList(token!); - const [selectedEventCallId, setSelectedEventCallId] = useState(null); - const firstEvent = data[0]; - useEffect(() => { - if (firstEvent != null) { - setSelectedEventCallId(firstEvent.callId); - } - }, [firstEvent]); - - const onEventClose = useCallback(() => { - setSelectedEventCallId(null); - }, [setSelectedEventCallId]); - return (