diff --git a/.claude/skills/studio-error-handling/SKILL.md b/.claude/skills/studio-error-handling/SKILL.md new file mode 100644 index 00000000000..de0ff4cbab9 --- /dev/null +++ b/.claude/skills/studio-error-handling/SKILL.md @@ -0,0 +1,51 @@ +--- +name: studio-error-handling +description: Error display and troubleshooting pattern for Supabase Studio. Use when + rendering API errors in the UI, adding inline troubleshooting steps for a new + error type, or wiring up the AI assistant debug button from an error state. +--- + +# Studio Error Handling Pattern + +Full docs and code examples: `apps/studio/components/interfaces/ErrorHandling/README.md` + +## How it works + +Classification happens in the **data layer**: `handleError` in `data/fetchers.ts` tests the error message against `ERROR_PATTERNS` and throws the matching error subclass (e.g. `ConnectionTimeoutError extends ResponseError`). The component (`ErrorMatcher`) reads `errorType` from the instance and does an O(1) lookup — it never does regex matching. + +``` +handleError() → throws ConnectionTimeoutError → React Query catches → ErrorMatcher reads errorType → renders troubleshooting +``` + +## Key files + +| File | Purpose | +| ------------------------------------- | ---------------------------------------------------------------- | +| `data/error-patterns.ts` | Array of `{ pattern, ErrorClass }` — the regex lives here | +| `types/api-errors.ts` | Error classes, `KnownErrorType` union, `ClassifiedError` type | +| `ErrorMatcher.tsx` | Component — reads `errorType`, looks up mapping, renders | +| `error-mappings.tsx` | `Record` | +| `errorMappings/ConnectionTimeout.tsx` | Reference troubleshooting component | +| `TroubleshootingSections.tsx` | Reusable accordion section components | +| `TroubleshootingAccordion.tsx` | Accordion wrapper with telemetry | + +## Usage + +Pass the **full error object** from React Query — not `error.message`: + +```tsx +{ + isError && ( + + ) +} +``` + +## What NOT to do + +- Do not pass `error.message` to `ErrorMatcher` — pass the full `error` object so the class is preserved. +- Do not put regex patterns in `error-mappings.tsx` — they belong in `data/error-patterns.ts`. +- Do not use `Object.assign` to stamp `errorType` — throw a proper subclass instead. +- Do not pass a raw URL string for support — use `supportFormParams={{ projectRef }}`. +- Do not put the page title inside the error mapping — it belongs on the `` caller. +- Do not add callback props (`onDebugWithAI`, `onRestartProject`) to troubleshooting components — use hooks inside them instead. diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index ab73703af78..cc1b8f213f7 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -167,6 +167,11 @@ export const docsConfig: DocsConfig = { href: '/docs/fragments/data-input', items: [], }, + { + title: 'Error Display', + href: '/docs/fragments/error-display', + items: [], + }, { title: 'Form Item Layout', href: '/docs/fragments/form-item-layout', diff --git a/apps/design-system/content/docs/fragments/error-display.mdx b/apps/design-system/content/docs/fragments/error-display.mdx new file mode 100644 index 00000000000..4b897b21e4e --- /dev/null +++ b/apps/design-system/content/docs/fragments/error-display.mdx @@ -0,0 +1,59 @@ +--- +title: Error Display +description: A card component for surfacing API errors with optional troubleshooting steps and a support link. +fragment: true +--- + +ErrorDisplay renders a styled error card with a warning header, a monospace error message block, an optional children slot for inline troubleshooting content, and a "Contact support" footer link that is always shown. + + + +Use ErrorDisplay as the base for any inline error state in the dashboard. For errors with known patterns, wire up [`ErrorMatcher`](https://github.com/supabase/supabase/tree/master/apps/studio/components/interfaces/ErrorHandling) on top to automatically inject matching troubleshooting steps. + +## Usage + +```tsx +import { ErrorDisplay } from 'ui-patterns/ErrorDisplay' +``` + +```tsx + +``` + +## Examples + +### With troubleshooting steps + +Pass any content as `children` — typically a `TroubleshootingAccordion` — to render inline troubleshooting between the error message and the support footer. + + + +## Props + +| Prop | Type | Default | Description | +| ------------------- | -------------------- | ------------------- | --------------------------------------------------------------------- | +| `title` | `string` | — | Displayed in the card header next to the warning icon. | +| `errorMessage` | `string` | — | Raw error string rendered in a monospace code block. | +| `supportFormParams` | `SupportFormParams?` | `undefined` | Typed params for the support form URL. The component builds the URL. | +| `supportLabel` | `string?` | `"Contact support"` | Override the support link label. | +| `children` | `ReactNode` | `undefined` | Slot for troubleshooting content rendered between message and footer. | +| `icon` | `ReactNode` | Warning triangle | Override the header icon. | +| `onRender` | `() => void?` | — | Fired once on mount — use for telemetry. | +| `onSupportClick` | `() => void?` | — | Fired when the support link is clicked — use for telemetry. | +| `className` | `string?` | — | Extra classes on the root `Card`. | + +### SupportFormParams + +| Field | Type | Description | +| ------------ | --------- | ----------------------------- | +| `projectRef` | `string?` | Project reference slug | +| `orgSlug` | `string?` | Organisation slug | +| `category` | `string?` | Pre-selected support category | +| `subject` | `string?` | Pre-filled subject line | +| `message` | `string?` | Pre-filled message body | +| `error` | `string?` | Raw error string for context | +| `sid` | `string?` | Sentry event ID | diff --git a/apps/design-system/registry/default/example/error-display-demo.tsx b/apps/design-system/registry/default/example/error-display-demo.tsx new file mode 100644 index 00000000000..e994c90bd4e --- /dev/null +++ b/apps/design-system/registry/default/example/error-display-demo.tsx @@ -0,0 +1,11 @@ +import { ErrorDisplay } from 'ui-patterns/ErrorDisplay' + +export default function ErrorDisplayDemo() { + return ( + + ) +} diff --git a/apps/design-system/registry/default/example/error-display-with-children.tsx b/apps/design-system/registry/default/example/error-display-with-children.tsx new file mode 100644 index 00000000000..9c2f1cf46bc --- /dev/null +++ b/apps/design-system/registry/default/example/error-display-with-children.tsx @@ -0,0 +1,16 @@ +import { ErrorDisplay } from 'ui-patterns/ErrorDisplay' + +export default function ErrorDisplayWithChildren() { + return ( + +
+ Troubleshooting steps would appear here — e.g. a{' '} + TroubleshootingAccordion. +
+
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index 821c5530446..34a31c697b7 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -1462,6 +1462,18 @@ export const examples: Registry = [ type: 'docs:example', files: ['example/expanding-textarea-demo.tsx'], }, + { + name: 'error-display-demo', + type: 'components:example', + registryDependencies: ['error-display'], + files: ['example/error-display-demo.tsx'], + }, + { + name: 'error-display-with-children', + type: 'components:example', + registryDependencies: ['error-display'], + files: ['example/error-display-with-children.tsx'], + }, { name: 'logs-bar-chart', type: 'components:example', diff --git a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx new file mode 100644 index 00000000000..38266a28e8d --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from '@testing-library/react' +import { ConnectionTimeoutError } from 'types/api-errors' +import { ResponseError } from 'types/base' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ErrorMatcher } from './ErrorMatcher' + +vi.mock('lib/telemetry/track', () => ({ useTrack: () => vi.fn() })) +vi.mock('state/ai-assistant-state', () => ({ + useAiAssistantStateSnapshot: () => ({ newChat: vi.fn() }), +})) +vi.mock('state/sidebar-manager-state', () => ({ + useSidebarManagerSnapshot: () => ({ openSidebar: vi.fn() }), +})) +vi.mock('components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider', () => ({ + SIDEBAR_KEYS: { AI_ASSISTANT: 'ai-assistant' }, +})) +vi.mock('./RestartProjectDialog', () => ({ + RestartProjectDialog: () => null, +})) + +describe('ErrorMatcher', () => { + beforeEach(() => vi.clearAllMocks()) + + it('renders the provided title and error message', () => { + render( + + ) + expect(screen.getByText('Failed to load tables')).toBeInTheDocument() + expect( + screen.getByText( + 'ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT.' + ) + ).toBeInTheDocument() + }) + + it('renders troubleshooting steps for classified errors', () => { + const error = new ConnectionTimeoutError('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT') + render() + expect(screen.getByText('Try restarting your project')).toBeInTheDocument() + expect(screen.getByText('Try our troubleshooting guide')).toBeInTheDocument() + expect(screen.getByText('Debug with AI')).toBeInTheDocument() + }) + + it('renders fallback for plain ResponseError (not a classified subclass)', () => { + render( + + ) + expect(screen.getByText('Failed to load tables')).toBeInTheDocument() + expect(screen.queryByText('Try restarting your project')).not.toBeInTheDocument() + }) + + it('renders fallback with provided title for unmatched errors', () => { + render( + + ) + expect(screen.getByText('Failed to load tables')).toBeInTheDocument() + expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument() + }) + + it('accepts error as object with message property', () => { + render( + + ) + expect(screen.getByText('UNKNOWN ERROR')).toBeInTheDocument() + }) + + it('builds support link with projectRef param', () => { + render( + + ) + expect(screen.getByRole('link', { name: /contact support/i })).toHaveAttribute( + 'href', + '/support/new?projectRef=my-project' + ) + }) + + it('builds support link with no params when supportFormParams is omitted', () => { + render() + expect(screen.getByRole('link', { name: /contact support/i })).toHaveAttribute( + 'href', + '/support/new' + ) + }) +}) diff --git a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.tsx b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.tsx new file mode 100644 index 00000000000..37381adefa5 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useTrack } from 'lib/telemetry/track' +import { ErrorDisplay, SupportFormParams } from 'ui-patterns/ErrorDisplay' + +import { getMappingForError } from './ErrorMatcher.utils' + +interface ErrorMatcherProps { + title: string + error: string | { message: string } + supportFormParams?: SupportFormParams + className?: string +} + +export function ErrorMatcher({ title, error, supportFormParams, className }: ErrorMatcherProps) { + const track = useTrack() + + const message = typeof error === 'string' ? error : error.message + const mapping = getMappingForError(error) + const Troubleshooting = mapping?.Troubleshooting + + return ( + { + track('dashboard_error_created', { + source: 'error_display', + errorType: mapping?.id, + hasTroubleshooting: !!mapping, + }) + if (mapping) { + track('inline_error_troubleshooter_exposed', { errorType: mapping.id }) + } + }} + onSupportClick={ + mapping + ? () => + track('inline_error_troubleshooter_action_clicked', { + errorType: mapping.id, + ctaType: 'contact_support', + }) + : undefined + } + > + {Troubleshooting && } + + ) +} diff --git a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.utils.test.ts b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.utils.test.ts new file mode 100644 index 00000000000..34d3a7cbe90 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.utils.test.ts @@ -0,0 +1,34 @@ +import { ConnectionTimeoutError, UnknownAPIResponseError } from 'types/api-errors' +import { describe, expect, it } from 'vitest' + +import { getMappingForError } from './ErrorMatcher.utils' + +describe('getMappingForError', () => { + it('returns the mapping for a classified error with a known errorType', () => { + const error = new ConnectionTimeoutError('connection terminated due to connection timeout') + const mapping = getMappingForError(error) + expect(mapping).not.toBeNull() + expect(mapping?.id).toBe('connection-timeout') + }) + + it('returns null for UnknownAPIResponseError (no troubleshooting guide)', () => { + const error = new UnknownAPIResponseError('something went wrong') + expect(getMappingForError(error)).toBeNull() + }) + + it('returns null for a plain string', () => { + expect(getMappingForError('some error message')).toBeNull() + }) + + it('returns null for null', () => { + expect(getMappingForError(null)).toBeNull() + }) + + it('returns null for an object with no errorType', () => { + expect(getMappingForError({ message: 'error' })).toBeNull() + }) + + it('returns null for an object with an unrecognised errorType', () => { + expect(getMappingForError({ errorType: 'not-a-real-type' })).toBeNull() + }) +}) diff --git a/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.utils.ts b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.utils.ts new file mode 100644 index 00000000000..09f73218315 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/ErrorMatcher.utils.ts @@ -0,0 +1,12 @@ +import { ResponseError } from 'types/base' + +import { ERROR_MAPPINGS, type ErrorMapping } from './error-mappings' + +export function getMappingForError(error: unknown): ErrorMapping | null { + const isResponseError = error instanceof ResponseError + if (!isResponseError) return null + for (const [ErrorClass, mapping] of ERROR_MAPPINGS) { + if (error instanceof ErrorClass) return mapping + } + return null +} diff --git a/apps/studio/components/interfaces/ErrorHandling/README.md b/apps/studio/components/interfaces/ErrorHandling/README.md new file mode 100644 index 00000000000..dda0f17e2ab --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/README.md @@ -0,0 +1,130 @@ +# Error Handling + +`ErrorMatcher` displays a typed API error. If the error was classified by `handleError` (i.e. it is an instance of a known error class), it shows matching troubleshooting steps. Otherwise it shows a generic error card. + +Classification happens in the data layer — `handleError` in `data/fetchers.ts` matches the error message against patterns and throws the appropriate error subclass (e.g. `ConnectionTimeoutError`). The component never does regex matching itself. + +The `title` always comes from the caller — the same error type can appear on different pages with different titles. + +## Usage + +```tsx +import { ErrorMatcher } from 'components/interfaces/ErrorHandling/ErrorMatcher' + +{ + isError && ( + + ) +} +``` + +Pass the full `error` object from React Query — not `error.message`. This lets `ErrorMatcher` check the error class and show the right troubleshooting steps. + +### Props + +| Prop | Type | Description | +| ------------------- | ------------------------------- | ------------------------------------------------------------------ | +| `title` | `string` | Displayed in the error card header. Set by the caller. | +| `error` | `string \| { message: string }` | The error from React Query (pass the full object, not `.message`). | +| `supportFormParams` | `Partial` | Typed params for the support form URL (projectRef, category…). | +| `className` | `string?` | Extra classes on the card. | + +`supportFormParams` is typed as `Partial` — autocomplete shows all available fields (`projectRef`, `orgSlug`, `category`, `subject`, `message`, `error`, `sid`). The URL is built by `createSupportFormUrl()` from `SupportForm.utils.tsx`. + +## Adding a new error mapping + +**1. Add the error class to `types/api-errors.ts`** + +```ts +export type KnownErrorType = 'connection-timeout' | 'your-error' + +export class YourError extends ResponseError { + readonly errorType = 'your-error' as const +} + +export type ClassifiedError = ConnectionTimeoutError | FailedToRetrieveProjectsError | YourError +``` + +**2. Add a pattern entry to `data/error-patterns.ts`** + +```ts +import { YourError } from 'types/api-errors' + +export const ERROR_PATTERNS: ErrorPattern[] = [ + // existing... + { + pattern: /YOUR_ERROR_PATTERN/i, + ErrorClass: YourError, + }, +] +``` + +`handleError` picks this up automatically — any matching API error will be thrown as a `YourError` instance. + +**3. Create `errorMappings/YourError.tsx`** + +```tsx +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' + +import { TroubleshootingAccordion } from '../TroubleshootingAccordion' +import { + FixWithAITroubleshootingSection, + TroubleshootingGuideSection, +} from '../TroubleshootingSections' + +const ERROR_TYPE = 'your-error' +const BUILD_PROMPT = () => `Describe the issue for the AI assistant.` + +export function YourErrorTroubleshooting() { + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + return ( + + + { + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ initialMessage: prompt }) + }} + /> + + ) +} +``` + +**4. Add it to `error-mappings.tsx`** + +```tsx +import { YourErrorTroubleshooting } from './errorMappings/YourError' + +export const ERROR_MAPPINGS: Record = { + // existing... + 'your-error': { + id: 'your-error', + Troubleshooting: YourErrorTroubleshooting, + }, +} +``` + +That's it. `ErrorMatcher` picks it up automatically. + +## Available section components + +| Component | Props | +| --------------------------------------- | ------------------------------------------------------- | +| `RestartDatabaseTroubleshootingSection` | `number`, `errorType`, `onRestartProject?` | +| `TroubleshootingGuideSection` | `number`, `errorType`, `href`, `title?`, `description?` | +| `FixWithAITroubleshootingSection` | `number`, `errorType`, `buildPrompt`, `onDebugWithAI?` | diff --git a/apps/studio/components/interfaces/ErrorHandling/RestartProjectDialog.tsx b/apps/studio/components/interfaces/ErrorHandling/RestartProjectDialog.tsx new file mode 100644 index 00000000000..4e015f323d2 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/RestartProjectDialog.tsx @@ -0,0 +1,104 @@ +'use client' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useSetProjectStatus } from 'data/projects/project-detail-query' +import { useProjectRestartMutation } from 'data/projects/project-restart-mutation' +import { useProjectRestartServicesMutation } from 'data/projects/project-restart-services-mutation' +import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import { useRouter } from 'next/router' +import { toast } from 'sonner' +import { ConfirmationModal } from 'ui-patterns/Dialogs/ConfirmationModal' + +interface RestartProjectDialogProps { + visible: boolean + onClose: () => void + /** Restart type: 'project' for full restart, 'database' for fast database reboot */ + restartType?: 'project' | 'database' +} + +export function RestartProjectDialog({ + visible, + onClose, + restartType = 'database', +}: RestartProjectDialogProps) { + const router = useRouter() + const { data: project } = useSelectedProjectQuery() + const { setProjectStatus } = useSetProjectStatus() + + const { can: canRestartProject } = useAsyncCheckPermissions( + PermissionAction.INFRA_EXECUTE, + 'reboot' + ) + + const { mutate: restartProject, isPending: isRestartingProject } = useProjectRestartMutation({ + onSuccess: () => { + if (project?.ref) { + setProjectStatus({ ref: project.ref, status: PROJECT_STATUS.RESTARTING }) + } + toast.success('Restarting project') + router.push(`/project/${project?.ref}`) + onClose() + }, + onError: (error) => { + toast.error(`Unable to restart project: ${error.message}`) + }, + }) + + const { mutate: restartProjectServices, isPending: isRestartingServices } = + useProjectRestartServicesMutation({ + onSuccess: () => { + if (project?.ref) { + setProjectStatus({ ref: project.ref, status: PROJECT_STATUS.RESTARTING }) + } + toast.success('Restarting database') + router.push(`/project/${project?.ref}`) + onClose() + }, + onError: (error) => { + toast.error(`Unable to restart database: ${error.message}`) + }, + }) + + const isLoading = isRestartingProject || isRestartingServices + + const handleRestart = () => { + if (!project?.ref) return + + if (!canRestartProject) { + return toast.error('You do not have the required permissions to restart this project') + } + + if (restartType === 'project') { + restartProject({ ref: project.ref }) + } else { + restartProjectServices({ + ref: project.ref, + region: project.region, + services: ['postgresql'], + }) + } + } + + const title = restartType === 'project' ? 'Restart project' : 'Restart database' + const description = + restartType === 'project' + ? 'Are you sure you want to restart your project? There will be a few minutes of downtime.' + : 'Are you sure you want to restart your database? There will be a brief downtime.' + + return ( + + ) +} diff --git a/apps/studio/components/interfaces/ErrorHandling/TroubleshootingAccordion.tsx b/apps/studio/components/interfaces/ErrorHandling/TroubleshootingAccordion.tsx new file mode 100644 index 00000000000..c65f398cabf --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/TroubleshootingAccordion.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useTrack } from 'lib/telemetry/track' +import { ReactNode } from 'react' +import { Accordion_Shadcn_ as Accordion, cn } from 'ui' + +interface TroubleshootingAccordionProps { + children: ReactNode + /** Error mapping ID — used for telemetry */ + errorType: string + /** Step titles keyed by step number — used for telemetry */ + stepTitles?: Record + /** Which step to expand by default (1-indexed), defaults to 1 */ + defaultExpandedStep?: number + className?: string +} + +export function TroubleshootingAccordion({ + children, + errorType, + stepTitles, + defaultExpandedStep = 1, + className, +}: TroubleshootingAccordionProps) { + const track = useTrack() + const defaultValue = defaultExpandedStep > 0 ? `step-${defaultExpandedStep}` : undefined + + return ( + { + const expanded = Boolean(value) + const step = expanded ? parseInt(value.replace('step-', ''), 10) : null + track('inline_error_troubleshooter_step_clicked', { + errorType, + step, + stepTitle: step !== null ? stepTitles?.[step] : undefined, + expanded, + }) + }} + > + {children} + + ) +} diff --git a/apps/studio/components/interfaces/ErrorHandling/TroubleshootingSections.tsx b/apps/studio/components/interfaces/ErrorHandling/TroubleshootingSections.tsx new file mode 100644 index 00000000000..e1b7a4b46a1 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/TroubleshootingSections.tsx @@ -0,0 +1,179 @@ +'use client' + +import { AiAssistantDropdown } from 'components/ui/AiAssistantDropdown' +import { useTrack } from 'lib/telemetry/track' +import { ExternalLink } from 'lucide-react' +import { useState } from 'react' +import { + AccordionContent_Shadcn_ as AccordionContent, + AccordionItem_Shadcn_ as AccordionItem, + AccordionTrigger_Shadcn_ as AccordionTrigger, + Button, +} from 'ui' + +import { RestartProjectDialog } from './RestartProjectDialog' + +interface StepTriggerProps { + number: number + title: string +} + +function StepTrigger({ number, title }: StepTriggerProps) { + return ( + +
+ + {number} + + {title} +
+
+ ) +} + +interface RestartDatabaseTroubleshootingSectionProps { + number: number + errorType: string + /** Override the restart handler. If not provided, opens the restart dialog internally. */ + onRestartProject?: () => void +} + +export function RestartDatabaseTroubleshootingSection({ + number, + errorType, + onRestartProject, +}: RestartDatabaseTroubleshootingSectionProps) { + const track = useTrack() + const [showDialog, setShowDialog] = useState(false) + + const handleClick = () => { + track('inline_error_troubleshooter_action_clicked', { + errorType, + ctaType: 'restart_db', + }) + if (onRestartProject) { + onRestartProject() + } else { + setShowDialog(true) + } + } + + return ( + <> + + + +
+

+ Restarting your project can help resolve timeout errors or stale connections. +

+ +
+
+
+ + setShowDialog(false)} + restartType="database" + /> + + ) +} + +interface TroubleshootingGuideSectionProps { + number: number + errorType: string + href: string + title?: string + description?: string +} + +export function TroubleshootingGuideSection({ + number, + errorType, + href, + title = 'Try our troubleshooting guide', + description, +}: TroubleshootingGuideSectionProps) { + const track = useTrack() + + return ( + + + +
+ {description &&

{description}

} + +
+
+
+ ) +} + +interface FixWithAITroubleshootingSectionProps { + number: number + errorType: string + description?: string + onDebugWithAI?: (prompt: string) => void + buildPrompt: () => string +} + +export function FixWithAITroubleshootingSection({ + number, + errorType, + description = 'Let our AI assistant help diagnose and suggest solutions.', + onDebugWithAI, + buildPrompt, +}: FixWithAITroubleshootingSectionProps) { + const track = useTrack() + + return ( + + + +
+

{description}

+ { + track('inline_error_troubleshooter_action_clicked', { + errorType, + ctaType: 'ask_ai', + }) + onDebugWithAI?.(buildPrompt()) + }} + size="tiny" + /> +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx b/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx new file mode 100644 index 00000000000..c4095cae060 --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/error-mappings.tsx @@ -0,0 +1,19 @@ +import { ComponentType } from 'react' +import { ConnectionTimeoutError } from 'types/api-errors' +import type { ClassifiedError, KnownErrorType } from 'types/api-errors' + +import { ConnectionTimeoutTroubleshooting } from './errorMappings/ConnectionTimeout' + +export interface ErrorMapping { + id: KnownErrorType + Troubleshooting: ComponentType +} + +type ErrorConstructor = new (...args: any[]) => ClassifiedError + +export const ERROR_MAPPINGS = new Map([ + [ + ConnectionTimeoutError, + { id: 'connection-timeout', Troubleshooting: ConnectionTimeoutTroubleshooting }, + ], +]) diff --git a/apps/studio/components/interfaces/ErrorHandling/errorMappings/ConnectionTimeout.tsx b/apps/studio/components/interfaces/ErrorHandling/errorMappings/ConnectionTimeout.tsx new file mode 100644 index 00000000000..db01ae411df --- /dev/null +++ b/apps/studio/components/interfaces/ErrorHandling/errorMappings/ConnectionTimeout.tsx @@ -0,0 +1,48 @@ +import { SIDEBAR_KEYS } from 'components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { useSidebarManagerSnapshot } from 'state/sidebar-manager-state' + +import { TroubleshootingAccordion } from '../TroubleshootingAccordion' +import { + FixWithAITroubleshootingSection, + RestartDatabaseTroubleshootingSection, + TroubleshootingGuideSection, +} from '../TroubleshootingSections' + +const ERROR_TYPE = 'connection-timeout' + +const BUILD_PROMPT = () => + `The user is encountering connection timeout errors. The error message is: "CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT". What are the most likely causes of this issue and how can the user resolve it?` + +export function ConnectionTimeoutTroubleshooting() { + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + return ( + + + + { + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ initialMessage: prompt }) + }} + buildPrompt={BUILD_PROMPT} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx index 96b9c286896..2990e303d92 100644 --- a/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx +++ b/apps/studio/components/interfaces/Home/ProjectList/ProjectList.tsx @@ -26,6 +26,8 @@ import { TableRow, } from 'ui' +import { ErrorMatcher } from '../../ErrorHandling/ErrorMatcher' +import { TroubleshootingAccordion } from '../../ErrorHandling/TroubleshootingAccordion' import { LoadingCardView, LoadingTableView, NoProjectsState } from './EmptyStates' import { LoadMoreRows } from './LoadMoreRow' import { ProjectCard } from './ProjectCard' diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.test.ts b/apps/studio/components/interfaces/Support/SupportForm.utils.test.ts new file mode 100644 index 00000000000..4c2a584d430 --- /dev/null +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' + +import { createSupportFormUrl } from './SupportForm.utils' + +describe('createSupportFormUrl', () => { + it('returns base URL with no params', () => { + expect(createSupportFormUrl({})).toBe('/support/new') + }) + + it('does not append a bare ? when params are empty', () => { + expect(createSupportFormUrl({})).not.toContain('?') + }) + + it('includes provided params in the query string', () => { + const url = createSupportFormUrl({ projectRef: 'my-project' }) + expect(url).toContain('projectRef=my-project') + }) + + it('includes multiple params', () => { + const url = createSupportFormUrl({ projectRef: 'my-project', subject: 'help' }) + expect(url).toContain('projectRef=my-project') + expect(url).toContain('subject=help') + }) +}) diff --git a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx index 7f447168120..42aeb037a1b 100644 --- a/apps/studio/components/interfaces/Support/SupportForm.utils.tsx +++ b/apps/studio/components/interfaces/Support/SupportForm.utils.tsx @@ -143,7 +143,8 @@ const serializeSupportFormInitialParams = createSerializer(supportFormUrlState) export function createSupportFormUrl(initialParams: Partial) { const serializedParams = serializeSupportFormInitialParams(initialParams) - return `/support/new${serializedParams ?? ''}` + const query = serializedParams && serializedParams !== '?' ? serializedParams : '' + return `/support/new${query}` } /** diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 9f63368ff44..9bc3596dc86 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -1,7 +1,4 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Filter, Plus } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState } from 'react' - import { keepPreviousData } from '@tanstack/react-query' import { useParams } from 'common' import { useBreakpoint } from 'common/hooks/useBreakpoint' @@ -9,8 +6,8 @@ import { ExportDialog } from 'components/grid/components/header/ExportDialog' import { parseSupaTable } from 'components/grid/SupabaseGrid.utils' import { SupaTable } from 'components/grid/types' import { ProtectedSchemaWarning } from 'components/interfaces/Database/ProtectedSchemaWarning' +import { ErrorMatcher } from 'components/interfaces/ErrorHandling/ErrorMatcher' import EditorMenuListSkeleton from 'components/layouts/TableEditorLayout/EditorMenuListSkeleton' -import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { InfiniteListDefault, LoaderForIconMenuItems } from 'components/ui/InfiniteList' import SchemaSelector from 'components/ui/SchemaSelector' @@ -23,22 +20,25 @@ import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' +import { Filter, Plus } from 'lucide-react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useTableEditorStateSnapshot } from 'state/table-editor' import { Button, Checkbox_Shadcn_, Label_Shadcn_, + Popover_Shadcn_, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, - Popover_Shadcn_, } from 'ui' import { InnerSideBarEmptyPanel, + InnerSideBarFilters, InnerSideBarFilterSearchInput, InnerSideBarFilterSortDropdown, InnerSideBarFilterSortDropdownItem, - InnerSideBarFilters, } from 'ui-patterns/InnerSideMenu' + import { useTableEditorTabsCleanUp } from '../Tabs/Tabs.utils' import { EntityListItem } from './EntityListItem' import { TableMenuEmptyState } from './TableMenuEmptyState' @@ -277,9 +277,12 @@ export const TableEditorMenu = () => { {isLoading && } {isError && ( -
- -
+ )} {isSuccess && ( diff --git a/apps/studio/data/error-patterns.test.ts b/apps/studio/data/error-patterns.test.ts new file mode 100644 index 00000000000..6a551af70c7 --- /dev/null +++ b/apps/studio/data/error-patterns.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { ERROR_PATTERNS } from './error-patterns' + +// Representative sample messages for each error class. +// Keep this in sync when adding new patterns — the test will fail if you don't. +const PATTERN_SAMPLES: Record = { + ConnectionTimeoutError: { + matches: [ + 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT', + 'connection terminated due to connection timeout', + 'ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT.', + 'Connection Terminated Due To Connection Timeout', // extra whitespace + ], + nonMatches: [ + 'connection timeout', + 'connection terminated', + 'query timed out', + 'idle connection timeout', + '', + ], + }, +} + +describe('ERROR_PATTERNS registry', () => { + it('has a PATTERN_SAMPLES entry for every registered pattern (keep samples in sync)', () => { + for (const { ErrorClass } of ERROR_PATTERNS) { + expect( + PATTERN_SAMPLES, + `Add a PATTERN_SAMPLES entry for '${ErrorClass.name}'` + ).toHaveProperty(ErrorClass.name) + } + }) + + describe('per-pattern match correctness', () => { + for (const { ErrorClass, pattern } of ERROR_PATTERNS) { + const samples = PATTERN_SAMPLES[ErrorClass.name] + if (!samples) continue + + describe(ErrorClass.name, () => { + it.each(samples.matches)('matches: %s', (msg) => { + expect(pattern.test(msg)).toBe(true) + }) + + it.each(samples.nonMatches)('does not match: %s', (msg) => { + expect(pattern.test(msg)).toBe(false) + }) + }) + } + }) + + describe('no message matches more than one pattern', () => { + const allSamples = Object.entries(PATTERN_SAMPLES).flatMap(([className, { matches }]) => + matches.map((msg) => ({ msg, sourceClass: className })) + ) + + it.each(allSamples)('$sourceClass sample "$msg" matches exactly one pattern', ({ msg }) => { + const matched = ERROR_PATTERNS.filter(({ pattern }) => pattern.test(msg)) + expect(matched.length).toBe(1) + }) + }) +}) diff --git a/apps/studio/data/error-patterns.ts b/apps/studio/data/error-patterns.ts new file mode 100644 index 00000000000..20b7542a6fd --- /dev/null +++ b/apps/studio/data/error-patterns.ts @@ -0,0 +1,25 @@ +import { ConnectionTimeoutError } from 'types/api-errors' +import type { ClassifiedError } from 'types/api-errors' +import type { ResponseError } from 'types/base' + +type ErrorConstructor = new ( + ...args: ConstructorParameters +) => ClassifiedError + +export interface ErrorPattern { + pattern: RegExp + ErrorClass: ErrorConstructor +} + +/** + * Maps each error class to its matching regex pattern. + * Using a Map guarantees each class can only appear once — duplicates are + * impossible by construction rather than only caught by tests. + */ +const ERROR_PATTERN_MAP = new Map([ + [ConnectionTimeoutError, /CONNECTION\s+TERMINATED\s+DUE\s+TO\s+CONNECTION\s+TIMEOUT/i], +]) + +export const ERROR_PATTERNS: ErrorPattern[] = Array.from(ERROR_PATTERN_MAP.entries()).map( + ([ErrorClass, pattern]) => ({ ErrorClass, pattern }) +) diff --git a/apps/studio/data/fetchers.ts b/apps/studio/data/fetchers.ts index 968d46851b0..1efd4ab910e 100644 --- a/apps/studio/data/fetchers.ts +++ b/apps/studio/data/fetchers.ts @@ -1,12 +1,14 @@ import * as Sentry from '@sentry/nextjs' import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants' -import { IS_PLATFORM, getAccessToken } from 'common' +import { getAccessToken, IS_PLATFORM } from 'common' import { API_URL } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import createClient from 'openapi-fetch' import { ResponseError } from 'types' +import { UnknownAPIResponseError } from 'types/api-errors' import type { paths } from './api' +import { ERROR_PATTERNS } from './error-patterns' import { ErrorMetadata } from '@/types/base' // generated from openapi-typescript @@ -172,14 +174,24 @@ export const handleError = (error: unknown, options: HandleErrorOptions = {}): n : undefined if (errorMessage) { - throw new ResponseError( - errorMessage, - errorCode, - requestId, - retryAfter, - requestPathname, - metadata - ) + const matched = ERROR_PATTERNS.find(({ pattern }) => pattern.test(errorMessage)) + throw matched + ? new matched.ErrorClass( + errorMessage, + errorCode, + requestId, + retryAfter, + requestPathname, + metadata + ) + : new UnknownAPIResponseError( + errorMessage, + errorCode, + requestId, + retryAfter, + requestPathname, + metadata + ) } } @@ -193,7 +205,7 @@ export const handleError = (error: unknown, options: HandleErrorOptions = {}): n // throw a generic error if we don't know what the error is. The message is intentionally vague because it might show // up in the UI. - throw new ResponseError(undefined) + throw new UnknownAPIResponseError(undefined) } // [Joshen] The methods below are brought over from lib/common/fetch because we still need them diff --git a/apps/studio/data/handleError.test.ts b/apps/studio/data/handleError.test.ts new file mode 100644 index 00000000000..c0e086ff428 --- /dev/null +++ b/apps/studio/data/handleError.test.ts @@ -0,0 +1,98 @@ +import { ConnectionTimeoutError, UnknownAPIResponseError } from 'types/api-errors' +import { ResponseError } from 'types/base' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sentry/nextjs', () => ({ captureException: vi.fn() })) +vi.mock('common', () => ({ IS_PLATFORM: false, getAccessToken: vi.fn() })) +vi.mock('lib/constants', () => ({ API_URL: 'http://localhost' })) +vi.mock('lib/helpers', () => ({ uuidv4: () => 'test-uuid' })) + +// Import after mocks are set up +const { handleError } = await import('./fetchers') + +function throwAndCatch(error: unknown): ResponseError { + try { + handleError(error) + } catch (e) { + return e as ResponseError + } + throw new Error('handleError did not throw') +} + +describe('handleError — error classification', () => { + beforeEach(() => vi.clearAllMocks()) + + describe('known patterns', () => { + it('classifies connection timeout via message field', () => { + const err = throwAndCatch({ message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' }) + expect(err).toBeInstanceOf(ConnectionTimeoutError) + expect((err as ConnectionTimeoutError).errorType).toBe('connection-timeout') + }) + + it('classifies connection timeout via msg field', () => { + const err = throwAndCatch({ msg: 'ERROR: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' }) + expect(err).toBeInstanceOf(ConnectionTimeoutError) + }) + + it('classification is case-insensitive', () => { + const err = throwAndCatch({ message: 'connection terminated due to connection timeout' }) + expect(err).toBeInstanceOf(ConnectionTimeoutError) + }) + + it('classified error is still instanceof ResponseError', () => { + const err = throwAndCatch({ message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT' }) + expect(err).toBeInstanceOf(ResponseError) + }) + }) + + describe('unclassified errors', () => { + it('throws UnknownAPIResponseError for unmatched messages', () => { + const err = throwAndCatch({ message: 'something went wrong' }) + expect(err).toBeInstanceOf(UnknownAPIResponseError) + expect(err).toBeInstanceOf(ResponseError) + }) + + it('throws UnknownAPIResponseError for empty message', () => { + const err = throwAndCatch({ message: '' }) + expect(err).toBeInstanceOf(UnknownAPIResponseError) + }) + + it('throws UnknownAPIResponseError for null', () => { + const err = throwAndCatch(null) + expect(err).toBeInstanceOf(UnknownAPIResponseError) + }) + + it('throws UnknownAPIResponseError for non-object', () => { + const err = throwAndCatch('raw string error') + expect(err).toBeInstanceOf(UnknownAPIResponseError) + }) + }) + + describe('field preservation', () => { + it('preserves all ResponseError fields on classified errors', () => { + const err = throwAndCatch({ + message: 'CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT', + code: 503, + requestId: 'req-abc', + retryAfter: 30, + requestPathname: '/rest/v1/table', + }) + expect(err.message).toBe('CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT') + expect(err.code).toBe(503) + expect(err.requestId).toBe('req-abc') + expect(err.retryAfter).toBe(30) + expect(err.requestPathname).toBe('/rest/v1/table') + }) + + it('preserves all ResponseError fields on unclassified errors', () => { + const err = throwAndCatch({ message: 'some error', code: 500, requestId: 'req-xyz' }) + expect(err.code).toBe(500) + expect(err.requestId).toBe('req-xyz') + }) + + it('msg field takes priority over message field for error text', () => { + const err = throwAndCatch({ msg: 'from msg field', message: 'from message field' }) + expect(err.message).toBe('from msg field') + }) + }) +}) diff --git a/apps/studio/lib/error-reporting.ts b/apps/studio/lib/error-reporting.ts index bf634e5e9af..ec0b23788c6 100644 --- a/apps/studio/lib/error-reporting.ts +++ b/apps/studio/lib/error-reporting.ts @@ -51,7 +51,7 @@ export function captureCriticalError( return } - handleUnknownError(error, context) + handleUnknownAPIResponseError(error, context) } function handleResponseError(error: ResponseError, context: string) { @@ -85,7 +85,7 @@ function handleError(error: Error, context: string) { }) } -function handleUnknownError(error: unknown, context: string) { +function handleUnknownAPIResponseError(error: unknown, context: string) { if ( error && typeof error === 'object' && diff --git a/apps/studio/types/api-errors.ts b/apps/studio/types/api-errors.ts new file mode 100644 index 00000000000..3b902510801 --- /dev/null +++ b/apps/studio/types/api-errors.ts @@ -0,0 +1,36 @@ +import { ResponseError } from './base' +import type { ErrorMetadata } from './base' + +export type KnownErrorType = 'connection-timeout' + +export class ConnectionTimeoutError extends ResponseError { + readonly errorType = 'connection-timeout' as const + + constructor( + message: string | undefined, + code?: number, + requestId?: string, + retryAfter?: number, + requestPathname?: string, + metadata?: ErrorMetadata + ) { + super(message, code, requestId, retryAfter, requestPathname, metadata) + } +} + +export class UnknownAPIResponseError extends ResponseError { + readonly errorType = 'unknown' as const + + constructor( + message: string | undefined, + code?: number, + requestId?: string, + retryAfter?: number, + requestPathname?: string, + metadata?: ErrorMetadata + ) { + super(message, code, requestId, retryAfter, requestPathname, metadata) + } +} + +export type ClassifiedError = ConnectionTimeoutError | UnknownAPIResponseError diff --git a/apps/studio/types/base.ts b/apps/studio/types/base.ts index 3981a57b14d..7d2d5757d45 100644 --- a/apps/studio/types/base.ts +++ b/apps/studio/types/base.ts @@ -100,6 +100,7 @@ export class ResponseError extends Error { retryAfter?: number requestPathname?: string metadata?: CostMetadata + errorType?: string constructor( message: string | undefined, diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index f6f0f40c1e6..367fd1cd9f6 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -2791,7 +2791,7 @@ export interface RequestUpgradeSubmittedEvent { /** * Triggered when a Studio error UI element is displayed (mounted). - * This includes error Admonitions and Toast notifications. + * This includes error Admonitions, Toast notifications, and ErrorDisplay components. * * @group Events * @source studio @@ -2802,7 +2802,69 @@ export interface DashboardErrorCreatedEvent { /** * Source of the error */ - source?: 'admonition' | 'toast' + source?: 'admonition' | 'toast' | 'error_display' + /** + * Type of error matched (for error_display source) + */ + errorType?: string + /** + * Whether troubleshooting steps are available (for error_display source) + */ + hasTroubleshooting?: boolean + } + groups: TelemetryGroups +} + +/** + * Triggered when the inline error troubleshooter is shown to the user. + * + * @group Events + * @source studio + */ +export interface InlineErrorTroubleshooterExposedEvent { + action: 'inline_error_troubleshooter_exposed' + properties: { + /** ID of the matched error mapping */ + errorType: string + } + groups: TelemetryGroups +} + +/** + * Triggered when a user opens or closes a troubleshooting accordion step. + * + * @group Events + * @source studio + */ +export interface InlineErrorTroubleshooterStepClickedEvent { + action: 'inline_error_troubleshooter_step_clicked' + properties: { + /** ID of the matched error mapping */ + errorType: string + /** Step number that was clicked (1, 2, 3, ...) — null when a step is collapsed */ + step: number | null + /** Title of the step that was clicked */ + stepTitle?: string + /** Whether the step was opened (true) or closed (false) */ + expanded: boolean + } + groups: TelemetryGroups +} + +/** + * Triggered when a user clicks an action within the inline error troubleshooter. + * Covers all CTAs including the contact support link. + * + * @group Events + * @source studio + */ +export interface InlineErrorTroubleshooterActionClickedEvent { + action: 'inline_error_troubleshooter_action_clicked' + properties: { + /** ID of the matched error mapping */ + errorType: string + /** Which CTA was clicked */ + ctaType: 'restart_db' | 'troubleshooting_guide' | 'ask_ai' | 'contact_support' } groups: TelemetryGroups } @@ -3104,6 +3166,9 @@ export type TelemetryEvent = | RequestUpgradeModalOpenedEvent | RequestUpgradeSubmittedEvent | DashboardErrorCreatedEvent + | InlineErrorTroubleshooterExposedEvent + | InlineErrorTroubleshooterStepClickedEvent + | InlineErrorTroubleshooterActionClickedEvent | IntegrationInstallCompletedEvent | IntegrationInstallSubmittedEvent | IntegrationUninstallSubmittedEvent diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 8e1894b832a..7090d080373 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -222,6 +222,10 @@ "import": "./src/CommandMenu/prepackaged/Support.tsx", "types": "./src/CommandMenu/prepackaged/Support.tsx" }, + "./CommandMenu/prepackaged/ThemeSwitcher.test": { + "import": "./src/CommandMenu/prepackaged/ThemeSwitcher.test.tsx", + "types": "./src/CommandMenu/prepackaged/ThemeSwitcher.test.tsx" + }, "./CommandMenu/prepackaged/ThemeSwitcher": { "import": "./src/CommandMenu/prepackaged/ThemeSwitcher.tsx", "types": "./src/CommandMenu/prepackaged/ThemeSwitcher.tsx" @@ -302,10 +306,30 @@ "import": "./src/EmptyStatePresentational/index.tsx", "types": "./src/EmptyStatePresentational/index.tsx" }, + "./ErrorDisplay/ErrorDisplay": { + "import": "./src/ErrorDisplay/ErrorDisplay.tsx", + "types": "./src/ErrorDisplay/ErrorDisplay.tsx" + }, + "./ErrorDisplay/ErrorDisplay.types": { + "import": "./src/ErrorDisplay/ErrorDisplay.types.ts", + "types": "./src/ErrorDisplay/ErrorDisplay.types.ts" + }, + "./ErrorDisplay": { + "import": "./src/ErrorDisplay/ErrorDisplay.tsx", + "types": "./src/ErrorDisplay/ErrorDisplay.tsx" + }, "./ExpandableVideo": { "import": "./src/ExpandableVideo/index.tsx", "types": "./src/ExpandableVideo/index.tsx" }, + "./FilterBar/CommandListItem": { + "import": "./src/FilterBar/CommandListItem.tsx", + "types": "./src/FilterBar/CommandListItem.tsx" + }, + "./FilterBar/DefaultCommandList.helpers": { + "import": "./src/FilterBar/DefaultCommandList.helpers.tsx", + "types": "./src/FilterBar/DefaultCommandList.helpers.tsx" + }, "./FilterBar/DefaultCommandList": { "import": "./src/FilterBar/DefaultCommandList.tsx", "types": "./src/FilterBar/DefaultCommandList.tsx" @@ -330,6 +354,10 @@ "import": "./src/FilterBar/FilterGroup.tsx", "types": "./src/FilterBar/FilterGroup.tsx" }, + "./FilterBar/OperatorSymbolBadge": { + "import": "./src/FilterBar/OperatorSymbolBadge.tsx", + "types": "./src/FilterBar/OperatorSymbolBadge.tsx" + }, "./FilterBar/hooks.test": { "import": "./src/FilterBar/hooks.test.ts", "types": "./src/FilterBar/hooks.test.ts" @@ -342,6 +370,10 @@ "import": "./src/FilterBar/index.ts", "types": "./src/FilterBar/index.ts" }, + "./FilterBar/menuItems.test": { + "import": "./src/FilterBar/menuItems.test.ts", + "types": "./src/FilterBar/menuItems.test.ts" + }, "./FilterBar/menuItems": { "import": "./src/FilterBar/menuItems.ts", "types": "./src/FilterBar/menuItems.ts" @@ -350,10 +382,18 @@ "import": "./src/FilterBar/types.ts", "types": "./src/FilterBar/types.ts" }, + "./FilterBar/useAIFilter": { + "import": "./src/FilterBar/useAIFilter.ts", + "types": "./src/FilterBar/useAIFilter.ts" + }, "./FilterBar/useCommandHandling": { "import": "./src/FilterBar/useCommandHandling.ts", "types": "./src/FilterBar/useCommandHandling.ts" }, + "./FilterBar/useCommandMenu": { + "import": "./src/FilterBar/useCommandMenu.ts", + "types": "./src/FilterBar/useCommandMenu.ts" + }, "./FilterBar/useKeyboardNavigation": { "import": "./src/FilterBar/useKeyboardNavigation.ts", "types": "./src/FilterBar/useKeyboardNavigation.ts" @@ -422,6 +462,10 @@ "import": "./src/McpUrlBuilder/utils/getMcpButtonData.ts", "types": "./src/McpUrlBuilder/utils/getMcpButtonData.ts" }, + "./McpUrlBuilder/utils/getMcpIconSrc": { + "import": "./src/McpUrlBuilder/utils/getMcpIconSrc.ts", + "types": "./src/McpUrlBuilder/utils/getMcpIconSrc.ts" + }, "./McpUrlBuilder/utils/getMcpUrl": { "import": "./src/McpUrlBuilder/utils/getMcpUrl.ts", "types": "./src/McpUrlBuilder/utils/getMcpUrl.ts" @@ -430,6 +474,10 @@ "import": "./src/MetricCard/index.tsx", "types": "./src/MetricCard/index.tsx" }, + "./MobileSheetNav/MobileSheetNav.test": { + "import": "./src/MobileSheetNav/MobileSheetNav.test.tsx", + "types": "./src/MobileSheetNav/MobileSheetNav.test.tsx" + }, "./MobileSheetNav/MobileSheetNav": { "import": "./src/MobileSheetNav/MobileSheetNav.tsx", "types": "./src/MobileSheetNav/MobileSheetNav.tsx" @@ -450,6 +498,10 @@ "import": "./src/MultiSelectDeprecated/index.tsx", "types": "./src/MultiSelectDeprecated/index.tsx" }, + "./MultipleCodeBlock": { + "import": "./src/MultipleCodeBlock/index.tsx", + "types": "./src/MultipleCodeBlock/index.tsx" + }, "./PageContainer": { "import": "./src/PageContainer/index.tsx", "types": "./src/PageContainer/index.tsx" @@ -582,10 +634,6 @@ "import": "./src/admonition.tsx", "types": "./src/admonition.tsx" }, - "./MultipleCodeBlock": { - "import": "./src/MultipleCodeBlock/index.tsx", - "types": "./src/MultipleCodeBlock/index.tsx" - }, "./consent": { "import": "./src/consent.tsx", "types": "./src/consent.tsx" diff --git a/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx new file mode 100644 index 00000000000..bbdd536a7ea --- /dev/null +++ b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.tsx @@ -0,0 +1,90 @@ +'use client' + +import { HelpCircle } from 'lucide-react' +import { forwardRef, useEffect, useRef } from 'react' +import { Card, CardHeader, cn } from 'ui' + +import { WarningIcon } from '../admonition' +import type { ErrorDisplayProps, SupportFormParams } from './ErrorDisplay.types' + +export type { SupportFormParams } from './ErrorDisplay.types' + +function buildSupportUrl(params?: SupportFormParams) { + if (!params) return '/support/new' + const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== '') + if (entries.length === 0) return '/support/new' + return `/support/new?${new URLSearchParams(entries as [string, string][]).toString()}` +} + +export const ErrorDisplay = forwardRef( + ( + { + title, + errorMessage, + supportFormParams, + supportLabel = 'Contact support', + children, + className, + icon, + onRender, + onSupportClick, + ...props + }, + ref + ) => { + const hasFired = useRef(false) + useEffect(() => { + if (hasFired.current) return + hasFired.current = true + onRender?.() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const supportUrl = buildSupportUrl(supportFormParams) + + return ( + + +
+ {icon ?? } +
+

+ {title} +

+
+ +
+
+            {errorMessage}
+          
+
+ + {children &&
{children}
} + +
+
+ +
+ Need help? + + {supportLabel} + +
+
+ ) + } +) + +ErrorDisplay.displayName = 'ErrorDisplay' diff --git a/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.types.ts b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.types.ts new file mode 100644 index 00000000000..659141af524 --- /dev/null +++ b/packages/ui-patterns/src/ErrorDisplay/ErrorDisplay.types.ts @@ -0,0 +1,67 @@ +import { ComponentPropsWithoutRef, ReactNode } from 'react' + +export interface SupportFormParams { + projectRef?: string + orgSlug?: string + category?: string + subject?: string + message?: string + error?: string + /** Sentry event ID */ + sid?: string +} + +export interface ErrorDisplayProps extends ComponentPropsWithoutRef<'div'> { + /** + * Title displayed in the header with warning icon + * @example "Failed to retrieve tables" + */ + title: string + + /** + * Error message displayed in monospace code block style + * @example "ERROR: FAILED TO RUN SQL QUERY: CONNECTION TERMINATED DUE TO CONNECTION TIMEOUT." + */ + errorMessage: string + + /** + * Typed params for the support form URL. The component builds the URL automatically. + * The "Contact support" footer is always shown. + * @example { projectRef: 'my-project' } + */ + supportFormParams?: SupportFormParams + + /** + * Text for the support link + * @default "Contact support" + */ + supportLabel?: string + + /** + * Children slot for accordion-style troubleshooting steps + * Typically contains TroubleshootingAccordion or similar content + */ + children?: ReactNode + + /** + * Additional CSS classes for the root container + */ + className?: string + + /** + * Custom icon to display in the header. Defaults to a filled warning triangle SVG. + */ + icon?: ReactNode + + /** + * Callback fired when the component is rendered + * Useful for tracking error display events + */ + onRender?: () => void + + /** + * Callback fired when the contact support link is clicked + * Useful for tracking support escalations + */ + onSupportClick?: () => void +} diff --git a/packages/ui-patterns/src/admonition.tsx b/packages/ui-patterns/src/admonition.tsx index 5af083b26d6..6700794f61b 100644 --- a/packages/ui-patterns/src/admonition.tsx +++ b/packages/ui-patterns/src/admonition.tsx @@ -55,11 +55,11 @@ const InfoIcon = () => ( ) -const WarningIcon = () => ( +export const WarningIcon = ({ className }: { className?: string }) => (