From 038ebaa2689c14bc9906afb542ef62c21cb13177 Mon Sep 17 00:00:00 2001 From: Razboy20 Date: Wed, 13 Mar 2024 16:45:32 -0500 Subject: [PATCH] feat: list reordering (#154) --- src/stories/components/Dropdown.stories.tsx | 52 ++++++--- src/stories/components/List.stories.tsx | 12 +- src/views/components/PopupMain.tsx | 49 ++++++-- .../CalendarSchedules/CalendarSchedules.tsx | 41 ++++--- src/views/components/common/List/List.tsx | 110 +++++++++++------- .../ScheduleListItem/ScheduleListItem.tsx | 3 +- src/views/hooks/useSchedules.ts | 18 ++- 7 files changed, 187 insertions(+), 98 deletions(-) diff --git a/src/stories/components/Dropdown.stories.tsx b/src/stories/components/Dropdown.stories.tsx index 212557e0..129f0829 100644 --- a/src/stories/components/Dropdown.stories.tsx +++ b/src/stories/components/Dropdown.stories.tsx @@ -4,9 +4,9 @@ import type { Meta, StoryObj } from '@storybook/react'; import List from '@views/components/common/List/List'; import ScheduleDropdown from '@views/components/common/ScheduleDropdown/ScheduleDropdown'; import ScheduleListItem from '@views/components/common/ScheduleListItem/ScheduleListItem'; -import useSchedules, { switchSchedule } from '@views/hooks/useSchedules'; +import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules'; import type { Serialized } from 'chrome-extension-toolkit'; -import React from 'react'; +import React, { useEffect } from 'react'; import { exampleSchedule } from '../injected/mocked'; @@ -47,27 +47,45 @@ const meta: Meta = { }, }, }, - render: (args: any) => ( -
- - { - const [activeSchedule] = useSchedules(); - return ( + render: (args: any) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [activeSchedule, schedules] = useSchedules(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + console.log(activeSchedule); + }, [activeSchedule]); + + return ( +
+ + a.name === b.name} + onReordered={reordered => { + const activeSchedule = getActiveSchedule(); + const activeIndex = reordered.findIndex(s => s.name === activeSchedule.name); + + // don't care about the promise + UserScheduleStore.set('schedules', reordered); + UserScheduleStore.set('activeIndex', activeIndex); + }} + gap={10} + > + {(schedule, handleProps) => ( { switchSchedule(schedule.name); }} + dragHandleProps={handleProps} /> - ); - })} - gap={10} - /> - -
- ), + )} +
+
+
+ ); + }, } satisfies Meta; export default meta; diff --git a/src/stories/components/List.stories.tsx b/src/stories/components/List.stories.tsx index 8666e5df..1e4593f7 100644 --- a/src/stories/components/List.stories.tsx +++ b/src/stories/components/List.stories.tsx @@ -1,3 +1,4 @@ +import type { DraggableProvidedDragHandleProps } from '@hello-pangea/dnd'; import { Course, Status } from '@shared/types/Course'; import { CourseMeeting } from '@shared/types/CourseMeeting'; import Instructor from '@shared/types/Instructor'; @@ -71,9 +72,10 @@ const generateCourses = (count: number): Course[] => { }; const exampleCourses = generateCourses(numberOfCourses); -const generateCourseBlocks = (exampleCourses: Course[], colors: CourseColors[]) => - exampleCourses.map((course, i) => ); -const ExampleCourseBlocks = generateCourseBlocks(exampleCourses, tailwindColorways); +const generateCourseBlocks = ( + { course, colors }: { course: Course; colors: CourseColors }, + dragHandleProps: DraggableProvidedDragHandleProps +) => ; const meta = { title: 'Components/Common/List', @@ -83,7 +85,6 @@ const meta = { }, tags: ['autodocs'], argTypes: { - draggableElements: { control: 'object' }, gap: { control: 'number' }, }, } satisfies Meta; @@ -93,7 +94,8 @@ type Story = StoryObj; export const Default: Story = { args: { - draggableElements: ExampleCourseBlocks, + draggables: exampleCourses.map((course, i) => ({ course, colors: tailwindColorways[i] })), + children: generateCourseBlocks, gap: 12, }, render: args => ( diff --git a/src/views/components/PopupMain.tsx b/src/views/components/PopupMain.tsx index d1add0ad..3ce3ca29 100644 --- a/src/views/components/PopupMain.tsx +++ b/src/views/components/PopupMain.tsx @@ -1,11 +1,11 @@ +import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import { tailwindColorways } from '@shared/util/storybook'; import Divider from '@views/components/common/Divider/Divider'; import ExtensionRoot from '@views/components/common/ExtensionRoot/ExtensionRoot'; import List from '@views/components/common/List/List'; -import PopupCourseBlock from '@views/components/common/PopupCourseBlock/PopupCourseBlock'; import Text from '@views/components/common/Text/Text'; import { handleOpenCalendar } from '@views/components/injected/CourseCatalogInjectedPopup/HeadingAndActions'; -import useSchedules, { switchSchedule } from '@views/hooks/useSchedules'; +import useSchedules, { getActiveSchedule, replaceSchedule, switchSchedule } from '@views/hooks/useSchedules'; import { openTabFromContentScript } from '@views/lib/openNewTabFromContentScript'; import clsx from 'clsx'; import React, { useState } from 'react'; @@ -16,6 +16,7 @@ import SettingsIcon from '~icons/material-symbols/settings'; import CourseStatus from './common/CourseStatus/CourseStatus'; import { LogoIcon } from './common/LogoIcon'; +import PopupCourseBlock from './common/PopupCourseBlock/PopupCourseBlock'; import ScheduleDropdown from './common/ScheduleDropdown/ScheduleDropdown'; import ScheduleListItem from './common/ScheduleListItem/ScheduleListItem'; @@ -61,27 +62,53 @@ export default function PopupMain(): JSX.Element {
( + draggables={schedules} + equalityCheck={(a, b) => a.name === b.name} + onReordered={reordered => { + const activeSchedule = getActiveSchedule(); + const activeIndex = reordered.findIndex(s => s.name === activeSchedule.name); + + // don't care about the promise + UserScheduleStore.set('schedules', reordered); + UserScheduleStore.set('activeIndex', activeIndex); + }} + gap={10} + > + {(schedule, handleProps) => ( { switchSchedule(schedule.name); }} + dragHandleProps={handleProps} /> - ))} - gap={10} - /> + )} +
{activeSchedule?.courses?.length > 0 && ( ( - - ))} + draggables={activeSchedule.courses.map((course, i) => ({ + course, + colors: tailwindColorways[i], + }))} + onReordered={reordered => { + activeSchedule.courses = reordered.map(c => c.course); + replaceSchedule(getActiveSchedule(), activeSchedule); + }} + equalityCheck={(a, b) => a.course.uniqueId === b.course.uniqueId} gap={10} - /> + > + {({ course, colors }, handleProps) => ( + + )} + )}
diff --git a/src/views/components/calendar/CalendarSchedules/CalendarSchedules.tsx b/src/views/components/calendar/CalendarSchedules/CalendarSchedules.tsx index 38f7bd81..236aa049 100644 --- a/src/views/components/calendar/CalendarSchedules/CalendarSchedules.tsx +++ b/src/views/components/calendar/CalendarSchedules/CalendarSchedules.tsx @@ -1,9 +1,10 @@ import { background } from '@shared/messages'; +import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import type { UserSchedule } from '@shared/types/UserSchedule'; import List from '@views/components/common/List/List'; import ScheduleListItem from '@views/components/common/ScheduleListItem/ScheduleListItem'; import Text from '@views/components/common/Text/Text'; -import useSchedules from '@views/hooks/useSchedules'; +import useSchedules, { getActiveSchedule, switchSchedule } from '@views/hooks/useSchedules'; import React, { useEffect, useState } from 'react'; import AddSchedule from '~icons/material-symbols/add'; @@ -47,20 +48,6 @@ export function CalendarSchedules({ style, dummySchedules, dummyActiveIndex }: P setNewSchedule(e.target.value); }; - const selectItem = (index: number) => { - background.switchSchedule({ scheduleName: schedules[index].name }).then(() => { - setActiveScheduleIndex(index); - }); - }; - - const scheduleComponents = schedules.map((schedule, index) => ( - selectItem(index)} - /> - )); - const fixBuildError = { dummySchedules, dummyActiveIndex, @@ -78,7 +65,29 @@ export function CalendarSchedules({ style, dummySchedules, dummyActiveIndex }: P
- + a.name === b.name} + onReordered={reordered => { + const activeSchedule = getActiveSchedule(); + const activeIndex = reordered.findIndex(s => s.name === activeSchedule.name); + + // don't care about the promise + UserScheduleStore.set('schedules', reordered); + UserScheduleStore.set('activeIndex', activeIndex); + }} + > + {(schedule, handleProps) => ( + { + switchSchedule(schedule.name); + }} + dragHandleProps={handleProps} + /> + )} + { + draggables: T[]; + children: (draggable: T, handleProps: DraggableProvidedDragHandleProps) => ReactElement; + onReordered: (elements: T[]) => void; + equalityCheck?: (a: T, b: T) => boolean; + gap?: number; // Impacts the spacing between items in the list } -function initial(count: number, draggableElements: any[] = []) { +function wrap(draggableElements: T[]) { return draggableElements.map((element, index) => ({ - id: `id:${index + count}`, - content: element as ReactElement, + id: `id:${index}`, + content: element, })); } -function reorder(list: { id: string; content: ReactElement }[], startIndex: number, endIndex: number) { - const result = Array.from(list); - const [removed] = result.splice(startIndex, 1); - result.splice(endIndex, 0, removed); - return result; +function reorder(list: T[], startIndex: number, endIndex: number) { + const listCopy = [...list]; + + const [removed] = listCopy.splice(startIndex, 1); + listCopy.splice(endIndex, 0, removed); + return listCopy; } -function getStyle({ provided, style /* , isDragging, gap */ }) { +function getStyle(provided: DraggableProvided, style: React.CSSProperties) { const combined = { ...style, ...provided.draggableProps.style, @@ -38,15 +42,21 @@ function getStyle({ provided, style /* , isDragging, gap */ }) { return combined; } -function Item({ provided, item, style, isDragging /* , gap */ }) { +function Item(props: { + provided: DraggableProvided; + style: React.CSSProperties; + item: T; + isDragging: boolean; + children: React.ReactElement; +}) { return (
- {React.cloneElement(item.content, { dragHandleProps: provided.dragHandleProps })} + {props.children}
); } @@ -57,30 +67,34 @@ function Item({ provided, item, style, isDragging /* , gap */ }) { * @example * */ -export default function List({ draggableElements, gap = 12 }: ListProps): JSX.Element { - const [items, setItems] = useState(() => initial(0, draggableElements)); +function List(props: ListProps): JSX.Element { + const [items, setItems] = useState(wrap(props.draggables)); + + const equalityCheck = props.equalityCheck || ((a, b) => a === b); + const transformFunction = props.children; useEffect(() => { - setItems(prevItems => { - const prevItemIds = prevItems.map(item => item.id); - const newElements = draggableElements.filter((_, index) => !prevItemIds.includes(`id:${index}`)); - const newItems = initial(prevItems.length, newElements); - return [...prevItems, ...newItems]; - }); - }, [draggableElements]); - const onDragEnd = useCallback( + // check if the draggables content has *actually* changed + if (props.draggables.every((element, index) => equalityCheck(element, items[index].content))) { + console.log("List's draggables have not changed"); + return; + } + + console.log("List's draggables have changed, updating..."); + + setItems(wrap(props.draggables)); + }, [props.draggables]); + + const onDragEnd: OnDragEndResponder = useCallback( result => { - if (!result.destination) { - return; - } + if (!result.destination) return; + if (result.source.index === result.destination.index) return; - if (result.source.index === result.destination.index) { - return; - } + // will reorder in place + const reordered = reorder(items, result.source.index, result.destination.index); - const newItems = reorder(items, result.source.index, result.destination.index); - - setItems(newItems satisfies { id: string; content: React.ReactElement }[]); + setItems(reordered); + props.onReordered(reordered.map(item => item.content)); }, [items] ); @@ -107,14 +121,20 @@ export default function List({ draggableElements, gap = 12 }: ListProps): JSX.El isDragging={snapshot.isDragging} item={items[rubric.source.index]} style={{ - style, + ...style, }} - /> + > + {transformFunction(items[rubric.source.index].content, provided.dragHandleProps)} + ); }} > {(provided, snapshot) => ( -
+
{items.map((item, index) => ( {draggableProvided => ( @@ -124,12 +144,10 @@ export default function List({ draggableElements, gap = 12 }: ListProps): JSX.El style={{ ...draggableProvided.draggableProps.style, // if last item, don't add margin - marginBottom: `${gap}px`, + marginBottom: `${props.gap}px`, }} > - {React.cloneElement(item.content, { - dragHandleProps: draggableProvided.dragHandleProps, - })} + {transformFunction(item.content, draggableProvided.dragHandleProps)}
)} @@ -142,3 +160,5 @@ export default function List({ draggableElements, gap = 12 }: ListProps): JSX.El
); } + +export default React.memo(List) as typeof List; diff --git a/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx b/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx index ad5733a2..6c69a950 100644 --- a/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx +++ b/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx @@ -10,7 +10,6 @@ import DragIndicatorIcon from '~icons/material-symbols/drag-indicator'; */ export type Props = { style?: React.CSSProperties; - active?: boolean; name: string; dragHandleProps?: Omit, 'className'>; onClick?: React.DOMAttributes['onClick']; @@ -22,7 +21,7 @@ export type Props = { export default function ScheduleListItem({ style, name, dragHandleProps, onClick }: Props): JSX.Element { const [activeSchedule] = useSchedules(); - const isActive = useMemo(() => activeSchedule?.name === name, [activeSchedule, name]); + const isActive = useMemo(() => activeSchedule.name === name, [activeSchedule, name]); return (
diff --git a/src/views/hooks/useSchedules.ts b/src/views/hooks/useSchedules.ts index b215e162..61d874e6 100644 --- a/src/views/hooks/useSchedules.ts +++ b/src/views/hooks/useSchedules.ts @@ -39,10 +39,12 @@ export default function useSchedules(): [active: UserSchedule | null, schedules: useEffect(() => { const l1 = UserScheduleStore.listen('schedules', ({ newValue }) => { - setSchedules(newValue.map(s => new UserSchedule(s))); + schedulesCache = newValue.map(s => new UserSchedule(s)); + setSchedules(schedulesCache); }); const l2 = UserScheduleStore.listen('activeIndex', ({ newValue }) => { + activeIndexCache = newValue; setActiveIndex(newValue); }); @@ -55,11 +57,23 @@ export default function useSchedules(): [active: UserSchedule | null, schedules: // recompute active schedule on a schedule/index change useEffect(() => { setActiveSchedule(schedules[activeIndex]); - }); + }, [activeIndex, schedules]); return [activeSchedule, schedules]; } +export function getActiveSchedule(): UserSchedule | null { + return schedulesCache[activeIndexCache] || null; +} + +export async function replaceSchedule(oldSchedule: UserSchedule, newSchedule: UserSchedule) { + const schedules = await UserScheduleStore.get('schedules'); + const oldIndex = schedules.findIndex(s => s.name === oldSchedule.name); + schedules[oldIndex] = newSchedule; + await UserScheduleStore.set('schedules', schedules); + console.log('schedule replaced'); +} + /** * Switches the active schedule to the one with the specified name. * @param name - The name of the schedule to switch to.