diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx index f43caee3d6..162e2bf22d 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.tsx @@ -16,5 +16,12 @@ export const StatusPageBanner = () => { if (!banner) return null - return + return ( + + ) } diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts index 5b2618d773..acb5f11c60 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.test.ts @@ -1,18 +1,22 @@ import { describe, expect, it } from 'vitest' -import { shouldShowBanner } from './StatusPageBanner.utils' +import { getRelevantIncidentIds, shouldShowBanner } from './StatusPageBanner.utils' -const noCache = { cache: null } as const +const noCache = { id: 'no-cache', cache: null } as const const noRestrictions = { + id: 'no-restrictions', cache: { affected_regions: null, affects_project_creation: false }, } const affectsCreation = { + id: 'affects-creation', cache: { affected_regions: null, affects_project_creation: true }, } const usEast1Only = { + id: 'us-east-1-only', cache: { affected_regions: ['us-east-1'], affects_project_creation: false }, } const usEast1AndCreation = { + id: 'us-east-1-and-creation', cache: { affected_regions: ['us-east-1'], affects_project_creation: true }, } @@ -87,7 +91,9 @@ describe('shouldShowBanner', () => { it('shows when affected_regions is an empty array', () => { expect( shouldShowBanner({ - incidents: [{ cache: { affected_regions: [], affects_project_creation: false } }], + incidents: [ + { id: 'test', cache: { affected_regions: [], affects_project_creation: false } }, + ], hasProjects: true, userRegions: new Set(['us-east-1']), }) @@ -110,7 +116,10 @@ describe('shouldShowBanner', () => { expect( shouldShowBanner({ incidents: [ - { cache: { affected_regions: ['eu-west-1'], affects_project_creation: false } }, + { + id: 'test', + cache: { affected_regions: ['eu-west-1'], affects_project_creation: false }, + }, ], hasProjects: true, userRegions: new Set(['us-east-1', 'eu-west-1']), @@ -123,6 +132,7 @@ describe('shouldShowBanner', () => { shouldShowBanner({ incidents: [ { + id: 'test', cache: { affected_regions: ['us-east-1', 'ap-southeast-1'], affects_project_creation: false, @@ -223,3 +233,107 @@ describe('shouldShowBanner', () => { }) }) }) + +describe('getRelevantIncidentIds', () => { + it('returns empty array when there are no incidents', () => { + expect( + getRelevantIncidentIds({ incidents: [], hasProjects: true, userRegions: new Set() }) + ).toEqual([]) + }) + + it('returns empty array when no incidents are relevant to the user', () => { + expect( + getRelevantIncidentIds({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toEqual([]) + }) + + it('returns the ID of a single relevant incident', () => { + expect( + getRelevantIncidentIds({ + incidents: [noRestrictions], + hasProjects: true, + userRegions: new Set(['us-east-1']), + }) + ).toEqual(['no-restrictions']) + }) + + it('returns IDs of all relevant incidents', () => { + const euWest1Only = { + id: 'eu-west-1-only', + cache: { affected_regions: ['eu-west-1'], affects_project_creation: false }, + } + const apSoutheast1Only = { + id: 'ap-southeast-1-only', + cache: { affected_regions: ['ap-southeast-1'], affects_project_creation: false }, + } + + expect( + getRelevantIncidentIds({ + incidents: [euWest1Only, apSoutheast1Only], + hasProjects: true, + userRegions: new Set(['eu-west-1', 'ap-southeast-1']), + }) + ).toEqual(expect.arrayContaining(['ap-southeast-1-only', 'eu-west-1-only'])) + }) + + it('excludes incidents irrelevant to the user from the result', () => { + // User is in eu-west-1; us-east-1-only incident should not be included + expect( + getRelevantIncidentIds({ + incidents: [usEast1Only, noRestrictions], + hasProjects: true, + userRegions: new Set(['eu-west-1']), + }) + ).toEqual(['no-restrictions']) + }) + + describe('user has no projects', () => { + it('includes incidents with affects_project_creation', () => { + expect( + getRelevantIncidentIds({ + incidents: [affectsCreation], + hasProjects: false, + userRegions: new Set(), + }) + ).toEqual(['affects-creation']) + }) + + it('excludes incidents without affects_project_creation', () => { + expect( + getRelevantIncidentIds({ + incidents: [noRestrictions, usEast1Only], + hasProjects: false, + userRegions: new Set(), + }) + ).toEqual([]) + }) + }) + + describe('hasUnknownRegions', () => { + it('includes region-restricted incidents when regions are unknown', () => { + expect( + getRelevantIncidentIds({ + incidents: [usEast1Only], + hasProjects: true, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toEqual(['us-east-1-only']) + }) + + it('still excludes incidents for no-project users even when regions are unknown', () => { + expect( + getRelevantIncidentIds({ + incidents: [usEast1Only], + hasProjects: false, + userRegions: new Set(), + hasUnknownRegions: true, + }) + ).toEqual([]) + }) + }) +}) diff --git a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts index d8fcf5f8f7..ccd3fa9827 100644 --- a/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts +++ b/apps/studio/components/layouts/AppLayout/StatusPageBanner.utils.ts @@ -1,6 +1,6 @@ import type { IncidentCache } from 'lib/api/incident-status' -type BannerIncident = { cache?: IncidentCache | null } +type BannerIncident = { id: string; cache?: IncidentCache | null } /** * Determines whether the incident status banner should be shown to a given user, @@ -42,3 +42,27 @@ export function shouldShowBanner({ return affectedRegions.some((region) => userRegions.has(region)) }) } + +/** + * Returns the IDs of incidents that are relevant to the given user. + * + * An incident is considered relevant if it would trigger banner visibility for + * the user, per the same logic as shouldShowBanner. + */ +export function getRelevantIncidentIds({ + incidents, + hasProjects, + userRegions, + hasUnknownRegions = false, +}: { + incidents: Array + hasProjects: boolean + userRegions: Set + hasUnknownRegions?: boolean +}): Array { + return incidents + .filter((incident) => + shouldShowBanner({ incidents: [incident], hasProjects, userRegions, hasUnknownRegions }) + ) + .map((incident) => incident.id) +} diff --git a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts index 8975cba3b9..4bce064808 100644 --- a/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts +++ b/apps/studio/components/layouts/AppLayout/useStatusPageBannerVisibility.ts @@ -1,7 +1,8 @@ import { useQueries } from '@tanstack/react-query' -import { useFlag } from 'common' +import { LOCAL_STORAGE_KEYS, useFlag } from 'common' +import { useCallback, useMemo } from 'react' -import { shouldShowBanner } from './StatusPageBanner.utils' +import { getRelevantIncidentIds, shouldShowBanner } from './StatusPageBanner.utils' import { useOrganizationsQuery } from '@/data/organizations/organizations-query' import { useIncidentStatusQuery } from '@/data/platform/incident-status-query' import { projectKeys } from '@/data/projects/keys' @@ -9,8 +10,9 @@ import { getOrganizationProjects, type OrgProject, } from '@/data/projects/org-projects-infinite-query' +import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' -export type StatusPageBannerData = { title: string } +export type StatusPageBannerData = { title: string; dismiss?: () => void } export function useStatusPageBannerVisibility(): StatusPageBannerData | null { const showIncidentBannerOverride = @@ -40,22 +42,63 @@ export function useStatusPageBannerVisibility(): StatusPageBannerData | null { const allProjects = orgProjectsQueries.flatMap((q) => q.data?.projects ?? []) const hasProjects = allProjects.length > 0 - const userRegions = new Set( - allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region)) + const userRegions = useMemo( + () => + new Set( + allProjects.flatMap((project: OrgProject) => project.databases.map((db) => db.region)) + ), + [allProjects] ) const hasUnknownRegions = orgProjectsQueries.some( (q) => q.isError || (q.data !== undefined && q.data.pagination.count > q.data.projects.length) ) + const [dismissedIds, setDismissedIds, { isSuccess: isDismissedLoaded }] = useLocalStorageQuery< + Array + >(LOCAL_STORAGE_KEYS.INCIDENT_BANNER_DISMISSED_IDS, []) + + const dismiss = useCallback(() => { + const activeIncidentIds = new Set(incidents.map((i) => i.id)) + const relevantIds = getRelevantIncidentIds({ + incidents, + hasProjects, + userRegions, + hasUnknownRegions, + }) + setDismissedIds((prev) => [ + ...new Set([...prev.filter((id) => activeIncidentIds.has(id)), ...relevantIds]), + ]) + }, [incidents, hasProjects, userRegions, hasUnknownRegions, setDismissedIds]) + if (showIncidentBannerOverride) return { title: 'We are investigating a technical issue' } if (!hasActiveIncidents || !isProjectsFetched) return null - if (!shouldShowBanner({ incidents, hasProjects, userRegions, hasUnknownRegions })) return null + // Filter out individually dismissed incidents. An incident stays dismissed as + // long as its ID remains in the stored set, regardless of whether other + // incidents are added or removed. + const dismissedIdSet = new Set(dismissedIds) + const undismissedIncidents = incidents.filter((i) => !dismissedIdSet.has(i.id)) + + // If dismissed state hasn't loaded yet, hide to prevent a flash of the banner. + // If all relevant incidents have been dismissed, hide the banner. + if ( + !isDismissedLoaded || + !shouldShowBanner({ + incidents: undismissedIncidents, + hasProjects, + userRegions, + hasUnknownRegions, + }) + ) + return null + + const title = hasProjects + ? 'We are investigating a technical issue' + : 'Project creation may be impacted in some regions' return { - title: hasProjects - ? 'We are investigating a technical issue' - : 'Project creation may be impacted in some regions', + title, + dismiss, } } diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 266def9198..b0ffc6549e 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -11,7 +11,7 @@ export const LOCAL_STORAGE_KEYS = { PROJECTS_SORT: 'projects-sort', FEEDBACK_WIDGET_CONTENT: 'feedback-widget-content', FEEDBACK_WIDGET_SCREENSHOT: 'feedback-widget-screenshot', - INCIDENT_BANNER_DISMISSED: (id: string) => `incident-banner-dismissed-${id}`, + INCIDENT_BANNER_DISMISSED_IDS: 'incident-banner-dismissed-ids', MAINTENANCE_BANNER_DISMISSED: (id: string) => `maintenance-banner-dismissed-${id}`, UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel',