Merge pull request #804 from PicPeak/fix/gallery-feedback-filter-chips

fix(gallery): show feedback filter chips on desktop for galleries without categories
This commit is contained in:
Paul Nothaft
2026-07-15 23:02:48 +02:00
committed by GitHub
2 changed files with 112 additions and 31 deletions
@@ -154,39 +154,45 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
{/* Category and Feedback Filters */} {/* Category and Feedback Filters */}
<div className="space-y-3"> <div className="space-y-3">
{/* Categories Row */} {/* Categories + desktop feedback row. Rendered whenever EITHER part
{categories && categories.length > 0 && ( has content: the desktop feedback chips must not depend on the
(optional) categories existing, or category-less galleries show
no feedback filter at all on desktop (#802 — the lg:hidden
fallback block below only covers mobile/tablet). */}
{((categories && categories.length > 0) || (feedbackEnabled && !!onFilterChange)) && (
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3"> <div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
{/* Categories: keep in a horizontal scroll container */} {/* Categories: keep in a horizontal scroll container */}
<div className="w-full overflow-x-auto pb-2 lg:pb-0"> {categories && categories.length > 0 && (
<div className="flex items-center gap-2 min-w-max"> <div className="w-full overflow-x-auto pb-2 lg:pb-0">
<Button <div className="flex items-center gap-2 min-w-max">
variant={selectedCategoryId === null ? 'primary' : 'outline'} <Button
size="sm" variant={selectedCategoryId === null ? 'primary' : 'outline'}
onClick={() => onCategoryChange(null)} size="sm"
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />} onClick={() => onCategoryChange(null)}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0" leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
> className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length}) >
</Button> {showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
{categories.map((category) => { </Button>
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length; {categories.map((category) => {
if (categoryPhotoCount === 0) return null; const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
if (categoryPhotoCount === 0) return null;
return ( return (
<Button <Button
key={category.id} key={category.id}
variant={selectedCategoryId === category.id ? 'primary' : 'outline'} variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
size="sm" size="sm"
onClick={() => onCategoryChange(category.id)} onClick={() => onCategoryChange(category.id)}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0" className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
> >
{category.name} ({categoryPhotoCount}) {category.name} ({categoryPhotoCount})
</Button> </Button>
); );
})} })}
</div>
</div> </div>
</div> )}
{/* Desktop: compact horizontal feedback filter with headline (icons only) */} {/* Desktop: compact horizontal feedback filter with headline (icons only) */}
{feedbackEnabled && onFilterChange && ( {feedbackEnabled && onFilterChange && (
@@ -244,7 +250,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
</div> </div>
)} )}
<p className="text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto"> {/* Without categories this row only carries desktop content (the
chips are lg-only; mobile has its own block below), so hide
the count below lg to keep the mobile layout unchanged. */}
<p className={`text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto ${categories && categories.length > 0 ? '' : 'hidden lg:block'}`}>
{photoCount} {t('common.media', 'media')} {photoCount} {t('common.media', 'media')}
</p> </p>
</div> </div>
@@ -0,0 +1,72 @@
/**
* Regression coverage for #802: the desktop feedback-filter chips
* (All / Likes / Saved / Rated / Commented) were nested inside the
* categories row, so a gallery WITHOUT photo categories (the default)
* rendered no feedback filter at all on desktop — the standalone
* fallback block is lg:hidden (mobile/tablet only). These tests pin
* that both chip groups exist in the DOM regardless of categories.
*/
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PhotoFilterBar } from '../PhotoFilterBar';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: unknown) =>
typeof fallback === 'string' ? fallback : _key,
i18n: { language: 'en' }
})
};
});
const baseProps = {
categories: [] as Array<{ id: number; name: string; slug: string }>,
photos: [] as never[],
selectedCategoryId: null,
onCategoryChange: vi.fn(),
searchTerm: '',
onSearchChange: vi.fn(),
sortBy: 'date' as const,
onSortChange: vi.fn(),
photoCount: 0,
};
describe('PhotoFilterBar feedback chips (#802)', () => {
it('renders both chip groups (desktop lg:flex + mobile lg:hidden) with NO categories', () => {
render(
<PhotoFilterBar
{...baseProps}
feedbackEnabled
currentFilter="all"
onFilterChange={vi.fn()}
/>
);
// Two groups: the desktop row variant and the mobile fallback. Before
// the fix, only the mobile one rendered when categories were empty,
// leaving desktop with no feedback filter at all.
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
});
it('still renders both chip groups when categories exist', () => {
render(
<PhotoFilterBar
{...baseProps}
categories={[{ id: 1, name: 'Ceremony', slug: 'ceremony' }]}
photos={[{ id: 1, category_id: 1 } as never]}
feedbackEnabled
currentFilter="all"
onFilterChange={vi.fn()}
/>
);
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
});
it('renders no chips when feedback is disabled', () => {
render(<PhotoFilterBar {...baseProps} />);
expect(screen.queryByText('Feedback Filter')).toBeNull();
});
});