mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
feat(portal): add explicit workspace management panel
This commit is contained in:
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "f091b6dbbdf775a750bbba5c4047fbba2553fa0393420916e2e9e5ca315e1437",
|
||||
"source_hash": "9e66ef00fa2623eb7e00f30d4cd4a823bfb4552eeb89136392577ad25b7caad2",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+62
-29
@@ -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
@@ -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[]>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user