Skip to content

Commit 0f0c93d

Browse files
leslieyip02ravern
andauthored
feat(planner): add import and export (#4077)
* feat: add planner upload and download * feat: add import reducer test * feat: add planner schema tests * feat: add planner clear button * feat: add unit test for removing _persist in export * feat: replace clear button tooltip with modal * feat: improve import error messages --------- Co-authored-by: Ravern Koh <[email protected]>
1 parent 71554a1 commit 0f0c93d

File tree

14 files changed

+518
-23
lines changed

14 files changed

+518
-23
lines changed

website/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@
171171
"reselect": "4.1.8",
172172
"samlify": "2.7.7",
173173
"searchkit": "2.4.1-alpha.5",
174-
"use-subscription": "1.10.0"
174+
"use-subscription": "1.10.0",
175+
"zod": "^3.25.67"
175176
},
176177
"browserslist": [
177178
"extends browserslist-config-nusmods"

website/src/actions/export.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { captureException } from 'utils/error';
99
import retryImport from 'utils/retryImport';
1010
import { getSemesterTimetableLessons } from 'selectors/timetables';
1111
import { TaModulesConfig } from 'types/timetables';
12+
import { PlannerStateSchema } from 'types/schemas/planner';
1213
import { SET_EXPORTED_DATA } from './constants';
14+
import { openNotification } from './app';
1315

1416
function downloadUrl(blob: Blob, filename: string) {
1517
const link = document.createElement('a');
@@ -79,3 +81,21 @@ export function setExportedData(modules: Module[], data: ExportData) {
7981
},
8082
};
8183
}
84+
85+
export function downloadPlanner() {
86+
return (_dispatch: Dispatch, getState: GetState) => {
87+
const { planner } = getState();
88+
89+
// removes _persist
90+
const parsed = PlannerStateSchema.safeParse(planner);
91+
if (!parsed.success) {
92+
_dispatch(openNotification('Planner data is corrupted.'));
93+
return;
94+
}
95+
96+
const exportState = parsed.data;
97+
const bytes = new TextEncoder().encode(JSON.stringify(exportState));
98+
const blob = new Blob([bytes], { type: 'application/json' });
99+
downloadUrl(blob, 'nusmods_planner.json');
100+
};
101+
}

website/src/actions/planner.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ModuleCode, Semester } from 'types/modules';
22
import { AddModuleData } from 'types/planner';
3-
import { CustomModule } from 'types/reducers';
3+
import { CustomModule, PlannerState } from 'types/reducers';
44
import { PLAN_TO_TAKE_SEMESTER, PLAN_TO_TAKE_YEAR } from 'utils/planner';
55

66
export const SET_PLANNER_MIN_YEAR = 'SET_PLANNER_MIN_YEAR' as const;
@@ -95,3 +95,19 @@ export function addCustomModule(moduleCode: ModuleCode, data: CustomModule) {
9595
payload: { moduleCode, data },
9696
};
9797
}
98+
99+
export const IMPORT_PLANNER = 'IMPORT_PLANNER' as const;
100+
export function importPlanner(importedState: PlannerState) {
101+
return {
102+
type: IMPORT_PLANNER,
103+
payload: { importedState },
104+
};
105+
}
106+
107+
export const CLEAR_PLANNER = 'CLEAR_PLANNER' as const;
108+
export function clearPlanner() {
109+
return {
110+
type: CLEAR_PLANNER,
111+
payload: {},
112+
};
113+
}

website/src/reducers/planner.test.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,18 @@ import {
1313
setPlannerMaxYear,
1414
setPlannerIBLOCs,
1515
setIgnorePrerequisitesCheck,
16+
IMPORT_PLANNER,
17+
importPlanner,
18+
CLEAR_PLANNER,
19+
clearPlanner,
1620
} from 'actions/planner';
1721
import { PlannerState } from 'types/reducers';
18-
import reducer, { migrateV0toV1, nextId } from './planner';
22+
import reducer, { defaultPlannerState, migrateV0toV1, nextId } from './planner';
1923

2024
const defaultState: PlannerState = {
25+
...defaultPlannerState,
2126
minYear: '2017/2018',
2227
maxYear: '2018/2019',
23-
iblocs: false,
24-
ignorePrereqCheck: false,
25-
modules: {},
26-
custom: {},
2728
};
2829

2930
describe(nextId, () => {
@@ -203,6 +204,42 @@ describe(REMOVE_PLANNER_MODULE, () => {
203204
});
204205
});
205206

207+
describe(IMPORT_PLANNER, () => {
208+
const initial: PlannerState = {
209+
...defaultState,
210+
};
211+
212+
const importedState: PlannerState = {
213+
minYear: '2024/2025',
214+
maxYear: '2025/2026',
215+
iblocs: true,
216+
ignorePrereqCheck: true,
217+
modules: {
218+
0: { id: '0', moduleCode: 'CS1010S', year: '2018/2019', semester: 1, index: 0 },
219+
},
220+
custom: {
221+
CS1010A: { title: 'CS1010B', moduleCredit: 4 },
222+
},
223+
};
224+
test('should import the given planner', () => {
225+
expect(reducer(initial, importPlanner(importedState))).toEqual(importedState);
226+
});
227+
});
228+
229+
describe(CLEAR_PLANNER, () => {
230+
const initial: PlannerState = {
231+
...defaultState,
232+
233+
modules: {
234+
0: { id: '0', moduleCode: 'CS1010S', year: '2018/2019', semester: 1, index: 0 },
235+
},
236+
};
237+
238+
test('should clear the planner', () => {
239+
expect(reducer(initial, clearPlanner())).toEqual(defaultPlannerState);
240+
});
241+
});
242+
206243
describe(migrateV0toV1, () => {
207244
test('should migrate old modules state to new modules state', () => {
208245
expect(

website/src/reducers/planner.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ import {
1616
SET_PLANNER_MAX_YEAR,
1717
SET_PLANNER_MIN_YEAR,
1818
SET_IGNORE_PREREQUISITES_CHECK,
19+
IMPORT_PLANNER,
20+
CLEAR_PLANNER,
1921
} from 'actions/planner';
2022
import { filterModuleForSemester } from 'selectors/planner';
2123
import config from 'config';
2224

23-
const defaultPlannerState: PlannerState = {
25+
export const defaultPlannerState: PlannerState = {
2426
minYear: config.academicYear,
2527
maxYear: config.academicYear,
2628
iblocs: false,
@@ -149,6 +151,16 @@ export default function planner(
149151
draft.modules[action.payload.id].moduleCode = action.payload.moduleCode;
150152
});
151153

154+
case IMPORT_PLANNER:
155+
return {
156+
...action.payload.importedState,
157+
};
158+
159+
case CLEAR_PLANNER:
160+
return {
161+
...defaultPlannerState,
162+
};
163+
152164
default:
153165
return state;
154166
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import {
2+
PlannerTimeSchema,
3+
CustomModuleSchema,
4+
CustomModuleDataSchema,
5+
PlannerStateSchema,
6+
} from './planner';
7+
8+
describe('PlannerTimeSchema', () => {
9+
it('passes with moduleCode only', () => {
10+
const data = {
11+
id: '1',
12+
year: '2024',
13+
semester: 1,
14+
index: 0,
15+
moduleCode: 'CS1010',
16+
};
17+
expect(() => PlannerTimeSchema.parse(data)).not.toThrow();
18+
});
19+
20+
it('passes with placeholderId only', () => {
21+
const data = {
22+
id: '2',
23+
year: '2024',
24+
semester: 2,
25+
index: 1,
26+
placeholderId: 'geq',
27+
};
28+
expect(() => PlannerTimeSchema.parse(data)).not.toThrow();
29+
});
30+
31+
it('fails without moduleCode and placeholderId', () => {
32+
const data = {
33+
id: '3',
34+
year: '2024',
35+
semester: 1,
36+
index: 0,
37+
};
38+
expect(() => PlannerTimeSchema.parse(data)).toThrow();
39+
});
40+
});
41+
42+
describe('CustomModuleSchema', () => {
43+
it('passes with valid data', () => {
44+
expect(() =>
45+
CustomModuleSchema.parse({
46+
title: 'Prompt Engineering',
47+
moduleCredit: 4,
48+
}),
49+
).not.toThrow();
50+
});
51+
52+
it('passes with null title', () => {
53+
expect(() =>
54+
CustomModuleSchema.parse({
55+
title: null,
56+
moduleCredit: 4,
57+
}),
58+
).not.toThrow();
59+
});
60+
61+
it('fails if moduleCredit is missing', () => {
62+
expect(() =>
63+
CustomModuleSchema.parse({
64+
title: 'No Credit',
65+
}),
66+
).toThrow();
67+
});
68+
});
69+
70+
describe('CustomModuleDataSchema', () => {
71+
it('parses a record of custom modules', () => {
72+
const data = {
73+
CS1010A: {
74+
title: 'CS1010B',
75+
moduleCredit: 4,
76+
},
77+
CS1010S: {
78+
title: null,
79+
moduleCredit: 2,
80+
},
81+
};
82+
expect(() => CustomModuleDataSchema.parse(data)).not.toThrow();
83+
});
84+
85+
it('fails if any module is invalid', () => {
86+
const data = {
87+
invalidMod: {
88+
title: 'CS1010C',
89+
moduleCredit: null,
90+
},
91+
};
92+
expect(() => CustomModuleDataSchema.parse(data)).toThrow();
93+
});
94+
});
95+
96+
describe('PlannerStateSchema', () => {
97+
it('parses valid planner state', () => {
98+
const data = {
99+
minYear: '2024',
100+
maxYear: '2028',
101+
iblocs: true,
102+
modules: {
103+
'0': {
104+
id: '1',
105+
year: '2024',
106+
semester: 1,
107+
index: 0,
108+
moduleCode: 'CS1231S',
109+
},
110+
'1': {
111+
id: '1',
112+
year: '2024',
113+
semester: 1,
114+
index: 1,
115+
moduleCode: 'CS2030S',
116+
},
117+
},
118+
custom: {
119+
CS1010A: {
120+
title: 'CS1010B',
121+
moduleCredit: 4,
122+
},
123+
},
124+
};
125+
expect(() => PlannerStateSchema.parse(data)).not.toThrow();
126+
});
127+
128+
it('fails if a module inside modules is invalid', () => {
129+
const data = {
130+
minYear: '2024',
131+
maxYear: '2028',
132+
iblocs: false,
133+
modules: {
134+
'mod-bad': {
135+
id: 'bad',
136+
year: '2025',
137+
semester: 1,
138+
index: 0,
139+
},
140+
},
141+
custom: {},
142+
};
143+
expect(() => PlannerStateSchema.parse(data)).toThrow();
144+
});
145+
146+
it('removes _persist', () => {
147+
const data = {
148+
minYear: '2024',
149+
maxYear: '2028',
150+
iblocs: true,
151+
modules: {
152+
'0': {
153+
id: '1',
154+
year: '2024',
155+
semester: 1,
156+
index: 0,
157+
moduleCode: 'CS1231S',
158+
},
159+
},
160+
custom: {},
161+
};
162+
const dataWithPersistConfig = {
163+
...data,
164+
_persist: {
165+
version: 1,
166+
rehydrated: true,
167+
},
168+
};
169+
170+
const parsed = PlannerStateSchema.safeParse(dataWithPersistConfig);
171+
expect(parsed.success).toBe(true);
172+
expect(parsed.data).toEqual(data);
173+
});
174+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { z } from 'zod';
2+
3+
export const PlannerTimeSchema = z
4+
.object({
5+
id: z.string(),
6+
year: z.string(),
7+
semester: z.number(),
8+
index: z.number(),
9+
moduleCode: z.string().optional(),
10+
placeholderId: z.string().optional(),
11+
})
12+
.refine((data) => data.placeholderId || data.moduleCode, {
13+
message: 'moduleCode is required if placeholderId is not provided',
14+
path: ['moduleCode'],
15+
});
16+
17+
export const CustomModuleSchema = z.object({
18+
title: z.string().nullable().optional(),
19+
moduleCredit: z.number(),
20+
});
21+
22+
export const CustomModuleDataSchema = z.record(CustomModuleSchema);
23+
24+
export const PlannerStateSchema = z
25+
.object({
26+
minYear: z.string(),
27+
maxYear: z.string(),
28+
iblocs: z.boolean(),
29+
ignorePrereqCheck: z.boolean().optional(),
30+
modules: z.record(PlannerTimeSchema),
31+
custom: CustomModuleDataSchema,
32+
})
33+
.strip();

0 commit comments

Comments
 (0)