Skip to content

Commit ccdab0e

Browse files
committed
Reorderable custom dashboards
1 parent 7751296 commit ccdab0e

5 files changed

Lines changed: 232 additions & 28 deletions

File tree

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 158 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ import {
2525
import { DialogClose } from "@radix-ui/react-dialog";
2626
import { Form, Link, useFetcher, useNavigation } from "@remix-run/react";
2727
import { LayoutGroup, motion } from "framer-motion";
28-
import { LineChartIcon } from "lucide-react";
29-
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
28+
import { GripVerticalIcon, LineChartIcon } from "lucide-react";
29+
import { type Ref, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
30+
import ReactGridLayout, { useContainerWidth, type Layout } from "react-grid-layout";
3031
import simplur from "simplur";
3132
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
3233
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
@@ -182,6 +183,75 @@ export function SideMenu({
182183
const { isManagedCloud } = useFeatures();
183184
const featureFlags = useFeatureFlags();
184185
const customDashboards = useCustomDashboards();
186+
const dashboardOrderFetcher = useFetcher();
187+
188+
// Dashboard reorder state
189+
const [dashboardOrder, setDashboardOrder] = useState<string[]>(
190+
() =>
191+
user.dashboardPreferences.sideMenu?.customDashboardOrder?.[organization.id] ??
192+
customDashboards.map((d) => d.friendlyId)
193+
);
194+
195+
// Sync order when organization changes (component may not remount)
196+
useEffect(() => {
197+
setDashboardOrder(
198+
user.dashboardPreferences.sideMenu?.customDashboardOrder?.[organization.id] ??
199+
customDashboards.map((d) => d.friendlyId)
200+
);
201+
}, [organization.id]);
202+
203+
// Sort dashboards by stored order, new dashboards go to end
204+
const orderedDashboards = useMemo(() => {
205+
const orderMap = new Map(dashboardOrder.map((id, i) => [id, i]));
206+
return [...customDashboards].sort((a, b) => {
207+
const aIdx = orderMap.get(a.friendlyId) ?? Infinity;
208+
const bIdx = orderMap.get(b.friendlyId) ?? Infinity;
209+
return aIdx - bIdx;
210+
});
211+
}, [customDashboards, dashboardOrder]);
212+
213+
// Layout for ReactGridLayout (1-column vertical list, each item h=1 row)
214+
const dashboardLayout = useMemo(
215+
() =>
216+
orderedDashboards.map((d, i) => ({
217+
i: d.friendlyId,
218+
x: 0,
219+
y: i,
220+
w: 1,
221+
h: 1,
222+
})),
223+
[orderedDashboards]
224+
);
225+
226+
// Width measurement for ReactGridLayout
227+
const {
228+
width: gridWidth,
229+
containerRef: gridContainerRef,
230+
mounted: gridMounted,
231+
} = useContainerWidth({ initialWidth: 216 });
232+
233+
const canReorder = orderedDashboards.length >= 2 && !isCollapsed;
234+
235+
// Handle drag stop - extract new order from layout y-positions
236+
const handleDashboardDragStop = useCallback(
237+
(layout: Layout) => {
238+
const sorted = [...layout].sort((a, b) => a.y - b.y);
239+
const newOrder = sorted.map((item) => item.i);
240+
if (JSON.stringify(newOrder) === JSON.stringify(dashboardOrder)) return;
241+
setDashboardOrder(newOrder);
242+
// Persist immediately
243+
if (!user.isImpersonating) {
244+
const formData = new FormData();
245+
formData.append("organizationId", organization.id);
246+
formData.append("customDashboardOrder", JSON.stringify(newOrder));
247+
dashboardOrderFetcher.submit(formData, {
248+
method: "POST",
249+
action: "/resources/preferences/sidemenu",
250+
});
251+
}
252+
},
253+
[dashboardOrder, organization.id, user.isImpersonating, dashboardOrderFetcher]
254+
);
185255

186256
const persistSideMenuPreferences = useCallback(
187257
(data: {
@@ -511,26 +581,82 @@ export function SideMenu({
511581
/>
512582
}
513583
/>
514-
{customDashboards.map((dashboard, index) => {
515-
const isLast = index === customDashboards.length - 1;
516-
return (
517-
<SideMenuItem
518-
key={dashboard.friendlyId}
519-
name={dashboard.title}
520-
icon={
521-
isCollapsed
522-
? LineChartIcon
523-
: isLast
524-
? TreeConnectorEnd
525-
: TreeConnectorBranch
526-
}
527-
activeIconColor={isCollapsed ? "text-text-dimmed" : "text-customDashboards"}
528-
inactiveIconColor={isCollapsed ? "text-text-dimmed" : "text-customDashboards"}
529-
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
530-
isCollapsed={isCollapsed}
531-
/>
532-
);
533-
})}
584+
<div ref={gridContainerRef as Ref<HTMLDivElement>}>
585+
{canReorder && gridMounted ? (
586+
<ReactGridLayout
587+
layout={dashboardLayout}
588+
width={gridWidth}
589+
gridConfig={{
590+
cols: 1,
591+
rowHeight: 32,
592+
margin: [0, 0] as const,
593+
containerPadding: [0, 0] as const,
594+
}}
595+
resizeConfig={{ enabled: false }}
596+
dragConfig={{ enabled: true, handle: ".sidebar-drag-handle" }}
597+
onDragStop={handleDashboardDragStop}
598+
className="sidebar-reorder-grid"
599+
autoSize
600+
>
601+
{orderedDashboards.map((dashboard, index) => {
602+
const isLast = index === orderedDashboards.length - 1;
603+
return (
604+
<div key={dashboard.friendlyId}>
605+
<SideMenuItem
606+
name={dashboard.title}
607+
icon={
608+
isCollapsed
609+
? LineChartIcon
610+
: isLast
611+
? TreeConnectorEnd
612+
: TreeConnectorBranch
613+
}
614+
activeIconColor={isCollapsed ? "text-customDashboards" : undefined}
615+
inactiveIconColor={isCollapsed ? "text-customDashboards" : undefined}
616+
to={v3CustomDashboardPath(
617+
organization,
618+
project,
619+
environment,
620+
dashboard
621+
)}
622+
isCollapsed={isCollapsed}
623+
action={
624+
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
625+
<GripVerticalIcon className="size-3.5" />
626+
</div>
627+
}
628+
/>
629+
</div>
630+
);
631+
})}
632+
</ReactGridLayout>
633+
) : (
634+
orderedDashboards.map((dashboard, index) => {
635+
const isLast = index === orderedDashboards.length - 1;
636+
return (
637+
<SideMenuItem
638+
key={dashboard.friendlyId}
639+
name={dashboard.title}
640+
icon={
641+
isCollapsed
642+
? LineChartIcon
643+
: isLast
644+
? TreeConnectorEnd
645+
: TreeConnectorBranch
646+
}
647+
activeIconColor={
648+
isCollapsed ? "text-customDashboards" : "text-charcoal-700"
649+
}
650+
inactiveIconColor={
651+
isCollapsed ? "text-customDashboards" : "text-charcoal-700"
652+
}
653+
to={v3CustomDashboardPath(organization, project, environment, dashboard)}
654+
isCollapsed={isCollapsed}
655+
/>
656+
);
657+
})
658+
)}
659+
</div>
534660
</SideMenuSection>
535661
)}
536662

@@ -1236,7 +1362,11 @@ function AnimatedChevron({
12361362
// Lines extend to y=-6 and y=26 to fill the full 32px row height (6px gap above/below the 20px icon).
12371363
function TreeConnectorBranch({ className }: { className?: string }) {
12381364
return (
1239-
<svg className={cn("overflow-visible", className)} viewBox="0 0 20 20" fill="none">
1365+
<svg
1366+
className={cn("overflow-visible", className, "text-charcoal-600")}
1367+
viewBox="0 0 20 20"
1368+
fill="none"
1369+
>
12401370
<line x1="10" y1="-6" x2="10" y2="26" stroke="currentColor" strokeWidth="1" />
12411371
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
12421372
</svg>
@@ -1245,7 +1375,11 @@ function TreeConnectorBranch({ className }: { className?: string }) {
12451375

12461376
function TreeConnectorEnd({ className }: { className?: string }) {
12471377
return (
1248-
<svg className={cn("overflow-visible", className)} viewBox="0 0 20 20" fill="none">
1378+
<svg
1379+
className={cn("overflow-visible", className, "text-charcoal-600")}
1380+
viewBox="0 0 20 20"
1381+
fill="none"
1382+
>
12491383
<line x1="10" y1="-6" x2="10" y2="10" stroke="currentColor" strokeWidth="1" />
12501384
<line x1="10" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="1" />
12511385
</svg>

apps/webapp/app/routes/resources.preferences.sidemenu.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
SideMenuSectionIdSchema,
55
type SideMenuSectionId,
66
} from "~/components/navigation/sideMenuTypes";
7-
import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
7+
import {
8+
updateCustomDashboardOrder,
9+
updateSideMenuPreferences,
10+
} from "~/services/dashboardPreferences.server";
811
import { requireUser } from "~/services/session.server";
912

1013
// Transforms form data string "true"/"false" to boolean, or undefined if not present
@@ -17,6 +20,9 @@ const RequestSchema = z.object({
1720
isCollapsed: booleanFromFormData,
1821
sectionId: SideMenuSectionIdSchema.optional(),
1922
sectionCollapsed: booleanFromFormData,
23+
// Dashboard reorder fields
24+
organizationId: z.string().optional(),
25+
customDashboardOrder: z.string().optional(), // JSON-encoded string[]
2026
});
2127

2228
export async function action({ request }: ActionFunctionArgs) {
@@ -30,10 +36,26 @@ export async function action({ request }: ActionFunctionArgs) {
3036
return json({ success: false, error: "Invalid request data" }, { status: 400 });
3137
}
3238

39+
// Handle dashboard order update
40+
if (result.data.organizationId && result.data.customDashboardOrder) {
41+
const orderResult = z.array(z.string()).safeParse(JSON.parse(result.data.customDashboardOrder));
42+
if (orderResult.success) {
43+
await updateCustomDashboardOrder({
44+
user,
45+
organizationId: result.data.organizationId,
46+
order: orderResult.data,
47+
});
48+
}
49+
return json({ success: true });
50+
}
51+
3352
// Build sectionCollapsed parameter if both sectionId and sectionCollapsed are provided
3453
const sectionCollapsed =
3554
result.data.sectionId !== undefined && result.data.sectionCollapsed !== undefined
36-
? { sectionId: result.data.sectionId as SideMenuSectionId, collapsed: result.data.sectionCollapsed }
55+
? {
56+
sectionId: result.data.sectionId as SideMenuSectionId,
57+
collapsed: result.data.sectionCollapsed,
58+
}
3759
: undefined;
3860

3961
await updateSideMenuPreferences({

apps/webapp/app/services/dashboardPreferences.server.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ const SideMenuPreferences = z.object({
77
isCollapsed: z.boolean().default(false),
88
// Map for section collapsed states - keys are section identifiers
99
collapsedSections: z.record(z.string(), z.boolean()).optional(),
10+
// Map of organization ID -> ordered array of dashboard friendlyIds
11+
customDashboardOrder: z.record(z.string(), z.array(z.string())).optional(),
1012
});
1113

1214
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
1315

14-
export { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes";
16+
import { type SideMenuSectionId } from "~/components/navigation/sideMenuTypes";
17+
export type { SideMenuSectionId };
1518

1619
const DashboardPreferences = z.object({
1720
version: z.literal("1"),
@@ -167,3 +170,41 @@ export async function updateSideMenuPreferences({
167170
},
168171
});
169172
}
173+
174+
export async function updateCustomDashboardOrder({
175+
user,
176+
organizationId,
177+
order,
178+
}: {
179+
user: UserFromSession;
180+
organizationId: string;
181+
order: string[];
182+
}) {
183+
if (user.isImpersonating) {
184+
return;
185+
}
186+
187+
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
188+
189+
const updatedSideMenu = SideMenuPreferences.parse({
190+
...currentSideMenu,
191+
customDashboardOrder: {
192+
...currentSideMenu.customDashboardOrder,
193+
[organizationId]: order,
194+
},
195+
});
196+
197+
const updatedPreferences: DashboardPreferences = {
198+
...user.dashboardPreferences,
199+
sideMenu: updatedSideMenu,
200+
};
201+
202+
return prisma.user.update({
203+
where: {
204+
id: user.id,
205+
},
206+
data: {
207+
dashboardPreferences: updatedPreferences,
208+
},
209+
});
210+
}

apps/webapp/app/tailwind.css

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
background: rgb(99 102 241) !important; /* indigo-500 */
1010
}
1111

12+
/* Sidebar reorder grid: subtle placeholder */
13+
.sidebar-reorder-grid .react-grid-item.react-grid-placeholder {
14+
background: rgb(39 42 46) !important; /* charcoal-700 */
15+
border-radius: 0.25rem;
16+
opacity: 1 !important;
17+
}
18+
1219
/* Override resize handle icon to white */
1320
.react-resizable-handle {
1421
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2IDYiIHN0eWxlPSJiYWNrZ3JvdW5kLWNvbG9yOiNmZmZmZmYwMCIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4Ij48ZyBvcGFjaXR5PSIwLjMwMiI+PHBhdGggZD0iTSA2IDYgTCAwIDYgTCAwIDQuMiBMIDQgNC4yIEwgNC4yIDQuMiBMIDQuMiAwIEwgNiAwIEwgNiA2IEwgNiA2IFoiIGZpbGw9IiNmZmZmZmYiLz48L2c+PC9zdmc+') !important;

apps/webapp/tailwind.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ const schedules = colors.yellow[500];
161161
const queues = colors.purple[500];
162162
const query = colors.blue[500];
163163
const metrics = colors.green[500];
164-
const customDashboards = charcoal[500];
164+
const customDashboards = charcoal[400];
165165
const deployments = colors.green[500];
166166
const concurrency = colors.amber[500];
167167
const limits = colors.purple[500];

0 commit comments

Comments
 (0)