feat: add installed gateway management to skills table with dynamic updates
This commit is contained in:
committed by
Abhimanyu Saharan
parent
a7e1e5cbf4
commit
577c0d2839
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user