Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/ops-console/pages/CampaignAssignmentTab.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
109 changes: 106 additions & 3 deletions src/ops-console/pages/CampaignAssignmentTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down
140 changes: 123 additions & 17 deletions src/ops-console/pages/CampaignAssignmentTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -49,7 +50,9 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
const isMediumScreen = viewportWidth > 600 && viewportWidth <= 900;

const [grades, setGrades] = useState<CampaignOption[]>([]);
const [subjects, setSubjects] = useState<CampaignOption[]>([]);
const [subjects, setSubjects] = useState<CampaignAssignmentUniqueSubject[]>(
[],
);
const [assignments, setAssignments] = useState<
CampaignAssignmentSummaryRow[]
>([]);
Expand Down Expand Up @@ -99,6 +102,13 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
};
}, [api, campaignId]);

useEffect(() => {
setSelectedGrades([]);
setSelectedSubjects([]);
setSubjects([]);
setPage(1);
}, [campaignId]);
Comment on lines +105 to +110

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

When campaignId changes, this useEffect resets the filter states (selectedGrades, selectedSubjects, subjects, page). However, because state updates are scheduled asynchronously, the loadAssignments effect (which also depends on campaignId) will immediately trigger a fetch using the new campaignId but the old filter states. This results in a redundant API call that is immediately cancelled once the state resets are processed and trigger a second render.

To prevent this redundant network request and potential race conditions, adjust the state during the render phase instead of using a useEffect hook.

  const [prevCampaignId, setPrevCampaignId] = useState(campaignId);
  if (campaignId !== prevCampaignId) {
    setPrevCampaignId(campaignId);
    setSelectedGrades([]);
    setSelectedSubjects([]);
    setSubjects([]);
    setPage(1);
  }


useEffect(() => {
let cancelled = false;

Expand All @@ -122,13 +132,14 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
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 {
Expand Down Expand Up @@ -162,6 +173,51 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
[subjects],
);

const availableSubjects = subjects;

const availableGrades = useMemo(() => {
const relevantGradeIds = new Set(
subjects.flatMap((subject) => subject.gradeIds),
);
Comment on lines +178 to +181

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To prevent potential runtime crashes if gradeIds is missing or undefined (e.g., due to incomplete test mocks or API anomalies), add a defensive fallback ?? [] when calling flatMap.

Suggested change
const availableGrades = useMemo(() => {
const relevantGradeIds = new Set(
subjects.flatMap((subject) => subject.gradeIds),
);
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;
Expand Down Expand Up @@ -218,10 +274,16 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
const selectedSubjectLabel = (id: string) => subjectNameById.get(id) ?? id;

const handleMultiSelectChange =
(setter: React.Dispatch<React.SetStateAction<string[]>>) =>
(
options: CampaignOption[],
setter: React.Dispatch<React.SetStateAction<string[]>>,
) =>
(event: SelectChangeEvent<string[]>) => {
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);
};

Expand All @@ -245,22 +307,44 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
<Select
multiple
displayEmpty
value={selectedGrades}
onChange={handleMultiSelectChange(setSelectedGrades)}
value={gradeSelectValues}
onChange={handleMultiSelectChange(
availableGrades,
setSelectedGrades,
)}
input={<OutlinedInput />}
renderValue={(selected) => {
const values = selected as string[];
if (values.length === 0) return t('All grades');
if (
values.length === 0 ||
values.length === gradeOptionIds.length
) {
return t('All grades');
}
return values.map(selectedGradeLabel).join(', ');
}}
className="campaign-assignment-select"
MenuProps={{
PaperProps: { className: 'campaign-assignment-menu' },
}}
>
{grades.map((grade) => (
<MenuItem key={grade.id} value={grade.id}>
<Checkbox checked={selectedGrades.includes(grade.id)} />
{availableGrades.map((grade) => (
<MenuItem
key={grade.id}
value={grade.id}
className={
selectedGrades.length === 0 ||
selectedGrades.includes(grade.id)
? 'campaign-assignment-menu-item-selected'
: undefined
}
>
<Checkbox
checked={
selectedGrades.length === 0 ||
selectedGrades.includes(grade.id)
}
/>
<ListItemText primary={grade.name} />
</MenuItem>
))}
Expand All @@ -283,22 +367,44 @@ const CampaignAssignmentTab: React.FC<CampaignAssignmentTabProps> = ({
<Select
multiple
displayEmpty
value={selectedSubjects}
onChange={handleMultiSelectChange(setSelectedSubjects)}
value={subjectSelectValues}
onChange={handleMultiSelectChange(
availableSubjects,
setSelectedSubjects,
)}
input={<OutlinedInput />}
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"
MenuProps={{
PaperProps: { className: 'campaign-assignment-menu' },
}}
>
{subjects.map((subject) => (
<MenuItem key={subject.id} value={subject.id}>
<Checkbox checked={selectedSubjects.includes(subject.id)} />
{availableSubjects.map((subject) => (
<MenuItem
key={subject.id}
value={subject.id}
className={
selectedSubjects.length === 0 ||
selectedSubjects.includes(subject.id)
? 'campaign-assignment-menu-item-selected'
: undefined
}
>
<Checkbox
checked={
selectedSubjects.length === 0 ||
selectedSubjects.includes(subject.id)
}
/>
<ListItemText primary={subject.name} />
</MenuItem>
))}
Expand Down
4 changes: 3 additions & 1 deletion src/services/api/ServiceApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,9 @@ export type CampaignAssignmentSummaryRow = {
lessonName: string;
};

export type CampaignAssignmentUniqueSubject = CampaignOption;
export type CampaignAssignmentUniqueSubject = CampaignOption & {
gradeIds: string[];
};

export type CampaignAssignmentsResponse = {
assignments: CampaignAssignmentSummaryRow[];
Expand Down
Loading
Loading