diff --git a/website/src/actions/__snapshots__/timetables.test.ts.snap b/website/src/actions/__snapshots__/timetables.test.ts.snap index edba593353..8f502de21f 100644 --- a/website/src/actions/__snapshots__/timetables.test.ts.snap +++ b/website/src/actions/__snapshots__/timetables.test.ts.snap @@ -10,6 +10,7 @@ exports[`cancelModifyLesson should not have payload 1`] = ` exports[`changeLesson should return updated information to change lesson 1`] = ` { "payload": { + "activeLesson": "1", "classNo": "1", "lessonType": "Recitation", "moduleCode": "CS1010S", diff --git a/website/src/actions/timetables.test.ts b/website/src/actions/timetables.test.ts index e021893fa4..87f78daccd 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -36,7 +36,7 @@ test('modifyLesson should return lesson payload', () => { test('changeLesson should return updated information to change lesson', () => { const semester: Semester = 1; const lesson: Lesson = lessons[1]; - expect(actions.changeLesson(semester, lesson)).toMatchSnapshot(); + expect(actions.changeLesson(semester, lesson, lesson.classNo)).toMatchSnapshot(); }); test('cancelModifyLesson should not have payload', () => { @@ -53,19 +53,21 @@ describe('fillTimetableBlanks', () => { const moduleBank = { modules: { CS1010S, CS3216 } }; const timetablesState = (semester: Semester, timetable: SemTimetableConfig) => ({ lessons: { [semester]: timetable }, + customisedModules: { [semester]: [] }, }); const semester = 1; const action = actions.validateTimetable(semester); test('do nothing if timetable is already full', () => { - const timetable = { + const timetable: SemTimetableConfig = { CS1010S: { - Lecture: '1', - Tutorial: '1', - Recitation: '1', + Lecture: ['1'], + Tutorial: ['1'], + Recitation: ['1'], }, }; + // TODO(zwliew): Correctly type all the `state: any` declarations in this function and the rest of the codebase. const state: any = { timetables: timetablesState(semester, timetable), moduleBank }; const dispatch = jest.fn(); action(dispatch, () => state); @@ -74,10 +76,10 @@ describe('fillTimetableBlanks', () => { }); test('fill missing lessons with randomly generated modules', () => { - const timetable = { + const timetable: SemTimetableConfig = { CS1010S: { - Lecture: '1', - Tutorial: '1', + Lecture: ['1'], + Tutorial: ['1'], }, CS3216: {}, }; @@ -95,9 +97,9 @@ describe('fillTimetableBlanks', () => { semester, moduleCode: 'CS1010S', lessonConfig: { - Lecture: '1', - Tutorial: '1', - Recitation: expect.any(String), + Lecture: ['1'], + Tutorial: ['1'], + Recitation: expect.any(Array), }, }, }); @@ -108,7 +110,7 @@ describe('fillTimetableBlanks', () => { semester, moduleCode: 'CS3216', lessonConfig: { - Lecture: '1', + Lecture: ['1'], }, }, }); diff --git a/website/src/actions/timetables.ts b/website/src/actions/timetables.ts index 3378567c52..ce7763badc 100644 --- a/website/src/actions/timetables.ts +++ b/website/src/actions/timetables.ts @@ -90,15 +90,86 @@ export function modifyLesson(activeLesson: Lesson) { }; } +export const CUSTOMISE_MODULE = 'CUSTOMISE_LESSON' as const; +export function customiseLesson(semester: Semester, moduleCode: ModuleCode) { + return { + type: CUSTOMISE_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + export const CHANGE_LESSON = 'CHANGE_LESSON' as const; export function setLesson( semester: Semester, moduleCode: ModuleCode, lessonType: LessonType, classNo: ClassNo, + activeLesson: ClassNo, ) { return { type: CHANGE_LESSON, + payload: { + semester, + moduleCode, + lessonType, + classNo, + activeLesson, + }, + }; +} + +export const ADD_CUSTOM_MODULE = 'ADD_CUSTOM_MODULE' as const; +export function addCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: ADD_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + +export const REMOVE_CUSTOM_MODULE = 'REMOVE_CUSTOM_MODULE' as const; +export function removeCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: REMOVE_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + +export const ADD_LESSON = 'ADD_LESSON' as const; +export function addLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, +) { + return { + type: ADD_LESSON, + payload: { + semester, + moduleCode, + lessonType, + classNo, + }, + }; +} + +export const REMOVE_LESSON = 'REMOVE_LESSON' as const; +export function removeLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, +) { + return { + type: REMOVE_LESSON, payload: { semester, moduleCode, @@ -108,8 +179,8 @@ export function setLesson( }; } -export function changeLesson(semester: Semester, lesson: Lesson) { - return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo); +export function changeLesson(semester: Semester, lesson: Lesson, activeLesson: ClassNo) { + return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo, activeLesson); } export const SET_LESSON_CONFIG = 'SET_LESSON_CONFIG' as const; @@ -165,6 +236,9 @@ export function validateTimetable(semester: Semester) { const module = moduleBank.modules[moduleCode]; if (!module) return; + // Do not validate customised modules. + if (timetables.customisedModules[semester]?.includes(moduleCode)) return; + const [validatedLessonConfig, changedLessonTypes] = validateModuleLessons( semester, lessonConfig, diff --git a/website/src/reducers/app.test.ts b/website/src/reducers/app.test.ts index 414271123c..806897cbc1 100644 --- a/website/src/reducers/app.test.ts +++ b/website/src/reducers/app.test.ts @@ -20,6 +20,7 @@ const appInitialState: AppState = { isFeedbackModalOpen: false, promptRefresh: false, notifications: [], + customiseModule: '', }; const appHasSemesterTwoState: AppState = { ...appInitialState, activeSemester: anotherSemester }; const appHasActiveLessonState: AppState = { ...appInitialState, activeLesson: lesson }; @@ -55,7 +56,7 @@ test('app should set active lesson', () => { }); test('app should accept lesson change and unset active lesson', () => { - const action = changeLesson(semester, lesson); + const action = changeLesson(semester, lesson, lesson.classNo); const nextState: AppState = reducer(appInitialState, action); expect(nextState).toEqual(appInitialState); diff --git a/website/src/reducers/app.ts b/website/src/reducers/app.ts index 1748f527f5..524d570f59 100644 --- a/website/src/reducers/app.ts +++ b/website/src/reducers/app.ts @@ -3,7 +3,12 @@ import { Actions } from 'types/actions'; import config from 'config'; import { forceRefreshPrompt } from 'utils/debug'; -import { MODIFY_LESSON, CHANGE_LESSON, CANCEL_MODIFY_LESSON } from 'actions/timetables'; +import { + MODIFY_LESSON, + CHANGE_LESSON, + CANCEL_MODIFY_LESSON, + CUSTOMISE_MODULE, +} from 'actions/timetables'; import { SELECT_SEMESTER } from 'actions/settings'; import { OPEN_NOTIFICATION, @@ -18,6 +23,7 @@ const defaultAppState = (): AppState => ({ activeSemester: config.semester, // The lesson being modified on the timetable. activeLesson: null, + customiseModule: '', isOnline: navigator.onLine, isFeedbackModalOpen: false, promptRefresh: forceRefreshPrompt(), @@ -37,6 +43,11 @@ function app(state: AppState = defaultAppState(), action: Actions): AppState { ...state, activeLesson: action.payload.activeLesson, }; + case CUSTOMISE_MODULE: + return { + ...state, + customiseModule: action.payload.moduleCode, + }; case CANCEL_MODIFY_LESSON: case CHANGE_LESSON: return { diff --git a/website/src/reducers/index.test.ts b/website/src/reducers/index.test.ts index 8a197c3845..bccc4c16ec 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -10,16 +10,16 @@ const exportData: ExportData = { semester: 1, timetable: { CS3216: { - Lecture: '1', + Lecture: ['1'], }, CS1010S: { - Lecture: '1', - Tutorial: '3', - Recitation: '2', + Lecture: ['1'], + Tutorial: ['3'], + Recitation: ['2'], }, PC1222: { - Lecture: '1', - Tutorial: '3', + Lecture: ['1'], + Tutorial: ['3'], }, }, colors: { @@ -52,16 +52,16 @@ test('reducers should set export data state', () => { lessons: { [1]: { CS3216: { - Lecture: '1', + Lecture: ['1'], }, CS1010S: { - Lecture: '1', - Tutorial: '3', - Recitation: '2', + Lecture: ['1'], + Tutorial: ['3'], + Recitation: ['2'], }, PC1222: { - Lecture: '1', - Tutorial: '3', + Lecture: ['1'], + Tutorial: ['3'], }, }, }, @@ -72,6 +72,7 @@ test('reducers should set export data state', () => { PC1222: 2, }, }, + customisedModules: {}, hidden: { [1]: ['PC1222'] }, academicYear: expect.any(String), archive: {}, diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index 57459dd2e7..46ef9b3674 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -1,4 +1,4 @@ -import reducer, { defaultTimetableState, persistConfig } from 'reducers/timetables'; +import reducer, { defaultTimetableState, migrateV1toV2, persistConfig } from 'reducers/timetables'; import { ADD_MODULE, hideLessonInTimetable, @@ -125,41 +125,41 @@ describe('lesson reducer', () => { lessons: { [1]: { CS1010S: { - Lecture: '1', - Recitation: '2', + Lecture: ['1'], + Recitation: ['2'], }, CS3216: { - Lecture: '1', + Lecture: ['1'], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: ['1'], }, }, }, }, setLessonConfig(1, 'CS1010S', { - Lecture: '2', - Recitation: '3', - Tutorial: '4', + Lecture: ['2'], + Recitation: ['3'], + Tutorial: ['4'], }), ), ).toMatchObject({ lessons: { [1]: { CS1010S: { - Lecture: '2', - Recitation: '3', - Tutorial: '4', + Lecture: ['2'], + Recitation: ['3'], + Tutorial: ['4'], }, CS3216: { - Lecture: '1', + Lecture: ['1'], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: ['1'], }, }, }, @@ -172,7 +172,7 @@ describe('stateReconciler', () => { '2015/2016': { [1]: { GET1006: { - Lecture: '1', + Lecture: ['1'], }, }, }, @@ -181,13 +181,13 @@ describe('stateReconciler', () => { const oldLessons = { [1]: { CS1010S: { - Lecture: '1', - Recitation: '2', + Lecture: ['1'], + Recitation: ['2'], }, }, [2]: { CS3217: { - Lecture: '1', + Lecture: ['1'], }, }, }; @@ -207,6 +207,7 @@ describe('stateReconciler', () => { }, academicYear: config.academicYear, archive: oldArchive, + customisedModules: {}, }; const { stateReconciler } = persistConfig; @@ -239,3 +240,60 @@ describe('stateReconciler', () => { }); }); }); + +describe('redux schema migration', () => { + const reduxDataV1 = { + lessons: { + [1]: { + CS1010S: { + Lecture: '1', + Recitation: '2', + }, + }, + [2]: { + CS3217: { + Lecture: '1', + }, + }, + }, + colors: {}, + hidden: {}, + academicYear: '2022/2023', + archive: {}, + _persist: { + version: 1, + rehydrated: false, + }, + }; + + const reduxDataV2 = { + lessons: { + [1]: { + CS1010S: { + Lecture: ['1'], + Recitation: ['2'], + }, + }, + [2]: { + CS3217: { + Lecture: ['1'], + }, + }, + }, + colors: {}, + hidden: {}, + academicYear: '2022/2023', + archive: {}, + customisedModules: { + [1]: [], + [2]: [], + }, + _persist: { + version: 1, // version kept the same because the framework does not support it in unit tests + rehydrated: false, + }, + }; + test('should migrate from V1 to V2', () => { + expect(migrateV1toV2(reduxDataV1)).toEqual(reduxDataV2); + }); +}); diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index f7032a75fc..c8c12f8c3a 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -1,27 +1,62 @@ import { get, omit, values } from 'lodash'; import produce from 'immer'; -import { createMigrate } from 'redux-persist'; +import { createMigrate, PersistedState } from 'redux-persist'; import { PersistConfig } from 'storage/persistReducer'; import { ModuleCode } from 'types/modules'; -import { ModuleLessonConfig, SemTimetableConfig } from 'types/timetables'; -import { ColorMapping, TimetablesState } from 'types/reducers'; +import { ModuleLessonConfig, SemTimetableConfig, TimetableConfig } from 'types/timetables'; +import { ColorMapping, CustomisedModulesMap, TimetablesState } from 'types/reducers'; import config from 'config'; import { ADD_MODULE, CHANGE_LESSON, + ADD_LESSON, + REMOVE_LESSON, HIDE_LESSON_IN_TIMETABLE, REMOVE_MODULE, SELECT_MODULE_COLOR, SET_LESSON_CONFIG, SET_TIMETABLE, SHOW_LESSON_IN_TIMETABLE, + ADD_CUSTOM_MODULE, + REMOVE_CUSTOM_MODULE, } from 'actions/timetables'; import { getNewColor } from 'utils/colors'; import { SET_EXPORTED_DATA } from 'actions/constants'; import { Actions } from '../types/actions'; +// Migration from state V1 -> V2 +type TimetableStateV1 = Omit & { + lessons: { [semester: string]: { [moduleCode: string]: { [lessonType: string]: string } } }; +}; +export function migrateV1toV2( + oldState: TimetableStateV1 & PersistedState, +): TimetablesState & PersistedState { + const newLessons: TimetableConfig = {}; + const newCustomisedModules: CustomisedModulesMap = {}; + + Object.entries(oldState.lessons).forEach(([semester, modules]) => { + newCustomisedModules[semester] = []; + + // Migrate existing lessons to V2 format. + const newSemester: SemTimetableConfig = {}; + Object.entries(modules).forEach(([moduleCode, lessons]) => { + newSemester[moduleCode] = {}; + Object.entries(lessons).forEach(([type, classNum]) => { + newSemester[moduleCode][type] = [classNum]; + }); + }); + newLessons[semester] = newSemester; + }); + + return { + ...oldState, + lessons: newLessons, + customisedModules: newCustomisedModules, + }; +} + export const persistConfig = { /* eslint-disable no-useless-computed-key */ migrate: createMigrate({ @@ -34,9 +69,12 @@ export const persistConfig = { // eslint-disable-next-line no-underscore-dangle, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain _persist: state?._persist!, }), + // Same as planner.ts + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [2]: migrateV1toV2 as any, }), /* eslint-enable */ - version: 1, + version: 2, // Our own state reconciler archives old timetables if the acad year is different, // otherwise use the persisted timetable state @@ -79,15 +117,33 @@ function moduleLessonConfig( switch (action.type) { case CHANGE_LESSON: { const { classNo, lessonType } = action.payload; - if (!(classNo && lessonType)) return state; + if (!classNo || !lessonType) return state; return { ...state, - [lessonType]: classNo, + [lessonType]: [ + ...state[lessonType].filter((lesson) => lesson !== action.payload.activeLesson), + action.payload.classNo, + ], }; } case SET_LESSON_CONFIG: return action.payload.lessonConfig; - + case ADD_LESSON: { + const { classNo, lessonType } = action.payload; + if (!classNo || !lessonType) return state; + return { + ...state, + [lessonType]: [...state[lessonType], classNo], + }; + } + case REMOVE_LESSON: { + const { classNo, lessonType } = action.payload; + if (!classNo || !lessonType) return state; + return { + ...state, + [lessonType]: state[lessonType].filter((lesson) => lesson !== classNo), + }; + } default: return state; } @@ -111,6 +167,8 @@ function semTimetable( case REMOVE_MODULE: return omit(state, [moduleCode]); case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: return { ...state, @@ -166,12 +224,31 @@ function semHiddenModules(state = defaultHiddenState, action: Actions) { } } +// Map of CustomisedModules +const defaultCustomisedModulesState: ModuleCode[] = []; +function customisedModules(state = defaultCustomisedModulesState, action: Actions) { + if (!action.payload) { + return state; + } + + switch (action.type) { + case ADD_CUSTOM_MODULE: + if (state.includes(action.payload.moduleCode)) return state; + return [...state, action.payload.moduleCode]; + case REMOVE_CUSTOM_MODULE: + return state.filter((c) => c !== action.payload.moduleCode); + default: + return state; + } +} + export const defaultTimetableState: TimetablesState = { lessons: {}, colors: {}, hidden: {}, academicYear: config.academicYear, archive: {}, + customisedModules: {}, }; function timetables( @@ -197,15 +274,23 @@ function timetables( case REMOVE_MODULE: case SELECT_MODULE_COLOR: case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: case HIDE_LESSON_IN_TIMETABLE: - case SHOW_LESSON_IN_TIMETABLE: { + case SHOW_LESSON_IN_TIMETABLE: + case ADD_CUSTOM_MODULE: + case REMOVE_CUSTOM_MODULE: { const { semester } = action.payload; return produce(state, (draft) => { draft.lessons[semester] = semTimetable(draft.lessons[semester], action); draft.colors[semester] = semColors(state.colors[semester], action); draft.hidden[semester] = semHiddenModules(state.hidden[semester], action); + draft.customisedModules[semester] = customisedModules( + state.customisedModules[semester], + action, + ); }); } diff --git a/website/src/selectors/timetables.ts b/website/src/selectors/timetables.ts index 75a5d719a9..ef66e8da7a 100644 --- a/website/src/selectors/timetables.ts +++ b/website/src/selectors/timetables.ts @@ -38,3 +38,8 @@ export const getSemesterTimetableColors = createSelector( (colors) => (semester: Semester | null) => semester === null ? EMPTY_OBJECT : colors[semester] ?? EMPTY_OBJECT, ); + +export const getCustomisingLesson = createSelector( + ({ app }: State) => app.customiseModule, + (customiseModule) => customiseModule ?? null, +); diff --git a/website/src/types/reducers.ts b/website/src/types/reducers.ts index cd37df9e8f..24bb6b7389 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -51,6 +51,7 @@ export type NotificationData = { readonly message: string } & NotificationOption export type AppState = { readonly activeSemester: Semester; readonly activeLesson: Lesson | null; + readonly customiseModule: ModuleCode; readonly isOnline: boolean; readonly isFeedbackModalOpen: boolean; readonly notifications: NotificationData[]; @@ -112,6 +113,7 @@ export type SettingsState = { export type ColorMapping = { [moduleCode: string]: ColorIndex }; export type SemesterColorMap = { [semester: string]: ColorMapping }; export type HiddenModulesMap = { [semester: string]: ModuleCode[] }; +export type CustomisedModulesMap = { [semester: string]: ModuleCode[] | undefined }; export type TimetablesState = { readonly lessons: TimetableConfig; @@ -120,6 +122,7 @@ export type TimetablesState = { readonly academicYear: string; // Mapping of academic year to old timetable config readonly archive: { [key: string]: TimetableConfig }; + readonly customisedModules: CustomisedModulesMap; }; /* venueBank.js */ diff --git a/website/src/types/timetables.ts b/website/src/types/timetables.ts index ebe2c793a1..2f59eabac8 100644 --- a/website/src/types/timetables.ts +++ b/website/src/types/timetables.ts @@ -2,7 +2,7 @@ import { ClassNo, LessonType, ModuleCode, ModuleTitle, RawLesson } from './modul // ModuleLessonConfig is a mapping of lessonType to ClassNo for a module. export type ModuleLessonConfig = { - [lessonType: string]: ClassNo; + [lessonType: string]: ClassNo[]; }; // SemTimetableConfig is the timetable data for each semester. diff --git a/website/src/utils/timetables.test.ts b/website/src/utils/timetables.test.ts index 0953a72dea..0dd5b7c80c 100644 --- a/website/src/utils/timetables.test.ts +++ b/website/src/utils/timetables.test.ts @@ -85,9 +85,9 @@ test('hydrateSemTimetableWithLessons should replace ClassNo with lessons', () => const modules: ModulesMap = { [moduleCode]: CS1010S }; const config: SemTimetableConfig = { [moduleCode]: { - Tutorial: '8', - Recitation: '4', - Lecture: '1', + Tutorial: ['8'], + Recitation: ['4'], + Lecture: ['1'], }, }; @@ -385,15 +385,15 @@ test('timetable serialization/deserialization', () => { {}, { CS1010S: {} }, { - GER1000: { Tutorial: 'B01' }, + GER1000: { Tutorial: ['B01'] }, }, { - CS2104: { Lecture: '1', Tutorial: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, - CS2107: { Lecture: '1', Tutorial: '8' }, - CS4212: { Lecture: '1', Tutorial: '1' }, - CS4243: { Laboratory: '2', Lecture: '1' }, - GER1000: { Tutorial: 'B01' }, + CS2104: { Lecture: ['1'], Tutorial: ['2'] }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, + CS2107: { Lecture: ['1'], Tutorial: ['8'] }, + CS4212: { Lecture: ['1'], Tutorial: ['1'] }, + CS4243: { Laboratory: ['2'], Lecture: ['1'] }, + GER1000: { Tutorial: ['B01'] }, }, ]; @@ -406,8 +406,8 @@ test('deserializing edge cases', () => { // Duplicate module code expect(deserializeTimetable('CS1010S=LEC:01&CS1010S=REC:11')).toEqual({ CS1010S: { - Lecture: '01', - Recitation: '11', + Lecture: ['01'], + Recitation: ['11'], }, }); @@ -416,7 +416,7 @@ test('deserializing edge cases', () => { CS1010S: {}, CS3217: {}, CS2105: { - Lecture: '1', + Lecture: ['1'], }, }); }); @@ -428,8 +428,8 @@ test('isSameTimetableConfig', () => { // Change lessonType order expect( isSameTimetableConfig( - { CS2104: { Tutorial: '1', Lecture: '2' } }, - { CS2104: { Lecture: '2', Tutorial: '1' } }, + { CS2104: { Tutorial: ['1'], Lecture: ['2'] } }, + { CS2104: { Lecture: ['2'], Tutorial: ['1'] } }, ), ).toBe(true); @@ -437,12 +437,12 @@ test('isSameTimetableConfig', () => { expect( isSameTimetableConfig( { - CS2104: { Lecture: '1', Tutorial: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, + CS2104: { Lecture: ['1'], Tutorial: ['2'] }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, }, { - CS2105: { Lecture: '1', Tutorial: '1' }, - CS2104: { Lecture: '1', Tutorial: '2' }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, + CS2104: { Lecture: ['1'], Tutorial: ['2'] }, }, ), ).toBe(true); @@ -450,8 +450,8 @@ test('isSameTimetableConfig', () => { // Different values expect( isSameTimetableConfig( - { CS2104: { Lecture: '1', Tutorial: '2' } }, - { CS2104: { Lecture: '2', Tutorial: '1' } }, + { CS2104: { Lecture: ['1'], Tutorial: ['2'] } }, + { CS2104: { Lecture: ['2'], Tutorial: ['1'] } }, ), ).toBe(false); @@ -459,11 +459,11 @@ test('isSameTimetableConfig', () => { expect( isSameTimetableConfig( { - CS2104: { Tutorial: '1', Lecture: '2' }, + CS2104: { Tutorial: ['1'], Lecture: ['2'] }, }, { - CS2104: { Tutorial: '1', Lecture: '2' }, - CS2105: { Lecture: '1', Tutorial: '1' }, + CS2104: { Tutorial: ['1'], Lecture: ['2'] }, + CS2105: { Lecture: ['1'], Tutorial: ['1'] }, }, ), ).toBe(false); @@ -499,9 +499,9 @@ describe(validateTimetableModules, () => { describe('validateModuleLessons', () => { const semester: Semester = 1; const lessons: ModuleLessonConfig = { - Lecture: '1', - Recitation: '10', - Tutorial: '11', + Lecture: ['1'], + Recitation: ['10'], + Tutorial: ['11'], }; test('should leave valid lessons untouched', () => { @@ -514,7 +514,7 @@ describe('validateModuleLessons', () => { semester, { ...lessons, - Laboratory: '2', // CS1010S has no lab + Laboratory: ['2'], // CS1010S has no lab }, CS1010S, ), @@ -527,7 +527,7 @@ describe('validateModuleLessons', () => { semester, { ...lessons, - Lecture: '2', // CS1010S has no Lecture 2 + Lecture: ['2'], // CS1010S has no Lecture 2 }, CS1010S, ), @@ -539,15 +539,15 @@ describe('validateModuleLessons', () => { validateModuleLessons( semester, { - Tutorial: '10', + Tutorial: ['10'], }, CS1010S, ), ).toEqual([ { - Lecture: '1', - Recitation: '1', - Tutorial: '10', + Lecture: ['1'], + Recitation: ['1'], + Tutorial: ['10'], }, ['Lecture', 'Recitation'], ]); diff --git a/website/src/utils/timetables.ts b/website/src/utils/timetables.ts index 42d69219b5..9bbbe2c246 100644 --- a/website/src/utils/timetables.ts +++ b/website/src/utils/timetables.ts @@ -76,6 +76,7 @@ export const LESSON_ABBREV_TYPE: { [key: string]: LessonType } = invert(LESSON_T // See: https://stackoverflow.com/a/31300627 export const LESSON_TYPE_SEP = ':'; export const LESSON_SEP = ','; +export const SAME_LESSON_SEP = ';'; const EMPTY_OBJECT = {}; @@ -103,8 +104,9 @@ export function randomModuleLessonConfig(lessons: readonly RawLesson[]): ModuleL return mapValues( lessonByGroupsByClassNo, - (group: { [classNo: string]: readonly RawLesson[] }) => + (group: { [classNo: string]: readonly RawLesson[] }) => [ (first(sample(group)) as RawLesson).classNo, + ], ); } @@ -121,11 +123,12 @@ export function hydrateSemTimetableWithLessons( if (!module) return EMPTY_OBJECT; // TODO: Split this part into a smaller function: hydrateModuleConfigWithLessons. - return mapValues(moduleLessonConfig, (classNo: ClassNo, lessonType: LessonType) => { + return mapValues(moduleLessonConfig, (classNos: ClassNo[], lessonType: LessonType) => { const lessons = getModuleTimetable(module, semester); const newLessons = lessons.filter( (lesson: RawLesson): boolean => - lesson.lessonType === lessonType && lesson.classNo === classNo, + lesson.lessonType === lessonType && + classNos.some((classNo) => classNo === lesson.classNo), ); const timetableLessons: Lesson[] = newLessons.map( @@ -337,11 +340,14 @@ export function validateModuleLessons( // - classNo is not valid anymore (ie. the class was removed) // // If a lesson type is removed, then it simply won't be copied over - if (!lessons.some((lesson) => lesson.classNo === classNo)) { - validatedLessonConfig[lessonType] = lessons[0].classNo; + const filteredClasses = + classNo && + classNo.filter((classNum) => lessons.some((lesson) => lesson.classNo === classNum)); + if (!classNo || filteredClasses.length === 0) { + validatedLessonConfig[lessonType] = [lessons[0].classNo]; updatedLessonTypes.push(lessonType); } else { - validatedLessonConfig[lessonType] = classNo; + validatedLessonConfig[lessonType] = filteredClasses; } }); @@ -359,9 +365,11 @@ export function getSemesterModules( } function serializeModuleConfig(config: ModuleLessonConfig): string { - // eg. { Lecture: 1, Laboratory: 2 } => LEC=1,LAB=2 + // eg. { Lecture: 1, Laboratory: 2, Laboratory: 3 } => LEC=1,LAB=2;3 return map(config, (classNo, lessonType) => - [LESSON_TYPE_ABBREV[lessonType], encodeURIComponent(classNo)].join(LESSON_TYPE_SEP), + [LESSON_TYPE_ABBREV[lessonType], encodeURIComponent(classNo.join(SAME_LESSON_SEP))].join( + LESSON_TYPE_SEP, + ), ).join(LESSON_SEP); } @@ -375,7 +383,7 @@ function parseModuleConfig(serialized: string | string[] | null): ModuleLessonCo const lessonType = LESSON_ABBREV_TYPE[lessonTypeAbbr]; // Ignore unparsable/invalid keys if (!lessonType) return; - config[lessonType] = classNo; + config[lessonType] = classNo.split(SAME_LESSON_SEP); }); }); diff --git a/website/src/views/components/module-info/AddModuleDropdown.test.tsx b/website/src/views/components/module-info/AddModuleDropdown.test.tsx index 9246bbdeda..c10bbf3544 100644 --- a/website/src/views/components/module-info/AddModuleDropdown.test.tsx +++ b/website/src/views/components/module-info/AddModuleDropdown.test.tsx @@ -76,7 +76,7 @@ describe(AddModuleDropdownComponent, () => { test('should show remove button when the module is in timetable', () => { // eslint-disable-next-line no-useless-computed-key - const container = make(CS3216, { [1]: { CS3216: { Lecture: '1' } } }); + const container = make(CS3216, { [1]: { CS3216: { Lecture: ['1'] } } }); const button = container.wrapper.find('button'); expect(button.text()).toMatch('Remove'); diff --git a/website/src/views/components/module-info/LessonTimetable.tsx b/website/src/views/components/module-info/LessonTimetable.tsx index 1d9c66a45f..83c2d9a09f 100644 --- a/website/src/views/components/module-info/LessonTimetable.tsx +++ b/website/src/views/components/module-info/LessonTimetable.tsx @@ -32,6 +32,7 @@ const SemesterLessonTimetable: FC<{ semesterData?: SemesterData }> = ({ semester history.push(venuePage(lesson.venue))} + customisedModules={[]} /> ); }; diff --git a/website/src/views/settings/BetaToggle.tsx b/website/src/views/settings/BetaToggle.tsx index 968a190bcc..9d51a536b3 100644 --- a/website/src/views/settings/BetaToggle.tsx +++ b/website/src/views/settings/BetaToggle.tsx @@ -4,7 +4,10 @@ import ExternalLink from 'views/components/ExternalLink'; import config from 'config'; import styles from './SettingsContainer.scss'; -export const currentTests = ['Course planner: plan courses in future semesters']; +export const currentTests = [ + 'Course planner: plan courses in future semesters', + 'TA view: customise the visibility of courses in your timetable', +]; type Props = { betaTester: boolean; diff --git a/website/src/views/settings/SettingsContainer.tsx b/website/src/views/settings/SettingsContainer.tsx index 5912759748..c575289950 100644 --- a/website/src/views/settings/SettingsContainer.tsx +++ b/website/src/views/settings/SettingsContainer.tsx @@ -129,7 +129,7 @@ const SettingsContainer: React.FC = ({

- +
diff --git a/website/src/views/tetris/TetrisGame.tsx b/website/src/views/tetris/TetrisGame.tsx index 77a6e72310..8374afabe7 100644 --- a/website/src/views/tetris/TetrisGame.tsx +++ b/website/src/views/tetris/TetrisGame.tsx @@ -101,6 +101,7 @@ function renderPiece(tiles: Board) { hoverLesson={null} onModifyCell={noop} onCellHover={noop} + customisedModules={[]} /> ); } @@ -407,7 +408,7 @@ export default class TetrisGame extends PureComponent {
{this.renderOverlay()} - +
diff --git a/website/src/views/timetable/ShareTimetable.test.tsx b/website/src/views/timetable/ShareTimetable.test.tsx index 2653ad50ee..670b9e6b53 100644 --- a/website/src/views/timetable/ShareTimetable.test.tsx +++ b/website/src/views/timetable/ShareTimetable.test.tsx @@ -29,7 +29,7 @@ describe('ShareTimetable', () => { const timetable = { CS1010S: { - Lecture: '1', + Lecture: ['1'], }, }; diff --git a/website/src/views/timetable/Timetable.tsx b/website/src/views/timetable/Timetable.tsx index cb88edcbce..6e50891675 100644 --- a/website/src/views/timetable/Timetable.tsx +++ b/website/src/views/timetable/Timetable.tsx @@ -16,6 +16,7 @@ import elements from 'views/elements'; import withTimer, { TimerData } from 'views/hocs/withTimer'; import { TimePeriod } from 'types/venues'; +import { ModuleCode } from 'types/modules'; import styles from './Timetable.scss'; import TimetableTimings from './TimetableTimings'; import TimetableDay from './TimetableDay'; @@ -29,6 +30,7 @@ type Props = TimerData & { showTitle?: boolean; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; + customisedModules: ModuleCode[]; }; type State = { @@ -108,6 +110,7 @@ class Timetable extends React.PureComponent { highlightPeriod={ highlightPeriod && index === highlightPeriod.day ? highlightPeriod : undefined } + customisedModules={this.props.customisedModules} /> ))} diff --git a/website/src/views/timetable/TimetableCell.test.tsx b/website/src/views/timetable/TimetableCell.test.tsx index b958b7044b..5b3e34ecc8 100644 --- a/website/src/views/timetable/TimetableCell.test.tsx +++ b/website/src/views/timetable/TimetableCell.test.tsx @@ -38,7 +38,9 @@ function make(additionalProps: Partial = {}) { return { onClick, onHover: props.onHover, - wrapper: shallow(), + wrapper: shallow( + , + ), }; } diff --git a/website/src/views/timetable/TimetableCell.tsx b/website/src/views/timetable/TimetableCell.tsx index 56a9903c05..e32b874797 100644 --- a/website/src/views/timetable/TimetableCell.tsx +++ b/website/src/views/timetable/TimetableCell.tsx @@ -4,7 +4,7 @@ import { isEqual } from 'lodash'; import { addWeeks, format, parseISO } from 'date-fns'; import NUSModerator, { AcadWeekInfo } from 'nusmoderator'; -import { consumeWeeks, WeekRange } from 'types/modules'; +import { consumeWeeks, ModuleCode, WeekRange } from 'types/modules'; import { HoverLesson, ModifiableLesson } from 'types/timetables'; import { OnHoverCell } from 'types/views'; @@ -26,6 +26,7 @@ type Props = { onClick?: (position: ClientRect) => void; hoverLesson?: HoverLesson | null; transparent: boolean; + customisedModules: ModuleCode[]; }; const lessonDateFormat = 'MMM dd'; @@ -86,7 +87,8 @@ function formatWeekRange(weekRange: WeekRange) { * might explore other representations e.g. grouped lessons */ const TimetableCell: React.FC = (props) => { - const { lesson, showTitle, onClick, onHover, hoverLesson, transparent } = props; + const { lesson, showTitle, onClick, onHover, hoverLesson, transparent, customisedModules } = + props; const moduleName = showTitle ? `${lesson.moduleCode} ${lesson.title}` : lesson.moduleCode; const Cell = props.onClick ? 'button' : 'div'; @@ -131,7 +133,10 @@ const TimetableCell: React.FC = (props) => { {...conditionalProps} >
-
{moduleName}
+
+ {moduleName} + {customisedModules.includes(lesson.moduleCode) && '*'} +
{LESSON_TYPE_ABBREV[lesson.lessonType]} [{lesson.classNo}]
diff --git a/website/src/views/timetable/TimetableContainer.test.tsx b/website/src/views/timetable/TimetableContainer.test.tsx index 51dbb583e1..0cf7a521c6 100644 --- a/website/src/views/timetable/TimetableContainer.test.tsx +++ b/website/src/views/timetable/TimetableContainer.test.tsx @@ -117,7 +117,7 @@ describe(TimetableContainerComponent, () => { test('should eventually display imported timetable if there is one', async () => { const semester = 1; - const importedTimetable = { [moduleCodeThatCanBeLoaded]: { Lecture: '1' } }; + const importedTimetable = { [moduleCodeThatCanBeLoaded]: { Lecture: ['1'] } }; const location = timetableShare(semester, importedTimetable); make(location); @@ -136,7 +136,7 @@ describe(TimetableContainerComponent, () => { test('should ignore invalid modules in imported timetable', () => { const semester = 1; - const importedTimetable = { TRUMP2020: { Lecture: '1' } }; + const importedTimetable = { TRUMP2020: { Lecture: ['1'] } }; const location = timetableShare(semester, importedTimetable); make(location); @@ -159,7 +159,7 @@ describe(TimetableContainerComponent, () => { store.dispatch({ type: SUCCESS_KEY(FETCH_MODULE), payload: CS3216 }); // Populate mock timetable - const timetable = { CS1010S: { Lecture: '1' }, CS3216: { Lecture: '1' } }; + const timetable = { CS1010S: { Lecture: ['1'] }, CS3216: { Lecture: ['1'] } }; (store.dispatch as Dispatch)(setTimetable(semester, timetable)); // Expect nothing to be fetched as timetable exists in `moduleBank`. diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index 14c371d5f4..463f506022 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import _ from 'lodash'; import { ColorMapping, HORIZONTAL, ModulesMap, TimetableOrientation } from 'types/reducers'; -import { Module, ModuleCode, Semester } from 'types/modules'; +import { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; import { ColoredLesson, Lesson, @@ -20,6 +20,8 @@ import { changeLesson, modifyLesson, removeModule, + addLesson, + removeLesson, } from 'actions/timetables'; import { undo } from 'actions/undoHistory'; import { @@ -72,6 +74,8 @@ type Props = OwnProps & { timetableWithLessons: SemTimetableConfigWithLessons; modules: ModulesMap; activeLesson: Lesson | null; + customiseModule: ModuleCode; + customisedModules: ModuleCode[]; timetableOrientation: TimetableOrientation; showTitle: boolean; hiddenInTimetable: ModuleCode[]; @@ -80,9 +84,21 @@ type Props = OwnProps & { addModule: (semester: Semester, moduleCode: ModuleCode) => void; removeModule: (semester: Semester, moduleCode: ModuleCode) => void; modifyLesson: (lesson: Lesson) => void; - changeLesson: (semester: Semester, lesson: Lesson) => void; + changeLesson: (semester: Semester, lesson: Lesson, activeLesson: ClassNo) => void; cancelModifyLesson: () => void; undo: () => void; + addLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, + ) => void; + removeLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, + ) => void; }; type State = { @@ -159,8 +175,28 @@ class TimetableContent extends React.Component { this.props.hiddenInTimetable.includes(moduleCode); modifyCell = (lesson: ModifiableLesson, position: ClientRect) => { - if (lesson.isAvailable) { - this.props.changeLesson(this.props.semester, lesson); + if (this.props.customiseModule === lesson.moduleCode) { + this.modifiedCell = { + position, + className: getLessonIdentifier(lesson), + }; + if (lesson.isAvailable) { + this.props.addLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lesson.classNo, + ); + } else if (lesson.isActive) { + this.props.removeLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lesson.classNo, + ); + } + } else if (lesson.isAvailable && this.props.activeLesson) { + this.props.changeLesson(this.props.semester, lesson, this.props.activeLesson.classNo); resetScrollPosition(); } else if (lesson.isActive) { @@ -222,6 +258,7 @@ class TimetableContent extends React.Component { readOnly={this.props.readOnly} tombstone={tombstone} resetTombstone={this.resetTombstone} + customisedModules={this.props.customisedModules} /> ); @@ -286,7 +323,34 @@ class TimetableContent extends React.Component { // Do not process hidden modules .filter((lesson) => !this.isHiddenInTimetable(lesson.moduleCode)); - if (activeLesson) { + if (this.props.customiseModule) { + const activeLessons = timetableLessons.filter( + (lesson) => lesson.moduleCode === this.props.customiseModule, + ); + timetableLessons = timetableLessons.filter( + (lesson) => lesson.moduleCode !== this.props.customiseModule, + ); + + const module = modules[this.props.customiseModule]; + const moduleTimetable = getModuleTimetable(module, semester); + moduleTimetable.forEach((lesson) => { + const isActiveLesson = + activeLessons.filter( + (timetableLesson) => + timetableLesson.classNo === lesson.classNo && + timetableLesson.lessonType === lesson.lessonType, + ).length > 0; + const modifiableLesson: Lesson & { isActive: boolean; isAvailable: boolean } = { + ...lesson, + // Inject module code in + moduleCode: this.props.customiseModule, + title: module.title, + isAvailable: !isActiveLesson, + isActive: isActiveLesson, + }; + timetableLessons.push(modifiableLesson); + }); + } else if (activeLesson) { const { moduleCode } = activeLesson; // Remove activeLesson because it will appear again timetableLessons = timetableLessons.filter( @@ -331,8 +395,9 @@ class TimetableContent extends React.Component { return { ...lesson, - isModifiable: - !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), + isModifiable: this.props.customiseModule + ? true + : !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), }; }), ), @@ -388,6 +453,7 @@ class TimetableContent extends React.Component { isScrolledHorizontally={this.state.isScrolledHorizontally} showTitle={isShowingTitle} onModifyCell={this.modifyCell} + customisedModules={this.props.customisedModules} />
)} @@ -443,6 +509,7 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { const { semester, timetable } = ownProps; const { modules } = state.moduleBank; const timetableWithLessons = hydrateSemTimetableWithLessons(timetable, modules, semester); + // TODO(zwliew): fix the type signature of state.timetables.hidden[semester] const hiddenInTimetable = state.timetables.hidden[semester] || []; return { @@ -451,6 +518,8 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { timetableWithLessons, modules, activeLesson: state.app.activeLesson, + customiseModule: state.app.customiseModule, + customisedModules: state.timetables.customisedModules[semester] ?? [], timetableOrientation: state.theme.timetableOrientation, showTitle: state.theme.showTitle, hiddenInTimetable, @@ -464,4 +533,6 @@ export default connect(mapStateToProps, { changeLesson, cancelModifyLesson, undo, + addLesson, + removeLesson, })(TimetableContent); diff --git a/website/src/views/timetable/TimetableDay.tsx b/website/src/views/timetable/TimetableDay.tsx index 30dcac70c9..d7f43c1691 100644 --- a/website/src/views/timetable/TimetableDay.tsx +++ b/website/src/views/timetable/TimetableDay.tsx @@ -6,6 +6,7 @@ import { OnHoverCell, OnModifyCell } from 'types/views'; import { convertTimeToIndex } from 'utils/timify'; import { TimePeriod } from 'types/venues'; +import { ModuleCode } from 'types/modules'; import styles from './TimetableDay.scss'; import TimetableRow from './TimetableRow'; import CurrentTimeIndicator from './CurrentTimeIndicator'; @@ -25,6 +26,7 @@ type Props = { onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; + customisedModules: ModuleCode[]; }; // Height of timetable per hour in vertical mode @@ -87,6 +89,7 @@ const TimetableDay: React.FC = (props) => { onModifyCell={props.onModifyCell} hoverLesson={props.hoverLesson} onCellHover={props.onCellHover} + customisedModules={props.customisedModules} /> ))} diff --git a/website/src/views/timetable/TimetableModuleTable.test.tsx b/website/src/views/timetable/TimetableModuleTable.test.tsx index b1e2557f4b..8e5da3ec09 100644 --- a/website/src/views/timetable/TimetableModuleTable.test.tsx +++ b/website/src/views/timetable/TimetableModuleTable.test.tsx @@ -3,6 +3,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { CS1010S, CS3216, CS4243 } from '__mocks__/modules'; import { addColors } from 'test-utils/theme'; +import * as redux from 'react-redux'; import { TimetableModulesTableComponent, Props } from './TimetableModulesTable'; import styles from './TimetableModulesTable.scss'; @@ -12,6 +13,12 @@ function make(props: Partial = {}) { const hideLessonInTimetable = jest.fn(); const showLessonInTimetable = jest.fn(); const resetTombstone = jest.fn(); + const customiseLesson = jest.fn(); + const addCustomModule = jest.fn(); + const removeCustomModule = jest.fn(); + + const beta = jest.spyOn(redux, 'useSelector'); + beta.mockReturnValue(false); const wrapper = shallow( = {}) { showLessonInTimetable={showLessonInTimetable} onRemoveModule={onRemoveModule} resetTombstone={resetTombstone} + customiseLesson={customiseLesson} + customiseModule="" + customisedModules={[]} + addCustomModule={addCustomModule} + removeCustomModule={removeCustomModule} {...props} />, ); diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index 651fa36f5b..1dd122de06 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; import { sortBy } from 'lodash'; @@ -8,16 +8,19 @@ import produce from 'immer'; import { ModuleWithColor, TombstoneModule } from 'types/views'; import { ColorIndex } from 'types/timetables'; import { ModuleCode, Semester } from 'types/modules'; -import { State as StoreState } from 'types/state'; +import { State, State as StoreState } from 'types/state'; import { ModuleTableOrder } from 'types/reducers'; - -import ColorPicker from 'views/components/ColorPicker'; -import { Eye, EyeOff, Trash } from 'react-feather'; import { + customiseLesson, + addCustomModule, + removeCustomModule, hideLessonInTimetable, selectModuleColor, showLessonInTimetable, } from 'actions/timetables'; +import ColorPicker from 'views/components/ColorPicker'; +import { Eye, EyeOff, Trash, Tool, Check } from 'react-feather'; + import { getExamDate, getFormattedExamDate, renderMCs } from 'utils/modules'; import { intersperse } from 'utils/array'; import { BULLET_NBSP } from 'utils/react'; @@ -37,61 +40,103 @@ export type Props = { moduleTableOrder: ModuleTableOrder; modules: ModuleWithColor[]; tombstone: TombstoneModule | null; // Placeholder for a deleted module + customiseModule: ModuleCode; + customisedModules: ModuleCode[]; // Actions selectModuleColor: (semester: Semester, moduleCode: ModuleCode, colorIndex: ColorIndex) => void; hideLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; showLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; onRemoveModule: (moduleCode: ModuleCode) => void; + addCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; + removeCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; resetTombstone: () => void; + customiseLesson: (semester: Semester, moduleCode: ModuleCode) => void; }; export const TimetableModulesTableComponent: React.FC = (props) => { + const beta = useSelector(({ settings }: State) => settings.beta); const renderModuleActions = (module: ModuleWithColor) => { const hideBtnLabel = `${module.hiddenInTimetable ? 'Show' : 'Hide'} ${module.moduleCode}`; const removeBtnLabel = `Remove ${module.moduleCode} from timetable`; + const customBtnLabel = `Customise ${module.moduleCode} in timetable`; + const doneBtnLabel = `Done customising ${module.moduleCode} in timetable`; const { semester } = props; return (
-
- + {props.customiseModule === module.moduleCode ? ( + - - - -
+ ) : ( +
+ + + + + + + {beta && ( + + + + )} +
+ )}
); }; const renderModule = (module: ModuleWithColor) => { - const { semester, readOnly, tombstone, resetTombstone } = props; + const { semester, readOnly, tombstone, resetTombstone, customisedModules } = props; if (tombstone && tombstone.moduleCode === module.moduleCode) { return ; @@ -123,6 +168,7 @@ export const TimetableModulesTableComponent: React.FC = (props) => { {!readOnly && renderModuleActions(module)} {module.moduleCode} {module.title} + {customisedModules.includes(module.moduleCode) && '*'}
{intersperse(secondRowText, BULLET_NBSP)}
@@ -162,10 +208,16 @@ export const TimetableModulesTableComponent: React.FC = (props) => { }; export default connect( - (state: StoreState) => ({ moduleTableOrder: state.settings.moduleTableOrder }), + (state: StoreState) => ({ + moduleTableOrder: state.settings.moduleTableOrder, + customiseModule: state.app.customiseModule, + }), { selectModuleColor, hideLessonInTimetable, showLessonInTimetable, + customiseLesson, + addCustomModule, + removeCustomModule, }, )(React.memo(TimetableModulesTableComponent)); diff --git a/website/src/views/timetable/TimetableRow.tsx b/website/src/views/timetable/TimetableRow.tsx index 65b22f8f0e..1531950a08 100644 --- a/website/src/views/timetable/TimetableRow.tsx +++ b/website/src/views/timetable/TimetableRow.tsx @@ -4,6 +4,7 @@ import { HoverLesson, ModifiableLesson } from 'types/timetables'; import { OnHoverCell, OnModifyCell } from 'types/views'; import { convertTimeToIndex } from 'utils/timify'; +import { ModuleCode } from 'types/modules'; import styles from './TimetableRow.scss'; import TimetableCell from './TimetableCell'; @@ -16,6 +17,7 @@ type Props = { hoverLesson?: HoverLesson | null; onCellHover: OnHoverCell; onModifyCell?: OnModifyCell; + customisedModules: ModuleCode[]; }; /** @@ -70,6 +72,7 @@ const TimetableRow: React.FC = (props) => { hoverLesson={props.hoverLesson} onHover={props.onCellHover} transparent={lesson.startTime === lesson.endTime} + customisedModules={props.customisedModules} {...conditionalProps} /> ); diff --git a/website/src/views/venues/VenueDetails.tsx b/website/src/views/venues/VenueDetails.tsx index 3d7a93d01c..3bcfa09986 100644 --- a/website/src/views/venues/VenueDetails.tsx +++ b/website/src/views/venues/VenueDetails.tsx @@ -92,6 +92,7 @@ const VenueDetailsComponent: FC = ({ highlightPeriod={highlightPeriod} isVerticalOrientation={narrowViewport} onModifyCell={navigateToLesson} + customisedModules={[]} />