diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b237dec..252112bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Bigcapital server-side will be in this file. +## [1.7.4-rc.2] - 20-04-2022 + - `BIG-374` Refactoring sidebar men with ability permissions and feature control on each item. + +### Fixed + - ## [1.7.3-rc.2] - 15-04-2022 ### Fixed diff --git a/package.json b/package.json index cf209c2af..a8ee35565 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "cross-env": "^7.0.2", "css-loader": "3.4.2", "deep-map-keys": "^2.0.1", + "deepdash": "^5.3.9", "dependency-graph": "^0.11.0", "dotenv": "8.2.0", "dotenv-expand": "5.1.0", diff --git a/src/components/Dashboard/Dashboard.js b/src/components/Dashboard/Dashboard.js index dbfd47c4e..9933ef858 100644 --- a/src/components/Dashboard/Dashboard.js +++ b/src/components/Dashboard/Dashboard.js @@ -3,7 +3,7 @@ import { Switch, Route } from 'react-router'; import 'style/pages/Dashboard/Dashboard.scss'; -import Sidebar from 'components/Sidebar/Sidebar'; +import { Sidebar } from 'containers/Dashboard/Sidebar/Sidebar'; import DashboardContent from 'components/Dashboard/DashboardContent'; import DialogsContainer from 'components/DialogsContainer'; import PreferencesPage from 'components/Preferences/PreferencesPage'; diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 74144e45b..e03fc74ad 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -104,7 +104,7 @@ import { // textClassName?: string; // } -export default class MenuItem extends AbstractPureComponent2 { +export class MenuItem extends AbstractPureComponent2 { static get defaultProps() { return { disabled: false, diff --git a/src/components/Sidebar/SidebarMenu.js b/src/components/Sidebar/SidebarMenu.js deleted file mode 100644 index 5a8d2bd82..000000000 --- a/src/components/Sidebar/SidebarMenu.js +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import { Menu, MenuDivider } from '@blueprintjs/core'; -import { useHistory, useLocation } from 'react-router-dom'; - -import { Choose } from 'components'; -import Icon from 'components/Icon'; -import MenuItem from 'components/MenuItem'; -import { MenuItemLabel } from 'components'; -import classNames from 'classnames'; -import SidebarOverlay from 'components/SidebarOverlay'; -import { compose } from 'redux'; -import withSubscriptions from '../../containers/Subscriptions/withSubscriptions'; - -const DEFAULT_ITEM = { - text: '', - href: '', -}; - -function matchPath(pathname, path, matchExact) { - return matchExact ? pathname === path : pathname.indexOf(path) !== -1; -} - -function SidebarMenuItemSpace({ space }) { - return
; -} - -function SidebarMenu({ menu, isSubscriptionActive }) { - const history = useHistory(); - const location = useLocation(); - - const [isOpen, setIsOpen] = React.useState(false); - const [currentItem, setCurrentItem] = React.useState(null); - - const menuItemsMapper = (list) => { - return list.map((item, index) => { - const hasChildren = Array.isArray(item.children); - - const isActive = - (item.children - ? item.children.some((c) => - matchPath(location.pathname, c.href, item.matchExact), - ) - : item.href && - matchPath(location.pathname, item.href, item.matchExact)) || - currentItem === item; - - const handleItemClick = () => { - if (item.href) { - history.push(item.href); - } - if (item.children && item.children.length > 0) { - setIsOpen(true); - setCurrentItem(item); - } else { - setIsOpen(false); - } - }; - - return ( - - - - - - - - - - - - - - - } - text={item.text} - disabled={item.disabled} - dropdownType={item.dropdownType || 'collapse'} - caretIconSize={16} - onClick={handleItemClick} - callapseActive={!!isActive} - itemClassName={classNames({ - 'is-active': isActive, - 'has-icon': !hasChildren && item.icon, - })} - hasSubmenu={hasChildren} - /> - - - ); - }); - }; - - const filterItems = menu.filter( - (item) => isSubscriptionActive || item.enableBilling, - ); - const items = menuItemsMapper(filterItems); - - const handleSidebarOverlayClose = () => { - setIsOpen(false); - }; - - return ( -
- {items}{' '} - -
- ); -} - -export default compose( - withSubscriptions( - ({ isSubscriptionActive }) => ({ isSubscriptionActive }), - 'main', - ), -)(SidebarMenu); diff --git a/src/components/Sidebar/utils.js b/src/components/Sidebar/utils.js deleted file mode 100644 index fcf056fac..000000000 --- a/src/components/Sidebar/utils.js +++ /dev/null @@ -1,48 +0,0 @@ -import sidebarMenuList from 'config/sidebarMenu'; -import { isArray, isEmpty } from 'lodash'; -import { useAbilityContext } from 'hooks/utils'; - -export function useGetSidebarMenu() { - const ability = useAbilityContext(); - - return sidebarMenuList - .map((item) => { - const children = isArray(item.children) - ? item.children.filter((childItem) => { - return isArray(childItem.permission) - ? childItem.permission.some((perm) => - ability.can(perm.ability, perm.subject), - ) - : childItem?.permission?.ability && childItem?.permission?.subject - ? ability.can( - childItem.permission.ability, - childItem.permission.subject, - ) - : true; - }) - : []; - - return { - ...item, - ...(isArray(item.children) - ? { - children, - } - : {}), - }; - }) - .filter((item) => { - return isArray(item.permission) - ? item.permission.some((per) => - ability.can(per.ability, per.subject), - ) - : item?.permission?.ability && item?.permission?.subject - ? ability.can(item.permission.ability, item.permission.subject) - : true; - }) - .filter((item) => - isEmpty(item.children) && !item.href && !item.label && !item.divider - ? false - : true, - ); -} diff --git a/src/components/SidebarOverlay/index.tsx b/src/components/SidebarOverlay/index.tsx deleted file mode 100644 index b4e37dd62..000000000 --- a/src/components/SidebarOverlay/index.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import React from 'react'; -import { Overlay } from '@blueprintjs/core'; -import { Link } from 'react-router-dom'; -import SidebarOverlayContainer from './SidebarOverlayContainer'; -interface ISidebarOverlayItem { - text: string; - href: string; - divider?: boolean; - label?: boolean; -} - -interface ISidebarOverlayProps { - isOpen: boolean; - items: ISidebarOverlayItem[]; - label: string; - onClose: Function; -} - -interface ISidebarOverlayItemProps { - text: string; - href: string; - onLinkClick: Function; -} - -interface ISidebarOverlayItemDivider { - divider: boolean; -} -/** - * Sidebar overlay item. - */ -function SidebarOverlayItem({ - text, - href, - onLinkClick, -}: ISidebarOverlayItemProps) { - const handleLinkClick = () => { - onLinkClick && onLinkClick(); - }; - return ( -
- - {text} - -
- ); -} - -interface ISidebarOverlayItemLabel { - text: string; -} - -function SidebarOverlayLabel({ text }: ISidebarOverlayItemLabel) { - return
{text}
; -} - -function SidebarOverlayDivider() { - return
; -} - -/** - * Sidebar overlay component. - */ -export default function SidebarOverlay({ - label, - isOpen: controllerdIsOpen, - onClose, - items, -}: ISidebarOverlayProps) { - const [isEverOpened, setEverOpened] = React.useState(false); - const [isOpen, setIsOpen] = React.useState(controllerdIsOpen); - - React.useEffect(() => { - if ( - typeof controllerdIsOpen !== 'undefined' && - isOpen !== controllerdIsOpen - ) { - setIsOpen(controllerdIsOpen); - } - }, [controllerdIsOpen, setIsOpen, isOpen]); - - React.useEffect(() => { - if (isOpen && !isEverOpened) { - setEverOpened(true); - } - }, [isEverOpened, isOpen]); - - if (!isEverOpened) { - return ''; - } - - // Handle overlay close event. - const handleOverlayClose = () => { - setIsOpen(false); - onClose && onClose(); - }; - // Handle overlay open event. - const handleOverlayOpen = () => { - setIsOpen(true); - }; - // Handle sidebar item link click. - const handleItemClick = () => { - setIsOpen(false); - onClose && onClose(); - }; - - return ( - -
- -
- {label && ( - <> - - - - )} - - {items.map((item) => - item.divider ? ( - - ) : item.label ? ( - - ) : ( - - ), - )} -
-
-
-
- ); -} diff --git a/src/components/index.js b/src/components/index.js index a569b36c2..68fdf5be6 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -106,6 +106,7 @@ export * from './Paper'; export * from './Accounts'; export * from './DataTableCells'; export * from './FlexGrid'; +export * from './MenuItem'; const Hint = FieldHint; diff --git a/src/config/sidebarMenu.js b/src/config/sidebarMenu.js index 2c332ffe3..b11315670 100644 --- a/src/config/sidebarMenu.js +++ b/src/config/sidebarMenu.js @@ -1,5 +1,11 @@ import React from 'react'; import { FormattedMessage as T } from 'components'; +import { Features } from '../common/features'; +import { + ISidebarMenuItemType, + ISidebarMenuOverlayIds, + ISidebarSubscriptionAbility, +} from '../containers/Dashboard/Sidebar/interfaces'; import { ReportsAction, AbilitySubject, @@ -18,819 +24,718 @@ import { ExpenseAction, CashflowAction, PreferencesAbility, - ExchangeRateAbility, SubscriptionBillingAbility, } from '../common/abilityOption'; -export default [ +export const SidebarMenu = [ + // --------------- + // # Homepage + // --------------- { text: , + type: ISidebarMenuItemType.Link, disabled: false, href: '/', matchExact: true, }, + // --------------- + // # Sales & Inventory + // --------------- { text: , - label: true, - permission: [ - { - subject: AbilitySubject.Item, - ability: ItemAction.View, - }, - { - subject: AbilitySubject.InventoryAdjustment, - ability: InventoryAdjustmentAction.View, - }, - { - subject: AbilitySubject.Estimate, - ability: SaleEstimateAction.View, - }, - { - subject: AbilitySubject.Invoice, - ability: SaleInvoiceAction.View, - }, - { - subject: AbilitySubject.Receipt, - ability: SaleReceiptAction.View, - }, - { - subject: AbilitySubject.PaymentReceive, - ability: PaymentReceiveAction.View, - }, - ], - }, - { - text: , + type: ISidebarMenuItemType.Group, children: [ { text: , - href: '/items', - permission: { - subject: AbilitySubject.Item, - ability: ItemAction.View, - }, - }, - { - text: , - href: '/inventory-adjustments', - permission: { - subject: AbilitySubject.InventoryAdjustment, - ability: InventoryAdjustmentAction.View, - }, - }, - { - text: , - href: '/warehouses-transfers', - }, - { - text: , - href: '/items/categories', - permission: { - subject: AbilitySubject.Item, - ability: ItemAction.View, - }, - }, - { - text: , - label: true, - permission: [ + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Items, + children: [ { - subject: AbilitySubject.Item, - ability: ItemAction.Create, + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/items', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Item, + ability: ItemAction.View, + }, + }, + { + text: , + href: '/inventory-adjustments', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.InventoryAdjustment, + ability: InventoryAdjustmentAction.View, + }, + }, + { + text: , + href: '/warehouses-transfers', + type: ISidebarMenuItemType.Link, + feature: Features.Warehouses, + }, + { + text: , + href: '/items/categories', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Item, + ability: ItemAction.View, + }, + }, + ], + }, + { + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/items/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Item, + ability: ItemAction.Create, + }, + }, + { + text: ( + + ), + href: '/warehouses-transfers/new', + type: ISidebarMenuItemType.Link, + feature: Features.Warehouses, + }, + { + text: , + href: '/items/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Item, + ability: ItemAction.Create, + }, + }, + { + text: , + href: '/items/categories/new', + type: ISidebarMenuItemType.Dialog, + dialogName: 'item-category-form', + permission: { + subject: AbilitySubject.Item, + ability: ItemAction.Create, + }, + }, + ], }, ], }, - { - divider: true, - permission: [ - { - subject: AbilitySubject.Item, - ability: ItemAction.Create, - }, - ], - }, - { - text: , - href: '/items/new', - permission: { - subject: AbilitySubject.Item, - ability: ItemAction.Create, - }, - }, - { - text: , - href: '/warehouses-transfers/new', - }, - { - text: , - href: '/items/new', - permission: { - subject: AbilitySubject.Item, - ability: ItemAction.Create, - }, - }, - { - text: , - href: '/items/categories/new', - permission: { - subject: AbilitySubject.Item, - ability: ItemAction.Create, - }, - }, ], }, + // --------------- + // # Sales + // --------------- { text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Sales, children: [ { - text: , - href: '/estimates', - permission: { - subject: AbilitySubject.Estimate, - ability: SaleEstimateAction.View, - }, - }, - { - text: , - href: '/invoices', - permission: { - subject: AbilitySubject.Invoice, - ability: SaleInvoiceAction.View, - }, - }, - { - text: , - href: '/receipts', - permission: { - subject: AbilitySubject.Receipt, - ability: SaleReceiptAction.View, - }, - }, - { - text: , - href: '/credit-notes', - }, - { - text: , - href: '/payment-receives', - permission: { - subject: AbilitySubject.PaymentReceive, - ability: PaymentReceiveAction.View, - }, + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/estimates', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Estimate, + ability: SaleEstimateAction.View, + }, + }, + { + text: , + href: '/invoices', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Invoice, + ability: SaleInvoiceAction.View, + }, + }, + { + text: , + href: '/receipts', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Receipt, + ability: SaleReceiptAction.View, + }, + }, + { + text: , + href: '/credit-notes', + type: ISidebarMenuItemType.Link, + }, + { + text: , + href: '/payment-receives', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.PaymentReceive, + ability: PaymentReceiveAction.View, + }, + }, + ], }, { text: , - label: true, - permission: [ + type: ISidebarMenuItemType.Group, + children: [ { - subject: AbilitySubject.Estimate, - ability: SaleEstimateAction.Create, + text: , + href: '/estimates/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Estimate, + ability: SaleEstimateAction.Create, + }, }, { - subject: AbilitySubject.Invoice, - ability: SaleInvoiceAction.Create, + text: , + href: '/invoices/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Invoice, + ability: SaleInvoiceAction.Create, + }, }, { - subject: AbilitySubject.Receipt, - ability: SaleReceiptAction.Create, + text: , + href: '/receipts/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Receipt, + ability: SaleReceiptAction.Create, + }, }, { - subject: AbilitySubject.PaymentReceive, - ability: PaymentReceiveAction.Create, + text: , + href: '/credit-notes/new', + type: ISidebarMenuItemType.Link, + }, + { + text: , + href: '/payment-receives/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.PaymentReceive, + ability: PaymentReceiveAction.Create, + }, }, ], }, - { - divider: true, - permission: [ - { - subject: AbilitySubject.Estimate, - ability: SaleEstimateAction.Create, - }, - { - subject: AbilitySubject.Invoice, - ability: SaleInvoiceAction.Create, - }, - { - subject: AbilitySubject.Receipt, - ability: SaleReceiptAction.Create, - }, - { - subject: AbilitySubject.PaymentReceive, - ability: PaymentReceiveAction.Create, - }, - ], - }, - { - text: , - href: '/estimates/new', - permission: { - subject: AbilitySubject.Estimate, - ability: SaleEstimateAction.Create, - }, - }, - { - text: , - href: '/invoices/new', - permission: { - subject: AbilitySubject.Invoice, - ability: SaleInvoiceAction.Create, - }, - }, - { - text: , - href: '/receipts/new', - permission: { - subject: AbilitySubject.Receipt, - ability: SaleReceiptAction.Create, - }, - }, - { - text: , - href: '/credit-notes/new', - }, - { - text: , - href: '/payment-receives/new', - permission: { - subject: AbilitySubject.PaymentReceive, - ability: PaymentReceiveAction.Create, - }, - }, ], }, + // --------------- + // # Purchases + // --------------- { text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Purchases, children: [ { - text: , - href: '/bills', - permission: { - subject: AbilitySubject.Bill, - ability: BillAction.View, - }, - }, - { - text: , - href: '/vendor-credits', - }, - { - text: , - href: '/payment-mades', - newTabHref: '/payment-mades/new', - permission: { - subject: AbilitySubject.PaymentMade, - ability: PaymentMadeAction.View, - }, + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/bills', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Bill, + ability: BillAction.View, + }, + }, + { + text: , + href: '/vendor-credits', + type: ISidebarMenuItemType.Link, + }, + { + text: , + href: '/payment-mades', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.PaymentMade, + ability: PaymentMadeAction.View, + }, + }, + ], }, { text: , - label: true, - permission: [ + type: ISidebarMenuItemType.Group, + children: [ { - subject: AbilitySubject.Bill, - ability: BillAction.Create, + text: , + href: '/bills/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Bill, + ability: BillAction.Create, + }, }, { - subject: AbilitySubject.PaymentMade, - ability: PaymentMadeAction.Create, + text: , + href: '/vendor-credits/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Bill, + ability: BillAction.Create, + }, + }, + { + text: , + href: '/payment-mades/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.PaymentMade, + ability: PaymentMadeAction.Create, + }, }, ], }, - { - divider: true, - permission: [ - { - subject: AbilitySubject.Bill, - ability: BillAction.Create, - }, - { - subject: AbilitySubject.PaymentMade, - ability: PaymentMadeAction.Create, - }, - ], - }, - { - text: , - href: '/bills/new', - permission: { - subject: AbilitySubject.Bill, - ability: BillAction.Create, - }, - }, - { - text: , - href: '/vendor-credits/new', - permission: { - subject: AbilitySubject.Bill, - ability: BillAction.Create, - }, - }, - { - text: , - href: '/payment-mades/new', - permission: { - subject: AbilitySubject.PaymentMade, - ability: PaymentMadeAction.Create, - }, - }, ], }, + // --------------- + // # Contacts + // --------------- { text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Contacts, children: [ { - text: , - href: '/customers', - permission: { - subject: AbilitySubject.Customer, - ability: CustomerAction.View, - }, - }, - { - text: , - href: '/vendors', - permission: { - subject: AbilitySubject.Vendor, - ability: VendorAction.Create, - }, + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/customers', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Customer, + ability: CustomerAction.View, + }, + }, + { + text: , + href: '/vendors', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Vendor, + ability: VendorAction.Create, + }, + }, + ], }, { text: , - label: true, - permission: [ + type: ISidebarMenuItemType.Group, + children: [ { - subject: AbilitySubject.Customer, - ability: CustomerAction.View, + text: , + href: '/customers/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Customer, + ability: CustomerAction.View, + }, }, { - subject: AbilitySubject.Vendor, - ability: VendorAction.View, + text: , + href: '/vendors/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Vendor, + ability: VendorAction.View, + }, }, ], }, - { - divider: true, - permission: [ - { - subject: AbilitySubject.Customer, - ability: CustomerAction.View, - }, - { - subject: AbilitySubject.Vendor, - ability: VendorAction.View, - }, - ], - }, - { - text: , - href: '/customers/new', - permission: { - subject: AbilitySubject.Customer, - ability: CustomerAction.View, - }, - }, - { - text: , - href: '/vendors/new', - permission: { - subject: AbilitySubject.Vendor, - ability: VendorAction.View, - }, - }, ], }, + // --------------- + // # Accounting + // --------------- { text: , - label: true, - permission: [ - { - subject: AbilitySubject.Account, - ability: AccountAction.View, - }, - { - subject: AbilitySubject.ManualJournal, - ability: ManualJournalAction.View, - }, - ], - }, - { - text: , + type: ISidebarMenuItemType.Group, children: [ { - text: , - href: '/accounts', - permission: { - subject: AbilitySubject.Account, - ability: AccountAction.View, - }, - }, - { - text: , - href: '/manual-journals', - permission: { - subject: AbilitySubject.ManualJournal, - ability: ManualJournalAction.View, - }, - }, - { - text: , - href: '/transactions-locking', - // permission: { - // subject: AbilitySubject.ManualJournal, - // ability: ManualJournalAction.TransactionLocking, - // }, - }, - { - text: , - href: '/exchange-rates', - permission: { - subject: AbilitySubject.ExchangeRate, - ability: ExchangeRateAbility.View, - }, - }, - { - text: , - label: true, - permission: { - subject: AbilitySubject.ManualJournal, - ability: ManualJournalAction.Create, - }, - }, - { - divider: true, - permission: { - subject: AbilitySubject.ManualJournal, - ability: ManualJournalAction.Create, - }, - }, - { - text: , - href: '/make-journal-entry', - permission: { - subject: AbilitySubject.ManualJournal, - ability: ManualJournalAction.Create, - }, + text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Financial, + children: [ + { + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/accounts', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Account, + ability: AccountAction.View, + }, + }, + { + text: , + href: '/manual-journals', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.ManualJournal, + ability: ManualJournalAction.View, + }, + }, + { + text: , + href: '/transactions-locking', + type: ISidebarMenuItemType.Link, + }, + ], + }, + { + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/make-journal-entry', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.ManualJournal, + ability: ManualJournalAction.Create, + }, + }, + ], + }, + ], }, ], }, + // --------------- + // # Cashflow + // --------------- { text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Cashflow, children: [ { - text: , - href: '/cashflow-accounts', - permission: { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.View, - }, + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/cashflow-accounts', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Cashflow, + ability: CashflowAction.View, + }, + }, + ], }, { text: , - label: true, - permission: [ - { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.Create, - }, - ], - }, - { + type: ISidebarMenuItemType.Group, divider: true, - permission: [ + children: [ { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.Create, + text: , + href: '/cashflow-accounts', + type: ISidebarMenuItemType.Dialog, + dialogName: 'money-in', + permission: { + subject: AbilitySubject.Cashflow, + ability: CashflowAction.Create, + }, + }, + { + text: , + href: '/cashflow-accounts', + type: ISidebarMenuItemType.Dialog, + permission: { + subject: AbilitySubject.Cashflow, + ability: CashflowAction.Create, + }, + }, + { + text: , + href: '/cashflow-accounts', + type: ISidebarMenuItemType.Dialog, + permission: { + subject: AbilitySubject.Cashflow, + ability: CashflowAction.Create, + }, + }, + { + text: , + href: '/cashflow-accounts', + type: ISidebarMenuItemType.Dialog, + permission: { + subject: AbilitySubject.Cashflow, + ability: CashflowAction.Create, + }, }, ], }, - { - text: , - href: '/cashflow-accounts', - permission: { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.Create, - }, - }, - { - text: , - href: '/cashflow-accounts', - permission: { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.Create, - }, - }, - { - text: , - href: '/cashflow-accounts', - permission: { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.Create, - }, - }, - { - text: , - href: '/cashflow-accounts', - permission: { - subject: AbilitySubject.Cashflow, - ability: CashflowAction.Create, - }, - }, ], }, + // --------------- + // # Expenses + // --------------- { text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Expenses, children: [ { text: , - href: '/expenses', - permission: { - subject: AbilitySubject.Expense, - ability: ExpenseAction.View, - }, + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/expenses', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Expense, + ability: ExpenseAction.View, + }, + }, + ], }, { text: , - label: true, - permission: { - subject: AbilitySubject.Expense, - ability: ExpenseAction.Create, - }, - }, - { - divider: true, - permission: { - subject: AbilitySubject.Expense, - ability: ExpenseAction.Create, - }, - }, - { - text: , - href: '/expenses/new', - permission: { - subject: AbilitySubject.Expense, - ability: ExpenseAction.Create, - }, + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/expenses/new', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Expense, + ability: ExpenseAction.Create, + }, + }, + ], }, ], }, + // --------------- + // # Reports + // --------------- { text: , + type: ISidebarMenuItemType.Overlay, + overlayId: ISidebarMenuOverlayIds.Reports, children: [ { - text: , - href: '/financial-reports/balance-sheet', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_BALANCE_SHEET, - }, + text: , + type: ISidebarMenuItemType.Group, + children: [ + { + text: , + href: '/financial-reports/balance-sheet', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_BALANCE_SHEET, + }, + }, + { + text: , + href: '/financial-reports/trial-balance-sheet', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_TRIAL_BALANCE_SHEET, + }, + }, + { + text: , + href: '/financial-reports/journal-sheet', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_JOURNAL, + }, + }, + { + text: , + href: '/financial-reports/general-ledger', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_GENERAL_LEDGET, + }, + }, + { + text: , + href: '/financial-reports/profit-loss-sheet', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_PROFIT_LOSS, + }, + }, + { + text: , + href: '/financial-reports/cash-flow', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_CASHFLOW_ACCOUNT_TRANSACTION, + }, + }, + { + text: , + href: '/financial-reports/receivable-aging-summary', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_AR_AGING_SUMMARY, + }, + }, + { + text: , + href: '/financial-reports/payable-aging-summary', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_AP_AGING_SUMMARY, + }, + }, + ], }, - { - text: , - href: '/financial-reports/trial-balance-sheet', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_TRIAL_BALANCE_SHEET, - }, - }, - { - text: , - href: '/financial-reports/journal-sheet', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_JOURNAL, - }, - }, - { - text: , - href: '/financial-reports/general-ledger', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_GENERAL_LEDGET, - }, - }, - { - text: , - href: '/financial-reports/profit-loss-sheet', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_PROFIT_LOSS, - }, - }, - { - text: , - href: '/financial-reports/cash-flow', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CASHFLOW_ACCOUNT_TRANSACTION, - }, - }, - { - text: , - href: '/financial-reports/receivable-aging-summary', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_AR_AGING_SUMMARY, - }, - }, - { - text: , - href: '/financial-reports/payable-aging-summary', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_AP_AGING_SUMMARY, - }, - }, - // { - // text: , - // href: '/financial-reports/realized-gain-loss', - // }, - // { - // text: , - // href: '/financial-reports/unrealized-gain-loss', - // }, { text: , - label: true, - permission: [ + type: ISidebarMenuItemType.Group, + children: [ { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_PURCHASES_BY_ITEMS, + text: , + type: ISidebarMenuItemType.Link, + href: '/financial-reports/purchases-by-items', + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_PURCHASES_BY_ITEMS, + }, }, { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_SALES_BY_ITEMS, + text: , + href: '/financial-reports/sales-by-items', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_SALES_BY_ITEMS, + }, }, { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CUSTOMERS_TRANSACTIONS, + text: , + href: '/financial-reports/transactions-by-customers', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_CUSTOMERS_TRANSACTIONS, + }, }, { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_VENDORS_TRANSACTIONS, + text: , + href: '/financial-reports/transactions-by-vendors', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_VENDORS_TRANSACTIONS, + }, }, { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CUSTOMERS_SUMMARY_BALANCE, + text: , + href: '/financial-reports/customers-balance-summary', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_CUSTOMERS_SUMMARY_BALANCE, + }, }, { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_VENDORS_SUMMARY_BALANCE, + text: , + href: '/financial-reports/vendors-balance-summary', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_VENDORS_SUMMARY_BALANCE, + }, }, ], }, - { - divider: true, - permission: [ - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_PURCHASES_BY_ITEMS, - }, - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_SALES_BY_ITEMS, - }, - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CUSTOMERS_TRANSACTIONS, - }, - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_VENDORS_TRANSACTIONS, - }, - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CUSTOMERS_SUMMARY_BALANCE, - }, - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_VENDORS_SUMMARY_BALANCE, - }, - ], - }, - { - text: , - href: '/financial-reports/purchases-by-items', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_PURCHASES_BY_ITEMS, - }, - }, - { - text: , - href: '/financial-reports/sales-by-items', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_SALES_BY_ITEMS, - }, - }, - { - text: , - href: '/financial-reports/transactions-by-customers', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CUSTOMERS_TRANSACTIONS, - }, - }, - { - text: , - href: '/financial-reports/transactions-by-vendors', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_VENDORS_TRANSACTIONS, - }, - }, - { - text: , - href: '/financial-reports/customers-balance-summary', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_CUSTOMERS_SUMMARY_BALANCE, - }, - }, - { - text: , - href: '/financial-reports/vendors-balance-summary', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_VENDORS_SUMMARY_BALANCE, - }, - }, { text: , - label: true, - permission: [ + type: ISidebarMenuItemType.Group, + children: [ { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_ITEM_DETAILS, + text: , + href: '/financial-reports/inventory-item-details', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_INVENTORY_ITEM_DETAILS, + }, }, { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY, + text: , + href: '/financial-reports/inventory-valuation', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Report, + ability: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY, + }, }, ], }, - { - divider: true, - permission: [ - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_ITEM_DETAILS, - }, - { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY, - }, - ], - }, - { - text: , - href: '/financial-reports/inventory-item-details', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_ITEM_DETAILS, - }, - }, - { - text: , - href: '/financial-reports/inventory-valuation', - permission: { - subject: AbilitySubject.Report, - ability: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY, - }, - }, ], }, { text: , - enableBilling: true, - label: true, - permission: [ + type: ISidebarMenuItemType.Group, + children: [ { - subject: AbilitySubject.Preferences, - ability: PreferencesAbility.Mutate, + text: , + href: '/preferences', + type: ISidebarMenuItemType.Link, + permission: { + subject: AbilitySubject.Preferences, + ability: PreferencesAbility.Mutate, + }, }, { - subject: AbilitySubject.SubscriptionBilling, - ability: SubscriptionBillingAbility.View, + text: , + href: '/billing', + type: ISidebarMenuItemType.Link, + subscription: [ + ISidebarSubscriptionAbility.Expired, + ISidebarSubscriptionAbility.Active, + ], + permission: { + subject: AbilitySubject.SubscriptionBilling, + ability: SubscriptionBillingAbility.View, + }, }, ], }, - { - text: , - href: '/preferences', - permission: { - subject: AbilitySubject.Preferences, - ability: PreferencesAbility.Mutate, - }, - }, - { - text: , - href: '/billing', - enableBilling: true, - permission: { - subject: AbilitySubject.SubscriptionBilling, - ability: SubscriptionBillingAbility.View, - }, - }, ]; diff --git a/src/components/Sidebar/Sidebar.js b/src/containers/Dashboard/Sidebar/Sidebar.js similarity index 53% rename from src/components/Sidebar/Sidebar.js rename to src/containers/Dashboard/Sidebar/Sidebar.js index 92f97cc95..82b633e62 100644 --- a/src/components/Sidebar/Sidebar.js +++ b/src/containers/Dashboard/Sidebar/Sidebar.js @@ -1,14 +1,19 @@ import React from 'react'; -import SidebarContainer from 'components/Sidebar/SidebarContainer'; -import SidebarHead from 'components/Sidebar/SidebarHead'; -import SidebarMenu from 'components/Sidebar/SidebarMenu'; -import { useGetSidebarMenu } from './utils'; +import { SidebarContainer } from './SidebarContainer'; +import { SidebarHead } from './SidebarHead'; +import { SidebarMenu } from './SidebarMenu'; +import { useMainSidebarMenu } from './hooks'; +import { SidebarOverlayBinded } from '../SidebarOverlay'; import 'style/containers/Dashboard/Sidebar.scss'; -export default function Sidebar({ dashboardContentRef }) { - const menu = useGetSidebarMenu(); +/** + * Dashboard sidebar. + * @returns {JSX.Element} + */ +export function Sidebar() { + const menu = useMainSidebarMenu(); return ( @@ -17,14 +22,14 @@ export default function Sidebar({ dashboardContentRef }) {
- +
); } /** - * Sidebar footer version. + * Sidebar footer version. * @returns {React.JSX} */ function SidebarFooterVersion() { diff --git a/src/components/Sidebar/SidebarContainer.js b/src/containers/Dashboard/Sidebar/SidebarContainer.js similarity index 76% rename from src/components/Sidebar/SidebarContainer.js rename to src/containers/Dashboard/Sidebar/SidebarContainer.js index 3b5242176..61826b8d9 100644 --- a/src/components/Sidebar/SidebarContainer.js +++ b/src/containers/Dashboard/Sidebar/SidebarContainer.js @@ -1,19 +1,21 @@ import React, { useEffect } from 'react'; import { Scrollbar } from 'react-scrollbars-custom'; import classNames from 'classnames'; -import withDashboardActions from 'containers/Dashboard/withDashboardActions'; + import withDashboard from 'containers/Dashboard/withDashboard'; +import withSubscriptions from 'containers/Subscriptions/withSubscriptions'; +import { useObserveSidebarExpendedBodyclass } from './hooks'; import { compose } from 'utils'; -import withSubscriptions from '../../containers/Subscriptions/withSubscriptions'; -function SidebarContainer({ +/** + * Sidebar container/ + * @returns {JSX.Element} + */ +function SidebarContainerJSX({ // #ownProps children, - // #withDashboardActions - toggleSidebarExpend, - // #withDashboard sidebarExpended, @@ -22,9 +24,10 @@ function SidebarContainer({ }) { const sidebarScrollerRef = React.useRef(); - useEffect(() => { - document.body.classList.toggle('has-mini-sidebar', !sidebarExpended); + // Toggles classname to body once sidebar expend/shrink. + useObserveSidebarExpendedBodyclass(sidebarExpended); + useEffect(() => { if (!sidebarExpended && sidebarScrollerRef.current) { sidebarScrollerRef.current.scrollTo({ top: 0, @@ -39,9 +42,9 @@ function SidebarContainer({ } }; - const scrollerElementRef = (ref) => { + const scrollerElementRef = React.useCallback((ref) => { sidebarScrollerRef.current = ref; - }; + }, []); return (
({ sidebarExpended, })), @@ -73,4 +75,4 @@ export default compose( ({ isSubscriptionActive }) => ({ isSubscriptionActive }), 'main', ), -)(SidebarContainer); +)(SidebarContainerJSX); diff --git a/src/components/Sidebar/SidebarHead.js b/src/containers/Dashboard/Sidebar/SidebarHead.js similarity index 86% rename from src/components/Sidebar/SidebarHead.js rename to src/containers/Dashboard/Sidebar/SidebarHead.js index 25f6d390f..cac2b3aa0 100644 --- a/src/components/Sidebar/SidebarHead.js +++ b/src/containers/Dashboard/Sidebar/SidebarHead.js @@ -1,9 +1,11 @@ import React from 'react'; import { Button, Popover, Menu, Position } from '@blueprintjs/core'; -import Icon from 'components/Icon'; + +import { Icon } from 'components'; + +import withCurrentOrganization from 'containers/Organization/withCurrentOrganization'; +import { useAuthenticatedAccount } from 'hooks/query'; import { compose, firstLettersArgs } from 'utils'; -import withCurrentOrganization from '../../containers/Organization/withCurrentOrganization'; -import { useAuthenticatedAccount } from '../../hooks/query'; // Popover modifiers. const POPOVER_MODIFIERS = { @@ -13,7 +15,7 @@ const POPOVER_MODIFIERS = { /** * Sideabr head. */ -function SidebarHead({ +function SidebarHeadJSX({ // #withCurrentOrganization organization, }) { @@ -61,6 +63,6 @@ function SidebarHead({ ); } -export default compose( +export const SidebarHead = compose( withCurrentOrganization(({ organization }) => ({ organization })), -)(SidebarHead); +)(SidebarHeadJSX); diff --git a/src/containers/Dashboard/Sidebar/SidebarMenu.js b/src/containers/Dashboard/Sidebar/SidebarMenu.js new file mode 100644 index 000000000..82572a755 --- /dev/null +++ b/src/containers/Dashboard/Sidebar/SidebarMenu.js @@ -0,0 +1,74 @@ +import React from 'react'; +import { Menu } from '@blueprintjs/core'; +import * as R from 'ramda'; + +import { MenuItem, MenuItemLabel } from 'components'; +import { ISidebarMenuItemType } from 'containers/Dashboard/Sidebar/interfaces'; +import { useIsSidebarMenuItemActive } from './hooks'; + +import withSubscriptions from 'containers/Subscriptions/withSubscriptions'; + +/** + * Sidebar menu item. + * @returns {JSX.Element} + */ +function SidebarMenuItem({ item, index }) { + // Detarmine whether the item is active. + const isActive = useIsSidebarMenuItemActive(item); + + return ( + + ); +} + +SidebarMenuItem.ItemTypes = [ + ISidebarMenuItemType.Link, + ISidebarMenuItemType.Overlay, + ISidebarMenuItemType.Dialog, +]; + +/** + * Detarmines which sidebar menu item type should display. + * @returns {JSX.Element} + */ +function SidebarMenuItemComposer({ item, index }) { + // Link item type. + return SidebarMenuItem.ItemTypes.indexOf(item.type) !== -1 ? ( + + ) : // Group item type. + item.type === ISidebarMenuItemType.Group ? ( + + ) : null; +} + +/** + * Sidebar menu. + * @returns {JSX.Element} + */ +function SidebarMenuJSX({ menu }) { + return ( +
+ + {menu.map((item, index) => ( + + ))} + +
+ ); +} + +export const SidebarMenu = R.compose( + withSubscriptions( + ({ isSubscriptionActive }) => ({ isSubscriptionActive }), + 'main', + ), +)(SidebarMenuJSX); diff --git a/src/containers/Dashboard/Sidebar/hooks.js b/src/containers/Dashboard/Sidebar/hooks.js new file mode 100644 index 000000000..df8168d13 --- /dev/null +++ b/src/containers/Dashboard/Sidebar/hooks.js @@ -0,0 +1,358 @@ +import _, { isEmpty, includes } from 'lodash'; +import React from 'react'; +import * as R from 'ramda'; +import { useHistory } from 'react-router-dom'; + +import { useAbilityContext } from 'hooks'; +import { useSidebarSubmenu, useFeatureCan } from 'hooks/state'; +import { SidebarMenu } from 'config/sidebarMenu'; +import { + ISidebarMenuItemType, + ISidebarSubscriptionAbility, +} from './interfaces'; +import { + useSidebarSubmnuActions, + useDialogActions, + useSubscription, +} from 'hooks/state'; +import { filterValuesDeep, deepdash } from 'utils'; + +const deepDashConfig = { + childrenPath: 'children', + pathFormat: 'array', +}; +const ingoreTypesEmpty = [ + ISidebarMenuItemType.Group, + ISidebarMenuItemType.Overlay, +]; + +/** + * Removes the all overlay items from the menu to the main-sidebar. + * @param {ISidebarMenuItem[]} menu + * @returns {ISidebarMenuItem[]} + */ +function removeSidebarOverlayChildren(menu) { + return deepdash.mapValuesDeep( + menu, + (item, key, parent, context) => { + if (item.type === ISidebarMenuItemType.Overlay) { + context.skipChildren(true); + return _.omit(item, ['children']); + } + return item; + }, + deepDashConfig, + ); +} + +/** + * Retrives the main sidebar pre-menu. + * @returns {ISidebarMenuItem[]} + */ +export function getMainSidebarMenu() { + return R.compose(removeSidebarOverlayChildren)(SidebarMenu); +} + +/** + * Predicates whether the sidebar item has feature ability. + */ +function useFilterSidebarItemFeaturePredicater() { + const { featureCan } = useFeatureCan(); + + return { + // Returns false if the item has `feature` prop and that feature has no ability. + predicate: (item) => { + if (item.feature && !featureCan(item.feature)) { + return false; + } + return true; + }, + }; +} + +/** + * Predicates whether the sidebar item has permissio ability. + */ +function useFilterSidebarItemAbilityPredicater() { + const ability = useAbilityContext(); + + return { + // Retruns false if the item has `permission` prop and that permission has no ability. + predicate: (item) => { + if ( + item.permission && + !ability.can(item.permission.ability, item.permission.subject) + ) { + return false; + } + return true; + }, + }; +} + +/** + * Filters the sidebar item based on the subscription state. + */ +function useFilterSidebarItemSubscriptionPredicater() { + const { isSubscriptionActive, isSubscriptionInactive } = useSubscription(); + + return { + predicate: (item) => { + const { subscription } = item; + + if (subscription) { + const isActive = includes(subscription, [ + ISidebarSubscriptionAbility.Active, + ]) + ? isSubscriptionActive + : true; + + const isInactive = includes(subscription, [ + ISidebarSubscriptionAbility.Inactive, + ]) + ? isSubscriptionInactive + : true; + + return isActive && isInactive; + } + return true; + }, + }; +} + +/** + * Filters sidebar menu items based on ability of the item permission. + * @param {} menu + * @returns {} + */ +function useFilterSidebarMenuAbility(menu) { + const { predicate: predFeature } = useFilterSidebarItemFeaturePredicater(); + const { predicate: predAbility } = useFilterSidebarItemAbilityPredicater(); + const { predicate: predSubscription } = + useFilterSidebarItemSubscriptionPredicater(); + + return deepdash.filterDeep( + menu, + (item) => { + return predFeature(item) && predAbility(item) && predSubscription(item); + }, + deepDashConfig, + ); +} + +/** + * Flats the sidebar menu groups. + * @param {*} menu + * @returns {} + */ +function useFlatSidebarMenu(menu) { + return React.useMemo(() => { + return deepdash.mapDeep(menu, (item) => item, deepDashConfig); + }, [menu]); +} + +/** + * Binds sidebar link item click action. + * @param {ISidebarMenuItem} item + */ +function useBindSidebarItemLinkClick() { + const history = useHistory(); + const { closeSidebarSubmenu } = useSidebarSubmnuActions(); + + // Handle sidebar item click. + const onClick = (item) => (event) => { + closeSidebarSubmenu(); + history.push(item.href); + }; + return { + bindOnClick: (item) => { + return { + ...item, + onClick: onClick(item), + }; + }, + }; +} + +/** + * Bind sidebar dialog item click action. + * @param {ISidebarMenuItem} item + */ +function useBindSidebarItemDialogClick() { + const { closeSidebarSubmenu } = useSidebarSubmnuActions(); + const { openDialog } = useDialogActions(); + + // Handle sidebar item click. + const onClick = (item) => (event) => { + closeSidebarSubmenu(); + openDialog(item.dialogName, item.dialogPayload); + }; + return { + bindOnClick: (item) => { + return { + ...item, + onClick: onClick(item), + }; + }, + }; +} + +/** + * Binds click action for the sidebar overlay item. + */ +function useBindSidebarItemOverlayClick() { + const { toggleSidebarSubmenu, closeSidebarSubmenu } = + useSidebarSubmnuActions(); + + // Handle sidebar item click. + const onClick = (item) => (event) => { + closeSidebarSubmenu(); + toggleSidebarSubmenu({ submenuId: item.overlayId }); + }; + return { + bindOnClick: (item) => { + return { + ...item, + onClick: onClick(item), + }; + }, + }; +} + +/** + * Binds click action of the given sidebar menu for each item based on item type. + */ +function useBindSidebarItemClick(menu) { + const { bindOnClick: bindLinkClickEvt } = useBindSidebarItemLinkClick(); + const { bindOnClick: bindOverlayClickEvt } = useBindSidebarItemOverlayClick(); + const { bindOnClick: bindItemDialogEvt } = useBindSidebarItemDialogClick(); + + return React.useMemo(() => { + return deepdash.mapValuesDeep( + menu, + (item) => { + return R.compose( + R.when( + R.propSatisfies(R.equals(ISidebarMenuItemType.Link), 'type'), + bindLinkClickEvt, + ), + R.when( + R.propSatisfies(R.equals(ISidebarMenuItemType.Overlay), 'type'), + bindOverlayClickEvt, + ), + R.when( + R.propSatisfies(R.equals(ISidebarMenuItemType.Dialog), 'type'), + bindItemDialogEvt, + ), + )(item); + }, + deepDashConfig, + ); + }, [menu]); +} + +/** + * Finds the given overlay submenu id from the menu graph. + * @param {ISidebarMenuOverlayIds} + * @param {ISidebarMenuItem[]} menu - + * @returns {ISidebarMenuItem[]} + */ +const findSubmenuBySubmenuId = R.curry((submenuId, menu) => { + const groupItem = deepdash.findDeep( + menu, + (item) => { + return ( + item.type === ISidebarMenuItemType.Overlay && + item.overlayId === submenuId + ); + }, + deepDashConfig, + ); + return groupItem?.value?.children || []; +}); + +/** + * Retrieves the main sidebar post-menu. + * @returns {ISidebarMenuItem[]} + */ +export function useMainSidebarMenu() { + return R.compose( + useBindSidebarItemClick, + useFlatSidebarMenu, + removeSidebarOverlayChildren, + useAssocSidebarItemHasChildren, + filterSidebarItemHasNoChildren, + useFilterSidebarMenuAbility, + )(SidebarMenu); +} + +/** + * Assoc `hasChildren` prop to sidebar menu items. + * @param {ISidebarMenuItem[]} items + * @returns {ISidebarMenuItem[]} + */ +function useAssocSidebarItemHasChildren(items) { + return deepdash.mapValuesDeep( + items, + (item) => { + return { + ...item, + hasChildren: !isEmpty(item.children), + }; + }, + deepDashConfig, + ); +} + +/** + * Retrieves the sub-sidebar post-menu. + * @param {ISidebarMenuOverlayIds} submenuId + * @returns {ISidebarMenuItem[]} + */ +export function useSubSidebarMenu(submenuId) { + if (!submenuId) return []; + + return R.compose( + useBindSidebarItemClick, + useFlatSidebarMenu, + filterSidebarItemHasNoChildren, + useFilterSidebarMenuAbility, + findSubmenuBySubmenuId(submenuId), + )(SidebarMenu); +} + +/** + * Observes the sidebar expending with body class. + * @param {boolean} sidebarExpended + */ +export function useObserveSidebarExpendedBodyclass(sidebarExpended) { + React.useEffect(() => { + document.body.classList.toggle('has-mini-sidebar', !sidebarExpended); + }, [sidebarExpended]); +} + +/** + * Detamrines whether the given sidebar menu item is active. + * @returns {boolean} + */ +export function useIsSidebarMenuItemActive(item) { + const { submenuId } = useSidebarSubmenu(); + return ( + item.type === ISidebarMenuItemType.Overlay && submenuId === item.overlayId + ); +} + +/** + * Filter sidebar specific items types that have no types. + * @param {ISidebarMenuItem[]} items - + * @returns {ISidebarMenuItem[]} + */ +export function filterSidebarItemHasNoChildren(items) { + return filterValuesDeep((item) => { + // If it was group item and has no children items so discard that item. + if (ingoreTypesEmpty.indexOf(item.type) !== -1 && isEmpty(item.children)) { + return false; + } + return true; + }, items); +} diff --git a/src/config/interfaces.tsx b/src/containers/Dashboard/Sidebar/interfaces.ts similarity index 56% rename from src/config/interfaces.tsx rename to src/containers/Dashboard/Sidebar/interfaces.ts index db265d6f4..b3f7eaa3a 100644 --- a/src/config/interfaces.tsx +++ b/src/containers/Dashboard/Sidebar/interfaces.ts @@ -2,7 +2,9 @@ export enum ISidebarMenuItemType { Label = 'label', Link = 'link', Group = 'group', - Overlay = 'overlay' + Overlay = 'overlay', + Dialog = 'dialog', + Drawer = 'drawer', } export interface ISidebarMenuItemOverlay extends ISidebarMenuItemCommon { @@ -16,6 +18,18 @@ export interface ISidebarMenuItemLink extends ISidebarMenuItemCommon { matchExact?: boolean; } +export interface ISidebarMenuItemDialog extends ISidebarMenuItemCommon { + type: ISidebarMenuItemType.Dialog; + dialogName: string; + dialogPayload: any; +} + +export interface ISidebarMenuItemDrawer extends ISidebarMenuItemCommon { + type: ISidebarMenuItemType.Drawer; + drawerName: string; + drawerPayload: any; +} + export interface ISidebarMenuItemLabel extends ISidebarMenuItemCommon { text?: string; type: ISidebarMenuItemType.Label; @@ -42,4 +56,22 @@ export type ISidebarMenuItem = | ISidebarMenuItemLink | ISidebarMenuItemLabel | ISidebarMenuItemGroup - | ISidebarMenuItemOverlay; + | ISidebarMenuItemOverlay + | ISidebarMenuItemDialog + | ISidebarMenuItemDrawer; + +export enum ISidebarMenuOverlayIds { + Items = 'Items', + Reports = 'Reports', + Sales = 'Sales', + Purchases = 'Purchases', + Financial = 'Financial', + Contacts = 'Contacts', + Cashflow = 'Cashflow', + Expenses = 'Expenses', +} + +export enum ISidebarSubscriptionAbility { + Expired = 'SubscriptionExpired', + Active = 'SubscriptionActive', +} diff --git a/src/containers/Dashboard/Sidebar/withDashboardSidebar.js b/src/containers/Dashboard/Sidebar/withDashboardSidebar.js new file mode 100644 index 000000000..c775a164e --- /dev/null +++ b/src/containers/Dashboard/Sidebar/withDashboardSidebar.js @@ -0,0 +1,13 @@ + +import { connect } from 'react-redux'; + +export default (mapState) => { + const mapStateToProps = (state, props) => { + const mapped = { + sidebarSubmenuOpen: state.dashboard.sidebarSubmenu.isOpen, + sidebarSubmenuId: state.dashboard.sidebarSubmenu.submenuId, + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + return connect(mapStateToProps); +} diff --git a/src/containers/Dashboard/Sidebar/withDashboardSidebarActions.js b/src/containers/Dashboard/Sidebar/withDashboardSidebarActions.js new file mode 100644 index 000000000..75083265e --- /dev/null +++ b/src/containers/Dashboard/Sidebar/withDashboardSidebarActions.js @@ -0,0 +1,16 @@ +import { connect } from 'react-redux'; +import { + closeSidebarSubmenu, + openSidebarSubmenu, +} from 'store/dashboard/dashboard.actions'; + +const mapActionsToProps = (dispatch) => ({ + // Opens the dashboard submenu sidebar. + openDashboardSidebarSubmenu: (submenuId) => + dispatch(openSidebarSubmenu(submenuId)), + + // Closes the dashboard submenu sidebar. + closeDashboardSidebarSubmenu: () => dispatch(closeSidebarSubmenu()), +}); + +export default connect(null, mapActionsToProps); diff --git a/src/containers/Dashboard/SidebarOverlay/SidebarOverlay.tsx b/src/containers/Dashboard/SidebarOverlay/SidebarOverlay.tsx new file mode 100644 index 000000000..dc14e63f9 --- /dev/null +++ b/src/containers/Dashboard/SidebarOverlay/SidebarOverlay.tsx @@ -0,0 +1,142 @@ +//@ts-nocheck +import React from 'react'; +import { Overlay, OverlayProps } from '@blueprintjs/core'; +import { Link } from 'react-router-dom'; +import { SidebarOverlayContainer } from './SidebarOverlayContainer'; +import { ISidebarMenuItem, ISidebarMenuItemType } from '../Sidebar/interfaces'; + +export interface ISidebarOverlayItem { + text: string; + href: string; + divider?: boolean; + label?: boolean; +} + +export interface ISidebarOverlayProps { + items: ISidebarMenuItem[]; +} + +export interface ISidebarOverlayItemProps { + text: string | JSX.Element; + href?: string; + onClick?: any; +} + +export interface ISidebarOverlayItemDivider { + divider: boolean; +} + +/** + * Sidebar overlay item. + * @param {ISidebarOverlayItemProps} + * @returns {JSX.Element} + */ +export function SidebarOverlayItemLink({ + text, + href, + onClick, +}: ISidebarOverlayItemProps) { + return ( +
+ + {text} + +
+ ); +} + +export interface ISidebarOverlayItemLabel { + text: string; +} + +/** + * Sidebar overlay label item. + * @param {ISidebarOverlayItemLabel} + * @returns {JSX.Element} + */ +export function SidebarOverlayLabel({ + text, +}: ISidebarOverlayItemLabel): JSX.Element { + return
{text}
; +} + +/** + * Sidebar overlay divider item. + * @returns {JSX.Element} + */ +export function SidebarOverlayDivider() { + return
; +} + +interface SidebarOverlayItemProps { + item: ISidebarMenuItem; +} + +/** + * Sidebar overlay item. + * @param {SidebarOverlayItemProps} props - + * @returns {JSX.Element} + */ +function SidebarOverlayItem({ item }: SidebarOverlayItemProps) { + // + return item.type === ISidebarMenuItemType.Group ? ( + + ) : // + item.type === ISidebarMenuItemType.Link || + item.type === ISidebarMenuItemType.Dialog ? ( + + ) : null; +} + +/** + * + */ +export interface ISidebarOverlayMenu { + items: ISidebarMenuItem[]; +} + +/** + * Sidebar overlay menu. + * @param {ISidebarOverlayMenu} + * @returns {JSX.Element} + */ +function SidebarOverlayMenu({ items }: ISidebarOverlayMenu) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +export interface SidebarOverlayProps extends OverlayProps { + items: ISidebarMenuItem[]; +} + +/** + * Sidebar overlay component. + * @param {SidebarOverlayProps} + * @returns {JSX.Element} + */ +export function SidebarOverlay({ items, label, ...rest }: SidebarOverlayProps) { + return ( + +
+ + {label && } + + + +
+
+ ); +} diff --git a/src/containers/Dashboard/SidebarOverlay/SidebarOverlayBinded.tsx b/src/containers/Dashboard/SidebarOverlay/SidebarOverlayBinded.tsx new file mode 100644 index 000000000..ca8eb4d59 --- /dev/null +++ b/src/containers/Dashboard/SidebarOverlay/SidebarOverlayBinded.tsx @@ -0,0 +1,54 @@ +// @ts-nocheck +import React from 'react'; +import * as R from 'ramda'; + +import { SidebarOverlay } from './SidebarOverlay'; +import withDashboardSidebarActions from 'containers/Dashboard/Sidebar/withDashboardSidebarActions'; +import withDashboardSidebar from 'containers/Dashboard/Sidebar/withDashboardSidebar'; + +import { useSubSidebarMenu } from '../Sidebar/hooks'; + +/** + * Dashboard sidebar menu. + * @returns {JSX.Element} + */ +function SidebarOverlayBindedRoot({ + // #withDashboardSidebar + sidebarSubmenuOpen, + sidebarSubmenuId, + + // #withDashboardSidebarActions + closeDashboardSidebarSubmenu, +}) { + const handleSidebarClosing = React.useCallback(() => { + closeDashboardSidebarSubmenu(); + }, []); + + return ( + + ); +} + +/** + * Dashboard sidebar submenu router. + */ +function SidebarOverlayBindedRouter({ sidebarSubmenuId, ...rest }) { + const sidebarItems = useSubSidebarMenu(sidebarSubmenuId); + + return ; +} + +/** + * Sidebar overlay binded with redux. + */ +export const SidebarOverlayBinded = R.compose( + withDashboardSidebar(({ sidebarSubmenuOpen, sidebarSubmenuId }) => ({ + sidebarSubmenuOpen, + sidebarSubmenuId, + })), + withDashboardSidebarActions, +)(SidebarOverlayBindedRoot); diff --git a/src/components/SidebarOverlay/SidebarOverlayContainer.tsx b/src/containers/Dashboard/SidebarOverlay/SidebarOverlayContainer.tsx similarity index 64% rename from src/components/SidebarOverlay/SidebarOverlayContainer.tsx rename to src/containers/Dashboard/SidebarOverlay/SidebarOverlayContainer.tsx index 0122127e1..c44c88d7b 100644 --- a/src/components/SidebarOverlay/SidebarOverlayContainer.tsx +++ b/src/containers/Dashboard/SidebarOverlay/SidebarOverlayContainer.tsx @@ -1,14 +1,16 @@ import React from 'react'; import { Scrollbar } from 'react-scrollbars-custom'; -interface ISidebarOverlayContainerProps { - children: JSX.Element | JSX.Element[], +export interface ISidebarOverlayContainerProps { + children: JSX.Element | JSX.Element[]; } /** * Sidebar overlay container. */ -export default function SidebarOverlayContainer({ children }: ISidebarOverlayContainerProps) { +export function SidebarOverlayContainer({ + children, +}: ISidebarOverlayContainerProps) { return (
diff --git a/src/containers/Dashboard/SidebarOverlay/index.ts b/src/containers/Dashboard/SidebarOverlay/index.ts new file mode 100644 index 000000000..7900f6d30 --- /dev/null +++ b/src/containers/Dashboard/SidebarOverlay/index.ts @@ -0,0 +1,3 @@ +export * from './SidebarOverlay'; +export * from './SidebarOverlayContainer'; +export * from './SidebarOverlayBinded' \ No newline at end of file diff --git a/src/hooks/state/dashboard.js b/src/hooks/state/dashboard.js index 04c22e37f..0e1846ecd 100644 --- a/src/hooks/state/dashboard.js +++ b/src/hooks/state/dashboard.js @@ -1,9 +1,14 @@ import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; import { splashStopLoading, splashStartLoading, dashboardPageTitle, + openSidebarSubmenu, + closeSidebarSubmenu, + openDialog, + closeDialog, } from '../../store/dashboard/dashboard.actions'; export const useDispatchAction = (action) => { @@ -30,3 +35,44 @@ export const useSplashLoading = () => { useDispatchAction(splashStopLoading), ]; }; + +/** + * Sidebar submenu actions. + */ +export const useSidebarSubmnuActions = () => { + return { + openSidebarSubmenu: useDispatchAction(openSidebarSubmenu), + closeSidebarSubmenu: useDispatchAction(closeSidebarSubmenu), + toggleSidebarSubmenu: useDispatchAction(openSidebarSubmenu), + }; +}; + +/** + * Retrieves the sidebar submenu selector. + */ +const sidebarSubmenuSelector = createSelector( + (state) => state.dashboard.sidebarSubmenu, + (sidebarSubmenu) => sidebarSubmenu, +); + +/** + * Retrieves the sidebar submenu selector. + */ +export const useSidebarSubmenu = () => { + const sidebarSubmenu = useSelector(sidebarSubmenuSelector); + + return { + isOpen: sidebarSubmenu?.isOpen || false, + submenuId: sidebarSubmenu?.submenuId || null, + }; +}; + +/** + * Dialogs actions. + */ +export const useDialogActions = () => { + return { + openDialog: useDispatchAction(openDialog), + closeDialog: useDispatchAction(closeDialog), + }; +}; diff --git a/src/store/dashboard/dashboard.actions.js b/src/store/dashboard/dashboard.actions.js index 5d6222faa..6a257a15c 100644 --- a/src/store/dashboard/dashboard.actions.js +++ b/src/store/dashboard/dashboard.actions.js @@ -111,3 +111,16 @@ export const setFeatureDashboardMeta = ({ features }) => { }, }; }; + +export function openSidebarSubmenu({ submenuId }) { + return { + type: t.SIDEBAR_SUBMENU_OPEN, + payload: { submenuId }, + }; +} + +export function closeSidebarSubmenu() { + return { + type: t.SIDEBAR_SUBMENU_CLOSE, + }; +} diff --git a/src/store/dashboard/dashboard.reducer.js b/src/store/dashboard/dashboard.reducer.js index 424bf9e0c..0bf38a06c 100644 --- a/src/store/dashboard/dashboard.reducer.js +++ b/src/store/dashboard/dashboard.reducer.js @@ -20,10 +20,8 @@ const initialState = { splashScreenLoading: null, appIsLoading: true, appIntlIsLoading: true, - features: { - // branches: true, - // warehouses: true, - }, + sidebarSubmenu: { isOpen: false, submenuId: null }, + features: {}, }; const STORAGE_KEY = 'bigcapital:dashboard'; @@ -131,6 +129,16 @@ const reducerInstance = createReducer(initialState, { state.splashScreenLoading = Math.max(state.splashScreenLoading, 0); }, + [t.SIDEBAR_SUBMENU_OPEN]: (state, action) => { + state.sidebarSubmenu.isOpen = true; + state.sidebarSubmenu.submenuId = action.payload.submenuId; + }, + + [t.SIDEBAR_SUBMENU_CLOSE]: (state, action) => { + state.sidebarSubmenu.isOpen = false; + state.sidebarSubmenu.submenuId = null; + }, + [t.RESET]: () => { purgeStoredState(CONFIG); }, diff --git a/src/store/dashboard/dashboard.types.js b/src/store/dashboard/dashboard.types.js index 7374a0437..d613b94ab 100644 --- a/src/store/dashboard/dashboard.types.js +++ b/src/store/dashboard/dashboard.types.js @@ -13,6 +13,9 @@ export default { ALTER_DASHBOARD_PAGE_SUBTITLE: 'ALTER_DASHBOARD_PAGE_SUBTITLE', SET_TOPBAR_EDIT_VIEW: 'SET_TOPBAR_EDIT_VIEW', SIDEBAR_EXPEND_TOGGLE: 'SIDEBAR_EXPEND_TOGGLE', + SIDEBAR_SUBMENU_OPEN: 'SIDEBAR_SUBMENU_OPEN', + SIDEBAR_SUBMENU_CLOSE: 'SIDEBAR_SUBMENU_CLOSE', + SIDEBAR_SUBMENU_TOGGLE: 'SIDEBAR_SUBMENU_TOGGLE', SET_DASHBOARD_BACK_LINK: 'SET_DASHBOARD_BACK_LINK', SPLASH_START_LOADING: 'SPLASH_START_LOADING', SPLASH_STOP_LOADING: 'SPLASH_STOP_LOADING', diff --git a/src/style/components/SidebarOverlay.scss b/src/style/components/SidebarOverlay.scss index ad4bb1ecc..c6f83b92c 100644 --- a/src/style/components/SidebarOverlay.scss +++ b/src/style/components/SidebarOverlay.scss @@ -75,6 +75,8 @@ padding: 14px 20px 10px; letter-spacing: 1px; color: #707a85; + border-bottom: 1px solid #e2e5ec; + margin-bottom: 6px; html[lang^="ar"] & { font-size: 13px; diff --git a/src/utils/deep.js b/src/utils/deep.js new file mode 100644 index 000000000..500c49b07 --- /dev/null +++ b/src/utils/deep.js @@ -0,0 +1,33 @@ +import _ from 'lodash'; +import Deepdash from 'deepdash'; + +export const deepdash = Deepdash(_); + +export const filterValuesDeep = (predicate, nodes) => { + return deepdash.condense( + deepdash.reduceDeep( + nodes, + (accumulator, value, key, parent, context) => { + const newValue = { ...value }; + + if (newValue.children) { + _.set(newValue, 'children', deepdash.condense(value.children)); + } + const isTrue = predicate(newValue, key, parent, context); + + if (isTrue === true) { + _.set(accumulator, context.path, newValue); + } else if (isTrue === false) { + _.unset(accumulator, context.path); + } + return accumulator; + }, + [], + { + childrenPath: 'children', + pathFormat: 'array', + callbackAfterIterate: true, + }, + ), + ); +}; diff --git a/src/utils/index.js b/src/utils/index.js index c2800a416..2a756b20b 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -14,6 +14,8 @@ import { isEqual } from 'lodash'; import jsCookie from 'js-cookie'; +export * from './deep'; + export const getCookie = (name, defaultValue) => _.defaultTo(jsCookie.get(name), defaultValue);