feat(skills): implement pagination and total count headers for marketplace skills

This commit is contained in:
Abhimanyu Saharan
2026-02-14 13:06:45 +05:30
parent 755bbde4f5
commit e7d47d9f8a
3 changed files with 259 additions and 24 deletions

View File

@@ -13,9 +13,9 @@ from typing import TYPE_CHECKING, Iterator, TextIO
from urllib.parse import unquote, urlparse
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, or_
from sqlmodel import col
from sqlmodel import col, select
from app.api.deps import require_org_admin
from app.core.time import utcnow
@@ -940,11 +940,14 @@ def _apply_pack_candidate_updates(
@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead])
async def list_marketplace_skills(
response: Response,
gateway_id: UUID = GATEWAY_ID_QUERY,
search: str | None = Query(default=None),
category: str | None = Query(default=None),
risk: str | None = Query(default=None),
pack_id: UUID | None = Query(default=None, alias="pack_id"),
limit: int | None = Query(default=None, ge=1, le=200),
offset: int = Query(default=0, ge=0),
session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[MarketplaceSkillCardRead]:
@@ -1002,11 +1005,19 @@ async def list_marketplace_skills(
),
)
skills = (
await skills_query
.order_by(col(MarketplaceSkill.created_at).desc())
.all(session)
)
if limit is not None:
count_statement = select(func.count()).select_from(
skills_query.statement.order_by(None).subquery()
)
total_count = int((await session.exec(count_statement)).one() or 0)
response.headers["X-Total-Count"] = str(total_count)
response.headers["X-Limit"] = str(limit)
response.headers["X-Offset"] = str(offset)
ordered_query = skills_query.order_by(col(MarketplaceSkill.created_at).desc())
if limit is not None:
ordered_query = ordered_query.offset(offset).limit(limit)
skills = await ordered_query.all(session)
installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(
session
)

View File

@@ -103,6 +103,7 @@ if origins:
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["X-Total-Count", "X-Limit", "X-Offset"],
)
logger.info("app.cors.enabled origins_count=%s", len(origins))
else:

View File

@@ -4,7 +4,7 @@ export const dynamic = "force-dynamic";
import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query";
@@ -29,7 +29,7 @@ import {
import { SkillInstallDialog } from "@/components/skills/SkillInstallDialog";
import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
import { buttonVariants } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { useOrganizationMembership } from "@/lib/use-organization-membership";
import { useUrlSorting } from "@/lib/use-url-sorting";
import { Input } from "@/components/ui/input";
@@ -48,14 +48,18 @@ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [
"source",
"updated_at",
];
const MARKETPLACE_DEFAULT_PAGE_SIZE = 25;
const MARKETPLACE_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
type MarketplaceSkillListParams =
listMarketplaceSkillsApiV1SkillsMarketplaceGetParams & {
search?: string;
category?: string;
risk?: string;
pack_id?: string;
};
type MarketplaceSkillListParams = {
gateway_id: string;
search?: string;
category?: string;
risk?: string;
pack_id?: string;
limit?: number;
offset?: number;
};
const RISK_SORT_ORDER: Record<string, number> = {
safe: 10,
@@ -118,8 +122,33 @@ function formatCategoryLabel(category: string) {
.join(" ");
}
function parsePositiveIntParam(value: string | null, fallback: number) {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return fallback;
}
return parsed;
}
function parsePageSizeParam(value: string | null) {
const parsed = parsePositiveIntParam(value, MARKETPLACE_DEFAULT_PAGE_SIZE);
if (
MARKETPLACE_PAGE_SIZE_OPTIONS.includes(
parsed as (typeof MARKETPLACE_PAGE_SIZE_OPTIONS)[number],
)
) {
return parsed;
}
return MARKETPLACE_DEFAULT_PAGE_SIZE;
}
export default function SkillsMarketplacePage() {
const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn);
@@ -137,9 +166,20 @@ export default function SkillsMarketplacePage() {
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(
null,
);
const [searchTerm, setSearchTerm] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("all");
const [selectedRisk, setSelectedRisk] = useState<string>("safe");
const initialSearch = searchParams.get("search") ?? "";
const initialCategory = (searchParams.get("category") ?? "all")
.trim()
.toLowerCase();
const initialRisk = (searchParams.get("risk") ?? "safe").trim().toLowerCase();
const initialPage = parsePositiveIntParam(searchParams.get("page"), 1);
const initialPageSize = parsePageSizeParam(searchParams.get("limit"));
const [searchTerm, setSearchTerm] = useState(initialSearch);
const [selectedCategory, setSelectedCategory] = useState<string>(
initialCategory || "all",
);
const [selectedRisk, setSelectedRisk] = useState<string>(initialRisk || "safe");
const [currentPage, setCurrentPage] = useState(initialPage);
const [pageSize, setPageSize] = useState(initialPageSize);
const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS,
@@ -180,6 +220,8 @@ export default function SkillsMarketplacePage() {
const skillsParams = useMemo<MarketplaceSkillListParams>(() => {
const params: MarketplaceSkillListParams = {
gateway_id: resolvedGatewayId,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
};
if (normalizedSearch) {
params.search = normalizedSearch;
@@ -194,7 +236,15 @@ export default function SkillsMarketplacePage() {
params.pack_id = selectedPackId;
}
return params;
}, [normalizedCategory, normalizedRisk, normalizedSearch, resolvedGatewayId, selectedPackId]);
}, [
currentPage,
pageSize,
normalizedCategory,
normalizedRisk,
normalizedSearch,
resolvedGatewayId,
selectedPackId,
]);
const filterOptionsParams = useMemo<MarketplaceSkillListParams>(() => {
const params: MarketplaceSkillListParams = {
gateway_id: resolvedGatewayId,
@@ -267,6 +317,41 @@ export default function SkillsMarketplacePage() {
);
const filteredSkills = useMemo(() => skills, [skills]);
const totalCountInfo = useMemo(() => {
if (skillsQuery.data?.status !== 200) {
return { hasKnownTotal: false, total: skills.length };
}
const totalCountHeader = skillsQuery.data.headers.get("x-total-count");
if (typeof totalCountHeader === "string" && totalCountHeader.trim() !== "") {
const parsed = Number(totalCountHeader);
if (Number.isFinite(parsed) && parsed >= 0) {
return { hasKnownTotal: true, total: parsed };
}
}
return { hasKnownTotal: false, total: skills.length };
}, [skills, skillsQuery.data]);
const totalSkills = useMemo(() => {
if (totalCountInfo.hasKnownTotal) {
return totalCountInfo.total;
}
return (currentPage - 1) * pageSize + skills.length;
}, [currentPage, pageSize, skills.length, totalCountInfo]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(totalSkills / pageSize)),
[pageSize, totalSkills],
);
const hasNextPage = useMemo(() => {
if (totalCountInfo.hasKnownTotal) {
return currentPage < totalPages;
}
return skills.length === pageSize;
}, [currentPage, pageSize, skills.length, totalCountInfo.hasKnownTotal, totalPages]);
const rangeStart =
totalSkills === 0 ? 0 : (currentPage - 1) * pageSize + 1;
const rangeEnd =
totalSkills === 0
? 0
: (currentPage - 1) * pageSize + skills.length;
const categoryFilterOptions = useMemo(() => {
const byValue = new Map<string, string>();
@@ -330,6 +415,74 @@ export default function SkillsMarketplacePage() {
}
}, [riskFilterOptions, selectedRisk]);
useEffect(() => {
setCurrentPage(1);
}, [
normalizedCategory,
normalizedRisk,
normalizedSearch,
pageSize,
resolvedGatewayId,
selectedPackId,
]);
useEffect(() => {
if (totalCountInfo.hasKnownTotal && currentPage > totalPages) {
setCurrentPage(totalPages);
}
}, [currentPage, totalCountInfo.hasKnownTotal, totalPages]);
useEffect(() => {
const nextParams = new URLSearchParams(searchParams.toString());
const normalizedSearchForUrl = searchTerm.trim();
if (normalizedSearchForUrl) {
nextParams.set("search", normalizedSearchForUrl);
} else {
nextParams.delete("search");
}
if (selectedCategory !== "all") {
nextParams.set("category", selectedCategory);
} else {
nextParams.delete("category");
}
if (selectedRisk !== "safe") {
nextParams.set("risk", selectedRisk);
} else {
nextParams.delete("risk");
}
if (pageSize !== MARKETPLACE_DEFAULT_PAGE_SIZE) {
nextParams.set("limit", String(pageSize));
} else {
nextParams.delete("limit");
}
if (currentPage > 1) {
nextParams.set("page", String(currentPage));
} else {
nextParams.delete("page");
}
const currentQuery = searchParams.toString();
const nextQuery = nextParams.toString();
if (nextQuery !== currentQuery) {
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}
}, [
currentPage,
pathname,
pageSize,
router,
searchParams,
searchTerm,
selectedCategory,
selectedRisk,
]);
const loadSkillsByGateway = useCallback(async () => {
// NOTE: This is technically N+1 (one request per gateway). We intentionally
// parallelize requests to keep the UI responsive and avoid slow sequential
@@ -600,11 +753,11 @@ export default function SkillsMarketplacePage() {
title="Skills Marketplace"
description={
selectedPack
? `${filteredSkills.length} skill${
filteredSkills.length === 1 ? "" : "s"
? `${totalSkills} skill${
totalSkills === 1 ? "" : "s"
} for ${selectedPack.name}.`
: `${filteredSkills.length} skill${
filteredSkills.length === 1 ? "" : "s"
: `${totalSkills} skill${
totalSkills === 1 ? "" : "s"
} synced from packs.`
}
isAdmin={isAdmin}
@@ -720,6 +873,76 @@ export default function SkillsMarketplacePage() {
}}
/>
</div>
<div className="flex items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
<div className="flex items-center gap-3">
<p>
Showing {rangeStart}-{rangeEnd} of {totalSkills}
</p>
<div className="flex items-center gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
Rows
</span>
<Select
value={String(pageSize)}
onValueChange={(value) => {
const next = Number.parseInt(value, 10);
if (
MARKETPLACE_PAGE_SIZE_OPTIONS.includes(
next as (typeof MARKETPLACE_PAGE_SIZE_OPTIONS)[number],
)
) {
setPageSize(next);
}
}}
>
<SelectTrigger
id="marketplace-footer-limit-filter"
className="h-8 w-24"
>
<SelectValue placeholder="25" />
</SelectTrigger>
<SelectContent>
{MARKETPLACE_PAGE_SIZE_OPTIONS.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={currentPage <= 1 || skillsQuery.isLoading}
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
>
Previous
</Button>
<span className="text-xs font-medium uppercase tracking-wide text-slate-500">
{totalCountInfo.hasKnownTotal
? `Page ${currentPage} of ${totalPages}`
: `Page ${currentPage}`}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={!hasNextPage || skillsQuery.isLoading}
onClick={() => {
setCurrentPage((prev) =>
totalCountInfo.hasKnownTotal
? Math.min(totalPages, prev + 1)
: prev + 1,
);
}}
>
Next
</Button>
</div>
</div>
</>
)}