Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Handle self hosted activation statuses #3523

Merged
merged 4 commits into from
Nov 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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