Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions website/src/actions/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
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');
Expand Down Expand Up @@ -79,3 +81,21 @@
},
};
}

export function downloadPlanner() {
return (_dispatch: Dispatch, getState: GetState) => {
const { planner } = getState();

Check warning on line 87 in website/src/actions/export.ts

View check run for this annotation

Codecov / codecov/patch

website/src/actions/export.ts#L85-L87

Added lines #L85 - L87 were not covered by tests

// removes _persist
const parsed = PlannerStateSchema.safeParse(planner);
if (!parsed.success) {
_dispatch(openNotification('Planner data is corrupted.'));
return;

Check warning on line 93 in website/src/actions/export.ts

View check run for this annotation

Codecov / codecov/patch

website/src/actions/export.ts#L90-L93

Added lines #L90 - L93 were not covered by tests
}

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');

Check warning on line 99 in website/src/actions/export.ts

View check run for this annotation

Codecov / codecov/patch

website/src/actions/export.ts#L96-L99

Added lines #L96 - L99 were not covered by tests
};
}
18 changes: 17 additions & 1 deletion website/src/actions/planner.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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: {},
};
}
47 changes: 42 additions & 5 deletions website/src/reducers/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => {
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 13 additions & 1 deletion website/src/reducers/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
174 changes: 174 additions & 0 deletions website/src/types/schemas/planner.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions website/src/types/schemas/planner.ts
Original file line number Diff line number Diff line change
@@ -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();
Loading