Skip to content

Commit

Permalink
feat: export/import functionality (backup/restore/share with friends)…
Browse files Browse the repository at this point in the history
… + a new input component (#433)

* feat: export schedule function to be added to handler

* feat: use UserScheduleStore and return json

* feat: download functionality

* feat: oh wow we already have a blob download util that is very very nice

* feat: return empty json if none found

* feat: import function completion

* feat: file uploading done

* feat: new input component-stories made-settings input replaced with component

* feat: attempt 1 to hook settings.tsx to importSchedule

* feat: it works horray aka using right Course constructor it works

* chore: fix jsdoc

* chore: comments and debug style

* docs: extra comment

* feat: name of schedule more user friendly

* feat: reworked how schedule is passed and check for file being schedule

* feat: color is kept on import

* fix: add sendResponse to exportSchedule

---------

Co-authored-by: doprz <[email protected]>
  • Loading branch information
DereC4 and doprz authored Nov 21, 2024
1 parent 8b92208 commit 7dbffc6
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 2 deletions.
4 changes: 4 additions & 0 deletions src/pages/background/handler/userScheduleHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import addCourse from '@pages/background/lib/addCourse';
import clearCourses from '@pages/background/lib/clearCourses';
import createSchedule from '@pages/background/lib/createSchedule';
import deleteSchedule from '@pages/background/lib/deleteSchedule';
import exportSchedule from '@pages/background/lib/exportSchedule';
import removeCourse from '@pages/background/lib/removeCourse';
import renameSchedule from '@pages/background/lib/renameSchedule';
import switchSchedule from '@pages/background/lib/switchSchedule';
Expand Down Expand Up @@ -40,6 +41,9 @@ const userScheduleHandler: MessageHandler<UserScheduleMessages> = {
.then(res => (response === 'json' ? res.json() : res.text()))
.then(sendResponse);
},
exportSchedule({ data, sendResponse }) {
exportSchedule(data.scheduleId).then(sendResponse);
},
};

export default userScheduleHandler;
8 changes: 6 additions & 2 deletions src/pages/background/lib/addCourse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import { getUnusedColor } from '@shared/util/colors';

/**
* Adds a course to a user's schedule.
*
* @param scheduleId - The id of the schedule to add the course to.
* @param course - The course to add.
* @param hasColor - If the course block already has colors manually set
* @returns A promise that resolves to void.
* @throws An error if the schedule is not found.
*/
export default async function addCourse(scheduleId: string, course: Course): Promise<void> {
export default async function addCourse(scheduleId: string, course: Course, hasColor = false): Promise<void> {
const schedules = await UserScheduleStore.get('schedules');
const activeSchedule = schedules.find(s => s.id === scheduleId);
if (!activeSchedule) {
throw new Error('Schedule not found');
}

course.colors = getUnusedColor(activeSchedule, course);
if (!hasColor) {
course.colors = getUnusedColor(activeSchedule, course);
}
activeSchedule.courses.push(course);
activeSchedule.updatedAt = Date.now();
await UserScheduleStore.set('schedules', schedules);
Expand Down
24 changes: 24 additions & 0 deletions src/pages/background/lib/exportSchedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';

/**
* Exports the provided schedule to a portable JSON
*
* @param scheduleId - The Id matching the to-be-exported schedule
* @returns JSON format of the provided schedule ID, empty if one was not found
*/
export default async function exportSchedule(scheduleId: string): Promise<string | undefined> {
try {
const storageData = await UserScheduleStore.get('schedules');
const selectedSchedule = storageData.find(s => s.id === scheduleId);

if (!selectedSchedule) {
console.warn(`Schedule ${scheduleId} does not exist`);
return JSON.stringify({});
}

console.log(selectedSchedule);
return JSON.stringify(selectedSchedule, null, 2);
} catch (error) {
console.error('Error getting storage data:', error);
}
}
35 changes: 35 additions & 0 deletions src/pages/background/lib/importSchedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Course } from '@shared/types/Course';
import type { UserSchedule } from '@shared/types/UserSchedule';
import type { Serialized } from 'chrome-extension-toolkit';

import addCourse from './addCourse';
import createSchedule from './createSchedule';
import switchSchedule from './switchSchedule';

function isValidSchedule(data: unknown): data is Serialized<UserSchedule> {
if (typeof data !== 'object' || data === null) return false;
const schedule = data as Record<string, unknown>;
return typeof schedule.id === 'string' && typeof schedule.name === 'string' && Array.isArray(schedule.courses);
}

/**
* Imports a user schedule from portable file, creating a new schedule for it
* @param scheduleData - Data to be parsed back into a course schedule
*/
export default async function importSchedule(scheduleData: unknown): Promise<void> {
if (isValidSchedule(scheduleData)) {
const newScheduleId = await createSchedule(scheduleData.name);
await switchSchedule(newScheduleId);

for (const c of scheduleData.courses) {
const course = new Course(c);
// eslint-disable-next-line no-await-in-loop
await addCourse(newScheduleId, course, true);
console.log(course.colors);
}
console.log('Course schedule successfully parsed!');
} else {
console.error('No schedule data provided for import');
}
}
8 changes: 8 additions & 0 deletions src/shared/messages/UserScheduleMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,12 @@ export interface UserScheduleMessages {
* @returns Undefined if successful, otherwise an error message
*/
renameSchedule: (data: { scheduleId: string; newName: string }) => string | undefined;

/**
* Exports the current schedule to a JSON file for backing up and sharing
*
* @param data - Id of schedule that will be exported
* @returns
*/
exportSchedule: (data: { scheduleId: string }) => string | undefined;
}
109 changes: 109 additions & 0 deletions src/stories/components/InputButton.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { colorsFlattened } from '@shared/util/themeColors';
import type { Meta, StoryObj } from '@storybook/react';
import InputButton from '@views/components/common/InputButton';
import React from 'react';

import AddIcon from '~icons/material-symbols/add';
import CalendarMonthIcon from '~icons/material-symbols/calendar-month';
import DescriptionIcon from '~icons/material-symbols/description';
import ImagePlaceholderIcon from '~icons/material-symbols/image';
import HappyFaceIcon from '~icons/material-symbols/mood';
import ReviewsIcon from '~icons/material-symbols/reviews';

/**
* Stole this straight from Button.stories.tsx to test the input
*/
const meta = {
title: 'Components/Common/InputButton',
component: InputButton,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
args: {
children: 'Upload File',
icon: ImagePlaceholderIcon,
},
argTypes: {
children: { control: 'text' },
color: {
control: 'select',
options: Object.keys(colorsFlattened),
},
variant: {
control: 'select',
options: ['filled', 'outline', 'single'],
},
disabled: {
control: 'boolean',
},
onChange: { action: 'file selected' }, // action to show when file is selected
},
} satisfies Meta<typeof InputButton>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
variant: 'filled',
color: 'ut-black', // Default theme color
},
};

export const Disabled: Story = {
args: {
variant: 'filled',
color: 'ut-black',
disabled: true,
},
};

// @ts-ignore
export const Grid: Story = {
render: props => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
<div style={{ display: 'flex', gap: '15px' }}>
<InputButton {...props} variant='filled' color='ut-black' />
<InputButton {...props} variant='outline' color='ut-black' />
<InputButton {...props} variant='single' color='ut-black' />
</div>

<hr />

<div style={{ display: 'flex', gap: '15px' }}>
<InputButton {...props} variant='filled' color='ut-black' disabled />
<InputButton {...props} variant='outline' color='ut-black' disabled />
<InputButton {...props} variant='single' color='ut-black' disabled />
</div>
</div>
),
};

export const PrettyColors: Story = {
// @ts-ignore
args: {
children: '',
},
render: props => {
const colorsNames = Object.keys(colorsFlattened) as (keyof typeof colorsFlattened)[];

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
{colorsNames.map(color => (
<div style={{ display: 'flex', gap: '15px' }} key={color}>
<InputButton {...props} variant='filled' color={color}>
Button
</InputButton>
<InputButton {...props} variant='outline' color={color}>
Button
</InputButton>
<InputButton {...props} variant='single' color={color}>
Button
</InputButton>
</div>
))}
</div>
);
},
};
74 changes: 74 additions & 0 deletions src/views/components/common/InputButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ThemeColor } from '@shared/types/ThemeColors';
import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors';
import Text from '@views/components/common/Text/Text';
import clsx from 'clsx';
import React from 'react';

import type IconComponent from '~icons/material-symbols';

interface Props {
className?: string;
style?: React.CSSProperties;
variant: 'filled' | 'outline' | 'single';
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
icon?: typeof IconComponent;
disabled?: boolean;
title?: string;
color: ThemeColor;
}

/**
* A reusable input button component that follows the Button.tsx consistency
*
* @returns
*/
export default function InputButton({
className,
style,
variant,
onChange,
icon,
disabled,
title,
color,
children,
}: React.PropsWithChildren<Props>): JSX.Element {
const Icon = icon;
const isIconOnly = !children && !!icon;
const colorHex = getThemeColorHexByName(color);
const colorRgb = getThemeColorRgbByName(color)?.join(' ');

return (
<label
style={
{
...style,
color: disabled ? 'ut-gray' : colorHex,
backgroundColor: `rgb(${colorRgb} / var(--un-bg-opacity)`,
} satisfies React.CSSProperties
}
className={clsx(
'btn',
{
'text-white! bg-opacity-100 hover:enabled:shadow-md active:enabled:shadow-sm shadow-black/20':
variant === 'filled',
'bg-opacity-0 border-current hover:enabled:bg-opacity-8 border': variant === 'outline',
'bg-opacity-0 border-none hover:enabled:bg-opacity-8': variant === 'single', // settings is the only "single"
'px-2 py-1.25': isIconOnly && variant !== 'outline',
'px-1.75 py-1.25': isIconOnly && variant === 'outline',
'px-3.75': variant === 'outline' && !isIconOnly,
},
className
)}
title={title}
>
{Icon && <Icon className='h-6 w-6' />}
{!isIconOnly && (
<Text variant='h4' className='inline-flex translate-y-0.08 items-center gap-2'>
{children}
</Text>
)}
<input type='file' className='hidden' disabled={disabled} onChange={disabled ? undefined : onChange} />
</label>
);
}
Loading

0 comments on commit 7dbffc6

Please sign in to comment.