From 3fffe67453a6661c3aa7948107c6c192253f3d3b Mon Sep 17 00:00:00 2001 From: Zhou Jiahao <54730603+zhoukerrr@users.noreply.github.com> Date: Sun, 19 Mar 2023 21:05:00 +0800 Subject: [PATCH 1/8] Change ModuleLessonConfig value to array (#3420) * feat: update lesson to array * chore: fix lint * fix: update test cases * chore: format lint * chore: update test cases * feat: update timetable redux schema * feat: fix lint * chore: fix lint * chore: add timetable schema migration test * fix: add comment for test version --------- Co-authored-by: Christopher Goh --- website/src/actions/timetables.test.ts | 18 ++-- website/src/reducers/index.test.ts | 24 ++--- website/src/reducers/timetables.test.ts | 87 +++++++++++++++---- website/src/reducers/timetables.ts | 45 +++++++++- website/src/types/timetables.ts | 2 +- website/src/utils/timetables.test.ts | 66 +++++++------- website/src/utils/timetables.ts | 26 ++++-- .../module-info/AddModuleDropdown.test.tsx | 2 +- .../views/timetable/ShareTimetable.test.tsx | 2 +- .../timetable/TimetableContainer.test.tsx | 6 +- 10 files changed, 188 insertions(+), 90 deletions(-) diff --git a/website/src/actions/timetables.test.ts b/website/src/actions/timetables.test.ts index e021893fa4..ac0742224b 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -60,9 +60,9 @@ describe('fillTimetableBlanks', () => { test('do nothing if timetable is already full', () => { const timetable = { CS1010S: { - Lecture: '1', - Tutorial: '1', - Recitation: '1', + Lecture: ['1'], + Tutorial: ['1'], + Recitation: ['1'], }, }; @@ -76,8 +76,8 @@ describe('fillTimetableBlanks', () => { test('fill missing lessons with randomly generated modules', () => { const timetable = { CS1010S: { - Lecture: '1', - Tutorial: '1', + Lecture: ['1'], + Tutorial: ['1'], }, CS3216: {}, }; @@ -95,9 +95,9 @@ describe('fillTimetableBlanks', () => { semester, moduleCode: 'CS1010S', lessonConfig: { - Lecture: '1', - Tutorial: '1', - Recitation: expect.any(String), + Lecture: ['1'], + Tutorial: ['1'], + Recitation: expect.any(Array), }, }, }); @@ -108,7 +108,7 @@ describe('fillTimetableBlanks', () => { semester, moduleCode: 'CS3216', lessonConfig: { - Lecture: '1', + Lecture: ['1'], }, }, }); diff --git a/website/src/reducers/index.test.ts b/website/src/reducers/index.test.ts index 8a197c3845..8b2864d52c 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -10,16 +10,16 @@ const exportData: ExportData = { semester: 1, timetable: { CS3216: { - Lecture: '1', + Lecture: ['1'], }, CS1010S: { - Lecture: '1', - Tutorial: '3', - Recitation: '2', + Lecture: ['1'], + Tutorial: ['3'], + Recitation: ['2'], }, PC1222: { - Lecture: '1', - Tutorial: '3', + Lecture: ['1'], + Tutorial: ['3'], }, }, colors: { @@ -52,16 +52,16 @@ test('reducers should set export data state', () => { lessons: { [1]: { CS3216: { - Lecture: '1', + Lecture: ['1'], }, CS1010S: { - Lecture: '1', - Tutorial: '3', - Recitation: '2', + Lecture: ['1'], + Tutorial: ['3'], + Recitation: ['2'], }, PC1222: { - Lecture: '1', - Tutorial: '3', + Lecture: ['1'], + Tutorial: ['3'], }, }, }, diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index 57459dd2e7..7c6d5f68c9 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -1,4 +1,4 @@ -import reducer, { defaultTimetableState, persistConfig } from 'reducers/timetables'; +import reducer, { defaultTimetableState, migrateV1toV2, persistConfig } from 'reducers/timetables'; import { ADD_MODULE, hideLessonInTimetable, @@ -125,41 +125,41 @@ describe('lesson reducer', () => { lessons: { [1]: { CS1010S: { - Lecture: '1', - Recitation: '2', + Lecture: ['1'], + Recitation: ['2'], }, CS3216: { - Lecture: '1', + Lecture: ['1'], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: ['1'], }, }, }, }, setLessonConfig(1, 'CS1010S', { - Lecture: '2', - Recitation: '3', - Tutorial: '4', + Lecture: ['2'], + Recitation: ['3'], + Tutorial: ['4'], }), ), ).toMatchObject({ lessons: { [1]: { CS1010S: { - Lecture: '2', - Recitation: '3', - Tutorial: '4', + Lecture: ['2'], + Recitation: ['3'], + Tutorial: ['4'], }, CS3216: { - Lecture: '1', + Lecture: ['1'], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: ['1'], }, }, }, @@ -172,7 +172,7 @@ describe('stateReconciler', () => { '2015/2016': { [1]: { GET1006: { - Lecture: '1', + Lecture: ['1'], }, }, }, @@ -181,13 +181,13 @@ describe('stateReconciler', () => { const oldLessons = { [1]: { CS1010S: { - Lecture: '1', - Recitation: '2', + Lecture: ['1'], + Recitation: ['2'], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: ['1'], }, }, }; @@ -239,3 +239,56 @@ describe('stateReconciler', () => { }); }); }); + +describe('redux schema migration', () => { + const reduxDataV1 = { + lessons: { + [1]: { + CS1010S: { + Lecture: '1', + Recitation: '2', + }, + }, + [2]: { + CS3217: { + Lecture: '1', + }, + }, + }, + colors: {}, + hidden: {}, + academicYear: '2022/2023', + archive: {}, + _persist: { + version: 1, + rehydrated: false, + }, + }; + + const reduxDataV2 = { + lessons: { + [1]: { + CS1010S: { + Lecture: ['1'], + Recitation: ['2'], + }, + }, + [2]: { + CS3217: { + Lecture: ['1'], + }, + }, + }, + colors: {}, + hidden: {}, + academicYear: '2022/2023', + archive: {}, + _persist: { + version: 1, // version kept the same because the framework does not support it in unit tests + rehydrated: false, + }, + }; + test('should migrate from V1 to V2', () => { + expect(migrateV1toV2(reduxDataV1)).toEqual(reduxDataV2); + }); +}); diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index f7032a75fc..343c52f2d6 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -1,10 +1,10 @@ import { get, omit, values } from 'lodash'; import produce from 'immer'; -import { createMigrate } from 'redux-persist'; +import { createMigrate, PersistedState } from 'redux-persist'; import { PersistConfig } from 'storage/persistReducer'; import { ModuleCode } from 'types/modules'; -import { ModuleLessonConfig, SemTimetableConfig } from 'types/timetables'; +import { ModuleLessonConfig, SemTimetableConfig, TimetableConfig } from 'types/timetables'; import { ColorMapping, TimetablesState } from 'types/reducers'; import config from 'config'; @@ -22,6 +22,40 @@ import { getNewColor } from 'utils/colors'; import { SET_EXPORTED_DATA } from 'actions/constants'; import { Actions } from '../types/actions'; +// Migration from state V1 -> V2 +type TimetableStateV1 = Omit & { + lessons: { [semester: string]: { [moduleCode: string]: { [lessonType: string]: string } } }; +}; +export function migrateV1toV2( + oldState: TimetableStateV1 & PersistedState, +): TimetablesState & PersistedState { + const newLessons: TimetableConfig = {}; + const oldLessons = oldState.lessons; + + Object.entries(oldLessons).forEach(([semester, modules]) => { + Object.entries(modules).forEach(([moduleCode, lessons]) => { + const newSemester: { [moduleCode: string]: { [lessonType: string]: string[] } } = { + [moduleCode]: {}, + }; + + Object.entries(lessons).forEach(([lessonType, lessonValue]) => { + const lessonArray = [lessonValue]; + newSemester[moduleCode][lessonType] = lessonArray; + }); + + if (!newLessons[semester]) { + newLessons[semester] = {}; + } + Object.assign(newLessons[semester], newSemester); + }); + }); + + return { + ...oldState, + lessons: newLessons, + }; +} + export const persistConfig = { /* eslint-disable no-useless-computed-key */ migrate: createMigrate({ @@ -34,9 +68,12 @@ export const persistConfig = { // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain _persist: state?._persist!, }), + // Same as planner.ts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [2]: migrateV1toV2 as any, }), /* eslint-enable */ - version: 1, + version: 2, // Our own state reconciler archives old timetables if the acad year is different, // otherwise use the persisted timetable state @@ -82,7 +119,7 @@ function moduleLessonConfig( if (!(classNo && lessonType)) return state; return { ...state, - [lessonType]: classNo, + [lessonType]: [classNo], }; } case SET_LESSON_CONFIG: diff --git a/website/src/types/timetables.ts b/website/src/types/timetables.ts index ebe2c793a1..2f59eabac8 100644 --- a/website/src/types/timetables.ts +++ b/website/src/types/timetables.ts @@ -2,7 +2,7 @@ import { ClassNo, LessonType, ModuleCode, ModuleTitle, RawLesson } from './modul // ModuleLessonConfig is a mapping of lessonType to ClassNo for a module. export type ModuleLessonConfig = { - [lessonType: string]: ClassNo; + [lessonType: string]: ClassNo[]; }; // SemTimetableConfig is the timetable data for each semester. diff --git a/website/src/utils/timetables.test.ts b/website/src/utils/timetables.test.ts index 0953a72dea..0dd5b7c80c 100644 --- a/website/src/utils/timetables.test.ts +++ b/website/src/utils/timetables.test.ts @@ -85,9 +85,9 @@ test('hydrateSemTimetableWithLessons should replace ClassNo with lessons', () => const modules: ModulesMap = { [moduleCode]: CS1010S }; const config: SemTimetableConfig = { [moduleCode]: { - Tutorial: '8', - Recitation: '4', - Lecture: '1', + Tutorial: ['8'], + Recitation: ['4'], + Lecture: ['1'], }, }; @@ -385,15 +385,15 @@ test('timetable serialization/deserialization', () => { {}, { CS1010S: {} }, { - GER1000: { Tutorial: 'B01' }, + GER1000: { Tutorial: ['B01'] }, }, { - CS2104: { Lecture: '1', Tutorial: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, - CS2107: { Lecture: '1', Tutorial: '8' }, - CS4212: { Lecture: '1', Tutorial: '1' }, - CS4243: { Laboratory: '2', Lecture: '1' }, - GER1000: { Tutorial: 'B01' }, + CS2104: { Lecture: ['1'], Tutorial: ['2'] }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, + CS2107: { Lecture: ['1'], Tutorial: ['8'] }, + CS4212: { Lecture: ['1'], Tutorial: ['1'] }, + CS4243: { Laboratory: ['2'], Lecture: ['1'] }, + GER1000: { Tutorial: ['B01'] }, }, ]; @@ -406,8 +406,8 @@ test('deserializing edge cases', () => { // Duplicate module code expect(deserializeTimetable('CS1010S=LEC:01&CS1010S=REC:11')).toEqual({ CS1010S: { - Lecture: '01', - Recitation: '11', + Lecture: ['01'], + Recitation: ['11'], }, }); @@ -416,7 +416,7 @@ test('deserializing edge cases', () => { CS1010S: {}, CS3217: {}, CS2105: { - Lecture: '1', + Lecture: ['1'], }, }); }); @@ -428,8 +428,8 @@ test('isSameTimetableConfig', () => { // Change lessonType order expect( isSameTimetableConfig( - { CS2104: { Tutorial: '1', Lecture: '2' } }, - { CS2104: { Lecture: '2', Tutorial: '1' } }, + { CS2104: { Tutorial: ['1'], Lecture: ['2'] } }, + { CS2104: { Lecture: ['2'], Tutorial: ['1'] } }, ), ).toBe(true); @@ -437,12 +437,12 @@ test('isSameTimetableConfig', () => { expect( isSameTimetableConfig( { - CS2104: { Lecture: '1', Tutorial: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, + CS2104: { Lecture: ['1'], Tutorial: ['2'] }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, }, { - CS2105: { Lecture: '1', Tutorial: '1' }, - CS2104: { Lecture: '1', Tutorial: '2' }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, + CS2104: { Lecture: ['1'], Tutorial: ['2'] }, }, ), ).toBe(true); @@ -450,8 +450,8 @@ test('isSameTimetableConfig', () => { // Different values expect( isSameTimetableConfig( - { CS2104: { Lecture: '1', Tutorial: '2' } }, - { CS2104: { Lecture: '2', Tutorial: '1' } }, + { CS2104: { Lecture: ['1'], Tutorial: ['2'] } }, + { CS2104: { Lecture: ['2'], Tutorial: ['1'] } }, ), ).toBe(false); @@ -459,11 +459,11 @@ test('isSameTimetableConfig', () => { expect( isSameTimetableConfig( { - CS2104: { Tutorial: '1', Lecture: '2' }, + CS2104: { Tutorial: ['1'], Lecture: ['2'] }, }, { - CS2104: { Tutorial: '1', Lecture: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, + CS2104: { Tutorial: ['1'], Lecture: ['2'] }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, }, ), ).toBe(false); @@ -499,9 +499,9 @@ describe(validateTimetableModules, () => { describe('validateModuleLessons', () => { const semester: Semester = 1; const lessons: ModuleLessonConfig = { - Lecture: '1', - Recitation: '10', - Tutorial: '11', + Lecture: ['1'], + Recitation: ['10'], + Tutorial: ['11'], }; test('should leave valid lessons untouched', () => { @@ -514,7 +514,7 @@ describe('validateModuleLessons', () => { semester, { ...lessons, - Laboratory: '2', // CS1010S has no lab + Laboratory: ['2'], // CS1010S has no lab }, CS1010S, ), @@ -527,7 +527,7 @@ describe('validateModuleLessons', () => { semester, { ...lessons, - Lecture: '2', // CS1010S has no Lecture 2 + Lecture: ['2'], // CS1010S has no Lecture 2 }, CS1010S, ), @@ -539,15 +539,15 @@ describe('validateModuleLessons', () => { validateModuleLessons( semester, { - Tutorial: '10', + Tutorial: ['10'], }, CS1010S, ), ).toEqual([ { - Lecture: '1', - Recitation: '1', - Tutorial: '10', + Lecture: ['1'], + Recitation: ['1'], + Tutorial: ['10'], }, ['Lecture', 'Recitation'], ]); diff --git a/website/src/utils/timetables.ts b/website/src/utils/timetables.ts index 42d69219b5..9bbbe2c246 100644 --- a/website/src/utils/timetables.ts +++ b/website/src/utils/timetables.ts @@ -76,6 +76,7 @@ export const LESSON_ABBREV_TYPE: { [key: string]: LessonType } = invert(LESSON_T // See: https://stackoverflow.com/a/31300627 export const LESSON_TYPE_SEP = ':'; export const LESSON_SEP = ','; +export const SAME_LESSON_SEP = ';'; const EMPTY_OBJECT = {}; @@ -103,8 +104,9 @@ export function randomModuleLessonConfig(lessons: readonly RawLesson[]): ModuleL return mapValues( lessonByGroupsByClassNo, - (group: { [classNo: string]: readonly RawLesson[] }) => + (group: { [classNo: string]: readonly RawLesson[] }) => [ (first(sample(group)) as RawLesson).classNo, + ], ); } @@ -121,11 +123,12 @@ export function hydrateSemTimetableWithLessons( if (!module) return EMPTY_OBJECT; // TODO: Split this part into a smaller function: hydrateModuleConfigWithLessons. - return mapValues(moduleLessonConfig, (classNo: ClassNo, lessonType: LessonType) => { + return mapValues(moduleLessonConfig, (classNos: ClassNo[], lessonType: LessonType) => { const lessons = getModuleTimetable(module, semester); const newLessons = lessons.filter( (lesson: RawLesson): boolean => - lesson.lessonType === lessonType && lesson.classNo === classNo, + lesson.lessonType === lessonType && + classNos.some((classNo) => classNo === lesson.classNo), ); const timetableLessons: Lesson[] = newLessons.map( @@ -337,11 +340,14 @@ export function validateModuleLessons( // - classNo is not valid anymore (ie. the class was removed) // // If a lesson type is removed, then it simply won't be copied over - if (!lessons.some((lesson) => lesson.classNo === classNo)) { - validatedLessonConfig[lessonType] = lessons[0].classNo; + const filteredClasses = + classNo && + classNo.filter((classNum) => lessons.some((lesson) => lesson.classNo === classNum)); + if (!classNo || filteredClasses.length === 0) { + validatedLessonConfig[lessonType] = [lessons[0].classNo]; updatedLessonTypes.push(lessonType); } else { - validatedLessonConfig[lessonType] = classNo; + validatedLessonConfig[lessonType] = filteredClasses; } }); @@ -359,9 +365,11 @@ export function getSemesterModules( } function serializeModuleConfig(config: ModuleLessonConfig): string { - // eg. { Lecture: 1, Laboratory: 2 } => LEC=1,LAB=2 + // eg. { Lecture: 1, Laboratory: 2, Laboratory: 3 } => LEC=1,LAB=2;3 return map(config, (classNo, lessonType) => - [LESSON_TYPE_ABBREV[lessonType], encodeURIComponent(classNo)].join(LESSON_TYPE_SEP), + [LESSON_TYPE_ABBREV[lessonType], encodeURIComponent(classNo.join(SAME_LESSON_SEP))].join( + LESSON_TYPE_SEP, + ), ).join(LESSON_SEP); } @@ -375,7 +383,7 @@ function parseModuleConfig(serialized: string | string[] | null): ModuleLessonCo const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr]; // Ignore unparsable/invalid keys if (!lessonType) return; - config[lessonType] = classNo; + config[lessonType] = classNo.split(SAME_LESSON_SEP); }); }); diff --git a/website/src/views/components/module-info/AddModuleDropdown.test.tsx b/website/src/views/components/module-info/AddModuleDropdown.test.tsx index 9246bbdeda..c10bbf3544 100644 --- a/website/src/views/components/module-info/AddModuleDropdown.test.tsx +++ b/website/src/views/components/module-info/AddModuleDropdown.test.tsx @@ -76,7 +76,7 @@ describe(AddModuleDropdownComponent, () => { test('should show remove button when the module is in timetable', () => { // eslint-disable-next-line no-useless-computed-key - const container = make(CS3216, { [1]: { CS3216: { Lecture: '1' } } }); + const container = make(CS3216, { [1]: { CS3216: { Lecture: ['1'] } } }); const button = container.wrapper.find('button'); expect(button.text()).toMatch('Remove'); diff --git a/website/src/views/timetable/ShareTimetable.test.tsx b/website/src/views/timetable/ShareTimetable.test.tsx index 2653ad50ee..670b9e6b53 100644 --- a/website/src/views/timetable/ShareTimetable.test.tsx +++ b/website/src/views/timetable/ShareTimetable.test.tsx @@ -29,7 +29,7 @@ describe('ShareTimetable', () => { const timetable = { CS1010S: { - Lecture: '1', + Lecture: ['1'], }, }; diff --git a/website/src/views/timetable/TimetableContainer.test.tsx b/website/src/views/timetable/TimetableContainer.test.tsx index 51dbb583e1..0cf7a521c6 100644 --- a/website/src/views/timetable/TimetableContainer.test.tsx +++ b/website/src/views/timetable/TimetableContainer.test.tsx @@ -117,7 +117,7 @@ describe(TimetableContainerComponent, () => { test('should eventually display imported timetable if there is one', async () => { const semester = 1; - const importedTimetable = { [moduleCodeThatCanBeLoaded]: { Lecture: '1' } }; + const importedTimetable = { [moduleCodeThatCanBeLoaded]: { Lecture: ['1'] } }; const location = timetableShare(semester, importedTimetable); make(location); @@ -136,7 +136,7 @@ describe(TimetableContainerComponent, () => { test('should ignore invalid modules in imported timetable', () => { const semester = 1; - const importedTimetable = { TRUMP2020: { Lecture: '1' } }; + const importedTimetable = { TRUMP2020: { Lecture: ['1'] } }; const location = timetableShare(semester, importedTimetable); make(location); @@ -159,7 +159,7 @@ describe(TimetableContainerComponent, () => { store.dispatch({ type: SUCCESS_KEY(FETCH_MODULE), payload: CS3216 }); // Populate mock timetable - const timetable = { CS1010S: { Lecture: '1' }, CS3216: { Lecture: '1' } }; + const timetable = { CS1010S: { Lecture: ['1'] }, CS3216: { Lecture: ['1'] } }; (store.dispatch as Dispatch)(setTimetable(semester, timetable)); // Expect nothing to be fetched as timetable exists in `moduleBank`. From 444da6cb968a6204604c6034931d5cb24c4d0acf Mon Sep 17 00:00:00 2001 From: Zhou Jiahao <54730603+zhoukerrr@users.noreply.github.com> Date: Sun, 2 Apr 2023 15:07:17 +0800 Subject: [PATCH 2/8] Add support for Timetable for TAs (#3434) * feat: fix lint * feat: add customise module button * feat: add redux actions * feat: connect button to redux action * feat: enter and edit module * feat: exit customising state * cahnge customisemod default state to empty string * feat: add timetable state for custimised mods * feat: remove validation for custom mods * feat: add TA label to cells * feat: hide and disable other edit buttons when editing * feat: add support for changing lessons after customisation * chore: fix test cases * chore: fox lint * chore: fix schema migration test --- .../__snapshots__/timetables.test.ts.snap | 1 + website/src/actions/timetables.test.ts | 2 +- website/src/actions/timetables.ts | 83 ++++++++++++- website/src/reducers/app.test.ts | 3 +- website/src/reducers/app.ts | 13 +- website/src/reducers/index.test.ts | 1 + website/src/reducers/timetables.test.ts | 3 + website/src/reducers/timetables.ts | 58 ++++++++- website/src/selectors/timetables.ts | 5 + website/src/types/reducers.ts | 3 + website/src/views/timetable/Timetable.tsx | 3 + website/src/views/timetable/TimetableCell.tsx | 10 +- .../src/views/timetable/TimetableContent.tsx | 83 +++++++++++-- website/src/views/timetable/TimetableDay.tsx | 3 + .../timetable/TimetableModuleTable.test.tsx | 11 ++ .../views/timetable/TimetableModulesTable.tsx | 114 +++++++++++++----- website/src/views/timetable/TimetableRow.tsx | 3 + 17 files changed, 352 insertions(+), 47 deletions(-) diff --git a/website/src/actions/__snapshots__/timetables.test.ts.snap b/website/src/actions/__snapshots__/timetables.test.ts.snap index edba593353..8f502de21f 100644 --- a/website/src/actions/__snapshots__/timetables.test.ts.snap +++ b/website/src/actions/__snapshots__/timetables.test.ts.snap @@ -10,6 +10,7 @@ exports[`cancelModifyLesson should not have payload 1`] = ` exports[`changeLesson should return updated information to change lesson 1`] = ` { "payload": { + "activeLesson": "1", "classNo": "1", "lessonType": "Recitation", "moduleCode": "CS1010S", diff --git a/website/src/actions/timetables.test.ts b/website/src/actions/timetables.test.ts index ac0742224b..8d253ab282 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -36,7 +36,7 @@ test('modifyLesson should return lesson payload', () => { test('changeLesson should return updated information to change lesson', () => { const semester: Semester = 1; const lesson: Lesson = lessons[1]; - expect(actions.changeLesson(semester, lesson)).toMatchSnapshot(); + expect(actions.changeLesson(semester, lesson, lesson.classNo)).toMatchSnapshot(); }); test('cancelModifyLesson should not have payload', () => { diff --git a/website/src/actions/timetables.ts b/website/src/actions/timetables.ts index 3378567c52..c1a73c833c 100644 --- a/website/src/actions/timetables.ts +++ b/website/src/actions/timetables.ts @@ -90,12 +90,24 @@ export function modifyLesson(activeLesson: Lesson) { }; } +export const CUSTOMISE_MODULE = 'CUSTOMISE_LESSON' as const; +export function customiseLesson(semester: Semester, moduleCode: ModuleCode) { + return { + type: CUSTOMISE_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + export const CHANGE_LESSON = 'CHANGE_LESSON' as const; export function setLesson( semester: Semester, moduleCode: ModuleCode, lessonType: LessonType, classNo: ClassNo, + activeLesson: ClassNo, ) { return { type: CHANGE_LESSON, @@ -104,12 +116,71 @@ export function setLesson( moduleCode, lessonType, classNo, + activeLesson, }, }; } -export function changeLesson(semester: Semester, lesson: Lesson) { - return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo); +export const ADD_CUSTOM_MODULE = 'ADD_CUSTOM_MODULE' as const; +export function addCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: ADD_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + +export const REMOVE_CUSTOM_MODULE = 'REMOVE_CUSTOM_MODULE' as const; +export function removeCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: REMOVE_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + +export const ADD_LESSON = 'ADD_LESSON' as const; +export function addLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, +) { + return { + type: ADD_LESSON, + payload: { + semester, + moduleCode, + lessonType, + classNo, + }, + }; +} + +export const REMOVE_LESSON = 'REMOVE_LESSON' as const; +export function removeLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, +) { + return { + type: REMOVE_LESSON, + payload: { + semester, + moduleCode, + lessonType, + classNo, + }, + }; +} + +export function changeLesson(semester: Semester, lesson: Lesson, activeLesson: ClassNo) { + return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo, activeLesson); } export const SET_LESSON_CONFIG = 'SET_LESSON_CONFIG' as const; @@ -165,6 +236,14 @@ export function validateTimetable(semester: Semester) { const module = moduleBank.modules[moduleCode]; if (!module) return; + // If the module is customised, we do not validate it + if ( + timetables.customisedModules && + timetables.customisedModules[semester] && + timetables.customisedModules[semester].includes(moduleCode) + ) { + return; + } const [validatedLessonConfig, changedLessonTypes] = validateModuleLessons( semester, lessonConfig, diff --git a/website/src/reducers/app.test.ts b/website/src/reducers/app.test.ts index 414271123c..806897cbc1 100644 --- a/website/src/reducers/app.test.ts +++ b/website/src/reducers/app.test.ts @@ -20,6 +20,7 @@ const appInitialState: AppState = { isFeedbackModalOpen: false, promptRefresh: false, notifications: [], + customiseModule: '', }; const appHasSemesterTwoState: AppState = { ...appInitialState, activeSemester: anotherSemester }; const appHasActiveLessonState: AppState = { ...appInitialState, activeLesson: lesson }; @@ -55,7 +56,7 @@ test('app should set active lesson', () => { }); test('app should accept lesson change and unset active lesson', () => { - const action = changeLesson(semester, lesson); + const action = changeLesson(semester, lesson, lesson.classNo); const nextState: AppState = reducer(appInitialState, action); expect(nextState).toEqual(appInitialState); diff --git a/website/src/reducers/app.ts b/website/src/reducers/app.ts index 1748f527f5..524d570f59 100644 --- a/website/src/reducers/app.ts +++ b/website/src/reducers/app.ts @@ -3,7 +3,12 @@ import { Actions } from 'types/actions'; import config from 'config'; import { forceRefreshPrompt } from 'utils/debug'; -import { MODIFY_LESSON, CHANGE_LESSON, CANCEL_MODIFY_LESSON } from 'actions/timetables'; +import { + MODIFY_LESSON, + CHANGE_LESSON, + CANCEL_MODIFY_LESSON, + CUSTOMISE_MODULE, +} from 'actions/timetables'; import { SELECT_SEMESTER } from 'actions/settings'; import { OPEN_NOTIFICATION, @@ -18,6 +23,7 @@ const defaultAppState = (): AppState => ({ activeSemester: config.semester, // The lesson being modified on the timetable. activeLesson: null, + customiseModule: '', isOnline: navigator.onLine, isFeedbackModalOpen: false, promptRefresh: forceRefreshPrompt(), @@ -37,6 +43,11 @@ function app(state: AppState = defaultAppState(), action: Actions): AppState { ...state, activeLesson: action.payload.activeLesson, }; + case CUSTOMISE_MODULE: + return { + ...state, + customiseModule: action.payload.moduleCode, + }; case CANCEL_MODIFY_LESSON: case CHANGE_LESSON: return { diff --git a/website/src/reducers/index.test.ts b/website/src/reducers/index.test.ts index 8b2864d52c..bccc4c16ec 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -72,6 +72,7 @@ test('reducers should set export data state', () => { PC1222: 2, }, }, + customisedModules: {}, hidden: { [1]: ['PC1222'] }, academicYear: expect.any(String), archive: {}, diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index 7c6d5f68c9..1589f59f91 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -207,6 +207,7 @@ describe('stateReconciler', () => { }, academicYear: config.academicYear, archive: oldArchive, + customisedModules: {}, }; const { stateReconciler } = persistConfig; @@ -259,6 +260,7 @@ describe('redux schema migration', () => { hidden: {}, academicYear: '2022/2023', archive: {}, + customisedModules: {}, _persist: { version: 1, rehydrated: false, @@ -283,6 +285,7 @@ describe('redux schema migration', () => { hidden: {}, academicYear: '2022/2023', archive: {}, + customisedModules: {}, _persist: { version: 1, // version kept the same because the framework does not support it in unit tests rehydrated: false, diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index 343c52f2d6..0500244fa8 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -11,12 +11,16 @@ import config from 'config'; import { ADD_MODULE, CHANGE_LESSON, + ADD_LESSON, + REMOVE_LESSON, HIDE_LESSON_IN_TIMETABLE, REMOVE_MODULE, SELECT_MODULE_COLOR, SET_LESSON_CONFIG, SET_TIMETABLE, SHOW_LESSON_IN_TIMETABLE, + ADD_CUSTOM_MODULE, + REMOVE_CUSTOM_MODULE, } from 'actions/timetables'; import { getNewColor } from 'utils/colors'; import { SET_EXPORTED_DATA } from 'actions/constants'; @@ -42,7 +46,6 @@ export function migrateV1toV2( const lessonArray = [lessonValue]; newSemester[moduleCode][lessonType] = lessonArray; }); - if (!newLessons[semester]) { newLessons[semester] = {}; } @@ -119,12 +122,30 @@ function moduleLessonConfig( if (!(classNo && lessonType)) return state; return { ...state, - [lessonType]: [classNo], + [lessonType]: [ + ...state[lessonType].filter((lesson) => lesson !== action.payload.activeLesson), + action.payload.classNo, + ], }; } case SET_LESSON_CONFIG: return action.payload.lessonConfig; - + case ADD_LESSON: { + const { classNo, lessonType } = action.payload; + if (!(classNo && lessonType)) return state; + return { + ...state, + [lessonType]: [...state[lessonType], classNo], + }; + } + case REMOVE_LESSON: { + const { classNo, lessonType } = action.payload; + if (!(classNo && lessonType)) return state; + return { + ...state, + [lessonType]: state[lessonType].filter((lesson) => lesson !== classNo), + }; + } default: return state; } @@ -148,6 +169,8 @@ function semTimetable( case REMOVE_MODULE: return omit(state, [moduleCode]); case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: return { ...state, @@ -203,12 +226,31 @@ function semHiddenModules(state = defaultHiddenState, action: Actions) { } } +// Map of CustomisedModules +const defaultCustomisedModulesState: ModuleCode[] = []; +function customisedModules(state = defaultCustomisedModulesState, action: Actions) { + if (!action.payload) { + return state; + } + + switch (action.type) { + case ADD_CUSTOM_MODULE: + if (state.includes(action.payload.moduleCode)) return state; + return [...state, action.payload.moduleCode]; + case REMOVE_CUSTOM_MODULE: + return state.filter((c) => c !== action.payload.moduleCode); + default: + return state; + } +} + export const defaultTimetableState: TimetablesState = { lessons: {}, colors: {}, hidden: {}, academicYear: config.academicYear, archive: {}, + customisedModules: {}, }; function timetables( @@ -234,15 +276,23 @@ function timetables( case REMOVE_MODULE: case SELECT_MODULE_COLOR: case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: case HIDE_LESSON_IN_TIMETABLE: - case SHOW_LESSON_IN_TIMETABLE: { + case SHOW_LESSON_IN_TIMETABLE: + case ADD_CUSTOM_MODULE: + case REMOVE_CUSTOM_MODULE: { const { semester } = action.payload; return produce(state, (draft) => { draft.lessons[semester] = semTimetable(draft.lessons[semester], action); draft.colors[semester] = semColors(state.colors[semester], action); draft.hidden[semester] = semHiddenModules(state.hidden[semester], action); + draft.customisedModules[semester] = customisedModules( + state.customisedModules[semester], + action, + ); }); } diff --git a/website/src/selectors/timetables.ts b/website/src/selectors/timetables.ts index 75a5d719a9..ef66e8da7a 100644 --- a/website/src/selectors/timetables.ts +++ b/website/src/selectors/timetables.ts @@ -38,3 +38,8 @@ export const getSemesterTimetableColors = createSelector( (colors) => (semester: Semester | null) => semester === null ? EMPTY_OBJECT : colors[semester] ?? EMPTY_OBJECT, ); + +export const getCustomisingLesson = createSelector( + ({ app }: State) => app.customiseModule, + (customiseModule) => customiseModule ?? null, +); diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index cd37df9e8f..406c7bc658 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -51,6 +51,7 @@ export type NotificationData = { readonly message: string } & NotificationOption export type AppState = { readonly activeSemester: Semester; readonly activeLesson: Lesson | null; + readonly customiseModule: ModuleCode; readonly isOnline: boolean; readonly isFeedbackModalOpen: boolean; readonly notifications: NotificationData[]; @@ -112,6 +113,7 @@ export type SettingsState = { export type ColorMapping = { [moduleCode: string]: ColorIndex }; export type SemesterColorMap = { [semester: string]: ColorMapping }; export type HiddenModulesMap = { [semester: string]: ModuleCode[] }; +export type CustomisedModulesMap = { [semester: string]: ModuleCode[] }; export type TimetablesState = { readonly lessons: TimetableConfig; @@ -120,6 +122,7 @@ export type TimetablesState = { readonly academicYear: string; // Mapping of academic year to old timetable config readonly archive: { [key: string]: TimetableConfig }; + readonly customisedModules: CustomisedModulesMap; }; /* venueBank.js */ diff --git a/website/src/views/timetable/Timetable.tsx b/website/src/views/timetable/Timetable.tsx index cb88edcbce..3fb0803f6a 100644 --- a/website/src/views/timetable/Timetable.tsx +++ b/website/src/views/timetable/Timetable.tsx @@ -16,6 +16,7 @@ import elements from 'views/elements'; import withTimer, { TimerData } from 'views/hocs/withTimer'; import { TimePeriod } from 'types/venues'; +import { ModuleCode } from 'types/modules'; import styles from './Timetable.scss'; import TimetableTimings from './TimetableTimings'; import TimetableDay from './TimetableDay'; @@ -29,6 +30,7 @@ type Props = TimerData & { showTitle?: boolean; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; + customisedModules?: ModuleCode[]; }; type State = { @@ -108,6 +110,7 @@ class Timetable extends React.PureComponent { highlightPeriod={ highlightPeriod && index === highlightPeriod.day ? highlightPeriod : undefined } + customisedModules={this.props.customisedModules} /> ))} diff --git a/website/src/views/timetable/TimetableCell.tsx b/website/src/views/timetable/TimetableCell.tsx index 56a9903c05..d4a0fa1bc5 100644 --- a/website/src/views/timetable/TimetableCell.tsx +++ b/website/src/views/timetable/TimetableCell.tsx @@ -4,7 +4,7 @@ import { isEqual } from 'lodash'; import { addWeeks, format, parseISO } from 'date-fns'; import NUSModerator, { AcadWeekInfo } from 'nusmoderator'; -import { consumeWeeks, WeekRange } from 'types/modules'; +import { consumeWeeks, ModuleCode, WeekRange } from 'types/modules'; import { HoverLesson, ModifiableLesson } from 'types/timetables'; import { OnHoverCell } from 'types/views'; @@ -26,6 +26,7 @@ type Props = { onClick?: (position: ClientRect) => void; hoverLesson?: HoverLesson | null; transparent: boolean; + customisedModules?: ModuleCode[]; }; const lessonDateFormat = 'MMM dd'; @@ -131,7 +132,12 @@ const TimetableCell: React.FC = (props) => { {...conditionalProps} >
-
{moduleName}
+
+ {moduleName} + {props.customisedModules && props.customisedModules.includes(lesson.moduleCode) + ? '*' + : null} +
{LESSON_TYPE_ABBREV[lesson.lessonType]} [{lesson.classNo}]
diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index 14c371d5f4..8617ebf54e 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import _ from 'lodash'; import { ColorMapping, HORIZONTAL, ModulesMap, TimetableOrientation } from 'types/reducers'; -import { Module, ModuleCode, Semester } from 'types/modules'; +import { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; import { ColoredLesson, Lesson, @@ -20,6 +20,8 @@ import { changeLesson, modifyLesson, removeModule, + addLesson, + removeLesson, } from 'actions/timetables'; import { undo } from 'actions/undoHistory'; import { @@ -72,6 +74,8 @@ type Props = OwnProps & { timetableWithLessons: SemTimetableConfigWithLessons; modules: ModulesMap; activeLesson: Lesson | null; + customiseModule: ModuleCode; + customisedModules: ModuleCode[]; timetableOrientation: TimetableOrientation; showTitle: boolean; hiddenInTimetable: ModuleCode[]; @@ -80,9 +84,21 @@ type Props = OwnProps & { addModule: (semester: Semester, moduleCode: ModuleCode) => void; removeModule: (semester: Semester, moduleCode: ModuleCode) => void; modifyLesson: (lesson: Lesson) => void; - changeLesson: (semester: Semester, lesson: Lesson) => void; + changeLesson: (semester: Semester, lesson: Lesson, activeLesson: ClassNo) => void; cancelModifyLesson: () => void; undo: () => void; + addLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, + ) => void; + removeLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, + ) => void; }; type State = { @@ -159,8 +175,28 @@ class TimetableContent extends React.Component { this.props.hiddenInTimetable.includes(moduleCode); modifyCell = (lesson: ModifiableLesson, position: ClientRect) => { - if (lesson.isAvailable) { - this.props.changeLesson(this.props.semester, lesson); + if (this.props.customiseModule === lesson.moduleCode) { + this.modifiedCell = { + position, + className: getLessonIdentifier(lesson), + }; + if (lesson.isAvailable) { + this.props.addLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lesson.classNo, + ); + } else if (lesson.isActive) { + this.props.removeLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lesson.classNo, + ); + } + } else if (lesson.isAvailable && this.props.activeLesson) { + this.props.changeLesson(this.props.semester, lesson, this.props.activeLesson.classNo); resetScrollPosition(); } else if (lesson.isActive) { @@ -286,7 +322,34 @@ class TimetableContent extends React.Component { // Do not process hidden modules .filter((lesson) => !this.isHiddenInTimetable(lesson.moduleCode)); - if (activeLesson) { + if (this.props.customiseModule) { + const activeLessons = timetableLessons.filter( + (lesson) => lesson.moduleCode === this.props.customiseModule, + ); + timetableLessons = timetableLessons.filter( + (lesson) => lesson.moduleCode !== this.props.customiseModule, + ); + + const module = modules[this.props.customiseModule]; + const moduleTimetable = getModuleTimetable(module, semester); + moduleTimetable.forEach((lesson) => { + const isActiveLesson = + activeLessons.filter( + (timetableLesson) => + timetableLesson.classNo === lesson.classNo && + timetableLesson.lessonType === lesson.lessonType, + ).length > 0; + const modifiableLesson: Lesson & { isActive?: boolean; isAvailable?: boolean } = { + ...lesson, + // Inject module code in + moduleCode: this.props.customiseModule, + title: module.title, + isAvailable: !isActiveLesson, + isActive: isActiveLesson, + }; + timetableLessons.push(modifiableLesson); + }); + } else if (activeLesson) { const { moduleCode } = activeLesson; // Remove activeLesson because it will appear again timetableLessons = timetableLessons.filter( @@ -331,8 +394,9 @@ class TimetableContent extends React.Component { return { ...lesson, - isModifiable: - !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), + isModifiable: this.props.customiseModule + ? true + : !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), }; }), ), @@ -388,6 +452,7 @@ class TimetableContent extends React.Component { isScrolledHorizontally={this.state.isScrolledHorizontally} showTitle={isShowingTitle} onModifyCell={this.modifyCell} + customisedModules={this.props.customisedModules} />
)} @@ -451,6 +516,8 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { timetableWithLessons, modules, activeLesson: state.app.activeLesson, + customiseModule: state.app.customiseModule, + customisedModules: state.timetables.customisedModules[semester], timetableOrientation: state.theme.timetableOrientation, showTitle: state.theme.showTitle, hiddenInTimetable, @@ -464,4 +531,6 @@ export default connect(mapStateToProps, { changeLesson, cancelModifyLesson, undo, + addLesson, + removeLesson, })(TimetableContent); diff --git a/website/src/views/timetable/TimetableDay.tsx b/website/src/views/timetable/TimetableDay.tsx index 30dcac70c9..5c853b3ddd 100644 --- a/website/src/views/timetable/TimetableDay.tsx +++ b/website/src/views/timetable/TimetableDay.tsx @@ -6,6 +6,7 @@ import { OnHoverCell, OnModifyCell } from 'types/views'; import { convertTimeToIndex } from 'utils/timify'; import { TimePeriod } from 'types/venues'; +import { ModuleCode } from 'types/modules'; import styles from './TimetableDay.scss'; import TimetableRow from './TimetableRow'; import CurrentTimeIndicator from './CurrentTimeIndicator'; @@ -25,6 +26,7 @@ type Props = { onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; + customisedModules?: ModuleCode[]; }; // Height of timetable per hour in vertical mode @@ -87,6 +89,7 @@ const TimetableDay: React.FC = (props) => { onModifyCell={props.onModifyCell} hoverLesson={props.hoverLesson} onCellHover={props.onCellHover} + customisedModules={props.customisedModules} /> ))} diff --git a/website/src/views/timetable/TimetableModuleTable.test.tsx b/website/src/views/timetable/TimetableModuleTable.test.tsx index b1e2557f4b..0eab20c993 100644 --- a/website/src/views/timetable/TimetableModuleTable.test.tsx +++ b/website/src/views/timetable/TimetableModuleTable.test.tsx @@ -3,6 +3,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { CS1010S, CS3216, CS4243 } from '__mocks__/modules'; import { addColors } from 'test-utils/theme'; +import * as redux from 'react-redux'; import { TimetableModulesTableComponent, Props } from './TimetableModulesTable'; import styles from './TimetableModulesTable.scss'; @@ -12,6 +13,12 @@ function make(props: Partial = {}) { const hideLessonInTimetable = jest.fn(); const showLessonInTimetable = jest.fn(); const resetTombstone = jest.fn(); + const customiseLesson = jest.fn(); + const addCustomModule = jest.fn(); + const removeCustomModule = jest.fn(); + + const beta = jest.spyOn(redux, 'useSelector'); + beta.mockReturnValue(false); const wrapper = shallow( = {}) { showLessonInTimetable={showLessonInTimetable} onRemoveModule={onRemoveModule} resetTombstone={resetTombstone} + customiseLesson={customiseLesson} + customiseModule="" + addCustomModule={addCustomModule} + removeCustomModule={removeCustomModule} {...props} />, ); diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index 651fa36f5b..9acb8e0a43 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; import { sortBy } from 'lodash'; @@ -8,16 +8,19 @@ import produce from 'immer'; import { ModuleWithColor, TombstoneModule } from 'types/views'; import { ColorIndex } from 'types/timetables'; import { ModuleCode, Semester } from 'types/modules'; -import { State as StoreState } from 'types/state'; +import { State, State as StoreState } from 'types/state'; import { ModuleTableOrder } from 'types/reducers'; - -import ColorPicker from 'views/components/ColorPicker'; -import { Eye, EyeOff, Trash } from 'react-feather'; import { + customiseLesson, + addCustomModule, + removeCustomModule, hideLessonInTimetable, selectModuleColor, showLessonInTimetable, } from 'actions/timetables'; +import ColorPicker from 'views/components/ColorPicker'; +import { Eye, EyeOff, Trash, Tool, Check } from 'react-feather'; + import { getExamDate, getFormattedExamDate, renderMCs } from 'utils/modules'; import { intersperse } from 'utils/array'; import { BULLET_NBSP } from 'utils/react'; @@ -37,55 +40,102 @@ export type Props = { moduleTableOrder: ModuleTableOrder; modules: ModuleWithColor[]; tombstone: TombstoneModule | null; // Placeholder for a deleted module + customiseModule: ModuleCode; // Actions selectModuleColor: (semester: Semester, moduleCode: ModuleCode, colorIndex: ColorIndex) => void; hideLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; showLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; onRemoveModule: (moduleCode: ModuleCode) => void; + addCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; + removeCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; resetTombstone: () => void; + customiseLesson: (semester: Semester, moduleCode: ModuleCode) => void; }; export const TimetableModulesTableComponent: React.FC = (props) => { + const beta = useSelector(({ settings }: State) => settings.beta); const renderModuleActions = (module: ModuleWithColor) => { const hideBtnLabel = `${module.hiddenInTimetable ? 'Show' : 'Hide'} ${module.moduleCode}`; const removeBtnLabel = `Remove ${module.moduleCode} from timetable`; + const customBtnLabel = `Customise ${module.moduleCode} in timetable`; + const doneBtnLabel = `Done customising ${module.moduleCode} in timetable`; const { semester } = props; return (
-
- + {props.customiseModule === module.moduleCode ? ( + - - - -
+ ) : ( +
+ + + + + + + {beta && ( + + + + )} +
+ )}
); }; @@ -162,10 +212,16 @@ export const TimetableModulesTableComponent: React.FC = (props) => { }; export default connect( - (state: StoreState) => ({ moduleTableOrder: state.settings.moduleTableOrder }), + (state: StoreState) => ({ + moduleTableOrder: state.settings.moduleTableOrder, + customiseModule: state.app.customiseModule, + }), { selectModuleColor, hideLessonInTimetable, showLessonInTimetable, + customiseLesson, + addCustomModule, + removeCustomModule, }, )(React.memo(TimetableModulesTableComponent)); diff --git a/website/src/views/timetable/TimetableRow.tsx b/website/src/views/timetable/TimetableRow.tsx index 65b22f8f0e..bed1f57505 100644 --- a/website/src/views/timetable/TimetableRow.tsx +++ b/website/src/views/timetable/TimetableRow.tsx @@ -4,6 +4,7 @@ import { HoverLesson, ModifiableLesson } from 'types/timetables'; import { OnHoverCell, OnModifyCell } from 'types/views'; import { convertTimeToIndex } from 'utils/timify'; +import { ModuleCode } from 'types/modules'; import styles from './TimetableRow.scss'; import TimetableCell from './TimetableCell'; @@ -16,6 +17,7 @@ type Props = { hoverLesson?: HoverLesson | null; onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; + customisedModules?: ModuleCode[]; }; /** @@ -70,6 +72,7 @@ const TimetableRow: React.FC = (props) => { hoverLesson={props.hoverLesson} onHover={props.onCellHover} transparent={lesson.startTime === lesson.endTime} + customisedModules={props.customisedModules} {...conditionalProps} /> ); From c929c0249d3d1a904c887b75664a8b633c36f067 Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Fri, 4 Aug 2023 11:23:57 +0800 Subject: [PATCH 3/8] timetables: add customisedModules prop when migrating to V2 schema --- website/src/reducers/timetables.test.ts | 6 ++++-- website/src/reducers/timetables.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index 1589f59f91..46ef9b3674 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -260,7 +260,6 @@ describe('redux schema migration', () => { hidden: {}, academicYear: '2022/2023', archive: {}, - customisedModules: {}, _persist: { version: 1, rehydrated: false, @@ -285,7 +284,10 @@ describe('redux schema migration', () => { hidden: {}, academicYear: '2022/2023', archive: {}, - customisedModules: {}, + customisedModules: { + [1]: [], + [2]: [], + }, _persist: { version: 1, // version kept the same because the framework does not support it in unit tests rehydrated: false, diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index 0500244fa8..7fb995ad3e 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -5,7 +5,7 @@ import { createMigrate, PersistedState } from 'redux-persist'; import { PersistConfig } from 'storage/persistReducer'; import { ModuleCode } from 'types/modules'; import { ModuleLessonConfig, SemTimetableConfig, TimetableConfig } from 'types/timetables'; -import { ColorMapping, TimetablesState } from 'types/reducers'; +import { ColorMapping, CustomisedModulesMap, TimetablesState } from 'types/reducers'; import config from 'config'; import { @@ -27,7 +27,7 @@ import { SET_EXPORTED_DATA } from 'actions/constants'; import { Actions } from '../types/actions'; // Migration from state V1 -> V2 -type TimetableStateV1 = Omit & { +type TimetableStateV1 = Omit & { lessons: { [semester: string]: { [moduleCode: string]: { [lessonType: string]: string } } }; }; export function migrateV1toV2( @@ -35,8 +35,11 @@ export function migrateV1toV2( ): TimetablesState & PersistedState { const newLessons: TimetableConfig = {}; const oldLessons = oldState.lessons; + const newCustomisedModules: CustomisedModulesMap = {}; Object.entries(oldLessons).forEach(([semester, modules]) => { + newCustomisedModules[semester] = []; + Object.entries(modules).forEach(([moduleCode, lessons]) => { const newSemester: { [moduleCode: string]: { [lessonType: string]: string[] } } = { [moduleCode]: {}, @@ -56,6 +59,7 @@ export function migrateV1toV2( return { ...oldState, lessons: newLessons, + customisedModules: newCustomisedModules, }; } From 32c561f06399216550e0cc35017f12d6afd944e3 Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Fri, 4 Aug 2023 11:58:07 +0800 Subject: [PATCH 4/8] BetaToggle: mention the existence of TA view --- website/src/views/settings/BetaToggle.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/website/src/views/settings/BetaToggle.tsx b/website/src/views/settings/BetaToggle.tsx index 968a190bcc..9d51a536b3 100644 --- a/website/src/views/settings/BetaToggle.tsx +++ b/website/src/views/settings/BetaToggle.tsx @@ -4,7 +4,10 @@ import ExternalLink from 'views/components/ExternalLink'; import config from 'config'; import styles from './SettingsContainer.scss'; -export const currentTests = ['Course planner: plan courses in future semesters']; +export const currentTests = [ + 'Course planner: plan courses in future semesters', + 'TA view: customise the visibility of courses in your timetable', +]; type Props = { betaTester: boolean; From 0d1b65c44a2d587562b5c10fe3353dc962a2bc60 Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Fri, 4 Aug 2023 16:52:33 +0800 Subject: [PATCH 5/8] refactor: make some vars non-nullable --- .../views/components/module-info/LessonTimetable.tsx | 1 + website/src/views/settings/SettingsContainer.tsx | 2 +- website/src/views/tetris/TetrisGame.tsx | 3 ++- website/src/views/timetable/Timetable.tsx | 2 +- website/src/views/timetable/TimetableCell.test.tsx | 4 +++- website/src/views/timetable/TimetableCell.tsx | 9 ++++----- website/src/views/timetable/TimetableContent.tsx | 3 ++- website/src/views/timetable/TimetableDay.tsx | 2 +- .../views/timetable/TimetableModuleTable.test.tsx | 1 + .../src/views/timetable/TimetableModulesTable.tsx | 12 +++--------- website/src/views/timetable/TimetableRow.tsx | 2 +- website/src/views/venues/VenueDetails.tsx | 1 + 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/website/src/views/components/module-info/LessonTimetable.tsx b/website/src/views/components/module-info/LessonTimetable.tsx index 1d9c66a45f..83c2d9a09f 100644 --- a/website/src/views/components/module-info/LessonTimetable.tsx +++ b/website/src/views/components/module-info/LessonTimetable.tsx @@ -32,6 +32,7 @@ const SemesterLessonTimetable: FC<{ semesterData?: SemesterData }> = ({ semester history.push(venuePage(lesson.venue))} + customisedModules={[]} /> ); }; diff --git a/website/src/views/settings/SettingsContainer.tsx b/website/src/views/settings/SettingsContainer.tsx index 5912759748..c575289950 100644 --- a/website/src/views/settings/SettingsContainer.tsx +++ b/website/src/views/settings/SettingsContainer.tsx @@ -129,7 +129,7 @@ const SettingsContainer: React.FC = ({

- +
diff --git a/website/src/views/tetris/TetrisGame.tsx b/website/src/views/tetris/TetrisGame.tsx index 77a6e72310..8374afabe7 100644 --- a/website/src/views/tetris/TetrisGame.tsx +++ b/website/src/views/tetris/TetrisGame.tsx @@ -101,6 +101,7 @@ function renderPiece(tiles: Board) { hoverLesson={null} onModifyCell={noop} onCellHover={noop} + customisedModules={[]} /> ); } @@ -407,7 +408,7 @@ export default class TetrisGame extends PureComponent {
{this.renderOverlay()} - +
diff --git a/website/src/views/timetable/Timetable.tsx b/website/src/views/timetable/Timetable.tsx index 3fb0803f6a..6e50891675 100644 --- a/website/src/views/timetable/Timetable.tsx +++ b/website/src/views/timetable/Timetable.tsx @@ -30,7 +30,7 @@ type Props = TimerData & { showTitle?: boolean; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; - customisedModules?: ModuleCode[]; + customisedModules: ModuleCode[]; }; type State = { diff --git a/website/src/views/timetable/TimetableCell.test.tsx b/website/src/views/timetable/TimetableCell.test.tsx index b958b7044b..5b3e34ecc8 100644 --- a/website/src/views/timetable/TimetableCell.test.tsx +++ b/website/src/views/timetable/TimetableCell.test.tsx @@ -38,7 +38,9 @@ function make(additionalProps: Partial = {}) { return { onClick, onHover: props.onHover, - wrapper: shallow(), + wrapper: shallow( + , + ), }; } diff --git a/website/src/views/timetable/TimetableCell.tsx b/website/src/views/timetable/TimetableCell.tsx index d4a0fa1bc5..e32b874797 100644 --- a/website/src/views/timetable/TimetableCell.tsx +++ b/website/src/views/timetable/TimetableCell.tsx @@ -26,7 +26,7 @@ type Props = { onClick?: (position: ClientRect) => void; hoverLesson?: HoverLesson | null; transparent: boolean; - customisedModules?: ModuleCode[]; + customisedModules: ModuleCode[]; }; const lessonDateFormat = 'MMM dd'; @@ -87,7 +87,8 @@ function formatWeekRange(weekRange: WeekRange) { * might explore other representations e.g. grouped lessons */ const TimetableCell: React.FC = (props) => { - const { lesson, showTitle, onClick, onHover, hoverLesson, transparent } = props; + const { lesson, showTitle, onClick, onHover, hoverLesson, transparent, customisedModules } = + props; const moduleName = showTitle ? `${lesson.moduleCode} ${lesson.title}` : lesson.moduleCode; const Cell = props.onClick ? 'button' : 'div'; @@ -134,9 +135,7 @@ const TimetableCell: React.FC = (props) => {
{moduleName} - {props.customisedModules && props.customisedModules.includes(lesson.moduleCode) - ? '*' - : null} + {customisedModules.includes(lesson.moduleCode) && '*'}
{LESSON_TYPE_ABBREV[lesson.lessonType]} [{lesson.classNo}] diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index 8617ebf54e..a11d98b873 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -258,6 +258,7 @@ class TimetableContent extends React.Component { readOnly={this.props.readOnly} tombstone={tombstone} resetTombstone={this.resetTombstone} + customisedModules={this.props.customisedModules} /> ); @@ -339,7 +340,7 @@ class TimetableContent extends React.Component { timetableLesson.classNo === lesson.classNo && timetableLesson.lessonType === lesson.lessonType, ).length > 0; - const modifiableLesson: Lesson & { isActive?: boolean; isAvailable?: boolean } = { + const modifiableLesson: Lesson & { isActive: boolean; isAvailable: boolean } = { ...lesson, // Inject module code in moduleCode: this.props.customiseModule, diff --git a/website/src/views/timetable/TimetableDay.tsx b/website/src/views/timetable/TimetableDay.tsx index 5c853b3ddd..d7f43c1691 100644 --- a/website/src/views/timetable/TimetableDay.tsx +++ b/website/src/views/timetable/TimetableDay.tsx @@ -26,7 +26,7 @@ type Props = { onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; - customisedModules?: ModuleCode[]; + customisedModules: ModuleCode[]; }; // Height of timetable per hour in vertical mode diff --git a/website/src/views/timetable/TimetableModuleTable.test.tsx b/website/src/views/timetable/TimetableModuleTable.test.tsx index 0eab20c993..8e5da3ec09 100644 --- a/website/src/views/timetable/TimetableModuleTable.test.tsx +++ b/website/src/views/timetable/TimetableModuleTable.test.tsx @@ -35,6 +35,7 @@ function make(props: Partial = {}) { resetTombstone={resetTombstone} customiseLesson={customiseLesson} customiseModule="" + customisedModules={[]} addCustomModule={addCustomModule} removeCustomModule={removeCustomModule} {...props} diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index 9acb8e0a43..655bc97cfe 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -70,9 +70,7 @@ export const TimetableModulesTableComponent: React.FC = (props) => { type="button" className={classnames('btn btn-outline-secondary btn-svg', styles.moduleAction)} aria-label={removeBtnLabel} - onClick={() => { - props.customiseLesson(semester, ''); - }} + onClick={() => props.customiseLesson(semester, '')} > @@ -118,12 +116,8 @@ export const TimetableModulesTableComponent: React.FC = (props) => { type="button" className={classnames('btn btn-outline-secondary btn-svg', styles.moduleAction)} aria-label={customBtnLabel} - disabled={ - props.customiseModule !== '' && props.customiseModule !== module.moduleCode - } - hidden={ - props.customiseModule !== '' && props.customiseModule !== module.moduleCode - } + disabled={!!props.customiseModule && props.customiseModule !== module.moduleCode} + hidden={!!props.customiseModule && props.customiseModule !== module.moduleCode} onClick={() => { // TODO: add modal for warning props.addCustomModule(semester, module.moduleCode); diff --git a/website/src/views/timetable/TimetableRow.tsx b/website/src/views/timetable/TimetableRow.tsx index bed1f57505..1531950a08 100644 --- a/website/src/views/timetable/TimetableRow.tsx +++ b/website/src/views/timetable/TimetableRow.tsx @@ -17,7 +17,7 @@ type Props = { hoverLesson?: HoverLesson | null; onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; - customisedModules?: ModuleCode[]; + customisedModules: ModuleCode[]; }; /** diff --git a/website/src/views/venues/VenueDetails.tsx b/website/src/views/venues/VenueDetails.tsx index 3d7a93d01c..3bcfa09986 100644 --- a/website/src/views/venues/VenueDetails.tsx +++ b/website/src/views/venues/VenueDetails.tsx @@ -92,6 +92,7 @@ const VenueDetailsComponent: FC = ({ highlightPeriod={highlightPeriod} isVerticalOrientation={narrowViewport} onModifyCell={navigateToLesson} + customisedModules={[]} />
From f29b21a2e2f1b243dea41f4752d0c789823e9a40 Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Fri, 4 Aug 2023 16:52:48 +0800 Subject: [PATCH 6/8] feat(timetable): display asterisk on module list as well --- website/src/views/timetable/TimetableModulesTable.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index 655bc97cfe..1dd122de06 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -41,6 +41,7 @@ export type Props = { modules: ModuleWithColor[]; tombstone: TombstoneModule | null; // Placeholder for a deleted module customiseModule: ModuleCode; + customisedModules: ModuleCode[]; // Actions selectModuleColor: (semester: Semester, moduleCode: ModuleCode, colorIndex: ColorIndex) => void; @@ -135,7 +136,7 @@ export const TimetableModulesTableComponent: React.FC = (props) => { }; const renderModule = (module: ModuleWithColor) => { - const { semester, readOnly, tombstone, resetTombstone } = props; + const { semester, readOnly, tombstone, resetTombstone, customisedModules } = props; if (tombstone && tombstone.moduleCode === module.moduleCode) { return ; @@ -167,6 +168,7 @@ export const TimetableModulesTableComponent: React.FC = (props) => { {!readOnly && renderModuleActions(module)} {module.moduleCode} {module.title} + {customisedModules.includes(module.moduleCode) && '*'}
{intersperse(secondRowText, BULLET_NBSP)}
From 61558e3449c02adda1c24657a13e0b4f1ba7ac3c Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Fri, 4 Aug 2023 17:54:44 +0800 Subject: [PATCH 7/8] timetable: fix more nullability issues --- website/src/actions/timetables.test.ts | 6 ++++-- website/src/actions/timetables.ts | 11 +++-------- website/src/types/reducers.ts | 2 +- website/src/views/timetable/TimetableContent.tsx | 3 ++- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/website/src/actions/timetables.test.ts b/website/src/actions/timetables.test.ts index 8d253ab282..87f78daccd 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -53,12 +53,13 @@ describe('fillTimetableBlanks', () => { const moduleBank = { modules: { CS1010S, CS3216 } }; const timetablesState = (semester: Semester, timetable: SemTimetableConfig) => ({ lessons: { [semester]: timetable }, + customisedModules: { [semester]: [] }, }); const semester = 1; const action = actions.validateTimetable(semester); test('do nothing if timetable is already full', () => { - const timetable = { + const timetable: SemTimetableConfig = { CS1010S: { Lecture: ['1'], Tutorial: ['1'], @@ -66,6 +67,7 @@ describe('fillTimetableBlanks', () => { }, }; + // TODO(zwliew): Correctly type all the `state: any` declarations in this function and the rest of the codebase. const state: any = { timetables: timetablesState(semester, timetable), moduleBank }; const dispatch = jest.fn(); action(dispatch, () => state); @@ -74,7 +76,7 @@ describe('fillTimetableBlanks', () => { }); test('fill missing lessons with randomly generated modules', () => { - const timetable = { + const timetable: SemTimetableConfig = { CS1010S: { Lecture: ['1'], Tutorial: ['1'], diff --git a/website/src/actions/timetables.ts b/website/src/actions/timetables.ts index c1a73c833c..ce7763badc 100644 --- a/website/src/actions/timetables.ts +++ b/website/src/actions/timetables.ts @@ -236,14 +236,9 @@ export function validateTimetable(semester: Semester) { const module = moduleBank.modules[moduleCode]; if (!module) return; - // If the module is customised, we do not validate it - if ( - timetables.customisedModules && - timetables.customisedModules[semester] && - timetables.customisedModules[semester].includes(moduleCode) - ) { - return; - } + // Do not validate customised modules. + if (timetables.customisedModules[semester]?.includes(moduleCode)) return; + const [validatedLessonConfig, changedLessonTypes] = validateModuleLessons( semester, lessonConfig, diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index 406c7bc658..24bb6b7389 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -113,7 +113,7 @@ export type SettingsState = { export type ColorMapping = { [moduleCode: string]: ColorIndex }; export type SemesterColorMap = { [semester: string]: ColorMapping }; export type HiddenModulesMap = { [semester: string]: ModuleCode[] }; -export type CustomisedModulesMap = { [semester: string]: ModuleCode[] }; +export type CustomisedModulesMap = { [semester: string]: ModuleCode[] | undefined }; export type TimetablesState = { readonly lessons: TimetableConfig; diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index a11d98b873..463f506022 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -509,6 +509,7 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { const { semester, timetable } = ownProps; const { modules } = state.moduleBank; const timetableWithLessons = hydrateSemTimetableWithLessons(timetable, modules, semester); + // TODO(zwliew): fix the type signature of state.timetables.hidden[semester] const hiddenInTimetable = state.timetables.hidden[semester] || []; return { @@ -518,7 +519,7 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { modules, activeLesson: state.app.activeLesson, customiseModule: state.app.customiseModule, - customisedModules: state.timetables.customisedModules[semester], + customisedModules: state.timetables.customisedModules[semester] ?? [], timetableOrientation: state.theme.timetableOrientation, showTitle: state.theme.showTitle, hiddenInTimetable, From 551c8e19b6c67cca5df3ad06c1d40c8ea92b90ff Mon Sep 17 00:00:00 2001 From: Zhao Wei Liew Date: Thu, 10 Aug 2023 23:08:08 +0800 Subject: [PATCH 8/8] reducers: clean up some code --- website/src/reducers/timetables.ts | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index 7fb995ad3e..c8c12f8c3a 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -34,26 +34,20 @@ export function migrateV1toV2( oldState: TimetableStateV1 & PersistedState, ): TimetablesState & PersistedState { const newLessons: TimetableConfig = {}; - const oldLessons = oldState.lessons; const newCustomisedModules: CustomisedModulesMap = {}; - Object.entries(oldLessons).forEach(([semester, modules]) => { + Object.entries(oldState.lessons).forEach(([semester, modules]) => { newCustomisedModules[semester] = []; + // Migrate existing lessons to V2 format. + const newSemester: SemTimetableConfig = {}; Object.entries(modules).forEach(([moduleCode, lessons]) => { - const newSemester: { [moduleCode: string]: { [lessonType: string]: string[] } } = { - [moduleCode]: {}, - }; - - Object.entries(lessons).forEach(([lessonType, lessonValue]) => { - const lessonArray = [lessonValue]; - newSemester[moduleCode][lessonType] = lessonArray; + newSemester[moduleCode] = {}; + Object.entries(lessons).forEach(([type, classNum]) => { + newSemester[moduleCode][type] = [classNum]; }); - if (!newLessons[semester]) { - newLessons[semester] = {}; - } - Object.assign(newLessons[semester], newSemester); }); + newLessons[semester] = newSemester; }); return { @@ -123,7 +117,7 @@ function moduleLessonConfig( switch (action.type) { case CHANGE_LESSON: { const { classNo, lessonType } = action.payload; - if (!(classNo && lessonType)) return state; + if (!classNo || !lessonType) return state; return { ...state, [lessonType]: [ @@ -136,7 +130,7 @@ function moduleLessonConfig( return action.payload.lessonConfig; case ADD_LESSON: { const { classNo, lessonType } = action.payload; - if (!(classNo && lessonType)) return state; + if (!classNo || !lessonType) return state; return { ...state, [lessonType]: [...state[lessonType], classNo], @@ -144,7 +138,7 @@ function moduleLessonConfig( } case REMOVE_LESSON: { const { classNo, lessonType } = action.payload; - if (!(classNo && lessonType)) return state; + if (!classNo || !lessonType) return state; return { ...state, [lessonType]: state[lessonType].filter((lesson) => lesson !== classNo),