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={[]}
/>
>