diff --git a/src/ops-console/pages/CampaignAssignmentTab.css b/src/ops-console/pages/CampaignAssignmentTab.css index ed814ac88..36fe15b29 100644 --- a/src/ops-console/pages/CampaignAssignmentTab.css +++ b/src/ops-console/pages/CampaignAssignmentTab.css @@ -93,6 +93,16 @@ padding-bottom: 0.375rem; } +.campaign-assignment-menu + .MuiMenuItem-root.campaign-assignment-menu-item-selected { + background-color: rgba(25, 118, 210, 0.08); +} + +.campaign-assignment-menu + .MuiMenuItem-root.campaign-assignment-menu-item-selected:hover { + background-color: rgba(25, 118, 210, 0.12); +} + .campaign-assignment-filterField .campaign-assignment-filterLabel { white-space: nowrap; } diff --git a/src/ops-console/pages/CampaignAssignmentTab.test.tsx b/src/ops-console/pages/CampaignAssignmentTab.test.tsx index dbe831664..22b3c9e92 100644 --- a/src/ops-console/pages/CampaignAssignmentTab.test.tsx +++ b/src/ops-console/pages/CampaignAssignmentTab.test.tsx @@ -71,8 +71,8 @@ describe('CampaignAssignmentTab', () => { ]; const defaultSubjects = [ - { id: '11', name: 'Math' }, - { id: '12', name: 'Science' }, + { id: '11', name: 'Math', gradeIds: ['1'] }, + { id: '12', name: 'Science', gradeIds: ['2'] }, ]; const defaultAssignments = [ @@ -104,7 +104,11 @@ describe('CampaignAssignmentTab', () => { subjectName: string; lessonName: string; }>; - uniqueSubjects?: Array<{ id: string; name: string }>; + uniqueSubjects?: Array<{ + id: string; + name: string; + gradeIds: string[]; + }>; total?: number; gradeError?: Error | null; assignmentError?: Error | null; @@ -277,6 +281,21 @@ describe('CampaignAssignmentTab', () => { expect(await screen.findByText('All grades')).toBeInTheDocument(); }); + it('shows only campaign grades from getAllGrades by default', async () => { + primeApi({ + grades: [...defaultGrades, { id: '3', name: 'Grade 3' }], + }); + + renderTab('campaign-1'); + + const [gradeSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(gradeSelect); + + expect(await screen.findByText('Grade 1')).toBeInTheDocument(); + expect(screen.getByText('Grade 2')).toBeInTheDocument(); + expect(screen.queryByText('Grade 3')).not.toBeInTheDocument(); + }); + it('renders the default subject filter value', async () => { primeApi(); @@ -285,6 +304,90 @@ describe('CampaignAssignmentTab', () => { expect(await screen.findByText('All subjects')).toBeInTheDocument(); }); + it('shows every campaign subject as selected when the subject menu opens', async () => { + primeApi(); + + renderTab('campaign-1'); + + const [, subjectSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(subjectSelect); + + expect(await screen.findByText('Math')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')).toHaveLength(2); + screen + .getAllByRole('checkbox') + .forEach((checkbox) => expect(checkbox).toBeChecked()); + + fireEvent.click(screen.getAllByRole('checkbox')[0]); + + await waitFor(() => + expect(screen.getAllByRole('checkbox')[0]).not.toBeChecked(), + ); + expect(screen.getAllByRole('checkbox')[1]).toBeChecked(); + expect(api.getCampaignAssignments).toHaveBeenLastCalledWith('campaign-1', { + page: 1, + pageSize: 20, + subjectIds: ['12'], + }); + }); + + it('keeps every campaign subject visible when grades are selected', async () => { + primeApi(); + + renderTab('campaign-1'); + + const [gradeSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(gradeSelect); + fireEvent.click(screen.getAllByRole('checkbox')[1]); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' }); + + await waitFor(() => + expect(api.getCampaignAssignments).toHaveBeenLastCalledWith( + 'campaign-1', + { + page: 1, + pageSize: 20, + gradeIds: ['1'], + }, + ), + ); + + const [, subjectSelect] = screen.getAllByRole('combobox'); + fireEvent.mouseDown(subjectSelect); + + expect(await screen.findByText('Math')).toBeInTheDocument(); + expect(screen.getByText('Science')).toBeInTheDocument(); + }); + + it('keeps every campaign grade visible when subjects are selected', async () => { + primeApi(); + + renderTab('campaign-1'); + + const [, subjectSelect] = await screen.findAllByRole('combobox'); + fireEvent.mouseDown(subjectSelect); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + fireEvent.keyDown(screen.getByRole('listbox'), { key: 'Escape' }); + + await waitFor(() => + expect(api.getCampaignAssignments).toHaveBeenLastCalledWith( + 'campaign-1', + { + page: 1, + pageSize: 20, + subjectIds: ['12'], + }, + ), + ); + + const [gradeSelect] = screen.getAllByRole('combobox'); + fireEvent.mouseDown(gradeSelect); + + expect(await screen.findByText('Grade 1')).toBeInTheDocument(); + expect(screen.getByText('Grade 2')).toBeInTheDocument(); + }); + it('renders both filter comboboxes', async () => { primeApi(); diff --git a/src/ops-console/pages/CampaignAssignmentTab.tsx b/src/ops-console/pages/CampaignAssignmentTab.tsx index 8ce704490..8f3e000d1 100644 --- a/src/ops-console/pages/CampaignAssignmentTab.tsx +++ b/src/ops-console/pages/CampaignAssignmentTab.tsx @@ -19,6 +19,7 @@ import DataTableBody, { type Column } from '../components/DataTableBody'; import DataTablePagination from '../components/DataTablePagination'; import type { CampaignAssignmentSummaryRow, + CampaignAssignmentUniqueSubject, CampaignOption, } from '../../services/api/ServiceApi'; @@ -49,7 +50,9 @@ const CampaignAssignmentTab: React.FC = ({ const isMediumScreen = viewportWidth > 600 && viewportWidth <= 900; const [grades, setGrades] = useState([]); - const [subjects, setSubjects] = useState([]); + const [subjects, setSubjects] = useState( + [], + ); const [assignments, setAssignments] = useState< CampaignAssignmentSummaryRow[] >([]); @@ -99,6 +102,13 @@ const CampaignAssignmentTab: React.FC = ({ }; }, [api, campaignId]); + useEffect(() => { + setSelectedGrades([]); + setSelectedSubjects([]); + setSubjects([]); + setPage(1); + }, [campaignId]); + useEffect(() => { let cancelled = false; @@ -122,13 +132,14 @@ const CampaignAssignmentTab: React.FC = ({ if (cancelled) return; setAssignments(response.assignments ?? []); - setSubjects(response.uniqueSubjects ?? []); + if (selectedGrades.length === 0 && selectedSubjects.length === 0) { + setSubjects(response.uniqueSubjects ?? []); + } setTotal(response.total ?? 0); } catch (err) { logger.error('Failed to load campaign assignments:', err); if (!cancelled) { setAssignments([]); - setSubjects([]); setTotal(0); } } finally { @@ -162,6 +173,51 @@ const CampaignAssignmentTab: React.FC = ({ [subjects], ); + const availableSubjects = subjects; + + const availableGrades = useMemo(() => { + const relevantGradeIds = new Set( + subjects.flatMap((subject) => subject.gradeIds), + ); + + return relevantGradeIds.size === 0 + ? grades + : grades.filter((grade) => relevantGradeIds.has(grade.id)); + }, [grades, subjects]); + + useEffect(() => { + const availableGradeIds = new Set(availableGrades.map((grade) => grade.id)); + setSelectedGrades((current) => { + if (current.length === 0) return current; + const next = current.filter((id) => availableGradeIds.has(id)); + return next.length === current.length ? current : next; + }); + }, [availableGrades]); + + useEffect(() => { + const availableSubjectIds = new Set( + availableSubjects.map((subject) => subject.id), + ); + setSelectedSubjects((current) => { + if (current.length === 0) return current; + const next = current.filter((id) => availableSubjectIds.has(id)); + return next.length === current.length ? current : next; + }); + }, [availableSubjects]); + + const gradeOptionIds = useMemo( + () => availableGrades.map((grade) => grade.id), + [availableGrades], + ); + const subjectOptionIds = useMemo( + () => availableSubjects.map((subject) => subject.id), + [availableSubjects], + ); + const gradeSelectValues = + selectedGrades.length === 0 ? gradeOptionIds : selectedGrades; + const subjectSelectValues = + selectedSubjects.length === 0 ? subjectOptionIds : selectedSubjects; + const dateColumnWidth = isSmallScreen ? 180 : isMediumScreen ? 240 : 280; const gradeColumnWidth = isSmallScreen ? 100 : 140; const subjectColumnWidth = isSmallScreen ? 120 : 180; @@ -218,10 +274,16 @@ const CampaignAssignmentTab: React.FC = ({ const selectedSubjectLabel = (id: string) => subjectNameById.get(id) ?? id; const handleMultiSelectChange = - (setter: React.Dispatch>) => + ( + options: CampaignOption[], + setter: React.Dispatch>, + ) => (event: SelectChangeEvent) => { const value = event.target.value; - setter(typeof value === 'string' ? value.split(',') : value); + const nextValues = typeof value === 'string' ? value.split(',') : value; + const optionIds = options.map((option) => option.id); + + setter(nextValues.length === optionIds.length ? [] : nextValues); setPage(1); }; @@ -245,12 +307,20 @@ const CampaignAssignmentTab: React.FC = ({ } renderValue={(selected) => { const values = selected as string[]; - if (values.length === 0) return t('All subjects'); + if ( + values.length === 0 || + values.length === subjectOptionIds.length + ) { + return t('All subjects'); + } return values.map(selectedSubjectLabel).join(', '); }} className="campaign-assignment-select" @@ -296,9 +388,23 @@ const CampaignAssignmentTab: React.FC = ({ PaperProps: { className: 'campaign-assignment-menu' }, }} > - {subjects.map((subject) => ( - - + {availableSubjects.map((subject) => ( + + ))} diff --git a/src/services/api/ServiceApi.ts b/src/services/api/ServiceApi.ts index 90d826810..5135f26f3 100644 --- a/src/services/api/ServiceApi.ts +++ b/src/services/api/ServiceApi.ts @@ -460,7 +460,9 @@ export type CampaignAssignmentSummaryRow = { lessonName: string; }; -export type CampaignAssignmentUniqueSubject = CampaignOption; +export type CampaignAssignmentUniqueSubject = CampaignOption & { + gradeIds: string[]; +}; export type CampaignAssignmentsResponse = { assignments: CampaignAssignmentSummaryRow[]; diff --git a/src/services/api/SupabaseApi.ts b/src/services/api/SupabaseApi.ts index ce9726afa..797ed6173 100644 --- a/src/services/api/SupabaseApi.ts +++ b/src/services/api/SupabaseApi.ts @@ -10866,6 +10866,7 @@ export class SupabaseApi implements ServiceApi { unique_subjects?: Array<{ subject_id: string; subject_name: string; + grade_ids?: string[] | null; }> | null; total_count: string | number; }; @@ -10895,6 +10896,9 @@ export class SupabaseApi implements ServiceApi { ? firstRow.unique_subjects.map((subject) => ({ id: String(subject.subject_id), name: String(subject.subject_name), + gradeIds: Array.isArray(subject.grade_ids) + ? subject.grade_ids.map(String) + : [], })) : [];