feat(portal): add explicit workspace management panel

This commit is contained in:
rcourtman
2026-03-27 14:04:46 +00:00
parent 9e6299dbb8
commit 298bf791b9
15 changed files with 465 additions and 200 deletions
@@ -140,6 +140,9 @@ Core rules:
starts drilling into one workspace at a time.
9. Workspace fleets must summarize health and attention state at the account
level, not force the user to scan badges one card at a time.
10. Workspace lifecycle actions must open in an explicit management surface
with the selected workspace context visible, not inside a hidden overflow
menu or a blind confirm-first interaction.
## Screen Model
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "f091b6dbbdf775a750bbba5c4047fbba2553fa0393420916e2e9e5ca315e1437",
"source_hash": "9e66ef00fa2623eb7e00f30d4cd4a823bfb4552eeb89136392577ad25b7caad2",
"build_inputs": [
"package.json",
"tsconfig.json",
+62 -29
View File
@@ -267,43 +267,73 @@ header .logout-btn:hover {
gap: 8px;
flex-shrink: 0;
}
.workspace-menu-wrap {
position: relative;
.btn-workspace-manage {
min-width: 112px;
}
.btn-workspace-menu {
min-width: 96px;
}
.workspace-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 10;
min-width: 190px;
.workspace-management-panel {
margin-top: 14px;
background: #fff;
border: 1px solid #dbe4f0;
border-radius: 10px;
padding: 6px;
box-shadow: 0 12px 28px rgba(15, 23, 42, .12);
border-radius: 12px;
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
}
.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-management-panel.visible {
border-color: #bfdbfe;
box-shadow: 0 10px 24px rgba(29, 78, 216, .08);
}
.workspace-menu-item:hover {
.workspace-management-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.workspace-management-header h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 700;
}
.workspace-management-header p {
margin: 0;
font-size: 13px;
color: #64748b;
line-height: 1.5;
max-width: 560px;
}
.workspace-management-empty {
font-size: 14px;
color: #64748b;
background: #f8fafc;
border: 1px dashed #cbd5e1;
border-radius: 10px;
padding: 16px;
}
.workspace-menu-item-danger {
color: #b91c1c;
.workspace-management-content h4 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 700;
}
.workspace-menu-item-danger:hover {
background: #fef2f2;
.workspace-management-meta {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
margin-bottom: 8px;
}
.workspace-management-summary {
margin: 0;
font-size: 14px;
color: #475569;
line-height: 1.55;
}
.workspace-management-actions {
margin-top: 14px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn-primary {
background: #1d4ed8;
@@ -884,6 +914,9 @@ header .logout-btn:hover {
flex-direction: column;
align-items: flex-start;
}
.workspace-management-header {
flex-direction: column;
}
.overview-stats {
grid-template-columns: 1fr;
}
+104 -46
View File
@@ -10,18 +10,63 @@
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 workspaceActionLabel(workspace) {
return workspace.state === "active" ? "Suspend workspace" : "Delete workspace";
}
function workspaceSummary(workspace) {
if (workspace.health_status === "healthy") return "Live updates and health checks are currently good.";
if (workspace.health_status === "unhealthy") return "This workspace needs attention before it is trustworthy.";
return "This workspace is still waiting on a completed health check.";
}
function workspaceMeta(workspace) {
var parts = [workspace.state];
if (workspace.health_status) parts.push(workspace.health_status);
if (workspace.created_at) {
var date = new Date(workspace.created_at);
if (!Number.isNaN(date.getTime())) {
parts.push("Created " + date.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" }));
}
}
return parts.join(" \xB7 ");
}
function findWorkspace(account, workspaceID) {
for (var i = 0; i < account.workspaces.length; i += 1) {
if (account.workspaces[i].id === workspaceID) return account.workspaces[i];
}
return null;
}
function renderWorkspaceManagement(account, entry) {
var panel = getElement("workspace-management-" + account.id);
if (!panel) return;
var empty = getElement("workspace-management-empty-" + account.id);
var content = getElement("workspace-management-content-" + account.id);
var title = getElement("workspace-management-title-" + account.id);
var meta = getElement("workspace-management-meta-" + account.id);
var summary = getElement("workspace-management-summary-" + account.id);
var actionButton = getElement("workspace-management-action-" + account.id);
var closeButton = getElement("workspace-management-close-" + account.id);
if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return;
var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null;
var hasSelection = !!workspace;
panel.classList.toggle("visible", hasSelection);
empty.hidden = hasSelection;
content.hidden = !hasSelection;
if (!workspace) {
actionButton.disabled = false;
actionButton.removeAttribute("data-workspace-id");
actionButton.removeAttribute("data-workspace-name");
actionButton.removeAttribute("data-workspace-action");
return;
}
title.textContent = workspace.display_name;
meta.textContent = workspaceMeta(workspace);
summary.textContent = workspaceSummary(workspace);
actionButton.textContent = workspaceActionLabel(workspace);
actionButton.disabled = entry.manageWorkspace.pending;
actionButton.setAttribute("data-workspace-id", workspace.id);
actionButton.setAttribute("data-workspace-name", workspace.display_name);
actionButton.setAttribute("data-workspace-action", workspace.state === "active" ? "suspend" : "delete");
closeButton.disabled = entry.manageWorkspace.pending;
}
function setTbodyMessage(tbody, msg, isError) {
tbody.textContent = "";
@@ -113,13 +158,20 @@
tbody.appendChild(tr);
}
}
function renderAccountUI(accountState) {
function renderAccountUI(accountState, accounts) {
var accountIDs = Object.keys(accountState.byAccountID);
for (var i = 0; i < accountIDs.length; i += 1) {
var accountID = accountIDs[i];
var entry = accountState.byAccountID[accountID];
var account = null;
for (var j = 0; j < accounts.length; j += 1) {
if (accounts[j].id === accountID) {
account = accounts[j];
break;
}
}
renderAddWorkspaceSection(accountID, entry);
renderWorkspaceMenus(accountID, entry);
if (account) renderWorkspaceManagement(account, entry);
renderTeamSection(accountID, entry);
}
}
@@ -130,9 +182,6 @@
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") || "";
@@ -157,13 +206,17 @@
event.preventDefault();
void deps.runtime.createWorkspace(accountID);
return;
case "toggle-workspace-menu":
case "select-workspace":
event.preventDefault();
deps.runtime.toggleWorkspaceMenu(
deps.runtime.selectWorkspace(
accountID,
actionEl.getAttribute("data-workspace-id") || ""
);
return;
case "clear-workspace-selection":
event.preventDefault();
deps.runtime.clearWorkspaceSelection(accountID);
return;
case "workspace-action":
event.preventDefault();
void deps.runtime.manageWorkspaceAction(
@@ -424,7 +477,8 @@
accountState.byAccountID[accountID] = {
addWorkspaceOpen: false,
createWorkspace: createMutationState(),
openWorkspaceMenuID: "",
selectedWorkspaceID: "",
manageWorkspace: createMutationState(),
teamVisible: false,
teamQuery: createQueryState([])
};
@@ -538,15 +592,7 @@
return true;
};
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 = "";
}
});
renderAccountUI(deps.store.getAccountState(), deps.store.getBootstrap().accounts || []);
};
var loadTeam = async function(accountID) {
var section = getElement("team-section-" + accountID);
@@ -588,7 +634,6 @@
var shouldFocus = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.openWorkspaceMenuID = "";
entry.addWorkspaceOpen = !entry.addWorkspaceOpen;
shouldFocus = entry.addWorkspaceOpen;
});
@@ -596,17 +641,16 @@
focusElement("ws-name-" + accountID);
}
};
var toggleWorkspaceMenu = function(accountID, workspaceID) {
var selectWorkspace = 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;
entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? "" : workspaceID;
});
};
var clearWorkspaceSelection = function(accountID) {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.selectedWorkspaceID = "";
});
};
var createWorkspace = async function(accountID) {
@@ -633,7 +677,6 @@
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.addWorkspaceOpen = false;
entry.openWorkspaceMenuID = "";
succeedMutationState(entry.createWorkspace);
});
deps.showToast("Workspace created!");
@@ -647,10 +690,13 @@
}
};
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;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
beginMutationState(entry.manageWorkspace);
});
try {
if (action === "suspend") {
await deps.api.suspendWorkspace(accountID, tenantID);
@@ -658,10 +704,23 @@
await deps.api.deleteWorkspace(accountID, tenantID);
}
if (!await refreshOrRedirect()) {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
resetMutationState(entry.manageWorkspace);
}, { notify: false });
return;
}
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.selectedWorkspaceID = "";
succeedMutationState(entry.manageWorkspace);
});
deps.showToast(verb + "ed workspace.");
} catch (error) {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
failMutationState(entry.manageWorkspace, error instanceof Error ? error.message : "Failed to " + verb.toLowerCase() + " workspace.");
}, { notify: false });
deps.showToast(error instanceof Error ? error.message : "Failed to " + verb.toLowerCase() + " workspace.", true);
}
};
@@ -681,7 +740,6 @@
var nextVisible = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.openWorkspaceMenuID = "";
entry.teamVisible = !entry.teamVisible;
nextVisible = entry.teamVisible;
});
@@ -750,8 +808,8 @@
deps.store.subscribeBootstrap(renderAccountRuntime);
return {
toggleAddWorkspace,
toggleWorkspaceMenu,
closeWorkspaceMenus,
selectWorkspace,
clearWorkspaceSelection,
openBilling,
toggleTeam,
inviteMember,
@@ -1501,9 +1559,7 @@
}
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 = '<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>";
manageAction = '<button type="button" class="btn-secondary btn-workspace-manage" data-action="select-workspace" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '">Manage</button>';
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + "</span>" : "";
return '<div class="workspace-card"><div class="ws-info"><div class="ws-title-row"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span></div><div class="ws-meta">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div><div class="ws-summary">' + escapeHTML(healthSummary) + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
@@ -1527,14 +1583,16 @@
var actions = "";
var teamSection = "";
var addWorkspaceForm = "";
var workspaceManagement = "";
if (account.can_manage) {
actions = '<div class="account-operations-panel"><div class="account-operations-copy"><h3>Account operations</h3><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
teamSection = '<div class="team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><h3>Team members</h3><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table><div class="team-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div>';
workspaceManagement = '<div class="workspace-management-panel" id="workspace-management-' + escapeAttr(account.id) + '"><div class="workspace-management-header"><div><h3>Workspace management</h3><p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p></div><button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' + escapeAttr(account.id) + '" data-action="clear-workspace-selection" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="workspace-management-empty" id="workspace-management-empty-' + escapeAttr(account.id) + '">Choose a workspace to manage from the fleet above.</div><div class="workspace-management-content" id="workspace-management-content-' + escapeAttr(account.id) + '" hidden><div class="workspace-management-meta" id="workspace-management-meta-' + escapeAttr(account.id) + '"></div><h4 id="workspace-management-title-' + escapeAttr(account.id) + '"></h4><p class="workspace-management-summary" id="workspace-management-summary-' + escapeAttr(account.id) + '"></p><div class="workspace-management-actions"><button type="button" class="btn-danger" id="workspace-management-action-' + escapeAttr(account.id) + '" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '">Manage workspace</button></div></div></div>';
if (account.kind === "msp") {
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (e.g. client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
}
}
return '<section class="account-section"><div class="account-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-status-grid"><div class="account-stat-card"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + '<div class="account-subsection-header"><h3>Workspace fleet</h3><p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p></div>' + workspaceHTML + teamSection + addWorkspaceForm + "</section>";
return '<section class="account-section"><div class="account-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-status-grid"><div class="account-stat-card"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + '<div class="account-subsection-header"><h3>Workspace fleet</h3><p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p></div>' + workspaceHTML + workspaceManagement + teamSection + addWorkspaceForm + "</section>";
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {
@@ -11,8 +11,8 @@ 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(),
selectWorkspace: vi.fn(),
clearWorkspaceSelection: vi.fn(),
openBilling: vi.fn(),
toggleTeam: vi.fn(),
inviteMember: vi.fn(),
@@ -30,10 +30,9 @@ 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>' +
'<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="select" data-action="select-workspace" data-account-id="acct_1" data-workspace-id="ws_1">Manage</button>' +
'<button id="close" data-action="clear-workspace-selection" data-account-id="acct_1">Done</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>';
@@ -52,8 +51,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('select')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(runtime.selectWorkspace).toHaveBeenCalledWith('acct_1', 'ws_1');
document.getElementById('close')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(runtime.clearWorkspaceSelection).toHaveBeenCalledWith('acct_1');
document.getElementById('manage')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(runtime.manageWorkspaceAction).toHaveBeenCalledWith('acct_1', 'ws_1', 'suspend', 'Alpha');
@@ -65,8 +67,5 @@ 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();
});
});
@@ -10,9 +10,6 @@ export function installAccountController(deps: AccountControllerDeps): void {
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') || '';
@@ -38,13 +35,17 @@ export function installAccountController(deps: AccountControllerDeps): void {
event.preventDefault();
void deps.runtime.createWorkspace(accountID);
return;
case 'toggle-workspace-menu':
case 'select-workspace':
event.preventDefault();
deps.runtime.toggleWorkspaceMenu(
deps.runtime.selectWorkspace(
accountID,
actionEl.getAttribute('data-workspace-id') || '',
);
return;
case 'clear-workspace-selection':
event.preventDefault();
deps.runtime.clearWorkspaceSelection(accountID);
return;
case 'workspace-action':
event.preventDefault();
void deps.runtime.manageWorkspaceAction(
@@ -81,6 +81,7 @@ describe('account runtime', function() {
'<div id="add-ws-form-acct_1" class="add-workspace-form">' +
'<input id="ws-name-acct_1" value="Acme Corp">' +
'<div id="ws-spinner-acct_1" hidden></div>' +
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><button id="workspace-management-action-acct_1"></button></div></div>' +
'</div>';
runtime.toggleAddWorkspace('acct_1');
@@ -106,25 +107,47 @@ 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() {
it('selects a workspace and routes explicit workspace actions through the management panel', async function() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
var confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deps.store.setBootstrap({
authenticated: true,
email: 'owner@example.com',
accounts: [{
id: 'acct_1',
name: 'Acme MSP',
kind: 'msp',
kind_label: 'MSP',
role: 'owner',
can_manage: true,
has_billing: true,
workspaces: [{
id: 'ws_2',
display_name: 'Alpha Workspace',
state: 'active',
healthy: true,
health_status: 'healthy',
}],
}],
});
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>';
'<div id="workspace-management-acct_1" class="workspace-management-panel">' +
'<button id="workspace-management-close-acct_1"></button>' +
'<div id="workspace-management-empty-acct_1"></div>' +
'<div id="workspace-management-content-acct_1" hidden>' +
'<div id="workspace-management-meta-acct_1"></div>' +
'<h4 id="workspace-management-title-acct_1"></h4>' +
'<p id="workspace-management-summary-acct_1"></p>' +
'<button id="workspace-management-action-acct_1"></button>' +
'</div>' +
'</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.selectWorkspace('acct_1', 'ws_2');
expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('ws_2');
expect(document.getElementById('workspace-management-title-acct_1')?.textContent).toContain('Alpha Workspace');
expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Suspend workspace');
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();
@@ -138,7 +161,7 @@ describe('account runtime', function() {
})
);
expect(deps.showToast).toHaveBeenCalledWith('Suspended workspace.');
expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe('');
expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('');
});
it('loads and updates team membership from runtime actions', async function() {
@@ -151,6 +174,7 @@ describe('account runtime', function() {
);
document.body.innerHTML =
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
'<table><tbody id="team-list-acct_1"></tbody></table>' +
'<input id="invite-email-acct_1">' +
@@ -15,8 +15,8 @@ export interface AccountRuntimeDeps {
export interface AccountRuntime {
toggleAddWorkspace(accountID: string): void;
toggleWorkspaceMenu(accountID: string, workspaceID: string): void;
closeWorkspaceMenus(): void;
selectWorkspace(accountID: string, workspaceID: string): void;
clearWorkspaceSelection(accountID: string): void;
openBilling(accountID: string): Promise<void>;
toggleTeam(accountID: string): void;
inviteMember(accountID: string): Promise<void>;
@@ -40,16 +40,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
};
var renderAccountRuntime = function(): void {
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 = '';
}
});
renderAccountUIState(deps.store.getAccountState(), deps.store.getBootstrap().accounts || []);
};
var loadTeam = async function(accountID: string): Promise<void> {
@@ -94,7 +85,6 @@ 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;
});
@@ -103,17 +93,17 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
}
};
var toggleWorkspaceMenu = function(accountID: string, workspaceID: string): void {
var selectWorkspace = 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;
entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? '' : workspaceID;
});
};
var clearWorkspaceSelection = function(accountID: string): void {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.selectedWorkspaceID = '';
});
};
@@ -141,7 +131,6 @@ 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!');
@@ -156,10 +145,13 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
};
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;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
beginMutationState(entry.manageWorkspace);
});
try {
if (action === 'suspend') {
await deps.api.suspendWorkspace(accountID, tenantID);
@@ -167,10 +159,23 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
await deps.api.deleteWorkspace(accountID, tenantID);
}
if (!await refreshOrRedirect()) {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
resetMutationState(entry.manageWorkspace);
}, { notify: false });
return;
}
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.selectedWorkspaceID = '';
succeedMutationState(entry.manageWorkspace);
});
deps.showToast(verb + 'ed workspace.');
} catch (error) {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
failMutationState(entry.manageWorkspace, error instanceof Error ? error.message : 'Failed to ' + verb.toLowerCase() + ' workspace.');
}, { notify: false });
deps.showToast(error instanceof Error ? error.message : 'Failed to ' + verb.toLowerCase() + ' workspace.', true);
}
};
@@ -192,7 +197,6 @@ 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;
});
@@ -266,8 +270,8 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
return {
toggleAddWorkspace: toggleAddWorkspace,
toggleWorkspaceMenu: toggleWorkspaceMenu,
closeWorkspaceMenus: closeWorkspaceMenus,
selectWorkspace: selectWorkspace,
clearWorkspaceSelection: clearWorkspaceSelection,
openBilling: openBilling,
toggleTeam: toggleTeam,
inviteMember: inviteMember,
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceMenus } from './account_view';
import type { PortalAccountState, PortalAccountUIEntry } from './types';
import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceManagement } from './account_view';
import type { PortalAccountState, PortalAccountSummary, PortalAccountUIEntry } from './types';
function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): PortalAccountUIEntry {
return {
@@ -10,7 +10,11 @@ function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): PortalAccou
pending: false,
error: '',
},
openWorkspaceMenuID: '',
selectedWorkspaceID: '',
manageWorkspace: {
pending: false,
error: '',
},
teamVisible: false,
teamQuery: {
status: 'idle',
@@ -26,6 +30,29 @@ describe('account view', function() {
document.body.innerHTML = '';
});
function createAccount(overrides: Partial<PortalAccountSummary> = {}): PortalAccountSummary {
return {
id: 'acct_1',
name: 'Acme MSP',
kind: 'msp',
kind_label: 'MSP',
role: 'owner',
can_manage: true,
has_billing: true,
workspaces: [
{
id: 'ws_1',
display_name: 'Alpha Workspace',
state: 'active',
healthy: true,
health_status: 'healthy',
created_at: '2026-03-26T10:00:00Z',
},
],
...overrides,
};
}
it('renders add-workspace visibility from account UI state', function() {
document.body.innerHTML =
'<div id="add-ws-form-acct_1" class="add-workspace-form"></div>' +
@@ -78,25 +105,34 @@ describe('account view', function() {
expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull();
});
it('renders workspace menu visibility from account UI state', function() {
it('renders workspace management selection 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>';
'<div id="workspace-management-acct_1" class="workspace-management-panel">' +
'<button id="workspace-management-close-acct_1"></button>' +
'<div id="workspace-management-empty-acct_1"></div>' +
'<div id="workspace-management-content-acct_1" hidden>' +
'<div id="workspace-management-meta-acct_1"></div>' +
'<h4 id="workspace-management-title-acct_1"></h4>' +
'<p id="workspace-management-summary-acct_1"></p>' +
'<button id="workspace-management-action-acct_1"></button>' +
'</div>' +
'</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');
renderWorkspaceManagement(createAccount(), createEntry({ selectedWorkspaceID: 'ws_1' }));
expect(document.getElementById('workspace-management-acct_1')?.classList.contains('visible')).toBe(true);
expect((document.getElementById('workspace-management-empty-acct_1') as HTMLElement).hidden).toBe(true);
expect((document.getElementById('workspace-management-content-acct_1') as HTMLElement).hidden).toBe(false);
expect(document.getElementById('workspace-management-title-acct_1')?.textContent).toContain('Alpha Workspace');
expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Suspend workspace');
});
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>' +
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="team-section-acct_1" class="team-section" data-actor-role="admin"><table><tbody id="team-list-acct_1"></tbody></table></div>' +
'<div id="add-ws-form-acct_2" class="add-workspace-form"></div><div id="ws-spinner-acct_2"></div>' +
'<div id="workspace-management-acct_2" class="workspace-management-panel"><button id="workspace-management-close-acct_2"></button><div id="workspace-management-empty-acct_2"></div><div id="workspace-management-content-acct_2" hidden><div id="workspace-management-meta-acct_2"></div><h4 id="workspace-management-title-acct_2"></h4><p id="workspace-management-summary-acct_2"></p><button id="workspace-management-action-acct_2"></button></div></div>' +
'<div id="team-section-acct_2" class="team-section" data-actor-role="owner"><table><tbody id="team-list-acct_2"></tbody></table></div>';
var state: PortalAccountState = {
@@ -104,6 +140,7 @@ describe('account view', function() {
acct_1: createEntry({ addWorkspaceOpen: true, teamVisible: true }),
acct_2: createEntry({
addWorkspaceOpen: false,
selectedWorkspaceID: 'ws_2',
teamVisible: true,
teamQuery: {
status: 'ready',
@@ -114,10 +151,26 @@ describe('account view', function() {
},
};
renderAccountUI(state);
renderAccountUI(state, [
createAccount(),
createAccount({
id: 'acct_2',
name: 'Beta MSP',
workspaces: [
{
id: 'ws_2',
display_name: 'Beta Workspace',
state: 'failed',
healthy: false,
health_status: 'unhealthy',
},
],
}),
]);
expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(true);
expect(document.getElementById('add-ws-form-acct_2')?.classList.contains('visible')).toBe(false);
expect(document.getElementById('team-list-acct_2')?.textContent).toContain('tech@example.com');
expect(document.getElementById('workspace-management-title-acct_2')?.textContent).toContain('Beta Workspace');
});
});
@@ -1,4 +1,10 @@
import type { PortalAccountState, PortalAccountUIEntry, PortalTeamMember } from './types';
import type {
PortalAccountState,
PortalAccountSummary,
PortalAccountUIEntry,
PortalTeamMember,
PortalWorkspaceSummary,
} from './types';
type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
@@ -15,18 +21,70 @@ 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 workspaceActionLabel(workspace: PortalWorkspaceSummary): string {
return workspace.state === 'active' ? 'Suspend workspace' : 'Delete workspace';
}
function workspaceSummary(workspace: PortalWorkspaceSummary): string {
if (workspace.health_status === 'healthy') return 'Live updates and health checks are currently good.';
if (workspace.health_status === 'unhealthy') return 'This workspace needs attention before it is trustworthy.';
return 'This workspace is still waiting on a completed health check.';
}
function workspaceMeta(workspace: PortalWorkspaceSummary): string {
var parts = [workspace.state];
if (workspace.health_status) parts.push(workspace.health_status);
if (workspace.created_at) {
var date = new Date(workspace.created_at);
if (!Number.isNaN(date.getTime())) {
parts.push('Created ' + date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }));
}
}
return parts.join(' · ');
}
function findWorkspace(account: PortalAccountSummary, workspaceID: string): PortalWorkspaceSummary | null {
for (var i = 0; i < account.workspaces.length; i += 1) {
if (account.workspaces[i].id === workspaceID) return account.workspaces[i];
}
return null;
}
export function renderWorkspaceManagement(account: PortalAccountSummary, entry: PortalAccountUIEntry): void {
var panel = getElement<HTMLElement>('workspace-management-' + account.id);
if (!panel) return;
var empty = getElement<HTMLElement>('workspace-management-empty-' + account.id);
var content = getElement<HTMLElement>('workspace-management-content-' + account.id);
var title = getElement<HTMLElement>('workspace-management-title-' + account.id);
var meta = getElement<HTMLElement>('workspace-management-meta-' + account.id);
var summary = getElement<HTMLElement>('workspace-management-summary-' + account.id);
var actionButton = getElement<HTMLButtonElement>('workspace-management-action-' + account.id);
var closeButton = getElement<HTMLButtonElement>('workspace-management-close-' + account.id);
if (!empty || !content || !title || !meta || !summary || !actionButton || !closeButton) return;
var workspace = entry.selectedWorkspaceID ? findWorkspace(account, entry.selectedWorkspaceID) : null;
var hasSelection = !!workspace;
panel.classList.toggle('visible', hasSelection);
empty.hidden = hasSelection;
content.hidden = !hasSelection;
if (!workspace) {
actionButton.disabled = false;
actionButton.removeAttribute('data-workspace-id');
actionButton.removeAttribute('data-workspace-name');
actionButton.removeAttribute('data-workspace-action');
return;
}
title.textContent = workspace.display_name;
meta.textContent = workspaceMeta(workspace);
summary.textContent = workspaceSummary(workspace);
actionButton.textContent = workspaceActionLabel(workspace);
actionButton.disabled = entry.manageWorkspace.pending;
actionButton.setAttribute('data-workspace-id', workspace.id);
actionButton.setAttribute('data-workspace-name', workspace.display_name);
actionButton.setAttribute('data-workspace-action', workspace.state === 'active' ? 'suspend' : 'delete');
closeButton.disabled = entry.manageWorkspace.pending;
}
function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): void {
@@ -129,13 +187,20 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry
}
}
export function renderAccountUI(accountState: PortalAccountState): void {
export function renderAccountUI(accountState: PortalAccountState, accounts: PortalAccountSummary[]): void {
var accountIDs = Object.keys(accountState.byAccountID);
for (var i = 0; i < accountIDs.length; i += 1) {
var accountID = accountIDs[i];
var entry = accountState.byAccountID[accountID];
var account = null as PortalAccountSummary | null;
for (var j = 0; j < accounts.length; j += 1) {
if (accounts[j].id === accountID) {
account = accounts[j];
break;
}
}
renderAddWorkspaceSection(accountID, entry);
renderWorkspaceMenus(accountID, entry);
if (account) renderWorkspaceManagement(account, entry);
renderTeamSection(accountID, entry);
}
}
@@ -147,7 +147,10 @@ describe('shell view', function() {
expect(html).toContain('This workspace is still waiting on a completed health check.');
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff');
expect(html).toContain('Open workspace');
expect(html).toContain('data-action="toggle-workspace-menu"');
expect(html).toContain('data-action="select-workspace"');
expect(html).toContain('Workspace management');
expect(html).toContain('Choose a workspace to manage from the fleet above.');
expect(html).toContain('data-action="clear-workspace-selection"');
expect(html).toContain('data-action="workspace-action"');
expect(html).toContain('service-card-button');
expect(html).toContain('id="open-retrieve-service"');
@@ -152,41 +152,14 @@ 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 =
'<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="' +
'<button type="button" class="btn-secondary btn-workspace-manage" data-action="select-workspace" 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>';
'">' +
'Manage' +
'</button>';
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + '</span>' : '';
@@ -236,6 +209,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
var actions = '';
var teamSection = '';
var addWorkspaceForm = '';
var workspaceManagement = '';
if (account.can_manage) {
actions =
'<div class="account-operations-panel">' +
@@ -296,6 +270,46 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'</div>' +
'</div>';
workspaceManagement =
'<div class="workspace-management-panel" id="workspace-management-' +
escapeAttr(account.id) +
'">' +
'<div class="workspace-management-header">' +
'<div>' +
'<h3>Workspace management</h3>' +
'<p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' +
escapeAttr(account.id) +
'" data-action="clear-workspace-selection" data-account-id="' +
escapeAttr(account.id) +
'">Done</button>' +
'</div>' +
'<div class="workspace-management-empty" id="workspace-management-empty-' +
escapeAttr(account.id) +
'">Choose a workspace to manage from the fleet above.</div>' +
'<div class="workspace-management-content" id="workspace-management-content-' +
escapeAttr(account.id) +
'" hidden>' +
'<div class="workspace-management-meta" id="workspace-management-meta-' +
escapeAttr(account.id) +
'"></div>' +
'<h4 id="workspace-management-title-' +
escapeAttr(account.id) +
'"></h4>' +
'<p class="workspace-management-summary" id="workspace-management-summary-' +
escapeAttr(account.id) +
'"></p>' +
'<div class="workspace-management-actions">' +
'<button type="button" class="btn-danger" id="workspace-management-action-' +
escapeAttr(account.id) +
'" data-action="workspace-action" data-account-id="' +
escapeAttr(account.id) +
'">Manage workspace</button>' +
'</div>' +
'</div>' +
'</div>';
if (account.kind === 'msp') {
addWorkspaceForm =
'<div class="add-workspace-form" id="add-ws-form-' +
@@ -359,6 +373,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'<p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p>' +
'</div>' +
workspaceHTML +
workspaceManagement +
teamSection +
addWorkspaceForm +
'</section>'
@@ -68,7 +68,8 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc
accountState.byAccountID[accountID] = {
addWorkspaceOpen: false,
createWorkspace: createMutationState(),
openWorkspaceMenuID: '',
selectedWorkspaceID: '',
manageWorkspace: createMutationState(),
teamVisible: false,
teamQuery: createQueryState<PortalTeamMember[]>([]),
};
@@ -47,13 +47,17 @@
.ws-summary { font-size: 13px; color: #475569; line-height: 1.45; }
.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-workspace-manage { min-width: 112px; }
.workspace-management-panel { margin-top: 14px; background: #fff; border: 1px solid #dbe4f0; border-radius: 12px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.workspace-management-panel.visible { border-color: #bfdbfe; box-shadow: 0 10px 24px rgba(29,78,216,.08); }
.workspace-management-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.workspace-management-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; }
.workspace-management-header p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; max-width: 560px; }
.workspace-management-empty { font-size: 14px; color: #64748b; background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 10px; padding: 16px; }
.workspace-management-content h4 { margin: 0 0 8px; font-size: 18px; font-weight: 700; }
.workspace-management-meta { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; color: #64748b; margin-bottom: 8px; }
.workspace-management-summary { margin: 0; font-size: 14px; color: #475569; line-height: 1.55; }
.workspace-management-actions { margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
.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; }
@@ -158,6 +162,7 @@
.account-operations-panel { flex-direction: column; }
.account-actions { justify-content: flex-start; }
.account-subsection-header { flex-direction: column; align-items: flex-start; }
.workspace-management-header { flex-direction: column; }
.overview-stats { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
@@ -63,7 +63,8 @@ export interface PortalMutationState {
export interface PortalAccountUIEntry {
addWorkspaceOpen: boolean;
createWorkspace: PortalMutationState;
openWorkspaceMenuID: string;
selectedWorkspaceID: string;
manageWorkspace: PortalMutationState;
teamVisible: boolean;
teamQuery: PortalQueryState<PortalTeamMember[]>;
}