From 4ce23c1f7f2450897fe89250208a592b6116707f Mon Sep 17 00:00:00 2001 From: Zhou Jiahao <54730603+zhoukerrr@users.noreply.github.com> Date: Sun, 2 Apr 2023 15:07:17 +0800 Subject: [PATCH] Add support for Timetable for TAs (#3434) * feat: fix lint * feat: add customise module button * feat: add redux actions * feat: connect button to redux action * feat: enter and edit module * feat: exit customising state * cahnge customisemod default state to empty string * feat: add timetable state for custimised mods * feat: remove validation for custom mods * feat: add TA label to cells * feat: hide and disable other edit buttons when editing * feat: add support for changing lessons after customisation * chore: fix test cases * chore: fox lint * chore: fix schema migration test --- .../__snapshots__/timetables.test.ts.snap | 1 + website/src/actions/timetables.test.ts | 2 +- website/src/actions/timetables.ts | 83 ++++++++++++- website/src/reducers/app.test.ts | 3 +- website/src/reducers/app.ts | 13 +- website/src/reducers/index.test.ts | 1 + website/src/reducers/timetables.test.ts | 3 + website/src/reducers/timetables.ts | 58 ++++++++- website/src/selectors/timetables.ts | 5 + website/src/types/reducers.ts | 3 + website/src/views/timetable/Timetable.tsx | 3 + website/src/views/timetable/TimetableCell.tsx | 10 +- .../src/views/timetable/TimetableContent.tsx | 83 +++++++++++-- website/src/views/timetable/TimetableDay.tsx | 3 + .../timetable/TimetableModuleTable.test.tsx | 11 ++ .../views/timetable/TimetableModulesTable.tsx | 114 +++++++++++++----- website/src/views/timetable/TimetableRow.tsx | 3 + 17 files changed, 352 insertions(+), 47 deletions(-) diff --git a/website/src/actions/__snapshots__/timetables.test.ts.snap b/website/src/actions/__snapshots__/timetables.test.ts.snap index edba5933537..8f502de21f0 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 ac0742224b6..8d253ab2829 100644 --- a/website/src/actions/timetables.test.ts +++ b/website/src/actions/timetables.test.ts @@ -36,7 +36,7 @@ test('modifyLesson should return lesson payload', () => { test('changeLesson should return updated information to change lesson', () => { const semester: Semester = 1; const lesson: Lesson = lessons[1]; - expect(actions.changeLesson(semester, lesson)).toMatchSnapshot(); + expect(actions.changeLesson(semester, lesson, lesson.classNo)).toMatchSnapshot(); }); test('cancelModifyLesson should not have payload', () => { diff --git a/website/src/actions/timetables.ts b/website/src/actions/timetables.ts index 3378567c523..c1a73c833cb 100644 --- a/website/src/actions/timetables.ts +++ b/website/src/actions/timetables.ts @@ -90,12 +90,24 @@ export function modifyLesson(activeLesson: Lesson) { }; } +export const CUSTOMISE_MODULE = 'CUSTOMISE_LESSON' as const; +export function customiseLesson(semester: Semester, moduleCode: ModuleCode) { + return { + type: CUSTOMISE_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + export const CHANGE_LESSON = 'CHANGE_LESSON' as const; export function setLesson( semester: Semester, moduleCode: ModuleCode, lessonType: LessonType, classNo: ClassNo, + activeLesson: ClassNo, ) { return { type: CHANGE_LESSON, @@ -104,12 +116,71 @@ export function setLesson( moduleCode, lessonType, classNo, + activeLesson, }, }; } -export function changeLesson(semester: Semester, lesson: Lesson) { - return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo); +export const ADD_CUSTOM_MODULE = 'ADD_CUSTOM_MODULE' as const; +export function addCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: ADD_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + +export const REMOVE_CUSTOM_MODULE = 'REMOVE_CUSTOM_MODULE' as const; +export function removeCustomModule(semester: Semester, moduleCode: ModuleCode) { + return { + type: REMOVE_CUSTOM_MODULE, + payload: { + semester, + moduleCode, + }, + }; +} + +export const ADD_LESSON = 'ADD_LESSON' as const; +export function addLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, +) { + return { + type: ADD_LESSON, + payload: { + semester, + moduleCode, + lessonType, + classNo, + }, + }; +} + +export const REMOVE_LESSON = 'REMOVE_LESSON' as const; +export function removeLesson( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, +) { + return { + type: REMOVE_LESSON, + payload: { + semester, + moduleCode, + lessonType, + classNo, + }, + }; +} + +export function changeLesson(semester: Semester, lesson: Lesson, activeLesson: ClassNo) { + return setLesson(semester, lesson.moduleCode, lesson.lessonType, lesson.classNo, activeLesson); } export const SET_LESSON_CONFIG = 'SET_LESSON_CONFIG' as const; @@ -165,6 +236,14 @@ export function validateTimetable(semester: Semester) { const module = moduleBank.modules[moduleCode]; if (!module) return; + // If the module is customised, we do not validate it + if ( + timetables.customisedModules && + timetables.customisedModules[semester] && + timetables.customisedModules[semester].includes(moduleCode) + ) { + return; + } const [validatedLessonConfig, changedLessonTypes] = validateModuleLessons( semester, lessonConfig, diff --git a/website/src/reducers/app.test.ts b/website/src/reducers/app.test.ts index 414271123c3..806897cbc14 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 1748f527f59..524d570f598 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 3ab3049f226..fbc869106c8 100644 --- a/website/src/reducers/index.test.ts +++ b/website/src/reducers/index.test.ts @@ -72,6 +72,7 @@ test('reducers should set export data state', () => { PC1222: 2, }, }, + customisedModules: {}, hidden: { [1]: ['PC1222'] }, academicYear: expect.any(String), archive: {}, diff --git a/website/src/reducers/timetables.test.ts b/website/src/reducers/timetables.test.ts index 7c6d5f68c98..1589f59f91a 100644 --- a/website/src/reducers/timetables.test.ts +++ b/website/src/reducers/timetables.test.ts @@ -207,6 +207,7 @@ describe('stateReconciler', () => { }, academicYear: config.academicYear, archive: oldArchive, + customisedModules: {}, }; const { stateReconciler } = persistConfig; @@ -259,6 +260,7 @@ describe('redux schema migration', () => { hidden: {}, academicYear: '2022/2023', archive: {}, + customisedModules: {}, _persist: { version: 1, rehydrated: false, @@ -283,6 +285,7 @@ describe('redux schema migration', () => { hidden: {}, academicYear: '2022/2023', archive: {}, + customisedModules: {}, _persist: { version: 1, // version kept the same because the framework does not support it in unit tests rehydrated: false, diff --git a/website/src/reducers/timetables.ts b/website/src/reducers/timetables.ts index 6939e1200d2..d56be22b665 100644 --- a/website/src/reducers/timetables.ts +++ b/website/src/reducers/timetables.ts @@ -11,12 +11,16 @@ import config from 'config'; import { ADD_MODULE, CHANGE_LESSON, + ADD_LESSON, + REMOVE_LESSON, HIDE_LESSON_IN_TIMETABLE, REMOVE_MODULE, SELECT_MODULE_COLOR, SET_LESSON_CONFIG, SET_TIMETABLE, SHOW_LESSON_IN_TIMETABLE, + ADD_CUSTOM_MODULE, + REMOVE_CUSTOM_MODULE, } from 'actions/timetables'; import { getNewColor } from 'utils/colors'; import { SET_EXPORTED_DATA } from 'actions/constants'; @@ -42,7 +46,6 @@ export function migrateV1toV2( const lessonArray = [lessonValue]; newSemester[moduleCode][lessonType] = lessonArray; }); - if (!newLessons[semester]) { newLessons[semester] = {}; } @@ -119,12 +122,30 @@ function moduleLessonConfig( if (!(classNo && lessonType)) return state; return { ...state, - [lessonType]: [classNo], + [lessonType]: [ + ...state[lessonType].filter((lesson) => lesson !== action.payload.activeLesson), + action.payload.classNo, + ], }; } case SET_LESSON_CONFIG: return action.payload.lessonConfig; - + case ADD_LESSON: { + const { classNo, lessonType } = action.payload; + if (!(classNo && lessonType)) return state; + return { + ...state, + [lessonType]: [...state[lessonType], classNo], + }; + } + case REMOVE_LESSON: { + const { classNo, lessonType } = action.payload; + if (!(classNo && lessonType)) return state; + return { + ...state, + [lessonType]: state[lessonType].filter((lesson) => lesson !== classNo), + }; + } default: return state; } @@ -148,6 +169,8 @@ function semTimetable( case REMOVE_MODULE: return omit(state, [moduleCode]); case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: return { ...state, @@ -203,12 +226,31 @@ function semHiddenModules(state = defaultHiddenState, action: Actions) { } } +// Map of CustomisedModules +const defaultCustomisedModulesState: ModuleCode[] = []; +function customisedModules(state = defaultCustomisedModulesState, action: Actions) { + if (!action.payload) { + return state; + } + + switch (action.type) { + case ADD_CUSTOM_MODULE: + if (state.includes(action.payload.moduleCode)) return state; + return [...state, action.payload.moduleCode]; + case REMOVE_CUSTOM_MODULE: + return state.filter((c) => c !== action.payload.moduleCode); + default: + return state; + } +} + export const defaultTimetableState: TimetablesState = { lessons: {}, colors: {}, hidden: {}, academicYear: config.academicYear, archive: {}, + customisedModules: {}, }; function timetables( @@ -234,15 +276,23 @@ function timetables( case REMOVE_MODULE: case SELECT_MODULE_COLOR: case CHANGE_LESSON: + case ADD_LESSON: + case REMOVE_LESSON: case SET_LESSON_CONFIG: case HIDE_LESSON_IN_TIMETABLE: - case SHOW_LESSON_IN_TIMETABLE: { + case SHOW_LESSON_IN_TIMETABLE: + case ADD_CUSTOM_MODULE: + case REMOVE_CUSTOM_MODULE: { const { semester } = action.payload; return produce(state, (draft) => { draft.lessons[semester] = semTimetable(draft.lessons[semester], action); draft.colors[semester] = semColors(state.colors[semester], action); draft.hidden[semester] = semHiddenModules(state.hidden[semester], action); + draft.customisedModules[semester] = customisedModules( + state.customisedModules[semester], + action, + ); }); } diff --git a/website/src/selectors/timetables.ts b/website/src/selectors/timetables.ts index 75a5d719a9a..ef66e8da7ad 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 cd37df9e8fa..406c7bc6585 100644 --- a/website/src/types/reducers.ts +++ b/website/src/types/reducers.ts @@ -51,6 +51,7 @@ export type NotificationData = { readonly message: string } & NotificationOption export type AppState = { readonly activeSemester: Semester; readonly activeLesson: Lesson | null; + readonly customiseModule: ModuleCode; readonly isOnline: boolean; readonly isFeedbackModalOpen: boolean; readonly notifications: NotificationData[]; @@ -112,6 +113,7 @@ export type SettingsState = { export type ColorMapping = { [moduleCode: string]: ColorIndex }; export type SemesterColorMap = { [semester: string]: ColorMapping }; export type HiddenModulesMap = { [semester: string]: ModuleCode[] }; +export type CustomisedModulesMap = { [semester: string]: ModuleCode[] }; export type TimetablesState = { readonly lessons: TimetableConfig; @@ -120,6 +122,7 @@ export type TimetablesState = { readonly academicYear: string; // Mapping of academic year to old timetable config readonly archive: { [key: string]: TimetableConfig }; + readonly customisedModules: CustomisedModulesMap; }; /* venueBank.js */ diff --git a/website/src/views/timetable/Timetable.tsx b/website/src/views/timetable/Timetable.tsx index 33bd2d80728..67f9e4791b8 100644 --- a/website/src/views/timetable/Timetable.tsx +++ b/website/src/views/timetable/Timetable.tsx @@ -16,6 +16,7 @@ import elements from 'views/elements'; import withTimer, { TimerData } from 'views/hocs/withTimer'; import { TimePeriod } from 'types/venues'; +import { ModuleCode } from 'types/modules'; import styles from './Timetable.scss'; import TimetableTimings from './TimetableTimings'; import TimetableDay from './TimetableDay'; @@ -29,6 +30,7 @@ type Props = TimerData & { showTitle?: boolean; onModifyCell?: OnModifyCell; highlightPeriod?: TimePeriod; + customisedModules?: ModuleCode[]; }; type State = { @@ -108,6 +110,7 @@ class Timetable extends React.PureComponent { highlightPeriod={ highlightPeriod && index === highlightPeriod.day ? highlightPeriod : undefined } + customisedModules={this.props.customisedModules} /> ))} diff --git a/website/src/views/timetable/TimetableCell.tsx b/website/src/views/timetable/TimetableCell.tsx index 56a9903c05d..d4a0fa1bc53 100644 --- a/website/src/views/timetable/TimetableCell.tsx +++ b/website/src/views/timetable/TimetableCell.tsx @@ -4,7 +4,7 @@ import { isEqual } from 'lodash'; import { addWeeks, format, parseISO } from 'date-fns'; import NUSModerator, { AcadWeekInfo } from 'nusmoderator'; -import { consumeWeeks, WeekRange } from 'types/modules'; +import { consumeWeeks, ModuleCode, WeekRange } from 'types/modules'; import { HoverLesson, ModifiableLesson } from 'types/timetables'; import { OnHoverCell } from 'types/views'; @@ -26,6 +26,7 @@ type Props = { onClick?: (position: ClientRect) => void; hoverLesson?: HoverLesson | null; transparent: boolean; + customisedModules?: ModuleCode[]; }; const lessonDateFormat = 'MMM dd'; @@ -131,7 +132,12 @@ const TimetableCell: React.FC = (props) => { {...conditionalProps} >
-
{moduleName}
+
+ {moduleName} + {props.customisedModules && props.customisedModules.includes(lesson.moduleCode) + ? '*' + : null} +
{LESSON_TYPE_ABBREV[lesson.lessonType]} [{lesson.classNo}]
diff --git a/website/src/views/timetable/TimetableContent.tsx b/website/src/views/timetable/TimetableContent.tsx index dc4d970ed09..f6284db9635 100644 --- a/website/src/views/timetable/TimetableContent.tsx +++ b/website/src/views/timetable/TimetableContent.tsx @@ -4,7 +4,7 @@ import { connect } from 'react-redux'; import _ from 'lodash'; import { ColorMapping, HORIZONTAL, ModulesMap, TimetableOrientation } from 'types/reducers'; -import { Module, ModuleCode, Semester } from 'types/modules'; +import { ClassNo, LessonType, Module, ModuleCode, Semester } from 'types/modules'; import { ColoredLesson, Lesson, @@ -20,6 +20,8 @@ import { changeLesson, modifyLesson, removeModule, + addLesson, + removeLesson, } from 'actions/timetables'; import { undo } from 'actions/undoHistory'; import { @@ -72,6 +74,8 @@ type Props = OwnProps & { timetableWithLessons: SemTimetableConfigWithLessons; modules: ModulesMap; activeLesson: Lesson | null; + customiseModule: ModuleCode; + customisedModules: ModuleCode[]; timetableOrientation: TimetableOrientation; showTitle: boolean; hiddenInTimetable: ModuleCode[]; @@ -80,9 +84,21 @@ type Props = OwnProps & { addModule: (semester: Semester, moduleCode: ModuleCode) => void; removeModule: (semester: Semester, moduleCode: ModuleCode) => void; modifyLesson: (lesson: Lesson) => void; - changeLesson: (semester: Semester, lesson: Lesson) => void; + changeLesson: (semester: Semester, lesson: Lesson, activeLesson: ClassNo) => void; cancelModifyLesson: () => void; undo: () => void; + addLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, + ) => void; + removeLesson: ( + semester: Semester, + moduleCode: ModuleCode, + lessonType: LessonType, + classNo: ClassNo, + ) => void; }; type State = { @@ -159,8 +175,28 @@ class TimetableContent extends React.Component { this.props.hiddenInTimetable.includes(moduleCode); modifyCell = (lesson: ModifiableLesson, position: ClientRect) => { - if (lesson.isAvailable) { - this.props.changeLesson(this.props.semester, lesson); + if (this.props.customiseModule === lesson.moduleCode) { + this.modifiedCell = { + position, + className: getLessonIdentifier(lesson), + }; + if (lesson.isAvailable) { + this.props.addLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lesson.classNo, + ); + } else if (lesson.isActive) { + this.props.removeLesson( + this.props.semester, + lesson.moduleCode, + lesson.lessonType, + lesson.classNo, + ); + } + } else if (lesson.isAvailable && this.props.activeLesson) { + this.props.changeLesson(this.props.semester, lesson, this.props.activeLesson.classNo); resetScrollPosition(); } else if (lesson.isActive) { @@ -286,7 +322,34 @@ class TimetableContent extends React.Component { // Do not process hidden modules .filter((lesson) => !this.isHiddenInTimetable(lesson.moduleCode)); - if (activeLesson) { + if (this.props.customiseModule) { + const activeLessons = timetableLessons.filter( + (lesson) => lesson.moduleCode === this.props.customiseModule, + ); + timetableLessons = timetableLessons.filter( + (lesson) => lesson.moduleCode !== this.props.customiseModule, + ); + + const module = modules[this.props.customiseModule]; + const moduleTimetable = getModuleTimetable(module, semester); + moduleTimetable.forEach((lesson) => { + const isActiveLesson = + activeLessons.filter( + (timetableLesson) => + timetableLesson.classNo === lesson.classNo && + timetableLesson.lessonType === lesson.lessonType, + ).length > 0; + const modifiableLesson: Lesson & { isActive?: boolean; isAvailable?: boolean } = { + ...lesson, + // Inject module code in + moduleCode: this.props.customiseModule, + title: module.title, + isAvailable: !isActiveLesson, + isActive: isActiveLesson, + }; + timetableLessons.push(modifiableLesson); + }); + } else if (activeLesson) { const { moduleCode } = activeLesson; // Remove activeLesson because it will appear again timetableLessons = timetableLessons.filter( @@ -331,8 +394,9 @@ class TimetableContent extends React.Component { return { ...lesson, - isModifiable: - !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), + isModifiable: this.props.customiseModule + ? true + : !readOnly && areOtherClassesAvailable(moduleTimetable, lesson.lessonType), }; }), ), @@ -388,6 +452,7 @@ class TimetableContent extends React.Component { isScrolledHorizontally={this.state.isScrolledHorizontally} showTitle={isShowingTitle} onModifyCell={this.modifyCell} + customisedModules={this.props.customisedModules} />
)} @@ -451,6 +516,8 @@ function mapStateToProps(state: StoreState, ownProps: OwnProps) { timetableWithLessons, modules, activeLesson: state.app.activeLesson, + customiseModule: state.app.customiseModule, + customisedModules: state.timetables.customisedModules[semester], timetableOrientation: state.theme.timetableOrientation, showTitle: state.theme.showTitle, hiddenInTimetable, @@ -464,4 +531,6 @@ export default connect(mapStateToProps, { changeLesson, cancelModifyLesson, undo, + addLesson, + removeLesson, })(TimetableContent); diff --git a/website/src/views/timetable/TimetableDay.tsx b/website/src/views/timetable/TimetableDay.tsx index 30dcac70c9d..5c853b3ddda 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 b1e2557f4bb..0eab20c9934 100644 --- a/website/src/views/timetable/TimetableModuleTable.test.tsx +++ b/website/src/views/timetable/TimetableModuleTable.test.tsx @@ -3,6 +3,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { CS1010S, CS3216, CS4243 } from '__mocks__/modules'; import { addColors } from 'test-utils/theme'; +import * as redux from 'react-redux'; import { TimetableModulesTableComponent, Props } from './TimetableModulesTable'; import styles from './TimetableModulesTable.scss'; @@ -12,6 +13,12 @@ function make(props: Partial = {}) { const hideLessonInTimetable = jest.fn(); const showLessonInTimetable = jest.fn(); const resetTombstone = jest.fn(); + const customiseLesson = jest.fn(); + const addCustomModule = jest.fn(); + const removeCustomModule = jest.fn(); + + const beta = jest.spyOn(redux, 'useSelector'); + beta.mockReturnValue(false); const wrapper = shallow( = {}) { showLessonInTimetable={showLessonInTimetable} onRemoveModule={onRemoveModule} resetTombstone={resetTombstone} + customiseLesson={customiseLesson} + customiseModule="" + addCustomModule={addCustomModule} + removeCustomModule={removeCustomModule} {...props} />, ); diff --git a/website/src/views/timetable/TimetableModulesTable.tsx b/website/src/views/timetable/TimetableModulesTable.tsx index 651fa36f5bf..9acb8e0a439 100644 --- a/website/src/views/timetable/TimetableModulesTable.tsx +++ b/website/src/views/timetable/TimetableModulesTable.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; import { sortBy } from 'lodash'; @@ -8,16 +8,19 @@ import produce from 'immer'; import { ModuleWithColor, TombstoneModule } from 'types/views'; import { ColorIndex } from 'types/timetables'; import { ModuleCode, Semester } from 'types/modules'; -import { State as StoreState } from 'types/state'; +import { State, State as StoreState } from 'types/state'; import { ModuleTableOrder } from 'types/reducers'; - -import ColorPicker from 'views/components/ColorPicker'; -import { Eye, EyeOff, Trash } from 'react-feather'; import { + customiseLesson, + addCustomModule, + removeCustomModule, hideLessonInTimetable, selectModuleColor, showLessonInTimetable, } from 'actions/timetables'; +import ColorPicker from 'views/components/ColorPicker'; +import { Eye, EyeOff, Trash, Tool, Check } from 'react-feather'; + import { getExamDate, getFormattedExamDate, renderMCs } from 'utils/modules'; import { intersperse } from 'utils/array'; import { BULLET_NBSP } from 'utils/react'; @@ -37,55 +40,102 @@ export type Props = { moduleTableOrder: ModuleTableOrder; modules: ModuleWithColor[]; tombstone: TombstoneModule | null; // Placeholder for a deleted module + customiseModule: ModuleCode; // Actions selectModuleColor: (semester: Semester, moduleCode: ModuleCode, colorIndex: ColorIndex) => void; hideLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; showLessonInTimetable: (semester: Semester, moduleCode: ModuleCode) => void; onRemoveModule: (moduleCode: ModuleCode) => void; + addCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; + removeCustomModule: (semester: Semester, moduleCode: ModuleCode) => void; resetTombstone: () => void; + customiseLesson: (semester: Semester, moduleCode: ModuleCode) => void; }; export const TimetableModulesTableComponent: React.FC = (props) => { + const beta = useSelector(({ settings }: State) => settings.beta); const renderModuleActions = (module: ModuleWithColor) => { const hideBtnLabel = `${module.hiddenInTimetable ? 'Show' : 'Hide'} ${module.moduleCode}`; const removeBtnLabel = `Remove ${module.moduleCode} from timetable`; + const customBtnLabel = `Customise ${module.moduleCode} in timetable`; + const doneBtnLabel = `Done customising ${module.moduleCode} in timetable`; const { semester } = props; return (
-
- + {props.customiseModule === module.moduleCode ? ( + - - - -
+ ) : ( +
+ + + + + + + {beta && ( + + + + )} +
+ )}
); }; @@ -162,10 +212,16 @@ export const TimetableModulesTableComponent: React.FC = (props) => { }; export default connect( - (state: StoreState) => ({ moduleTableOrder: state.settings.moduleTableOrder }), + (state: StoreState) => ({ + moduleTableOrder: state.settings.moduleTableOrder, + customiseModule: state.app.customiseModule, + }), { selectModuleColor, hideLessonInTimetable, showLessonInTimetable, + customiseLesson, + addCustomModule, + removeCustomModule, }, )(React.memo(TimetableModulesTableComponent)); diff --git a/website/src/views/timetable/TimetableRow.tsx b/website/src/views/timetable/TimetableRow.tsx index 65b22f8f0e4..bed1f57505c 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} /> );