From de0682521d1cedc03a1f76610121d3d417bd40ee Mon Sep 17 00:00:00 2001 From: Syerikjan Kh Date: Fri, 21 Feb 2025 10:16:45 -0500 Subject: [PATCH] Feat: OSS connections page state filter and update all added (#100688) * feat: OSS connections page state filter and update all added * fix: use combobox instead of select * fix: show no updates available text * ref: extract update all button to a component --- .betterer.results | 3 +- .../pages/AddNewConnectionPage.tsx | 31 +++- .../tabs/ConnectData/CardGrid/CardGrid.tsx | 58 +++++-- .../tabs/ConnectData/ConnectData.test.tsx | 85 +++++++++-- .../tabs/ConnectData/ConnectData.tsx | 141 ++++++++++++++---- .../admin/components/UpdateAllButton.test.tsx | 29 ++++ .../admin/components/UpdateAllButton.tsx | 24 +++ .../features/plugins/admin/pages/Browse.tsx | 18 ++- public/locales/en-US/grafana.json | 15 +- public/locales/pseudo-LOCALE/grafana.json | 15 +- 10 files changed, 348 insertions(+), 71 deletions(-) create mode 100644 public/app/features/plugins/admin/components/UpdateAllButton.test.tsx create mode 100644 public/app/features/plugins/admin/components/UpdateAllButton.tsx 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đ."