diff --git a/packages/app-admin-ui/src/Navigation/Navigation.tsx b/packages/app-admin-ui/src/Navigation/Navigation.tsx
index e395585bf0..cb93152535 100644
--- a/packages/app-admin-ui/src/Navigation/Navigation.tsx
+++ b/packages/app-admin-ui/src/Navigation/Navigation.tsx
@@ -3,6 +3,7 @@ import { NavigationRenderer, useAdminConfig } from "@webiny/app-admin";
import { Sidebar } from "@webiny/admin-ui";
import { SidebarMenuItems } from "./SidebarMenuItems.js";
import { SimpleLink } from "@webiny/app-admin";
+import { PinnedMenuItems } from "./PinnedMenuItems.js";
export const Navigation = NavigationRenderer.createDecorator(() => {
return function Navigation() {
@@ -21,6 +22,7 @@ export const Navigation = NavigationRenderer.createDecorator(() => {
icon={icon}
footer={}
>
+
);
diff --git a/packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx b/packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx
new file mode 100644
index 0000000000..fd2ff74ade
--- /dev/null
+++ b/packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx
@@ -0,0 +1,122 @@
+import React from "react";
+import { Icon } from "@webiny/admin-ui";
+import { ReactComponent as PinIcon } from "@webiny/icons/push_pin.svg";
+import { useLocalStorage, useLocalStorageValue } from "@webiny/app";
+import { useSidebar } from "@webiny/admin-ui";
+
+/**
+ * Props for the PinnableMenuItem component.
+ *
+ * @property name - Unique string identifier for the menu item. Used for localStorage keys.
+ * @property children - React node(s) representing the menu item's content.
+ */
+type PinnableMenuItemProps = {
+ name: string;
+ children: React.ReactNode;
+};
+
+/**
+ * Generates the localStorage key for a pinned menu item.
+ *
+ * @param name - The unique name of the menu item.
+ * @returns The localStorage key string for the pinned state.
+ */
+export const createPinnedKey = (name: string) => `navigation/${name}/pinned`;
+
+/**
+ * The localStorage key for the order of pinned menu items.
+ */
+export const PINNED_ORDER_KEY = "navigation/order/pinned";
+/**
+ * Parses the pinned order value from localStorage.
+ *
+ * @param order - The value retrieved from localStorage (string or array).
+ * @returns An array of menu item names in pinned order.
+ */
+const parseOrder = (order: unknown): string[] => {
+ if (Array.isArray(order)) {
+ return order;
+ }
+ if (typeof order === "string") {
+ try {
+ const parsed = JSON.parse(order);
+ return Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+ }
+ return [];
+};
+
+/**
+ * Custom hook to manage the pinned state of a menu item.
+ *
+ * @param name - Unique string identifier for the menu item.
+ * @returns An object containing:
+ * - isPinned: boolean | undefined - Whether the menu item is pinned.
+ * - pin: () => void - Function to pin the menu item.
+ * - unpin: () => void - Function to unpin the menu item.
+ *
+ * @sideEffect Updates localStorage when pinning/unpinning.
+ * @note The pinned state and order are persisted in localStorage.
+ */
+const usePinnedMenuItem = (name: string) => {
+ const pinKey = createPinnedKey(name);
+ const pinOrder = useLocalStorageValue(PINNED_ORDER_KEY);
+ const isPinned = useLocalStorageValue(pinKey);
+ const { set, remove } = useLocalStorage();
+
+ const updateOrder = (order: string[]) => set(PINNED_ORDER_KEY, JSON.stringify(order));
+
+ const pin = () => {
+ const order = parseOrder(pinOrder);
+ if (!order.includes(name)) {
+ updateOrder([...order, name]);
+ }
+ set(pinKey, true);
+ };
+
+ const unpin = () => {
+ const order = parseOrder(pinOrder).filter(item => item !== name);
+ updateOrder(order);
+ remove(pinKey);
+ };
+
+ return { isPinned, pin, unpin };
+};
+
+/**
+ * PinnableMenuItem component allows any menu item to be "pinned" by the user.
+ * The pinned state is persisted in localStorage, making the menu item visually distinct and easily accessible.
+ *
+ * @param props - {@link PinnableMenuItemProps}
+ * @returns JSX.Element - Renders the children and a pin/unpin icon.
+ *
+ * @example
+ *
+ *
+ *
+ *
+ * @sideEffect Persists pinned state and order in localStorage.
+ * @note The pin icon appears on hover and toggles the pinned state.
+ */
+export const PinnableMenuItem = ({ name, children }: PinnableMenuItemProps) => {
+ const { isPinned, pin, unpin } = usePinnedMenuItem(name);
+ const { expanded: isSidebarExpanded } = useSidebar();
+
+ return (
+
+ {children}
+
+ }
+ className="wby-fill-neutral-strong hover:wby-fill-neutral-xstrong"
+ />
+
+
+ );
+};
diff --git a/packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx b/packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx
new file mode 100644
index 0000000000..2ac8268512
--- /dev/null
+++ b/packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx
@@ -0,0 +1,152 @@
+import React, { useMemo } from "react";
+import { AdminConfig, useLocalStorageValues, useLocalStorageValue } from "@webiny/app-admin";
+import { createPinnedKey, PINNED_ORDER_KEY, PinnableMenuItem } from "./PinnableMenuItem.js";
+import type { MenuConfig } from "@webiny/app-admin/config/AdminConfig/Menu.js";
+import { Separator } from "@webiny/admin-ui";
+
+/**
+ * Retrieves the icon from the parent menu of a given child menu.
+ *
+ * @param childMenu - The menu configuration object representing the child menu.
+ * @param allMenus - An array of all menu configuration objects.
+ * @returns The React node representing the parent's icon, or `undefined` if no parent or icon is found.
+ *
+ * @remarks
+ * - If the child menu does not have a parent, or the parent menu's element is not a valid React element, returns `undefined`.
+ * - Assumes that the parent menu's element has an `icon` prop.
+ *
+ * @example
+ * const icon = getParentIcon(childMenu, allMenus);
+ * if (icon) {
+ * // Render the icon
+ * }
+ */
+const getParentIcon = (
+ childMenu: MenuConfig,
+ allMenus: MenuConfig[]
+): React.ReactNode | undefined => {
+ if (!childMenu.parent) {
+ return undefined;
+ }
+ const parentMenu = allMenus.find(menu => menu.name === childMenu.parent);
+ if (!parentMenu || !React.isValidElement(parentMenu.element)) {
+ return undefined;
+ }
+ // Type assertion to fix 'unknown' type error
+ return (parentMenu.element.props as { icon?: React.ReactNode }).icon;
+};
+
+/**
+ * Props for the PinnedMenuItems component.
+ * @property menuItems - Array of menu item objects from admin config.
+ */
+export interface PinnedMenuItemsProps {
+ menuItems: ReturnType["menus"];
+}
+
+/**
+ * Filters menu items to include only those that are pinnable.
+ * @param menuItems - Array of menu item objects.
+ * @returns Array of pinnable menu items.
+ */
+const getPinnableMenus = (menuItems: PinnedMenuItemsProps["menuItems"]) =>
+ menuItems?.filter(({ pinnable }) => pinnable) || [];
+
+/**
+ * Generates local storage keys for each pinnable menu item.
+ * @param menus - Array of menu item objects.
+ * @returns Array of local storage key strings.
+ */
+const getPinnableKeys = (menus: PinnedMenuItemsProps["menuItems"]) =>
+ menus.map(({ name }) => createPinnedKey(name));
+
+/**
+ * Parses the pinned order from a raw local storage value.
+ * @param rawOrder - Value from local storage (string or array).
+ * @returns Array of menu item names in pinned order.
+ *
+ * Note: If parsing fails, returns an empty array.
+ */
+const parsePinnedOrder = (rawOrder: unknown): string[] => {
+ if (Array.isArray(rawOrder)) {
+ return rawOrder;
+ }
+ if (typeof rawOrder === "string") {
+ try {
+ const parsed = JSON.parse(rawOrder);
+ if (Array.isArray(parsed)) {
+ return parsed;
+ }
+ } catch {
+ // ignore parse error, fallback to empty array
+ }
+ }
+ return [];
+};
+
+/**
+ * Sorts pinned menu items according to user-defined order.
+ * @param menus - Array of menu item objects.
+ * @param pinnedStates - Object mapping menu item keys to pinned state (boolean).
+ * @param pinnedOrder - Array of menu item names in desired order.
+ * @returns Array of sorted pinned menu items.
+ */
+const getSortedPinnedItems = (
+ menus: PinnedMenuItemsProps["menuItems"],
+ pinnedStates: Record,
+ pinnedOrder: string[]
+) => {
+ const pinned = menus.filter(({ name }) => pinnedStates[createPinnedKey(name)]);
+
+ return pinned.sort((a, b) => {
+ const aIdx = pinnedOrder.indexOf(a.name);
+ const bIdx = pinnedOrder.indexOf(b.name);
+ return (
+ (aIdx === -1 ? Number.MAX_SAFE_INTEGER : aIdx) -
+ (bIdx === -1 ? Number.MAX_SAFE_INTEGER : bIdx)
+ );
+ });
+};
+
+/**
+ * Renders a group of pinned menu items in the admin UI.
+ *
+ * - Uses local storage to determine which menu items are pinned and their order.
+ * - Only displays the group if there are pinned items.
+ *
+ * @param props.menuItems - Array of menu item objects from admin config.
+ * @returns React fragment containing the "Pinned" menu group and its items, or null if none are pinned.
+ *
+ * @example
+ *
+ */
+export const PinnedMenuItems = ({ menuItems }: PinnedMenuItemsProps) => {
+ const rawPinnedOrder = useLocalStorageValue(PINNED_ORDER_KEY);
+ const [pinnableMenus, pinnableKeys, pinnedOrder] = useMemo(() => {
+ const menus = getPinnableMenus(menuItems);
+ const keys = getPinnableKeys(menus);
+ const order = parsePinnedOrder(rawPinnedOrder);
+ return [menus, keys, order];
+ }, [menuItems, rawPinnedOrder]);
+ const pinnedStates = useLocalStorageValues(pinnableKeys);
+
+ const pinnedItems = useMemo(
+ () => getSortedPinnedItems(pinnableMenus, pinnedStates, pinnedOrder),
+ [pinnableMenus, pinnedStates, pinnedOrder]
+ );
+
+ if (!pinnedItems.length) {
+ return null;
+ }
+
+ return (
+ <>
+ {pinnedItems.map(m => (
+
+ {React.cloneElement(m.element!, { icon: getParentIcon(m, menuItems) })}
+
+ ))}
+
+ >
+ );
+};
diff --git a/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx b/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx
index ac29b57bbd..0d20dd1153 100644
--- a/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx
+++ b/packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx
@@ -1,5 +1,6 @@
import React from "react";
import type { MenuConfig } from "@webiny/app-admin/config/AdminConfig/Menu.js";
+import { PinnableMenuItem } from "./PinnableMenuItem.js";
export interface MenusProps {
menus: MenuConfig[];
@@ -42,6 +43,16 @@ export const SidebarMenuItems = (props: MenusProps) => {
);
}
- return React.cloneElement(m.element, { key: m.parent + m.name });
+ const menuItem = React.cloneElement(m.element, { key: m.parent + m.name });
+
+ if (m.pinnable) {
+ return (
+
+ {menuItem}
+
+ );
+ }
+
+ return menuItem;
});
};
diff --git a/packages/app-admin-users-cognito/src/Cognito.tsx b/packages/app-admin-users-cognito/src/Cognito.tsx
index 00fc4bcbdb..f49bdc6067 100644
--- a/packages/app-admin-users-cognito/src/Cognito.tsx
+++ b/packages/app-admin-users-cognito/src/Cognito.tsx
@@ -56,6 +56,7 @@ const CognitoIdP = (props: CognitoProps) => {
}
/>
diff --git a/packages/app-admin/src/config/AdminConfig/Menu.tsx b/packages/app-admin/src/config/AdminConfig/Menu.tsx
index 9ceb1fb066..8f68238c7b 100644
--- a/packages/app-admin/src/config/AdminConfig/Menu.tsx
+++ b/packages/app-admin/src/config/AdminConfig/Menu.tsx
@@ -16,9 +16,10 @@ export interface MenuProps {
pin?: "start" | "end";
before?: string;
after?: string;
+ pinnable?: boolean;
}
-export type MenuConfig = Pick;
+export type MenuConfig = Pick;
const BaseMenu = ({
name,
@@ -28,7 +29,8 @@ const BaseMenu = ({
remove,
pin,
before,
- after
+ after,
+ pinnable = false
}: MenuProps) => {
const getId = useIdGenerator("Menu");
@@ -56,6 +58,7 @@ const BaseMenu = ({
+
);
diff --git a/packages/app-file-manager/src/modules/Settings/index.tsx b/packages/app-file-manager/src/modules/Settings/index.tsx
index 8aec156e19..e801fee333 100644
--- a/packages/app-file-manager/src/modules/Settings/index.tsx
+++ b/packages/app-file-manager/src/modules/Settings/index.tsx
@@ -32,6 +32,7 @@ export const SettingsModule = () => {
}
/>
diff --git a/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx b/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx
index 3eddd68a98..6f501dd026 100644
--- a/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx
+++ b/packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx
@@ -23,6 +23,7 @@ const CmsContentModelsMenu = ({ canAccess }: ChildMenuProps) => {
}
/>
);
@@ -38,6 +39,7 @@ const CmsContentGroupsMenu = ({ canAccess }: ChildMenuProps) => {
}
diff --git a/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx b/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx
index e0e352fa20..eb6fe95f77 100644
--- a/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx
+++ b/packages/app-headless-cms/src/admin/menus/GroupContentModels.tsx
@@ -35,6 +35,7 @@ export const GroupContentModels = ({ group }: { group: CmsGroup }) => {