feat: add installed gateway management to skills table with dynamic updates

This commit is contained in:
Abhimanyu Saharan
2026-02-14 02:21:50 +05:30
committed by Abhimanyu Saharan
parent a7e1e5cbf4
commit 577c0d2839
2 changed files with 151 additions and 46 deletions

View File

@@ -76,6 +76,9 @@ export default function SkillsMarketplacePage() {
const [gatewayInstalledById, setGatewayInstalledById] = useState< const [gatewayInstalledById, setGatewayInstalledById] = useState<
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [installedGatewayNamesBySkillId, setInstalledGatewayNamesBySkillId] = useState<
Record<string, string[]>
>({});
const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false); const [isGatewayStatusLoading, setIsGatewayStatusLoading] = useState(false);
const [gatewayStatusError, setGatewayStatusError] = useState<string | null>(null); const [gatewayStatusError, setGatewayStatusError] = useState<string | null>(null);
const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(null); const [installingGatewayId, setInstallingGatewayId] = useState<string | null>(null);
@@ -156,6 +159,55 @@ export default function SkillsMarketplacePage() {
}); });
}, [selectedPack, skills]); }, [selectedPack, skills]);
useEffect(() => {
let cancelled = false;
const loadInstalledGatewaysBySkill = async () => {
if (!isSignedIn || !isAdmin || gateways.length === 0 || skills.length === 0) {
setInstalledGatewayNamesBySkillId({});
return;
}
try {
const responses = await Promise.all(
gateways.map(async (gateway) => {
const response = await listMarketplaceSkillsApiV1SkillsMarketplaceGet({
gateway_id: gateway.id,
});
return { gatewayName: gateway.name, response };
}),
);
if (cancelled) return;
const nextInstalledGatewayNamesBySkillId: Record<string, string[]> = {};
for (const skill of skills) {
nextInstalledGatewayNamesBySkillId[skill.id] = [];
}
for (const { gatewayName, response } of responses) {
if (response.status !== 200) continue;
for (const skill of response.data) {
if (!skill.installed) continue;
if (!nextInstalledGatewayNamesBySkillId[skill.id]) continue;
nextInstalledGatewayNamesBySkillId[skill.id].push(gatewayName);
}
}
setInstalledGatewayNamesBySkillId(nextInstalledGatewayNamesBySkillId);
} catch {
if (cancelled) return;
setInstalledGatewayNamesBySkillId({});
}
};
void loadInstalledGatewaysBySkill();
return () => {
cancelled = true;
};
}, [gateways, isAdmin, isSignedIn, skills]);
const installMutation = const installMutation =
useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost<ApiError>( useInstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdInstallPost<ApiError>(
{ {
@@ -168,6 +220,18 @@ export default function SkillsMarketplacePage() {
...previous, ...previous,
[variables.params.gateway_id]: true, [variables.params.gateway_id]: true,
})); }));
const gatewayName =
gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name;
if (gatewayName) {
setInstalledGatewayNamesBySkillId((previous) => {
const installedOn = previous[variables.skillId] ?? [];
if (installedOn.includes(gatewayName)) return previous;
return {
...previous,
[variables.skillId]: [...installedOn, gatewayName],
};
});
}
}, },
}, },
}, },
@@ -178,10 +242,25 @@ export default function SkillsMarketplacePage() {
useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost<ApiError>( useUninstallMarketplaceSkillApiV1SkillsMarketplaceSkillIdUninstallPost<ApiError>(
{ {
mutation: { mutation: {
onSuccess: async () => { onSuccess: async (_, variables) => {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["/api/v1/skills/marketplace"], queryKey: ["/api/v1/skills/marketplace"],
}); });
setGatewayInstalledById((previous) => ({
...previous,
[variables.params.gateway_id]: false,
}));
const gatewayName =
gateways.find((gateway) => gateway.id === variables.params.gateway_id)?.name;
if (gatewayName) {
setInstalledGatewayNamesBySkillId((previous) => {
const installedOn = previous[variables.skillId] ?? [];
return {
...previous,
[variables.skillId]: installedOn.filter((name) => name !== gatewayName),
};
});
}
}, },
}, },
}, },
@@ -247,14 +326,24 @@ export default function SkillsMarketplacePage() {
const isMutating = installMutation.isPending || uninstallMutation.isPending; const isMutating = installMutation.isPending || uninstallMutation.isPending;
const handleInstallToGateway = async (gatewayId: string) => { const handleGatewayInstallAction = async (
gatewayId: string,
isInstalled: boolean,
) => {
if (!selectedSkill) return; if (!selectedSkill) return;
setInstallingGatewayId(gatewayId); setInstallingGatewayId(gatewayId);
try { try {
await installMutation.mutateAsync({ if (isInstalled) {
skillId: selectedSkill.id, await uninstallMutation.mutateAsync({
params: { gateway_id: gatewayId }, skillId: selectedSkill.id,
}); params: { gateway_id: gatewayId },
});
} else {
await installMutation.mutateAsync({
skillId: selectedSkill.id,
params: { gateway_id: gatewayId },
});
}
} finally { } finally {
setInstallingGatewayId(null); setInstallingGatewayId(null);
} }
@@ -300,19 +389,13 @@ export default function SkillsMarketplacePage() {
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"> <div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<MarketplaceSkillsTable <MarketplaceSkillsTable
skills={visibleSkills} skills={visibleSkills}
installedGatewayNamesBySkillId={installedGatewayNamesBySkillId}
isLoading={skillsQuery.isLoading} isLoading={skillsQuery.isLoading}
sorting={sorting} sorting={sorting}
onSortingChange={onSortingChange} onSortingChange={onSortingChange}
stickyHeader stickyHeader
canInstallActions={Boolean(resolvedGatewayId)}
isMutating={isMutating} isMutating={isMutating}
onSkillClick={setSelectedSkill} onSkillClick={setSelectedSkill}
onUninstall={(skill) =>
uninstallMutation.mutate({
skillId: skill.id,
params: { gateway_id: resolvedGatewayId },
})
}
emptyState={{ emptyState={{
title: "No marketplace skills yet", title: "No marketplace skills yet",
description: "Add packs first, then synced skills will appear here.", description: "Add packs first, then synced skills will appear here.",
@@ -359,8 +442,9 @@ export default function SkillsMarketplacePage() {
) : ( ) : (
gateways.map((gateway) => { gateways.map((gateway) => {
const isInstalled = gatewayInstalledById[gateway.id] === true; const isInstalled = gatewayInstalledById[gateway.id] === true;
const isInstalling = const isUpdatingGateway =
installMutation.isPending && installingGatewayId === gateway.id; installingGatewayId === gateway.id &&
(installMutation.isPending || uninstallMutation.isPending);
return ( return (
<div <div
key={gateway.id} key={gateway.id}
@@ -368,17 +452,23 @@ export default function SkillsMarketplacePage() {
> >
<div> <div>
<p className="text-sm font-medium text-slate-900">{gateway.name}</p> <p className="text-sm font-medium text-slate-900">{gateway.name}</p>
<p className="text-xs text-slate-500">
{isInstalled ? "Installed" : "Not installed"}
</p>
</div> </div>
<Button <Button
type="button" type="button"
size="sm" size="sm"
onClick={() => void handleInstallToGateway(gateway.id)} variant={isInstalled ? "outline" : "primary"}
disabled={isInstalled || installMutation.isPending} onClick={() =>
void handleGatewayInstallAction(gateway.id, isInstalled)
}
disabled={installMutation.isPending || uninstallMutation.isPending}
> >
{isInstalled ? "Installed" : isInstalling ? "Installing..." : "Install"} {isInstalled
? isUpdatingGateway
? "Uninstalling..."
: "Uninstall"
: isUpdatingGateway
? "Installing..."
: "Install"}
</Button> </Button>
</div> </div>
); );
@@ -387,8 +477,8 @@ export default function SkillsMarketplacePage() {
{gatewayStatusError ? ( {gatewayStatusError ? (
<p className="text-sm text-rose-600">{gatewayStatusError}</p> <p className="text-sm text-rose-600">{gatewayStatusError}</p>
) : null} ) : null}
{installMutation.error ? ( {mutationError ? (
<p className="text-sm text-rose-600">{installMutation.error.message}</p> <p className="text-sm text-rose-600">{mutationError}</p>
) : null} ) : null}
</div> </div>

View File

@@ -19,15 +19,14 @@ import { truncateText as truncate } from "@/lib/formatters";
type MarketplaceSkillsTableProps = { type MarketplaceSkillsTableProps = {
skills: MarketplaceSkillCardRead[]; skills: MarketplaceSkillCardRead[];
installedGatewayNamesBySkillId?: Record<string, string[]>;
isLoading?: boolean; isLoading?: boolean;
sorting?: SortingState; sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>; onSortingChange?: OnChangeFn<SortingState>;
stickyHeader?: boolean; stickyHeader?: boolean;
disableSorting?: boolean; disableSorting?: boolean;
canInstallActions: boolean;
isMutating?: boolean; isMutating?: boolean;
onSkillClick?: (skill: MarketplaceSkillCardRead) => void; onSkillClick?: (skill: MarketplaceSkillCardRead) => void;
onUninstall: (skill: MarketplaceSkillCardRead) => void;
onDelete?: (skill: MarketplaceSkillCardRead) => void; onDelete?: (skill: MarketplaceSkillCardRead) => void;
getEditHref?: (skill: MarketplaceSkillCardRead) => string; getEditHref?: (skill: MarketplaceSkillCardRead) => string;
emptyState?: Omit<DataTableEmptyState, "icon"> & { emptyState?: Omit<DataTableEmptyState, "icon"> & {
@@ -88,15 +87,14 @@ const toPackDetailHref = (packUrl: string): string => {
export function MarketplaceSkillsTable({ export function MarketplaceSkillsTable({
skills, skills,
installedGatewayNamesBySkillId,
isLoading = false, isLoading = false,
sorting, sorting,
onSortingChange, onSortingChange,
stickyHeader = false, stickyHeader = false,
disableSorting = false, disableSorting = false,
canInstallActions,
isMutating = false, isMutating = false,
onSkillClick, onSkillClick,
onUninstall,
onDelete, onDelete,
getEditHref, getEditHref,
emptyState, emptyState,
@@ -129,7 +127,10 @@ export function MarketplaceSkillsTable({
) : ( ) : (
<p className="text-sm font-medium text-slate-900">{row.original.name}</p> <p className="text-sm font-medium text-slate-900">{row.original.name}</p>
)} )}
<p className="mt-1 line-clamp-2 text-xs text-slate-500"> <p
className="mt-1 line-clamp-2 text-xs text-slate-500"
title={row.original.description || "No description provided."}
>
{row.original.description || "No description provided."} {row.original.description || "No description provided."}
</p> </p>
</div> </div>
@@ -171,11 +172,37 @@ export function MarketplaceSkillsTable({
{ {
accessorKey: "source", accessorKey: "source",
header: "Source", header: "Source",
cell: ({ row }) => ( cell: ({ row }) => {
<span className="text-sm text-slate-700" title={row.original.source || ""}> const sourceHref = row.original.source || row.original.source_url;
{truncate(row.original.source || "unknown", 36)} return (
</span> <Link
), href={sourceHref}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-slate-700 hover:text-blue-600 hover:underline"
title={sourceHref}
>
{truncate(sourceHref, 36)}
</Link>
);
},
},
{
id: "installed_on",
header: "Installed On",
enableSorting: false,
cell: ({ row }) => {
const installedOn = installedGatewayNamesBySkillId?.[row.original.id] ?? [];
if (installedOn.length === 0) {
return <span className="text-sm text-slate-500">-</span>;
}
const installedOnText = installedOn.join(", ");
return (
<span className="text-sm text-slate-700" title={installedOnText}>
{installedOnText}
</span>
);
},
}, },
{ {
accessorKey: "updated_at", accessorKey: "updated_at",
@@ -188,17 +215,6 @@ export function MarketplaceSkillsTable({
enableSorting: false, enableSorting: false,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{row.original.installed ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onUninstall(row.original)}
disabled={isMutating || !canInstallActions}
>
Uninstall
</Button>
) : null}
{getEditHref ? ( {getEditHref ? (
<Link <Link
href={getEditHref(row.original)} href={getEditHref(row.original)}
@@ -225,12 +241,11 @@ export function MarketplaceSkillsTable({
return baseColumns; return baseColumns;
}, [ }, [
canInstallActions,
getEditHref, getEditHref,
installedGatewayNamesBySkillId,
isMutating, isMutating,
onDelete, onDelete,
onSkillClick, onSkillClick,
onUninstall,
]); ]);
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library