From 691fd11d10b17993598ea4404b90a117eba74665 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Sat, 14 Feb 2026 14:26:04 +0000 Subject: [PATCH] docs(frontend): document skills marketplace + packs data-flow --- frontend/src/app/skills/marketplace/page.tsx | 19 +++++++++++ frontend/src/app/skills/packs/page.tsx | 11 ++++++ .../skills/MarketplaceSkillForm.tsx | 13 +++++++ .../src/components/skills/table-helpers.tsx | 7 ++++ frontend/src/lib/skills-source.ts | 34 +++++++++++++++++++ 5 files changed, 84 insertions(+) diff --git a/frontend/src/app/skills/marketplace/page.tsx b/frontend/src/app/skills/marketplace/page.tsx index 95d1f3c3..bd76a4d6 100644 --- a/frontend/src/app/skills/marketplace/page.tsx +++ b/frontend/src/app/skills/marketplace/page.tsx @@ -145,6 +145,18 @@ function parsePageSizeParam(value: string | null) { return MARKETPLACE_DEFAULT_PAGE_SIZE; } +/** + * Skills Marketplace admin page. + * + * Data-flow notes: + * - URL query params are treated as the shareable source-of-truth for filters + * and pagination (we mirror local state -> URL via `router.replace`). + * - We keep a *separate* query (without pagination) for building filter option + * lists (categories/risks) so options don't disappear just because the current + * page is filtered/paginated. + * - Total row count is best-effort: when the API provides `x-total-count`, we + * use it; otherwise we infer whether there is a next page from page-size. + */ export default function SkillsMarketplacePage() { const queryClient = useQueryClient(); const router = useRouter(); @@ -247,6 +259,8 @@ export default function SkillsMarketplacePage() { resolvedGatewayId, selectedPackId, ]); + // Fetch a non-paginated (or minimally constrained) slice for building filter options. + // Keeping this separate avoids "missing" categories/risks when the main list is paginated. const filterOptionsParams = useMemo(() => { const params: MarketplaceSkillListParams = { gateway_id: resolvedGatewayId, @@ -314,6 +328,8 @@ export default function SkillsMarketplacePage() { ); const filteredSkills = useMemo(() => skills, [skills]); + // Prefer the API total-count header when available (true pagination). + // When missing, we fall back to a heuristic for "has next page". const totalCountInfo = useMemo(() => { if (skillsQuery.data?.status !== 200) { return { hasKnownTotal: false, total: skills.length }; @@ -435,6 +451,9 @@ export default function SkillsMarketplacePage() { } }, [currentPage, totalCountInfo.hasKnownTotal, totalPages]); + // Mirror local filter/page state back into the URL so the view is shareable and + // back/forward navigation works. We use `replace` (not push) to avoid filling + // browser history with every keystroke / filter tweak. useEffect(() => { const nextParams = new URLSearchParams(searchParams.toString()); const normalizedSearchForUrl = searchTerm.trim(); diff --git a/frontend/src/app/skills/packs/page.tsx b/frontend/src/app/skills/packs/page.tsx index 8b35eb94..2f07a567 100644 --- a/frontend/src/app/skills/packs/page.tsx +++ b/frontend/src/app/skills/packs/page.tsx @@ -32,6 +32,15 @@ const PACKS_SORTABLE_COLUMNS = [ "updated_at", ]; +/** + * Skill packs admin page. + * + * Notes: + * - Sync actions are intentionally serialized (per-pack) to avoid a thundering herd + * of GitHub fetches / backend sync jobs. + * - We keep UI state (`syncingPackIds`, warnings) local; the canonical list is + * still React Query (invalidate after sync/delete). + */ export default function SkillsPacksPage() { const queryClient = useQueryClient(); const { isSignedIn } = useAuth(); @@ -141,6 +150,8 @@ export default function SkillsPacksPage() { try { let hasFailure = false; + // Run sequentially so the UI remains predictable and the backend isn't hit with + // concurrent sync bursts (which can trigger rate-limits). for (const pack of packs) { if (!pack.id) continue; setSyncingPackIds((previous) => { diff --git a/frontend/src/components/skills/MarketplaceSkillForm.tsx b/frontend/src/components/skills/MarketplaceSkillForm.tsx index 541e97e2..89e31c27 100644 --- a/frontend/src/components/skills/MarketplaceSkillForm.tsx +++ b/frontend/src/components/skills/MarketplaceSkillForm.tsx @@ -48,6 +48,12 @@ const extractErrorMessage = (error: unknown, fallback: string) => { return fallback; }; +/** + * Form used for creating/editing a marketplace skill source. + * + * Intentionally keeps validation lightweight + client-side only: + * the backend remains the source of truth and returns actionable errors. + */ export function MarketplaceSkillForm({ initialValues, sourceUrlReadOnly = false, @@ -80,6 +86,13 @@ export function MarketplaceSkillForm({ ); const [errorMessage, setErrorMessage] = useState(null); + /** + * Basic repo URL validation. + * + * This is strict by design (https + github.com + at least owner/repo) + * to catch obvious mistakes early. More complex URLs (subpaths, branches) + * are handled server-side. + */ const isValidSourceUrl = (value: string) => { try { const parsed = new URL(value); diff --git a/frontend/src/components/skills/table-helpers.tsx b/frontend/src/components/skills/table-helpers.tsx index bbbf3592..241275ce 100644 --- a/frontend/src/components/skills/table-helpers.tsx +++ b/frontend/src/components/skills/table-helpers.tsx @@ -26,6 +26,13 @@ export const SKILLS_TABLE_EMPTY_ICON = ( ); +/** + * Small helper for supporting both controlled and uncontrolled table sorting. + * + * TanStack Table expects a `sorting` state + `onSortingChange` callback. + * Some pages want to control this from the URL (shareable links), while others + * are fine letting the table manage it internally. + */ export const useTableSortingState = ( sorting: SortingState | undefined, onSortingChange: OnChangeFn | undefined, diff --git a/frontend/src/lib/skills-source.ts b/frontend/src/lib/skills-source.ts index 5c9f0fb6..df1d32bf 100644 --- a/frontend/src/lib/skills-source.ts +++ b/frontend/src/lib/skills-source.ts @@ -1,8 +1,24 @@ +/** + * Normalize a repository-ish URL for UI usage. + * + * - Trims whitespace + * - Removes trailing slashes + * - Strips a trailing ".git" (common for clone URLs) + */ export const normalizeRepoSourceUrl = (sourceUrl: string): string => { const trimmed = sourceUrl.trim().replace(/\/+$/, ""); return trimmed.endsWith(".git") ? trimmed.slice(0, -4) : trimmed; }; +/** + * Extract the base repo URL from a skill source URL. + * + * We support skill source URLs that may include a `/tree//...` suffix + * (e.g. a skill living inside a monorepo). In those cases, we want to link to + * the repo root (the "pack") rather than the nested path. + * + * Returns `null` for invalid/unsupported URLs. + */ export const repoBaseFromSkillSourceUrl = ( skillSourceUrl: string, ): string | null => { @@ -26,11 +42,23 @@ export const repoBaseFromSkillSourceUrl = ( } }; +/** + * Derive the pack URL from a skill source URL. + * + * Prefer the repo base when the URL points at a nested `/tree/...` path. + * Otherwise, fall back to the original source URL. + */ export const packUrlFromSkillSourceUrl = (skillSourceUrl: string): string => { const repoBase = repoBaseFromSkillSourceUrl(skillSourceUrl); return repoBase ?? skillSourceUrl; }; +/** + * Create a short, stable label for a pack URL (used in tables and filter pills). + * + * - For GitHub-like URLs, prefers `owner/repo`. + * - For other URLs, falls back to the host. + */ export const packLabelFromUrl = (packUrl: string): string => { try { const parsed = new URL(packUrl); @@ -44,6 +72,12 @@ export const packLabelFromUrl = (packUrl: string): string => { } }; +/** + * Build a packs page href filtered to a specific pack URL. + * + * We use a query param instead of path segments so the packs list can be + * shareable/bookmarkable without additional route definitions. + */ export const packsHrefFromPackUrl = (packUrl: string): string => { const params = new URLSearchParams({ source_url: packUrl }); return `/skills/packs?${params.toString()}`;