From cefcddd14b3e6a2599cb7f48d086502bf727a417 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 10:03:32 +0000 Subject: [PATCH] fix(portal): replace fake workspace overflow with explicit actions --- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.css | 38 +++++++++ internal/cloudcp/portal/dist/portal_app.js | 85 +++++++++++++++---- .../frontend/src/account_controller.test.ts | 17 +++- .../portal/frontend/src/account_controller.ts | 21 +++-- .../frontend/src/account_runtime.test.ts | 35 ++++++++ .../portal/frontend/src/account_runtime.ts | 49 +++++++++-- .../portal/frontend/src/account_view.test.ts | 17 +++- .../portal/frontend/src/account_view.ts | 15 ++++ .../portal/frontend/src/shell_view.test.ts | 4 +- .../cloudcp/portal/frontend/src/shell_view.ts | 45 +++++++--- internal/cloudcp/portal/frontend/src/state.ts | 1 + .../cloudcp/portal/frontend/src/styles.css | 7 ++ internal/cloudcp/portal/frontend/src/types.ts | 1 + 14 files changed, 292 insertions(+), 45 deletions(-) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 7d6f0cdcbc..c3bd792abf 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "a68f28330c399b792cc04898ffa0d0571018bdb7047dcfc6224a77cd503e3801", + "source_hash": "1c0e03c2843117f80f2e4854bdc42da2385c33944b2fef1653a5f8bbbbccbab6", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index 75c7bae97e..46337e8dc0 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -176,6 +176,44 @@ header .logout-btn:hover { gap: 8px; flex-shrink: 0; } +.workspace-menu-wrap { + position: relative; +} +.btn-workspace-menu { + min-width: 96px; +} +.workspace-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 10; + min-width: 190px; + background: #fff; + border: 1px solid #dbe4f0; + border-radius: 10px; + padding: 6px; + box-shadow: 0 12px 28px rgba(15, 23, 42, .12); +} +.workspace-menu-item { + width: 100%; + background: transparent; + border: 0; + border-radius: 8px; + padding: 9px 12px; + font: inherit; + text-align: left; + cursor: pointer; + color: #0f172a; +} +.workspace-menu-item:hover { + background: #f8fafc; +} +.workspace-menu-item-danger { + color: #b91c1c; +} +.workspace-menu-item-danger:hover { + background: #fef2f2; +} .btn-primary { background: #1d4ed8; color: #fff; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 360b1caea4..29871c090e 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -10,6 +10,19 @@ var input = getElement(id); if (input) input.focus(); } + function renderWorkspaceMenus(accountID, entry) { + var menus = document.querySelectorAll('[data-workspace-menu-account-id="' + accountID + '"]'); + for (var i = 0; i < menus.length; i += 1) { + var menu = menus[i]; + var workspaceID = menu.getAttribute("data-workspace-id") || ""; + var open = entry.openWorkspaceMenuID === workspaceID; + menu.hidden = !open; + var button = getElement("workspace-menu-button-" + accountID + "-" + workspaceID); + if (button) { + button.setAttribute("aria-expanded", open ? "true" : "false"); + } + } + } function setTbodyMessage(tbody, msg, isError) { tbody.textContent = ""; var tr = document.createElement("tr"); @@ -106,6 +119,7 @@ var accountID = accountIDs[i]; var entry = accountState.byAccountID[accountID]; renderAddWorkspaceSection(accountID, entry); + renderWorkspaceMenus(accountID, entry); renderTeamSection(accountID, entry); } } @@ -113,7 +127,12 @@ // src/account_controller.ts function installAccountController(deps) { document.addEventListener("click", function(event) { - var actionEl = asHTMLElement(event.target)?.closest("[data-action]"); + var target = asHTMLElement(event.target); + if (!target) return; + var actionEl = target.closest("[data-action]"); + if (!target.closest(".workspace-menu-wrap")) { + deps.runtime.closeWorkspaceMenus(); + } if (!actionEl) return; var action = actionEl.getAttribute("data-action") || ""; var accountID = actionEl.getAttribute("data-account-id") || ""; @@ -138,13 +157,19 @@ event.preventDefault(); void deps.runtime.createWorkspace(accountID); return; - case "workspace-manage": + case "toggle-workspace-menu": event.preventDefault(); - void deps.runtime.manageWorkspace( - event, + deps.runtime.toggleWorkspaceMenu( + accountID, + actionEl.getAttribute("data-workspace-id") || "" + ); + return; + case "workspace-action": + event.preventDefault(); + void deps.runtime.manageWorkspaceAction( accountID, actionEl.getAttribute("data-workspace-id") || "", - actionEl.getAttribute("data-workspace-state") || "", + actionEl.getAttribute("data-workspace-action") || "", actionEl.getAttribute("data-workspace-name") || "" ); return; @@ -399,6 +424,7 @@ accountState.byAccountID[accountID] = { addWorkspaceOpen: false, createWorkspace: createMutationState(), + openWorkspaceMenuID: "", teamVisible: false, teamQuery: createQueryState([]) }; @@ -514,6 +540,14 @@ var renderAccountRuntime = function() { renderAccountUI(deps.store.getAccountState()); }; + var closeWorkspaceMenus = function() { + deps.store.updateAccountState(function(accountState) { + var accountIDs = Object.keys(accountState.byAccountID); + for (var i = 0; i < accountIDs.length; i += 1) { + accountState.byAccountID[accountIDs[i]].openWorkspaceMenuID = ""; + } + }); + }; var loadTeam = async function(accountID) { var section = getElement("team-section-" + accountID); if (!section) return; @@ -554,6 +588,7 @@ var shouldFocus = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.openWorkspaceMenuID = ""; entry.addWorkspaceOpen = !entry.addWorkspaceOpen; shouldFocus = entry.addWorkspaceOpen; }); @@ -561,6 +596,19 @@ focusElement("ws-name-" + accountID); } }; + var toggleWorkspaceMenu = function(accountID, workspaceID) { + deps.store.updateAccountState(function(accountState) { + var accountIDs = Object.keys(accountState.byAccountID); + for (var i = 0; i < accountIDs.length; i += 1) { + var entry = ensurePortalAccountUIEntry(accountState, accountIDs[i]); + if (accountIDs[i] !== accountID) { + entry.openWorkspaceMenuID = ""; + } + } + var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.openWorkspaceMenuID = entry.openWorkspaceMenuID === workspaceID ? "" : workspaceID; + }); + }; var createWorkspace = async function(accountID) { var nameEl = getElement("ws-name-" + accountID); if (!nameEl) return; @@ -585,6 +633,7 @@ deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.addWorkspaceOpen = false; + entry.openWorkspaceMenuID = ""; succeedMutationState(entry.createWorkspace); }); deps.showToast("Workspace created!"); @@ -597,12 +646,13 @@ deps.showToast(message, true); } }; - var manageWorkspace = async function(evt, accountID, tenantID, state, name) { - evt.stopPropagation(); - var action = state === "active" ? "Suspend" : "Delete"; - if (!window.confirm(action + ' workspace "' + name + '"?')) return; + var manageWorkspaceAction = async function(accountID, tenantID, action, name) { + closeWorkspaceMenus(); + var verb = action === "suspend" ? "Suspend" : action === "delete" ? "Delete" : ""; + if (!verb) return; + if (!window.confirm(verb + ' workspace "' + name + '"?')) return; try { - if (state === "active") { + if (action === "suspend") { await deps.api.suspendWorkspace(accountID, tenantID); } else { await deps.api.deleteWorkspace(accountID, tenantID); @@ -610,9 +660,9 @@ if (!await refreshOrRedirect()) { return; } - deps.showToast(action + "d workspace."); + deps.showToast(verb + "ed workspace."); } catch (error) { - deps.showToast(error instanceof Error ? error.message : "Failed to " + action.toLowerCase() + " workspace.", true); + deps.showToast(error instanceof Error ? error.message : "Failed to " + verb.toLowerCase() + " workspace.", true); } }; var openBilling = async function(accountID) { @@ -631,6 +681,7 @@ var nextVisible = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.openWorkspaceMenuID = ""; entry.teamVisible = !entry.teamVisible; nextVisible = entry.teamVisible; }); @@ -699,11 +750,13 @@ deps.store.subscribeBootstrap(renderAccountRuntime); return { toggleAddWorkspace, + toggleWorkspaceMenu, + closeWorkspaceMenus, openBilling, toggleTeam, inviteMember, createWorkspace, - manageWorkspace, + manageWorkspaceAction, removeMember, changeRole }; @@ -1394,13 +1447,15 @@ var createdLabel = formatWorkspaceDate(workspace.created_at); var openAction = ""; if (state === "active") { - openAction = '
'; + openAction = '
'; } else { openAction = '' + safeState + ""; } var manageAction = ""; if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { - manageAction = ''; + var menuAction = state === "active" ? "suspend" : "delete"; + var menuLabel = state === "active" ? "Suspend workspace" : "Delete workspace"; + manageAction = '
"; } var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + "" : ""; return '
' + escapeHTML(workspace.display_name) + '
' + healthBadgeHTML(workspace) + '' + safeState + "" + createdMeta + '
' + openAction + manageAction + "
"; diff --git a/internal/cloudcp/portal/frontend/src/account_controller.test.ts b/internal/cloudcp/portal/frontend/src/account_controller.test.ts index 050e147b55..4e30d22edc 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.test.ts @@ -11,11 +11,13 @@ describe('account controller', function() { it('routes account actions to the matching runtime handlers', function() { var runtime = { toggleAddWorkspace: vi.fn(), + toggleWorkspaceMenu: vi.fn(), + closeWorkspaceMenus: vi.fn(), openBilling: vi.fn(), toggleTeam: vi.fn(), inviteMember: vi.fn(), createWorkspace: vi.fn(), - manageWorkspace: vi.fn(), + manageWorkspaceAction: vi.fn(), removeMember: vi.fn(), changeRole: vi.fn(), }; @@ -28,7 +30,10 @@ describe('account controller', function() { '' + '' + '' + - '' + + '
' + + '' + + '' + + '
' + '' + ''; @@ -47,8 +52,11 @@ describe('account controller', function() { document.getElementById('create')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(runtime.createWorkspace).toHaveBeenCalledWith('acct_1'); + document.getElementById('menu')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(runtime.toggleWorkspaceMenu).toHaveBeenCalledWith('acct_1', 'ws_1'); + document.getElementById('manage')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(runtime.manageWorkspace).toHaveBeenCalledWith(expect.any(MouseEvent), 'acct_1', 'ws_1', 'active', 'Alpha'); + expect(runtime.manageWorkspaceAction).toHaveBeenCalledWith('acct_1', 'ws_1', 'suspend', 'Alpha'); document.getElementById('remove')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); expect(runtime.removeMember).toHaveBeenCalledWith('acct_1', 'u1', 'owner@example.com'); @@ -57,5 +65,8 @@ describe('account controller', function() { role.value = 'admin'; role.dispatchEvent(new Event('change', { bubbles: true })); expect(runtime.changeRole).toHaveBeenCalledWith('acct_1', 'u1', 'admin'); + + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(runtime.closeWorkspaceMenus).toHaveBeenCalled(); }); }); diff --git a/internal/cloudcp/portal/frontend/src/account_controller.ts b/internal/cloudcp/portal/frontend/src/account_controller.ts index 1db1331fc0..14948a175d 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.ts @@ -7,7 +7,12 @@ export interface AccountControllerDeps { export function installAccountController(deps: AccountControllerDeps): void { document.addEventListener('click', function(event) { - var actionEl = asHTMLElement(event.target)?.closest('[data-action]'); + var target = asHTMLElement(event.target); + if (!target) return; + var actionEl = target.closest('[data-action]'); + if (!target.closest('.workspace-menu-wrap')) { + deps.runtime.closeWorkspaceMenus(); + } if (!actionEl) return; var action = actionEl.getAttribute('data-action') || ''; var accountID = actionEl.getAttribute('data-account-id') || ''; @@ -33,13 +38,19 @@ export function installAccountController(deps: AccountControllerDeps): void { event.preventDefault(); void deps.runtime.createWorkspace(accountID); return; - case 'workspace-manage': + case 'toggle-workspace-menu': event.preventDefault(); - void deps.runtime.manageWorkspace( - event, + deps.runtime.toggleWorkspaceMenu( accountID, actionEl.getAttribute('data-workspace-id') || '', - actionEl.getAttribute('data-workspace-state') || '', + ); + return; + case 'workspace-action': + event.preventDefault(); + void deps.runtime.manageWorkspaceAction( + accountID, + actionEl.getAttribute('data-workspace-id') || '', + actionEl.getAttribute('data-workspace-action') || '', actionEl.getAttribute('data-workspace-name') || '', ); return; diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 6b31526339..33d989b777 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -106,6 +106,41 @@ describe('account runtime', function() { expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true); }); + it('toggles workspace menus and routes explicit workspace actions', async function() { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}))); + var confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + + document.body.innerHTML = + '' + + '' + + '' + + ''; + + runtime.toggleWorkspaceMenu('acct_1', 'ws_1'); + expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe('ws_1'); + expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(false); + + runtime.toggleWorkspaceMenu('acct_1', 'ws_1'); + expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe(''); + expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(true); + + runtime.toggleWorkspaceMenu('acct_1', 'ws_2'); + await runtime.manageWorkspaceAction('acct_1', 'ws_2', 'suspend', 'Alpha Workspace'); + await flushAsync(); + + expect(confirmSpy).toHaveBeenCalledWith('Suspend workspace "Alpha Workspace"?'); + expect(fetch).toHaveBeenCalledWith( + '/api/accounts/acct_1/tenants/ws_2', + expect.objectContaining({ + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ state: 'suspended' }), + }) + ); + expect(deps.showToast).toHaveBeenCalledWith('Suspended workspace.'); + expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe(''); + }); + it('loads and updates team membership from runtime actions', async function() { vi.stubGlobal( 'fetch', diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.ts b/internal/cloudcp/portal/frontend/src/account_runtime.ts index 7fe3146657..ac147efeae 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.ts @@ -15,11 +15,13 @@ export interface AccountRuntimeDeps { export interface AccountRuntime { toggleAddWorkspace(accountID: string): void; + toggleWorkspaceMenu(accountID: string, workspaceID: string): void; + closeWorkspaceMenus(): void; openBilling(accountID: string): Promise; toggleTeam(accountID: string): void; inviteMember(accountID: string): Promise; createWorkspace(accountID: string): Promise; - manageWorkspace(event: Event, accountID: string, tenantID: string, state: string, name: string): Promise; + manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise; removeMember(accountID: string, userID: string, email: string): Promise; changeRole(accountID: string, userID: string, newRole: string): Promise; } @@ -41,6 +43,15 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime renderAccountUIState(deps.store.getAccountState()); }; + var closeWorkspaceMenus = function(): void { + deps.store.updateAccountState(function(accountState) { + var accountIDs = Object.keys(accountState.byAccountID); + for (var i = 0; i < accountIDs.length; i += 1) { + accountState.byAccountID[accountIDs[i]].openWorkspaceMenuID = ''; + } + }); + }; + var loadTeam = async function(accountID: string): Promise { var section = getElement('team-section-' + accountID); if (!section) return; @@ -83,6 +94,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var shouldFocus = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.openWorkspaceMenuID = ''; entry.addWorkspaceOpen = !entry.addWorkspaceOpen; shouldFocus = entry.addWorkspaceOpen; }); @@ -91,6 +103,20 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime } }; + var toggleWorkspaceMenu = function(accountID: string, workspaceID: string): void { + deps.store.updateAccountState(function(accountState) { + var accountIDs = Object.keys(accountState.byAccountID); + for (var i = 0; i < accountIDs.length; i += 1) { + var entry = ensurePortalAccountUIEntry(accountState, accountIDs[i]); + if (accountIDs[i] !== accountID) { + entry.openWorkspaceMenuID = ''; + } + } + var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.openWorkspaceMenuID = entry.openWorkspaceMenuID === workspaceID ? '' : workspaceID; + }); + }; + var createWorkspace = async function(accountID: string): Promise { var nameEl = getElement('ws-name-' + accountID); if (!nameEl) return; @@ -115,6 +141,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); entry.addWorkspaceOpen = false; + entry.openWorkspaceMenuID = ''; succeedMutationState(entry.createWorkspace); }); deps.showToast('Workspace created!'); @@ -128,12 +155,13 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime } }; - var manageWorkspace = async function(evt: Event, accountID: string, tenantID: string, state: string, name: string): Promise { - evt.stopPropagation(); - var action = state === 'active' ? 'Suspend' : 'Delete'; - if (!window.confirm(action + ' workspace "' + name + '"?')) return; + var manageWorkspaceAction = async function(accountID: string, tenantID: string, action: string, name: string): Promise { + closeWorkspaceMenus(); + var verb = action === 'suspend' ? 'Suspend' : action === 'delete' ? 'Delete' : ''; + if (!verb) return; + if (!window.confirm(verb + ' workspace "' + name + '"?')) return; try { - if (state === 'active') { + if (action === 'suspend') { await deps.api.suspendWorkspace(accountID, tenantID); } else { await deps.api.deleteWorkspace(accountID, tenantID); @@ -141,9 +169,9 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime if (!await refreshOrRedirect()) { return; } - deps.showToast(action + 'd workspace.'); + deps.showToast(verb + 'ed workspace.'); } catch (error) { - deps.showToast(error instanceof Error ? error.message : 'Failed to ' + action.toLowerCase() + ' workspace.', true); + deps.showToast(error instanceof Error ? error.message : 'Failed to ' + verb.toLowerCase() + ' workspace.', true); } }; @@ -164,6 +192,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime var nextVisible = false; deps.store.updateAccountState(function(accountState) { var entry = ensurePortalAccountUIEntry(accountState, accountID); + entry.openWorkspaceMenuID = ''; entry.teamVisible = !entry.teamVisible; nextVisible = entry.teamVisible; }); @@ -237,11 +266,13 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime return { toggleAddWorkspace: toggleAddWorkspace, + toggleWorkspaceMenu: toggleWorkspaceMenu, + closeWorkspaceMenus: closeWorkspaceMenus, openBilling: openBilling, toggleTeam: toggleTeam, inviteMember: inviteMember, createWorkspace: createWorkspace, - manageWorkspace: manageWorkspace, + manageWorkspaceAction: manageWorkspaceAction, removeMember: removeMember, changeRole: changeRole, }; diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index 44b086d832..a2f14903e7 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from 'vitest'; -import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection } from './account_view'; +import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceMenus } from './account_view'; import type { PortalAccountState, PortalAccountUIEntry } from './types'; function createEntry(overrides: Partial = {}): PortalAccountUIEntry { @@ -10,6 +10,7 @@ function createEntry(overrides: Partial = {}): PortalAccou pending: false, error: '', }, + openWorkspaceMenuID: '', teamVisible: false, teamQuery: { status: 'idle', @@ -77,6 +78,20 @@ describe('account view', function() { expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull(); }); + it('renders workspace menu visibility from account UI state', function() { + document.body.innerHTML = + '' + + '' + + '' + + ''; + + renderWorkspaceMenus('acct_1', createEntry({ openWorkspaceMenuID: 'ws_2' })); + expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(true); + expect((document.getElementById('workspace-menu-acct_1-ws_2') as HTMLElement).hidden).toBe(false); + expect(document.getElementById('workspace-menu-button-acct_1-ws_1')?.getAttribute('aria-expanded')).toBe('false'); + expect(document.getElementById('workspace-menu-button-acct_1-ws_2')?.getAttribute('aria-expanded')).toBe('true'); + }); + it('renders account UI for every tracked account entry', function() { document.body.innerHTML = '
' + diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 4dcebd577f..ecaf320abc 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -15,6 +15,20 @@ export function focusElement(id: string): void { if (input) input.focus(); } +export function renderWorkspaceMenus(accountID: string, entry: PortalAccountUIEntry): void { + var menus = document.querySelectorAll('[data-workspace-menu-account-id="' + accountID + '"]'); + for (var i = 0; i < menus.length; i += 1) { + var menu = menus[i]; + var workspaceID = menu.getAttribute('data-workspace-id') || ''; + var open = entry.openWorkspaceMenuID === workspaceID; + menu.hidden = !open; + var button = getElement('workspace-menu-button-' + accountID + '-' + workspaceID); + if (button) { + button.setAttribute('aria-expanded', open ? 'true' : 'false'); + } + } +} + function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): void { tbody.textContent = ''; var tr = document.createElement('tr'); @@ -121,6 +135,7 @@ export function renderAccountUI(accountState: PortalAccountState): void { var accountID = accountIDs[i]; var entry = accountState.byAccountID[accountID]; renderAddWorkspaceSection(accountID, entry); + renderWorkspaceMenus(accountID, entry); renderTeamSection(accountID, entry); } } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index a0eb1042d1..458885471f 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -133,7 +133,9 @@ describe('shell view', function() { expect(html).toContain('Unhealthy'); expect(html).toContain('Checking'); expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff'); - expect(html).toContain('data-action="workspace-manage"'); + expect(html).toContain('Open workspace'); + expect(html).toContain('data-action="toggle-workspace-menu"'); + expect(html).toContain('data-action="workspace-action"'); expect(html).toContain('service-card-button'); expect(html).toContain('id="open-retrieve-service"'); expect(html).toContain('id="data-service-panel"'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index f67ea82eb7..80adb94e78 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -59,7 +59,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor '
' + - '' + + '' + '
'; } else { openAction = '' + safeState + ''; @@ -67,16 +67,41 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor var manageAction = ''; if (account.can_manage && (state === 'active' || state === 'suspended' || state === 'failed')) { + var menuAction = state === 'active' ? 'suspend' : 'delete'; + var menuLabel = state === 'active' ? 'Suspend workspace' : 'Delete workspace'; manageAction = - ''; + '
' + + '' + + '' + + '
'; } var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + '' : ''; diff --git a/internal/cloudcp/portal/frontend/src/state.ts b/internal/cloudcp/portal/frontend/src/state.ts index f2af3742fa..a21d6fdcfe 100644 --- a/internal/cloudcp/portal/frontend/src/state.ts +++ b/internal/cloudcp/portal/frontend/src/state.ts @@ -68,6 +68,7 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc accountState.byAccountID[accountID] = { addWorkspaceOpen: false, createWorkspace: createMutationState(), + openWorkspaceMenuID: '', teamVisible: false, teamQuery: createQueryState([]), }; diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 5ccbc6e80a..63caedfa3c 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -31,6 +31,13 @@ .ws-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .ws-created { font-size: 11px; color: #94a3b8; } .ws-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + .workspace-menu-wrap { position: relative; } + .btn-workspace-menu { min-width: 96px; } + .workspace-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 10; min-width: 190px; background: #fff; border: 1px solid #dbe4f0; border-radius: 10px; padding: 6px; box-shadow: 0 12px 28px rgba(15,23,42,.12); } + .workspace-menu-item { width: 100%; background: transparent; border: 0; border-radius: 8px; padding: 9px 12px; font: inherit; text-align: left; cursor: pointer; color: #0f172a; } + .workspace-menu-item:hover { background: #f8fafc; } + .workspace-menu-item-danger { color: #b91c1c; } + .workspace-menu-item-danger:hover { background: #fef2f2; } .btn-primary { background: #1d4ed8; color: #fff; border: 0; border-radius: 8px; padding: 8px 18px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; } .btn-primary:hover { background: #1e40af; } .btn-secondary { background: #fff; color: #334155; border: 1px solid #cbd5e1; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; } diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index bf68fe4149..1b6ba6c32c 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -63,6 +63,7 @@ export interface PortalMutationState { export interface PortalAccountUIEntry { addWorkspaceOpen: boolean; createWorkspace: PortalMutationState; + openWorkspaceMenuID: string; teamVisible: boolean; teamQuery: PortalQueryState; }