import { AntDesignOutlined } from '@ant-design/icons'; import _ from 'lodash'; import { ResourceDefinitions, ResourceProps, Translate, useGetIdentity, usePermissions, useResourceDefinitions, useTranslate } from 'ra-core'; import { PropsWithChildren, createContext, useContext, useEffect, useMemo, useState } from 'react'; import { useLocation } from 'react-router'; type MenuItem = { id: string; title: string; icon?: React.ReactNode; type: 'item' | 'group' | 'collapse'; url: string; resource?: boolean; roles?: string[]; children?: MenuItem[]; breadcrumbs?: boolean; }; type MenuGroup = MenuItem & { parent?: MenuGroup; children?: MenuGroup[]; }; type MenuBreadcrumb = MenuItem & { parent?: MenuBreadcrumb; last?: boolean; }; type MenuPermissions = string[] | undefined; const RESOURCE_PREFIXES: string[] = ['e-', 'entities/']; function getResourceName(name: string): string { let resourceName = name; RESOURCE_PREFIXES.forEach((prefix) => { resourceName = resourceName?.startsWith(prefix) ? resourceName?.substring(prefix.length) : resourceName; }); return resourceName; } function isInGroup(group: MenuGroup, resource: ResourceProps): boolean { return resource.options?.group === group.id || (!resource.options?.group && group.id === 'admin'); } function hasPermission(resource: ResourceProps, permissions: MenuPermissions): boolean { const name = getResourceName(resource.name); const hasPermission = permissions?.includes(`${name}:list`) || permissions?.includes(`${name}:find`); return hasPermission as boolean; } function getItem(menu: MenuItem[], id: string): MenuItem | undefined { let item = menu.find((item) => item.id === id); if (!item) { for (let i = 0; i < menu.length; i++) { const element = menu[i]; if (element.children) { item = getItem(element.children, id); if (item) { return item; } } } } return item; } function getGroup(groups: MenuGroup[], resource: ResourceProps, permissions: MenuPermissions): MenuGroup | undefined { let group = groups.find( (g) => ['group', 'collapse'].indexOf(g.type) !== -1 && isInGroup(g, resource) /* && hasPermission(resource, permissions)*/ ); if (!group) { for (let i = 0; i < groups.length; i++) { const element = groups[i]; if (element.children) { group = getGroup(element.children, resource, permissions); if (group) { return group; } } } } return group; } function getGroupById(groups: MenuGroup[], id: string): MenuGroup | undefined { let group = groups.find((g) => g.id === id); if (!group) { for (let i = 0; i < groups.length; i++) { const element = groups[i]; if (element.children) { group = getGroupById(element.children, id); if (group) { return group; } } } } return group; } function filterNotEmpty(group: MenuGroup): boolean { if (!group) { return false; } if (group.type === 'item') { return true; } if (group.children) { group.children = group.children.filter(filterNotEmpty); } const match = group.children && group.children.length > 0; return match as boolean; } function mapParent(group: MenuGroup): MenuGroup { if (group.children) { group.children.forEach((child: MenuGroup) => { child.parent = group; mapParent(child); }); } return group; } function build( userMenu: MenuItem[], resources: ResourceDefinitions, permissions: MenuPermissions, translate: Translate, roles: string[] ): MenuGroup[] { function translateRecursive(group: MenuGroup) { group.title = translate(group.title, { _: group.title }); if (group.children) { group.children.forEach((child) => translateRecursive(child)); } return group; } const resourceNames = Object.keys(resources); function removeNotFoundResources(group: MenuGroup, resources: string[]): MenuGroup { if (group.children) { group.children = group.children.filter((child) => { if (child.resource === false) { const childRoles = child.roles || []; if (childRoles.length === 0 || childRoles.some((role) => roles.includes(role))) { return true; } return false; } if (child.type === 'item') { return resources.includes(child.id); } removeNotFoundResources(child, resources); return child.children && child.children.length > 0; }); } return group; } const groupsA = _.cloneDeep(userMenu) .map((group) => ({ children: [], ...group, type: group.type || 'group' })) .map(translateRecursive) .map((group) => removeNotFoundResources(group, resourceNames)); const groupsB = Object.values(resources) // Detect group of each configured ra resource. .map((resource) => resource.options?.group || 'admin') // Generate unique list of groups (because a resource can be configured with the same group of another resource) .filter((group, index, self) => self.indexOf(group) === index) // Remove from the list groups already defined by the user (Applica developer) .filter((group) => group !== undefined && getGroupById(userMenu, group) === null) .map((group) => ({ id: group, title: translate(`ra.menu.group.${group}`), type: 'group', icon: AntDesignOutlined, children: [] })); const groups = [...groupsA, ...groupsB] as MenuGroup[]; Object.values(resources).forEach((resource: ResourceProps) => { const resourceGroup = getGroup(groups, resource, permissions); const resourceMenu = resourceGroup?.children?.find((c) => c.id === resource.name); const resourceExists = resourceGroup?.children?.some((c) => c.id === resource.name); const resourceRoles = resourceMenu?.roles || resource.options?.roles || []; const hasRoles = !resourceRoles || resourceRoles.length === 0 || resourceRoles.some((role: string) => roles.includes(role)); const hasPerms = hasPermission(resource, permissions); if (!hasPerms || !!resource.list || !hasRoles) { if (resourceExists && !!resourceGroup && !!resourceGroup.children && resourceGroup?.children?.length > 0) { resourceGroup.children = resourceGroup.children.filter((c) => c.id !== resource.name); } return; } if (resourceGroup) { if (!resourceGroup.children) { resourceGroup.children = []; } if (resourceExists) { return; } } }); return groups.filter(filterNotEmpty).map(mapParent); } type MenuProviderContextProps = { openItem: string[]; openComponent: string; selectedID: string | null; drawerOpen: boolean; error: any; menu: MenuItem[]; activeItem: (openItem: string[]) => void; openDrawer: (drawerOpen: boolean) => void; activeID: (selectedID: string | null) => void; hasError: (error: any) => void; setMenu: (menu: MenuItem[]) => void; }; function notImplemented() { throw new Error('Not implemented'); } const initialState: MenuProviderContextProps = { openItem: [], openComponent: 'buttons', selectedID: null, drawerOpen: false, error: null, menu: [], activeItem: notImplemented, openDrawer: notImplemented, activeID: notImplemented, hasError: notImplemented, setMenu: notImplemented }; type MenuProviderProps = PropsWithChildren<{ menu: MenuItem[]; }>; const MenuContext = createContext(initialState); function MenuConfigProvider({ menu, children }: MenuProviderProps) { const [menuConfig, setMenuConfig] = useState({ ...initialState, menu }) as any; function setMenu(menu: MenuItem[]) { setMenuConfig((menuConfig: MenuProviderContextProps) => ({ ...menuConfig, menu })); } function activeItem(openItem: string) { setMenuConfig((menuConfig: MenuProviderContextProps) => ({ ...menuConfig, openItem })); } function activeID(selectedID: string) { setMenuConfig((menuConfig: MenuProviderContextProps) => ({ ...menuConfig, selectedID })); } function openDrawer(drawerOpen: boolean) { setMenuConfig((menuConfig: MenuProviderContextProps) => ({ ...menuConfig, drawerOpen })); } function hasError(error: any) { setMenuConfig((menuConfig: MenuProviderContextProps) => ({ ...menuConfig, error })); } useEffect(() => { setMenu(menu); }, [menu]); return ( {children} ); } function useMenuContext(): MenuProviderContextProps { return useContext(MenuContext); } type UseMenuProps = { menu: MenuGroup[] | null; isLoading: boolean; getItem: (id: string) => MenuItem | undefined; findInMenu: (menu: MenuGroup[], id: string) => MenuItem | undefined; }; function useMenu(): UseMenuProps { const resources = useResourceDefinitions(); const { isLoading: loadingIdentity, data: identity } = useGetIdentity(); const isAuthenticated = !!identity && !!identity.id; const { permissions } = usePermissions(isAuthenticated); const { menu: userMenu } = useMenuContext(); const translate = useTranslate(); const menu = useMemo(() => { if (loadingIdentity || !identity) { return null; } const roles = ((identity?.roles || []) as { id: string }[]).map((role) => role.id); const groups = build(userMenu, resources, permissions, translate, roles); return groups; }, [loadingIdentity, identity, resources, permissions, translate, userMenu]); const isLoading = useMemo(() => loadingIdentity, [loadingIdentity]); function getMenuGroup(id: string): MenuGroup | undefined { if (!menu) { return undefined; } return getItem(menu, id); } return { menu, isLoading, getItem: getMenuGroup, findInMenu: isLoading ? () => undefined : getItem }; } function useBreadcrumbs() { const translate = useTranslate(); const { isLoading, menu: _menu, findInMenu } = useMenu(); const { pathname } = useLocation(); return useMemo(() => { const pathArgs = pathname.split('/').filter((x) => x); const menu = _.cloneDeep(_menu); let id = pathname; let menuItem = findInMenu(menu!, pathname); if (!menuItem || menuItem.type === 'collapse' || menuItem.type === 'group') { menuItem = findInMenu(menu!, pathArgs[pathArgs.length - 1]); id = menuItem ? menuItem.id : id; } if (!menuItem || menuItem.type === 'collapse' || menuItem.type === 'group') { for (let i = pathArgs.length; i >= 0; i--) { const path = pathArgs.slice(0, i).join('/'); menuItem = findInMenu(menu!, path); if (menuItem) { id = menuItem.id; break; } } } const urlParts = menuItem?.url?.split('/'); const urlPartsLength = urlParts?.length || 0; if (pathArgs.length >= urlPartsLength) { const data = pathArgs[pathArgs.length - 1]; if (menuItem) { const context = data === 'create' || pathname === menuItem.url ? 'create' : 'edit'; const title = `resources.${id}.breadcrumbs.${context}`; const child: MenuBreadcrumb = { id: `${id}-${context}`, type: 'item', title: translate(title, { _: translate(title, { _: title }) }), url: pathname, breadcrumbs: true, parent: menuItem }; menuItem.type = 'collapse'; menuItem.breadcrumbs = true; menuItem.children = menuItem.children || []; menuItem.children.push(child); menuItem = child; } } let breadcrumbs: MenuBreadcrumb[] = []; if (menuItem) { let current: MenuBreadcrumb = { ...menuItem, last: true }; while (current) { breadcrumbs.push(current); current = current.parent as MenuBreadcrumb; } breadcrumbs = breadcrumbs.reverse(); } return { isLoading, navigation: { items: menu }, breadcrumbs }; }, [isLoading, pathname, _menu, findInMenu, translate]); } function useMenuConfig(): MenuProviderContextProps { return useMenuContext(); } export type { MenuBreadcrumb, MenuGroup, MenuItem, MenuPermissions, MenuProviderContextProps, MenuProviderProps, UseMenuProps }; export { MenuConfigProvider, useBreadcrumbs, useMenu, useMenuConfig, useMenuContext };