From 028751a18a656edb5156fa5620c91585432a3988 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 15 Nov 2022 12:08:15 +0000 Subject: [PATCH] Navigation: Add quick actions button (#58707) * initial implementation for quick add * add new isCreateAction prop on NavModel * adjust separator margin * switch to primary button * undo changes to plugin.json * remove unused props from interface * use a consistent dropdown overlay type * memoize findCreateActions * add prop description * use a function so that menus are only rendered when the dropdown is open --- packages/grafana-data/src/types/navModel.ts | 2 + pkg/services/navtree/models.go | 1 + pkg/services/navtree/navtreeimpl/navtree.go | 16 ++-- .../AppChrome/NavToolbarSeparator.tsx | 9 ++- .../AppChrome/QuickAdd/QuickAdd.test.tsx | 73 ++++++++++++++++++ .../AppChrome/QuickAdd/QuickAdd.tsx | 76 +++++++++++++++++++ .../components/AppChrome/QuickAdd/utils.ts | 14 ++++ .../components/AppChrome/TopSearchBar.tsx | 11 +-- .../MegaMenu/NavBarMenuItemWrapper.tsx | 2 +- 9 files changed, 189 insertions(+), 15 deletions(-) create mode 100644 public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx create mode 100644 public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx create mode 100644 public/app/core/components/AppChrome/QuickAdd/utils.ts diff --git a/packages/grafana-data/src/types/navModel.ts b/packages/grafana-data/src/types/navModel.ts index 33905084342..63e3e7050f0 100644 --- a/packages/grafana-data/src/types/navModel.ts +++ b/packages/grafana-data/src/types/navModel.ts @@ -28,6 +28,8 @@ export interface NavLinkDTO { emptyMessageId?: string; // The ID of the plugin that registered the page (in case it was registered by a plugin, otherwise left empty) pluginId?: string; + // Whether the page is used to create a new resource. We may place these in a different position in the UI. + isCreateAction?: boolean; } export interface NavModelItem extends NavLinkDTO { diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go index 00358200547..f71ceea0808 100644 --- a/pkg/services/navtree/models.go +++ b/pkg/services/navtree/models.go @@ -68,6 +68,7 @@ type NavLink struct { HighlightID string `json:"highlightId,omitempty"` EmptyMessageId string `json:"emptyMessageId,omitempty"` PluginID string `json:"pluginId,omitempty"` // (Optional) The ID of the plugin that registered nav link (e.g. as a standalone plugin page) + IsCreateAction bool `json:"isCreateAction,omitempty"` } func (node *NavLink) Sort() { diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go index 94ed6c125b4..73d601b6ba3 100644 --- a/pkg/services/navtree/navtreeimpl/navtree.go +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -408,13 +408,17 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm b dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, }) + } + if hasEditPerm { if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ - Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", ShowIconInNavbar: true, + Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", ShowIconInNavbar: true, IsCreateAction: true, }) } + } + if hasEditPerm && !s.features.IsEnabled(featuremgmt.FlagTopnav) { if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) { dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "dashboards/folder/new", @@ -498,13 +502,15 @@ func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) fallbackHasEditPerm := func(*models.ReqContext) bool { return hasEditPerm } if hasAccess(fallbackHasEditPerm, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { - alertChildNavs = append(alertChildNavs, &navtree.NavLink{ - Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, - }) + if !s.features.IsEnabled(featuremgmt.FlagTopnav) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, + }) + } alertChildNavs = append(alertChildNavs, &navtree.NavLink{ Text: "New alert rule", SubTitle: "Create an alert rule", Id: "alert", - Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, + Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, IsCreateAction: true, }) } diff --git a/public/app/core/components/AppChrome/NavToolbarSeparator.tsx b/public/app/core/components/AppChrome/NavToolbarSeparator.tsx index 72b328ba2d9..36fe42e58b8 100644 --- a/public/app/core/components/AppChrome/NavToolbarSeparator.tsx +++ b/public/app/core/components/AppChrome/NavToolbarSeparator.tsx @@ -1,4 +1,4 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; @@ -6,18 +6,19 @@ import { config } from '@grafana/runtime'; import { useStyles2 } from '@grafana/ui'; export interface Props { + className?: string; leftActionsSeparator?: boolean; } -export function NavToolbarSeparator({ leftActionsSeparator }: Props) { +export function NavToolbarSeparator({ className, leftActionsSeparator }: Props) { const styles = useStyles2(getStyles); if (leftActionsSeparator) { - return
; + return
; } if (config.featureToggles.topnav) { - return
; + return
; } return null; diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx new file mode 100644 index 00000000000..dbf20988fde --- /dev/null +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { NavModelItem, NavSection } from '@grafana/data'; +import { configureStore } from 'app/store/configureStore'; + +import { QuickAdd } from './QuickAdd'; + +const setup = () => { + const navBarTree: NavModelItem[] = [ + { + text: 'Section 1', + section: NavSection.Core, + id: 'section1', + url: 'section1', + children: [ + { text: 'New child 1', id: 'child1', url: 'section1/child1', isCreateAction: true }, + { text: 'Child2', id: 'child2', url: 'section1/child2' }, + ], + }, + { + text: 'Section 2', + id: 'section2', + section: NavSection.Config, + url: 'section2', + children: [{ text: 'New child 3', id: 'child3', url: 'section2/child3', isCreateAction: true }], + }, + ]; + + const store = configureStore({ navBarTree }); + + return render( + + + + ); +}; + +describe('QuickAdd', () => { + it('renders a `New` button', () => { + setup(); + expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument(); + }); + + it('renders the `New` text on a larger viewport', () => { + (window.matchMedia as jest.Mock).mockImplementation(() => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: () => false, + })); + setup(); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + it('does not render the text on a smaller viewport', () => { + (window.matchMedia as jest.Mock).mockImplementation(() => ({ + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + matches: () => true, + })); + setup(); + expect(screen.queryByText('New')).not.toBeInTheDocument(); + }); + + it('shows isCreateAction options when clicked', async () => { + setup(); + await userEvent.click(screen.getByRole('button', { name: 'New' })); + expect(screen.getByRole('link', { name: 'New child 1' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'New child 3' })).toBeInTheDocument(); + }); +}); diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx new file mode 100644 index 00000000000..13dbdcc9b65 --- /dev/null +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx @@ -0,0 +1,76 @@ +import { css } from '@emotion/css'; +import React, { useMemo, useState } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Menu, Dropdown, Button, Icon, useStyles2, useTheme2, ToolbarButton } from '@grafana/ui'; +import { useMediaQueryChange } from 'app/core/hooks/useMediaQueryChange'; +import { useSelector } from 'app/types'; + +import { NavToolbarSeparator } from '../NavToolbarSeparator'; + +import { findCreateActions } from './utils'; + +export interface Props {} + +export const QuickAdd = ({}: Props) => { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const navBarTree = useSelector((state) => state.navBarTree); + const breakpoint = theme.breakpoints.values.sm; + + const [isSmallScreen, setIsSmallScreen] = useState(window.matchMedia(`(max-width: ${breakpoint}px)`).matches); + const createActions = useMemo(() => findCreateActions(navBarTree), [navBarTree]); + + useMediaQueryChange({ + breakpoint, + onChange: (e) => { + setIsSmallScreen(e.matches); + }, + }); + + const MenuActions = () => { + return ( + + {createActions.map((createAction, index) => ( + + ))} + + ); + }; + + return createActions.length > 0 ? ( + <> + + {isSmallScreen ? ( + + ) : ( + + )} + + + + ) : null; +}; + +const getStyles = (theme: GrafanaTheme2) => ({ + buttonContent: css({ + alignItems: 'center', + display: 'flex', + }), + buttonText: css({ + [theme.breakpoints.down('md')]: { + display: 'none', + }, + }), + separator: css({ + marginLeft: theme.spacing(1), + [theme.breakpoints.down('md')]: { + display: 'none', + }, + }), +}); diff --git a/public/app/core/components/AppChrome/QuickAdd/utils.ts b/public/app/core/components/AppChrome/QuickAdd/utils.ts new file mode 100644 index 00000000000..2e4a0075472 --- /dev/null +++ b/public/app/core/components/AppChrome/QuickAdd/utils.ts @@ -0,0 +1,14 @@ +import { NavModelItem } from '@grafana/data'; + +export function findCreateActions(navTree: NavModelItem[]): NavModelItem[] { + const results: NavModelItem[] = []; + for (const navItem of navTree) { + if (navItem.isCreateAction) { + results.push(navItem); + } + if (navItem.children) { + results.push(...findCreateActions(navItem.children)); + } + } + return results; +} diff --git a/public/app/core/components/AppChrome/TopSearchBar.tsx b/public/app/core/components/AppChrome/TopSearchBar.tsx index 679f7b388ae..b4244f43365 100644 --- a/public/app/core/components/AppChrome/TopSearchBar.tsx +++ b/public/app/core/components/AppChrome/TopSearchBar.tsx @@ -8,6 +8,7 @@ import { useSelector } from 'app/types'; import { NewsContainer } from './News/NewsContainer'; import { OrganizationSwitcher } from './Organization/OrganizationSwitcher'; +import { QuickAdd } from './QuickAdd/QuickAdd'; import { SignInLink } from './TopBar/SignInLink'; import { TopNavBarMenu } from './TopBar/TopNavBarMenu'; import { TopSearchBarSection } from './TopBar/TopSearchBarSection'; @@ -33,15 +34,16 @@ export function TopSearchBar() { + {helpNode && ( - }> + } placement="bottom-end"> )} {!contextSrv.user.isSignedIn && } {profileNode && ( - }> + } placement="bottom-end"> ({ layout: css({ height: TOP_BAR_LEVEL_HEIGHT, display: 'flex', - gap: theme.spacing(0.5), + gap: theme.spacing(1), alignItems: 'center', padding: theme.spacing(0, 2), borderBottom: `1px solid ${theme.colors.border.weak}`, justifyContent: 'space-between', [theme.breakpoints.up('sm')]: { - gridTemplateColumns: '1fr 2fr 1fr', + gridTemplateColumns: '1fr 1fr 1fr', display: 'grid', justifyContent: 'flex-start', @@ -83,7 +85,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ width: '24px', }, }), - newsButton: css({ [theme.breakpoints.down('sm')]: { display: 'none', diff --git a/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx b/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx index 3c2798e13cf..edfc7b8e4d1 100644 --- a/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx +++ b/public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx @@ -39,7 +39,7 @@ export function NavBarMenuItemWrapper({ {link.children.map((childLink) => { const icon = childLink.icon ? toIconName(childLink.icon) : undefined; return ( - !childLink.divider && ( + !childLink.isCreateAction && (