diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx index 815311dacd3..67bd4c68c79 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/DashboardSidebar.tsx @@ -1,34 +1,188 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + DragOverlay, + KeyboardSensor, + MeasuringStrategy, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; import { DashboardSidebarHeader } from "./components/DashboardSidebarHeader"; import { DashboardSidebarProjectSection } from "./components/DashboardSidebarProjectSection"; import { useDashboardSidebarData } from "./hooks/useDashboardSidebarData"; import { useDashboardSidebarShortcuts } from "./hooks/useDashboardSidebarShortcuts"; +import type { DashboardSidebarProject } from "./types"; interface DashboardSidebarProps { isCollapsed?: boolean; } +function SortableProjectWrapper({ + project, + isCollapsed, + isDraggingProject, + workspaceShortcutLabels, + onWorkspaceHover, + onToggleCollapse, +}: { + project: DashboardSidebarProject; + isCollapsed: boolean; + isDraggingProject: boolean; + workspaceShortcutLabels: Map; + onWorkspaceHover: (workspaceId: string) => void | Promise; + onToggleCollapse: (projectId: string) => void; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: project.id }); + + return ( +
+ +
+ ); +} + export function DashboardSidebar({ isCollapsed = false, }: DashboardSidebarProps) { const { groups, refreshWorkspacePullRequest, toggleProjectCollapsed } = useDashboardSidebarData(); const workspaceShortcutLabels = useDashboardSidebarShortcuts(groups); + const { reorderProjects } = useDashboardSidebarState(); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [activeProject, setActiveProject] = + useState(null); + + // Local project order — syncs from groups, updated on drag end + const [projectOrder, setProjectOrder] = useState(() => + groups.map((p) => p.id), + ); + useEffect(() => { + setProjectOrder(groups.map((p) => p.id)); + }, [groups]); + + const orderedGroups = useMemo(() => { + const byId = new Map(groups.map((g) => [g.id, g])); + return projectOrder + .map((id) => byId.get(id)) + .filter((g): g is DashboardSidebarProject => g != null); + }, [groups, projectOrder]); + + const handleDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + if (over && active.id !== over.id) { + const oldIndex = projectOrder.indexOf(String(active.id)); + const newIndex = projectOrder.indexOf(String(over.id)); + if (oldIndex !== -1 && newIndex !== -1) { + const reordered = arrayMove(projectOrder, oldIndex, newIndex); + setProjectOrder(reordered); + reorderProjects(reordered); + } + } + setActiveProject(null); + }, + [projectOrder, reorderProjects], + ); return (
- {groups.map((project) => ( - - ))} + { + const project = groups.find((p) => p.id === active.id); + setActiveProject(project ?? null); + }} + onDragEnd={handleDragEnd} + onDragCancel={() => setActiveProject(null)} + > + + {orderedGroups.map((project) => ( + + ))} + + + {createPortal( + + {activeProject && ( +
+ {}} + onToggleCollapse={() => {}} + /> +
+ )} +
, + document.body, + )} +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx index 13fc81d25a2..35f44b3c0a8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/DashboardSidebarProjectSection.tsx @@ -1,10 +1,12 @@ +import type { + DraggableAttributes, + DraggableSyntheticListeners, +} from "@dnd-kit/core"; import { cn } from "@superset/ui/utils"; +import { AnimatePresence, motion } from "framer-motion"; import { useMemo } from "react"; import type { DashboardSidebarProject } from "../../types"; -import { - getProjectChildrenSections, - getProjectChildrenWorkspaces, -} from "../../utils/projectChildren"; +import { getProjectChildrenWorkspaces } from "../../utils/projectChildren"; import { DashboardSidebarCollapsedProjectContent } from "./components/DashboardSidebarCollapsedProjectContent"; import { DashboardSidebarExpandedProjectContent } from "./components/DashboardSidebarExpandedProjectContent"; import { DashboardSidebarProjectContextMenu } from "./components/DashboardSidebarProjectContextMenu"; @@ -14,23 +16,24 @@ import { useDashboardSidebarProjectSectionActions } from "./hooks/useDashboardSi interface DashboardSidebarProjectSectionProps { project: DashboardSidebarProject; isSidebarCollapsed?: boolean; + isDraggingProject?: boolean; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onToggleCollapse: (projectId: string) => void; + dragHandleListeners?: DraggableSyntheticListeners; + dragHandleAttributes?: DraggableAttributes; } export function DashboardSidebarProjectSection({ project, isSidebarCollapsed = false, + isDraggingProject = false, workspaceShortcutLabels, onWorkspaceHover, onToggleCollapse, + dragHandleListeners, + dragHandleAttributes, }: DashboardSidebarProjectSectionProps) { - const allSections = useMemo( - () => getProjectChildrenSections(project.children), - [project.children], - ); - const flattenedCollapsedWorkspaces = useMemo( () => getProjectChildrenWorkspaces(project.children), [project.children], @@ -104,19 +107,33 @@ export function DashboardSidebarProjectSection({ onStartRename={startRename} onToggleCollapse={() => onToggleCollapse(project.id)} onNewWorkspace={handleNewWorkspace} + {...(dragHandleAttributes ?? {})} + {...(dragHandleListeners ?? {})} /> - + + {!isDraggingProject && ( + + + + )} + ); } diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx index 6e8365dbbd7..dbd7a960c72 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarProjectSection/components/DashboardSidebarExpandedProjectContent/DashboardSidebarExpandedProjectContent.tsx @@ -1,12 +1,21 @@ +import { DndContext, DragOverlay } from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { AnimatePresence, motion } from "framer-motion"; +import { createPortal } from "react-dom"; +import { useSidebarDnd } from "../../../../hooks/useSidebarDnd"; +import { parseId } from "../../../../hooks/useSidebarDnd/useSidebarDnd"; import type { DashboardSidebarProjectChild } from "../../../../types"; -import { DashboardSidebarSection as DashboardSidebarSectionComponent } from "../../../DashboardSidebarSection"; -import { DashboardSidebarWorkspaceItem } from "../../../DashboardSidebarWorkspaceItem"; +import { SidebarDragOverlay } from "../../../SidebarDragOverlay"; +import { SortableSectionHeader } from "../../../SortableSectionHeader"; +import { SortableWorkspaceItem } from "../../../SortableWorkspaceItem"; interface DashboardSidebarExpandedProjectContentProps { + projectId: string; isCollapsed: boolean; projectChildren: DashboardSidebarProjectChild[]; - allSections: Array<{ id: string; name: string }>; workspaceShortcutLabels: Map; onWorkspaceHover: (workspaceId: string) => void | Promise; onDeleteSection: (sectionId: string) => void; @@ -15,15 +24,32 @@ interface DashboardSidebarExpandedProjectContentProps { } export function DashboardSidebarExpandedProjectContent({ + projectId, isCollapsed, projectChildren, - allSections, workspaceShortcutLabels, onWorkspaceHover, onDeleteSection, onRenameSection, onToggleSectionCollapse, }: DashboardSidebarExpandedProjectContentProps) { + const { + sensors, + measuring, + collisionDetection, + flatItems, + sortableItems, + activeId, + activeType, + activeItem, + predictedColor, + groupInfo, + collapsedSectionIds, + workspacesById, + sectionsById, + handlers, + } = useSidebarDnd({ projectId, projectChildren }); + return ( {!isCollapsed && ( @@ -35,30 +61,84 @@ export function DashboardSidebarExpandedProjectContent({ className="overflow-hidden" >
- {projectChildren.map((child) => - child.type === "workspace" ? ( - onWorkspaceHover(child.workspace.id)} - shortcutLabel={workspaceShortcutLabels.get( - child.workspace.id, - )} - /> - ) : ( - - ), - )} + + + {flatItems.map((id) => { + const parsed = parseId(id); + if (!parsed) return null; + + if (parsed.type === "section") { + const section = sectionsById.get(parsed.realId); + if (!section) return null; + return ( + + ); + } + + const workspace = workspacesById.get(parsed.realId); + if (!workspace) return null; + const group = groupInfo.get(parsed.realId); + const isInSection = !!group; + const isInCollapsedSection = + isInSection && collapsedSectionIds.has(group.sectionId); + const hidden = + isInCollapsedSection || + (activeType === "section" && isInSection); + + return ( + + {!hidden && ( + + + onWorkspaceHover(parsed.realId) + } + shortcutLabel={workspaceShortcutLabels.get( + parsed.realId, + )} + /> + + )} + + ); + })} + + + {createPortal( + + {activeId ? ( + + ) : null} + , + document.body, + )} +
)} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx index 49dd48bbcde..91584d872f5 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarSection/components/DashboardSidebarSectionHeader/DashboardSidebarSectionHeader.tsx @@ -1,7 +1,7 @@ import { cn } from "@superset/ui/utils"; import { type ComponentPropsWithoutRef, forwardRef } from "react"; import { HiChevronRight } from "react-icons/hi2"; -import { LuPencil } from "react-icons/lu"; +import { LuGripVertical, LuPencil } from "react-icons/lu"; import { RenameInput } from "renderer/screens/main/components/WorkspaceSidebar/RenameInput"; import type { DashboardSidebarSection } from "../../../../types"; @@ -54,12 +54,16 @@ export const DashboardSidebarSectionHeader = forwardRef< } } className={cn( - "group flex min-h-8 w-full items-center pl-2 pr-2 py-1.5 text-[11px] font-medium", + "group flex min-h-8 w-full items-center pl-0.5 pr-2 py-1.5 text-[11px] font-medium", "text-muted-foreground hover:bg-muted/50 transition-colors", className, )} {...props} > +
+ +
+
{isRenaming ? ( void; shortcutLabel?: string; isCollapsed?: boolean; + isInSection?: boolean; } export function DashboardSidebarWorkspaceItem({ @@ -19,6 +20,7 @@ export function DashboardSidebarWorkspaceItem({ onHoverCardOpen, shortcutLabel, isCollapsed = false, + isInSection = false, }: DashboardSidebarWorkspaceItemProps) { const { id, @@ -88,6 +90,7 @@ export function DashboardSidebarWorkspaceItem({ ) : ( (null); + + useEffect(() => { + if (isActive) { + localRef.current?.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [isActive]); const creationStatusText = useMemo( () => getCreationStatusText(creationStatus), @@ -73,7 +87,11 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} aria-disabled={creationStatus ? true : undefined} - ref={ref} + ref={(node) => { + localRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + }} onClick={onClick} onKeyDown={(event) => { if (onClick && (event.key === "Enter" || event.key === " ")) { @@ -85,8 +103,8 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef< className={cn( "relative flex w-full items-center pl-3 pr-2 text-left text-sm", onClick && "cursor-pointer hover:bg-muted/50", - "transition-colors group", - showSubtitle ? "py-1.5" : "py-2", + "group", + "py-1.5", isActive && "bg-muted", className, )} @@ -120,160 +138,85 @@ export const DashboardSidebarExpandedWorkspaceRow = forwardRef<
- {showSubtitle ? ( -
- {isRenaming ? ( - - ) : ( - - {name || branch} - - )} - -
- {creationStatusText ? ( - - {creationStatusText} - - ) : ( - <> - -
- {shortcutLabel && ( - - {shortcutLabel} - - )} - - - - - - - - -
- +
+ {isRenaming ? ( + + ) : ( + + > + {name || branch} + + )} - {showBranchSubtitle && ( - - {branch} +
+ {creationStatusText ? ( + + {creationStatusText} - )} - - {pullRequest && ( - - )} -
- ) : ( -
- {isRenaming ? ( - ) : ( - - {name || branch} - + <> + +
+ {shortcutLabel && ( + + {shortcutLabel} + + )} + + + + + + + + +
+ )} - -
- {creationStatusText ? ( - - {creationStatusText} - - ) : ( - <> - -
- {shortcutLabel && ( - - {shortcutLabel} - - )} - - - - - - - - -
- - )} -
- )} + + + {branch} + + + {pullRequest && ( + + )} +
); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx index 46c1cb9cdf9..18872835fa0 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/DashboardSidebarWorkspaceItem/components/DashboardSidebarWorkspaceContextMenu/DashboardSidebarWorkspaceContextMenu.tsx @@ -18,10 +18,10 @@ import { useLiveQuery } from "@tanstack/react-db"; import { useState } from "react"; import { LuArrowRightLeft, + LuArrowUp, LuCopy, LuFolderOpen, LuFolderPlus, - LuMinus, LuPencil, LuTrash2, LuX, @@ -31,6 +31,7 @@ import { useCollections } from "renderer/routes/_authenticated/providers/Collect interface DashboardSidebarWorkspaceContextMenuProps { hoverCardContent?: React.ReactNode; projectId: string; + isInSection?: boolean; onHoverCardOpen?: () => void; onCreateSection: () => void; onMoveToSection: (sectionId: string | null) => void; @@ -44,6 +45,7 @@ interface DashboardSidebarWorkspaceContextMenuProps { export function DashboardSidebarWorkspaceContextMenu({ projectId, + isInSection, onHoverCardOpen, hoverCardContent, onCreateSection, @@ -68,6 +70,7 @@ export function DashboardSidebarWorkspaceContextMenu({ .select(({ sidebarSections }) => ({ id: sidebarSections.sectionId, name: sidebarSections.name, + color: sidebarSections.color, })), [collections, projectId], ); @@ -98,20 +101,29 @@ export function DashboardSidebarWorkspaceContextMenu({ New Section - onMoveToSection(null)}> - - Ungrouped - + {sections.length > 0 && } {sections.map((section) => ( onMoveToSection(section.id)} > + {section.color && ( + + )} {section.name} ))} + {isInSection && ( + onMoveToSection(null)}> + + Remove from Group + + )} + +
+ ); + } + + const { section } = activeItem; + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + return ( +
+
+
+ +
+ {section.name} + + ({section.workspaces.length}) + +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts new file mode 100644 index 00000000000..58e16f18656 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SidebarDragOverlay/index.ts @@ -0,0 +1 @@ +export { SidebarDragOverlay } from "./SidebarDragOverlay"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx new file mode 100644 index 00000000000..ffaf10713cb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/SortableSectionHeader.tsx @@ -0,0 +1,86 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useState } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import { PROJECT_COLOR_DEFAULT } from "shared/constants/project-colors"; +import type { DashboardSidebarSection } from "../../types"; +import { DashboardSidebarSectionContextMenu } from "../DashboardSidebarSection/components/DashboardSidebarSectionContextMenu"; +import { DashboardSidebarSectionHeader } from "../DashboardSidebarSection/components/DashboardSidebarSectionHeader"; + +interface SortableSectionHeaderProps { + sortableId: string; + section: DashboardSidebarSection; + onDelete: (sectionId: string) => void; + onRename: (sectionId: string, name: string) => void; + onToggleCollapse: (sectionId: string) => void; +} + +export function SortableSectionHeader({ + sortableId, + section, + onDelete, + onRename, + onToggleCollapse, +}: SortableSectionHeaderProps) { + const { setSectionColor } = useDashboardSidebarState(); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(section.name); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: sortableId }); + + const hasColor = + section.color != null && section.color !== PROJECT_COLOR_DEFAULT; + + const handleSubmitRename = () => { + const trimmed = renameValue.trim(); + if (trimmed) onRename(section.id, trimmed); + setIsRenaming(false); + }; + + return ( +
+ setIsRenaming(true)} + onSetColor={(color) => setSectionColor(section.id, color)} + onDelete={() => onDelete(section.id)} + > + { + setRenameValue(section.name); + setIsRenaming(false); + }} + onStartRename={() => { + setRenameValue(section.name); + setIsRenaming(true); + }} + onToggleCollapse={() => onToggleCollapse(section.id)} + {...attributes} + {...listeners} + /> + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts new file mode 100644 index 00000000000..6816fe842bb --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableSectionHeader/index.ts @@ -0,0 +1 @@ +export { SortableSectionHeader } from "./SortableSectionHeader"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx new file mode 100644 index 00000000000..67bc43abbd7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/SortableWorkspaceItem.tsx @@ -0,0 +1,52 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import type { DashboardSidebarWorkspace } from "../../types"; +import { DashboardSidebarWorkspaceItem } from "../DashboardSidebarWorkspaceItem"; + +interface SortableWorkspaceItemProps { + sortableId: string; + workspace: DashboardSidebarWorkspace; + accentColor?: string | null; + isInSection?: boolean; + onHoverCardOpen?: () => void; + shortcutLabel?: string; +} + +export function SortableWorkspaceItem({ + sortableId, + workspace, + accentColor, + isInSection, + onHoverCardOpen, + shortcutLabel, +}: SortableWorkspaceItemProps) { + const { + setNodeRef, + attributes, + listeners, + isDragging, + transform, + transition, + } = useSortable({ id: sortableId }); + + return ( +
+ +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts new file mode 100644 index 00000000000..b10a73a7c4f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/components/SortableWorkspaceItem/index.ts @@ -0,0 +1 @@ +export { SortableWorkspaceItem } from "./SortableWorkspaceItem"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts new file mode 100644 index 00000000000..342f8978ef9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/index.ts @@ -0,0 +1 @@ +export { useSidebarDnd } from "./useSidebarDnd"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts new file mode 100644 index 00000000000..df654f453a7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/DashboardSidebar/hooks/useSidebarDnd/useSidebarDnd.ts @@ -0,0 +1,362 @@ +import { + closestCenter, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, + KeyboardSensor, + MeasuringStrategy, + MouseSensor, + TouchSensor, + type UniqueIdentifier, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useDashboardSidebarState } from "renderer/routes/_authenticated/hooks/useDashboardSidebarState"; +import type { + DashboardSidebarProjectChild, + DashboardSidebarSection, + DashboardSidebarWorkspace, +} from "../../types"; + +// ── ID helpers ─────────────────────────────────────────────────────── + +const WS = "ws::"; +const SEC = "sec::"; + +export const wsId = (id: string) => `${WS}${id}`; +export const secId = (id: string) => `${SEC}${id}`; +export const isSec = (id: UniqueIdentifier) => String(id).startsWith(SEC); + +export const parseId = (id: UniqueIdentifier) => { + const s = String(id); + if (s.startsWith(WS)) + return { type: "workspace" as const, realId: s.slice(WS.length) }; + if (s.startsWith(SEC)) + return { type: "section" as const, realId: s.slice(SEC.length) }; + return null; +}; + +// ── Measuring config ───────────────────────────────────────────────── + +export const measuring = { + droppable: { strategy: MeasuringStrategy.Always as const }, +}; + +// ── Build flat list from project children ──────────────────────────── + +function buildFlatItems( + children: DashboardSidebarProjectChild[], +): UniqueIdentifier[] { + const items: UniqueIdentifier[] = []; + for (const child of children) { + if (child.type === "workspace") { + items.push(wsId(child.workspace.id)); + } else { + items.push(secId(child.section.id)); + // Always include workspaces so AnimatePresence can animate collapse + for (const ws of child.section.workspaces) { + items.push(wsId(ws.id)); + } + } + } + return items; +} + +// ── Parse flat list to determine section membership ────────────────── + +interface ParsedFlatItems { + topLevel: Array<{ type: "workspace" | "section"; id: string }>; + sections: Record; +} + +function parseFlatItems(items: UniqueIdentifier[]): ParsedFlatItems { + const result: ParsedFlatItems = { topLevel: [], sections: {} }; + let currentSection: string | null = null; + + for (const id of items) { + const parsed = parseId(id); + if (!parsed) continue; + if (parsed.type === "section") { + currentSection = parsed.realId; + result.topLevel.push({ type: "section", id: parsed.realId }); + result.sections[parsed.realId] = []; + } else if (parsed.type === "workspace") { + if (currentSection) { + result.sections[currentSection].push(parsed.realId); + } else { + result.topLevel.push({ type: "workspace", id: parsed.realId }); + } + } + } + return result; +} + +// ── Hook ───────────────────────────────────────────────────────────── + +interface UseSidebarDndOptions { + projectId: string; + projectChildren: DashboardSidebarProjectChild[]; +} + +export function useSidebarDnd({ + projectId, + projectChildren, +}: UseSidebarDndOptions) { + const { reorderProjectChildren, moveWorkspaceToSectionAtIndex } = + useDashboardSidebarState(); + + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), + useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const [flatItems, setFlatItems] = useState(() => + buildFlatItems(projectChildren), + ); + const [activeId, setActiveId] = useState(null); + const activeType: "workspace" | "section" | null = activeId + ? isSec(activeId) + ? "section" + : "workspace" + : null; + const [overId, setOverId] = useState(null); + const clonedRef = useRef(null); + + // When dragging a section, SortableContext only has section IDs. + // When dragging a workspace (or idle), SortableContext has everything. + const sortableItems = useMemo(() => { + if (activeType === "section") { + return flatItems.filter((id) => isSec(id)); + } + return flatItems; + }, [flatItems, activeType]); + + // Sync from external data when items or their order/membership changes + const prevFingerprintRef = useRef(""); + useEffect(() => { + if (activeId) return; // Don't reset during active drag + const fingerprint = projectChildren + .map((c) => + c.type === "workspace" + ? c.workspace.id + : `s:${c.section.id}:${c.section.workspaces.map((w) => w.id).join("|")}`, + ) + .join(","); + if (fingerprint !== prevFingerprintRef.current) { + prevFingerprintRef.current = fingerprint; + setFlatItems(buildFlatItems(projectChildren)); + } + }, [projectChildren, activeId]); + + const collapsedSectionIds = useMemo(() => { + const set = new Set(); + for (const child of projectChildren) { + if (child.type === "section" && child.section.isCollapsed) { + set.add(child.section.id); + } + } + return set; + }, [projectChildren]); + + // ── Lookups ────────────────────────────────────────────────────── + + const workspacesById = useMemo(() => { + const map = new Map(); + for (const child of projectChildren) { + if (child.type === "workspace") { + map.set(child.workspace.id, child.workspace); + } else { + for (const ws of child.section.workspaces) { + map.set(ws.id, ws); + } + } + } + return map; + }, [projectChildren]); + + const sectionsById = useMemo(() => { + const map = new Map(); + for (const child of projectChildren) { + if (child.type === "section") { + map.set(child.section.id, child.section); + } + } + return map; + }, [projectChildren]); + + // Which section does each workspace belong to? (for visual grouping) + const groupInfo = useMemo(() => { + const map = new Map(); + let currentSection: { id: string; color: string | null } | null = null; + + for (const id of flatItems) { + const parsed = parseId(id); + if (!parsed) continue; + if (parsed.type === "section") { + const sec = sectionsById.get(parsed.realId); + currentSection = sec ? { id: sec.id, color: sec.color } : null; + } else if (parsed.type === "workspace" && currentSection) { + map.set(parsed.realId, { + sectionId: currentSection.id, + color: currentSection.color, + }); + } + } + return map; + }, [flatItems, sectionsById]); + + const activeItem = useMemo(() => { + if (!activeId) return null; + const parsed = parseId(activeId); + if (!parsed) return null; + if (parsed.type === "workspace") { + const ws = workspacesById.get(parsed.realId); + return ws ? { type: "workspace" as const, workspace: ws } : null; + } + const sec = sectionsById.get(parsed.realId); + return sec ? { type: "section" as const, section: sec } : null; + }, [activeId, workspacesById, sectionsById]); + + // Color the active workspace's ghost should show based on where it would land + const predictedColor = useMemo(() => { + if (!activeId || !overId || activeType !== "workspace") return null; + const overIndex = flatItems.indexOf(overId); + if (overIndex === -1) return null; + // If over is a section header, the workspace lands ABOVE it, + // so look for the section above the over position (skip the over itself) + const startFrom = isSec(overId) ? overIndex - 1 : overIndex; + for (let i = startFrom; i >= 0; i--) { + const p = parseId(flatItems[i]); + if (p?.type === "section") { + const sec = sectionsById.get(p.realId); + return sec?.color ?? null; + } + } + return null; // ungrouped — no section above + }, [activeId, overId, activeType, flatItems, sectionsById]); + + // ── Persistence ────────────────────────────────────────────────── + + const commitToDb = useCallback( + (items: UniqueIdentifier[]) => { + const parsed = parseFlatItems(items); + + // Top-level order (ungrouped workspaces + sections interleaved) + reorderProjectChildren(projectId, parsed.topLevel); + + // Each section's workspace order + for (const [sectionId, wsIds] of Object.entries(parsed.sections)) { + for (let i = 0; i < wsIds.length; i++) { + moveWorkspaceToSectionAtIndex(wsIds[i], projectId, sectionId, i); + } + } + }, + [projectId, reorderProjectChildren, moveWorkspaceToSectionAtIndex], + ); + + // ── Handlers ───────────────────────────────────────────────────── + + const onDragStart = useCallback( + ({ active }: DragStartEvent) => { + setActiveId(active.id); + clonedRef.current = [...flatItems]; + }, + [flatItems], + ); + + const onDragOver = useCallback(({ over }: DragOverEvent) => { + setOverId(over?.id ?? null); + }, []); + + const onDragEnd = useCallback( + ({ active, over }: DragEndEvent) => { + setActiveId(null); + setOverId(null); + + if (!over || active.id === over.id) return; + + if (isSec(active.id)) { + // Section drag: only section IDs were in the SortableContext. + // Reorder sections, then rebuild the full flat list with + // workspaces in their original positions under each section. + const sectionIds = flatItems.filter((id) => isSec(id)); + const oldIdx = sectionIds.indexOf(active.id); + const newIdx = sectionIds.indexOf(over.id); + if (oldIdx === -1 || newIdx === -1 || oldIdx === newIdx) return; + + const reorderedSections = arrayMove(sectionIds, oldIdx, newIdx); + + // Rebuild flat list: ungrouped workspaces first, then + // each section with its workspaces in new section order + const ungrouped: UniqueIdentifier[] = []; + const sectionGroups = new Map(); + + let currentSec: string | null = null; + for (const id of flatItems) { + if (isSec(id)) { + currentSec = String(id); + sectionGroups.set(currentSec, []); + } else if (currentSec) { + sectionGroups.get(currentSec)?.push(id); + } else { + ungrouped.push(id); + } + } + + const newItems: UniqueIdentifier[] = [...ungrouped]; + for (const secSortId of reorderedSections) { + newItems.push(secSortId); + const wsInSec = sectionGroups.get(String(secSortId)) ?? []; + newItems.push(...wsInSec); + } + + setFlatItems(newItems); + commitToDb(newItems); + } else { + // Workspace drag: simple arrayMove in the full flat list + const oldIndex = flatItems.indexOf(active.id); + const overIndex = flatItems.indexOf(over.id); + if (oldIndex === -1 || overIndex === -1 || oldIndex === overIndex) + return; + + const newItems = arrayMove(flatItems, oldIndex, overIndex); + setFlatItems(newItems); + commitToDb(newItems); + } + }, + [flatItems, commitToDb], + ); + + const onDragCancel = useCallback(() => { + if (clonedRef.current) { + setFlatItems(clonedRef.current); + } + setActiveId(null); + setOverId(null); + clonedRef.current = null; + }, []); + + return { + sensors, + measuring, + collisionDetection: closestCenter, + flatItems, + sortableItems, + activeId, + activeType, + activeItem, + predictedColor, + groupInfo, + collapsedSectionIds, + workspacesById, + sectionsById, + handlers: { onDragStart, onDragOver, onDragEnd, onDragCancel }, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts index 1ab72fb083a..83aee1ce847 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/hooks/useDashboardSidebarState/useDashboardSidebarState.ts @@ -2,6 +2,7 @@ import type { WorkspaceState } from "@superset/panes"; import { useCallback } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { AppCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/collections"; +import { PROJECT_CUSTOM_COLORS } from "shared/constants/project-colors"; function getNextTabOrder(items: Array<{ tabOrder: number }>): number { const maxTabOrder = items.reduce( @@ -123,6 +124,63 @@ export function useDashboardSidebarState() { [collections], ); + const reorderProjectChildren = useCallback( + ( + projectId: string, + orderedItems: Array<{ type: "workspace" | "section"; id: string }>, + ) => { + orderedItems.forEach((item, index) => { + const tabOrder = index + 1; + if (item.type === "workspace") { + if (!collections.v2WorkspaceLocalState.get(item.id)) return; + collections.v2WorkspaceLocalState.update(item.id, (draft) => { + draft.sidebarState.tabOrder = tabOrder; + draft.sidebarState.sectionId = null; + draft.sidebarState.projectId = projectId; + }); + } else { + if (!collections.v2SidebarSections.get(item.id)) return; + collections.v2SidebarSections.update(item.id, (draft) => { + draft.tabOrder = tabOrder; + }); + } + }); + }, + [collections], + ); + + const moveWorkspaceToSectionAtIndex = useCallback( + ( + workspaceId: string, + projectId: string, + sectionId: string, + index: number, + ) => { + const existing = collections.v2WorkspaceLocalState.get(workspaceId); + if (!existing) return; + const siblings = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === sectionId, + ) + .sort((a, b) => a.sidebarState.tabOrder - b.sidebarState.tabOrder); + const reordered = [...siblings]; + reordered.splice(index, 0, existing); + reordered.forEach((item, i) => { + collections.v2WorkspaceLocalState.update(item.workspaceId, (draft) => { + draft.sidebarState.tabOrder = i + 1; + draft.sidebarState.sectionId = sectionId; + draft.sidebarState.projectId = projectId; + }); + }); + }, + [collections], + ); + const createSection = useCallback( (projectId: string, name = "New Section") => { ensureSidebarProjectRecord(collections, projectId); @@ -132,6 +190,11 @@ export function useDashboardSidebarState() { collections.v2SidebarSections.state.values(), ).filter((item) => item.projectId === projectId); + const randomColor = + PROJECT_CUSTOM_COLORS[ + Math.floor(Math.random() * PROJECT_CUSTOM_COLORS.length) + ].value; + collections.v2SidebarSections.insert({ sectionId, projectId, @@ -139,7 +202,7 @@ export function useDashboardSidebarState() { createdAt: new Date(), tabOrder: getNextTabOrder(sectionOrders), isCollapsed: false, - color: null, + color: randomColor, }); return sectionId; @@ -182,6 +245,55 @@ export function useDashboardSidebarState() { const existing = collections.v2WorkspaceLocalState.get(workspaceId); if (!existing) return; + if (sectionId === null) { + // "Remove from group" — place right above the first section. + // Find the lowest section tabOrder, then use tabOrder - 1. + // If no sections exist, append to end of ungrouped workspaces. + const sectionOrders = Array.from( + collections.v2SidebarSections.state.values(), + ) + .filter((s) => s.projectId === projectId) + .map((s) => s.tabOrder); + + const firstSectionOrder = + sectionOrders.length > 0 ? Math.min(...sectionOrders) : null; + + let newTabOrder: number; + if (firstSectionOrder != null) { + // Place right before the first section, after existing ungrouped + const ungroupedOrders = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === null, + ) + .map((item) => ({ tabOrder: item.sidebarState.tabOrder })); + newTabOrder = getNextTabOrder(ungroupedOrders); + } else { + // No sections — append to end + const ungroupedOrders = Array.from( + collections.v2WorkspaceLocalState.state.values(), + ) + .filter( + (item) => + item.sidebarState.projectId === projectId && + item.workspaceId !== workspaceId && + item.sidebarState.sectionId === null, + ) + .map((item) => ({ tabOrder: item.sidebarState.tabOrder })); + newTabOrder = getNextTabOrder(ungroupedOrders); + } + + collections.v2WorkspaceLocalState.update(workspaceId, (draft) => { + draft.sidebarState.sectionId = null; + draft.sidebarState.tabOrder = newTabOrder; + }); + return; + } + const siblingRows = Array.from( collections.v2WorkspaceLocalState.state.values(), ) @@ -274,7 +386,9 @@ export function useDashboardSidebarState() { ensureProjectInSidebar, ensureWorkspaceInSidebar, moveWorkspaceToSection, + moveWorkspaceToSectionAtIndex, removeProjectFromSidebar, + reorderProjectChildren, removeWorkspaceFromSidebar, reorderProjects, reorderWorkspaces, diff --git a/apps/desktop/src/shared/constants/project-colors.test.ts b/apps/desktop/src/shared/constants/project-colors.test.ts index 10ae785e603..f1a957b1abb 100644 --- a/apps/desktop/src/shared/constants/project-colors.test.ts +++ b/apps/desktop/src/shared/constants/project-colors.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { - PROJECT_COLOR_DEFAULT, - PROJECT_COLORS, - PROJECT_CUSTOM_COLORS, -} from "./project-colors"; +import { PROJECT_COLORS, PROJECT_CUSTOM_COLORS } from "./project-colors"; function hexToRgb(hex: string) { const normalizedHex = hex.replace("#", ""); @@ -32,7 +28,7 @@ describe("PROJECT_COLORS", () => { expect(new Set(colorNames).size).toBe(colorNames.length); expect(new Set(colorValues).size).toBe(colorValues.length); - expect(PROJECT_COLORS[0]?.value).toBe(PROJECT_COLOR_DEFAULT); + expect(PROJECT_COLORS.length).toBeGreaterThan(0); }); it("keeps custom swatches visually distinct", () => { diff --git a/apps/desktop/src/shared/constants/project-colors.ts b/apps/desktop/src/shared/constants/project-colors.ts index a4142db1525..0341c438e22 100644 --- a/apps/desktop/src/shared/constants/project-colors.ts +++ b/apps/desktop/src/shared/constants/project-colors.ts @@ -2,7 +2,6 @@ export const PROJECT_COLOR_DEFAULT = "default"; export const PROJECT_COLORS = [ - { name: "Default", value: PROJECT_COLOR_DEFAULT }, { name: "Red", value: "#ef4444" }, { name: "Orange", value: "#f97316" }, { name: "Yellow", value: "#eab308" }, @@ -17,9 +16,7 @@ export const PROJECT_COLORS = [ { name: "Slate", value: "#64748b" }, ] as const; -export const PROJECT_CUSTOM_COLORS = PROJECT_COLORS.filter( - (color) => color.value !== PROJECT_COLOR_DEFAULT, -); +export const PROJECT_CUSTOM_COLORS = PROJECT_COLORS; export const PROJECT_COLOR_VALUES: string[] = PROJECT_COLORS.map( (color) => color.value,