Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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();
18 changes: 18 additions & 0 deletions website/src/views/planner/PlannerClearButton.tsx
Original file line number Diff line number Diff line change
@@ -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: Props) => (
<Tooltip content="Are you sure? This action is irreversible!" placement="left">

Check warning on line 10 in website/src/views/planner/PlannerClearButton.tsx

View check run for this annotation

Codecov / codecov/patch

website/src/views/planner/PlannerClearButton.tsx#L9-L10

Added lines #L9 - L10 were not covered by tests
<button className="btn btn-svg btn-outline-primary" type="button" onClick={props.clearPlanner}>
<Trash className="svg" />
<p>Clear</p>
</button>
</Tooltip>
);

export default PlannerClearButton;
Loading