Skip to content

Commit 89f86f1

Browse files
committed
Save the toggle states in dashboardPreferences
1 parent 702f630 commit 89f86f1

File tree

3 files changed

+170
-6
lines changed

3 files changed

+170
-6
lines changed

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

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ import {
2222
TableCellsIcon,
2323
UsersIcon
2424
} from "@heroicons/react/20/solid";
25-
import { Link, useNavigation } from "@remix-run/react";
25+
import { Link, useFetcher, useNavigation } from "@remix-run/react";
2626
import { LayoutGroup, motion } from "framer-motion";
27-
import { useEffect, useRef, useState, type ReactNode } from "react";
27+
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2828
import simplur from "simplur";
2929
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
3030
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
@@ -44,6 +44,7 @@ import { useHasAdminAccess } from "~/hooks/useUser";
4444
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
4545
import { ShortcutKey } from "../primitives/ShortcutKey";
4646
import { type User } from "~/models/user.server";
47+
import { type DashboardPreferences } from "~/services/dashboardPreferences.server";
4748
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
4849
import { type FeedbackType } from "~/routes/resources.feedback";
4950
import { IncidentStatusPanel } from "~/routes/resources.incidents";
@@ -104,7 +105,10 @@ import { SideMenuHeader } from "./SideMenuHeader";
104105
import { SideMenuItem } from "./SideMenuItem";
105106
import { SideMenuSection } from "./SideMenuSection";
106107

107-
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
108+
type SideMenuUser = Pick<User, "email" | "admin"> & {
109+
isImpersonating: boolean;
110+
dashboardPreferences: DashboardPreferences;
111+
};
108112
export type SideMenuProject = Pick<
109113
MatchedProject,
110114
"id" | "name" | "slug" | "version" | "environments" | "engine"
@@ -130,17 +134,82 @@ export function SideMenu({
130134
}: SideMenuProps) {
131135
const borderRef = useRef<HTMLDivElement>(null);
132136
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
133-
const [isCollapsed, setIsCollapsed] = useState(false);
137+
const [isCollapsed, setIsCollapsed] = useState(
138+
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
139+
);
140+
const preferencesFetcher = useFetcher();
141+
const pendingPreferencesRef = useRef<{
142+
isCollapsed?: boolean;
143+
manageSectionCollapsed?: boolean;
144+
}>({});
145+
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
134146
const currentPlan = useCurrentPlan();
135147
const { isConnected } = useDevPresence();
136148
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
137149
const isAdmin = useHasAdminAccess();
138150
const { isManagedCloud } = useFeatures();
139151
const featureFlags = useFeatureFlags();
140152

153+
const persistSideMenuPreferences = useCallback(
154+
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
155+
if (user.isImpersonating) return;
156+
157+
// Merge with any pending changes
158+
pendingPreferencesRef.current = {
159+
...pendingPreferencesRef.current,
160+
...data,
161+
};
162+
163+
// Clear existing timeout
164+
if (debounceTimeoutRef.current) {
165+
clearTimeout(debounceTimeoutRef.current);
166+
}
167+
168+
// Debounce the actual submission by 500ms
169+
debounceTimeoutRef.current = setTimeout(() => {
170+
const pending = pendingPreferencesRef.current;
171+
const formData = new FormData();
172+
if (pending.isCollapsed !== undefined) {
173+
formData.append("isCollapsed", String(pending.isCollapsed));
174+
}
175+
if (pending.manageSectionCollapsed !== undefined) {
176+
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
177+
}
178+
preferencesFetcher.submit(formData, {
179+
method: "POST",
180+
action: "/resources/preferences/sidemenu",
181+
});
182+
pendingPreferencesRef.current = {};
183+
}, 500);
184+
},
185+
[user.isImpersonating, preferencesFetcher]
186+
);
187+
188+
// Cleanup timeout on unmount
189+
useEffect(() => {
190+
return () => {
191+
if (debounceTimeoutRef.current) {
192+
clearTimeout(debounceTimeoutRef.current);
193+
}
194+
};
195+
}, []);
196+
197+
const handleToggleCollapsed = () => {
198+
const newIsCollapsed = !isCollapsed;
199+
setIsCollapsed(newIsCollapsed);
200+
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
201+
};
202+
203+
const handleManageSectionToggle = useCallback(
204+
(collapsed: boolean) => {
205+
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
206+
},
207+
[persistSideMenuPreferences]
208+
);
209+
141210
useShortcutKeys({
142211
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
143-
action: () => setIsCollapsed((prev) => !prev),
212+
action: handleToggleCollapsed,
144213
});
145214

146215
useEffect(() => {
@@ -164,7 +233,7 @@ export function SideMenu({
164233
isCollapsed ? "w-[2.75rem]" : "w-56"
165234
)}
166235
>
167-
<CollapseToggle isCollapsed={isCollapsed} onToggle={() => setIsCollapsed(!isCollapsed)} />
236+
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
168237
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
169238
<div
170239
className={cn(
@@ -353,6 +422,8 @@ export function SideMenu({
353422
title="Manage"
354423
isSideMenuCollapsed={isCollapsed}
355424
itemSpacingClassName="space-y-0"
425+
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
426+
onCollapseToggle={handleManageSectionToggle}
356427
>
357428
<SideMenuItem
358429
name="Bulk actions"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { json, type ActionFunctionArgs } from "@remix-run/node";
2+
import { z } from "zod";
3+
import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
4+
import { requireUser } from "~/services/session.server";
5+
6+
const RequestSchema = z.object({
7+
isCollapsed: z.boolean().optional(),
8+
manageSectionCollapsed: z.boolean().optional(),
9+
});
10+
11+
export async function action({ request }: ActionFunctionArgs) {
12+
const user = await requireUser(request);
13+
14+
const formData = await request.formData();
15+
const rawData = Object.fromEntries(formData);
16+
17+
// Parse booleans from form data strings
18+
const data = {
19+
isCollapsed:
20+
rawData.isCollapsed !== undefined ? rawData.isCollapsed === "true" : undefined,
21+
manageSectionCollapsed:
22+
rawData.manageSectionCollapsed !== undefined
23+
? rawData.manageSectionCollapsed === "true"
24+
: undefined,
25+
};
26+
27+
const result = RequestSchema.safeParse(data);
28+
if (!result.success) {
29+
return json({ success: false, error: "Invalid request data" }, { status: 400 });
30+
}
31+
32+
await updateSideMenuPreferences({
33+
user,
34+
isCollapsed: result.data.isCollapsed,
35+
manageSectionCollapsed: result.data.manageSectionCollapsed,
36+
});
37+
38+
return json({ success: true });
39+
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { prisma } from "~/db.server";
33
import { logger } from "./logger.server";
44
import { type UserFromSession } from "./session.server";
55

6+
const SideMenuPreferences = z.object({
7+
isCollapsed: z.boolean(),
8+
manageSectionCollapsed: z.boolean(),
9+
});
10+
11+
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
12+
613
const DashboardPreferences = z.object({
714
version: z.literal("1"),
815
currentProjectId: z.string().optional(),
@@ -12,6 +19,7 @@ const DashboardPreferences = z.object({
1219
currentEnvironment: z.object({ id: z.string() }),
1320
})
1421
),
22+
sideMenu: SideMenuPreferences.optional(),
1523
});
1624

1725
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
@@ -99,3 +107,49 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
99107
},
100108
});
101109
}
110+
111+
export async function updateSideMenuPreferences({
112+
user,
113+
isCollapsed,
114+
manageSectionCollapsed,
115+
}: {
116+
user: UserFromSession;
117+
isCollapsed?: boolean;
118+
manageSectionCollapsed?: boolean;
119+
}) {
120+
if (user.isImpersonating) {
121+
return;
122+
}
123+
124+
const currentSideMenu = user.dashboardPreferences.sideMenu ?? {
125+
isCollapsed: false,
126+
manageSectionCollapsed: false,
127+
};
128+
129+
const updatedSideMenu: SideMenuPreferences = {
130+
isCollapsed: isCollapsed ?? currentSideMenu.isCollapsed,
131+
manageSectionCollapsed: manageSectionCollapsed ?? currentSideMenu.manageSectionCollapsed,
132+
};
133+
134+
// Only update if something changed
135+
if (
136+
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
137+
updatedSideMenu.manageSectionCollapsed === currentSideMenu.manageSectionCollapsed
138+
) {
139+
return;
140+
}
141+
142+
const updatedPreferences: DashboardPreferences = {
143+
...user.dashboardPreferences,
144+
sideMenu: updatedSideMenu,
145+
};
146+
147+
return prisma.user.update({
148+
where: {
149+
id: user.id,
150+
},
151+
data: {
152+
dashboardPreferences: updatedPreferences,
153+
},
154+
});
155+
}

0 commit comments

Comments
 (0)