Home page: no UI to delete a workspace; cascade should remove its boards and notify open editors #85

Open
opened 2026-04-28 09:19:09 +00:00 by AhmedHanafy725 · 3 comments
Member

Bug

The boards home page does not expose any way to delete a workspace. Users can create workspaces inline (+ New workspace... in the filter dropdown and the New Board modal) but cannot remove one once it's no longer wanted, except by hopping over to the admin dashboard.

When a workspace is deleted, every board inside it should be deleted too. The DB schema already declares boards.workspace_id ... REFERENCES workspaces(id) ON DELETE CASCADE, so a hard DELETE FROM workspaces does cascade-remove the boards -- but without a board.deleted WebSocket broadcast for each cascaded board, any user already editing one of those boards keeps trying to sync. They eventually catch the failure via the rpc.js fallback we landed for issue #83, but they should be told immediately, the same way a single-board deletion already informs them.

Reproduction

  1. Open the home page.
  2. Open the workspace dropdown -- there is no Delete workspace option, no trash icon next to the selector, no menu, nothing.
  3. To delete a workspace today the user has to leave the home page, navigate to /index.html, find the workspace, and delete it from there. After that, related boards vanish from the home grid (cascade) but any open editor session on one of those boards stays interactive until its next sync errors.

Affected files (expected)

  • crates/hero_whiteboard_server/src/handlers/workspace.rs -- delete should return the cascaded board ids alongside the existing {deleted: N} shape so the UI proxy can broadcast.
  • crates/hero_whiteboard_server/src/db/queries.rs -- a small helper to list the live board ids belonging to the workspace, before the cascade fires.
  • crates/hero_whiteboard_ui/src/routes.rs -- extend the existing rpc_proxy sniffer (today: method == "board.delete") to also handle method == "workspace.delete". After a successful response, broadcast {type: "board.deleted", board_id} for each id in result.board_ids.
  • crates/hero_whiteboard_ui/templates/web/home.html -- add a Delete affordance next to the workspace dropdown when a specific workspace is selected (not All Workspaces and not + New workspace...). Open a themed confirmation modal that names the workspace and warns the boards inside will also be deleted; on confirm call workspace.delete. After success, reset currentWorkspaceId = '', refresh the dropdown, refresh the board list. Match the styling of the existing delete-board-modal (red destructive button, inline error region, Enter/Escape handlers).

No SDK / openrpc edits required; the JSON-RPC error path is already in shape.

Expected behavior

  • A trash icon (or equivalent) appears next to the workspace dropdown on the home page when a real workspace is selected.
  • Clicking it opens a themed confirmation modal: Delete workspace "Sprint planning"? This will also delete all N boards inside it. This cannot be undone.
  • On confirm:
    • The server deletes the workspace; the FK cascade hard-deletes the workspace's boards.
    • The server returns {deleted: 1, board_ids: [...]} listing the cascaded board ids.
    • The UI proxy broadcasts board.deleted for each id so any open editor closes via the existing overlay flow.
    • The home page resets the filter to All Workspaces, clears the localStorage entry, refreshes the dropdown and the board list.
  • Cancel closes the modal and changes nothing.
  • RPC failure shows inline inside the modal (no alert); the modal stays open for retry.
  • Escape cancels; Enter confirms (matches the rest of the page's modals).

Acceptance criteria

  • A trash icon next to the workspace dropdown is visible only when a real workspace is selected; it is hidden for All Workspaces and never shown for the + New workspace... sentinel.
  • Clicking it opens a themed confirmation modal showing the workspace name and the number of boards that will be deleted (fetched from board.list).
  • Confirm calls workspace.delete; on success closes the modal, broadcasts board.deleted for every cascaded board id, resets the filter to All Workspaces, refreshes the dropdown and the board list.
  • If the workspace contains zero boards, the modal still works -- the message just omits the board count line.
  • workspace.delete returns {deleted: 1, board_ids: [...]} listing the boards that were cascade-deleted.
  • The UI's rpc_proxy sniffer broadcasts board.deleted for each cascaded id; existing single-board delete behavior is unchanged.
  • Any open editor on a cascaded board sees the Board deleted overlay (already wired in board.html / sync.js).
  • Cancel in the confirmation modal does nothing.
  • RPC failure shows inline inside the modal; modal stays open for retry.
  • Escape closes the modal; Enter confirms.
  • A regression test asserts that workspace.delete returns the cascaded board ids and that the boards no longer satisfy is_board_live.
  • cargo test --workspace passes; cargo clippy --workspace -- -D warnings clean; cargo fmt --all -- --check clean.

Notes

  • Capture the board ids in the server handler before issuing DELETE FROM workspaces -- once the cascade fires, the rows are gone and we can't enumerate them anymore.
  • Soft-deleted boards in the workspace are out of scope; only live boards matter for the broadcast since deleted boards have no active subscribers.
  • The boards are physically removed because of the FK cascade; this matches the user's expectation that "delete workspace" removes the boards. Soft-delete for workspaces themselves is out of scope for this issue.
  • Match the destructive-action styling of delete-board-modal (red Delete button, var(--wb-error)).
  • The Default Workspace (id 1) is auto-created by the server when missing; do not block deletion of id 1 -- the next board.create will recreate it. If that turns out to be confusing UX, a separate issue can add a guard.
## Bug The boards home page does not expose any way to delete a workspace. Users can create workspaces inline (`+ New workspace...` in the filter dropdown and the New Board modal) but cannot remove one once it's no longer wanted, except by hopping over to the admin dashboard. When a workspace is deleted, every board inside it should be deleted too. The DB schema already declares `boards.workspace_id ... REFERENCES workspaces(id) ON DELETE CASCADE`, so a hard `DELETE FROM workspaces` does cascade-remove the boards -- but without a `board.deleted` WebSocket broadcast for each cascaded board, any user already editing one of those boards keeps trying to sync. They eventually catch the failure via the rpc.js fallback we landed for issue #83, but they should be told immediately, the same way a single-board deletion already informs them. ## Reproduction 1. Open the home page. 2. Open the workspace dropdown -- there is no `Delete workspace` option, no trash icon next to the selector, no menu, nothing. 3. To delete a workspace today the user has to leave the home page, navigate to `/index.html`, find the workspace, and delete it from there. After that, related boards vanish from the home grid (cascade) but any open editor session on one of those boards stays interactive until its next sync errors. ## Affected files (expected) - `crates/hero_whiteboard_server/src/handlers/workspace.rs` -- `delete` should return the cascaded board ids alongside the existing `{deleted: N}` shape so the UI proxy can broadcast. - `crates/hero_whiteboard_server/src/db/queries.rs` -- a small helper to list the live board ids belonging to the workspace, before the cascade fires. - `crates/hero_whiteboard_ui/src/routes.rs` -- extend the existing `rpc_proxy` sniffer (today: `method == "board.delete"`) to also handle `method == "workspace.delete"`. After a successful response, broadcast `{type: "board.deleted", board_id}` for each id in `result.board_ids`. - `crates/hero_whiteboard_ui/templates/web/home.html` -- add a Delete affordance next to the workspace dropdown when a specific workspace is selected (not `All Workspaces` and not `+ New workspace...`). Open a themed confirmation modal that names the workspace and warns the boards inside will also be deleted; on confirm call `workspace.delete`. After success, reset `currentWorkspaceId = ''`, refresh the dropdown, refresh the board list. Match the styling of the existing `delete-board-modal` (red destructive button, inline error region, Enter/Escape handlers). No SDK / openrpc edits required; the JSON-RPC error path is already in shape. ## Expected behavior - A trash icon (or equivalent) appears next to the workspace dropdown on the home page when a real workspace is selected. - Clicking it opens a themed confirmation modal: `Delete workspace "Sprint planning"? This will also delete all N boards inside it. This cannot be undone.` - On confirm: - The server deletes the workspace; the FK cascade hard-deletes the workspace's boards. - The server returns `{deleted: 1, board_ids: [...]}` listing the cascaded board ids. - The UI proxy broadcasts `board.deleted` for each id so any open editor closes via the existing overlay flow. - The home page resets the filter to `All Workspaces`, clears the localStorage entry, refreshes the dropdown and the board list. - Cancel closes the modal and changes nothing. - RPC failure shows inline inside the modal (no `alert`); the modal stays open for retry. - Escape cancels; Enter confirms (matches the rest of the page's modals). ## Acceptance criteria - [ ] A trash icon next to the workspace dropdown is visible only when a real workspace is selected; it is hidden for `All Workspaces` and never shown for the `+ New workspace...` sentinel. - [ ] Clicking it opens a themed confirmation modal showing the workspace name and the number of boards that will be deleted (fetched from `board.list`). - [ ] Confirm calls `workspace.delete`; on success closes the modal, broadcasts `board.deleted` for every cascaded board id, resets the filter to `All Workspaces`, refreshes the dropdown and the board list. - [ ] If the workspace contains zero boards, the modal still works -- the message just omits the board count line. - [ ] `workspace.delete` returns `{deleted: 1, board_ids: [...]}` listing the boards that were cascade-deleted. - [ ] The UI's `rpc_proxy` sniffer broadcasts `board.deleted` for each cascaded id; existing single-board delete behavior is unchanged. - [ ] Any open editor on a cascaded board sees the `Board deleted` overlay (already wired in board.html / sync.js). - [ ] Cancel in the confirmation modal does nothing. - [ ] RPC failure shows inline inside the modal; modal stays open for retry. - [ ] Escape closes the modal; Enter confirms. - [ ] A regression test asserts that `workspace.delete` returns the cascaded board ids and that the boards no longer satisfy `is_board_live`. - [ ] `cargo test --workspace` passes; `cargo clippy --workspace -- -D warnings` clean; `cargo fmt --all -- --check` clean. ## Notes - Capture the board ids in the server handler **before** issuing `DELETE FROM workspaces` -- once the cascade fires, the rows are gone and we can't enumerate them anymore. - Soft-deleted boards in the workspace are out of scope; only live boards matter for the broadcast since deleted boards have no active subscribers. - The boards are physically removed because of the FK cascade; this matches the user's expectation that "delete workspace" removes the boards. Soft-delete for workspaces themselves is out of scope for this issue. - Match the destructive-action styling of `delete-board-modal` (red Delete button, `var(--wb-error)`). - The Default Workspace (id 1) is auto-created by the server when missing; do **not** block deletion of id 1 -- the next `board.create` will recreate it. If that turns out to be confusing UX, a separate issue can add a guard.
Author
Member

Implementation Spec for Issue #85

Objective

Give the home page a workspace-delete affordance that hard-removes the workspace, lets the FK cascade tear down its boards, and broadcasts board.deleted to every open editor on those boards so they close out cleanly via the existing notice flow.

Requirements

  • A trash icon next to the workspace filter dropdown when a real workspace is selected (not All Workspaces and not + New workspace...).
  • Clicking it opens a themed confirmation modal showing the workspace name and the count of live boards inside it; Cancel + Delete buttons.
  • On confirm, calls workspace.delete. The server returns {deleted: 1, board_ids: [...]} listing the cascaded board ids.
  • The UI's rpc_proxy sniffer (which already handles board.delete) is extended to also handle workspace.delete and broadcast {type: "board.deleted", board_id} for each id in result.board_ids.
  • After success: reset currentWorkspaceId = '', clear the localStorage entry, refresh the dropdown and the board list.
  • RPC failure shows inline; modal stays open.
  • Escape cancels; Enter confirms.
  • Regression test: workspace.delete returns the cascaded board ids; subsequent is_board_live for any of them returns false.
  • cargo fmt --all -- --check clean (CI catches this).

Files to Modify

  • crates/hero_whiteboard_server/src/db/queries.rslive_board_ids_in_workspace(conn, workspace_id) -> Vec<u64> helper.
  • crates/hero_whiteboard_server/src/handlers/workspace.rs::delete — capture live board ids first, then delete the workspace; return both deleted and board_ids.
  • crates/hero_whiteboard_server/src/handlers/object.rs (test module) — extend with workspace_delete_returns_cascaded_board_ids.
  • crates/hero_whiteboard_ui/src/routes.rs::rpc_proxy — generalize the existing sniff/broadcast block so it also recognizes workspace.delete and broadcasts on each id in result.board_ids.
  • crates/hero_whiteboard_ui/templates/web/home.html — Delete-workspace button + confirmation modal + handlers; reset state after delete.

No SDK / openrpc edits.

Implementation Plan

Step 1: Server-side workspace.delete returns cascaded board ids + regression test

Files: crates/hero_whiteboard_server/src/db/queries.rs, crates/hero_whiteboard_server/src/handlers/workspace.rs, crates/hero_whiteboard_server/src/handlers/object.rs (tests).

  1. In db/queries.rs, add:
    /// All *live* board ids belonging to a workspace, in stable id order.
    pub fn live_board_ids_in_workspace(
        conn: &Connection,
        workspace_id: u64,
    ) -> rusqlite::Result<Vec<u64>> {
        let mut stmt = conn.prepare(
            "SELECT id FROM boards
             WHERE workspace_id = ?1 AND deleted_at IS NULL
             ORDER BY id",
        )?;
        let rows = stmt.query_map(params![workspace_id], |row| {
            Ok(row.get::<_, i64>(0)? as u64)
        })?;
        rows.collect()
    }
    
  2. In handlers/workspace.rs::delete, capture the ids before issuing the delete:
    pub fn delete(state: &AppState, params: serde_json::Value) -> anyhow::Result<serde_json::Value> {
        let id = params["id"].as_u64().ok_or_else(|| anyhow::anyhow!("missing 'id'"))?;
        let db = state.db.lock().unwrap();
        let board_ids = queries::live_board_ids_in_workspace(&db, id)?;
        let deleted = queries::delete_workspace(&db, id)?;
        Ok(serde_json::json!({
            "deleted": deleted,
            "board_ids": board_ids,
        }))
    }
    
    The cascade fires on DELETE FROM workspaces; we just snapshot the ids before letting it run so the proxy can broadcast.
  3. In handlers/object.rs::tests, add:
    #[test]
    fn workspace_delete_returns_cascaded_board_ids() {
        let state = make_state();
        let workspace_id = seed_workspace(&state);
        let b1 = handlers::board::create(&state, json!({"workspace_id": workspace_id, "name": "a"})).unwrap();
        let b2 = handlers::board::create(&state, json!({"workspace_id": workspace_id, "name": "b"})).unwrap();
        let b1_id = b1["id"].as_u64().unwrap();
        let b2_id = b2["id"].as_u64().unwrap();
    
        let resp = handlers::workspace::delete(&state, json!({"id": workspace_id})).unwrap();
        let ids: Vec<u64> = resp["board_ids"].as_array().unwrap().iter()
            .map(|v| v.as_u64().unwrap()).collect();
        assert!(ids.contains(&b1_id) && ids.contains(&b2_id));
    
        let db = state.db.lock().unwrap();
        assert!(!queries::is_board_live(&db, b1_id).unwrap());
        assert!(!queries::is_board_live(&db, b2_id).unwrap());
    }
    

Dependencies: none.

Step 2: rpc_proxy broadcasts board.deleted for each cascaded id

Files: crates/hero_whiteboard_ui/src/routes.rs.

  1. Today the proxy sniffs method == "board.delete" and broadcasts on success. Generalize:
    • Capture the request method + params once.
    • For "board.delete": behavior unchanged (single id from params.id, broadcast one event).
    • For "workspace.delete": after a 200 response, parse result.board_ids (an array of u64) and broadcast one board.deleted per id.
  2. Keep the parse-failure path silent so the proxy stays a strict pass-through for everything else.
  3. The existing BroadcastMsg { sender_id: 0, ... } sentinel is reused -- no changes to ws.rs are needed.

Recommended structure:

enum DeletedBroadcast { None, Single(u64), Many(Vec<u64>) }

let pending: DeletedBroadcast = match method_and_params {
    Some(("board.delete", id))      => DeletedBroadcast::Single(id),
    Some(("workspace.delete", _id)) => DeletedBroadcast::Many(vec![]), // resolved from response
    _                               => DeletedBroadcast::None,
};
// ... after response ...
let ids: Vec<u64> = match pending {
    DeletedBroadcast::Single(id) => vec![id],
    DeletedBroadcast::Many(_)    => parsed.get("result")
                                          .and_then(|r| r.get("board_ids"))
                                          .and_then(|v| v.as_array())
                                          .map(|a| a.iter().filter_map(|x| x.as_u64()).collect())
                                          .unwrap_or_default(),
    DeletedBroadcast::None       => Vec::new(),
};
for id in ids {
    let payload = serde_json::json!({"type":"board.deleted","board_id":id}).to_string();
    ws::broadcast_to_board(&state.channels, &id.to_string(), payload).await;
}

Dependencies: Step 1 (response shape).

Step 3: UI button + confirmation modal

Files: crates/hero_whiteboard_ui/templates/web/home.html.

  1. Add a small trash button next to the workspace filter <select>. Render only when currentWorkspaceId is a real id (not '' and not '__new__'). Wire onclick="openDeleteWorkspaceModal()".
  2. Add a delete-workspace-modal block matching the styling of delete-board-modal:
    • Header Delete Workspace.
    • Prompt paragraph populated dynamically: Delete workspace "<name>"? plus, when board count > 0, This will also delete <N> board(s) inside it..
    • Plain This cannot be undone. line.
    • Inline #delete-workspace-error region.
    • Cancel + Delete (red var(--wb-error)) buttons.
  3. JS:
    • openDeleteWorkspaceModal(): looks up the current workspace name from workspacesCache, calls rpcCall('board.list', { workspace_id: id }) to count, populates the prompt, opens the modal, clears the error region, focuses Delete.
    • closeDeleteWorkspaceModal(): hides the modal.
    • submitDeleteWorkspace(): calls rpcCall('workspace.delete', { id }). On success closes the modal, resets currentWorkspaceId = '', removes localStorage entry, calls loadWorkspaces() and loadBoards(). On error shows inline.
    • The board-id broadcast happens on the server proxy side; the local cleanup just refreshes the home view.
  4. Extend the keydown handler with the new modal so Enter triggers Delete and Escape cancels.
  5. The trash button visibility must update whenever currentWorkspaceId changes -- update it in loadWorkspaces, onWorkspaceChange, and after submitDeleteWorkspace succeeds.

Dependencies: Step 2 (so confirmed deletes propagate to open editors).

Acceptance Criteria

  • Trash icon next to the workspace dropdown is visible only when a real workspace is selected.
  • Clicking it opens a themed confirmation modal with the workspace name and the live-board count.
  • Confirm deletes the workspace; the server returns {deleted, board_ids}; the UI proxy broadcasts board.deleted for each id; the home page resets to All Workspaces and refreshes.
  • If the workspace contains zero boards, the modal still works without the count line.
  • Cancel changes nothing.
  • RPC failure shows inline; modal stays open for retry.
  • Escape cancels; Enter confirms.
  • Open editors on cascaded boards see the existing Board deleted overlay.
  • Regression test passes (workspace_delete_returns_cascaded_board_ids).
  • cargo test --workspace, cargo clippy --workspace -- -D warnings, cargo fmt --all -- --check all pass.

Notes

  • Capture the live board ids in the server handler before the DELETE FROM workspaces runs; the FK cascade hard-deletes the rows, so we can't enumerate them after the fact.
  • Reuse the destructive styling from delete-board-modal (red Delete button on var(--wb-error), #fff text).
  • Don't add a soft-delete column to workspaces in this issue; that's a bigger change worth its own discussion.
  • The Default Workspace (id 1) is auto-recreated on the next board.create. Allow it to be deleted; do not special-case it.
## Implementation Spec for Issue #85 ### Objective Give the home page a workspace-delete affordance that hard-removes the workspace, lets the FK cascade tear down its boards, and broadcasts `board.deleted` to every open editor on those boards so they close out cleanly via the existing notice flow. ### Requirements - A trash icon next to the workspace filter dropdown when a real workspace is selected (not `All Workspaces` and not `+ New workspace...`). - Clicking it opens a themed confirmation modal showing the workspace name and the count of live boards inside it; Cancel + Delete buttons. - On confirm, calls `workspace.delete`. The server returns `{deleted: 1, board_ids: [...]}` listing the cascaded board ids. - The UI's `rpc_proxy` sniffer (which already handles `board.delete`) is extended to also handle `workspace.delete` and broadcast `{type: "board.deleted", board_id}` for each id in `result.board_ids`. - After success: reset `currentWorkspaceId = ''`, clear the localStorage entry, refresh the dropdown and the board list. - RPC failure shows inline; modal stays open. - Escape cancels; Enter confirms. - Regression test: `workspace.delete` returns the cascaded board ids; subsequent `is_board_live` for any of them returns `false`. - `cargo fmt --all -- --check` clean (CI catches this). ### Files to Modify - `crates/hero_whiteboard_server/src/db/queries.rs` — `live_board_ids_in_workspace(conn, workspace_id) -> Vec<u64>` helper. - `crates/hero_whiteboard_server/src/handlers/workspace.rs::delete` — capture live board ids first, then delete the workspace; return both `deleted` and `board_ids`. - `crates/hero_whiteboard_server/src/handlers/object.rs` (test module) — extend with `workspace_delete_returns_cascaded_board_ids`. - `crates/hero_whiteboard_ui/src/routes.rs::rpc_proxy` — generalize the existing sniff/broadcast block so it also recognizes `workspace.delete` and broadcasts on each id in `result.board_ids`. - `crates/hero_whiteboard_ui/templates/web/home.html` — Delete-workspace button + confirmation modal + handlers; reset state after delete. No SDK / openrpc edits. ### Implementation Plan #### Step 1: Server-side `workspace.delete` returns cascaded board ids + regression test Files: `crates/hero_whiteboard_server/src/db/queries.rs`, `crates/hero_whiteboard_server/src/handlers/workspace.rs`, `crates/hero_whiteboard_server/src/handlers/object.rs` (tests). 1. In `db/queries.rs`, add: ```rust /// All *live* board ids belonging to a workspace, in stable id order. pub fn live_board_ids_in_workspace( conn: &Connection, workspace_id: u64, ) -> rusqlite::Result<Vec<u64>> { let mut stmt = conn.prepare( "SELECT id FROM boards WHERE workspace_id = ?1 AND deleted_at IS NULL ORDER BY id", )?; let rows = stmt.query_map(params![workspace_id], |row| { Ok(row.get::<_, i64>(0)? as u64) })?; rows.collect() } ``` 2. In `handlers/workspace.rs::delete`, capture the ids before issuing the delete: ```rust pub fn delete(state: &AppState, params: serde_json::Value) -> anyhow::Result<serde_json::Value> { let id = params["id"].as_u64().ok_or_else(|| anyhow::anyhow!("missing 'id'"))?; let db = state.db.lock().unwrap(); let board_ids = queries::live_board_ids_in_workspace(&db, id)?; let deleted = queries::delete_workspace(&db, id)?; Ok(serde_json::json!({ "deleted": deleted, "board_ids": board_ids, })) } ``` The cascade fires on `DELETE FROM workspaces`; we just snapshot the ids before letting it run so the proxy can broadcast. 3. In `handlers/object.rs::tests`, add: ```rust #[test] fn workspace_delete_returns_cascaded_board_ids() { let state = make_state(); let workspace_id = seed_workspace(&state); let b1 = handlers::board::create(&state, json!({"workspace_id": workspace_id, "name": "a"})).unwrap(); let b2 = handlers::board::create(&state, json!({"workspace_id": workspace_id, "name": "b"})).unwrap(); let b1_id = b1["id"].as_u64().unwrap(); let b2_id = b2["id"].as_u64().unwrap(); let resp = handlers::workspace::delete(&state, json!({"id": workspace_id})).unwrap(); let ids: Vec<u64> = resp["board_ids"].as_array().unwrap().iter() .map(|v| v.as_u64().unwrap()).collect(); assert!(ids.contains(&b1_id) && ids.contains(&b2_id)); let db = state.db.lock().unwrap(); assert!(!queries::is_board_live(&db, b1_id).unwrap()); assert!(!queries::is_board_live(&db, b2_id).unwrap()); } ``` Dependencies: none. #### Step 2: rpc_proxy broadcasts `board.deleted` for each cascaded id Files: `crates/hero_whiteboard_ui/src/routes.rs`. 1. Today the proxy sniffs `method == "board.delete"` and broadcasts on success. Generalize: - Capture the request method + params once. - For `"board.delete"`: behavior unchanged (single id from `params.id`, broadcast one event). - For `"workspace.delete"`: after a 200 response, parse `result.board_ids` (an array of u64) and broadcast one `board.deleted` per id. 2. Keep the parse-failure path silent so the proxy stays a strict pass-through for everything else. 3. The existing `BroadcastMsg { sender_id: 0, ... }` sentinel is reused -- no changes to `ws.rs` are needed. Recommended structure: ```rust enum DeletedBroadcast { None, Single(u64), Many(Vec<u64>) } let pending: DeletedBroadcast = match method_and_params { Some(("board.delete", id)) => DeletedBroadcast::Single(id), Some(("workspace.delete", _id)) => DeletedBroadcast::Many(vec![]), // resolved from response _ => DeletedBroadcast::None, }; // ... after response ... let ids: Vec<u64> = match pending { DeletedBroadcast::Single(id) => vec![id], DeletedBroadcast::Many(_) => parsed.get("result") .and_then(|r| r.get("board_ids")) .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|x| x.as_u64()).collect()) .unwrap_or_default(), DeletedBroadcast::None => Vec::new(), }; for id in ids { let payload = serde_json::json!({"type":"board.deleted","board_id":id}).to_string(); ws::broadcast_to_board(&state.channels, &id.to_string(), payload).await; } ``` Dependencies: Step 1 (response shape). #### Step 3: UI button + confirmation modal Files: `crates/hero_whiteboard_ui/templates/web/home.html`. 1. Add a small trash button next to the workspace filter `<select>`. Render only when `currentWorkspaceId` is a real id (not `''` and not `'__new__'`). Wire `onclick="openDeleteWorkspaceModal()"`. 2. Add a `delete-workspace-modal` block matching the styling of `delete-board-modal`: - Header `Delete Workspace`. - Prompt paragraph populated dynamically: `Delete workspace "<name>"?` plus, when board count > 0, `This will also delete <N> board(s) inside it.`. - Plain `This cannot be undone.` line. - Inline `#delete-workspace-error` region. - Cancel + Delete (red `var(--wb-error)`) buttons. 3. JS: - `openDeleteWorkspaceModal()`: looks up the current workspace name from `workspacesCache`, calls `rpcCall('board.list', { workspace_id: id })` to count, populates the prompt, opens the modal, clears the error region, focuses Delete. - `closeDeleteWorkspaceModal()`: hides the modal. - `submitDeleteWorkspace()`: calls `rpcCall('workspace.delete', { id })`. On success closes the modal, resets `currentWorkspaceId = ''`, removes localStorage entry, calls `loadWorkspaces()` and `loadBoards()`. On error shows inline. - The board-id broadcast happens on the server proxy side; the local cleanup just refreshes the home view. 4. Extend the keydown handler with the new modal so Enter triggers Delete and Escape cancels. 5. The trash button visibility must update whenever `currentWorkspaceId` changes -- update it in `loadWorkspaces`, `onWorkspaceChange`, and after `submitDeleteWorkspace` succeeds. Dependencies: Step 2 (so confirmed deletes propagate to open editors). ### Acceptance Criteria - [ ] Trash icon next to the workspace dropdown is visible only when a real workspace is selected. - [ ] Clicking it opens a themed confirmation modal with the workspace name and the live-board count. - [ ] Confirm deletes the workspace; the server returns `{deleted, board_ids}`; the UI proxy broadcasts `board.deleted` for each id; the home page resets to `All Workspaces` and refreshes. - [ ] If the workspace contains zero boards, the modal still works without the count line. - [ ] Cancel changes nothing. - [ ] RPC failure shows inline; modal stays open for retry. - [ ] Escape cancels; Enter confirms. - [ ] Open editors on cascaded boards see the existing `Board deleted` overlay. - [ ] Regression test passes (`workspace_delete_returns_cascaded_board_ids`). - [ ] `cargo test --workspace`, `cargo clippy --workspace -- -D warnings`, `cargo fmt --all -- --check` all pass. ### Notes - Capture the live board ids in the server handler **before** the `DELETE FROM workspaces` runs; the FK cascade hard-deletes the rows, so we can't enumerate them after the fact. - Reuse the destructive styling from `delete-board-modal` (red Delete button on `var(--wb-error)`, `#fff` text). - Don't add a soft-delete column to workspaces in this issue; that's a bigger change worth its own discussion. - The Default Workspace (id 1) is auto-recreated on the next `board.create`. Allow it to be deleted; do not special-case it.
Author
Member

Test Results

  • cargo test --workspace: all green, including the new handlers::object::tests::workspace_delete_returns_cascaded_board_ids regression test (3 tests in the module total).
  • cargo clippy --workspace -- -D warnings: clean.
  • cargo fmt --all -- --check: clean.
  • cargo check --workspace: clean.

The new test seeds two boards in a workspace, calls workspace.delete, asserts the response contains both board ids in board_ids, and that is_board_live returns false for each (they were cascaded away).

Manual verification recommended:

  1. Open the home page; pick a real workspace from the filter dropdown — confirm a red trash icon appears next to the dropdown.
  2. Click it — a themed confirmation modal opens with the workspace name and the live-board count.
  3. Cancel — modal closes, nothing changes.
  4. Open a board in that workspace in a second window. Back in window A, click the trash icon and confirm Delete.
  5. Window A: workspace and its boards are gone; the dropdown resets to All Workspaces. Window B: the Board deleted overlay appears.
  6. Delete an empty workspace — modal works; cascade message is omitted; deletion succeeds.
## Test Results - `cargo test --workspace`: all green, including the new `handlers::object::tests::workspace_delete_returns_cascaded_board_ids` regression test (3 tests in the module total). - `cargo clippy --workspace -- -D warnings`: clean. - `cargo fmt --all -- --check`: clean. - `cargo check --workspace`: clean. The new test seeds two boards in a workspace, calls `workspace.delete`, asserts the response contains both board ids in `board_ids`, and that `is_board_live` returns false for each (they were cascaded away). Manual verification recommended: 1. Open the home page; pick a real workspace from the filter dropdown — confirm a red trash icon appears next to the dropdown. 2. Click it — a themed confirmation modal opens with the workspace name and the live-board count. 3. Cancel — modal closes, nothing changes. 4. Open a board in that workspace in a second window. Back in window A, click the trash icon and confirm Delete. 5. Window A: workspace and its boards are gone; the dropdown resets to All Workspaces. Window B: the Board deleted overlay appears. 6. Delete an empty workspace — modal works; cascade message is omitted; deletion succeeds.
Author
Member

Implementation Summary

5 files changed, +232 / -31.

Server (crates/hero_whiteboard_server/)

  • db/queries.rs: added live_board_ids_in_workspace(conn, workspace_id) -> Vec<u64> so the handler can snapshot the live boards in a workspace before the FK cascade removes them.
  • handlers/workspace.rs::delete: now snapshots the cascaded board ids first, then issues DELETE FROM workspaces (which cascades). Response shape is {deleted: 1, board_ids: [...]} so the UI proxy can broadcast.
  • handlers/object.rs::tests: added workspace_delete_returns_cascaded_board_ids (asserts the response contains the expected ids and that the boards no longer satisfy is_board_live).

UI proxy (crates/hero_whiteboard_ui/src/routes.rs)

  • rpc_proxy generalized from a single-method (board.delete) sniffer to a small enum that also recognizes workspace.delete. After a successful response, it broadcasts {type: "board.deleted", board_id: <id>} to every relevant channel — one event for board.delete (from params.id), N events for workspace.delete (from result.board_ids).

UI (crates/hero_whiteboard_ui/templates/web/home.html)

  • Added a red trash button next to the workspace filter dropdown. Visibility is driven by updateDeleteWorkspaceBtn(); only shown when a real workspace is currently selected (not All Workspaces, never the + New workspace... sentinel).
  • Added a delete-workspace-modal matching the delete-board-modal styling: workspace name in the prompt, optional cascade count line (best-effort board.list lookup), inline error region, red destructive Delete button (var(--wb-error)), Cancel button.
  • Added openDeleteWorkspaceModal() / closeDeleteWorkspaceModal() / submitDeleteWorkspace(). On success: closes the modal, resets currentWorkspaceId to '', removes the localStorage entry, refreshes the dropdown and the board list. On failure: error inline, modal stays open.
  • loadWorkspaces now falls back to All Workspaces when the cached currentWorkspaceId is no longer in the list (handles the just-deleted-workspace case on reload).
  • Extended the page's keydown handler so Enter triggers Delete and Escape cancels for the new modal.

Verification

  • cargo test --workspace: all green.
  • cargo clippy --workspace -- -D warnings: clean.
  • cargo fmt --all -- --check: clean.
  • cargo check --workspace: clean.

Notes / caveats

  • Capture-then-delete order is essential: the FK cascade on DELETE FROM workspaces hard-removes the board rows, so we snapshot the ids first.
  • The cascade is hard (rows are gone), matching the user's expectation that "delete workspace" makes the boards disappear. Soft-delete for workspaces is out of scope for this issue.
  • Default Workspace id 1 is auto-recreated on the next board.create if missing, so deleting it is allowed (no special-casing).
  • Open editors on cascaded boards see the existing Board deleted overlay (already wired in #83's work).
## Implementation Summary 5 files changed, +232 / -31. ### Server (`crates/hero_whiteboard_server/`) - `db/queries.rs`: added `live_board_ids_in_workspace(conn, workspace_id) -> Vec<u64>` so the handler can snapshot the live boards in a workspace before the FK cascade removes them. - `handlers/workspace.rs::delete`: now snapshots the cascaded board ids first, then issues `DELETE FROM workspaces` (which cascades). Response shape is `{deleted: 1, board_ids: [...]}` so the UI proxy can broadcast. - `handlers/object.rs::tests`: added `workspace_delete_returns_cascaded_board_ids` (asserts the response contains the expected ids and that the boards no longer satisfy `is_board_live`). ### UI proxy (`crates/hero_whiteboard_ui/src/routes.rs`) - `rpc_proxy` generalized from a single-method (`board.delete`) sniffer to a small enum that also recognizes `workspace.delete`. After a successful response, it broadcasts `{type: "board.deleted", board_id: <id>}` to every relevant channel — one event for `board.delete` (from `params.id`), N events for `workspace.delete` (from `result.board_ids`). ### UI (`crates/hero_whiteboard_ui/templates/web/home.html`) - Added a red trash button next to the workspace filter dropdown. Visibility is driven by `updateDeleteWorkspaceBtn()`; only shown when a real workspace is currently selected (not `All Workspaces`, never the `+ New workspace...` sentinel). - Added a `delete-workspace-modal` matching the `delete-board-modal` styling: workspace name in the prompt, optional cascade count line (best-effort `board.list` lookup), inline error region, red destructive Delete button (`var(--wb-error)`), Cancel button. - Added `openDeleteWorkspaceModal()` / `closeDeleteWorkspaceModal()` / `submitDeleteWorkspace()`. On success: closes the modal, resets `currentWorkspaceId` to `''`, removes the localStorage entry, refreshes the dropdown and the board list. On failure: error inline, modal stays open. - `loadWorkspaces` now falls back to `All Workspaces` when the cached `currentWorkspaceId` is no longer in the list (handles the just-deleted-workspace case on reload). - Extended the page's keydown handler so Enter triggers Delete and Escape cancels for the new modal. ### Verification - `cargo test --workspace`: all green. - `cargo clippy --workspace -- -D warnings`: clean. - `cargo fmt --all -- --check`: clean. - `cargo check --workspace`: clean. ### Notes / caveats - Capture-then-delete order is essential: the FK cascade on `DELETE FROM workspaces` hard-removes the board rows, so we snapshot the ids first. - The cascade is hard (rows are gone), matching the user's expectation that "delete workspace" makes the boards disappear. Soft-delete for workspaces is out of scope for this issue. - Default Workspace id 1 is auto-recreated on the next `board.create` if missing, so deleting it is allowed (no special-casing). - Open editors on cascaded boards see the existing `Board deleted` overlay (already wired in #83's work).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#85
No description provided.