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: schedule list item action menu #230

Merged
merged 17 commits into from
Oct 5, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"prepare": "husky"
},
"dependencies": {
"@headlessui-float/react": "^0.15.0",
"@headlessui/react": "^2.0.3",
"@hello-pangea/dnd": "^16.5.0",
"@unocss/vite": "^0.58.6",
Expand Down
571 changes: 290 additions & 281 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

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('Cannot delete active schedule');
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(scheduleName: string): Promise<string | undefined> {
const schedules = await UserScheduleStore.get('schedules');
const schedule = schedules.find(schedule => schedule.name === scheduleName);

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

const updatedName = await handleDuplicate(scheduleName);

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 = `${baseName}(${index})`;

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

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;
}
caseycharleston marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// ScheduleActions.tsx
import { MenuItem } from '@headlessui/react';
import deleteSchedule from '@pages/background/lib/deleteSchedule';
import type { UserSchedule } from '@shared/types/UserSchedule';
import Text from '@views/components/common/Text/Text';
import React from 'react';

import { Button } from '../Button';
import { usePrompt } from './DialogProvider';

caseycharleston marked this conversation as resolved.
Show resolved Hide resolved
/**
* ConfirmDelete Component
* Displays a confirmation dialog before deleting a schedule.
* This is a reusable utility component.
*/
export const ConfirmDelete = ({ schedule }: { schedule: UserSchedule }) => {
const showDialog = usePrompt();

const handleDelete = () => {
showDialog({
title: `Are you sure you want to delete ${schedule.name}?`,
description: `Deleting "${schedule.name}" will remove it from your schedules list permanently.`,
// eslint-disable-next-line react/no-unstable-nested-components
buttons: close => (
<>
<Button variant='outline' color='ut-red' onClick={() => deleteSchedule(schedule.id)}>
Yes
</Button>
<Button variant='outline' color='ut-burntorange' onClick={close}>
No
</Button>
</>
),
caseycharleston marked this conversation as resolved.
Show resolved Hide resolved
});
};
caseycharleston marked this conversation as resolved.
Show resolved Hide resolved

return (
<MenuItem as='div' onClick={handleDelete}>
{({ focus }) => (
<Text className={`block px-4 py-1 ${focus ? 'bg-gray-100 text-red-600' : 'text-red-600'}`}>Delete</Text>
)}
</MenuItem>
);
};

/**
* DeleteActiveScheduleError Component
* Displays an error dialog if the user tries to delete the active schedule.
* This is a reusable utility component.
*/
export const DeleteActiveScheduleError = ({ schedule }: { schedule: UserSchedule }) => {
const showDialog = usePrompt();

const showError = () => {
showDialog({
title: `Unable to delete active schedule.`,
description: `Deleting active schedule "${schedule.name}" is not allowed. If possible, switch to another schedule and try again.`,
// eslint-disable-next-line react/no-unstable-nested-components
buttons: close => (
<Button variant='outline' color='ut-burntorange' onClick={close}>
Close
</Button>
),
});
};

return (
<MenuItem as='div' onClick={showError}>
{({ focus }) => (
<Text className={`block px-4 py-1 ${focus ? 'bg-gray-100 text-red-600' : 'text-red-600'}`}>Delete</Text>
)}
</MenuItem>
);
};
62 changes: 50 additions & 12 deletions src/views/components/common/ScheduleListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import deleteSchedule from '@pages/background/lib/deleteSchedule';
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { Float } from '@headlessui-float/react';
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';
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 DialogProvider from './DialogProvider/DialogProvider';
import { ConfirmDelete, DeleteActiveScheduleError } from './DialogProvider/ScheduleListItemDialogProviders';

/**
* Props for the ScheduleListItem component.
Expand All @@ -30,7 +35,6 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
const editorRef = React.useRef<HTMLInputElement>(null);
useEffect(() => {
const editor = editorRef.current;

setEditorValue(schedule.name);

if (isEditing && editor) {
Expand All @@ -41,12 +45,10 @@ 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;
}
caseycharleston marked this conversation as resolved.
Show resolved Hide resolved

setIsEditing(false);
};

Expand Down Expand Up @@ -93,10 +95,46 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }:
)}
</div>
<div>
<XIcon
className='invisible h-5 w-5 text-ut-red group-hover:visible'
onClick={() => deleteSchedule(schedule.id)}
/>
<DialogProvider>
<Menu>
<Float
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
placement='bottom-end'
portal
>
<MenuButton className='bg-white'>
<MoreActionsIcon className='invisible h-5 w-5 cursor-pointer rounded text-blueGray btn-transition group-hover:visible group-hover:border-blueGray group-hover:bg-blueGray group-hover:bg-opacity-25 focusable' />
</MenuButton>

<MenuItems className='w-30 cursor-pointer rounded bg-white py-1 text-black shadow-lg'>
<MenuItem as='div' onClick={() => setIsEditing(true)}>
{({ focus }) => (
<Text className={`block px-4 py-1 ${focus ? 'bg-gray-100' : ''}`}>
Rename
</Text>
)}
</MenuItem>
<MenuItem as='div' onClick={() => duplicateSchedule(schedule.name)}>
{({ focus }) => (
<Text className={`block px-4 py-1 ${focus ? 'bg-gray-100' : ''}`}>
Duplicate
</Text>
)}
</MenuItem>
{schedule.id === activeSchedule.id ? (
<DeleteActiveScheduleError schedule={schedule} />
) : (
<ConfirmDelete schedule={schedule} />
)}
</MenuItems>
</Float>
</Menu>
</DialogProvider>
</div>
</div>
</li>
Expand Down
Loading