feat: add skill packs management with support for category, risk, and source fields

This commit is contained in:
Abhimanyu Saharan
2026-02-14 03:30:13 +05:30
committed by Abhimanyu Saharan
parent da6cc2544b
commit 10748f71a8
11 changed files with 522 additions and 490 deletions

View File

@@ -1,11 +1,10 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import Link from "next/link";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
useReactTable,
@@ -15,7 +14,16 @@ import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters";
import { Button, buttonVariants } from "@/components/ui/button";
import {
SKILLS_TABLE_EMPTY_ICON,
useTableSortingState,
} from "@/components/skills/table-helpers";
import { truncateText as truncate } from "@/lib/formatters";
import {
packLabelFromUrl,
packUrlFromSkillSourceUrl,
packsHrefFromPackUrl,
} from "@/lib/skills-source";
type MarketplaceSkillsTableProps = {
skills: MarketplaceSkillCardRead[];
@@ -34,57 +42,6 @@ type MarketplaceSkillsTableProps = {
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
<path d="M8 7v10" />
<path d="M16 7v10" />
</svg>
);
const toPackUrl = (sourceUrl: string): string => {
try {
const parsed = new URL(sourceUrl);
const treeMarker = "/tree/";
const markerIndex = parsed.pathname.indexOf(treeMarker);
if (markerIndex > 0) {
const repoPath = parsed.pathname.slice(0, markerIndex);
return `${parsed.origin}${repoPath}`;
}
return sourceUrl;
} catch {
return sourceUrl;
}
};
const toPackLabel = (packUrl: string): string => {
try {
const parsed = new URL(packUrl);
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length >= 2) {
return `${segments[0]}/${segments[1]}`;
}
return parsed.host;
} catch {
return "Open pack";
}
};
const toPacksHref = (packUrl: string): string => {
const params = new URLSearchParams({ source_url: packUrl });
return `/skills/packs?${params.toString()}`;
};
export function MarketplaceSkillsTable({
skills,
installedGatewayNamesBySkillId,
@@ -99,15 +56,11 @@ export function MarketplaceSkillsTable({
getEditHref,
emptyState,
}: MarketplaceSkillsTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const { resolvedSorting, handleSortingChange } = useTableSortingState(
sorting,
onSortingChange,
[{ id: "name", desc: false }],
);
const columns = useMemo<ColumnDef<MarketplaceSkillCardRead>[]>(() => {
const baseColumns: ColumnDef<MarketplaceSkillCardRead>[] = [
@@ -140,13 +93,13 @@ export function MarketplaceSkillsTable({
accessorKey: "source_url",
header: "Pack",
cell: ({ row }) => {
const packUrl = toPackUrl(row.original.source_url);
const packUrl = packUrlFromSkillSourceUrl(row.original.source_url);
return (
<Link
href={toPacksHref(packUrl)}
href={packsHrefFromPackUrl(packUrl)}
className="inline-flex items-center gap-1 text-sm font-medium text-slate-700 hover:text-blue-600"
>
{truncate(toPackLabel(packUrl), 40)}
{truncate(packLabelFromUrl(packUrl), 40)}
</Link>
);
},
@@ -271,7 +224,7 @@ export function MarketplaceSkillsTable({
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
icon: emptyState.icon ?? SKILLS_TABLE_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,

View File

@@ -0,0 +1,105 @@
"use client";
import type { MarketplaceSkillCardRead } from "@/api/generated/model";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
type GatewaySummary = {
id: string;
name: string;
};
type SkillInstallDialogProps = {
selectedSkill: MarketplaceSkillCardRead | null;
gateways: GatewaySummary[];
gatewayInstalledById: Record<string, boolean>;
isGatewayStatusLoading: boolean;
installingGatewayId: string | null;
isMutating: boolean;
gatewayStatusError: string | null;
mutationError: string | null;
onOpenChange: (open: boolean) => void;
onToggleInstall: (gatewayId: string, isInstalled: boolean) => void;
};
export function SkillInstallDialog({
selectedSkill,
gateways,
gatewayInstalledById,
isGatewayStatusLoading,
installingGatewayId,
isMutating,
gatewayStatusError,
mutationError,
onOpenChange,
onToggleInstall,
}: SkillInstallDialogProps) {
return (
<Dialog open={Boolean(selectedSkill)} onOpenChange={onOpenChange}>
<DialogContent
aria-label="Install skill on gateways"
className="max-w-xl p-6 sm:p-7"
>
<DialogHeader className="pb-1">
<DialogTitle>{selectedSkill ? selectedSkill.name : "Install skill"}</DialogTitle>
<DialogDescription>
Choose one or more gateways where this skill should be installed.
</DialogDescription>
</DialogHeader>
<div className="mt-2 space-y-3.5">
{isGatewayStatusLoading ? (
<p className="text-sm text-slate-500">Loading gateways...</p>
) : (
gateways.map((gateway) => {
const isInstalled = gatewayInstalledById[gateway.id] === true;
const isUpdatingGateway = installingGatewayId === gateway.id && isMutating;
return (
<div
key={gateway.id}
className="flex items-center justify-between rounded-xl border border-slate-200 bg-white p-4"
>
<div>
<p className="text-sm font-medium text-slate-900">{gateway.name}</p>
</div>
<Button
type="button"
size="sm"
variant={isInstalled ? "outline" : "primary"}
onClick={() => onToggleInstall(gateway.id, isInstalled)}
disabled={isMutating}
>
{isInstalled
? isUpdatingGateway
? "Uninstalling..."
: "Uninstall"
: isUpdatingGateway
? "Installing..."
: "Install"}
</Button>
</div>
);
})
)}
{gatewayStatusError ? (
<p className="text-sm text-rose-600">{gatewayStatusError}</p>
) : null}
{mutationError ? <p className="text-sm text-rose-600">{mutationError}</p> : null}
</div>
<DialogFooter className="mt-6 border-t border-slate-200 pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isMutating}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,11 +1,10 @@
import { useMemo, useState } from "react";
import { useMemo } from "react";
import Link from "next/link";
import {
type ColumnDef,
type OnChangeFn,
type SortingState,
type Updater,
getCoreRowModel,
getSortedRowModel,
useReactTable,
@@ -15,6 +14,10 @@ import type { SkillPackRead } from "@/api/generated/model";
import { DataTable, type DataTableEmptyState } from "@/components/tables/DataTable";
import { dateCell } from "@/components/tables/cell-formatters";
import { Button } from "@/components/ui/button";
import {
SKILLS_TABLE_EMPTY_ICON,
useTableSortingState,
} from "@/components/skills/table-helpers";
import { truncateText as truncate } from "@/lib/formatters";
type SkillPacksTableProps = {
@@ -33,24 +36,6 @@ type SkillPacksTableProps = {
};
};
const DEFAULT_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
<path d="M8 7v10" />
<path d="M16 7v10" />
</svg>
);
export function SkillPacksTable({
packs,
isLoading = false,
@@ -64,15 +49,11 @@ export function SkillPacksTable({
getEditHref,
emptyState,
}: SkillPacksTableProps) {
const [internalSorting, setInternalSorting] = useState<SortingState>([
{ id: "name", desc: false },
]);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
const { resolvedSorting, handleSortingChange } = useTableSortingState(
sorting,
onSortingChange,
[{ id: "name", desc: false }],
);
const columns = useMemo<ColumnDef<SkillPackRead>[]>(() => {
const baseColumns: ColumnDef<SkillPackRead>[] = [
@@ -175,7 +156,7 @@ export function SkillPacksTable({
emptyState={
emptyState
? {
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
icon: emptyState.icon ?? SKILLS_TABLE_EMPTY_ICON,
title: emptyState.title,
description: emptyState.description,
actionHref: emptyState.actionHref,

View File

@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import {
type OnChangeFn,
type SortingState,
type Updater,
} from "@tanstack/react-table";
export const SKILLS_TABLE_EMPTY_ICON = (
<svg
className="h-16 w-16 text-slate-300"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 7h16" />
<path d="M4 12h16" />
<path d="M4 17h16" />
<path d="M8 7v10" />
<path d="M16 7v10" />
</svg>
);
export const useTableSortingState = (
sorting: SortingState | undefined,
onSortingChange: OnChangeFn<SortingState> | undefined,
defaultSorting: SortingState,
): {
resolvedSorting: SortingState;
handleSortingChange: OnChangeFn<SortingState>;
} => {
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSorting);
const resolvedSorting = sorting ?? internalSorting;
const handleSortingChange: OnChangeFn<SortingState> =
onSortingChange ??
((updater: Updater<SortingState>) => {
setInternalSorting(updater);
});
return {
resolvedSorting,
handleSortingChange,
};
};