diff --git a/src/pages/RepoPage/ActivationAlert/ActivationAlert.test.tsx b/src/pages/RepoPage/ActivationAlert/ActivationAlert.test.tsx index 1305713210..f523818430 100644 --- a/src/pages/RepoPage/ActivationAlert/ActivationAlert.test.tsx +++ b/src/pages/RepoPage/ActivationAlert/ActivationAlert.test.tsx @@ -4,6 +4,8 @@ import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { MemoryRouter, Route } from 'react-router-dom' +import config from 'config' + import { PlanName, Plans } from 'shared/utils/billing' import ActivationAlert from './ActivationAlert' @@ -20,6 +22,9 @@ vi.mock('./ActivationRequiredAlert', () => ({ vi.mock('./UnauthorizedRepoDisplay', () => ({ default: () => 'UnauthorizedRepoDisplay', })) +vi.mock('./ActivationRequiredSelfHosted', () => ({ + default: () => 'ActivationRequiredSelfHostedBanner', +})) const queryClient = new QueryClient() @@ -64,8 +69,11 @@ describe('ActivationAlert', () => { function setup( privateRepos = true, value: PlanName = Plans.USERS_BASIC, - hasSeatsLeft = true + hasSeatsLeft = true, + isSelfHosted = false ) { + config.IS_SELF_HOSTED = isSelfHosted + server.use( graphql.query('GetPlanData', (info) => { return HttpResponse.json({ @@ -131,4 +139,14 @@ describe('ActivationAlert', () => { ) expect(activationRequiredAlert).toBeInTheDocument() }) + + it('renders ActivationRequiredSelfHosted when user is self hosted', async () => { + setup(false, Plans.USERS_BASIC, true, true) + render(, { wrapper }) + + const activationRequiredSelfHostedBanner = await screen.findByText( + /ActivationRequiredSelfHostedBanner/ + ) + expect(activationRequiredSelfHostedBanner).toBeInTheDocument() + }) }) diff --git a/src/pages/RepoPage/ActivationAlert/ActivationAlert.tsx b/src/pages/RepoPage/ActivationAlert/ActivationAlert.tsx index 1dd1ba3477..a7632a470e 100644 --- a/src/pages/RepoPage/ActivationAlert/ActivationAlert.tsx +++ b/src/pages/RepoPage/ActivationAlert/ActivationAlert.tsx @@ -1,9 +1,12 @@ import { useParams } from 'react-router-dom' +import config from 'config' + import { usePlanData } from 'services/account' import { isFreePlan } from 'shared/utils/billing' import ActivationRequiredAlert from './ActivationRequiredAlert' +import ActivationRequiredSelfHosted from './ActivationRequiredSelfHosted' import FreePlanSeatsTakenAlert from './FreePlanSeatsTakenAlert' import PaidPlanSeatsTakenAlert from './PaidPlanSeatsTakenAlert' import UnauthorizedRepoDisplay from './UnauthorizedRepoDisplay' @@ -29,6 +32,10 @@ function ActivationAlert() { const renderActivationRequiredAlert = !isFreePlan(planData?.plan?.value) && planData?.plan?.hasSeatsLeft + if (config.IS_SELF_HOSTED) { + return + } + if (renderFreePlanSeatsTakenAlert) { return } diff --git a/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.test.tsx b/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.test.tsx new file mode 100644 index 0000000000..127e0d73c6 --- /dev/null +++ b/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.test.tsx @@ -0,0 +1,179 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { graphql, http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' +import { vi } from 'vitest' + +import ActivationRequiredSelfHosted from './ActivationRequiredSelfHosted' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + suspense: false, + }, + }, +}) + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +) + +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'warn' }) + console.error = () => {} +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() + vi.clearAllMocks() +}) + +afterAll(() => { + server.close() +}) + +describe('ActivationRequiredSelfHosted', () => { + function setup(isAdmin: boolean, seatsUsed: number, seatsLimit: number) { + server.use( + http.get('/internal/users/current', (info) => + HttpResponse.json({ + isAdmin, + email: 'user@example.com', + name: 'Test User', + ownerid: 1, + username: 'testuser', + activated: true, + }) + ), + graphql.query('Seats', (info) => { + return HttpResponse.json({ + data: { + config: { + seatsUsed, + seatsLimit, + }, + }, + }) + }) + ) + } + describe('when user has seats left', () => { + it('renders the banner with correct heading', async () => { + setup(false, 0, 10) + render(, { wrapper }) + + const bannerHeading = await screen.findByRole('heading', { + name: /Activation Required/, + }) + expect(bannerHeading).toBeInTheDocument() + }) + + it('renders the banner with correct description', async () => { + setup(false, 1, 10) + render(, { wrapper }) + + const description = await screen.findByText( + /You have available seats, but activation is needed./ + ) + expect(description).toBeInTheDocument() + }) + + it('renders copy for the user', async () => { + setup(false, 1, 10) + render(, { wrapper }) + + const copy = await screen.findByText(/Contact your admin for activation./) + expect(copy).toBeInTheDocument() + }) + + it('does not render the link if the user is not an admin', async () => { + setup(false, 1, 10) + render(, { wrapper }) + + await waitFor(() => queryClient.isFetching) + await waitFor(() => !queryClient.isFetching) + + const link = screen.queryByRole('link', { + name: /Manage members/, + }) + expect(link).not.toBeInTheDocument() + }) + + it('renders the correct img', async () => { + setup(false, 1, 10) + render(, { wrapper }) + + const img = await screen.findByAltText('Forbidden') + expect(img).toBeInTheDocument() + expect(img).toHaveAttribute( + 'src', + '/src/layouts/shared/NetworkErrorBoundary/assets/error-upsidedown-umbrella.svg' + ) + }) + + describe('when the user is an admin', () => { + it('renders the correct link', async () => { + setup(true, 1, 10) + render(, { wrapper }) + + const link = await screen.findByRole('link', { + name: /Manage members/, + }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', '/admin/gh/users') + }) + }) + }) + + describe('when user has no seats left', () => { + it('renders the banner with correct heading', async () => { + setup(false, 10, 10) + render(, { wrapper }) + + const bannerHeading = await screen.findByRole('heading', { + name: /Activation Required/, + }) + expect(bannerHeading).toBeInTheDocument() + }) + + it('renders the banner with correct description', async () => { + setup(false, 10, 10) + render(, { wrapper }) + + const description = await screen.findByText( + /Your organization has utilized all available seats./ + ) + expect(description).toBeInTheDocument() + }) + + it('renders the correct img', async () => { + setup(false, 10, 10) + render(, { wrapper }) + + const img = await screen.findByAltText('Forbidden') + expect(img).toBeInTheDocument() + }) + + describe('renders contact sales link', () => { + it('when the user is an admin', async () => { + setup(true, 10, 10) + render(, { wrapper }) + + const link = await screen.findByRole('link', { + name: /Contact Sales/, + }) + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute('href', 'https://about.codecov.io/sales') + }) + }) + }) +}) diff --git a/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.tsx b/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.tsx new file mode 100644 index 0000000000..61d6bb0d96 --- /dev/null +++ b/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.tsx @@ -0,0 +1,74 @@ +import upsideDownUmbrella from 'layouts/shared/NetworkErrorBoundary/assets/error-upsidedown-umbrella.svg' +import { + useSelfHostedCurrentUser, + useSelfHostedSeatsConfig, +} from 'services/selfHosted' +import A from 'ui/A' +import Button from 'ui/Button' + +const alertWrapperClassName = + 'flex flex-col items-center justify-center gap-8 bg-ds-gray-primary pb-28 pt-12 text-center' + +function SeatsLimitReached() { + return ( +
+ Forbidden +
+

Activation Required

+

Your organization has utilized all available seats.

+
+ + Contact Sales + {' '} + to increase your seat count. +
+
+
+ ) +} + +function SeatsAvailable({ isAdmin }: { isAdmin: boolean }) { + return ( +
+ Forbidden +
+

Activation Required

+

You have available seats, but activation is needed.

+
+ {isAdmin ? ( + + ) : ( +

Contact your admin for activation.

+ )} +
+ ) +} + +function ActivationRequiredSelfHosted() { + const { data } = useSelfHostedCurrentUser() + const { data: selfHostedSeats } = useSelfHostedSeatsConfig() + + const hasSelfHostedSeats = + selfHostedSeats?.seatsUsed && + selfHostedSeats?.seatsLimit && + selfHostedSeats?.seatsUsed < selfHostedSeats?.seatsLimit + + return hasSelfHostedSeats ? ( + + ) : ( + + ) +} + +export default ActivationRequiredSelfHosted diff --git a/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/index.ts b/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/index.ts new file mode 100644 index 0000000000..4d7e546b7d --- /dev/null +++ b/src/pages/RepoPage/ActivationAlert/ActivationRequiredSelfHosted/index.ts @@ -0,0 +1 @@ +export { default } from './ActivationRequiredSelfHosted' diff --git a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.test.tsx b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.test.tsx index 0fecd53bcf..bc8afe79a9 100644 --- a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.test.tsx +++ b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.test.tsx @@ -4,6 +4,8 @@ import { graphql, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { MemoryRouter, Route } from 'react-router-dom' +import config from 'config' + import { PlanName, Plans } from 'shared/utils/billing' import ActivationBanner from './ActivationBanner' @@ -20,6 +22,9 @@ vi.mock('./FreePlanSeatsLimitBanner', () => ({ vi.mock('./PaidPlanSeatsLimitBanner', () => ({ default: () => 'PaidPlanSeatsLimitBanner', })) +vi.mock('./ActivationRequiredSelfHosted', () => ({ + default: () => 'ActivationRequiredSelfHostedBanner', +})) const queryClient = new QueryClient() @@ -65,8 +70,11 @@ describe('ActivationBanner', () => { privateRepos = true, trialStatus = 'NOT_STARTED', value: PlanName = Plans.USERS_BASIC, - hasSeatsLeft = true + hasSeatsLeft = true, + isSelfHosted = false ) { + config.IS_SELF_HOSTED = isSelfHosted + server.use( graphql.query('GetPlanData', (info) => { return HttpResponse.json({ @@ -141,4 +149,14 @@ describe('ActivationBanner', () => { ) expect(PaidPlanSeatsLimitBanner).toBeInTheDocument() }) + + it('renders activation required self hosted banner if user is self hosted', async () => { + setup(true, 'ONGOING', Plans.USERS_BASIC, true, true) + render(, { wrapper }) + + const ActivationRequiredSelfHostedBanner = await screen.findByText( + /ActivationRequiredSelfHostedBanner/ + ) + expect(ActivationRequiredSelfHostedBanner).toBeInTheDocument() + }) }) diff --git a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.tsx b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.tsx index 8943500426..30449ef802 100644 --- a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.tsx +++ b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationBanner.tsx @@ -1,9 +1,12 @@ import { useParams } from 'react-router-dom' +import config from 'config' + import { TrialStatuses, usePlanData } from 'services/account' import { isBasicPlan, isFreePlan } from 'shared/utils/billing' import ActivationRequiredBanner from './ActivationRequiredBanner' +import ActivationRequiredSelfHosted from './ActivationRequiredSelfHosted' import FreePlanSeatsLimitBanner from './FreePlanSeatsLimitBanner' import PaidPlanSeatsLimitBanner from './PaidPlanSeatsLimitBanner' import TrialEligibleBanner from './TrialEligibleBanner' @@ -27,6 +30,10 @@ function ActivationBanner() { const seatsLimitReached = !planData?.plan?.hasSeatsLeft const isFreePlanValue = isFreePlan(planData?.plan?.value) + if (config.IS_SELF_HOSTED) { + return + } + if (isTrialEligible) { return } diff --git a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.test.tsx b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.test.tsx new file mode 100644 index 0000000000..0edb01834d --- /dev/null +++ b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.test.tsx @@ -0,0 +1,159 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import { graphql, http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' + +import ActivationRequiredSelfHosted from './ActivationRequiredSelfHosted' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}) + +const server = setupServer() + +beforeAll(() => { + server.listen({ onUnhandledRequest: 'warn' }) + console.error = () => {} +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() + vi.clearAllMocks() +}) + +afterAll(() => { + server.close() +}) + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +) + +describe('ActivationRequiredSelfHosted', () => { + function setup(isAdmin: boolean, seatsUsed: number, seatsLimit: number) { + server.use( + http.get('/internal/users/current', (info) => + HttpResponse.json({ + isAdmin, + email: 'user@example.com', + name: 'Test User', + ownerid: 1, + username: 'testuser', + activated: true, + }) + ), + graphql.query('Seats', (info) => { + return HttpResponse.json({ + data: { + config: { + seatsUsed, + seatsLimit, + }, + }, + }) + }) + ) + } + + describe('When seats limit is not reached', () => { + it('renders the banner with correct content', async () => { + setup(false, 2, 10) + render(, { wrapper }) + + const bannerHeading = await screen.findByRole('heading', { + name: /Activation Required/, + }) + expect(bannerHeading).toBeInTheDocument() + + const description = await screen.findByText( + /You have available seats, but activation is needed./ + ) + expect(description).toBeInTheDocument() + }) + + it('renders contact admin copy if not admin', async () => { + setup(false, 2, 10) + render(, { wrapper }) + + const contactAdminCopy = await screen.findByText( + /Contact your admin for activation./ + ) + expect(contactAdminCopy).toBeInTheDocument() + }) + + it('does not render manage members link if not admin', async () => { + setup(false, 2, 10) + render(, { wrapper }) + + await waitFor(() => queryClient.isFetching) + await waitFor(() => !queryClient.isFetching) + + const manageMembersLink = screen.queryByRole('link', { + name: /Manage members/, + }) + expect(manageMembersLink).not.toBeInTheDocument() + }) + + describe('when admin', () => { + it('renders manage members link', async () => { + setup(true, 2, 10) + render(, { wrapper }) + + await waitFor(() => queryClient.isFetching) + await waitFor(() => !queryClient.isFetching) + + const manageMembersLink = await screen.findByRole('link', { + name: /Manage members/, + }) + expect(manageMembersLink).toBeInTheDocument() + expect(manageMembersLink).toHaveAttribute('href', '/admin/gh/users') + }) + }) + }) + + describe('When seats limit is reached', () => { + it('renders the banner with correct content', async () => { + setup(false, 10, 10) + render(, { wrapper }) + + const bannerHeading = await screen.findByRole('heading', { + name: /Seats Limit Reached/, + }) + expect(bannerHeading).toBeInTheDocument() + }) + + it('renders the correct description', async () => { + setup(false, 10, 10) + render(, { wrapper }) + + const description = await screen.findByText( + /Your organization has utilized all available seats./ + ) + expect(description).toBeInTheDocument() + }) + + it('renders contact sales link', async () => { + setup(false, 10, 10) + render(, { wrapper }) + + const contactSalesLink = await screen.findByRole('link', { + name: /Contact Sales/, + }) + expect(contactSalesLink).toBeInTheDocument() + expect(contactSalesLink).toHaveAttribute( + 'href', + 'https://about.codecov.io/sales' + ) + }) + }) +}) diff --git a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.tsx b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.tsx new file mode 100644 index 0000000000..9bcd83587f --- /dev/null +++ b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/ActivationRequiredSelfHosted.tsx @@ -0,0 +1,79 @@ +import { + useSelfHostedCurrentUser, + useSelfHostedSeatsConfig, +} from 'services/selfHosted' +import A from 'ui/A' +import Banner from 'ui/Banner' +import BannerContent from 'ui/Banner/BannerContent' +import BannerHeading from 'ui/Banner/BannerHeading' +import Button from 'ui/Button' + +function SeatsLimitReached() { + return ( + + + +

ℹ Seats Limit Reached

+
+
+ Your organization has utilized all available seats.{' '} + + Contact Sales + {' '} + to increase your seat count. +
+
+
+ ) +} + +function SeatsAvailable({ isAdmin }: { isAdmin: boolean }) { + return ( + + + +

ℹ Activation Required

+
+ {isAdmin ? ( + + ) : ( +

Contact your admin for activation.

+ )} +
+
+

You have available seats, but activation is needed.

+
+
+ ) +} + +function ActivationRequiredSelfHosted() { + const { data } = useSelfHostedCurrentUser() + const { data: selfHostedSeats } = useSelfHostedSeatsConfig() + + const hasSelfHostedSeats = + selfHostedSeats?.seatsUsed && + selfHostedSeats?.seatsLimit && + selfHostedSeats?.seatsUsed < selfHostedSeats?.seatsLimit + + return hasSelfHostedSeats ? ( + + ) : ( + + ) +} + +export default ActivationRequiredSelfHosted diff --git a/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/index.ts b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/index.ts new file mode 100644 index 0000000000..4d7e546b7d --- /dev/null +++ b/src/pages/RepoPage/CoverageOnboarding/ActivationBanner/ActivationRequiredSelfHosted/index.ts @@ -0,0 +1 @@ +export { default } from './ActivationRequiredSelfHosted'