From 1852c3135f90d1d919b34ea8c9ba071dfc289987 Mon Sep 17 00:00:00 2001 From: qovery-agent Date: Fri, 25 Sep 2026 13:11:30 +0000 Subject: [PATCH 1/2] feat(environments): make environment list columns sortable Allow sorting the environment list by clicking on column headers, with a click toggling the direction between ascending and descending. Sortable columns: Environment (name), Last operation, Cluster and Last update. This mirrors the sortable behavior already used on the service rows (arrow-down for ascending, arrow-up for descending) and exposes the current sort state via aria-sort for accessibility. Defaults are preserved: ephemeral environments still start sorted by the most recent operation, other sections keep the alphabetical order. Co-Authored-By: Claude Opus 4.8 --- .../environment-section.spec.tsx | 88 +++++++++++ .../environment-section.tsx | 143 +++++++++++++++--- .../lib/hooks/use-services/use-services.ts | 1 + .../__snapshots__/service-list.spec.tsx.snap | 7 +- .../lib/service-list/service-list.spec.tsx | 25 +++ .../src/lib/service-list/service-list.tsx | 34 +++-- 6 files changed, 264 insertions(+), 34 deletions(-) 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 41f983080c7..d0b34729316 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 @@ -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( + + ) + + 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( + + ) + + 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( + + ) + + const environmentHeader = screen.getByRole('columnheader', { name: 'Environment' }) + const clusterHeader = screen.getByRole('columnheader', { name: 'Cluster' }) + + 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() + + 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( (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 ( + + + + ) +} + export function EnvironmentSection({ type, items, @@ -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(null) + + 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) @@ -291,18 +375,37 @@ export function EnvironmentSection({ /> - - Environment - - - Last operation - - - Cluster - - - Last update - + + + + Actions diff --git a/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts b/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts index e19efe5eef5..56208352a22 100644 --- a/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts +++ b/libs/domains/services/feature/src/lib/hooks/use-services/use-services.ts @@ -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), ]), ]) diff --git a/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap b/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap index b7c5ca58d0e..ec5f6b31089 100644 --- a/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap +++ b/libs/domains/services/feature/src/lib/service-list/__snapshots__/service-list.spec.tsx.snap @@ -63,7 +63,12 @@ exports[`ServiceList should match snapshot 1`] = ` - Last operation + { expect(rows).toHaveLength(5) }) + it('sorts services by last operation and clears the sort on the third click', async () => { + const { userEvent } = renderWithProviders() + 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() + 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() expect(screen.queryByText('ARGOCD APP')).not.toBeInTheDocument() diff --git a/libs/domains/services/feature/src/lib/service-list/service-list.tsx b/libs/domains/services/feature/src/lib/service-list/service-list.tsx index 47961bf25d5..9ba8aa24c4e 100644 --- a/libs/domains/services/feature/src/lib/service-list/service-list.tsx +++ b/libs/domains/services/feature/src/lib/service-list/service-list.tsx @@ -240,19 +240,27 @@ export function ServiceList({ className, containerClassName, environment, ...pro return }, }), - columnHelper.display({ - id: 'last_deployment', - header: 'Last operation', - enableColumnFilter: false, - enableSorting: false, - cell: (info) => ( - - ), - }), + 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, + sortingFn: 'basic', + sortDescFirst: false, + cell: (info) => ( + + ), + } + ), columnHelper.accessor('version', { header: 'Target version', enableColumnFilter: false, From 87f36b4f961dd66b5f686285cb5256eb1c05aa51 Mon Sep 17 00:00:00 2001 From: RemiBonnet Date: Mon, 28 Sep 2026 09:32:48 +0200 Subject: [PATCH 2/2] fix(tables): expose effective sorting state in headers --- .../environment-section.spec.tsx | 10 ++++- .../environment-section.tsx | 38 +++++++++++++------ .../__snapshots__/service-list.spec.tsx.snap | 2 + .../lib/service-list/service-list.spec.tsx | 4 ++ .../src/lib/service-list/service-list.tsx | 9 +++++ 5 files changed, 50 insertions(+), 13 deletions(-) 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 d0b34729316..2569c6b2b79 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 @@ -218,7 +218,7 @@ describe('EnvironmentSection', () => { const environmentHeader = screen.getByRole('columnheader', { name: 'Environment' }) const clusterHeader = screen.getByRole('columnheader', { name: 'Cluster' }) - expect(environmentHeader).toHaveAttribute('aria-sort', 'none') + expect(environmentHeader).toHaveAttribute('aria-sort', 'ascending') expect(clusterHeader).toHaveAttribute('aria-sort', 'none') expect(environmentHeader.querySelector('.fa-arrow-down')).not.toBeInTheDocument() @@ -235,13 +235,19 @@ describe('EnvironmentSection', () => { expect(environmentHeader).toHaveAttribute('aria-sort', 'none') expect(clusterHeader.querySelector('.fa-arrow-down')).toBeInTheDocument() expect(environmentHeader.querySelector('.fa-arrow-up')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /sort by cluster/i })) + await userEvent.click(screen.getByRole('button', { name: /sort by cluster/i })) + expect(clusterHeader).toHaveAttribute('aria-sort', 'none') + expect(environmentHeader).toHaveAttribute('aria-sort', 'ascending') + expect(clusterHeader.querySelector('.fa-arrow-down, .fa-arrow-up')).not.toBeInTheDocument() }) it('keeps the ephemeral default sort without showing an arrow initially', () => { renderWithProviders() const lastOperationHeader = screen.getByRole('columnheader', { name: 'Last operation' }) - expect(lastOperationHeader).toHaveAttribute('aria-sort', 'none') + expect(lastOperationHeader).toHaveAttribute('aria-sort', 'descending') expect(lastOperationHeader.querySelector('.fa-arrow-up')).not.toBeInTheDocument() }) 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 afa0f8f3732..a16bc2e2c8e 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 @@ -192,8 +192,14 @@ function lastOperationTimestamp(overview: EnvironmentOverviewResponse) { return lastDeploymentDate ? new Date(lastDeploymentDate).getTime() : 0 } -function lastUpdateTimestamp(overview: EnvironmentOverviewResponse) { - return new Date(overview.updated_at ?? Date.now()).getTime() +function lastUpdateTimestamp(overview: EnvironmentOverviewResponse, fallbackTimestamp: number) { + return overview.updated_at ? new Date(overview.updated_at).getTime() : fallbackTimestamp +} + +function defaultEnvironmentSort(type: EnvironmentModeEnum): EnvironmentSort { + return type === EnvironmentModeEnum.PREVIEW + ? { column: 'last-operation', direction: 'desc' } + : { column: 'name', direction: 'asc' } } type EnvironmentSortColumn = 'name' | 'last-operation' | 'cluster' | 'last-update' @@ -207,13 +213,17 @@ interface EnvironmentSort { function compareEnvironments( environmentA: EnvironmentOverviewResponse, environmentB: EnvironmentOverviewResponse, - column: EnvironmentSortColumn + column: EnvironmentSortColumn, + fallbackTimestamp: number ) { 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)) + .with( + 'last-update', + () => lastUpdateTimestamp(environmentA, fallbackTimestamp) - lastUpdateTimestamp(environmentB, fallbackTimestamp) + ) .exhaustive() } @@ -221,6 +231,7 @@ function SortableColumnHeader({ label, column, sort, + effectiveSort, onSort, className, buttonClassName, @@ -228,6 +239,7 @@ function SortableColumnHeader({ label: string column: EnvironmentSortColumn sort: EnvironmentSort | null + effectiveSort: EnvironmentSort onSort: (column: EnvironmentSortColumn) => void className?: string buttonClassName?: string @@ -236,7 +248,9 @@ function SortableColumnHeader({ return (