feat(frontend): add SubjectSidebar visibility panel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';
|
||||||
|
|
||||||
|
// Presentational panel listing subjects grouped by their group, with per-subject
|
||||||
|
// and per-group visibility checkboxes. All state lives in the parent; this component
|
||||||
|
// only renders and calls handlers.
|
||||||
|
export function SubjectSidebar({
|
||||||
|
lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
|
||||||
|
}) {
|
||||||
|
const groups = groupLines(lines);
|
||||||
|
const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[210px] shrink-0 border-l border-gray-100 pl-3">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Subjects</span>
|
||||||
|
{anyHidden && (
|
||||||
|
<button type="button" onClick={onShowAll} aria-label="Show all subjects"
|
||||||
|
className="text-xs text-indigo-500 hover:text-indigo-700">Show all</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 max-h-[320px] overflow-y-auto pr-1">
|
||||||
|
{groups.map(({ group, subjects }) => {
|
||||||
|
const ids = subjects.map((s) => s.id);
|
||||||
|
const state = groupVisibilityState(ids, hiddenSubjects);
|
||||||
|
return (
|
||||||
|
<div key={group}>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs font-medium text-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={state === 'all'}
|
||||||
|
ref={(el) => { if (el) el.indeterminate = state === 'some'; }}
|
||||||
|
onChange={() => onToggleGroup(ids, state === 'all')}
|
||||||
|
className="accent-indigo-500" />
|
||||||
|
<span className="truncate">{group}</span>
|
||||||
|
<span className="text-gray-400">({subjects.length})</span>
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 space-y-0.5 pl-1">
|
||||||
|
{subjects.map((s) => (
|
||||||
|
<label key={s.id}
|
||||||
|
onMouseEnter={() => onHighlight(s.id)}
|
||||||
|
onMouseLeave={() => onHighlight(null)}
|
||||||
|
className="flex items-center gap-1.5 text-xs text-gray-600 cursor-pointer hover:bg-gray-50 rounded px-1 py-0.5">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={!hiddenSubjects.has(s.id)}
|
||||||
|
onChange={() => onToggleSubject(s.id)}
|
||||||
|
className="accent-indigo-500" />
|
||||||
|
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: s.color }} />
|
||||||
|
<span className="truncate">{s.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||||
|
import { SubjectSidebar } from '../src/components/SubjectSidebar';
|
||||||
|
|
||||||
|
const LINES = [
|
||||||
|
{ id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
|
||||||
|
{ id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
|
||||||
|
{ id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function setup(overrides = {}) {
|
||||||
|
const props = {
|
||||||
|
lines: LINES,
|
||||||
|
hiddenSubjects: new Set(),
|
||||||
|
onToggleSubject: jest.fn(),
|
||||||
|
onToggleGroup: jest.fn(),
|
||||||
|
onShowAll: jest.fn(),
|
||||||
|
onHighlight: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
render(<SubjectSidebar {...props} />);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders each group header with its subject count', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.getByText('Group A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Group B')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('(2)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('(1)')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a subject checkbox per subject, checked when visible', () => {
|
||||||
|
setup({ hiddenSubjects: new Set(['a2']) });
|
||||||
|
expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
|
||||||
|
expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
|
||||||
|
const { onToggleSubject } = setup();
|
||||||
|
fireEvent.click(screen.getByLabelText('Mouse-A1'));
|
||||||
|
expect(onToggleSubject).toHaveBeenCalledWith('a1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the group checkbox as indeterminate when the group is mixed', () => {
|
||||||
|
setup({ hiddenSubjects: new Set(['a1']) });
|
||||||
|
const groupCheckbox = screen.getByLabelText(/Group A/);
|
||||||
|
expect(groupCheckbox.indeterminate).toBe(true);
|
||||||
|
expect(groupCheckbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks the group checkbox when all members are visible', () => {
|
||||||
|
setup();
|
||||||
|
expect(screen.getByLabelText(/Group A/).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
|
||||||
|
const { onToggleGroup } = setup();
|
||||||
|
fireEvent.click(screen.getByLabelText(/Group A/));
|
||||||
|
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the group checkbox unchecked and not indeterminate when all members are hidden', () => {
|
||||||
|
setup({ hiddenSubjects: new Set(['a1', 'a2']) });
|
||||||
|
const groupCheckbox = screen.getByLabelText(/Group A/);
|
||||||
|
expect(groupCheckbox.checked).toBe(false);
|
||||||
|
expect(groupCheckbox.indeterminate).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onToggleGroup with hidden=false when a fully-hidden group is clicked', () => {
|
||||||
|
const { onToggleGroup } = setup({ hiddenSubjects: new Set(['a1', 'a2']) });
|
||||||
|
fireEvent.click(screen.getByLabelText(/Group A/));
|
||||||
|
expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<SubjectSidebar lines={LINES} hiddenSubjects={new Set()}
|
||||||
|
onToggleSubject={() => {}} onToggleGroup={() => {}}
|
||||||
|
onShowAll={() => {}} onHighlight={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Show all')).toBeNull();
|
||||||
|
rerender(
|
||||||
|
<SubjectSidebar lines={LINES} hiddenSubjects={new Set(['a1'])}
|
||||||
|
onToggleSubject={() => {}} onToggleGroup={() => {}}
|
||||||
|
onShowAll={() => {}} onHighlight={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Show all')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onShowAll when the Show all link is clicked', () => {
|
||||||
|
const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
|
||||||
|
fireEvent.click(screen.getByText('Show all'));
|
||||||
|
expect(onShowAll).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onHighlight on subject row hover enter/leave', () => {
|
||||||
|
const { onHighlight } = setup();
|
||||||
|
const row = screen.getByLabelText('Mouse-A1').closest('label');
|
||||||
|
fireEvent.mouseEnter(row);
|
||||||
|
expect(onHighlight).toHaveBeenCalledWith('a1');
|
||||||
|
fireEvent.mouseLeave(row);
|
||||||
|
expect(onHighlight).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user