Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0d77eed
Feature: implement SearcherBar component with enhanced search functio…
ErwanDecoster Jun 12, 2025
e74cf71
fix: enhance SearcherBar with ENS resolution using IExec SDK and remo…
ErwanDecoster Jun 12, 2025
d2347b2
feat: add shake animation to SearcherBar on error and improve search …
ErwanDecoster Jun 12, 2025
5447f66
fix: correct overflow property in layout for consistent styling
ErwanDecoster Jun 12, 2025
6cb6dd1
fix: remove unused gas fields from search query for cleaner response
ErwanDecoster Jun 18, 2025
a45c6b4
fix: update search mutation to return resolved value and improve succ…
ErwanDecoster Jun 18, 2025
3f7bafe
fix: update type in navigateToEntity function for improved type safety
ErwanDecoster Jun 18, 2025
492293e
fix: standardize field names in search query for consistency
ErwanDecoster Jun 18, 2025
33014af
fix: refactor navigateToEntity function for improved readability and …
ErwanDecoster Jun 18, 2025
9f4ba5a
fix: integrate getReadonlyIExec in SearcherBar to search ENS when use…
ErwanDecoster Jun 18, 2025
574793f
feat: enhance SearcherBar to support initial search from location state
ErwanDecoster Jun 18, 2025
1ae5268
fix: ensure input ref is checked before focusing on error handling
ErwanDecoster Jun 18, 2025
2833091
feat: use chainSlug for chainId retrieval from URL in SearcherBar for…
ErwanDecoster Jun 18, 2025
b49ed36
Merge branch 'main' into feature/add-search-fonctionality
ErwanDecoster Jun 18, 2025
c065c47
fix: simplify SearcherBar component usage by removing unnecessary key…
ErwanDecoster Jun 20, 2025
c227520
refactor: remove unused fields from transaction query and clean up im…
ErwanDecoster Jun 20, 2025
3aabb55
feat: update getReadonlyIExec to accept chainId and retrieve provider…
ErwanDecoster Jun 20, 2025
e49b76c
fix: update search redirection to avoid usage of forwardedSearch usin…
ErwanDecoster Jun 23, 2025
25baee0
chore: upgrade iexec SDK to support arbitrum-sepolia-testnet
PierreJeanjacquot Jun 23, 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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"graphql": "^16.10.0",
"iexec": "^8.15.0",
"iexec": "^8.16.0",
"lucide-react": "^0.487.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"react": "^19.0.0",
Expand Down
25 changes: 21 additions & 4 deletions src/externals/iexecSdkClient.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { IExec, IExecConfig, Eip1193Provider } from 'iexec';
import { type Connector } from 'wagmi';
import { getChainFromId } from '@/utils/chain.utils';

let iExec: IExec | null = null;
let readonlyIExec: IExec | null = null;

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

// Clean both SDKs
export function cleanIExecSDKs() {
iExec = null;
readonlyIExec = null;
}

export async function initIExecSDKs({ connector }: { connector?: Connector }) {
Expand All @@ -23,12 +26,13 @@ export async function initIExecSDKs({ connector }: { connector?: Connector }) {
return;
}
// Initialize
const config = new IExecConfig({ ethProvider: provider });
const config = new IExecConfig(
{ ethProvider: provider },
{ allowExperimentalNetworks: true }
);
iExec = new IExec(config);

IEXEC_CLIENT_RESOLVES.forEach((resolve) => {
return resolve(iExec);
});
IEXEC_CLIENT_RESOLVES.forEach((resolve) => resolve(iExec!));
IEXEC_CLIENT_RESOLVES.length = 0;
}

Expand All @@ -40,3 +44,16 @@ export function getIExec(): Promise<IExec> {
}
return Promise.resolve(iExec);
}

export function getReadonlyIExec(chainId: number): IExec {
const chain = getChainFromId(chainId);
if (!chain) throw new Error(`Unknown chainId ${chainId}`);

if (!readonlyIExec) {
readonlyIExec = new IExec(
{ ethProvider: chain.id },
{ allowExperimentalNetworks: true }
);
}
return readonlyIExec;
}
21 changes: 21 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,27 @@
--font-anybody--font-variation-settings: 'wdth' 120;
--font-mono: 'Space Mono', monospace;
--font-grotesk: 'Space Grotesk', sans-serif;

@keyframes shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-4px);
}
40% {
transform: translateX(4px);
}
60% {
transform: translateX(-4px);
}
80% {
transform: translateX(4px);
}
}

--animate-shake: shake 0.4s ease-in-out;
}

@theme inline {
Expand Down
46 changes: 0 additions & 46 deletions src/modules/SearcherBar.tsx

This file was deleted.

203 changes: 203 additions & 0 deletions src/modules/search/SearcherBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import { execute } from '@/graphql/execute';
import { cn } from '@/lib/utils';
import { useMutation } from '@tanstack/react-query';
import { useNavigate, useParams } from '@tanstack/react-router';
import { Search } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { ChainLink } from '@/components/ChainLink';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { getIExec, getReadonlyIExec } from '@/externals/iexecSdkClient';
import useUserStore from '@/stores/useUser.store';
import { getChainFromId, getChainFromSlug } from '@/utils/chain.utils';
import { searchQuery } from './searchQuery';

export function SearcherBar({
className,
initialSearch,
}: {
className?: string;
initialSearch?: string;
}) {
const { isConnected, address: userAddress } = useUserStore();
const [inputValue, setInputValue] = useState('');
const [shake, setShake] = useState(false);
const [errorCount, setErrorCount] = useState(0);
const [localError, setLocalError] = useState<Error | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);

// Get ChainId from URL to be faster when using URL search
const { chainSlug } = useParams({ from: '/$chainSlug' });
const chainId = getChainFromSlug(chainSlug)?.id;

const navigate = useNavigate();

const navigateToEntity = (
data: Record<string, unknown>,
slug: string,
value: string
) => {
const route = Object.entries({
deal: 'deal',
task: 'task',
dataset: 'dataset',
app: 'app',
workerpool: 'workerpool',
account: 'address',
transaction: 'tx',
}).find(([entityKey]) => data[entityKey]);

if (route) {
const [, routePath] = route;
navigate({ to: `/${slug}/${routePath}/${value}` });
} else {
throw new Error('An error occurred please try again');
}
};

const { mutate, mutateAsync, isPending, isError, error } = useMutation({
mutationKey: ['search', inputValue],
mutationFn: async (value: string) => {
const isValid =
value.length === 42 || // address
value.length === 66 || // tx, deal, task hash
value.endsWith('.eth'); // ENS

if (!isValid) {
throw new Error('Invalid value');
}

let resolvedValue = value;

if (value.endsWith('.eth')) {
const iexec = isConnected
? await getIExec()
: getReadonlyIExec(chainId!);
const resolved = await iexec.ens.resolveName(value);
if (!resolved) {
throw new Error(`Fail to resolve ENS : ${value}`);
}
resolvedValue = resolved.toLowerCase();
}

const result = await execute(searchQuery, chainId, {
search: resolvedValue,
});

const isEmpty = Object.values(result).every((v) => v === null);
if (isEmpty) {
throw new Error('No data found');
}
return { result, id: resolvedValue };
},
onSuccess: (data) => {
const chainSlug = getChainFromId(chainId)?.slug;
if (!chainSlug) return;
navigateToEntity(data.result, chainSlug, data.id);
},

onError: (err) => {
console.error('Search error:', err);
if (inputRef.current) {
inputRef.current.focus();
}
requestAnimationFrame(() => {
setErrorCount((prev) => prev + 1);
});
},
});

useEffect(() => {
const run = async () => {
if (initialSearch && chainId) {
const normalized = initialSearch.trim().toLowerCase();
setInputValue(normalized);
try {
setLocalError(null); // reset
await mutateAsync(normalized);
} catch (err) {
console.error('Initial search error:', err);
setErrorCount((prev) => prev + 1);
// mutation don't return error when used in useEffect we need to use a local state
setLocalError(
err instanceof Error ? err : new Error('Unknown error')
);
}
}
};

run();
}, [initialSearch, chainId]);

useEffect(() => {
if (errorCount > 0) {
setShake(true);
const timer = setTimeout(() => setShake(false), 1_000);
return () => clearTimeout(timer);
}
}, [errorCount]);

const handleSearch = () => {
const rawValue = inputValue.trim().toLowerCase();
if (!rawValue) return;
mutate(rawValue);
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSearch();
};

return (
<div className={cn('m-auto w-full', className)}>
<div className="relative w-full">
<Input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isPending}
className={cn(
'bg-input border-secondary w-full rounded-2xl py-5.5 pl-12 sm:py-6.5',
isConnected && 'sm:pr-32',
(isError || localError) &&
'focus-visible:border-danger-border focus:outline-danger-border focus-visible:ring-danger-border',
shake && 'animate-shake'
)}
placeholder="Search address, deal id, task id, transaction hash..."
/>
{(localError || error) && (
<p className="bg-danger text-danger-foreground border-danger-border absolute -bottom-8 rounded-full border px-4">
{localError ? localError.message : error?.message}
</p>
)}
<Search
size="18"
className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 sm:left-6"
/>
{isConnected && (
<Button
variant="outline"
className="bg-input hover:bg-secondary absolute top-1/2 right-4 hidden -translate-y-1/2 sm:flex"
asChild
>
<ChainLink to={`/address/${userAddress}`}>My activity</ChainLink>
</Button>
)}
</div>

<div className={cn('mt-4 flex justify-center gap-4', isError && 'mt-10')}>
<div className="flex justify-center sm:hidden">
<Button variant="outline" onClick={handleSearch} disabled={isPending}>
{isPending ? 'Searching...' : 'Search'}
</Button>
</div>

{isConnected && (
<Button variant="outline" className="sm:hidden" asChild>
<ChainLink to={`/address/${userAddress}`}>My activity</ChainLink>
</Button>
)}
</div>
</div>
);
}
27 changes: 27 additions & 0 deletions src/modules/search/searchQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { graphql } from '@/graphql/gql';

export const searchQuery = graphql(`
query Search($search: ID!) {
app(id: $search) {
id
}
dataset(id: $search) {
id
}
workerpool(id: $search) {
id
}
deal(id: $search) {
id
}
task(id: $search) {
id
}
account(id: $search) {
id
}
transaction(id: $search) {
id
}
}
`);
Loading
Loading