Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 36 additions & 12 deletions hooks/use-bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,51 @@ import {
type BountyFieldsFragment,
} from "@/lib/graphql/generated";
import { bountyKeys } from "@/lib/query/query-keys";
import { formatPaginatedBounties } from "@/lib/utils/pagination";

export { bountyKeys };

/**
* Hook for fetching a paginated list of bounties from GraphQL API
*
* This hook wraps the generated `useBountiesQuery` and transforms the GraphQL
* PaginatedBounties response to the internal PaginatedResponse format expected by UI components.
*
* @param params - Query parameters including page, limit, filters, and search
* @returns Object containing bounties data, pagination info, and query status (loading, error, etc.)
*
* @example
* const { data, isLoading, error } = useBounties({
* page: 1,
* limit: 20,
* search: "security"
* });
*
* if (isLoading) return <Skeleton />;
* if (error) return <Error message={error.message} />;
*
* return (
* <div>
* {data?.data.map(bounty => <BountyCard key={bounty.id} bounty={bounty} />)}
* <Pagination
* page={data?.pagination.page}
* totalPages={data?.pagination.totalPages}
* />
* </div>
* );
*/
export function useBounties(params?: BountyQueryInput) {
const { data, ...rest } = useBountiesQuery({ query: params });

return {
...rest,
data: data
? {
data: data.bounties.bounties as BountyFieldsFragment[],
pagination: {
page: params?.page ?? 1,
limit: data.bounties.limit,
total: data.bounties.total,
totalPages:
data.bounties.limit > 0
? Math.ceil(data.bounties.total / data.bounties.limit)
: 0,
},
}
? formatPaginatedBounties(
data.bounties.bounties as BountyFieldsFragment[],
data.bounties.total,
data.bounties.limit,
params?.page ?? 1,
)
: undefined,
};
}
51 changes: 51 additions & 0 deletions hooks/use-bounty-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,60 @@ import {
} from "@/lib/graphql/generated";
import { bountyKeys } from "@/lib/query/query-keys";

/** LocalStorage key for persisting recent searches */
const RECENT_SEARCHES_KEY = "bounties-recent-searches";
/** Maximum number of recent searches to persist */
const MAX_RECENT_SEARCHES = 5;

/**
* Hook for searching bounties with debounced GraphQL queries
*
* Provides a complete search experience with:
* - Debounced search queries (300ms delay)
* - Recent searches persistence using localStorage
* - Loading and fetching state management
* - Keyboard-friendly open/close toggle
*
* The search is disabled until the user opens the search dialog and enters text,
* preventing unnecessary API calls.
*
* @returns Object containing search state, results, and management functions:
* - searchTerm: Current search input text
* - setSearchTerm: Update search text
* - debouncedSearch: Debounced search term sent to API
* - isOpen: Whether search dialog is open
* - setIsOpen: Set search dialog visibility
* - toggleOpen: Toggle search dialog open/closed
* - results: Array of bounty results from GraphQL API
* - isLoading: Whether initial query is loading
* - recentSearches: Array of previously searched terms
* - addRecentSearch: Save a term to recent searches
* - removeRecentSearch: Remove a term from recent searches
* - clearRecentSearches: Clear all recent searches
*
* @example
* const {
* searchTerm,
* setSearchTerm,
* results,
* isLoading,
* recentSearches
* } = useBountySearch();
*
* return (
* <SearchDialog>
* <SearchInput value={searchTerm} onChange={setSearchTerm} />
* {isLoading ? (
* <Spinner />
* ) : (
* <>
* {recentSearches.length > 0 && <RecentSearches items={recentSearches} />}
* {results.length > 0 && <SearchResults items={results} />}
* </>
* )}
* </SearchDialog>
* );
*/
export function useBountySearch() {
const [searchTerm, setSearchTerm] = useState("");
const [isOpen, setIsOpen] = useState(false);
Expand Down
56 changes: 46 additions & 10 deletions hooks/use-infinite-bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,48 @@ import {
} from "@/lib/graphql/generated";
import { type PaginatedResponse } from "@/lib/api/types";
import { bountyKeys } from "@/lib/query/query-keys";
import { formatPaginatedBounties } from "@/lib/utils/pagination";

/** Default number of bounties to fetch per page */
const DEFAULT_LIMIT = 20;

/**
* Hook for fetching bounties with infinite scroll pagination from GraphQL API
*
* This hook uses React Query's `useInfiniteQuery` to manage paginated data fetching
* with automatic pagination handling. It fetches the next page of results as needed
* and maintains accumulated data across pages.
*
* The hook automatically manages:
* - Pagination state (current page, items per page)
* - Loading and error states
* - Next/previous page determination
* - Result accumulation and flattening
*
* @param params - Query parameters (page is omitted, determined by pagination)
* @returns Infinite query result with pages array and pagination functions
*
* @example
* const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteBounties({
* limit: 20,
* search: "security"
* });
*
* const allBounties = data?.pages.flatMap(page => page.data) ?? [];
*
* return (
* <InfiniteScroll
* dataLength={allBounties.length}
* next={fetchNextPage}
* hasMore={hasNextPage ?? false}
* loader={<Spinner />}
* >
* {allBounties.map(bounty => (
* <BountyCard key={bounty.id} bounty={bounty} />
* ))}
* </InfiniteScroll>
* );
*/
export function useInfiniteBounties(params?: Omit<BountyQueryInput, "page">) {
return useInfiniteQuery<PaginatedResponse<BountyFieldsFragment>>({
queryKey: bountyKeys.infinite(params),
Expand All @@ -25,16 +64,13 @@ export function useInfiniteBounties(params?: Omit<BountyQueryInput, "page">) {
limit: params?.limit ?? DEFAULT_LIMIT,
},
})();
const data = response.bounties;
return {
data: data.bounties as BountyFieldsFragment[],
pagination: {
page: pageParam as number,
limit: data.limit,
total: data.total,
totalPages: data.limit > 0 ? Math.ceil(data.total / data.limit) : 0,
},
};
const paginatedData = response.bounties;
return formatPaginatedBounties(
paginatedData.bounties as BountyFieldsFragment[],
paginatedData.total,
paginatedData.limit,
pageParam as number,
);
},
initialPageParam: 1,
getNextPageParam: (lastPage) => {
Expand Down
53 changes: 53 additions & 0 deletions lib/utils/pagination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Pagination utility functions for GraphQL responses
* Converts GraphQL PaginatedBounties response to the internal PaginatedResponse format
*/

import type { PaginatedResponse } from "@/lib/api/types";
import type { BountyFieldsFragment } from "@/lib/graphql/generated";

/**
* Calculates the total number of pages from total items and limit
* @param total - Total number of items
* @param limit - Items per page
* @returns Total number of pages, or 0 if limit is 0 or invalid
*/
export function calculateTotalPages(total: number, limit: number): number {
return limit > 0 ? Math.ceil(total / limit) : 0;
}

/**
* Transforms a GraphQL PaginatedBounties response to the internal PaginatedResponse format
* Handles the mapping of GraphQL response structure to UI expectations
*
* @param bounties - Array of bounty items from GraphQL response
* @param total - Total count of bounties
* @param limit - Items per page
* @param page - Current page number
* @returns PaginatedResponse with bounties array and pagination metadata
*
* @example
* const response = await bounties query...
* const formatted = formatPaginatedBounties(
* response.bounties.bounties,
* response.bounties.total,
* response.bounties.limit,
* 1
* );
*/
export function formatPaginatedBounties(
bounties: BountyFieldsFragment[],
total: number,
limit: number,
page: number,
): PaginatedResponse<BountyFieldsFragment> {
return {
data: bounties,
pagination: {
page,
limit,
total,
totalPages: calculateTotalPages(total, limit),
},
};
}