Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,37 @@ describe('EnvironmentSection', () => {
expect(mockNavigate).not.toHaveBeenCalled()
})

it('should select an environment row without navigating', async () => {
const onEnvironmentSelectionChange = jest.fn()
const { userEvent } = renderWithProviders(
<EnvironmentSection
type={EnvironmentModeEnum.DEVELOPMENT}
items={[overview]}
onEnvironmentSelectionChange={onEnvironmentSelectionChange}
/>
)

await userEvent.click(screen.getAllByRole('checkbox')[1])

expect(onEnvironmentSelectionChange).toHaveBeenCalledWith('env-1', true)
expect(mockNavigate).not.toHaveBeenCalled()
})

it('should select all environments in a section', async () => {
const onSectionSelectionChange = jest.fn()
const { userEvent } = renderWithProviders(
<EnvironmentSection
type={EnvironmentModeEnum.DEVELOPMENT}
items={[overview]}
onSectionSelectionChange={onSectionSelectionChange}
/>
)

await userEvent.click(screen.getAllByRole('checkbox')[0])

expect(onSectionSelectionChange).toHaveBeenCalledWith(['env-1'], true)
})

it('should display both action buttons when there are no services', async () => {
const noServiceOverview = {
...overview,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { EnvironmentModeEnum, type EnvironmentOverviewResponse, StateEnum } from
import { type KeyboardEvent, type MouseEvent } from 'react'
import { match } from 'ts-pattern'
import { ClusterAvatar } from '@qovery/domains/clusters/feature'
import { Button, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui'
import { Button, Checkbox, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui'
import { timeAgo } from '@qovery/shared/util-dates'
import { pluralize, twMerge } from '@qovery/shared/util-js'
import { MenuManageDeployment, MenuOtherActions } from '../../environment-action-toolbar/environment-action-toolbar'
Expand All @@ -14,8 +14,12 @@ import { isArgoCdEnvironment } from '../../utils/argocd'

const { Table } = TablePrimitives

const gridLayoutClassName =
'grid w-full grid-cols-[minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px]'
export const environmentTableGridLayoutClassName =
'grid w-full grid-cols-[44px_minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px]'
export const environmentSelectionCellClassName = 'flex h-full items-center pl-4'
export const environmentNameCellContentClassName =
'flex h-full min-w-0 flex-col justify-center gap-1 py-2 pl-0 pr-4 xl:flex-row xl:items-center xl:justify-between xl:gap-2'
export const environmentTableCellClassName = 'h-auto border-l border-neutral py-2'

function DisabledManageDeploymentButton({ tooltip }: { tooltip: string }) {
return (
Expand All @@ -31,14 +35,21 @@ function DisabledManageDeploymentButton({ tooltip }: { tooltip: string }) {
)
}

function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
function EnvRow({
overview,
checked,
onCheckedChange,
}: {
overview: EnvironmentOverviewResponse
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
const navigate = useNavigate()
const { organizationId = '', projectId = '' } = useParams({ strict: false })
const { data: environments = [] } = useEnvironments({ projectId, suspense: true })
const environment = environments.find((env) => env.id === overview.id)
const isEnvironmentManagedByArgoCd = isArgoCdEnvironment(overview)
const lastOperationDate = overview.deployment_status?.last_deployment_date
const cellClassName = 'h-auto border-l border-neutral py-2'
const stopRowNavigation = (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
event.stopPropagation()
}
Expand All @@ -57,15 +68,30 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
role="link"
className={twMerge(
'w-full hover:cursor-pointer hover:bg-surface-neutral-subtle focus:bg-surface-neutral-subtle',
gridLayoutClassName
environmentTableGridLayoutClassName
)}
onClick={handleNavigate}
onKeyDown={(e) => {
if (e.key === 'Enter') handleNavigate()
}}
>
<Table.Cell className={twMerge(cellClassName, 'border-none p-0')}>
<div className="flex h-full min-w-0 flex-col justify-center gap-1 px-4 py-2 xl:flex-row xl:items-center xl:justify-between xl:gap-2">
<Table.Cell className="h-auto border-none p-0">
<div className={environmentSelectionCellClassName} onClick={stopRowNavigation} onKeyDown={stopRowNavigation}>
<Checkbox
aria-label={`Select ${overview.name}`}
checked={checked}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
if (checked === 'indeterminate') {
return
}
onCheckedChange(checked)
}}
/>
</div>
</Table.Cell>
Comment thread
rmnbrd marked this conversation as resolved.
<Table.Cell className={twMerge(environmentTableCellClassName, 'border-none p-0')}>
<div className={environmentNameCellContentClassName}>
<div className="flex min-w-0 items-center gap-1.5">
<Link
to="/organization/$organizationId/project/$projectId/environment/$environmentId"
Expand Down Expand Up @@ -95,7 +121,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
</div>
</div>
</Table.Cell>
<Table.Cell className={cellClassName}>
<Table.Cell className={environmentTableCellClassName}>
<div className="flex h-full items-center justify-between">
<div className="flex flex-col gap-1 xl:flex-row xl:items-center xl:gap-2">
{overview.services_overview.service_count === 0 ? (
Expand All @@ -112,7 +138,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
<EnvironmentStateChip mode="last-deployment" environmentId={overview.id} variant="monochrome" />
</div>
</Table.Cell>
<Table.Cell className={cellClassName}>
<Table.Cell className={environmentTableCellClassName}>
<div className="flex h-full items-center justify-between">
{overview.cluster && (
<Link
Expand All @@ -130,10 +156,10 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
)}
</div>
</Table.Cell>
<Table.Cell className={cellClassName}>
<Table.Cell className={environmentTableCellClassName}>
<div className="flex h-full items-center">{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago</div>
</Table.Cell>
<Table.Cell className={cellClassName}>
<Table.Cell className={environmentTableCellClassName}>
<div
className="flex h-full items-center justify-end gap-1.5"
onClick={stopRowNavigation}
Expand Down Expand Up @@ -165,10 +191,16 @@ export function EnvironmentSection({
type,
items,
onCreateEnvClicked,
selectedEnvironmentIds = [],
onEnvironmentSelectionChange,
onSectionSelectionChange,
}: {
type: EnvironmentModeEnum
items: EnvironmentOverviewResponse[]
onCreateEnvClicked?: () => void
selectedEnvironmentIds?: string[]
onEnvironmentSelectionChange?: (environmentId: string, checked: boolean) => void
onSectionSelectionChange?: (environmentIds: string[], checked: boolean) => void
}) {
const title = match(type)
.with('PRODUCTION', () => 'Production')
Expand Down Expand Up @@ -201,6 +233,11 @@ export function EnvironmentSection({
)
})

const selectedEnvironmentIdsSet = new Set(selectedEnvironmentIds)
const selectedItemsCount = items.filter(({ id }) => selectedEnvironmentIdsSet.has(id)).length
const isAllRowsSelected = selectedItemsCount === items.length && items.length > 0
const sectionChecked = selectedItemsCount > 0 && !isAllRowsSelected ? 'indeterminate' : isAllRowsSelected

return (
<Section className="flex flex-col gap-3.5">
<div className="flex items-center gap-2">
Expand All @@ -215,8 +252,31 @@ export function EnvironmentSection({
) : (
<Table.Root className="w-full min-w-[1080px]" containerClassName="no-scrollbar overflow-x-auto">
<Table.Header>
<Table.Row className={twMerge('w-full items-center text-xs', gridLayoutClassName)}>
<Table.ColumnHeaderCell className="flex h-9 items-center text-neutral-subtle">
<Table.Row className={twMerge('w-full items-center text-xs', environmentTableGridLayoutClassName)}>
<Table.ColumnHeaderCell className="h-9 p-0 text-neutral-subtle">
<div
className={environmentSelectionCellClassName}
onClick={(e) => {
e.stopPropagation()
}}
>
<Checkbox
aria-label={`Select all ${title.toLowerCase()} environments`}
checked={sectionChecked}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
if (checked === 'indeterminate') {
return
}
onSectionSelectionChange?.(
items.map(({ id }) => id),
checked
)
}}
/>
</div>
</Table.ColumnHeaderCell>
Comment thread
rmnbrd marked this conversation as resolved.
<Table.ColumnHeaderCell className="flex h-9 items-center py-0 pl-0 pr-4 text-neutral-subtle">
Environment
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-9 items-center border-l border-neutral text-neutral-subtle">
Expand All @@ -236,7 +296,12 @@ export function EnvironmentSection({

<Table.Body className="divide-y divide-neutral">
{items.map((environmentOverview) => (
<EnvRow key={environmentOverview.id} overview={environmentOverview} />
<EnvRow
key={environmentOverview.id}
overview={environmentOverview}
checked={selectedEnvironmentIdsSet.has(environmentOverview.id)}
onCheckedChange={(checked) => onEnvironmentSelectionChange?.(environmentOverview.id, checked)}
/>
))}
</Table.Body>
</Table.Root>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios'
import { renderWithProviders, screen } from '@qovery/shared/util-tests'
import { EnvironmentsTableActionBar } from './environments-table-action-bar'

const mockStopEnvironment = jest.fn()
const mockOpenModalConfirmation = jest.fn()

jest.mock('@qovery/shared/ui', () => ({
...jest.requireActual('@qovery/shared/ui'),
useModalConfirmation: () => ({
openModalConfirmation: mockOpenModalConfirmation,
}),
}))

jest.mock('../hooks/use-stop-environment/use-stop-environment', () => ({
useStopEnvironment: () => ({
mutateAsync: mockStopEnvironment,
}),
}))

const rows: EnvironmentOverviewResponse[] = [
{
id: 'env-1',
name: 'Stoppable environment',
mode: EnvironmentModeEnum.DEVELOPMENT,
services_overview: {
service_count: 2,
managed_by: 'QOVERY',
},
deployment_status: {
last_deployment_state: 'DEPLOYED',
},
},
{
id: 'env-2',
name: 'Stopped environment',
mode: EnvironmentModeEnum.DEVELOPMENT,
services_overview: {
service_count: 2,
managed_by: 'QOVERY',
},
deployment_status: {
last_deployment_state: 'STOPPED',
},
},
] as EnvironmentOverviewResponse[]

describe('EnvironmentsTableActionBar', () => {
beforeEach(() => {
mockStopEnvironment.mockReset()
mockOpenModalConfirmation.mockReset()
mockStopEnvironment.mockResolvedValue(undefined)
})

it('should render successfully', () => {
const { baseElement } = renderWithProviders(
<EnvironmentsTableActionBar projectId="project-1" selectedRows={[]} resetRowSelection={jest.fn()} />
)
expect(baseElement).toBeTruthy()
})

it('should show selected environment count', () => {
renderWithProviders(
<EnvironmentsTableActionBar projectId="project-1" selectedRows={rows} resetRowSelection={jest.fn()} />
)

expect(screen.getByText('2 selected environments')).toBeInTheDocument()
})

it('should disable stop action when no selected environment can be stopped', async () => {
const { userEvent } = renderWithProviders(
<EnvironmentsTableActionBar projectId="project-1" selectedRows={[rows[1]]} resetRowSelection={jest.fn()} />
)

await userEvent.click(screen.getByRole('button', { name: /more/i }))

const stopSelectedItem = screen.getByText('Stop selected')
expect(stopSelectedItem.closest('[role="menuitem"]')).toHaveAttribute('aria-disabled', 'true')

expect(mockOpenModalConfirmation).not.toHaveBeenCalled()
})

it('should confirm and stop only stoppable selected environments', async () => {
const resetRowSelection = jest.fn()
const selectedRows = [
...rows,
{
id: 'env-3',
name: 'Restarted environment',
mode: EnvironmentModeEnum.DEVELOPMENT,
services_overview: {
service_count: 2,
managed_by: 'QOVERY',
},
deployment_status: {
last_deployment_state: 'RESTARTED',
},
},
] as EnvironmentOverviewResponse[]

const { userEvent } = renderWithProviders(
<EnvironmentsTableActionBar
projectId="project-1"
selectedRows={selectedRows}
resetRowSelection={resetRowSelection}
/>
)

await userEvent.click(screen.getByRole('button', { name: /more/i }))
await userEvent.click(screen.getByText('Stop selected'))

expect(mockOpenModalConfirmation).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Confirm stop',
confirmationMethod: 'action',
confirmationAction: 'stop',
})
)

const modalProps = mockOpenModalConfirmation.mock.calls[0][0]
renderWithProviders(modalProps.description)
expect(screen.getByText(/You are going to stop 2 environments/)).toBeInTheDocument()

renderWithProviders(modalProps.warning)
expect(screen.getByText('Some environments will not be impacted:')).toBeInTheDocument()
expect(screen.getByText('Stopped environment')).toBeInTheDocument()

await modalProps.action()

expect(mockStopEnvironment).toHaveBeenCalledTimes(2)
expect(mockStopEnvironment).toHaveBeenCalledWith({ environmentId: 'env-1' })
expect(mockStopEnvironment).toHaveBeenCalledWith({ environmentId: 'env-3' })
expect(resetRowSelection).toHaveBeenCalledTimes(1)
})
})
Loading
Loading