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
142 changes: 107 additions & 35 deletions apps/dashboard/app/(app)/apis/_components/api-list-client.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,130 @@
"use client";

import { EmptyComponentSpacer } from "@/components/empty-component-spacer";
import type {
ApiOverview,
ApisOverviewResponse,
} from "@/lib/trpc/routers/api/overview/query-overview/schemas";
import { trpc } from "@/lib/trpc/client";
import { BookBookmark } from "@unkey/icons";
import { Button, Empty } from "@unkey/ui";
import { useState } from "react";
import { ApiListGrid } from "./api-list-grid";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ApiListCard } from "./api-list-card";
import { ApiListControlCloud } from "./control-cloud";
import { ApiListControls } from "./controls";
import { CreateApiButton } from "./create-api-button";
import { ApiCardSkeleton } from "./skeleton";

export const ApiListClient = ({
initialData,
}: {
initialData: ApisOverviewResponse;
}) => {
const [isSearching, setIsSearching] = useState<boolean>(false);
const [apiList, setApiList] = useState<ApiOverview[]>(initialData.apiList);
const DEFAULT_LIMIT = 10;

export const ApiListClient = () => {
const router = useRouter();
const searchParams = useSearchParams();
const isNewApi = searchParams?.get("new") === "true";

const {
data: apisData,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = trpc.api.overview.query.useInfiniteQuery(
{ limit: DEFAULT_LIMIT },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);

const allApis = useMemo(() => {
if (!apisData?.pages) {
return [];
}
return apisData.pages.flatMap((page) => page.apiList);
}, [apisData]);

const [apiList, setApiList] = useState(allApis);
const [isSearching, setIsSearching] = useState(false);

useEffect(() => {
setApiList(allApis);
}, [allApis]);
Comment on lines +46 to +48
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Consider potential state synchronization conflicts

The useEffect synchronizes allApis (server data) to apiList (local state), but search operations modify apiList directly. If new server data arrives while searching, the search results would be overwritten.

While this may not be a practical issue due to search disabling pagination, consider a more explicit state management pattern to avoid potential conflicts.

// Consider using derived state instead of synchronization:
-const [apiList, setApiList] = useState(allApis);
-
-useEffect(() => {
-  setApiList(allApis);
-}, [allApis]);
+const [searchResults, setSearchResults] = useState<typeof allApis>([]);
+const [isSearching, setIsSearching] = useState(false);
+
+const displayedApis = isSearching ? searchResults : allApis;

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/dashboard/app/(app)/apis/_components/api-list-client.tsx around lines 46
to 48, the useEffect hook directly sets apiList state from allApis, but since
apiList is also modified by search operations, incoming updates to allApis can
overwrite search results. To fix this, separate the source data (allApis) from
the filtered or displayed data (apiList) by maintaining distinct states or use
memoized derived state for search results, ensuring that updates to allApis do
not overwrite user-driven changes like search filtering.


useEffect(() => {
if (error) {
router.push("/new");
}
}, [error, router]);

const loadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};

return (
<div className="flex flex-col">
<ApiListControls apiList={apiList} onApiListChange={setApiList} onSearch={setIsSearching} />
<ApiListControls apiList={allApis} onApiListChange={setApiList} onSearch={setIsSearching} />
<ApiListControlCloud />
{initialData.apiList.length > 0 ? (
<ApiListGrid
isSearching={isSearching}
initialData={initialData}
setApiList={setApiList}
apiList={apiList}
/>

{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3 md:gap-5 w-full p-5">
{Array.from({ length: DEFAULT_LIMIT }).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: It's okay to use index
<ApiCardSkeleton key={i} />
))}
</div>
) : apiList.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3 md:gap-5 w-full p-5">
{apiList.map((api) => (
<ApiListCard api={api} key={api.id} />
))}
</div>

<div className="flex flex-col items-center justify-center mt-8 space-y-4 pb-8">
<div className="text-center text-sm text-accent-11">
Showing {apiList.length} of {apisData?.pages[0]?.total || 0} APIs
</div>

{!isSearching && hasNextPage && (
<Button onClick={loadMore} disabled={isFetchingNextPage} size="md">
{isFetchingNextPage ? (
<div className="flex items-center space-x-2">
<div className="animate-spin h-4 w-4 border-2 border-gray-7 border-t-transparent rounded-full" />
<span>Loading...</span>
</div>
) : (
<div className="flex items-center space-x-2">
<span>Load more</span>
</div>
)}
</Button>
)}
</div>
</>
) : (
<EmptyComponentSpacer>
<Empty className="m-0 p-0">
<Empty.Icon />
<Empty.Title>No APIs found</Empty.Title>
<Empty.Description>
You haven&apos;t created any APIs yet. Create one to get started.
{isSearching
? "No APIs match your search criteria. Try a different search term."
: "You haven't created any APIs yet. Create one to get started."}
</Empty.Description>
<Empty.Actions className="mt-4 ">
<CreateApiButton />
<a
href="https://www.unkey.com/docs/introduction"
target="_blank"
rel="noopener noreferrer"
>
<Button size="md">
<BookBookmark />
Documentation
</Button>
</a>
</Empty.Actions>
{!isSearching && (
<Empty.Actions className="mt-4">
<CreateApiButton defaultOpen={isNewApi} />
<a
href="https://www.unkey.com/docs/introduction"
target="_blank"
rel="noopener noreferrer"
>
<Button size="md">
<BookBookmark />
Documentation
</Button>
</a>
</Empty.Actions>
)}
</Empty>
</EmptyComponentSpacer>
)}
Expand Down
28 changes: 13 additions & 15 deletions apps/dashboard/app/(app)/apis/_components/api-list-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
import { EmptyComponentSpacer } from "@/components/empty-component-spacer";

import type {
ApiOverview,
ApisOverviewResponse,
} from "@/lib/trpc/routers/api/overview/query-overview/schemas";
import type { ApiOverview } from "@/lib/trpc/routers/api/overview/query-overview/schemas";
import { ChevronDown } from "@unkey/icons";
import { Button, Empty } from "@unkey/ui";
import type { Dispatch, SetStateAction } from "react";
import { ApiListCard } from "./api-list-card";
import { useFetchApiOverview } from "./hooks/use-fetch-api-overview";

export const ApiListGrid = ({
initialData,
setApiList,
apiList,
isSearching,
onLoadMore,
hasMore,
isLoadingMore,
total,
}: {
initialData: ApisOverviewResponse;
apiList: ApiOverview[];
setApiList: Dispatch<SetStateAction<ApiOverview[]>>;
isSearching?: boolean;
onLoadMore: () => void;
hasMore: boolean;
isLoadingMore: boolean;
total: number;
}) => {
const { total, loadMore, isLoading, hasMore } = useFetchApiOverview(initialData, setApiList);

if (apiList.length === 0) {
return (
<EmptyComponentSpacer>
Expand All @@ -44,13 +40,15 @@ export const ApiListGrid = ({
<ApiListCard api={api} key={api.id} />
))}
</div>

<div className="flex flex-col items-center justify-center mt-8 space-y-4 pb-8">
<div className="text-center text-sm text-accent-11">
Showing {apiList.length} of {total} APIs
</div>

{!isSearching && hasMore && (
<Button onClick={loadMore} disabled={isLoading} size="md">
{isLoading ? (
<Button onClick={onLoadMore} disabled={isLoadingMore} size="md">
{isLoadingMore ? (
<div className="flex items-center space-x-2">
<div className="animate-spin h-4 w-4 border-2 border-gray-7 border-t-transparent rounded-full" />
<span>Loading...</span>
Expand Down
56 changes: 56 additions & 0 deletions apps/dashboard/app/(app)/apis/_components/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Key, ProgressBar } from "@unkey/icons";

export const ChartSkeleton = () => {
return <div className="h-[140px] bg-grayA-2 p-4 flex items-end gap-1" />;
};

export const MetricStatsSkeleton = () => {
return (
<>
<div className="flex gap-[14px] items-center">
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center h-4">
<div className="bg-accent-8 rounded h-[10px] w-1 animate-pulse" />
<div className="h-3 w-16 bg-grayA-3 rounded animate-pulse" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex gap-2 items-center h-4">
<div className="bg-orange-9 rounded h-[10px] w-1 animate-pulse" />
<div className="h-3 w-16 bg-grayA-3 rounded animate-pulse" />
</div>
</div>
</div>
<div className="flex items-center gap-2 min-w-0 max-w-[40%] h-4">
<Key className="text-accent-11 flex-shrink-0 opacity-30" />
<div className="h-3 w-10 bg-grayA-3 rounded animate-pulse" />
</div>
</>
);
};

export const ApiCardSkeleton = () => {
return (
<div className="flex flex-col border border-gray-6 rounded-xl overflow-hidden">
<ChartSkeleton />
<div className="p-4 md:p-6 border-t border-gray-6 flex flex-col gap-2">
<div className="flex justify-between items-center">
<div className="flex flex-col flex-grow min-w-0">
<div className="flex gap-2 md:gap-3 items-center h-6">
<div className="flex-shrink-0 opacity-30">
<ProgressBar className="text-accent-11" />
</div>
<div className="h-5 w-32 bg-grayA-3 rounded animate-pulse" />
</div>
<div className="h-4">
<div className="h-3 w-32 bg-grayA-3 rounded animate-pulse" />
</div>
</div>
</div>
<div className="flex items-center w-full justify-between gap-3 md:gap-4 mt-2 h-[18px]">
<MetricStatsSkeleton />
</div>
</div>
</div>
);
};
25 changes: 0 additions & 25 deletions apps/dashboard/app/(app)/apis/navigation.tsx

This file was deleted.

48 changes: 20 additions & 28 deletions apps/dashboard/app/(app)/apis/page.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,28 @@
import { getAuth } from "@/lib/auth";
import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { ApiListClient } from "./_components/api-list-client";
import { DEFAULT_OVERVIEW_FETCH_LIMIT } from "./_components/constants";
import { fetchApiOverview } from "./actions";
import { Navigation } from "./navigation";

export const dynamic = "force-dynamic";

type Props = {
searchParams: { new?: boolean };
};
"use client";

export default async function ApisOverviewPage(props: Props) {
const { orgId } = await getAuth();
const workspace = await db.query.workspaces.findFirst({
where: (table, { and, eq, isNull }) => and(eq(table.orgId, orgId), isNull(table.deletedAtM)),
});

if (!workspace) {
return redirect("/new");
}
import { Navbar } from "@/components/navigation/navbar";
import { Nodes } from "@unkey/icons";
import { useSearchParams } from "next/navigation";
import { ApiListClient } from "./_components/api-list-client";
import { CreateApiButton } from "./_components/create-api-button";

const initialData = await fetchApiOverview({
workspaceId: workspace.id,
limit: DEFAULT_OVERVIEW_FETCH_LIMIT,
});
export default function ApisOverviewPage() {
const searchParams = useSearchParams();
const isNewApi = searchParams?.get("new") === "true";

return (
<div>
<Navigation isNewApi={!!props.searchParams.new} apisLength={initialData.total} />
<ApiListClient initialData={initialData} />
<Navbar>
<Navbar.Breadcrumbs icon={<Nodes />}>
<Navbar.Breadcrumbs.Link href="/apis" active>
APIs
</Navbar.Breadcrumbs.Link>
</Navbar.Breadcrumbs>
<Navbar.Actions>
<CreateApiButton key="createApi" defaultOpen={isNewApi} />
</Navbar.Actions>
</Navbar>
<ApiListClient />
</div>
);
}