diff --git a/src/App.tsx b/src/App.tsx index 99898bbcdf..4087d9374d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { ThemeContextProvider } from 'shared/ThemeContext' import AccountSettings from './pages/AccountSettings' import AdminSettings from './pages/AdminSettings' +import { OnboardingContainerProvider } from './pages/OwnerPage/OnboardingContainerContext/context' const AnalyticsPage = lazy(() => import('./pages/AnalyticsPage')) const CodecovAIPage = lazy(() => import('./pages/CodecovAIPage')) const CommitDetailPage = lazy(() => import('./pages/CommitDetailPage')) @@ -197,8 +198,10 @@ function App() { <> - - + + + + diff --git a/src/assets/onboarding/click_here_to_install.png b/src/assets/onboarding/click_here_to_install.png new file mode 100644 index 0000000000..d3b4ba3e80 Binary files /dev/null and b/src/assets/onboarding/click_here_to_install.png differ diff --git a/src/assets/onboarding/org_list_install_app.png b/src/assets/onboarding/org_list_install_app.png new file mode 100644 index 0000000000..a34bc6cb2d Binary files /dev/null and b/src/assets/onboarding/org_list_install_app.png differ diff --git a/src/layouts/Header/components/UserDropdown/UserDropdown.test.tsx b/src/layouts/Header/components/UserDropdown/UserDropdown.test.tsx index be41d8c94e..d61b02f413 100644 --- a/src/layouts/Header/components/UserDropdown/UserDropdown.test.tsx +++ b/src/layouts/Header/components/UserDropdown/UserDropdown.test.tsx @@ -8,6 +8,7 @@ import { type Mock } from 'vitest' import config from 'config' +import { OnboardingContainerProvider } from 'pages/OwnerPage/OnboardingContainerContext/context' import { useImage } from 'services/image' import { Plans } from 'shared/utils/billing' @@ -72,18 +73,20 @@ const wrapper: (initialEntries?: string) => React.FC = ({ children }) => ( - - - {children} - { - testLocation = location - return null - }} - /> - - + + + + {children} + { + testLocation = location + return null + }} + /> + + + ) diff --git a/src/layouts/Header/components/UserDropdown/UserDropdown.tsx b/src/layouts/Header/components/UserDropdown/UserDropdown.tsx index 7a056b2094..8dd0969b64 100644 --- a/src/layouts/Header/components/UserDropdown/UserDropdown.tsx +++ b/src/layouts/Header/components/UserDropdown/UserDropdown.tsx @@ -2,6 +2,8 @@ import { useHistory, useParams } from 'react-router-dom' import config from 'config' +import { useOnboardingContainer } from 'pages/OwnerPage/OnboardingContainerContext/context' +import { LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER } from 'pages/OwnerPage/OnboardingOrg/constants' import { useUser } from 'services/user' import { Provider } from 'shared/api/helpers' import { providerToName } from 'shared/utils/provider' @@ -35,16 +37,31 @@ function UserDropdown() { const { provider } = useParams() const isGh = providerToName(provider) === 'Github' const history = useHistory() + const { showOnboardingContainer, setShowOnboardingContainer } = + useOnboardingContainer() - const items = - !config.IS_SELF_HOSTED && isGh - ? [ - { - to: { pageName: 'codecovAppInstallation' }, - children: 'Install Codecov app', - } as DropdownItem, - ] - : [] + const items: DropdownItem[] = [ + { + onClick: () => { + setShowOnboardingContainer(!showOnboardingContainer) + localStorage.setItem( + LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER, + showOnboardingContainer ? 'false' : 'true' + ) + }, + hook: 'toggle-onboarding-container', + children: showOnboardingContainer + ? 'Hide getting started' + : 'Show getting started', + }, + ] + + if (!config.IS_SELF_HOSTED && isGh) { + items.push({ + to: { pageName: 'codecovAppInstallation' }, + children: 'Install Codecov app', + } as DropdownItem) + } const handleSignOut = async () => { await fetch(`${config.API_URL}/logout`, { diff --git a/src/pages/DefaultOrgSelector/DefaultOrgSelector.jsx b/src/pages/DefaultOrgSelector/DefaultOrgSelector.jsx index 7e8b54a119..1a66d45372 100644 --- a/src/pages/DefaultOrgSelector/DefaultOrgSelector.jsx +++ b/src/pages/DefaultOrgSelector/DefaultOrgSelector.jsx @@ -25,6 +25,7 @@ import Button from 'ui/Button' import Icon from 'ui/Icon/Icon' import Select from 'ui/Select' +import { ONBOARDING_SOURCE } from './constants' import GitHubHelpBanner from './GitHubHelpBanner' import { useMyOrganizations } from './hooks/useMyOrganizations' @@ -140,7 +141,9 @@ function DefaultOrgSelector() { fireTrial({ owner: selectedOrg }) } - return history.push(`/${provider}/${selectedOrg}?source=onboarding`) + return history.push( + `/${provider}/${selectedOrg}?source=${ONBOARDING_SOURCE}` + ) } if (userIsLoading) return null diff --git a/src/pages/DefaultOrgSelector/constants.ts b/src/pages/DefaultOrgSelector/constants.ts new file mode 100644 index 0000000000..c452e5205a --- /dev/null +++ b/src/pages/DefaultOrgSelector/constants.ts @@ -0,0 +1 @@ +export const ONBOARDING_SOURCE = 'onboarding' diff --git a/src/pages/DefaultOrgSelector/index.js b/src/pages/DefaultOrgSelector/index.js index 626a6ea5ef..75001b16d1 100644 --- a/src/pages/DefaultOrgSelector/index.js +++ b/src/pages/DefaultOrgSelector/index.js @@ -1 +1,2 @@ export { default } from './DefaultOrgSelector' +export { ONBOARDING_SOURCE } from './constants' diff --git a/src/pages/OwnerPage/HeaderBanners/GithubConfigBanner/GithubConfigBanner.jsx b/src/pages/OwnerPage/HeaderBanners/GithubConfigBanner/GithubConfigBanner.jsx index bd85d176eb..0ca6dc7074 100644 --- a/src/pages/OwnerPage/HeaderBanners/GithubConfigBanner/GithubConfigBanner.jsx +++ b/src/pages/OwnerPage/HeaderBanners/GithubConfigBanner/GithubConfigBanner.jsx @@ -13,7 +13,7 @@ const GithubConfigBanner = () => { if (!isGh) return null return ( -
+

diff --git a/src/pages/OwnerPage/OnboardingContainerContext/context.test.tsx b/src/pages/OwnerPage/OnboardingContainerContext/context.test.tsx new file mode 100644 index 0000000000..9189dc7951 --- /dev/null +++ b/src/pages/OwnerPage/OnboardingContainerContext/context.test.tsx @@ -0,0 +1,104 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' + +import { ONBOARDING_SOURCE } from 'pages/DefaultOrgSelector/constants' +import { LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER } from 'pages/OwnerPage/OnboardingOrg/constants' + +import { OnboardingContainerProvider, useOnboardingContainer } from './context' + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +const TestComponent = () => { + const { showOnboardingContainer, setShowOnboardingContainer } = + useOnboardingContainer() + + return ( +
+
Show container: {showOnboardingContainer.toString()}
+ +
+ ) +} + +describe('OnboardingContainer context', () => { + beforeEach(() => { + localStorage.clear() + }) + + describe('when called outside of provider', () => { + it('throws error', () => { + console.error = () => {} + expect(() => render(, { wrapper })).toThrow( + 'useOnboardingContainer has to be used within ``' + ) + }) + }) + + describe('when called inside provider', () => { + it('initializes with false when no localStorage value exists', () => { + render( + + + , + { wrapper } + ) + + expect(screen.getByText('Show container: false')).toBeInTheDocument() + }) + + it('initializes with true when source param is onboarding', () => { + render( + + + + + + ) + + expect(screen.getByText('Show container: true')).toBeInTheDocument() + expect( + localStorage.getItem(LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER) + ).toBe('true') + }) + + it('initializes with stored localStorage value', () => { + localStorage.setItem(LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER, 'true') + + render( + + + , + { wrapper } + ) + + expect(screen.getByText('Show container: true')).toBeInTheDocument() + }) + + it('can toggle the container visibility', async () => { + const user = userEvent.setup() + + render( + + + , + { wrapper } + ) + + expect(screen.getByText('Show container: false')).toBeInTheDocument() + + const button = screen.getByRole('button', { name: 'toggle container' }) + await user.click(button) + + expect(screen.getByText('Show container: true')).toBeInTheDocument() + }) + }) +}) diff --git a/src/pages/OwnerPage/OnboardingContainerContext/context.tsx b/src/pages/OwnerPage/OnboardingContainerContext/context.tsx new file mode 100644 index 0000000000..87cb1e5b4e --- /dev/null +++ b/src/pages/OwnerPage/OnboardingContainerContext/context.tsx @@ -0,0 +1,60 @@ +import { createContext, useContext, useState } from 'react' + +import { ONBOARDING_SOURCE } from 'pages/DefaultOrgSelector/constants' +import { LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER } from 'pages/OwnerPage/OnboardingOrg/constants' +import { useLocationParams } from 'services/navigation' + +type OnboardingContainerContextValue = { + showOnboardingContainer: boolean + setShowOnboardingContainer: (showOnboardingContainer: boolean) => void +} + +export const OnboardingContainerContext = + createContext(null) + +export const OnboardingContainerProvider: React.FC = ({ + children, +}) => { + const { + params, + }: { + params: { source?: string } + } = useLocationParams() + if ( + params['source'] === ONBOARDING_SOURCE && + localStorage.getItem(LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER) === null + ) { + localStorage.setItem(LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER, 'true') + } + const localStorageValue = localStorage.getItem( + LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER + ) + + const [showOnboardingContainer, setShowOnboardingContainer] = + useState(localStorageValue === 'true' ? true : false) + + return ( + + {children} + + ) +} + +OnboardingContainerContext.displayName = 'OnboardingContainerContext' + +export function useOnboardingContainer() { + const rawContext = useContext(OnboardingContainerContext) + + if (rawContext === null) { + throw new Error( + 'useOnboardingContainer has to be used within ``' + ) + } + + return rawContext +} diff --git a/src/pages/OwnerPage/OnboardingOrg/OnboardingOrg.test.tsx b/src/pages/OwnerPage/OnboardingOrg/OnboardingOrg.test.tsx new file mode 100644 index 0000000000..81ae20cbea --- /dev/null +++ b/src/pages/OwnerPage/OnboardingOrg/OnboardingOrg.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { type Mock, vi } from 'vitest' + +import { useLocationParams } from 'services/navigation' + +import { LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER } from './constants' +import OnboardingOrg from './OnboardingOrg' + +import { OnboardingContainerProvider } from '../OnboardingContainerContext/context' + +vi.mock('services/navigation', async () => { + const servicesNavigation = await vi.importActual('services/navigation') + + return { + ...servicesNavigation, + useLocationParams: vi.fn(), + } +}) + +const mockedUseLocationParams = useLocationParams as Mock + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('OnboardingOrg', () => { + beforeEach(() => { + localStorage.clear() + mockedUseLocationParams.mockReturnValue({ params: {} }) + }) + + it('renders the component correctly', () => { + render(, { wrapper }) + + expect( + screen.getByText('How to integrate another organization to Codecov') + ).toBeInTheDocument() + expect( + screen.getByText('Add your GitHub Organization to Codecov') + ).toBeInTheDocument() + expect(screen.getByText('Install Codecov')).toBeInTheDocument() + expect(screen.getByText('Dismiss')).toBeInTheDocument() + expect( + screen.getByAltText('GitHub Organization Install List Example') + ).toBeInTheDocument() + }) + + it('handles dismiss button click correctly', async () => { + const user = userEvent.setup() + render(, { wrapper }) + + // const dismissButton = screen.getByText('Dismiss') + const dismissButton = screen.getByTestId('dismiss-onboarding-org') + expect(dismissButton).toBeInTheDocument() + await user.click(dismissButton) + + expect(localStorage.getItem(LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER)).toBe( + 'false' + ) + }) + + it('opens and closes the AppInstallModal', async () => { + const user = userEvent.setup() + render(, { wrapper }) + + // Modal should be closed initially + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + // Click install button to open the modal + const installButton = screen.getByText('Install Codecov') + await user.click(installButton) + + // Modal should be open + expect(screen.getByRole('dialog')).toBeInTheDocument() + + const cancelButton = screen.getByRole('button', { name: /Cancel/i }) + await user.click(cancelButton) + + // Modal should be closed + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/src/pages/OwnerPage/OnboardingOrg/OnboardingOrg.tsx b/src/pages/OwnerPage/OnboardingOrg/OnboardingOrg.tsx new file mode 100644 index 0000000000..7ac6a557e4 --- /dev/null +++ b/src/pages/OwnerPage/OnboardingOrg/OnboardingOrg.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' + +import orgListInstallApp from 'assets/onboarding/org_list_install_app.png' +import AppInstallModal from 'shared/AppInstallModal' +import Button from 'ui/Button' + +import { LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER } from './constants' + +import { useOnboardingContainer } from '../OnboardingContainerContext/context' + +function OnboardingOrg() { + const { showOnboardingContainer, setShowOnboardingContainer } = + useOnboardingContainer() + + const dismiss = () => { + setShowOnboardingContainer(!showOnboardingContainer) + localStorage.setItem( + LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER, + !showOnboardingContainer ? 'false' : 'true' + ) + } + + const [showModal, setShowModal] = useState(false) + + return ( + <> +
+
+
+ How to integrate another organization to Codecov +
+ +
+
+
+ GitHub Organization Install List Example +
+
+
+ Add your GitHub Organization to Codecov +
+
+ To get full access, you need to install the Codecov app on your + GitHub organization. Admin required. +
+
+ +
+
+
+
+ setShowModal(false)} + onComplete={() => setShowModal(false)} + /> + + ) +} + +export default OnboardingOrg diff --git a/src/pages/OwnerPage/OnboardingOrg/constants.ts b/src/pages/OwnerPage/OnboardingOrg/constants.ts new file mode 100644 index 0000000000..99bfb5a0ba --- /dev/null +++ b/src/pages/OwnerPage/OnboardingOrg/constants.ts @@ -0,0 +1,2 @@ +export const LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER = + 'show-onboarding-container' diff --git a/src/pages/OwnerPage/OnboardingOrg/index.ts b/src/pages/OwnerPage/OnboardingOrg/index.ts new file mode 100644 index 0000000000..b4629aec61 --- /dev/null +++ b/src/pages/OwnerPage/OnboardingOrg/index.ts @@ -0,0 +1,2 @@ +export { default } from './OnboardingOrg' +export { LOCAL_STORAGE_SHOW_ONBOARDING_CONTAINER } from './constants' diff --git a/src/pages/OwnerPage/OwnerPage.jsx b/src/pages/OwnerPage/OwnerPage.jsx index 957429c3b4..f8cbeaa630 100644 --- a/src/pages/OwnerPage/OwnerPage.jsx +++ b/src/pages/OwnerPage/OwnerPage.jsx @@ -11,6 +11,8 @@ import { ActiveContext } from 'shared/context' import ListRepo from 'shared/ListRepo' import HeaderBanners from './HeaderBanners' +import { useOnboardingContainer } from './OnboardingContainerContext/context' +import OnboardingOrg from './OnboardingOrg' import Tabs from './Tabs' export const LOCAL_STORAGE_USER_STARTED_TRIAL_KEY = 'user-started-trial' @@ -47,6 +49,8 @@ function OwnerPage() { LOCAL_STORAGE_USER_STARTED_TRIAL_KEY ) + const { showOnboardingContainer } = useOnboardingContainer() + useEffect(() => { if (userStartedTrial) { renderToast({ @@ -74,6 +78,7 @@ function OwnerPage() {
+ {showOnboardingContainer && } {ownerData?.isCurrentUserPartOfOrg && ( )} diff --git a/src/pages/OwnerPage/OwnerPage.test.jsx b/src/pages/OwnerPage/OwnerPage.test.jsx index d9868db345..8be19eebba 100644 --- a/src/pages/OwnerPage/OwnerPage.test.jsx +++ b/src/pages/OwnerPage/OwnerPage.test.jsx @@ -4,6 +4,7 @@ import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { MemoryRouter, Route } from 'react-router-dom' +import { OnboardingContainerProvider } from './OnboardingContainerContext/context' import OwnerPage from './OwnerPage' const mocks = vi.hoisted(() => ({ @@ -31,14 +32,16 @@ let testLocation const wrapper = ({ children }) => ( - {children} - { - testLocation = location - return null - }} - /> + + {children} + { + testLocation = location + return null + }} + /> + ) diff --git a/src/shared/AppInstallModal/AppInstallModal.test.tsx b/src/shared/AppInstallModal/AppInstallModal.test.tsx index 938d23f0e4..6e8d31e2d1 100644 --- a/src/shared/AppInstallModal/AppInstallModal.test.tsx +++ b/src/shared/AppInstallModal/AppInstallModal.test.tsx @@ -89,4 +89,95 @@ describe('AppInstallModal', () => { expect(onComplete).toHaveBeenCalled() }) }) + + describe('when isShareRequestVersion is true (default)', () => { + it('renders share request version of modal', () => { + render( + + ) + + expect( + screen.getByText('Share GitHub app installation') + ).toBeInTheDocument() + expect( + screen.getByText( + "Copy the link below and share it with your organization's admin or owner to assist." + ) + ).toBeInTheDocument() + + const doneButton = screen.getByText('Done') + expect(doneButton).toBeInTheDocument() + expect(screen.queryByText('Cancel')).not.toBeInTheDocument() + }) + + it('calls onComplete when Done button is clicked', async () => { + render( + + ) + + await userEvent.click(screen.getByText('Done')) + expect(onComplete).toHaveBeenCalledTimes(1) + }) + }) + + describe('when isShareRequestVersion is false', () => { + it('renders install version of modal', () => { + render( + + ) + + expect(screen.getByText('Install Codecov app')).toBeInTheDocument() + expect( + screen.getByText( + 'You need to install Codecov app on your GitHub organization as an admin.' + ) + ).toBeInTheDocument() + + expect(screen.getByText('Cancel')).toBeInTheDocument() + expect( + screen.getByText('Install Codecov app via GitHub') + ).toBeInTheDocument() + }) + + it('calls onClose when Cancel button is clicked', async () => { + render( + + ) + + await userEvent.click(screen.getByText('Cancel')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('calls onComplete when Install button is clicked', async () => { + render( + + ) + + await userEvent.click(screen.getByText('Install Codecov app via GitHub')) + expect(onComplete).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/src/shared/AppInstallModal/AppInstallModal.tsx b/src/shared/AppInstallModal/AppInstallModal.tsx index 22820087f6..d1c3eeacce 100644 --- a/src/shared/AppInstallModal/AppInstallModal.tsx +++ b/src/shared/AppInstallModal/AppInstallModal.tsx @@ -1,3 +1,4 @@ +import clickHereToInstall from 'assets/onboarding/click_here_to_install.png' import Button from 'ui/Button' import { CodeSnippet } from 'ui/CodeSnippet' import Modal from 'ui/Modal' @@ -7,12 +8,14 @@ const COPY_APP_INSTALL_STRING = interface AppInstallModalProps { isOpen: boolean + isShareRequestVersion?: boolean onClose: () => void onComplete: () => void } function AppInstallModal({ isOpen, + isShareRequestVersion = true, onClose, onComplete, }: AppInstallModalProps) { @@ -20,21 +23,58 @@ function AppInstallModal({ - - Copy the link below and share it with your organization's admin - or owner to assist. - - -
{COPY_APP_INSTALL_STRING}
-
-
+ isShareRequestVersion ? ( +
+ + Copy the link below and share it with your organization's + admin or owner to assist. + + +
{COPY_APP_INSTALL_STRING}
+
+
+ ) : ( +
+ + You need to install Codecov app on your GitHub organization as an + admin. + +
+ click here to install screenshot +
+ + If you're + + {' '} + not an admin, share the link below with your organization's + owner{' '} + + to install the Codecov app: + + +
{COPY_APP_INSTALL_STRING}
+
+
+ ) } footer={ -
+
+ {!isShareRequestVersion && ( + + )}
} diff --git a/src/shared/ListRepo/ListRepo.jsx b/src/shared/ListRepo/ListRepo.jsx index 3c33e5e4f6..c93c6d130f 100644 --- a/src/shared/ListRepo/ListRepo.jsx +++ b/src/shared/ListRepo/ListRepo.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types' import { Suspense, useContext } from 'react' import { useParams } from 'react-router-dom' +import { ONBOARDING_SOURCE } from 'pages/DefaultOrgSelector' import { useLocationParams } from 'services/navigation' import { orderingOptions } from 'services/repos' import { TierNames, useTier } from 'services/tier' @@ -54,7 +55,7 @@ function ListRepo({ canRefetch }) {
) - const cameFromOnboarding = params['source'] === 'onboarding' + const cameFromOnboarding = params['source'] === ONBOARDING_SOURCE const isMyOwnerPage = currentUser?.user?.username === owner const showDemoAlert = cameFromOnboarding && isMyOwnerPage