docs(frontend): document skills marketplace + packs data-flow
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
Reference in New Issue
Block a user