-
Notifications
You must be signed in to change notification settings - Fork 31
feat(environments): make environment list columns sortable #3020
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: staging
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||
|
|
@@ -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, | ||
|
|
@@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Prompt for AI agents |
||
|
|
||
| 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({ | |
| /> | ||
| </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> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This newly sortable service column has no Prompt for AI agents |
||
| 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, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.