diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dae012381..23fc7ed62 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -13,4 +13,5 @@ export * from "./loader"; export * from "./control-link"; export * from "./toast"; export * from "./drag-handle"; -export * from "./drop-indicator"; \ No newline at end of file +export * from "./drop-indicator"; +export * from "./sortable"; diff --git a/packages/ui/src/sortable/draggable.tsx b/packages/ui/src/sortable/draggable.tsx new file mode 100644 index 000000000..7fded837e --- /dev/null +++ b/packages/ui/src/sortable/draggable.tsx @@ -0,0 +1,62 @@ +import React, { useEffect, useRef, useState } from "react"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { isEqual } from "lodash"; +import { cn } from "../../helpers"; +import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { DropIndicator } from "../drop-indicator"; + +type Props = { + children: React.ReactNode; + data: any; //@todo make this generic + className?: string; +}; +const Draggable = ({ children, data, className }: Props) => { + const ref = useRef(null); + const [dragging, setDragging] = useState(false); // NEW + const [isDraggedOver, setIsDraggedOver] = useState(false); + + const [closestEdge, setClosestEdge] = useState(null); + useEffect(() => { + const el = ref.current; + + if (el) { + combine( + draggable({ + element: el, + onDragStart: () => setDragging(true), // NEW + onDrop: () => setDragging(false), // NEW + getInitialData: () => data, + }), + dropTargetForElements({ + element: el, + onDragEnter: (args) => { + setIsDraggedOver(true); + setClosestEdge(extractClosestEdge(args.self.data)); + }, + onDragLeave: () => setIsDraggedOver(false), + onDrop: () => { + setIsDraggedOver(false); + }, + canDrop: ({ source }) => !isEqual(source.data, data) && source.data.__uuid__ === data.__uuid__, + getData: ({ input, element }) => + attachClosestEdge(data, { + input, + element, + allowedEdges: ["top", "bottom"], + }), + }) + ); + } + }, [data]); + + return ( +
+ {} + {children} + {} +
+ ); +}; + +export { Draggable }; diff --git a/packages/ui/src/sortable/index.ts b/packages/ui/src/sortable/index.ts new file mode 100644 index 000000000..9dde5a404 --- /dev/null +++ b/packages/ui/src/sortable/index.ts @@ -0,0 +1,2 @@ +export * from "./sortable"; +export * from "./draggable"; diff --git a/packages/ui/src/sortable/sortable.stories.tsx b/packages/ui/src/sortable/sortable.stories.tsx new file mode 100644 index 000000000..6d40ddc2e --- /dev/null +++ b/packages/ui/src/sortable/sortable.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; +import { Draggable } from "./draggable"; +import { Sortable } from "./sortable"; + +const meta: Meta = { + title: "Sortable", + component: Sortable, +}; + +export default meta; +type Story = StoryObj; + +const data = [ + { id: "1", name: "John Doe" }, + { id: "2", name: "Jane Doe 2" }, + { id: "3", name: "Alice" }, + { id: "4", name: "Bob" }, + { id: "5", name: "Charlie" }, +]; +export const Default: Story = { + args: { + data, + render: (item: any) => ( + // +
{item.name}
+ //
+ ), + onChange: (data) => console.log(data.map(({ id }) => id)), + keyExtractor: (item: any) => item.id, + }, +}; diff --git a/packages/ui/src/sortable/sortable.tsx b/packages/ui/src/sortable/sortable.tsx new file mode 100644 index 000000000..9d79a8f59 --- /dev/null +++ b/packages/ui/src/sortable/sortable.tsx @@ -0,0 +1,79 @@ +import React, { Fragment, useEffect, useMemo } from "react"; +import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { Draggable } from "./draggable"; + +type Props = { + data: T[]; + render: (item: T, index: number) => React.ReactNode; + onChange: (data: T[]) => void; + keyExtractor: (item: T, index: number) => string; + containerClassName?: string; + id: string; +}; + +const moveItem = ( + data: T[], + source: T, + destination: T & Record, + keyExtractor: (item: T, index: number) => string +) => { + const sourceIndex = data.indexOf(source); + if (sourceIndex === -1) return data; + + const destinationIndex = data.findIndex((item, index) => keyExtractor(item, index) === keyExtractor(destination, 0)); + + if (destinationIndex === -1) return data; + + const symbolKey = Reflect.ownKeys(destination).find((key) => key.toString() === "Symbol(closestEdge)"); + const position = symbolKey ? destination[symbolKey as symbol] : "bottom"; // Add 'as symbol' to cast symbolKey to symbol + const newData = [...data]; + const [movedItem] = newData.splice(sourceIndex, 1); + + let adjustedDestinationIndex = destinationIndex; + if (position === "bottom") { + adjustedDestinationIndex++; + } + + // Prevent moving item out of bounds + if (adjustedDestinationIndex > newData.length) { + adjustedDestinationIndex = newData.length; + } + + newData.splice(adjustedDestinationIndex, 0, movedItem); + + return newData; +}; + +export const Sortable = ({ data, render, onChange, keyExtractor, containerClassName, id }: Props) => { + useEffect(() => { + const unsubscribe = monitorForElements({ + onDrop({ source, location }) { + const destination = location?.current?.dropTargets[0]; + if (!destination) return; + onChange(moveItem(data, source.data as T, destination.data as T & { closestEdge: string }, keyExtractor)); + }, + }); + + // Clean up the subscription on unmount + return () => { + if (unsubscribe) unsubscribe(); + }; + }, [data, keyExtractor, onChange]); + + const enhancedData = useMemo(() => { + const uuid = id ? id : Math.random().toString(36).substring(7); + return data.map((item) => ({ ...item, __uuid__: uuid })); + }, [data, id]); + + return ( + <> + {enhancedData.map((item, index) => ( + + {render(item, index)} + + ))} + + ); +}; + +export default Sortable; diff --git a/packages/ui/src/typography/index.tsx b/packages/ui/src/typography/index.tsx new file mode 100644 index 000000000..0b1b7ffe1 --- /dev/null +++ b/packages/ui/src/typography/index.tsx @@ -0,0 +1 @@ +export * from "./sub-heading"; diff --git a/packages/ui/src/typography/sub-heading.tsx b/packages/ui/src/typography/sub-heading.tsx new file mode 100644 index 000000000..9e7075583 --- /dev/null +++ b/packages/ui/src/typography/sub-heading.tsx @@ -0,0 +1,15 @@ +import React from "react"; +import { cn } from "../../helpers"; + +type Props = { + children: React.ReactNode; + className?: string; + noMargin?: boolean; +}; +const SubHeading = ({ children, className, noMargin }: Props) => ( +

+ {children} +

+); + +export { SubHeading }; diff --git a/web/lib/app-provider.tsx b/web/lib/app-provider.tsx index 1044af012..692a43b9f 100644 --- a/web/lib/app-provider.tsx +++ b/web/lib/app-provider.tsx @@ -43,8 +43,6 @@ export const AppProvider: FC = observer((props) => { return ( <> - {/* TODO: Need to handle custom themes for toast */} - @@ -56,6 +54,8 @@ export const AppProvider: FC = observer((props) => { posthogAPIKey={config?.posthog_api_key || undefined} posthogHost={config?.posthog_host || undefined} > + {/* TODO: Need to handle custom themes for toast */} + {children} diff --git a/yarn.lock b/yarn.lock index b807736a8..e1fee6c52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13945,4 +13945,4 @@ yocto-queue@^0.1.0: zxcvbn@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/zxcvbn/-/zxcvbn-4.4.2.tgz#28ec17cf09743edcab056ddd8b1b06262cc73c30" - integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ== + integrity sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ== \ No newline at end of file