diff --git a/website/package.json b/website/package.json index afa18200ea..886e8debbd 100644 --- a/website/package.json +++ b/website/package.json @@ -171,7 +171,8 @@ "reselect": "4.1.8", "samlify": "2.7.7", "searchkit": "2.4.1-alpha.5", - "use-subscription": "1.10.0" + "use-subscription": "1.10.0", + "zod": "^3.25.67" }, "browserslist": [ "extends browserslist-config-nusmods" diff --git a/website/src/actions/export.ts b/website/src/actions/export.ts index d3782b28a1..7667b25b32 100644 --- a/website/src/actions/export.ts +++ b/website/src/actions/export.ts @@ -9,7 +9,9 @@ import { captureException } from 'utils/error'; import retryImport from 'utils/retryImport'; import { getSemesterTimetableLessons } from 'selectors/timetables'; import { TaModulesConfig } from 'types/timetables'; +import { PlannerStateSchema } from 'types/schemas/planner'; import { SET_EXPORTED_DATA } from './constants'; +import { openNotification } from './app'; function downloadUrl(blob: Blob, filename: string) { const link = document.createElement('a'); @@ -79,3 +81,21 @@ export function setExportedData(modules: Module[], data: ExportData) { }, }; } + +export function downloadPlanner() { + return (_dispatch: Dispatch, getState: GetState) => { + const { planner } = getState(); + + // removes _persist + const parsed = PlannerStateSchema.safeParse(planner); + if (!parsed.success) { + _dispatch(openNotification('Planner data is corrupted.')); + return; + } + + const exportState = parsed.data; + const bytes = new TextEncoder().encode(JSON.stringify(exportState)); + const blob = new Blob([bytes], { type: 'application/json' }); + downloadUrl(blob, 'nusmods_planner.json'); + }; +} diff --git a/website/src/actions/planner.ts b/website/src/actions/planner.ts index 7e90b3fa61..d87b781c39 100644 --- a/website/src/actions/planner.ts +++ b/website/src/actions/planner.ts @@ -1,6 +1,6 @@ import { ModuleCode, Semester } from 'types/modules'; import { AddModuleData } from 'types/planner'; -import { CustomModule } from 'types/reducers'; +import { CustomModule, PlannerState } from 'types/reducers'; import { PLAN_TO_TAKE_SEMESTER, PLAN_TO_TAKE_YEAR } from 'utils/planner'; export const SET_PLANNER_MIN_YEAR = 'SET_PLANNER_MIN_YEAR' as const; @@ -95,3 +95,19 @@ export function addCustomModule(moduleCode: ModuleCode, data: CustomModule) { payload: { moduleCode, data }, }; } + +export const IMPORT_PLANNER = 'IMPORT_PLANNER' as const; +export function importPlanner(importedState: PlannerState) { + return { + type: IMPORT_PLANNER, + payload: { importedState }, + }; +} + +export const CLEAR_PLANNER = 'CLEAR_PLANNER' as const; +export function clearPlanner() { + return { + type: CLEAR_PLANNER, + payload: {}, + }; +} diff --git a/website/src/reducers/planner.test.ts b/website/src/reducers/planner.test.ts index 76fa754bc1..3aacbccc54 100644 --- a/website/src/reducers/planner.test.ts +++ b/website/src/reducers/planner.test.ts @@ -13,17 +13,18 @@ import { setPlannerMaxYear, setPlannerIBLOCs, setIgnorePrerequisitesCheck, + IMPORT_PLANNER, + importPlanner, + CLEAR_PLANNER, + clearPlanner, } from 'actions/planner'; import { PlannerState } from 'types/reducers'; -import reducer, { migrateV0toV1, nextId } from './planner'; +import reducer, { defaultPlannerState, migrateV0toV1, nextId } from './planner'; const defaultState: PlannerState = { + ...defaultPlannerState, minYear: '2017/2018', maxYear: '2018/2019', - iblocs: false, - ignorePrereqCheck: false, - modules: {}, - custom: {}, }; describe(nextId, () => { @@ -203,6 +204,42 @@ describe(REMOVE_PLANNER_MODULE, () => { }); }); +describe(IMPORT_PLANNER, () => { + const initial: PlannerState = { + ...defaultState, + }; + + const importedState: PlannerState = { + minYear: '2024/2025', + maxYear: '2025/2026', + iblocs: true, + ignorePrereqCheck: true, + modules: { + 0: { id: '0', moduleCode: 'CS1010S', year: '2018/2019', semester: 1, index: 0 }, + }, + custom: { + CS1010A: { title: 'CS1010B', moduleCredit: 4 }, + }, + }; + test('should import the given planner', () => { + expect(reducer(initial, importPlanner(importedState))).toEqual(importedState); + }); +}); + +describe(CLEAR_PLANNER, () => { + const initial: PlannerState = { + ...defaultState, + + modules: { + 0: { id: '0', moduleCode: 'CS1010S', year: '2018/2019', semester: 1, index: 0 }, + }, + }; + + test('should clear the planner', () => { + expect(reducer(initial, clearPlanner())).toEqual(defaultPlannerState); + }); +}); + describe(migrateV0toV1, () => { test('should migrate old modules state to new modules state', () => { expect( diff --git a/website/src/reducers/planner.ts b/website/src/reducers/planner.ts index 5595516e7d..86e1ce5048 100644 --- a/website/src/reducers/planner.ts +++ b/website/src/reducers/planner.ts @@ -16,11 +16,13 @@ import { SET_PLANNER_MAX_YEAR, SET_PLANNER_MIN_YEAR, SET_IGNORE_PREREQUISITES_CHECK, + IMPORT_PLANNER, + CLEAR_PLANNER, } from 'actions/planner'; import { filterModuleForSemester } from 'selectors/planner'; import config from 'config'; -const defaultPlannerState: PlannerState = { +export const defaultPlannerState: PlannerState = { minYear: config.academicYear, maxYear: config.academicYear, iblocs: false, @@ -149,6 +151,16 @@ export default function planner( draft.modules[action.payload.id].moduleCode = action.payload.moduleCode; }); + case IMPORT_PLANNER: + return { + ...action.payload.importedState, + }; + + case CLEAR_PLANNER: + return { + ...defaultPlannerState, + }; + default: return state; } diff --git a/website/src/types/schemas/planner.test.ts b/website/src/types/schemas/planner.test.ts new file mode 100644 index 0000000000..11e622bdd2 --- /dev/null +++ b/website/src/types/schemas/planner.test.ts @@ -0,0 +1,174 @@ +import { + PlannerTimeSchema, + CustomModuleSchema, + CustomModuleDataSchema, + PlannerStateSchema, +} from './planner'; + +describe('PlannerTimeSchema', () => { + it('passes with moduleCode only', () => { + const data = { + id: '1', + year: '2024', + semester: 1, + index: 0, + moduleCode: 'CS1010', + }; + expect(() => PlannerTimeSchema.parse(data)).not.toThrow(); + }); + + it('passes with placeholderId only', () => { + const data = { + id: '2', + year: '2024', + semester: 2, + index: 1, + placeholderId: 'geq', + }; + expect(() => PlannerTimeSchema.parse(data)).not.toThrow(); + }); + + it('fails without moduleCode and placeholderId', () => { + const data = { + id: '3', + year: '2024', + semester: 1, + index: 0, + }; + expect(() => PlannerTimeSchema.parse(data)).toThrow(); + }); +}); + +describe('CustomModuleSchema', () => { + it('passes with valid data', () => { + expect(() => + CustomModuleSchema.parse({ + title: 'Prompt Engineering', + moduleCredit: 4, + }), + ).not.toThrow(); + }); + + it('passes with null title', () => { + expect(() => + CustomModuleSchema.parse({ + title: null, + moduleCredit: 4, + }), + ).not.toThrow(); + }); + + it('fails if moduleCredit is missing', () => { + expect(() => + CustomModuleSchema.parse({ + title: 'No Credit', + }), + ).toThrow(); + }); +}); + +describe('CustomModuleDataSchema', () => { + it('parses a record of custom modules', () => { + const data = { + CS1010A: { + title: 'CS1010B', + moduleCredit: 4, + }, + CS1010S: { + title: null, + moduleCredit: 2, + }, + }; + expect(() => CustomModuleDataSchema.parse(data)).not.toThrow(); + }); + + it('fails if any module is invalid', () => { + const data = { + invalidMod: { + title: 'CS1010C', + moduleCredit: null, + }, + }; + expect(() => CustomModuleDataSchema.parse(data)).toThrow(); + }); +}); + +describe('PlannerStateSchema', () => { + it('parses valid planner state', () => { + const data = { + minYear: '2024', + maxYear: '2028', + iblocs: true, + modules: { + '0': { + id: '1', + year: '2024', + semester: 1, + index: 0, + moduleCode: 'CS1231S', + }, + '1': { + id: '1', + year: '2024', + semester: 1, + index: 1, + moduleCode: 'CS2030S', + }, + }, + custom: { + CS1010A: { + title: 'CS1010B', + moduleCredit: 4, + }, + }, + }; + expect(() => PlannerStateSchema.parse(data)).not.toThrow(); + }); + + it('fails if a module inside modules is invalid', () => { + const data = { + minYear: '2024', + maxYear: '2028', + iblocs: false, + modules: { + 'mod-bad': { + id: 'bad', + year: '2025', + semester: 1, + index: 0, + }, + }, + custom: {}, + }; + expect(() => PlannerStateSchema.parse(data)).toThrow(); + }); + + it('removes _persist', () => { + const data = { + minYear: '2024', + maxYear: '2028', + iblocs: true, + modules: { + '0': { + id: '1', + year: '2024', + semester: 1, + index: 0, + moduleCode: 'CS1231S', + }, + }, + custom: {}, + }; + const dataWithPersistConfig = { + ...data, + _persist: { + version: 1, + rehydrated: true, + }, + }; + + const parsed = PlannerStateSchema.safeParse(dataWithPersistConfig); + expect(parsed.success).toBe(true); + expect(parsed.data).toEqual(data); + }); +}); diff --git a/website/src/types/schemas/planner.ts b/website/src/types/schemas/planner.ts new file mode 100644 index 0000000000..95df693554 --- /dev/null +++ b/website/src/types/schemas/planner.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const PlannerTimeSchema = z + .object({ + id: z.string(), + year: z.string(), + semester: z.number(), + index: z.number(), + moduleCode: z.string().optional(), + placeholderId: z.string().optional(), + }) + .refine((data) => data.placeholderId || data.moduleCode, { + message: 'moduleCode is required if placeholderId is not provided', + path: ['moduleCode'], + }); + +export const CustomModuleSchema = z.object({ + title: z.string().nullable().optional(), + moduleCredit: z.number(), +}); + +export const CustomModuleDataSchema = z.record(CustomModuleSchema); + +export const PlannerStateSchema = z + .object({ + minYear: z.string(), + maxYear: z.string(), + iblocs: z.boolean(), + ignorePrereqCheck: z.boolean().optional(), + modules: z.record(PlannerTimeSchema), + custom: CustomModuleDataSchema, + }) + .strip(); diff --git a/website/src/views/planner/PlannerClearButton.scss b/website/src/views/planner/PlannerClearButton.scss new file mode 100644 index 0000000000..59ebc7dbe9 --- /dev/null +++ b/website/src/views/planner/PlannerClearButton.scss @@ -0,0 +1,23 @@ +@import '~styles/utils/modules-entry.scss'; + +.header { + text-align: center; + + svg { + width: 5rem; + height: 5rem; + color: theme-color(success); + } + + h3 { + margin: 0.4rem 0; + font-weight: $font-weight-bold; + font-size: 1.4rem; + } + + @include media-breakpoint-down(sm) { + br { + display: none; + } + } +} diff --git a/website/src/views/planner/PlannerClearButton.tsx b/website/src/views/planner/PlannerClearButton.tsx new file mode 100644 index 0000000000..c65e3bf211 --- /dev/null +++ b/website/src/views/planner/PlannerClearButton.tsx @@ -0,0 +1,56 @@ +import { FC, useState } from 'react'; +import { XSquare } from 'react-feather'; +import CloseButton from 'views/components/CloseButton'; +import Modal from 'views/components/Modal'; + +import styles from './PlannerClearButton.scss'; + +type Props = { + clearPlanner: () => void; +}; + +const PlannerClearButton: FC = (props: Props) => { + const [isOpen, setIsOpen] = useState(false); + + const closeModal = () => setIsOpen(false); + + return ( + <> + + + + +
+ + +

Do you want to reset your planner?

+

+ This will permanently reset all courses and settings.


+ Tip: If you are unsure, you can make a backup by exporting the current + plan. +

+
+ + +
+ + ); +}; + +export default PlannerClearButton; diff --git a/website/src/views/planner/PlannerContainer/PlannerContainer.scss b/website/src/views/planner/PlannerContainer/PlannerContainer.scss index aa5ca8d0ae..a647734712 100644 --- a/website/src/views/planner/PlannerContainer/PlannerContainer.scss +++ b/website/src/views/planner/PlannerContainer/PlannerContainer.scss @@ -1,6 +1,8 @@ @import '~styles/utils/modules-entry'; @import '../variables'; +$btn-margin: 0.5rem; + .pageContainer { composes: page-container from global; height: 100%; @@ -40,14 +42,22 @@ justify-content: flex-end; align-items: center; - @include media-breakpoint-down(sm) { - .settingsButton { - p { - @include sr-only; - } + .buttonGroup { + display: flex; + flex-direction: row; + gap: $btn-margin; + } - svg { - margin-right: 0; + @include media-breakpoint-down(md) { + .buttonGroup { + button { + p { + @include sr-only; + } + + svg { + margin-right: 0; + } } } } @@ -64,6 +74,11 @@ } @include scrollable; + + @include media-breakpoint-down(xs) { + flex-wrap: wrap; + justify-content: end; + } } .addYearButton { diff --git a/website/src/views/planner/PlannerContainer/PlannerContainer.tsx b/website/src/views/planner/PlannerContainer/PlannerContainer.tsx index e71dcf2367..6084f5429a 100644 --- a/website/src/views/planner/PlannerContainer/PlannerContainer.tsx +++ b/website/src/views/planner/PlannerContainer/PlannerContainer.tsx @@ -19,6 +19,8 @@ import { } from 'utils/planner'; import { addPlannerModule, + clearPlanner, + importPlanner, movePlannerModule, removePlannerModule, setPlaceholderModule, @@ -31,9 +33,14 @@ import Title from 'views/components/Title'; import LoadingSpinner from 'views/components/LoadingSpinner'; import Modal from 'views/components/Modal'; import { State as StoreState } from 'types/state'; +import { downloadPlanner } from 'actions/export'; +import { PlannerState } from 'types/reducers'; import PlannerSemester from '../PlannerSemester'; import PlannerYear from '../PlannerYear'; import PlannerSettings from '../PlannerSettings'; +import PlannerClearButton from '../PlannerClearButton'; +import PlannerImportButton from '../PlannerImportButton'; +import PlannerExportButton from '../PlannerExportButton'; import CustomModuleForm from '../CustomModuleForm'; import styles from './PlannerContainer.scss'; @@ -54,6 +61,9 @@ export type Props = Readonly<{ removeModule: (id: string) => void; setPlaceholderModule: (id: string, moduleCode: ModuleCode) => void; addModuleToTimetable: (semester: Semester, module: ModuleCode) => void; + importPlanner: (importedState: PlannerState) => void; + clearPlanner: () => void; + downloadPlanner: () => void; }>; type SemesterModules = { [semester: string]: PlannerModuleInfo[] }; @@ -159,14 +169,20 @@ export class PlannerContainerComponent extends PureComponent {

{renderMCs(credits)}

- +
+ + + + + +
); @@ -306,6 +322,9 @@ const PlannerContainer = connect(mapStateToProps, { moveModule: movePlannerModule, removeModule: removePlannerModule, addModuleToTimetable, + importPlanner, + clearPlanner, + downloadPlanner, })(PlannerContainerComponent); export default PlannerContainer; diff --git a/website/src/views/planner/PlannerExportButton.tsx b/website/src/views/planner/PlannerExportButton.tsx new file mode 100644 index 0000000000..ae38eb665a --- /dev/null +++ b/website/src/views/planner/PlannerExportButton.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Download } from 'react-feather'; + +type Props = { + downloadPlanner: () => void; +}; + +const PlannerExportButton: FC = (props: Props) => ( + +); + +export default PlannerExportButton; diff --git a/website/src/views/planner/PlannerImportButton.tsx b/website/src/views/planner/PlannerImportButton.tsx new file mode 100644 index 0000000000..e554c22a06 --- /dev/null +++ b/website/src/views/planner/PlannerImportButton.tsx @@ -0,0 +1,69 @@ +import { FC, useCallback, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { openNotification } from 'actions/app'; +import { PlannerState } from 'types/reducers'; +import { Upload } from 'react-feather'; +import { PlannerStateSchema } from 'types/schemas/planner'; + +const UPLOAD_ERROR_MESSAGE = `Something went wrong while uploading. Please try again.`; +const PARSE_ERROR_MESSAGE = `The uploaded file doesn't seem valid. You can try re-downloading the plan from its source.`; + +type Props = { + importPlanner: (importedState: PlannerState) => void; +}; + +const PlannerImportButton: FC = (props: Props) => { + const dispatch = useDispatch(); + const upload = useCallback( + (importedState: PlannerState) => { + props.importPlanner(importedState); + dispatch(openNotification('Imported successfully!')); + }, + [dispatch, props], + ); + + const fileInputRef = useRef(null); + + const onClick = () => { + fileInputRef.current?.click(); + }; + + const handleLoad = (event: ProgressEvent) => { + const loaded = event.target?.result as string; + if (!loaded) { + return; + } + + Promise.resolve() + .then(() => JSON.parse(loaded)) + .then(PlannerStateSchema.parseAsync) + .then(upload) + .catch(() => handleError(PARSE_ERROR_MESSAGE)); + }; + + const handleError = useCallback( + (message: string) => { + dispatch(openNotification(message)); + }, + [dispatch], + ); + + const handleFileUpload = (input: React.ChangeEvent) => { + const fileReader = new FileReader(); + fileReader.onload = handleLoad; + fileReader.onerror = () => handleError(UPLOAD_ERROR_MESSAGE); + + const blob: Blob = input.target.files?.[0] as Blob; + fileReader.readAsText(blob, 'UTF-8'); + }; + + return ( + + ); +}; + +export default PlannerImportButton; diff --git a/website/yarn.lock b/website/yarn.lock index e62518f37e..83ab45b695 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -13275,6 +13275,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== +zod@^3.25.67: + version "3.25.67" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.67.tgz#62987e4078e2ab0f63b491ef0c4f33df24236da8" + integrity sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw== + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920"