mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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
This commit is contained in:
parent
84a69135a7
commit
028751a18a
@ -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();
|
||||
});
|
||||
});
|
76
public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx
Normal file
76
public/app/core/components/AppChrome/QuickAdd/QuickAdd.tsx
Normal file
@ -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',
|
||||
},
|
||||
}),
|
||||
});
|
14
public/app/core/components/AppChrome/QuickAdd/utils.ts
Normal file
14
public/app/core/components/AppChrome/QuickAdd/utils.ts
Normal file
@ -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…
Reference in New Issue
Block a user