fix(portal): replace fake workspace overflow with explicit actions

This commit is contained in:
rcourtman
2026-03-27 10:03:32 +00:00
parent 6b1fedc0c1
commit cefcddd14b
14 changed files with 292 additions and 45 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "a68f28330c399b792cc04898ffa0d0571018bdb7047dcfc6224a77cd503e3801",
"source_hash": "1c0e03c2843117f80f2e4854bdc42da2385c33944b2fef1653a5f8bbbbccbab6",
"build_inputs": [
"package.json",
"tsconfig.json",
+38
View File
@@ -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;
+70 -15
View File
@@ -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 = '<form method="POST" action="' + escapeAttr(accountAPIBasePath + "/" + account.id + "/tenants/" + workspace.id + "/handoff") + '"><button type="submit" class="btn-primary">Open \u2192</button></form>';
openAction = '<form method="POST" action="' + escapeAttr(accountAPIBasePath + "/" + account.id + "/tenants/" + workspace.id + "/handoff") + '"><button type="submit" class="btn-primary">Open workspace</button></form>';
} else {
openAction = '<span class="workspace-state-label">' + safeState + "</span>";
}
var manageAction = "";
if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) {
manageAction = '<button type="button" class="btn-danger" data-action="workspace-manage" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" data-workspace-state="' + escapeAttr(state) + '" data-workspace-name="' + escapeAttr(workspace.display_name) + '">\u22EF</button>';
var menuAction = state === "active" ? "suspend" : "delete";
var menuLabel = state === "active" ? "Suspend workspace" : "Delete workspace";
manageAction = '<div class="workspace-menu-wrap"><button type="button" class="btn-secondary btn-workspace-menu" id="workspace-menu-button-' + escapeAttr(account.id) + "-" + escapeAttr(workspace.id) + '" data-action="toggle-workspace-menu" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" aria-haspopup="menu" aria-expanded="false">Manage</button><div class="workspace-menu" id="workspace-menu-' + escapeAttr(account.id) + "-" + escapeAttr(workspace.id) + '" data-workspace-menu-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" role="menu" hidden><button type="button" class="workspace-menu-item workspace-menu-item-danger" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" data-workspace-action="' + escapeAttr(menuAction) + '" data-workspace-name="' + escapeAttr(workspace.display_name) + '">' + escapeHTML(menuLabel) + "</button></div></div>";
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + "</span>" : "";
return '<div class="workspace-card"><div class="ws-info"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span><div class="ws-meta">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
@@ -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() {
'<button id="team" data-action="toggle-team" data-account-id="acct_1">Team</button>' +
'<button id="invite" data-action="invite-member" data-account-id="acct_1">Invite</button>' +
'<button id="create" data-action="create-workspace" data-account-id="acct_1">Create</button>' +
'<button id="manage" data-action="workspace-manage" data-account-id="acct_1" data-workspace-id="ws_1" data-workspace-state="active" data-workspace-name="Alpha">Manage</button>' +
'<div class="workspace-menu-wrap">' +
'<button id="menu" data-action="toggle-workspace-menu" data-account-id="acct_1" data-workspace-id="ws_1">Manage</button>' +
'<button id="manage" data-action="workspace-action" data-account-id="acct_1" data-workspace-id="ws_1" data-workspace-action="suspend" data-workspace-name="Alpha">Suspend</button>' +
'</div>' +
'<button id="remove" data-action="remove-member" data-account-id="acct_1" data-user-id="u1" data-member-email="owner@example.com">Remove</button>' +
'<select id="role" data-action="change-role" data-account-id="acct_1" data-user-id="u1"><option value="admin">Admin</option></select>';
@@ -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();
});
});
@@ -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;
@@ -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 =
'<button id="workspace-menu-button-acct_1-ws_1" aria-expanded="false"></button>' +
'<div id="workspace-menu-acct_1-ws_1" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_1" hidden></div>' +
'<button id="workspace-menu-button-acct_1-ws_2" aria-expanded="false"></button>' +
'<div id="workspace-menu-acct_1-ws_2" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_2" hidden></div>';
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',
@@ -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<void>;
toggleTeam(accountID: string): void;
inviteMember(accountID: string): Promise<void>;
createWorkspace(accountID: string): Promise<void>;
manageWorkspace(event: Event, accountID: string, tenantID: string, state: string, name: string): Promise<void>;
manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise<void>;
removeMember(accountID: string, userID: string, email: string): Promise<void>;
changeRole(accountID: string, userID: string, newRole: string): Promise<void>;
}
@@ -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<void> {
var section = getElement<HTMLElement>('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<void> {
var nameEl = getElement<HTMLInputElement>('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<void> {
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<void> {
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,
};
@@ -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> = {}): PortalAccountUIEntry {
@@ -10,6 +10,7 @@ function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): 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 =
'<button id="workspace-menu-button-acct_1-ws_1" aria-expanded="false"></button>' +
'<div id="workspace-menu-acct_1-ws_1" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_1" hidden></div>' +
'<button id="workspace-menu-button-acct_1-ws_2" aria-expanded="false"></button>' +
'<div id="workspace-menu-acct_1-ws_2" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_2" hidden></div>';
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 =
'<div id="add-ws-form-acct_1" class="add-workspace-form"></div><div id="ws-spinner-acct_1"></div>' +
@@ -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<HTMLElement>('[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<HTMLElement>('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);
}
}
@@ -133,7 +133,9 @@ describe('shell view', function() {
expect(html).toContain('Unhealthy</span>');
expect(html).toContain('Checking</span>');
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"');
@@ -59,7 +59,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
'<form method="POST" action="' +
escapeAttr(accountAPIBasePath + '/' + account.id + '/tenants/' + workspace.id + '/handoff') +
'">' +
'<button type="submit" class="btn-primary">Open →</button>' +
'<button type="submit" class="btn-primary">Open workspace</button>' +
'</form>';
} else {
openAction = '<span class="workspace-state-label">' + safeState + '</span>';
@@ -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 =
'<button type="button" class="btn-danger" data-action="workspace-manage" data-account-id="' +
escapeAttr(account.id) +
'" data-workspace-id="' +
escapeAttr(workspace.id) +
'" data-workspace-state="' +
escapeAttr(state) +
'" data-workspace-name="' +
escapeAttr(workspace.display_name) +
'">⋯</button>';
'<div class="workspace-menu-wrap">' +
'<button type="button" class="btn-secondary btn-workspace-menu" id="workspace-menu-button-' +
escapeAttr(account.id) +
'-' +
escapeAttr(workspace.id) +
'" data-action="toggle-workspace-menu" data-account-id="' +
escapeAttr(account.id) +
'" data-workspace-id="' +
escapeAttr(workspace.id) +
'" aria-haspopup="menu" aria-expanded="false">Manage</button>' +
'<div class="workspace-menu" id="workspace-menu-' +
escapeAttr(account.id) +
'-' +
escapeAttr(workspace.id) +
'" data-workspace-menu-account-id="' +
escapeAttr(account.id) +
'" data-workspace-id="' +
escapeAttr(workspace.id) +
'" role="menu" hidden>' +
'<button type="button" class="workspace-menu-item workspace-menu-item-danger" data-action="workspace-action" data-account-id="' +
escapeAttr(account.id) +
'" data-workspace-id="' +
escapeAttr(workspace.id) +
'" data-workspace-action="' +
escapeAttr(menuAction) +
'" data-workspace-name="' +
escapeAttr(workspace.display_name) +
'">' +
escapeHTML(menuLabel) +
'</button>' +
'</div>' +
'</div>';
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + '</span>' : '';
@@ -68,6 +68,7 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc
accountState.byAccountID[accountID] = {
addWorkspaceOpen: false,
createWorkspace: createMutationState(),
openWorkspaceMenuID: '',
teamVisible: false,
teamQuery: createQueryState<PortalTeamMember[]>([]),
};
@@ -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; }
@@ -63,6 +63,7 @@ export interface PortalMutationState {
export interface PortalAccountUIEntry {
addWorkspaceOpen: boolean;
createWorkspace: PortalMutationState;
openWorkspaceMenuID: string;
teamVisible: boolean;
teamQuery: PortalQueryState<PortalTeamMember[]>;
}