Skip to content
Merged
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
881 changes: 0 additions & 881 deletions app/src/sections/plots-gallery/FilterBar.tsx

This file was deleted.

123 changes: 123 additions & 0 deletions app/src/sections/plots-gallery/FilterBar/FilterChips.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
FilterChips,
type FilterChipsProps,
} from 'src/sections/plots-gallery/FilterBar/FilterChips';
import { render, screen } from 'src/test-utils';

const callbacks = {
onChipClick: vi.fn(),
onChipMenuClose: vi.fn(),
onRemoveValue: vi.fn(),
onRemoveActiveGroup: vi.fn(),
onAddValueToActiveGroup: vi.fn(),
onRemoveGroup: vi.fn(),
};

function renderChips(overrides: Partial<FilterChipsProps> = {}) {
return render(
<FilterChips
activeFilters={[{ category: 'lib', values: ['matplotlib'] }]}
randomAnimation={null}
orCounts={[]}
currentTotal={100}
chipMenuAnchor={null}
activeGroupIndex={null}
{...callbacks}
{...overrides}
/>
);
}

describe('FilterChips', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('renders one chip per group with category:values label', () => {
renderChips({
activeFilters: [
{ category: 'lib', values: ['matplotlib', 'seaborn'] },
{ category: 'plot', values: ['scatter'] },
],
});
expect(screen.getByText('lib:matplotlib,seaborn')).toBeInTheDocument();
expect(screen.getByText('plot:scatter')).toBeInTheDocument();
});

it('shows the old label during the out phase of the random animation', () => {
renderChips({
randomAnimation: { index: 0, phase: 'out', oldLabel: 'lib:plotly' },
});
expect(screen.getByText('lib:plotly')).toBeInTheDocument();
expect(screen.queryByText('lib:matplotlib')).not.toBeInTheDocument();
});

it('shows the new label during the in phase of the random animation', () => {
renderChips({
randomAnimation: { index: 0, phase: 'in', oldLabel: 'lib:plotly' },
});
expect(screen.getByText('lib:matplotlib')).toBeInTheDocument();
});

it('calls onChipClick with the group index when a chip is clicked', () => {
renderChips();
fireEvent.click(screen.getByText('lib:matplotlib'));
expect(callbacks.onChipClick).toHaveBeenCalledWith(expect.anything(), 0);
});

it('calls onRemoveGroup when the chip delete icon is clicked', () => {
renderChips();
const deleteIcon = document.querySelector('.MuiChip-deleteIcon');
expect(deleteIcon).toBeTruthy();
fireEvent.click(deleteIcon as Element);
expect(callbacks.onRemoveGroup).toHaveBeenCalledWith(0);
});

describe('chip action menu', () => {
const menuProps: Partial<FilterChipsProps> = {
activeFilters: [{ category: 'lib', values: ['matplotlib', 'seaborn'] }],
orCounts: [{ plotly: 20 }],
currentTotal: 100,
chipMenuAnchor: document.createElement('div'),
activeGroupIndex: 0,
};

beforeEach(() => {
document.body.appendChild(menuProps.chipMenuAnchor as HTMLElement);
});

it('offers OR additions with preview counts', () => {
renderChips(menuProps);
expect(screen.getByText('add (or)')).toBeInTheDocument();
expect(screen.getByText('plotly')).toBeInTheDocument();
expect(screen.getByText('(120)')).toBeInTheDocument(); // currentTotal + orCount

fireEvent.click(screen.getByText('plotly'));
expect(callbacks.onAddValueToActiveGroup).toHaveBeenCalledWith('plotly');
});

it('offers removing individual values', () => {
renderChips(menuProps);
fireEvent.click(screen.getByText('matplotlib'));
expect(callbacks.onRemoveValue).toHaveBeenCalledWith('matplotlib');
});

it('offers remove all only for multi-value groups', () => {
renderChips(menuProps);
fireEvent.click(screen.getByText('remove all'));
expect(callbacks.onRemoveActiveGroup).toHaveBeenCalled();
});

it('hides remove all for single-value groups', () => {
renderChips({
...menuProps,
activeFilters: [{ category: 'lib', values: ['matplotlib'] }],
orCounts: [{}],
});
expect(screen.queryByText('remove all')).not.toBeInTheDocument();
});
});
});
190 changes: 190 additions & 0 deletions app/src/sections/plots-gallery/FilterBar/FilterChips.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';

import { colors, fontSize, semanticColors, typography } from 'src/theme';
import type { ActiveFilters } from 'src/types';
import { getAvailableValuesForGroup } from 'src/utils';

/** Random-button chip animation state (owned by PlotsPage). */
export interface RandomAnimation {
index: number;
phase: 'out' | 'in';
oldLabel?: string;
}

export interface FilterChipsProps {
activeFilters: ActiveFilters;
randomAnimation: RandomAnimation | null;
orCounts: Record<string, number>[]; // Per-group counts for OR additions
currentTotal: number; // Total number of filtered images
chipMenuAnchor: HTMLElement | null;
activeGroupIndex: number | null;
onChipClick: (event: React.MouseEvent<HTMLElement>, groupIndex: number) => void;
onChipMenuClose: () => void;
onRemoveValue: (value: string) => void; // Remove one value from the active group
onRemoveActiveGroup: () => void; // Remove the entire active group (via chip menu)
onAddValueToActiveGroup: (value: string) => void; // Add value to active group (OR)
onRemoveGroup: (groupIndex: number) => void; // Remove a group directly (chip delete icon)
}

/**
* Active-filter chips with roll animation plus the per-chip action menu
* (add OR value, remove single value, remove all).
*/
export function FilterChips({
activeFilters,
randomAnimation,
orCounts,
currentTotal,
chipMenuAnchor,
activeGroupIndex,
onChipClick,
onChipMenuClose,
onRemoveValue,
onRemoveActiveGroup,
onAddValueToActiveGroup,
onRemoveGroup,
}: FilterChipsProps) {
// Get active group for chip menu
const activeGroup = activeGroupIndex !== null ? activeFilters[activeGroupIndex] : null;
const availableValuesForActiveGroup =
activeGroupIndex !== null
? getAvailableValuesForGroup(activeGroupIndex, activeFilters, orCounts, currentTotal)
: [];

return (
<>
{/* Active filter chips */}
{activeFilters.map((group, index) => {
const isAnimating = randomAnimation?.index === index;
const animationClass = isAnimating ? `chip-blur-${randomAnimation.phase}` : undefined;
// Show old label during 'out' phase, new label during 'in' phase
const displayLabel =
isAnimating && randomAnimation.phase === 'out' && randomAnimation.oldLabel
? randomAnimation.oldLabel
: `${group.category}:${group.values.join(',')}`;

return (
<Chip
key={`${group.category}-${index}`}
label={displayLabel}
onClick={e => onChipClick(e, index)}
onDelete={() => onRemoveGroup(index)}
deleteIcon={<CloseIcon sx={{ fontSize: '1rem !important' }} />}
sx={{
fontFamily: typography.fontFamily,
fontSize: fontSize.base,
height: 32,
bgcolor: 'var(--bg-surface)',
border: `1px solid ${colors.primary}`,
color: 'var(--ink-soft)',
cursor: 'pointer',
'&:hover': { bgcolor: 'var(--bg-elevated)' },
'& .MuiChip-deleteIcon': {
color: 'var(--ink-muted)',
'&:hover': { color: colors.primary },
},
...(animationClass === 'chip-blur-out' && {
animation: 'chip-roll-out 0.5s ease-in forwards',
}),
...(animationClass === 'chip-blur-in' && {
animation: 'chip-roll-in 0.5s ease-out forwards',
}),
'@keyframes chip-roll-out': {
'0%': { transform: 'perspective(200px) rotateX(0deg)' },
'100%': { transform: 'perspective(200px) rotateX(180deg)' },
},
'@keyframes chip-roll-in': {
'0%': { transform: 'perspective(200px) rotateX(180deg)' },
'100%': { transform: 'perspective(200px) rotateX(360deg)' },
},
}}
/>
);
})}

{/* Chip action menu */}
<Menu
anchorEl={chipMenuAnchor}
open={Boolean(chipMenuAnchor)}
onClose={onChipMenuClose}
slotProps={{
paper: {
sx: {
minWidth: 180,
maxHeight: 350,
},
},
}}
>
{activeGroup && [
// Add value (OR) - submenu with available values
...(availableValuesForActiveGroup.length > 0
? [
<Typography
key="add-or-header"
sx={{
px: 2,
py: 0.5,
fontSize: fontSize.xs,
color: semanticColors.mutedText,
fontFamily: typography.fontFamily,
textTransform: 'uppercase',
}}
>
add (or)
</Typography>,
...availableValuesForActiveGroup.map(([value, count]) => (
<MenuItem
key={`add-${value}`}
onClick={() => onAddValueToActiveGroup(value)}
sx={{ fontFamily: typography.fontFamily, py: 0.5 }}
>
<AddIcon
fontSize="small"
sx={{ mr: 1, color: colors.success, fontSize: '1rem' }}
/>
<Typography sx={{ fontSize: fontSize.base, flex: 1 }}>{value}</Typography>
<Typography sx={{ fontSize: fontSize.sm, color: semanticColors.mutedText }}>
({count})
</Typography>
</MenuItem>
)),
<Divider key="divider-add" />,
]
: []),
// Remove individual values
...activeGroup.values.map(value => (
<MenuItem
key={`remove-${value}`}
onClick={() => onRemoveValue(value)}
sx={{ fontFamily: typography.fontFamily }}
>
<CloseIcon fontSize="small" sx={{ mr: 1, color: colors.error }} />
{value}
</MenuItem>
)),
// Remove all (only if more than 1 value)
...(activeGroup.values.length > 1
? [
<Divider key="divider-remove" />,
<MenuItem
key="remove-all"
onClick={onRemoveActiveGroup}
sx={{ fontFamily: typography.fontFamily, color: colors.error }}
>
<CloseIcon fontSize="small" sx={{ mr: 1 }} />
remove all
</MenuItem>,
]
: []),
]}
</Menu>
</>
);
}
Loading
Loading