Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4d09ae2
feat(navigation): add pinning for sidebar menu items
ku5ic Oct 6, 2025
51ad1b8
refactor(nav): rename pinnableItems to pinnableStates
ku5ic Oct 6, 2025
e5e8221
Merge branch 'next' into ku5ic/feature/WEB-5364/menu_items_pinning_fu…
ku5ic Oct 6, 2025
d5cd259
feat(ui): show pin icon on hover and when pinned
ku5ic Oct 6, 2025
96560d4
feat(ui): simplify pinnable menu item icon logic
ku5ic Oct 7, 2025
84e474c
feat(admin-ui): improve pinned menu items rendering logic
ku5ic Oct 7, 2025
b226ce6
feat(menu): add pinnable option to settings menus
ku5ic Oct 7, 2025
5fce21d
feat(admin-ui): persist pinned menu order in localStorage
ku5ic Oct 7, 2025
fca3d7b
feat(admin-ui): add "Webiny" group to pinned menu items
ku5ic Oct 7, 2025
af06162
Merge branch 'next' into ku5ic/feature/WEB-5364/menu_items_pinning_fu…
ku5ic Oct 8, 2025
cd8d10f
feat(admin-ui): show parent icon for pinned menu items
ku5ic Oct 8, 2025
d7534b2
refactor(navigation): move icon prop to menu element
ku5ic Oct 8, 2025
f24413f
fix(admin-ui): handle possible undefined menu element
ku5ic Oct 8, 2025
10eae03
feat(ui): hide pinned menu items when sidebar is collapsed
ku5ic Oct 8, 2025
cd33be0
refactor(navigation): rename PINNED_KEY to pinnedKey
ku5ic Oct 9, 2025
224d0e7
fix(admin-ui): improve pinned menu sorting logic
ku5ic Oct 9, 2025
a961df9
refactor(nav): optimize pinned menu items memoization
ku5ic Oct 9, 2025
d52be94
feat(ui): rename sidebar state from pinned to expanded
ku5ic Oct 9, 2025
c0b74cb
refactor(navigation): rename pinnedKey to createPinnedKey
ku5ic Oct 9, 2025
d47da02
Merge branch 'next' into ku5ic/feature/WEB-5364/menu_items_pinning_fu…
ku5ic Oct 9, 2025
83eea6c
refactor(nav): simplify pinned menu rendering logic
ku5ic Oct 9, 2025
bfb5d62
refactor(properties): remove unused pinnable prop
ku5ic Oct 10, 2025
cbd66a4
fix(admin-ui): simplify icon rendering for pinned menu items
ku5ic Oct 10, 2025
0bcc609
feat(ui): show pin icon on menu item hover
ku5ic Oct 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/app-admin-ui/src/Navigation/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -21,6 +22,7 @@ export const Navigation = NavigationRenderer.createDecorator(() => {
icon={icon}
footer={<SidebarMenuItems menus={menus} where={{ tags: ["footer"] }} />}
>
<PinnedMenuItems menuItems={menus} />
<SidebarMenuItems menus={menus} />
</Sidebar>
);
Expand Down
122 changes: 122 additions & 0 deletions packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx
Original file line number Diff line number Diff line change
@@ -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
* <PinnableMenuItem name="dashboard">
* <MenuItem label="Dashboard" />
* </PinnableMenuItem>
*
* @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 (
<div className="wby-relative wby-group/pin">
{children}
<div
className={`group-hover/pin:wby-visible wby-absolute wby-right-sm wby-top-1/2 -wby-translate-y-1/2 wby-cursor-pointer ${isPinned && isSidebarExpanded ? "wby-visible" : "wby-invisible"}`}
>
<Icon
label={isPinned ? "Unpin menu item" : "Pin menu item"}
onClick={isPinned ? unpin : pin}
icon={<PinIcon />}
className="wby-fill-neutral-strong hover:wby-fill-neutral-xstrong"
/>
</div>
</div>
);
};
152 changes: 152 additions & 0 deletions packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AdminConfig.useAdminConfig>["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<string, boolean>,
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
* <PinnedMenuItems menuItems={menus} />
*/
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 => (
<PinnableMenuItem key={m.name} name={m.name}>
{React.cloneElement(m.element!, { icon: getParentIcon(m, menuItems) })}
</PinnableMenuItem>
))}
<Separator />
</>
);
};
13 changes: 12 additions & 1 deletion packages/app-admin-ui/src/Navigation/SidebarMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -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 (
<PinnableMenuItem key={m.parent + m.name} name={m.name}>
{menuItem}
</PinnableMenuItem>
);
}

return menuItem;
});
};
1 change: 1 addition & 0 deletions packages/app-admin-users-cognito/src/Cognito.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const CognitoIdP = (props: CognitoProps) => {
<Menu
name={"cognito.settings.adminUsers"}
parent={"settings"}
pinnable={true}
element={<Menu.Link text={"Users"} to={getLink(Routes.Users.List)} />}
/>
</HasPermission>
Expand Down
7 changes: 5 additions & 2 deletions packages/app-admin/src/config/AdminConfig/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ export interface MenuProps {
pin?: "start" | "end";
before?: string;
after?: string;
pinnable?: boolean;
}

export type MenuConfig = Pick<MenuProps, "name" | "parent" | "tags" | "element">;
export type MenuConfig = Pick<MenuProps, "name" | "parent" | "tags" | "element" | "pinnable">;

const BaseMenu = ({
name,
Expand All @@ -28,7 +29,8 @@ const BaseMenu = ({
remove,
pin,
before,
after
after,
pinnable = false
}: MenuProps) => {
const getId = useIdGenerator("Menu");

Expand Down Expand Up @@ -56,6 +58,7 @@ const BaseMenu = ({
<Property id={getId(name, "parent")} name={"parent"} value={parent} />
<Property id={getId(name, "tags")} name={"tags"} value={tags} />
<Property id={getId(name, "element")} name={"element"} value={element} />
<Property id={getId(name, "pinnable")} name={"pinnable"} value={pinnable} />
</Property>
</ConnectToProperties>
);
Expand Down
1 change: 1 addition & 0 deletions packages/app-file-manager/src/modules/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const SettingsModule = () => {
<Menu
parent={"settings"}
name={"settings.fm.general"}
pinnable={true}
element={<Menu.Link text={"General"} to={getLink(Routes.Settings)} />}
/>
</HasPermission>
Expand Down
2 changes: 2 additions & 0 deletions packages/app-headless-cms/src/admin/menus/CmsMenuLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const CmsContentModelsMenu = ({ canAccess }: ChildMenuProps) => {
<Menu
name={"headlessCMS.contentModels.models"}
parent={"headlessCMS"}
pinnable={true}
element={<Menu.Link text={"Models"} to={router.getLink(Routes.ContentModels.List)} />}
/>
);
Expand All @@ -38,6 +39,7 @@ const CmsContentGroupsMenu = ({ canAccess }: ChildMenuProps) => {
<Menu
name={"headlessCMS.contentModels.groups"}
parent={"headlessCMS"}
pinnable={true}
element={
<Menu.Link text={"Groups"} to={router.getLink(Routes.ContentModelGroups.List)} />
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export const GroupContentModels = ({ group }: { group: CmsGroup }) => {
<Menu
parent={group.id}
name={contentModel.modelId}
pinnable={true}
element={
<Menu.Link
text={contentModel.name}
Expand Down
1 change: 1 addition & 0 deletions packages/app-mailer/src/Module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const MailerSettings = () => {
<Menu
name={"mailer.settings.general"}
parent={"settings"}
pinnable={true}
element={<Menu.Link text={"Settings"} to={router.getLink(Routes.Settings)} />}
/>
</HasPermission>
Expand Down
Loading
Loading