-
Notifications
You must be signed in to change notification settings - Fork 655
feat(navigation): add pinning for sidebar menu items #4729
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ku5ic
wants to merge
24
commits into
next
Choose a base branch
from
ku5ic/feature/WEB-5364/menu_items_pinning_functionality
base: next
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 16 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 51ad1b8
refactor(nav): rename pinnableItems to pinnableStates
ku5ic e5e8221
Merge branch 'next' into ku5ic/feature/WEB-5364/menu_items_pinning_fu…
ku5ic d5cd259
feat(ui): show pin icon on hover and when pinned
ku5ic 96560d4
feat(ui): simplify pinnable menu item icon logic
ku5ic 84e474c
feat(admin-ui): improve pinned menu items rendering logic
ku5ic b226ce6
feat(menu): add pinnable option to settings menus
ku5ic 5fce21d
feat(admin-ui): persist pinned menu order in localStorage
ku5ic fca3d7b
feat(admin-ui): add "Webiny" group to pinned menu items
ku5ic af06162
Merge branch 'next' into ku5ic/feature/WEB-5364/menu_items_pinning_fu…
ku5ic cd8d10f
feat(admin-ui): show parent icon for pinned menu items
ku5ic d7534b2
refactor(navigation): move icon prop to menu element
ku5ic f24413f
fix(admin-ui): handle possible undefined menu element
ku5ic 10eae03
feat(ui): hide pinned menu items when sidebar is collapsed
ku5ic cd33be0
refactor(navigation): rename PINNED_KEY to pinnedKey
ku5ic 224d0e7
fix(admin-ui): improve pinned menu sorting logic
ku5ic a961df9
refactor(nav): optimize pinned menu items memoization
ku5ic d52be94
feat(ui): rename sidebar state from pinned to expanded
ku5ic c0b74cb
refactor(navigation): rename pinnedKey to createPinnedKey
ku5ic d47da02
Merge branch 'next' into ku5ic/feature/WEB-5364/menu_items_pinning_fu…
ku5ic 83eea6c
refactor(nav): simplify pinned menu rendering logic
ku5ic bfb5d62
refactor(properties): remove unused pinnable prop
ku5ic cbd66a4
fix(admin-ui): simplify icon rendering for pinned menu items
ku5ic 0bcc609
feat(ui): show pin icon on menu item hover
ku5ic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
122 changes: 122 additions & 0 deletions
122
packages/app-admin-ui/src/Navigation/PinnableMenuItem.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 pinnedKey = (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 = pinnedKey(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 { pinned: isSidebarPinned } = useSidebar(); | ||
|
||
return ( | ||
<div className="wby-relative"> | ||
{children} | ||
<div | ||
className={`wby-opacity-0 hover:wby-opacity-100 wby-absolute wby-right-sm wby-top-1/2 -wby-translate-y-1/2 wby-cursor-pointer ${isPinned && isSidebarPinned ? "wby-opacity-100" : ""}`} | ||
> | ||
<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> | ||
); | ||
}; |
158 changes: 158 additions & 0 deletions
158
packages/app-admin-ui/src/Navigation/PinnedMenuItems.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
import React, { useMemo } from "react"; | ||
import { AdminConfig, useLocalStorageValues, useLocalStorageValue } from "@webiny/app-admin"; | ||
import { pinnedKey, PINNED_ORDER_KEY, PinnableMenuItem } from "./PinnableMenuItem.js"; | ||
import type { MenuConfig } from "@webiny/app-admin/config/AdminConfig/Menu.js"; | ||
import { useSidebar } from "@webiny/admin-ui"; | ||
|
||
const { Menu } = AdminConfig; | ||
|
||
/** | ||
* 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 }) => pinnedKey(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[pinnedKey(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 pinnableMenus = getPinnableMenus(menuItems); | ||
const pinnableKeys = getPinnableKeys(pinnableMenus); | ||
const pinnedStates = useLocalStorageValues(pinnableKeys); | ||
const rawPinnedOrder = useLocalStorageValue(PINNED_ORDER_KEY); | ||
const pinnedOrder = useMemo(() => parsePinnedOrder(rawPinnedOrder), [rawPinnedOrder]); | ||
const { pinned: isSidebarPinned } = useSidebar(); | ||
|
||
const pinnedItems = useMemo( | ||
() => getSortedPinnedItems(pinnableMenus, pinnedStates, pinnedOrder), | ||
[pinnableMenus, pinnedStates, pinnedOrder] | ||
); | ||
|
||
if (!pinnedItems.length) { | ||
return null; | ||
} | ||
|
||
const renderIcon = (m: MenuConfig): React.ReactNode => { | ||
const parentIcon = getParentIcon(m, menuItems); | ||
return React.isValidElement(parentIcon) ? React.cloneElement(parentIcon) : parentIcon; | ||
}; | ||
|
||
return ( | ||
<> | ||
<Menu.Group text="Pinned" className={`${!isSidebarPinned ? "wby-hidden" : ""}`} /> | ||
{pinnedItems.map(m => ( | ||
<PinnableMenuItem key={m.name} name={m.name}> | ||
{React.cloneElement(m.element!, { icon: renderIcon(m) })} | ||
</PinnableMenuItem> | ||
))} | ||
<Menu.Group text="Webiny" className={`${!isSidebarPinned ? "wby-hidden" : ""}`} /> | ||
</> | ||
); | ||
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
const key = createPinnedKey(whatever)