Skip to content

Commit

Permalink
feat: Handle self hosted activation statuses (#3523)
Browse files Browse the repository at this point in the history
  • Loading branch information
RulaKhaled authored Nov 25, 2024
1 parent 721a639 commit cfffad4
Show file tree
Hide file tree
Showing 10 changed files with 545 additions and 2 deletions.
20 changes: 19 additions & 1 deletion src/pages/RepoPage/ActivationAlert/ActivationAlert.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,6 +22,9 @@ vi.mock('./ActivationRequiredAlert', () => ({
vi.mock('./UnauthorizedRepoDisplay', () => ({
default: () => 'UnauthorizedRepoDisplay',
}))
vi.mock('./ActivationRequiredSelfHosted', () => ({
default: () => 'ActivationRequiredSelfHostedBanner',
}))

const queryClient = new QueryClient()

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(<ActivationAlert />, { wrapper })

const activationRequiredSelfHostedBanner = await screen.findByText(
/ActivationRequiredSelfHostedBanner/
)
expect(activationRequiredSelfHostedBanner).toBeInTheDocument()
})
})
7 changes: 7 additions & 0 deletions src/pages/RepoPage/ActivationAlert/ActivationAlert.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -29,6 +32,10 @@ function ActivationAlert() {
const renderActivationRequiredAlert =
!isFreePlan(planData?.plan?.value) && planData?.plan?.hasSeatsLeft

if (config.IS_SELF_HOSTED) {
return <ActivationRequiredSelfHosted />
}

if (renderFreePlanSeatsTakenAlert) {
return <FreePlanSeatsTakenAlert />
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<React.PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={['/gh/codecov/gazebo/new']}>
<Route path="/:provider/:owner/:repo/new">{children}</Route>
</MemoryRouter>
</QueryClientProvider>
)

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: '[email protected]',
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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { 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(<ActivationRequiredSelfHosted />, { wrapper })

const link = await screen.findByRole('link', {
name: /Contact Sales/,
})
expect(link).toBeInTheDocument()
expect(link).toHaveAttribute('href', 'https://about.codecov.io/sales')
})
})
})
})
Original file line number Diff line number Diff line change
@@ -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 (
<div className={alertWrapperClassName}>
<img src={upsideDownUmbrella} alt="Forbidden" className="w-36" />
<div className="flex w-2/5 flex-col gap-1">
<h1 className="text-2xl">Activation Required</h1>
<p>Your organization has utilized all available seats.</p>
<div className="mt-5">
<A
to={{ pageName: 'sales' }}
isExternal
hook="contact-sales-self-hosted"
>
Contact Sales
</A>{' '}
to increase your seat count.
</div>
</div>
</div>
)
}

function SeatsAvailable({ isAdmin }: { isAdmin: boolean }) {
return (
<div className={alertWrapperClassName}>
<img src={upsideDownUmbrella} alt="Forbidden" className="w-36" />
<div className="flex w-2/5 flex-col gap-1">
<h1 className="text-2xl">Activation Required</h1>
<p>You have available seats, but activation is needed.</p>
</div>
{isAdmin ? (
<Button
to={{ pageName: 'users' }}
disabled={undefined}
hook={undefined}
variant="primary"
>
Manage members
</Button>
) : (
<p>Contact your admin for activation.</p>
)}
</div>
)
}

function ActivationRequiredSelfHosted() {
const { data } = useSelfHostedCurrentUser()
const { data: selfHostedSeats } = useSelfHostedSeatsConfig()

const hasSelfHostedSeats =
selfHostedSeats?.seatsUsed &&
selfHostedSeats?.seatsLimit &&
selfHostedSeats?.seatsUsed < selfHostedSeats?.seatsLimit

return hasSelfHostedSeats ? (
<SeatsAvailable isAdmin={data?.isAdmin ?? false} />
) : (
<SeatsLimitReached />
)
}

export default ActivationRequiredSelfHosted
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ActivationRequiredSelfHosted'
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,6 +22,9 @@ vi.mock('./FreePlanSeatsLimitBanner', () => ({
vi.mock('./PaidPlanSeatsLimitBanner', () => ({
default: () => 'PaidPlanSeatsLimitBanner',
}))
vi.mock('./ActivationRequiredSelfHosted', () => ({
default: () => 'ActivationRequiredSelfHostedBanner',
}))

const queryClient = new QueryClient()

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(<ActivationBanner />, { wrapper })

const ActivationRequiredSelfHostedBanner = await screen.findByText(
/ActivationRequiredSelfHostedBanner/
)
expect(ActivationRequiredSelfHostedBanner).toBeInTheDocument()
})
})
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -27,6 +30,10 @@ function ActivationBanner() {
const seatsLimitReached = !planData?.plan?.hasSeatsLeft
const isFreePlanValue = isFreePlan(planData?.plan?.value)

if (config.IS_SELF_HOSTED) {
return <ActivationRequiredSelfHosted />
}

if (isTrialEligible) {
return <TrialEligibleBanner />
}
Expand Down
Loading

0 comments on commit cfffad4

Please sign in to comment.