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 @@ -157,6 +157,94 @@ describe('EnvironmentSection', () => {
expect(screen.queryByText(/0 seconds ago/i)).not.toBeInTheDocument()
})

it('sorts environments on click and restores the default order after the third click', async () => {
const environments = [
{ ...overview, id: 'env-b', name: 'Beta' },
{ ...overview, id: 'env-a', name: 'Alpha' },
] as EnvironmentOverviewResponse[]

const { userEvent } = renderWithProviders(
<EnvironmentSection type={EnvironmentModeEnum.DEVELOPMENT} items={environments} />
)

const getEnvironmentNames = () =>
screen
.getAllByRole('checkbox', { name: /^Select (Alpha|Beta)$/i })
.map((checkbox) => checkbox.getAttribute('aria-label')?.replace('Select ', ''))

expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])

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

expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])

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

expect(getEnvironmentNames()).toEqual(['Beta', 'Alpha'])

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

expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])
})

it('sorts a missing last update as just now, as displayed in its row', async () => {
jest.setSystemTime(new Date('2026-03-18T12:00:00.000Z'))
const environments = [
{ ...overview, id: 'env-a', name: 'Alpha' },
{ ...overview, id: 'env-b', name: 'Beta', updated_at: undefined },
] as EnvironmentOverviewResponse[]

const { userEvent } = renderWithProviders(
<EnvironmentSection type={EnvironmentModeEnum.DEVELOPMENT} items={environments} />
)

await userEvent.click(screen.getByRole('button', { name: /sort by last update/i }))
const getEnvironmentNames = () =>
screen
.getAllByRole('checkbox', { name: /^Select (Alpha|Beta)$/i })
.map((checkbox) => checkbox.getAttribute('aria-label')?.replace('Select ', ''))

expect(getEnvironmentNames()).toEqual(['Alpha', 'Beta'])

await userEvent.click(screen.getByRole('button', { name: /sort by last update/i }))
expect(getEnvironmentNames()).toEqual(['Beta', 'Alpha'])
})

it('should reflect the current sort state through aria-sort on the column headers', async () => {
const { userEvent } = renderWithProviders(
<EnvironmentSection type={EnvironmentModeEnum.DEVELOPMENT} items={[overview]} />
)

const environmentHeader = screen.getByRole('columnheader', { name: 'Environment' })
const clusterHeader = screen.getByRole('columnheader', { name: 'Cluster' })
Comment thread
RemiBonnet marked this conversation as resolved.

expect(environmentHeader).toHaveAttribute('aria-sort', 'none')
expect(clusterHeader).toHaveAttribute('aria-sort', 'none')
expect(environmentHeader.querySelector('.fa-arrow-down')).not.toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
expect(environmentHeader).toHaveAttribute('aria-sort', 'ascending')
expect(environmentHeader.querySelector('.fa-arrow-down')).toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: /sort by environment/i }))
expect(environmentHeader).toHaveAttribute('aria-sort', 'descending')
expect(environmentHeader.querySelector('.fa-arrow-up')).toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: /sort by cluster/i }))
expect(clusterHeader).toHaveAttribute('aria-sort', 'ascending')
expect(environmentHeader).toHaveAttribute('aria-sort', 'none')
expect(clusterHeader.querySelector('.fa-arrow-down')).toBeInTheDocument()
expect(environmentHeader.querySelector('.fa-arrow-up')).not.toBeInTheDocument()
})

it('keeps the ephemeral default sort without showing an arrow initially', () => {
renderWithProviders(<EnvironmentSection type={EnvironmentModeEnum.PREVIEW} items={[overview]} />)

const lastOperationHeader = screen.getByRole('columnheader', { name: 'Last operation' })
expect(lastOperationHeader).toHaveAttribute('aria-sort', 'none')
expect(lastOperationHeader.querySelector('.fa-arrow-up')).not.toBeInTheDocument()
})

it('should disable the deploy button when the environment is managed by ArgoCD', async () => {
const { userEvent } = renderWithProviders(
<EnvironmentSection
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Link, useNavigate, useParams } from '@tanstack/react-router'
import { EnvironmentModeEnum, type EnvironmentOverviewResponse, StateEnum } from 'qovery-typescript-axios'
import { type KeyboardEvent, type MouseEvent, useMemo } from 'react'
import { type KeyboardEvent, type MouseEvent, useCallback, useMemo, useState } from 'react'
import { match } from 'ts-pattern'
import { ClusterAvatar } from '@qovery/domains/clusters/feature'
import { Button, Checkbox, DeploymentAction, Heading, Icon, Section, TablePrimitives, Tooltip } from '@qovery/shared/ui'
Expand Down Expand Up @@ -192,6 +192,71 @@ function lastOperationTimestamp(overview: EnvironmentOverviewResponse) {
return lastDeploymentDate ? new Date(lastDeploymentDate).getTime() : 0
}

function lastUpdateTimestamp(overview: EnvironmentOverviewResponse) {
return new Date(overview.updated_at ?? Date.now()).getTime()
}

type EnvironmentSortColumn = 'name' | 'last-operation' | 'cluster' | 'last-update'
type SortDirection = 'asc' | 'desc'

interface EnvironmentSort {
column: EnvironmentSortColumn
direction: SortDirection
}

function compareEnvironments(
environmentA: EnvironmentOverviewResponse,
environmentB: EnvironmentOverviewResponse,
column: EnvironmentSortColumn
) {
return match(column)
.with('name', () => (environmentA.name ?? '').localeCompare(environmentB.name ?? ''))
.with('cluster', () => (environmentA.cluster?.name ?? '').localeCompare(environmentB.cluster?.name ?? ''))
.with('last-operation', () => lastOperationTimestamp(environmentA) - lastOperationTimestamp(environmentB))
.with('last-update', () => lastUpdateTimestamp(environmentA) - lastUpdateTimestamp(environmentB))
.exhaustive()
}

function SortableColumnHeader({
label,
column,
sort,
onSort,
className,
buttonClassName,
}: {
label: string
column: EnvironmentSortColumn
sort: EnvironmentSort | null
onSort: (column: EnvironmentSortColumn) => void
className?: string
buttonClassName?: string
}) {
const isSorted = sort?.column === column

return (
<Table.ColumnHeaderCell
aria-sort={isSorted ? (sort?.direction === 'asc' ? 'ascending' : 'descending') : 'none'}
className={twMerge('flex h-9 items-center p-0 text-neutral-subtle', className)}
>
<button
type="button"
aria-label={`Sort by ${label.toLowerCase()}`}
className={twMerge('flex h-full w-full cursor-pointer select-none items-center gap-1', buttonClassName)}
onClick={() => onSort(column)}
>
{label}
{isSorted &&
sort &&
match(sort.direction)
.with('asc', () => <Icon className="text-ssm" iconName="arrow-down" />)
.with('desc', () => <Icon className="text-ssm" iconName="arrow-up" />)
.exhaustive()}
</button>
</Table.ColumnHeaderCell>
)
}

export function EnvironmentSection({
type,
items,
Expand All @@ -214,15 +279,34 @@ export function EnvironmentSection({
.with('PREVIEW', () => 'Ephemeral')
.exhaustive()

// Ephemeral environments default to the most recent operation first, every other
// section keeps the historical alphabetical order. Clicking a header overrides this.
const [sort, setSort] = useState<EnvironmentSort | null>(null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The default-sorted column is announced as unsorted until the user clicks a header. Pass the resolved default sort to SortableColumnHeader (or initialize equivalent state) so aria-sort reports the actual active column and direction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libs/domains/environments/feature/src/lib/environments-table/environment-section/environment-section.tsx, line 284:

<comment>The default-sorted column is announced as unsorted until the user clicks a header. Pass the resolved default sort to `SortableColumnHeader` (or initialize equivalent state) so `aria-sort` reports the actual active column and direction.</comment>

<file context>
@@ -280,32 +281,32 @@ export function EnvironmentSection({
-      ? { column: 'last-operation', direction: 'desc' }
-      : { column: 'name', direction: 'asc' }
-  )
+  const [sort, setSort] = useState<EnvironmentSort | null>(null)
 
   const handleSort = useCallback((column: EnvironmentSortColumn) => {
</file context>


const handleSort = useCallback((column: EnvironmentSortColumn) => {
setSort((currentSort) => {
if (currentSort?.column !== column) return { column, direction: 'asc' }
return currentSort.direction === 'asc' ? { column, direction: 'desc' } : null
})
}, [])

const sortedItems = useMemo(() => {
if (type !== EnvironmentModeEnum.PREVIEW) {
return items
}
const activeSort: EnvironmentSort =
sort ??
(type === EnvironmentModeEnum.PREVIEW
? { column: 'last-operation', direction: 'desc' }
: { column: 'name', direction: 'asc' })
const directionFactor = activeSort.direction === 'asc' ? 1 : -1

return [...items].sort(
(environmentA, environmentB) => lastOperationTimestamp(environmentB) - lastOperationTimestamp(environmentA)
)
}, [items, type])
return [...items].sort((environmentA, environmentB) => {
const comparison = compareEnvironments(environmentA, environmentB, activeSort.column)
if (comparison !== 0) {
return directionFactor * comparison
}
// Stable, predictable fallback so rows with equal values keep an alphabetical order
return (environmentA.name ?? '').localeCompare(environmentB.name ?? '')
})
}, [items, sort, type])

const EmptyState = () =>
match(type)
Expand Down Expand Up @@ -291,18 +375,37 @@ export function EnvironmentSection({
/>
</div>
</Table.ColumnHeaderCell>
<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">
Last operation
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-9 items-center border-l border-neutral text-neutral-subtle">
Cluster
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell className="flex h-9 items-center border-l border-neutral text-neutral-subtle">
Last update
</Table.ColumnHeaderCell>
<SortableColumnHeader
label="Environment"
column="name"
sort={sort}
onSort={handleSort}
buttonClassName="pl-0 pr-4"
/>
<SortableColumnHeader
label="Last operation"
column="last-operation"
sort={sort}
onSort={handleSort}
className="border-l border-neutral"
buttonClassName="px-4"
/>
<SortableColumnHeader
label="Cluster"
column="cluster"
sort={sort}
onSort={handleSort}
className="border-l border-neutral"
buttonClassName="px-4"
/>
<SortableColumnHeader
label="Last update"
column="last-update"
sort={sort}
onSort={handleSort}
className="border-l border-neutral"
buttonClassName="px-4"
/>
<Table.ColumnHeaderCell className="flex h-9 items-center justify-end border-l border-neutral text-left text-neutral-subtle">
Actions
</Table.ColumnHeaderCell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export function useServices({ environmentId, suspense = false }: UseServicesProp
JSON.stringify([
...runningStatusResults.map(({ data }) => data?.state),
...deploymentStatusResults.map(({ data }) => data?.state),
...deploymentStatusResults.map(({ data }) => data?.last_deployment_date),
]),
])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ exports[`ServiceList should match snapshot 1`] = `
<th
class="px-4 text-left font-code font-normal relative flex h-full items-center border-r border-neutral text-neutral-subtle last:border-r-0"
>
Last operation
<button
class="flex items-center gap-1 truncate cursor-pointer select-none"
type="button"
>
Last operation
</button>
</th>
<th
class="px-4 text-left font-code font-normal relative flex h-full items-center border-r border-neutral text-neutral-subtle last:border-r-0"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,31 @@ describe('ServiceList', () => {
expect(rows).toHaveLength(5)
})

it('sorts services by last operation and clears the sort on the third click', async () => {
const { userEvent } = renderWithProviders(<ServiceList {...serviceListProps} />)
const lastOperationHeader = screen.getByRole('columnheader', { name: 'Last operation' })
const getServiceNames = () =>
screen
.getAllByRole('row')
.slice(1)
.map((row) => row.textContent?.match(/FRONT-END|back-end-A|CRONJOB|seed_script/)?.[0])

expect(lastOperationHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This test asserts the FontAwesome glyph classes, which are an implementation detail, while the feature's advertised accessibility contract (header aria-sort) goes unverified. Assert the header's aria-sort attribute (ascending/descending/none) instead of, or in addition to, the icon classes so the test keeps checking meaning when the icon library or glyph names change.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libs/domains/services/feature/src/lib/service-list/service-list.spec.tsx, line 455:

<comment>This test asserts the FontAwesome glyph classes, which are an implementation detail, while the feature's advertised accessibility contract (header aria-sort) goes unverified. Assert the header's aria-sort attribute (ascending/descending/none) instead of, or in addition to, the icon classes so the test keeps checking meaning when the icon library or glyph names change.</comment>

<file context>
@@ -443,6 +443,31 @@ describe('ServiceList', () => {
+        .slice(1)
+        .map((row) => row.textContent?.match(/FRONT-END|back-end-A|CRONJOB|seed_script/)?.[0])
+
+    expect(lastOperationHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument()
+    expect(getServiceNames()).toEqual(['FRONT-END', 'back-end-A', 'CRONJOB', 'seed_script'])
+
</file context>

expect(getServiceNames()).toEqual(['FRONT-END', 'back-end-A', 'CRONJOB', 'seed_script'])

await userEvent.click(screen.getByRole('button', { name: 'Last operation' }))
expect(getServiceNames()).toEqual(['FRONT-END', 'CRONJOB', 'back-end-A', 'seed_script'])
expect(lastOperationHeader.querySelector('.fa-arrow-down')).toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: 'Last operation' }))
expect(getServiceNames()).toEqual(['seed_script', 'back-end-A', 'CRONJOB', 'FRONT-END'])
expect(lastOperationHeader.querySelector('.fa-arrow-up')).toBeInTheDocument()

await userEvent.click(screen.getByRole('button', { name: 'Last operation' }))
expect(getServiceNames()).toEqual(['FRONT-END', 'back-end-A', 'CRONJOB', 'seed_script'])
expect(lastOperationHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument()
})

it('should not display ArgoCD services', () => {
renderWithProviders(<ServiceList {...serviceListProps} />)
expect(screen.queryByText('ARGOCD APP')).not.toBeInTheDocument()
Expand Down
34 changes: 21 additions & 13 deletions libs/domains/services/feature/src/lib/service-list/service-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,19 +240,27 @@ export function ServiceList({ className, containerClassName, environment, ...pro
return <StatusChip status={info.row.original.status} />
},
}),
columnHelper.display({
id: 'last_deployment',
header: 'Last operation',
enableColumnFilter: false,
enableSorting: false,
cell: (info) => (
<ServiceLastDeploymentCell
service={info.row.original}
environment={environment}
isSkipped={info.row.original.isSkipped}
/>
),
}),
columnHelper.accessor(
(service) => {
const lastDeploymentDate = service.isSkipped ? undefined : service.deploymentStatus?.last_deployment_date
return lastDeploymentDate ? new Date(lastDeploymentDate).getTime() : 0
},
{
id: 'last_deployment',
header: 'Last operation',
enableColumnFilter: false,
enableSorting: true,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This newly sortable service column has no aria-sort state in the shared header branch, so screen readers cannot tell whether it is unsorted, ascending, or descending. Add the current sort state to the Table.ColumnHeaderCell, including none when inactive.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At libs/domains/services/feature/src/lib/service-list/service-list.tsx, line 252:

<comment>This newly sortable service column has no `aria-sort` state in the shared header branch, so screen readers cannot tell whether it is unsorted, ascending, or descending. Add the current sort state to the `Table.ColumnHeaderCell`, including `none` when inactive.</comment>

<file context>
@@ -240,19 +240,27 @@ export function ServiceList({ className, containerClassName, environment, ...pro
+          id: 'last_deployment',
+          header: 'Last operation',
+          enableColumnFilter: false,
+          enableSorting: true,
+          sortingFn: 'basic',
+          sortDescFirst: false,
</file context>

sortingFn: 'basic',
sortDescFirst: false,
cell: (info) => (
<ServiceLastDeploymentCell
service={info.row.original}
environment={environment}
isSkipped={info.row.original.isSkipped}
/>
),
}
),
columnHelper.accessor('version', {
header: 'Target version',
enableColumnFilter: false,
Expand Down
Loading