Skip to content

feat(web): bulk-remove multiple projects from the serve UI#469

Open
yustme wants to merge 3 commits into
mainfrom
feat/web-bulk-delete-projects
Open

feat(web): bulk-remove multiple projects from the serve UI#469
yustme wants to merge 3 commits into
mainfrom
feat/web-bulk-delete-projects

Conversation

@yustme

@yustme yustme commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What

Adds multi-project removal to the kbagent serve Web UI. The Projects table gets per-row checkboxes plus a select-all header (with an indeterminate state), and a Remove from kbagent action that unregisters several projects in one go.

A styled confirmation modal lists the affected aliases and makes the scope explicit: this only edits the local kbagent config — it does NOT delete the Keboola projects. The existing single-row trash button now flows through the same modal + endpoint.

Changes

  • Backend serviceProjectService.bulk_remove_projects(aliases, dry_run): removes each alias, accumulating per-alias errors so one bad alias (missing / ephemeral __env__) never blocks the rest. Local config.json only, no remote API call. Returns {removed, failed, dry_run}.
  • REST endpointPOST /projects/bulk-delete (body {aliases, dry_run}), declared before the /{alias} routes so the literal path is matched first (never resolves to remove_project("bulk-delete")).
  • FrontendDataTable gains optional checkbox selection (per-row + select-all/indeterminate); Projects.tsx adds selection state, the bulk action, and a reusable ConfirmModal (matches the ManageTokenModal style) that lists aliases and spells out the local-only scope.
  • Tests — service (partial failure, dry-run, dedup) + router call-parity (kwargs, dry_run forwarding, route-not-shadowed-by-/{alias}).

UX

Remove from kbagent button appears only when ≥1 project is selected. The confirm modal:

  • title Remove N projects from kbagent
  • body: "This only unregisters … locally (edits the kbagent config). It does not delete the Keboola projects."
  • lists the selected aliases, red Remove from kbagent confirm + Cancel.

Verified visually in a running serve --ui.

Tests / checks

  • 4190 passed, 135 skipped; ruff lint+format, ty (warnings only), skill-check, changelog-check, command-sync, error-codes all green. Frontend tsc -b + vite build clean.

Notes

  • Bumps to 0.66.0. This shares the 0.66.0 slot with the open create-table-from-source PR (feat(storage): create-table from a source table + BigQuery partition/clustering #468); whichever merges second will need a one-line version rebase (pyproject + changelog key + make version-sync).
  • A separate, stacked feature (convert-to-partitioned in the table-detail modal) lives on feat/web-convert-to-partitioned and is not part of this PR.

yustme added 2 commits June 29, 2026 18:45
Add checkbox selection to the Projects table (per-row + select-all with an
indeterminate state) and a "Remove from kbagent" action that removes several
projects at once. Replaces the native window.confirm() with a styled
ConfirmModal that lists the affected aliases and spells out that this only
unregisters them locally (edits kbagent config) and does NOT delete the
Keboola projects.

Backend: ProjectService.bulk_remove_projects (per-alias error accumulation +
dry_run, local config.json only) and POST /projects/bulk-delete, declared
before the /{alias} routes so the literal path is matched first. The single
trash button now flows through the same modal + endpoint.

Tests: service (partial failure, dry-run, dedup) + router (kwargs, dry_run
forwarding, route-not-shadowed).
@devin-ai-integration

Copy link
Copy Markdown

Review

Overall well-structured PR — clean separation between service/router/frontend, good test coverage, and the ConfirmModal is a nice reusable component that matches the existing ManageTokenModal style. A few things worth addressing below, one of which is a correctness bug in the dry-run path.

Summary

Severity Count
Bug (should fix) 1
Important 2
Nit 2

See inline comments for details.

# Validate existence (and the ephemeral guard) without
# mutating: a missing alias has no persisted project.
if self._config_store.get_project(alias) is None:
raise ConfigError(f"Project '{alias}' not found.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The dry-run path only checks get_project(alias) is None but skips the ephemeral (__env__) guard. The real removal path goes through config_store.remove_project() which calls _reject_ephemeral_mutation(config, alias, "removed") — so an __env__ project would show up in the removed list during dry-run but would actually fail with a ConfigError on real execution.

To match the live path's validation, you'd need something like:

if dry_run:
    config = self._config_store.load()
    project = config.projects.get(alias)
    if project is None:
        raise ConfigError(f"Project '{alias}' not found.")
    if project.ephemeral:
        raise ConfigError(
            f"Project '{alias}' is synthesized from environment variables "
            "and cannot be removed."
        )
    removed.append(alias)

Or factor the validation out of config_store.remove_project into a reusable check so both paths stay in sync.

alert(`Removed ${res.removed.length}; ${res.failed.length} failed:\n${lines}`);
}
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: No onError handler on the mutation. If the POST request itself fails (network error, 500, etc.), the user gets no feedback — the modal just disappears via onSettled and nothing happens. The partial-failure handling in onSuccess is good, but a total request failure is silently swallowed.

Consider adding:

onError: (err) => {
  alert(err instanceof ApiError ? err.message : (err as Error).message);
},

Or better, show an inline error instead of alert() (see nit below).

api.post<BulkDeleteResult>("/projects/bulk-delete", { aliases }),
onSuccess: (res) => {
setSelectedAliases(new Set());
qc.invalidateQueries({ queryKey: ["projects"] });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important: After a successful bulk-delete, the selected detail panel (below the table) could still show a project that was just removed. The selectedAliases set is cleared, but selected (the Project | null state for the detail pane) is not.

Consider clearing it if the selected project was among those removed:

onSuccess: (res) => {
  setSelectedAliases(new Set());
  if (selected && res.removed.includes(selected.alias)) {
    setSelected(null);
  }
  qc.invalidateQueries({ queryKey: ["projects"] });
  // ...
},

Comment thread web/frontend/src/pages/Projects.tsx Outdated
qc.invalidateQueries({ queryKey: ["projects"] });
if (res.failed.length > 0) {
const lines = res.failed.map((f) => `• ${f.alias}: ${f.error}`).join("\n");
alert(`Removed ${res.removed.length}; ${res.failed.length} failed:\n${lines}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: alert() feels out of place in an app that otherwise uses styled modals and <ErrorBox>. Consider showing partial failures in an inline error banner or reusing the ConfirmModal pattern as a result dialog. Not blocking, but it's a jarring UX switch.

const confirmRef = useRef<HTMLButtonElement>(null);

useEffect(() => {
confirmRef.current?.focus();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The confirm button auto-focuses on mount, so pressing Enter immediately confirms the destructive action. For a danger modal this is a deliberate trade-off (speed vs. safety). If you want the safer default, consider focusing the Cancel button instead, or requiring an explicit click for danger actions. Current behavior matches what the PR description says, so this is just a "consider" note, not blocking.

- Service: dry-run now applies the same validation as the live remove via a new
  ConfigStore.ensure_removable() — an ephemeral `__env__` project is reported in
  `failed` (not `removed`), matching the real run that would ConfigError. Adds a
  regression test.
- Frontend: bulk-delete mutation gains an onError handler so a total request
  failure (network/5xx) surfaces instead of silently closing the modal; partial
  and total failures now show in an inline, dismissible ErrorBox banner instead
  of a native alert(). After a successful remove, the detail pane is cleared if
  it was showing a just-removed project.
- ConfirmModal: danger modals focus Cancel on open (a stray Enter no longer
  fires the destructive action); non-danger modals keep Confirm focused.
@yustme

yustme commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review — addressed in f69d4e0:

  • Bug (dry-run skipped ephemeral guard) — fixed. Added ConfigStore.ensure_removable(alias) (missing + ephemeral checks, reusing _reject_ephemeral_mutation); the bulk dry-run now routes through it, so an __env__ project lands in failed instead of removed, matching the live remove. Regression test added (test_dry_run_rejects_ephemeral_env_project).
  • No onError handler — fixed. The bulk-delete mutation now has onError; a total request failure (network/5xx) is surfaced instead of the modal silently closing.
  • Stale detail pane — fixed. On a successful remove, the selected detail panel is cleared if it was showing a just-removed project.
  • alert() is jarring (nit) — addressed. Partial and total failures now render in an inline, dismissible ErrorBox banner above the table (no more alert()).
  • Auto-focus on confirm for danger (nit) — addressed. ConfirmModal now focuses Cancel for danger modals so a stray Enter can't fire the destructive action; non-danger modals keep Confirm focused.

All green: affected pytest (115 passed) + ruff/ty clean; frontend tsc -b + vite build clean.

@devin-ai-integration

Copy link
Copy Markdown

Re-review (post f69d4e0)

All five items from the first review are properly addressed:

# Original finding Status
1 Bug: dry-run skipped ephemeral guard FixedConfigStore.ensure_removable() factors out the existence + ephemeral check; bulk dry-run routes through it. Regression test (test_dry_run_rejects_ephemeral_env_project) covers it.
2 Important: missing onError on bulkDeleteMu FixedonError now surfaces network/5xx failures via the inline ErrorBox banner.
3 Important: stale detail pane after delete FixedonSuccess clears selected when the displayed project was among res.removed.
4 Nit: alert() inconsistent with styled UI Fixed — replaced with a dismissible ErrorBox banner above the table.
5 Nit: auto-focus on confirm for danger modal FixedConfirmModal now focuses Cancel for danger modals, Confirm for non-danger. Clean conditional via (danger ? cancelRef : confirmRef).current?.focus().

Quality of the fixes

  • ensure_removable mirrors remove_project's validation exactly (existence check → _reject_ephemeral_mutation) without duplicating the mutation logic — good factoring. The docstrings clearly state the intent (read-only, shared by dry-run and live paths).
  • The selection-sync useEffect that prunes selectedAliases against the live project list is a nice defensive addition — handles the edge case where a project disappears from another tab/session.
  • Test coverage is solid: 6 service tests + 3 router tests, including the ephemeral regression case.

No new issues found. LGTM.

@yustme yustme requested a review from padak June 29, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant