diff --git a/.server-changes/favorite-pages-and-sidebar-customization.md b/.server-changes/favorite-pages-and-sidebar-customization.md new file mode 100644 index 00000000000..d4468b4df51 --- /dev/null +++ b/.server-changes/favorite-pages-and-sidebar-customization.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Favorite any dashboard page to a new Favorites section in the side menu, and customize the sidebar by renaming favorites, hiding items, and reordering items and sections. diff --git a/apps/webapp/app/assets/icons/CrossIcon.tsx b/apps/webapp/app/assets/icons/CrossIcon.tsx new file mode 100644 index 00000000000..fa0c714602f --- /dev/null +++ b/apps/webapp/app/assets/icons/CrossIcon.tsx @@ -0,0 +1,14 @@ +export function CrossIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EyeClosedIcon.tsx b/apps/webapp/app/assets/icons/EyeClosedIcon.tsx new file mode 100644 index 00000000000..c83d2499267 --- /dev/null +++ b/apps/webapp/app/assets/icons/EyeClosedIcon.tsx @@ -0,0 +1,35 @@ +export function EyeClosedIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EyeOpenIcon.tsx b/apps/webapp/app/assets/icons/EyeOpenIcon.tsx new file mode 100644 index 00000000000..e00cfa0fcf5 --- /dev/null +++ b/apps/webapp/app/assets/icons/EyeOpenIcon.tsx @@ -0,0 +1,27 @@ +export function EyeOpenIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RenameIcon.tsx b/apps/webapp/app/assets/icons/RenameIcon.tsx new file mode 100644 index 00000000000..6514e0b09cd --- /dev/null +++ b/apps/webapp/app/assets/icons/RenameIcon.tsx @@ -0,0 +1,27 @@ +export function RenameIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx new file mode 100644 index 00000000000..0960e4a2011 --- /dev/null +++ b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx @@ -0,0 +1,26 @@ +export function SidebarCustomizeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 87e5d08f371..c4ce2db6d0f 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -73,6 +73,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx new file mode 100644 index 00000000000..d2c18442e3b --- /dev/null +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -0,0 +1,506 @@ +import { DialogClose } from "@radix-ui/react-dialog"; +import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/20/solid"; +import { GripVerticalIcon } from "lucide-react"; +import { useState } from "react"; +import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout"; +import { CrossIcon } from "~/assets/icons/CrossIcon"; +import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon"; +import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; +import { FormError } from "../primitives/FormError"; +import { Header3 } from "../primitives/Headers"; +import { Icon, type RenderIcon } from "../primitives/Icon"; +import { Input } from "../primitives/Input"; +import { isItemHidden, orderByPreference } from "./sideMenuTypes"; + +export type CustomizeSidebarItem = { + id: string; + name: string; + icon: RenderIcon; + iconClassName?: string; + defaultHidden?: boolean; + /** Favorites get an inline-editable name in the modal. */ + isFavorite?: boolean; +}; + +export type CustomizeSidebarSection = { + id: string; + title: string; + /** Items in DEFAULT order (favorites: saved order — that is their default). */ + items: CustomizeSidebarItem[]; +}; + +type SavedPreferences = { + sectionOrder?: string[]; + hiddenItems?: Record; + sectionItemOrder?: Record; +}; + +/** What Confirm produces; null clears a stored preference back to its default. */ +export type SidebarCustomizationPayload = { + sectionOrder: string[] | null; + hiddenItems: Record | null; + sectionItemOrder: Record | null; + favorites?: Array<{ id: string; label: string }>; + removedFavoriteIds?: string[]; +}; + +type DialogState = { + sectionOrder: string[]; + /** section id -> item ids in order */ + itemOrders: Record; + /** item id -> effective hidden */ + hidden: Record; + /** favorite id -> label being edited */ + labels: Record; + /** favorite ids staged for removal; applied on Confirm */ + removed: string[]; +}; + +const FAVORITES_SECTION_ID = "favorites"; +const ROW_HEIGHT = 44; + +function buildState( + sections: CustomizeSidebarSection[], + prefs: SavedPreferences | undefined +): DialogState { + const orderedSections = prefs ? orderByPreference(sections, prefs.sectionOrder) : sections; + + const itemOrders: Record = {}; + const hidden: Record = {}; + const labels: Record = {}; + + for (const section of sections) { + // Favorites' array order is canonical (already applied), so saved item order only applies to + // the static sections. + const orderedItems = + prefs && section.id !== FAVORITES_SECTION_ID + ? orderByPreference(section.items, prefs.sectionItemOrder?.[section.id]) + : section.items; + itemOrders[section.id] = orderedItems.map((item) => item.id); + + for (const item of section.items) { + hidden[item.id] = prefs + ? isItemHidden(item, prefs.hiddenItems) + : (item.defaultHidden ?? false); + if (item.isFavorite) { + labels[item.id] = item.name; + } + } + } + + return { + sectionOrder: orderedSections.map((section) => section.id), + itemOrders, + hidden, + labels, + removed: [], + }; +} + +function arraysEqual(a: string[], b: string[]) { + return a.length === b.length && a.every((value, index) => value === b[index]); +} + +/** + * The "Customize sidebar" modal: reorder sections (arrows), reorder items (drag), hide/show items + * (eye), and rename favorites inline. Nothing is applied until Confirm; Reset restores the default + * layout without touching which pages are favorited. + */ +export function CustomizeSidebarDialog({ + sections, + prefs, + onConfirm, + isConfirming, + confirmError, +}: { + sections: CustomizeSidebarSection[]; + prefs: SavedPreferences | undefined; + /** + * Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. The + * parent submits the payload and closes the dialog once the save lands (or reports back via + * `confirmError`), so a failed save never silently reads as a successful one. + */ + onConfirm: (payload: SidebarCustomizationPayload) => void; + /** True from Confirm until the save (and the refreshed side menu data) lands. */ + isConfirming: boolean; + /** Save failure to surface next to Confirm; the dialog stays open for a retry. */ + confirmError?: string; +}) { + const [state, setState] = useState(() => buildState(sections, prefs)); + + // The Favorites section disappears with its last staged-removed favorite, matching the side + // menu (which hides the section when empty) + const displayedSections = (current: DialogState) => + current.sectionOrder + .map((id) => sections.find((section) => section.id === id)) + .filter((section): section is CustomizeSidebarSection => section !== undefined) + .filter( + (section) => + section.id !== FAVORITES_SECTION_ID || + section.items.some((item) => !current.removed.includes(item.id)) + ); + + const orderedSections = displayedSections(state); + + const moveSection = (sectionId: string, direction: -1 | 1) => { + setState((current) => { + // Swap with the DISPLAYED neighbor: a hidden Favorites entry may still sit in + // sectionOrder between two visible sections + const displayed = displayedSections(current).map((section) => section.id); + const neighborId = displayed[displayed.indexOf(sectionId) + direction]; + if (!neighborId) return current; + const next = [...current.sectionOrder]; + const a = next.indexOf(sectionId); + const b = next.indexOf(neighborId); + [next[a], next[b]] = [next[b], next[a]]; + return { ...current, sectionOrder: next }; + }); + }; + + const reorderItems = (sectionId: string, itemIds: string[]) => { + setState((current) => ({ + ...current, + itemOrders: { ...current.itemOrders, [sectionId]: itemIds }, + })); + }; + + const toggleHidden = (itemId: string) => { + setState((current) => ({ + ...current, + hidden: { ...current.hidden, [itemId]: !current.hidden[itemId] }, + })); + }; + + const setLabel = (itemId: string, label: string) => { + setState((current) => ({ ...current, labels: { ...current.labels, [itemId]: label } })); + }; + + const removeFavorite = (itemId: string) => { + setState((current) => ({ ...current, removed: [...current.removed, itemId] })); + }; + + // Reset restores the default layout (positions + visibility) but never touches favorite names + // or staged removals; Cancel is the way out of those + const reset = () => + setState((current) => ({ + ...buildState(sections, undefined), + labels: current.labels, + removed: current.removed, + })); + + const hasBlankLabels = sections.some((section) => + section.items.some( + (item) => + item.isFavorite && + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim().length === 0 + ) + ); + + const confirm = () => { + const defaults = buildState(sections, undefined); + + const hiddenOverrides: Record = {}; + for (const section of sections) { + for (const item of section.items) { + if (state.removed.includes(item.id)) continue; + const isHidden = state.hidden[item.id] ?? false; + if (isHidden !== (item.defaultHidden ?? false)) { + hiddenOverrides[item.id] = isHidden; + } + } + } + + const sectionItemOrder: Record = {}; + for (const section of sections) { + if (section.id === FAVORITES_SECTION_ID) continue; + const order = state.itemOrders[section.id] ?? []; + if (!arraysEqual(order, defaults.itemOrders[section.id] ?? [])) { + sectionItemOrder[section.id] = order; + } + } + + const favoritesSection = sections.find((section) => section.id === FAVORITES_SECTION_ID); + const favoriteOrder = (state.itemOrders[FAVORITES_SECTION_ID] ?? []).filter( + (id) => !state.removed.includes(id) + ); + const favoritesChanged = + favoritesSection !== undefined && + (state.removed.length > 0 || + !arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) || + favoritesSection.items.some( + (item) => + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim() !== item.name + )); + + // Parts equal to the defaults are sent as null so the stored preference is cleared, not pinned + const payload: SidebarCustomizationPayload = { + sectionOrder: arraysEqual(state.sectionOrder, defaults.sectionOrder) + ? null + : state.sectionOrder, + hiddenItems: Object.keys(hiddenOverrides).length > 0 ? hiddenOverrides : null, + sectionItemOrder: Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : null, + favorites: favoritesChanged + ? favoriteOrder.map((id) => ({ id, label: state.labels[id] ?? "" })) + : undefined, + removedFavoriteIds: state.removed.length > 0 ? state.removed : undefined, + }; + + onConfirm(payload); + }; + + return ( + + Customize sidebar + {/* Bleeds through the container's right padding (-mr-4/pr-4) so the scrollbar sits at the + modal edge, and through the vertical grid gaps (-mt-1.25/-mb-4) so the scrollport (and + scrollbar) starts at the header divider and ends at the footer border. pt-3/pb-3 are + INSIDE the scrollport: resting gaps around the list that content scrolls through. */} +
+ {orderedSections.map((section, index) => ( +
+
+ {section.title} +
+ moveSection(section.id, -1)} + > + + + moveSection(section.id, 1)} + > + + +
+
+ item.id)).filter( + (id) => !state.removed.includes(id) + )} + hidden={state.hidden} + labels={state.labels} + onReorder={(itemIds) => reorderItems(section.id, itemIds)} + onToggleHidden={toggleHidden} + onLabelChange={setLabel} + onRemove={removeFavorite} + /> +
+ ))} +
+ {/* Negative margins stretch the top divider across the modal's full width */} + +
+ + + + +
+
+ {confirmError && !isConfirming && ( + {confirmError} + )} + +
+
+
+ ); +} + +function SectionMoveButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SectionItemList({ + section, + order, + hidden, + labels, + onReorder, + onToggleHidden, + onLabelChange, + onRemove, +}: { + section: CustomizeSidebarSection; + order: string[]; + hidden: Record; + labels: Record; + onReorder: (itemIds: string[]) => void; + onToggleHidden: (itemId: string) => void; + onLabelChange: (itemId: string, label: string) => void; + onRemove: (itemId: string) => void; +}) { + const { width, containerRef } = useContainerWidth({ initialWidth: 416 }); + + const items = order + .map((id) => section.items.find((item) => item.id === id)) + .filter((item): item is CustomizeSidebarItem => item !== undefined); + + const layout = items.map((item, index) => ({ i: item.id, x: 0, y: index, w: 1, h: 1 })); + + const handleDragStop = (nextLayout: Layout) => { + const sorted = [...nextLayout].sort((a, b) => a.y - b.y).map((entry) => entry.i); + if (!arraysEqual(sorted, order)) { + onReorder(sorted); + } + }; + + const renderRow = (item: CustomizeSidebarItem, options: { draggable: boolean }) => ( + onToggleHidden(item.id)} + onLabelChange={(label) => onLabelChange(item.id, label)} + onRemove={() => onRemove(item.id)} + /> + ); + + return ( +
}> + {items.length >= 2 ? ( + + {items.map((item) => ( +
{renderRow(item, { draggable: true })}
+ ))} +
+ ) : ( + items.map((item) =>
{renderRow(item, { draggable: false })}
) + )} +
+ ); +} + +function ModalItemRow({ + item, + isHidden, + label, + draggable, + onToggleHidden, + onLabelChange, + onRemove, +}: { + item: CustomizeSidebarItem; + isHidden: boolean; + label: string | undefined; + draggable: boolean; + onToggleHidden: () => void; + onLabelChange: (label: string) => void; + onRemove: () => void; +}) { + return ( +
+
+ + {item.isFavorite ? ( + <> + onLabelChange(e.target.value)} + variant="medium" + maxLength={64} + containerClassName="max-w-60" + aria-label={`Rename ${item.name}`} + /> + {(label ?? item.name).trim().length === 0 && ( + Name can't be blank + )} + + ) : ( + {item.name} + )} +
+
+ {item.isFavorite && ( + + )} + + {draggable ? ( +
+ +
+ ) : ( +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx index 7f3b6e3bacb..39a7de00c1d 100644 --- a/apps/webapp/app/components/navigation/DashboardList.tsx +++ b/apps/webapp/app/components/navigation/DashboardList.tsx @@ -184,6 +184,7 @@ function DashboardChildMenuItem({ to={item.path} isCollapsed={isCollapsed} disableIconHover + yieldActiveToFavorite action={ showDragHandle ? (
diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx new file mode 100644 index 00000000000..c0d03453b38 --- /dev/null +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -0,0 +1,144 @@ +import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline"; +import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid"; +import { useFetcher, useLocation, useSearchParams } from "@remix-run/react"; +import { useEffect } from "react"; +import { useIsImpersonating } from "~/hooks/useOrganizations"; +import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { useOptionalUser } from "~/hooks/useUser"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { ShortcutKey } from "../primitives/ShortcutKey"; +import { SimpleTooltip } from "../primitives/Tooltip"; +import { + buildFavoriteLabel, + canonicalFavoriteUrl, + FAVORITE_SEARCH_PARAM, + FAVORITES_ACTION_PATH, + favoritePageUrl, + resolvePageMeta, + useFavorites, +} from "./favoritePages"; + +/** + * The star in the page header that favorites the current page (full URL, including filters and + * tabs) to the side menu. Toggled by click or Option+F. + */ +export function FavoritePageButton({ + pageTitle, + className, +}: { + pageTitle?: string; + className?: string; +}) { + const user = useOptionalUser(); + const isImpersonating = useIsImpersonating(); + const location = useLocation(); + const favorites = useFavorites(); + const fetcher = useFetcher(); + const [, setSearchParams] = useSearchParams(); + + // The marker param and pagination position never count toward URL identity, so paging through + // a favorited view keeps the same favorite (and never saves a soon-stale cursor) + const url = favoritePageUrl(location.pathname, location.search); + + // A marker that isn't one of this user's favorites came from a shared link (or a favorite + // that's since been removed): clean it from the URL so the page behaves like a normal visit. + const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); + const hasForeignMarker = + user !== undefined && marker !== null && !favorites.some((f) => f.id === marker); + + useEffect(() => { + if (!hasForeignMarker) return; + setSearchParams( + (previous) => { + const next = new URLSearchParams(previous); + next.delete(FAVORITE_SEARCH_PARAM); + return next; + }, + { replace: true, preventScrollReset: true } + ); + }, [hasForeignMarker, setSearchParams]); + const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url); + const isFavorited = existing !== undefined; + // The tooltip names the favorite: its custom name once saved, else the label saving would use + // (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d") + const pageName = + existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle); + + const toggle = () => { + if (existing) { + fetcher.submit( + { intent: "remove", id: existing.id }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } else { + fetcher.submit( + { + intent: "add", + id: crypto.randomUUID(), + url, + label: buildFavoriteLabel(location.pathname, location.search, pageTitle), + icon: resolvePageMeta(location.pathname).icon, + }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } + }; + + const showButton = user !== undefined && !isImpersonating; + + // Option+F reports event.key "ƒ" on macOS, but the hotkeys matcher falls back to the physical + // event.code ("KeyF"), so the standard hook captures it; exact modifier matching keeps the + // bare "f" filter shortcut separate. + useShortcutKeys({ + shortcut: { key: "f", modifiers: ["alt"] }, + action: (event) => { + event.preventDefault(); + toggle(); + }, + disabled: !showButton, + }); + + if (!showButton) { + return null; + } + + const tooltipLabel = isFavorited + ? `Remove ${pageName} from favorites` + : `Add ${pageName} to favorites`; + + return ( + +
@@ -1097,10 +1270,274 @@ export function SideMenu({
+ { + setCustomizeOpen(open); + if (!open) { + // Cancel/ESC during a pending confirm abandons the wait; a still-running save is + // harmless (the menu revalidates whenever it lands) + setCustomizeConfirmPending(false); + setCustomizeError(undefined); + } + }} + > + {/* Mounted only while open so the modal state re-seeds from current preferences each time */} + {isCustomizeOpen && ( + + )} + ); } +/** A section built from {@link SideMenuItemConfig}s with the user's order/hidden prefs applied. */ +function CustomizableSideMenuSection({ + section, + itemOrder, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, + onCustomize, +}: { + section: SideMenuSectionConfig; + itemOrder: string[] | undefined; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; +}) { + const orderedItems = orderByPreference(section.items, itemOrder); + const visibleItems = orderedItems.filter((item) => !isItemHidden(item, hiddenItems)); + const moreItems = orderedItems.filter((item) => isItemHidden(item, hiddenItems)); + + return ( + + {visibleItems.map((item) => ( + + + {item.after} + + ))} + {moreItems.length > 0 && ( + ({ + id: item.id, + name: item.name, + icon: item.icon, + to: item.to, + }))} + isCollapsed={isCollapsed} + onCustomize={onCustomize} + /> + )} + + ); +} + +/** The user's favorited pages; hidden favorites collapse into the trailing "More" item. */ +function FavoritesSideMenuSection({ + favorites, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, + onCustomize, + onRemoveFavorite, + onRenameFavorite, +}: { + favorites: FavoritePage[]; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; + onRemoveFavorite: (id: string) => void; + onRenameFavorite: (id: string, label: string) => void; +}) { + const visible = favorites.filter((favorite) => !(hiddenItems?.[favorite.id] ?? false)); + const hidden = favorites.filter((favorite) => hiddenItems?.[favorite.id] ?? false); + + return ( + + {visible.map((favorite) => ( + + ))} + {hidden.length > 0 && ( + ({ + id: favorite.id, + name: favorite.label, + icon: favoritePageIcon(favorite.icon), + iconClassName: favoritePageIconClassName(favorite.icon), + to: favoriteLinkTo(favorite), + }))} + isCollapsed={isCollapsed} + onCustomize={onCustomize} + /> + )} + + ); +} + +/** + * The trailing "More" item of a section: a popover listing that section's hidden items. Only + * rendered when the section has hidden items. + */ +function SideMenuMoreItem({ + items, + isCollapsed, + onCustomize, +}: { + items: Array<{ id: string; name: string; icon: RenderIcon; iconClassName?: string; to: string }>; + isCollapsed: boolean; + /** Undefined when customizing isn't offered (impersonated sessions can't persist a layout). */ + onCustomize: (() => void) | undefined; +}) { + const [isOpen, setOpen] = useState(false); + const navigation = useNavigation(); + + // Watch search too: navigating to a favorite can change only the search on the same pathname + useEffect(() => { + setOpen(false); + }, [navigation.location?.pathname, navigation.location?.search]); + + return ( + + + + + More + + + } + content="More" + side="right" + sideOffset={8} + hidden={!isCollapsed} + asChild + tabbable + disableHoverableContent + /> + +
+ {items.map((item) => ( + + ))} +
+ {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} + {onCustomize && ( +
+ { + setOpen(false); + onCustomize(); + }} + /> +
+ )} +
+
+ ); +} + +/** The ellipsis on section headers (visible on hover) opening the sidebar customization menu. */ +function SectionHeaderMenu({ onCustomize }: { onCustomize: () => void }) { + const [isOpen, setOpen] = useState(false); + + return ( + + + + + + {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} +
+ { + setOpen(false); + onCustomize(); + }} + /> +
+
+
+ ); +} + function V3DeprecationPanel({ isCollapsed, isV3, diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index f17051266d4..035fcc54370 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -3,6 +3,9 @@ import { type ButtonHTMLAttributes, forwardRef, type ReactNode, + useEffect, + useRef, + useState, } from "react"; import { Link } from "@remix-run/react"; import { motion } from "framer-motion"; @@ -10,6 +13,60 @@ import { usePathName } from "~/hooks/usePathName"; import { cn } from "~/utils/cn"; import { type RenderIcon, Icon } from "../primitives/Icon"; import { SimpleTooltip } from "../primitives/Tooltip"; +import { useActiveFavoriteId } from "./favoritePages"; + +/** Right-edge fade shown instead of a hard clip, only while the label actually overflows. */ +const LABEL_OVERFLOW_MASK = "linear-gradient(to right, black calc(100% - 1.5rem), transparent)"; + +/** + * A menu label that fades out at its right edge when (and only when) the text overflows. Text + * that fits renders exactly as before, with no mask. Overflow is re-measured when the element + * resizes (e.g. while drag-resizing the menu) and when the label text changes. + */ +export function SideMenuLabel({ + children, + className, + style, +}: { + children: ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const ref = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const checkRef = useRef<() => void>(() => {}); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth + 1); + checkRef.current = check; + check(); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // Re-measure when the text changes (a rename can flip overflow without resizing the element) + useEffect(() => { + checkRef.current(); + }, [children]); + + return ( + + {children} + + ); +} export function SideMenuItem({ icon, @@ -27,6 +84,8 @@ export function SideMenuItem({ action, disableIconHover = false, indented = false, + isActive: isActiveOverride, + yieldActiveToFavorite = false, "data-action": dataAction, }: { icon?: RenderIcon; @@ -45,10 +104,24 @@ export function SideMenuItem({ disableIconHover?: boolean; /** Indented variant for grouped sub-items; only applied when the menu is expanded. */ indented?: boolean; + /** Overrides the default pathname === to active check (e.g. favorites match on full URL). */ + isActive?: boolean; + /** + * In menus that render the Favorites section (the main project menu), an active favorite owns + * the highlight, so the plain item yields its active state to it. Menus without a favorites + * list (org settings, account) must not set this: they have no favorite item to carry the + * highlight instead. + */ + yieldActiveToFavorite?: boolean; "data-action"?: string; }) { const pathName = usePathName(); - const isActive = pathName === to; + // Only the user's OWN favorites own a view (via the marker param); markers from shared links + // don't count (see useActiveFavoriteId). + const activeFavoriteId = useActiveFavoriteId(); + const isActive = + isActiveOverride ?? + (pathName === to && (!yieldActiveToFavorite || activeFavoriteId === undefined)); const isIndented = indented && !isCollapsed; @@ -92,14 +165,14 @@ export function SideMenuItem({ className="flex w-full min-w-0 items-center justify-between" style={{ opacity: "var(--sm-label-opacity, 1)" }} > - {name} - + {badge && !isCollapsed && (
{badge}
)} @@ -189,9 +262,9 @@ export const SideMenuItemButton = forwardRef< icon={icon} className="size-5 shrink-0 text-text-dimmed group-hover/menuitem:text-text-bright" /> - + {name} - + {trailing && {trailing}} ); diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 8225d9e701c..4f0d37e2716 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -12,6 +12,11 @@ type Props = { itemSpacingClassName?: string; /** Optional action element (e.g., + button) to render on the right side of the header */ headerAction?: React.ReactNode; + /** + * Optional menu (e.g. an ellipsis popover) overlaid on the right of the header. Only visible + * while hovering the header row, or while its popover is open. + */ + headerMenu?: React.ReactNode; }; /** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */ @@ -23,6 +28,7 @@ export function SideMenuSection({ isSideMenuCollapsed = false, itemSpacingClassName = "space-y-px", headerAction, + headerMenu, }: Props) { const [isCollapsed, setIsCollapsed] = useState(initialCollapsed); const contentRef = useRef(null); @@ -45,7 +51,7 @@ export function SideMenuSection({ return (
{/* Header container - stays in DOM to preserve height */} -
+
{/* Header fades out as the menu narrows via --sm-label-opacity (falls back to 1 unset). Hover background and text color snap (no transition), matching the nav items. @@ -53,8 +59,9 @@ export function SideMenuSection({ + {headerMenu !== undefined && + !isSideMenuCollapsed && ( + // Outer div fades with the labels (inline style would defeat the hover opacity classes + // on the inner div, so they're split). +
+ {/* focus-within keeps the trigger visible for keyboard users tabbing onto it */} +
+ {headerMenu} +
+
+ )} {/* Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded. */} diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx new file mode 100644 index 00000000000..4439aadedfd --- /dev/null +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -0,0 +1,511 @@ +import { BeakerIcon } from "@heroicons/react/24/outline"; +import { IconChartHistogram } from "@tabler/icons-react"; +import { useFetchers, useLocation } from "@remix-run/react"; +import { ClockIcon } from "~/assets/icons/ClockIcon"; +import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon"; +import { TaskIconSmall } from "~/assets/icons/TaskIcon"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon"; +import { AIPenIcon } from "~/assets/icons/AIPenIcon"; +import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon"; +import { BatchesIcon } from "~/assets/icons/BatchesIcon"; +import { BellIcon } from "~/assets/icons/BellIcon"; +import { Box3DIcon } from "~/assets/icons/Box3DIcon"; +import { BugIcon } from "~/assets/icons/BugIcon"; +import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon"; +import { ChartArrowIcon } from "~/assets/icons/ChartArrowIcon"; +import { ChartBarIcon } from "~/assets/icons/ChartBarIcon"; +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; +import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon"; +import { CreditCardIcon } from "~/assets/icons/CreditCardIcon"; +import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon"; +import { DialIcon } from "~/assets/icons/DialIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon"; +import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon"; +import { IDIcon } from "~/assets/icons/IDIcon"; +import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon"; +import { KeyIcon } from "~/assets/icons/KeyIcon"; +import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon"; +import { LogsIcon } from "~/assets/icons/LogsIcon"; +import { PadlockIcon } from "~/assets/icons/PadlockIcon"; +import { QueuesIcon } from "~/assets/icons/QueuesIcon"; +import { RolesIcon } from "~/assets/icons/RolesIcon"; +import { RunsIcon } from "~/assets/icons/RunsIcon"; +import { ShieldIcon } from "~/assets/icons/ShieldIcon"; +import { SlackIcon } from "~/assets/icons/SlackIcon"; +import { SlidersIcon } from "~/assets/icons/SlidersIcon"; +import { StarIcon } from "~/assets/icons/StarIcon"; +import { TasksIcon } from "~/assets/icons/TasksIcon"; +import { UsageIcon } from "~/assets/icons/UsageIcon"; +import { UserGroupIcon } from "~/assets/icons/UserGroupIcon"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import { VercelLogo } from "~/components/integrations/VercelLogo"; +import { useOptionalUser } from "~/hooks/useUser"; +import { type FavoritePage } from "~/services/dashboardPreferences.server"; +import { type RenderIcon } from "../primitives/Icon"; + +export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites"; + +/** + * Marker search param appended to favorite links. It makes a favorited URL distinct from its + * plain counterpart, so only the favorite (never the matching main menu item) highlights as + * active, and the marker identifies WHICH favorite when several share a pathname. + */ +export const FAVORITE_SEARCH_PARAM = "fav"; + +/** + * Icons a favorited page can be saved with, keyed by a stable string so preferences never store + * component references, plus the icon color used when the favorite is the active page and an + * optional size override for icons drawn without internal padding (brand logos). Unknown keys + * fall back to the star. + */ +const FAVORITE_PAGE_ICONS: Record< + string, + { icon: RenderIcon; activeColor: string; className?: string } +> = { + tasks: { icon: TasksIcon, activeColor: "text-tasks" }, + // Task detail pages carry their task-type icon, matching TaskTriggerSourceIcon + "task-standard": { icon: TaskIconSmall, activeColor: "text-tasks" }, + "task-scheduled": { icon: ClockIcon, activeColor: "text-schedules" }, + "task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" }, + runs: { icon: RunsIcon, activeColor: "text-runs" }, + sessions: { icon: AIChatIcon, activeColor: "text-sessions" }, + prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" }, + models: { icon: Box3DIcon, activeColor: "text-models" }, + logs: { icon: LogsIcon, activeColor: "text-logs" }, + errors: { icon: BugIcon, activeColor: "text-errors" }, + query: { icon: CodeSquareIcon, activeColor: "text-query" }, + queues: { icon: QueuesIcon, activeColor: "text-queues" }, + dashboards: { icon: ChartBarIcon, activeColor: "text-metrics" }, + "run-metrics": { icon: ChartArrowIcon, activeColor: "text-runs" }, + "ai-metrics": { icon: AIMetricsIcon, activeColor: "text-aiMetrics" }, + "custom-dashboard": { icon: IconChartHistogram, activeColor: "text-text-bright" }, + deployments: { icon: DeploymentsIcon, activeColor: "text-deployments" }, + "environment-variables": { icon: IDIcon, activeColor: "text-environmentVariables" }, + branches: { icon: BranchEnvironmentIconSmall, activeColor: "text-previewBranches" }, + regions: { icon: GlobeLinesIcon, activeColor: "text-regions" }, + waitpoints: { icon: WaitpointTokenIcon, activeColor: "text-sky-500" }, + batches: { icon: BatchesIcon, activeColor: "text-batches" }, + "bulk-actions": { icon: ListCheckedIcon, activeColor: "text-text-bright" }, + apikeys: { icon: KeyIcon, activeColor: "text-text-bright" }, + alerts: { icon: BellIcon, activeColor: "text-text-bright" }, + concurrency: { icon: ConcurrencyIcon, activeColor: "text-text-bright" }, + limits: { icon: DialIcon, activeColor: "text-text-bright" }, + schedules: { icon: ClockIcon, activeColor: "text-schedules" }, + test: { icon: BeakerIcon, activeColor: "text-text-bright" }, + "project-settings": { icon: SlidersIcon, activeColor: "text-text-bright" }, + integrations: { icon: IntegrationsIcon, activeColor: "text-text-bright" }, + // Brand logos have no internal padding, so they render one step smaller (matching the org menu) + slack: { icon: SlackIcon, activeColor: "text-text-bright", className: "size-4" }, + vercel: { icon: VercelLogo, activeColor: "text-text-bright", className: "size-4" }, + project: { icon: FolderOpenIcon, activeColor: "text-text-bright" }, + "org-settings": { icon: SlidersIcon, activeColor: "text-text-bright" }, + team: { icon: UserGroupIcon, activeColor: "text-text-bright" }, + billing: { icon: CreditCardIcon, activeColor: "text-text-bright" }, + usage: { icon: UsageIcon, activeColor: "text-text-bright" }, + roles: { icon: RolesIcon, activeColor: "text-text-bright" }, + sso: { icon: PadlockIcon, activeColor: "text-text-bright" }, + "private-connections": { icon: ChainLinkIcon, activeColor: "text-text-bright" }, + account: { icon: AvatarCircleIcon, activeColor: "text-text-bright" }, + tokens: { icon: ShieldIcon, activeColor: "text-text-bright" }, + security: { icon: PadlockIcon, activeColor: "text-text-bright" }, + page: { icon: StarIcon, activeColor: "text-text-bright" }, +}; + +export function favoritePageIcon(iconKey: string | undefined): RenderIcon { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.icon : undefined) ?? StarIcon; +} + +export function favoritePageActiveColor(iconKey: string | undefined): string { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.activeColor : undefined) ?? "text-text-bright"; +} + +/** Size override for favorite icons that need one (see FAVORITE_PAGE_ICONS). */ +export function favoritePageIconClassName(iconKey: string | undefined): string | undefined { + return iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.className : undefined; +} + +/** Href for a favorite: its saved URL plus the marker param (see FAVORITE_SEARCH_PARAM). */ +export function favoriteLinkTo(favorite: FavoritePage): string { + const [path, search = ""] = favorite.url.split("?"); + const params = new URLSearchParams(search); + params.set(FAVORITE_SEARCH_PARAM, favorite.id); + return `${path}?${params.toString()}`; +} + +/** Pagination position params: never part of a favorite's identity (see favoritePageUrl). */ +const PAGINATION_PARAMS = ["cursor", "direction", "page"]; + +/** + * The canonical URL a favorite saves and matches against: the path and search minus the favorite + * marker (presentation-only) and the pagination position (cursors go stale, and page N of a view + * is not a different view). A favorite pins filters and tabs, never a transient page of them. + */ +export function favoritePageUrl(pathname: string, search: string): string { + const params = new URLSearchParams(search); + params.delete(FAVORITE_SEARCH_PARAM); + for (const param of PAGINATION_PARAMS) { + params.delete(param); + } + const result = params.toString(); + return pathname + (result.length > 0 ? `?${result}` : ""); +} + +/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate + * pagination stripping). */ +export function canonicalFavoriteUrl(url: string): string { + const [pathname, search = ""] = url.split("?"); + return favoritePageUrl(pathname, search); +} + +/** + * A favorite is active only while the URL is the view it saved: its marker param is present AND + * the canonical URL still matches. Changing any filter on the page diverges the URL from the + * favorite, so it deactivates (and the regular menu item takes over) — but paging within the + * view keeps it active, matching what the favorite pins. + */ +export function isFavoriteActive( + favorite: FavoritePage, + pathname: string, + search: string +): boolean { + return ( + new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id && + canonicalFavoriteUrl(favorite.url) === favoritePageUrl(pathname, search) + ); +} + +/** + * The id of the favorite driving the current view: the URL's marker param, but only when it + * belongs to one of the current user's favorites AND the URL still matches that favorite's + * saved view. A marker from someone else's shared link, a removed favorite's stale link, or a + * view whose filters have since been changed resolves to undefined, so regular menu + * highlighting applies. + */ +export function useActiveFavoriteId(): string | undefined { + const location = useLocation(); + const favorites = useFavorites(); + + const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); + if (!marker) return undefined; + const favorite = favorites.find((f) => f.id === marker); + if (!favorite) return undefined; + return isFavoriteActive(favorite, location.pathname, location.search) ? marker : undefined; +} + +type PageMeta = { + /** Key into FAVORITE_PAGE_ICONS. */ + icon: string; + /** The page's name as shown in navigation, e.g. "Queues". */ + name: string; + /** Singular label-prefix for detail pages, e.g. "Queue" -> "Queue: my-queue". */ + singular?: string; + /** + * Entity name taken from the URL, used verbatim as the label. For detail pages whose header + * title is composed JSX (task/agent pages render an icon + slug), so no plain-text title + * reaches the star. The icon already conveys the type, so no prefix is added. + */ + entityName?: string; +}; + +const ENV_PAGE_META: Record = { + "": { icon: "tasks", name: "Tasks", singular: "Task" }, + runs: { icon: "runs", name: "Runs", singular: "Run" }, + sessions: { icon: "sessions", name: "Sessions", singular: "Session" }, + prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" }, + models: { icon: "models", name: "Models", singular: "Model" }, + logs: { icon: "logs", name: "Logs" }, + errors: { icon: "errors", name: "Errors", singular: "Error" }, + query: { icon: "query", name: "Query" }, + queues: { icon: "queues", name: "Queues", singular: "Queue" }, + dashboards: { icon: "dashboards", name: "Dashboards", singular: "Dashboard" }, + deployments: { icon: "deployments", name: "Deploys", singular: "Deploy" }, + "environment-variables": { icon: "environment-variables", name: "Environment variables" }, + branches: { icon: "branches", name: "Preview branches", singular: "Branch" }, + regions: { icon: "regions", name: "Regions" }, + waitpoints: { icon: "waitpoints", name: "Waitpoint tokens", singular: "Waitpoint" }, + batches: { icon: "batches", name: "Batches", singular: "Batch" }, + "bulk-actions": { icon: "bulk-actions", name: "Bulk actions", singular: "Bulk action" }, + apikeys: { icon: "apikeys", name: "API keys" }, + alerts: { icon: "alerts", name: "Alerts", singular: "Alert" }, + concurrency: { icon: "concurrency", name: "Concurrency" }, + limits: { icon: "limits", name: "Limits" }, + schedules: { icon: "schedules", name: "Schedules", singular: "Schedule" }, + test: { icon: "test", name: "Test", singular: "Test" }, + // The playground route is the Test page too (its header reads "Test") + playground: { icon: "test", name: "Test", singular: "Test" }, +}; + +const ORG_SETTINGS_PAGE_META: Record = { + "": { icon: "org-settings", name: "Organization settings" }, + team: { icon: "team", name: "Team" }, + billing: { icon: "billing", name: "Billing" }, + "billing-limits": { icon: "alerts", name: "Billing alerts" }, + usage: { icon: "usage", name: "Usage" }, + roles: { icon: "roles", name: "Roles" }, + sso: { icon: "sso", name: "SSO" }, + "private-connections": { icon: "private-connections", name: "Private connections" }, + integrations: { icon: "integrations", name: "Integrations" }, + danger: { icon: "org-settings", name: "Danger zone" }, +}; + +const ACCOUNT_PAGE_META: Record = { + "": { icon: "account", name: "Profile" }, + tokens: { icon: "tokens", name: "Personal Access Tokens" }, + security: { icon: "security", name: "Security" }, +}; + +/** Best-effort icon + name for any dashboard page, derived from its URL shape. */ +export function resolvePageMeta(pathname: string): PageMeta { + const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/); + if (envMatch) { + const segments = (envMatch[1] ?? "").split("/").filter(Boolean); + const first = segments[0] ?? ""; + if (first === "settings") { + return segments[1] === "integrations" + ? { icon: "integrations", name: "Integrations" } + : { icon: "project-settings", name: "Project settings" }; + } + if (first === "dashboards") { + // The built-in metric dashboards and custom dashboards have their own identities (and icons) + if (segments[1] === "overview") return { icon: "run-metrics", name: "Run metrics" }; + if (segments[1] === "llm") return { icon: "ai-metrics", name: "AI metrics" }; + if (segments[1] === "custom") { + return { icon: "custom-dashboard", name: "Dashboards", singular: "Dashboard" }; + } + } + + // Task detail: /tasks/{standard|scheduled}/{slug}. The slug is the only place the task name + // exists (the page header renders it as JSX), so it becomes the label. + if (first === "tasks" && segments[2]) { + const slug = decodeURIComponent(segments[2]); + return segments[1] === "scheduled" + ? { icon: "task-scheduled", name: "Scheduled task", entityName: slug } + : { icon: "task-standard", name: "Standard task", entityName: slug }; + } + + // Agent tasks live outside /tasks: /agents/{slug} + if (first === "agents") { + return segments[1] + ? { + icon: "task-agent", + name: "Agent task", + entityName: decodeURIComponent(segments[1]), + } + : { icon: "task-agent", name: "Agents" }; + } + + return ENV_PAGE_META[first] ?? { icon: "page", name: "Page" }; + } + + const orgSettingsMatch = pathname.match(/^\/orgs\/[^/]+\/settings(?:\/([^?]*))?$/); + if (orgSettingsMatch) { + const segments = (orgSettingsMatch[1] ?? "").split("/").filter(Boolean); + if (segments[0] === "integrations" && segments[1] === "slack") { + return { icon: "slack", name: "Slack integration" }; + } + if (segments[0] === "integrations" && segments[1] === "vercel") { + return { icon: "vercel", name: "Vercel integration" }; + } + return ORG_SETTINGS_PAGE_META[segments[0] ?? ""] ?? { icon: "org-settings", name: "Settings" }; + } + + if (/^\/orgs\/[^/]+\/projects\/[^/]+/.test(pathname)) { + return { icon: "project", name: "Project" }; + } + + if (/^\/orgs\/[^/]+/.test(pathname)) { + return { icon: "project", name: "Projects" }; + } + + const accountMatch = pathname.match(/^\/account(?:\/([^?]*))?$/); + if (accountMatch) { + const segments = (accountMatch[1] ?? "").split("/").filter(Boolean); + return ACCOUNT_PAGE_META[segments[0] ?? ""] ?? { icon: "account", name: "Account" }; + } + + return { icon: "page", name: "Page" }; +} + +const MAX_LABEL_LENGTH = 50; + +function truncateLabel(label: string): string { + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label; +} + +/** + * Short id for a detail page whose last URL segment is a friendly id ("run_cmryyza…05hrqq9n"). + * Uses the same 8-character tail the dashboard tables display, so the label matches what the + * user sees elsewhere. + */ +function detailIdFromPath(pathname: string): string | undefined { + const segments = pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1]; + if (last && /^(run|batch|session|deployment|schedule|waitpoint)_[a-z0-9]{8,}$/i.test(last)) { + return last.slice(-8); + } + return undefined; +} + +/** Task type filter on the Tasks page (?types=…) becomes the whole favorite name. */ +const TASK_TYPE_LABELS: Record = { + AGENT: "Agent tasks", + STANDARD: "Standard tasks", + SCHEDULED: "Scheduled tasks", +}; + +/** "COMPLETED_SUCCESSFULLY" -> "Completed successfully", "history" -> "History". */ +function humanizeValue(value: string): string { + const lowered = value.toLowerCase().replaceAll("_", " "); + return lowered.charAt(0).toUpperCase() + lowered.slice(1); +} + +/** Pagination/UI-state params that never describe what the user filtered. */ +const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, ...PAGINATION_PARAMS, "span"]; + +/** + * Summarize a filtered view's search params into a short, selective descriptor for the favorite + * label ("Completed successfully, last 7d +2"). The best-known filters are named (at most two); + * everything else only counts toward a "+N" so heavily filtered views stay readable. + */ +function describeFilters(search: string): string | undefined { + const params = new URLSearchParams(search); + for (const param of NON_FILTER_PARAMS) { + params.delete(param); + } + + const parts: string[] = []; + const consumed = new Set(); + + const take = (key: string, describe: (values: string[]) => string | undefined) => { + const values = params.getAll(key).filter((value) => value.length > 0); + if (values.length === 0) return; + consumed.add(key); + const described = describe(values); + if (described) parts.push(described); + }; + + // Priority order: the filters most likely to identify the view come first + take("statuses", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} statuses`)); + take("levels", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} levels`)); + take("tasks", (v) => (v.length === 1 ? v[0] : `${v.length} tasks`)); + take("queues", (v) => (v.length === 1 ? v[0].replace(/^task\//, "") : `${v.length} queues`)); + take("tags", (v) => (v.length === 1 ? v[0] : `${v.length} tags`)); + take("period", (v) => `last ${v[0]}`); + if (params.has("from") || params.has("to")) { + consumed.add("from"); + consumed.add("to"); + parts.push("custom range"); + } + take("versions", (v) => (v.length === 1 ? v[0] : `${v.length} versions`)); + take("machines", (v) => (v.length === 1 ? v[0] : `${v.length} machines`)); + take("tab", (v) => humanizeValue(v[0])); + // The runs list appends rootOnly=false by default; only the non-default value is a filter + take("rootOnly", (v) => (v[0] === "true" ? "root only" : undefined)); + + const remaining = new Set([...params.keys()].filter((key) => !consumed.has(key))).size; + + const MAX_NAMED_PARTS = 2; + const shown = parts.slice(0, MAX_NAMED_PARTS); + const extra = parts.length - shown.length + remaining; + + if (shown.length === 0) { + return extra > 0 ? `${extra} filter${extra === 1 ? "" : "s"}` : undefined; + } + return shown.join(", ") + (extra > 0 ? ` +${extra}` : ""); +} + +/** + * Compose the default side menu label for a favorited page. Plain list pages keep their nav + * name ("Queues"); detail pages get an identifying prefix ("Queue: email-queue", or the short + * id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs: + * Completed successfully, last 7d"). Users can always rename. + */ +export function buildFavoriteLabel( + pathname: string, + search: string, + pageTitle: string | undefined +): string { + const meta = resolvePageMeta(pathname); + const title = pageTitle?.trim(); + const prefix = meta.singular ?? meta.name; + + // Generic titles ("Runs", "Run") identify nothing on their own; prefer ids/filters from the URL + const isGenericTitle = + !title || + title.toLowerCase() === meta.name.toLowerCase() || + title.toLowerCase() === prefix.toLowerCase(); + + if (isGenericTitle) { + // Named entity from the URL (task/agent slug) is the label on its own; its icon carries the type + if (meta.entityName) return truncateLabel(meta.entityName); + + // The Tasks page filtered to a single task type takes that type as the whole name + if (meta.icon === "tasks") { + const types = new URLSearchParams(search).getAll("types"); + if (types.length === 1 && TASK_TYPE_LABELS[types[0]]) { + return TASK_TYPE_LABELS[types[0]]; + } + } + + const detailId = detailIdFromPath(pathname); + if (detailId) return `${prefix}: ${detailId}`; + + const filters = describeFilters(search); + return truncateLabel(filters ? `${meta.name}: ${filters}` : meta.name); + } + + const label = title.toLowerCase().startsWith(prefix.toLowerCase()) + ? title + : `${prefix}: ${title}`; + return truncateLabel(label); +} + +/** + * The user's favorited pages with any in-flight mutations applied, so the star button and the + * side menu section update instantly and stay in sync while the server round-trip completes. + */ +export function useFavorites(): FavoritePage[] { + const user = useOptionalUser(); + const fetchers = useFetchers(); + + let favorites = user?.dashboardPreferences.sideMenu?.favorites ?? []; + + for (const fetcher of fetchers) { + if (fetcher.formAction !== FAVORITES_ACTION_PATH || !fetcher.formData) continue; + + const intent = fetcher.formData.get("intent"); + const id = fetcher.formData.get("id"); + if (typeof id !== "string") continue; + + switch (intent) { + case "add": { + const url = fetcher.formData.get("url"); + const label = fetcher.formData.get("label"); + const icon = fetcher.formData.get("icon"); + if (typeof url !== "string" || typeof label !== "string") break; + if (!favorites.some((f) => f.url === url)) { + // Newest favorites go to the top of the section (matches addFavorite server-side) + favorites = [ + { id, url, label, icon: typeof icon === "string" ? icon : undefined }, + ...favorites, + ]; + } + break; + } + case "remove": { + favorites = favorites.filter((f) => f.id !== id); + break; + } + case "rename": { + const label = fetcher.formData.get("label"); + if (typeof label !== "string") break; + favorites = favorites.map((f) => (f.id === id ? { ...f, label } : f)); + break; + } + } + } + + return favorites; +} diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index bb245f4bcb3..508c4121175 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -2,6 +2,7 @@ import { z } from "zod"; // Valid section IDs that can have their collapsed state toggled export const SideMenuSectionIdSchema = z.enum([ + "favorites", "ai", "manage", "metrics", @@ -12,3 +13,58 @@ export const SideMenuSectionIdSchema = z.enum([ // Inferred type from the schema export type SideMenuSectionId = z.infer; + +// Size popover items to match the side-menu items, overriding the smaller small-menu-item +// defaults via tailwind-merge; icon carries the default dimmed color. +export const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; +export const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; + +/** Default top-to-bottom order of the customizable side menu sections. */ +export const DEFAULT_SECTION_ORDER: SideMenuSectionId[] = [ + "favorites", + "ai", + "metrics", + "deployments", + "manage", +]; + +/** + * Order entries by a saved preference. Entries missing from the saved order (e.g. a section or + * item that shipped after the user customized) are inserted at their default position relative + * to the entries around them, not dumped at the end — so "Favorites" still lands above "AI" for + * users who saved an order before favorites existed. + */ +export function orderByPreference( + entries: T[], + savedOrder: string[] | undefined +): T[] { + if (!savedOrder || savedOrder.length === 0) return entries; + + const defaultIndex = new Map(entries.map((entry, index) => [entry.id, index])); + // Set-dedupe: a corrupted saved order with duplicate ids must not render an entry twice + const orderedIds = [...new Set(savedOrder.filter((id) => defaultIndex.has(id)))]; + const missingIds = entries.map((entry) => entry.id).filter((id) => !orderedIds.includes(id)); + + for (const id of missingIds) { + const idDefault = defaultIndex.get(id) ?? 0; + let insertAt = orderedIds.length; + for (let i = 0; i < orderedIds.length; i++) { + if ((defaultIndex.get(orderedIds[i]) ?? 0) > idDefault) { + insertAt = i; + break; + } + } + orderedIds.splice(insertAt, 0, id); + } + + const byId = new Map(entries.map((entry) => [entry.id, entry])); + return orderedIds.map((id) => byId.get(id)!); +} + +/** Effective hidden state for a menu item: the user's override wins, else the item's default. */ +export function isItemHidden( + item: { id: string; defaultHidden?: boolean }, + hiddenItems: Record | undefined +): boolean { + return hiddenItems?.[item.id] ?? item.defaultHidden ?? false; +} diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index a7c68f059f5..691b9323bad 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -7,6 +7,7 @@ import { Header2 } from "./Headers"; import { LoadingBarDivider } from "./LoadingBarDivider"; import { SimpleTooltip } from "./Tooltip"; import { DashboardAgentLauncher } from "../dashboard-agent/dashboardAgentLauncher"; +import { FavoritePageButton } from "../navigation/FavoritePageButton"; type WithChildren = { children: React.ReactNode; @@ -46,8 +47,10 @@ type PageTitleProps = { }; export function PageTitle({ title, backButton, accessory }: PageTitleProps) { + const titleText = typeof title === "string" ? title : undefined; + return ( -
+
{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 }; }); }