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;
}