feat(dashboard): make widget groups reorderable via drag-and-drop
This commit is contained in:
@@ -1,37 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box, Typography, IconButton } from '@mui/material';
|
||||
import { Delete as DeleteIcon } from '@mui/icons-material';
|
||||
import { Delete as DeleteIcon, DragIndicator as DragIndicatorIcon } from '@mui/icons-material';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
interface WidgetGroupProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
gridColumn?: string;
|
||||
groupId?: string;
|
||||
sortableId?: string;
|
||||
editMode?: boolean;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete }: WidgetGroupProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
function WidgetGroup({ title, children, groupId, sortableId, editMode, onDelete }: WidgetGroupProps) {
|
||||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||
id: groupId ? `group-${groupId}` : 'group-default',
|
||||
disabled: !editMode,
|
||||
});
|
||||
|
||||
const {
|
||||
setNodeRef: setSortableRef,
|
||||
attributes,
|
||||
listeners,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sortableId ?? '',
|
||||
disabled: !editMode || !sortableId,
|
||||
});
|
||||
|
||||
// Count non-null children to hide empty groups
|
||||
const validChildren = React.Children.toArray(children).filter(Boolean);
|
||||
|
||||
if (validChildren.length === 0 && !editMode) return null;
|
||||
|
||||
const sortableStyle = sortableId ? {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
zIndex: isDragging ? 1 : undefined,
|
||||
} : {};
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={setNodeRef}
|
||||
ref={setSortableRef}
|
||||
style={sortableStyle}
|
||||
sx={{
|
||||
position: 'relative',
|
||||
borderRadius: 2,
|
||||
p: 2.5,
|
||||
pt: 3.5,
|
||||
gridColumn,
|
||||
gridColumn: '1 / -1',
|
||||
bgcolor: isOver && editMode ? 'rgba(25, 118, 210, 0.04)' : 'rgba(0, 0, 0, 0.02)',
|
||||
border: '1px solid',
|
||||
borderColor: isOver && editMode ? 'primary.light' : 'rgba(0, 0, 0, 0.04)',
|
||||
@@ -40,6 +63,7 @@ function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
ref={setDropRef}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: -9,
|
||||
@@ -53,6 +77,16 @@ function WidgetGroup({ title, children, gridColumn, groupId, editMode, onDelete
|
||||
borderRadius: 1,
|
||||
}}
|
||||
>
|
||||
{editMode && sortableId && (
|
||||
<IconButton
|
||||
size="small"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
sx={{ p: 0, mr: 0.25, color: 'text.disabled', cursor: 'grab', '&:active': { cursor: 'grabbing' } }}
|
||||
>
|
||||
<DragIndicatorIcon sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
|
||||
@@ -29,6 +29,7 @@ import type { DragEndEvent, DragStartEvent, DragOverEvent } from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
rectSortingStrategy,
|
||||
verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
@@ -100,6 +101,7 @@ function Dashboard() {
|
||||
const [addGroupOpen, setAddGroupOpen] = useState(false);
|
||||
const [newGroupTitle, setNewGroupTitle] = useState('');
|
||||
const [resetOpen, setResetOpen] = useState(false);
|
||||
const [localGroupOrder, setLocalGroupOrder] = useState<string[]>(BUILTIN_GROUPS.map(g => g.name));
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
@@ -185,8 +187,20 @@ function Dashboard() {
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
// Sync group order from preferences
|
||||
const customGroupNames = (preferences.customGroups as { name: string; title: string }[] | undefined ?? []).map(g => g.name);
|
||||
const allGroupNames = [...BUILTIN_GROUPS.map(g => g.name), ...customGroupNames];
|
||||
const savedGroupOrder = preferences.groupsOrder as string[] | undefined;
|
||||
if (savedGroupOrder) {
|
||||
const ordered = savedGroupOrder.filter((n: string) => allGroupNames.includes(n));
|
||||
const remaining = allGroupNames.filter(n => !ordered.includes(n));
|
||||
setLocalGroupOrder([...ordered, ...remaining]);
|
||||
} else {
|
||||
setLocalGroupOrder(allGroupNames);
|
||||
}
|
||||
}, [preferences?.widgetOrder, preferences?.customGroups]);
|
||||
}
|
||||
}, [preferences?.widgetOrder, preferences?.customGroups, preferences?.groupsOrder]);
|
||||
|
||||
// Flat map of all widget defs for cross-group lookup
|
||||
const allWidgetDefs = useMemo(() => {
|
||||
@@ -226,6 +240,9 @@ function Dashboard() {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
// Skip group-level drags
|
||||
if ((active.id as string).startsWith('group:')) return;
|
||||
|
||||
const activeWidget = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
@@ -260,15 +277,32 @@ function Dashboard() {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const activeWidget = active.id as string;
|
||||
const overId = over.id as string;
|
||||
const activeIdStr = active.id as string;
|
||||
const overIdStr = over.id as string;
|
||||
|
||||
// Group reorder
|
||||
if (activeIdStr.startsWith('group:') && overIdStr.startsWith('group:')) {
|
||||
const sourceName = activeIdStr.replace('group:', '');
|
||||
const targetName = overIdStr.replace('group:', '');
|
||||
const oldIndex = localGroupOrder.indexOf(sourceName);
|
||||
const newIndex = localGroupOrder.indexOf(targetName);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const newOrder = arrayMove(localGroupOrder, oldIndex, newIndex);
|
||||
setLocalGroupOrder(newOrder);
|
||||
preferencesApi.update({ widgetOrder: localOrder, groupsOrder: newOrder }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeWidget = activeIdStr;
|
||||
|
||||
const sourceGroup = findGroupForWidget(activeWidget);
|
||||
let targetGroup: string | undefined;
|
||||
if (overId.startsWith('group-')) {
|
||||
targetGroup = overId.replace('group-', '');
|
||||
if (overIdStr.startsWith('group-')) {
|
||||
targetGroup = overIdStr.replace('group-', '');
|
||||
} else {
|
||||
targetGroup = findGroupForWidget(overId);
|
||||
targetGroup = findGroupForWidget(overIdStr);
|
||||
}
|
||||
|
||||
if (!sourceGroup || !targetGroup) return;
|
||||
@@ -277,7 +311,7 @@ function Dashboard() {
|
||||
// Same group reorder
|
||||
const order = localOrder[sourceGroup];
|
||||
const oldIndex = order.indexOf(activeWidget);
|
||||
const newIndex = order.indexOf(overId);
|
||||
const newIndex = order.indexOf(overIdStr);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
const newOrder = arrayMove(order, oldIndex, newIndex);
|
||||
setLocalOrder((prev) => ({ ...prev, [sourceGroup]: newOrder }));
|
||||
@@ -295,7 +329,7 @@ function Dashboard() {
|
||||
return current;
|
||||
});
|
||||
}, 0);
|
||||
}, [localOrder, queryClient, findGroupForWidget]);
|
||||
}, [localOrder, localGroupOrder, queryClient, findGroupForWidget]);
|
||||
|
||||
// ── Add custom group ──
|
||||
const handleAddGroup = useCallback(() => {
|
||||
@@ -309,8 +343,10 @@ function Dashboard() {
|
||||
|
||||
const updatedCustomGroups = [...((preferences?.customGroups as { name: string; title: string }[]) || []), { name, title }];
|
||||
const updatedOrder = { ...localOrder, [name]: [] };
|
||||
const updatedGroupOrder = [...localGroupOrder, name];
|
||||
setLocalOrder(updatedOrder);
|
||||
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups }).then(() => {
|
||||
setLocalGroupOrder(updatedGroupOrder);
|
||||
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
setNewGroupTitle('');
|
||||
@@ -334,16 +370,20 @@ function Dashboard() {
|
||||
}
|
||||
|
||||
const updatedCustomGroups = ((preferences?.customGroups as { name: string; title: string }[]) || []).filter((g) => g.name !== groupName);
|
||||
const updatedGroupOrder = localGroupOrder.filter(n => n !== groupName);
|
||||
setLocalOrder(updatedOrder);
|
||||
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups }).then(() => {
|
||||
setLocalGroupOrder(updatedGroupOrder);
|
||||
preferencesApi.update({ widgetOrder: updatedOrder, customGroups: updatedCustomGroups, groupsOrder: updatedGroupOrder }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
}, [localOrder, preferences?.customGroups, queryClient]);
|
||||
}, [localOrder, localGroupOrder, preferences?.customGroups, queryClient]);
|
||||
|
||||
// ── Reset to defaults ──
|
||||
const handleReset = useCallback(() => {
|
||||
const defaultGroupOrder = BUILTIN_GROUPS.map(g => g.name);
|
||||
setLocalOrder(DEFAULT_ORDER);
|
||||
preferencesApi.update({ widgetOrder: DEFAULT_ORDER, customGroups: [] }).then(() => {
|
||||
setLocalGroupOrder(defaultGroupOrder);
|
||||
preferencesApi.update({ widgetOrder: DEFAULT_ORDER, customGroups: [], groupsOrder: defaultGroupOrder }).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user-preferences'] });
|
||||
});
|
||||
setResetOpen(false);
|
||||
@@ -374,7 +414,7 @@ function Dashboard() {
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<WidgetGroup title={title} gridColumn="1 / -1" key="information" groupId="information" editMode={editMode}>
|
||||
<WidgetGroup title={title} gridColumn="1 / -1" key="information" groupId="information" sortableId="group:information" editMode={editMode}>
|
||||
{linksVisible && linkCollections.map((collection) => (
|
||||
<Fade key={collection.id} in={!dataLoading} timeout={600} style={{ transitionDelay: nextDelay() }}>
|
||||
<Box>
|
||||
@@ -401,6 +441,7 @@ function Dashboard() {
|
||||
gridColumn="1 / -1"
|
||||
key={group}
|
||||
groupId={group}
|
||||
sortableId={`group:${group}`}
|
||||
editMode={editMode}
|
||||
onDelete={isCustomGroup ? () => handleDeleteGroup(group) : undefined}
|
||||
>
|
||||
@@ -420,18 +461,22 @@ function Dashboard() {
|
||||
);
|
||||
};
|
||||
|
||||
// Compute the full group list: built-in + custom groups from preferences
|
||||
// Compute the full group list: built-in + custom groups, ordered by localGroupOrder
|
||||
const groupOrder = useMemo(() => {
|
||||
const groups = [...BUILTIN_GROUPS];
|
||||
const allGroups = [...BUILTIN_GROUPS];
|
||||
if (preferences?.customGroups) {
|
||||
for (const cg of preferences.customGroups as { name: string; title: string }[]) {
|
||||
if (!groups.some((g) => g.name === cg.name)) {
|
||||
groups.push(cg);
|
||||
if (!allGroups.some((g) => g.name === cg.name)) {
|
||||
allGroups.push(cg);
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}, [preferences?.customGroups]);
|
||||
const orderedGroups = localGroupOrder
|
||||
.map(name => allGroups.find(g => g.name === name))
|
||||
.filter((g): g is { name: string; title: string } => !!g);
|
||||
const remaining = allGroups.filter(g => !localGroupOrder.includes(g.name));
|
||||
return [...orderedGroups, ...remaining];
|
||||
}, [preferences?.customGroups, localGroupOrder]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
@@ -470,11 +515,33 @@ function Dashboard() {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<SortableContext
|
||||
items={groupOrder.map(g => `group:${g.name}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{groupOrder.map((g) => renderGroup(g.name, g.title))}
|
||||
</SortableContext>
|
||||
</Box>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
activeId.startsWith('group:') ? (
|
||||
<Box sx={{
|
||||
opacity: 0.7,
|
||||
bgcolor: 'background.paper',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.light',
|
||||
borderRadius: 2,
|
||||
p: 2,
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ fontSize: '0.6875rem', fontWeight: 600, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'text.secondary' }}>
|
||||
{groupOrder.find(g => `group:${g.name}` === activeId)?.title ?? activeId}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ opacity: 0.8, transform: 'scale(1.02)', pointerEvents: 'none' }}>
|
||||
{(() => {
|
||||
for (const defs of Object.values(widgetDefs)) {
|
||||
@@ -484,6 +551,7 @@ function Dashboard() {
|
||||
return null;
|
||||
})()}
|
||||
</Box>
|
||||
)
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
Reference in New Issue
Block a user