Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for user data migration from local storage #1199

Merged
merged 5 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from 2 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
771 changes: 279 additions & 492 deletions frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@rive-app/react-canvas": "4.8.3",
"@tanstack/react-query": "5.29.2",
"@tippyjs/react": "4.2.6",
"antd": "5.20.2",
"antd": "5.21.4",
"axios": "1.7.7",
"dayjs": "1.11.11",
"fast-fuzzy": "1.12.0",
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/components/MigrationModal/MigrationModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/anchor-is-valid */
import React from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Modal } from 'antd';
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 { resetTabs } from 'reducers/courseTabsSlice';
import { resetSettings } from 'reducers/settingsSlice';

type Props = {
open?: boolean;
onOk?: () => void; // runs after resetting the data
onCancel?: () => void;
};

function migrationErrorNotification() {
openNotification({
type: 'error',
message: 'Migration failed',
description:
'An error occurred whilst migrating your data. Either try again, reset your data, or download your data and attempt to import it again/contact DevSoc for help.'
});
}

const MigrationModal = ({ open, onOk, onCancel }: Props) => {
const dispatch = useDispatch();
const token = useToken();
const queryClient = useQueryClient();
const navigate = useNavigate();

const importUserMutation = useMutation({
mutationFn: (user: UserJson) => importUserApi(token, user),
onSuccess: () => {
localStorage.removeItem('oldUser');
queryClient.resetQueries();
navigate('/course-selector');
onOk?.();
},
onError: () => {
migrationErrorNotification();
}
});

const clearOldUser = () => {
localStorage.removeItem('oldUser');
dispatch(resetSettings());
dispatch(resetTabs());
onCancel?.();
};

const handleMigration = async () => {
try {
const user = importUser(JSON.parse(localStorage.getItem('oldUser')!) as JSON);
importUserMutation.mutate(user);
} catch (error) {
migrationErrorNotification();
}
};

const download = () => {
const blob = new Blob([localStorage.getItem('oldUser')!], { 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();
};

return (
<Modal
title="Local Circles Data Detected"
open={open ?? false}
closable={false}
onOk={handleMigration}
okButtonProps={{ type: 'primary' }}
okText="Migrate Old Data"
onCancel={clearOldUser}
cancelText="Clear Old Data"
cancelButtonProps={{ type: 'primary', danger: true }}
mask
maskClosable={false}
keyboard={false}
>
<div>
<p>
As you may have noticed, Circles moved to a login system. We&apos;ve detected an old
planner saved locally, and can attempt to migrate it for you. You can also{' '}
<a onClick={download}>download</a> it for importing later.
</p>
<p>
If you&apos;re signed in as a guest, you may wish to <a href="/logout">logout</a> and sign
in with your zID to save your data in the long term.
</p>
</div>
</Modal>
);
};

export default MigrationModal;
3 changes: 3 additions & 0 deletions frontend/src/components/MigrationModal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import MigrationModal from './MigrationModal';

export default MigrationModal;
55 changes: 54 additions & 1 deletion frontend/src/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import type { MigrationManifest } from 'redux-persist';
import { createMigrate } from 'redux-persist';
import { getCourseInfo } from 'utils/api/coursesApi';
import { importUser } from 'utils/export';

/**
* IMPORTANT NOTE:
Expand Down Expand Up @@ -60,6 +61,58 @@ const persistMigrations: MigrationManifest = {
newState.degree.specs = (newState.degree.specs as string[]).map((spec) =>
spec.includes('-') ? spec.split('-')[1] : spec
);
return newState;
},
5: (oldState) => {
const newState = {};
newState.identity = null;
newState.courseTabs = oldState.courseTabs;
newState.settings = {
theme: 'light',
showLockedCourses: oldState.settings.showLockedCourses,
showPastWarnings: oldState.settings.showWarnings
};

const hiddenYears = Object.keys(oldState.planner.hidden)
.filter((year) => oldState.planner.hidden[year])
.map((year) => parseInt(year, 10) - oldState.planner.startYear);

const courses = Object.fromEntries(
Object.entries(oldState.planner.courses).map(([code, course]) => [
code,
{
mark: course.mark === undefined ? null : course.mark,
ignoreFromProgression: course.ignoreFromProgression
}
])
);

const json = {
settings: {
showMarks: oldState.settings.showMarks,
hiddenYears
},
degree: {
programCode: oldState.degree.programCode,
specs: oldState.degree.specs
},
planner: {
unplanned: oldState.planner.unplanned,
startYear: oldState.planner.startYear,
isSummerEnabled: oldState.planner.isSummerEnabled,
lockedTerms: oldState.planner.completedTerms,
years: oldState.planner.years
},
courses
};

try {
const user = importUser(json);
localStorage.setItem('oldUser', JSON.stringify(user));
} catch {
// Failed to import user
}

return newState;
}
};
Expand All @@ -71,7 +124,7 @@ const persistMigrations: MigrationManifest = {
* and alongside it, provide a migration function to translate the old state structure
* to the new one.
*/
export const persistVersion = 4;
export const persistVersion = 5;

const persistMigrate = createMigrate(persistMigrations);

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/config/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const persistConfig = {
key: 'root',
version: persistVersion,
storage,
whitelist: ['degree', 'courses', 'planner', 'settings'],
whitelist: ['settings'],
migrate: persistMigrate
};

Expand Down
10 changes: 9 additions & 1 deletion frontend/src/pages/DegreeWizard/DegreeWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DegreeWizardPayload } from 'types/degreeWizard';
import { getSpecialisationTypes } from 'utils/api/specsApi';
import { getUserIsSetup, resetUserDegree } from 'utils/api/userApi';
import openNotification from 'utils/openNotification';
import MigrationModal from 'components/MigrationModal';
import PageTemplate from 'components/PageTemplate';
import ResetModal from 'components/ResetModal';
import useToken from 'hooks/useToken';
Expand Down Expand Up @@ -101,11 +102,18 @@ const DegreeWizard = () => {
navigate('/logout');
};

const [migrationNeeded, setMigrationNeeded] = useState(localStorage.getItem('oldUser') !== null);

return (
<PageTemplate showHeader={false} showBugButton={false}>
<S.ContainerWrapper>
<MigrationModal
open={migrationNeeded}
onOk={() => setMigrationNeeded(false)}
onCancel={() => setMigrationNeeded(false)}
/>
<ResetModal
open={isSetup}
open={isSetup && !migrationNeeded}
onCancel={() => navigate('/course-selector')}
onOk={() => resetDegree.mutate()}
/>
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/pages/LandingPage/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React, { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getUserIsSetup } from 'utils/api/userApi';
import { importUser } from 'utils/export';
import PageLoading from 'components/PageLoading';
import { inDev } from 'config/constants';
import useToken from 'hooks/useToken';
Expand All @@ -13,6 +15,23 @@ import KeyFeaturesSection from './KeyFeaturesSection';
import SponsorSection from './SponsorSection';

const LandingPage = () => {
// THIS IS FOR MIGRATION. DELETE WHEN MIGRATION IS DONE
const [searchParams, setSearchParams] = useSearchParams();
const migration = searchParams.get('migration');
if (migration) {
try {
const json = JSON.parse(migration) as JSON;
// Throws error if migration is bad
importUser(json);
localStorage.setItem('oldUser', migration);
} catch {
// eslint-disable-next-line no-console
console.error('Bad migration query param');
}
setSearchParams({});
}
// END OF MIGRATION CODE

// determine our next location
const token = useToken({ allowUnset: true });

Expand Down
4 changes: 3 additions & 1 deletion frontend/src/reducers/settingsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const settingsSlice = createSlice({
name: 'settings',
initialState: initialSettingsState,
reducers: {
resetSettings: () => initialSettingsState,
toggleTheme: (state, action: PayloadAction<Theme>) => {
state.theme = action.payload;
},
Expand All @@ -31,6 +32,7 @@ const settingsSlice = createSlice({
}
});

export const { toggleTheme, toggleLockedCourses, toggleShowPastWarnings } = settingsSlice.actions;
export const { resetSettings, toggleTheme, toggleLockedCourses, toggleShowPastWarnings } =
settingsSlice.actions;

export default settingsSlice.reducer;
8 changes: 4 additions & 4 deletions frontend/src/utils/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,17 @@ export type UserJson = {

const settingsSchema = z.strictObject({
showMarks: z.boolean(),
hiddenYears: z.array(z.number())
hiddenYears: z.array(z.number().int().positive())
});

const degreeSchema = z.strictObject({
programCode: z.string(),
programCode: z.string().length(4),
specs: z.array(z.string())
});

const plannerSchema = z.strictObject({
unplanned: z.array(z.string()),
startYear: z.number(),
startYear: z.number().int().gte(2019),
isSummerEnabled: z.boolean(),
lockedTerms: z.record(z.string(), z.boolean()),
years: z.array(z.record(z.string(), z.array(z.string())))
Expand Down Expand Up @@ -68,7 +68,7 @@ export const exportUser = (user: UserResponse): UserJson => {
};
};

export const importUser = (data: JSON) => {
export const importUser = (data: JSON): UserJson => {
const parsed = exportOutputSchema.safeParse(data);
if (!parsed.success || parsed.data === undefined) {
parsed.error.errors.forEach((err) => {
Expand Down
Loading