docs(frontend): document skills marketplace + packs data-flow

This commit is contained in:
Abhimanyu Saharan
2026-02-14 14:26:04 +00:00
parent 44e4499a26
commit 691fd11d10
5 changed files with 84 additions and 0 deletions

View File

@@ -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<MarketplaceSkillListParams>(() => {
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();

View File

@@ -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) => {

View File

@@ -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<string | null>(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);

View File

@@ -26,6 +26,13 @@ export const SKILLS_TABLE_EMPTY_ICON = (
</svg>
);
/**
* 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<SortingState> | undefined,

View File

@@ -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/<branch>/...` 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()}`;