mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Make portal shell labels literal
This commit is contained in:
@@ -264,7 +264,11 @@ Core rules:
|
||||
40. Portal task and status copy must stay literal. Customer-facing copy may
|
||||
not rely on commentary such as `obvious`, `actual work`, `trustworthy`,
|
||||
or `settled` when the runtime already knows the concrete state, action,
|
||||
or failure being shown.
|
||||
or failure being shown. The same rule applies to shell badges, section
|
||||
labels, and error headings: they must say the exact state or action (for
|
||||
example `Manage access`, `Hosted billing attached`, or `Failed to load
|
||||
roster`) instead of shorthand such as `Manage`, `Hosted`, `Email`, or
|
||||
generic alert labels.
|
||||
|
||||
## Screen Model
|
||||
|
||||
|
||||
@@ -374,7 +374,10 @@ That same typed portal runtime contract must also keep task and status copy
|
||||
literal across the account surface: customer-facing wording may not use
|
||||
commentary such as `obvious`, `actual work`, `trustworthy`, or `settled` when
|
||||
the runtime already knows the concrete state, action, or failure being
|
||||
rendered.
|
||||
rendered. The same typed contract applies to shell badges, section labels,
|
||||
and error headings: they must render the exact action or state (`Manage
|
||||
access`, `Hosted billing attached`, `Failed to load roster`) instead of
|
||||
shorthand such as `Manage`, `Hosted`, or generic alert labels.
|
||||
That same typed overview contract must also preserve a sharp, high-density enterprise visual aesthetic (e.g. Cloudflare/GCP density standards) across all portal scenarios, removing gradients and heavy box-shadows to ensure a calm, rigorous visual language with standard 256px sidebars, Inter-grade typography, clean text-transform rules, and cleanly unboxed typography without excessive pills or stacked metrics.
|
||||
plus a package-local `tsc --noEmit` gate, so future account-shell work should
|
||||
extend the typed source boundary instead of reviving opaque global runtime
|
||||
|
||||
@@ -942,6 +942,10 @@ That same portal shell/runtime boundary must also keep task and status copy
|
||||
literal across the account surface: customer-facing wording may not lean on
|
||||
commentary such as `obvious`, `actual work`, `trustworthy`, or `settled` when
|
||||
the runtime already knows the concrete state, action, or failure being shown.
|
||||
That includes shell badges, section labels, and error headings: they must use
|
||||
the exact action or state (`Manage access`, `Hosted billing attached`,
|
||||
`Failed to load roster`) instead of shorthand such as `Manage`, `Hosted`, or
|
||||
generic alert labels.
|
||||
That same canonical shell/runtime boundary now also owns the bootstrap truth
|
||||
for when self-hosted commercial history is relevant. Hosted-only accounts must
|
||||
not render self-hosted license, refund, privacy, or support-escalation copy
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "21a6dc0fc99f359ec17be255e817006a957679e26097181db88399b969d7aadf",
|
||||
"source_hash": "646baefa83486237ab1a2dabf6fb5fb019d16f45cbe8632aa8e881d986d9bb71",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+38
-19
@@ -240,11 +240,11 @@
|
||||
return;
|
||||
}
|
||||
if (entry.accessQuery.status === "loading") {
|
||||
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading\u2026</span></div><div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? "Manage" : "View") + "</span></div>";
|
||||
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading\u2026</span></div><div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? "Manage access" : "View roster") + "</span></div>";
|
||||
return;
|
||||
}
|
||||
if (entry.accessQuery.status === "error") {
|
||||
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Needs attention</span></div><div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? "Manage" : "View") + "</span></div>";
|
||||
stats.innerHTML = '<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Load failed</span></div><div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? "Manage access" : "View roster") + "</span></div>";
|
||||
return;
|
||||
}
|
||||
var members = entry.accessQuery.data;
|
||||
@@ -411,7 +411,7 @@
|
||||
}
|
||||
if (entry.accessQuery.status === "error") {
|
||||
if (rosterPanel) rosterPanel.classList.add("state-only");
|
||||
setContainerMessage(roster, "Roster needs attention", entry.accessQuery.error, true);
|
||||
setContainerMessage(roster, "Failed to load roster", entry.accessQuery.error, true);
|
||||
return;
|
||||
}
|
||||
if (!entry.accessQuery.data.length) {
|
||||
@@ -1988,6 +1988,25 @@
|
||||
function suspendedWorkspaceChipLabel(count) {
|
||||
return count === 1 ? "1 suspended workspace" : String(count) + " suspended workspaces";
|
||||
}
|
||||
function overviewNavBadgeLabel(hosted, attentionCount, readyCount) {
|
||||
if (!hosted) return "Billing available";
|
||||
if (attentionCount > 0) return reviewWorkspaceChipLabel(attentionCount);
|
||||
return readyWorkspaceChipLabel(readyCount);
|
||||
}
|
||||
function workspacesNavBadgeLabel(hosted, totalWorkspaces) {
|
||||
if (!hosted) return "Unavailable";
|
||||
return workspaceCountLabel(totalWorkspaces);
|
||||
}
|
||||
function accessNavBadgeLabel(hosted, canManage) {
|
||||
if (!hosted) return "Unavailable";
|
||||
return canManage ? "Manage access" : "View roster";
|
||||
}
|
||||
function billingNavBadgeLabel(hostedBillingCount) {
|
||||
return hostedBillingCount > 0 ? "Hosted billing" : "Self-hosted billing";
|
||||
}
|
||||
function supportNavBadgeLabel() {
|
||||
return "Escalation only";
|
||||
}
|
||||
function hasHostedAccounts(accounts) {
|
||||
return accounts.length > 0;
|
||||
}
|
||||
@@ -2072,7 +2091,7 @@
|
||||
return results;
|
||||
}
|
||||
function accountContextRoleMeta(account) {
|
||||
return portalRoleLabel(account.role) + (account.can_manage ? " access" : " role");
|
||||
return portalRoleLabel(account.role) + " role";
|
||||
}
|
||||
function accountContextLeadCopy(account) {
|
||||
var accountPrefix = account.kind === "msp" ? "Hosted workspace account" : "Hosted account";
|
||||
@@ -2082,11 +2101,11 @@
|
||||
return accountPrefix + (account.has_billing ? " where you can open workspaces and review who already has access. An owner or admin handles access changes and billing." : " where you can open workspaces and review who already has access. An owner or admin handles account changes.");
|
||||
}
|
||||
function accountContextAccessSummary(account) {
|
||||
return account.can_manage ? portalRoleLabel(account.role) : "View only";
|
||||
return account.can_manage ? "Manage access" : "View roster";
|
||||
}
|
||||
function accountContextBillingSummary(account) {
|
||||
if (!account.has_billing) return "Not attached";
|
||||
return account.can_manage ? "Billing enabled" : "Owner/admin required";
|
||||
return account.can_manage ? "Hosted billing attached" : "Owner/admin required";
|
||||
}
|
||||
function renderAccountContextStrip(account) {
|
||||
var workspaceLabel = workspaceCountLabel((account.workspaces || []).length);
|
||||
@@ -2153,7 +2172,7 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
return '<aside class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-header"><div class="portal-shell-nav-eyebrow">Pulse Account</div><div class="portal-shell-nav-title">Account tasks</div><div class="portal-shell-nav-support">' + (hosted ? "Workspaces, Access, Billing, then Support." : "Billing first. Support only after the billing path fails.") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "01", "Overview", "Review status, ready workspaces, and the next action.", attentionCount > 0 ? String(attentionCount) + " review" : hosted ? String(readyWorkspaces) + " ready" : "Summary") + shellSectionButton("workspaces", activeSection, "02", "Workspaces", workspaceNavCopy(hosted, canManage), hosted ? String(readyWorkspaces) + " ready" : "Unavailable") + shellSectionButton("access", activeSection, "03", "Access", accessNavCopy(hosted, canManage), hosted ? canManage ? "Manage" : "View" : "Unavailable") + shellSectionButton("billing", activeSection, "04", "Billing", billingNavCopy(hostedBillingCount, canManageHostedBilling), hostedBillingCount > 0 ? hostedBillingCount > 1 ? "Hosted +" : "Hosted" : "Self-hosted") + shellSectionButton("support", activeSection, "05", "Support", supportNavCopy(hosted, canManage), supportEmail ? "Email" : "Help") + "</div></aside>";
|
||||
return '<aside class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-header"><div class="portal-shell-nav-eyebrow">Pulse Account</div><div class="portal-shell-nav-title">Account tasks</div><div class="portal-shell-nav-support">' + (hosted ? "Use Workspaces, then Access, then Billing. Use Support only if those paths fail." : "Use Billing first. Use Support only if that path fails.") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "01", "Overview", "Review workspace counts, current state, and the current action.", overviewNavBadgeLabel(hosted, attentionCount, readyWorkspaces)) + shellSectionButton("workspaces", activeSection, "02", "Workspaces", workspaceNavCopy(hosted, canManage), workspacesNavBadgeLabel(hosted, totalWorkspaces)) + shellSectionButton("access", activeSection, "03", "Access", accessNavCopy(hosted, canManage), accessNavBadgeLabel(hosted, canManage)) + shellSectionButton("billing", activeSection, "04", "Billing", billingNavCopy(hostedBillingCount, canManageHostedBilling), billingNavBadgeLabel(hostedBillingCount)) + shellSectionButton("support", activeSection, "05", "Support", supportNavCopy(hosted, canManage), supportNavBadgeLabel()) + "</div></aside>";
|
||||
}
|
||||
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
|
||||
var status = workspaceHealthState(workspace);
|
||||
@@ -2218,7 +2237,7 @@
|
||||
) + '</p><div class="overview-task-list"><div class="overview-task-item"><strong>Ready</strong><span>' + escapeHTML(
|
||||
entries.length > 0 ? readyWorkspaceHeadline(ready.length) : accounts.length > 0 ? readyWorkspaceHeadline(0) : "0 hosted workspaces are ready to use"
|
||||
) + '</span></div><div class="overview-task-item"><strong>Suspended</strong><span>' + escapeHTML(
|
||||
suspendedCount > 0 ? suspendedCount === 1 ? "1 workspace is suspended and excluded from routine use until you resume it." : String(suspendedCount) + " workspaces are suspended and excluded from routine use until you resume them." : "0 suspended workspaces."
|
||||
suspendedCount > 0 ? suspendedCount === 1 ? "1 workspace is suspended. Resume it before opening it again." : String(suspendedCount) + " workspaces are suspended. Resume them before opening them again." : "0 suspended workspaces."
|
||||
) + "</span></div></div></article>";
|
||||
}
|
||||
return '<article class="overview-task-card overview-task-card-attention"><div class="account-panel-kicker">Needs attention</div><h4>' + escapeHTML(reviewWorkspaceHeadline(attention.length)) + '</h4><p>Each listed workspace is failed or still waiting on a completed health check.</p><div class="overview-task-list">' + attention.slice(0, 3).map(function(entry) {
|
||||
@@ -2239,7 +2258,7 @@
|
||||
return '<article class="overview-task-card"><div class="account-panel-kicker">Ready</div><h4>' + escapeHTML(
|
||||
!accounts.length ? "Billing is available" : readyWorkspaceHeadline(0)
|
||||
) + "</h4><p>" + escapeHTML(
|
||||
!accounts.length ? "Use Billing for self-hosted subscriptions, licenses, refunds, and privacy requests." : totalWorkspaces > 0 ? suspendedCount === totalWorkspaces ? "Every hosted workspace is suspended right now." : "Open Workspaces for the current workspace state before routine use." : canManageHosted ? "The first hosted workspace still needs to be created before routine work can start." : "An owner or admin still needs to create the first hosted workspace before routine work can start."
|
||||
!accounts.length ? "Use Billing for self-hosted subscriptions, licenses, refunds, and privacy requests." : totalWorkspaces > 0 ? suspendedCount === totalWorkspaces ? "Every hosted workspace is suspended right now." : "Open Workspaces to see the current state of each hosted workspace." : canManageHosted ? "No hosted workspace exists yet. Create the first one in Workspaces." : "No hosted workspace exists yet. An owner or admin must create the first one."
|
||||
) + "</p></article>";
|
||||
}
|
||||
return '<article class="overview-task-card"><div class="account-panel-kicker">Ready</div><h4>' + escapeHTML(readyWorkspaceHeadline(ready.length)) + '</h4><p>Each listed workspace is active and passed its latest health check.</p><div class="overview-task-list">' + ready.slice(0, 3).map(function(entry) {
|
||||
@@ -2265,10 +2284,10 @@
|
||||
}) || null;
|
||||
var hostedViewOnly = accounts.length > 0 && !accessAccount;
|
||||
if (attention.length) {
|
||||
title = "Review workspace health";
|
||||
description = attention.length > 1 ? "Open Workspaces. Review each failed or pending workspace before taking another account action." : "Open Workspaces. Review " + attention[0].workspace.display_name + " before taking another account action.";
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
|
||||
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>' : "";
|
||||
title = "Open Workspaces";
|
||||
description = attention.length > 1 ? "Open Workspaces to review each failed or pending workspace." : "Open Workspaces to review " + attention[0].workspace.display_name + ".";
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
|
||||
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>' : "";
|
||||
} else if (ready.length) {
|
||||
title = "Open workspace";
|
||||
description = accounts.length > 1 ? "Open " + ready[0].workspace.display_name + " in " + ready[0].account.name + "." : "Open the ready workspace.";
|
||||
@@ -2278,12 +2297,12 @@
|
||||
title = "Create workspace";
|
||||
description = "No workspace is ready. Create a workspace in " + creatableAccount.name + ".";
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(creatableAccount.id) + '">Create workspace</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Manage access</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
|
||||
} else if (billingAccount) {
|
||||
title = "Open billing";
|
||||
description = "Use Billing for invoices, payment methods, or subscription changes.";
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>';
|
||||
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>' : "";
|
||||
secondaryAction = accessAccount ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>' : "";
|
||||
} else if (!accounts.length) {
|
||||
title = "Open billing";
|
||||
description = "Use Billing for self-hosted subscriptions, licenses, refunds, or privacy requests.";
|
||||
@@ -2292,12 +2311,12 @@
|
||||
if (totalWorkspaces > 0) {
|
||||
title = "Review workspace state";
|
||||
description = "No workspace is ready. Open Workspaces to review current state, then hand off changes to an owner or admin.";
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
|
||||
} else {
|
||||
title = "Review who can act";
|
||||
description = showSelfHostedCommercial ? "No hosted workspace is attached. Review Access to see who can manage this hosted account, or use Billing for self-hosted tasks." : "No hosted workspace is attached yet. Review Access to see who can create or manage the first workspace on this account.";
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
|
||||
secondaryAction = showSelfHostedCommercial ? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>' : "";
|
||||
}
|
||||
} else if (accessAccount) {
|
||||
@@ -2328,7 +2347,7 @@
|
||||
reviewWorkspaceChipLabel(attentionCount),
|
||||
suspendedWorkspaceChipLabel(suspendedCount)
|
||||
] : ["No hosted account", "0 hosted workspaces", "Billing available", "Support only on escalation"];
|
||||
return '<section class="account-content-panel account-content-panel-overview"><div class="account-stage-header account-stage-header-overview overview-stage-header"><div><div class="account-panel-kicker">Overview</div><h3>Account state</h3><p>Current hosted workspace state, readiness, and next action.</p>' + renderSectionContextChips(chips) + '</div></div><div class="overview-task-grid">' + renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) + renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) + renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + "</div></section>";
|
||||
return '<section class="account-content-panel account-content-panel-overview"><div class="account-stage-header account-stage-header-overview overview-stage-header"><div><div class="account-panel-kicker">Overview</div><h3>Account state</h3><p>Hosted workspace counts, current state, and current action.</p>' + renderSectionContextChips(chips) + '</div></div><div class="overview-task-grid">' + renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) + renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) + renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + "</div></section>";
|
||||
}
|
||||
function renderNoHostedWorkspacesSection() {
|
||||
return '<section class="account-content-panel account-content-panel-workspaces"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspaces</div><h3>Workspaces</h3><p>No hosted workspace is attached to this account.</p>' + renderSectionContextChips(["None attached", "Billing instead"]) + '</div></div><div class="empty-state empty-state-spaced"><p>There is nothing to open or manage here yet.</p><p class="support-copy">Use Billing for self-hosted subscriptions, licenses, refunds, or privacy requests.</p></div></section>';
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('account view', function() {
|
||||
expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders team loading, error, and populated member states', function() {
|
||||
it('renders access loading, error, and populated member states', function() {
|
||||
document.body.innerHTML =
|
||||
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
|
||||
'<div id="access-stats-acct_1"></div>' +
|
||||
@@ -116,16 +116,17 @@ describe('account view', function() {
|
||||
accessQuery: { status: 'loading', error: '', data: [] },
|
||||
}));
|
||||
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Loading roster');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Mode');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Access');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage access');
|
||||
|
||||
renderAccessSection('acct_1', createEntry({
|
||||
accessVisible: true,
|
||||
accessQuery: { status: 'error', error: 'Failed to load access roster.', data: [] },
|
||||
}));
|
||||
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Roster needs attention');
|
||||
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Failed to load roster');
|
||||
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Failed to load access roster.');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Load failed');
|
||||
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Manage access');
|
||||
|
||||
renderAccessSection(
|
||||
'acct_1',
|
||||
|
||||
@@ -179,13 +179,13 @@ function renderAccessStats(accountID: string, entry: PortalAccountUIEntry, canMa
|
||||
if (entry.accessQuery.status === 'loading') {
|
||||
stats.innerHTML =
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value">Loading…</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? 'Manage' : 'View') + '</span></div>';
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? 'Manage access' : 'View roster') + '</span></div>';
|
||||
return;
|
||||
}
|
||||
if (entry.accessQuery.status === 'error') {
|
||||
stats.innerHTML =
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Needs attention</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Mode</span><span class="access-stat-value">' + (canManage ? 'Manage' : 'View') + '</span></div>';
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Roster</span><span class="access-stat-value access-stat-error">Load failed</span></div>' +
|
||||
'<div class="access-stat-card"><span class="access-stat-label">Access</span><span class="access-stat-value">' + (canManage ? 'Manage access' : 'View roster') + '</span></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -384,7 +384,7 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt
|
||||
}
|
||||
if (entry.accessQuery.status === 'error') {
|
||||
if (rosterPanel) rosterPanel.classList.add('state-only');
|
||||
setContainerMessage(roster, 'Roster needs attention', entry.accessQuery.error, true);
|
||||
setContainerMessage(roster, 'Failed to load roster', entry.accessQuery.error, true);
|
||||
return;
|
||||
}
|
||||
if (!entry.accessQuery.data.length) {
|
||||
|
||||
@@ -136,17 +136,17 @@ describe('shell view', function() {
|
||||
expect(html).toContain('Billing');
|
||||
expect(html).toContain('Support');
|
||||
expect(html).toContain('Account tasks');
|
||||
expect(html).toContain('Review status, ready workspaces, and the next action.');
|
||||
expect(html).toContain('Review workspace counts, current state, and the current action.');
|
||||
expect(html).toContain('1 account');
|
||||
expect(html).toContain('3 workspaces');
|
||||
expect(html).toContain('1 ready workspace');
|
||||
expect(html).toContain('2 workspaces to review');
|
||||
expect(html).toContain('Manage');
|
||||
expect(html).toContain('Manage access');
|
||||
expect(html).toContain('id="billing-section"');
|
||||
expect(html).toContain('portal-account-context');
|
||||
expect(html).toContain('Owner access');
|
||||
expect(html).toContain('Owner role');
|
||||
expect(html).toContain('portal-account-context-summary');
|
||||
expect(html).toContain('Billing enabled');
|
||||
expect(html).toContain('Hosted billing attached');
|
||||
expect(html).toContain('id="accounts-root"');
|
||||
expect(html).toContain('MSP account');
|
||||
expect(html).toContain('Acme MSP');
|
||||
@@ -156,15 +156,15 @@ describe('shell view', function() {
|
||||
expect(html).not.toContain('Manage billing');
|
||||
expect(html).not.toContain('Manage team');
|
||||
expect(html).toContain('Account state');
|
||||
expect(html).toContain('Current hosted workspace state, readiness, and next action.');
|
||||
expect(html).toContain('Hosted workspace counts, current state, and current action.');
|
||||
expect(html).toContain('section-context-strip');
|
||||
expect(html).toContain('Needs attention');
|
||||
expect(html).toContain('Ready');
|
||||
expect(html).toContain('Next action');
|
||||
expect(html).toContain('2 workspaces need review');
|
||||
expect(html).toContain('1 workspace is ready to use');
|
||||
expect(html).toContain('Review workspaces');
|
||||
expect(html).toContain('Review access');
|
||||
expect(html).toContain('Open Workspaces');
|
||||
expect(html).toContain('Open Access');
|
||||
expect(html).toContain('account-stage-header-actions');
|
||||
expect(html).toContain('0 suspended workspaces');
|
||||
expect(html).toContain('Alpha Workspace');
|
||||
@@ -348,9 +348,10 @@ describe('shell view', function() {
|
||||
expect((html.match(/portal-account-context\"/g) || []).length).toBe(1);
|
||||
expect((html.match(/portal-account-context-stat/g) || []).length).toBe(3);
|
||||
expect((html.match(/account-context-chip\"/g) || []).length).toBe(3);
|
||||
expect(html).toContain('Admin access');
|
||||
expect(html).toContain('Admin role');
|
||||
expect(html).toContain('Hosted account for workspace access, access control, and billing.');
|
||||
expect(html).toContain('Billing enabled');
|
||||
expect(html).toContain('Manage access');
|
||||
expect(html).toContain('Hosted billing attached');
|
||||
});
|
||||
|
||||
it('renders a view-only access surface when the account cannot manage access', function() {
|
||||
@@ -386,11 +387,11 @@ describe('shell view', function() {
|
||||
expect(html).toContain('Open a workspace and review current state here. An owner or admin must create or change hosted workspaces.');
|
||||
expect(html).toContain('Tech role');
|
||||
expect(html).toContain('Hosted account where you can open workspaces and review who already has access. An owner or admin handles access changes and billing.');
|
||||
expect(html).toContain('View only');
|
||||
expect(html).toContain('View roster');
|
||||
expect(html).toContain('Owner/admin required');
|
||||
expect(html).toContain('0 workspaces need review');
|
||||
expect(html).toContain('1 workspace is ready to use');
|
||||
expect(html).toContain('Review access');
|
||||
expect(html).toContain('View roster');
|
||||
expect(html).toContain('Owner or admin required');
|
||||
expect(html).toContain('Review who already has access to this hosted account. An owner or admin must make changes.');
|
||||
expect(html).toContain('Review the hosted roster here. An owner or admin must make changes.');
|
||||
@@ -481,7 +482,7 @@ describe('shell view', function() {
|
||||
|
||||
expect(html).toContain('Review who can act');
|
||||
expect(html).toContain('Review Access to see who can create or manage the first workspace on this account.');
|
||||
expect(html).toContain('Review access');
|
||||
expect(html).toContain('Open Access');
|
||||
expect(html).not.toContain('Choose the right task path');
|
||||
expect(html).not.toContain('If this is an access change, go to Access. If it is a billing or license issue, go to Billing. Support is only for escalation.');
|
||||
});
|
||||
@@ -508,8 +509,8 @@ describe('shell view', function() {
|
||||
);
|
||||
|
||||
expect(html).toContain('0 workspaces are ready to use');
|
||||
expect(html).toContain('An owner or admin still needs to create the first hosted workspace before routine work can start.');
|
||||
expect(html).not.toContain('Open Workspaces for the current workspace state before routine use.');
|
||||
expect(html).toContain('No hosted workspace exists yet. An owner or admin must create the first one.');
|
||||
expect(html).not.toContain('Open Workspaces to see the current state of each hosted workspace.');
|
||||
});
|
||||
|
||||
it('keeps ready state honest for managed hosted accounts with no workspace yet', function() {
|
||||
@@ -534,8 +535,8 @@ describe('shell view', function() {
|
||||
);
|
||||
|
||||
expect(html).toContain('0 workspaces are ready to use');
|
||||
expect(html).toContain('The first hosted workspace still needs to be created before routine work can start.');
|
||||
expect(html).not.toContain('Open Workspaces for the current workspace state before routine use.');
|
||||
expect(html).toContain('No hosted workspace exists yet. Create the first one in Workspaces.');
|
||||
expect(html).not.toContain('Open Workspaces to see the current state of each hosted workspace.');
|
||||
});
|
||||
|
||||
it('keeps next action on review surfaces for suspended hosted view-only accounts', function() {
|
||||
@@ -569,8 +570,8 @@ describe('shell view', function() {
|
||||
|
||||
expect(html).toContain('Review workspace state');
|
||||
expect(html).toContain('Open Workspaces to review current state, then hand off changes to an owner or admin.');
|
||||
expect(html).toContain('Review workspaces');
|
||||
expect(html).toContain('Review access');
|
||||
expect(html).toContain('Open Workspaces');
|
||||
expect(html).toContain('Open Access');
|
||||
expect(html).not.toContain('Choose the right task path');
|
||||
});
|
||||
|
||||
@@ -604,7 +605,7 @@ describe('shell view', function() {
|
||||
);
|
||||
|
||||
expect(html).toContain('0 workspaces are ready to use');
|
||||
expect(html).toContain('1 workspace is suspended and excluded from routine use until you resume it.');
|
||||
expect(html).toContain('1 workspace is suspended. Resume it before opening it again.');
|
||||
expect(html).not.toContain('Active workspaces look clear for routine use.');
|
||||
});
|
||||
|
||||
@@ -715,7 +716,7 @@ describe('shell view', function() {
|
||||
expect(html).toContain('0 workspaces are ready to use');
|
||||
expect(html).toContain('Every hosted workspace is suspended right now.');
|
||||
expect(html).toContain('Create workspace');
|
||||
expect(html).toContain('1 workspace is suspended and excluded from routine use until you resume it.');
|
||||
expect(html).toContain('1 workspace is suspended. Resume it before opening it again.');
|
||||
});
|
||||
|
||||
it('preserves the high-density grid, standard sidebar hooks, and strictly horizontal, pill-free action constraints in the rendered shell', function() {
|
||||
|
||||
@@ -77,6 +77,30 @@ function suspendedWorkspaceChipLabel(count: number): string {
|
||||
return count === 1 ? '1 suspended workspace' : String(count) + ' suspended workspaces';
|
||||
}
|
||||
|
||||
function overviewNavBadgeLabel(hosted: boolean, attentionCount: number, readyCount: number): string {
|
||||
if (!hosted) return 'Billing available';
|
||||
if (attentionCount > 0) return reviewWorkspaceChipLabel(attentionCount);
|
||||
return readyWorkspaceChipLabel(readyCount);
|
||||
}
|
||||
|
||||
function workspacesNavBadgeLabel(hosted: boolean, totalWorkspaces: number): string {
|
||||
if (!hosted) return 'Unavailable';
|
||||
return workspaceCountLabel(totalWorkspaces);
|
||||
}
|
||||
|
||||
function accessNavBadgeLabel(hosted: boolean, canManage: boolean): string {
|
||||
if (!hosted) return 'Unavailable';
|
||||
return canManage ? 'Manage access' : 'View roster';
|
||||
}
|
||||
|
||||
function billingNavBadgeLabel(hostedBillingCount: number): string {
|
||||
return hostedBillingCount > 0 ? 'Hosted billing' : 'Self-hosted billing';
|
||||
}
|
||||
|
||||
function supportNavBadgeLabel(): string {
|
||||
return 'Escalation only';
|
||||
}
|
||||
|
||||
function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean {
|
||||
return accounts.length > 0;
|
||||
}
|
||||
@@ -197,7 +221,7 @@ function attentionWorkspaces(workspaces: PortalWorkspaceSummary[]): PortalWorksp
|
||||
}
|
||||
|
||||
function accountContextRoleMeta(account: PortalAccountSummary): string {
|
||||
return portalRoleLabel(account.role) + (account.can_manage ? ' access' : ' role');
|
||||
return portalRoleLabel(account.role) + ' role';
|
||||
}
|
||||
|
||||
function accountContextLeadCopy(account: PortalAccountSummary): string {
|
||||
@@ -213,12 +237,12 @@ function accountContextLeadCopy(account: PortalAccountSummary): string {
|
||||
}
|
||||
|
||||
function accountContextAccessSummary(account: PortalAccountSummary): string {
|
||||
return account.can_manage ? portalRoleLabel(account.role) : 'View only';
|
||||
return account.can_manage ? 'Manage access' : 'View roster';
|
||||
}
|
||||
|
||||
function accountContextBillingSummary(account: PortalAccountSummary): string {
|
||||
if (!account.has_billing) return 'Not attached';
|
||||
return account.can_manage ? 'Billing enabled' : 'Owner/admin required';
|
||||
return account.can_manage ? 'Hosted billing attached' : 'Owner/admin required';
|
||||
}
|
||||
|
||||
function renderAccountContextStrip(account: PortalAccountSummary): string {
|
||||
@@ -344,14 +368,17 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s
|
||||
'<div class="portal-shell-nav-header">' +
|
||||
'<div class="portal-shell-nav-eyebrow">Pulse Account</div>' +
|
||||
'<div class="portal-shell-nav-title">Account tasks</div>' +
|
||||
'<div class="portal-shell-nav-support">' + (hosted ? 'Workspaces, Access, Billing, then Support.' : 'Billing first. Support only after the billing path fails.') + '</div>' +
|
||||
'<div class="portal-shell-nav-support">' + (hosted
|
||||
? 'Use Workspaces, then Access, then Billing. Use Support only if those paths fail.'
|
||||
: 'Use Billing first. Use Support only if that path fails.'
|
||||
) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="portal-shell-nav-group">' +
|
||||
shellSectionButton('overview', activeSection, '01', 'Overview', 'Review status, ready workspaces, and the next action.', attentionCount > 0 ? String(attentionCount) + ' review' : (hosted ? String(readyWorkspaces) + ' ready' : 'Summary')) +
|
||||
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', workspaceNavCopy(hosted, canManage), hosted ? String(readyWorkspaces) + ' ready' : 'Unavailable') +
|
||||
shellSectionButton('access', activeSection, '03', 'Access', accessNavCopy(hosted, canManage), hosted ? (canManage ? 'Manage' : 'View') : 'Unavailable') +
|
||||
shellSectionButton('billing', activeSection, '04', 'Billing', billingNavCopy(hostedBillingCount, canManageHostedBilling), hostedBillingCount > 0 ? (hostedBillingCount > 1 ? 'Hosted +' : 'Hosted') : 'Self-hosted') +
|
||||
shellSectionButton('support', activeSection, '05', 'Support', supportNavCopy(hosted, canManage), supportEmail ? 'Email' : 'Help') +
|
||||
shellSectionButton('overview', activeSection, '01', 'Overview', 'Review workspace counts, current state, and the current action.', overviewNavBadgeLabel(hosted, attentionCount, readyWorkspaces)) +
|
||||
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', workspaceNavCopy(hosted, canManage), workspacesNavBadgeLabel(hosted, totalWorkspaces)) +
|
||||
shellSectionButton('access', activeSection, '03', 'Access', accessNavCopy(hosted, canManage), accessNavBadgeLabel(hosted, canManage)) +
|
||||
shellSectionButton('billing', activeSection, '04', 'Billing', billingNavCopy(hostedBillingCount, canManageHostedBilling), billingNavBadgeLabel(hostedBillingCount)) +
|
||||
shellSectionButton('support', activeSection, '05', 'Support', supportNavCopy(hosted, canManage), supportNavBadgeLabel()) +
|
||||
'</div>' +
|
||||
'</aside>'
|
||||
);
|
||||
@@ -544,8 +571,8 @@ function renderOverviewAttentionCard(
|
||||
) + '</span></div>' +
|
||||
'<div class="overview-task-item"><strong>Suspended</strong><span>' + escapeHTML(suspendedCount > 0
|
||||
? suspendedCount === 1
|
||||
? '1 workspace is suspended and excluded from routine use until you resume it.'
|
||||
: String(suspendedCount) + ' workspaces are suspended and excluded from routine use until you resume them.'
|
||||
? '1 workspace is suspended. Resume it before opening it again.'
|
||||
: String(suspendedCount) + ' workspaces are suspended. Resume them before opening them again.'
|
||||
: '0 suspended workspaces.'
|
||||
) + '</span></div>' +
|
||||
'</div>' +
|
||||
@@ -599,10 +626,10 @@ function renderOverviewReadyCard(
|
||||
: totalWorkspaces > 0
|
||||
? suspendedCount === totalWorkspaces
|
||||
? 'Every hosted workspace is suspended right now.'
|
||||
: 'Open Workspaces for the current workspace state before routine use.'
|
||||
: 'Open Workspaces to see the current state of each hosted workspace.'
|
||||
: canManageHosted
|
||||
? 'The first hosted workspace still needs to be created before routine work can start.'
|
||||
: 'An owner or admin still needs to create the first hosted workspace before routine work can start.'
|
||||
? 'No hosted workspace exists yet. Create the first one in Workspaces.'
|
||||
: 'No hosted workspace exists yet. An owner or admin must create the first one.'
|
||||
) + '</p>' +
|
||||
'</article>'
|
||||
);
|
||||
@@ -655,13 +682,13 @@ function renderOverviewNextActionCard(
|
||||
var hostedViewOnly = accounts.length > 0 && !accessAccount;
|
||||
|
||||
if (attention.length) {
|
||||
title = 'Review workspace health';
|
||||
title = 'Open Workspaces';
|
||||
description = attention.length > 1
|
||||
? 'Open Workspaces. Review each failed or pending workspace before taking another account action.'
|
||||
: 'Open Workspaces. Review ' + attention[0].workspace.display_name + ' before taking another account action.';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
|
||||
? 'Open Workspaces to review each failed or pending workspace.'
|
||||
: 'Open Workspaces to review ' + attention[0].workspace.display_name + '.';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
|
||||
secondaryAction = accessAccount
|
||||
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>'
|
||||
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>'
|
||||
: '';
|
||||
} else if (ready.length) {
|
||||
title = 'Open workspace';
|
||||
@@ -676,13 +703,13 @@ function renderOverviewNextActionCard(
|
||||
title = 'Create workspace';
|
||||
description = 'No workspace is ready. Create a workspace in ' + creatableAccount.name + '.';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(creatableAccount.id) + '">Create workspace</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Manage access</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
|
||||
} else if (billingAccount) {
|
||||
title = 'Open billing';
|
||||
description = 'Use Billing for invoices, payment methods, or subscription changes.';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>';
|
||||
secondaryAction = accessAccount
|
||||
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>'
|
||||
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>'
|
||||
: '';
|
||||
} else if (!accounts.length) {
|
||||
title = 'Open billing';
|
||||
@@ -692,14 +719,14 @@ function renderOverviewNextActionCard(
|
||||
if (totalWorkspaces > 0) {
|
||||
title = 'Review workspace state';
|
||||
description = 'No workspace is ready. Open Workspaces to review current state, then hand off changes to an owner or admin.';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Review workspaces</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="workspaces">Open Workspaces</button>';
|
||||
secondaryAction = '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
|
||||
} else {
|
||||
title = 'Review who can act';
|
||||
description = showSelfHostedCommercial
|
||||
? 'No hosted workspace is attached. Review Access to see who can manage this hosted account, or use Billing for self-hosted tasks.'
|
||||
: 'No hosted workspace is attached yet. Review Access to see who can create or manage the first workspace on this account.';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Review access</button>';
|
||||
primaryAction = '<button class="btn-primary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="access">Open Access</button>';
|
||||
secondaryAction = showSelfHostedCommercial
|
||||
? '<button class="btn-secondary btn-compact" type="button" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>'
|
||||
: '';
|
||||
@@ -753,7 +780,7 @@ function renderShellOverviewSection(context: ShellViewContext): string {
|
||||
'<div>' +
|
||||
'<div class="account-panel-kicker">Overview</div>' +
|
||||
'<h3>Account state</h3>' +
|
||||
'<p>Current hosted workspace state, readiness, and next action.</p>' +
|
||||
'<p>Hosted workspace counts, current state, and current action.</p>' +
|
||||
renderSectionContextChips(chips) +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
Reference in New Issue
Block a user