mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
fix(portal): replace fake workspace overflow with explicit actions
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "a68f28330c399b792cc04898ffa0d0571018bdb7047dcfc6224a77cd503e3801",
|
||||
"source_hash": "1c0e03c2843117f80f2e4854bdc42da2385c33944b2fef1653a5f8bbbbccbab6",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+38
@@ -176,6 +176,44 @@ header .logout-btn:hover {
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.workspace-menu-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.btn-workspace-menu {
|
||||
min-width: 96px;
|
||||
}
|
||||
.workspace-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
min-width: 190px;
|
||||
background: #fff;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, .12);
|
||||
}
|
||||
.workspace-menu-item {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 9px 12px;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: #0f172a;
|
||||
}
|
||||
.workspace-menu-item:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.workspace-menu-item-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
.workspace-menu-item-danger:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #1d4ed8;
|
||||
color: #fff;
|
||||
|
||||
+70
-15
@@ -10,6 +10,19 @@
|
||||
var input = getElement(id);
|
||||
if (input) input.focus();
|
||||
}
|
||||
function renderWorkspaceMenus(accountID, entry) {
|
||||
var menus = document.querySelectorAll('[data-workspace-menu-account-id="' + accountID + '"]');
|
||||
for (var i = 0; i < menus.length; i += 1) {
|
||||
var menu = menus[i];
|
||||
var workspaceID = menu.getAttribute("data-workspace-id") || "";
|
||||
var open = entry.openWorkspaceMenuID === workspaceID;
|
||||
menu.hidden = !open;
|
||||
var button = getElement("workspace-menu-button-" + accountID + "-" + workspaceID);
|
||||
if (button) {
|
||||
button.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
}
|
||||
}
|
||||
}
|
||||
function setTbodyMessage(tbody, msg, isError) {
|
||||
tbody.textContent = "";
|
||||
var tr = document.createElement("tr");
|
||||
@@ -106,6 +119,7 @@
|
||||
var accountID = accountIDs[i];
|
||||
var entry = accountState.byAccountID[accountID];
|
||||
renderAddWorkspaceSection(accountID, entry);
|
||||
renderWorkspaceMenus(accountID, entry);
|
||||
renderTeamSection(accountID, entry);
|
||||
}
|
||||
}
|
||||
@@ -113,7 +127,12 @@
|
||||
// src/account_controller.ts
|
||||
function installAccountController(deps) {
|
||||
document.addEventListener("click", function(event) {
|
||||
var actionEl = asHTMLElement(event.target)?.closest("[data-action]");
|
||||
var target = asHTMLElement(event.target);
|
||||
if (!target) return;
|
||||
var actionEl = target.closest("[data-action]");
|
||||
if (!target.closest(".workspace-menu-wrap")) {
|
||||
deps.runtime.closeWorkspaceMenus();
|
||||
}
|
||||
if (!actionEl) return;
|
||||
var action = actionEl.getAttribute("data-action") || "";
|
||||
var accountID = actionEl.getAttribute("data-account-id") || "";
|
||||
@@ -138,13 +157,19 @@
|
||||
event.preventDefault();
|
||||
void deps.runtime.createWorkspace(accountID);
|
||||
return;
|
||||
case "workspace-manage":
|
||||
case "toggle-workspace-menu":
|
||||
event.preventDefault();
|
||||
void deps.runtime.manageWorkspace(
|
||||
event,
|
||||
deps.runtime.toggleWorkspaceMenu(
|
||||
accountID,
|
||||
actionEl.getAttribute("data-workspace-id") || ""
|
||||
);
|
||||
return;
|
||||
case "workspace-action":
|
||||
event.preventDefault();
|
||||
void deps.runtime.manageWorkspaceAction(
|
||||
accountID,
|
||||
actionEl.getAttribute("data-workspace-id") || "",
|
||||
actionEl.getAttribute("data-workspace-state") || "",
|
||||
actionEl.getAttribute("data-workspace-action") || "",
|
||||
actionEl.getAttribute("data-workspace-name") || ""
|
||||
);
|
||||
return;
|
||||
@@ -399,6 +424,7 @@
|
||||
accountState.byAccountID[accountID] = {
|
||||
addWorkspaceOpen: false,
|
||||
createWorkspace: createMutationState(),
|
||||
openWorkspaceMenuID: "",
|
||||
teamVisible: false,
|
||||
teamQuery: createQueryState([])
|
||||
};
|
||||
@@ -514,6 +540,14 @@
|
||||
var renderAccountRuntime = function() {
|
||||
renderAccountUI(deps.store.getAccountState());
|
||||
};
|
||||
var closeWorkspaceMenus = function() {
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var accountIDs = Object.keys(accountState.byAccountID);
|
||||
for (var i = 0; i < accountIDs.length; i += 1) {
|
||||
accountState.byAccountID[accountIDs[i]].openWorkspaceMenuID = "";
|
||||
}
|
||||
});
|
||||
};
|
||||
var loadTeam = async function(accountID) {
|
||||
var section = getElement("team-section-" + accountID);
|
||||
if (!section) return;
|
||||
@@ -554,6 +588,7 @@
|
||||
var shouldFocus = false;
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.openWorkspaceMenuID = "";
|
||||
entry.addWorkspaceOpen = !entry.addWorkspaceOpen;
|
||||
shouldFocus = entry.addWorkspaceOpen;
|
||||
});
|
||||
@@ -561,6 +596,19 @@
|
||||
focusElement("ws-name-" + accountID);
|
||||
}
|
||||
};
|
||||
var toggleWorkspaceMenu = function(accountID, workspaceID) {
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var accountIDs = Object.keys(accountState.byAccountID);
|
||||
for (var i = 0; i < accountIDs.length; i += 1) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountIDs[i]);
|
||||
if (accountIDs[i] !== accountID) {
|
||||
entry.openWorkspaceMenuID = "";
|
||||
}
|
||||
}
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.openWorkspaceMenuID = entry.openWorkspaceMenuID === workspaceID ? "" : workspaceID;
|
||||
});
|
||||
};
|
||||
var createWorkspace = async function(accountID) {
|
||||
var nameEl = getElement("ws-name-" + accountID);
|
||||
if (!nameEl) return;
|
||||
@@ -585,6 +633,7 @@
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.addWorkspaceOpen = false;
|
||||
entry.openWorkspaceMenuID = "";
|
||||
succeedMutationState(entry.createWorkspace);
|
||||
});
|
||||
deps.showToast("Workspace created!");
|
||||
@@ -597,12 +646,13 @@
|
||||
deps.showToast(message, true);
|
||||
}
|
||||
};
|
||||
var manageWorkspace = async function(evt, accountID, tenantID, state, name) {
|
||||
evt.stopPropagation();
|
||||
var action = state === "active" ? "Suspend" : "Delete";
|
||||
if (!window.confirm(action + ' workspace "' + name + '"?')) return;
|
||||
var manageWorkspaceAction = async function(accountID, tenantID, action, name) {
|
||||
closeWorkspaceMenus();
|
||||
var verb = action === "suspend" ? "Suspend" : action === "delete" ? "Delete" : "";
|
||||
if (!verb) return;
|
||||
if (!window.confirm(verb + ' workspace "' + name + '"?')) return;
|
||||
try {
|
||||
if (state === "active") {
|
||||
if (action === "suspend") {
|
||||
await deps.api.suspendWorkspace(accountID, tenantID);
|
||||
} else {
|
||||
await deps.api.deleteWorkspace(accountID, tenantID);
|
||||
@@ -610,9 +660,9 @@
|
||||
if (!await refreshOrRedirect()) {
|
||||
return;
|
||||
}
|
||||
deps.showToast(action + "d workspace.");
|
||||
deps.showToast(verb + "ed workspace.");
|
||||
} catch (error) {
|
||||
deps.showToast(error instanceof Error ? error.message : "Failed to " + action.toLowerCase() + " workspace.", true);
|
||||
deps.showToast(error instanceof Error ? error.message : "Failed to " + verb.toLowerCase() + " workspace.", true);
|
||||
}
|
||||
};
|
||||
var openBilling = async function(accountID) {
|
||||
@@ -631,6 +681,7 @@
|
||||
var nextVisible = false;
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.openWorkspaceMenuID = "";
|
||||
entry.teamVisible = !entry.teamVisible;
|
||||
nextVisible = entry.teamVisible;
|
||||
});
|
||||
@@ -699,11 +750,13 @@
|
||||
deps.store.subscribeBootstrap(renderAccountRuntime);
|
||||
return {
|
||||
toggleAddWorkspace,
|
||||
toggleWorkspaceMenu,
|
||||
closeWorkspaceMenus,
|
||||
openBilling,
|
||||
toggleTeam,
|
||||
inviteMember,
|
||||
createWorkspace,
|
||||
manageWorkspace,
|
||||
manageWorkspaceAction,
|
||||
removeMember,
|
||||
changeRole
|
||||
};
|
||||
@@ -1394,13 +1447,15 @@
|
||||
var createdLabel = formatWorkspaceDate(workspace.created_at);
|
||||
var openAction = "";
|
||||
if (state === "active") {
|
||||
openAction = '<form method="POST" action="' + escapeAttr(accountAPIBasePath + "/" + account.id + "/tenants/" + workspace.id + "/handoff") + '"><button type="submit" class="btn-primary">Open \u2192</button></form>';
|
||||
openAction = '<form method="POST" action="' + escapeAttr(accountAPIBasePath + "/" + account.id + "/tenants/" + workspace.id + "/handoff") + '"><button type="submit" class="btn-primary">Open workspace</button></form>';
|
||||
} else {
|
||||
openAction = '<span class="workspace-state-label">' + safeState + "</span>";
|
||||
}
|
||||
var manageAction = "";
|
||||
if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) {
|
||||
manageAction = '<button type="button" class="btn-danger" data-action="workspace-manage" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" data-workspace-state="' + escapeAttr(state) + '" data-workspace-name="' + escapeAttr(workspace.display_name) + '">\u22EF</button>';
|
||||
var menuAction = state === "active" ? "suspend" : "delete";
|
||||
var menuLabel = state === "active" ? "Suspend workspace" : "Delete workspace";
|
||||
manageAction = '<div class="workspace-menu-wrap"><button type="button" class="btn-secondary btn-workspace-menu" id="workspace-menu-button-' + escapeAttr(account.id) + "-" + escapeAttr(workspace.id) + '" data-action="toggle-workspace-menu" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" aria-haspopup="menu" aria-expanded="false">Manage</button><div class="workspace-menu" id="workspace-menu-' + escapeAttr(account.id) + "-" + escapeAttr(workspace.id) + '" data-workspace-menu-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" role="menu" hidden><button type="button" class="workspace-menu-item workspace-menu-item-danger" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '" data-workspace-action="' + escapeAttr(menuAction) + '" data-workspace-name="' + escapeAttr(workspace.display_name) + '">' + escapeHTML(menuLabel) + "</button></div></div>";
|
||||
}
|
||||
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + "</span>" : "";
|
||||
return '<div class="workspace-card"><div class="ws-info"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span><div class="ws-meta">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
|
||||
|
||||
@@ -11,11 +11,13 @@ describe('account controller', function() {
|
||||
it('routes account actions to the matching runtime handlers', function() {
|
||||
var runtime = {
|
||||
toggleAddWorkspace: vi.fn(),
|
||||
toggleWorkspaceMenu: vi.fn(),
|
||||
closeWorkspaceMenus: vi.fn(),
|
||||
openBilling: vi.fn(),
|
||||
toggleTeam: vi.fn(),
|
||||
inviteMember: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
manageWorkspace: vi.fn(),
|
||||
manageWorkspaceAction: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
changeRole: vi.fn(),
|
||||
};
|
||||
@@ -28,7 +30,10 @@ describe('account controller', function() {
|
||||
'<button id="team" data-action="toggle-team" data-account-id="acct_1">Team</button>' +
|
||||
'<button id="invite" data-action="invite-member" data-account-id="acct_1">Invite</button>' +
|
||||
'<button id="create" data-action="create-workspace" data-account-id="acct_1">Create</button>' +
|
||||
'<button id="manage" data-action="workspace-manage" data-account-id="acct_1" data-workspace-id="ws_1" data-workspace-state="active" data-workspace-name="Alpha">Manage</button>' +
|
||||
'<div class="workspace-menu-wrap">' +
|
||||
'<button id="menu" data-action="toggle-workspace-menu" data-account-id="acct_1" data-workspace-id="ws_1">Manage</button>' +
|
||||
'<button id="manage" data-action="workspace-action" data-account-id="acct_1" data-workspace-id="ws_1" data-workspace-action="suspend" data-workspace-name="Alpha">Suspend</button>' +
|
||||
'</div>' +
|
||||
'<button id="remove" data-action="remove-member" data-account-id="acct_1" data-user-id="u1" data-member-email="owner@example.com">Remove</button>' +
|
||||
'<select id="role" data-action="change-role" data-account-id="acct_1" data-user-id="u1"><option value="admin">Admin</option></select>';
|
||||
|
||||
@@ -47,8 +52,11 @@ describe('account controller', function() {
|
||||
document.getElementById('create')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(runtime.createWorkspace).toHaveBeenCalledWith('acct_1');
|
||||
|
||||
document.getElementById('menu')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(runtime.toggleWorkspaceMenu).toHaveBeenCalledWith('acct_1', 'ws_1');
|
||||
|
||||
document.getElementById('manage')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(runtime.manageWorkspace).toHaveBeenCalledWith(expect.any(MouseEvent), 'acct_1', 'ws_1', 'active', 'Alpha');
|
||||
expect(runtime.manageWorkspaceAction).toHaveBeenCalledWith('acct_1', 'ws_1', 'suspend', 'Alpha');
|
||||
|
||||
document.getElementById('remove')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(runtime.removeMember).toHaveBeenCalledWith('acct_1', 'u1', 'owner@example.com');
|
||||
@@ -57,5 +65,8 @@ describe('account controller', function() {
|
||||
role.value = 'admin';
|
||||
role.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
expect(runtime.changeRole).toHaveBeenCalledWith('acct_1', 'u1', 'admin');
|
||||
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
expect(runtime.closeWorkspaceMenus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,12 @@ export interface AccountControllerDeps {
|
||||
|
||||
export function installAccountController(deps: AccountControllerDeps): void {
|
||||
document.addEventListener('click', function(event) {
|
||||
var actionEl = asHTMLElement(event.target)?.closest('[data-action]');
|
||||
var target = asHTMLElement(event.target);
|
||||
if (!target) return;
|
||||
var actionEl = target.closest('[data-action]');
|
||||
if (!target.closest('.workspace-menu-wrap')) {
|
||||
deps.runtime.closeWorkspaceMenus();
|
||||
}
|
||||
if (!actionEl) return;
|
||||
var action = actionEl.getAttribute('data-action') || '';
|
||||
var accountID = actionEl.getAttribute('data-account-id') || '';
|
||||
@@ -33,13 +38,19 @@ export function installAccountController(deps: AccountControllerDeps): void {
|
||||
event.preventDefault();
|
||||
void deps.runtime.createWorkspace(accountID);
|
||||
return;
|
||||
case 'workspace-manage':
|
||||
case 'toggle-workspace-menu':
|
||||
event.preventDefault();
|
||||
void deps.runtime.manageWorkspace(
|
||||
event,
|
||||
deps.runtime.toggleWorkspaceMenu(
|
||||
accountID,
|
||||
actionEl.getAttribute('data-workspace-id') || '',
|
||||
actionEl.getAttribute('data-workspace-state') || '',
|
||||
);
|
||||
return;
|
||||
case 'workspace-action':
|
||||
event.preventDefault();
|
||||
void deps.runtime.manageWorkspaceAction(
|
||||
accountID,
|
||||
actionEl.getAttribute('data-workspace-id') || '',
|
||||
actionEl.getAttribute('data-workspace-action') || '',
|
||||
actionEl.getAttribute('data-workspace-name') || '',
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -106,6 +106,41 @@ describe('account runtime', function() {
|
||||
expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles workspace menus and routes explicit workspace actions', async function() {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
|
||||
var confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
document.body.innerHTML =
|
||||
'<button id="workspace-menu-button-acct_1-ws_1" aria-expanded="false"></button>' +
|
||||
'<div id="workspace-menu-acct_1-ws_1" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_1" hidden></div>' +
|
||||
'<button id="workspace-menu-button-acct_1-ws_2" aria-expanded="false"></button>' +
|
||||
'<div id="workspace-menu-acct_1-ws_2" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_2" hidden></div>';
|
||||
|
||||
runtime.toggleWorkspaceMenu('acct_1', 'ws_1');
|
||||
expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe('ws_1');
|
||||
expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(false);
|
||||
|
||||
runtime.toggleWorkspaceMenu('acct_1', 'ws_1');
|
||||
expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe('');
|
||||
expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(true);
|
||||
|
||||
runtime.toggleWorkspaceMenu('acct_1', 'ws_2');
|
||||
await runtime.manageWorkspaceAction('acct_1', 'ws_2', 'suspend', 'Alpha Workspace');
|
||||
await flushAsync();
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Suspend workspace "Alpha Workspace"?');
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/api/accounts/acct_1/tenants/ws_2',
|
||||
expect.objectContaining({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ state: 'suspended' }),
|
||||
})
|
||||
);
|
||||
expect(deps.showToast).toHaveBeenCalledWith('Suspended workspace.');
|
||||
expect(deps.store.getAccountState().byAccountID.acct_1.openWorkspaceMenuID).toBe('');
|
||||
});
|
||||
|
||||
it('loads and updates team membership from runtime actions', async function() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
@@ -15,11 +15,13 @@ export interface AccountRuntimeDeps {
|
||||
|
||||
export interface AccountRuntime {
|
||||
toggleAddWorkspace(accountID: string): void;
|
||||
toggleWorkspaceMenu(accountID: string, workspaceID: string): void;
|
||||
closeWorkspaceMenus(): void;
|
||||
openBilling(accountID: string): Promise<void>;
|
||||
toggleTeam(accountID: string): void;
|
||||
inviteMember(accountID: string): Promise<void>;
|
||||
createWorkspace(accountID: string): Promise<void>;
|
||||
manageWorkspace(event: Event, accountID: string, tenantID: string, state: string, name: string): Promise<void>;
|
||||
manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise<void>;
|
||||
removeMember(accountID: string, userID: string, email: string): Promise<void>;
|
||||
changeRole(accountID: string, userID: string, newRole: string): Promise<void>;
|
||||
}
|
||||
@@ -41,6 +43,15 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
renderAccountUIState(deps.store.getAccountState());
|
||||
};
|
||||
|
||||
var closeWorkspaceMenus = function(): void {
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var accountIDs = Object.keys(accountState.byAccountID);
|
||||
for (var i = 0; i < accountIDs.length; i += 1) {
|
||||
accountState.byAccountID[accountIDs[i]].openWorkspaceMenuID = '';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
var loadTeam = async function(accountID: string): Promise<void> {
|
||||
var section = getElement<HTMLElement>('team-section-' + accountID);
|
||||
if (!section) return;
|
||||
@@ -83,6 +94,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
var shouldFocus = false;
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.openWorkspaceMenuID = '';
|
||||
entry.addWorkspaceOpen = !entry.addWorkspaceOpen;
|
||||
shouldFocus = entry.addWorkspaceOpen;
|
||||
});
|
||||
@@ -91,6 +103,20 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
}
|
||||
};
|
||||
|
||||
var toggleWorkspaceMenu = function(accountID: string, workspaceID: string): void {
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var accountIDs = Object.keys(accountState.byAccountID);
|
||||
for (var i = 0; i < accountIDs.length; i += 1) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountIDs[i]);
|
||||
if (accountIDs[i] !== accountID) {
|
||||
entry.openWorkspaceMenuID = '';
|
||||
}
|
||||
}
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.openWorkspaceMenuID = entry.openWorkspaceMenuID === workspaceID ? '' : workspaceID;
|
||||
});
|
||||
};
|
||||
|
||||
var createWorkspace = async function(accountID: string): Promise<void> {
|
||||
var nameEl = getElement<HTMLInputElement>('ws-name-' + accountID);
|
||||
if (!nameEl) return;
|
||||
@@ -115,6 +141,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.addWorkspaceOpen = false;
|
||||
entry.openWorkspaceMenuID = '';
|
||||
succeedMutationState(entry.createWorkspace);
|
||||
});
|
||||
deps.showToast('Workspace created!');
|
||||
@@ -128,12 +155,13 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
}
|
||||
};
|
||||
|
||||
var manageWorkspace = async function(evt: Event, accountID: string, tenantID: string, state: string, name: string): Promise<void> {
|
||||
evt.stopPropagation();
|
||||
var action = state === 'active' ? 'Suspend' : 'Delete';
|
||||
if (!window.confirm(action + ' workspace "' + name + '"?')) return;
|
||||
var manageWorkspaceAction = async function(accountID: string, tenantID: string, action: string, name: string): Promise<void> {
|
||||
closeWorkspaceMenus();
|
||||
var verb = action === 'suspend' ? 'Suspend' : action === 'delete' ? 'Delete' : '';
|
||||
if (!verb) return;
|
||||
if (!window.confirm(verb + ' workspace "' + name + '"?')) return;
|
||||
try {
|
||||
if (state === 'active') {
|
||||
if (action === 'suspend') {
|
||||
await deps.api.suspendWorkspace(accountID, tenantID);
|
||||
} else {
|
||||
await deps.api.deleteWorkspace(accountID, tenantID);
|
||||
@@ -141,9 +169,9 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
if (!await refreshOrRedirect()) {
|
||||
return;
|
||||
}
|
||||
deps.showToast(action + 'd workspace.');
|
||||
deps.showToast(verb + 'ed workspace.');
|
||||
} catch (error) {
|
||||
deps.showToast(error instanceof Error ? error.message : 'Failed to ' + action.toLowerCase() + ' workspace.', true);
|
||||
deps.showToast(error instanceof Error ? error.message : 'Failed to ' + verb.toLowerCase() + ' workspace.', true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,6 +192,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
var nextVisible = false;
|
||||
deps.store.updateAccountState(function(accountState) {
|
||||
var entry = ensurePortalAccountUIEntry(accountState, accountID);
|
||||
entry.openWorkspaceMenuID = '';
|
||||
entry.teamVisible = !entry.teamVisible;
|
||||
nextVisible = entry.teamVisible;
|
||||
});
|
||||
@@ -237,11 +266,13 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
|
||||
|
||||
return {
|
||||
toggleAddWorkspace: toggleAddWorkspace,
|
||||
toggleWorkspaceMenu: toggleWorkspaceMenu,
|
||||
closeWorkspaceMenus: closeWorkspaceMenus,
|
||||
openBilling: openBilling,
|
||||
toggleTeam: toggleTeam,
|
||||
inviteMember: inviteMember,
|
||||
createWorkspace: createWorkspace,
|
||||
manageWorkspace: manageWorkspace,
|
||||
manageWorkspaceAction: manageWorkspaceAction,
|
||||
removeMember: removeMember,
|
||||
changeRole: changeRole,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection } from './account_view';
|
||||
import { renderAccountUI, renderAddWorkspaceSection, renderTeamSection, renderWorkspaceMenus } from './account_view';
|
||||
import type { PortalAccountState, PortalAccountUIEntry } from './types';
|
||||
|
||||
function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): PortalAccountUIEntry {
|
||||
@@ -10,6 +10,7 @@ function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): PortalAccou
|
||||
pending: false,
|
||||
error: '',
|
||||
},
|
||||
openWorkspaceMenuID: '',
|
||||
teamVisible: false,
|
||||
teamQuery: {
|
||||
status: 'idle',
|
||||
@@ -77,6 +78,20 @@ describe('account view', function() {
|
||||
expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders workspace menu visibility from account UI state', function() {
|
||||
document.body.innerHTML =
|
||||
'<button id="workspace-menu-button-acct_1-ws_1" aria-expanded="false"></button>' +
|
||||
'<div id="workspace-menu-acct_1-ws_1" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_1" hidden></div>' +
|
||||
'<button id="workspace-menu-button-acct_1-ws_2" aria-expanded="false"></button>' +
|
||||
'<div id="workspace-menu-acct_1-ws_2" data-workspace-menu-account-id="acct_1" data-workspace-id="ws_2" hidden></div>';
|
||||
|
||||
renderWorkspaceMenus('acct_1', createEntry({ openWorkspaceMenuID: 'ws_2' }));
|
||||
expect((document.getElementById('workspace-menu-acct_1-ws_1') as HTMLElement).hidden).toBe(true);
|
||||
expect((document.getElementById('workspace-menu-acct_1-ws_2') as HTMLElement).hidden).toBe(false);
|
||||
expect(document.getElementById('workspace-menu-button-acct_1-ws_1')?.getAttribute('aria-expanded')).toBe('false');
|
||||
expect(document.getElementById('workspace-menu-button-acct_1-ws_2')?.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('renders account UI for every tracked account entry', function() {
|
||||
document.body.innerHTML =
|
||||
'<div id="add-ws-form-acct_1" class="add-workspace-form"></div><div id="ws-spinner-acct_1"></div>' +
|
||||
|
||||
@@ -15,6 +15,20 @@ export function focusElement(id: string): void {
|
||||
if (input) input.focus();
|
||||
}
|
||||
|
||||
export function renderWorkspaceMenus(accountID: string, entry: PortalAccountUIEntry): void {
|
||||
var menus = document.querySelectorAll<HTMLElement>('[data-workspace-menu-account-id="' + accountID + '"]');
|
||||
for (var i = 0; i < menus.length; i += 1) {
|
||||
var menu = menus[i];
|
||||
var workspaceID = menu.getAttribute('data-workspace-id') || '';
|
||||
var open = entry.openWorkspaceMenuID === workspaceID;
|
||||
menu.hidden = !open;
|
||||
var button = getElement<HTMLElement>('workspace-menu-button-' + accountID + '-' + workspaceID);
|
||||
if (button) {
|
||||
button.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): void {
|
||||
tbody.textContent = '';
|
||||
var tr = document.createElement('tr');
|
||||
@@ -121,6 +135,7 @@ export function renderAccountUI(accountState: PortalAccountState): void {
|
||||
var accountID = accountIDs[i];
|
||||
var entry = accountState.byAccountID[accountID];
|
||||
renderAddWorkspaceSection(accountID, entry);
|
||||
renderWorkspaceMenus(accountID, entry);
|
||||
renderTeamSection(accountID, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +133,9 @@ describe('shell view', function() {
|
||||
expect(html).toContain('Unhealthy</span>');
|
||||
expect(html).toContain('Checking</span>');
|
||||
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff');
|
||||
expect(html).toContain('data-action="workspace-manage"');
|
||||
expect(html).toContain('Open workspace');
|
||||
expect(html).toContain('data-action="toggle-workspace-menu"');
|
||||
expect(html).toContain('data-action="workspace-action"');
|
||||
expect(html).toContain('service-card-button');
|
||||
expect(html).toContain('id="open-retrieve-service"');
|
||||
expect(html).toContain('id="data-service-panel"');
|
||||
|
||||
@@ -59,7 +59,7 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
|
||||
'<form method="POST" action="' +
|
||||
escapeAttr(accountAPIBasePath + '/' + account.id + '/tenants/' + workspace.id + '/handoff') +
|
||||
'">' +
|
||||
'<button type="submit" class="btn-primary">Open →</button>' +
|
||||
'<button type="submit" class="btn-primary">Open workspace</button>' +
|
||||
'</form>';
|
||||
} else {
|
||||
openAction = '<span class="workspace-state-label">' + safeState + '</span>';
|
||||
@@ -67,16 +67,41 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
|
||||
|
||||
var manageAction = '';
|
||||
if (account.can_manage && (state === 'active' || state === 'suspended' || state === 'failed')) {
|
||||
var menuAction = state === 'active' ? 'suspend' : 'delete';
|
||||
var menuLabel = state === 'active' ? 'Suspend workspace' : 'Delete workspace';
|
||||
manageAction =
|
||||
'<button type="button" class="btn-danger" data-action="workspace-manage" data-account-id="' +
|
||||
escapeAttr(account.id) +
|
||||
'" data-workspace-id="' +
|
||||
escapeAttr(workspace.id) +
|
||||
'" data-workspace-state="' +
|
||||
escapeAttr(state) +
|
||||
'" data-workspace-name="' +
|
||||
escapeAttr(workspace.display_name) +
|
||||
'">⋯</button>';
|
||||
'<div class="workspace-menu-wrap">' +
|
||||
'<button type="button" class="btn-secondary btn-workspace-menu" id="workspace-menu-button-' +
|
||||
escapeAttr(account.id) +
|
||||
'-' +
|
||||
escapeAttr(workspace.id) +
|
||||
'" data-action="toggle-workspace-menu" data-account-id="' +
|
||||
escapeAttr(account.id) +
|
||||
'" data-workspace-id="' +
|
||||
escapeAttr(workspace.id) +
|
||||
'" aria-haspopup="menu" aria-expanded="false">Manage</button>' +
|
||||
'<div class="workspace-menu" id="workspace-menu-' +
|
||||
escapeAttr(account.id) +
|
||||
'-' +
|
||||
escapeAttr(workspace.id) +
|
||||
'" data-workspace-menu-account-id="' +
|
||||
escapeAttr(account.id) +
|
||||
'" data-workspace-id="' +
|
||||
escapeAttr(workspace.id) +
|
||||
'" role="menu" hidden>' +
|
||||
'<button type="button" class="workspace-menu-item workspace-menu-item-danger" data-action="workspace-action" data-account-id="' +
|
||||
escapeAttr(account.id) +
|
||||
'" data-workspace-id="' +
|
||||
escapeAttr(workspace.id) +
|
||||
'" data-workspace-action="' +
|
||||
escapeAttr(menuAction) +
|
||||
'" data-workspace-name="' +
|
||||
escapeAttr(workspace.display_name) +
|
||||
'">' +
|
||||
escapeHTML(menuLabel) +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + '</span>' : '';
|
||||
|
||||
@@ -68,6 +68,7 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc
|
||||
accountState.byAccountID[accountID] = {
|
||||
addWorkspaceOpen: false,
|
||||
createWorkspace: createMutationState(),
|
||||
openWorkspaceMenuID: '',
|
||||
teamVisible: false,
|
||||
teamQuery: createQueryState<PortalTeamMember[]>([]),
|
||||
};
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
.ws-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.ws-created { font-size: 11px; color: #94a3b8; }
|
||||
.ws-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.workspace-menu-wrap { position: relative; }
|
||||
.btn-workspace-menu { min-width: 96px; }
|
||||
.workspace-menu { position: absolute; top: calc(100% + 6px); right: 0; z-index: 10; min-width: 190px; background: #fff; border: 1px solid #dbe4f0; border-radius: 10px; padding: 6px; box-shadow: 0 12px 28px rgba(15,23,42,.12); }
|
||||
.workspace-menu-item { width: 100%; background: transparent; border: 0; border-radius: 8px; padding: 9px 12px; font: inherit; text-align: left; cursor: pointer; color: #0f172a; }
|
||||
.workspace-menu-item:hover { background: #f8fafc; }
|
||||
.workspace-menu-item-danger { color: #b91c1c; }
|
||||
.workspace-menu-item-danger:hover { background: #fef2f2; }
|
||||
.btn-primary { background: #1d4ed8; color: #fff; border: 0; border-radius: 8px; padding: 8px 18px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; }
|
||||
.btn-primary:hover { background: #1e40af; }
|
||||
.btn-secondary { background: #fff; color: #334155; border: 1px solid #cbd5e1; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; }
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface PortalMutationState {
|
||||
export interface PortalAccountUIEntry {
|
||||
addWorkspaceOpen: boolean;
|
||||
createWorkspace: PortalMutationState;
|
||||
openWorkspaceMenuID: string;
|
||||
teamVisible: boolean;
|
||||
teamQuery: PortalQueryState<PortalTeamMember[]>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user