Explore: Show links to queryless apps (#96625)

* Extract basic extensions to a separate files

* Add simple queryless apps links

* Move links for queryless apps next to the datasource picker

* Update tests

* Add translations

* Add tracking

* Update translations

* Fix tests and betterer

* Fix the mock for the test (the hook may be called twice now)

* Add a todo
eleijonmarck/lbac-for-datasources/check-team-existance-before-adding-rule
Piotr Jamróz 5 months ago committed by GitHub
parent 8bb24bc7b3
commit bb673fc8ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      .betterer.results
  2. 2
      public/app/features/explore/Explore.test.tsx
  3. 13
      public/app/features/explore/ExploreToolbar.tsx
  4. 144
      public/app/features/explore/extensions/ToolbarExtensionPoint.test.tsx
  5. 71
      public/app/features/explore/extensions/ToolbarExtensionPoint.tsx
  6. 47
      public/app/features/explore/extensions/toolbar/BasicExtensions.tsx
  7. 42
      public/app/features/explore/extensions/toolbar/QuerylessAppsExtensions.tsx
  8. 10
      public/app/features/explore/extensions/toolbar/types.ts
  9. 2
      public/locales/en-US/grafana.json
  10. 2
      public/locales/pseudo-LOCALE/grafana.json

@ -3148,9 +3148,6 @@ exports[`better eslint`] = {
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "2"],
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "3"]
],
"public/app/features/explore/extensions/ToolbarExtensionPoint.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/explore/hooks/useStateSync/index.ts:5381": [
[0, 0, 0, "Do not re-export imported variable (\`./external.utils\`)", "0"]
],

@ -180,7 +180,7 @@ describe('Explore', () => {
});
it('should render toolbar extension point if extensions is available', async () => {
usePluginLinksMock.mockReturnValueOnce({
usePluginLinksMock.mockReturnValue({
links: [
{
id: '1',

@ -255,6 +255,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
hideTextValue={showSmallDataSourcePicker}
width={showSmallDataSourcePicker ? 8 : undefined}
/>,
<ToolbarExtensionPoint
key="toolbar-extension-point"
exploreId={exploreId}
timeZone={timeZone}
extensionsToShow="queryless"
/>,
].filter(Boolean)}
forceShowLeftItems
>
@ -295,7 +301,12 @@ export function ExploreToolbar({ exploreId, onChangeTime, onContentOutlineToogle
</ToolbarButton>
</ButtonGroup>
),
<ToolbarExtensionPoint key="toolbar-extension-point" exploreId={exploreId} timeZone={timeZone} />,
<ToolbarExtensionPoint
key="toolbar-extension-point"
exploreId={exploreId}
timeZone={timeZone}
extensionsToShow="basic"
/>,
!isLive && (
<ExploreTimeControls
key="timeControls"

@ -51,6 +51,18 @@ function renderWithExploreStore(
render(<Provider store={store}>{children}</Provider>, {});
}
function setupToolbarExtensionPoint(
{ noTimezone, showQuerylessApps }: { noTimezone?: boolean; showQuerylessApps?: boolean } = { noTimezone: false }
) {
return (
<ToolbarExtensionPoint
exploreId="left"
timeZone={noTimezone ? '' : 'browser'}
extensionsToShow={showQuerylessApps ? 'queryless' : 'basic'}
/>
);
}
describe('ToolbarExtensionPoint', () => {
describe('with extension points', () => {
beforeAll(() => {
@ -79,13 +91,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
@ -95,7 +107,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should call onClick from extension when menu item is clicked', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'Add to dashboard' }));
@ -109,7 +121,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render confirm navigation modal when extension with path is clicked', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
await userEvent.click(screen.getByRole('menuitem', { name: 'ML: Forecast' }));
@ -123,7 +135,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />, {
renderWithExploreStore(setupToolbarExtensionPoint(), {
targets,
data,
});
@ -148,7 +160,7 @@ describe('ToolbarExtensionPoint', () => {
const targets = [{ refId: 'A' }];
const data = createEmptyQueryResponse();
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="" />, {
renderWithExploreStore(setupToolbarExtensionPoint({ noTimezone: true }), {
targets,
data,
});
@ -160,7 +172,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should correct extension point id when fetching extensions', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
const [options] = usePluginLinksMock.mock.calls[0];
const { extensionPointId } = options;
@ -195,13 +207,13 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "Add" extension point menu button', () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.getByRole('button', { name: 'Add' })).toBeVisible();
});
it('should render menu with extensions when "Add" is clicked', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
@ -219,7 +231,7 @@ describe('ToolbarExtensionPoint', () => {
});
it('should render "add to dashboard" action button if one pane is visible', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
await waitFor(() => {
const button = screen.getByRole('button', { name: /add to dashboard/i });
@ -237,9 +249,119 @@ describe('ToolbarExtensionPoint', () => {
});
it('should not render "add to dashboard" action button', async () => {
renderWithExploreStore(<ToolbarExtensionPoint exploreId="left" timeZone="browser" />);
renderWithExploreStore(setupToolbarExtensionPoint());
expect(screen.queryByRole('button', { name: /add to dashboard/i })).not.toBeInTheDocument();
});
});
describe('with multiple queryless apps links', () => {
beforeAll(() => {
usePluginLinksMock.mockReturnValue({
links: [
{
pluginId: 'grafana',
id: '1',
type: PluginExtensionTypes.link,
title: 'Add to dashboard',
category: 'Dashboards',
description: 'Add the current query as a panel to a dashboard',
onClick: jest.fn(),
},
{
pluginId: 'grafana-ml-app',
id: '2',
type: PluginExtensionTypes.link,
title: 'ML: Forecast',
description: 'Add the query as a ML forecast',
path: '/a/grafana-ml-ap/forecast',
},
{
pluginId: 'grafana-pyroscope-app',
id: '3',
type: PluginExtensionTypes.link,
title: 'Explore Profiles',
description: 'Explore Profiles',
path: '/a/grafana-pyroscope-app',
},
{
pluginId: 'grafana-lokiexplore-app',
id: '4',
type: PluginExtensionTypes.link,
title: 'Explore Logs',
description: 'Explore Logs',
path: '/a/grafana-lokiexplore-app',
},
],
isLoading: false,
});
});
it('should render menu with extensions without queryless apps when "Add" is clicked', async () => {
renderWithExploreStore(setupToolbarExtensionPoint());
await userEvent.click(screen.getByRole('button', { name: 'Add' }));
expect(screen.getByRole('group', { name: 'Dashboards' })).toBeVisible();
expect(screen.getByRole('menuitem', { name: 'Add to dashboard' })).toBeVisible();
expect(screen.getByRole('menuitem', { name: 'ML: Forecast' })).toBeVisible();
expect(screen.queryByRole('menuitem', { name: 'Explore Profiles' })).not.toBeInTheDocument();
});
it('should render queryless apps links', async () => {
renderWithExploreStore(setupToolbarExtensionPoint({ showQuerylessApps: true }));
await userEvent.click(screen.getByRole('button', { name: /go queryless/i }));
expect(screen.queryByRole('group', { name: 'Dashboards' })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Add to dashboard' })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'ML: Forecast' })).not.toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Explore Profiles' })).toBeVisible();
expect(screen.getByRole('menuitem', { name: 'Explore Logs' })).toBeVisible();
});
});
describe('with single queryless apps link', () => {
beforeAll(() => {
usePluginLinksMock.mockReturnValue({
links: [
{
pluginId: 'grafana',
id: '1',
type: PluginExtensionTypes.link,
title: 'Add to dashboard',
category: 'Dashboards',
description: 'Add the current query as a panel to a dashboard',
onClick: jest.fn(),
},
{
pluginId: 'grafana-ml-app',
id: '2',
type: PluginExtensionTypes.link,
title: 'ML: Forecast',
description: 'Add the query as a ML forecast',
path: '/a/grafana-ml-ap/forecast',
},
{
pluginId: 'grafana-pyroscope-app',
id: '3',
type: PluginExtensionTypes.link,
title: 'Explore Profiles',
description: 'Explore Profiles',
path: '/a/grafana-pyroscope-app',
},
],
isLoading: false,
});
});
it('should render single queryless app link', async () => {
renderWithExploreStore(setupToolbarExtensionPoint({ showQuerylessApps: true }));
await userEvent.click(screen.getByRole('button', { name: /go queryless/i }));
expect(screen.queryByRole('menuitem', { name: 'Explore Profiles' })).not.toBeInTheDocument();
expect(screen.queryByRole('menuitem', { name: 'Explore Logs' })).not.toBeInTheDocument();
});
});
});

@ -1,66 +1,69 @@
import { lazy, ReactElement, Suspense, useMemo, useState } from 'react';
import { ReactElement, useMemo, useState } from 'react';
import { type PluginExtensionLink, PluginExtensionPoints, RawTimeRange, getTimeZone } from '@grafana/data';
import { config, usePluginLinks } from '@grafana/runtime';
import { config, reportInteraction, usePluginLinks } from '@grafana/runtime';
import { DataQuery, TimeZone } from '@grafana/schema';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, ExplorePanelData, useSelector } from 'app/types';
import { getExploreItemSelector, isLeftPaneSelector, selectCorrelationDetails } from '../state/selectors';
import { ConfirmNavigationModal } from './ConfirmNavigationModal';
import { ToolbarExtensionPointMenu } from './ToolbarExtensionPointMenu';
const AddToDashboard = lazy(() =>
import('./AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
);
import { BasicExtensions } from './toolbar/BasicExtensions';
import { QuerylessAppsExtensions } from './toolbar/QuerylessAppsExtensions';
type Props = {
exploreId: string;
timeZone: TimeZone;
extensionsToShow: 'queryless' | 'basic';
};
const QUERYLESS_APPS = ['grafana-pyroscope-app', 'grafana-lokiexplore-app', 'grafana-exploretraces-app'];
export function ToolbarExtensionPoint(props: Props): ReactElement | null {
const { exploreId } = props;
const { exploreId, extensionsToShow } = props;
const [selectedExtension, setSelectedExtension] = useState<PluginExtensionLink | undefined>();
const [isOpen, setIsOpen] = useState<boolean>(false);
const context = useExtensionPointContext(props);
// TODO: Pull it up to avoid calling it twice
const { links } = usePluginLinks({
extensionPointId: PluginExtensionPoints.ExploreToolbarAction,
context: context,
limitPerPlugin: 3,
});
const selectExploreItem = getExploreItemSelector(exploreId);
const noQueriesInPane = useSelector(selectExploreItem)?.queries?.length;
// If we only have the explore core extension point registered we show the old way of
// adding a query to a dashboard.
if (links.length <= 1) {
const canAddPanelToDashboard =
contextSrv.hasPermission(AccessControlAction.DashboardsCreate) ||
contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
if (!canAddPanelToDashboard) {
return null;
}
return (
<Suspense fallback={null}>
<AddToDashboard exploreId={exploreId} />
</Suspense>
);
}
const noQueriesInPane = Boolean(useSelector(selectExploreItem)?.queries?.length);
const menu = <ToolbarExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
const querylessLinks = links.filter((link) => QUERYLESS_APPS.includes(link.pluginId));
const commonLinks = links.filter((link) => !QUERYLESS_APPS.includes(link.pluginId));
return (
<>
<Dropdown onVisibleChange={setIsOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="Add" disabled={!Boolean(noQueriesInPane)} variant="canvas" isOpen={isOpen}>
Add
</ToolbarButton>
</Dropdown>
{extensionsToShow === 'queryless' && (
<QuerylessAppsExtensions
links={querylessLinks}
noQueriesInPane={noQueriesInPane}
exploreId={exploreId}
setSelectedExtension={(extension) => {
setSelectedExtension(extension);
reportInteraction('grafana_explore_queryless_app_link_clicked', {
pluginId: extension.pluginId,
});
}}
setIsModalOpen={setIsOpen}
isModalOpen={isOpen}
/>
)}
{extensionsToShow === 'basic' && (
<BasicExtensions
links={commonLinks}
noQueriesInPane={noQueriesInPane}
exploreId={exploreId}
setSelectedExtension={setSelectedExtension}
setIsModalOpen={setIsOpen}
isModalOpen={isOpen}
/>
)}
{!!selectedExtension && !!selectedExtension.path && (
<ConfirmNavigationModal
path={selectedExtension.path}

@ -0,0 +1,47 @@
import { lazy, Suspense } from 'react';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import { Trans } from '../../../../core/internationalization';
import { ToolbarExtensionPointMenu } from '../ToolbarExtensionPointMenu';
import { ExtensionDropdownProps } from './types';
const AddToDashboard = lazy(() =>
import('./../AddToDashboard').then(({ AddToDashboard }) => ({ default: AddToDashboard }))
);
export function BasicExtensions(props: ExtensionDropdownProps) {
const { exploreId, links, setSelectedExtension, setIsModalOpen, isModalOpen, noQueriesInPane } = props;
// If we only have the explore core extension point registered we show the old way of
// adding a query to a dashboard.
if (links.length <= 1) {
const canAddPanelToDashboard =
contextSrv.hasPermission(AccessControlAction.DashboardsCreate) ||
contextSrv.hasPermission(AccessControlAction.DashboardsWrite);
if (!canAddPanelToDashboard) {
return null;
}
return (
<Suspense fallback={null}>
<AddToDashboard exploreId={exploreId} />
</Suspense>
);
}
const menu = <ToolbarExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
return (
<>
<Dropdown onVisibleChange={setIsModalOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton aria-label="Add" disabled={!Boolean(noQueriesInPane)} variant="canvas" isOpen={isModalOpen}>
<Trans i18nKey="explore.toolbar.add-to-extensions">Add</Trans>
</ToolbarButton>
</Dropdown>
</>
);
}

@ -0,0 +1,42 @@
import { first } from 'lodash';
import { Dropdown, ToolbarButton } from '@grafana/ui';
import { Trans } from '../../../../core/internationalization';
import { ToolbarExtensionPointMenu } from '../ToolbarExtensionPointMenu';
import { ExtensionDropdownProps } from './types';
export function QuerylessAppsExtensions(props: ExtensionDropdownProps) {
const { links, setSelectedExtension, setIsModalOpen, isModalOpen, noQueriesInPane } = props;
if (links.length === 0) {
return undefined;
}
const menu = <ToolbarExtensionPointMenu extensions={links} onSelect={setSelectedExtension} />;
if (links.length === 1) {
const link = first(links)!;
return (
<ToolbarButton variant="canvas" icon={link.icon} onClick={() => setSelectedExtension(link)}>
<Trans i18nKey="explore.toolbar.add-to-queryless-extensions">Go queryless</Trans>
</ToolbarButton>
);
}
return (
<>
<Dropdown onVisibleChange={setIsModalOpen} placement="bottom-start" overlay={menu}>
<ToolbarButton
aria-label="Go Queryless"
disabled={!Boolean(noQueriesInPane)}
variant="canvas"
isOpen={isModalOpen}
>
<Trans i18nKey="explore.toolbar.add-to-queryless-extensions">Go queryless</Trans>
</ToolbarButton>
</Dropdown>
</>
);
}

@ -0,0 +1,10 @@
import { PluginExtensionLink } from '@grafana/data';
export type ExtensionDropdownProps = {
links: PluginExtensionLink[];
exploreId: string;
setSelectedExtension: (extension: PluginExtensionLink) => void;
setIsModalOpen: (value: boolean) => void;
isModalOpen: boolean;
noQueriesInPane: boolean;
};

@ -1261,6 +1261,8 @@
"title-with-name": "Table - {{name}}"
},
"toolbar": {
"add-to-extensions": "Add",
"add-to-queryless-extensions": "Go queryless",
"aria-label": "Explore toolbar",
"copy-link": "Copy URL",
"copy-link-abs-time": "Copy absolute URL",

@ -1261,6 +1261,8 @@
"title-with-name": "Ŧäþľę - {{name}}"
},
"toolbar": {
"add-to-extensions": "Åđđ",
"add-to-queryless-extensions": "Ğő qūęřyľęşş",
"aria-label": "Ēχpľőřę ŧőőľþäř",
"copy-link": "Cőpy ŮŖĿ",
"copy-link-abs-time": "Cőpy äþşőľūŧę ŮŖĿ",

Loading…
Cancel
Save