feat: Make it easier to handle multiple tags

- Tags can now be removed straight from the email header
- Tagging control now allows the user to (de)select multiple tags in one go
This commit is contained in:
Mathy Vanvoorden
2026-07-29 17:18:42 +02:00
parent 108406a885
commit d9d9f91a86
11 changed files with 221 additions and 108 deletions
@@ -0,0 +1,41 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { TagBadge } from '../tag-badge';
import { useSettingsStore, type KeywordDefinition } from '@/stores/settings-store';
const TAGS: KeywordDefinition[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'green' },
];
describe('TagBadge', () => {
beforeEach(() => {
useSettingsStore.setState({ emailKeywords: TAGS, nestedTags: true });
});
it('names the tag by its full path', () => {
render(<TagBadge tagId="work/clients" variant="badge" />);
expect(screen.getByText('Work/Clients')).toBeInTheDocument();
});
it('names a tag it has no definition for by its id', () => {
render(<TagBadge tagId="from-elsewhere" variant="badge" />);
expect(screen.getByText('from-elsewhere')).toBeInTheDocument();
});
it('offers removal only when asked to', () => {
const onRemove = vi.fn();
const { rerender } = render(<TagBadge tagId="work" variant="badge" />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<TagBadge tagId="work" variant="badge" onRemove={onRemove} />);
fireEvent.click(screen.getByRole('button', { name: 'remove_tag' }));
expect(onRemove).toHaveBeenCalledOnce();
});
it('leaves the dot alone, having nowhere to put the control', () => {
render(<TagBadge tagId="work" variant="dot" onRemove={() => {}} />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
expect(screen.getByLabelText('Work')).toBeInTheDocument();
});
});
+21 -5
View File
@@ -53,12 +53,28 @@ describe('TagPicker', () => {
expect(onToggle).toHaveBeenCalledWith('work/clients');
});
it('offers the clear-all row only while something is applied', () => {
const { rerender } = render(<TagPicker selectedIds={[]} onToggle={() => {}} onClearAll={() => {}} />);
expect(screen.queryByText('remove_tag')).not.toBeInTheDocument();
it('lists a tag it has no definition for, so it can be taken off', () => {
const onToggle = vi.fn();
const { rerender } = render(<TagPicker selectedIds={['from-elsewhere']} onToggle={onToggle} />);
rerender(<TagPicker selectedIds={['work']} onToggle={() => {}} onClearAll={() => {}} />);
expect(screen.getByText('remove_tag')).toBeInTheDocument();
const row = screen.getByText('from-elsewhere').closest('button')!;
expect(row).toHaveAttribute('aria-checked', 'true');
fireEvent.click(row);
expect(onToggle).toHaveBeenCalledWith('from-elsewhere');
// Nothing but the message says it exists, so deselecting is the last of it.
rerender(<TagPicker selectedIds={[]} onToggle={onToggle} />);
expect(screen.queryByText('from-elsewhere')).not.toBeInTheDocument();
});
it('counts undefined tags towards the filter box, and matches them', () => {
const strays = Array.from({ length: 8 }, (_, i) => `stray-${i}`);
const { container } = render(<TagPicker selectedIds={strays} onToggle={() => {}} />);
fireEvent.change(screen.getByLabelText('tag_filter_placeholder'), { target: { value: 'stray-3' } });
expect(within(container).getByText('stray-3')).toBeInTheDocument();
expect(within(container).queryByText('Work')).not.toBeInTheDocument();
});
it('hides the filter box until the list is long enough to need one', () => {