diff --git a/libs/pages/layout/src/lib/ui/console-migration-prompt/console-migration-prompt.tsx b/libs/pages/layout/src/lib/ui/console-migration-prompt/console-migration-prompt.tsx new file mode 100644 index 00000000000..a2430579a87 --- /dev/null +++ b/libs/pages/layout/src/lib/ui/console-migration-prompt/console-migration-prompt.tsx @@ -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 ( + + {open && ( +
+ +
+
+
+ +
+

+ A new version of the Qovery console available! Join the early access now. +

+
+ + +
+
+
+
+
+ )} +
+ ) +} + +export default ConsoleMigrationPrompt diff --git a/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx b/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx index 2aa489dfdb2..f2ed1cf100d 100644 --- a/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx +++ b/libs/pages/layout/src/lib/ui/layout-page/layout-page.spec.tsx @@ -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: [ { @@ -17,11 +34,36 @@ jest.mock('@qovery/domains/clusters/feature', () => { } }) -const renderComponent = (props: LayoutPageProps) => ( - - - -) +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 + +const renderComponent = (props: LayoutPageProps) => describe('LayoutPage', () => { const props: LayoutPageProps = { @@ -29,23 +71,65 @@ describe('LayoutPage', () => { 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) + }) + 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') + }) }) diff --git a/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx b/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx index 2c6e2921942..652b40d0897 100644 --- a/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx +++ b/libs/pages/layout/src/lib/ui/layout-page/layout-page.tsx @@ -1,6 +1,7 @@ -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' @@ -8,7 +9,12 @@ 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, @@ -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 @@ -57,10 +69,17 @@ export function LayoutPage(props: PropsWithChildren) { 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)) @@ -129,6 +148,39 @@ export function LayoutPage(props: PropsWithChildren) { 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 && ( @@ -193,6 +245,11 @@ export function LayoutPage(props: PropsWithChildren) { {showFloatingDeploymentCard && ( )} + ) diff --git a/libs/pages/layout/src/lib/ui/layout-page/layout-page.utils.ts b/libs/pages/layout/src/lib/ui/layout-page/layout-page.utils.ts new file mode 100644 index 00000000000..4565eee9d10 --- /dev/null +++ b/libs/pages/layout/src/lib/ui/layout-page/layout-page.utils.ts @@ -0,0 +1,3 @@ +export function redirectToUrl(url: string) { + window.location.assign(url) +} diff --git a/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.spec.tsx b/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.spec.tsx index 5b766c79766..232eb122900 100644 --- a/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.spec.tsx +++ b/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.spec.tsx @@ -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 describe('PageUserGeneral', () => { beforeEach(() => { @@ -17,6 +24,11 @@ describe('PageUserGeneral', () => { communication_email: '', }, }) + useConsoleRedirectPreferenceMockSpy.mockReturnValue({ + isNewConsoleDefault: false, + setIsNewConsoleDefault: jest.fn(), + }) + useFeatureFlagEnabledMock.mockReturnValue(false) }) it('should render successfully', () => { @@ -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() + + expect(screen.getByText('Use the new console by default')).toBeInTheDocument() + }) }) diff --git a/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.tsx b/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.tsx index 431b61dc47b..9e48900b303 100644 --- a/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.tsx +++ b/libs/pages/user/src/lib/feature/page-user-general-feature/page-user-general-feature.tsx @@ -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' @@ -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', @@ -49,6 +52,9 @@ export function PageUserGeneralFeature() { loading={loading} picture={user?.profile_picture_url as string} accountOptions={accountOptions} + showNewConsoleToggle={isNewNavigationActivationEnabled} + isNewConsoleDefault={isNewConsoleDefault} + onNewConsoleDefaultChange={setIsNewConsoleDefault} /> ) diff --git a/libs/pages/user/src/lib/ui/page-user-general/page-user-general.spec.tsx b/libs/pages/user/src/lib/ui/page-user-general/page-user-general.spec.tsx index 9d539593b2a..52c347f8ffa 100644 --- a/libs/pages/user/src/lib/ui/page-user-general/page-user-general.spec.tsx +++ b/libs/pages/user/src/lib/ui/page-user-general/page-user-general.spec.tsx @@ -10,6 +10,9 @@ describe('PageUserGeneral', () => { loading: false, accountOptions: [], picture: '/', + showNewConsoleToggle: true, + isNewConsoleDefault: false, + onNewConsoleDefaultChange: jest.fn(), } const defaultValues = { @@ -38,6 +41,7 @@ describe('PageUserGeneral', () => { screen.getByLabelText('Last name') screen.getAllByLabelText('Account email') screen.getByLabelText('Communication email') + screen.getByText('Use the new console by default') }) it('should submit the form', async () => { @@ -57,4 +61,35 @@ describe('PageUserGeneral', () => { await userEvent.click(submitButton) expect(mockSubmit).toHaveBeenCalled() }) + + it('should toggle the console preference', async () => { + const onNewConsoleDefaultChange = jest.fn() + + const { userEvent } = renderWithProviders( + wrapWithReactHookForm( + , + { + defaultValues: defaultValues, + } + ) + ) + + await userEvent.click(screen.getByText('Use the new console by default')) + + expect(onNewConsoleDefaultChange).toHaveBeenCalledWith(true) + }) + + it('should not render the console preference toggle when disabled', () => { + renderWithProviders( + wrapWithReactHookForm(, { + defaultValues: defaultValues, + }) + ) + + expect(screen.queryByText('Use the new console by default')).not.toBeInTheDocument() + }) }) diff --git a/libs/pages/user/src/lib/ui/page-user-general/page-user-general.tsx b/libs/pages/user/src/lib/ui/page-user-general/page-user-general.tsx index 35909a90386..33a7fcf9eca 100644 --- a/libs/pages/user/src/lib/ui/page-user-general/page-user-general.tsx +++ b/libs/pages/user/src/lib/ui/page-user-general/page-user-general.tsx @@ -1,19 +1,30 @@ import { type FormEventHandler } from 'react' import { Controller, useFormContext } from 'react-hook-form' import { type Value } from '@qovery/shared/interfaces' -import { BlockContent, Button, Heading, InputSelect, InputText, Section } from '@qovery/shared/ui' +import { BlockContent, Button, Heading, InputSelect, InputText, InputToggle, Section } from '@qovery/shared/ui' export interface PageUserGeneralProps { onSubmit: FormEventHandler loading: boolean picture: string accountOptions: Value[] + showNewConsoleToggle: boolean + isNewConsoleDefault: boolean + onNewConsoleDefaultChange: (value: boolean) => void } const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone const timezoneOffset = new Date().getTimezoneOffset() / -60 -export function PageUserGeneral({ onSubmit, loading, picture, accountOptions }: PageUserGeneralProps) { +export function PageUserGeneral({ + onSubmit, + loading, + picture, + accountOptions, + showNewConsoleToggle, + isNewConsoleDefault, + onNewConsoleDefaultChange, +}: PageUserGeneralProps) { const { control, formState, watch } = useFormContext() return ( @@ -112,6 +123,17 @@ export function PageUserGeneral({ onSubmit, loading, picture, accountOptions }:

Timezone used to display timestamp within the interface

+ {showNewConsoleToggle && ( + + )}