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 urllib.parse import unquote, urlparse
from uuid import UUID 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 sqlalchemy import func, or_
from sqlmodel import col from sqlmodel import col, select
from app.api.deps import require_org_admin from app.api.deps import require_org_admin
from app.core.time import utcnow from app.core.time import utcnow
@@ -940,11 +940,14 @@ def _apply_pack_candidate_updates(
@router.get("/marketplace", response_model=list[MarketplaceSkillCardRead]) @router.get("/marketplace", response_model=list[MarketplaceSkillCardRead])
async def list_marketplace_skills( async def list_marketplace_skills(
response: Response,
gateway_id: UUID = GATEWAY_ID_QUERY, gateway_id: UUID = GATEWAY_ID_QUERY,
search: str | None = Query(default=None), search: str | None = Query(default=None),
category: str | None = Query(default=None), category: str | None = Query(default=None),
risk: str | None = Query(default=None), risk: str | None = Query(default=None),
pack_id: UUID | None = Query(default=None, alias="pack_id"), 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, session: AsyncSession = SESSION_DEP,
ctx: OrganizationContext = ORG_ADMIN_DEP, ctx: OrganizationContext = ORG_ADMIN_DEP,
) -> list[MarketplaceSkillCardRead]: ) -> list[MarketplaceSkillCardRead]:
@@ -1002,11 +1005,19 @@ async def list_marketplace_skills(
), ),
) )
skills = ( if limit is not None:
await skills_query count_statement = select(func.count()).select_from(
.order_by(col(MarketplaceSkill.created_at).desc()) skills_query.statement.order_by(None).subquery()
.all(session) )
) 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( installations = await GatewayInstalledSkill.objects.filter_by(gateway_id=gateway.id).all(
session session
) )

View File

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

View File

@@ -4,7 +4,7 @@ export const dynamic = "force-dynamic";
import Link from "next/link"; import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { useAuth } from "@/auth/clerk";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -29,7 +29,7 @@ import {
import { SkillInstallDialog } from "@/components/skills/SkillInstallDialog"; import { SkillInstallDialog } from "@/components/skills/SkillInstallDialog";
import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable"; import { MarketplaceSkillsTable } from "@/components/skills/MarketplaceSkillsTable";
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"; 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 { useOrganizationMembership } from "@/lib/use-organization-membership";
import { useUrlSorting } from "@/lib/use-url-sorting"; import { useUrlSorting } from "@/lib/use-url-sorting";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -48,14 +48,18 @@ const MARKETPLACE_SKILLS_SORTABLE_COLUMNS = [
"source", "source",
"updated_at", "updated_at",
]; ];
const MARKETPLACE_DEFAULT_PAGE_SIZE = 25;
const MARKETPLACE_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
type MarketplaceSkillListParams = type MarketplaceSkillListParams = {
listMarketplaceSkillsApiV1SkillsMarketplaceGetParams & { gateway_id: string;
search?: string; search?: string;
category?: string; category?: string;
risk?: string; risk?: string;
pack_id?: string; pack_id?: string;
}; limit?: number;
offset?: number;
};
const RISK_SORT_ORDER: Record<string, number> = { const RISK_SORT_ORDER: Record<string, number> = {
safe: 10, safe: 10,
@@ -118,8 +122,33 @@ function formatCategoryLabel(category: string) {
.join(" "); .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() { export default function SkillsMarketplacePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { isSignedIn } = useAuth(); const { isSignedIn } = useAuth();
const { isAdmin } = useOrganizationMembership(isSignedIn); const { isAdmin } = useOrganizationMembership(isSignedIn);
@@ -137,9 +166,20 @@ export default function SkillsMarketplacePage() {
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>( const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(
null, null,
); );
const [searchTerm, setSearchTerm] = useState(""); const initialSearch = searchParams.get("search") ?? "";
const [selectedCategory, setSelectedCategory] = useState<string>("all"); const initialCategory = (searchParams.get("category") ?? "all")
const [selectedRisk, setSelectedRisk] = useState<string>("safe"); .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({ const { sorting, onSortingChange } = useUrlSorting({
allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS, allowedColumnIds: MARKETPLACE_SKILLS_SORTABLE_COLUMNS,
@@ -180,6 +220,8 @@ export default function SkillsMarketplacePage() {
const skillsParams = useMemo<MarketplaceSkillListParams>(() => { const skillsParams = useMemo<MarketplaceSkillListParams>(() => {
const params: MarketplaceSkillListParams = { const params: MarketplaceSkillListParams = {
gateway_id: resolvedGatewayId, gateway_id: resolvedGatewayId,
limit: pageSize,
offset: (currentPage - 1) * pageSize,
}; };
if (normalizedSearch) { if (normalizedSearch) {
params.search = normalizedSearch; params.search = normalizedSearch;
@@ -194,7 +236,15 @@ export default function SkillsMarketplacePage() {
params.pack_id = selectedPackId; params.pack_id = selectedPackId;
} }
return params; return params;
}, [normalizedCategory, normalizedRisk, normalizedSearch, resolvedGatewayId, selectedPackId]); }, [
currentPage,
pageSize,
normalizedCategory,
normalizedRisk,
normalizedSearch,
resolvedGatewayId,
selectedPackId,
]);
const filterOptionsParams = useMemo<MarketplaceSkillListParams>(() => { const filterOptionsParams = useMemo<MarketplaceSkillListParams>(() => {
const params: MarketplaceSkillListParams = { const params: MarketplaceSkillListParams = {
gateway_id: resolvedGatewayId, gateway_id: resolvedGatewayId,
@@ -267,6 +317,41 @@ export default function SkillsMarketplacePage() {
); );
const filteredSkills = useMemo(() => skills, [skills]); 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 categoryFilterOptions = useMemo(() => {
const byValue = new Map<string, string>(); const byValue = new Map<string, string>();
@@ -330,6 +415,74 @@ export default function SkillsMarketplacePage() {
} }
}, [riskFilterOptions, selectedRisk]); }, [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 () => { const loadSkillsByGateway = useCallback(async () => {
// NOTE: This is technically N+1 (one request per gateway). We intentionally // NOTE: This is technically N+1 (one request per gateway). We intentionally
// parallelize requests to keep the UI responsive and avoid slow sequential // parallelize requests to keep the UI responsive and avoid slow sequential
@@ -600,11 +753,11 @@ export default function SkillsMarketplacePage() {
title="Skills Marketplace" title="Skills Marketplace"
description={ description={
selectedPack selectedPack
? `${filteredSkills.length} skill${ ? `${totalSkills} skill${
filteredSkills.length === 1 ? "" : "s" totalSkills === 1 ? "" : "s"
} for ${selectedPack.name}.` } for ${selectedPack.name}.`
: `${filteredSkills.length} skill${ : `${totalSkills} skill${
filteredSkills.length === 1 ? "" : "s" totalSkills === 1 ? "" : "s"
} synced from packs.` } synced from packs.`
} }
isAdmin={isAdmin} isAdmin={isAdmin}
@@ -720,6 +873,76 @@ export default function SkillsMarketplacePage() {
}} }}
/> />
</div> </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>
</> </>
)} )}