Skip to content

Commit

Permalink
feat: schedule list item action menu (#230)
Browse files Browse the repository at this point in the history
* feat: action menu for schedule list item

* feat: schedule action menu functionality

* feat: dialog provider popups for delete

* feat: duplicate schedule satiesfies type

* refactor: change non-null assertion to early return for rename schedule

* refactor: move schedule list item dialog providers to util file

* style: run prettier

* chore: inline object with satisfies operator

* fix: border issues

* style: change popups to match figma

* fix: update import for schedule list item dialog providers

* style: change dropdown text style to match figma

* fix: add back dialog context

* style: rounded edges when hovering over action + soften border color

* chore: cleanup and improve styling

* fix: dialog in popupmain

---------

Co-authored-by: doprz <52579214+doprz@users.noreply.github.com>
Co-authored-by: Razboy20 <razboy20@gmail.com>
  • Loading branch information
3 people authored Oct 5, 2024
1 parent 9ec05ef commit 15fc369
Show file tree
Hide file tree
Showing 10 changed files with 325 additions and 145 deletions.
10 changes: 6 additions & 4 deletions src/pages/background/lib/createSchedule.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { generateRandomId } from '@shared/util/random';

import handleDuplicate from './handleDuplicate';

/**
* Creates a new schedule with the given name
* @param scheduleName the name of the schedule to create
* @returns undefined if successful, otherwise an error message
*/
export default async function createSchedule(scheduleName: string): Promise<string | undefined> {
const schedules = await UserScheduleStore.get('schedules');
// if (schedules.find(schedule => schedule.name === scheduleName)) {
// return `Schedule ${scheduleName} already exists`;
// }

// Duplicate schedule found, we need to append a number to the end of the schedule name
const updatedName = await handleDuplicate(scheduleName);

schedules.push({
id: generateRandomId(),
name: scheduleName,
name: updatedName,
courses: [],
hours: 0,
updatedAt: Date.now(),
Expand Down
6 changes: 5 additions & 1 deletion src/pages/background/lib/deleteSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ export default async function deleteSchedule(scheduleId: string): Promise<string
throw new Error(`Schedule ${scheduleId} does not exist`);
}
if (scheduleIndex === activeIndex) {
throw new Error('You cannot delete your active schedule! Please switch to another schedule before deleting.');
throw new Error(`Cannot delete active schedule`);
}

if (scheduleIndex < activeIndex) {
await UserScheduleStore.set('activeIndex', activeIndex - 1);
}

schedules.splice(scheduleIndex, 1);
Expand Down
31 changes: 31 additions & 0 deletions src/pages/background/lib/duplicateSchedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';
import { generateRandomId } from '@shared/util/random';

import handleDuplicate from './handleDuplicate';

/**
* Creates a new schedule with the given name
* @param scheduleName the name of the schedule to create
* @returns undefined if successful, otherwise an error message
*/
export default async function duplicateSchedule(scheduleId: string): Promise<string | undefined> {
const schedules = await UserScheduleStore.get('schedules');
const schedule = schedules.find(schedule => schedule.id === scheduleId);

if (schedule === undefined) {
throw new Error(`Schedule ${scheduleId} does not exist`);
}

const updatedName = await handleDuplicate(schedule.name);

schedules.push({
id: generateRandomId(),
name: updatedName,
courses: JSON.parse(JSON.stringify(schedule.courses)),
hours: schedule.hours,
updatedAt: Date.now(),
} satisfies typeof schedule);

await UserScheduleStore.set('schedules', schedules);
return undefined;
}
37 changes: 37 additions & 0 deletions src/pages/background/lib/handleDuplicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';

/**
* Duplicates a new schedule with the given name.
* Assumes that each schedule has a unique name.
* @param scheduleName the name of the schedule to handle duplication for
* @param schedules the list of UserSchedules to find existing names
* @returns the new name for the schedule, of the form `{baseName}({index})`
*/
export default async function handleDuplicate(scheduleName: string): Promise<string> {
const schedules = await UserScheduleStore.get('schedules');

// No point in checking for duplicates if the name is unique
if (schedules.find(schedule => schedule.name === scheduleName) === undefined) {
return scheduleName;
}

// Regex for matching `{baseName}({index})`, where match[1] = baseName, match[2] = (index)
const regex = /^(.+?)(\(\d+\))?$/;

// Extract base name and existing index
const match = scheduleName.match(regex);
const baseName = match && match[1] ? match[1] : scheduleName;

// Extract number from parentheses and increment
let index = match && match[2] ? parseInt(match[2].slice(1, -1), 10) + 1 : 1;

let newName: string;

// Increment until an unused index is found
do {
newName = `${baseName} (${index++})`;
// eslint-disable-next-line @typescript-eslint/no-loop-func
} while (schedules.find(schedule => schedule.name === newName));

return newName;
}
30 changes: 25 additions & 5 deletions src/pages/background/lib/renameSchedule.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import { UserScheduleStore } from '@shared/storage/UserScheduleStore';

import handleDuplicate from './handleDuplicate';

/**
* Renames a schedule with the specified name to a new name.
* @param scheduleId - The id of the schedule to be renamed.
* @param newName - The new name for the schedule.
* @returns A promise that resolves to a string if there is an error, or undefined if the schedule is renamed successfully.
* @returns A promise that resolves to the new name if successful, otherwise undefined.
*/
export default async function renameSchedule(scheduleId: string, newName: string): Promise<string | undefined> {
const schedules = await UserScheduleStore.get('schedules');

const scheduleIndex = schedules.findIndex(schedule => schedule.id === scheduleId);
if (scheduleIndex === -1) {
return `Schedule ${scheduleId} does not exist`;
return undefined;
}
const schedule = schedules[scheduleIndex];
if (schedule === undefined) {
return undefined;
}

// if old name is of the form `{baseName}{index}` and newName === baseName, do nothing.
const oldName = schedule.name;
const regex = /^(.+?)(\(\d+\))?$/;
const match = oldName?.match(regex);
const baseName = match?.[1] ?? '';
const baseNameOfNewName = newName.match(regex)?.[1];

if (baseName === baseNameOfNewName) {
return oldName;
}

const updatedName = await handleDuplicate(newName);

schedules[scheduleIndex]!.name = newName;
// schedules[scheduleIndex].updatedAt = Date.now();
schedule.name = updatedName;
schedule.updatedAt = Date.now();

await UserScheduleStore.set('schedules', schedules);
return undefined;
return newName;
}
198 changes: 102 additions & 96 deletions src/views/components/PopupMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import RefreshIcon from '~icons/material-symbols/refresh';
import SettingsIcon from '~icons/material-symbols/settings';

import CourseStatus from './common/CourseStatus';
import DialogProvider from './common/DialogProvider/DialogProvider';
import { SmallLogo } from './common/LogoIcon';
import PopupCourseBlock from './common/PopupCourseBlock';
import ScheduleDropdown from './common/ScheduleDropdown';
Expand Down Expand Up @@ -50,111 +51,116 @@ export default function PopupMain(): JSX.Element {

return (
<ExtensionRoot>
<div className='h-screen max-h-full flex flex-col bg-white'>
<div className='p-5 py-3.5'>
<div className='flex items-center justify-between bg-white'>
<SmallLogo />
<div className='flex items-center gap-2.5'>
<button className='bg-ut-burntorange px-2 py-1.25 btn' onClick={handleCalendarOpenOnClick}>
<CalendarIcon className='size-6 text-white' />
</button>
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
<SettingsIcon className='size-6 color-ut-black' />
</button>
<DialogProvider>
<div className='h-screen max-h-full flex flex-col bg-white'>
<div className='p-5 py-3.5'>
<div className='flex items-center justify-between bg-white'>
<SmallLogo />
<div className='flex items-center gap-2.5'>
<button
className='bg-ut-burntorange px-2 py-1.25 btn'
onClick={handleCalendarOpenOnClick}
>
<CalendarIcon className='size-6 text-white' />
</button>
<button className='bg-transparent px-2 py-1.25 btn' onClick={handleOpenOptions}>
<SettingsIcon className='size-6 color-ut-black' />
</button>
</div>
</div>
</div>
</div>
<Divider orientation='horizontal' size='100%' />
<div className='px-5 pb-2.5 pt-3.75'>
<ScheduleDropdown>
<List
draggables={schedules}
itemKey={schedule => schedule.id}
onReordered={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);
<Divider orientation='horizontal' size='100%' />
<div className='px-5 pb-2.5 pt-3.75'>
<ScheduleDropdown>
<List
draggables={schedules}
itemKey={schedule => schedule.id}
onReordered={reordered => {
const activeSchedule = getActiveSchedule();
const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id);

// don't care about the promise
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
gap={10}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
)}
</List>
</ScheduleDropdown>
</div>
{activeSchedule?.courses?.length === 0 && (
<div className='max-w-64 flex flex-col items-center self-center gap-1.25 px-2 py-2'>
<Text variant='small' className='text-center text-ut-gray !font-normal'>
{funny}
</Text>
<Text variant='small' className='text-center text-black'>
(No courses added)
</Text>
</div>
)}
<div className='flex-1 self-stretch overflow-y-auto px-5'>
{activeSchedule?.courses?.length > 0 && (
<List
draggables={activeSchedule.courses}
onReordered={reordered => {
activeSchedule.courses = reordered;
replaceSchedule(getActiveSchedule(), activeSchedule);
}}
itemKey={e => e.uniqueId}
gap={10}
>
{(course, handleProps) => (
<PopupCourseBlock
key={course.uniqueId}
course={course}
colors={course.colors}
dragHandleProps={handleProps}
/>
)}
</List>
)}
</div>
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
<div className='flex gap-2.5'>
{enableCourseStatusChips && (
<>
<CourseStatus status='WAITLISTED' size='mini' />
<CourseStatus status='CLOSED' size='mini' />
<CourseStatus status='CANCELLED' size='mini' />
</>
)}
// don't care about the promise
UserScheduleStore.set('schedules', reordered);
UserScheduleStore.set('activeIndex', activeIndex);
}}
gap={10}
>
{(schedule, handleProps) => (
<ScheduleListItem
schedule={schedule}
onClick={() => {
switchSchedule(schedule.id);
}}
dragHandleProps={handleProps}
/>
)}
</List>
</ScheduleDropdown>
</div>
{enableCourseRefreshing && (
<div className='inline-flex items-center self-center gap-1'>
<Text variant='mini' className='text-ut-gray !font-normal'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
{activeSchedule?.courses?.length === 0 && (
<div className='max-w-64 flex flex-col items-center self-center gap-1.25 px-2 py-2'>
<Text variant='small' className='text-center text-ut-gray !font-normal'>
{funny}
</Text>
<button
className='h-4 w-4 bg-transparent p-0 btn'
onClick={() => {
setIsRefreshing(true);
<Text variant='small' className='text-center text-black'>
(No courses added)
</Text>
</div>
)}
<div className='flex-1 self-stretch overflow-y-auto px-5'>
{activeSchedule?.courses?.length > 0 && (
<List
draggables={activeSchedule.courses}
onReordered={reordered => {
activeSchedule.courses = reordered;
replaceSchedule(getActiveSchedule(), activeSchedule);
}}
itemKey={e => e.uniqueId}
gap={10}
>
<RefreshIcon
className={clsx('h-4 w-4 text-ut-black animate-duration-800', {
'animate-spin': isRefreshing,
})}
/>
</button>
{(course, handleProps) => (
<PopupCourseBlock
key={course.uniqueId}
course={course}
colors={course.colors}
dragHandleProps={handleProps}
/>
)}
</List>
)}
</div>
<div className='w-full flex flex-col items-center gap-1.25 p-5 pt-3.75'>
<div className='flex gap-2.5'>
{enableCourseStatusChips && (
<>
<CourseStatus status='WAITLISTED' size='mini' />
<CourseStatus status='CLOSED' size='mini' />
<CourseStatus status='CANCELLED' size='mini' />
</>
)}
</div>
)}
{enableCourseRefreshing && (
<div className='inline-flex items-center self-center gap-1'>
<Text variant='mini' className='text-ut-gray !font-normal'>
DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)}
</Text>
<button
className='h-4 w-4 bg-transparent p-0 btn'
onClick={() => {
setIsRefreshing(true);
}}
>
<RefreshIcon
className={clsx('h-4 w-4 text-ut-black animate-duration-800', {
'animate-spin': isRefreshing,
})}
/>
</button>
</div>
)}
</div>
</div>
</div>
</DialogProvider>
</ExtensionRoot>
);
}
4 changes: 2 additions & 2 deletions src/views/components/common/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Eleme
>
<div className={clsx('fixed inset-0 z-50 bg-slate-700/35')} />
</TransitionChild>
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<div className='fixed inset-0 z-50 flex items-center justify-center p-2'>
<TransitionChild
as={Fragment}
enter='transition duration-375 motion-reduce:duration-0 ease-[cubic-bezier(0.05,0.4,0.2,1)]'
Expand All @@ -56,7 +56,7 @@ export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Eleme
>
<DialogPanel
className={clsx(
'z-99 max-h-[90vh] flex flex-col overflow-y-auto border border-solid border-ut-offwhite rounded bg-white shadow-xl ml-[calc(100vw-100%)]',
'z-99 max-h-[90vh] flex flex-col overflow-y-auto border border-solid border-ut-offwhite rounded bg-white shadow-xl ml-[calc(100vw-100%-1rem)]',
className
)}
>
Expand Down
Loading

0 comments on commit 15fc369

Please sign in to comment.