Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { AnimatePresence, motion } from 'framer-motion'
import { Button, Icon } from '@qovery/shared/ui'

export interface ConsoleMigrationPromptProps {
open: boolean
onClose: () => void
onConfirm: () => void
}

export function ConsoleMigrationPrompt({ open, onClose, onConfirm }: ConsoleMigrationPromptProps) {
return (
<AnimatePresence>
{open && (
<div className="fixed bottom-[18px] left-[calc(50%+2rem)] max-w-content-with-navigation-left -translate-x-1/2">
<motion.aside
key="console-migration-prompt"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{
animate: { duration: 0.12, ease: 'easeOut', delay: 2 },
exit: { duration: 0.12, ease: 'easeOut' },
}}
>
<div className="overflow-hidden rounded-full border border-neutral-200 bg-white/95 shadow-[0_16px_48px_rgba(16,30,54,0.14)] backdrop-blur">
<div className="flex h-10 items-center justify-center gap-2 bg-gradient-to-r from-brand-500/10 via-sky-400/10 to-brand-500/5 px-2">
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-500/10 text-brand-500">
<Icon iconName="gift" className="text-xs" />
</div>
<p className="max-w-[32rem] truncate text-center text-ssm text-neutral-400">
A new version of the Qovery console available! Join the early access now.
</p>
<div className="flex shrink-0 items-center gap-1">
<Button type="button" size="sm" color="brand" radius="full" onClick={onConfirm}>
Try now
</Button>
<Button type="button" size="sm" variant="outline" color="neutral" radius="full" onClick={onClose}>
Not interested
</Button>
</div>
</div>
</div>
</motion.aside>
</div>
)}
</AnimatePresence>
)
}

export default ConsoleMigrationPrompt
118 changes: 101 additions & 17 deletions libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
import posthog from 'posthog-js'
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { CloudProviderEnum } from 'qovery-typescript-axios'
import { IntercomProvider } from 'react-use-intercom'
import { type ReactNode } from 'react'
import * as iamFeature from '@qovery/shared/iam/feature'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import LayoutPage, { type LayoutPageProps } from './layout-page'
import { redirectToUrl } from './layout-page.utils'

jest.mock('posthog-js', () => ({
capture: jest.fn(),
getFeatureFlagPayload: jest.fn(),
isFeatureEnabled: jest.fn(() => false),
onFeatureFlags: jest.fn(),
}))

jest.mock('posthog-js/react', () => ({
useFeatureFlagEnabled: jest.fn(() => true),
useFeatureFlagVariantKey: jest.fn(() => false),
}))

jest.mock('@qovery/domains/clusters/feature', () => {
return {
...jest.requireActual('@qovery/domains/clusters/feature'),
ClusterDeploymentProgressCard: () => null,
useClusterStatuses: () => ({
data: [
{
Expand All @@ -17,35 +34,102 @@ jest.mock('@qovery/domains/clusters/feature', () => {
}
})

const renderComponent = (props: LayoutPageProps) => (
<IntercomProvider appId="__test__app__id__" autoBoot={false}>
<LayoutPage {...props} />
</IntercomProvider>
)
jest.mock('@qovery/domains/organizations/feature', () => ({
FreeTrialBanner: () => null,
InvoiceBanner: () => null,
useOrganization: () => ({ data: undefined }),
}))

jest.mock('@qovery/shared/assistant/feature', () => ({
AssistantTrigger: () => null,
}))

jest.mock('@qovery/shared/devops-copilot/feature', () => ({
DevopsCopilotButton: () => null,
DevopsCopilotTrigger: () => null,
}))

jest.mock('@qovery/shared/posthog/feature', () => ({
AnnouncementBanner: () => null,
}))

jest.mock('./layout-page.utils', () => ({
redirectToUrl: jest.fn(),
}))

jest.mock('../navigation/navigation', () => () => null)
jest.mock('../top-bar/top-bar', () => ({ children }: { children: ReactNode }) => <>{children}</>)
jest.mock('../../feature/spotlight-trigger/spotlight-trigger', () => () => null)

const useFeatureFlagEnabledMock = useFeatureFlagEnabled as jest.MockedFunction<typeof useFeatureFlagEnabled>

const renderComponent = (props: LayoutPageProps) => <LayoutPage {...props} />

describe('LayoutPage', () => {
const props: LayoutPageProps = {
defaultOrganizationId: '0000-0000-0000-0000',
topBar: false,
}

beforeEach(() => {
localStorage.clear()
jest.clearAllMocks()
useFeatureFlagEnabledMock.mockReturnValue(true)
jest.spyOn(iamFeature, 'getNewConsoleUrl').mockReturnValue('https://new-console.qovery.com/organization/123')
jest.spyOn(iamFeature, 'useConsoleRedirectPreference').mockReturnValue({
preferredConsole: 'legacy',
isNewConsoleDefault: false,
setPreferredConsole: jest.fn(),
setIsNewConsoleDefault: jest.fn(),
})
jest.spyOn(iamFeature, 'useUserRole').mockReturnValue({
roles: [],
isQoveryAdminUser: false,
loading: false,
} as ReturnType<typeof iamFeature.useUserRole>)
})

it('should render successfully', () => {
const { baseElement } = renderWithProviders(renderComponent({ ...props }))
expect(baseElement).toBeTruthy()
})

it('should have cluster deployment error banner', () => {
props.clusters = [
{
id: '0000-0000-0000-0000',
name: 'cluster-name',
created_at: '',
region: '',
cloud_provider: CloudProviderEnum.AWS,
},
]

renderWithProviders(renderComponent({ ...props }))
renderWithProviders(
renderComponent({
...props,
clusters: [
{
id: '0000-0000-0000-0000',
name: 'cluster-name',
created_at: '',
region: '',
cloud_provider: CloudProviderEnum.AWS,
},
],
})
)
screen.getByText('Check the credentials configuration')
})

it('should capture an analytics event when dismissing the console migration prompt', async () => {
const { userEvent } = renderWithProviders(renderComponent({ ...props }))

await userEvent.click(screen.getByRole('button', { name: 'Not interested' }))

expect(posthog.capture).toHaveBeenCalledWith('console-migration-prompt-dismissed', {
target_url: 'https://new-console.qovery.com/organization/123',
})
})

it('should capture an analytics event when confirming the console migration prompt', async () => {
const { userEvent } = renderWithProviders(renderComponent({ ...props }))

await userEvent.click(screen.getByRole('button', { name: 'Try now' }))

expect(posthog.capture).toHaveBeenCalledWith('console-migration-prompt-confirmed', {
target_url: 'https://new-console.qovery.com/organization/123',
})
expect(redirectToUrl).toHaveBeenCalledWith('https://new-console.qovery.com/organization/123')
})
})
63 changes: 60 additions & 3 deletions libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { useFeatureFlagVariantKey } from 'posthog-js/react'
import posthog from 'posthog-js'
import { useFeatureFlagEnabled, useFeatureFlagVariantKey } from 'posthog-js/react'
import { type Cluster, ClusterStateEnum, type Organization } from 'qovery-typescript-axios'
import { type PropsWithChildren, useMemo } from 'react'
import { type PropsWithChildren, useEffect, useMemo } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import { match } from 'ts-pattern'
import { ClusterDeploymentProgressCard, useClusterStatuses } from '@qovery/domains/clusters/feature'
import { useAlerts } from '@qovery/domains/observability/feature'
import { FreeTrialBanner, InvoiceBanner, useOrganization } from '@qovery/domains/organizations/feature'
import { AssistantTrigger } from '@qovery/shared/assistant/feature'
import { DevopsCopilotButton, DevopsCopilotTrigger } from '@qovery/shared/devops-copilot/feature'
import { useUserRole } from '@qovery/shared/iam/feature'
import {
getNewConsoleUrl,
shouldBypassLegacyConsoleRedirect,
useConsoleRedirectPreference,
useUserRole,
} from '@qovery/shared/iam/feature'
import { AnnouncementBanner } from '@qovery/shared/posthog/feature'
import {
CLUSTER_SETTINGS_CREDENTIALS_URL,
Expand All @@ -17,9 +23,15 @@ import {
INFRA_LOGS_URL,
} from '@qovery/shared/routes'
import { Banner, WarningScreenMobile } from '@qovery/shared/ui'
import { useLocalStorage } from '@qovery/shared/util-hooks'
import SpotlightTrigger from '../../feature/spotlight-trigger/spotlight-trigger'
import ConsoleMigrationPrompt from '../console-migration-prompt/console-migration-prompt'
import Navigation from '../navigation/navigation'
import TopBar from '../top-bar/top-bar'
import { redirectToUrl } from './layout-page.utils'

const CONSOLE_MIGRATION_PROMPT_CONFIRMED = 'console-migration-prompt-confirmed'
const CONSOLE_MIGRATION_PROMPT_DISMISSED = 'console-migration-prompt-dismissed'

export interface LayoutPageProps {
defaultOrganizationId: string
Expand Down Expand Up @@ -57,10 +69,17 @@ export function LayoutPage(props: PropsWithChildren<LayoutPageProps>) {
const { data: clusterStatuses } = useClusterStatuses({ organizationId, enabled: !!organizationId })
const { data: organization } = useOrganization({ organizationId })
const { roles, isQoveryAdminUser } = useUserRole()
const { isNewConsoleDefault, setIsNewConsoleDefault } = useConsoleRedirectPreference()
const isNewNavigationActivationEnabled = Boolean(useFeatureFlagEnabled('new-navigation-activation'))
const isAlertingFeatureFlagEnabled = useFeatureFlagVariantKey('alerting')
const isFeatureFlag = useFeatureFlagVariantKey('devops-copilot')
const [isConsoleMigrationBannerDismissed, setIsConsoleMigrationBannerDismissed] = useLocalStorage(
'legacy-console-migration-banner-dismissed',
false
)

const isQoveryUserWithMobileCheck = checkQoveryUser(isQoveryAdminUser)
const newConsoleUrl = getNewConsoleUrl()

const matchLogInfraRoute = pathname.includes(INFRA_LOGS_URL(organizationId, clusterStatuses?.[0]?.cluster_id))

Expand Down Expand Up @@ -129,6 +148,39 @@ export function LayoutPage(props: PropsWithChildren<LayoutPageProps>) {
return deployingClusters.length > 0
}, [deployingClusters])

const shouldShowConsoleMigrationBanner = Boolean(
isNewNavigationActivationEnabled && newConsoleUrl && !isNewConsoleDefault && !isConsoleMigrationBannerDismissed
)

useEffect(() => {
if (!isNewConsoleDefault || shouldBypassLegacyConsoleRedirect() || !newConsoleUrl) {
return
}

if (window.location.href !== newConsoleUrl) {
redirectToUrl(newConsoleUrl)
}
}, [newConsoleUrl, isNewConsoleDefault])

const handleConsoleMigration = () => {
if (!newConsoleUrl) {
return
}

posthog.capture(CONSOLE_MIGRATION_PROMPT_CONFIRMED, {
target_url: newConsoleUrl,
})
setIsNewConsoleDefault(true)
redirectToUrl(newConsoleUrl)
}

const handleConsoleMigrationDismiss = () => {
posthog.capture(CONSOLE_MIGRATION_PROMPT_DISMISSED, {
target_url: newConsoleUrl,
})
setIsConsoleMigrationBannerDismissed(true)
}

return (
<>
{displayQoveryAdminBanner && (
Expand Down Expand Up @@ -193,6 +245,11 @@ export function LayoutPage(props: PropsWithChildren<LayoutPageProps>) {
{showFloatingDeploymentCard && (
<ClusterDeploymentProgressCard organizationId={organizationId} clusters={deployingClusters} />
)}
<ConsoleMigrationPrompt
open={shouldShowConsoleMigrationBanner}
onConfirm={handleConsoleMigration}
onClose={handleConsoleMigrationDismiss}
/>
</main>
</>
)
Expand Down
3 changes: 3 additions & 0 deletions libs/pages/layout/src/lib/ui/layout-page/layout-page.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function redirectToUrl(url: string) {
window.location.assign(url)
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import * as domainUserFeature from '@qovery/shared/iam/feature'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import PageUserGeneralFeature from './page-user-general-feature'

jest.mock('posthog-js/react', () => ({
useFeatureFlagEnabled: jest.fn(() => false),
}))

const useEditUserAccountMockSpy = jest.spyOn(domainUserFeature, 'useEditUserAccount') as jest.Mock
const useUserAccountMockSky = jest.spyOn(domainUserFeature, 'useUserAccount') as jest.Mock
const useConsoleRedirectPreferenceMockSpy = jest.spyOn(domainUserFeature, 'useConsoleRedirectPreference') as jest.Mock
const useFeatureFlagEnabledMock = useFeatureFlagEnabled as jest.MockedFunction<typeof useFeatureFlagEnabled>

describe('PageUserGeneral', () => {
beforeEach(() => {
Expand All @@ -17,6 +24,11 @@ describe('PageUserGeneral', () => {
communication_email: '',
},
})
useConsoleRedirectPreferenceMockSpy.mockReturnValue({
isNewConsoleDefault: false,
setIsNewConsoleDefault: jest.fn(),
})
useFeatureFlagEnabledMock.mockReturnValue(false)
})

it('should render successfully', () => {
Expand All @@ -42,4 +54,12 @@ describe('PageUserGeneral', () => {
communication_email: 'test2@test.com',
})
})

it('should render the console toggle only when the feature flag is enabled', () => {
useFeatureFlagEnabledMock.mockReturnValue(true)

renderWithProviders(<PageUserGeneralFeature />)

expect(screen.getByText('Use the new console by default')).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useFeatureFlagEnabled } from 'posthog-js/react'
import { FormProvider, useForm } from 'react-hook-form'
import { useAuth } from '@qovery/shared/auth'
import { type IconEnum } from '@qovery/shared/enums'
import { useEditUserAccount, useUserAccount } from '@qovery/shared/iam/feature'
import { useConsoleRedirectPreference, useEditUserAccount, useUserAccount } from '@qovery/shared/iam/feature'
import { Icon } from '@qovery/shared/ui'
import { useDocumentTitle } from '@qovery/shared/util-hooks'
import PageUserGeneral from '../../ui/page-user-general/page-user-general'
Expand All @@ -12,6 +13,8 @@ export function PageUserGeneralFeature() {
const { user: userToken } = useAuth()
const { data: user } = useUserAccount()
const { mutateAsync, isLoading: loading } = useEditUserAccount()
const { isNewConsoleDefault, setIsNewConsoleDefault } = useConsoleRedirectPreference()
const isNewNavigationActivationEnabled = Boolean(useFeatureFlagEnabled('new-navigation-activation'))

const methods = useForm({
mode: 'onChange',
Expand Down Expand Up @@ -49,6 +52,9 @@ export function PageUserGeneralFeature() {
loading={loading}
picture={user?.profile_picture_url as string}
accountOptions={accountOptions}
showNewConsoleToggle={isNewNavigationActivationEnabled}
isNewConsoleDefault={isNewConsoleDefault}
onNewConsoleDefaultChange={setIsNewConsoleDefault}
/>
</FormProvider>
)
Expand Down
Loading
Loading