From 35fb646f8b72d572bdb0fa32c37fc4560db7125e Mon Sep 17 00:00:00 2001 From: Lucas Date: Sun, 1 Sep 2024 18:55:13 +1000 Subject: [PATCH] Add JSON import and export support --- backend/server/db/helpers/models.py | 9 ++ backend/server/routers/model.py | 6 - backend/server/routers/user.py | 105 +++++++++--- frontend/package-lock.json | 46 +++--- frontend/package.json | 5 +- .../EditMarkModal/EditMarkModal.tsx | 2 +- frontend/src/hooks/useSettings.ts | 16 +- .../Hero/HeroContent/HeroContent.tsx | 2 +- .../ExportPlannerMenu/ExportPlannerMenu.tsx | 30 +++- .../ImportPlannerMenu/ImportPlannerMenu.tsx | 149 ++++++------------ .../OptionsHeader/OptionsHeader.tsx | 17 +- frontend/src/types/planner.ts | 2 +- frontend/src/types/userResponse.ts | 2 +- frontend/src/utils/api/userApi.ts | 5 + frontend/src/utils/export.ts | 82 ++++++++++ 15 files changed, 296 insertions(+), 182 deletions(-) create mode 100644 frontend/src/utils/export.ts diff --git a/backend/server/db/helpers/models.py b/backend/server/db/helpers/models.py index 27dfcc88d..6ac1379cf 100644 --- a/backend/server/db/helpers/models.py +++ b/backend/server/db/helpers/models.py @@ -16,6 +16,9 @@ class UserDegreeStorage(BaseModel): programCode: str specs: List[str] +class UserCourseMinimal(BaseModel): + mark: Union[Literal['SY', 'FL', 'PS', 'CR', 'DN', 'HD'], int, None] + ignoreFromProgression: bool class UserCourseStorage(BaseModel): code: str @@ -52,6 +55,12 @@ class _BaseUserStorage(BaseModel): # NOTE: could also put uid here if we want guest: bool +class UserImport(BaseModel): + degree: UserDegreeStorage + courses: dict[str, UserCourseMinimal] + planner: UserPlannerStorage + settings: UserSettingsStorage + class UserStorage(_BaseUserStorage): setup: Literal[True] = True degree: UserDegreeStorage diff --git a/backend/server/routers/model.py b/backend/server/routers/model.py index 5f72d94a2..81bc2f00e 100644 --- a/backend/server/routers/model.py +++ b/backend/server/routers/model.py @@ -265,12 +265,6 @@ class Storage(TypedDict): courses: dict[str, CourseStorage] settings: SettingsStorage -class LocalStorage(BaseModel): - model_config = ConfigDict(extra='forbid') - - degree: DegreeLocalStorage - planner: PlannerLocalStorage - class StartYear(BaseModel): model_config = ConfigDict(extra='forbid') diff --git a/backend/server/routers/user.py b/backend/server/routers/user.py index 7f4fcbaef..369e5d93d 100644 --- a/backend/server/routers/user.py +++ b/backend/server/routers/user.py @@ -1,12 +1,12 @@ -from itertools import chain from typing import Annotated, Dict, Optional, cast from fastapi import APIRouter, HTTPException, Security +from server.db.helpers.models import PartialUserStorage, UserCourseStorage, UserCoursesStorage, UserImport from data.processors.models import SpecData from server.routers.utility.common import get_all_specialisations, get_course_details from server.routers.utility.sessions.middleware import HTTPBearerToUserID from server.routers.utility.user import get_setup_user, set_user -from server.routers.model import CourseMark, CourseStorage, DegreeLength, DegreeWizardInfo, HiddenYear, SettingsStorage, StartYear, CourseStorageWithExtra, DegreeLocalStorage, LocalStorage, PlannerLocalStorage, Storage, SpecType +from server.routers.model import CourseMark, DegreeLength, DegreeWizardInfo, HiddenYear, SettingsStorage, StartYear, CourseStorageWithExtra, DegreeLocalStorage, PlannerLocalStorage, Storage, SpecType import server.db.helpers.users as udb @@ -17,30 +17,83 @@ require_uid = HTTPBearerToUserID() -# Ideally not used often. -@router.post("/saveLocalStorage") -def save_local_storage(localStorage: LocalStorage, uid: Annotated[str, Security(require_uid)]): - planned: list[str] = sum((sum(year.values(), []) - for year in localStorage.planner['years']), []) - unplanned: list[str] = localStorage.planner['unplanned'] - courses: dict[str, CourseStorage] = { - course: { - 'code': course, - 'mark': None, # wtf we nuking marks? - 'uoc': get_course_details(course)['UOC'], - 'ignoreFromProgression': False - } - for course in chain(planned, unplanned) - } - # cancer, but the FE inspired this cancer - real_planner = localStorage.planner.copy() - item: Storage = { - 'degree': localStorage.degree, - 'planner': real_planner, - 'courses': courses, - 'settings': SettingsStorage(showMarks=False, hiddenYears=set()), - } - set_user(uid, item) +@router.put("/import") +def import_user(data: UserImport, uid: Annotated[str, Security(require_uid)]): + assert udb.reset_user(uid) + + if data.planner.startYear < 2019: + raise HTTPException(status_code=400, detail="Invalid start year") + if len(data.planner.years) > 10: + raise HTTPException(status_code=400, detail="Too many years") + if len(data.planner.years) < 1: + raise HTTPException(status_code=400, detail="Not enough years") + + possible_specs = get_all_specialisations(data.degree.programCode) + if possible_specs is None: + raise HTTPException(status_code=400, detail="Invalid program code") + + flattened_containers: list[tuple[bool, list[str]]] = [ + ( + program_sub_container["is_optional"], + list(program_sub_container["specs"].keys()) + ) + for spec_type_container in cast(dict[SpecType, dict[str, SpecData]], possible_specs).values() + for program_sub_container in spec_type_container.values() + ] + + invalid_lhs_specs = set(data.degree.specs).difference( + spec_code + for (_, spec_codes) in flattened_containers + for spec_code in spec_codes + ) + + spec_reqs_not_met = any( + ( + not is_optional + and not set(spec_codes).intersection(data.degree.specs) + ) + for (is_optional, spec_codes) in flattened_containers + ) + + if invalid_lhs_specs or spec_reqs_not_met: + raise HTTPException(status_code=400, detail="Invalid specialisations") + + for term in data.planner.lockedTerms.keys(): + year, termIndex = term.split("T") + if int(year) < data.planner.startYear or int(year) >= data.planner.startYear + len(data.planner.years): + raise HTTPException(status_code=400, detail="Invalid locked term") + if termIndex not in ["0", "1", "2", "3"]: + raise HTTPException(status_code=400, detail="Invalid locked term") + for year in data.settings.hiddenYears: + if year < 0 or year >= len(data.planner.years): + raise HTTPException(status_code=400, detail="Invalid hidden year") + + courses = [] + for course in data.planner.unplanned: + if course not in data.courses.keys(): + raise HTTPException(status_code=400, detail="Unplanned course not in courses") + courses.append(course) + for year in data.planner.years: + for term in [year.T0, year.T1, year.T2, year.T3]: + for course in term: + if course not in data.courses.keys(): + raise HTTPException(status_code=400, detail="Planned course not in courses") + courses.append(course) + for course in data.courses.keys(): + if course not in courses: + raise HTTPException(status_code=400, detail="Course in courses not in planner") + mark = data.courses[course].mark + if isinstance(mark, int) and (mark < 0 or mark > 100): + raise HTTPException(status_code=400, detail="Invalid mark") + userCourses: UserCoursesStorage = {} + for course in courses: + # This raises an exception + uoc = get_course_details(course)["UOC"] + userCourses[course] = UserCourseStorage(code=course, mark=data.courses[course].mark, uoc=uoc, ignoreFromProgression=data.courses[course].ignoreFromProgression) + + user = PartialUserStorage(degree=data.degree, courses=userCourses, planner=data.planner, settings=data.settings) + udb.reset_user(uid) + udb.update_user(uid, user) @router.get("/data/all") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ed11ea5eb..8d42ec81d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,7 +19,7 @@ "@tanstack/react-query": "5.29.2", "@tippyjs/react": "4.2.6", "antd": "5.18.1", - "axios": "1.6.8", + "axios": "1.7.7", "dayjs": "1.11.11", "fast-fuzzy": "1.12.0", "framer-motion": "11.0.20", @@ -41,7 +41,7 @@ "redux-thunk": "3.1.0", "styled-components": "6.1.11", "use-debounce": "10.0.1", - "uuid": "9.0.1" + "zod": "3.23.8" }, "devDependencies": { "@babel/core": "7.24.7", @@ -56,7 +56,6 @@ "@types/react-helmet": "6.1.11", "@types/react-scroll": "1.8.10", "@types/styled-components": "5.1.34", - "@types/uuid": "9.0.8", "@typescript-eslint/eslint-plugin": "7.3.1", "@typescript-eslint/parser": "7.3.1", "@vitejs/plugin-react": "4.2.1", @@ -3432,12 +3431,6 @@ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true - }, "node_modules/@types/yargs": { "version": "17.0.32", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", @@ -4337,9 +4330,10 @@ } }, "node_modules/axios": { - "version": "1.6.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", - "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -8601,10 +8595,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", - "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -11575,18 +11570,6 @@ "base64-arraybuffer": "^1.0.2" } }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/vite": { "version": "5.2.6", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.6.tgz", @@ -12383,6 +12366,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 9635575ca..ee22150d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,7 +15,7 @@ "@tanstack/react-query": "5.29.2", "@tippyjs/react": "4.2.6", "antd": "5.18.1", - "axios": "1.6.8", + "axios": "1.7.7", "dayjs": "1.11.11", "fast-fuzzy": "1.12.0", "framer-motion": "11.0.20", @@ -37,7 +37,7 @@ "redux-thunk": "3.1.0", "styled-components": "6.1.11", "use-debounce": "10.0.1", - "uuid": "9.0.1" + "zod": "3.23.8" }, "scripts": { "start": "vite --port 3000 --host", @@ -83,7 +83,6 @@ "@types/react-helmet": "6.1.11", "@types/react-scroll": "1.8.10", "@types/styled-components": "5.1.34", - "@types/uuid": "9.0.8", "@typescript-eslint/eslint-plugin": "7.3.1", "@typescript-eslint/parser": "7.3.1", "@vitejs/plugin-react": "4.2.1", diff --git a/frontend/src/components/EditMarkModal/EditMarkModal.tsx b/frontend/src/components/EditMarkModal/EditMarkModal.tsx index 1e8d42bfa..b7ba16d20 100644 --- a/frontend/src/components/EditMarkModal/EditMarkModal.tsx +++ b/frontend/src/components/EditMarkModal/EditMarkModal.tsx @@ -56,7 +56,7 @@ const EditMarkModal = ({ code, open, onCancel }: Props) => { // mark is a letter grade updateMarkMutation.mutate({ course: code, mark: markValue as Grade }); } else if (markValue === '' || markValue === undefined) { - updateMarkMutation.mutate({ course: code, mark: undefined }); + updateMarkMutation.mutate({ course: code, mark: null }); } else { message.error('Could not update mark. Please enter a valid mark or letter grade'); } diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts index 6caa8d2e0..ab2e3210d 100644 --- a/frontend/src/hooks/useSettings.ts +++ b/frontend/src/hooks/useSettings.ts @@ -1,5 +1,6 @@ import { useCallback } from 'react'; import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { SettingsResponse } from 'types/userResponse'; import { getUserSettings, hideYear as hideYearApi, @@ -16,12 +17,13 @@ import useToken from './useToken'; type Theme = 'light' | 'dark'; -interface Settings { +export interface LocalSettings { theme: Theme; - showMarks: boolean; showLockedCourses: boolean; showPastWarnings: boolean; - hiddenYears: number[]; +} + +export interface Settings extends LocalSettings, SettingsResponse { mutateTheme: (theme: Theme) => void; toggleShowMarks: () => void; hideYear: (yearIndex: number) => void; @@ -103,8 +105,12 @@ function useSettings(queryClient?: QueryClient): Settings { const showYears = showYearsMutation.mutate; return { - ...userSettings, - ...localSettings, + // Do not use the spread operator here, as it doesn't update after the mutation + theme: localSettings.theme, + showLockedCourses: localSettings.showLockedCourses, + showPastWarnings: localSettings.showPastWarnings, + showMarks: userSettings.showMarks, + hiddenYears: userSettings.hiddenYears, mutateTheme, toggleShowMarks, hideYear, diff --git a/frontend/src/pages/LandingPage/Hero/HeroContent/HeroContent.tsx b/frontend/src/pages/LandingPage/Hero/HeroContent/HeroContent.tsx index 8ac7f4f7f..3627678d7 100644 --- a/frontend/src/pages/LandingPage/Hero/HeroContent/HeroContent.tsx +++ b/frontend/src/pages/LandingPage/Hero/HeroContent/HeroContent.tsx @@ -23,7 +23,7 @@ const HeroContent = ({ startLocation }: Props) => { transition={{ type: 'spring', stiffness: 400, damping: 10 }} whileHover={{ scale: 1.1 }} > - START WITH CSESOC + GET STARTED diff --git a/frontend/src/pages/TermPlanner/ExportPlannerMenu/ExportPlannerMenu.tsx b/frontend/src/pages/TermPlanner/ExportPlannerMenu/ExportPlannerMenu.tsx index 419b001dc..9e6c61841 100644 --- a/frontend/src/pages/TermPlanner/ExportPlannerMenu/ExportPlannerMenu.tsx +++ b/frontend/src/pages/TermPlanner/ExportPlannerMenu/ExportPlannerMenu.tsx @@ -1,13 +1,20 @@ import React, { useState } from 'react'; import { Radio } from 'antd'; +import { getUser } from 'utils/api/userApi'; +import { exportUser } from 'utils/export'; +import useToken from 'hooks/useToken'; import CS from '../common/styles'; import S from './styles'; -type Props = { - plannerRef: React.RefObject; -}; +// type Props = { +// plannerRef: React.RefObject; +// }; + +// const ExportPlannerMenu = ({ plannerRef }: Props) => { +const ExportPlannerMenu = () => { + const token = useToken(); -const ExportPlannerMenu = ({ plannerRef }: Props) => { + const plannerRef = React.createRef(); const exportFormats = ['png', 'jpg', 'json']; const exportFields = { fileName: 'Term Planner' }; @@ -21,6 +28,21 @@ const ExportPlannerMenu = ({ plannerRef }: Props) => { exportComponentAsPNG(plannerRef, exportFields); } else if (format === 'jpg') { exportComponentAsJPEG(plannerRef, exportFields); + } else if (format === 'json') { + getUser(token) + .then((user) => { + const exported = exportUser(user); + const blob = new Blob([JSON.stringify(exported)], { type: 'application/json' }); + const jsonObjectUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = jsonObjectUrl; + const date = new Date(); + a.download = `circles-planner-export-${date.toISOString()}.json`; + a.click(); + }) + .catch((err) => { + console.error('Error at exportPlannerMenu: ', err); + }); } }; diff --git a/frontend/src/pages/TermPlanner/ImportPlannerMenu/ImportPlannerMenu.tsx b/frontend/src/pages/TermPlanner/ImportPlannerMenu/ImportPlannerMenu.tsx index b4cf5aa98..ad6b1f035 100644 --- a/frontend/src/pages/TermPlanner/ImportPlannerMenu/ImportPlannerMenu.tsx +++ b/frontend/src/pages/TermPlanner/ImportPlannerMenu/ImportPlannerMenu.tsx @@ -1,17 +1,9 @@ import React, { useRef, useState } from 'react'; import { LoadingOutlined } from '@ant-design/icons'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Spin } from 'antd'; -import { JSONPlanner, Term } from 'types/planner'; -import { badPlanner } from 'types/userResponse'; -import { getCourseInfo } from 'utils/api/coursesApi'; -import { addToUnplanned, setUnplannedCourseToTerm } from 'utils/api/plannerApi'; -import { - getUserPlanner, - toggleSummerTerm, - updateDegreeLength, - updateStartYear -} from 'utils/api/userApi'; +import { importUser as importUserApi } from 'utils/api/userApi'; +import { importUser, UserJson } from 'utils/export'; import openNotification from 'utils/openNotification'; import useToken from 'hooks/useToken'; import CS from '../common/styles'; @@ -19,11 +11,26 @@ import S from './styles'; const ImportPlannerMenu = () => { const token = useToken(); - const plannerQuery = useQuery({ - queryKey: ['planner'], - queryFn: () => getUserPlanner(token) + const queryClient = useQueryClient(); + + const importUserMutation = useMutation({ + mutationFn: (user: UserJson) => importUserApi(token, user), + onSuccess: () => { + queryClient.resetQueries(); + }, + onError: () => { + openNotification({ + type: 'error', + message: 'Import failed', + description: 'An error occurred when importing the planner' + }); + } }); - const planner = plannerQuery.data || badPlanner; + + const handleImport = (user: UserJson) => { + importUserMutation.mutate(user); + }; + const inputRef = useRef(null); const [loading, setLoading] = useState(false); @@ -46,103 +53,39 @@ const ImportPlannerMenu = () => { return; } - const plannedCourses: string[] = []; - planner.years.forEach((year) => { - Object.values(year).forEach((termKey) => { - termKey.forEach((code) => { - plannedCourses.push(code); - }); - }); - }); - setLoading(true); const reader = new FileReader(); reader.readAsText(e.target.files[0], 'UTF-8'); reader.onload = async (ev) => { - if (ev.target !== null) { - const content = ev.target.result; - e.target.value = ''; + if (ev.target === null) { + return; + } - try { - const fileInJson = JSON.parse(content as string) as JSONPlanner; - if ( - !Object.prototype.hasOwnProperty.call(fileInJson, 'startYear') || - !Object.prototype.hasOwnProperty.call(fileInJson, 'numYears') || - !Object.prototype.hasOwnProperty.call(fileInJson, 'isSummerEnabled') || - !Object.prototype.hasOwnProperty.call(fileInJson, 'years') || - !Object.prototype.hasOwnProperty.call(fileInJson, 'version') - ) { - openNotification({ - type: 'error', - message: 'Invalid structure of the JSON file', - description: 'The structure of the JSON file is not valid.' - }); - return; - } - try { - await updateDegreeLength(token, fileInJson.numYears); - await updateStartYear(token, fileInJson.startYear.toString()); - } catch { - openNotification({ - type: 'error', - message: 'Error setting degree start year or length', - description: 'There was an error updating the degree start year or length.' - }); - return; - } - if (planner.isSummerEnabled !== fileInJson.isSummerEnabled) { - try { - await toggleSummerTerm(token); - } catch { - openNotification({ - type: 'error', - message: 'Error setting summer term', - description: 'An error occurred when trying to import summer term visibility' - }); - return; - } - } - fileInJson.years.forEach((year, yearIndex) => { - Object.entries(year).forEach(([term, termCourses]) => { - termCourses.forEach(async (code, index) => { - const course = await getCourseInfo(code); - if (plannedCourses.indexOf(course.code) === -1) { - plannedCourses.push(course.code); - addToUnplanned(token, course.code); - const destYear = Number(yearIndex) + Number(planner.startYear); - const destTerm = term as Term; - const destRow = destYear - planner.startYear; - const destIndex = index; - const data = { - destRow, - destTerm, - destIndex, - courseCode: code - }; - setUnplannedCourseToTerm(token, data); - } - }); - }); - }); - setLoading(false); - } catch (err) { - setLoading(false); - // eslint-disable-next-line no-console - console.error('Error at uploadedJSONFile', err); - openNotification({ - type: 'error', - message: 'Invalid JSON format', - description: 'An error occured when parsing the JSON file' - }); - return; - } + const content = ev.target.result; + e.target.value = ''; + try { + const fileInJson = JSON.parse(content as string) as JSON; + const user = importUser(fileInJson); + handleImport(user); + setLoading(false); + } catch (err) { + setLoading(false); + // eslint-disable-next-line no-console + console.error('Error at uploadedJSONFile', err); openNotification({ - type: 'success', - message: 'JSON Imported', - description: 'Planner has been successfully imported.' + type: 'error', + message: 'Invalid JSON format', + description: 'An error occured when parsing the JSON file' }); + return; } + + openNotification({ + type: 'success', + message: 'JSON Imported', + description: 'Planner has been successfully imported.' + }); }; }; diff --git a/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx b/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx index 154b662ef..14eccb873 100644 --- a/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx +++ b/frontend/src/pages/TermPlanner/OptionsHeader/OptionsHeader.tsx @@ -1,7 +1,14 @@ /* eslint-disable import/no-extraneous-dependencies */ import React from 'react'; import { FaRegCalendarTimes } from 'react-icons/fa'; -import { EyeFilled, QuestionCircleOutlined, SettingFilled, WarningFilled } from '@ant-design/icons'; +import { + DownloadOutlined, + EyeFilled, + QuestionCircleOutlined, + SettingFilled, + UploadOutlined, + WarningFilled +} from '@ant-design/icons'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import Tippy from '@tippyjs/react'; import { Popconfirm, Switch, Tooltip } from 'antd'; @@ -9,7 +16,9 @@ import { unscheduleAll } from 'utils/api/plannerApi'; import { getUserPlanner } from 'utils/api/userApi'; import useSettings from 'hooks/useSettings'; import useToken from 'hooks/useToken'; +import ExportPlannerMenu from '../ExportPlannerMenu'; import HelpMenu from '../HelpMenu/HelpMenu'; +import ImportPlannerMenu from '../ImportPlannerMenu'; import SettingsMenu from '../SettingsMenu'; import { isPlannerEmpty } from '../utils'; import S from './styles'; @@ -85,8 +94,8 @@ const OptionsHeader = () => { - {/* } + } moveTransition="transform 0.2s ease-out" interactive trigger="click" @@ -118,7 +127,7 @@ const OptionsHeader = () => { - */} + {planner && !isPlannerEmpty(planner) && ( diff --git a/frontend/src/types/planner.ts b/frontend/src/types/planner.ts index d448959ec..0915416ed 100644 --- a/frontend/src/types/planner.ts +++ b/frontend/src/types/planner.ts @@ -1,6 +1,6 @@ export type Term = 'T0' | 'T1' | 'T2' | 'T3'; -export type Mark = number | Grade | undefined; +export type Mark = number | Grade | null; export type Grade = 'SY' | 'FL' | 'PS' | 'CR' | 'DN' | 'HD'; diff --git a/frontend/src/types/userResponse.ts b/frontend/src/types/userResponse.ts index 7e504b541..dde4bfb7b 100644 --- a/frontend/src/types/userResponse.ts +++ b/frontend/src/types/userResponse.ts @@ -3,9 +3,9 @@ import { Mark } from './planner'; export type UserResponse = { degree: DegreeResponse; - // TODO: NOT STRINGS planner: PlannerResponse; courses: Record; + settings: SettingsResponse; }; export type DegreeResponse = { diff --git a/frontend/src/utils/api/userApi.ts b/frontend/src/utils/api/userApi.ts index f2b6ef772..2e6b1607b 100644 --- a/frontend/src/utils/api/userApi.ts +++ b/frontend/src/utils/api/userApi.ts @@ -6,6 +6,7 @@ import { SettingsResponse, UserResponse } from 'types/userResponse'; +import { UserJson } from 'utils/export'; import { withAuthorization } from './authApi'; export const getUser = async (token: string): Promise => { @@ -13,6 +14,10 @@ export const getUser = async (token: string): Promise => { return user.data as UserResponse; }; +export const importUser = async (token: string, user: UserJson): Promise => { + await axios.put(`user/import`, user, { headers: withAuthorization(token) }); +}; + export const getUserDegree = async (token: string): Promise => { const degree = await axios.get(`user/data/degree`, { headers: withAuthorization(token) }); return degree.data as DegreeResponse; diff --git a/frontend/src/utils/export.ts b/frontend/src/utils/export.ts new file mode 100644 index 000000000..db035fedd --- /dev/null +++ b/frontend/src/utils/export.ts @@ -0,0 +1,82 @@ +/* eslint-disable import/prefer-default-export */ +import { + CourseResponse, + DegreeResponse, + PlannerResponse, + SettingsResponse, + UserResponse +} from 'types/userResponse'; +import { z } from 'zod'; + +// CoursesResponse does NOT match the db, nor is it minimal +type ExportedCourse = Pick; + +export type UserJson = { + settings: SettingsResponse; + degree: DegreeResponse; + planner: PlannerResponse; + courses: Record; +}; + +const settingsSchema = z.strictObject({ + showMarks: z.boolean(), + hiddenYears: z.array(z.number()) +}); + +const degreeSchema = z.strictObject({ + programCode: z.string(), + specs: z.array(z.string()) +}); + +const plannerSchema = z.strictObject({ + unplanned: z.array(z.string()), + startYear: z.number(), + isSummerEnabled: z.boolean(), + lockedTerms: z.record(z.string(), z.boolean()), + years: z.array(z.record(z.string(), z.array(z.string()))) +}); + +const courseSchema = z.strictObject({ + mark: z + .union([z.number().positive().int(), z.enum(['SY', 'FL', 'PS', 'CR', 'DN', 'HD'])]) + .nullable(), + ignoreFromProgression: z.boolean() +}); + +const exportOutputSchema = z.strictObject({ + settings: settingsSchema, + degree: degreeSchema, + planner: plannerSchema, + courses: z.record(z.string(), courseSchema) +}); + +export const exportUser = (user: UserResponse): UserJson => { + const courses: UserJson['courses'] = Object.fromEntries( + Object.entries(user.courses).map(([code, course]) => [ + code, + { + mark: course.mark, + ignoreFromProgression: course.ignoreFromProgression + } + ]) + ); + + return { + settings: user.settings, + degree: user.degree, + planner: user.planner, + courses + }; +}; + +export const importUser = (data: JSON) => { + const parsed = exportOutputSchema.safeParse(data); + if (!parsed.success || parsed.data === undefined) { + parsed.error.errors.forEach((err) => { + console.error(err.message); + }); + throw new Error('Invalid data'); + } + + return parsed.data; +};