-
Notifications
You must be signed in to change notification settings - Fork 166
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add history navigation to cache tab #1394
Changes from all commits
fa5e99c
5295604
45c5295
43e09e8
d065177
974245b
bf41bee
7174ca0
8763d5f
281427a
b4e25d1
f2e10f8
02460bf
c01bd13
57d0228
609402a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"apollo-client-devtools": minor | ||
--- | ||
|
||
Add forward and back buttons to the cache tab to navigate the history of cache entries you've visited. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,46 @@ | ||
import type { VariantProps } from "class-variance-authority"; | ||
import { cva } from "class-variance-authority"; | ||
import type { ElementType, ReactNode } from "react"; | ||
import type { OmitNull } from "../types/utils"; | ||
import IconErrorSolid from "@apollo/icons/large/IconErrorSolid.svg"; | ||
import { twMerge } from "tailwind-merge"; | ||
|
||
type Variants = OmitNull<Required<VariantProps<typeof alert>>>; | ||
|
||
interface AlertProps extends Variants { | ||
children?: ReactNode; | ||
className?: string; | ||
} | ||
|
||
const alert = cva( | ||
["px-4", "py-3", "border-l-4", "rounded", "flex", "gap-2", "items-start"], | ||
{ | ||
variants: { | ||
variant: { | ||
error: [ | ||
"border-l-error", | ||
"dark:border-l-error-dark", | ||
"bg-error", | ||
"dark:bg-error-dark", | ||
"text-error", | ||
"dark:text-error-dark", | ||
], | ||
}, | ||
}, | ||
} | ||
); | ||
|
||
const ICONS: Record<Variants["variant"], ElementType> = { | ||
error: IconErrorSolid, | ||
}; | ||
|
||
export function Alert({ children, className, variant }: AlertProps) { | ||
const Icon = ICONS[variant]; | ||
|
||
return ( | ||
<div className={twMerge(alert({ variant }), className)}> | ||
<Icon className="size-6" /> | ||
<div className="flex-1 font-body text-md font-normal">{children}</div> | ||
</div> | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,18 +1,28 @@ | ||
import type { ReactNode } from "react"; | ||
import { Fragment, useState, useMemo } from "react"; | ||
import { Fragment, useState, useMemo, useSyncExternalStore } from "react"; | ||
import type { TypedDocumentNode } from "@apollo/client"; | ||
import { gql, useQuery } from "@apollo/client"; | ||
import IconArrowLeft from "@apollo/icons/small/IconArrowLeft.svg"; | ||
import IconArrowRight from "@apollo/icons/small/IconArrowRight.svg"; | ||
|
||
import { SidebarLayout } from "../Layouts/SidebarLayout"; | ||
import { SearchField } from "../SearchField"; | ||
import { EntityList } from "./sidebar/EntityList"; | ||
import { Loading } from "./common/Loading"; | ||
import type { GetCache, GetCacheVariables } from "../../types/gql"; | ||
import type { JSONObject } from "../../types/json"; | ||
import { JSONTreeViewer } from "../JSONTreeViewer"; | ||
import clsx from "clsx"; | ||
import { CopyButton } from "../CopyButton"; | ||
import { EmptyMessage } from "../EmptyMessage"; | ||
import { History } from "../../utilities/history"; | ||
import { Button } from "../Button"; | ||
import { ButtonGroup } from "../ButtonGroup"; | ||
import { Tooltip } from "../Tooltip"; | ||
import { Alert } from "../Alert"; | ||
import { List } from "../List"; | ||
import { ListItem } from "../ListItem"; | ||
import { getRootCacheIds } from "./common/utils"; | ||
import HighlightMatch from "../HighlightMatch"; | ||
|
||
const { Sidebar, Main } = SidebarLayout; | ||
|
||
|
@@ -39,9 +49,11 @@ function filterCache(cache: Cache, searchTerm: string) { | |
); | ||
} | ||
|
||
const history = new History("ROOT_QUERY"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instantiating this outside of the component allows us to keep the history when changing tabs, otherwise this resets anytime the |
||
|
||
export function Cache() { | ||
const [searchTerm, setSearchTerm] = useState(""); | ||
const [cacheId, setCacheId] = useState("ROOT_QUERY"); | ||
const cacheId = useSyncExternalStore(history.listen, history.getCurrent); | ||
|
||
const { loading, data } = useQuery(GET_CACHE); | ||
const cache = useMemo( | ||
|
@@ -55,6 +67,8 @@ export function Cache() { | |
); | ||
|
||
const dataExists = Object.keys(cache).length > 0; | ||
const cacheItem = cache[cacheId]; | ||
const cacheIds = getRootCacheIds(filteredCache); | ||
|
||
return ( | ||
<SidebarLayout> | ||
|
@@ -70,20 +84,61 @@ export function Cache() { | |
value={searchTerm} | ||
/> | ||
<div className="overflow-auto h-full"> | ||
<EntityList | ||
data={filteredCache} | ||
selectedCacheId={cacheId} | ||
setCacheId={setCacheId} | ||
searchTerm={searchTerm} | ||
/> | ||
<List> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This component was fairly small and "EntityList" wasn't a super great name for this anyways, so I went ahead and inlined the implementation here and removed the old component. |
||
{cacheIds.map((id) => { | ||
return ( | ||
<ListItem | ||
key={id} | ||
onClick={() => history.push(id)} | ||
selected={id === cacheId} | ||
className="font-code" | ||
> | ||
{searchTerm ? ( | ||
<HighlightMatch searchTerm={searchTerm} value={id} /> | ||
) : ( | ||
id | ||
)} | ||
</ListItem> | ||
); | ||
})} | ||
</List> | ||
</div> | ||
</Fragment> | ||
) : null} | ||
</Sidebar> | ||
<Main className="!overflow-auto"> | ||
<Main className="!overflow-auto flex flex-col"> | ||
{dataExists ? ( | ||
<div className="flex items-start justify-between mb-2 gap-2"> | ||
<div> | ||
<> | ||
<div className="flex items-start justify-between"> | ||
<ButtonGroup> | ||
<Tooltip content="Go back" delayDuration={500}> | ||
<Button | ||
aria-label="Go back" | ||
icon={<IconArrowLeft />} | ||
size="xs" | ||
variant="secondary" | ||
disabled={!history.canGoBack()} | ||
onClick={() => history.back()} | ||
/> | ||
</Tooltip> | ||
<Tooltip content="Go forward" delayDuration={500}> | ||
<Button | ||
aria-label="Go forward" | ||
icon={<IconArrowRight />} | ||
size="xs" | ||
variant="secondary" | ||
disabled={!history.canGoForward()} | ||
onClick={() => history.forward()} | ||
/> | ||
</Tooltip> | ||
</ButtonGroup> | ||
<CopyButton | ||
size="sm" | ||
text={JSON.stringify(cache[cacheId])} | ||
className={clsx({ invisible: !cacheItem })} | ||
/> | ||
</div> | ||
<div className="my-2"> | ||
<div className="text-xs font-bold uppercase">Cache ID</div> | ||
<h1 | ||
className="font-code font-medium text-xl text-heading dark:text-heading-dark break-all" | ||
|
@@ -92,17 +147,16 @@ export function Cache() { | |
{cacheId} | ||
</h1> | ||
</div> | ||
<CopyButton size="md" text={JSON.stringify(cache[cacheId])} /> | ||
</div> | ||
</> | ||
) : ( | ||
<EmptyMessage className="m-auto mt-20" /> | ||
)} | ||
|
||
{loading ? ( | ||
<Loading /> | ||
) : dataExists ? ( | ||
) : cacheItem ? ( | ||
<JSONTreeViewer | ||
data={cache[cacheId]} | ||
data={cacheItem} | ||
hideRoot={true} | ||
valueRenderer={(valueAsString, value, key) => { | ||
return ( | ||
|
@@ -112,7 +166,7 @@ export function Cache() { | |
})} | ||
onClick={() => { | ||
if (key === "__ref") { | ||
setCacheId(value as string); | ||
history.push(value as string); | ||
} | ||
}} | ||
> | ||
|
@@ -121,6 +175,11 @@ export function Cache() { | |
); | ||
}} | ||
/> | ||
) : dataExists ? ( | ||
<Alert variant="error" className="mt-4"> | ||
This cache entry was either removed from the cache or does not | ||
exist. | ||
</Alert> | ||
) : null} | ||
</Main> | ||
</SidebarLayout> | ||
|
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,12 +5,18 @@ interface TooltipProps { | |
asChild?: boolean; | ||
content: ReactNode; | ||
children?: ReactNode; | ||
delayDuration?: number; | ||
side?: "top" | "bottom" | "left" | "right"; | ||
} | ||
|
||
export function Tooltip({ content, children, side = "bottom" }: TooltipProps) { | ||
export function Tooltip({ | ||
content, | ||
children, | ||
delayDuration, | ||
side = "bottom", | ||
}: TooltipProps) { | ||
return ( | ||
<Root> | ||
<Root delayDuration={delayDuration}> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice UX detail 💯 |
||
<Trigger asChild>{children}</Trigger> | ||
<Portal> | ||
<Content | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
export class History<T> { | ||
private stack: T[] = []; | ||
private currentIndex = 0; | ||
private listeners = new Set<(value: T) => void>(); | ||
|
||
constructor(initialValue: T) { | ||
this.stack.push(initialValue); | ||
} | ||
|
||
getCurrent = () => { | ||
return this.stack[this.currentIndex]; | ||
}; | ||
|
||
go = (delta: number) => { | ||
const previousIndex = this.currentIndex; | ||
this.currentIndex = Math.min( | ||
Math.max(0, this.currentIndex + delta), | ||
this.stack.length - 1 | ||
); | ||
|
||
if (this.currentIndex !== previousIndex) { | ||
this.listeners.forEach((listener) => { | ||
listener(this.getCurrent()); | ||
}); | ||
} | ||
}; | ||
|
||
back = () => { | ||
this.go(-1); | ||
}; | ||
|
||
forward = () => { | ||
this.go(1); | ||
}; | ||
|
||
canGoBack = () => { | ||
return this.currentIndex > 0; | ||
}; | ||
|
||
canGoForward = () => { | ||
return this.currentIndex < this.stack.length - 1; | ||
}; | ||
|
||
push = (value: T) => { | ||
this.stack.splice(this.currentIndex + 1); | ||
this.stack.push(value); | ||
this.forward(); | ||
}; | ||
|
||
listen = (listener: (value: T) => void) => { | ||
this.listeners.add(listener); | ||
|
||
return () => this.listeners.delete(listener); | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For now we only need the
error
variant, so I've only implemented this one. We can add others as we need them.