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 }) => { { } /> diff --git a/packages/app-security-access-management/src/Extension.tsx b/packages/app-security-access-management/src/Extension.tsx index 01031ffadb..490fb75ee3 100644 --- a/packages/app-security-access-management/src/Extension.tsx +++ b/packages/app-security-access-management/src/Extension.tsx @@ -62,6 +62,7 @@ const AccessManagementExtension = () => { } /> @@ -70,6 +71,7 @@ const AccessManagementExtension = () => { } @@ -81,6 +83,7 @@ const AccessManagementExtension = () => { } diff --git a/packages/app-website-builder/src/Extension.tsx b/packages/app-website-builder/src/Extension.tsx index 19bfb8a280..48de43932b 100644 --- a/packages/app-website-builder/src/Extension.tsx +++ b/packages/app-website-builder/src/Extension.tsx @@ -41,6 +41,7 @@ export const Extension = () => { } /> @@ -51,6 +52,7 @@ export const Extension = () => { } @@ -61,6 +63,7 @@ export const Extension = () => { { /> - } /> + } + /> - } /> + } + />