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
pull/58750/head
Ashley Harrison 3 years ago committed by GitHub
parent 84a69135a7
commit 028751a18a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-data/src/types/navModel.ts
  2. 1
      pkg/services/navtree/models.go
  3. 16
      pkg/services/navtree/navtreeimpl/navtree.go
  4. 9
      public/app/core/components/AppChrome/NavToolbarSeparator.tsx
  5. 73
      public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx
  6. 76
      public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx
  7. 14
      public/app/core/components/AppChrome/QuickAdd/utils.ts
  8. 11
      public/app/core/components/AppChrome/TopSearchBar.tsx
  9. 2
      public/app/core/components/MegaMenu/NavBarMenuItemWrapper.tsx

@ -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 {

@ -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() {

@ -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,
})
}

@ -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 <div className={styles.leftActionsSeparator} />;
return <div className={cx(className, styles.leftActionsSeparator)} />;
}
if (config.featureToggles.topnav) {
return <div className={styles.line} />;
return <div className={cx(className, styles.line)} />;
}
return null;

@ -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(
<Provider store={store}>
<QuickAdd />
</Provider>
);
};
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();
});
});

@ -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 (
<Menu>
{createActions.map((createAction, index) => (
<Menu.Item key={index} url={createAction.url} label={createAction.text} />
))}
</Menu>
);
};
return createActions.length > 0 ? (
<>
<Dropdown overlay={MenuActions} placement="bottom-end">
{isSmallScreen ? (
<ToolbarButton iconOnly icon="plus-circle" aria-label="New" />
) : (
<Button variant="primary" size="sm" icon="plus">
<div className={styles.buttonContent}>
<span className={styles.buttonText}>New</span>
<Icon name="angle-down" />
</div>
</Button>
)}
</Dropdown>
<NavToolbarSeparator className={styles.separator} />
</>
) : 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',
},
}),
});

@ -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;
}

@ -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() {
<TopSearchBarInput />
</TopSearchBarSection>
<TopSearchBarSection align="right">
<QuickAdd />
{helpNode && (
<Dropdown overlay={() => <TopNavBarMenu node={helpNode} />}>
<Dropdown overlay={() => <TopNavBarMenu node={helpNode} />} placement="bottom-end">
<ToolbarButton iconOnly icon="question-circle" aria-label="Help" />
</Dropdown>
)}
<NewsContainer className={styles.newsButton} />
{!contextSrv.user.isSignedIn && <SignInLink />}
{profileNode && (
<Dropdown overlay={<TopNavBarMenu node={profileNode} />}>
<Dropdown overlay={() => <TopNavBarMenu node={profileNode} />} placement="bottom-end">
<ToolbarButton
className={styles.profileButton}
imgSrc={contextSrv.user.gravatarUrl}
@ -59,14 +61,14 @@ const getStyles = (theme: GrafanaTheme2) => ({
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',

@ -39,7 +39,7 @@ export function NavBarMenuItemWrapper({
{link.children.map((childLink) => {
const icon = childLink.icon ? toIconName(childLink.icon) : undefined;
return (
!childLink.divider && (
!childLink.isCreateAction && (
<NavBarMenuItem
key={`${link.text}-${childLink.text}`}
isActive={isMatchOrChildMatch(childLink, activeItem)}

Loading…
Cancel
Save