diff --git a/src/pages/background/lib/createSchedule.ts b/src/pages/background/lib/createSchedule.ts index 52ceff1b..59399311 100644 --- a/src/pages/background/lib/createSchedule.ts +++ b/src/pages/background/lib/createSchedule.ts @@ -1,6 +1,8 @@ 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 @@ -8,13 +10,13 @@ import { generateRandomId } from '@shared/util/random'; */ export default async function createSchedule(scheduleName: string): Promise { 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(), diff --git a/src/pages/background/lib/deleteSchedule.ts b/src/pages/background/lib/deleteSchedule.ts index 2f627fba..949f2352 100644 --- a/src/pages/background/lib/deleteSchedule.ts +++ b/src/pages/background/lib/deleteSchedule.ts @@ -17,7 +17,11 @@ export default async function deleteSchedule(scheduleId: string): Promise { + 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; +} diff --git a/src/pages/background/lib/handleDuplicate.ts b/src/pages/background/lib/handleDuplicate.ts new file mode 100644 index 00000000..63300066 --- /dev/null +++ b/src/pages/background/lib/handleDuplicate.ts @@ -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 { + 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; +} diff --git a/src/pages/background/lib/renameSchedule.ts b/src/pages/background/lib/renameSchedule.ts index 15024cc0..eeac6d6f 100644 --- a/src/pages/background/lib/renameSchedule.ts +++ b/src/pages/background/lib/renameSchedule.ts @@ -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 { 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; } - schedules[scheduleIndex]!.name = newName; - // schedules[scheduleIndex].updatedAt = Date.now(); + // 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); + + schedule.name = updatedName; + schedule.updatedAt = Date.now(); await UserScheduleStore.set('schedules', schedules); - return undefined; + return newName; } diff --git a/src/views/components/PopupMain.tsx b/src/views/components/PopupMain.tsx index e0f9e74e..a929817e 100644 --- a/src/views/components/PopupMain.tsx +++ b/src/views/components/PopupMain.tsx @@ -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'; @@ -50,111 +51,116 @@ export default function PopupMain(): JSX.Element { return ( -
-
-
- -
- - + +
+
+
+ +
+ + +
-
- -
- - schedule.id} - onReordered={reordered => { - const activeSchedule = getActiveSchedule(); - const activeIndex = reordered.findIndex(s => s.id === activeSchedule.id); + +
+ + 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) => ( - { - switchSchedule(schedule.id); - }} - dragHandleProps={handleProps} - /> - )} - - -
- {activeSchedule?.courses?.length === 0 && ( -
- - {funny} - - - (No courses added) - + // don't care about the promise + UserScheduleStore.set('schedules', reordered); + UserScheduleStore.set('activeIndex', activeIndex); + }} + gap={10} + > + {(schedule, handleProps) => ( + { + switchSchedule(schedule.id); + }} + dragHandleProps={handleProps} + /> + )} + +
- )} -
- {activeSchedule?.courses?.length > 0 && ( - { - activeSchedule.courses = reordered; - replaceSchedule(getActiveSchedule(), activeSchedule); - }} - itemKey={e => e.uniqueId} - gap={10} - > - {(course, handleProps) => ( - - )} - + {activeSchedule?.courses?.length === 0 && ( +
+ + {funny} + + + (No courses added) + +
)} -
-
-
- {enableCourseStatusChips && ( - <> - - - - +
+ {activeSchedule?.courses?.length > 0 && ( + { + activeSchedule.courses = reordered; + replaceSchedule(getActiveSchedule(), activeSchedule); + }} + itemKey={e => e.uniqueId} + gap={10} + > + {(course, handleProps) => ( + + )} + )}
- {enableCourseRefreshing && ( -
- - DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)} - - +
+
+ {enableCourseStatusChips && ( + <> + + + + + )}
- )} + {enableCourseRefreshing && ( +
+ + DATA LAST UPDATED: {getUpdatedAtDateTimeString(activeSchedule.updatedAt)} + + +
+ )} +
-
+ ); } diff --git a/src/views/components/common/Dialog.tsx b/src/views/components/common/Dialog.tsx index fcbc39c1..a1e08f62 100644 --- a/src/views/components/common/Dialog.tsx +++ b/src/views/components/common/Dialog.tsx @@ -44,7 +44,7 @@ export default function Dialog(props: PropsWithChildren): JSX.Eleme >
-
+
): JSX.Eleme > diff --git a/src/views/components/common/ExtensionRoot/ExtensionRoot.module.scss b/src/views/components/common/ExtensionRoot/ExtensionRoot.module.scss index 762d7065..fbdc62c3 100644 --- a/src/views/components/common/ExtensionRoot/ExtensionRoot.module.scss +++ b/src/views/components/common/ExtensionRoot/ExtensionRoot.module.scss @@ -11,7 +11,7 @@ } .extensionRoot { - @apply font-sans h-full; + @apply font-sans; color: #303030; [data-rfd-drag-handle-context-id=':r1:'] { diff --git a/src/views/components/common/ExtensionRoot/ExtensionRoot.tsx b/src/views/components/common/ExtensionRoot/ExtensionRoot.tsx index 9e478a9c..18bb5799 100644 --- a/src/views/components/common/ExtensionRoot/ExtensionRoot.tsx +++ b/src/views/components/common/ExtensionRoot/ExtensionRoot.tsx @@ -2,24 +2,27 @@ import 'uno.css'; import clsx from 'clsx'; -import React from 'react'; +import React, { forwardRef } from 'react'; import styles from './ExtensionRoot.module.scss'; -interface Props { - testId?: string; - className?: string; -} +export const styleResetClass = styles.extensionRoot; /** * A wrapper component for the extension elements that adds some basic styling to them */ -export default function ExtensionRoot(props: React.PropsWithChildren): JSX.Element { +export default function ExtensionRoot(props: React.HTMLProps): JSX.Element { + const { className, ...others } = props; + return ( -
- {props.children} -
+
); } + +export const ExtensionRootWrapper = forwardRef>((props, ref) => ( +
+
+
+)); diff --git a/src/views/components/common/ScheduleListItem.tsx b/src/views/components/common/ScheduleListItem.tsx index 02d84c94..e454f545 100644 --- a/src/views/components/common/ScheduleListItem.tsx +++ b/src/views/components/common/ScheduleListItem.tsx @@ -1,4 +1,6 @@ +import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react'; import deleteSchedule from '@pages/background/lib/deleteSchedule'; +import duplicateSchedule from '@pages/background/lib/duplicateSchedule'; import renameSchedule from '@pages/background/lib/renameSchedule'; import type { UserSchedule } from '@shared/types/UserSchedule'; import Text from '@views/components/common/Text/Text'; @@ -6,11 +8,12 @@ import useSchedules from '@views/hooks/useSchedules'; import clsx from 'clsx'; import React, { useEffect, useMemo, useState } from 'react'; -import XIcon from '~icons/material-symbols/close'; import DragIndicatorIcon from '~icons/material-symbols/drag-indicator'; +import MoreActionsIcon from '~icons/material-symbols/more-vert'; import { Button } from './Button'; -import { usePrompt } from './DialogProvider/DialogProvider'; +import DialogProvider, { usePrompt } from './DialogProvider/DialogProvider'; +import { ExtensionRootWrapper, styleResetClass } from './ExtensionRoot/ExtensionRoot'; /** * Props for the ScheduleListItem component. @@ -29,14 +32,12 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }: const [activeSchedule] = useSchedules(); const [isEditing, setIsEditing] = useState(false); const [editorValue, setEditorValue] = useState(schedule.name); - const [error, setError] = useState(undefined); const showDialog = usePrompt(); const editorRef = React.useRef(null); useEffect(() => { const editor = editorRef.current; - setEditorValue(schedule.name); if (isEditing && editor) { @@ -47,35 +48,63 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }: const isActive = useMemo(() => activeSchedule.id === schedule.id, [activeSchedule, schedule]); - const handleBlur = () => { - if (editorValue.trim() !== '') { - schedule.name = editorValue.trim(); - renameSchedule(schedule.id, schedule.name); + const handleBlur = async () => { + if (editorValue.trim() !== '' && editorValue.trim() !== schedule.name) { + schedule.name = (await renameSchedule(schedule.id, editorValue.trim())) as string; } - setIsEditing(false); }; - const onDelete = () => { - deleteSchedule(schedule.id).catch(e => setError(e.message)); - }; - - useEffect(() => { - if (error) { - console.error(error); + const handleDelete = () => { + if (schedule.id === activeSchedule.id) { showDialog({ - title: Something went wrong., - description: error, + title: `Unable to delete active schedule.`, + + description: ( + <> + Deleting the active schedule + {schedule.name} + is not allowed. Please switch to another schedule and try again. + + ), // eslint-disable-next-line react/no-unstable-nested-components buttons: close => ( - ), - onClose: () => setError(undefined), + }); + } else { + showDialog({ + title: `Are you sure?`, + description: ( + <> + Deleting + {schedule.name} + is permanent and will remove all added courses from that schedule. + + ), + // eslint-disable-next-line react/no-unstable-nested-components + buttons: close => ( + <> + + + + ), }); } - }); + }; return (
@@ -85,12 +114,12 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
!isEditing && onClick?.(...e)} >
)}
-
- -
+ + + + + + + + + setIsEditing(true)} + className='w-full rounded bg-transparent p-2 text-left data-[focus]:bg-gray-200/40' + > + Rename + + + + duplicateSchedule(schedule.id)} + className='w-full rounded bg-transparent p-2 text-left data-[focus]:bg-gray-200/40' + > + Duplicate + + + + + Delete + + + + +