mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
Converge error fallback chrome on shared primitives
This commit is contained in:
@@ -520,6 +520,9 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
and reporting exports use the shared `success`, `successOutline`, and
|
||||
`successGhost` Button variants instead of carrying page-local emerald action
|
||||
shells.
|
||||
Shared error-boundary fallback actions are also command buttons: reset,
|
||||
reload, and retry controls must compose `Button` so emergency UI does not
|
||||
become a separate local button vocabulary.
|
||||
Compact icon-only row, inline, and floating action controls belong to
|
||||
`ActionIconButton`. Feature surfaces may own the icon choice, click handler,
|
||||
label text, and layout slot, but icon-button size, tone, focus ring,
|
||||
@@ -3391,6 +3394,10 @@ credential slots own the source-specific lifecycle or API meaning, while
|
||||
The `settings-connection-editor-local-*-callout-shell` pattern guards block
|
||||
future connection-editor files from reintroducing amber, red, or rose local
|
||||
notice shells.
|
||||
Shared error-boundary fallbacks use the same boundary: the fallback owns error
|
||||
copy and reset/reload handlers, while `CalloutCard` owns danger tone, spacing,
|
||||
dark-mode styling, and alert layout instead of inline red panels or raw SVG
|
||||
alert glyphs.
|
||||
|
||||
Settings loading placeholders must route through the shared
|
||||
`SettingsLoadingSkeleton` primitive instead of local `animate-pulse` blocks.
|
||||
|
||||
@@ -389,6 +389,32 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "error-boundary-callout-shell",
|
||||
"category": "callout",
|
||||
"summary": "Shared error-boundary fallback notices must compose CalloutCard and library icons for tone, spacing, and dark-mode styling instead of recreating local red panels or inline SVG alert glyphs.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/CalloutCard.tsx",
|
||||
"export": "CalloutCard"
|
||||
},
|
||||
"requiredConsumers": [{ "path": "src/components/ErrorBoundary.tsx" }],
|
||||
"forbiddenPatterns": [
|
||||
{
|
||||
"path": "src/components/ErrorBoundary.tsx",
|
||||
"patterns": [
|
||||
"<svg",
|
||||
"bg-red-50 dark:bg-red-900 border border-red-200",
|
||||
"p-4 bg-red-50 dark:bg-red-900 border border-red-200"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/__tests__/ErrorBoundary.test.tsx",
|
||||
"src/components/shared/CalloutCard.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "platform-inline-notice-shell",
|
||||
"category": "callout",
|
||||
@@ -513,15 +539,11 @@
|
||||
},
|
||||
{
|
||||
"path": "src/components/Settings/AgentProfilesPanel.tsx",
|
||||
"patterns": [
|
||||
"animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
|
||||
]
|
||||
"patterns": ["animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Settings/RolesPanel.tsx",
|
||||
"patterns": [
|
||||
"animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
|
||||
]
|
||||
"patterns": ["animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Settings/SSOProvidersPanel.tsx",
|
||||
@@ -543,21 +565,15 @@
|
||||
},
|
||||
{
|
||||
"path": "src/components/Settings/UserAssignmentsDialog.tsx",
|
||||
"patterns": [
|
||||
"animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"
|
||||
]
|
||||
"patterns": ["animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"]
|
||||
},
|
||||
{
|
||||
"path": "src/components/Settings/UserAssignmentsPanel.tsx",
|
||||
"patterns": [
|
||||
"animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"
|
||||
]
|
||||
"patterns": ["animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"]
|
||||
},
|
||||
{
|
||||
"path": "src/components/shared/Button.tsx",
|
||||
"patterns": [
|
||||
"class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-current\""
|
||||
]
|
||||
"patterns": ["class=\"animate-spin -ml-1 mr-2 h-4 w-4 text-current\""]
|
||||
},
|
||||
{
|
||||
"path": "src/components/shared/HistoryChartOverlay.tsx",
|
||||
@@ -735,6 +751,7 @@
|
||||
},
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/AI/Chat/ChatMessages.tsx" },
|
||||
{ "path": "src/components/ErrorBoundary.tsx" },
|
||||
{ "path": "src/components/Infrastructure/ResourceDetailDrawer.tsx" },
|
||||
{ "path": "src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx" },
|
||||
{ "path": "src/components/Settings/AgentProfilesPanel.tsx" },
|
||||
@@ -809,10 +826,19 @@
|
||||
"inline-flex items-center justify-center rounded-md border border-emerald-300 bg-white px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100",
|
||||
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "src/components/ErrorBoundary.tsx",
|
||||
"patterns": [
|
||||
"px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700",
|
||||
"px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700",
|
||||
"text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
]
|
||||
}
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/__tests__/ErrorBoundary.test.tsx",
|
||||
"src/components/shared/Button.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Component, JSX, ErrorBoundary as SolidErrorBoundary } from 'solid-js';
|
||||
import AlertTriangleIcon from 'lucide-solid/icons/alert-triangle';
|
||||
|
||||
import { Button } from '@/components/shared/Button';
|
||||
import { CalloutCard } from '@/components/shared/CalloutCard';
|
||||
import { logError } from '@/utils/logger';
|
||||
import { SectionHeader } from '@/components/shared/SectionHeader';
|
||||
|
||||
@@ -13,19 +17,7 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
|
||||
<div class="min-h-screen flex items-center justify-center bg-base p-4">
|
||||
<div class="max-w-md w-full bg-surface rounded-md shadow-sm p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<svg
|
||||
class="w-12 h-12 text-red-500 mr-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<AlertTriangleIcon class="mr-3 h-12 w-12 text-red-500" aria-hidden="true" />
|
||||
<div>
|
||||
<SectionHeader
|
||||
title="Something went wrong"
|
||||
@@ -37,28 +29,26 @@ const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (pr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded p-3 mb-4">
|
||||
<p class="text-sm text-red-800 dark:text-red-200">
|
||||
Please try again or reload the page. If the problem persists, contact your
|
||||
administrator.
|
||||
</p>
|
||||
</div>
|
||||
<CalloutCard
|
||||
tone="danger"
|
||||
scale="compact"
|
||||
padding="sm"
|
||||
class="mb-4"
|
||||
description="Please try again or reload the page. If the problem persists, contact your administrator."
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.reset}
|
||||
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Button onClick={props.reset} variant="primary" size="md" class="flex-1">
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
class="flex-1 px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700 transition-colors"
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="flex-1"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-muted leading-relaxed">
|
||||
@@ -102,36 +92,18 @@ export const ComponentErrorBoundary: Component<{
|
||||
return (
|
||||
<ErrorBoundary
|
||||
fallback={(error, reset) => (
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded">
|
||||
<div class="flex items-center mb-2">
|
||||
<svg
|
||||
class="w-5 h-5 text-red-500 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<SectionHeader
|
||||
title={`Error in ${props.name}`}
|
||||
size="sm"
|
||||
titleClass="text-red-800 dark:text-red-200"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-red-700 dark:text-red-300 mb-2">{error.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
class="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
<CalloutCard
|
||||
tone="danger"
|
||||
scale="compact"
|
||||
padding="md"
|
||||
icon={<AlertTriangleIcon class="h-5 w-5" aria-hidden="true" />}
|
||||
title={`Error in ${props.name}`}
|
||||
description={error.message}
|
||||
>
|
||||
<Button onClick={reset} variant="danger" size="xs" class="mt-1">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</CalloutCard>
|
||||
)}
|
||||
onError={(error) => {
|
||||
logError(`Error in component ${props.name}`, error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library';
|
||||
import type { JSX } from 'solid-js';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import errorBoundarySource from '@/components/ErrorBoundary.tsx?raw';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mocks */
|
||||
@@ -90,6 +91,17 @@ describe('ErrorBoundary', () => {
|
||||
expect(screen.getByText(/Technical details are suppressed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps fallback chrome on shared primitives', () => {
|
||||
expect(errorBoundarySource).toContain('CalloutCard');
|
||||
expect(errorBoundarySource).toContain('Button');
|
||||
expect(errorBoundarySource).toContain('lucide-solid/icons/alert-triangle');
|
||||
expect(errorBoundarySource).not.toContain('<svg');
|
||||
expect(errorBoundarySource).not.toContain(
|
||||
'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
|
||||
);
|
||||
expect(errorBoundarySource).not.toContain('bg-red-50 dark:bg-red-900 border border-red-200');
|
||||
});
|
||||
|
||||
/* ---------- Logs error via logError ---------- */
|
||||
|
||||
it('calls logError exactly once when a child throws', async () => {
|
||||
|
||||
@@ -115,6 +115,7 @@ import inlineDetailTableRowSource from '@/components/shared/InlineDetailTableRow
|
||||
import sharedTemplateRegistrySource from '../../../scripts/shared-template-registry.json?raw';
|
||||
import discoveryTabSource from '@/components/Discovery/DiscoveryTab.tsx?raw';
|
||||
import emailProviderSelectSource from '@/components/Alerts/EmailProviderSelect.tsx?raw';
|
||||
import errorBoundarySource from '@/components/ErrorBoundary.tsx?raw';
|
||||
import incidentTimelinePanelSource from '@/components/Alerts/IncidentTimelinePanel.tsx?raw';
|
||||
import alertDetailPresentationSource from '@/utils/alertDetailPresentation.ts?raw';
|
||||
import alertSeverityPresentationSource from '@/utils/alertSeverityPresentation.ts?raw';
|
||||
@@ -2881,6 +2882,7 @@ describe('shared primitive guardrails', () => {
|
||||
expect(registeredRule?.canonical?.export).toBe('Button');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/AI/Chat/ChatMessages.tsx',
|
||||
'src/components/ErrorBoundary.tsx',
|
||||
'src/components/Infrastructure/ResourceDetailDrawer.tsx',
|
||||
'src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx',
|
||||
'src/components/Settings/AgentProfilesPanel.tsx',
|
||||
@@ -2951,8 +2953,26 @@ describe('shared primitive guardrails', () => {
|
||||
'inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium text-emerald-900 hover:bg-emerald-100',
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
path: 'src/components/ErrorBoundary.tsx',
|
||||
patterns: expect.arrayContaining([
|
||||
'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
|
||||
'px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700',
|
||||
'text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700',
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(errorBoundarySource).toContain('Button');
|
||||
expect(errorBoundarySource).not.toContain(
|
||||
'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700',
|
||||
);
|
||||
expect(errorBoundarySource).not.toContain(
|
||||
'px-4 py-2 bg-slate-600 text-white rounded hover:bg-slate-700',
|
||||
);
|
||||
expect(errorBoundarySource).not.toContain(
|
||||
'text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700',
|
||||
);
|
||||
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/buttonModel.ts');
|
||||
expect(registeredGuard?.canonical?.export).toBe('getButtonClass');
|
||||
expect(registeredGuard?.allPatterns).toEqual([
|
||||
@@ -3677,6 +3697,44 @@ describe('shared primitive guardrails', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('routes shared error-boundary fallbacks through shared callout and button primitives', () => {
|
||||
const registry = JSON.parse(sharedTemplateRegistrySource) as {
|
||||
rules?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
requiredConsumers?: Array<{ path?: string }>;
|
||||
forbiddenPatterns?: Array<{ path?: string; patterns?: string[] }>;
|
||||
}>;
|
||||
};
|
||||
const registeredRule = registry.rules?.find(
|
||||
(rule) => rule.id === 'error-boundary-callout-shell',
|
||||
);
|
||||
|
||||
expect(registeredRule?.canonical?.path).toBe('src/components/shared/CalloutCard.tsx');
|
||||
expect(registeredRule?.canonical?.export).toBe('CalloutCard');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/ErrorBoundary.tsx',
|
||||
]);
|
||||
expect(registeredRule?.forbiddenPatterns).toEqual([
|
||||
{
|
||||
path: 'src/components/ErrorBoundary.tsx',
|
||||
patterns: [
|
||||
'<svg',
|
||||
'bg-red-50 dark:bg-red-900 border border-red-200',
|
||||
'p-4 bg-red-50 dark:bg-red-900 border border-red-200',
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(errorBoundarySource).toContain('CalloutCard');
|
||||
expect(errorBoundarySource).toContain('Button');
|
||||
expect(errorBoundarySource).toContain('lucide-solid/icons/alert-triangle');
|
||||
expect(errorBoundarySource).not.toContain('<svg');
|
||||
expect(errorBoundarySource).not.toContain('bg-red-50 dark:bg-red-900 border border-red-200');
|
||||
expect(errorBoundarySource).not.toContain(
|
||||
'p-4 bg-red-50 dark:bg-red-900 border border-red-200',
|
||||
);
|
||||
});
|
||||
|
||||
it('routes platform inline notices through InlineNotice', () => {
|
||||
const registry = JSON.parse(sharedTemplateRegistrySource) as {
|
||||
rules?: Array<{
|
||||
|
||||
Reference in New Issue
Block a user