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..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 @@ -157,6 +157,100 @@ 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', 'ascending') + 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() + + 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', 'descending') + 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, fallbackTimestamp) - lastUpdateTimestamp(environmentB, fallbackTimestamp) + ) + .exhaustive() +} + +function SortableColumnHeader({ + label, + column, + sort, + effectiveSort, + onSort, + className, + buttonClassName, +}: { + label: string + column: EnvironmentSortColumn + sort: EnvironmentSort | null + effectiveSort: EnvironmentSort + onSort: (column: EnvironmentSortColumn) => void + className?: string + buttonClassName?: string +}) { + const isSorted = sort?.column === column + + return ( + + + + ) +} + export function EnvironmentSection({ type, items, @@ -214,15 +293,32 @@ 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 effectiveSort = sort ?? defaultEnvironmentSort(type) + + 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 = sort ?? defaultEnvironmentSort(type) + const directionFactor = activeSort.direction === 'asc' ? 1 : -1 + const fallbackTimestamp = Date.now() - return [...items].sort( - (environmentA, environmentB) => lastOperationTimestamp(environmentB) - lastOperationTimestamp(environmentA) - ) - }, [items, type]) + return [...items].sort((environmentA, environmentB) => { + const comparison = compareEnvironments(environmentA, environmentB, activeSort.column, fallbackTimestamp) + 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 +387,41 @@ 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..f41756ddccc 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 @@ -48,6 +48,7 @@ exports[`ServiceList should match snapshot 1`] = ` { 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(lastOperationHeader).toHaveAttribute('aria-sort', 'none') + 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() + expect(lastOperationHeader).toHaveAttribute('aria-sort', 'ascending') + + 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() + expect(lastOperationHeader).toHaveAttribute('aria-sort', 'descending') + + 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() + expect(lastOperationHeader).toHaveAttribute('aria-sort', 'none') + }) + 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..f299e68fb38 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, @@ -390,6 +398,15 @@ export function ServiceList({ className, containerClassName, environment, ...pro {headerGroup.headers.map((header, i) => ( 'ascending' as const) + .with('desc', () => 'descending' as const) + .with(false, () => 'none' as const) + .exhaustive() + : undefined + } className={twMerge( 'relative flex h-full items-center border-r border-neutral text-neutral-subtle last:border-r-0', i === 1 || i === 0 ? 'border-none p-0' : ''