diff --git a/.betterer.results b/.betterer.results
index 106ccbad80f..89f038771ec 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -3144,8 +3144,7 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use export all (\`export * from ...\`)", "0"]
],
"public/app/features/connections/tabs/ConnectData/ConnectData.tsx:5381": [
- [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "1"]
+ [0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/connections/tabs/ConnectData/NoAccessModal/NoAccessModal.tsx:5381": [
[0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"],
diff --git a/public/app/features/connections/pages/AddNewConnectionPage.tsx b/public/app/features/connections/pages/AddNewConnectionPage.tsx
index bb81e688c8e..e9431d4eca9 100644
--- a/public/app/features/connections/pages/AddNewConnectionPage.tsx
+++ b/public/app/features/connections/pages/AddNewConnectionPage.tsx
@@ -1,12 +1,41 @@
+import { useState } from 'react';
+
+import { PluginType } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
+import UpdateAllButton from 'app/features/plugins/admin/components/UpdateAllButton';
+import UpdateAllModal from 'app/features/plugins/admin/components/UpdateAllModal';
+import { useGetUpdatable } from 'app/features/plugins/admin/state/hooks';
import { AddNewConnection } from '../tabs/ConnectData';
export function AddNewConnectionPage() {
+ const { isLoading: areUpdatesLoading, updatablePlugins } = useGetUpdatable();
+ const updatableDSPlugins = updatablePlugins.filter((plugin) => plugin.type === PluginType.datasource);
+ const [showUpdateModal, setShowUpdateModal] = useState(false);
+ const disableUpdateAllButton = updatableDSPlugins.length <= 0 || areUpdatesLoading;
+
+ const onUpdateAll = () => {
+ setShowUpdateModal(true);
+ };
+
+ const updateAllButton = (
+
+ );
+
return (
-
+
+ setShowUpdateModal(false)}
+ plugins={updatableDSPlugins}
+ />
);
diff --git a/public/app/features/connections/tabs/ConnectData/CardGrid/CardGrid.tsx b/public/app/features/connections/tabs/ConnectData/CardGrid/CardGrid.tsx
index 07cdde766ae..2efa50542bf 100644
--- a/public/app/features/connections/tabs/ConnectData/CardGrid/CardGrid.tsx
+++ b/public/app/features/connections/tabs/ConnectData/CardGrid/CardGrid.tsx
@@ -2,8 +2,19 @@ import { css } from '@emotion/css';
import * as React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
-import { Card, Grid, useStyles2 } from '@grafana/ui';
-import { PluginAngularBadge } from 'app/features/plugins/admin/components/Badges';
+import { featureEnabled } from '@grafana/runtime';
+import { Card, Grid, useStyles2, Stack, Badge } from '@grafana/ui';
+import { t } from 'app/core/internationalization';
+import {
+ PluginAngularBadge,
+ PluginDeprecatedBadge,
+ PluginDisabledBadge,
+ PluginInstalledBadge,
+ PluginUpdateAvailableBadge,
+} from 'app/features/plugins/admin/components/Badges';
+import { getBadgeColor } from 'app/features/plugins/admin/components/Badges/sharedStyles';
+import { isPluginUpdatable } from 'app/features/plugins/admin/helpers';
+import { CatalogPlugin } from 'app/features/plugins/admin/types';
const getStyles = (theme: GrafanaTheme2) => ({
heading: css({
@@ -40,13 +51,28 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
});
-export type CardGridItem = {
- id: string;
- name: string;
- description: string;
- url: string;
+function PluginEnterpriseBadgeWithoutSignature() {
+ const customBadgeStyles = useStyles2(getBadgeColor);
+
+ if (featureEnabled('enterprise.plugins')) {
+ return ;
+ }
+
+ return (
+
+ );
+}
+
+export type CardGridItem = CatalogPlugin & {
logo?: string;
- angularDetected?: boolean;
};
export interface CardGridProps {
@@ -75,12 +101,16 @@ export const CardGrid = ({ items, onClickItem }: CardGridProps) => {
-
- {item.angularDetected ? (
-
-
-
- ) : null}
+
+
+ {item.isEnterprise && }
+ {item.isDeprecated && }
+ {item.isInstalled && }
+ {item.isDisabled && }
+ {isPluginUpdatable(item) && }
+ {item.angularDetected && }
+
+
))}
diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx
index 30faaeeea0a..3ddc0442d0c 100644
--- a/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx
+++ b/public/app/features/connections/tabs/ConnectData/ConnectData.test.tsx
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { TestProvider } from 'test/helpers/TestProvider';
import { PluginType } from '@grafana/data';
+import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { getCatalogPluginMock, getPluginsStateMock } from 'app/features/plugins/admin/__mocks__';
import { CatalogPlugin } from 'app/features/plugins/admin/types';
@@ -17,7 +18,8 @@ jest.mock('@grafana/runtime', () => ({
useChromeHeaderHeight: jest.fn(),
}));
-const renderPage = (plugins: CatalogPlugin[] = []): RenderResult => {
+const renderPage = (plugins: CatalogPlugin[] = [], path = '/add-new-connection'): RenderResult => {
+ locationService.push(path);
return render(
@@ -31,7 +33,7 @@ const mockCatalogDataSourcePlugin = getCatalogPluginMock({
id: 'sample-data-source',
});
-describe('Angular badge', () => {
+describe('Badges', () => {
test('does not show angular badge for non-angular plugins', async () => {
renderPage([
getCatalogPluginMock({
@@ -61,6 +63,28 @@ describe('Angular badge', () => {
});
expect(screen.queryByText('Angular')).toBeInTheDocument();
});
+
+ test('shows enterprise and deprecated badges for plugins', async () => {
+ renderPage([
+ getCatalogPluginMock({
+ id: 'test-plugin',
+ name: 'test Plugin',
+ type: PluginType.datasource,
+ isEnterprise: true,
+ }),
+ getCatalogPluginMock({
+ id: 'test2-plugin',
+ name: 'test2 Plugin',
+ type: PluginType.datasource,
+ isDeprecated: true,
+ }),
+ ]);
+ await waitFor(() => {
+ expect(screen.queryByText('test Plugin')).toBeInTheDocument();
+ });
+ expect(screen.queryByText('Enterprise')).toBeVisible();
+ expect(screen.queryByText('Deprecated')).toBeVisible();
+ });
});
describe('Add new connection', () => {
@@ -81,20 +105,57 @@ describe('Add new connection', () => {
expect(await screen.findByText('Sample data source')).toBeVisible();
});
+ test('should list plugins with update when filtering by update', async () => {
+ const { queryByText } = renderPage(
+ [
+ getCatalogPluginMock({
+ id: 'plugin-1',
+ name: 'Plugin 1',
+ isInstalled: true,
+ hasUpdate: true,
+ type: PluginType.datasource,
+ }),
+ getCatalogPluginMock({ id: 'plugin-2', name: 'Plugin 2', isInstalled: false }),
+ getCatalogPluginMock({
+ id: 'plugin-3',
+ name: 'Plugin 3',
+ isInstalled: true,
+ hasUpdate: true,
+ type: PluginType.datasource,
+ }),
+ getCatalogPluginMock({ id: 'plugin-4', name: 'Plugin 4', isInstalled: true, isCore: true }),
+ ],
+ '/add-new-connection?filterBy=has-update'
+ );
+
+ await waitFor(() => expect(queryByText('Plugin 1')).toBeInTheDocument());
+ expect(queryByText('Plugin 3')).toBeInTheDocument();
+
+ expect(queryByText('Plugin 2')).not.toBeInTheDocument();
+ expect(queryByText('Plugin 4')).not.toBeInTheDocument();
+ });
test('renders card if search term matches', async () => {
- renderPage([getCatalogPluginMock(), mockCatalogDataSourcePlugin]);
- const searchField = await screen.findByRole('textbox');
+ renderPage(
+ [
+ getCatalogPluginMock({ type: PluginType.datasource, id: 'test1', name: 'test33' }),
+ getCatalogPluginMock({ id: 'test2', type: PluginType.datasource, name: 'querymatches' }),
+ ],
+ '/add-new-connection?filterBy=all&sortBy=nameAsc&search=querymatches'
+ );
+ expect(await screen.findByText('querymatches')).toBeVisible();
+ });
- await userEvent.type(searchField, 'ampl');
- expect(await screen.findByText('Sample data source')).toBeVisible();
+ test('renders no results if search term does not match', async () => {
+ renderPage(
+ [
+ getCatalogPluginMock({ type: PluginType.datasource, id: 'test1', name: 'test33' }),
+ getCatalogPluginMock({ id: 'test2', type: PluginType.datasource, name: 'querymatches' }),
+ ],
+ '/add-new-connection?filterBy=all&sortBy=nameAsc&search=dfvdfv'
+ );
- await userEvent.clear(searchField);
- await userEvent.type(searchField, 'cramp');
- expect(screen.queryByText('No results matching your query were found')).toBeInTheDocument();
-
- await userEvent.clear(searchField);
- expect(await screen.findByText('Sample data source')).toBeVisible();
+ expect(await screen.findByText('No results matching your query were found')).toBeVisible();
});
test('shows a "No access" modal if the user does not have permissions to create datasources', async () => {
diff --git a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx
index 4cd292c298a..c8d3683685d 100644
--- a/public/app/features/connections/tabs/ConnectData/ConnectData.tsx
+++ b/public/app/features/connections/tabs/ConnectData/ConnectData.tsx
@@ -1,14 +1,19 @@
import { css } from '@emotion/css';
-import { useMemo, useState, FormEvent, MouseEvent } from 'react';
+import { useMemo, useState, MouseEvent } from 'react';
+import { useLocation } from 'react-router-dom-v5-compat';
-import { GrafanaTheme2, PluginType } from '@grafana/data';
-import { reportInteraction } from '@grafana/runtime';
-import { useStyles2, LoadingPlaceholder, EmptyState } from '@grafana/ui';
+import { PluginType, GrafanaTheme2, SelectableValue } from '@grafana/data';
+import { locationSearchToObject, reportInteraction } from '@grafana/runtime';
+import { LoadingPlaceholder, EmptyState, Field, RadioButtonGroup, Tooltip, Combobox, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
-import { t } from 'app/core/internationalization';
+import { t, Trans } from 'app/core/internationalization';
+import { HorizontalGroup } from 'app/features/plugins/admin/components/HorizontalGroup';
import { RoadmapLinks } from 'app/features/plugins/admin/components/RoadmapLinks';
-import { useGetAll } from 'app/features/plugins/admin/state/hooks';
+import { SearchField } from 'app/features/plugins/admin/components/SearchField';
+import { Sorters } from 'app/features/plugins/admin/helpers';
+import { useHistory } from 'app/features/plugins/admin/hooks/useHistory';
+import { useGetAll, useIsRemotePluginsAvailable } from 'app/features/plugins/admin/state/hooks';
import { AccessControlAction } from 'app/types';
import { ROUTES } from '../../constants';
@@ -16,7 +21,6 @@ import { ROUTES } from '../../constants';
import { CardGrid, type CardGridItem } from './CardGrid';
import { CategoryHeader } from './CategoryHeader';
import { NoAccessModal } from './NoAccessModal';
-import { Search } from './Search';
const getStyles = (theme: GrafanaTheme2) => ({
spacer: css({
@@ -28,6 +32,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
modalContent: css({
overflow: 'visible',
}),
+ actionBar: css({
+ [theme.breakpoints.up('xl')]: {
+ marginLeft: 'auto',
+ },
+ }),
});
export function AddNewConnection() {
@@ -35,32 +44,34 @@ export function AddNewConnection() {
const searchTerm = queryParams.search ? String(queryParams.search) : '';
const [isNoAccessModalOpen, setIsNoAccessModalOpen] = useState(false);
const [focusedItem, setFocusedItem] = useState(null);
- const styles = useStyles2(getStyles);
+ const location = useLocation();
+ const history = useHistory();
+ const locationSearch = locationSearchToObject(location.search);
+ const sortBy = (locationSearch.sortBy as Sorters) || Sorters.nameAsc;
+ const filterBy = locationSearch.filterBy?.toString() || 'all';
const canCreateDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate);
-
- const handleSearchChange = (e: FormEvent) => {
+ const styles = useStyles2(getStyles);
+ const handleSearchChange = (val: string) => {
setQueryParams({
- search: e.currentTarget.value.toLowerCase(),
+ search: val,
});
};
+ const remotePluginsAvailable = useIsRemotePluginsAvailable();
- const { error, plugins, isLoading } = useGetAll({
- keyword: searchTerm,
- type: PluginType.datasource,
- });
-
- const cardGridItems = useMemo(
- () =>
- plugins.map((plugin) => ({
- id: plugin.id,
- name: plugin.name,
- description: plugin.description,
- logo: plugin.info.logos.small,
- url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
- angularDetected: plugin.angularDetected,
- })),
- [plugins]
+ const { error, plugins, isLoading } = useGetAll(
+ {
+ keyword: searchTerm,
+ type: PluginType.datasource,
+ isInstalled: filterBy === 'installed' ? true : undefined,
+ hasUpdate: filterBy === 'has-update' ? true : undefined,
+ },
+ sortBy
);
+ const filterByOptions = [
+ { value: 'all', label: 'All' },
+ { value: 'installed', label: 'Installed' },
+ { value: 'has-update', label: 'New Updates' },
+ ];
const onClickCardGridItem = (e: MouseEvent, item: CardGridItem) => {
if (!canCreateDataSources) {
@@ -85,20 +96,86 @@ export function AddNewConnection() {
setFocusedItem(null);
};
+ const cardGridItems = useMemo(
+ () =>
+ plugins.map((plugin) => ({
+ ...plugin,
+ logo: plugin.info.logos.small,
+ url: ROUTES.DataSourcesDetails.replace(':id', plugin.id),
+ })),
+ [plugins]
+ );
+
+ const onSortByChange = (value: SelectableValue) => {
+ history.push({ query: { sortBy: value.value } });
+ };
+
+ const onFilterByChange = (value: string) => {
+ history.push({ query: { filterBy: value } });
+ };
+
const showNoResults = useMemo(() => !isLoading && !error && plugins.length < 1, [isLoading, error, plugins]);
const categoryHeaderLabel = t('connections.connect-data.category-header-label', 'Data sources');
return (
<>
{focusedItem && }
-
- {/* We need this extra spacing when there are no filters */}
-
+
+
+
+
+
+ {/* Filter by installed / all */}
+ {remotePluginsAvailable ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+ {/* Sorting */}
+
+
+
+
+
{isLoading ? (
-
+
) : !!error ? (
- Error: {error.message}
+
+ Error message: "{{ error: error.message }}"
+
) : (
)}
diff --git a/public/app/features/plugins/admin/components/UpdateAllButton.test.tsx b/public/app/features/plugins/admin/components/UpdateAllButton.test.tsx
new file mode 100644
index 00000000000..8a0cb1e4945
--- /dev/null
+++ b/public/app/features/plugins/admin/components/UpdateAllButton.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+
+import UpdateAllButton from './UpdateAllButton';
+
+describe('UpdateAllButton', () => {
+ const onUpdateAllMock = jest.fn();
+
+ beforeEach(() => {
+ onUpdateAllMock.mockClear();
+ });
+
+ it('should display "No updates available" when disabled', () => {
+ render();
+
+ expect(screen.getByText('No updates available')).toBeInTheDocument();
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+
+ it('should display update count and be clickable when enabled', () => {
+ render();
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveTextContent('Update all (3)');
+ expect(button).toBeEnabled();
+
+ fireEvent.click(button);
+ expect(onUpdateAllMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/public/app/features/plugins/admin/components/UpdateAllButton.tsx b/public/app/features/plugins/admin/components/UpdateAllButton.tsx
new file mode 100644
index 00000000000..7ba9c7ad3aa
--- /dev/null
+++ b/public/app/features/plugins/admin/components/UpdateAllButton.tsx
@@ -0,0 +1,24 @@
+import { Button } from '@grafana/ui';
+import { Trans } from 'app/core/internationalization';
+
+interface UpdateAllButtonProps {
+ disabled: boolean;
+ onUpdateAll: () => void;
+ updatablePluginsLength: number;
+}
+
+const UpdateAllButton = ({ disabled, onUpdateAll, updatablePluginsLength }: UpdateAllButtonProps) => {
+ return (
+
+ );
+};
+
+export default UpdateAllButton;
diff --git a/public/app/features/plugins/admin/pages/Browse.tsx b/public/app/features/plugins/admin/pages/Browse.tsx
index 58857662c54..70615d4f743 100644
--- a/public/app/features/plugins/admin/pages/Browse.tsx
+++ b/public/app/features/plugins/admin/pages/Browse.tsx
@@ -4,9 +4,8 @@ import { useLocation } from 'react-router-dom-v5-compat';
import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime';
-import { Select, RadioButtonGroup, useStyles2, Tooltip, Field, Button } from '@grafana/ui';
+import { Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
-import { Trans } from 'app/core/internationalization';
import { getNavModel } from 'app/core/selectors/navModel';
import { ROUTES as CONNECTIONS_ROUTES } from 'app/features/connections/constants';
import { useSelector } from 'app/types';
@@ -15,6 +14,7 @@ import { HorizontalGroup } from '../components/HorizontalGroup';
import { PluginList } from '../components/PluginList';
import { RoadmapLinks } from '../components/RoadmapLinks';
import { SearchField } from '../components/SearchField';
+import UpdateAllButton from '../components/UpdateAllButton';
import { UpdateAllModal } from '../components/UpdateAllModal';
import { Sorters } from '../helpers';
import { useHistory } from '../hooks/useHistory';
@@ -86,15 +86,17 @@ export default function Browse() {
.
);
- const updateAll = (
-
+
+ const updateAllButton = (
+
);
return (
-
+
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json
index 9773ba5c185..88c3d0aa34b 100644
--- a/public/locales/en-US/grafana.json
+++ b/public/locales/en-US/grafana.json
@@ -810,6 +810,7 @@
"collapse": "Collapse",
"edit": "Edit",
"help": "Help",
+ "loading": "Loading...",
"locale": {
"default": "Default"
},
@@ -1642,6 +1643,10 @@
"incomplete-request-error": "Sorry, I was unable to complete your request. Please try again.",
"send-custom-feedback": "Send"
},
+ "get-enterprise": {
+ "requires-license": "Requires a Grafana Enterprise license",
+ "title": "Enterprise"
+ },
"grafana-ui": {
"action-editor": {
"button": {
@@ -2045,6 +2050,7 @@
"render-image-error-description": "An error occurred when generating the image"
}
},
+ "lock-icon": "lock icon",
"login": {
"divider": {
"connecting-text": "or"
@@ -2839,10 +2845,11 @@
},
"plugins": {
"catalog": {
+ "no-updates-available": "No updates available",
"update-all": {
"all-plugins-updated": "All plugins updated!",
"available-header": "Available",
- "button": "Update all",
+ "button": "Update all ({{length}})",
"cloud-update-message": "* It may take a few minutes for the plugins to be available for usage.",
"error": "Error updating plugin:",
"error-status-text": "failed - see error messages",
@@ -2898,6 +2905,12 @@
"empty-state": {
"message": "No plugins found"
},
+ "filter": {
+ "disabled": "This filter has been disabled because the Grafana server cannot access grafana.com",
+ "sort": "Sort",
+ "sort-list": "Sort Plugins List",
+ "state": "State"
+ },
"plugin-help": {
"error": "An error occurred when loading help.",
"not-found": "No query help could be found."
diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json
index b442e198f22..f4b2de7e6dc 100644
--- a/public/locales/pseudo-LOCALE/grafana.json
+++ b/public/locales/pseudo-LOCALE/grafana.json
@@ -810,6 +810,7 @@
"collapse": "Cőľľäpşę",
"edit": "Ēđįŧ",
"help": "Ħęľp",
+ "loading": "Ŀőäđįʼnģ...",
"locale": {
"default": "Đęƒäūľŧ"
},
@@ -1642,6 +1643,10 @@
"incomplete-request-error": "Ŝőřřy, Ĩ ŵäş ūʼnäþľę ŧő čőmpľęŧę yőūř řęqūęşŧ. Pľęäşę ŧřy äģäįʼn.",
"send-custom-feedback": "Ŝęʼnđ"
},
+ "get-enterprise": {
+ "requires-license": "Ŗęqūįřęş ä Ğřäƒäʼnä Ēʼnŧęřpřįşę ľįčęʼnşę",
+ "title": "Ēʼnŧęřpřįşę"
+ },
"grafana-ui": {
"action-editor": {
"button": {
@@ -2045,6 +2050,7 @@
"render-image-error-description": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ģęʼnęřäŧįʼnģ ŧĥę įmäģę"
}
},
+ "lock-icon": "ľőčĸ įčőʼn",
"login": {
"divider": {
"connecting-text": "őř"
@@ -2839,10 +2845,11 @@
},
"plugins": {
"catalog": {
+ "no-updates-available": "Ńő ūpđäŧęş äväįľäþľę",
"update-all": {
"all-plugins-updated": "Åľľ pľūģįʼnş ūpđäŧęđ!",
"available-header": "Åväįľäþľę",
- "button": "Ůpđäŧę äľľ",
+ "button": "Ůpđäŧę äľľ ({{length}})",
"cloud-update-message": "* Ĩŧ mäy ŧäĸę ä ƒęŵ mįʼnūŧęş ƒőř ŧĥę pľūģįʼnş ŧő þę äväįľäþľę ƒőř ūşäģę.",
"error": "Ēřřőř ūpđäŧįʼnģ pľūģįʼn:",
"error-status-text": "ƒäįľęđ - şęę ęřřőř męşşäģęş",
@@ -2898,6 +2905,12 @@
"empty-state": {
"message": "Ńő pľūģįʼnş ƒőūʼnđ"
},
+ "filter": {
+ "disabled": "Ŧĥįş ƒįľŧęř ĥäş þęęʼn đįşäþľęđ þęčäūşę ŧĥę Ğřäƒäʼnä şęřvęř čäʼnʼnőŧ äččęşş ģřäƒäʼnä.čőm",
+ "sort": "Ŝőřŧ",
+ "sort-list": "Ŝőřŧ Pľūģįʼnş Ŀįşŧ",
+ "state": "Ŝŧäŧę"
+ },
"plugin-help": {
"error": "Åʼn ęřřőř őččūřřęđ ŵĥęʼn ľőäđįʼnģ ĥęľp.",
"not-found": "Ńő qūęřy ĥęľp čőūľđ þę ƒőūʼnđ."