{backButton && (
)}
{title}
- {accessory !== undefined &&
- (typeof accessory === "string" ? (
- }
- content={accessory}
- className="max-w-xs"
- disableHoverableContent
- />
- ) : (
- accessory
- ))}
+ {accessory !== undefined && (
+ // ml-px optically evens the accessory against the title's tight text edge
+
+ {typeof accessory === "string" ? (
+ }
+ content={accessory}
+ className="max-w-xs"
+ disableHoverableContent
+ />
+ ) : (
+ accessory
+ )}
+
+ )}
+ {/* -ml-1 pulls the star's button box near-flush: its inner padding then provides the
+ visual gap, matching the title-to-accessory spacing while hovered */}
+
);
}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
index 8aa6427afbf..153004813a8 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx
@@ -48,7 +48,8 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
-import { requireUser, requireUserId } from "~/services/session.server";
+import { removeFavoritesByUrlSubstring } from "~/services/dashboardPreferences.server";
+import { requireUser } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
@@ -115,10 +116,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
- const userId = await requireUserId(request);
+ const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
- const project = await findProjectBySlug(organizationSlug, projectParam, userId);
+ const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
@@ -144,6 +145,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
where: { id: dashboard.id },
});
+ // Drop any favorites pointing at this dashboard so the side menu doesn't keep a dead link
+ await removeFavoritesByUrlSubstring({
+ user,
+ substring: `/dashboards/custom/${dashboard.friendlyId}`,
+ });
+
return redirectWithSuccessMessage(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
diff --git a/apps/webapp/app/routes/resources.preferences.favorites.tsx b/apps/webapp/app/routes/resources.preferences.favorites.tsx
new file mode 100644
index 00000000000..071d2644939
--- /dev/null
+++ b/apps/webapp/app/routes/resources.preferences.favorites.tsx
@@ -0,0 +1,80 @@
+import { json, type ActionFunctionArgs } from "@remix-run/node";
+import { z } from "zod";
+import {
+ addFavorite,
+ removeFavorite,
+ renameFavorite,
+} from "~/services/dashboardPreferences.server";
+import { logger } from "~/services/logger.server";
+import { requireUser } from "~/services/session.server";
+
+const FavoriteLabel = z
+ .string()
+ .transform((value) => value.trim())
+ .pipe(z.string().min(1).max(64));
+
+const RequestSchema = z.discriminatedUnion("intent", [
+ z.object({
+ intent: z.literal("add"),
+ id: z.string().min(1).max(64),
+ // App-relative URL only ("/..." but not protocol-relative "//...")
+ url: z
+ .string()
+ .min(1)
+ .max(2048)
+ .refine((url) => url.startsWith("/") && !url.startsWith("//"), {
+ message: "URL must be app-relative",
+ }),
+ label: FavoriteLabel,
+ icon: z
+ .string()
+ .regex(/^[a-z0-9-]+$/)
+ .max(64)
+ .optional(),
+ }),
+ z.object({
+ intent: z.literal("remove"),
+ id: z.string().min(1).max(64),
+ }),
+ z.object({
+ intent: z.literal("rename"),
+ id: z.string().min(1).max(64),
+ label: FavoriteLabel,
+ }),
+]);
+
+export async function action({ request }: ActionFunctionArgs) {
+ const user = await requireUser(request);
+
+ const formData = await request.formData();
+ const result = RequestSchema.safeParse(Object.fromEntries(formData));
+
+ if (!result.success) {
+ return json({ success: false, error: "Invalid request data" }, { status: 400 });
+ }
+
+ // Errors come back as a response (never a throw, which would escalate a preferences write to
+ // the error boundary); the side menu's optimistic entries revert when the fetcher settles.
+ try {
+ switch (result.data.intent) {
+ case "add": {
+ const { id, url, label, icon } = result.data;
+ await addFavorite({ user, favorite: { id, url, label, icon } });
+ break;
+ }
+ case "remove": {
+ await removeFavorite({ user, id: result.data.id });
+ break;
+ }
+ case "rename": {
+ await renameFavorite({ user, id: result.data.id, label: result.data.label });
+ break;
+ }
+ }
+ } catch (error) {
+ logger.error("Failed to update favorites", { error: String(error) });
+ return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
+ }
+
+ return json({ success: true });
+}
diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
index 4e43269d0ea..35b11a4cf1c 100644
--- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
+++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx
@@ -4,7 +4,12 @@ import {
SideMenuSectionIdSchema,
type SideMenuSectionId,
} from "~/components/navigation/sideMenuTypes";
-import { updateItemOrder, updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
+import {
+ updateItemOrder,
+ updateSideMenuCustomization,
+ updateSideMenuPreferences,
+} from "~/services/dashboardPreferences.server";
+import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
// Transforms form data string "true"/"false" to boolean, or undefined if not present
@@ -22,11 +27,32 @@ const RequestSchema = z.object({
organizationId: z.string().optional(),
listId: z.string().optional(),
itemOrder: z.string().optional(), // JSON-encoded string[]
+ customization: z.string().optional(), // JSON-encoded CustomizationSchema
+});
+
+// Payload of the "Customize sidebar" modal. For the nullable fields, null resets to default and
+// an absent field leaves the stored value unchanged.
+const CustomizationSchema = z.object({
+ sectionOrder: z.array(z.string().max(64)).max(50).nullish(),
+ hiddenItems: z.record(z.string().max(64), z.boolean()).nullish(),
+ sectionItemOrder: z.record(z.string().max(64), z.array(z.string().max(64)).max(100)).nullish(),
+ favorites: z
+ .array(z.object({ id: z.string().max(64), label: z.string().max(64) }))
+ .max(100)
+ .optional(),
+ removedFavoriteIds: z.array(z.string().max(64)).max(100).optional(),
});
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
+ // Every writer below deliberately skips impersonated sessions so an admin's browsing can't
+ // rewrite the customer's saved layout. That skip is a no-op, not a failed save, so report it as
+ // success: it must not reach the callers that surface write failures to the user.
+ if (user.isImpersonating) {
+ return json({ success: true });
+ }
+
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
@@ -35,6 +61,42 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
+ // Handle a "Customize sidebar" modal submit
+ if (result.data.customization) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(result.data.customization);
+ } catch {
+ parsed = null;
+ }
+ const customizationResult = CustomizationSchema.safeParse(parsed);
+ if (!customizationResult.success) {
+ return json({ success: false, error: "Invalid request data" }, { status: 400 });
+ }
+ const { sectionOrder, hiddenItems, sectionItemOrder, favorites, removedFavoriteIds } =
+ customizationResult.data;
+ // The modal keeps its "Confirm" pending until this responds, so failures must come back as a
+ // response (never a throw, which would escalate a preferences write to the error boundary).
+ try {
+ const updated = await updateSideMenuCustomization({
+ user,
+ sectionOrder,
+ hiddenItems,
+ sectionItemOrder,
+ favorites,
+ removedFavoriteIds,
+ });
+ // undefined means nothing was written (impersonating, or the user row is gone)
+ if (!updated) {
+ return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
+ }
+ } catch (error) {
+ logger.error("Failed to save sidebar customization", { error: String(error) });
+ return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
+ }
+ return json({ success: true });
+ }
+
// Handle item order update
if (result.data.organizationId && result.data.listId && result.data.itemOrder) {
let parsed: unknown;
diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts
index a8b9149eff5..9f5f84fe2e8 100644
--- a/apps/webapp/app/services/dashboardPreferences.server.ts
+++ b/apps/webapp/app/services/dashboardPreferences.server.ts
@@ -1,8 +1,21 @@
import { z } from "zod";
-import { prisma } from "~/db.server";
+import { $transaction, prisma } from "~/db.server";
import { logger } from "./logger.server";
import { type UserFromSession } from "./session.server";
+const FavoritePage = z.object({
+ /** Stable id, generated client-side when the page is favorited. */
+ id: z.string(),
+ /** App-relative URL including any search params (filters, tabs). */
+ url: z.string(),
+ /** Display label shown in the side menu; user-renamable. */
+ label: z.string(),
+ /** Key into the favorite page icon registry. */
+ icon: z.string().optional(),
+});
+
+export type FavoritePage = z.infer
;
+
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
/** Expanded side menu width in px, set by the resize handle. */
@@ -18,6 +31,14 @@ const SideMenuPreferences = z.object({
})
)
.optional(),
+ /** Pages the user favorited, in display order. */
+ favorites: z.array(FavoritePage).optional(),
+ /** Custom top-to-bottom order of side menu sections (section ids). */
+ sectionOrder: z.array(z.string()).optional(),
+ /** Per-item visibility overrides (item id -> hidden). Items absent fall back to their default. */
+ hiddenItems: z.record(z.string(), z.boolean()).optional(),
+ /** Custom item order within a section (section id -> item ids). */
+ sectionItemOrder: z.record(z.string(), z.array(z.string())).optional(),
});
export type SideMenuPreferences = z.infer;
@@ -59,6 +80,51 @@ export function getDashboardPreferences(data?: any | null): DashboardPreferences
return result.data;
}
+/**
+ * Every preference writer is a read-modify-write over one JSON column, and several fire
+ * concurrently (debounced collapse/width, favorite toggles, the customize modal, dashboard
+ * reorders). Each write re-reads the row under a FOR UPDATE lock so concurrent writers
+ * serialize instead of clobbering each other's fields with stale reads — without the lock, a
+ * debounced collapse write could resurrect customizations the modal's Reset just cleared.
+ *
+ * Return undefined from `mutate` to skip the write (no-op update).
+ */
+async function mutateDashboardPreferences(
+ userId: string,
+ mutate: (current: DashboardPreferences) => DashboardPreferences | undefined
+) {
+ return await $transaction(
+ prisma,
+ "mutateDashboardPreferences",
+ async (tx) => {
+ const rows = await tx.$queryRaw>`
+ SELECT "dashboardPreferences" FROM "User" WHERE id = ${userId} FOR UPDATE
+ `;
+ if (rows.length === 0) {
+ return undefined;
+ }
+
+ const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences));
+ if (!updated) {
+ return undefined;
+ }
+
+ return await tx.user.update({
+ where: {
+ id: userId,
+ },
+ data: {
+ dashboardPreferences: updated,
+ },
+ });
+ },
+ // Concurrent writers queue on the row lock, so under load (several debounced writes plus a
+ // revalidation burst) a transaction can time out acquiring a connection or the lock; those
+ // codes are retriable and preference writes are idempotent.
+ { maxRetries: 3 }
+ );
+}
+
export async function updateCurrentProjectEnvironmentId({
user,
projectId,
@@ -72,7 +138,9 @@ export async function updateCurrentProjectEnvironmentId({
return;
}
- //only update if the existing preferences are different
+ // Fast path: this runs on nearly every navigation (env layout loader), so skip the locked
+ // transaction when the session snapshot already matches. The in-transaction check below stays
+ // authoritative for the rare stale-snapshot case.
if (
user.dashboardPreferences.currentProjectId === projectId &&
user.dashboardPreferences.projects[projectId]?.currentEnvironment?.id === environmentId
@@ -80,26 +148,26 @@ export async function updateCurrentProjectEnvironmentId({
return;
}
- //ok we need to update the preferences
- const updatedPreferences: DashboardPreferences = {
- ...user.dashboardPreferences,
- currentProjectId: projectId,
- projects: {
- ...user.dashboardPreferences.projects,
- [projectId]: {
- ...user.dashboardPreferences.projects[projectId],
- currentEnvironment: { id: environmentId },
- },
- },
- };
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ //only update if the existing preferences are different
+ if (
+ prefs.currentProjectId === projectId &&
+ prefs.projects[projectId]?.currentEnvironment?.id === environmentId
+ ) {
+ return undefined;
+ }
- return prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- dashboardPreferences: updatedPreferences,
- },
+ return {
+ ...prefs,
+ currentProjectId: projectId,
+ projects: {
+ ...prefs.projects,
+ [projectId]: {
+ ...prefs.projects[projectId],
+ currentEnvironment: { id: environmentId },
+ },
+ },
+ };
});
}
@@ -108,19 +176,10 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
return;
}
- const updatedPreferences: DashboardPreferences = {
- ...user.dashboardPreferences,
+ return mutateDashboardPreferences(user.id, (prefs) => ({
+ ...prefs,
currentProjectId: undefined,
- };
-
- return prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- dashboardPreferences: updatedPreferences,
- },
- });
+ }));
}
export async function updateSideMenuPreferences({
@@ -140,48 +199,234 @@ export async function updateSideMenuPreferences({
return;
}
- // Parse with schema to apply defaults, then overlay any new values
- const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ // Parse with schema to apply defaults, then overlay any new values
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+
+ // Build the updated collapsedSections map
+ let updatedCollapsedSections = { ...currentSideMenu.collapsedSections };
+
+ if (sectionCollapsed) {
+ updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed;
+ }
+
+ const updatedSideMenu = SideMenuPreferences.parse({
+ ...currentSideMenu,
+ ...(isCollapsed !== undefined && { isCollapsed }),
+ ...(width !== undefined && { width }),
+ collapsedSections: updatedCollapsedSections,
+ });
+
+ // Only update if something changed
+ const hasCollapsedSectionsChanged =
+ JSON.stringify(updatedSideMenu.collapsedSections) !==
+ JSON.stringify(currentSideMenu.collapsedSections);
+
+ if (
+ updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
+ updatedSideMenu.width === currentSideMenu.width &&
+ !hasCollapsedSectionsChanged
+ ) {
+ return undefined;
+ }
- // Build the updated collapsedSections map
- let updatedCollapsedSections = { ...currentSideMenu.collapsedSections };
+ return { ...prefs, sideMenu: updatedSideMenu };
+ });
+}
- if (sectionCollapsed) {
- updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed;
+/** The most favorites a user can save; a sanity cap, not a product limit. */
+const MAX_FAVORITES = 50;
+
+export async function addFavorite({
+ user,
+ favorite,
+}: {
+ user: UserFromSession;
+ favorite: FavoritePage;
+}) {
+ if (user.isImpersonating) {
+ return;
}
- const updatedSideMenu = SideMenuPreferences.parse({
- ...currentSideMenu,
- ...(isCollapsed !== undefined && { isCollapsed }),
- ...(width !== undefined && { width }),
- collapsedSections: updatedCollapsedSections,
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+ const favorites = currentSideMenu.favorites ?? [];
+
+ // The star is a toggle keyed on the exact URL, so an existing entry means we're already done
+ if (favorites.some((f) => f.url === favorite.url)) {
+ return undefined;
+ }
+
+ if (favorites.length >= MAX_FAVORITES) {
+ return undefined;
+ }
+
+ // Newest favorites go to the top of the section
+ return {
+ ...prefs,
+ sideMenu: { ...currentSideMenu, favorites: [favorite, ...favorites] },
+ };
});
+}
- // Only update if something changed
- const hasCollapsedSectionsChanged =
- JSON.stringify(updatedSideMenu.collapsedSections) !==
- JSON.stringify(currentSideMenu.collapsedSections);
+export async function removeFavorite({ user, id }: { user: UserFromSession; id: string }) {
+ if (user.isImpersonating) {
+ return;
+ }
- if (
- updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
- updatedSideMenu.width === currentSideMenu.width &&
- !hasCollapsedSectionsChanged
- ) {
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+ const favorites = currentSideMenu.favorites ?? [];
+ const remaining = favorites.filter((f) => f.id !== id);
+
+ if (remaining.length === favorites.length) {
+ return undefined;
+ }
+
+ return {
+ ...prefs,
+ sideMenu: {
+ ...currentSideMenu,
+ favorites: remaining.length > 0 ? remaining : undefined,
+ },
+ };
+ });
+}
+
+/**
+ * Remove any favorites whose URL contains the given substring. Used when the favorited entity
+ * itself is deleted (e.g. a custom dashboard's friendly id) so the side menu doesn't keep a
+ * dead link.
+ */
+export async function removeFavoritesByUrlSubstring({
+ user,
+ substring,
+}: {
+ user: UserFromSession;
+ substring: string;
+}) {
+ if (user.isImpersonating) {
return;
}
- const updatedPreferences: DashboardPreferences = {
- ...user.dashboardPreferences,
- sideMenu: updatedSideMenu,
- };
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+ const favorites = currentSideMenu.favorites ?? [];
+ const remaining = favorites.filter((favorite) => !favorite.url.includes(substring));
- return prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- dashboardPreferences: updatedPreferences,
- },
+ if (remaining.length === favorites.length) {
+ return undefined;
+ }
+
+ return {
+ ...prefs,
+ sideMenu: {
+ ...currentSideMenu,
+ favorites: remaining.length > 0 ? remaining : undefined,
+ },
+ };
+ });
+}
+
+export async function renameFavorite({
+ user,
+ id,
+ label,
+}: {
+ user: UserFromSession;
+ id: string;
+ label: string;
+}) {
+ if (user.isImpersonating) {
+ return;
+ }
+
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+ const favorites = currentSideMenu.favorites ?? [];
+
+ const favorite = favorites.find((f) => f.id === id);
+ if (!favorite || favorite.label === label) {
+ return undefined;
+ }
+
+ return {
+ ...prefs,
+ sideMenu: {
+ ...currentSideMenu,
+ favorites: favorites.map((f) => (f.id === id ? { ...f, label } : f)),
+ },
+ };
+ });
+}
+
+export async function updateSideMenuCustomization({
+ user,
+ sectionOrder,
+ hiddenItems,
+ sectionItemOrder,
+ favorites,
+ removedFavoriteIds,
+}: {
+ user: UserFromSession;
+ /** undefined = leave unchanged, null = reset to default */
+ sectionOrder?: string[] | null;
+ /** undefined = leave unchanged, null = reset to default */
+ hiddenItems?: Record | null;
+ /** undefined = leave unchanged, null = reset to default */
+ sectionItemOrder?: Record | null;
+ /** Full favorites arrangement: new order + labels. undefined = leave unchanged. */
+ favorites?: Array<{ id: string; label: string }>;
+ /** Favorites deleted from the customize modal. */
+ removedFavoriteIds?: string[];
+}) {
+ if (user.isImpersonating) {
+ return;
+ }
+
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+ const next: SideMenuPreferences = { ...currentSideMenu };
+
+ if (sectionOrder !== undefined) {
+ next.sectionOrder = sectionOrder && sectionOrder.length > 0 ? sectionOrder : undefined;
+ }
+
+ if (hiddenItems !== undefined) {
+ next.hiddenItems =
+ hiddenItems && Object.keys(hiddenItems).length > 0 ? hiddenItems : undefined;
+ }
+
+ if (sectionItemOrder !== undefined) {
+ next.sectionItemOrder =
+ sectionItemOrder && Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : undefined;
+ }
+
+ if (favorites !== undefined || removedFavoriteIds !== undefined) {
+ const removed = new Set(removedFavoriteIds ?? []);
+ const current = (currentSideMenu.favorites ?? []).filter((f) => !removed.has(f.id));
+ const byId = new Map(current.map((f) => [f.id, f]));
+ const rearranged: FavoritePage[] = [];
+
+ for (const { id, label } of favorites ?? []) {
+ const existing = byId.get(id);
+ if (!existing) continue;
+ const trimmed = label.trim();
+ rearranged.push({ ...existing, label: trimmed.length > 0 ? trimmed : existing.label });
+ byId.delete(id);
+ }
+
+ // Favorites the payload didn't mention (e.g. added mid-edit) keep their place at the end
+ for (const favorite of current) {
+ if (byId.has(favorite.id)) {
+ rearranged.push(favorite);
+ }
+ }
+
+ next.favorites = rearranged.length > 0 ? rearranged : undefined;
+ }
+
+ return { ...prefs, sideMenu: SideMenuPreferences.parse(next) };
});
}
@@ -209,34 +454,24 @@ export async function updateItemOrder({
return;
}
- const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
- const currentOrg = currentSideMenu.organizations?.[organizationId];
-
- const updatedSideMenu = SideMenuPreferences.parse({
- ...currentSideMenu,
- organizations: {
- ...currentSideMenu.organizations,
- [organizationId]: {
- ...currentOrg,
- orderedItems: {
- ...currentOrg?.orderedItems,
- [listId]: order,
+ return mutateDashboardPreferences(user.id, (prefs) => {
+ const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
+ const currentOrg = currentSideMenu.organizations?.[organizationId];
+
+ const updatedSideMenu = SideMenuPreferences.parse({
+ ...currentSideMenu,
+ organizations: {
+ ...currentSideMenu.organizations,
+ [organizationId]: {
+ ...currentOrg,
+ orderedItems: {
+ ...currentOrg?.orderedItems,
+ [listId]: order,
+ },
},
},
- },
- });
+ });
- const updatedPreferences: DashboardPreferences = {
- ...user.dashboardPreferences,
- sideMenu: updatedSideMenu,
- };
-
- return prisma.user.update({
- where: {
- id: user.id,
- },
- data: {
- dashboardPreferences: updatedPreferences,
- },
+ return { ...prefs, sideMenu: updatedSideMenu };
});
}