From 2821005d8cb8c92341b5f8171339d9bee28d95a9 Mon Sep 17 00:00:00 2001 From: Sebastian Sdorra Date: Tue, 31 Mar 2020 08:26:01 +0200 Subject: [PATCH] handling collapse state in a more simple and consistence way --- .../src/navigation/ExternalLink.tsx | 2 + .../src/navigation/MenuContext.tsx | 73 +++++++++++++++ .../src/navigation/NavAction.tsx | 2 + .../ui-components/src/navigation/NavLink.tsx | 76 ++++++--------- .../src/navigation/Navigation.tsx | 2 + .../{MenuContext.ts => RoutingProps.ts} | 18 +--- .../SecondaryNavigation.stories.tsx | 30 ++---- .../src/navigation/SecondaryNavigation.tsx | 93 +++++++++---------- .../navigation/SecondaryNavigationItem.tsx | 15 +-- .../src/navigation/SubNavigation.tsx | 18 ++-- scm-ui/ui-components/src/navigation/index.ts | 2 +- 11 files changed, 178 insertions(+), 153 deletions(-) create mode 100644 scm-ui/ui-components/src/navigation/MenuContext.tsx rename scm-ui/ui-components/src/navigation/{MenuContext.ts => RoutingProps.ts} (73%) diff --git a/scm-ui/ui-components/src/navigation/ExternalLink.tsx b/scm-ui/ui-components/src/navigation/ExternalLink.tsx index a87b118ee3..1565f6fa79 100644 --- a/scm-ui/ui-components/src/navigation/ExternalLink.tsx +++ b/scm-ui/ui-components/src/navigation/ExternalLink.tsx @@ -30,6 +30,8 @@ type Props = { label: string; }; +// TODO is it used in the menu? should it use MenuContext for collapse state? + const ExternalLink: FC = ({ to, icon, label }) => { let showIcon; if (icon) { diff --git a/scm-ui/ui-components/src/navigation/MenuContext.tsx b/scm-ui/ui-components/src/navigation/MenuContext.tsx new file mode 100644 index 0000000000..01b28866d5 --- /dev/null +++ b/scm-ui/ui-components/src/navigation/MenuContext.tsx @@ -0,0 +1,73 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import React, { FC, useContext, useState } from "react"; + +const MENU_COLLAPSED = "secondary-menu-collapsed"; + +export type MenuContext = { + isCollapsed: () => boolean; + setCollapsed: (collapsed: boolean) => void; +}; + +export const LocalStorageMenuContextProvider: FC = ({children}) => { + const [state, setState] = useState(localStorage.getItem(MENU_COLLAPSED) === "true"); + const context = { + isCollapsed() { + return state; + }, + setCollapsed(collapsed: boolean) { + localStorage.setItem(MENU_COLLAPSED, String(collapsed)); + setState(collapsed); + } + }; + + return {children}; +}; + +export const MenuContext = React.createContext({ + isCollapsed() { + return false; + }, + setCollapsed() {} +}); + +export const StateMenuContextProvider: FC = ({children}) => { + const [collapsed, setCollapsed] = useState(false); + + const context = { + isCollapsed() { + return collapsed; + }, + setCollapsed + }; + + return {children}; +}; + +const useMenuContext = () => { + return useContext(MenuContext); +}; + +export default useMenuContext; diff --git a/scm-ui/ui-components/src/navigation/NavAction.tsx b/scm-ui/ui-components/src/navigation/NavAction.tsx index dd350d1c71..394b30d961 100644 --- a/scm-ui/ui-components/src/navigation/NavAction.tsx +++ b/scm-ui/ui-components/src/navigation/NavAction.tsx @@ -29,6 +29,8 @@ type Props = { action: () => void; }; +// TODO is it used in the menu? should it use MenuContext for collapse state? + class NavAction extends React.Component { render() { const { label, icon, action } = this.props; diff --git a/scm-ui/ui-components/src/navigation/NavLink.tsx b/scm-ui/ui-components/src/navigation/NavLink.tsx index 7a527e5ab5..132590e7f6 100644 --- a/scm-ui/ui-components/src/navigation/NavLink.tsx +++ b/scm-ui/ui-components/src/navigation/NavLink.tsx @@ -23,60 +23,44 @@ */ import * as React from "react"; import classNames from "classnames"; -import { Link, Route } from "react-router-dom"; +import { Link, useRouteMatch } from "react-router-dom"; +import { RoutingProps } from "./RoutingProps"; +import { FC } from "react"; +import { useContext } from "react"; +import useMenuContext, { MenuContext } from "./MenuContext"; -// TODO mostly copy of PrimaryNavigationLink - -type Props = { - to: string; - icon?: string; +type Props = RoutingProps & { label: string; - activeOnlyWhenExact?: boolean; - activeWhenMatch?: (route: any) => boolean; - collapsed?: boolean; title?: string; + icon?: string; }; -class NavLink extends React.Component { - static defaultProps = { - activeOnlyWhenExact: true - }; +const NavLink: FC = ({ to, activeOnlyWhenExact, icon, label, title }) => { + const match = useRouteMatch({ + path: to, + exact: activeOnlyWhenExact + }); - isActive(route: any) { - const { activeWhenMatch } = this.props; - return route.match || (activeWhenMatch && activeWhenMatch(route)); - } + const context = useMenuContext(); + const collapsed = context.isCollapsed(); - renderLink = (route: any) => { - const { to, icon, label, collapsed, title } = this.props; - - let showIcon = null; - if (icon) { - showIcon = ( - <> - {" "} - - ); - } - - return ( -
  • - - {showIcon} - {collapsed ? null : label} - -
  • + let showIcon = null; + if (icon) { + showIcon = ( + <> + {" "} + ); - }; - - render() { - const { to, activeOnlyWhenExact } = this.props; - - return ; } -} + + return ( +
  • + + {showIcon} + {collapsed ? null : label} + +
  • + ); +}; export default NavLink; diff --git a/scm-ui/ui-components/src/navigation/Navigation.tsx b/scm-ui/ui-components/src/navigation/Navigation.tsx index dc848b4d24..0ccd94e7f7 100644 --- a/scm-ui/ui-components/src/navigation/Navigation.tsx +++ b/scm-ui/ui-components/src/navigation/Navigation.tsx @@ -27,6 +27,8 @@ type Props = { children?: ReactNode; }; +// TODO it is used? + class Navigation extends React.Component { render() { return ; diff --git a/scm-ui/ui-components/src/navigation/MenuContext.ts b/scm-ui/ui-components/src/navigation/RoutingProps.ts similarity index 73% rename from scm-ui/ui-components/src/navigation/MenuContext.ts rename to scm-ui/ui-components/src/navigation/RoutingProps.ts index e2760da025..e34f62b97c 100644 --- a/scm-ui/ui-components/src/navigation/MenuContext.ts +++ b/scm-ui/ui-components/src/navigation/RoutingProps.ts @@ -22,18 +22,8 @@ * SOFTWARE. */ -import React from "react"; - -const MENU_COLLAPSED = "secondary-menu-collapsed"; - -export const MenuContext = React.createContext({ - menuCollapsed: isMenuCollapsed(), - setMenuCollapsed: (collapsed: boolean) => {} -}); - -export function isMenuCollapsed() { - return localStorage.getItem(MENU_COLLAPSED) === "true"; -} -export function storeMenuCollapsed(status: boolean) { - localStorage.setItem(MENU_COLLAPSED, String(status)); +export type RoutingProps = { + to: string; + activeOnlyWhenExact?: boolean; + activeWhenMatch?: (route: any) => boolean; } diff --git a/scm-ui/ui-components/src/navigation/SecondaryNavigation.stories.tsx b/scm-ui/ui-components/src/navigation/SecondaryNavigation.stories.tsx index f96909a8af..73531dcb18 100644 --- a/scm-ui/ui-components/src/navigation/SecondaryNavigation.stories.tsx +++ b/scm-ui/ui-components/src/navigation/SecondaryNavigation.stories.tsx @@ -30,6 +30,7 @@ import styled from "styled-components"; import SubNavigation from "./SubNavigation"; import { Binder, ExtensionPoint, BinderContext } from "@scm-manager/ui-extensions"; import { MemoryRouter } from "react-router-dom"; +import { StateMenuContextProvider } from "./MenuContext"; const Columns = styled.div` margin: 2rem; @@ -52,6 +53,7 @@ const withRoute = (route: string) => { }; storiesOf("Navigation|Secondary", module) + .addDecorator(story => {story()}) .addDecorator(story => (
    {story()}
    @@ -59,42 +61,26 @@ storiesOf("Navigation|Secondary", module) )) .add("Default", () => withRoute("/")( - {}}> - - - - ) - ) - .add("Collapsed", () => - withRoute("/")( - {}}> - - + + + ) ) .add("Sub Navigation", () => withRoute("/")( - {}}> + {starships} ) ) - .add("Sub Navigation Collapsed", () => - withRoute("/hitchhiker/starships/heart-of-gold")( - {}}> - - {starships} - - ) - ) - .add("Collapsed EP Sub", () => { + .add("Extension Point", () => { const binder = new Binder("menu"); binder.bind("subnav.sample", starships); return withRoute("/hitchhiker/starships/titanic")( - {}}> + diff --git a/scm-ui/ui-components/src/navigation/SecondaryNavigation.tsx b/scm-ui/ui-components/src/navigation/SecondaryNavigation.tsx index b2465630a6..efb925c679 100644 --- a/scm-ui/ui-components/src/navigation/SecondaryNavigation.tsx +++ b/scm-ui/ui-components/src/navigation/SecondaryNavigation.tsx @@ -21,18 +21,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, ReactElement, ReactNode, useContext, useEffect } from "react"; + +import React, { FC, ReactElement, ReactNode} from "react"; import styled from "styled-components"; import SubNavigation from "./SubNavigation"; import { matchPath, useLocation } from "react-router-dom"; -import { isMenuCollapsed, MenuContext } from "./MenuContext"; +import useMenuContext from "./MenuContext"; import { ExtensionPoint, Binder, useBinder } from "@scm-manager/ui-extensions"; +import { RoutingProps } from "./RoutingProps"; type Props = { label: string; - children: ReactElement[]; - collapsed: boolean; - onCollapse?: (newStatus: boolean) => void; }; type CollapsedProps = { @@ -61,32 +60,14 @@ const MenuLabel = styled.p` cursor: pointer; `; -const SecondaryNavigation: FC = ({ label, children, collapsed, onCollapse }) => { +const SecondaryNavigation: FC = ({ label, children}) => { const location = useLocation(); const binder = useBinder(); - const menuContext = useContext(MenuContext); + const menuContext = useMenuContext(); const subNavActive = isSubNavigationActive(binder, children, location.pathname); - const isCollapsed = collapsed && !subNavActive; + const isCollapsed = menuContext.isCollapsed(); - useEffect(() => { - if (isMenuCollapsed()) { - menuContext.setMenuCollapsed(!subNavActive); - } - }, [subNavActive]); - - const childrenWithProps = React.Children.map(children, (child: ReactElement) => - React.cloneElement(child, { - collapsed: isCollapsed, - propTransformer: (props: object) => { - const np = { - ...props, - collapsed: isCollapsed - }; - return np; - } - }) - ); const arrowIcon = isCollapsed ? : ; return ( @@ -95,16 +76,16 @@ const SecondaryNavigation: FC = ({ label, children, collapsed, onCollapse onCollapse(!isCollapsed) : undefined} + onClick={!subNavActive ? () => menuContext.setCollapsed(!isCollapsed) : undefined} > - {onCollapse && !subNavActive && ( + {!subNavActive && ( {arrowIcon} )} {isCollapsed ? "" : label} -
      {childrenWithProps}
    +
      {children}
    ); @@ -116,32 +97,42 @@ const createParentPath = (to: string) => { return parents.join("/"); }; +const expandExtensionPoints = (binder: Binder, child: ReactElement): Array => { + // @ts-ignore + if (child.type.name === ExtensionPoint.name) { + // @ts-ignore + return binder.getExtensions(child.props.name, child.props.props); + } + return [child]; +}; + +const mapToProps = (child: ReactElement) => { + return child.props; +}; + +const isSubNavigation = (child: ReactElement) => { + // @ts-ignore + return child.type.name === SubNavigation.name; +}; + +const isActive = (url: string, props: RoutingProps) => { + const path = createParentPath(props.to); + const matches = matchPath(url, { + path, + exact: props.activeOnlyWhenExact + }); + return matches != null; +}; + const isSubNavigationActive = (binder: Binder, children: ReactNode, url: string): boolean => { const childArray = React.Children.toArray(children); + const match = childArray .filter(React.isValidElement) - .flatMap(child => { - // @ts-ignore - if (child.type.name === ExtensionPoint.name) { - // @ts-ignore - return binder.getExtensions(child.props.name, child.props.props); - } - return [child]; - }) - .filter(child => { - return child.type.name === SubNavigation.name; - }) - .map(child => { - return child.props; - }) - .find(props => { - const path = createParentPath(props.to); - const matches = matchPath(url, { - path, - exact: props.activeOnlyWhenExact as boolean - }); - return matches != null; - }); + .flatMap(child => expandExtensionPoints(binder, child)) + .filter(isSubNavigation) + .map(mapToProps) + .find(props => isActive(url, props)); return match != null; }; diff --git a/scm-ui/ui-components/src/navigation/SecondaryNavigationItem.tsx b/scm-ui/ui-components/src/navigation/SecondaryNavigationItem.tsx index 6f846e426c..33924c4a29 100644 --- a/scm-ui/ui-components/src/navigation/SecondaryNavigationItem.tsx +++ b/scm-ui/ui-components/src/navigation/SecondaryNavigationItem.tsx @@ -21,20 +21,15 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, ReactElement, ReactNode } from "react"; -import { MenuContext } from "./MenuContext"; +import React, {FC} from "react"; import SubNavigation from "./SubNavigation"; import NavLink from "./NavLink"; +import {RoutingProps} from "./RoutingProps"; -type Props = { - to: string; - icon?: string; +type Props = RoutingProps & { label: string; - title: string; - collapsed?: boolean; - activeWhenMatch?: (route: any) => boolean; - activeOnlyWhenExact?: boolean; - children?: ReactElement[]; + title?: string; + icon?: string; }; const SecondaryNavigationItem: FC = ({ children, ...props }) => { diff --git a/scm-ui/ui-components/src/navigation/SubNavigation.tsx b/scm-ui/ui-components/src/navigation/SubNavigation.tsx index 165f10588b..2955527d78 100644 --- a/scm-ui/ui-components/src/navigation/SubNavigation.tsx +++ b/scm-ui/ui-components/src/navigation/SubNavigation.tsx @@ -21,22 +21,19 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, ReactElement, useContext, useEffect } from "react"; +import React, { FC, useContext} from "react"; import { Link, useRouteMatch } from "react-router-dom"; import classNames from "classnames"; +import useMenuContext, {MenuContext} from "./MenuContext"; +import {RoutingProps} from "./RoutingProps"; -type Props = { - to: string; - icon?: string; +type Props = RoutingProps & { label: string; - activeOnlyWhenExact?: boolean; - activeWhenMatch?: (route: any) => boolean; - children?: ReactElement[]; - collapsed?: boolean; title?: string; + icon?: string; }; -const SubNavigation: FC = ({ to, activeOnlyWhenExact, icon, collapsed, title, label, children }) => { +const SubNavigation: FC = ({ to, activeOnlyWhenExact, icon, title, label, children }) => { const parents = to.split("/"); parents.splice(-1, 1); const parent = parents.join("/"); @@ -46,6 +43,9 @@ const SubNavigation: FC = ({ to, activeOnlyWhenExact, icon, collapsed, ti exact: activeOnlyWhenExact }); + const context = useMenuContext(); + const collapsed = context.isCollapsed(); + let defaultIcon = "fas fa-cog"; if (icon) { defaultIcon = icon; diff --git a/scm-ui/ui-components/src/navigation/index.ts b/scm-ui/ui-components/src/navigation/index.ts index b7326f6810..942beb0080 100644 --- a/scm-ui/ui-components/src/navigation/index.ts +++ b/scm-ui/ui-components/src/navigation/index.ts @@ -31,5 +31,5 @@ export { default as SubNavigation } from "./SubNavigation"; export { default as PrimaryNavigation } from "./PrimaryNavigation"; export { default as PrimaryNavigationLink } from "./PrimaryNavigationLink"; export { default as SecondaryNavigation } from "./SecondaryNavigation"; -export { MenuContext, storeMenuCollapsed, isMenuCollapsed } from "./MenuContext"; +export { MenuContext, LocalStorageMenuContextProvider } from "./MenuContext"; export { default as SecondaryNavigationItem } from "./SecondaryNavigationItem";