Skip to content

Commit 2ca1756

Browse files
Feature: implement SearcherBar component (#28)
* Feature: implement SearcherBar component with enhanced search functionality * fix: enhance SearcherBar with ENS resolution using IExec SDK and remove unused fetchEnsAddress utility * feat: add shake animation to SearcherBar on error and improve search handling * fix: correct overflow property in layout for consistent styling * fix: remove unused gas fields from search query for cleaner response * fix: update search mutation to return resolved value and improve success handling * fix: update type in navigateToEntity function for improved type safety * fix: standardize field names in search query for consistency * fix: refactor navigateToEntity function for improved readability and maintainability * fix: integrate getReadonlyIExec in SearcherBar to search ENS when user is disconnected * feat: enhance SearcherBar to support initial search from location state * fix: ensure input ref is checked before focusing on error handling * feat: use chainSlug for chainId retrieval from URL in SearcherBar for better reactivity * fix: simplify SearcherBar component usage by removing unnecessary key prop * refactor: remove unused fields from transaction query and clean up imports in layout * feat: update getReadonlyIExec to accept chainId and retrieve provider dynamically * fix: update search redirection to avoid usage of forwardedSearch using params instead * chore: upgrade iexec SDK to support arbitrum-sepolia-testnet --------- Co-authored-by: Pierre Jeanjacquot <[email protected]>
1 parent 6a31b4a commit 2ca1756

File tree

22 files changed

+339
-72
lines changed

22 files changed

+339
-72
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"class-variance-authority": "^0.7.1",
4242
"clsx": "^2.1.1",
4343
"graphql": "^16.10.0",
44-
"iexec": "^8.15.0",
44+
"iexec": "^8.16.0",
4545
"lucide-react": "^0.487.0",
4646
"prettier-plugin-tailwindcss": "^0.6.11",
4747
"react": "^19.0.0",

src/externals/iexecSdkClient.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { IExec, IExecConfig, Eip1193Provider } from 'iexec';
22
import { type Connector } from 'wagmi';
3+
import { getChainFromId } from '@/utils/chain.utils';
34

45
let iExec: IExec | null = null;
6+
let readonlyIExec: IExec | null = null;
57

68
// Basic promise queue for pending getIExec() requests
79
const IEXEC_CLIENT_RESOLVES: Array<Promise<IExec>> = [];
810

911
// Clean both SDKs
1012
export function cleanIExecSDKs() {
1113
iExec = null;
14+
readonlyIExec = null;
1215
}
1316

1417
export async function initIExecSDKs({ connector }: { connector?: Connector }) {
@@ -23,12 +26,13 @@ export async function initIExecSDKs({ connector }: { connector?: Connector }) {
2326
return;
2427
}
2528
// Initialize
26-
const config = new IExecConfig({ ethProvider: provider });
29+
const config = new IExecConfig(
30+
{ ethProvider: provider },
31+
{ allowExperimentalNetworks: true }
32+
);
2733
iExec = new IExec(config);
2834

29-
IEXEC_CLIENT_RESOLVES.forEach((resolve) => {
30-
return resolve(iExec);
31-
});
35+
IEXEC_CLIENT_RESOLVES.forEach((resolve) => resolve(iExec!));
3236
IEXEC_CLIENT_RESOLVES.length = 0;
3337
}
3438

@@ -40,3 +44,16 @@ export function getIExec(): Promise<IExec> {
4044
}
4145
return Promise.resolve(iExec);
4246
}
47+
48+
export function getReadonlyIExec(chainId: number): IExec {
49+
const chain = getChainFromId(chainId);
50+
if (!chain) throw new Error(`Unknown chainId ${chainId}`);
51+
52+
if (!readonlyIExec) {
53+
readonlyIExec = new IExec(
54+
{ ethProvider: chain.id },
55+
{ allowExperimentalNetworks: true }
56+
);
57+
}
58+
return readonlyIExec;
59+
}

src/index.css

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,27 @@
104104
--font-anybody--font-variation-settings: 'wdth' 120;
105105
--font-mono: 'Space Mono', monospace;
106106
--font-grotesk: 'Space Grotesk', sans-serif;
107+
108+
@keyframes shake {
109+
0%,
110+
100% {
111+
transform: translateX(0);
112+
}
113+
20% {
114+
transform: translateX(-4px);
115+
}
116+
40% {
117+
transform: translateX(4px);
118+
}
119+
60% {
120+
transform: translateX(-4px);
121+
}
122+
80% {
123+
transform: translateX(4px);
124+
}
125+
}
126+
127+
--animate-shake: shake 0.4s ease-in-out;
107128
}
108129

109130
@theme inline {

src/modules/SearcherBar.tsx

Lines changed: 0 additions & 46 deletions
This file was deleted.

src/modules/search/SearcherBar.tsx

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { execute } from '@/graphql/execute';
2+
import { cn } from '@/lib/utils';
3+
import { useMutation } from '@tanstack/react-query';
4+
import { useNavigate, useParams } from '@tanstack/react-router';
5+
import { Search } from 'lucide-react';
6+
import { useEffect, useRef, useState } from 'react';
7+
import { ChainLink } from '@/components/ChainLink';
8+
import { Button } from '@/components/ui/button';
9+
import { Input } from '@/components/ui/input';
10+
import { getIExec, getReadonlyIExec } from '@/externals/iexecSdkClient';
11+
import useUserStore from '@/stores/useUser.store';
12+
import { getChainFromId, getChainFromSlug } from '@/utils/chain.utils';
13+
import { searchQuery } from './searchQuery';
14+
15+
export function SearcherBar({
16+
className,
17+
initialSearch,
18+
}: {
19+
className?: string;
20+
initialSearch?: string;
21+
}) {
22+
const { isConnected, address: userAddress } = useUserStore();
23+
const [inputValue, setInputValue] = useState('');
24+
const [shake, setShake] = useState(false);
25+
const [errorCount, setErrorCount] = useState(0);
26+
const [localError, setLocalError] = useState<Error | null>(null);
27+
const inputRef = useRef<HTMLInputElement | null>(null);
28+
29+
// Get ChainId from URL to be faster when using URL search
30+
const { chainSlug } = useParams({ from: '/$chainSlug' });
31+
const chainId = getChainFromSlug(chainSlug)?.id;
32+
33+
const navigate = useNavigate();
34+
35+
const navigateToEntity = (
36+
data: Record<string, unknown>,
37+
slug: string,
38+
value: string
39+
) => {
40+
const route = Object.entries({
41+
deal: 'deal',
42+
task: 'task',
43+
dataset: 'dataset',
44+
app: 'app',
45+
workerpool: 'workerpool',
46+
account: 'address',
47+
transaction: 'tx',
48+
}).find(([entityKey]) => data[entityKey]);
49+
50+
if (route) {
51+
const [, routePath] = route;
52+
navigate({ to: `/${slug}/${routePath}/${value}` });
53+
} else {
54+
throw new Error('An error occurred please try again');
55+
}
56+
};
57+
58+
const { mutate, mutateAsync, isPending, isError, error } = useMutation({
59+
mutationKey: ['search', inputValue],
60+
mutationFn: async (value: string) => {
61+
const isValid =
62+
value.length === 42 || // address
63+
value.length === 66 || // tx, deal, task hash
64+
value.endsWith('.eth'); // ENS
65+
66+
if (!isValid) {
67+
throw new Error('Invalid value');
68+
}
69+
70+
let resolvedValue = value;
71+
72+
if (value.endsWith('.eth')) {
73+
const iexec = isConnected
74+
? await getIExec()
75+
: getReadonlyIExec(chainId!);
76+
const resolved = await iexec.ens.resolveName(value);
77+
if (!resolved) {
78+
throw new Error(`Fail to resolve ENS : ${value}`);
79+
}
80+
resolvedValue = resolved.toLowerCase();
81+
}
82+
83+
const result = await execute(searchQuery, chainId, {
84+
search: resolvedValue,
85+
});
86+
87+
const isEmpty = Object.values(result).every((v) => v === null);
88+
if (isEmpty) {
89+
throw new Error('No data found');
90+
}
91+
return { result, id: resolvedValue };
92+
},
93+
onSuccess: (data) => {
94+
const chainSlug = getChainFromId(chainId)?.slug;
95+
if (!chainSlug) return;
96+
navigateToEntity(data.result, chainSlug, data.id);
97+
},
98+
99+
onError: (err) => {
100+
console.error('Search error:', err);
101+
if (inputRef.current) {
102+
inputRef.current.focus();
103+
}
104+
requestAnimationFrame(() => {
105+
setErrorCount((prev) => prev + 1);
106+
});
107+
},
108+
});
109+
110+
useEffect(() => {
111+
const run = async () => {
112+
if (initialSearch && chainId) {
113+
const normalized = initialSearch.trim().toLowerCase();
114+
setInputValue(normalized);
115+
try {
116+
setLocalError(null); // reset
117+
await mutateAsync(normalized);
118+
} catch (err) {
119+
console.error('Initial search error:', err);
120+
setErrorCount((prev) => prev + 1);
121+
// mutation don't return error when used in useEffect we need to use a local state
122+
setLocalError(
123+
err instanceof Error ? err : new Error('Unknown error')
124+
);
125+
}
126+
}
127+
};
128+
129+
run();
130+
}, [initialSearch, chainId]);
131+
132+
useEffect(() => {
133+
if (errorCount > 0) {
134+
setShake(true);
135+
const timer = setTimeout(() => setShake(false), 1_000);
136+
return () => clearTimeout(timer);
137+
}
138+
}, [errorCount]);
139+
140+
const handleSearch = () => {
141+
const rawValue = inputValue.trim().toLowerCase();
142+
if (!rawValue) return;
143+
mutate(rawValue);
144+
};
145+
146+
const handleKeyDown = (e: React.KeyboardEvent) => {
147+
if (e.key === 'Enter') handleSearch();
148+
};
149+
150+
return (
151+
<div className={cn('m-auto w-full', className)}>
152+
<div className="relative w-full">
153+
<Input
154+
ref={inputRef}
155+
value={inputValue}
156+
onChange={(e) => setInputValue(e.target.value)}
157+
onKeyDown={handleKeyDown}
158+
disabled={isPending}
159+
className={cn(
160+
'bg-input border-secondary w-full rounded-2xl py-5.5 pl-12 sm:py-6.5',
161+
isConnected && 'sm:pr-32',
162+
(isError || localError) &&
163+
'focus-visible:border-danger-border focus:outline-danger-border focus-visible:ring-danger-border',
164+
shake && 'animate-shake'
165+
)}
166+
placeholder="Search address, deal id, task id, transaction hash..."
167+
/>
168+
{(localError || error) && (
169+
<p className="bg-danger text-danger-foreground border-danger-border absolute -bottom-8 rounded-full border px-4">
170+
{localError ? localError.message : error?.message}
171+
</p>
172+
)}
173+
<Search
174+
size="18"
175+
className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 sm:left-6"
176+
/>
177+
{isConnected && (
178+
<Button
179+
variant="outline"
180+
className="bg-input hover:bg-secondary absolute top-1/2 right-4 hidden -translate-y-1/2 sm:flex"
181+
asChild
182+
>
183+
<ChainLink to={`/address/${userAddress}`}>My activity</ChainLink>
184+
</Button>
185+
)}
186+
</div>
187+
188+
<div className={cn('mt-4 flex justify-center gap-4', isError && 'mt-10')}>
189+
<div className="flex justify-center sm:hidden">
190+
<Button variant="outline" onClick={handleSearch} disabled={isPending}>
191+
{isPending ? 'Searching...' : 'Search'}
192+
</Button>
193+
</div>
194+
195+
{isConnected && (
196+
<Button variant="outline" className="sm:hidden" asChild>
197+
<ChainLink to={`/address/${userAddress}`}>My activity</ChainLink>
198+
</Button>
199+
)}
200+
</div>
201+
</div>
202+
);
203+
}

src/modules/search/searchQuery.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { graphql } from '@/graphql/gql';
2+
3+
export const searchQuery = graphql(`
4+
query Search($search: ID!) {
5+
app(id: $search) {
6+
id
7+
}
8+
dataset(id: $search) {
9+
id
10+
}
11+
workerpool(id: $search) {
12+
id
13+
}
14+
deal(id: $search) {
15+
id
16+
}
17+
task(id: $search) {
18+
id
19+
}
20+
account(id: $search) {
21+
id
22+
}
23+
transaction(id: $search) {
24+
id
25+
}
26+
}
27+
`);

0 commit comments

Comments
 (0)