diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index de62e8abea1..215efbfa17d 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -1,32 +1,19 @@ import { useFlag } from 'common' import { ClockSkewBanner } from 'components/layouts/AppLayout/ClockSkewBanner' -import { IncidentBanner } from 'components/layouts/AppLayout/IncidentBanner' import { NoticeBanner } from 'components/layouts/AppLayout/NoticeBanner' +import { StatusPageBanner } from 'components/layouts/AppLayout/StatusPageBanner' import { PropsWithChildren } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' -import { MaintenanceBanner } from '@/components/layouts/AppLayout/MaintenanceBanner' -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { - const { data: allStatusPageEvents } = useIncidentStatusQuery() - const { maintenanceEvents = [], incidents = [] } = allStatusPageEvents ?? {} - - // Only show incident banner for incidents with real impact (not "none") - const hasBannerWorthyIncidents = incidents.some((incident) => incident.impact !== 'none') - const ongoingIncident = - useFlag('ongoingIncident') || - process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' || - hasBannerWorthyIncidents - const ongoingMaintenance = maintenanceEvents.length > 0 - const showNoticeBanner = useFlag('showNoticeBanner') const clockSkewBanner = useFlag('clockSkewBanner') return (
- {ongoingIncident ? : ongoingMaintenance ? : null} + {showNoticeBanner && } {clockSkewBanner && } diff --git a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx b/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx deleted file mode 100644 index 5f5830ef2e1..00000000000 --- a/apps/studio/components/layouts/AppLayout/IncidentBanner.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' -import { InlineLink } from 'components/ui/InlineLink' - -/** - * Used to display ongoing incidents - */ -export const IncidentBanner = () => { - return ( - - Follow the status page for - updates - - } - /> - ) -} diff --git a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx b/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx deleted file mode 100644 index 4c5610b36b6..00000000000 --- a/apps/studio/components/layouts/AppLayout/MaintenanceBanner.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from 'common' -import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' -import { InlineLink } from 'components/ui/InlineLink' - -import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' - -/** - * Used to display ongoing maintenance - */ -export function MaintenanceBanner() { - const { data: allStatusPageEvents } = useIncidentStatusQuery() - const { maintenanceEvents = [] } = allStatusPageEvents ?? {} - const currentEventId = maintenanceEvents[0]?.id ?? '' - - const [dismissed, setDismissed] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(currentEventId), - false - ) - - if (dismissed) return null - - return ( - - Follow the status page for - updates - - } - onDismiss={() => setDismissed(true)} - /> - ) -} diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx new file mode 100644 index 00000000000..b5ffe723f8c --- /dev/null +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx @@ -0,0 +1,68 @@ +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { HeaderBanner } from 'components/interfaces/Organization/HeaderBanner' +import { InlineLink } from 'components/ui/InlineLink' + +import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' + +const BANNER_DESCRIPTION = ( + <> + Follow the status page for updates + +) + +/** + * Used to display ongoing incidents or maintenances + */ +export const StatusPageBanner = () => { + const { data: allStatusPageEvents } = useIncidentStatusQuery() + const { incidents = [], maintenanceEvents = [] } = allStatusPageEvents ?? {} + + // Only show incident banner for incidents with real impact (not "none") + const highImpactIncident = incidents.find((incident) => incident.impact !== 'none') + const incidentEventId = highImpactIncident?.id ?? '' + + const showIncidentBannerOverride = + useFlag('ongoingIncident') || process.env.NEXT_PUBLIC_ONGOING_INCIDENT === 'true' + + const ongoingMaintenance = maintenanceEvents.length > 0 + const maintenanceEventId = maintenanceEvents[0]?.id ?? '' + + const [dismissedIncident, setDismissedIncident] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.INCIDENT_BANNER_DISMISSED(incidentEventId), + false + ) + + const [dismissedMaintenance, setDismissedMaintenance] = useLocalStorageQuery( + LOCAL_STORAGE_KEYS.MAINTENANCE_BANNER_DISMISSED(maintenanceEventId), + false + ) + + if (showIncidentBannerOverride || (highImpactIncident && !dismissedIncident)) { + return ( + setDismissedIncident(true) + } + /> + ) + } + + if (ongoingMaintenance && !dismissedMaintenance) { + return ( + setDismissedMaintenance(true)} + /> + ) + } + + return null +} diff --git a/apps/studio/data/platform/incident-status-query.ts b/apps/studio/data/platform/incident-status-query.ts index a6db924e096..7c767fc1cb5 100644 --- a/apps/studio/data/platform/incident-status-query.ts +++ b/apps/studio/data/platform/incident-status-query.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import type { IncidentInfo } from 'lib/api/incident-status' -import { BASE_PATH, IS_PLATFORM } from 'lib/constants' +import { BASE_PATH, IS_PLATFORM, IS_TEST_ENV } from 'lib/constants' import { partition } from 'lodash' import { UseCustomQueryOptions } from 'types' @@ -42,5 +42,6 @@ export const useIncidentStatusQuery = ( queryFn: ({ signal }) => getIncidentStatus(signal), staleTime: 1000 * 60 * 5, // 5 minutes to match API cache ...options, - enabled: IS_PLATFORM && (options.enabled ?? true), + // Enable in platform mode, or in test environment for E2E testing + enabled: (IS_PLATFORM || IS_TEST_ENV) && (options.enabled ?? true), }) diff --git a/e2e/studio/features/status-page-banner.spec.ts b/e2e/studio/features/status-page-banner.spec.ts new file mode 100644 index 00000000000..a1c504e1dfd --- /dev/null +++ b/e2e/studio/features/status-page-banner.spec.ts @@ -0,0 +1,174 @@ +import { expect } from '@playwright/test' + +import { test } from '../utils/test.js' +import { toUrl } from '../utils/to-url.js' + +// Mock incident data - impact is "major" (not "none" or "maintenance") +const mockIncident = { + id: 'incident-123', + name: 'Test Incident', + status: 'investigating', + impact: 'major', + active_since: new Date().toISOString(), +} + +// Mock incident with "none" impact - should NOT trigger the incident banner +const mockIncidentNoneImpact = { + id: 'incident-none-456', + name: 'No Impact Incident', + status: 'investigating', + impact: 'none', + active_since: new Date().toISOString(), +} + +// Mock maintenance event - impact is "maintenance" +const mockMaintenance = { + id: 'maintenance-789', + name: 'Scheduled Maintenance', + status: 'in_progress', + impact: 'maintenance', + active_since: new Date().toISOString(), +} + +test.describe('StatusPageBanner', () => { + test.describe.configure({ mode: 'serial' }) + + test.beforeEach(async ({ page }) => { + // Clear any dismissed banner state from localStorage before each test + await page.addInitScript(() => { + // Clear all incident/maintenance banner dismissals and test overrides + for (const key of Object.keys(localStorage)) { + if ( + key.startsWith('incident-banner-dismissed-') || + key.startsWith('maintenance-banner-dismissed-') + ) { + localStorage.removeItem(key) + } + } + }) + }) + + test('incident banner shows for incidents with impact not equal to "none"', async ({ + page, + ref, + }) => { + // Mock the incident status API to return an incident with major impact + await page.route('**/api/incident-status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([mockIncident]), + }) + }) + + await page.goto(toUrl(`/project/${ref}`)) + + // Wait for the incident banner to be visible + const incidentBanner = page.getByText('We are investigating a technical issue') + await expect(incidentBanner).toBeVisible({ timeout: 15000 }) + + // Verify the dismiss button IS present for real incidents + const dismissButton = page.getByRole('button', { name: 'Dismiss banner' }) + await expect(dismissButton).toBeVisible() + }) + + test('incident banner does NOT show for incidents with impact "none"', async ({ page, ref }) => { + // Mock the incident status API to return an incident with "none" impact + await page.route('**/api/incident-status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([mockIncidentNoneImpact]), + }) + }) + + await page.goto(toUrl(`/project/${ref}`)) + + // Wait for page to load + await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 15000 }) + + // Verify the incident banner is NOT visible + const incidentBanner = page.getByText('We are investigating a technical issue') + await expect(incidentBanner).not.toBeVisible() + }) + + test('incident banner takes precedence over maintenance banner', async ({ page, ref }) => { + // Mock the incident status API to return both an incident and a maintenance event + await page.route('**/api/incident-status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([mockIncident, mockMaintenance]), + }) + }) + + await page.goto(toUrl(`/project/${ref}`)) + + // Wait for the incident banner to be visible + const incidentBanner = page.getByText('We are investigating a technical issue') + await expect(incidentBanner).toBeVisible({ timeout: 15000 }) + + // Verify the maintenance banner is NOT visible (incident takes precedence) + const maintenanceBanner = page.getByText('Scheduled maintenance is in progress') + await expect(maintenanceBanner).not.toBeVisible() + }) + + test('maintenance banner shows after dismissing incident banner when both exist', async ({ + page, + ref, + }) => { + // Mock the incident status API to return both an incident and a maintenance event + await page.route('**/api/incident-status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([mockIncident, mockMaintenance]), + }) + }) + + await page.goto(toUrl(`/project/${ref}`)) + + // Wait for the incident banner to be visible + const incidentBanner = page.getByText('We are investigating a technical issue') + await expect(incidentBanner).toBeVisible({ timeout: 15000 }) + + // Click the dismiss button to dismiss the incident banner + const dismissButton = page.getByRole('button', { name: 'Dismiss banner' }) + await expect(dismissButton).toBeVisible() + await dismissButton.click() + + // Wait for the incident banner to disappear + await expect(incidentBanner).not.toBeVisible({ timeout: 5000 }) + + // Verify the maintenance banner is now visible + const maintenanceBanner = page.getByText('Scheduled maintenance is in progress') + await expect(maintenanceBanner).toBeVisible({ timeout: 5000 }) + }) + + test('maintenance banner is dismissible', async ({ page, ref }) => { + // Mock the incident status API to return only a maintenance event + await page.route('**/api/incident-status', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify([mockMaintenance]), + }) + }) + + await page.goto(toUrl(`/project/${ref}`)) + + // Wait for the maintenance banner to be visible + const maintenanceBanner = page.getByText('Scheduled maintenance is in progress') + await expect(maintenanceBanner).toBeVisible({ timeout: 15000 }) + + // Verify the dismiss button is present + const dismissButton = page.getByRole('button', { name: 'Dismiss banner' }) + await expect(dismissButton).toBeVisible() + + // Dismiss the maintenance banner + await dismissButton.click() + + // Verify the maintenance banner is no longer visible + await expect(maintenanceBanner).not.toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 44e56284601..6a6aeb6518b 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -9,6 +9,7 @@ export const LOCAL_STORAGE_KEYS = { PROJECTS_VIEW: 'projects-view', FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content', FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot', + INCIDENT_BANNER_DISMISSED: (id: string) => `incident-banner-dismissed-${id}`, MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`, UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',