From 247244256f19f361910e3518bfa786905264880e Mon Sep 17 00:00:00 2001 From: leslie yip Date: Fri, 27 Jun 2025 23:56:39 +0800 Subject: [PATCH 1/7] feat: add planner upload and download --- website/package.json | 3 +- website/src/actions/export.ts | 18 ++++++ website/src/actions/planner.ts | 10 +++- website/src/reducers/planner.ts | 6 ++ website/src/types/schemas/planner.ts | 33 ++++++++++ .../PlannerContainer/PlannerContainer.scss | 24 +++++--- .../PlannerContainer/PlannerContainer.tsx | 23 ++++--- website/src/views/planner/PlannerExport.tsx | 20 +++++++ website/src/views/planner/PlannerImport.tsx | 60 +++++++++++++++++++ website/yarn.lock | 5 ++ 10 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 website/src/types/schemas/planner.ts create mode 100644 website/src/views/planner/PlannerExport.tsx create mode 100644 website/src/views/planner/PlannerImport.tsx 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..e607e0e13c 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,19 @@ export function setExportedData(modules: Module[], data: ExportData) { }, }; } + +export function downloadPlannerAsJson() { + return (_dispatch: Dispatch, getState: GetState) => { + const { planner } = getState(); + 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..5d976ae734 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,11 @@ export function addCustomModule(moduleCode: ModuleCode, data: CustomModule) { payload: { moduleCode, data }, }; } + +export const IMPORT_JSON_PLANNER = 'IMPORT_JSON' as const; +export function importJsonPlanner(importedState: PlannerState) { + return { + type: IMPORT_JSON_PLANNER, + payload: { importedState }, + }; +} diff --git a/website/src/reducers/planner.ts b/website/src/reducers/planner.ts index 5595516e7d..0784e253f5 100644 --- a/website/src/reducers/planner.ts +++ b/website/src/reducers/planner.ts @@ -16,6 +16,7 @@ import { SET_PLANNER_MAX_YEAR, SET_PLANNER_MIN_YEAR, SET_IGNORE_PREREQUISITES_CHECK, + IMPORT_JSON_PLANNER, } from 'actions/planner'; import { filterModuleForSemester } from 'selectors/planner'; import config from 'config'; @@ -149,6 +150,11 @@ export default function planner( draft.modules[action.payload.id].moduleCode = action.payload.moduleCode; }); + case IMPORT_JSON_PLANNER: + return { + ...action.payload.importedState, + }; + default: return state; } 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/PlannerContainer/PlannerContainer.scss b/website/src/views/planner/PlannerContainer/PlannerContainer.scss index aa5ca8d0ae..c8be352a2d 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(sm) { + .buttonGroup { + button { + p { + @include sr-only; + } + + svg { + margin-right: 0; + } } } } diff --git a/website/src/views/planner/PlannerContainer/PlannerContainer.tsx b/website/src/views/planner/PlannerContainer/PlannerContainer.tsx index e71dcf2367..c489a08213 100644 --- a/website/src/views/planner/PlannerContainer/PlannerContainer.tsx +++ b/website/src/views/planner/PlannerContainer/PlannerContainer.tsx @@ -34,6 +34,8 @@ import { State as StoreState } from 'types/state'; import PlannerSemester from '../PlannerSemester'; import PlannerYear from '../PlannerYear'; import PlannerSettings from '../PlannerSettings'; +import PlannerExport from '../PlannerExport'; +import PlannerImport from '../PlannerImport'; import CustomModuleForm from '../CustomModuleForm'; import styles from './PlannerContainer.scss'; @@ -159,14 +161,19 @@ export class PlannerContainerComponent extends PureComponent {

{renderMCs(credits)}

- +
+ + + + +
); diff --git a/website/src/views/planner/PlannerExport.tsx b/website/src/views/planner/PlannerExport.tsx new file mode 100644 index 0000000000..3f236fe1ca --- /dev/null +++ b/website/src/views/planner/PlannerExport.tsx @@ -0,0 +1,20 @@ +import { downloadPlannerAsJson } from 'actions/export'; +import { FC, useCallback } from 'react'; +import { Download } from 'react-feather'; +import { useDispatch } from 'react-redux'; + +const PlannerExport: FC = () => { + const dispatch = useDispatch(); + const download = useCallback(() => { + dispatch(downloadPlannerAsJson()); + }, [dispatch]); + + return ( + + ); +}; + +export default PlannerExport; diff --git a/website/src/views/planner/PlannerImport.tsx b/website/src/views/planner/PlannerImport.tsx new file mode 100644 index 0000000000..c7b61d598b --- /dev/null +++ b/website/src/views/planner/PlannerImport.tsx @@ -0,0 +1,60 @@ +import { FC, useCallback, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { openNotification } from 'actions/app'; +import { importJsonPlanner } from 'actions/planner'; +import { PlannerState } from 'types/reducers'; +import { Upload } from 'react-feather'; +import { PlannerStateSchema } from 'types/schemas/planner'; + +const PlannerImport: FC = () => { + const dispatch = useDispatch(); + const upload = useCallback( + (importedState: PlannerState) => { + dispatch(importJsonPlanner(importedState)); + dispatch(openNotification('Imported successfully!')); + }, + [dispatch], + ); + + 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); + }; + + const handleError = useCallback(() => { + dispatch(openNotification('Cannot import the uploaded file.')); + }, [dispatch]); + + const handleFileUpload = (input: React.ChangeEvent) => { + const fileReader = new FileReader(); + fileReader.onload = handleLoad; + fileReader.onerror = handleError; + + const blob: Blob = input.target.files?.[0] as Blob; + fileReader.readAsText(blob, 'UTF-8'); + }; + + return ( + + ); +}; + +export default PlannerImport; 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" From c8c5d2198c2874f731e87996320ff2c43019c6e0 Mon Sep 17 00:00:00 2001 From: leslie yip Date: Sat, 28 Jun 2025 00:28:16 +0800 Subject: [PATCH 2/7] feat: add import reducer test --- website/src/reducers/planner.test.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/website/src/reducers/planner.test.ts b/website/src/reducers/planner.test.ts index 76fa754bc1..b5c9f24085 100644 --- a/website/src/reducers/planner.test.ts +++ b/website/src/reducers/planner.test.ts @@ -13,6 +13,8 @@ import { setPlannerMaxYear, setPlannerIBLOCs, setIgnorePrerequisitesCheck, + IMPORT_JSON_PLANNER, + importJsonPlanner, } from 'actions/planner'; import { PlannerState } from 'types/reducers'; import reducer, { migrateV0toV1, nextId } from './planner'; @@ -203,6 +205,28 @@ describe(REMOVE_PLANNER_MODULE, () => { }); }); +describe(IMPORT_JSON_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 remove the specified module', () => { + expect(reducer(initial, importJsonPlanner(importedState))).toEqual(importedState); + }); +}); + describe(migrateV0toV1, () => { test('should migrate old modules state to new modules state', () => { expect( From 91674832cc18a51ea4abc3f4c94c37e082142dcb Mon Sep 17 00:00:00 2001 From: leslie yip Date: Sat, 28 Jun 2025 00:28:31 +0800 Subject: [PATCH 3/7] feat: add planner schema tests --- website/src/types/schemas/planner.test.ts | 145 ++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 website/src/types/schemas/planner.test.ts diff --git a/website/src/types/schemas/planner.test.ts b/website/src/types/schemas/planner.test.ts new file mode 100644 index 0000000000..65e2db8ca1 --- /dev/null +++ b/website/src/types/schemas/planner.test.ts @@ -0,0 +1,145 @@ +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(); + }); +}); From 730f6a19e94334cc489d3273c1e7427914770f9c Mon Sep 17 00:00:00 2001 From: leslie yip Date: Sat, 28 Jun 2025 11:30:38 +0800 Subject: [PATCH 4/7] feat: add planner clear button --- website/src/actions/export.ts | 2 +- website/src/actions/planner.ts | 14 ++++++-- website/src/reducers/planner.test.ts | 33 +++++++++++++------ website/src/reducers/planner.ts | 12 +++++-- .../src/views/planner/PlannerClearButton.tsx | 18 ++++++++++ .../PlannerContainer/PlannerContainer.scss | 7 +++- .../PlannerContainer/PlannerContainer.tsx | 20 ++++++++--- website/src/views/planner/PlannerExport.tsx | 20 ----------- .../src/views/planner/PlannerExportButton.tsx | 15 +++++++++ ...nnerImport.tsx => PlannerImportButton.tsx} | 13 +++++--- 10 files changed, 107 insertions(+), 47 deletions(-) create mode 100644 website/src/views/planner/PlannerClearButton.tsx delete mode 100644 website/src/views/planner/PlannerExport.tsx create mode 100644 website/src/views/planner/PlannerExportButton.tsx rename website/src/views/planner/{PlannerImport.tsx => PlannerImportButton.tsx} (87%) diff --git a/website/src/actions/export.ts b/website/src/actions/export.ts index e607e0e13c..d167fee070 100644 --- a/website/src/actions/export.ts +++ b/website/src/actions/export.ts @@ -82,7 +82,7 @@ export function setExportedData(modules: Module[], data: ExportData) { }; } -export function downloadPlannerAsJson() { +export function downloadPlanner() { return (_dispatch: Dispatch, getState: GetState) => { const { planner } = getState(); const parsed = PlannerStateSchema.safeParse(planner); diff --git a/website/src/actions/planner.ts b/website/src/actions/planner.ts index 5d976ae734..d87b781c39 100644 --- a/website/src/actions/planner.ts +++ b/website/src/actions/planner.ts @@ -96,10 +96,18 @@ export function addCustomModule(moduleCode: ModuleCode, data: CustomModule) { }; } -export const IMPORT_JSON_PLANNER = 'IMPORT_JSON' as const; -export function importJsonPlanner(importedState: PlannerState) { +export const IMPORT_PLANNER = 'IMPORT_PLANNER' as const; +export function importPlanner(importedState: PlannerState) { return { - type: IMPORT_JSON_PLANNER, + 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 b5c9f24085..3aacbccc54 100644 --- a/website/src/reducers/planner.test.ts +++ b/website/src/reducers/planner.test.ts @@ -13,19 +13,18 @@ import { setPlannerMaxYear, setPlannerIBLOCs, setIgnorePrerequisitesCheck, - IMPORT_JSON_PLANNER, - importJsonPlanner, + 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, () => { @@ -205,7 +204,7 @@ describe(REMOVE_PLANNER_MODULE, () => { }); }); -describe(IMPORT_JSON_PLANNER, () => { +describe(IMPORT_PLANNER, () => { const initial: PlannerState = { ...defaultState, }; @@ -222,8 +221,22 @@ describe(IMPORT_JSON_PLANNER, () => { CS1010A: { title: 'CS1010B', moduleCredit: 4 }, }, }; - test('should remove the specified module', () => { - expect(reducer(initial, importJsonPlanner(importedState))).toEqual(importedState); + 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); }); }); diff --git a/website/src/reducers/planner.ts b/website/src/reducers/planner.ts index 0784e253f5..86e1ce5048 100644 --- a/website/src/reducers/planner.ts +++ b/website/src/reducers/planner.ts @@ -16,12 +16,13 @@ import { SET_PLANNER_MAX_YEAR, SET_PLANNER_MIN_YEAR, SET_IGNORE_PREREQUISITES_CHECK, - IMPORT_JSON_PLANNER, + 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, @@ -150,11 +151,16 @@ export default function planner( draft.modules[action.payload.id].moduleCode = action.payload.moduleCode; }); - case IMPORT_JSON_PLANNER: + case IMPORT_PLANNER: return { ...action.payload.importedState, }; + case CLEAR_PLANNER: + return { + ...defaultPlannerState, + }; + default: return state; } diff --git a/website/src/views/planner/PlannerClearButton.tsx b/website/src/views/planner/PlannerClearButton.tsx new file mode 100644 index 0000000000..f8e66e2961 --- /dev/null +++ b/website/src/views/planner/PlannerClearButton.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react'; +import { Trash } from 'react-feather'; +import Tooltip from 'views/components/Tooltip'; + +type Props = { + clearPlanner: () => void; +}; + +const PlannerClearButton: FC = (props: Props) => ( + + + +); + +export default PlannerClearButton; diff --git a/website/src/views/planner/PlannerContainer/PlannerContainer.scss b/website/src/views/planner/PlannerContainer/PlannerContainer.scss index c8be352a2d..a647734712 100644 --- a/website/src/views/planner/PlannerContainer/PlannerContainer.scss +++ b/website/src/views/planner/PlannerContainer/PlannerContainer.scss @@ -48,7 +48,7 @@ $btn-margin: 0.5rem; gap: $btn-margin; } - @include media-breakpoint-down(sm) { + @include media-breakpoint-down(md) { .buttonGroup { button { p { @@ -74,6 +74,11 @@ $btn-margin: 0.5rem; } @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 c489a08213..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,11 +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 PlannerExport from '../PlannerExport'; -import PlannerImport from '../PlannerImport'; +import PlannerClearButton from '../PlannerClearButton'; +import PlannerImportButton from '../PlannerImportButton'; +import PlannerExportButton from '../PlannerExportButton'; import CustomModuleForm from '../CustomModuleForm'; import styles from './PlannerContainer.scss'; @@ -56,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[] }; @@ -162,8 +170,9 @@ export class PlannerContainerComponent extends PureComponent {
- - + + + - ); -}; - -export default PlannerExport; 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/PlannerImport.tsx b/website/src/views/planner/PlannerImportButton.tsx similarity index 87% rename from website/src/views/planner/PlannerImport.tsx rename to website/src/views/planner/PlannerImportButton.tsx index c7b61d598b..1465bb161c 100644 --- a/website/src/views/planner/PlannerImport.tsx +++ b/website/src/views/planner/PlannerImportButton.tsx @@ -1,19 +1,22 @@ import { FC, useCallback, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { openNotification } from 'actions/app'; -import { importJsonPlanner } from 'actions/planner'; import { PlannerState } from 'types/reducers'; import { Upload } from 'react-feather'; import { PlannerStateSchema } from 'types/schemas/planner'; -const PlannerImport: FC = () => { +type Props = { + importPlanner: (importedState: PlannerState) => void; +}; + +const PlannerImportButton: FC = (props: Props) => { const dispatch = useDispatch(); const upload = useCallback( (importedState: PlannerState) => { - dispatch(importJsonPlanner(importedState)); + props.importPlanner(importedState); dispatch(openNotification('Imported successfully!')); }, - [dispatch], + [dispatch, props], ); const fileInputRef = useRef(null); @@ -57,4 +60,4 @@ const PlannerImport: FC = () => { ); }; -export default PlannerImport; +export default PlannerImportButton; From dcf0e5896ce8f40dff6c52fd7edb4c8f50759481 Mon Sep 17 00:00:00 2001 From: leslie yip Date: Sat, 28 Jun 2025 11:37:56 +0800 Subject: [PATCH 5/7] feat: add unit test for removing _persist in export --- website/src/actions/export.ts | 2 ++ website/src/types/schemas/planner.test.ts | 29 +++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/website/src/actions/export.ts b/website/src/actions/export.ts index d167fee070..7667b25b32 100644 --- a/website/src/actions/export.ts +++ b/website/src/actions/export.ts @@ -85,6 +85,8 @@ 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.')); diff --git a/website/src/types/schemas/planner.test.ts b/website/src/types/schemas/planner.test.ts index 65e2db8ca1..11e622bdd2 100644 --- a/website/src/types/schemas/planner.test.ts +++ b/website/src/types/schemas/planner.test.ts @@ -142,4 +142,33 @@ describe('PlannerStateSchema', () => { }; 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); + }); }); From 0a9148c6d159256dc2e314bb80bed03893a7a718 Mon Sep 17 00:00:00 2001 From: leslie yip Date: Sun, 29 Jun 2025 00:25:20 +0800 Subject: [PATCH 6/7] feat: replace clear button tooltip with modal --- .../src/views/planner/PlannerClearButton.scss | 23 +++++++ .../src/views/planner/PlannerClearButton.tsx | 60 +++++++++++++++---- 2 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 website/src/views/planner/PlannerClearButton.scss 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 index f8e66e2961..c65e3bf211 100644 --- a/website/src/views/planner/PlannerClearButton.tsx +++ b/website/src/views/planner/PlannerClearButton.tsx @@ -1,18 +1,56 @@ -import { FC } from 'react'; -import { Trash } from 'react-feather'; -import Tooltip from 'views/components/Tooltip'; +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 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; From ce77bb70dc8f0d8d00bbf7d18d6df5a25b878864 Mon Sep 17 00:00:00 2001 From: leslie yip Date: Sun, 29 Jun 2025 00:36:34 +0800 Subject: [PATCH 7/7] feat: improve import error messages --- .../src/views/planner/PlannerImportButton.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/website/src/views/planner/PlannerImportButton.tsx b/website/src/views/planner/PlannerImportButton.tsx index 1465bb161c..e554c22a06 100644 --- a/website/src/views/planner/PlannerImportButton.tsx +++ b/website/src/views/planner/PlannerImportButton.tsx @@ -5,6 +5,9 @@ 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; }; @@ -35,17 +38,20 @@ const PlannerImportButton: FC = (props: Props) => { .then(() => JSON.parse(loaded)) .then(PlannerStateSchema.parseAsync) .then(upload) - .catch(handleError); + .catch(() => handleError(PARSE_ERROR_MESSAGE)); }; - const handleError = useCallback(() => { - dispatch(openNotification('Cannot import the uploaded file.')); - }, [dispatch]); + const handleError = useCallback( + (message: string) => { + dispatch(openNotification(message)); + }, + [dispatch], + ); const handleFileUpload = (input: React.ChangeEvent) => { const fileReader = new FileReader(); fileReader.onload = handleLoad; - fileReader.onerror = handleError; + fileReader.onerror = () => handleError(UPLOAD_ERROR_MESSAGE); const blob: Blob = input.target.files?.[0] as Blob; fileReader.readAsText(blob, 'UTF-8');