diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e1275b39..47d2dc2b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.65.1", + "version": "0.66.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 6d7eb53b..45549029 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.65.1", + "version": "0.66.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/pyproject.toml b/pyproject.toml index 4db6e17b..5117765e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.65.1" +version = "0.66.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 1eb76afd..c8984b3e 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,15 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.66.0": [ + "New: bulk-remove projects from the `kbagent serve` Web UI. The Projects table now has " + "per-row checkboxes plus a select-all header, and a `Remove from kbagent` action that " + "unregisters several projects at once. A styled confirmation modal lists the affected " + "aliases and makes clear this only edits the local kbagent config -- it does NOT delete " + "the Keboola projects. Backed by a new `POST /projects/bulk-delete` REST endpoint " + "(`ProjectService.bulk_remove_projects`) with per-alias error accumulation and a " + "`dry_run` mode; one bad alias never blocks the rest.", + ], "0.65.1": [ "BREAKING: Removed `data-app git-bind-credential` (and its `kbagent serve` endpoint). It shipped in " "0.65.0 on a misdiagnosis: managed-repo deploys were failing and we believed the platform " diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py index a867075e..8ecbd6c8 100644 --- a/src/keboola_agent_cli/config_store.py +++ b/src/keboola_agent_cli/config_store.py @@ -394,6 +394,22 @@ def add_project(self, alias: str, project: ProjectConfig) -> None: config.default_project = alias self.save(config) + def ensure_removable(self, alias: str) -> None: + """Validate that ``alias`` can be removed, without mutating anything. + + Raises the same errors a real ``remove_project`` would: ``ConfigError`` + if the alias does not exist, or if it is an ephemeral ``__env__`` + project synthesized from the environment. Shared by the live remove and + the bulk dry-run preview so both paths stay in sync (read-only). + + Raises: + ConfigError: If the alias is missing or ephemeral. + """ + config = self.load() + if alias not in config.projects: + raise ConfigError(f"Project '{alias}' not found.") + self._reject_ephemeral_mutation(config, alias, "removed") + def remove_project(self, alias: str) -> None: """Remove a project from the configuration. @@ -403,7 +419,8 @@ def remove_project(self, alias: str) -> None: alias: The project alias to remove. Raises: - ConfigError: If the alias does not exist. + ConfigError: If the alias does not exist, or is an ephemeral + ``__env__`` project. """ config = self.load() if alias not in config.projects: diff --git a/src/keboola_agent_cli/server/routers/projects.py b/src/keboola_agent_cli/server/routers/projects.py index c3b02651..154b90ab 100644 --- a/src/keboola_agent_cli/server/routers/projects.py +++ b/src/keboola_agent_cli/server/routers/projects.py @@ -29,6 +29,11 @@ class ProjectDescription(BaseModel): description: str +class ProjectBulkDelete(BaseModel): + aliases: list[str] + dry_run: bool = False + + @router.get("", summary="List registered projects") def list_projects(registry: ServiceRegistry = Depends(get_registry)) -> dict[str, Any]: """All registered project aliases.""" @@ -43,6 +48,19 @@ def add_project( return registry.project.add_project(body.alias, body.stack_url, body.token) +@router.post("/bulk-delete", summary="Remove multiple projects") +def bulk_delete_projects( + body: ProjectBulkDelete, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """Remove several projects at once, accumulating per-alias errors. + + Returns ``{removed, failed, dry_run}``; one bad alias does not block the + rest. Mirrors `kbagent project remove` applied to a list. Declared before + the ``/{alias}`` routes so the literal path is matched first. + """ + return registry.project.bulk_remove_projects(aliases=body.aliases, dry_run=body.dry_run) + + @router.delete("/{alias}", summary="Remove a project") def remove_project(alias: str, registry: ServiceRegistry = Depends(get_registry)) -> dict[str, Any]: """Remove a project by alias. Mirrors `kbagent project remove`.""" diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index 48353079..277f2b6b 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -97,6 +97,56 @@ def remove_project(self, alias: str) -> dict[str, str]: self._config_store.remove_project(alias) return {"alias": alias, "message": f"Project '{alias}' removed."} + def bulk_remove_projects( + self, + aliases: list[str], + dry_run: bool = False, + ) -> dict[str, Any]: + """Remove several projects in one call, accumulating per-alias errors. + + One failing alias (e.g. it does not exist, or is an ephemeral + ``__env__`` project that cannot be removed) does not stop the others -- + the failure is recorded under ``failed`` and the rest proceed. Like the + single-remove path this only edits ``config.json`` locally; no remote + API call is made. + + Args: + aliases: Project aliases to remove. Duplicates are de-duplicated + while preserving order. + dry_run: When True, validate each alias and report what WOULD be + removed without mutating ``config.json``. + + Returns: + ``{"removed": [...], "failed": [{"alias", "error"}], "dry_run": bool}`` + where ``removed`` lists the aliases removed (or that would be + removed in dry-run mode). + """ + seen: set[str] = set() + ordered: list[str] = [] + for alias in aliases: + if alias not in seen: + seen.add(alias) + ordered.append(alias) + + removed: list[str] = [] + failed: list[dict[str, str]] = [] + for alias in ordered: + try: + if dry_run: + # Apply the SAME validation as the live remove (missing + # alias + ephemeral `__env__` guard) without mutating, so a + # dry-run never reports an alias as removable that the real + # run would reject. + self._config_store.ensure_removable(alias) + removed.append(alias) + else: + self._config_store.remove_project(alias) + removed.append(alias) + except ConfigError as exc: + failed.append({"alias": alias, "error": exc.message}) + + return {"removed": removed, "failed": failed, "dry_run": dry_run} + def edit_project( self, alias: str, diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 31c44745..6876bbf3 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -852,3 +852,73 @@ def test_data_app_runs_endpoint_calls_service(tmp_path: Path) -> None: assert res.status_code == 200, res.text data_app_svc.list_app_runs.assert_called_once_with(PROJECT, APP_ID, limit=3) + + +# --------------------------------------------------------------------------- +# projects.py POST /projects/bulk-delete +# Service: project.bulk_remove_projects(aliases=..., dry_run=...) +# --------------------------------------------------------------------------- + + +def test_bulk_delete_projects_passes_aliases(tmp_path: Path) -> None: + """POST /projects/bulk-delete must call ProjectService.bulk_remove_projects.""" + project_svc = MagicMock() + project_svc.bulk_remove_projects.return_value = { + "removed": ["a", "b"], + "failed": [], + "dry_run": False, + } + registry = _mock_registry(project=project_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.post( + "/projects/bulk-delete", + headers=AUTH, + json={"aliases": ["a", "b"]}, + ) + + assert res.status_code == 200, res.text + assert res.json()["removed"] == ["a", "b"] + project_svc.bulk_remove_projects.assert_called_once_with(aliases=["a", "b"], dry_run=False) + + +def test_bulk_delete_projects_forwards_dry_run(tmp_path: Path) -> None: + """The dry_run flag in the body must reach the service.""" + project_svc = MagicMock() + project_svc.bulk_remove_projects.return_value = { + "removed": ["a"], + "failed": [], + "dry_run": True, + } + registry = _mock_registry(project=project_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.post( + "/projects/bulk-delete", + headers=AUTH, + json={"aliases": ["a"], "dry_run": True}, + ) + + assert res.status_code == 200, res.text + project_svc.bulk_remove_projects.assert_called_once_with(aliases=["a"], dry_run=True) + + +def test_bulk_delete_route_not_shadowed_by_alias_delete(tmp_path: Path) -> None: + """The literal /projects/bulk-delete POST must not be swallowed by /{alias}. + + A DELETE /{alias} exists; the bulk route is a POST to a literal path, so it + must resolve to bulk_remove_projects, never remove_project('bulk-delete'). + """ + project_svc = MagicMock() + project_svc.bulk_remove_projects.return_value = {"removed": [], "failed": [], "dry_run": False} + registry = _mock_registry(project=project_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.post("/projects/bulk-delete", headers=AUTH, json={"aliases": []}) + + assert res.status_code == 200, res.text + project_svc.bulk_remove_projects.assert_called_once() + project_svc.remove_project.assert_not_called() diff --git a/tests/test_services.py b/tests/test_services.py index fb76c71e..72d6430f 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -183,6 +183,105 @@ def test_remove_nonexistent_raises_error(self, tmp_config_dir: Path) -> None: service.remove_project("nonexistent") +class TestBulkRemoveProjects: + """Tests for ProjectService.bulk_remove_projects().""" + + @staticmethod + def _service_with_projects(tmp_config_dir: Path, *aliases: str) -> ProjectService: + store = ConfigStore(config_dir=tmp_config_dir) + mock_client = make_mock_client() + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + for alias in aliases: + service.add_project( + alias=alias, + stack_url="https://connection.keboola.com", + token="901-55555-fakeTestTokenDoNotUseXXXXXXXX", + ) + return service + + def test_removes_all_requested(self, tmp_config_dir: Path) -> None: + service = self._service_with_projects(tmp_config_dir, "a", "b", "c") + + result = service.bulk_remove_projects(["a", "c"]) + + assert result["removed"] == ["a", "c"] + assert result["failed"] == [] + assert result["dry_run"] is False + assert service._config_store.get_project("a") is None + assert service._config_store.get_project("c") is None + # Untouched alias survives. + assert service._config_store.get_project("b") is not None + + def test_partial_failure_accumulates(self, tmp_config_dir: Path) -> None: + """A missing alias is recorded under failed; the rest still go.""" + service = self._service_with_projects(tmp_config_dir, "a", "b") + + result = service.bulk_remove_projects(["a", "ghost", "b"]) + + assert result["removed"] == ["a", "b"] + assert len(result["failed"]) == 1 + assert result["failed"][0]["alias"] == "ghost" + assert "not found" in result["failed"][0]["error"].lower() + assert service._config_store.get_project("a") is None + assert service._config_store.get_project("b") is None + + def test_dry_run_does_not_mutate(self, tmp_config_dir: Path) -> None: + service = self._service_with_projects(tmp_config_dir, "a", "b") + + result = service.bulk_remove_projects(["a", "b"], dry_run=True) + + assert result["dry_run"] is True + assert result["removed"] == ["a", "b"] + assert result["failed"] == [] + # Nothing actually removed. + assert service._config_store.get_project("a") is not None + assert service._config_store.get_project("b") is not None + + def test_dry_run_reports_missing_as_failed(self, tmp_config_dir: Path) -> None: + service = self._service_with_projects(tmp_config_dir, "a") + + result = service.bulk_remove_projects(["a", "ghost"], dry_run=True) + + assert result["removed"] == ["a"] + assert [f["alias"] for f in result["failed"]] == ["ghost"] + assert service._config_store.get_project("a") is not None + + def test_duplicate_aliases_deduplicated(self, tmp_config_dir: Path) -> None: + service = self._service_with_projects(tmp_config_dir, "a") + + result = service.bulk_remove_projects(["a", "a"]) + + assert result["removed"] == ["a"] + assert result["failed"] == [] + + def test_dry_run_rejects_ephemeral_env_project( + self, tmp_config_dir: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A dry-run must reject an ephemeral `__env__` project, matching the + live remove path (which raises ConfigError). Regression for the + dry-run-skipped-the-ephemeral-guard bug flagged in review.""" + monkeypatch.setenv("KBAGENT_PROJECT_FROM_ENV", "1") + monkeypatch.setenv("KBC_TOKEN", "901-99999-fakeHeadlessTokenDoNotUseXXXXX") + monkeypatch.setenv("KBC_STORAGE_API_URL", "https://connection.keboola.com") + store = ConfigStore(config_dir=tmp_config_dir) + service = ProjectService( + config_store=store, + client_factory=lambda _u, _t: make_mock_client(), + ) + # The env-synthesized project exists in memory... + assert store.get_project("__env__") is not None + + result = service.bulk_remove_projects(["__env__"], dry_run=True) + + # ...but must not be reported as removable. + assert result["removed"] == [] + assert [f["alias"] for f in result["failed"]] == ["__env__"] + assert "environment" in result["failed"][0]["error"].lower() + + class TestEditProject: """Tests for ProjectService.edit_project().""" diff --git a/uv.lock b/uv.lock index e5ec232b..9e5ad008 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.65.1" +version = "0.66.0" source = { editable = "." } dependencies = [ { name = "croniter" }, diff --git a/web/frontend/src/components/ConfirmModal.tsx b/web/frontend/src/components/ConfirmModal.tsx new file mode 100644 index 00000000..a3574366 --- /dev/null +++ b/web/frontend/src/components/ConfirmModal.tsx @@ -0,0 +1,114 @@ +import { AlertTriangle, X } from "lucide-react"; +import { useEffect, useRef } from "react"; +import type { ReactNode } from "react"; + +/** + * Lightweight confirmation dialog matching the app's modal style (see + * ManageTokenModal). Replaces the native ``window.confirm()`` for actions that + * deserve a clearer, on-brand prompt. Esc or a backdrop click cancels. On open + * we focus Cancel for ``danger`` modals (so a stray Enter does NOT fire the + * destructive action) and the confirm button otherwise. + */ +export function ConfirmModal({ + title, + body, + items, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + danger = false, + busy = false, + onConfirm, + onCancel, +}: { + title: string; + body?: ReactNode; + /** Optional list rendered in a scrollable box (e.g. affected aliases). */ + items?: string[]; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + busy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + const confirmRef = useRef(null); + const cancelRef = useRef(null); + + useEffect(() => { + // Danger modals focus Cancel so a stray Enter cannot fire the destructive + // action; non-danger modals focus Confirm for fast keyboard confirmation. + (danger ? cancelRef : confirmRef).current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !busy) onCancel(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [danger, busy, onCancel]); + + return ( +
{ + if (!busy) onCancel(); + }} + > +
e.stopPropagation()} + > +
+

+ {title} +

+ +
+ + {body ?
{body}
: null} + + {items && items.length > 0 ? ( +
    + {items.map((it) => ( +
  • + {it} +
  • + ))} +
+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/web/frontend/src/components/Table.tsx b/web/frontend/src/components/Table.tsx index caeb165d..398eba6f 100644 --- a/web/frontend/src/components/Table.tsx +++ b/web/frontend/src/components/Table.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ChangeEvent, ReactNode } from "react"; export interface Column { header: string; @@ -13,23 +13,53 @@ export function DataTable({ rowKey, onRowClick, emptyMessage = "No data", + selectedKeys, + onToggleRow, + onToggleAll, }: { rows: T[]; columns: Column[]; rowKey: (row: T) => string; onRowClick?: (row: T) => void; emptyMessage?: string; + /** + * When provided together with `onToggleRow`, a leading checkbox column is + * rendered and rows whose key is in this set show as checked. Toggling a + * checkbox does not trigger `onRowClick`. + */ + selectedKeys?: Set; + onToggleRow?: (key: string, checked: boolean) => void; + onToggleAll?: (checked: boolean) => void; }) { + const selectable = !!selectedKeys && !!onToggleRow; + if (rows.length === 0) { return (
{emptyMessage}
); } + + const allSelected = selectable && rows.every((r) => selectedKeys!.has(rowKey(r))); + const someSelected = selectable && !allSelected && rows.some((r) => selectedKeys!.has(rowKey(r))); + return (
+ {selectable && ( + + )} {columns.map((col, i) => ( - {rows.map((row) => ( - onRowClick(row) : undefined} - className={`border-b border-zinc-100 dark:border-zinc-900/50 ${ - onRowClick ? "cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-900/40" : "" - }`} - > - {columns.map((col, i) => ( - - ))} - - ))} + {rows.map((row) => { + const key = rowKey(row); + return ( + onRowClick(row) : undefined} + className={`border-b border-zinc-100 dark:border-zinc-900/50 ${ + onRowClick ? "cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-900/40" : "" + }`} + > + {selectable && ( + + )} + {columns.map((col, i) => ( + + ))} + + ); + })}
+ { + if (el) el.indeterminate = someSelected; + }} + onChange={(e: ChangeEvent) => onToggleAll?.(e.target.checked)} + /> + ({
- {col.cell(row)} -
+ e.stopPropagation()} + onChange={(e: ChangeEvent) => + onToggleRow!(key, e.target.checked) + } + /> + + {col.cell(row)} +
diff --git a/web/frontend/src/pages/Projects.tsx b/web/frontend/src/pages/Projects.tsx index ad6f16b1..28c25f27 100644 --- a/web/frontend/src/pages/Projects.tsx +++ b/web/frontend/src/pages/Projects.tsx @@ -2,15 +2,27 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, Plus, RefreshCw, Trash2, XCircle } from "lucide-react"; import { useEffect, useState } from "react"; import { ApiError, api } from "../api/client"; +import { ConfirmModal } from "../components/ConfirmModal"; import { Empty, ErrorBox, Loading, PageTitle } from "../components/Empty"; import { JsonView } from "../components/JsonView"; import { DataTable } from "../components/Table"; import type { Project, ProjectStatus } from "../types"; +interface BulkDeleteResult { + removed: string[]; + failed: { alias: string; error: string }[]; + dry_run: boolean; +} + export function ProjectsPage() { const qc = useQueryClient(); const [showAdd, setShowAdd] = useState(false); const [selected, setSelected] = useState(null); + const [selectedAliases, setSelectedAliases] = useState>(new Set()); + // Aliases pending a remove-confirmation (single trash button or bulk action). + const [confirmAliases, setConfirmAliases] = useState(null); + // Inline banner for remove failures (partial or total request error). + const [removeNotice, setRemoveNotice] = useState(null); const projectsQ = useQuery<{ projects: Project[] }>({ queryKey: ["projects"], @@ -29,16 +41,71 @@ export function ProjectsPage() { if (statusQ.data) qc.invalidateQueries({ queryKey: ["projects"] }); }, [statusQ.data, qc]); - const removeMu = useMutation({ - mutationFn: (alias: string) => api.delete(`/projects/${encodeURIComponent(alias)}`), - onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), - }); - const useMu = useMutation({ mutationFn: (alias: string) => api.post(`/projects/use/${encodeURIComponent(alias)}`), onSuccess: () => qc.invalidateQueries({ queryKey: ["projects"] }), }); + const bulkDeleteMu = useMutation({ + mutationFn: (aliases: string[]) => + api.post("/projects/bulk-delete", { aliases }), + onSuccess: (res) => { + setSelectedAliases(new Set()); + // The detail pane may be showing a project that was just removed. + if (selected && res.removed.includes(selected.alias)) setSelected(null); + qc.invalidateQueries({ queryKey: ["projects"] }); + if (res.failed.length > 0) { + const lines = res.failed.map((f) => `${f.alias} (${f.error})`).join(", "); + setRemoveNotice( + `Removed ${res.removed.length}; ${res.failed.length} failed: ${lines}`, + ); + } else { + setRemoveNotice(null); + } + }, + onError: (err) => { + // A total request failure (network / 5xx) would otherwise be silent -- + // the modal closes via onSettled with no feedback. + setRemoveNotice(err instanceof ApiError ? err.message : (err as Error).message); + }, + }); + + const projects = projectsQ.data?.projects ?? []; + + // Keep the selection in sync with the live project list: drop any alias that + // no longer exists (e.g. removed in another tab) so stale keys never linger. + useEffect(() => { + setSelectedAliases((prev) => { + const live = new Set(projects.map((p) => p.alias)); + const next = new Set([...prev].filter((a) => live.has(a))); + return next.size === prev.size ? prev : next; + }); + }, [projects]); + + const toggleRow = (alias: string, checked: boolean) => + setSelectedAliases((prev) => { + const next = new Set(prev); + if (checked) next.add(alias); + else next.delete(alias); + return next; + }); + + const toggleAll = (checked: boolean) => + setSelectedAliases(checked ? new Set(projects.map((p) => p.alias)) : new Set()); + + const requestBulkRemove = () => { + if (selectedAliases.size === 0) return; + setConfirmAliases([...selectedAliases]); + }; + + const confirmRemove = () => { + if (!confirmAliases) return; + setRemoveNotice(null); + bulkDeleteMu.mutate(confirmAliases, { + onSettled: () => setConfirmAliases(null), + }); + }; + const statusByAlias = new Map(statusQ.data?.status?.map((s) => [s.alias, s]) ?? []); return ( @@ -48,6 +115,17 @@ export function ProjectsPage() { description="Keboola projects registered in this kbagent config." actions={ <> + {selectedAliases.size > 0 ? ( + + ) : null} + + ) : null} + {showAdd ? ( { @@ -85,9 +176,12 @@ export function ProjectsPage() { /> ) : ( p.alias} onRowClick={(p) => setSelected(p)} + selectedKeys={selectedAliases} + onToggleRow={toggleRow} + onToggleAll={toggleAll} columns={[ { header: "Alias", @@ -177,9 +271,7 @@ export function ProjectsPage() { className="nerd-btn text-xs hover:text-red-400 hover:border-red-700" onClick={(e) => { e.stopPropagation(); - if (confirm(`Remove project '${p.alias}' from kbagent?`)) { - removeMu.mutate(p.alias); - } + setConfirmAliases([p.alias]); }} > @@ -202,6 +294,30 @@ export function ProjectsPage() { ) : null} + + {confirmAliases ? ( + + This only unregisters{" "} + {confirmAliases.length === 1 ? "this project" : "these projects"} locally (edits the + kbagent config). It does not delete the Keboola{" "} + {confirmAliases.length === 1 ? "project" : "projects"}. + + } + items={confirmAliases} + confirmLabel="Remove from kbagent" + busy={bulkDeleteMu.isPending} + onConfirm={confirmRemove} + onCancel={() => setConfirmAliases(null)} + /> + ) : null} ); }