feat: add skill packs management with support for category, risk, and source fields
This commit is contained in:
committed by
Abhimanyu Saharan
parent
da6cc2544b
commit
10748f71a8
@@ -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,
|
||||
|
||||
105
frontend/src/components/skills/SkillInstallDialog.tsx
Normal file
105
frontend/src/components/skills/SkillInstallDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
49
frontend/src/components/skills/table-helpers.tsx
Normal file
49
frontend/src/components/skills/table-helpers.tsx
Normal 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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user