From ed7595e3a9b51317712166a1e42333dbb7cfbd6a Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Wed, 15 May 2019 20:49:24 +0200 Subject: [PATCH] Improve nav item extensibility --- .../console-demo-plugin/src/plugin.ts | 36 ++- .../console-plugin-sdk/src/typings/nav.ts | 46 +-- .../components/{nav.jsx => nav/index.jsx} | 263 +----------------- frontend/public/components/nav/items.tsx | 118 ++++++++ frontend/public/components/nav/section.tsx | 177 ++++++++++++ frontend/public/declarations.d.ts | 1 + frontend/public/redux.ts | 4 +- 7 files changed, 357 insertions(+), 288 deletions(-) rename frontend/public/components/{nav.jsx => nav/index.jsx} (55%) create mode 100644 frontend/public/components/nav/items.tsx create mode 100644 frontend/public/components/nav/section.tsx diff --git a/frontend/packages/console-demo-plugin/src/plugin.ts b/frontend/packages/console-demo-plugin/src/plugin.ts index d3be1fa96b8..11401fa3221 100644 --- a/frontend/packages/console-demo-plugin/src/plugin.ts +++ b/frontend/packages/console-demo-plugin/src/plugin.ts @@ -1,23 +1,33 @@ import { Plugin, + ModelFeatureFlag, HrefNavItem, ResourceNSNavItem, + ResourceClusterNavItem, ResourceListPage, ResourceDetailPage, - ModelFeatureFlag, } from '@console/plugin-sdk'; // TODO(vojtech): internal code needed by plugins should be moved to console-shared package import { PodModel } from '@console/internal/models'; +import { FLAGS } from '@console/internal/const'; type ConsumedExtensions = + | ModelFeatureFlag | HrefNavItem | ResourceNSNavItem + | ResourceClusterNavItem | ResourceListPage - | ResourceDetailPage - | ModelFeatureFlag; + | ResourceDetailPage; const plugin: Plugin = [ + { + type: 'FeatureFlag/Model', + properties: { + model: PodModel, + flag: 'TEST_MODEL_FLAG', + }, + }, { type: 'NavItem/Href', properties: { @@ -32,7 +42,7 @@ const plugin: Plugin = [ { type: 'NavItem/ResourceNS', properties: { - section: 'Workloads', + section: 'Home', componentProps: { name: 'Test ResourceNS Link', resource: 'pods', @@ -41,24 +51,28 @@ const plugin: Plugin = [ }, }, { - type: 'ResourcePage/List', + type: 'NavItem/ResourceCluster', properties: { - model: PodModel, - loader: () => import('@console/internal/components/pod' /* webpackChunkName: "pod" */).then(m => m.PodsPage), + section: 'Home', + componentProps: { + name: 'Test ResourceCluster Link', + resource: 'projects', + required: [FLAGS.OPENSHIFT, 'TEST_MODEL_FLAG'], + }, }, }, { - type: 'ResourcePage/Detail', + type: 'ResourcePage/List', properties: { model: PodModel, - loader: () => import('@console/internal/components/pod' /* webpackChunkName: "pod" */).then(m => m.PodsDetailsPage), + loader: () => import('@console/internal/components/pod' /* webpackChunkName: "pod" */).then(m => m.PodsPage), }, }, { - type: 'FeatureFlag/Model', + type: 'ResourcePage/Detail', properties: { model: PodModel, - flag: 'TEST_MODEL_FLAG', + loader: () => import('@console/internal/components/pod' /* webpackChunkName: "pod" */).then(m => m.PodsDetailsPage), }, }, ]; diff --git a/frontend/packages/console-plugin-sdk/src/typings/nav.ts b/frontend/packages/console-plugin-sdk/src/typings/nav.ts index b8ea5267ad6..f78a7b97265 100644 --- a/frontend/packages/console-plugin-sdk/src/typings/nav.ts +++ b/frontend/packages/console-plugin-sdk/src/typings/nav.ts @@ -1,30 +1,29 @@ import { Extension } from '.'; -import { K8sKind } from '@console/internal/module/k8s'; +import { NavSectionTitle } from '@console/internal/components/nav/section'; + +import { + NavLinkProps, + HrefLinkProps, + ResourceNSLinkProps, + ResourceClusterLinkProps, +} from '@console/internal/components/nav/items'; namespace ExtensionProperties { interface NavItem { - // TODO(vojtech): link to existing nav sections by value - section: 'Home' | 'Workloads'; - componentProps: { - name: string; - required?: string; - disallowed?: string; - startsWith?: string[]; - } + section: NavSectionTitle; + componentProps: Pick; } export interface HrefNavItem extends NavItem { - componentProps: NavItem['componentProps'] & { - href: string; - activePath?: string; - } + componentProps: NavItem['componentProps'] & Pick; } export interface ResourceNSNavItem extends NavItem { - componentProps: NavItem['componentProps'] & { - resource: string; - model?: K8sKind; - } + componentProps: NavItem['componentProps'] & Pick; + } + + export interface ResourceClusterNavItem extends NavItem { + componentProps: NavItem['componentProps'] & Pick; } } @@ -36,8 +35,11 @@ export interface ResourceNSNavItem extends Extension { + type: 'NavItem/ResourceCluster'; +} + +export type NavItem = HrefNavItem | ResourceNSNavItem | ResourceClusterNavItem; export function isHrefNavItem(e: Extension): e is HrefNavItem { return e.type === 'NavItem/Href'; @@ -47,6 +49,10 @@ export function isResourceNSNavItem(e: Extension): e is ResourceNSNavItem { return e.type === 'NavItem/ResourceNS'; } +export function isResourceClusterNavItem(e: Extension): e is ResourceClusterNavItem { + return e.type === 'NavItem/ResourceCluster'; +} + export function isNavItem(e: Extension): e is NavItem { - return isHrefNavItem(e) || isResourceNSNavItem(e); + return isHrefNavItem(e) || isResourceNSNavItem(e) || isResourceClusterNavItem(e); } diff --git a/frontend/public/components/nav.jsx b/frontend/public/components/nav/index.jsx similarity index 55% rename from frontend/public/components/nav.jsx rename to frontend/public/components/nav/index.jsx index 3398fe707ec..e40ede0ca67 100644 --- a/frontend/public/components/nav.jsx +++ b/frontend/public/components/nav/index.jsx @@ -1,14 +1,12 @@ import * as React from 'react'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; -import * as _ from 'lodash-es'; import * as PropTypes from 'prop-types'; -import memoize from 'memoize-one'; +import { Nav, NavItemSeparator, NavList, PageSidebar } from '@patternfly/react-core'; + +import { featureReducerName } from '../../reducers/features'; +import { FLAGS } from '../../const'; +import { monitoringReducerName, MonitoringRoutes } from '../../reducers/monitoring'; -import { featureReducerName, flagPending } from '../reducers/features'; -import { FLAGS } from '../const'; -import { monitoringReducerName, MonitoringRoutes } from '../reducers/monitoring'; -import { formatNamespacedRouteForResource } from '../actions/ui'; import { BuildConfigModel, BuildModel, @@ -25,122 +23,11 @@ import { MachineSetModel, PackageManifestModel, SubscriptionModel, -} from '../models'; -import { referenceForModel } from '../module/k8s'; - -import { stripBasePath } from './utils'; -import * as plugins from '../plugins'; - -export const matchesPath = (resourcePath, prefix) => resourcePath === prefix || _.startsWith(resourcePath, `${prefix}/`); -export const matchesModel = (resourcePath, model) => model && matchesPath(resourcePath, referenceForModel(model)); - -import { Nav, NavExpandable, NavItem, NavItemSeparator, NavList, PageSidebar } from '@patternfly/react-core'; - -const stripNS = href => { - href = stripBasePath(href); - return href.replace(/^\/?k8s\//, '').replace(/^\/?(cluster|all-namespaces|ns\/[^/]*)/, '').replace(/^\//, ''); -}; - -const ExternalLink = ({href, name}) => - {name} -; - -class NavLink extends React.PureComponent { - static isActive() { - throw new Error('not implemented'); - } - - get to() { - throw new Error('not implemented'); - } - - static startsWith(resourcePath, someStrings) { - return _.some(someStrings, s => resourcePath.startsWith(s)); - } - - render() { - const { isActive, id, name, onClick } = this.props; - - // onClick is now handled globally by the Nav's onSelect, - // however onClick can still be passed if desired in certain cases - - return ( - - - {name} - - - ); - } -} - -NavLink.defaultProps = { - required: '', - disallowed: '', -}; +} from '../../models'; -NavLink.propTypes = { - required: PropTypes.string, - disallowed: PropTypes.string, -}; - -class ResourceNSLink extends NavLink { - static isActive(props, resourcePath, activeNamespace) { - const href = stripNS(formatNamespacedRouteForResource(props.resource, activeNamespace)); - return matchesPath(resourcePath, href) || matchesModel(resourcePath, props.model); - } - - get to() { - const { resource, activeNamespace } = this.props; - return formatNamespacedRouteForResource(resource, activeNamespace); - } -} - -ResourceNSLink.propTypes = { - name: PropTypes.string.isRequired, - startsWith: PropTypes.arrayOf(PropTypes.string), - resource: PropTypes.string.isRequired, - model: PropTypes.object, - activeNamespace: PropTypes.string, -}; - -class ResourceClusterLink extends NavLink { - static isActive(props, resourcePath) { - return resourcePath === props.resource || _.startsWith(resourcePath, `${props.resource}/`) || matchesModel(resourcePath, props.model); - } - - get to() { - return `/k8s/cluster/${this.props.resource}`; - } -} - -ResourceClusterLink.propTypes = { - name: PropTypes.string.isRequired, - startsWith: PropTypes.arrayOf(PropTypes.string), - resource: PropTypes.string.isRequired, - model: PropTypes.object, -}; - -class HrefLink extends NavLink { - static isActive(props, resourcePath) { - const noNSHref = stripNS(props.href); - return resourcePath === noNSHref || _.startsWith(resourcePath, `${noNSHref}/`); - } - - get to() { - return this.props.href; - } -} - -HrefLink.propTypes = { - name: PropTypes.string.isRequired, - startsWith: PropTypes.arrayOf(PropTypes.string), - href: PropTypes.string.isRequired, -}; +import { referenceForModel } from '../../module/k8s'; +import { ExternalLink, HrefLink, ResourceNSLink, ResourceClusterLink } from './items'; +import { NavSection } from './section'; // Wrap `NavItemSeparator` so we can use `required` without prop type errors. const Separator = () => ; @@ -149,138 +36,6 @@ Separator.propTypes = { required: PropTypes.string, }; -const navSectionStateToProps = (state, {required}) => { - const flags = state[featureReducerName]; - const canRender = required ? flags.get(required) : true; - - return { - flags, canRender, - activeNamespace: state.UI.get('activeNamespace'), - location: state.UI.get('location'), - }; -}; - -const NavSection = connect(navSectionStateToProps)( - class NavSection extends React.Component { - constructor(props) { - super(props); - this.toggle = (e, val) => this.toggle_(e, val); - this.state = { isOpen: false, activeChild: null }; - - const activeChild = this.getActiveChild(); - if (activeChild) { - this.state.activeChild = activeChild; - this.state.isOpen = true; - } - } - - shouldComponentUpdate(nextProps, nextState) { - const { isOpen } = this.state; - - if (isOpen !== nextProps.isOpen) { - return true; - } - - if (!isOpen && !nextState.isOpen) { - return false; - } - - return nextProps.location !== this.props.location || nextProps.flags !== this.props.flags; - } - - getActiveChild() { - const { activeNamespace, location, children } = this.props; - - if (!children) { - return stripBasePath(location).startsWith(this.props.activePath); - } - - const resourcePath = location ? stripNS(location) : ''; - - //current bug? - we should be checking if children is a single item or .filter is undefined - return children.filter(c => { - if (!c) { - return false; - } - if (c.props.startsWith) { - const active = c.type.startsWith(resourcePath, c.props.startsWith, activeNamespace); - if (active || !c.props.activePath) { - return active; - } - } - return c.type.isActive && c.type.isActive(c.props, resourcePath, activeNamespace); - }).map(c => c.props.name)[0]; - } - - componentDidUpdate(prevProps, prevState) { - if (prevProps.location === this.props.location) { - return; - } - - const activeChild = this.getActiveChild(); - const state = {activeChild}; - if (activeChild && !prevState.activeChild) { - state.isOpen = true; - } - this.setState(state); - } - - toggle_(e, expandState) { - this.setState({isOpen: expandState}); - } - - getPluginNavItems = memoize( - (section) => plugins.registry.getNavItems(section) - ); - - render() { - if (!this.props.canRender) { - return null; - } - - const { title, children, activeNamespace, flags } = this.props; - const { isOpen, activeChild } = this.state; - const isActive = !!activeChild; - - const mapChild = (c) => { - if (!c) { - return null; - } - const {name, required, disallowed} = c.props; - const requiredArray = required ? _.castArray(required) : []; - const requirementMissing = _.some(requiredArray, flag => ( - flag && (flagPending(flags.get(flag)) || !flags.get(flag)) - )); - if (requirementMissing) { - return null; - } - if (disallowed && (flagPending(flags.get(disallowed)) || flags.get(disallowed))) { - return null; - } - return React.cloneElement(c, {key: name, isActive: name === this.state.activeChild, activeNamespace, flags}); - }; - - const Children = React.Children.map(children, mapChild); - - const PluginChildren = _.compact(this.getPluginNavItems(title).map((item, index) => { - if (plugins.isHrefNavItem(item)) { - return ; - } - if (plugins.isResourceNSNavItem(item)) { - return ; - } - })).map(mapChild); - - return (Children || PluginChildren.length > 0) ? ( - - {Children} - {PluginChildren} - - ) : null; - } - } -); - const searchStartsWith = ['search']; const operatorManagementStartsWith = [ referenceForModel(PackageManifestModel), diff --git a/frontend/public/components/nav/items.tsx b/frontend/public/components/nav/items.tsx new file mode 100644 index 00000000000..6fecd216d61 --- /dev/null +++ b/frontend/public/components/nav/items.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { Link, LinkProps } from 'react-router-dom'; +import * as _ from 'lodash-es'; +import { NavItem } from '@patternfly/react-core'; + +import { formatNamespacedRouteForResource } from '../../actions/ui'; +import { referenceForModel, K8sKind } from '../../module/k8s'; +import { stripBasePath } from '../utils'; + +export const matchesPath = (resourcePath, prefix) => resourcePath === prefix || _.startsWith(resourcePath, `${prefix}/`); +export const matchesModel = (resourcePath, model) => model && matchesPath(resourcePath, referenceForModel(model)); + +export const stripNS = href => { + href = stripBasePath(href); + return href.replace(/^\/?k8s\//, '').replace(/^\/?(cluster|all-namespaces|ns\/[^/]*)/, '').replace(/^\//, ''); +}; + +export const ExternalLink = ({href, name}) => + {name} +; + +class NavLink

extends React.PureComponent

{ + static defaultProps = { + required: '', + disallowed: '', + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + static isActive(...args): boolean { + throw new Error('not implemented'); + } + + get to(): string { + throw new Error('not implemented'); + } + + static startsWith(resourcePath: string, ...someStrings: string[]) { + return _.some(someStrings, s => resourcePath.startsWith(s)); + } + + render() { + const { isActive, id, name, onClick } = this.props; + + // onClick is now handled globally by the Nav's onSelect, + // however onClick can still be passed if desired in certain cases + + return ( + + + {name} + + + ); + } +} + +export class ResourceNSLink extends NavLink { + static isActive(props, resourcePath, activeNamespace) { + const href = stripNS(formatNamespacedRouteForResource(props.resource, activeNamespace)); + return matchesPath(resourcePath, href) || matchesModel(resourcePath, props.model); + } + + get to() { + const { resource, activeNamespace } = this.props; + return formatNamespacedRouteForResource(resource, activeNamespace); + } +} + +export class ResourceClusterLink extends NavLink { + static isActive(props, resourcePath) { + return resourcePath === props.resource || _.startsWith(resourcePath, `${props.resource}/`) || matchesModel(resourcePath, props.model); + } + + get to() { + return `/k8s/cluster/${this.props.resource}`; + } +} + +export class HrefLink extends NavLink { + static isActive(props, resourcePath) { + const noNSHref = stripNS(props.href); + return resourcePath === noNSHref || _.startsWith(resourcePath, `${noNSHref}/`); + } + + get to() { + return this.props.href; + } +} + +export type NavLinkProps = { + name: string; + id?: LinkProps['id']; + onClick?: LinkProps['onClick']; + isActive?: boolean; + required?: string | string[]; + disallowed?: string; + startsWith?: string[]; + activePath?: string; +}; + +export type ResourceNSLinkProps = NavLinkProps & { + resource: string; + model?: K8sKind; + activeNamespace?: string; +}; + +export type ResourceClusterLinkProps = NavLinkProps & { + resource: string; + model?: K8sKind; +}; + +export type HrefLinkProps = NavLinkProps & { + href: string; +}; diff --git a/frontend/public/components/nav/section.tsx b/frontend/public/components/nav/section.tsx new file mode 100644 index 00000000000..a9151915928 --- /dev/null +++ b/frontend/public/components/nav/section.tsx @@ -0,0 +1,177 @@ +import * as React from 'react'; +import { connect } from 'react-redux'; +import * as _ from 'lodash-es'; +import memoize from 'memoize-one'; +import { NavExpandable } from '@patternfly/react-core'; + +import { RootState } from '../../redux'; +import { featureReducerName, flagPending, FeatureState } from '../../reducers/features'; +import { stripBasePath } from '../utils'; +import * as plugins from '../../plugins'; +import { HrefLink, ResourceNSLink, ResourceClusterLink, stripNS } from './items'; + +const navSectionStateToProps = (state: RootState, {required}) => { + const flags = state[featureReducerName]; + const canRender = required ? flags.get(required) : true; + + return { + flags, canRender, + activeNamespace: state.UI.get('activeNamespace'), + location: state.UI.get('location'), + }; +}; + +export const NavSection = connect(navSectionStateToProps)( + class NavSection extends React.Component { + public state: NavSectionState; + + constructor(props) { + super(props); + this.state = { isOpen: false, activeChild: null }; + + const activeChild = this.getActiveChild(); + if (activeChild) { + this.state.activeChild = activeChild; + this.state.isOpen = true; + } + } + + shouldComponentUpdate(nextProps, nextState) { + const { isOpen } = this.state; + + if (isOpen !== nextProps.isOpen) { + return true; + } + + if (!isOpen && !nextState.isOpen) { + return false; + } + + return nextProps.location !== this.props.location || nextProps.flags !== this.props.flags; + } + + getActiveChild() { + const { activeNamespace, location, children } = this.props; + + if (!children) { + return stripBasePath(location).startsWith(this.props.activePath); + } + + const resourcePath = location ? stripNS(location) : ''; + + //current bug? - we should be checking if children is a single item or .filter is undefined + return (children as any[]).filter(c => { + if (!c) { + return false; + } + if (c.props.startsWith) { + const active = c.type.startsWith(resourcePath, c.props.startsWith, activeNamespace); + if (active || !c.props.activePath) { + return active; + } + } + return c.type.isActive && c.type.isActive(c.props, resourcePath, activeNamespace); + }).map(c => c.props.name)[0]; + } + + componentDidUpdate(prevProps, prevState) { + if (prevProps.location === this.props.location) { + return; + } + + const activeChild = this.getActiveChild(); + const state: Partial = {activeChild}; + if (activeChild && !prevState.activeChild) { + state.isOpen = true; + } + this.setState(state as NavSectionState); + } + + toggle = (e, expandState) => { + this.setState({isOpen: expandState}); + } + + getPluginNavItems = memoize( + (section) => plugins.registry.getNavItems(section) + ); + + render() { + if (!this.props.canRender) { + return null; + } + + const { title, children, activeNamespace, flags } = this.props; + const { isOpen, activeChild } = this.state; + const isActive = !!activeChild; + + const mapChild = (c) => { + if (!c) { + return null; + } + const {name, required, disallowed} = c.props; + const requiredArray = required ? _.castArray(required) : []; + const requirementMissing = _.some(requiredArray, flag => ( + flag && (flagPending(flags.get(flag)) || !flags.get(flag)) + )); + if (requirementMissing) { + return null; + } + if (disallowed && (flagPending(flags.get(disallowed)) || flags.get(disallowed))) { + return null; + } + return React.cloneElement(c, { + key: name, + isActive: name === this.state.activeChild, + activeNamespace, + flags, + }); + }; + + const Children = React.Children.map(children, mapChild); + + const PluginChildren = _.compact(this.getPluginNavItems(title).map((item, index) => { + if (plugins.isHrefNavItem(item)) { + return ; + } + if (plugins.isResourceNSNavItem(item)) { + return ; + } + if (plugins.isResourceClusterNavItem(item)) { + return ; + } + })).map(mapChild); + + return (Children || PluginChildren.length > 0) ? ( + + {Children} + {PluginChildren} + + ) : null; + } + } +); + +export type NavSectionTitle = + | 'Administration' + | 'Builds' + | 'Catalog' + | 'Compute' + | 'Home' + | 'Monitoring' + | 'Networking' + | 'Storage' + | 'Workloads'; + +type NavSectionProps = { + flags?: FeatureState; + title: NavSectionTitle; + canRender?: boolean; + activeNamespace?: string; + activePath?: string; + location?: string; +}; + +type NavSectionState = { + isOpen: boolean; + activeChild: React.ReactNode; +}; diff --git a/frontend/public/declarations.d.ts b/frontend/public/declarations.d.ts index 586fedcd123..a7e5429d288 100644 --- a/frontend/public/declarations.d.ts +++ b/frontend/public/declarations.d.ts @@ -34,6 +34,7 @@ declare interface Window { statuspageID: string; }; windowError?: boolean; + __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: Function; } // From https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html diff --git a/frontend/public/redux.ts b/frontend/public/redux.ts index 81c6aac0c8d..a2f91049611 100644 --- a/frontend/public/redux.ts +++ b/frontend/public/redux.ts @@ -7,9 +7,7 @@ import UIReducers, { UIState } from './reducers/ui'; const composeEnhancers = // eslint-disable-next-line no-undef - (process.env.NODE_ENV !== 'production' && - (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || - compose; + (process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) || compose; /** * This is the entirety of the `redux-thunk` library.