From 4624908cfb202dc929f30bc324bfcc77a07b1045 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 8 Jun 2026 11:33:30 +0200 Subject: [PATCH 1/3] feat(envs): add bulk stop action Update stop confirmation modal --- .../environment-section.spec.tsx | 31 ++++ .../environment-section.tsx | 93 ++++++++++-- .../environments-table-action-bar.spec.tsx | 59 ++++++++ .../environments-table-action-bar.tsx | 103 +++++++++++++ .../environments-table.spec.tsx | 4 + .../environments-table/environments-table.tsx | 135 +++++++++++++----- 6 files changed, 378 insertions(+), 47 deletions(-) create mode 100644 libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx create mode 100644 libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx index b1574007df7..41f983080c7 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.spec.tsx @@ -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( + + ) + + 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( + + ) + + 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, diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx index 6c56d02236d..555306c4a54 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx @@ -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' @@ -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 ( @@ -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 | KeyboardEvent) => { event.stopPropagation() } @@ -57,15 +68,29 @@ 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() }} > - -
+ + + + +
- +
{overview.services_overview.service_count === 0 ? ( @@ -112,7 +137,7 @@ function EnvRow({ overview }: { overview: EnvironmentOverviewResponse }) {
- +
{overview.cluster && ( - +
{timeAgo(new Date(overview.updated_at ?? Date.now()))} ago
- +
void + selectedEnvironmentIds?: string[] + onEnvironmentSelectionChange?: (environmentId: string, checked: boolean) => void + onSectionSelectionChange?: (environmentIds: string[], checked: boolean) => void }) { const title = match(type) .with('PRODUCTION', () => 'Production') @@ -201,6 +232,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 (
@@ -215,8 +251,30 @@ export function EnvironmentSection({ ) : ( - - + + + + + Environment @@ -236,7 +294,12 @@ export function EnvironmentSection({ {items.map((environmentOverview) => ( - + onEnvironmentSelectionChange?.(environmentOverview.id, checked)} + /> ))} diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx new file mode 100644 index 00000000000..e3bc633b42a --- /dev/null +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx @@ -0,0 +1,59 @@ +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() + +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() + }) + + it('should render successfully', () => { + const { baseElement } = renderWithProviders( + + ) + expect(baseElement).toBeTruthy() + }) + + it('should show selected environment count', () => { + renderWithProviders( + + ) + + expect(screen.getByText('2 selected environments')).toBeInTheDocument() + }) +}) diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx new file mode 100644 index 00000000000..ac8c94c95b7 --- /dev/null +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.tsx @@ -0,0 +1,103 @@ +import { type EnvironmentOverviewResponse } from 'qovery-typescript-axios' +import { Button, DropdownMenu, Icon, Tooltip, useModalConfirmation } from '@qovery/shared/ui' +import { isStopAvailable, pluralize, twMerge } from '@qovery/shared/util-js' +import { useStopEnvironment } from '../hooks/use-stop-environment/use-stop-environment' + +export interface EnvironmentsTableActionBarProps { + projectId: string + selectedRows: EnvironmentOverviewResponse[] + resetRowSelection: () => void +} + +export function EnvironmentsTableActionBar({ + projectId, + selectedRows, + resetRowSelection, +}: EnvironmentsTableActionBarProps) { + const hasSelection = Boolean(selectedRows.length) + const { openModalConfirmation } = useModalConfirmation() + const { mutateAsync: stopEnvironment } = useStopEnvironment({ projectId }) + + const stoppableEnvironments = selectedRows.filter( + ({ deployment_status }) => + deployment_status?.last_deployment_state && isStopAvailable(deployment_status.last_deployment_state) + ) + + const unstoppableEnvironments = selectedRows.filter( + ({ id: selectedId }) => !stoppableEnvironments.find(({ id }) => id === selectedId) + ) + + const handleStopEnvironments = () => { + const count = stoppableEnvironments.length + + openModalConfirmation({ + title: 'Confirm stop', + description: ( + + You are going to stop {count} {pluralize(count, 'environment')}. To confirm, please type "stop". + + ), + warning: unstoppableEnvironments.length ? ( + <> + Some environments will not be impacted: +
    + {unstoppableEnvironments.map(({ id, name }) => ( +
  • {name}
  • + ))} +
+ + ) : null, + confirmationMethod: 'action', + confirmationAction: 'stop', + action: async () => { + await Promise.all(stoppableEnvironments.map(({ id }) => stopEnvironment({ environmentId: id }))) + resetRowSelection() + }, + }) + } + + return ( +
+
+
+ + {selectedRows.length} selected {pluralize(selectedRows.length, 'environment')} + + + + + + + + + } + onSelect={handleStopEnvironments} + disabled={stoppableEnvironments.length === 0} + > + Stop selected + + + + +
+
+
+ ) +} + +export default EnvironmentsTableActionBar diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx index f71f4dbc731..0559f73cd93 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table.spec.tsx @@ -26,6 +26,10 @@ jest.mock('../environment-action-toolbar/environment-action-toolbar', () => ({ MenuOtherActions: () => , })) +jest.mock('./environments-table-action-bar', () => ({ + EnvironmentsTableActionBar: () =>
, +})) + jest.mock('./environment-section/environment-section', () => ({ __esModule: true, EnvironmentSection: ({ type, items }: EnvironmentSectionMockProps) => ( diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx index 3d1884b4ed8..8360d8da1de 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table.tsx @@ -1,12 +1,19 @@ import { useParams } from '@tanstack/react-router' import { EnvironmentModeEnum, type EnvironmentOverviewResponse } from 'qovery-typescript-axios' -import { Suspense, useCallback, useMemo } from 'react' +import { Suspense, useCallback, useMemo, useState } from 'react' import { useEnvironmentsOverview, useProject } from '@qovery/domains/projects/feature' import { Button, Heading, Icon, Section, Skeleton, TablePrimitives, useModal } from '@qovery/shared/ui' import { useDocumentTitle } from '@qovery/shared/util-hooks' import CreateCloneEnvironmentModal from '../create-clone-environment-modal/create-clone-environment-modal' import EnvironmentMode from '../environment-mode/environment-mode' -import { EnvironmentSection } from './environment-section/environment-section' +import { + EnvironmentSection, + environmentNameCellContentClassName, + environmentSelectionCellClassName, + environmentTableCellClassName, + environmentTableGridLayoutClassName, +} from './environment-section/environment-section' +import { EnvironmentsTableActionBar } from './environments-table-action-bar' const { Table } = TablePrimitives @@ -23,9 +30,8 @@ const SECTION_TITLES: Record = { [EnvironmentModeEnum.DEVELOPMENT]: 'Development', [EnvironmentModeEnum.PREVIEW]: 'Ephemeral', } - -const skeletonGridLayoutClassName = - 'grid w-full grid-cols-[minmax(280px,2fr)_minmax(220px,1.4fr)_minmax(240px,1.2fr)_minmax(140px,1fr)_96px] items-center' +const BODY_TEXT_SKELETON_HEIGHT = 20 +const ACTION_BUTTON_SKELETON_HEIGHT = 32 function EnvironmentsTableSkeleton() { return ( @@ -47,45 +53,62 @@ function EnvironmentsTableSkeleton() {
- - {[130, 110, 80, 90, 70].map((width, index) => ( - 0 ? 'border-l border-neutral' : ''}`} - > - - - ))} + + +
+ +
+
+ + + + + + + + + + + + + + +
{[...Array(2)].map((_, index) => ( - - -
- - + + +
+ +
+
+ +
+ +
- -
- + +
+
- -
- + +
+
- -
- + +
+
- -
- + +
+
@@ -105,6 +128,7 @@ function EnvironmentsTableContent() { const { organizationId = '', projectId = '' } = useParams({ strict: false }) const { data: project } = useProject({ organizationId, projectId, suspense: true }) const { data: environmentsOverview } = useEnvironmentsOverview({ projectId, suspense: true }) + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState([]) const groupedEnvs = useMemo(() => { if (!environmentsOverview) { @@ -144,6 +168,39 @@ function EnvironmentsTableContent() { [projectId, organizationId, closeModal, openModal] ) + const handleEnvironmentSelectionChange = useCallback((environmentId: string, checked: boolean) => { + setSelectedEnvironmentIds((currentSelectedEnvironmentIds) => { + if (checked) { + return currentSelectedEnvironmentIds.includes(environmentId) + ? currentSelectedEnvironmentIds + : [...currentSelectedEnvironmentIds, environmentId] + } + + return currentSelectedEnvironmentIds.filter((selectedEnvironmentId) => selectedEnvironmentId !== environmentId) + }) + }, []) + + const handleSectionSelectionChange = useCallback((environmentIds: string[], checked: boolean) => { + setSelectedEnvironmentIds((currentSelectedEnvironmentIds) => { + if (checked) { + return Array.from(new Set([...currentSelectedEnvironmentIds, ...environmentIds])) + } + + return currentSelectedEnvironmentIds.filter( + (selectedEnvironmentId) => !environmentIds.includes(selectedEnvironmentId) + ) + }) + }, []) + + const selectedRows = useMemo(() => { + if (!environmentsOverview) { + return [] + } + + const selectedEnvironmentIdsSet = new Set(selectedEnvironmentIds) + return environmentsOverview.filter(({ id }) => selectedEnvironmentIdsSet.has(id)) + }, [environmentsOverview, selectedEnvironmentIds]) + const Sections = useCallback(() => { const filledSections = SECTIONS.filter((section) => groupedEnvs?.has(section)) const emptySections = SECTIONS.filter((section) => !groupedEnvs?.has(section)) @@ -155,11 +212,20 @@ function EnvironmentsTableContent() { type={section} items={groupedEnvs?.get(section) || []} onCreateEnvClicked={() => onCreateEnvClicked(section)} + selectedEnvironmentIds={selectedEnvironmentIds} + onEnvironmentSelectionChange={handleEnvironmentSelectionChange} + onSectionSelectionChange={handleSectionSelectionChange} /> ))} ) - }, [groupedEnvs, onCreateEnvClicked]) + }, [ + groupedEnvs, + handleEnvironmentSelectionChange, + handleSectionSelectionChange, + onCreateEnvClicked, + selectedEnvironmentIds, + ]) return (
@@ -184,6 +250,11 @@ function EnvironmentsTableContent() {
+ setSelectedEnvironmentIds([])} + />
) } From 7b47941cf6fd084aa8eb368f33b87c335813f34f Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 8 Jun 2026 14:59:48 +0200 Subject: [PATCH 2/3] Add missing aria-labels to checkboxes --- .../environment-section/environment-section.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx index 555306c4a54..ff343493176 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx @@ -76,8 +76,9 @@ function EnvRow({ }} > -
@@ -253,13 +254,14 @@ export function EnvironmentSection({ - +
Environment From a289f2a0652d2e7d1408e4a51f0e426fbf6584c3 Mon Sep 17 00:00:00 2001 From: Romain Billard Date: Mon, 8 Jun 2026 15:10:56 +0200 Subject: [PATCH 3/3] Add unit test --- .../environments-table-action-bar.spec.tsx | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx index e3bc633b42a..daf424af4b8 100644 --- a/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx +++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx @@ -3,6 +3,14 @@ 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: () => ({ @@ -40,6 +48,8 @@ const rows: EnvironmentOverviewResponse[] = [ describe('EnvironmentsTableActionBar', () => { beforeEach(() => { mockStopEnvironment.mockReset() + mockOpenModalConfirmation.mockReset() + mockStopEnvironment.mockResolvedValue(undefined) }) it('should render successfully', () => { @@ -56,4 +66,70 @@ describe('EnvironmentsTableActionBar', () => { expect(screen.getByText('2 selected environments')).toBeInTheDocument() }) + + it('should disable stop action when no selected environment can be stopped', async () => { + const { userEvent } = renderWithProviders( + + ) + + 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( + + ) + + 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) + }) })