diff --git a/app/assets/stylesheets/patternfly_overrides.scss b/app/assets/stylesheets/patternfly_overrides.scss index d72f21ab8f3..773bcb79f45 100644 --- a/app/assets/stylesheets/patternfly_overrides.scss +++ b/app/assets/stylesheets/patternfly_overrides.scss @@ -1078,3 +1078,230 @@ table.table-compressed tr td .piechart { table.c3-tooltip td { background-color: #393f44; } + +.nav-pf-vertical { + .menu-list-group-item { + border-bottom: 1px solid rgb(3, 3, 3); + border-top: 1px solid rgb(3, 3, 3); + box-sizing: border-box; + padding: 0; + margin-bottom: -1px; + position: relative; + &:first-child { + border-top: none; + } + a { + background-color: transparent; + color: #d1d1d1; + cursor: pointer; + display: block; + font-size: 14px; + font-weight: 400; + height: 63px; + line-height: 26px; + padding: 17px 20px 17px 25px; + position: relative; + white-space: nowrap; + width: 225px; + text-decoration: none; + .pficon, .fa { + color: #72767b; + float: left; + font-size: 20px; + line-height: 26px; + margin-right: 10px; + text-align: center; + width: 24px; + } + } + .list-group-item-value { + flex: 1; + max-width: none; + padding-right: 15px; + line-height: 25px; + overflow: hidden; + text-overflow: ellipsis; + } + } + .menu-list-group-item.active { + a { + background-color: #393f44; + color: #fff; + font-weight: 600; + .pficon, .fa { + color: #39a5dc; + } + } + a:before { + background: #39a5dc; + content: " "; + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 3px; + } + .nav-pf-secondary-nav { + .nav-item-pf-header { + a { + ::before { + opacity: 1; + } + } + } + } + } + .menu-list-group-item:hover { + a { + background-color: #393f44; + color: #fff; + font-weight: 600; + width: calc(225px + 1px); + z-index: 1031; + .pficon, .fa { + color: #39a5dc; + } + } + } + .nav-pf-secondary-nav { + .nav-item-pf-header { + a { + ::before { + font-family: "FontAwesome"; + content: '\f08d'; + color: #0099d3; + margin-right: 7px; + opacity: 0; + } + &.collapsed { + ::before { + transform: rotate(45deg); + display: inline-block; + } + } + color: #fff; + opacity: 1; + font-size: 16px; + font-family: "Open Sans", Helvetica, Arial, sans-serif; + font-weight: 400; + cursor: pointer; + padding: 0; + height: auto; + background: transparent; + z-index: 0; + } + } + .menu-list-group-item { + border: none; + padding: 0 0 5px 0; + width: 225px; + margin-bottom: -1px; + &.tertiary-nav-item-pf { + a { + ::after { + color: #72767b; + content: "\f105"; + display: block; + font-family: "FontAwesome"; + font-size: 20px; + line-height: 20px; + padding: 0; + position: absolute; + right: 20px; + top: 4px; + } + } + } + a { + background-color: #393f44; + color: #d1d1d1; + font-size: 12px; + font-weight: inherit; + height: inherit; + margin-left: 20px; + width: calc(225px - 20px); + display: flex; + opacity: 1; + padding: 4px 0 2px 0; + .list-group-item-value { + padding-left: 5px; + flex: 1; + max-width: none; + padding-right: 15px; + } + } + } + .menu-list-group-item.active, .menu-list-group-item:hover { + a { + background-color: #4d5258; + color: #fff; + font-weight: 600; + } + } + a:before { + display: none; + } + .menu-list-group-item:hover { + a { + color: #fff; + font-weight: 600; + } + } + } + + .nav-pf-tertiary-nav{ + .nav-item-pf-header { + a { + font-size: 16px; + font-weight: 400 !important; + margin: 0; + padding: 0; + background-color: #4d5258; + color: #fff; + ::after { + display: none !important; + } + } + } + .menu-list-group-item { + a { + background-color: #4d5258; + span { + font-weight: 400; + color: #fff; + } + ::after { + display: none !important; + } + } + } + .menu-list-group-item.active, .menu-list-group-item:hover { + a { + background-color: #393f44; + span { + font-weight: 600; + } + } + } + } + + &.collapsed { + .menu-list-group-item, .menu-list-group-item.active { + >a.top-level { + >.list-group-item-value { + display: none; + } + padding: 17px 0 17px 25px; + width: 75px; + &::after { + right: 10px; + } + } + >a.top-level:hover { + width: calc(75px + 1px); + } + } + } +} + + diff --git a/app/helpers/application_helper/navbar.rb b/app/helpers/application_helper/navbar.rb index 0270954dd3f..f2bad38901d 100644 --- a/app/helpers/application_helper/navbar.rb +++ b/app/helpers/application_helper/navbar.rb @@ -1,45 +1,64 @@ module ApplicationHelper module Navbar + def menu_to_json(placement = :default) + structure = [] + Menu::Manager.menu(placement) do |menu_section| + next unless menu_section.visible? + + structure << item_to_hash(menu_section) + end + structure + end + + def item_to_hash(item) + { + :id => item.id, + :title => item.name, + :iconClass => item.icon, + :href => item.link_params[:href], + :type => item.type, + :preventHref => !item.href, + :visible => item.visible?, + :active => item_active?(item), + :items => item.items.to_a.map(&method(:item_to_hash)) + } + end + # FIXME: The 'active' below is an active section not an item. That is wrong. # What works is the "legacy" part that compares @layout to item.id. # This assumes that these matches -- @layout and item.id. Moving forward we # need to remove that assumption. However to do that we need figure some way # to identify the active menu item here. - def item_nav_class(item) - active = controller.menu_section_id(controller.params) || @layout.to_sym + def item_active?(item) + if item.leaf? + # FIXME: remove @layout condition when every controller sets menu_section properly + active = controller.menu_section_id(controller.params) || @layout.to_sym + item.id.to_sym == active || item.id.to_sym == @layout.to_sym + else + return section_nav_class_iframe(item) if params[:action] == 'iframe' - # FIXME: remove @layout condition when every controller sets menu_section properly - item.id.to_sym == active || item.id.to_sym == @layout.to_sym ? 'active' : nil - end + active = controller.menu_section_id(controller.params) || @layout.to_sym - # special handling for custom menu sections and items - def section_nav_class_iframe(section) - if params[:sid].present? - section.id.to_s == params[:sid] ? 'active' : nil - elsif params[:id].present? - section.contains_item_id?(params[:id]) ? 'active' : nil - end - end - - def section_nav_class(section) - return section_nav_class_iframe(section) if params[:action] == 'iframe' - - active = controller.menu_section_id(controller.params) || @layout.to_sym + if item.parent.nil? + # first-level, fallback to old logic for now + # FIXME: exception behavior to remove + active = 'my_tasks' if %w[my_tasks all_tasks].include?(@layout) + active = 'cloud_volume' if @layout == 'cloud_volume_snapshot' || @layout == 'cloud_volume_backup' + active = 'cloud_object_store_container' if @layout == 'cloud_object_store_object' + active = active.to_sym + end - if section.parent.nil? - # first-level, fallback to old logic for now - # FIXME: exception behavior to remove - active = 'my_tasks' if %w[my_tasks all_tasks].include?(@layout) - active = 'cloud_volume' if @layout == 'cloud_volume_snapshot' || @layout == 'cloud_volume_backup' - active = 'cloud_object_store_container' if @layout == 'cloud_object_store_object' - active = active.to_sym + # FIXME: remove to_s, to_sym once all items use symbol ids + item.id.to_sym == active || + item.contains_item_id?(active.to_s) || + item.contains_item_id?(active.to_sym) end + end - return 'active' if section.id.to_sym == active - - # FIXME: remove to_s, to_sym once all items use symbol ids - return 'active' if section.contains_item_id?(active.to_s) - return 'active' if section.contains_item_id?(active.to_sym) + # special handling for custom menu sections and items + def section_nav_class_iframe(section) + params[:sid].present? && section.id.to_s == params[:sid] || + params[:id].present? && section.contains_item_id?(params[:id]) end end end diff --git a/app/javascript/components/main-menu/helpers.js b/app/javascript/components/main-menu/helpers.js new file mode 100644 index 00000000000..e1215e3aba7 --- /dev/null +++ b/app/javascript/components/main-menu/helpers.js @@ -0,0 +1,25 @@ +export const getHrefByType = (type, href, id) => { + switch (type) { + case 'big_iframe': + return `/dashboard/iframe?id=${id}`; + case 'modal': + return undefined; + default: + return href; + } +}; + +export const getTargetByType = type => (type === 'new_window' ? '_blank' : '_self'); + +export const getItemId = id => `menu_item_${id}`; + +export const getSectionId = id => `menu_section_${id}`; + +export const getIdByCategory = (isSection, id) => (isSection ? getSectionId(id) : getItemId(id)); + +export const handleUnsavedChanges = (type) => { + if (type === 'modal') { + return sendDataWithRx({ type: 'showAboutModal' }); + } + return window.miqCheckForChanges(); +}; diff --git a/app/javascript/components/main-menu/main-menu.jsx b/app/javascript/components/main-menu/main-menu.jsx new file mode 100644 index 00000000000..170f6c0acfd --- /dev/null +++ b/app/javascript/components/main-menu/main-menu.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Grid } from 'patternfly-react'; +import TopLevel from './top-level'; +import SecondLevel from './second-level'; +import ThirdLevel from './third-level'; +import { menuProps, RecursiveMenuProps } from './recursive-props'; + +const Fallback = props => ; + +const getLevelComponent = level => ({ + 0: props => , + 1: props => , +})[level] || Fallback; + +export const MenuItem = ({ level, ...props }) => getLevelComponent(level)(props); + +const MainMenu = ({ menu }) => ( + +
+
    + {menu.map(props => )} +
+
+
+); + +MainMenu.propTypes = { + menu: PropTypes.arrayOf(PropTypes.shape({ + ...menuProps, + items: PropTypes.arrayOf(PropTypes.shape({ + ...menuProps, + items: PropTypes.arrayOf(PropTypes.shape(RecursiveMenuProps())), + })), + })).isRequired, +}; + +export default MainMenu; diff --git a/app/javascript/components/main-menu/recursive-props.js b/app/javascript/components/main-menu/recursive-props.js new file mode 100644 index 00000000000..ae34a993cf6 --- /dev/null +++ b/app/javascript/components/main-menu/recursive-props.js @@ -0,0 +1,16 @@ +import PropTypes from 'prop-types'; + +export const menuProps = { + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + iconClass: PropTypes.string, + href: PropTypes.string.isRequired, + preventHref: PropTypes.bool, + visible: PropTypes.bool, + active: PropTypes.bool, +}; + +export const RecursiveMenuProps = () => ({ + ...menuProps, + items: PropTypes.arrayOf(PropTypes.shape(menuProps)), +}); diff --git a/app/javascript/components/main-menu/second-level.jsx b/app/javascript/components/main-menu/second-level.jsx new file mode 100644 index 00000000000..bb36e2a9dbf --- /dev/null +++ b/app/javascript/components/main-menu/second-level.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { MenuItem } from './main-menu'; +import { menuProps } from './recursive-props'; +import { + getHrefByType, getIdByCategory, getTargetByType, handleUnsavedChanges, +} from './helpers'; + +const SecondLevel = ({ + id, + title, + href, + items, + level, + type, + active, +}) => ( +
  • 0 ? 'tertiary-nav-item-pf' : ''} ${active ? 'active' : ''}`} id={getIdByCategory(items.length > 0, id)}> + { + if (handleUnsavedChanges(type) === false) { + event.preventDefault(); + } + return false; + }} + target={getTargetByType(type)} + > + {title} + +
    + + {items.length > 0 && ( +
      + {items.map(props => )} +
    + )} +
    +
  • +); + +SecondLevel.propTypes = { + ...menuProps, + items: PropTypes.arrayOf(PropTypes.shape({ + ...menuProps, + })), +}; + +SecondLevel.defaultProps = { + items: [], +}; + +export default SecondLevel; diff --git a/app/javascript/components/main-menu/third-level.jsx b/app/javascript/components/main-menu/third-level.jsx new file mode 100644 index 00000000000..3d09edbafac --- /dev/null +++ b/app/javascript/components/main-menu/third-level.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { menuProps } from './recursive-props'; +import { getHrefByType, getTargetByType, handleUnsavedChanges } from './helpers'; + +const ThirdLevel = ({ + id, + title, + href, + active, + visible, + type, +}) => (!visible ? null : ( +
  • + { + if (handleUnsavedChanges(type) === false) { + event.preventDefault(); + } + return false; + }} + target={getTargetByType(type)} + > + {title} + +
  • +)); + +ThirdLevel.propTypes = { + ...menuProps, +}; + +export default ThirdLevel; diff --git a/app/javascript/components/main-menu/top-level.jsx b/app/javascript/components/main-menu/top-level.jsx new file mode 100644 index 00000000000..80c9bb9952e --- /dev/null +++ b/app/javascript/components/main-menu/top-level.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { MenuItem } from './main-menu'; +import { menuProps, RecursiveMenuProps } from './recursive-props'; +import { + getHrefByType, getSectionId, getTargetByType, handleUnsavedChanges, getItemId, +} from './helpers'; + +const TopLevel = ({ + level, + id, + title, + iconClass, + href, + active, + items, + type, +}) => { + const isSection = items.length > 0; + + if (isSection) { + return ( +
  • + { + if (handleUnsavedChanges(type) === false) { + event.preventDefault(); + } + return false; + }} + href={getHrefByType(type, href, id)} + target={getTargetByType(type)} + className="top-level" + > + + {title} + + +
    + +
      + {items.map(props => )} +
    +
    +
    +
  • + ); + } + + return ( +
  • + { + if (handleUnsavedChanges(type) === false) { + event.preventDefault(); + } + return false; + }} + href={getHrefByType(type, href, id)} + > + + {title} + +
  • + ); +}; + +TopLevel.propTypes = { + ...menuProps, + items: PropTypes.arrayOf(PropTypes.shape({ + ...menuProps, + items: PropTypes.arrayOf(PropTypes.shape(RecursiveMenuProps())), + })), +}; + +TopLevel.defaultProps = { + items: [], +}; + +export default TopLevel; diff --git a/app/javascript/packs/component-definitions-common.js b/app/javascript/packs/component-definitions-common.js index 6c04450df92..7e552ea9185 100644 --- a/app/javascript/packs/component-definitions-common.js +++ b/app/javascript/packs/component-definitions-common.js @@ -20,6 +20,7 @@ import GenericGroupWrapper from '../react/generic_group_wrapper'; import GraphQLExplorer from '../graphql-explorer'; import { HierarchicalTreeView } from '../components/tree-view'; import ImportDatastoreViaGit from '../components/automate-import-export-form/import-datastore-via-git'; +import MainMenu from '../components/main-menu/main-menu'; import MiqAboutModal from '../components/miq-about-modal'; import MiqToolbar from '../components/miq-toolbar'; import OptimizationList from '../optimization/optimization_list'; @@ -60,6 +61,7 @@ ManageIQ.component.addReact('GenericGroupWrapper', GenericGroupWrapper); ManageIQ.component.addReact('GraphQLExplorer', GraphQLExplorer); ManageIQ.component.addReact('HierarchicalTreeView', HierarchicalTreeView); ManageIQ.component.addReact('ImportDatastoreViaGit', ImportDatastoreViaGit); +ManageIQ.component.addReact('MainMenu', MainMenu); ManageIQ.component.addReact('MiqAboutModal', MiqAboutModal); ManageIQ.component.addReact('MiqToolbar', MiqToolbar); ManageIQ.component.addReact('OptimizationList', OptimizationList); diff --git a/app/javascript/packs/globals.js b/app/javascript/packs/globals.js index 340deb2dbc1..80e5a6a9612 100644 --- a/app/javascript/packs/globals.js +++ b/app/javascript/packs/globals.js @@ -18,9 +18,23 @@ require('jquery-ui/ui/widgets/sortable'); require('jquery-ujs'); require('jquery.observe_field'); require('patternfly-bootstrap-treeview'); -require('patternfly/dist/js/patternfly.min'); require('jquery.hotkeys'); +// all of patternfly js except for vertical navigation (patternfly-functions-vertical-nav.js) +// list from https://github.com/patternfly/patternfly/blob/master/Gruntfile.js#L62-L85 +require('patternfly/dist/js/patternfly-settings.js'); +require('patternfly/dist/js/patternfly-functions-base.js'); +require('patternfly/dist/js/patternfly-functions-list.js'); +require('patternfly/dist/js/patternfly-functions-sidebar.js'); +require('patternfly/dist/js/patternfly-functions-popovers.js'); +require('patternfly/dist/js/patternfly-functions-data-tables.js'); +require('patternfly/dist/js/patternfly-functions-navigation.js'); +require('patternfly/dist/js/patternfly-functions-count-chars.js'); +require('patternfly/dist/js/patternfly-functions-colors.js'); +require('patternfly/dist/js/patternfly-functions-charts.js'); +require('patternfly/dist/js/patternfly-functions-fixed-heights.js'); +require('patternfly/dist/js/patternfly-functions-tree-grid.js'); + window.angular = require('angular'); require('angular-ui-bootstrap'); require('angular-gettext'); @@ -48,9 +62,9 @@ require('moment-duration-format')(window.moment); require('@pf3/timeline'); window.CodeMirror = require('codemirror'); -require('codemirror/mode/css/css.js'); // not referenced directly, needed by htmlmixed +require('codemirror/mode/css/css.js'); // not referenced directly, needed by htmlmixed require('codemirror/mode/htmlmixed/htmlmixed.js'); -require('codemirror/mode/javascript/javascript.js'); // not referenced directly, needed by htmlmixed +require('codemirror/mode/javascript/javascript.js'); // not referenced directly, needed by htmlmixed require('codemirror/mode/ruby/ruby.js'); require('codemirror/mode/shell/shell.js'); require('codemirror/mode/xml/xml.js'); diff --git a/app/javascript/spec/main-menu/__snapshots__/main-menu.spec.js.snap b/app/javascript/spec/main-menu/__snapshots__/main-menu.spec.js.snap new file mode 100644 index 00000000000..fe62382fc89 --- /dev/null +++ b/app/javascript/spec/main-menu/__snapshots__/main-menu.spec.js.snap @@ -0,0 +1,768 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Main menu test should render correctly 1`] = ` + + +
    +
    + +
    +
    +
    +
    +`; diff --git a/app/javascript/spec/main-menu/main-menu.spec.js b/app/javascript/spec/main-menu/main-menu.spec.js new file mode 100644 index 00000000000..9787d066e84 --- /dev/null +++ b/app/javascript/spec/main-menu/main-menu.spec.js @@ -0,0 +1,112 @@ +import React from 'react'; +import { mount } from 'enzyme'; +import toJson from 'enzyme-to-json'; +import MainMenu from '../../components/main-menu/main-menu'; +import TopLevel from '../../components/main-menu/top-level'; +import SecondLevel from '../../components/main-menu/second-level'; +import ThirdLevel from '../../components/main-menu/third-level'; + +describe('Main menu test', () => { + let mockNavItems; + + beforeEach(() => { + mockNavItems = [{ + id: 'vi', + title: 'Cloud Intel', + iconClass: 'fa fa-dashboard', + href: '/dashboard/maintab/?tab=vi', + preventHref: true, + visible: true, + active: true, + type: 'default', + items: [{ + id: 'dashboard', + title: 'Dashboard', + iconClass: null, + href: '/dashboard/show', + preventHref: false, + visible: true, + active: true, + type: 'modal', + items: [], + }, { + id: 'chargeback', + title: 'Chargeback', + iconClass: null, + href: '/chargeback/explorer', + preventHref: false, + visible: true, + active: false, + type: 'big_iframe', + items: [], + }], + }, { + id: 'compute', + title: 'Compute', + iconClass: 'pficon pficon-cpu', + href: '/dashboard/maintab/?tab=compute', + preventHref: true, + visible: true, + active: false, + type: 'new_window', + items: [{ + id: 'clo', + title: 'Clouds', + iconClass: 'fa fa-plus', + href: '/dashboard/maintab/?tab=clo', + preventHref: true, + visible: true, + active: false, + type: 'new_window', + items: [{ + id: 'ems_cloud', + title: 'Providers', + iconClass: null, + href: '/ems_cloud/show_list', + preventHref: false, + visible: true, + active: true, + type: 'new_window', + items: [], + }, { + id: 'availability_zone', + title: 'Availability Zones', + iconClass: null, + href: '/availability_zone/show_list', + preventHref: false, + visible: true, + active: false, + type: 'default', + items: [], + }, { + id: 'host_aggregate', + title: 'Host Aggregates', + iconClass: null, + href: '/host_aggregate/show_list', + preventHref: false, + visible: false, + active: false, + type: 'new_window', + items: [], + }], + }], + }]; + }); + + it('should render correctly', () => { + const wrapper = mount(); + expect(toJson(wrapper)).toMatchSnapshot(); + }); + + it('should render proper level components', () => { + const wrapper = mount(); + expect(wrapper.find(TopLevel)).toHaveLength(2); + expect(wrapper.find(SecondLevel)).toHaveLength(3); + expect(wrapper.find(ThirdLevel)).toHaveLength(3); + }); + + it('should render active third level components properly', () => { + const wrapper = mount(); + expect(wrapper.find(ThirdLevel).find('li.menu-list-group-item.active')).toHaveLength(1); + }); +}); diff --git a/app/javascript/spec/toolbar/__snapshots__/dashboard_toolbar.spec.jsx.snap b/app/javascript/spec/toolbar/__snapshots__/dashboard_toolbar.spec.jsx.snap index 0bb8ce27407..a26232b30d5 100644 --- a/app/javascript/spec/toolbar/__snapshots__/dashboard_toolbar.spec.jsx.snap +++ b/app/javascript/spec/toolbar/__snapshots__/dashboard_toolbar.spec.jsx.snap @@ -23,140 +23,146 @@ exports[` renders ok 1`] = `
    - - - -
    - - - - - - -
    -
    -
    -
    + + +   + add + + + + + + + +
    + + + +