Converge billing admin row actions on shared Button primitives

This commit is contained in:
rcourtman
2026-06-13 21:37:30 +01:00
parent 49751f9225
commit 9578cd7368
6 changed files with 147 additions and 14 deletions
@@ -582,6 +582,10 @@ or other self-hosted uncapped continuity plans.
table state stay cloud-paid owned, while the refresh button chrome must
compose the frontend-primitives `Button` family instead of carrying a
hosted-billing-local compact action shell.
Hosted billing admin organization row actions follow the same ownership
split: cloud-paid owns Suspend, Activate, Reload, tenant billing state, and
mutation semantics, while frontend-primitives owns the shared `Button`
chrome used by those row actions.
14. Add or change shared commercial plan/usage presentation through `frontend-modern/src/components/Settings/CommercialBillingSections.tsx` and `frontend-modern/src/utils/commercialBillingModel.ts`
15. Add or change organization billing and usage presentation through `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx`, `frontend-modern/src/components/Settings/OrganizationBillingLoadingState.tsx`, and `frontend-modern/src/components/Settings/useOrganizationBillingPanelState.ts`
16. Add or change self-hosted Pro plan, recovery, and entitlement actions through `frontend-modern/src/components/Settings/ProLicensePanel.tsx`, `frontend-modern/src/components/Settings/ProLicensePlanSection.tsx`, `frontend-modern/src/components/Settings/SelfHostedCommercialRecoverySection.tsx`, and `frontend-modern/src/components/Settings/useProLicensePanelState.ts`
@@ -1626,6 +1630,10 @@ subscription update runtime, and
owns the tenant grid plus expanded JSON presentation. Future hosted billing
admin changes must extend that split instead of pulling hosted state and
mutation flow back into the panel render shell.
Hosted billing admin organization row actions follow the shared frontend
primitive contract as well: cloud-paid owns Suspend, Activate, Reload, tenant
billing state, and mutation semantics, while frontend-primitives owns the
`Button` chrome for those row actions.
The organization billing settings surface now follows the same rule. Changes
to `frontend-modern/src/components/Settings/OrganizationBillingPanel.tsx` must
carry this contract and the dedicated organization-billing proof file instead
@@ -568,6 +568,10 @@ not a replacement status card, CTA band, or page-local nested card.
entitlement state, and click handlers, while `Button`, `ButtonLink`, and
`UpgradeButtonLink` own the primary, outline, warning, and upgrade/link
chrome.
Hosted billing admin organization row actions follow the same boundary:
cloud-paid surfaces own Suspend, Activate, Reload, tenant state, and mutation
semantics, while `Button` owns the row-action chrome through the secondary
`sm` and `xs` sizes.
If a new surface needs a variant that the shared primitive does not expose,
extend the primitive and registry guard rather than adding a page-local
class string.
@@ -945,6 +945,7 @@
{ "path": "src/components/Settings/APIAccessPanel.tsx" },
{ "path": "src/components/Settings/AvailabilitySettingsPanel.tsx" },
{ "path": "src/components/Settings/BillingAdminPanel.tsx" },
{ "path": "src/components/Settings/BillingAdminOrganizationsTable.tsx" },
{ "path": "src/components/Settings/ConnectionEditor/AddressProbeStep.tsx" },
{ "path": "src/components/Settings/ConnectionEditor/ConnectionEditor.tsx" },
{
@@ -989,6 +990,13 @@
"w-full sm:w-auto px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
]
},
{
"path": "src/components/Settings/BillingAdminOrganizationsTable.tsx",
"patterns": [
"px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50",
"px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover"
]
},
{
"path": "src/components/Settings/APIAccessPanel.tsx",
"patterns": [
@@ -5525,6 +5533,50 @@
"scripts/shared-template-audit.mjs"
]
},
{
"id": "billing-admin-organization-row-action-local-shell",
"category": "action-button",
"summary": "Billing admin organization Suspend and Activate row actions must not recreate the compact hosted-billing button shell; use Button with the secondary variant and sm size.",
"canonical": {
"path": "src/components/shared/buttonModel.ts",
"export": "getButtonClass"
},
"scopes": ["src/components/Settings/BillingAdminOrganizationsTable.tsx"],
"extensions": [".tsx"],
"allPatterns": [
"px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
],
"legacyReason": "Retired migration debt. Hosted billing organization row actions belong to the shared Button primitive family.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "billing-admin-organization-reload-action-local-shell",
"category": "action-button",
"summary": "Billing admin organization expanded-row Reload actions must not recreate the compact hosted-billing button shell; use Button with the secondary variant and xs size.",
"canonical": {
"path": "src/components/shared/buttonModel.ts",
"export": "getButtonClass"
},
"scopes": ["src/components/Settings/BillingAdminOrganizationsTable.tsx"],
"extensions": [".tsx"],
"allPatterns": [
"px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover"
],
"legacyReason": "Retired migration debt. Hosted billing expanded-row reload actions belong to the shared Button primitive family.",
"allowedPaths": [],
"ignoredPaths": ["src/components/shared/Button.test.tsx"],
"proof": [
"src/components/shared/SharedPrimitives.guardrails.test.ts",
"src/components/shared/Button.test.tsx",
"scripts/shared-template-audit.mjs"
]
},
{
"id": "sso-provider-primary-action-local-shell",
"category": "action-button",
@@ -1,4 +1,5 @@
import type { Component } from 'solid-js';
import { Button } from '@/components/shared/Button';
import { PulseDataGrid } from '@/components/shared/PulseDataGrid';
import {
BILLING_ADMIN_EMPTY_STATE,
@@ -54,7 +55,9 @@ export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizations
<div class="text-xs text-muted">
<span class="font-mono">{org.org_id}</span>
{getBillingAdminOrganizationBadges(org).map((badge) => (
<span class={`ml-2 rounded px-1.5 py-0.5 ${badge.badgeClass}`}>{badge.label}</span>
<span class={`ml-2 rounded px-1.5 py-0.5 ${badge.badgeClass}`}>
{badge.label}
</span>
))}
</div>
</button>
@@ -85,7 +88,9 @@ export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizations
label: 'Trial',
render: (org) => {
const billing = props.billingByOrgID[(org.org_id || '').trim()];
return <span class="text-xs text-base-content">{getBillingAdminTrialStatus(billing)}</span>;
return (
<span class="text-xs text-base-content">{getBillingAdminTrialStatus(billing)}</span>
);
},
},
{
@@ -111,8 +116,9 @@ export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizations
const currentSubState = (billing?.subscription_state || '').toLowerCase() || 'unknown';
return (
<div class="inline-flex flex-col sm:flex-row sm:items-center gap-2">
<button
type="button"
<Button
variant="secondary"
size="sm"
onClick={() => {
props.onSuspendOrganization(orgID);
}}
@@ -121,12 +127,12 @@ export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizations
props.billingLoadingByOrgID[orgID] ||
currentSubState === 'suspended'
}
class="px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Suspend Org
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => {
props.onActivateOrganization(orgID);
}}
@@ -135,10 +141,9 @@ export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizations
props.billingLoadingByOrgID[orgID] ||
currentSubState === 'active'
}
class="px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50"
>
Activate Org
</button>
</Button>
</div>
);
},
@@ -153,15 +158,15 @@ export const BillingAdminOrganizationsTable: Component<BillingAdminOrganizations
<div class="rounded-md border border-border bg-surface-alt p-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-2">
<div class="text-xs font-semibold text-muted">Billing state JSON</div>
<button
type="button"
<Button
variant="secondary"
size="xs"
onClick={() => {
props.onReloadOrganization(orgID);
}}
class="px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover"
>
Reload
</button>
</Button>
</div>
<pre class="text-xs overflow-x-auto whitespace-pre-wrap font-mono text-base-content">
{JSON.stringify(props.billingByOrgID[orgID] ?? { loading: true }, null, 2)}
@@ -147,6 +147,16 @@ describe('BillingAdminPanel', () => {
expect(billingAdminPanelStateSource).toContain('promisePool');
expect(billingAdminOrganizationsTableSource).toContain('PulseDataGrid');
expect(billingAdminOrganizationsTableSource).toContain('Billing state JSON');
expect(billingAdminOrganizationsTableSource).toContain('@/components/shared/Button');
expect(billingAdminOrganizationsTableSource).toContain('variant="secondary"');
expect(billingAdminOrganizationsTableSource).toContain('size="sm"');
expect(billingAdminOrganizationsTableSource).toContain('size="xs"');
expect(billingAdminOrganizationsTableSource).not.toContain(
'px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50',
);
expect(billingAdminOrganizationsTableSource).not.toContain(
'px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover',
);
});
it('stays unavailable in demo mode without loading hosted billing admin data', () => {
@@ -130,6 +130,7 @@ import thresholdsTableDockerIgnoredPrefixesSectionSource from '@/components/Aler
import webhookConfigFormSource from '@/components/Alerts/WebhookConfigForm.tsx?raw';
import reportMergeModalSource from '@/components/Infrastructure/ReportMergeModal.tsx?raw';
import availabilitySettingsPanelSource from '@/components/Settings/AvailabilitySettingsPanel.tsx?raw';
import billingAdminOrganizationsTableSource from '@/components/Settings/BillingAdminOrganizationsTable.tsx?raw';
import addressProbeStepSource from '@/components/Settings/ConnectionEditor/AddressProbeStep.tsx?raw';
import connectionEditorSource from '@/components/Settings/ConnectionEditor/ConnectionEditor.tsx?raw';
import availabilityTargetSlotSource from '@/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx?raw';
@@ -2906,6 +2907,12 @@ describe('shared primitive guardrails', () => {
const settingsDialogCloseGuard = registry.patternGuards?.find(
(guard) => guard.id === 'button-outline-settings-dialog-close-local-shell',
);
const billingAdminOrganizationRowActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'billing-admin-organization-row-action-local-shell',
);
const billingAdminOrganizationReloadActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'billing-admin-organization-reload-action-local-shell',
);
const ssoProviderPrimaryActionGuard = registry.patternGuards?.find(
(guard) => guard.id === 'sso-provider-primary-action-local-shell',
);
@@ -2958,6 +2965,7 @@ describe('shared primitive guardrails', () => {
'src/components/Settings/APIAccessPanel.tsx',
'src/components/Settings/AvailabilitySettingsPanel.tsx',
'src/components/Settings/BillingAdminPanel.tsx',
'src/components/Settings/BillingAdminOrganizationsTable.tsx',
'src/components/Settings/ConnectionEditor/AddressProbeStep.tsx',
'src/components/Settings/ConnectionEditor/ConnectionEditor.tsx',
'src/components/Settings/ConnectionEditor/CredentialSlots/AvailabilityTargetSlot.tsx',
@@ -2997,6 +3005,13 @@ describe('shared primitive guardrails', () => {
'w-full sm:w-auto px-3 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/BillingAdminOrganizationsTable.tsx',
patterns: expect.arrayContaining([
'px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50',
'px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover',
]),
}),
expect.objectContaining({
path: 'src/components/Settings/APIAccessPanel.tsx',
patterns: expect.arrayContaining([
@@ -3107,6 +3122,17 @@ describe('shared primitive guardrails', () => {
expect(updateProgressModalSource).not.toContain(
'px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors',
);
expect(billingAdminOrganizationsTableSource).toContain('@/components/shared/Button');
expect(billingAdminOrganizationsTableSource).toContain('<Button');
expect(billingAdminOrganizationsTableSource).toContain('variant="secondary"');
expect(billingAdminOrganizationsTableSource).toContain('size="sm"');
expect(billingAdminOrganizationsTableSource).toContain('size="xs"');
expect(billingAdminOrganizationsTableSource).not.toContain(
'px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50',
);
expect(billingAdminOrganizationsTableSource).not.toContain(
'px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover',
);
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
expect(registeredGuard?.canonical?.export).toBe('getButtonClass');
expect(registeredGuard?.allPatterns).toEqual([
@@ -3317,6 +3343,34 @@ describe('shared primitive guardrails', () => {
expect(settingsDialogCloseGuard?.ignoredPaths).toEqual([
'src/components/shared/Button.test.tsx',
]);
expect(billingAdminOrganizationRowActionGuard?.canonical?.path).toBe(
'src/components/shared/buttonModel.ts',
);
expect(billingAdminOrganizationRowActionGuard?.canonical?.export).toBe('getButtonClass');
expect(billingAdminOrganizationRowActionGuard?.allPatterns).toEqual([
'px-2.5 py-1.5 text-xs font-medium rounded-md border border-border bg-surface hover:bg-surface-hover disabled:opacity-50',
]);
expect(billingAdminOrganizationRowActionGuard?.scopes).toEqual([
'src/components/Settings/BillingAdminOrganizationsTable.tsx',
]);
expect(billingAdminOrganizationRowActionGuard?.allowedPaths ?? []).toHaveLength(0);
expect(billingAdminOrganizationRowActionGuard?.ignoredPaths).toEqual([
'src/components/shared/Button.test.tsx',
]);
expect(billingAdminOrganizationReloadActionGuard?.canonical?.path).toBe(
'src/components/shared/buttonModel.ts',
);
expect(billingAdminOrganizationReloadActionGuard?.canonical?.export).toBe('getButtonClass');
expect(billingAdminOrganizationReloadActionGuard?.allPatterns).toEqual([
'px-2 py-1 text-xs rounded-md border border-border bg-surface hover:bg-surface-hover',
]);
expect(billingAdminOrganizationReloadActionGuard?.scopes).toEqual([
'src/components/Settings/BillingAdminOrganizationsTable.tsx',
]);
expect(billingAdminOrganizationReloadActionGuard?.allowedPaths ?? []).toHaveLength(0);
expect(billingAdminOrganizationReloadActionGuard?.ignoredPaths).toEqual([
'src/components/shared/Button.test.tsx',
]);
expect(ssoProviderPrimaryActionGuard?.canonical?.path).toBe(
'src/components/shared/buttonModel.ts',
);