diff --git a/src/components/ProfileMenu/ProfileMenu.test.tsx b/src/components/ProfileMenu/ProfileMenu.test.tsx
index 6b4937c27f..e1a6c72aac 100644
--- a/src/components/ProfileMenu/ProfileMenu.test.tsx
+++ b/src/components/ProfileMenu/ProfileMenu.test.tsx
@@ -7,11 +7,14 @@ import { Util } from '../../utility/util';
import { useAppSelector } from '../../redux/hooks';
import { MemoryRouter } from 'react-router-dom';
import {
+ CURRENT_MODE,
EVENTS,
+ MODES,
PAGES,
STICKER_BOOK_NOTIFICATION_DOT_ENABLED,
ENABLE_STICKER_BOOK,
} from '../../common/constants';
+import { schoolUtil } from '../../utility/schoolUtil';
// --- Mocks ---
@@ -36,15 +39,22 @@ jest.mock('../../i18n', () => ({
}));
const mockPush = jest.fn();
+const mockReplace = jest.fn();
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useHistory: () => ({
push: mockPush,
- replace: jest.fn(),
+ replace: mockReplace,
location: { pathname: '/' },
}),
}));
+jest.mock('../../utility/schoolUtil', () => ({
+ schoolUtil: {
+ setCurrentClass: jest.fn(),
+ },
+}));
+
// Mock useGbContext
jest.mock('../../growthbook/Growthbook', () => ({
useGbContext: () => ({ setGbUpdated: jest.fn() }),
@@ -60,10 +70,12 @@ const mockApi = {
};
const mockStudent = { id: 'student-123', name: 'Test Student' };
+const mockSetCurrentClass = schoolUtil.setCurrentClass as jest.Mock;
describe('ProfileMenu Notification Logic', () => {
beforeEach(() => {
jest.clearAllMocks();
+ localStorage.clear();
mockApi.getUserStickerBook.mockReset();
mockApi.getUserStickerBook.mockResolvedValue([]);
mockApi.markStciekercolledasTrue.mockReset();
@@ -191,4 +203,30 @@ describe('ProfileMenu Notification Logic', () => {
expect.any(Object),
);
});
+
+ test('keeps school-mode switch profile on SelectMode selection flow', async () => {
+ localStorage.setItem(CURRENT_MODE, MODES.TEACHER_SCHOOL);
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => expect(mockApi.getUserStickerBook).toHaveBeenCalled());
+
+ const switchProfileItem = screen
+ .getByText(/Switch Profile/i)
+ .closest('.profile-menu-item');
+ fireEvent.click(switchProfileItem!);
+
+ await waitFor(() => {
+ expect(Util.setCurrentStudent).toHaveBeenCalledWith(null);
+ });
+ expect(mockSetCurrentClass).not.toHaveBeenCalled();
+ expect(mockReplace).toHaveBeenCalledWith(PAGES.SELECT_MODE, {
+ from: '/',
+ fromSchoolModeSwitchProfile: true,
+ });
+ });
});
diff --git a/src/components/ProfileMenu/ProfileMenu.tsx b/src/components/ProfileMenu/ProfileMenu.tsx
index ee1add4a3a..c209da4a59 100644
--- a/src/components/ProfileMenu/ProfileMenu.tsx
+++ b/src/components/ProfileMenu/ProfileMenu.tsx
@@ -177,6 +177,7 @@ const ProfileMenu = ({ onClose }: ProfileMenuProps) => {
setGbUpdated(true);
history.replace(PAGES.SELECT_MODE, {
from: history.location.pathname,
+ fromSchoolModeSwitchProfile: true,
});
};
diff --git a/src/ops-console/components/DataTableBody.css b/src/ops-console/components/DataTableBody.css
index 62fbdb7dff..622ec4f805 100644
--- a/src/ops-console/components/DataTableBody.css
+++ b/src/ops-console/components/DataTableBody.css
@@ -6,7 +6,8 @@
scrollbar-gutter: stable;
margin-bottom: 8px;
-webkit-overflow-scrolling: touch;
- touch-action: pan-x pan-y;
+ overscroll-behavior: contain;
+ touch-action: pan-x pan-y pinch-zoom;
}
.data-tablebody-head-cell {
diff --git a/src/ops-console/components/ProgramListTable.tsx b/src/ops-console/components/ProgramListTable.tsx
index 4edeb794fe..34b0f50974 100644
--- a/src/ops-console/components/ProgramListTable.tsx
+++ b/src/ops-console/components/ProgramListTable.tsx
@@ -40,6 +40,7 @@ type ProgramListTableProps = {
isExportDisabled: boolean;
isExporting: boolean;
onExport: () => void | Promise;
+ canCreateProgram: boolean;
onNewProgram: () => void;
columns: Column[];
rows: ProgramListRow[];
@@ -88,7 +89,9 @@ const ProgramListControls: React.FC = (props) => (
isExporting={props.isExporting}
onClick={props.onExport}
/>
-
+ {props.canCreateProgram ? (
+
+ ) : null}
{
).not.toBeInTheDocument();
});
+ it('revokes message editing immediately when the campaign is cancelled', async () => {
+ apiHandler.getCampaignMessaging.mockResolvedValue(
+ buildResponse([buildMessagingRow()]),
+ );
+
+ const { rerender } = render(
+ ,
+ );
+
+ await screen.findByText('Class 1 Digital');
+ fireEvent.click(screen.getByLabelText('Edit global send schedule'));
+ expect(
+ screen.getByRole('button', { name: 'Save Changes' }),
+ ).toBeInTheDocument();
+
+ rerender();
+
+ await waitFor(() =>
+ expect(
+ screen.queryByLabelText('Edit global send schedule'),
+ ).not.toBeInTheDocument(),
+ );
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('button', { name: 'Save Changes' }),
+ ).not.toBeInTheDocument(),
+ );
+ expect(apiHandler.updateCampaignMessaging).not.toHaveBeenCalled();
+ });
+
it('closes the message time picker after selecting a time value', async () => {
apiHandler.getCampaignMessaging.mockResolvedValue(
buildResponse([buildMessagingRow()]),
diff --git a/src/ops-console/components/campaignMessages/CampaignMessages.tsx b/src/ops-console/components/campaignMessages/CampaignMessages.tsx
index 5950e03a75..c50949fe5f 100644
--- a/src/ops-console/components/campaignMessages/CampaignMessages.tsx
+++ b/src/ops-console/components/campaignMessages/CampaignMessages.tsx
@@ -8,24 +8,31 @@ import {
useCampaignMessagesController,
} from './CampaignMessagesLogic';
import type { CampaignMessagesScheduleType } from './CampaignMessagesLogic';
+import type { CampaignFrequency } from '../../../services/api/ServiceApi';
import './CampaignMessages.css';
interface CampaignMessagesProps {
campaignId?: string;
campaignStartDate?: string;
campaignEndDate?: string;
+ campaignFrequency?: CampaignFrequency;
+ isCampaignCancelled?: boolean;
}
const CampaignMessages: React.FC = ({
campaignId,
campaignStartDate,
campaignEndDate,
+ campaignFrequency,
+ isCampaignCancelled,
}) => {
const { t } = useTranslation();
const controller = useCampaignMessagesController({
campaignId,
campaignStartDate,
campaignEndDate,
+ campaignFrequency,
+ isCampaignCancelled,
translate: (key) => String(t(key)),
});
const timePickerWrapperRefs = useRef<
diff --git a/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts b/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts
index e70cfe7b1a..fdbca3e8ae 100644
--- a/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts
+++ b/src/ops-console/components/campaignMessages/CampaignMessagesLogic.ts
@@ -9,11 +9,16 @@ import {
} from 'react';
import { ServiceConfig } from '../../../services/ServiceConfig';
import {
+ CampaignFrequency,
CampaignMessagingQueryParams,
CampaignMessagingRow,
} from '../../../services/api/ServiceApi';
import { hasCampaignWriteAccess } from '../../../services/api/campaignListingHelpers';
import { Json } from '../../../services/database';
+import {
+ buildFrequencyTimelineDates,
+ DEFAULT_FREQUENCY,
+} from '../campaignSetup/campaignAssignmentUtils';
import { buildCampaignDurationTimelineDates } from '../campaignSetup/campaignCommunicationUtils';
import { useAppSelector } from '../../../redux/hooks';
import { AuthState } from '../../../redux/slices/auth/authSlice';
@@ -119,6 +124,8 @@ interface UseCampaignMessagesControllerParams {
campaignId?: string;
campaignStartDate?: string;
campaignEndDate?: string;
+ campaignFrequency?: CampaignFrequency;
+ isCampaignCancelled?: boolean;
translate: (key: string) => string;
}
@@ -680,12 +687,16 @@ export const useCampaignMessagesController = ({
campaignId,
campaignStartDate,
campaignEndDate,
+ campaignFrequency = DEFAULT_FREQUENCY,
+ isCampaignCancelled = false,
translate,
}: UseCampaignMessagesControllerParams): CampaignMessagesController => {
const { roles } = useAppSelector(
(state: RootState) => state.auth as AuthState,
);
- const canEdit = hasCampaignWriteAccess(roles || []);
+ // A cancelled campaign must become read-only even when the user's role
+ // normally grants campaign write access.
+ const canEdit = !isCampaignCancelled && hasCampaignWriteAccess(roles || []);
const [messagesData, setMessagesData] = useState(
() => emptyCampaignMessagesData,
);
@@ -712,6 +723,17 @@ export const useCampaignMessagesController = ({
Record
>({});
const timelineDates = useMemo(
+ () =>
+ campaignStartDate && campaignEndDate
+ ? buildFrequencyTimelineDates(
+ campaignStartDate,
+ campaignEndDate,
+ campaignFrequency,
+ )
+ : [],
+ [campaignEndDate, campaignFrequency, campaignStartDate],
+ );
+ const campaignRangeDates = useMemo(
() =>
campaignStartDate && campaignEndDate
? buildCampaignDurationTimelineDates(campaignStartDate, campaignEndDate)
@@ -751,8 +773,8 @@ export const useCampaignMessagesController = ({
// Fetch the full calendar range so persisted Sunday rows cannot displace
// later non-Sunday campaign dates from the paginated response.
const pageSize =
- timelineDates.length > 0
- ? timelineDates.length
+ campaignRangeDates.length > 0
+ ? campaignRangeDates.length
: CAMPAIGN_MESSAGES_PAGE_SIZE;
const loadedMessagesData = await loadCampaignMessagesData(
campaignId,
@@ -774,7 +796,7 @@ export const useCampaignMessagesController = ({
return () => {
isMounted = false;
};
- }, [campaignId, displayTimelineDates, timelineDates.length]);
+ }, [campaignId, campaignRangeDates.length, displayTimelineDates]);
useEffect(
() => () => {
@@ -1078,8 +1100,8 @@ export const useCampaignMessagesController = ({
{
page: 1,
pageSize:
- timelineDates.length > 0
- ? timelineDates.length
+ campaignRangeDates.length > 0
+ ? campaignRangeDates.length
: CAMPAIGN_MESSAGES_PAGE_SIZE,
},
);
diff --git a/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx b/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx
index cb5215883e..ad086086a1 100644
--- a/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx
+++ b/src/ops-console/components/campaignSetup/CampaignCommunicationTimelineStep.tsx
@@ -18,6 +18,7 @@ const CampaignCommunicationTimelineStep: React.FC<
CampaignCommunicationTimelineStepProps
> = ({
form,
+ frequency,
assignmentDrafts,
selectedSchoolIds,
communicationState,
@@ -31,8 +32,8 @@ const CampaignCommunicationTimelineStep: React.FC<
const { t } = useTranslation();
const timeOptions = useMemo(() => buildTimeOptions(), []);
const timelineDates = useMemo(
- () => buildCommunicationTimelineDates(assignmentDrafts, form),
- [assignmentDrafts, form],
+ () => buildCommunicationTimelineDates(assignmentDrafts, form, frequency),
+ [assignmentDrafts, form, frequency],
);
const { campaignReach, loadingReach } = useCampaignReach(selectedSchoolIds);
diff --git a/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx b/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx
index 71b23a8b1b..0db46bed49 100644
--- a/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx
+++ b/src/ops-console/components/campaignSetup/CampaignMultiSelect.tsx
@@ -1,7 +1,8 @@
-import React from 'react';
+import React, { useRef, useState } from 'react';
import {
Autocomplete,
Checkbox,
+ ClickAwayListener,
TextField,
useMediaQuery,
} from '@mui/material';
@@ -40,80 +41,99 @@ export const CampaignMultiSelect = ({
}: CampaignMultiSelectProps) => {
const isMobileView = useMediaQuery('(max-width:48rem)');
const shouldPreventKeyboard = preventMobileKeyboard && isMobileView;
+ const [open, setOpen] = useState(false);
+ const rootRef = useRef(null);
+ const handleClickAway = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node | null;
+ if (target && rootRef.current?.contains(target)) {
+ return;
+ }
+
+ setOpen(false);
+ };
return (
-
+
+
setOpen(true)}
+ onClose={() => setOpen(false)}
+ options={options}
+ value={value}
+ loading={loading}
+ getOptionLabel={getOptionLabel}
+ isOptionEqualToValue={isOptionEqualToValue}
+ slotProps={{
+ popper: {
+ placement: 'bottom-start',
+ modifiers: [
+ {
+ name: 'flip',
+ enabled: false,
+ },
+ {
+ name: 'preventOverflow',
+ options: {
+ altAxis: true,
+ padding: 8,
+ tether: true,
+ },
+ },
+ ],
},
- {
- name: 'preventOverflow',
- options: {
- altAxis: true,
- padding: 8,
- tether: true,
+ paper: {
+ sx: {
+ marginTop: 0.5,
},
},
- ],
- },
- paper: {
- sx: {
- marginTop: 0.5,
- },
- },
- }}
- ListboxProps={{
- style: {
- maxHeight: '16rem',
- overflowY: 'auto',
- },
- }}
- renderOption={(props, option, { selected }) => {
- const { key, ...optionProps } = props as AutocompleteOptionProps;
- const label = getOptionLabel ? getOptionLabel(option) : String(option);
- return (
-
-
- {label}
-
- );
- }}
- onChange={(_, nextValue) => onChange(nextValue)}
- renderTags={(selected) =>
- renderSelectedLabel
- ? renderSelectedLabel(selected)
- : CampaignCountPlaceholder(selected, placeholder)
- }
- renderInput={(params) => (
- {
+ const { key, ...optionProps } = props as AutocompleteOptionProps;
+ const label = getOptionLabel
+ ? getOptionLabel(option)
+ : String(option);
+ return (
+
+
+ {label}
+
+ );
+ }}
+ onChange={(_, nextValue) => onChange(nextValue)}
+ renderTags={(selected) =>
+ renderSelectedLabel
+ ? renderSelectedLabel(selected)
+ : CampaignCountPlaceholder(selected, placeholder)
+ }
+ renderInput={(params) => (
+
+ )}
/>
- )}
- />
+
+
);
};
diff --git a/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx b/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx
index 7b47c57c96..fbd15fd72c 100644
--- a/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx
+++ b/src/ops-console/components/campaignSetup/CampaignSetupActions.tsx
@@ -34,7 +34,9 @@ export const CampaignSetupActions: React.FC = ({
? !isFormValid || isSubmitting
: activeStep === 1
? !isAssignmentComplete
- : false;
+ : activeStep === 4
+ ? isSubmitting
+ : false;
const handleNextClick = () => {
if (activeStep === 0) {
diff --git a/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx b/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx
index 7fb6f3176a..056e3996c9 100644
--- a/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx
+++ b/src/ops-console/components/campaignSetup/TargetAudienceSection.tsx
@@ -21,6 +21,7 @@ export const TargetAudienceSection: React.FC = ({
savedGroups,
selectedSavedGroupId,
audienceOptions,
+ availableGrades,
selectedBlocks,
selectedSchools,
selectedGrades,
@@ -29,6 +30,7 @@ export const TargetAudienceSection: React.FC = ({
hasCustomGradeSelection,
schoolsForSelectedBlocks,
loadingAudience,
+ loadingGrades,
selectedProgramName,
summaryBlockCount,
summarySchoolCount,
@@ -55,6 +57,23 @@ export const TargetAudienceSection: React.FC = ({
() => new Map(programs.map((program) => [program.id, program.name])),
[programs],
);
+ const gradeSelectScopeKey = useMemo(() => {
+ const schoolKey =
+ selectedSchools.length === audienceOptions.schools.length
+ ? 'all-schools'
+ : selectedSchools
+ .map((school) => school.id)
+ .sort()
+ .join('|');
+ const gradeKey = availableGrades.map((grade) => grade.id).join('|');
+
+ return `${schoolKey}:${gradeKey}`;
+ }, [audienceOptions.schools.length, availableGrades, selectedSchools]);
+ const scopedSelectedGrades = useMemo(() => {
+ const availableGradeIds = new Set(availableGrades.map((grade) => grade.id));
+
+ return selectedGrades.filter((grade) => availableGradeIds.has(grade.id));
+ }, [availableGrades, selectedGrades]);
return (
@@ -166,9 +185,10 @@ export const TargetAudienceSection: React.FC = ({
Grade
option.name}
diff --git a/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts b/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts
index 13a00dbf1e..29a3b2ee22 100644
--- a/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts
+++ b/src/ops-console/components/campaignSetup/campaignAssignmentUtils.ts
@@ -1,10 +1,11 @@
import {
CampaignAssignmentChapterOption,
CampaignAssignmentSubjectOption,
+ CampaignFrequency,
} from '../../../services/api/ServiceApi';
import { CampaignSetupFormState } from './types';
-export type Frequency = 'daily' | 'alternate_days' | 'alternate_week';
+export type Frequency = CampaignFrequency;
export type AssignmentRow = {
rowId: string;
@@ -151,13 +152,18 @@ const getScheduleDates = (
return dates;
};
+export const buildFrequencyTimelineDates = (
+ startDate: string,
+ endDate: string,
+ frequency: Frequency,
+): string[] =>
+ getScheduleDates(startDate, endDate, frequency, Number.MAX_SAFE_INTEGER);
+
export const getRequiredAssignmentCount = (
startDate: string,
endDate: string,
frequency: Frequency,
-) =>
- getScheduleDates(startDate, endDate, frequency, Number.MAX_SAFE_INTEGER)
- .length;
+) => buildFrequencyTimelineDates(startDate, endDate, frequency).length;
const distributeDatesAcrossAssignments = (
dates: string[],
diff --git a/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts b/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts
index 44d769926c..3da02b94d5 100644
--- a/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts
+++ b/src/ops-console/components/campaignSetup/campaignCommunicationTypes.ts
@@ -1,4 +1,4 @@
-import { CampaignAssignmentDraft } from './campaignAssignmentUtils';
+import { CampaignAssignmentDraft, Frequency } from './campaignAssignmentUtils';
import {
CampaignCommunicationRowState,
CampaignCommunicationState,
@@ -8,6 +8,7 @@ import { CampaignSetupFormState } from './types';
export type CampaignCommunicationTimelineStepProps = {
form: CampaignSetupFormState;
+ frequency: Frequency;
assignmentDrafts: CampaignAssignmentDraft[];
selectedSchoolIds: string[];
communicationState: CampaignCommunicationState;
diff --git a/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts b/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts
index 62faf438dd..4cbd048100 100644
--- a/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts
+++ b/src/ops-console/components/campaignSetup/campaignCommunicationUtils.ts
@@ -1,6 +1,10 @@
import { t } from 'i18next';
import { CAMPAIGN_OBJECTIVE } from '../../../common/constants';
-import { CampaignAssignmentDraft } from './campaignAssignmentUtils';
+import {
+ buildFrequencyTimelineDates,
+ CampaignAssignmentDraft,
+ Frequency,
+} from './campaignAssignmentUtils';
import { CampaignSetupFormState } from './types';
export type CampaignCommunicationRowState = {
@@ -51,11 +55,16 @@ export const createEmptyCommunicationRow =
export const buildCommunicationTimelineDates = (
assignmentDrafts: CampaignAssignmentDraft[],
form?: CampaignSetupFormState,
+ frequency?: Frequency,
): string[] => {
if (form?.objective === CAMPAIGN_OBJECTIVE.HOMEPAGE_LEARNING_PATHWAY) {
return buildCampaignDurationTimelineDates(form.startDate, form.endDate);
}
+ if (form && frequency) {
+ return buildFrequencyTimelineDates(form.startDate, form.endDate, frequency);
+ }
+
return Array.from(new Set(assignmentDrafts.map((draft) => draft.startsAt)))
.filter(Boolean)
.sort();
diff --git a/src/ops-console/components/campaignSetup/types.ts b/src/ops-console/components/campaignSetup/types.ts
index 4a25ddc1de..d4336bf545 100644
--- a/src/ops-console/components/campaignSetup/types.ts
+++ b/src/ops-console/components/campaignSetup/types.ts
@@ -73,6 +73,7 @@ export type TargetAudienceSectionProps = {
savedGroups: CampaignSavedAudienceGroup[];
selectedSavedGroupId: string;
audienceOptions: CampaignAudienceOptions;
+ availableGrades: CampaignOption[];
selectedBlocks: string[];
selectedSchools: CampaignSchoolOption[];
selectedGrades: CampaignOption[];
@@ -81,6 +82,7 @@ export type TargetAudienceSectionProps = {
hasCustomGradeSelection: boolean;
schoolsForSelectedBlocks: CampaignSchoolOption[];
loadingAudience: boolean;
+ loadingGrades: boolean;
selectedProgramName: string;
summaryBlockCount: number;
summarySchoolCount: number;
diff --git a/src/ops-console/components/campaignSetup/useCampaignReach.ts b/src/ops-console/components/campaignSetup/useCampaignReach.ts
index 069be7b6c2..ac0eec59cb 100644
--- a/src/ops-console/components/campaignSetup/useCampaignReach.ts
+++ b/src/ops-console/components/campaignSetup/useCampaignReach.ts
@@ -21,7 +21,7 @@ export const useCampaignReach = (selectedSchoolIds: string[]) => {
if (
selectedSchoolIds.length === 0 ||
!api.getParentWhatsappClassesBySchoolId ||
- !api.getParentWhatsappParentPhonesByClassId
+ !api.getCampaignParentsInGroupBySchoolIds
) {
setCampaignReach(emptyReach);
return;
@@ -29,39 +29,21 @@ export const useCampaignReach = (selectedSchoolIds: string[]) => {
setLoadingReach(true);
try {
- const schoolClasses = await Promise.all(
- selectedSchoolIds.map((schoolId) =>
- api.getParentWhatsappClassesBySchoolId!(schoolId),
- ),
- );
-
- const groupedClasses = schoolClasses
- .flat()
- .filter(
- (classRow) =>
- classRow.group_id && String(classRow.group_id).trim() !== '',
- );
+ const schoolClasses =
+ await api.getParentWhatsappClassesBySchoolId(selectedSchoolIds);
- const memberLists = await Promise.all(
- groupedClasses.map(async (classRow) => {
- try {
- const phones = await api.getParentWhatsappParentPhonesByClassId!(
- classRow.id,
- );
- return Array.from(new Set(phones));
- } catch {
- return [];
- }
- }),
+ const groupedClasses = schoolClasses.filter(
+ (classRow) =>
+ classRow.group_id && String(classRow.group_id).trim() !== '',
);
+ const memberCount =
+ await api.getCampaignParentsInGroupBySchoolIds(selectedSchoolIds);
+
if (!mounted) return;
setCampaignReach({
groupCount: groupedClasses.length,
- memberCount: memberLists.reduce(
- (total, members) => total + members.length,
- 0,
- ),
+ memberCount,
});
} catch (error) {
logger.error('Failed to load campaign reach:', error);
diff --git a/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx b/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx
index 73cba41bdc..f4074df418 100644
--- a/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx
+++ b/src/ops-console/components/campaignsOverview/CampaignsOverview.tsx
@@ -155,12 +155,19 @@ const CampaignsOverview: React.FC = ({
{shouldShowMessages && (
)}
{shouldShowReports && (
diff --git a/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx b/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx
index a3f58ff308..56b3a79cb9 100644
--- a/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx
+++ b/src/ops-console/components/campaignsOverview/CampaignsOverviewInfoTooltip.tsx
@@ -112,7 +112,13 @@ const CampaignsOverviewInfoTooltip: React.FC<
type="button"
aria-label={`${label} info`}
aria-expanded={resolvedIsOpen}
- onClick={handleToggle}
+ onClick={(event) => {
+ event.stopPropagation();
+ handleToggle();
+ }}
+ onPointerDown={(event) => {
+ event.stopPropagation();
+ }}
onFocus={keepTooltipWithinParent}
onMouseEnter={keepTooltipWithinParent}
>
diff --git a/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts b/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts
index 560712e06c..bd8e3e59f5 100644
--- a/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts
+++ b/src/ops-console/components/campaignsOverview/CampaignsOverviewLogic.ts
@@ -1,3 +1,5 @@
+import type { CampaignFrequency } from '../../../services/api/ServiceApi';
+
export type CampaignsOverviewDisplayValue =
| string
| number
@@ -72,6 +74,7 @@ export interface CampaignsOverviewApiCampaign {
objective?: string | null;
start_date?: string | null;
end_date?: string | null;
+ frequency?: CampaignFrequency | null;
rewards?: string | null;
updated_at?: string | null;
campaign_status?: CampaignStatus | null;
diff --git a/src/ops-console/hooks/useCampaignAudienceSelection.ts b/src/ops-console/hooks/useCampaignAudienceSelection.ts
index f23c48d9aa..8f1139b209 100644
--- a/src/ops-console/hooks/useCampaignAudienceSelection.ts
+++ b/src/ops-console/hooks/useCampaignAudienceSelection.ts
@@ -72,6 +72,8 @@ export const useCampaignAudienceSelection = ({
const [hasCustomSchoolSelection, setHasCustomSchoolSelection] =
useState(false);
const [hasCustomGradeSelection, setHasCustomGradeSelection] = useState(false);
+ const [availableGrades, setAvailableGrades] = useState([]);
+ const [loadingGrades, setLoadingGrades] = useState(false);
const [loadingAudience, setLoadingAudience] = useState(false);
const resetAudienceSelection = () => {
@@ -82,9 +84,11 @@ export const useCampaignAudienceSelection = ({
setHasCustomBlockSelection(false);
setHasCustomSchoolSelection(false);
setHasCustomGradeSelection(false);
+ setAvailableGrades([]);
setAudienceOptions(emptyAudienceOptions);
setAudienceSummary(emptyAudienceSummary);
setLoadingAudience(false);
+ setLoadingGrades(false);
setLoadingAudienceSummary(false);
setSaveGroup(false);
setMessage(null);
@@ -105,6 +109,8 @@ export const useCampaignAudienceSelection = ({
setSelectedBlocks([]);
setSelectedSchools([]);
setSelectedGrades([]);
+ setAvailableGrades([]);
+ setLoadingGrades(false);
setLoadingAudience(false);
return;
}
@@ -144,19 +150,10 @@ export const useCampaignAudienceSelection = ({
: audienceOptions.blocks,
);
}
-
- if (!hasCustomGradeSelection) {
- setSelectedGrades((current) =>
- areOptionIdArraysEqual(current, audienceOptions.grades)
- ? current
- : audienceOptions.grades,
- );
- }
}, [
audienceOptions,
form.programId,
hasCustomBlockSelection,
- hasCustomGradeSelection,
selectedSavedGroupId,
]);
@@ -195,18 +192,91 @@ export const useCampaignAudienceSelection = ({
() => selectedSchools.map((school) => school.id),
[selectedSchools],
);
- const selectedGradeIds = useMemo(
- () => selectedGrades.map((grade) => grade.id),
- [selectedGrades],
- );
const isAllSchools =
selectedSchoolIds.length === 0 ||
(allSchoolIds.length > 0 &&
selectedSchoolIds.length === allSchoolIds.length);
+
+ useEffect(() => {
+ let isActive = true;
+
+ const fetchAvailableGrades = async () => {
+ if (!form.programId) {
+ setAvailableGrades([]);
+ setLoadingGrades(false);
+ return;
+ }
+
+ if (isAllSchools) {
+ setAvailableGrades((current) =>
+ areOptionIdArraysEqual(current, audienceOptions.grades)
+ ? current
+ : audienceOptions.grades,
+ );
+ setLoadingGrades(false);
+ return;
+ }
+
+ if (selectedSchoolIds.length === 0) {
+ setAvailableGrades([]);
+ setLoadingGrades(false);
+ return;
+ }
+
+ setLoadingGrades(true);
+ try {
+ const grades = await api.getCampaignGradesForSchools(selectedSchoolIds);
+ if (!isActive) return;
+ setAvailableGrades(grades);
+ } catch (error) {
+ if (!isActive) return;
+ logger.error('Failed to load campaign grades for schools:', error);
+ setAvailableGrades([]);
+ setMessage({
+ type: 'error',
+ text: 'Unable to load grades for the selected schools.',
+ });
+ } finally {
+ if (isActive) setLoadingGrades(false);
+ }
+ };
+
+ fetchAvailableGrades();
+
+ return () => {
+ isActive = false;
+ };
+ }, [
+ api,
+ audienceOptions.grades,
+ form.programId,
+ isAllSchools,
+ selectedSchoolIds,
+ setMessage,
+ ]);
+
+ useEffect(() => {
+ if (loadingGrades) return;
+
+ setSelectedGrades((current) => {
+ const availableGradeIds = new Set(
+ availableGrades.map((grade) => grade.id),
+ );
+ const nextGrades = hasCustomGradeSelection
+ ? current.filter((grade) => availableGradeIds.has(grade.id))
+ : availableGrades;
+
+ return areOptionIdArraysEqual(current, nextGrades) ? current : nextGrades;
+ });
+ }, [availableGrades, hasCustomGradeSelection, loadingGrades]);
+
+ const selectedGradeIds = useMemo(
+ () => selectedGrades.map((grade) => grade.id),
+ [selectedGrades],
+ );
const isAllGrades =
- selectedGradeIds.length === 0 ||
- (audienceOptions.grades.length > 0 &&
- selectedGradeIds.length === audienceOptions.grades.length);
+ availableGrades.length > 0 &&
+ selectedGradeIds.length === availableGrades.length;
const selectedProgramName =
programs.find((program) => program.id === form.programId)?.name || '-';
@@ -216,10 +286,8 @@ export const useCampaignAudienceSelection = ({
);
const summaryGradeIds = useMemo(
() =>
- isAllGrades
- ? audienceOptions.grades.map((grade) => grade.id)
- : selectedGradeIds,
- [audienceOptions.grades, isAllGrades, selectedGradeIds],
+ isAllGrades ? availableGrades.map((grade) => grade.id) : selectedGradeIds,
+ [availableGrades, isAllGrades, selectedGradeIds],
);
const summaryBlockCount = isAllSchools
? audienceOptions.blocks.length
@@ -344,6 +412,7 @@ export const useCampaignAudienceSelection = ({
return {
audienceOptions,
audienceSummary,
+ availableGrades,
handleBlocksChange,
handleGradesChange,
handleProgramChange,
@@ -356,6 +425,7 @@ export const useCampaignAudienceSelection = ({
isAllSchools,
loadingAudience,
loadingAudienceSummary,
+ loadingGrades,
resetAudienceSelection,
schoolsForSelectedBlocks,
selectedBlocks,
diff --git a/src/ops-console/hooks/useCampaignSetupForm.ts b/src/ops-console/hooks/useCampaignSetupForm.ts
index 69ccca05f6..4b129c1995 100644
--- a/src/ops-console/hooks/useCampaignSetupForm.ts
+++ b/src/ops-console/hooks/useCampaignSetupForm.ts
@@ -94,6 +94,13 @@ export const useCampaignSetupForm = () => {
const [submitAttempted, setSubmitAttempted] = useState(false);
const [rewardSubmitAttempted, setRewardSubmitAttempted] = useState(false);
const [message, setMessage] = useState(null);
+ const assignmentFrequency = useMemo(
+ () =>
+ assignmentConfigs[activeAssignmentGradeId]?.frequency ??
+ Object.values(assignmentConfigs)[0]?.frequency ??
+ DEFAULT_FREQUENCY,
+ [activeAssignmentGradeId, assignmentConfigs],
+ );
const audience = useCampaignAudienceSelection({
api,
@@ -431,6 +438,7 @@ export const useCampaignSetupForm = () => {
assignmentOptions,
areRewardsValid,
assignmentConfigs,
+ assignmentFrequency,
assignmentDrafts,
campaignRewards,
createdCampaignId,
diff --git a/src/ops-console/pages/CampaignAssignmentTab.css b/src/ops-console/pages/CampaignAssignmentTab.css
index ed814ac880..36fe15b291 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 86bdbcbaf1..531df69bc2 100644
--- a/src/ops-console/pages/CampaignAssignmentTab.test.tsx
+++ b/src/ops-console/pages/CampaignAssignmentTab.test.tsx
@@ -62,7 +62,6 @@ jest.mock('../components/DataTablePagination', () => ({
describe('CampaignAssignmentTab', () => {
const api = {
getAllGrades: jest.fn(),
- getCampaignSubjectsByCampaignId: jest.fn(),
getCampaignAssignments: jest.fn(),
};
@@ -72,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 = [
@@ -85,41 +84,42 @@ describe('CampaignAssignmentTab', () => {
},
];
+ const defaultUniqueSubjects = defaultSubjects;
+
const renderTab = (campaignId?: string) =>
render();
const primeApi = ({
grades = defaultGrades,
- subjects = defaultSubjects,
assignments = defaultAssignments,
+ uniqueSubjects = defaultUniqueSubjects,
total = 1,
gradeError = null,
- subjectError = null,
assignmentError = null,
}: {
grades?: Array<{ id: string; name: string }>;
- subjects?: Array<{ id: string; name: string }>;
assignments?: Array<{
assignmentDate: string;
gradeName: string;
subjectName: string;
lessonName: string;
}>;
+ uniqueSubjects?: Array<{
+ id: string;
+ name: string;
+ gradeIds: string[];
+ }>;
total?: number;
gradeError?: Error | null;
- subjectError?: Error | null;
assignmentError?: Error | null;
} = {}) => {
api.getAllGrades.mockImplementation(() =>
gradeError ? Promise.reject(gradeError) : Promise.resolve(grades),
);
- api.getCampaignSubjectsByCampaignId.mockImplementation(() =>
- subjectError ? Promise.reject(subjectError) : Promise.resolve(subjects),
- );
api.getCampaignAssignments.mockImplementation(() =>
assignmentError
? Promise.reject(assignmentError)
- : Promise.resolve({ assignments, total }),
+ : Promise.resolve({ assignments, uniqueSubjects, total }),
);
};
@@ -135,11 +135,6 @@ describe('CampaignAssignmentTab', () => {
renderTab('campaign-1');
- await waitFor(() =>
- expect(api.getCampaignSubjectsByCampaignId).toHaveBeenCalledWith(
- 'campaign-1',
- ),
- );
await waitFor(() =>
expect(api.getCampaignAssignments).toHaveBeenCalledWith('campaign-1', {
page: 1,
@@ -148,7 +143,7 @@ describe('CampaignAssignmentTab', () => {
);
expect(api.getAllGrades).toHaveBeenCalledTimes(1);
- expect(screen.getByTestId('data-table')).toBeInTheDocument();
+ expect(await screen.findByTestId('data-table')).toBeInTheDocument();
expect(screen.getByText('Lesson Alpha')).toBeInTheDocument();
});
@@ -173,7 +168,6 @@ describe('CampaignAssignmentTab', () => {
expect(await screen.findByText('No Assignments Found')).toBeInTheDocument();
expect(api.getAllGrades).not.toHaveBeenCalled();
- expect(api.getCampaignSubjectsByCampaignId).not.toHaveBeenCalled();
expect(api.getCampaignAssignments).not.toHaveBeenCalled();
});
@@ -270,16 +264,29 @@ describe('CampaignAssignmentTab', () => {
it('shows the loading spinner while requests are pending', () => {
api.getAllGrades.mockImplementation(() => new Promise(() => undefined));
- api.getCampaignSubjectsByCampaignId.mockImplementation(
+ api.getCampaignAssignments.mockImplementation(
() => new Promise(() => undefined),
);
+
+ renderTab('campaign-1');
+
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ });
+
+ it('keeps filters disabled until campaign grade metadata is loaded', async () => {
+ api.getAllGrades.mockResolvedValue(defaultGrades);
api.getCampaignAssignments.mockImplementation(
() => new Promise(() => undefined),
);
renderTab('campaign-1');
- expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ await waitFor(() => expect(api.getAllGrades).toHaveBeenCalled());
+ screen
+ .getAllByRole('combobox')
+ .forEach((combobox) =>
+ expect(combobox).toHaveAttribute('aria-disabled', 'true'),
+ );
});
it('renders the default grade filter value', async () => {
@@ -290,6 +297,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();
@@ -298,6 +320,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 e9c42274d1..8cb5b67b12 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[]
>([]);
@@ -75,10 +78,7 @@ const CampaignAssignmentTab: React.FC = ({
setIsLoadingFilters(true);
try {
- const [nextGrades, nextSubjects] = await Promise.all([
- api.getAllGrades(),
- api.getCampaignSubjectsByCampaignId(campaignId),
- ]);
+ const nextGrades = await api.getAllGrades();
if (cancelled) return;
@@ -88,12 +88,6 @@ const CampaignAssignmentTab: React.FC = ({
name: String(grade.name),
})),
);
- setSubjects(
- nextSubjects.map((subject) => ({
- id: String(subject.id),
- name: String(subject.name),
- })),
- );
} catch (err) {
logger.error('Failed to load campaign assignment filters:', err);
} finally {
@@ -108,6 +102,13 @@ const CampaignAssignmentTab: React.FC = ({
};
}, [api, campaignId]);
+ useEffect(() => {
+ setSelectedGrades([]);
+ setSelectedSubjects([]);
+ setSubjects([]);
+ setPage(1);
+ }, [campaignId]);
+
useEffect(() => {
let cancelled = false;
@@ -131,6 +132,9 @@ const CampaignAssignmentTab: React.FC = ({
if (cancelled) return;
setAssignments(response.assignments ?? []);
+ if (selectedGrades.length === 0 && selectedSubjects.length === 0) {
+ setSubjects(response.uniqueSubjects ?? []);
+ }
setTotal(response.total ?? 0);
} catch (err) {
logger.error('Failed to load campaign assignments:', err);
@@ -169,6 +173,49 @@ const CampaignAssignmentTab: React.FC = ({
[subjects],
);
+ const availableSubjects = subjects;
+
+ const availableGrades = useMemo(() => {
+ const relevantGradeIds = new Set(
+ subjects.flatMap((subject) => subject.gradeIds),
+ );
+
+ return 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;
@@ -225,10 +272,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);
};
@@ -247,17 +300,28 @@ const CampaignAssignmentTab: React.FC = ({
}
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"
@@ -265,9 +329,23 @@ const CampaignAssignmentTab: React.FC = ({
PaperProps: { className: 'campaign-assignment-menu' },
}}
>
- {grades.map((grade) => (
-