@@ -215,8 +252,31 @@ export function EnvironmentSection({
) : (
-
-
+
+
+ {
+ e.stopPropagation()
+ }}
+ >
+ e.stopPropagation()}
+ onCheckedChange={(checked) => {
+ if (checked === 'indeterminate') {
+ return
+ }
+ onSectionSelectionChange?.(
+ items.map(({ id }) => id),
+ checked
+ )
+ }}
+ />
+
+
+
Environment
@@ -236,7 +296,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..daf424af4b8
--- /dev/null
+++ b/libs/domains/environments/feature/src/lib/environments-table/environments-table-action-bar.spec.tsx
@@ -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(
+
+ )
+ expect(baseElement).toBeTruthy()
+ })
+
+ it('should show selected environment count', () => {
+ renderWithProviders(
+
+ )
+
+ 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)
+ })
+})
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() {