Skip to content

Commit ea62e9b

Browse files
authored
feat(explorer): collapsible sections for functions (#3727)
1 parent 405a600 commit ea62e9b

10 files changed

Lines changed: 241 additions & 199 deletions

File tree

.changeset/gold-bees-smash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@latticexyz/explorer": patch
3+
---
4+
5+
Interact page is now organized into collapsible namespace and system sections.

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/InteractForm.tsx

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"use client";
22

33
import { useSearchParams } from "next/navigation";
4+
import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs";
45
import { AbiFunction, AbiItem, Hex } from "viem";
5-
import { useDeferredValue, useMemo, useState } from "react";
6+
import { useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
67
import { hexToResource } from "@latticexyz/common";
78
import IBaseWorldAbi from "@latticexyz/world/out/IBaseWorld.sol/IBaseWorld.abi.json";
89
import { Input } from "../../../../../../components/ui/Input";
910
import { useSystemAbisQuery } from "../../../../queries/useSystemAbisQuery";
1011
import { useWorldAbiQuery } from "../../../../queries/useWorldAbiQuery";
12+
import { getFunctionElementId } from "../../../../utils/getFunctionElementId";
1113
import { FunctionsContent } from "./content/FunctionsContent";
1214
import { SidebarContent } from "./sidebar/SidebarContent";
1315

@@ -34,6 +36,8 @@ export type FilteredFunctions = {
3436

3537
export function InteractForm() {
3638
const searchParams = useSearchParams();
39+
const [, setExpanded] = useQueryState("expanded", parseAsArrayOf(parseAsString).withDefault([]));
40+
const hasSetInitialExpanded = useRef(false);
3741
const { data: worldAbiData, isFetched: isWorldAbiFetched } = useWorldAbiQuery();
3842
const { data: systemAbis, isFetched: isSystemAbisFetched } = useSystemAbisQuery();
3943
const isFetched = isWorldAbiFetched && isSystemAbisFetched;
@@ -105,6 +109,79 @@ export function InteractForm() {
105109
};
106110
}, [systemAbis, deferredFilterValue]);
107111

112+
useEffect(() => {
113+
if (isFetched && !hasSetInitialExpanded.current) {
114+
const hash = window.location.hash.slice(1);
115+
if (hash) {
116+
for (const { namespace, systems } of filteredSystemFunctions.namespaces) {
117+
for (const system of systems) {
118+
for (const func of system.functions) {
119+
const functionId = getFunctionElementId(func, system.systemId);
120+
if (hash === functionId) {
121+
setExpanded([namespace, system.systemId]);
122+
hasSetInitialExpanded.current = true;
123+
return;
124+
}
125+
}
126+
}
127+
}
128+
129+
for (const system of filteredSystemFunctions.core) {
130+
for (const func of system.functions) {
131+
const functionId = getFunctionElementId(func, system.systemId);
132+
if (hash === functionId) {
133+
setExpanded(["core", system.systemId]);
134+
hasSetInitialExpanded.current = true;
135+
return;
136+
}
137+
}
138+
}
139+
}
140+
141+
const initialNamespace = filteredSystemFunctions.namespaces[0];
142+
if (initialNamespace) {
143+
setExpanded([initialNamespace.namespace]);
144+
hasSetInitialExpanded.current = true;
145+
}
146+
}
147+
}, [isFetched, filteredSystemFunctions, setExpanded]);
148+
149+
useEffect(() => {
150+
if (isFetched && deferredFilterValue) {
151+
const expandedItems: string[] = [];
152+
153+
for (const { namespace, systems } of filteredSystemFunctions.namespaces) {
154+
const hasMatchingSystem = systems.some(
155+
(system) =>
156+
system.name.toLowerCase().includes(deferredFilterValue.toLowerCase()) ||
157+
system.functions.some((func) => func.name.toLowerCase().includes(deferredFilterValue.toLowerCase())),
158+
);
159+
if (hasMatchingSystem) {
160+
expandedItems.push(namespace);
161+
systems.forEach((system) => {
162+
if (
163+
system.name.toLowerCase().includes(deferredFilterValue.toLowerCase()) ||
164+
system.functions.some((func) => func.name.toLowerCase().includes(deferredFilterValue.toLowerCase()))
165+
) {
166+
expandedItems.push(system.systemId);
167+
}
168+
});
169+
}
170+
}
171+
172+
for (const system of filteredSystemFunctions.core) {
173+
if (
174+
system.name.toLowerCase().includes(deferredFilterValue.toLowerCase()) ||
175+
system.functions.some((func) => func.name.toLowerCase().includes(deferredFilterValue.toLowerCase()))
176+
) {
177+
expandedItems.push(system.systemId);
178+
}
179+
}
180+
181+
setExpanded(expandedItems);
182+
}
183+
}, [isFetched, deferredFilterValue, filteredSystemFunctions, setExpanded]);
184+
108185
return (
109186
<>
110187
<div className="-mr-1g -ml-1 flex gap-x-4 overflow-y-hidden">
@@ -129,7 +206,6 @@ export function InteractForm() {
129206
<FunctionsContent
130207
worldAbi={worldAbiData?.abi}
131208
filteredFunctions={filteredSystemFunctions}
132-
filterValue={deferredFilterValue}
133209
isLoading={!isFetched}
134210
/>
135211
</div>

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/content/FunctionField.tsx

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,13 @@
33
import { CoinsIcon, ExternalLinkIcon, EyeIcon, LoaderIcon, SendIcon } from "lucide-react";
44
import Link from "next/link";
55
import { useParams, useSearchParams } from "next/navigation";
6+
import { useRouter } from "next/navigation";
67
import { toast } from "sonner";
7-
import {
8-
Abi,
9-
AbiFunction,
10-
AbiParameter,
11-
Address,
12-
Hex,
13-
decodeEventLog,
14-
encodeFunctionData,
15-
stringify,
16-
toFunctionHash,
17-
} from "viem";
8+
import { Abi, AbiFunction, AbiParameter, Address, Hex, decodeEventLog, encodeFunctionData, stringify } from "viem";
189
import { useAccount, useConfig, usePublicClient } from "wagmi";
1910
import { waitForTransactionReceipt, writeContract } from "wagmi/actions";
2011
import { z } from "zod";
21-
import { useCallback, useState } from "react";
12+
import { useCallback, useEffect, useState } from "react";
2213
import { useForm } from "react-hook-form";
2314
import { zodResolver } from "@hookform/resolvers/zod";
2415
import { encodeSystemCall } from "@latticexyz/world/internal";
@@ -36,8 +27,8 @@ import {
3627
import { Input } from "../../../../../../../components/ui/Input";
3728
import { ScrollIntoViewLink } from "../../../../../components/ScrollIntoViewLink";
3829
import { useChain } from "../../../../../hooks/useChain";
39-
import { useHashState } from "../../../../../hooks/useHashState";
4030
import { blockExplorerTransactionUrl } from "../../../../../utils/blockExplorerTransactionUrl";
31+
import { getFunctionElementId } from "../../../../../utils/getFunctionElementId";
4132
import { encodeFunctionArgs } from "../../explore/utils/encodeFunctionArgs";
4233

4334
export enum FunctionType {
@@ -49,6 +40,7 @@ type Props = {
4940
worldAbi: Abi;
5041
functionAbi: AbiFunction;
5142
systemId?: Hex;
43+
useSearchParamsArgs: boolean;
5244
};
5345

5446
type DecodedEvent = {
@@ -86,7 +78,7 @@ const getInputPlaceholder = (input: AbiParameter): string => {
8678
return `[${componentsString}]`;
8779
};
8880

89-
export function FunctionField({ systemId, worldAbi, functionAbi }: Props) {
81+
export function FunctionField({ systemId, worldAbi, functionAbi, useSearchParamsArgs }: Props) {
9082
const searchParams = useSearchParams();
9183
const publicClient = usePublicClient();
9284
const operationType: FunctionType =
@@ -98,22 +90,31 @@ export function FunctionField({ systemId, worldAbi, functionAbi }: Props) {
9890
const account = useAccount();
9991
const { worldAddress } = useParams();
10092
const { id: chainId } = useChain();
101-
const [functionHash] = useHashState();
10293
const [isLoading, setIsLoading] = useState(false);
10394
const [result, setResult] = useState<string>();
10495
const [events, setEvents] = useState<DecodedEvent[]>();
10596
const [txHash, setTxHash] = useState<Hex>();
10697
const txUrl = blockExplorerTransactionUrl({ hash: txHash, chainId });
10798
const inputLabels = functionAbi.inputs.map(getInputLabel);
99+
const router = useRouter();
108100

109101
const form = useForm<z.infer<typeof formSchema>>({
110102
resolver: zodResolver(formSchema),
111103
defaultValues: {
112-
inputs: functionHash === toFunctionHash(functionAbi) ? JSON.parse(searchParams.get("args") || "[]") : [],
113-
value: functionHash === toFunctionHash(functionAbi) ? searchParams.get("value") ?? "" : "",
104+
inputs: useSearchParamsArgs ? JSON.parse(searchParams.get("args") || "[]") : [],
105+
value: useSearchParamsArgs ? searchParams.get("value") ?? "" : "",
114106
},
115107
});
116108

109+
useEffect(() => {
110+
if (useSearchParamsArgs) {
111+
const params = new URLSearchParams(searchParams.toString());
112+
params.delete("args");
113+
params.delete("value");
114+
router.replace(`?${params.toString()}${window.location.hash}`);
115+
}
116+
}, [useSearchParamsArgs, searchParams, router]);
117+
117118
const getShareableUrl = useCallback(() => {
118119
const values = form.watch();
119120
const params = new URLSearchParams(searchParams.toString());
@@ -131,11 +132,11 @@ export function FunctionField({ systemId, worldAbi, functionAbi }: Props) {
131132
}
132133

133134
const url = new URL(window.location.href);
134-
url.hash = toFunctionHash(functionAbi);
135+
url.hash = getFunctionElementId(functionAbi, systemId);
135136
url.search = params.toString();
136137

137138
return url.toString();
138-
}, [form, functionAbi, searchParams]);
139+
}, [form, functionAbi, searchParams, systemId]);
139140

140141
const onSubmit = useCallback(
141142
async (values: z.infer<typeof formSchema>) => {
@@ -232,13 +233,13 @@ export function FunctionField({ systemId, worldAbi, functionAbi }: Props) {
232233
<Form {...form}>
233234
<form
234235
onSubmit={form.handleSubmit(onSubmit)}
235-
id={toFunctionHash(functionAbi)}
236+
id={getFunctionElementId(functionAbi, systemId)}
236237
className="space-y-4 rounded border border-white/10 bg-black/20 p-3 pb-4"
237238
>
238239
<div className="flex items-center justify-between">
239240
<h3 className="font-semibold">
240241
<ScrollIntoViewLink
241-
elementId={toFunctionHash(functionAbi)}
242+
elementId={getFunctionElementId(functionAbi, systemId)}
242243
className="group inline-flex items-center hover:no-underline"
243244
>
244245
<span className="text-orange-500 group-hover:underline">{functionAbi.name}</span>

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/content/FunctionsContent.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { Abi, AbiItem } from "viem";
2+
import { useRef } from "react";
23
import { Skeleton } from "../../../../../../../components/ui/Skeleton";
34
import { FilteredFunctions, NamespaceSection, System } from "../InteractForm";
45
import { SystemContent } from "./SystemContent";
56

67
type Props = {
7-
worldAbi?: Abi;
88
filteredFunctions: FilteredFunctions;
9-
filterValue: string;
109
isLoading: boolean;
10+
worldAbi?: Abi;
1111
};
1212

1313
export function FunctionsContent({ worldAbi, filteredFunctions, isLoading }: Props) {
14+
const initialFunctionHash = useRef(window.location.hash.slice(1)).current;
1415
return (
1516
<div className="w-full overflow-y-auto pl-1 pr-1">
1617
{isLoading && (
@@ -31,6 +32,7 @@ export function FunctionsContent({ worldAbi, filteredFunctions, isLoading }: Pro
3132
name={namespace}
3233
systems={systems}
3334
worldAbi={worldAbi as AbiItem[]}
35+
initialFunctionHash={initialFunctionHash}
3436
isNamespace
3537
/>
3638
))}
@@ -41,6 +43,7 @@ export function FunctionsContent({ worldAbi, filteredFunctions, isLoading }: Pro
4143
name={system.name}
4244
functions={system.functions}
4345
worldAbi={worldAbi as AbiItem[]}
46+
initialFunctionHash={initialFunctionHash}
4447
/>
4548
))}
4649
</>

packages/explorer/src/app/(explorer)/[chainName]/worlds/[worldAddress]/interact/content/SystemContent.tsx

Lines changed: 71 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { AbiFunction, AbiItem, Hex, toFunctionHash } from "viem";
1+
import { ChevronsUpDown } from "lucide-react";
2+
import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs";
3+
import { AbiFunction, AbiItem, Hex } from "viem";
4+
import { Button } from "../../../../../../../components/ui/Button";
5+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../../../../components/ui/Collapsible";
6+
import { getFunctionElementId } from "../../../../../utils/getFunctionElementId";
27
import { FunctionField } from "./FunctionField";
38

49
type System = {
@@ -14,37 +19,84 @@ type SystemContentProps = {
1419
functions?: AbiFunction[];
1520
worldAbi: AbiItem[];
1621
isNamespace?: boolean;
22+
initialFunctionHash?: string;
1723
};
1824

19-
export function SystemContent({ name, systems, functions, worldAbi, isNamespace }: SystemContentProps) {
25+
export function SystemContent({
26+
name,
27+
systems,
28+
functions,
29+
worldAbi,
30+
isNamespace,
31+
initialFunctionHash,
32+
}: SystemContentProps) {
33+
const [isExpanded, setIsExpanded] = useQueryState("expanded", parseAsArrayOf(parseAsString).withDefault([]));
34+
35+
const handleToggleExpanded = () => {
36+
setIsExpanded((prev) => {
37+
if (prev.includes(name)) {
38+
return prev.filter((item) => item !== name);
39+
}
40+
return [...prev, name];
41+
});
42+
};
43+
2044
if (isNamespace && systems) {
2145
return (
2246
<div>
23-
<h4 className="mt-4 text-4xl font-semibold opacity-50">{name}</h4>
24-
{systems.map((system) => (
25-
<div key={system.systemId}>
26-
<h4 className="my-4 text-2xl font-semibold">{system.name}</h4>
27-
{system.functions.map((abi: AbiFunction) => (
28-
<FunctionField
29-
key={toFunctionHash(abi)}
30-
systemId={system.systemId as Hex}
31-
worldAbi={worldAbi}
32-
functionAbi={abi}
33-
/>
47+
<Collapsible open={isExpanded.includes(name)} onOpenChange={handleToggleExpanded}>
48+
<CollapsibleTrigger asChild>
49+
<div className="group flex w-full cursor-pointer items-center justify-between">
50+
<h4 className="mt-4 text-2xl font-semibold">{name}</h4>
51+
<Button size="sm" variant="ghost">
52+
<ChevronsUpDown className="h-4 w-4" />
53+
</Button>
54+
</div>
55+
</CollapsibleTrigger>
56+
<CollapsibleContent>
57+
{systems.map((system) => (
58+
<div key={system.systemId}>
59+
<h4 className="my-4 text-xl font-semibold opacity-70">{system.name}</h4>
60+
{system.functions.map((abi: AbiFunction) => (
61+
<FunctionField
62+
key={getFunctionElementId(abi, system.systemId)}
63+
systemId={system.systemId as Hex}
64+
worldAbi={worldAbi}
65+
functionAbi={abi}
66+
useSearchParamsArgs={initialFunctionHash === getFunctionElementId(abi, system.systemId)}
67+
/>
68+
))}
69+
</div>
3470
))}
35-
</div>
36-
))}
71+
</CollapsibleContent>
72+
</Collapsible>
3773
</div>
3874
);
3975
}
4076

4177
if (functions) {
4278
return (
4379
<div>
44-
<h4 className="my-4 text-2xl font-semibold">{name}</h4>
45-
{functions.map((abi: AbiFunction) => (
46-
<FunctionField key={toFunctionHash(abi)} worldAbi={worldAbi} functionAbi={abi} />
47-
))}
80+
<Collapsible open={isExpanded.includes(name)} onOpenChange={handleToggleExpanded}>
81+
<CollapsibleTrigger asChild>
82+
<div className="group flex w-full cursor-pointer items-center justify-between">
83+
<h4 className="my-4 text-2xl font-semibold">{name}</h4>
84+
<Button size="sm" variant="ghost">
85+
<ChevronsUpDown className="h-4 w-4" />
86+
</Button>
87+
</div>
88+
</CollapsibleTrigger>
89+
<CollapsibleContent>
90+
{functions.map((abi: AbiFunction) => (
91+
<FunctionField
92+
key={getFunctionElementId(abi)}
93+
worldAbi={worldAbi}
94+
functionAbi={abi}
95+
useSearchParamsArgs={initialFunctionHash === getFunctionElementId(abi)}
96+
/>
97+
))}
98+
</CollapsibleContent>
99+
</Collapsible>
48100
</div>
49101
);
50102
}

0 commit comments

Comments
 (0)