diff --git a/website/src/actions/__snapshots__/timetables.test.ts.snap b/website/src/actions/__snapshots__/timetables.test.ts.snap index edba593353..eecede1e4f 100644 --- a/website/src/actions/__snapshots__/timetables.test.ts.snap +++ b/website/src/actions/__snapshots__/timetables.test.ts.snap @@ -1,5 +1,44 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`addCustomModule should add the custom module defined 1`] = ` +{ + "payload": { + "lessons": [ + { + "classNo": "01", + "day": "Monday", + "endTime": "0900", + "isCustom": true, + "lessonType": "Lecture", + "moduleCode": "CS1101S", + "startTime": "0800", + "title": "Programming Methodology", + "venue": "COM1-0330", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + ], + }, + ], + "moduleCode": "CS1101S", + "semester": 2, + "title": "Programming Methodology", + }, + "type": "ADD_CUSTOM_MODULE", +} +`; + exports[`cancelModifyLesson should not have payload 1`] = ` { "payload": null, @@ -19,6 +58,16 @@ exports[`changeLesson should return updated information to change lesson 1`] = ` } `; +exports[`deleteCustomModule should modify the custom module 1`] = ` +{ + "payload": { + "moduleCode": "CS1101S", + "semester": 2, + }, + "type": "DELETE_CUSTOM_MODULE", +} +`; + exports[`fetchTimetableModules should fetch modules 1`] = ` [ [ @@ -47,6 +96,46 @@ exports[`hide/show timetable modules should dispatch a module code for showing 1 } `; +exports[`modifyCustomModule should modify the custom module 1`] = ` +{ + "payload": { + "lessons": [ + { + "classNo": "01", + "day": "Monday", + "endTime": "0900", + "isCustom": true, + "lessonType": "Lecture", + "moduleCode": "CS2030", + "startTime": "0800", + "title": "Programming Methodology", + "venue": "COM1-0330", + "weeks": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + ], + }, + ], + "moduleCode": "CS2030", + "oldModuleCode": "CS1101S", + "semester": 2, + "title": "Programming Methodology", + }, + "type": "MODIFY_CUSTOM_MODULE", +} +`; + exports[`modifyLesson should return lesson payload 1`] = ` { "payload": { diff --git a/website/src/actions/timetables.test.ts b/website/src/actions/timetables.test.ts index e021893fa4..2b973f916a 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -1,5 +1,5 @@ import { ModuleCode, Semester } from 'types/modules'; -import { SemTimetableConfig, Lesson } from 'types/timetables'; +import { SemTimetableConfig, Lesson, CustomModuleLesson } from 'types/timetables'; import lessons from '__mocks__/lessons-array.json'; import { CS1010S, CS3216 } from '__mocks__/modules'; @@ -129,6 +129,60 @@ describe('hide/show timetable modules', () => { }); }); +describe(actions.addCustomModule, () => { + const semester: Semester = 2; + const moduleCode: ModuleCode = 'CS1101S'; + const lesson: CustomModuleLesson = { + classNo: '01', + day: 'Monday', + startTime: '0800', + endTime: '0900', + lessonType: 'Lecture', + venue: 'COM1-0330', + moduleCode, + title: 'Programming Methodology', + isCustom: true, + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }; + + test('should add the custom module defined', () => { + expect(actions.addCustomModule(semester, moduleCode, lesson.title, [lesson])).toMatchSnapshot(); + }); +}); + +describe(actions.modifyCustomModule, () => { + const semester: Semester = 2; + const moduleCode: ModuleCode = 'CS1101S'; + const newModuleCode: ModuleCode = 'CS2030'; + const lesson: CustomModuleLesson = { + classNo: '01', + day: 'Monday', + startTime: '0800', + endTime: '0900', + lessonType: 'Lecture', + venue: 'COM1-0330', + moduleCode: newModuleCode, + title: 'Programming Methodology', + isCustom: true, + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }; + + test('should modify the custom module', () => { + expect( + actions.modifyCustomModule(semester, moduleCode, newModuleCode, lesson.title, [lesson]), + ).toMatchSnapshot(); + }); +}); + +describe(actions.deleteCustomModule, () => { + const semester: Semester = 2; + const moduleCode: ModuleCode = 'CS1101S'; + + test('should modify the custom module', () => { + expect(actions.deleteCustomModule(semester, moduleCode)).toMatchSnapshot(); + }); +}); + describe(actions.fetchTimetableModules, () => { const moduleCodes: any = { CS1010S: {}, diff --git a/website/src/actions/timetables.ts b/website/src/actions/timetables.ts index bfa8e1f30a..4b5be1d5a6 100644 --- a/website/src/actions/timetables.ts +++ b/website/src/actions/timetables.ts @@ -8,8 +8,15 @@ import type { TaModulesConfig, } from 'types/timetables'; import type { Dispatch, GetState } from 'types/redux'; -import type { ColorMapping } from 'types/reducers'; -import type { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; +import type { ColorMapping, CustomModuleLessonData } from 'types/reducers'; +import type { + ClassNo, + CustomLesson, + LessonType, + Module, + ModuleCode, + Semester, +} from 'types/modules'; import { fetchModule } from 'actions/moduleBank'; import { openNotification } from 'actions/app'; @@ -24,19 +31,22 @@ import { getModuleTimetable } from 'utils/modules'; // Actions that should not be used directly outside of thunks export const SET_TIMETABLE = 'SET_TIMETABLE' as const; export const ADD_MODULE = 'ADD_MODULE' as const; +export const SET_CUSTOM_IMPORTED = 'SET_CUSTOM_IMPORTED' as const; export const SET_HIDDEN_IMPORTED = 'SET_HIDDEN_IMPORTED' as const; export const SET_TA_IMPORTED = 'SET_TA_IMPORTED' as const; +export const TEMP_IMPORTED_SEM = 'TEMP_IMPORTED_SEM' as const; export const Internal = { setTimetable( semester: Semester, timetable: SemTimetableConfig | undefined, colors?: ColorMapping, hiddenModules?: ModuleCode[], + customModules?: CustomModuleLessonData, taModules?: TaModulesConfig, ) { return { type: SET_TIMETABLE, - payload: { semester, timetable, colors, hiddenModules, taModules }, + payload: { semester, timetable, colors, hiddenModules, customModules, taModules }, }; }, @@ -172,7 +182,8 @@ export function setTimetable( semester, validatedTimetable, colors, - getState().timetables.hidden[semester] ?? [], + getState().timetables.hidden[TEMP_IMPORTED_SEM] || [], + getState().timetables.customModules[TEMP_IMPORTED_SEM] || [], getState().timetables.ta[semester] ?? {}, ), ); @@ -219,6 +230,20 @@ export function fetchTimetableModules(timetables: SemTimetableConfig[]) { }; } +export function setCustomModulesFromImport( + semester: Semester, + customModules: CustomModuleLessonData, +) { + return (dispatch: Dispatch) => dispatch(setCustomImported(semester, customModules)); +} + +export function setCustomImported(semester: Semester, customModules: CustomModuleLessonData) { + return { + type: SET_CUSTOM_IMPORTED, + payload: { semester, customModules }, + }; +} + export function setHiddenModulesFromImport(semester: Semester, hiddenModules: ModuleCode[]) { return (dispatch: Dispatch) => dispatch(setHiddenImported(semester, hiddenModules)); } @@ -273,6 +298,54 @@ export function showLessonInTimetable(semester: Semester, moduleCode: ModuleCode }; } +export const ADD_CUSTOM_MODULE = 'ADD_CUSTOM_MODULE' as const; +export function addCustomModule( + semester: Semester, + moduleCode: ModuleCode, + title: string, + lessons: CustomLesson[], +) { + return { + type: ADD_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + title, + lessons, + }, + }; +} + +export const MODIFY_CUSTOM_MODULE = 'MODIFY_CUSTOM_MODULE' as const; +export function modifyCustomModule( + semester: Semester, + oldModuleCode: ModuleCode, + moduleCode: ModuleCode, + title: string, + lessons: CustomLesson[], +) { + return { + type: MODIFY_CUSTOM_MODULE, + payload: { + semester, + oldModuleCode, + moduleCode, + title, + lessons, + }, + }; +} + +export const DELETE_CUSTOM_MODULE = 'DELETE_CUSTOM_MODULE' as const; +export function deleteCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: DELETE_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} export const ADD_TA_LESSON_IN_TIMETABLE = 'ADD_TA_LESSON_IN_TIMETABLE' as const; export function addTaLessonInTimetable( semester: Semester, diff --git a/website/src/entry/export/TimetableOnly.tsx b/website/src/entry/export/TimetableOnly.tsx index 58853ade4a..8769579710 100644 --- a/website/src/entry/export/TimetableOnly.tsx +++ b/website/src/entry/export/TimetableOnly.tsx @@ -19,6 +19,7 @@ export default class TimetableOnly extends Component { semester: 1, timetable: {}, colors: {}, + custom: {}, hidden: [], ta: {}, }; @@ -27,8 +28,9 @@ export default class TimetableOnly extends Component { const { store } = this.props; const theme = store.getState().theme.id; - const { semester, timetable, colors, hidden, ta } = this.state; - const filledColors = fillColorMapping(timetable, colors); + // TODO handle exportable custom modules + const { semester, timetable, colors, hidden, ta, custom } = this.state; + const filledColors = fillColorMapping(timetable, colors, []); return ( @@ -39,6 +41,7 @@ export default class TimetableOnly extends Component { semester={semester} timetable={timetable} colors={filledColors} + customImportedModules={custom} hiddenImportedModules={hidden} taImportedModules={ta} readOnly diff --git a/website/src/reducers/index.test.ts b/website/src/reducers/index.test.ts index fa421a4c5e..fcd5ef615d 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -69,6 +69,7 @@ test('reducers should set export data state', () => { }, }, }, + customModules: {}, colors: { [1]: { CS3216: 1, diff --git a/website/src/reducers/index.ts b/website/src/reducers/index.ts index 4f789a7527..4021491708 100644 --- a/website/src/reducers/index.ts +++ b/website/src/reducers/index.ts @@ -1,4 +1,4 @@ -import { REMOVE_MODULE, SET_TIMETABLE } from 'actions/timetables'; +import { DELETE_CUSTOM_MODULE, REMOVE_MODULE, SET_TIMETABLE } from 'actions/timetables'; import persistReducer from 'storage/persistReducer'; import { State } from 'types/state'; @@ -29,7 +29,7 @@ const planner = persistReducer('planner', plannerReducer, plannerPersistConfig); const defaultState = {} as unknown as State; const undoReducer = createUndoReducer({ limit: 1, - actionsToWatch: [REMOVE_MODULE, SET_TIMETABLE], + actionsToWatch: [REMOVE_MODULE, SET_TIMETABLE, DELETE_CUSTOM_MODULE], storedKeyPaths: ['timetables', 'theme.colors'], }); diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index d9f2f3083b..12e070cb52 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -1,4 +1,4 @@ -import { PersistConfig } from 'redux-persist/es/types'; +import config from 'config'; import reducer, { defaultTimetableState, persistConfig } from 'reducers/timetables'; import { ADD_MODULE, @@ -9,11 +9,15 @@ import { showLessonInTimetable, setHiddenImported, Internal, + addCustomModule, + modifyCustomModule, + deleteCustomModule, addTaLessonInTimetable, removeTaLessonInTimetable, } from 'actions/timetables'; import { TimetablesState } from 'types/reducers'; -import config from 'config'; +import { PersistConfig } from 'redux-persist/es/types'; +import { CustomModuleLesson } from 'types/timetables'; const initialState = defaultTimetableState; @@ -74,6 +78,7 @@ describe('color reducers', () => { timetable: { CS1010S: {} }, colors: { CS1010S: 0 }, hiddenModules: [], + customModules: {}, taModules: {}, }, }).colors[1], @@ -126,6 +131,198 @@ describe('hidden module reducer', () => { }); }); +describe('custom modules reducer', () => { + const lesson: CustomModuleLesson = { + classNo: '01', + day: 'Monday', + startTime: '0800', + endTime: '0900', + lessonType: 'Lecture', + venue: 'COM1-0330', + moduleCode: 'CS1101S', + title: 'Programming Methodology', + isCustom: true, + weeks: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13], + }; + test('should allow new custom modules', () => { + expect( + reducer(initialState, addCustomModule(1, 'CS1101S', lesson.title, [lesson])), + ).toMatchObject({ + customModules: { + '1': { + CS1101S: { title: lesson.title, lessons: [lesson] }, + }, + }, + }); + }); + + test('should allow changing of custom modules', () => { + expect( + reducer( + { + ...initialState, + customModules: { + '1': { + CS1101S: { title: lesson.title, lessons: [lesson] }, + }, + }, + }, + modifyCustomModule(1, 'CS1101S', 'CS2030', lesson.title, [lesson]), + ), + ).toMatchObject({ + customModules: { + '1': { + CS2030: { title: lesson.title, lessons: [lesson] }, + }, + }, + }); + }); + + test('should allow changing of custom modules', () => { + expect( + reducer( + { + ...initialState, + customModules: { + '1': { + CS1101S: { title: lesson.title, lessons: [lesson] }, + }, + }, + }, + deleteCustomModule(1, 'CS1101S'), + ), + ).toMatchObject({ + customModules: {}, + }); + }); +}); + +describe('lesson reducer', () => { + test('should allow lesson config to be set', () => { + expect( + reducer( + { + ...initialState, + lessons: { + [1]: { + CS1010S: { + Lecture: '1', + Recitation: '2', + }, + CS3216: { + Lecture: '1', + }, + }, + [2]: { + CS3217: { + Lecture: '1', + }, + }, + }, + }, + setLessonConfig(1, 'CS1010S', { + Lecture: '2', + Recitation: '3', + Tutorial: '4', + }), + ), + ).toMatchObject({ + lessons: { + [1]: { + CS1010S: { + Lecture: '2', + Recitation: '3', + Tutorial: '4', + }, + CS3216: { + Lecture: '1', + }, + }, + [2]: { + CS3217: { + Lecture: '1', + }, + }, + }, + }); + }); +}); + +describe('stateReconciler', () => { + const oldArchive = { + '2015/2016': { + [1]: { + GET1006: { + Lecture: '1', + }, + }, + }, + }; + + const oldLessons = { + [1]: { + CS1010S: { + Lecture: '1', + Recitation: '2', + }, + }, + [2]: { + CS3217: { + Lecture: '1', + }, + }, + }; + + const inbound: TimetablesState = { + lessons: oldLessons, + colors: { + [1]: { + CS1010S: 1, + }, + [2]: { + CS3217: 2, + }, + }, + hidden: { + [1]: ['CS1010S'], + }, + ta: {}, + academicYear: config.academicYear, + archive: oldArchive, + customModules: {}, + }; + + const { stateReconciler } = persistConfig; + if (!stateReconciler) { + throw new Error('No stateReconciler'); + } + + const reconcilerPersistConfig = { debug: false } as PersistConfig; + + test('should return inbound state when academic year is the same', () => { + expect(stateReconciler(inbound, initialState, initialState, reconcilerPersistConfig)).toEqual( + inbound, + ); + }); + + test('should archive old timetables and clear state when academic year is different', () => { + const oldInbound = { + ...inbound, + academicYear: '2016/2017', + }; + + expect( + stateReconciler(oldInbound, initialState, initialState, reconcilerPersistConfig), + ).toEqual({ + ...initialState, + archive: { + ...oldArchive, + '2016/2017': oldLessons, + }, + }); + }); +}); + describe('TA module reducer', () => { const withTaModules: TimetablesState = { ...initialState, @@ -273,6 +470,7 @@ describe('stateReconciler', () => { hidden: { [1]: ['CS1010S'], }, + customModules: {}, ta: {}, academicYear: config.academicYear, archive: oldArchive, @@ -310,13 +508,6 @@ describe('stateReconciler', () => { }); describe('import timetable', () => { - const stateWithHidden = { - ...initialState, - hidden: { - [1]: ['CS1101S', 'CS1231S'], - }, - }; - test('should have hidden modules set when importing hidden', () => { expect( reducer(initialState, setHiddenImported(1, ['CS1101S', 'CS1231S'])).hidden, @@ -326,20 +517,46 @@ describe('import timetable', () => { // Should change hidden modules when a new set of modules is imported expect( - reducer(stateWithHidden, setHiddenImported(1, ['CS2100', 'CS2103T'])).hidden, + reducer( + { + ...initialState, + hidden: { + [1]: ['CS1101S', 'CS1231S'], + }, + }, + setHiddenImported(1, ['CS2100', 'CS2103T']), + ).hidden, ).toMatchObject({ [1]: ['CS2100', 'CS2103T'], }); // should delete hidden modules when there are none - expect(reducer(stateWithHidden, setHiddenImported(1, [])).hidden).toMatchObject({ + expect( + reducer( + { + ...initialState, + hidden: { + [1]: ['CS1101S', 'CS1231S'], + }, + }, + setHiddenImported(1, []), + ).hidden, + ).toMatchObject({ [1]: [], }); }); test('should copy over hidden modules when deciding to replace saved timetable', () => { expect( - reducer(stateWithHidden, Internal.setTimetable(1, {}, {}, stateWithHidden.hidden[1])).hidden, + reducer( + { + ...initialState, + hidden: { + [1]: ['CS1101S', 'CS1231S'], + }, + }, + Internal.setTimetable(1, {}, {}, ['CS1101S', 'CS1231S']), + ).hidden, ).toMatchObject({ '1': ['CS1101S', 'CS1231S'], }); diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index bcea4e5ea6..88f7672c02 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -5,13 +5,17 @@ import { createMigrate } from 'redux-persist'; import { PersistConfig } from 'storage/persistReducer'; import { ClassNo, LessonType, ModuleCode } from 'types/modules'; import { ModuleLessonConfig, SemTimetableConfig, TaModulesConfig } from 'types/timetables'; -import { ColorMapping, TimetablesState } from 'types/reducers'; +import { ColorMapping, CustomModuleLessonData, TimetablesState } from 'types/reducers'; import config from 'config'; import { + ADD_CUSTOM_MODULE, ADD_MODULE, CHANGE_LESSON, + TEMP_IMPORTED_SEM, + DELETE_CUSTOM_MODULE, HIDE_LESSON_IN_TIMETABLE, + MODIFY_CUSTOM_MODULE, REMOVE_MODULE, RESET_TIMETABLE, SELECT_MODULE_COLOR, @@ -21,6 +25,7 @@ import { ADD_TA_LESSON_IN_TIMETABLE, SET_TIMETABLE, SHOW_LESSON_IN_TIMETABLE, + SET_CUSTOM_IMPORTED, REMOVE_TA_LESSON_IN_TIMETABLE, DISABLE_TA_MODE_IN_TIMETABLE, } from 'actions/timetables'; @@ -49,9 +54,18 @@ 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!, }), + 3: (state) => ({ + ...state, + customModules: {}, + // FIXME: Remove the next line when _persist is optional again. + // Cause: https://github.com/rt2zz/redux-persist/pull/919 + // Issue: https://github.com/rt2zz/redux-persist/pull/1170 + // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain + _persist: state?._persist!, + }), }), /* eslint-enable */ - version: 2, + version: 3, // Our own state reconciler archives old timetables if the acad year is different, // otherwise use the persisted timetable state @@ -144,12 +158,14 @@ function semColors(state: ColorMapping = DEFAULT_SEM_COLOR_MAP, action: Actions) switch (action.type) { case ADD_MODULE: + case ADD_CUSTOM_MODULE: return { ...state, [moduleCode]: getNewColor(values(state)), }; case REMOVE_MODULE: + case DELETE_CUSTOM_MODULE: return omit(state, moduleCode); case SELECT_MODULE_COLOR: @@ -181,6 +197,40 @@ function semHiddenModules(state = DEFAULT_HIDDEN_STATE, action: Actions) { } } +// Map of semester to ModulesMap for custom modules +const DEFAULT_CUSTOM_MODULE_STATE: CustomModuleLessonData = {}; +function semCustomModules( + state: CustomModuleLessonData = DEFAULT_CUSTOM_MODULE_STATE, + action: Actions, +): CustomModuleLessonData { + if (!action.payload) { + return state; + } + + switch (action.type) { + case ADD_CUSTOM_MODULE: + return { + ...state, + [action.payload.moduleCode]: { + title: action.payload.title, + lessons: action.payload.lessons, + }, + }; + case MODIFY_CUSTOM_MODULE: + return { + ...omit(state, [action.payload.oldModuleCode]), + [action.payload.moduleCode]: { + title: action.payload.title, + lessons: action.payload.lessons, + }, + }; + case DELETE_CUSTOM_MODULE: + return omit(state, [action.payload.moduleCode]); + default: + return state; + } +} + // Map of semester to list of TA modules const DEFAULT_TA_STATE: TaModulesConfig = {}; function semTaModules(state = DEFAULT_TA_STATE, action: Actions): TaModulesConfig { @@ -229,6 +279,7 @@ export const defaultTimetableState: TimetablesState = { ta: {}, academicYear: config.academicYear, archive: {}, + customModules: {}, }; function timetables( @@ -242,13 +293,19 @@ function timetables( switch (action.type) { case SET_TIMETABLE: { - const { semester, timetable, colors, hiddenModules, taModules } = action.payload; + const { semester, timetable, colors, hiddenModules, customModules, taModules } = + action.payload; return produce(state, (draft) => { - draft.lessons[semester] = timetable ?? DEFAULT_SEM_TIMETABLE_CONFIG; - draft.colors[semester] = colors ?? {}; - draft.hidden[semester] = hiddenModules ?? []; + draft.lessons[semester] = timetable || DEFAULT_SEM_TIMETABLE_CONFIG; + draft.colors[semester] = colors || DEFAULT_SEM_COLOR_MAP; + draft.hidden[semester] = hiddenModules || DEFAULT_HIDDEN_STATE; + draft.customModules[semester] = customModules || DEFAULT_CUSTOM_MODULE_STATE; draft.ta[semester] = taModules ?? {}; + + // Remove the old hidden imported modules + delete draft.hidden[TEMP_IMPORTED_SEM]; + delete draft.customModules[TEMP_IMPORTED_SEM]; }); } @@ -259,10 +316,14 @@ function timetables( draft.lessons[semester] = DEFAULT_SEM_TIMETABLE_CONFIG; draft.colors[semester] = DEFAULT_SEM_COLOR_MAP; draft.hidden[semester] = DEFAULT_HIDDEN_STATE; + draft.customModules[semester] = DEFAULT_CUSTOM_MODULE_STATE; draft.ta[semester] = DEFAULT_TA_STATE; }); } + case ADD_CUSTOM_MODULE: + case MODIFY_CUSTOM_MODULE: + case DELETE_CUSTOM_MODULE: case ADD_MODULE: case REMOVE_MODULE: case SELECT_MODULE_COLOR: @@ -279,6 +340,7 @@ function timetables( 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.customModules[semester] = semCustomModules(state.customModules[semester], action); draft.ta[semester] = semTaModules(state.ta[semester], action); }); } @@ -295,6 +357,13 @@ function timetables( }; } + case SET_CUSTOM_IMPORTED: { + const { semester, customModules } = action.payload; + return produce(state, (draft) => { + draft.customModules[semester] = customModules; + }); + } + case SET_HIDDEN_IMPORTED: { const { semester, hiddenModules } = action.payload; return produce(state, (draft) => { diff --git a/website/src/test-utils/theme.ts b/website/src/test-utils/theme.ts index 79decdbb0b..e36244a88e 100644 --- a/website/src/test-utils/theme.ts +++ b/website/src/test-utils/theme.ts @@ -15,6 +15,7 @@ export function expectColor(element: ReactWrapper | ShallowWrapper, color?: Colo export function addColors( modules: Module[], isHiddenInTimetable = false, + isCustom = false, isTaInTimetable = false, canTa = false, ): ModuleWithColor[] { @@ -22,6 +23,7 @@ export function addColors( ...module, colorIndex: index, isHiddenInTimetable, + isCustom, isTaInTimetable, canTa, })); diff --git a/website/src/types/modules.ts b/website/src/types/modules.ts index b2e3faead1..d9db7e0e89 100644 --- a/website/src/types/modules.ts +++ b/website/src/types/modules.ts @@ -14,7 +14,7 @@ export type Department = string; export type Workload = string | readonly number[]; export type Venue = string; export type Weeks = NumericWeeks | WeekRange; -export type NumericWeeks = readonly number[]; +export type NumericWeeks = number[]; export type WeekRange = { // The start and end dates start: string; @@ -43,6 +43,8 @@ export type Day = | 'Saturday' | 'Sunday'; +export const LessonDays: readonly Day[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']; + export const WorkingDays: readonly Day[] = [ 'Monday', 'Tuesday', @@ -138,6 +140,16 @@ export type RawLesson = Readonly<{ weeks: Weeks; }>; +export type CustomLesson = { + classNo: ClassNo; + day: DayText; + startTime: StartTime; + endTime: EndTime; + lessonType: LessonType; + venue: Venue; + weeks: Weeks; +}; + // Semester-specific information of a module. export type SemesterData = { semester: Semester; @@ -218,6 +230,9 @@ export type Module = { prereqTree?: PrereqTree; fulfillRequirements?: readonly ModuleCode[]; + // Flag for Custom Modules + isCustom?: boolean; + // Meta timestamp: number; }; diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index 33c38c76e6..5f195a98a3 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -1,9 +1,9 @@ import { AxiosError } from 'axios'; import { RegPeriodType, ScheduleType } from 'config'; - import { ColorSchemePreference } from './settings'; import { ColorIndex, Lesson, TaModulesConfig, TimetableConfig } from './timetables'; import { + CustomLesson, Faculty, Module, ModuleCode, @@ -113,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 CustomModulesMap = { [semester: string]: CustomModuleLessonData }; export type TaModulesMap = { [semester: string]: TaModulesConfig }; export type TimetablesState = { @@ -121,6 +122,7 @@ export type TimetablesState = { readonly hidden: HiddenModulesMap; readonly ta: TaModulesMap; readonly academicYear: string; + readonly customModules: CustomModulesMap; // Mapping of academic year to old timetable config readonly archive: { [key: string]: TimetableConfig }; }; @@ -159,6 +161,10 @@ export type CustomModuleData = { [moduleCode: string]: CustomModule; }; +export type CustomModuleLessonData = { + [moduleCode: string]: { title: string; lessons: CustomLesson[] }; +}; + // Mapping modules to when they will be taken export type PlannerState = Readonly<{ minYear: string; diff --git a/website/src/types/timetables.ts b/website/src/types/timetables.ts index 34670cddd0..951d57263b 100644 --- a/website/src/types/timetables.ts +++ b/website/src/types/timetables.ts @@ -1,4 +1,4 @@ -import { ClassNo, LessonType, ModuleCode, ModuleTitle, RawLesson } from './modules'; +import { ClassNo, CustomLesson, LessonType, ModuleCode, ModuleTitle, RawLesson } from './modules'; // ModuleLessonConfig is a mapping of lessonType to ClassNo for a module. export type ModuleLessonConfig = { @@ -15,17 +15,22 @@ export type TaModulesConfig = { [moduleCode: ModuleCode]: [lessonType: LessonType, classNo: ClassNo][]; }; -// ModuleLessonConfigWithLessons is a mapping of lessonType to an array of Lessons for a module. -export type Lesson = RawLesson & { +export type LessonModuleDetails = { moduleCode: ModuleCode; title: ModuleTitle; + isCustom?: boolean; }; +// ModuleLessonConfigWithLessons is a mapping of lessonType to an array of Lessons for a module. +export type Lesson = RawLesson & LessonModuleDetails; + export type ColoredLesson = Lesson & { colorIndex: ColorIndex; isTaInTimetable?: boolean; }; +export type CustomModuleLesson = CustomLesson & LessonModuleDetails; + type Modifiable = { isModifiable?: boolean; isAvailable?: boolean; diff --git a/website/src/utils/colors.test.ts b/website/src/utils/colors.test.ts index 6ad2c01488..9d76dc445d 100644 --- a/website/src/utils/colors.test.ts +++ b/website/src/utils/colors.test.ts @@ -75,12 +75,12 @@ describe(colorLessonsByKey, () => { describe(fillColorMapping, () => { test('should return color map with colors for all modules', () => { - expect(Object.keys(fillColorMapping({ CS1010S: {}, CS3216: {} }, {}))).toEqual([ + expect(Object.keys(fillColorMapping({ CS1010S: {}, CS3216: {} }, {}, []))).toEqual([ 'CS1010S', 'CS3216', ]); - expect(fillColorMapping({ CS1010S: {}, CS3216: {} }, { CS1010S: 0, CS3216: 1 })).toEqual({ + expect(fillColorMapping({ CS1010S: {}, CS3216: {} }, { CS1010S: 0, CS3216: 1 }, [])).toEqual({ CS1010S: 0, CS3216: 1, }); @@ -89,13 +89,14 @@ describe(fillColorMapping, () => { fillColorMapping( { CS1010S: {}, CS3216: {} }, { CS1010S: 0, CS3216: 1, CS1101S: 1, CS2105: 0, CS1231: 2 }, + [], ), ).toEqual({ CS1010S: 0, CS3216: 1, }); - expect(fillColorMapping({ CS1010S: {}, CS3216: {} }, { CS1010S: 0, CS3216: 0 })).toEqual({ + expect(fillColorMapping({ CS1010S: {}, CS3216: {} }, { CS1010S: 0, CS3216: 0 }, [])).toEqual({ CS1010S: 0, CS3216: 0, }); @@ -114,7 +115,7 @@ describe(fillColorMapping, () => { }; const uniqueColors = (timetable: SemTimetableConfig, colors: ColorMapping) => - uniq(Object.values(fillColorMapping(timetable, colors))); + uniq(Object.values(fillColorMapping(timetable, colors, []))); expect(uniqueColors(FILLED_TIMETABLE, {})).toHaveLength(8); expect(uniqueColors(FILLED_TIMETABLE, { CS3216: 1, CS1101S: 0 })).toHaveLength(8); diff --git a/website/src/utils/colors.ts b/website/src/utils/colors.ts index c006edff60..64fe26fa08 100644 --- a/website/src/utils/colors.ts +++ b/website/src/utils/colors.ts @@ -55,13 +55,16 @@ export function colorLessonsByKey( export function fillColorMapping( timetable: SemTimetableConfig, original: ColorMapping, + customModules: ModuleCode[], ): ColorMapping { const colorMap: ColorMapping = {}; const colorsUsed: ColorIndex[] = []; const withoutColors: ModuleCode[] = []; + const moduleCodes = Object.keys(timetable).concat(customModules); + // Collect a list of all colors used and all modules without colors - Object.keys(timetable).forEach((moduleCode) => { + moduleCodes.forEach((moduleCode) => { if (moduleCode in original) { colorMap[moduleCode] = original[moduleCode]; colorsUsed.push(Number(original[moduleCode])); diff --git a/website/src/utils/custom.test.ts b/website/src/utils/custom.test.ts new file mode 100644 index 0000000000..151ecb4f00 --- /dev/null +++ b/website/src/utils/custom.test.ts @@ -0,0 +1,31 @@ +import { appendCustomIdentifier, removeCustomIdentifier, createCustomModule } from './customModule'; + +test('appendCustomIdentifier should return the proper custom module code', () => { + expect(appendCustomIdentifier('CS2030')).toEqual('CUSTOMCS2030'); + expect(appendCustomIdentifier('CUSTOMCS2030')).toEqual('CUSTOMCUSTOMCS2030'); + expect(appendCustomIdentifier('')).toEqual('CUSTOM'); +}); + +test('removeCustomIdentifier should return the proper module code', () => { + expect(removeCustomIdentifier('CUSTOMCS2030')).toEqual('CS2030'); + expect(removeCustomIdentifier('CUSTOMCUSTOMCS2030')).toEqual('CUSTOMCS2030'); + expect(removeCustomIdentifier('abc', true)).toEqual('abc'); + expect(() => removeCustomIdentifier('abc')).toThrow(); +}); + +test('createCustomModule should return the proper custom module', () => { + const actual = createCustomModule('CS1101S', 'Programming Methodology'); + const expected = { + moduleCode: 'CS1101S', + title: 'Programming Methodology', + isCustom: true, + acadYear: '', + moduleCredit: '0', + department: '', + faculty: '', + semesterData: [], + timestamp: 0, + }; + + expect(expected).toEqual(actual); +}); diff --git a/website/src/utils/customModule.ts b/website/src/utils/customModule.ts new file mode 100644 index 0000000000..e9d4220329 --- /dev/null +++ b/website/src/utils/customModule.ts @@ -0,0 +1,258 @@ +import { castArray } from 'lodash'; +import { CustomLesson, Module, WeekRange, Weeks, consumeWeeks } from 'types/modules'; +import { CustomModuleLessonData } from 'types/reducers'; + +const CUSTOM_IDENTIFIER = 'CUSTOM'; + +export function validateCustomModuleCode(moduleCode: string): void { + if (moduleCode.trim().length > 0 && !moduleCode.startsWith(CUSTOM_IDENTIFIER)) { + throw new Error( + `Invalid custom module code ${moduleCode}. Should begin with ${CUSTOM_IDENTIFIER}`, + ); + } +} + +export function appendCustomIdentifier(moduleCode: string): string { + return `${CUSTOM_IDENTIFIER}${moduleCode}`; +} + +export function removeCustomIdentifier(customModuleCode: string, ignoreValidation = false): string { + if (!ignoreValidation) validateCustomModuleCode(customModuleCode); + return customModuleCode.replace(CUSTOM_IDENTIFIER, ''); +} + +export function createCustomModule(customModuleCode: string, title: string): Module { + return { + moduleCode: customModuleCode, + title, + isCustom: true, + acadYear: '', + moduleCredit: '0', + department: '', + faculty: '', + semesterData: [], + timestamp: 0, + }; +} + +export class CustomModuleSerializer { + private static readonly ESCAPE_DELIMITER = '\\'; + + private static readonly CUSTOM_MODULE_DELIMITER = '|'; + + private static readonly CUSTOM_MODULE_LESSON_DELIMITER = ';'; + + private static readonly CUSTOM_MODULE_KEY_DELIMITER = ':'; + + private static readonly CUSTOM_MODULE_SHORT_KEY_MAP: (keyof CustomLesson)[] = [ + 'lessonType', + 'classNo', + 'venue', + 'day', + 'startTime', + 'endTime', + 'weeks', + ]; + + private static readonly CUSTOM_MODULE_SHORT_KEY_TO_INDEX: { + [key in keyof CustomLesson]: number; + } = Object.fromEntries( + CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_MAP.map((key, index) => [key, index]), + ) as Record; + + private static escapeDelimiter(str: string | boolean | undefined): string { + if (str === undefined) return ''; + return str + .toString() + .replaceAll( + CustomModuleSerializer.ESCAPE_DELIMITER, + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.ESCAPE_DELIMITER}`, + ) + .replaceAll( + CustomModuleSerializer.CUSTOM_MODULE_DELIMITER, + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.CUSTOM_MODULE_DELIMITER}`, + ) + .replaceAll( + CustomModuleSerializer.CUSTOM_MODULE_LESSON_DELIMITER, + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.CUSTOM_MODULE_DELIMITER}`, + ) + .replaceAll( + CustomModuleSerializer.CUSTOM_MODULE_KEY_DELIMITER, + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.CUSTOM_MODULE_KEY_DELIMITER}`, + ); + } + + private static unescapeDelimiter(str: string): string { + return str + .replaceAll( + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.CUSTOM_MODULE_KEY_DELIMITER}`, + CustomModuleSerializer.CUSTOM_MODULE_KEY_DELIMITER, + ) + .replaceAll( + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.CUSTOM_MODULE_DELIMITER}`, + CustomModuleSerializer.CUSTOM_MODULE_DELIMITER, + ) + .replaceAll( + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.CUSTOM_MODULE_LESSON_DELIMITER}`, + CustomModuleSerializer.CUSTOM_MODULE_LESSON_DELIMITER, + ) + .replaceAll( + `${CustomModuleSerializer.ESCAPE_DELIMITER}${CustomModuleSerializer.ESCAPE_DELIMITER}`, + CustomModuleSerializer.ESCAPE_DELIMITER, + ); + } + + private static serializeNumericWeeks(numericWeeks: number[]): string { + const weekRanges: string[] = []; + let start = numericWeeks[0]; + let end = start; + + for (let i = 1; i < numericWeeks.length; i++) { + if (numericWeeks[i] === end + 1) { + end = numericWeeks[i]; + } else { + weekRanges.push(start === end ? start.toString() : `${start}-${end}`); + start = numericWeeks[i]; + end = start; + } + } + weekRanges.push(start === end ? start.toString() : `${start}-${end}`); + + return `n${weekRanges.join(',')}`; + } + + private static serializeWeekRanges(weekRanges: WeekRange): string { + return `d${weekRanges.start}|${weekRanges.end}|${weekRanges.weekInterval ?? ''}`; + } + + private static serializeWeeks(weeks: Weeks): string { + return consumeWeeks( + weeks, + CustomModuleSerializer.serializeNumericWeeks, + CustomModuleSerializer.serializeWeekRanges, + ); + } + + private static serializeCustomLesson(lesson: CustomLesson): string { + return CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_MAP.map( + (key) => + (key === 'weeks' + ? CustomModuleSerializer.serializeWeeks(lesson[key]) + : CustomModuleSerializer.escapeDelimiter(lesson[key])) || '', + ) + .map(encodeURIComponent) + .join(CustomModuleSerializer.CUSTOM_MODULE_KEY_DELIMITER); + } + + static serializeCustomModuleList(lessonData: CustomModuleLessonData): string { + return Object.entries(lessonData) + .map(([moduleCode, { title, lessons }]) => { + validateCustomModuleCode(moduleCode); + + const serializedLessons = lessons + .map(CustomModuleSerializer.serializeCustomLesson) + .join(CustomModuleSerializer.CUSTOM_MODULE_LESSON_DELIMITER); + + return [ + CustomModuleSerializer.escapeDelimiter(moduleCode), + CustomModuleSerializer.escapeDelimiter(title), + serializedLessons, + ].join(CustomModuleSerializer.CUSTOM_MODULE_LESSON_DELIMITER); + }) + .join(CustomModuleSerializer.CUSTOM_MODULE_DELIMITER); + } + + private static deserializeNumericWeeks(serializedWeeks: string): number[] { + const weeks: number[] = []; + const parts = serializedWeeks.split(','); + + parts.forEach((part) => { + const [start, end] = part.split('-').map(Number); + for (let i = start; i <= (end || start); i++) { + weeks.push(i); + } + }); + + return weeks; + } + + private static deserializeWeekRanges(serializedWeeks: string): WeekRange { + const [start, end, weekInterval] = serializedWeeks.split('|'); + return { + start, + end, + weekInterval: weekInterval ? Number(weekInterval) : undefined, + }; + } + + private static deserializeWeeks(serialized: string): Weeks { + const type = serialized[0]; + const serializedWeeks = serialized.slice(1); + + if (type === 'n') return CustomModuleSerializer.deserializeNumericWeeks(serializedWeeks); + if (type === 'd') return CustomModuleSerializer.deserializeWeekRanges(serializedWeeks); + + throw new Error(`Invalid week range ${serializedWeeks}`); + } + + private static deserializeCustomLesson(serialized: string): CustomLesson { + const parts = CustomModuleSerializer.splitByUnescapedDelimiter( + serialized, + CustomModuleSerializer.CUSTOM_MODULE_KEY_DELIMITER, + ) + .map(decodeURIComponent) + .map(CustomModuleSerializer.unescapeDelimiter); + + return { + lessonType: parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.lessonType], + classNo: parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.classNo], + venue: parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.venue], + day: parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.day], + startTime: parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.startTime], + endTime: parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.endTime], + weeks: CustomModuleSerializer.deserializeWeeks( + parts[CustomModuleSerializer.CUSTOM_MODULE_SHORT_KEY_TO_INDEX.weeks], + ), + }; + } + + private static regexpEscape(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + private static splitByUnescapedDelimiter(str: string, delimiter: string): string[] { + const regex = new RegExp( + `(? + Object.fromEntries( + // Split by module delimiter, then deserialize each module + CustomModuleSerializer.splitByUnescapedDelimiter( + value ?? '', + CustomModuleSerializer.CUSTOM_MODULE_DELIMITER, + ).map((moduleString) => { + const [moduleCode, title, ...serializedLessons] = + CustomModuleSerializer.splitByUnescapedDelimiter( + moduleString, + CustomModuleSerializer.CUSTOM_MODULE_LESSON_DELIMITER, + ); + return [ + moduleCode, + { + title, + lessons: serializedLessons.map(CustomModuleSerializer.deserializeCustomLesson), + }, + ]; + }), + ), + ) + .reduce((acc, val) => ({ ...acc, ...val }), {}); // Merge all deserialized objects into one + } +} diff --git a/website/src/utils/modules.ts b/website/src/utils/modules.ts index 56b671d0d8..65111a9ac3 100644 --- a/website/src/utils/modules.ts +++ b/website/src/utils/modules.ts @@ -27,6 +27,9 @@ export function getModuleSemesterData( module: Module, semester: Semester, ): SemesterData | undefined { + if (module.isCustom) { + return undefined; + } return module.semesterData.find((semData: SemesterData) => semData.semester === semester); } diff --git a/website/src/utils/timetables.ts b/website/src/utils/timetables.ts index 784ce64767..06ff2d95b9 100644 --- a/website/src/utils/timetables.ts +++ b/website/src/utils/timetables.ts @@ -49,12 +49,13 @@ import { TimetableDayFormat, } from 'types/timetables'; -import { ModuleCodeMap, ModulesMap } from 'types/reducers'; +import { CustomModuleLessonData, ModuleCodeMap, ModulesMap } from 'types/reducers'; import { ExamClashes } from 'types/views'; import { getTimeAsDate } from './timify'; import { getModuleTimetable, getExamDate, getExamDuration } from './modules'; import { deltas } from './array'; +import { CustomModuleSerializer } from './customModule'; type lessonTypeAbbrev = { [lessonType: string]: string }; export const LESSON_TYPE_ABBREV: lessonTypeAbbrev = { @@ -589,6 +590,12 @@ export function serializeHidden(hiddenModules: ModuleCode[]) { return `&hidden=${hiddenModules.join(',')}`; } +export function deserializeCustom(serialized: string): CustomModuleLessonData { + const params = qs.parse(serialized); + if (!params.custom) return {}; + return CustomModuleSerializer.deserializeCustomModuleList(params.custom); +} + export function deserializeHidden(serialized: string): ModuleCode[] { const params = qs.parse(serialized); if (!params.hidden) return []; diff --git a/website/src/views/components/DateField.scss b/website/src/views/components/DateField.scss new file mode 100644 index 0000000000..7482dec36f --- /dev/null +++ b/website/src/views/components/DateField.scss @@ -0,0 +1,36 @@ +@import '~styles/utils/modules-entry.scss'; + +.container { + display: flex; + flex-direction: column; + + .row { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0; + + span { + margin: 0; + } + + input { + width: 1.75rem; + border: none; + outline: none; + font-size: inherit; + font-family: inherit; + text-align: center; + color: inherit; + background: none; + + &:nth-child(5) { + width: 2.5rem; + } + } + } + + .focused { + border-color: #ff5138; + } +} diff --git a/website/src/views/components/DateField.tsx b/website/src/views/components/DateField.tsx new file mode 100644 index 0000000000..9333b8aace --- /dev/null +++ b/website/src/views/components/DateField.tsx @@ -0,0 +1,131 @@ +import React, { + useCallback, + useState, + useRef, + useEffect, + ChangeEventHandler, + InputHTMLAttributes, +} from 'react'; +import { getDate, getMonth, getYear, isValid, parse } from 'date-fns'; +import classNames from 'classnames'; +import styles from './DateField.scss'; + +interface CustomModuleModalWeekRangeSelectorProps { + defaultDate?: Date; + onChange: (date: Date) => void; +} + +const DateField: React.FC = ({ + defaultDate = new Date(), + onChange, +}) => { + const [day, setDay] = useState(getDate(defaultDate).toString()); + const [month, setMonth] = useState((getMonth(defaultDate) + 1).toString()); + const [year, setYear] = useState(getYear(defaultDate).toString()); + + const [fullDate, setFullDate] = useState(defaultDate); + + const monthRef = useRef(null); + const yearRef = useRef(null); + + const resetToLastValid = useCallback(() => { + const tmpDate = parse(`${year}-${month}-${day}`, 'yyyy-MM-dd', new Date()); + + // Ensure valid date and year is not some ridiculous number + if (isValid(tmpDate) && tmpDate.getFullYear() > 2000 && tmpDate.getFullYear() < 2100) { + setFullDate(tmpDate); + } else { + setDay(getDate(fullDate).toString()); + setMonth((getMonth(fullDate) + 1).toString()); + setYear(getYear(fullDate).toString()); + } + }, [day, month, year, fullDate]); + + // Update parent component when date changes + useEffect(() => { + onChange(fullDate); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fullDate]); + + const handleDayChange: ChangeEventHandler = (e) => { + const { value } = e.target; + if (/^\d{0,2}$/.test(value)) { + setDay(value); + + if (value.length === 2) { + monthRef.current?.focus(); + } + } + }; + + const handleMonthChange: ChangeEventHandler = (e) => { + const { value } = e.target; + if (/^\d{0,2}$/.test(value)) { + setMonth(value); + + if (value.length === 2) { + yearRef.current?.focus(); + } + } + }; + + const handleYearChange: ChangeEventHandler = (e) => { + const { value } = e.target; + if (/^\d{0,4}$/.test(value)) { + setYear(value); + } + }; + + // Track if field is focused to apply styles + const [numFieldFocus, setNumFieldFocus] = useState(0); + const focusCountHandlers: InputHTMLAttributes = { + onBlur: () => { + setNumFieldFocus((n) => n - 1); + }, + onFocus: (e) => { + e.target.select(); + setNumFieldFocus((n) => n + 1); + }, + }; + + useEffect(() => { + if (numFieldFocus === 0) { + resetToLastValid(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [numFieldFocus]); + + return ( +
+
+ + / + + / + +
+
+ ); +}; + +export default DateField; diff --git a/website/src/views/mpe/form/ModulesSelect.test.tsx b/website/src/views/mpe/form/ModulesSelect.test.tsx index e8fd04cab1..c0c74847bc 100644 --- a/website/src/views/mpe/form/ModulesSelect.test.tsx +++ b/website/src/views/mpe/form/ModulesSelect.test.tsx @@ -30,6 +30,7 @@ const commonProps = { matchBreakpoint: false, disabled: false, onRemoveModule: jest.fn(), + semester: 1, }; describe(ModulesSelectComponent, () => { diff --git a/website/src/views/routes/paths.ts b/website/src/views/routes/paths.ts index 84b45b422e..24440cbb22 100644 --- a/website/src/views/routes/paths.ts +++ b/website/src/views/routes/paths.ts @@ -4,6 +4,8 @@ import { Venue } from 'types/venues'; import { SemTimetableConfig, TaModulesConfig } from 'types/timetables'; import { serializeHidden, serializeTa, serializeTimetable } from 'utils/timetables'; import config from 'config'; +import { CustomModuleLessonData } from 'types/reducers'; +import { CustomModuleSerializer } from 'utils/customModule'; // IMPORTANT: Remember to update any route changes on the sitemap @@ -25,9 +27,17 @@ export const TIMETABLE_SHARE = 'share'; export function timetableShare( semester: Semester, timetable: SemTimetableConfig, + customModules: CustomModuleLessonData, hiddenModules: ModuleCode[], taModules: TaModulesConfig, ): string { + const serializedCustom = + Object.keys(customModules).length === 0 + ? '' + : `&custom=${encodeURIComponent( + CustomModuleSerializer.serializeCustomModuleList(customModules), + )}`; + // Convert the list of hidden modules to a comma-separated string, if there are any const serializedHidden = hiddenModules.length === 0 ? '' : serializeHidden(hiddenModules); const serializedTa = isEmpty(taModules) ? '' : serializeTa(taModules); @@ -35,6 +45,7 @@ export function timetableShare( return ( `${timetablePage(semester)}/${TIMETABLE_SHARE}` + `?${serializeTimetable(timetable)}` + + `${serializedCustom}` + `${serializedHidden}` + `${serializedTa}` ); diff --git a/website/src/views/timetable/CustomModuleEdit.scss b/website/src/views/timetable/CustomModuleEdit.scss new file mode 100644 index 0000000000..b71121147d --- /dev/null +++ b/website/src/views/timetable/CustomModuleEdit.scss @@ -0,0 +1,5 @@ +@import "~styles/utils/modules-entry.scss"; + +.select { + padding-bottom: 0.5em; +} diff --git a/website/src/views/timetable/CustomModuleEdit.tsx b/website/src/views/timetable/CustomModuleEdit.tsx new file mode 100644 index 0000000000..2c405e8572 --- /dev/null +++ b/website/src/views/timetable/CustomModuleEdit.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import { Edit } from 'react-feather'; +import { CustomLesson, ModuleCode, Semester } from 'types/modules'; +import Tooltip from 'views/components/Tooltip'; +import classnames from 'classnames'; +import CustomModuleModal from './CustomModuleModal'; + +export type Props = { + moduleCode: ModuleCode; + moduleTitle: string; + customLessons: CustomLesson[]; + moduleActionStyle: string; + actionIconStyle: string; + semester: Semester; + + isModuleCodeAdded: (moduleCode: ModuleCode) => boolean; + editCustomModule: ( + oldModuleCode: ModuleCode, + newModuleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => void; +}; + +type State = { + isOpen: boolean; +}; + +export default class CustomModuleEdit extends React.PureComponent { + fields = ['moduleCode', 'title', 'lessonType', 'venue', 'day', 'startTime', 'endTime']; + + override state: State = { + isOpen: false, + }; + + openModal = () => { + this.setState({ isOpen: true }); + }; + + closeModal = () => + this.setState({ + isOpen: false, + }); + + override render() { + const { isOpen } = this.state; + + return ( + <> + + + + + + ); + } +} diff --git a/website/src/views/timetable/CustomModuleModal.scss b/website/src/views/timetable/CustomModuleModal.scss new file mode 100644 index 0000000000..1c39f19cac --- /dev/null +++ b/website/src/views/timetable/CustomModuleModal.scss @@ -0,0 +1,92 @@ +@import '~styles/utils/modules-entry.scss'; + +.header { + text-align: center; + + h3 { + margin-bottom: 0.4rem; + font-weight: $font-weight-bold; + font-size: 1.4rem; + } + + .row { + display: flex; + text-align: left; + + .rowTime { + display: flex; + justify-content: flex-start; + align-items: center; + width: 60%; + + & > .columnSmall { + position: relative; + padding-right: 0; + margin-bottom: 1rem; + } + + .startTime > small { + position: absolute; + bottom: 0; + left: 0; + width: max-content; + transform: translateY(100%); + } + + .endTime > small { + display: none; + } + } + + .columnSmall { + width: 33%; + padding-top: 0.5em; + padding-right: 10%; + + label { + font-style: bold; + } + } + + .column { + width: 45%; + padding-top: 0.5em; + padding-right: 10%; + + label { + font-style: bold; + } + + &.weeksContainer { + width: 100%; + padding-top: 0.1em; + } + + &.slotContainer { + width: 100%; + padding-top: 0.1em; + } + } + + .columnLarge { + width: 70%; + padding-top: 0.5em; + padding-right: 10%; + + label { + font-style: bold; + } + } + + .buttonColumn { + padding-top: 1em; + margin-left: auto; + } + } + + @include media-breakpoint-down(sm) { + br { + display: none; + } + } +} diff --git a/website/src/views/timetable/CustomModuleModal.tsx b/website/src/views/timetable/CustomModuleModal.tsx new file mode 100644 index 0000000000..5549d90228 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModal.tsx @@ -0,0 +1,578 @@ +import * as React from 'react'; + +import CloseButton from 'views/components/CloseButton'; +import Modal from 'views/components/Modal'; +import { + CustomLesson, + ModuleCode, + NumericWeeks, + Semester, + Semesters, + WeekRange, + Weeks, +} from 'types/modules'; +import { LESSON_TYPE_ABBREV } from 'utils/timetables'; +import { ModifiableLesson } from 'types/timetables'; +import { appendCustomIdentifier, removeCustomIdentifier } from 'utils/customModule'; +import { SCHOOLDAYS, getLessonTimeHours, getLessonTimeMinutes } from 'utils/timify'; +import { noop } from 'lodash'; +import classNames from 'classnames'; +import academicCalendarJSON from 'data/academic-calendar'; +import { addWeeks, parse, parseISO } from 'date-fns'; +import NUSModerator from 'nusmoderator'; +import TimetableCell from './TimetableCell'; +import styles from './CustomModuleModal.scss'; +import CustomModuleModalDropdown from './CustomModuleModalDropdown'; +import CustomModuleModalField from './CustomModuleModalField'; +import CustomModuleModalWeekRangeSelector from './CustomModuleModalWeekRangeSelector'; +import CustomModuleModalWeekButtonSelector from './CustomModuleModalWeekButtonSelector'; +import CustomModuleModalSlotSelector from './CustomModuleModalSlotSelector'; + +export type Props = { + moduleCode?: ModuleCode; + moduleTitle?: string; + customLessonData?: CustomLesson[]; + isOpen: boolean; + isEdit: boolean; + semester: Semester; + + handleCustomModule: ( + oldModuleCode: ModuleCode, + moduleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => void; + isModuleCodeAdded: (moduleCode: ModuleCode) => boolean; + closeModal: () => void; +}; + +type State = { + selectedIndex: number; // Index of the lesson being added/edited + maxIndex: number; // Index of the last lesson + 1 + + moduleCode: ModuleCode; + moduleTitle: string; + lessonData: CustomLesson[]; + isSubmitting: boolean; +}; + +const MINIMUM_CUSTOM_MODULE_DURATION_MINUTES = 60; +const INTERVAL_IN_MINUTES = 30; + +const isSpecialTerm = (semester: Semester) => semester >= 3; + +const getDefaultWeeks = (semester: Semester): Weeks => { + if (!isSpecialTerm(semester)) return Array.from({ length: 13 }, (_, i) => i + 1); + + // Convert shortened AY to full e.g. 24/25 to 2024/2025 + const year = NUSModerator.academicCalendar + .getAcadYear(new Date()) + .year.split('/') + .map((x) => `20${x}`) + .join('/'); + + const semStart = parse( + academicCalendarJSON[year][semester].start.join('-'), + 'yyyy-MM-dd', + new Date(), + ); + const semEnd = addWeeks(semStart, 6); + return { + start: semStart.toISOString(), + end: semEnd.toISOString(), + }; +}; + +export default class CustomModuleModal extends React.PureComponent { + fields = ['moduleCode', 'title', 'lessonType', 'venue', 'day', 'startTime', 'endTime']; + + DEFAULT_LESSON_STATE: CustomLesson = { + lessonType: '', + venue: '', + day: 'Monday', + startTime: '0800', + endTime: '0900', + classNo: '', + weeks: getDefaultWeeks(this.props.semester), + }; + + override state: State = { + selectedIndex: 0, + maxIndex: this.props.customLessonData?.length ?? 1, + + moduleCode: removeCustomIdentifier(this.props.moduleCode ?? '', true), + moduleTitle: this.props.moduleTitle ?? '', + lessonData: this.props.customLessonData ?? [this.DEFAULT_LESSON_STATE], + isSubmitting: false, + }; + + resetState = () => { + this.setState({ + selectedIndex: 0, + maxIndex: this.props.customLessonData?.length ?? 1, + + moduleCode: removeCustomIdentifier(this.props.moduleCode ?? '', true), + moduleTitle: this.props.moduleTitle ?? '', + lessonData: this.props.customLessonData ?? [this.DEFAULT_LESSON_STATE], + isSubmitting: false, + }); + }; + + setModuleCode = (event: React.ChangeEvent) => { + this.setState((prev) => ({ ...prev, moduleCode: event.target.value })); + }; + + setModuleTitle = (event: React.ChangeEvent) => { + this.setState((prev) => ({ ...prev, moduleTitle: event.target.value })); + }; + + setLessonStateViaInput = (event: React.ChangeEvent) => { + const newState: State = { + ...this.state, + lessonData: this.state.lessonData.map((lesson, index) => { + if (index === this.state.selectedIndex) { + return { + ...lesson, + [event.target.name]: event.target.value, + }; + } + return structuredClone(lesson); + }), + }; + this.setState(newState); + return null; + }; + + setLessonStateViaSelect = (keyValue: { [key: string]: string | Weeks }) => { + const newState: State = { + ...this.state, + lessonData: this.state.lessonData.map((lesson, index) => { + if (index === this.state.selectedIndex) { + return { + ...lesson, + ...keyValue, + }; + } + return structuredClone(lesson); + }), + }; + this.setState(newState); + }; + + getLessonDetails = (index: number): ModifiableLesson => ({ + ...this.state.lessonData[index], + colorIndex: 0, + moduleCode: appendCustomIdentifier(this.state.moduleCode), + title: this.state.moduleTitle, + isCustom: true, + }); + + getValidationErrors = (): Record => { + const errors: Record = {}; + + // Validate module code first, then each lesson data + if (this.state.moduleCode.length === 0) { + errors.moduleCode = 'Module code is required'; + } + + const effectiveModuleCode = appendCustomIdentifier(this.state.moduleCode); + + // If editing, new code must either be same as old code, or not already added + if (this.props.isEdit) { + if ( + effectiveModuleCode !== this.props.moduleCode && + this.props.isModuleCodeAdded(effectiveModuleCode) + ) { + errors.moduleCode = 'Module code is already added'; + } + } + // If adding, new code must not already be added + else if (this.props.isModuleCodeAdded(effectiveModuleCode)) { + errors.moduleCode = 'Module code is already added'; + } + + if (Object.keys(errors).length > 0) { + return errors; + } + + // Validate each lesson data + for (let i = 0; i < this.state.maxIndex; i++) { + const { startTime, endTime, classNo, weeks } = this.state.lessonData[i]; + + if (classNo.length === 0) { + errors.classNo = 'Class number is required'; + } + + if (isSpecialTerm(this.props.semester)) { + const weekRange = weeks as WeekRange; + const start = parseISO(weekRange.start); + const end = parseISO(weekRange.end); + + if (end < start) { + errors.weeks = 'End date must be after start date'; + } + } else if ((weeks as NumericWeeks).length === 0) { + errors.weeks = 'Weeks are required. Select all to indicate every week'; + } + + const timeDifferenceInMinutes = + (getLessonTimeHours(endTime) - getLessonTimeHours(startTime)) * 60 + + (getLessonTimeMinutes(endTime) - getLessonTimeMinutes(startTime)); + + // -1 to account for n-1 minutes being valid + if (timeDifferenceInMinutes < MINIMUM_CUSTOM_MODULE_DURATION_MINUTES - 1) { + errors.time = `Lesson must be ${MINIMUM_CUSTOM_MODULE_DURATION_MINUTES} mins or longer`; + } + + if (Object.keys(errors).length > 0) { + this.setState({ selectedIndex: i }); + return errors; + } + } + return errors; + }; + + submitModule() { + const errors = this.getValidationErrors(); + + if (Object.keys(errors).length > 0) { + this.setState({ + isSubmitting: true, + }); + return; + } + + const { moduleCode } = this.state; + const { isEdit, handleCustomModule, moduleCode: oldModuleCode } = this.props; + + const customModuleCode = appendCustomIdentifier(moduleCode); + + if (isEdit) { + handleCustomModule( + oldModuleCode ?? '', + customModuleCode, + this.state.moduleTitle, + this.state.lessonData, + ); + } else { + handleCustomModule('', customModuleCode, this.state.moduleTitle, this.state.lessonData); + } + this.resetState(); + this.props.closeModal(); + this.setState({ + isSubmitting: false, + }); + } + + renderTimeRanges(field: 'startTime' | 'endTime') { + const errors = this.state.isSubmitting ? this.getValidationErrors() : {}; + + const value = + field === 'startTime' + ? this.state.lessonData[this.state.selectedIndex].startTime + : this.state.lessonData[this.state.selectedIndex].endTime; + + // Generate timeslots in 30 minute intervals + let startIndex = 0; + let numberIntervals = + (24 * 60 - MINIMUM_CUSTOM_MODULE_DURATION_MINUTES) / INTERVAL_IN_MINUTES + 1; + if (field === 'endTime') { + const NUMBER_SLOT_MIN_DURATION = MINIMUM_CUSTOM_MODULE_DURATION_MINUTES / INTERVAL_IN_MINUTES; + startIndex = + (getLessonTimeHours(this.state.lessonData[this.state.selectedIndex].startTime) * 60 + + getLessonTimeMinutes(this.state.lessonData[this.state.selectedIndex].startTime)) / + 30 + + NUMBER_SLOT_MIN_DURATION; + + numberIntervals -= startIndex - NUMBER_SLOT_MIN_DURATION; + } + + const timeslotIndices = Array.from({ length: numberIntervals }, (_, i) => i + startIndex); + let timeslots = timeslotIndices.map((timeslot) => { + const timeMinutes = timeslot * 30; + const hourString = Math.floor(timeMinutes / 60) + .toString() + .padStart(2, '0'); + const minuteString = (timeMinutes % 60).toString().padStart(2, '0'); + const timeString = hourString + minuteString; + return timeString; + }); + + if (field === 'endTime') { + timeslots = timeslots.filter( + (time) => time > this.state.lessonData[this.state.selectedIndex].startTime, + ); + } + + if (timeslots[timeslots.length - 1] === '2400') { + timeslots[timeslots.length - 1] = '2359'; + } + + return ( + { + if ( + field === 'startTime' && + this.state.lessonData[this.state.selectedIndex].endTime < time + ) { + const hours = getLessonTimeHours(time) + MINIMUM_CUSTOM_MODULE_DURATION_MINUTES / 60; + const minutes = + getLessonTimeMinutes(time) + (MINIMUM_CUSTOM_MODULE_DURATION_MINUTES % 60); + + let endTime = `${hours.toString().padStart(2, '0')}${minutes + .toString() + .padStart(2, '0')}`; + + if (endTime === '2400') { + endTime = '2359'; + } + this.setLessonStateViaSelect({ + startTime: time, + endTime, + }); + } else { + this.setLessonStateViaSelect({ [field]: time }); + } + }} + error={errors.time} + required + /> + ); + } + + renderModulePreview() { + return ( +
+
+ + +
+
+ + +
+
+ ); + } + + renderSlotSelector(semester: Semester) { + if (!Semesters.includes(semester)) throw new Error('Invalid semester'); + + return ( + <> + i + 1)} + selected={[this.state.selectedIndex + 1]} + setSelected={(selected) => this.setState({ selectedIndex: selected[0] - 1 })} + addButtonHandler={() => + this.setState((state) => ({ + selectedIndex: state.maxIndex, + maxIndex: state.maxIndex + 1, + lessonData: [...state.lessonData, this.DEFAULT_LESSON_STATE], + })) + } + deleteButtonHandler={() => { + if (this.state.maxIndex === 1) return; + const newLessonData = this.state.lessonData.filter( + (_, i) => i !== this.state.selectedIndex, + ); + this.setState((state) => ({ + selectedIndex: Math.min(state.selectedIndex, state.maxIndex - 2), + maxIndex: state.maxIndex - 1, + lessonData: newLessonData, + })); + }} + /> + + ); + } + + renderWeekSelector(semester: Semester, weekErrors: string) { + if (!Semesters.includes(semester)) throw new Error('Invalid semester'); + + const { weeks } = this.state.lessonData[this.state.selectedIndex]; + + if (!isSpecialTerm(semester)) { + return ( + <> + +
+ + this.setLessonStateViaSelect({ weeks: selectedWeeks }) + } + error={weekErrors} + options={getDefaultWeeks(this.props.semester) as NumericWeeks} + /> + + ); + } + + // Special term displays start/end date with week interval + return ( + <> + +
+ this.setLessonStateViaSelect({ weeks: updatedWeeks })} + error={weekErrors} + /> + + ); + } + + renderInputFields() { + const { moduleCode, moduleTitle } = this.state; + const { classNo } = this.state.lessonData[this.state.selectedIndex]; + const errors = this.state.isSubmitting ? this.getValidationErrors() : {}; + + return ( + <> +
+
+ +
+
+ +
+
+
+
+
+ {this.renderSlotSelector(this.props.semester)} +
+
+
+ {this.renderModulePreview()} +
+
+ +
+
+ +
+ this.setLessonStateViaSelect({ lessonType })} + error={errors.lessonType} + value={this.state.lessonData[this.state.selectedIndex].lessonType} + /> +
+
+
+
+ +
+
+
+
+ +
+ d)} + defaultSelectedOption={this.state.lessonData[this.state.selectedIndex].day} + onChange={(d) => this.setLessonStateViaSelect({ day: d })} + error={errors.day} + value={this.state.lessonData[this.state.selectedIndex].day} + required + /> +
+
+
+ +
+ {this.renderTimeRanges('startTime')} +
+
+ +
+ {this.renderTimeRanges('endTime')} +
+
+
+
+
+ {this.renderWeekSelector(this.props.semester, errors.weeks)} +
+
+
+
+ +
+
+ + ); + } + + override render() { + return ( + + +
+

{this.props.isEdit ? <>Edit Custom Module : <>Add Custom Module}

+ {this.renderInputFields()} +
+
+ ); + } +} diff --git a/website/src/views/timetable/CustomModuleModalButtonGroup.scss b/website/src/views/timetable/CustomModuleModalButtonGroup.scss new file mode 100644 index 0000000000..7aeaa95bbc --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalButtonGroup.scss @@ -0,0 +1,56 @@ +@import '~styles/utils/modules-entry.scss'; + +.container { + .buttonGroup { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + gap: 0; + + .button { + width: 100%; + padding: 0.3rem 0.1rem; + margin: 0; + + .selected { + color: white; + background-color: $primary; + } + + &:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + } + } + + p.shortcuts { + display: flex; + margin-top: 0.5rem; + margin-bottom: 0; + gap: 1; + + :not(:first-child) { + margin-left: 0.5rem; + } + + :not(:last-child) { + margin-right: 0.1rem; + } + + button { + height: fit-content; + padding: 0 0.5rem; + } + } +} + +.errorLabel { + margin: 0; + color: red; +} diff --git a/website/src/views/timetable/CustomModuleModalButtonGroup.tsx b/website/src/views/timetable/CustomModuleModalButtonGroup.tsx new file mode 100644 index 0000000000..fef634b162 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalButtonGroup.tsx @@ -0,0 +1,64 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import styles from './CustomModuleModalButtonGroup.scss'; + +interface CustomModuleModalButtonGroupProps { + options: number[]; + selected: number[]; + isSingleSelect?: boolean; + + setSelected: (weeks: number[]) => void; + addButtonHandler?: () => void; +} + +const CustomModuleModalButtonGroup: React.FC = ({ + options, + selected, + isSingleSelect, + setSelected, + addButtonHandler, +}) => { + const toggleSelected = useCallback( + (option: number) => { + // For multi-select, toggle the selected state of the option + if (isSingleSelect) { + setSelected([option]); + } else if (selected.includes(option)) { + setSelected(selected.filter((i) => i !== option)); + } else { + setSelected([...selected, option].sort((a, b) => a - b)); + } + }, + [isSingleSelect, selected, setSelected], + ); + + return ( +
+ {options.map((option) => ( + + ))} + {addButtonHandler && ( + + )} +
+ ); +}; + +export default CustomModuleModalButtonGroup; diff --git a/website/src/views/timetable/CustomModuleModalDropdown.scss b/website/src/views/timetable/CustomModuleModalDropdown.scss new file mode 100644 index 0000000000..9b921f42a8 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalDropdown.scss @@ -0,0 +1,44 @@ +@import '~styles/utils/modules-entry.scss'; + +.dropdown { + position: relative; + display: inline-block; + + .dropdownButton { + padding-right: 0.5rem; + + .btnSvg { + margin-right: 0; + margin-left: 0.5rem; + } + } +} + +.errorLabel { + margin: 0; + color: red; +} + +.dropdownMenu { + position: fixed; + bottom: 0; + z-index: calc($modal-z-index + 10); + list-style-type: none; + width: fit-content; + height: fit-content; + padding: 0; + margin: 0; + + &.open { + display: block; + } + + &.closed { + display: none; + } + + .item { + padding: 8px 12px; + cursor: pointer; + } +} diff --git a/website/src/views/timetable/CustomModuleModalDropdown.tsx b/website/src/views/timetable/CustomModuleModalDropdown.tsx new file mode 100644 index 0000000000..eef7577d62 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalDropdown.tsx @@ -0,0 +1,110 @@ +import { useSelect } from 'downshift'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown } from 'react-feather'; +import classNames from 'classnames'; + +import styles from './CustomModuleModalDropdown.scss'; + +interface CustomModuleModalDropdownProps { + className?: string; + options: string[]; + defaultSelectedOption?: string; + defaultText?: string; + error?: string; + required?: boolean; + value: string; + onChange: (value: string) => void; +} + +const CustomModuleModalDropdown: React.FC = ({ + className, + options, + defaultSelectedOption, + defaultText, + error, + required, + value, + onChange, +}) => { + const optionsWithBlank = useMemo( + () => (required ? options : ['', ...options]), + [options, required], + ); + + const { isOpen, getToggleButtonProps, getMenuProps, getItemProps, selectedItem } = useSelect({ + items: optionsWithBlank, + defaultSelectedItem: defaultSelectedOption, + selectedItem: value, + onSelectedItemChange: ({ selectedItem: item }) => { + if (typeof item === 'string') { + onChange(item); + } + }, + }); + + const buttonRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState({}); + + const updateDropdownPosition = useCallback(() => { + if (isOpen && buttonRef.current) { + const buttonRect = buttonRef.current.getBoundingClientRect(); + const distanceToBottom = window.innerHeight - buttonRect.bottom; + setDropdownStyle({ + top: buttonRect.bottom, + left: buttonRect.left, + maxHeight: distanceToBottom, + }); + } + }, [isOpen, buttonRef]); + + // Update dropdown position when dropdown is opened or window resized + useEffect(updateDropdownPosition, [isOpen, buttonRef, updateDropdownPosition]); + useLayoutEffect(() => { + window.addEventListener('resize', () => { + updateDropdownPosition(); + }); + }, [updateDropdownPosition]); + + return ( +
+
+ +
    + {optionsWithBlank.map((option, index) => ( +
  • + {option.length ? option : 'None'} +
  • + ))} +
+
+ {error ?? ''} +
+ ); +}; + +export default CustomModuleModalDropdown; diff --git a/website/src/views/timetable/CustomModuleModalField.scss b/website/src/views/timetable/CustomModuleModalField.scss new file mode 100644 index 0000000000..72232ed3de --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalField.scss @@ -0,0 +1,8 @@ +.inputField { + margin-bottom: 0; +} + +.errorLabel { + margin: 0; + color: red; +} diff --git a/website/src/views/timetable/CustomModuleModalField.tsx b/website/src/views/timetable/CustomModuleModalField.tsx new file mode 100644 index 0000000000..784b6cb90c --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalField.tsx @@ -0,0 +1,39 @@ +import classNames from 'classnames'; +import React, { ChangeEvent } from 'react'; +import styles from './CustomModuleModalField.scss'; + +interface CustomModuleModalFieldProps { + id: string; + value?: string; + label?: string; + errors?: Record; + onChange: (e: ChangeEvent) => void; +} + +const CustomModuleModalField: React.FC = ({ + id, + errors, + label, + value, + onChange, +}) => ( + <> + {label && } + { + onChange(e); + }} + className={classNames( + styles.inputField, + 'form-control', + `${errors && errors[id] ? 'alert alert-danger' : ''}`, + )} + value={value ?? ''} + required + /> + {errors && {errors[id] ?? ''}} + +); +export default CustomModuleModalField; diff --git a/website/src/views/timetable/CustomModuleModalSlotSelector.tsx b/website/src/views/timetable/CustomModuleModalSlotSelector.tsx new file mode 100644 index 0000000000..e76de995f7 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalSlotSelector.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import CustomModuleModalButtonGroup from './CustomModuleModalButtonGroup'; +import styles from './CustomModuleModalButtonGroup.scss'; + +interface CustomModuleModalSlotSelectorProps { + options: number[]; + selected: number[]; + + setSelected: (weeks: number[]) => void; + addButtonHandler: () => void; + deleteButtonHandler: () => void; +} + +const CustomModuleModalSlotSelector = ({ + options, + selected, + setSelected, + addButtonHandler, + deleteButtonHandler, +}: CustomModuleModalSlotSelectorProps) => ( + <> +
+

+ {' '} + {options.length > 1 && ( + + )} +

+ +
+ +); +export default CustomModuleModalSlotSelector; diff --git a/website/src/views/timetable/CustomModuleModalWeekButtonSelector.tsx b/website/src/views/timetable/CustomModuleModalWeekButtonSelector.tsx new file mode 100644 index 0000000000..750fa1fdf1 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalWeekButtonSelector.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import CustomModuleModalButtonGroup from './CustomModuleModalButtonGroup'; +import styles from './CustomModuleModalButtonGroup.scss'; + +interface CustomModuleModalWeekButtonSelectorProps { + options: number[]; + error?: string; + selected: number[]; + + setSelected: (weeks: number[]) => void; +} + +const CustomModuleModalWeekButtonSelector = ({ + options, + error, + selected, + setSelected, +}: CustomModuleModalWeekButtonSelectorProps) => { + const handleSelectNone = () => { + setSelected([]); + }; + + const handleSelectAll = () => { + setSelected(options); + }; + + const handleSelectOdd = () => { + setSelected(options.filter((v) => v % 2 === 1)); + }; + + const handleSelectEven = () => { + setSelected(options.filter((v) => v % 2 === 0)); + }; + + return ( +
+ +

+ + + + +

+ {error ?? ''} +
+ ); +}; + +export default CustomModuleModalWeekButtonSelector; diff --git a/website/src/views/timetable/CustomModuleModalWeekRangeSelector.scss b/website/src/views/timetable/CustomModuleModalWeekRangeSelector.scss new file mode 100644 index 0000000000..2f5e536b31 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalWeekRangeSelector.scss @@ -0,0 +1,37 @@ +@import '~styles/utils/modules-entry.scss'; + +.container { + display: flex; + flex-direction: column; + + .row { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 0.5rem; + + p { + margin: 0; + } + + input { + text-align: center; + } + } + + .intervalInput { + input { + width: 3rem; + } + } + + .column { + display: flex; + flex-direction: column; + } +} + +.errorLabel { + margin: 0; + color: red; +} diff --git a/website/src/views/timetable/CustomModuleModalWeekRangeSelector.tsx b/website/src/views/timetable/CustomModuleModalWeekRangeSelector.tsx new file mode 100644 index 0000000000..60a3c09c89 --- /dev/null +++ b/website/src/views/timetable/CustomModuleModalWeekRangeSelector.tsx @@ -0,0 +1,79 @@ +import classNames from 'classnames'; +import React, { useCallback, useEffect, useState } from 'react'; +import { WeekRange } from 'types/modules'; +import DateField from 'views/components/DateField'; +import { parseISO } from 'date-fns'; +import styles from './CustomModuleModalWeekRangeSelector.scss'; + +interface CustomModuleModalWeekRangeSelectorProps { + defaultWeekRange: WeekRange; + onChange: (weekRange: WeekRange) => void; + error?: string; +} + +const CustomModuleModalWeekRangeSelector: React.FC = ({ + defaultWeekRange, + onChange, + error, +}) => { + const [weekRange, setWeekRange] = useState(defaultWeekRange); + const [displayedInterval, setDisplayedInterval] = useState( + defaultWeekRange.weekInterval?.toString() ?? '1', + ); + + const updateParent = useCallback(() => { + onChange(weekRange); + }, [weekRange, onChange]); + + useEffect(() => { + updateParent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [weekRange]); + + return ( +
+
+ { + setWeekRange({ ...weekRange, start: date.toISOString() }); + }} + /> +

to

+ { + setWeekRange({ ...weekRange, end: date.toISOString() }); + }} + /> +

every

+
+ { + if (weekRange.weekInterval === undefined) setDisplayedInterval('1'); + }} + onChange={(e) => { + if (e.target.value.length === 0) { + setWeekRange({ ...weekRange, weekInterval: undefined }); + setDisplayedInterval(''); + } else if (/^\d+$/.test(e.target.value)) { + const value = parseInt(e.target.value, 10); + setDisplayedInterval(e.target.value); + setWeekRange({ ...weekRange, weekInterval: value === 1 ? undefined : value }); + } + }} + className="form-control" + value={displayedInterval} + required + /> +
+

week(s)

+
+ + {error ?? ''} +
+ ); +}; + +export default CustomModuleModalWeekRangeSelector; diff --git a/website/src/views/timetable/CustomModuleSelect.scss b/website/src/views/timetable/CustomModuleSelect.scss new file mode 100644 index 0000000000..638b9f99dc --- /dev/null +++ b/website/src/views/timetable/CustomModuleSelect.scss @@ -0,0 +1,5 @@ +@import '~styles/utils/modules-entry.scss'; + +.select > button { + width: 100%; +} diff --git a/website/src/views/timetable/CustomModuleSelect.tsx b/website/src/views/timetable/CustomModuleSelect.tsx new file mode 100644 index 0000000000..9050c5b64e --- /dev/null +++ b/website/src/views/timetable/CustomModuleSelect.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; + +import { PlusCircle } from 'react-feather'; +import { CustomLesson, ModuleCode, Semester } from 'types/modules'; +import styles from './CustomModuleSelect.scss'; +import CustomModuleModal from './CustomModuleModal'; + +export type Props = { + addCustomModule: (moduleCode: ModuleCode, title: string, lessons: CustomLesson[]) => void; + isModuleCodeAdded: (moduleCode: ModuleCode) => boolean; + semester: Semester; +}; + +type State = { + isOpen: boolean; +}; + +export default class CustomModuleSelect extends React.PureComponent { + override state: State = { + isOpen: false, + }; + + openModal = () => { + this.setState({ isOpen: true }); + }; + + closeModal = () => + this.setState({ + isOpen: false, + }); + + handleCustomModule = ( + _oldModuleCode: ModuleCode, + moduleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => { + this.props.addCustomModule(moduleCode, title, lessons); + }; + + override render() { + const { isOpen } = this.state; + + return ( +
+ + + +
+ ); + } +} diff --git a/website/src/views/timetable/ModuleTombstone.tsx b/website/src/views/timetable/ModuleTombstone.tsx index 56ae201f8f..b059594aff 100644 --- a/website/src/views/timetable/ModuleTombstone.tsx +++ b/website/src/views/timetable/ModuleTombstone.tsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import classnames from 'classnames'; import { undo } from 'actions/undoHistory'; import { Module } from 'types/modules'; +import { removeCustomIdentifier } from 'utils/customModule'; import styles from './TimetableModulesTable.scss'; export type Props = { @@ -13,7 +14,7 @@ export type Props = { const ModuleTombstone: React.FC = (props) => (
- {props.module.moduleCode} removed + {removeCustomIdentifier(props.module.moduleCode, true)} removed
diff --git a/website/src/views/timetable/TimetableCell.tsx b/website/src/views/timetable/TimetableCell.tsx index b62b4e28de..aaab5f7715 100644 --- a/website/src/views/timetable/TimetableCell.tsx +++ b/website/src/views/timetable/TimetableCell.tsx @@ -17,6 +17,7 @@ import { import { TRANSPARENT_COLOR_INDEX } from 'utils/colors'; import elements from 'views/elements'; import Tooltip from 'views/components/Tooltip/Tooltip'; +import { removeCustomIdentifier } from 'utils/customModule'; import { Minus, Plus } from 'react-feather'; import styles from './TimetableCell.scss'; @@ -90,7 +91,10 @@ function formatWeekRange(weekRange: WeekRange) { const TimetableCell: React.FC = (props) => { const { lesson, showTitle, onClick, onHover, hoverLesson, transparent } = props; - const moduleName = showTitle ? `${lesson.moduleCode} ${lesson.title}` : lesson.moduleCode; + const moduleCode = lesson.isCustom + ? removeCustomIdentifier(lesson.moduleCode) + : lesson.moduleCode; + const moduleName = showTitle ? `${moduleCode} ${lesson.title}` : moduleCode; const Cell = props.onClick ? 'button' : 'div'; const isHoveredOver = isEqual(getHoverLesson(lesson), hoverLesson); diff --git a/website/src/views/timetable/TimetableContainer.test.tsx b/website/src/views/timetable/TimetableContainer.test.tsx index a029c37663..e531b97680 100644 --- a/website/src/views/timetable/TimetableContainer.test.tsx +++ b/website/src/views/timetable/TimetableContainer.test.tsx @@ -130,7 +130,7 @@ describe(TimetableContainerComponent, () => { const importedTimetable = { [moduleCodeThatCanBeLoaded]: { 'Sectional Teaching': 'A1' }, // BFS1001 doesn't have Lecture, only SectionalTeaching }; - const location = timetableShare(semester, importedTimetable, [], {}); + const location = timetableShare(semester, importedTimetable, {}, [], {}); make(location); // Expect spinner when loading modules @@ -152,7 +152,13 @@ describe(TimetableContainerComponent, () => { test('should eventually display imported timetable without any modules loaded', async () => { const semester = 1; const importedTimetable = { [moduleCodeThatCanBeLoaded]: { 'Sectional Teaching': 'A1' } }; - const location = timetableShare(semester, importedTimetable, [moduleCodeThatCanBeLoaded], {}); + const location = timetableShare( + semester, + importedTimetable, + {}, + [moduleCodeThatCanBeLoaded], + {}, + ); make(location); // Expect spinner when loading modules @@ -174,7 +180,7 @@ describe(TimetableContainerComponent, () => { test('should ignore invalid modules in imported timetable', () => { const semester = 1; const importedTimetable = { TRUMP2020: { Lecture: '1' } }; - const location = timetableShare(semester, importedTimetable, [], {}); + const location = timetableShare(semester, importedTimetable, {}, [], {}); make(location); // Expect nothing to be fetched and the invalid module to be ignored diff --git a/website/src/views/timetable/TimetableContainer.tsx b/website/src/views/timetable/TimetableContainer.tsx index b63fd86622..9d7872e5e6 100644 --- a/website/src/views/timetable/TimetableContainer.tsx +++ b/website/src/views/timetable/TimetableContainer.tsx @@ -5,7 +5,7 @@ import { Repeat } from 'react-feather'; import classnames from 'classnames'; import type { ModuleCode, Semester } from 'types/modules'; -import type { ColorMapping } from 'types/reducers'; +import type { ColorMapping, CustomModuleLessonData } from 'types/reducers'; import type { State } from 'types/state'; import type { SemTimetableConfig, TaModulesConfig } from 'types/timetables'; @@ -13,6 +13,7 @@ import { selectSemester } from 'actions/settings'; import { getSemesterTimetableColors, getSemesterTimetableLessons } from 'selectors/timetables'; import { fetchTimetableModules, + setCustomModulesFromImport, setHiddenModulesFromImport, setTaModulesFromImport, setTimetable, @@ -20,7 +21,12 @@ import { import { openNotification } from 'actions/app'; import { undo } from 'actions/undoHistory'; import { getModuleCondensed } from 'selectors/moduleBank'; -import { deserializeHidden, deserializeTa, deserializeTimetable } from 'utils/timetables'; +import { + deserializeCustom, + deserializeHidden, + deserializeTa, + deserializeTimetable, +} from 'utils/timetables'; import { fillColorMapping } from 'utils/colors'; import { semesterForTimetablePage, TIMETABLE_SHARE, timetablePage } from 'views/routes/paths'; import deferComponentRender from 'views/hocs/deferComponentRender'; @@ -45,6 +51,7 @@ const SharingHeader: FC<{ filledColors: ColorMapping; importedTimetable: SemTimetableConfig | null; hiddenImportedModules: ModuleCode[] | null; + customImportedModules: CustomModuleLessonData | null; taImportedModules: TaModulesConfig | null; setImportedTimetable: (timetable: SemTimetableConfig | null) => void; }> = ({ @@ -52,6 +59,7 @@ const SharingHeader: FC<{ filledColors, importedTimetable, hiddenImportedModules, + customImportedModules, taImportedModules, setImportedTimetable, }) => { @@ -69,11 +77,17 @@ const SharingHeader: FC<{ if (!importedTimetable) { return; } + dispatch(setTimetable(semester, importedTimetable, filledColors)); if (hiddenImportedModules) { dispatch(setHiddenModulesFromImport(semester, hiddenImportedModules)); } + + if (customImportedModules) { + dispatch(setCustomModulesFromImport(semester, customImportedModules)); + } + if (taImportedModules) { dispatch(setTaModulesFromImport(semester, taImportedModules)); } @@ -97,6 +111,7 @@ const SharingHeader: FC<{ hiddenImportedModules, taImportedModules, semester, + customImportedModules, ]); if (!importedTimetable) { @@ -174,6 +189,9 @@ export const TimetableContainerComponent: FC = () => { const colors = useSelector(getSemesterTimetableColors)(semester); const getModule = useSelector(getModuleCondensed); const modules = useSelector(({ moduleBank }: State) => moduleBank.modules); + const customModules = useSelector( + ({ timetables }: State) => timetables.customModules[semester ?? ''], + ); const activeSemester = useSelector(({ app }: State) => app.activeSemester); const location = useLocation(); @@ -181,6 +199,11 @@ export const TimetableContainerComponent: FC = () => { semester && params.action ? deserializeTimetable(location.search) : null, ); + const importedCustom = useMemo( + () => (semester && params.action ? deserializeCustom(location.search) : null), + [semester, params.action, location.search], + ); + const importedHidden = useMemo( () => (semester && params.action ? deserializeHidden(location.search) : null), [semester, params.action, location.search], @@ -212,10 +235,13 @@ export const TimetableContainerComponent: FC = () => { }, [getModule, importedTimetable, modules, timetable]); const displayedTimetable = importedTimetable || timetable; + const displayedCustom = importedCustom || customModules; + const filledColors = useMemo( - () => fillColorMapping(displayedTimetable, colors), - [colors, displayedTimetable], + () => fillColorMapping(displayedTimetable, colors, Object.keys(displayedCustom ?? {})), + [colors, displayedTimetable, displayedCustom], ); + const readOnly = displayedTimetable === importedTimetable; useScrollToTop(); @@ -237,6 +263,7 @@ export const TimetableContainerComponent: FC = () => { semester={semester} timetable={displayedTimetable} hiddenImportedModules={importedHidden} + customImportedModules={displayedCustom} taImportedModules={importedTa} colors={filledColors} header={ @@ -245,6 +272,7 @@ export const TimetableContainerComponent: FC = () => { semester={semester} filledColors={filledColors} importedTimetable={importedTimetable} + customImportedModules={importedCustom} hiddenImportedModules={importedHidden} taImportedModules={importedTa} setImportedTimetable={setImportedTimetable} diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index 0faf5754f6..582cb07242 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -3,8 +3,14 @@ import classnames from 'classnames'; import { connect } from 'react-redux'; import { sortBy, difference, values, flatten, mapValues, isEmpty } from 'lodash'; -import { ColorMapping, HORIZONTAL, ModulesMap, TimetableOrientation } from 'types/reducers'; -import { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; +import { + ColorMapping, + CustomModuleLessonData, + HORIZONTAL, + ModulesMap, + TimetableOrientation, +} from 'types/reducers'; +import { ClassNo, CustomLesson, LessonType, Module, ModuleCode, Semester } from 'types/modules'; import { ColoredLesson, Lesson, @@ -16,10 +22,13 @@ import { } from 'types/timetables'; import { + addCustomModule, addModule, addTaLessonInTimetable, cancelModifyLesson, changeLesson, + deleteCustomModule, + modifyCustomModule, modifyLesson, removeModule, removeTaLessonInTimetable, @@ -33,6 +42,7 @@ import { getExamDate, getModuleTimetable, } from 'utils/modules'; +import { createCustomModule } from 'utils/customModule'; import { areOtherClassesAvailable, arrangeLessonsForWeek, @@ -72,6 +82,7 @@ type OwnProps = { timetable: SemTimetableConfig; colors: ColorMapping; hiddenImportedModules: ModuleCode[] | null; + customImportedModules: CustomModuleLessonData | null; taImportedModules: TaModulesConfig | null; }; @@ -83,10 +94,25 @@ type Props = OwnProps & { timetableOrientation: TimetableOrientation; showTitle: boolean; hiddenInTimetable: ModuleCode[]; + customModules: CustomModuleLessonData; taInTimetable: TaModulesConfig; // Actions addModule: (semester: Semester, moduleCode: ModuleCode) => void; + addCustomModule: ( + semester: Semester, + moduleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => void; + deleteCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; + modifyCustomModule: ( + semester: Semester, + oldModuleCode: ModuleCode, + moduleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => void; removeModule: (semester: Semester, moduleCode: ModuleCode) => void; resetTimetable: (semester: Semester) => void; modifyLesson: (lesson: Lesson) => void; @@ -240,6 +266,11 @@ class TimetableContent extends React.Component { this.resetTombstone(); }; + addCustomModule = (moduleCode: ModuleCode, title: string, lessons: CustomLesson[]) => { + this.props.addCustomModule(this.props.semester, moduleCode, title, lessons); + this.resetTombstone(); + }; + removeModule = (moduleCodeToRemove: ModuleCode) => { // Save the index of the module before removal so the tombstone can be inserted into // the correct position @@ -257,11 +288,43 @@ class TimetableContent extends React.Component { this.props.resetTimetable(this.props.semester); }; + removeCustomModule = (moduleCodeToRemove: ModuleCode) => { + // Save the index of the module before removal so the tombstone can be inserted into + // the correct position + const index = this.addedModules().findIndex( + ({ moduleCode }) => moduleCode === moduleCodeToRemove, + ); + this.props.deleteCustomModule(this.props.semester, moduleCodeToRemove); + const moduleWithColor = this.toModuleWithColor(this.addedModules()[index]); + + // A tombstone is displayed in place of a deleted module + this.setState({ tombstone: { ...moduleWithColor, index } }); + }; + + editCustomModule = ( + oldModuleCode: ModuleCode, + newModuleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => { + this.props.modifyCustomModule( + this.props.semester, + oldModuleCode, + newModuleCode, + title, + lessons, + ); + }; + resetTombstone = () => this.setState({ tombstone: null }); // Returns modules currently in the timetable addedModules(): Module[] { - const modules = getSemesterModules(this.props.timetableWithLessons, this.props.modules); + const modules = getSemesterModules(this.props.timetableWithLessons, this.props.modules).concat( + Object.entries(this.props.customModules).map(([moduleCode, { title }]) => + createCustomModule(moduleCode, title), + ), + ); return sortBy(modules, (module: Module) => getExamDate(module, this.props.semester)); } @@ -279,10 +342,14 @@ class TimetableContent extends React.Component { tombstone: TombstoneModule | null = null, ) => ( { const { semester, modules, + customModules, colors, activeLesson, timetableOrientation, @@ -354,6 +422,11 @@ class TimetableContent extends React.Component { const { showExamCalendar } = this.state; let timetableLessons: Lesson[] = timetableLessonsArray(this.props.timetableWithLessons) + .concat( + Object.entries(customModules).flatMap(([moduleCode, { title, lessons }]) => + lessons.map((lesson) => ({ ...lesson, moduleCode, title, isCustom: true })), + ), + ) // Omit all lessons for hidden modules .filter((lesson) => !this.isHiddenInTimetable(lesson.moduleCode)); @@ -415,7 +488,8 @@ class TimetableContent extends React.Component { (dayRows) => dayRows.map((row) => row.map((lesson) => { - const module: Module = modules[lesson.moduleCode]; + const module: Module = + modules[lesson.moduleCode] || createCustomModule(lesson.moduleCode, lesson.title); const moduleTimetable = getModuleTimetable(module, semester); return { @@ -499,6 +573,8 @@ class TimetableContent extends React.Component { showExamCalendar={showExamCalendar} resetTimetable={this.resetTimetable} toggleExamCalendar={() => this.setState({ showExamCalendar: !showExamCalendar })} + customModules={customModules} + addCustomModule={this.addCustomModule} hiddenModules={hiddenInTimetable} taModules={taInTimetable} /> @@ -538,8 +614,11 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { const { semester, timetable } = ownProps; const { modules } = state.moduleBank; + // Determine the key to check for hidden modules based on readOnly status const hiddenInTimetable = ownProps.hiddenImportedModules ?? state.timetables.hidden[semester] ?? []; + const customModules = + ownProps.customImportedModules ?? state.timetables.customModules[semester] ?? {}; const taInTimetable = ownProps.taImportedModules ?? state.timetables.ta[semester] ?? {}; const timetableWithLessons = hydrateSemTimetableWithLessons(timetable, modules, semester); @@ -558,6 +637,7 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { timetable, timetableWithLessons: filteredTimetableWithLessons, modules, + customModules, activeLesson: state.app.activeLesson, timetableOrientation: state.theme.timetableOrientation, showTitle: state.theme.showTitle, @@ -568,6 +648,9 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { export default connect(mapStateToProps, { addModule, + addCustomModule, + deleteCustomModule, + modifyCustomModule, removeModule, resetTimetable, modifyLesson, diff --git a/website/src/views/timetable/TimetableModulesTable.test.tsx b/website/src/views/timetable/TimetableModulesTable.test.tsx index dd70ceeee9..f9b24c869f 100644 --- a/website/src/views/timetable/TimetableModulesTable.test.tsx +++ b/website/src/views/timetable/TimetableModulesTable.test.tsx @@ -7,6 +7,8 @@ import { TimetableModulesTableComponent, Props } from './TimetableModulesTable'; import styles from './TimetableModulesTable.scss'; function make(props: Partial = {}) { + const addModule = jest.fn(); + const editCustomModule = jest.fn(); const selectModuleColor = jest.fn(); const hideLessonInTimetable = jest.fn(); const showLessonInTimetable = jest.fn(); @@ -14,6 +16,7 @@ function make(props: Partial = {}) { const disableTaModeInTimetable = jest.fn(); const onRemoveModule = jest.fn(); const resetTombstone = jest.fn(); + const onRemoveCustomModule = jest.fn(); const wrapper = shallow( = {}) { moduleTableOrder="exam" modules={[]} tombstone={null} + addModule={addModule} + editCustomModule={editCustomModule} selectModuleColor={selectModuleColor} hideLessonInTimetable={hideLessonInTimetable} showLessonInTimetable={showLessonInTimetable} enableTaModeInTimetable={enableTaModeInTimetable} disableTaModeInTimetable={disableTaModeInTimetable} onRemoveModule={onRemoveModule} + onRemoveCustomModule={onRemoveCustomModule} resetTombstone={resetTombstone} + customModules={{}} {...props} />, ); @@ -96,7 +103,7 @@ describe(TimetableModulesTableComponent, () => { const withoutTaButton = getButtons(make({ modules: addColors([CS1010S]) }).wrapper); expect(withoutTaButton.at(0).children()).toHaveLength(2); - const modulesWithTaAbleModule = addColors([CS1010S], false, false, true); + const modulesWithTaAbleModule = addColors([CS1010S], false, false, false, true); const withTaButton = getButtons(make({ modules: modulesWithTaAbleModule }).wrapper); expect(withTaButton.at(0).children()).toHaveLength(3); }); diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index c37bad9c8b..e103bc0ad5 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -8,9 +8,9 @@ import { produce } from 'immer'; import { Book, BookOpen, Eye, EyeOff, Trash } from 'react-feather'; import { ModuleWithColor, TombstoneModule } from 'types/views'; import { ColorIndex } from 'types/timetables'; -import { ModuleCode, Semester } from 'types/modules'; +import { CustomLesson, ModuleCode, Semester } from 'types/modules'; import { State as StoreState } from 'types/state'; -import { ModuleTableOrder } from 'types/reducers'; +import { CustomModuleLessonData, ModuleTableOrder } from 'types/reducers'; import ColorPicker from 'views/components/ColorPicker'; import { @@ -26,6 +26,7 @@ import { getExamDuration, renderExamDuration, } from 'utils/modules'; +import { removeCustomIdentifier } from 'utils/customModule'; import { intersperse } from 'utils/array'; import { BULLET_NBSP } from 'utils/react'; import { modulePage } from 'views/routes/paths'; @@ -36,6 +37,7 @@ import config from 'config'; import styles from './TimetableModulesTable.scss'; import ModuleTombstone from './ModuleTombstone'; import { moduleOrders } from './ModulesTableFooter'; +import CustomModuleEdit from './CustomModuleEdit'; export type Props = { semester: Semester; @@ -43,36 +45,69 @@ export type Props = { horizontalOrientation: boolean; moduleTableOrder: ModuleTableOrder; modules: ModuleWithColor[]; + customModules: CustomModuleLessonData; tombstone: TombstoneModule | null; // Placeholder for a deleted module // Actions + addModule: (semester: Semester, moduleCode: ModuleCode) => void; selectModuleColor: (semester: Semester, moduleCode: ModuleCode, colorIndex: ColorIndex) => void; hideLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; showLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; enableTaModeInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; disableTaModeInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; onRemoveModule: (moduleCode: ModuleCode) => void; + onRemoveCustomModule: (moduleCode: ModuleCode) => void; + editCustomModule: ( + oldModuleCode: ModuleCode, + newModuleCode: ModuleCode, + title: string, + lessons: CustomLesson[], + ) => void; resetTombstone: () => void; }; export const TimetableModulesTableComponent: React.FC = (props) => { const renderModuleActions = (module: ModuleWithColor) => { - const removeBtnLabel = `Remove ${module.moduleCode} from timetable`; - const hideBtnLabel = `${module.isHiddenInTimetable ? 'Show' : 'Hide'} ${module.moduleCode}`; + const actualModuleCode = module.isCustom + ? removeCustomIdentifier(module.moduleCode) + : module.moduleCode; + + const removeBtnLabel = `Remove ${actualModuleCode} from timetable`; + const hideBtnLabel = `${module.isHiddenInTimetable ? 'Show' : 'Hide'} ${actualModuleCode}`; const taBtnLabel = `${module.isTaInTimetable ? 'Disable' : 'Enable'} TA for ${ module.moduleCode }`; const { semester } = props; + const removeModule = (moduleCode: ModuleCode, isCustom: boolean | undefined) => { + if (isCustom) { + props.onRemoveCustomModule(moduleCode); + } else { + props.onRemoveModule(moduleCode); + } + }; + return (
- + {module.isCustom && ( + !!props.customModules[moduleCode]} + editCustomModule={props.editCustomModule} + moduleActionStyle={styles.moduleAction} + actionIconStyle={styles.actionIcon} + semester={semester} + /> + )} + @@ -126,6 +161,9 @@ export const TimetableModulesTableComponent: React.FC = (props) => { const renderModule = (module: ModuleWithColor) => { const { semester, readOnly, tombstone, resetTombstone } = props; + const actualModuleCode = module.isCustom + ? removeCustomIdentifier(module.moduleCode) + : module.moduleCode; if (tombstone && tombstone.moduleCode === module.moduleCode) { return ; @@ -133,7 +171,9 @@ export const TimetableModulesTableComponent: React.FC = (props) => { // Second row of text consists of the exam date and the MCs const secondRowText = [renderMCs(module.moduleCredit)]; - if (config.examAvailabilitySet.has(semester)) { + if (module.isCustom) { + secondRowText[0] = 'Custom Module'; + } else if (config.examAvailabilitySet.has(semester)) { const examDuration = getExamDuration(module, semester); const examDate = getExamDate(module, semester); @@ -150,7 +190,7 @@ export const TimetableModulesTableComponent: React.FC = (props) => { <>
= (props) => {
{!readOnly && renderModuleActions(module)} - - {module.moduleCode} {module.title} + + {actualModuleCode} {module.title}
{intersperse(secondRowText, BULLET_NBSP)}
@@ -184,20 +224,22 @@ export const TimetableModulesTableComponent: React.FC = (props) => { modules = sortBy(modules, (module) => moduleOrders[moduleTableOrder].orderBy(module, semester)); return ( -
- {modules.map((module) => ( -
- {renderModule(module)} -
- ))} -
+ <> +
+ {modules.map((module) => ( +
+ {renderModule(module)} +
+ ))} +
+ ); };