Feat: Show statuses of run filters with colors (#2325)

* feat: hover / selected + unselected states to indicate applied filters

* feat: implement the rest of the states

* fix: handle unselect + equality checks properly

* fix: font weight on selected filters
This commit is contained in:
matt
2025-09-22 14:11:32 -04:00
committed by GitHub
parent fa65fa0133
commit 18615f6de2
4 changed files with 132 additions and 53 deletions

View File

@@ -13,6 +13,7 @@ import {
flattenDAGsKey,
createdAfterKey,
finishedBeforeKey,
statusKey,
} from '@/pages/main/v1/workflow-runs-v1/components/v1/task-runs-columns';
import { ToolbarFilters } from './data-table-toolbar';
import {
@@ -33,6 +34,7 @@ import {
import { DateTimePicker } from '@/components/v1/molecules/time-picker/date-time-picker';
import { XCircleIcon } from '@heroicons/react/24/outline';
import { Column } from '@tanstack/react-table';
import { V1TaskStatus } from '@/lib/api';
interface FilterControlProps<TData> {
column?: Column<TData, any>;
@@ -433,6 +435,15 @@ interface DataTableOptionsProps<TData> {
onResetFilters?: () => void;
}
function arraysEqual<T>(a: T[], b: T[]) {
return (
Array.isArray(a) &&
Array.isArray(b) &&
a.length === b.length &&
a.every((val, index) => val === b[index])
);
}
export function DataTableOptions<TData>({
table,
filters,
@@ -444,6 +455,13 @@ export function DataTableOptions<TData>({
const activeFiltersCount = React.useMemo(
() =>
cf?.filter((f) => {
if (
f.id === statusKey &&
arraysEqual(f.value as V1TaskStatus[], Object.values(V1TaskStatus))
) {
return false;
}
if (f.id === createdAfterKey || f.id === finishedBeforeKey) {
return false;
}

View File

@@ -105,7 +105,7 @@ function Sidebar({ className, memberships }: SidebarProps) {
return (
<div
className={cn(
'h-full border-r overflow-y-auto w-full md:min-w-80 md:w-80 top-16 absolute z-[100] md:relative md:top-0 md:bg-[unset] md:dark:bg-[unset] bg-slate-100 dark:bg-slate-900',
'h-full border-r overflow-y-auto w-full md:min-w-64 md:w-64 top-16 absolute z-[100] md:relative md:top-0 md:bg-[unset] md:dark:bg-[unset] bg-slate-100 dark:bg-slate-900',
className,
)}
>

View File

@@ -1,11 +1,11 @@
import { V1TaskRunMetrics, V1TaskStatus } from '@/lib/api';
import { Badge, badgeVariants } from '@/components/v1/ui/badge';
import { VariantProps } from 'class-variance-authority';
import { V1TaskStatus } from '@/lib/api';
import { Badge } from '@/components/v1/ui/badge';
import { useRunsContext } from '../hooks/runs-provider';
import { getStatusesFromFilters } from '../hooks/use-runs-table-state';
import { CheckCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
import { PlayIcon, X, Ban, ChartColumn } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useCallback } from 'react';
function statusToFriendlyName(status: V1TaskStatus) {
switch (status) {
@@ -44,18 +44,47 @@ function statusToIcon(status: V1TaskStatus) {
}
function MetricBadge({
metrics,
status,
onClick,
variant,
className,
}: {
metrics: V1TaskRunMetrics;
status: V1TaskStatus;
onClick?: (status: V1TaskStatus) => void;
variant: VariantProps<typeof badgeVariants>['variant'];
className?: string;
}) {
const { filters, metrics } = useRunsContext();
const currentStatuses = getStatusesFromFilters(filters.columnFilters);
const isSelected = currentStatuses.includes(status);
const { setStatuses } = filters;
const handleStatusClick = useCallback(
(status: V1TaskStatus) => {
const currentStatuses = getStatusesFromFilters(filters.columnFilters);
const isSelected = currentStatuses.includes(status);
const allStatuses = Object.values(V1TaskStatus);
const isAllSelected =
currentStatuses.length === allStatuses.length &&
allStatuses.every((s) => currentStatuses.includes(s));
if (isSelected) {
if (isAllSelected) {
setStatuses([status]);
} else {
const newStatuses = currentStatuses.filter((s) => s !== status);
if (newStatuses.length === 0) {
setStatuses(allStatuses);
} else {
setStatuses(newStatuses);
}
}
} else {
setStatuses([...currentStatuses, status]);
}
},
[filters.columnFilters, setStatuses],
);
const metric = metrics.find((m) => m.status === status);
if (!metric) {
@@ -68,9 +97,13 @@ function MetricBadge({
return (
<Badge
variant={variant}
className={cn('cursor-pointer text-sm px-3 py-1 w-fit h-8', className)}
onClick={() => onClick?.(status)}
data-is-selected={isSelected}
variant={isSelected ? 'default' : 'outline'}
className={cn(
'cursor-pointer text-sm px-3 py-1 w-fit h-8 data-[is-selected=false]:font-light',
className,
)}
onClick={() => handleStatusClick(status)}
>
<span className="flex items-center gap-1">
<span>{formattedCount}</span>
@@ -83,65 +116,91 @@ function MetricBadge({
export const V1WorkflowRunsMetricsView = () => {
const {
metrics,
filters,
display: { hideMetrics },
actions: { updateUIState },
} = useRunsContext();
const { setStatuses } = filters;
const onViewQueueMetricsClick = () => {
updateUIState({ viewQueueMetrics: true });
};
const handleStatusClick = (status: V1TaskStatus) => {
const currentStatuses = getStatusesFromFilters(filters.columnFilters);
const isSelected = currentStatuses.includes(status);
if (isSelected) {
setStatuses(currentStatuses.filter((s) => s !== status));
} else {
setStatuses([...currentStatuses, status]);
}
};
// format of className strings is:
// default, then unselected, then selected, then hover+selected, then hover+unselected
return (
<dl className="flex flex-row justify-start gap-2">
<div className="flex flex-row justify-start gap-2">
<MetricBadge
metrics={metrics}
status={V1TaskStatus.COMPLETED}
onClick={handleStatusClick}
variant="successful"
className={`
text-green-800 dark:text-green-300
data-[is-selected=false]:border data-[is-selected=false]:border-green-500/20
data-[is-selected=true]:bg-green-500/20
hover:data-[is-selected=true]:bg-green-500/20
hover:data-[is-selected=false]:bg-green-500/20 hover:data-[is-selected=false]:border-transparent
`}
/>
<MetricBadge
metrics={metrics}
status={V1TaskStatus.RUNNING}
onClick={handleStatusClick}
variant="inProgress"
className={`
text-yellow-800 dark:text-yellow-300
data-[is-selected=false]:border data-[is-selected=false]:border-yellow-500/20
data-[is-selected=true]:bg-yellow-500/20
hover:data-[is-selected=true]:bg-yellow-500/20
hover:data-[is-selected=false]:bg-yellow-500/20 hover:data-[is-selected=false]:border-transparent
`}
/>
<MetricBadge
metrics={metrics}
status={V1TaskStatus.FAILED}
onClick={handleStatusClick}
variant="failed"
className={`
text-red-800 dark:text-red-300
data-[is-selected=false]:border data-[is-selected=false]:border-red-500/20
data-[is-selected=true]:bg-red-500/20
hover:data-[is-selected=true]:bg-red-500/20
hover:data-[is-selected=false]:bg-red-500/20 hover:data-[is-selected=false]:border-transparent
`}
/>
<MetricBadge
metrics={metrics}
status={V1TaskStatus.CANCELLED}
onClick={handleStatusClick}
variant="outlineDestructive"
className={`
text-orange-800 dark:text-orange-300
data-[is-selected=false]:border data-[is-selected=false]:border-orange-500/20
data-[is-selected=true]:bg-orange-500/20
hover:data-[is-selected=true]:bg-orange-500/20
hover:data-[is-selected=false]:bg-orange-500/20 hover:data-[is-selected=false]:border-transparent
`}
/>
<MetricBadge
metrics={metrics}
status={V1TaskStatus.QUEUED}
onClick={handleStatusClick}
variant="outline"
className="rounded-sm font-normal"
className={`
text-fuchsia-800 dark:text-fuchsia-300
data-[is-selected=false]:border data-[is-selected=false]:border-fuchsia-500/20
data-[is-selected=true]:bg-fuchsia-500/20
hover:data-[is-selected=true]:bg-fuchsia-500/20
hover:data-[is-selected=false]:bg-fuchsia-500/20 hover:data-[is-selected=false]:border-transparent
`}
/>
{!hideMetrics && (
@@ -154,6 +213,6 @@ export const V1WorkflowRunsMetricsView = () => {
<ChartColumn className="size-4 cq-xl:hidden" />
</Badge>
)}
</dl>
</div>
);
};

View File

@@ -45,7 +45,9 @@ const createApiFilterSchema = (initialValues?: { workflowIds?: string[] }) =>
z.object({
s: z.string().default(() => getCreatedAfterFromTimeRange('1d')), // since
u: z.string().optional(), // until
st: z.array(z.nativeEnum(V1TaskStatus)).optional(), // statuses
st: z
.array(z.nativeEnum(V1TaskStatus))
.default(() => Object.values(V1TaskStatus)), // statuses
w: z // workflow ids
.array(z.string())
.optional()
@@ -123,12 +125,12 @@ export const useRunsTableFilters = (
const setStatuses = useCallback(
(statuses: V1TaskStatus[]) => {
const newColumnFilters =
statuses.length > 0
? columnFilters
.filter((f) => f.id !== statusKey)
.concat([{ id: statusKey, value: statuses }])
: columnFilters.filter((f) => f.id !== statusKey);
const finalStatuses =
statuses.length > 0 ? statuses : Object.values(V1TaskStatus);
const newColumnFilters = columnFilters
.filter((f) => f.id !== statusKey)
.concat([{ id: statusKey, value: finalStatuses }]);
setColumnFilters(newColumnFilters);
},