From 7b34c2fa52377e80340f53be2fdd0a7724de36ac Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Mon, 1 Jun 2026 09:30:51 -0400 Subject: [PATCH] feat(frontend): add SubjectSidebar visibility panel Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/SubjectSidebar.jsx | 59 ++++++++++++ frontend/tests/SubjectSidebar.test.jsx | 104 +++++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 frontend/src/components/SubjectSidebar.jsx create mode 100644 frontend/tests/SubjectSidebar.test.jsx diff --git a/frontend/src/components/SubjectSidebar.jsx b/frontend/src/components/SubjectSidebar.jsx new file mode 100644 index 0000000..2d836c5 --- /dev/null +++ b/frontend/src/components/SubjectSidebar.jsx @@ -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 ( +
+
+ Subjects + {anyHidden && ( + + )} +
+
+ {groups.map(({ group, subjects }) => { + const ids = subjects.map((s) => s.id); + const state = groupVisibilityState(ids, hiddenSubjects); + return ( +
+ +
+ {subjects.map((s) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/tests/SubjectSidebar.test.jsx b/frontend/tests/SubjectSidebar.test.jsx new file mode 100644 index 0000000..069e123 --- /dev/null +++ b/frontend/tests/SubjectSidebar.test.jsx @@ -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(); + 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( + {}} onToggleGroup={() => {}} + onShowAll={() => {}} onHighlight={() => {}} />, + ); + expect(screen.queryByText('Show all')).toBeNull(); + rerender( + {}} 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); +});