From d6d49d8ba3dd76fcdb8afdf58fb6bef4a039a548 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Wed, 20 Jul 2022 09:25:09 +0200 Subject: [PATCH] DataSources: refactor datasource pages to be reusable (#51874) * refactor: move utility functions out of the redux actions * refactor: move password handlers to the feature root * refactor: move API related functions to an api.ts * refactor: move components under a /components folder * refactor: move page containers under a /pages folder and extract components * refactor: update mocks to be easier to reuse * refactor: move tests into a state/tests/ subfolder * refactor: expose 'initialState' for plugins * refactor: move generic types to the root folder of the feature * refactor: import path fixe * refactor: update import paths for app routes * chore: update betterer * refactor: fix type errors due to changed mock functions * chore: fix mocking context_srv in tests * refactor: udpate imports to be more concise * fix: update failing test because of mocks * refactor: use the new `navId` prop where we can * fix: use UID instead ID in datasource edit links * fix:clean up Redux state when unmounting the edit page * refactor: use `uid` instead of `id` * refactor: always fetch the plugin details when editing a datasource The deleted lines could provide performance benefits, although they also make the implementation more prone to errors. (Mostly because we are storing the information about the currently loaded plugin in a single field, and it was not validating if it is for the latest one). We are planning to introduce some kind of caching, but first we would like to clean up the underlying state a bit (plugins & datasources. * fix: add missing dispatch() wrapper for update datasource callback * refactor: prefer using absolute import paths Co-authored-by: Ryan McKinley * fix: ESLINT import order issue * refactor: put test files next to their files * refactor: use implicit return types for components * fix: remove caching from datasource fetching I have introduced a cache to only fetch data-sources once, however as we are missing a good logic for updating the instances in the Redux store when they change (create, update, delete), this approach is not keeping the UI in sync. Due to this reason I have removed the caching for now, and will reintroduce it once we have a more robust client-side state logic. Co-authored-by: Ryan McKinley --- .betterer.results | 23 +- .../datasources/DataSourceDashboards.test.tsx | 44 --- .../datasources/DataSourceDashboards.tsx | 89 ----- .../features/datasources/DataSourcesList.tsx | 54 ---- .../datasources/DataSourcesListPage.tsx | 22 -- .../DataSourcesListPageContent.tsx | 58 ---- .../datasources/NewDataSourcePage.tsx | 244 -------------- .../datasources/__mocks__/dataSourcesMocks.ts | 120 +++++-- .../features/datasources/__mocks__/index.ts | 2 + .../__mocks__/store.navIndex.mock.ts | 4 +- public/app/features/datasources/api.test.ts | 37 +++ public/app/features/datasources/api.ts | 74 +++++ .../BasicSettings.test.tsx | 4 +- .../BasicSettings.tsx | 10 +- .../ButtonRow.test.tsx | 12 +- .../{settings => components}/ButtonRow.tsx | 12 +- .../{settings => components}/CloudInfoBox.tsx | 6 +- .../{ => components}/DashboardsTable.test.tsx | 2 +- .../{ => components}/DashboardsTable.tsx | 12 +- .../components/DataSourceCategories.tsx | 43 +++ .../components/DataSourceDashboards.test.tsx | 52 +++ .../components/DataSourceDashboards.tsx | 85 +++++ .../components/DataSourceLoadError.tsx | 36 +++ .../DataSourceMissingRightsMessage.tsx | 14 + .../components/DataSourcePluginConfigPage.tsx | 23 ++ .../DataSourcePluginSettings.tsx} | 15 +- .../components/DataSourcePluginState.tsx | 19 ++ .../components/DataSourceReadOnlyMessage.tsx | 15 + .../components/DataSourceTestingStatus.tsx | 39 +++ .../components/DataSourceTypeCard.tsx | 109 +++++++ .../components/DataSourceTypeCardList.tsx | 33 ++ .../{ => components}/DataSourcesList.test.tsx | 30 +- .../components/DataSourcesList.tsx | 106 ++++++ .../DataSourcesListHeader.tsx | 24 +- .../components/EditDataSource.test.tsx | 251 ++++++++++++++ .../datasources/components/EditDataSource.tsx | 171 ++++++++++ .../datasources/components/NewDataSource.tsx | 89 +++++ public/app/features/datasources/mocks.ts | 24 -- .../pages/DataSourceDashboardsPage.test.tsx | 82 +++++ .../pages/DataSourceDashboardsPage.tsx | 22 ++ .../{ => pages}/DataSourcesListPage.test.tsx | 18 +- .../datasources/pages/DataSourcesListPage.tsx | 17 + .../pages/EditDataSourcePage.test.tsx | 99 ++++++ .../datasources/pages/EditDataSourcePage.tsx | 26 ++ .../datasources/pages/NewDataSourcePage.tsx | 35 ++ .../{utils => }/passwordHandlers.test.ts | 0 .../{utils => }/passwordHandlers.ts | 0 .../settings/DataSourceSettingsPage.test.tsx | 169 ---------- .../settings/DataSourceSettingsPage.tsx | 306 ------------------ .../datasources/state/actions.test.ts | 149 +++------ .../app/features/datasources/state/actions.ts | 162 ++-------- .../datasources/state/buildCategories.test.ts | 3 +- .../app/features/datasources/state/hooks.ts | 161 +++++++++ .../app/features/datasources/state/index.ts | 6 + .../features/datasources/state/navModel.ts | 4 +- .../datasources/state/reducers.test.ts | 6 +- .../features/datasources/state/reducers.ts | 2 +- .../features/datasources/state/selectors.ts | 5 +- public/app/features/datasources/types.ts | 9 + public/app/features/datasources/utils.test.ts | 41 +++ public/app/features/datasources/utils.ts | 47 +++ .../admin/components/PluginDashboards.tsx | 2 +- .../features/plugins/admin/state/reducer.ts | 36 ++- public/app/features/plugins/plugin_loader.ts | 2 +- public/app/features/teams/TeamList.test.tsx | 9 +- .../elasticsearch/configuration/mocks.ts | 22 +- public/app/plugins/datasource/loki/mocks.ts | 6 +- .../prometheus/configuration/mocks.ts | 14 +- public/app/routes/routes.tsx | 14 +- public/app/types/datasources.ts | 2 +- public/test/jest-setup.ts | 5 +- 71 files changed, 2053 insertions(+), 1435 deletions(-) delete mode 100644 public/app/features/datasources/DataSourceDashboards.test.tsx delete mode 100644 public/app/features/datasources/DataSourceDashboards.tsx delete mode 100644 public/app/features/datasources/DataSourcesList.tsx delete mode 100644 public/app/features/datasources/DataSourcesListPage.tsx delete mode 100644 public/app/features/datasources/DataSourcesListPageContent.tsx delete mode 100644 public/app/features/datasources/NewDataSourcePage.tsx create mode 100644 public/app/features/datasources/__mocks__/index.ts create mode 100644 public/app/features/datasources/api.test.ts create mode 100644 public/app/features/datasources/api.ts rename public/app/features/datasources/{settings => components}/BasicSettings.test.tsx (86%) rename public/app/features/datasources/{settings => components}/BasicSettings.tsx (88%) rename public/app/features/datasources/{settings => components}/ButtonRow.test.tsx (80%) rename public/app/features/datasources/{settings => components}/ButtonRow.tsx (82%) rename public/app/features/datasources/{settings => components}/CloudInfoBox.tsx (95%) rename public/app/features/datasources/{ => components}/DashboardsTable.test.tsx (98%) rename public/app/features/datasources/{ => components}/DashboardsTable.tsx (84%) create mode 100644 public/app/features/datasources/components/DataSourceCategories.tsx create mode 100644 public/app/features/datasources/components/DataSourceDashboards.test.tsx create mode 100644 public/app/features/datasources/components/DataSourceDashboards.tsx create mode 100644 public/app/features/datasources/components/DataSourceLoadError.tsx create mode 100644 public/app/features/datasources/components/DataSourceMissingRightsMessage.tsx create mode 100644 public/app/features/datasources/components/DataSourcePluginConfigPage.tsx rename public/app/features/datasources/{settings/PluginSettings.tsx => components/DataSourcePluginSettings.tsx} (86%) create mode 100644 public/app/features/datasources/components/DataSourcePluginState.tsx create mode 100644 public/app/features/datasources/components/DataSourceReadOnlyMessage.tsx create mode 100644 public/app/features/datasources/components/DataSourceTestingStatus.tsx create mode 100644 public/app/features/datasources/components/DataSourceTypeCard.tsx create mode 100644 public/app/features/datasources/components/DataSourceTypeCardList.tsx rename public/app/features/datasources/{ => components}/DataSourcesList.test.tsx (53%) create mode 100644 public/app/features/datasources/components/DataSourcesList.tsx rename public/app/features/datasources/{ => components}/DataSourcesListHeader.tsx (61%) create mode 100644 public/app/features/datasources/components/EditDataSource.test.tsx create mode 100644 public/app/features/datasources/components/EditDataSource.tsx create mode 100644 public/app/features/datasources/components/NewDataSource.tsx delete mode 100644 public/app/features/datasources/mocks.ts create mode 100644 public/app/features/datasources/pages/DataSourceDashboardsPage.test.tsx create mode 100644 public/app/features/datasources/pages/DataSourceDashboardsPage.tsx rename public/app/features/datasources/{ => pages}/DataSourcesListPage.test.tsx (84%) create mode 100644 public/app/features/datasources/pages/DataSourcesListPage.tsx create mode 100644 public/app/features/datasources/pages/EditDataSourcePage.test.tsx create mode 100644 public/app/features/datasources/pages/EditDataSourcePage.tsx create mode 100644 public/app/features/datasources/pages/NewDataSourcePage.tsx rename public/app/features/datasources/{utils => }/passwordHandlers.test.ts (100%) rename public/app/features/datasources/{utils => }/passwordHandlers.ts (100%) delete mode 100644 public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx delete mode 100644 public/app/features/datasources/settings/DataSourceSettingsPage.tsx create mode 100644 public/app/features/datasources/state/hooks.ts create mode 100644 public/app/features/datasources/state/index.ts create mode 100644 public/app/features/datasources/types.ts create mode 100644 public/app/features/datasources/utils.test.ts create mode 100644 public/app/features/datasources/utils.ts diff --git a/.betterer.results b/.betterer.results index 643c4919068..79edee50234 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4756,16 +4756,16 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "10"], [0, 0, 0, "Unexpected any. Specify a different type.", "11"] ], - "public/app/features/datasources/DataSourceDashboards.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/datasources/__mocks__/dataSourcesMocks.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], - "public/app/features/datasources/settings/ButtonRow.tsx:5381": [ + "public/app/features/datasources/components/ButtonRow.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], + "public/app/features/datasources/passwordHandlers.test.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], + "public/app/features/datasources/passwordHandlers.ts:5381": [ + [0, 0, 0, "Unexpected any. Specify a different type.", "0"] + ], "public/app/features/datasources/state/actions.test.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], @@ -4790,12 +4790,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Do not use any type assertions.", "1"] ], - "public/app/features/datasources/utils/passwordHandlers.test.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], - "public/app/features/datasources/utils/passwordHandlers.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/features/dimensions/color.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], @@ -5466,9 +5460,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "2"], [0, 0, 0, "Do not use any type assertions.", "3"] ], - "public/app/features/plugins/admin/state/reducer.ts:5381": [ - [0, 0, 0, "Do not use any type assertions.", "0"] - ], "public/app/features/plugins/admin/state/selectors.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/datasources/DataSourceDashboards.test.tsx b/public/app/features/datasources/DataSourceDashboards.test.tsx deleted file mode 100644 index f90ad2b1a2b..00000000000 --- a/public/app/features/datasources/DataSourceDashboards.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -import { DataSourceSettings } from '@grafana/data'; -import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; -import { RouteDescriptor } from 'app/core/navigation/types'; -import { PluginDashboard } from 'app/types'; - -import { DataSourceDashboards, Props } from './DataSourceDashboards'; - -const setup = (propOverrides?: Partial) => { - const props: Props = { - ...getRouteComponentProps(), - navModel: { main: { text: 'nav-text' }, node: { text: 'node-text' } }, - dashboards: [] as PluginDashboard[], - dataSource: {} as DataSourceSettings, - dataSourceId: 'x', - importDashboard: jest.fn(), - loadDataSource: jest.fn(), - loadPluginDashboards: jest.fn(), - removeDashboard: jest.fn(), - route: {} as RouteDescriptor, - isLoading: false, - ...propOverrides, - }; - - return render(); -}; - -describe('Render', () => { - it('should render without exploding', () => { - expect(() => setup()).not.toThrow(); - }); - it('should render component', () => { - setup(); - - expect(screen.getByRole('heading', { name: 'nav-text' })).toBeInTheDocument(); - expect(screen.getByRole('table')).toBeInTheDocument(); - expect(screen.getByRole('list')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Documentation' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Support' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Community' })).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/datasources/DataSourceDashboards.tsx b/public/app/features/datasources/DataSourceDashboards.tsx deleted file mode 100644 index 9033a94ecc6..00000000000 --- a/public/app/features/datasources/DataSourceDashboards.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React, { PureComponent } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { Page } from 'app/core/components/Page/Page'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { getNavModel } from 'app/core/selectors/navModel'; -import { PluginDashboard, StoreState } from 'app/types'; - -import { importDashboard, removeDashboard } from '../dashboard/state/actions'; -import { loadPluginDashboards } from '../plugins/admin/state/actions'; - -import DashboardTable from './DashboardsTable'; -import { loadDataSource } from './state/actions'; -import { getDataSource } from './state/selectors'; - -export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} - -function mapStateToProps(state: StoreState, props: OwnProps) { - const dataSourceId = props.match.params.uid; - - return { - navModel: getNavModel(state.navIndex, `datasource-dashboards-${dataSourceId}`), - dashboards: state.plugins.dashboards, - dataSource: getDataSource(state.dataSources, dataSourceId), - isLoading: state.plugins.isLoadingPluginDashboards, - dataSourceId, - }; -} - -const mapDispatchToProps = { - importDashboard, - loadDataSource, - loadPluginDashboards, - removeDashboard, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -export type Props = OwnProps & ConnectedProps; - -export class DataSourceDashboards extends PureComponent { - async componentDidMount() { - const { loadDataSource, dataSourceId } = this.props; - await loadDataSource(dataSourceId); - this.props.loadPluginDashboards(); - } - - onImport = (dashboard: PluginDashboard, overwrite: boolean) => { - const { dataSource, importDashboard } = this.props; - const data: any = { - pluginId: dashboard.pluginId, - path: dashboard.path, - overwrite, - inputs: [], - }; - - if (dataSource) { - data.inputs.push({ - name: '*', - type: 'datasource', - pluginId: dataSource.type, - value: dataSource.name, - }); - } - - importDashboard(data, dashboard.title); - }; - - onRemove = (dashboard: PluginDashboard) => { - this.props.removeDashboard(dashboard.uid); - }; - - render() { - const { dashboards, navModel, isLoading } = this.props; - return ( - - - this.onImport(dashboard, overwrite)} - onRemove={(dashboard) => this.onRemove(dashboard)} - /> - - - ); - } -} - -export default connector(DataSourceDashboards); diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx deleted file mode 100644 index dbdf2971194..00000000000 --- a/public/app/features/datasources/DataSourcesList.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// Libraries -import { css } from '@emotion/css'; -import React from 'react'; - -// Types -import { DataSourceSettings } from '@grafana/data'; -import { Card, Tag, useStyles } from '@grafana/ui'; - -export type Props = { - dataSources: DataSourceSettings[]; -}; - -export const DataSourcesList = ({ dataSources }: Props) => { - const styles = useStyles(getStyles); - - return ( -
    - {dataSources.map((dataSource) => { - return ( -
  • - - {dataSource.name} - - - - - {[ - dataSource.typeName, - dataSource.url, - dataSource.isDefault && , - ]} - - -
  • - ); - })} -
- ); -}; - -export default DataSourcesList; - -const getStyles = () => { - return { - list: css({ - listStyle: 'none', - display: 'grid', - // gap: '8px', Add back when legacy support for old Card interface is dropped - }), - logo: css({ - objectFit: 'contain', - }), - }; -}; diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx deleted file mode 100644 index b9bf34c4c1f..00000000000 --- a/public/app/features/datasources/DataSourcesListPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; - -import { Page } from 'app/core/components/Page/Page'; -import { getNavModel } from 'app/core/selectors/navModel'; -import { StoreState } from 'app/types'; - -import { DataSourcesListPageContent } from './DataSourcesListPageContent'; - -export const DataSourcesListPage = () => { - const navModel = useSelector(({ navIndex }: StoreState) => getNavModel(navIndex, 'datasources')); - - return ( - - - - - - ); -}; - -export default DataSourcesListPage; diff --git a/public/app/features/datasources/DataSourcesListPageContent.tsx b/public/app/features/datasources/DataSourcesListPageContent.tsx deleted file mode 100644 index aebe1de2c33..00000000000 --- a/public/app/features/datasources/DataSourcesListPageContent.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React, { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import { IconName } from '@grafana/ui'; -import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import PageLoader from 'app/core/components/PageLoader/PageLoader'; -import { contextSrv } from 'app/core/core'; -import { StoreState, AccessControlAction } from 'app/types'; - -import DataSourcesList from './DataSourcesList'; -import { DataSourcesListHeader } from './DataSourcesListHeader'; -import { loadDataSources } from './state/actions'; -import { getDataSourcesCount, getDataSources } from './state/selectors'; - -const buttonIcon: IconName = 'database'; -const emptyListModel = { - title: 'No data sources defined', - buttonIcon, - buttonLink: 'datasources/new', - buttonTitle: 'Add data source', - proTip: 'You can also define data sources through configuration files.', - proTipLink: 'http://docs.grafana.org/administration/provisioning/#datasources?utm_source=grafana_ds_list', - proTipLinkTitle: 'Learn more', - proTipTarget: '_blank', -}; - -export const DataSourcesListPageContent = () => { - const dispatch = useDispatch(); - const dataSources = useSelector((state: StoreState) => getDataSources(state.dataSources)); - const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources)); - const hasFetched = useSelector(({ dataSources }: StoreState) => dataSources.hasFetched); - const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); - const emptyList = { - ...emptyListModel, - buttonDisabled: !canCreateDataSource, - }; - - useEffect(() => { - if (!hasFetched) { - dispatch(loadDataSources()); - } - }, [dispatch, hasFetched]); - - if (!hasFetched) { - return ; - } - - if (dataSourcesCount === 0) { - return ; - } - - return ( - <> - - - - ); -}; diff --git a/public/app/features/datasources/NewDataSourcePage.tsx b/public/app/features/datasources/NewDataSourcePage.tsx deleted file mode 100644 index 2f226c69784..00000000000 --- a/public/app/features/datasources/NewDataSourcePage.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { css, cx } from '@emotion/css'; -import React, { FC, PureComponent } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { DataSourcePluginMeta, GrafanaTheme2, NavModel } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { Card, LinkButton, List, PluginSignatureBadge, FilterInput, useStyles2 } from '@grafana/ui'; -import { Page } from 'app/core/components/Page/Page'; -import { StoreState } from 'app/types'; - -import { PluginsErrorsInfo } from '../plugins/components/PluginsErrorsInfo'; - -import { addDataSource, loadDataSourcePlugins } from './state/actions'; -import { setDataSourceTypeSearchQuery } from './state/reducers'; -import { getDataSourcePlugins } from './state/selectors'; - -function mapStateToProps(state: StoreState) { - return { - navModel: getNavModel(), - plugins: getDataSourcePlugins(state.dataSources), - searchQuery: state.dataSources.dataSourceTypeSearchQuery, - categories: state.dataSources.categories, - isLoading: state.dataSources.isLoadingDataSources, - }; -} - -const mapDispatchToProps = { - addDataSource, - loadDataSourcePlugins, - setDataSourceTypeSearchQuery, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -type Props = ConnectedProps; - -class NewDataSourcePage extends PureComponent { - componentDidMount() { - this.props.loadDataSourcePlugins(); - } - - onDataSourceTypeClicked = (plugin: DataSourcePluginMeta) => { - this.props.addDataSource(plugin); - }; - - onSearchQueryChange = (value: string) => { - this.props.setDataSourceTypeSearchQuery(value); - }; - - renderPlugins(plugins: DataSourcePluginMeta[], id?: string) { - if (!plugins || !plugins.length) { - return null; - } - - return ( - li { - margin-bottom: 2px; - } - `} - getItemKey={(item) => item.id.toString()} - renderItem={(item) => ( - this.onDataSourceTypeClicked(item)} - onLearnMoreClick={this.onLearnMoreClick} - /> - )} - aria-labelledby={id} - /> - ); - } - - onLearnMoreClick = (evt: React.SyntheticEvent) => { - evt.stopPropagation(); - }; - - renderCategories() { - const { categories } = this.props; - - return ( - <> - {categories.map((category) => ( -
-
- {category.title} -
- {this.renderPlugins(category.plugins, category.id)} -
- ))} -
- - Find more data source plugins on grafana.com - -
- - ); - } - - render() { - const { navModel, isLoading, searchQuery, plugins } = this.props; - - return ( - - -
- -
- - Cancel - -
- {!searchQuery && } -
- {searchQuery && this.renderPlugins(plugins)} - {!searchQuery && this.renderCategories()} -
- - - ); - } -} - -interface DataSourceTypeCardProps { - plugin: DataSourcePluginMeta; - onClick: () => void; - onLearnMoreClick: (evt: React.SyntheticEvent) => void; -} - -const DataSourceTypeCard: FC = (props) => { - const { plugin, onLearnMoreClick } = props; - const isPhantom = plugin.module === 'phantom'; - const onClick = !isPhantom && !plugin.unlicensed ? props.onClick : () => {}; - // find first plugin info link - const learnMoreLink = plugin.info?.links?.length > 0 ? plugin.info.links[0] : null; - - const styles = useStyles2(getStyles); - - return ( - - - {plugin.name} - - - - - {plugin.info.description} - {!isPhantom && ( - - - - )} - - {learnMoreLink && ( - - {learnMoreLink.name} - - )} - - - ); -}; - -function getStyles(theme: GrafanaTheme2) { - return { - heading: css({ - fontSize: theme.v1.typography.heading.h5, - fontWeight: 'inherit', - }), - figure: css({ - width: 'inherit', - marginRight: '0px', - '> img': { - width: theme.spacing(7), - }, - }), - meta: css({ - marginTop: '6px', - position: 'relative', - }), - description: css({ - margin: '0px', - fontSize: theme.typography.size.sm, - }), - actions: css({ - position: 'relative', - alignSelf: 'center', - marginTop: '0px', - opacity: 0, - - '.card-parent:hover &, .card-parent:focus-within &': { - opacity: 1, - }, - }), - card: css({ - gridTemplateAreas: ` - "Figure Heading Actions" - "Figure Description Actions" - "Figure Meta Actions" - "Figure - Actions"`, - }), - logo: css({ - marginRight: theme.v1.spacing.lg, - marginLeft: theme.v1.spacing.sm, - width: theme.spacing(7), - maxHeight: theme.spacing(7), - }), - }; -} - -export function getNavModel(): NavModel { - const main = { - icon: 'database', - id: 'datasource-new', - text: 'Add data source', - href: 'datasources/new', - subTitle: 'Choose a data source type', - }; - - return { - main: main, - node: main, - }; -} - -export default connector(NewDataSourcePage); diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts index 0d8fd898ee4..1de1c161e99 100644 --- a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts +++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts @@ -1,48 +1,100 @@ -import { DataSourceSettings } from '@grafana/data'; +import { merge } from 'lodash'; -export const getMockDataSources = (amount: number) => { - const dataSources = []; +import { DataSourceSettings, DataSourcePluginMeta } from '@grafana/data'; +import { DataSourceSettingsState, PluginDashboard } from 'app/types'; - for (let i = 0; i < amount; i++) { - dataSources.push({ +export const getMockDashboard = (override?: Partial) => ({ + uid: 'G1btqkgkK', + pluginId: 'grafana-timestream-datasource', + title: 'Sample (DevOps)', + imported: true, + importedUri: 'db/sample-devops', + importedUrl: '/d/G1btqkgkK/sample-devops', + slug: '', + dashboardId: 12, + folderId: 0, + importedRevision: 1, + revision: 1, + description: '', + path: 'dashboards/sample.json', + removed: false, + ...override, +}); + +export const getMockDataSources = (amount: number, overrides?: Partial): DataSourceSettings[] => + [...Array(amount)].map((_, i) => + getMockDataSource({ + ...overrides, + id: i, + uid: `uid-${i}`, + database: overrides?.database ? `${overrides.database}-${i}` : `database-${i}`, + name: overrides?.name ? `${overrides.name}-${i}` : `dataSource-${i}`, + }) + ); + +export const getMockDataSource = (overrides?: Partial>): DataSourceSettings => + merge( + { access: '', basicAuth: false, - database: `database-${i}`, - id: i, + basicAuthUser: '', + withCredentials: false, + database: '', + id: 13, + uid: 'x', isDefault: false, jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, - name: `dataSource-${i}`, + name: 'gdev-cloudwatch', + typeName: 'Cloudwatch', orgId: 1, readOnly: false, type: 'cloudwatch', typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', url: '', user: '', - }); - } + secureJsonFields: {}, + }, + overrides + ); - return dataSources as DataSourceSettings[]; -}; +export const getMockDataSourceMeta = (overrides?: Partial): DataSourcePluginMeta => + merge( + { + id: 0, + name: 'datasource-test', + type: 'datasource', + info: { + author: { + name: 'Sample Author', + url: 'https://sample-author.com', + }, + description: 'Some sample description.', + links: [{ name: 'Website', url: 'https://sample-author.com' }], + logos: { + large: 'large-logo', + small: 'small-logo', + }, + screenshots: [], + updated: '2022-07-01', + version: '1.5.0', + }, -export const getMockDataSource = (): DataSourceSettings => { - return { - access: '', - basicAuth: false, - basicAuthUser: '', - withCredentials: false, - database: '', - id: 13, - uid: 'x', - isDefault: false, - jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, - name: 'gdev-cloudwatch', - typeName: 'Cloudwatch', - orgId: 1, - readOnly: false, - type: 'cloudwatch', - typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', - url: '', - user: '', - secureJsonFields: {}, - }; -}; + module: 'plugins/datasource-test/module', + baseUrl: 'public/plugins/datasource-test', + }, + overrides + ); + +export const getMockDataSourceSettingsState = (overrides?: Partial): DataSourceSettingsState => + merge( + { + plugin: { + meta: getMockDataSourceMeta(), + components: {}, + }, + testingStatus: {}, + loadError: null, + loading: false, + }, + overrides + ); diff --git a/public/app/features/datasources/__mocks__/index.ts b/public/app/features/datasources/__mocks__/index.ts new file mode 100644 index 00000000000..0485099d6c4 --- /dev/null +++ b/public/app/features/datasources/__mocks__/index.ts @@ -0,0 +1,2 @@ +export * from './dataSourcesMocks'; +export * from './store.navIndex.mock'; diff --git a/public/app/features/datasources/__mocks__/store.navIndex.mock.ts b/public/app/features/datasources/__mocks__/store.navIndex.mock.ts index 17569b14120..7caaf5ed637 100644 --- a/public/app/features/datasources/__mocks__/store.navIndex.mock.ts +++ b/public/app/features/datasources/__mocks__/store.navIndex.mock.ts @@ -1,6 +1,6 @@ -import { NavSection } from '@grafana/data'; +import { NavSection, NavIndex } from '@grafana/data'; -export default { +export const navIndex: NavIndex = { dashboards: { id: 'dashboards', text: 'Dashboards', diff --git a/public/app/features/datasources/api.test.ts b/public/app/features/datasources/api.test.ts new file mode 100644 index 00000000000..f4bb9caa984 --- /dev/null +++ b/public/app/features/datasources/api.test.ts @@ -0,0 +1,37 @@ +import { of } from 'rxjs'; + +import { BackendSrvRequest, FetchResponse } from '@grafana/runtime'; +import { getBackendSrv } from 'app/core/services/backend_srv'; + +import { getDataSourceByIdOrUid } from './api'; + +jest.mock('app/core/services/backend_srv'); +jest.mock('@grafana/runtime', () => ({ + ...(jest.requireActual('@grafana/runtime') as unknown as object), + getBackendSrv: jest.fn(), +})); + +const mockResponse = (response: Partial) => { + (getBackendSrv as jest.Mock).mockReturnValueOnce({ + fetch: (options: BackendSrvRequest) => { + return of(response as FetchResponse); + }, + }); +}; + +describe('Datasources / API', () => { + describe('getDataSourceByIdOrUid()', () => { + it('should resolve to the datasource object in case it is fetched using a UID', async () => { + const response = { + ok: true, + data: { + id: 111, + uid: 'abcdefg', + }, + }; + mockResponse(response); + + expect(await getDataSourceByIdOrUid(response.data.uid)).toBe(response.data); + }); + }); +}); diff --git a/public/app/features/datasources/api.ts b/public/app/features/datasources/api.ts new file mode 100644 index 00000000000..5bfe388fed3 --- /dev/null +++ b/public/app/features/datasources/api.ts @@ -0,0 +1,74 @@ +import { lastValueFrom } from 'rxjs'; + +import { DataSourceSettings } from '@grafana/data'; +import { getBackendSrv } from 'app/core/services/backend_srv'; +import { accessControlQueryParam } from 'app/core/utils/accessControl'; + +export const getDataSources = async (): Promise => { + return await getBackendSrv().get('/api/datasources'); +}; + +/** + * @deprecated Use `getDataSourceByUid` instead. + */ +export const getDataSourceById = async (id: string) => { + const response = await lastValueFrom( + getBackendSrv().fetch({ + method: 'GET', + url: `/api/datasources/${id}`, + params: accessControlQueryParam(), + showErrorAlert: false, + }) + ); + + if (response.ok) { + return response.data; + } + + throw Error(`Could not find data source by ID: "${id}"`); +}; + +export const getDataSourceByUid = async (uid: string) => { + const response = await lastValueFrom( + getBackendSrv().fetch({ + method: 'GET', + url: `/api/datasources/uid/${uid}`, + params: accessControlQueryParam(), + showErrorAlert: false, + }) + ); + + if (response.ok) { + return response.data; + } + + throw Error(`Could not find data source by UID: "${uid}"`); +}; + +export const getDataSourceByIdOrUid = async (idOrUid: string) => { + // Try with UID first, as we are trying to migrate to that + try { + return await getDataSourceByUid(idOrUid); + } catch (err) { + console.log(`Failed to lookup data source using UID "${idOrUid}"`); + } + + // Try using ID + try { + return await getDataSourceById(idOrUid); + } catch (err) { + console.log(`Failed to lookup data source using ID "${idOrUid}"`); + } + + throw Error('Could not find data source'); +}; + +export const createDataSource = (dataSource: Partial) => + getBackendSrv().post('/api/datasources', dataSource); + +export const getDataSourcePlugins = () => getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' }); + +export const updateDataSource = (dataSource: DataSourceSettings) => + getBackendSrv().put(`/api/datasources/uid/${dataSource.uid}`, dataSource); + +export const deleteDataSource = (uid: string) => getBackendSrv().delete(`/api/datasources/uid/${uid}`); diff --git a/public/app/features/datasources/settings/BasicSettings.test.tsx b/public/app/features/datasources/components/BasicSettings.test.tsx similarity index 86% rename from public/app/features/datasources/settings/BasicSettings.test.tsx rename to public/app/features/datasources/components/BasicSettings.test.tsx index 0f58cf241f4..b8ffbb5196e 100644 --- a/public/app/features/datasources/settings/BasicSettings.test.tsx +++ b/public/app/features/datasources/components/BasicSettings.test.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import BasicSettings, { Props } from './BasicSettings'; +import { BasicSettings, Props } from './BasicSettings'; const setup = () => { const props: Props = { @@ -16,7 +16,7 @@ const setup = () => { return render(); }; -describe('Basic Settings', () => { +describe('', () => { it('should render component', () => { setup(); diff --git a/public/app/features/datasources/settings/BasicSettings.tsx b/public/app/features/datasources/components/BasicSettings.tsx similarity index 88% rename from public/app/features/datasources/settings/BasicSettings.tsx rename to public/app/features/datasources/components/BasicSettings.tsx index 5b3eb0117a7..bd99284cb29 100644 --- a/public/app/features/datasources/settings/BasicSettings.tsx +++ b/public/app/features/datasources/components/BasicSettings.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { InlineField, InlineSwitch, Input } from '@grafana/ui'; @@ -10,10 +10,11 @@ export interface Props { onDefaultChange: (value: boolean) => void; } -const BasicSettings: FC = ({ dataSourceName, isDefault, onDefaultChange, onNameChange }) => { +export function BasicSettings({ dataSourceName, isDefault, onDefaultChange, onNameChange }: Props) { return (
+ {/* Name */}
= ({ dataSourceName, isDefault, onDefaultChange,
+ {/* Is Default */} = ({ dataSourceName, isDefault, onDefaultChange,
); -}; - -export default BasicSettings; +} diff --git a/public/app/features/datasources/settings/ButtonRow.test.tsx b/public/app/features/datasources/components/ButtonRow.test.tsx similarity index 80% rename from public/app/features/datasources/settings/ButtonRow.test.tsx rename to public/app/features/datasources/components/ButtonRow.test.tsx index 0fdc4163fb9..dc2208f5092 100644 --- a/public/app/features/datasources/settings/ButtonRow.test.tsx +++ b/public/app/features/datasources/components/ButtonRow.test.tsx @@ -3,15 +3,7 @@ import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; -import ButtonRow, { Props } from './ButtonRow'; - -jest.mock('app/core/core', () => { - return { - contextSrv: { - hasPermission: () => true, - }, - }; -}); +import { ButtonRow, Props } from './ButtonRow'; const setup = (propOverrides?: object) => { const props: Props = { @@ -28,7 +20,7 @@ const setup = (propOverrides?: object) => { return render(); }; -describe('Button Row', () => { +describe('', () => { it('should render component', () => { setup(); diff --git a/public/app/features/datasources/settings/ButtonRow.tsx b/public/app/features/datasources/components/ButtonRow.tsx similarity index 82% rename from public/app/features/datasources/settings/ButtonRow.tsx rename to public/app/features/datasources/components/ButtonRow.tsx index 473043ac451..b0082ce1e5b 100644 --- a/public/app/features/datasources/settings/ButtonRow.tsx +++ b/public/app/features/datasources/components/ButtonRow.tsx @@ -1,9 +1,9 @@ -import React, { FC } from 'react'; +import React from 'react'; import { selectors } from '@grafana/e2e-selectors'; import { Button, LinkButton } from '@grafana/ui'; import { contextSrv } from 'app/core/core'; -import { AccessControlAction } from 'app/types/'; +import { AccessControlAction } from 'app/types'; export interface Props { exploreUrl: string; @@ -14,7 +14,7 @@ export interface Props { onTest: (event: any) => void; } -const ButtonRow: FC = ({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }) => { +export function ButtonRow({ canSave, canDelete, onDelete, onSubmit, onTest, exploreUrl }: Props) { const canExploreDataSources = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); return ( @@ -46,12 +46,10 @@ const ButtonRow: FC = ({ canSave, canDelete, onDelete, onSubmit, onTest, )} {!canSave && ( - )}
); -}; - -export default ButtonRow; +} diff --git a/public/app/features/datasources/settings/CloudInfoBox.tsx b/public/app/features/datasources/components/CloudInfoBox.tsx similarity index 95% rename from public/app/features/datasources/settings/CloudInfoBox.tsx rename to public/app/features/datasources/components/CloudInfoBox.tsx index ce087cd6148..5873fd2def2 100644 --- a/public/app/features/datasources/settings/CloudInfoBox.tsx +++ b/public/app/features/datasources/components/CloudInfoBox.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { DataSourceSettings } from '@grafana/data'; import { GrafanaEdition } from '@grafana/data/src/types/config'; @@ -12,7 +12,7 @@ export interface Props { dataSource: DataSourceSettings; } -export const CloudInfoBox: FC = ({ dataSource }) => { +export function CloudInfoBox({ dataSource }: Props) { let mainDS = ''; let extraDS = ''; @@ -71,4 +71,4 @@ export const CloudInfoBox: FC = ({ dataSource }) => { }} ); -}; +} diff --git a/public/app/features/datasources/DashboardsTable.test.tsx b/public/app/features/datasources/components/DashboardsTable.test.tsx similarity index 98% rename from public/app/features/datasources/DashboardsTable.test.tsx rename to public/app/features/datasources/components/DashboardsTable.test.tsx index 63ff4bdce52..8b463ad4343 100644 --- a/public/app/features/datasources/DashboardsTable.test.tsx +++ b/public/app/features/datasources/components/DashboardsTable.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { PluginDashboard } from '../../types'; +import { PluginDashboard } from 'app/types'; import DashboardsTable, { Props } from './DashboardsTable'; diff --git a/public/app/features/datasources/DashboardsTable.tsx b/public/app/features/datasources/components/DashboardsTable.tsx similarity index 84% rename from public/app/features/datasources/DashboardsTable.tsx rename to public/app/features/datasources/components/DashboardsTable.tsx index fb9f010a87c..49eebab0a3a 100644 --- a/public/app/features/datasources/DashboardsTable.tsx +++ b/public/app/features/datasources/components/DashboardsTable.tsx @@ -1,16 +1,18 @@ -import React, { FC } from 'react'; +import React from 'react'; import { Button, Icon } from '@grafana/ui'; - -import { PluginDashboard } from '../../types'; +import { PluginDashboard } from 'app/types'; export interface Props { + // List of plugin dashboards to show in the table dashboards: PluginDashboard[]; + // Callback used when the user clicks on importing a dashboard onImport: (dashboard: PluginDashboard, overwrite: boolean) => void; + // Callback used when the user clicks on removing a dashboard onRemove: (dashboard: PluginDashboard) => void; } -const DashboardsTable: FC = ({ dashboards, onImport, onRemove }) => { +export function DashboardsTable({ dashboards, onImport, onRemove }: Props) { function buttonText(dashboard: PluginDashboard) { return dashboard.revision !== dashboard.importedRevision ? 'Update' : 'Re-import'; } @@ -57,6 +59,6 @@ const DashboardsTable: FC = ({ dashboards, onImport, onRemove }) => { ); -}; +} export default DashboardsTable; diff --git a/public/app/features/datasources/components/DataSourceCategories.tsx b/public/app/features/datasources/components/DataSourceCategories.tsx new file mode 100644 index 00000000000..fb15338e207 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceCategories.tsx @@ -0,0 +1,43 @@ +import React from 'react'; + +import { DataSourcePluginMeta } from '@grafana/data'; +import { LinkButton } from '@grafana/ui'; +import { DataSourcePluginCategory } from 'app/types'; + +import { DataSourceTypeCardList } from './DataSourceTypeCardList'; + +export type Props = { + // The list of data-source plugin categories to display + categories: DataSourcePluginCategory[]; + + // Called when a data-source plugin is clicked on in the list + onClickDataSourceType: (dataSource: DataSourcePluginMeta) => void; +}; + +export function DataSourceCategories({ categories, onClickDataSourceType }: Props) { + return ( + <> + {/* Categories */} + {categories.map(({ id, title, plugins }) => ( +
+
+ {title} +
+ +
+ ))} + + {/* Find more */} +
+ + Find more data source plugins on grafana.com + +
+ + ); +} diff --git a/public/app/features/datasources/components/DataSourceDashboards.test.tsx b/public/app/features/datasources/components/DataSourceDashboards.test.tsx new file mode 100644 index 00000000000..38c94d0dba2 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceDashboards.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { configureStore } from 'app/store/configureStore'; + +import { getMockDashboard } from '../__mocks__'; + +import { DataSourceDashboardsView, ViewProps } from './DataSourceDashboards'; + +const setup = ({ + dashboards = [], + isLoading = false, + onImportDashboard = jest.fn(), + onRemoveDashboard = jest.fn(), +}: Partial) => { + const store = configureStore(); + + return render( + + + + ); +}; + +describe('', () => { + it('should show a loading indicator while loading', () => { + setup({ isLoading: true }); + + expect(screen.queryByText(/loading/i)).toBeVisible(); + }); + + it('should not show a loading indicator when loaded', () => { + setup({ isLoading: false }); + + expect(screen.queryByText(/loading/i)).not.toBeInTheDocument(); + }); + + it('should show a list of dashboards once loaded', () => { + setup({ + dashboards: [getMockDashboard({ title: 'My Dashboard 1' }), getMockDashboard({ title: 'My Dashboard 2' })], + }); + + expect(screen.queryByText('My Dashboard 1')).toBeVisible(); + expect(screen.queryByText('My Dashboard 2')).toBeVisible(); + }); +}); diff --git a/public/app/features/datasources/components/DataSourceDashboards.tsx b/public/app/features/datasources/components/DataSourceDashboards.tsx new file mode 100644 index 00000000000..f419b1684bd --- /dev/null +++ b/public/app/features/datasources/components/DataSourceDashboards.tsx @@ -0,0 +1,85 @@ +import React, { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { importDashboard, removeDashboard } from 'app/features/dashboard/state/actions'; +import { loadPluginDashboards } from 'app/features/plugins/admin/state/actions'; +import { PluginDashboard, StoreState } from 'app/types'; + +import DashboardTable from '../components/DashboardsTable'; +import { useLoadDataSource } from '../state'; + +export type Props = { + // The UID of the data source + uid: string; +}; + +export function DataSourceDashboards({ uid }: Props) { + useLoadDataSource(uid); + + const dispatch = useDispatch(); + const dataSource = useSelector((s: StoreState) => s.dataSources.dataSource); + const dashboards = useSelector((s: StoreState) => s.plugins.dashboards); + const isLoading = useSelector((s: StoreState) => s.plugins.isLoadingPluginDashboards); + + useEffect(() => { + // Load plugin dashboards only when the datasource has loaded + if (dataSource.id > 0) { + dispatch(loadPluginDashboards()); + } + }, [dispatch, dataSource]); + + const onImportDashboard = (dashboard: PluginDashboard, overwrite: boolean) => { + dispatch( + importDashboard( + { + pluginId: dashboard.pluginId, + path: dashboard.path, + overwrite, + inputs: [ + { + name: '*', + type: 'datasource', + pluginId: dataSource.type, + value: dataSource.name, + }, + ], + }, + dashboard.title + ) + ); + }; + + const onRemoveDashboard = ({ uid }: PluginDashboard) => { + dispatch(removeDashboard(uid)); + }; + + return ( + + ); +} + +export type ViewProps = { + isLoading: boolean; + dashboards: PluginDashboard[]; + onImportDashboard: (dashboard: PluginDashboard, overwrite: boolean) => void; + onRemoveDashboard: (dashboard: PluginDashboard) => void; +}; + +export const DataSourceDashboardsView = ({ + isLoading, + dashboards, + onImportDashboard, + onRemoveDashboard, +}: ViewProps) => { + if (isLoading) { + return ; + } + + return ; +}; diff --git a/public/app/features/datasources/components/DataSourceLoadError.tsx b/public/app/features/datasources/components/DataSourceLoadError.tsx new file mode 100644 index 00000000000..de27a9a5436 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceLoadError.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +import { Button } from '@grafana/ui'; + +import { DataSourceRights } from '../types'; + +import { DataSourceReadOnlyMessage } from './DataSourceReadOnlyMessage'; + +export type Props = { + dataSourceRights: DataSourceRights; + onDelete: () => void; +}; + +export function DataSourceLoadError({ dataSourceRights, onDelete }: Props) { + const { readOnly, hasDeleteRights } = dataSourceRights; + const canDelete = !readOnly && hasDeleteRights; + const navigateBack = () => history.back(); + + return ( + <> + {readOnly && } + +
+ {canDelete && ( + + )} + + +
+ + ); +} diff --git a/public/app/features/datasources/components/DataSourceMissingRightsMessage.tsx b/public/app/features/datasources/components/DataSourceMissingRightsMessage.tsx new file mode 100644 index 00000000000..420ddf6bd4e --- /dev/null +++ b/public/app/features/datasources/components/DataSourceMissingRightsMessage.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { Alert } from '@grafana/ui'; + +export const missingRightsMessage = + 'You are not allowed to modify this data source. Please contact your server admin to update this data source.'; + +export function DataSourceMissingRightsMessage() { + return ( + + {missingRightsMessage} + + ); +} diff --git a/public/app/features/datasources/components/DataSourcePluginConfigPage.tsx b/public/app/features/datasources/components/DataSourcePluginConfigPage.tsx new file mode 100644 index 00000000000..d55c9f0a623 --- /dev/null +++ b/public/app/features/datasources/components/DataSourcePluginConfigPage.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { GenericDataSourcePlugin } from '../types'; + +export type Props = { + plugin?: GenericDataSourcePlugin | null; + pageId: string; +}; + +export function DataSourcePluginConfigPage({ plugin, pageId }: Props) { + if (!plugin || !plugin.configPages) { + return null; + } + + const page = plugin.configPages.find(({ id }) => id === pageId); + + if (page) { + // TODO: Investigate if any plugins are using this? We should change this interface + return ; + } + + return
Page not found: {page}
; +} diff --git a/public/app/features/datasources/settings/PluginSettings.tsx b/public/app/features/datasources/components/DataSourcePluginSettings.tsx similarity index 86% rename from public/app/features/datasources/settings/PluginSettings.tsx rename to public/app/features/datasources/components/DataSourcePluginSettings.tsx index ca322ff2cc5..5e5fa1c57a4 100644 --- a/public/app/features/datasources/settings/PluginSettings.tsx +++ b/public/app/features/datasources/components/DataSourcePluginSettings.tsx @@ -1,17 +1,10 @@ import { cloneDeep } from 'lodash'; import React, { PureComponent } from 'react'; -import { - DataQuery, - DataSourceApi, - DataSourceJsonData, - DataSourcePlugin, - DataSourcePluginMeta, - DataSourceSettings, -} from '@grafana/data'; +import { DataSourcePluginMeta, DataSourceSettings } from '@grafana/data'; import { AngularComponent, getAngularLoader } from '@grafana/runtime'; -export type GenericDataSourcePlugin = DataSourcePlugin>; +import { GenericDataSourcePlugin } from '../types'; export interface Props { plugin: GenericDataSourcePlugin; @@ -20,7 +13,7 @@ export interface Props { onModelChange: (dataSource: DataSourceSettings) => void; } -export class PluginSettings extends PureComponent { +export class DataSourcePluginSettings extends PureComponent { element: HTMLDivElement | null = null; component?: AngularComponent; scopeProps: { @@ -92,5 +85,3 @@ export class PluginSettings extends PureComponent { ); } } - -export default PluginSettings; diff --git a/public/app/features/datasources/components/DataSourcePluginState.tsx b/public/app/features/datasources/components/DataSourcePluginState.tsx new file mode 100644 index 00000000000..b6698e9a41b --- /dev/null +++ b/public/app/features/datasources/components/DataSourcePluginState.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +import { PluginState } from '@grafana/data'; +import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; + +export type Props = { + state?: PluginState; +}; + +export function DataSourcePluginState({ state }: Props) { + return ( +
+ + +
+ ); +} diff --git a/public/app/features/datasources/components/DataSourceReadOnlyMessage.tsx b/public/app/features/datasources/components/DataSourceReadOnlyMessage.tsx new file mode 100644 index 00000000000..6fff1faf496 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceReadOnlyMessage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Alert } from '@grafana/ui'; + +export const readOnlyMessage = + 'This data source was added by config and cannot be modified using the UI. Please contact your server admin to update this data source.'; + +export function DataSourceReadOnlyMessage() { + return ( + + {readOnlyMessage} + + ); +} diff --git a/public/app/features/datasources/components/DataSourceTestingStatus.tsx b/public/app/features/datasources/components/DataSourceTestingStatus.tsx new file mode 100644 index 00000000000..99987f9ec74 --- /dev/null +++ b/public/app/features/datasources/components/DataSourceTestingStatus.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Alert } from '@grafana/ui'; +import { TestingStatus } from 'app/types'; + +export type Props = { + testingStatus?: TestingStatus; +}; + +export function DataSourceTestingStatus({ testingStatus }: Props) { + const isError = testingStatus?.status === 'error'; + const message = testingStatus?.message; + const detailsMessage = testingStatus?.details?.message; + const detailsVerboseMessage = testingStatus?.details?.verboseMessage; + + if (message) { + return ( +
+ + {testingStatus?.details && ( + <> + {detailsMessage} + {detailsVerboseMessage ? ( +
{detailsVerboseMessage}
+ ) : null} + + )} +
+
+ ); + } + + return null; +} diff --git a/public/app/features/datasources/components/DataSourceTypeCard.tsx b/public/app/features/datasources/components/DataSourceTypeCard.tsx new file mode 100644 index 00000000000..e313e14787b --- /dev/null +++ b/public/app/features/datasources/components/DataSourceTypeCard.tsx @@ -0,0 +1,109 @@ +import { css, cx } from '@emotion/css'; +import React from 'react'; + +import { DataSourcePluginMeta, GrafanaTheme2 } from '@grafana/data'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; +import { Card, LinkButton, PluginSignatureBadge, useStyles2 } from '@grafana/ui'; + +export type Props = { + dataSourcePlugin: DataSourcePluginMeta; + onClick: () => void; +}; + +export function DataSourceTypeCard({ onClick, dataSourcePlugin }: Props) { + const isPhantom = dataSourcePlugin.module === 'phantom'; + const isClickable = !isPhantom && !dataSourcePlugin.unlicensed; + const learnMoreLink = dataSourcePlugin.info?.links?.length > 0 ? dataSourcePlugin.info.links[0] : null; + + const styles = useStyles2(getStyles); + + return ( + {}}> + {/* Name */} + + {dataSourcePlugin.name} + + + {/* Logo */} + + + + + {dataSourcePlugin.info.description} + + {/* Signature */} + {!isPhantom && ( + + + + )} + + {/* Learn more */} + + {learnMoreLink && ( + e.stopPropagation()} + rel="noopener" + target="_blank" + variant="secondary" + > + {learnMoreLink.name} + + )} + + + ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + heading: css({ + fontSize: theme.v1.typography.heading.h5, + fontWeight: 'inherit', + }), + figure: css({ + width: 'inherit', + marginRight: '0px', + '> img': { + width: theme.spacing(7), + }, + }), + meta: css({ + marginTop: '6px', + position: 'relative', + }), + description: css({ + margin: '0px', + fontSize: theme.typography.size.sm, + }), + actions: css({ + position: 'relative', + alignSelf: 'center', + marginTop: '0px', + opacity: 0, + + '.card-parent:hover &, .card-parent:focus-within &': { + opacity: 1, + }, + }), + card: css({ + gridTemplateAreas: ` + "Figure Heading Actions" + "Figure Description Actions" + "Figure Meta Actions" + "Figure - Actions"`, + }), + logo: css({ + marginRight: theme.v1.spacing.lg, + marginLeft: theme.v1.spacing.sm, + width: theme.spacing(7), + maxHeight: theme.spacing(7), + }), + }; +} diff --git a/public/app/features/datasources/components/DataSourceTypeCardList.tsx b/public/app/features/datasources/components/DataSourceTypeCardList.tsx new file mode 100644 index 00000000000..1749688f5eb --- /dev/null +++ b/public/app/features/datasources/components/DataSourceTypeCardList.tsx @@ -0,0 +1,33 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { DataSourcePluginMeta } from '@grafana/data'; +import { List } from '@grafana/ui'; + +import { DataSourceTypeCard } from './DataSourceTypeCard'; + +export type Props = { + // The list of data-source plugins to display + dataSourcePlugins: DataSourcePluginMeta[]; + // Called when a data-source plugin is clicked on in the list + onClickDataSourceType: (dataSource: DataSourcePluginMeta) => void; +}; + +export function DataSourceTypeCardList({ dataSourcePlugins, onClickDataSourceType }: Props) { + if (!dataSourcePlugins || !dataSourcePlugins.length) { + return null; + } + + return ( + item.id.toString()} + renderItem={(item) => onClickDataSourceType(item)} />} + className={css` + > li { + margin-bottom: 2px; + } + `} + /> + ); +} diff --git a/public/app/features/datasources/DataSourcesList.test.tsx b/public/app/features/datasources/components/DataSourcesList.test.tsx similarity index 53% rename from public/app/features/datasources/DataSourcesList.test.tsx rename to public/app/features/datasources/components/DataSourcesList.test.tsx index f09b772431f..45991e8bc1c 100644 --- a/public/app/features/datasources/DataSourcesList.test.tsx +++ b/public/app/features/datasources/components/DataSourcesList.test.tsx @@ -2,40 +2,38 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { Provider } from 'react-redux'; -import { LayoutModes } from '@grafana/data'; import { configureStore } from 'app/store/configureStore'; -import { DataSourcesState } from 'app/types'; -import DataSourcesList from './DataSourcesList'; -import { getMockDataSources } from './__mocks__/dataSourcesMocks'; -import { initialState } from './state/reducers'; +import { getMockDataSources } from '../__mocks__'; -const setup = (stateOverride?: Partial) => { - const store = configureStore({ - dataSources: { - ...initialState, - dataSources: getMockDataSources(3), - layoutMode: LayoutModes.Grid, - ...stateOverride, - }, - }); +import { DataSourcesListView } from './DataSourcesList'; + +const setup = () => { + const store = configureStore(); return render( - + ); }; -describe('DataSourcesList', () => { +describe('', () => { it('should render list of datasources', () => { setup(); + expect(screen.getAllByRole('listitem')).toHaveLength(3); expect(screen.getAllByRole('heading')).toHaveLength(3); }); it('should render all elements in the list item', () => { setup(); + expect(screen.getByRole('heading', { name: 'dataSource-0' })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'dataSource-0' })).toBeInTheDocument(); }); diff --git a/public/app/features/datasources/components/DataSourcesList.tsx b/public/app/features/datasources/components/DataSourcesList.tsx new file mode 100644 index 00000000000..4a00393656d --- /dev/null +++ b/public/app/features/datasources/components/DataSourcesList.tsx @@ -0,0 +1,106 @@ +import { css } from '@emotion/css'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +import { DataSourceSettings } from '@grafana/data'; +import { Card, Tag, useStyles } from '@grafana/ui'; +import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { contextSrv } from 'app/core/core'; +import { StoreState, AccessControlAction } from 'app/types'; + +import { getDataSources, getDataSourcesCount, useLoadDataSources } from '../state'; + +import { DataSourcesListHeader } from './DataSourcesListHeader'; + +export function DataSourcesList() { + useLoadDataSources(); + + const dataSources = useSelector((state: StoreState) => getDataSources(state.dataSources)); + const dataSourcesCount = useSelector(({ dataSources }: StoreState) => getDataSourcesCount(dataSources)); + const hasFetched = useSelector(({ dataSources }: StoreState) => dataSources.hasFetched); + const hasCreateRights = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); + + return ( + + ); +} + +export type ViewProps = { + dataSources: DataSourceSettings[]; + dataSourcesCount: number; + isLoading: boolean; + hasCreateRights: boolean; +}; + +export function DataSourcesListView({ dataSources, dataSourcesCount, isLoading, hasCreateRights }: ViewProps) { + const styles = useStyles(getStyles); + + if (isLoading) { + return ; + } + + if (dataSourcesCount === 0) { + return ( + + ); + } + + return ( + <> + {/* List Header */} + + + {/* List */} +
    + {dataSources.map((dataSource) => { + return ( +
  • + + {dataSource.name} + + + + + {[ + dataSource.typeName, + dataSource.url, + dataSource.isDefault && , + ]} + + +
  • + ); + })} +
+ + ); +} + +const getStyles = () => { + return { + list: css({ + listStyle: 'none', + display: 'grid', + // gap: '8px', Add back when legacy support for old Card interface is dropped + }), + logo: css({ + objectFit: 'contain', + }), + }; +}; diff --git a/public/app/features/datasources/DataSourcesListHeader.tsx b/public/app/features/datasources/components/DataSourcesListHeader.tsx similarity index 61% rename from public/app/features/datasources/DataSourcesListHeader.tsx rename to public/app/features/datasources/components/DataSourcesListHeader.tsx index 612bb3fdfb3..d006f521cf5 100644 --- a/public/app/features/datasources/DataSourcesListHeader.tsx +++ b/public/app/features/datasources/components/DataSourcesListHeader.tsx @@ -1,19 +1,35 @@ import React, { useCallback } from 'react'; import { useSelector, useDispatch } from 'react-redux'; +import { AnyAction } from 'redux'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; import { contextSrv } from 'app/core/core'; import { AccessControlAction, StoreState } from 'app/types'; -import { setDataSourcesSearchQuery } from './state/reducers'; -import { getDataSourcesSearchQuery } from './state/selectors'; +import { getDataSourcesSearchQuery, setDataSourcesSearchQuery } from '../state'; -export const DataSourcesListHeader = () => { +export function DataSourcesListHeader() { const dispatch = useDispatch(); const setSearchQuery = useCallback((q: string) => dispatch(setDataSourcesSearchQuery(q)), [dispatch]); const searchQuery = useSelector(({ dataSources }: StoreState) => getDataSourcesSearchQuery(dataSources)); const canCreateDataSource = contextSrv.hasPermission(AccessControlAction.DataSourcesCreate); + return ( + + ); +} + +export type ViewProps = { + searchQuery: string; + setSearchQuery: (q: string) => AnyAction; + canCreateDataSource: boolean; +}; + +export function DataSourcesListHeaderView({ searchQuery, setSearchQuery, canCreateDataSource }: ViewProps) { const linkButton = { href: 'datasources/new', title: 'Add data source', @@ -23,4 +39,4 @@ export const DataSourcesListHeader = () => { return ( ); -}; +} diff --git a/public/app/features/datasources/components/EditDataSource.test.tsx b/public/app/features/datasources/components/EditDataSource.test.tsx new file mode 100644 index 00000000000..382952e6ce6 --- /dev/null +++ b/public/app/features/datasources/components/EditDataSource.test.tsx @@ -0,0 +1,251 @@ +import { screen, render } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; + +import { PluginState } from '@grafana/data'; +import { setAngularLoader } from '@grafana/runtime'; +import { configureStore } from 'app/store/configureStore'; + +import { getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__'; + +import { missingRightsMessage } from './DataSourceMissingRightsMessage'; +import { readOnlyMessage } from './DataSourceReadOnlyMessage'; +import { EditDataSourceView, ViewProps } from './EditDataSource'; + +const setup = (props?: Partial) => { + const store = configureStore(); + + return render( + + + + ); +}; + +describe('', () => { + beforeAll(() => { + setAngularLoader({ + load: () => ({ + destroy: jest.fn(), + digest: jest.fn(), + getScope: () => ({ $watch: () => {} }), + }), + }); + }); + + describe('On loading errors', () => { + it('should render a Back button', () => { + setup({ + dataSource: getMockDataSource({ name: 'My Datasource' }), + dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }), + }); + + expect(screen.queryByText('Loading ...')).not.toBeInTheDocument(); + expect(screen.queryByText('My Datasource')).not.toBeInTheDocument(); + expect(screen.queryByText('Back')).toBeVisible(); + }); + + it('should render a Delete button if the user has rights delete the datasource', () => { + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }), + dataSourceRights: { + readOnly: false, + hasDeleteRights: true, + hasWriteRights: true, + }, + }); + + expect(screen.queryByText('Delete')).toBeVisible(); + }); + + it('should not render a Delete button if the user has no rights to delete the datasource', () => { + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }), + dataSourceRights: { + readOnly: false, + hasDeleteRights: false, + hasWriteRights: true, + }, + }); + + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + + it('should render a message if the datasource is read-only', () => { + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ loadError: 'Some weird error.' }), + dataSourceRights: { + readOnly: true, + hasDeleteRights: false, + hasWriteRights: true, + }, + }); + + expect(screen.queryByText(readOnlyMessage)).toBeVisible(); + }); + }); + + describe('On loading', () => { + it('should render a loading indicator while the data is being fetched', () => { + setup({ + dataSource: getMockDataSource({ name: 'My Datasource' }), + dataSourceSettings: getMockDataSourceSettingsState({ loading: true }), + }); + + expect(screen.queryByText('Loading ...')).toBeVisible(); + expect(screen.queryByText('My Datasource')).not.toBeInTheDocument(); + }); + + it('should not render loading when data is already available', () => { + setup(); + + expect(screen.queryByText('Loading ...')).not.toBeInTheDocument(); + }); + }); + + describe('On editing', () => { + it('should render no messages if the user has write access and if the data-source is not read-only', () => { + setup({ + dataSourceRights: { + readOnly: false, + hasDeleteRights: true, + hasWriteRights: true, + }, + }); + + expect(screen.queryByText(readOnlyMessage)).not.toBeInTheDocument(); + expect(screen.queryByText(missingRightsMessage)).not.toBeInTheDocument(); + }); + + it('should render a message if the user has no write access', () => { + setup({ + dataSourceRights: { + readOnly: false, + hasDeleteRights: false, + hasWriteRights: false, + }, + }); + + expect(screen.queryByText(missingRightsMessage)).toBeVisible(); + }); + + it('should render a message if the data-source is read-only', () => { + setup({ + dataSourceRights: { + readOnly: true, + hasDeleteRights: false, + hasWriteRights: false, + }, + }); + + expect(screen.queryByText(readOnlyMessage)).toBeVisible(); + }); + + it('should render a beta info message if the plugin is still in Beta state', () => { + setup({ + dataSourceMeta: getMockDataSourceMeta({ + state: PluginState.beta, + }), + }); + + expect(screen.getByTitle('This feature is close to complete but not fully tested')).toBeVisible(); + }); + + it('should render an alpha info message if the plugin is still in Alpha state', () => { + setup({ + dataSourceMeta: getMockDataSourceMeta({ + state: PluginState.alpha, + }), + }); + + expect( + screen.getByTitle('This feature is experimental and future updates might not be backward compatible') + ).toBeVisible(); + }); + + it('should render testing errors with a detailed error message', () => { + const message = 'message'; + const detailsMessage = 'detailed message'; + + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ + testingStatus: { + message, + status: 'error', + details: { message: detailsMessage }, + }, + }), + }); + + expect(screen.getByText(message)).toBeVisible(); + expect(screen.getByText(detailsMessage)).toBeVisible(); + }); + + it('should render testing errors with empty details', () => { + const message = 'message'; + + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ + testingStatus: { + message, + status: 'error', + details: {}, + }, + }), + }); + + expect(screen.getByText(message)).toBeVisible(); + }); + + it('should render testing errors with no details', () => { + const message = 'message'; + + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ + testingStatus: { + message, + status: 'error', + }, + }), + }); + + expect(screen.getByText(message)).toBeVisible(); + }); + + it('should use the verboseMessage property in the error details whenever it is available', () => { + const message = 'message'; + const detailsMessage = 'detailed message'; + const detailsVerboseMessage = 'even more detailed...'; + + setup({ + dataSourceSettings: getMockDataSourceSettingsState({ + testingStatus: { + message, + status: 'error', + details: { + details: detailsMessage, + verboseMessage: detailsVerboseMessage, + }, + }, + }), + }); + + expect(screen.queryByText(message)).toBeVisible(); + expect(screen.queryByText(detailsMessage)).not.toBeInTheDocument(); + expect(screen.queryByText(detailsVerboseMessage)).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/datasources/components/EditDataSource.tsx b/public/app/features/datasources/components/EditDataSource.tsx new file mode 100644 index 00000000000..b7e2dde1e7c --- /dev/null +++ b/public/app/features/datasources/components/EditDataSource.tsx @@ -0,0 +1,171 @@ +import { AnyAction } from '@reduxjs/toolkit'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +import { DataSourcePluginMeta, DataSourceSettings as DataSourceSettingsType } from '@grafana/data'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { DataSourceSettingsState, ThunkResult } from 'app/types'; + +import { + dataSourceLoaded, + setDataSourceName, + setIsDefault, + useDataSource, + useDataSourceExploreUrl, + useDataSourceMeta, + useDataSourceRights, + useDataSourceSettings, + useDeleteLoadedDataSource, + useInitDataSourceSettings, + useTestDataSource, + useUpdateDatasource, +} from '../state'; +import { DataSourceRights } from '../types'; + +import { BasicSettings } from './BasicSettings'; +import { ButtonRow } from './ButtonRow'; +import { CloudInfoBox } from './CloudInfoBox'; +import { DataSourceLoadError } from './DataSourceLoadError'; +import { DataSourceMissingRightsMessage } from './DataSourceMissingRightsMessage'; +import { DataSourcePluginConfigPage } from './DataSourcePluginConfigPage'; +import { DataSourcePluginSettings } from './DataSourcePluginSettings'; +import { DataSourcePluginState } from './DataSourcePluginState'; +import { DataSourceReadOnlyMessage } from './DataSourceReadOnlyMessage'; +import { DataSourceTestingStatus } from './DataSourceTestingStatus'; + +export type Props = { + // The ID of the data source + uid: string; + // The ID of the custom datasource setting page + pageId?: string | null; +}; + +export function EditDataSource({ uid, pageId }: Props) { + useInitDataSourceSettings(uid); + + const dispatch = useDispatch(); + const dataSource = useDataSource(uid); + const dataSourceMeta = useDataSourceMeta(uid); + const dataSourceSettings = useDataSourceSettings(); + const dataSourceRights = useDataSourceRights(uid); + const exploreUrl = useDataSourceExploreUrl(uid); + const onDelete = useDeleteLoadedDataSource(); + const onTest = useTestDataSource(uid); + const onUpdate = useUpdateDatasource(); + const onDefaultChange = (value: boolean) => dispatch(setIsDefault(value)); + const onNameChange = (name: string) => dispatch(setDataSourceName(name)); + const onOptionsChange = (ds: DataSourceSettingsType) => dispatch(dataSourceLoaded(ds)); + + return ( + + ); +} + +export type ViewProps = { + pageId?: string | null; + dataSource: DataSourceSettingsType; + dataSourceMeta: DataSourcePluginMeta; + dataSourceSettings: DataSourceSettingsState; + dataSourceRights: DataSourceRights; + exploreUrl: string; + onDelete: () => void; + onDefaultChange: (isDefault: boolean) => AnyAction; + onNameChange: (name: string) => AnyAction; + onOptionsChange: (dataSource: DataSourceSettingsType) => AnyAction; + onTest: () => ThunkResult; + onUpdate: (dataSource: DataSourceSettingsType) => ThunkResult; +}; + +export function EditDataSourceView({ + pageId, + dataSource, + dataSourceMeta, + dataSourceSettings, + dataSourceRights, + exploreUrl, + onDelete, + onDefaultChange, + onNameChange, + onOptionsChange, + onTest, + onUpdate, +}: ViewProps) { + const { plugin, loadError, testingStatus, loading } = dataSourceSettings; + const { readOnly, hasWriteRights, hasDeleteRights } = dataSourceRights; + const hasDataSource = dataSource.id > 0; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + await onUpdate({ ...dataSource }); + + onTest(); + }; + + if (loadError) { + return ; + } + + if (loading) { + return ; + } + + // TODO - is this needed? + if (!hasDataSource) { + return null; + } + + if (pageId) { + return ; + } + + return ( +
+ {!hasWriteRights && } + {readOnly && } + {dataSourceMeta.state && } + + + + + + {plugin && ( + + )} + + + + + + ); +} diff --git a/public/app/features/datasources/components/NewDataSource.tsx b/public/app/features/datasources/components/NewDataSource.tsx new file mode 100644 index 00000000000..658a02a4c68 --- /dev/null +++ b/public/app/features/datasources/components/NewDataSource.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { AnyAction } from 'redux'; + +import { DataSourcePluginMeta } from '@grafana/data'; +import { LinkButton, FilterInput } from '@grafana/ui'; +import PageLoader from 'app/core/components/PageLoader/PageLoader'; +import { PluginsErrorsInfo } from 'app/features/plugins/components/PluginsErrorsInfo'; +import { DataSourcePluginCategory, StoreState } from 'app/types'; + +import { DataSourceCategories } from '../components/DataSourceCategories'; +import { DataSourceTypeCardList } from '../components/DataSourceTypeCardList'; +import { + useAddDatasource, + useLoadDataSourcePlugins, + getFilteredDataSourcePlugins, + setDataSourceTypeSearchQuery, +} from '../state'; + +export function NewDataSource() { + useLoadDataSourcePlugins(); + + const dispatch = useDispatch(); + const filteredDataSources = useSelector((s: StoreState) => getFilteredDataSourcePlugins(s.dataSources)); + const searchQuery = useSelector((s: StoreState) => s.dataSources.dataSourceTypeSearchQuery); + const isLoading = useSelector((s: StoreState) => s.dataSources.isLoadingDataSources); + const dataSourceCategories = useSelector((s: StoreState) => s.dataSources.categories); + const onAddDataSource = useAddDatasource(); + const onSetSearchQuery = (q: string) => dispatch(setDataSourceTypeSearchQuery(q)); + + return ( + + ); +} + +export type ViewProps = { + dataSources: DataSourcePluginMeta[]; + dataSourceCategories: DataSourcePluginCategory[]; + searchQuery: string; + isLoading: boolean; + onAddDataSource: (dataSource: DataSourcePluginMeta) => void; + onSetSearchQuery: (q: string) => AnyAction; +}; + +export function NewDataSourceView({ + dataSources, + dataSourceCategories, + searchQuery, + isLoading, + onAddDataSource, + onSetSearchQuery, +}: ViewProps) { + if (isLoading) { + return ; + } + + return ( + <> + {/* Search */} +
+ +
+ + Cancel + +
+ + {/* Show any plugin errors while not searching for anything specific */} + {!searchQuery && } + + {/* Search results */} +
+ {searchQuery && ( + + )} + {!searchQuery && ( + + )} +
+ + ); +} diff --git a/public/app/features/datasources/mocks.ts b/public/app/features/datasources/mocks.ts deleted file mode 100644 index 07ad30f1d6c..00000000000 --- a/public/app/features/datasources/mocks.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { DataSourceSettings } from '@grafana/data'; - -export function createDatasourceSettings(jsonData: T): DataSourceSettings { - return { - id: 0, - uid: 'x', - orgId: 0, - name: 'datasource-test', - typeLogoUrl: '', - type: 'datasource', - typeName: 'Datasource', - access: 'server', - url: 'http://localhost', - user: '', - database: '', - basicAuth: false, - basicAuthUser: '', - isDefault: false, - jsonData, - readOnly: false, - withCredentials: false, - secureJsonFields: {}, - }; -} diff --git a/public/app/features/datasources/pages/DataSourceDashboardsPage.test.tsx b/public/app/features/datasources/pages/DataSourceDashboardsPage.test.tsx new file mode 100644 index 00000000000..861f8154da2 --- /dev/null +++ b/public/app/features/datasources/pages/DataSourceDashboardsPage.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import { setAngularLoader } from '@grafana/runtime'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { configureStore } from 'app/store/configureStore'; + +import { navIndex, getMockDataSource } from '../__mocks__'; +import * as api from '../api'; +import { initialState as dataSourcesInitialState } from '../state'; + +import DataSourceDashboardsPage from './DataSourceDashboardsPage'; + +jest.mock('../api'); +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + hasPermission: () => true, + hasPermissionInMetadata: () => true, + }, +})); + +const setup = (uid: string, store: Store) => + render( + + + + ); + +describe('', () => { + const uid = 'foo'; + const dataSourceName = 'My DataSource'; + const dataSource = getMockDataSource<{}>({ uid, name: dataSourceName }); + let store: Store; + + beforeAll(() => { + setAngularLoader({ + load: () => ({ + destroy: jest.fn(), + digest: jest.fn(), + getScope: () => ({ $watch: () => {} }), + }), + }); + }); + + beforeEach(() => { + // @ts-ignore + api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource); + + store = configureStore({ + dataSources: { + ...dataSourcesInitialState, + dataSource: dataSource, + }, + navIndex: { + ...navIndex, + [`datasource-dashboards-${uid}`]: { + id: `datasource-dashboards-${uid}`, + text: dataSourceName, + icon: 'list-ul', + url: `/datasources/edit/${uid}/dashboards`, + }, + }, + }); + }); + + it('should render the dashboards page without an issue', () => { + setup(uid, store); + + expect(screen.queryByText(dataSourceName)).toBeVisible(); + }); +}); diff --git a/public/app/features/datasources/pages/DataSourceDashboardsPage.tsx b/public/app/features/datasources/pages/DataSourceDashboardsPage.tsx new file mode 100644 index 00000000000..8913c74cf2c --- /dev/null +++ b/public/app/features/datasources/pages/DataSourceDashboardsPage.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +import { Page } from 'app/core/components/Page/Page'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { DataSourceDashboards } from '../components/DataSourceDashboards'; + +export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {} + +export function DataSourceDashboardsPage(props: Props) { + const uid = props.match.params.uid; + + return ( + + + + + + ); +} + +export default DataSourceDashboardsPage; diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/pages/DataSourcesListPage.test.tsx similarity index 84% rename from public/app/features/datasources/DataSourcesListPage.test.tsx rename to public/app/features/datasources/pages/DataSourcesListPage.test.tsx index fe992512b95..95eaaf78f39 100644 --- a/public/app/features/datasources/DataSourcesListPage.test.tsx +++ b/public/app/features/datasources/pages/DataSourcesListPage.test.tsx @@ -6,24 +6,14 @@ import { DataSourceSettings, LayoutModes } from '@grafana/data'; import { configureStore } from 'app/store/configureStore'; import { DataSourcesState } from 'app/types'; +import { navIndex, getMockDataSources } from '../__mocks__'; +import { initialState } from '../state'; + import { DataSourcesListPage } from './DataSourcesListPage'; -import { getMockDataSources } from './__mocks__/dataSourcesMocks'; -import navIndex from './__mocks__/store.navIndex.mock'; -import { initialState } from './state/reducers'; - -jest.mock('app/core/core', () => { - return { - contextSrv: { - hasPermission: () => true, - }, - }; -}); - -const getMock = jest.fn().mockResolvedValue([]); jest.mock('app/core/services/backend_srv', () => ({ ...jest.requireActual('app/core/services/backend_srv'), - getBackendSrv: () => ({ get: getMock }), + getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }), })); const setup = (stateOverride?: Partial) => { diff --git a/public/app/features/datasources/pages/DataSourcesListPage.tsx b/public/app/features/datasources/pages/DataSourcesListPage.tsx new file mode 100644 index 00000000000..2315b036367 --- /dev/null +++ b/public/app/features/datasources/pages/DataSourcesListPage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Page } from 'app/core/components/Page/Page'; + +import { DataSourcesList } from '../components/DataSourcesList'; + +export function DataSourcesListPage() { + return ( + + + + + + ); +} + +export default DataSourcesListPage; diff --git a/public/app/features/datasources/pages/EditDataSourcePage.test.tsx b/public/app/features/datasources/pages/EditDataSourcePage.test.tsx new file mode 100644 index 00000000000..a61533f116e --- /dev/null +++ b/public/app/features/datasources/pages/EditDataSourcePage.test.tsx @@ -0,0 +1,99 @@ +import { screen, render } from '@testing-library/react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; + +import { LayoutModes } from '@grafana/data'; +import { setAngularLoader } from '@grafana/runtime'; +import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; +import { configureStore } from 'app/store/configureStore'; + +import { navIndex, getMockDataSource, getMockDataSourceMeta, getMockDataSourceSettingsState } from '../__mocks__'; +import * as api from '../api'; +import { initialState } from '../state'; + +import { EditDataSourcePage } from './EditDataSourcePage'; + +jest.mock('../api'); +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + hasPermission: () => true, + hasPermissionInMetadata: () => true, + }, +})); + +const setup = (uid: string, store: Store) => + render( + + + + ); + +describe('', () => { + const uid = 'foo'; + const name = 'My DataSource'; + const dataSource = getMockDataSource<{}>({ uid, name }); + const dataSourceMeta = getMockDataSourceMeta(); + const dataSourceSettings = getMockDataSourceSettingsState(); + let store: Store; + + beforeAll(() => { + setAngularLoader({ + load: () => ({ + destroy: jest.fn(), + digest: jest.fn(), + getScope: () => ({ $watch: () => {} }), + }), + }); + }); + + beforeEach(() => { + // @ts-ignore + api.getDataSourceByIdOrUid = jest.fn().mockResolvedValue(dataSource); + + store = configureStore({ + dataSourceSettings, + dataSources: { + ...initialState, + dataSources: [dataSource], + dataSource: dataSource, + dataSourceMeta: dataSourceMeta, + layoutMode: LayoutModes.Grid, + hasFetched: true, + }, + navIndex: { + ...navIndex, + [`datasource-settings-${uid}`]: { + id: `datasource-settings-${uid}`, + text: name, + icon: 'list-ul', + url: `/datasources/edit/${uid}`, + }, + }, + }); + }); + + it('should render the edit page without an issue', () => { + setup(uid, store); + + expect(screen.queryByText('Loading ...')).not.toBeInTheDocument(); + + // Title + expect(screen.queryByText(name)).toBeVisible(); + + // Buttons + expect(screen.queryByRole('button', { name: /Back/i })).toBeVisible(); + expect(screen.queryByRole('button', { name: /Delete/i })).toBeVisible(); + expect(screen.queryByRole('button', { name: /Save (.*) test/i })).toBeVisible(); + expect(screen.queryByText('Explore')).toBeVisible(); + }); +}); diff --git a/public/app/features/datasources/pages/EditDataSourcePage.tsx b/public/app/features/datasources/pages/EditDataSourcePage.tsx new file mode 100644 index 00000000000..7d57c31abb0 --- /dev/null +++ b/public/app/features/datasources/pages/EditDataSourcePage.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +import { Page } from 'app/core/components/Page/Page'; +import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; + +import { EditDataSource } from '../components/EditDataSource'; +import { useDataSourceSettingsNav } from '../state'; + +export interface Props extends GrafanaRouteComponentProps<{ uid: string }> {} + +export function EditDataSourcePage(props: Props) { + const uid = props.match.params.uid; + const params = new URLSearchParams(props.location.search); + const pageId = params.get('page'); + const nav = useDataSourceSettingsNav(uid, pageId); + + return ( + + + + + + ); +} + +export default EditDataSourcePage; diff --git a/public/app/features/datasources/pages/NewDataSourcePage.tsx b/public/app/features/datasources/pages/NewDataSourcePage.tsx new file mode 100644 index 00000000000..f362cd5c8b6 --- /dev/null +++ b/public/app/features/datasources/pages/NewDataSourcePage.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +import { NavModel } from '@grafana/data'; +import { Page } from 'app/core/components/Page/Page'; + +import { NewDataSource } from '../components/NewDataSource'; + +const navModel = getNavModel(); + +export function NewDataSourcePage() { + return ( + + + + + + ); +} + +export function getNavModel(): NavModel { + const main = { + icon: 'database', + id: 'datasource-new', + text: 'Add data source', + href: 'datasources/new', + subTitle: 'Choose a data source type', + }; + + return { + main: main, + node: main, + }; +} + +export default NewDataSourcePage; diff --git a/public/app/features/datasources/utils/passwordHandlers.test.ts b/public/app/features/datasources/passwordHandlers.test.ts similarity index 100% rename from public/app/features/datasources/utils/passwordHandlers.test.ts rename to public/app/features/datasources/passwordHandlers.test.ts diff --git a/public/app/features/datasources/utils/passwordHandlers.ts b/public/app/features/datasources/passwordHandlers.ts similarity index 100% rename from public/app/features/datasources/utils/passwordHandlers.ts rename to public/app/features/datasources/passwordHandlers.ts diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx deleted file mode 100644 index 57e0d50c8a6..00000000000 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { screen, render } from '@testing-library/react'; -import React from 'react'; - -import { PluginState } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { cleanUpAction } from 'app/core/actions/cleanUp'; -import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps'; - -import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; -import { getMockDataSource } from '../__mocks__/dataSourcesMocks'; -import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; - -import { DataSourceSettingsPage, Props } from './DataSourceSettingsPage'; - -jest.mock('app/core/core', () => { - return { - contextSrv: { - hasPermission: () => true, - hasPermissionInMetadata: () => true, - }, - }; -}); - -const getMockNode = () => ({ - text: 'text', - subTitle: 'subtitle', - icon: 'icon', -}); - -const getProps = (): Props => ({ - ...getRouteComponentProps(), - navModel: { - node: getMockNode(), - main: getMockNode(), - }, - dataSource: getMockDataSource(), - dataSourceMeta: getMockPlugin(), - dataSourceId: 'x', - deleteDataSource: jest.fn(), - loadDataSource: jest.fn(), - setDataSourceName, - updateDataSource: jest.fn(), - initDataSourceSettings: jest.fn(), - testDataSource: jest.fn(), - setIsDefault, - dataSourceLoaded, - cleanUpAction, - page: null, - plugin: null, - loadError: null, - loading: false, - testingStatus: {}, -}); - -describe('Render', () => { - it('should not render loading when props are ready', () => { - render(); - - expect(screen.queryByText('Loading ...')).not.toBeInTheDocument(); - }); - - it('should render loading if datasource is not ready', () => { - const mockProps = getProps(); - mockProps.dataSource.id = 0; - mockProps.loading = true; - - render(); - - expect(screen.getByText('Loading ...')).toBeInTheDocument(); - }); - - it('should render beta info text if plugin state is beta', () => { - const mockProps = getProps(); - mockProps.dataSourceMeta.state = PluginState.beta; - - render(); - - expect(screen.getByTitle('This feature is close to complete but not fully tested')).toBeInTheDocument(); - }); - - it('should render alpha info text if plugin state is alpha', () => { - const mockProps = getProps(); - mockProps.dataSourceMeta.state = PluginState.alpha; - - render(); - - expect( - screen.getByTitle('This feature is experimental and future updates might not be backward compatible') - ).toBeInTheDocument(); - }); - - it('should not render is ready only message is readOnly is false', () => { - const mockProps = getProps(); - mockProps.dataSource.readOnly = false; - - render(); - - expect(screen.queryByLabelText(selectors.pages.DataSource.readOnly)).not.toBeInTheDocument(); - }); - - it('should render is ready only message is readOnly is true', () => { - const mockProps = getProps(); - mockProps.dataSource.readOnly = true; - - render(); - - expect(screen.getByLabelText(selectors.pages.DataSource.readOnly)).toBeInTheDocument(); - }); - - it('should render error message with detailed message', () => { - const mockProps = { - ...getProps(), - testingStatus: { - message: 'message', - status: 'error', - details: { message: 'detailed message' }, - }, - }; - - render(); - - expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument(); - expect(screen.getByText(mockProps.testingStatus.details.message)).toBeInTheDocument(); - }); - - it('should render error message with empty details', () => { - const mockProps = { - ...getProps(), - testingStatus: { - message: 'message', - status: 'error', - details: {}, - }, - }; - - render(); - - expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument(); - }); - - it('should render error message without details', () => { - const mockProps = { - ...getProps(), - testingStatus: { - message: 'message', - status: 'error', - }, - }; - - render(); - - expect(screen.getByText(mockProps.testingStatus.message)).toBeInTheDocument(); - }); - - it('should render verbose error message with detailed verbose error message', () => { - const mockProps = { - ...getProps(), - testingStatus: { - message: 'message', - status: 'error', - details: { message: 'detailed message', verboseMessage: 'verbose message' }, - }, - }; - - render(); - - expect(screen.getByText(mockProps.testingStatus.details.verboseMessage)).toBeInTheDocument(); - }); -}); diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.tsx deleted file mode 100644 index 3630fb0d48f..00000000000 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.tsx +++ /dev/null @@ -1,306 +0,0 @@ -import React, { PureComponent } from 'react'; -import { connect, ConnectedProps } from 'react-redux'; - -import { DataSourceSettings, urlUtil } from '@grafana/data'; -import { selectors } from '@grafana/e2e-selectors'; -import { Alert, Button } from '@grafana/ui'; -import { cleanUpAction } from 'app/core/actions/cleanUp'; -import appEvents from 'app/core/app_events'; -import { Page } from 'app/core/components/Page/Page'; -import { contextSrv } from 'app/core/core'; -import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; -import { getNavModel } from 'app/core/selectors/navModel'; -import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; -import { StoreState, AccessControlAction } from 'app/types/'; - -import { ShowConfirmModalEvent } from '../../../types/events'; -import { - deleteDataSource, - initDataSourceSettings, - loadDataSource, - testDataSource, - updateDataSource, -} from '../state/actions'; -import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from '../state/navModel'; -import { dataSourceLoaded, setDataSourceName, setIsDefault } from '../state/reducers'; -import { getDataSource, getDataSourceMeta } from '../state/selectors'; - -import BasicSettings from './BasicSettings'; -import ButtonRow from './ButtonRow'; -import { CloudInfoBox } from './CloudInfoBox'; -import { PluginSettings } from './PluginSettings'; - -export interface OwnProps extends GrafanaRouteComponentProps<{ uid: string }> {} - -function mapStateToProps(state: StoreState, props: OwnProps) { - const dataSourceId = props.match.params.uid; - const params = new URLSearchParams(props.location.search); - const dataSource = getDataSource(state.dataSources, dataSourceId); - const { plugin, loadError, loading, testingStatus } = state.dataSourceSettings; - const page = params.get('page'); - - const nav = plugin - ? getDataSourceNav(buildNavModel(dataSource, plugin), page || 'settings') - : getDataSourceLoadingNav('settings'); - - const navModel = getNavModel( - state.navIndex, - page ? `datasource-page-${page}` : `datasource-settings-${dataSourceId}`, - nav - ); - - return { - dataSource: getDataSource(state.dataSources, dataSourceId), - dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type), - dataSourceId: dataSourceId, - page, - plugin, - loadError, - loading, - testingStatus, - navModel, - }; -} - -const mapDispatchToProps = { - deleteDataSource, - loadDataSource, - setDataSourceName, - updateDataSource, - setIsDefault, - dataSourceLoaded, - initDataSourceSettings, - testDataSource, - cleanUpAction, -}; - -const connector = connect(mapStateToProps, mapDispatchToProps); - -export type Props = OwnProps & ConnectedProps; - -export class DataSourceSettingsPage extends PureComponent { - componentDidMount() { - const { initDataSourceSettings, dataSourceId } = this.props; - initDataSourceSettings(dataSourceId); - } - - componentWillUnmount() { - this.props.cleanUpAction({ - stateSelector: (state) => state.dataSourceSettings, - }); - } - - onSubmit = async (evt: React.FormEvent) => { - evt.preventDefault(); - - await this.props.updateDataSource({ ...this.props.dataSource }); - - this.testDataSource(); - }; - - onTest = async (evt: React.FormEvent) => { - evt.preventDefault(); - - this.testDataSource(); - }; - - onDelete = () => { - appEvents.publish( - new ShowConfirmModalEvent({ - title: 'Delete', - text: `Are you sure you want to delete the "${this.props.dataSource.name}" data source?`, - yesText: 'Delete', - icon: 'trash-alt', - onConfirm: () => { - this.confirmDelete(); - }, - }) - ); - }; - - confirmDelete = () => { - this.props.deleteDataSource(); - }; - - onModelChange = (dataSource: DataSourceSettings) => { - this.props.dataSourceLoaded(dataSource); - }; - - isReadOnly() { - return this.props.dataSource.readOnly === true; - } - - renderIsReadOnlyMessage() { - return ( - - This data source was added by config and cannot be modified using the UI. Please contact your server admin to - update this data source. - - ); - } - - renderMissingEditRightsMessage() { - return ( - - You are not allowed to modify this data source. Please contact your server admin to update this data source. - - ); - } - - testDataSource() { - const { dataSource, testDataSource } = this.props; - testDataSource(dataSource.name); - } - - get hasDataSource() { - return this.props.dataSource.id > 0; - } - - onNavigateToExplore() { - const { dataSource } = this.props; - const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' }); - const url = urlUtil.renderUrl('/explore', { left: exploreState }); - return url; - } - - renderLoadError() { - const { loadError, dataSource } = this.props; - const canDeleteDataSource = - !this.isReadOnly() && contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource); - - const node = { - text: loadError!, - subTitle: 'Data Source Error', - icon: 'exclamation-triangle', - }; - const nav = { - node: node, - main: node, - }; - - return ( - - - {this.isReadOnly() && this.renderIsReadOnlyMessage()} -
- {canDeleteDataSource && ( - - )} - -
-
-
- ); - } - - renderConfigPageBody(page: string) { - const { plugin } = this.props; - if (!plugin || !plugin.configPages) { - return null; // still loading - } - - for (const p of plugin.configPages) { - if (p.id === page) { - // Investigate is any plugins using this? We should change this interface - return ; - } - } - - return
Page not found: {page}
; - } - - renderAlertDetails() { - const { testingStatus } = this.props; - - return ( - <> - {testingStatus?.details?.message} - {testingStatus?.details?.verboseMessage ? ( -
{testingStatus?.details?.verboseMessage}
- ) : null} - - ); - } - - renderSettings() { - const { dataSourceMeta, setDataSourceName, setIsDefault, dataSource, plugin, testingStatus } = this.props; - const canWriteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource); - const canDeleteDataSource = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource); - - return ( -
- {!canWriteDataSource && this.renderMissingEditRightsMessage()} - {this.isReadOnly() && this.renderIsReadOnlyMessage()} - {dataSourceMeta.state && ( -
- - -
- )} - - - - setIsDefault(state)} - onNameChange={(name) => setDataSourceName(name)} - /> - - {plugin && ( - - )} - - {testingStatus?.message && ( -
- - {testingStatus.details && this.renderAlertDetails()} - -
- )} - - this.onSubmit(event)} - canSave={!this.isReadOnly() && canWriteDataSource} - canDelete={!this.isReadOnly() && canDeleteDataSource} - onDelete={this.onDelete} - onTest={(event) => this.onTest(event)} - exploreUrl={this.onNavigateToExplore()} - /> - - ); - } - - render() { - const { navModel, page, loadError, loading } = this.props; - - if (loadError) { - return this.renderLoadError(); - } - - return ( - - - {this.hasDataSource ?
{page ? this.renderConfigPageBody(page) : this.renderSettings()}
: null} -
-
- ); - } -} - -export default connector(DataSourceSettingsPage); diff --git a/public/app/features/datasources/state/actions.test.ts b/public/app/features/datasources/state/actions.test.ts index 8425aeb836f..127e6bdc9ca 100644 --- a/public/app/features/datasources/state/actions.test.ts +++ b/public/app/features/datasources/state/actions.test.ts @@ -1,21 +1,19 @@ -import { of } from 'rxjs'; import { thunkTester } from 'test/core/thunk/thunkTester'; -import { BackendSrvRequest, FetchError, FetchResponse } from '@grafana/runtime'; -import { getBackendSrv } from 'app/core/services/backend_srv'; +import { DataSourceSettings } from '@grafana/data'; +import { FetchError } from '@grafana/runtime'; import { ThunkResult, ThunkDispatch } from 'app/types'; -import { getMockPlugin, getMockPlugins } from '../../plugins/__mocks__/pluginMocks'; -import { GenericDataSourcePlugin } from '../settings/PluginSettings'; -import { initDataSourceSettings } from '../state/actions'; +import { getMockDataSource } from '../__mocks__'; +import * as api from '../api'; +import { GenericDataSourcePlugin } from '../types'; import { - findNewName, - nameExits, InitDataSourceSettingDependencies, testDataSource, TestDataSourceDependencies, - getDataSourceUsingUidOrId, + initDataSourceSettings, + loadDataSource, } from './actions'; import { initDataSourceSettingsSucceeded, @@ -23,8 +21,10 @@ import { testDataSourceStarting, testDataSourceSucceeded, testDataSourceFailed, + dataSourceLoaded, } from './reducers'; +jest.mock('../api'); jest.mock('app/core/services/backend_srv'); jest.mock('@grafana/runtime', () => ({ ...(jest.requireActual('@grafana/runtime') as unknown as object), @@ -67,118 +67,59 @@ const failDataSourceTest = async (error: object) => { return dispatchedActions; }; -describe('getDataSourceUsingUidOrId', () => { - const uidResponse = { - ok: true, - data: { - id: 111, - uid: 'abcdefg', - }, - }; +describe('loadDataSource()', () => { + it('should resolve to a data-source if a UID was used for fetching', async () => { + const dataSourceMock = getMockDataSource(); + const dispatch = jest.fn(); + const getState = jest.fn(); - const idResponse = { - ok: true, - data: { - id: 222, - uid: 'xyz', - }, - }; + (api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock); - it('should return UID response data', async () => { - (getBackendSrv as jest.Mock).mockReturnValueOnce({ - fetch: (options: BackendSrvRequest) => { - return of(uidResponse as FetchResponse); - }, - }); + const dataSource = await loadDataSource(dataSourceMock.uid)(dispatch, getState, undefined); - expect(await getDataSourceUsingUidOrId('abcdefg')).toBe(uidResponse.data); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(dataSourceLoaded(dataSource)); + expect(dataSource).toBe(dataSourceMock); }); - it('should return ID response data', async () => { - const uidResponse = { - ok: false, - }; + it('should resolve to an empty data-source if an ID (deprecated) was used for fetching', async () => { + const id = 123; + const uid = 'uid'; + const dataSourceMock = getMockDataSource({ id, uid }); + const dispatch = jest.fn(); + const getState = jest.fn(); - (getBackendSrv as jest.Mock) - .mockReturnValueOnce({ - fetch: (options: BackendSrvRequest) => { - return of(uidResponse as FetchResponse); - }, - }) - .mockReturnValueOnce({ - fetch: (options: BackendSrvRequest) => { - return of(idResponse as FetchResponse); - }, - }); - - expect(await getDataSourceUsingUidOrId(222)).toBe(idResponse.data); - }); - - it('should return empty response data', async () => { // @ts-ignore delete window.location; window.location = {} as Location; - const uidResponse = { - ok: false, - }; + (api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock); - (getBackendSrv as jest.Mock) - .mockReturnValueOnce({ - fetch: (options: BackendSrvRequest) => { - return of(uidResponse as FetchResponse); - }, - }) - .mockReturnValueOnce({ - fetch: (options: BackendSrvRequest) => { - return of(idResponse as FetchResponse); - }, - }); + // Fetch the datasource by ID + const dataSource = await loadDataSource(id.toString())(dispatch, getState, undefined); - expect(await getDataSourceUsingUidOrId('222')).toStrictEqual({}); - expect(window.location.href).toBe('/datasources/edit/xyz'); - }); -}); - -describe('Name exists', () => { - const plugins = getMockPlugins(5); - - it('should be true', () => { - const name = 'pretty cool plugin-1'; - - expect(nameExits(plugins, name)).toEqual(true); + expect(dataSource).toEqual({}); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenCalledWith(dataSourceLoaded({} as DataSourceSettings)); }); - it('should be false', () => { - const name = 'pretty cool plugin-6'; + it('should redirect to a URL which uses the UID if an ID (deprecated) was used for fetching', async () => { + const id = 123; + const uid = 'uid'; + const dataSourceMock = getMockDataSource({ id, uid }); + const dispatch = jest.fn(); + const getState = jest.fn(); - expect(nameExits(plugins, name)); - }); -}); + // @ts-ignore + delete window.location; + window.location = {} as Location; -describe('Find new name', () => { - it('should create a new name', () => { - const plugins = getMockPlugins(5); - const name = 'pretty cool plugin-1'; + (api.getDataSourceByIdOrUid as jest.Mock).mockResolvedValueOnce(dataSourceMock); - expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6'); - }); + // Fetch the datasource by ID + await loadDataSource(id.toString())(dispatch, getState, undefined); - it('should create new name without suffix', () => { - const plugin = getMockPlugin(); - plugin.name = 'prometheus'; - const plugins = [plugin]; - const name = 'prometheus'; - - expect(findNewName(plugins, name)).toEqual('prometheus-1'); - }); - - it('should handle names that end with -', () => { - const plugin = getMockPlugin(); - const plugins = [plugin]; - const name = 'pretty cool plugin-'; - - expect(findNewName(plugins, name)).toEqual('pretty cool plugin-'); + expect(window.location.href).toBe(`/datasources/edit/${uid}`); }); }); @@ -187,7 +128,7 @@ describe('initDataSourceSettings', () => { it('then initDataSourceSettingsFailed should be dispatched', async () => { const dispatchedActions = await thunkTester({}).givenThunk(initDataSourceSettings).whenThunkIsDispatched(''); - expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid ID'))]); + expect(dispatchedActions).toEqual([initDataSourceSettingsFailed(new Error('Invalid UID'))]); }); }); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index a95d07cb030..715d4cba37e 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -1,5 +1,3 @@ -import { lastValueFrom } from 'rxjs'; - import { DataSourcePluginMeta, DataSourceSettings, locationUtil } from '@grafana/data'; import { DataSourceWithBackend, @@ -10,14 +8,15 @@ import { locationService, } from '@grafana/runtime'; import { updateNavIndex } from 'app/core/actions'; +import { contextSrv } from 'app/core/core'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import { accessControlQueryParam } from 'app/core/utils/accessControl'; import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { getPluginSettings } from 'app/features/plugins/pluginSettings'; import { importDataSourcePlugin } from 'app/features/plugins/plugin_loader'; import { DataSourcePluginCategory, ThunkDispatch, ThunkResult } from 'app/types'; -import { contextSrv } from '../../../core/services/context_srv'; +import * as api from '../api'; +import { nameExits, findNewName } from '../utils'; import { buildCategories } from './buildCategories'; import { buildNavModel } from './navModel'; @@ -54,7 +53,7 @@ export interface TestDataSourceDependencies { } export const initDataSourceSettings = ( - pageId: string, + uid: string, dependencies: InitDataSourceSettingDependencies = { loadDataSource, loadDataSourceMeta, @@ -64,21 +63,16 @@ export const initDataSourceSettings = ( } ): ThunkResult => { return async (dispatch, getState) => { - if (!pageId) { - dispatch(initDataSourceSettingsFailed(new Error('Invalid ID'))); + if (!uid) { + dispatch(initDataSourceSettingsFailed(new Error('Invalid UID'))); return; } try { - const loadedDataSource = await dispatch(dependencies.loadDataSource(pageId)); + const loadedDataSource = await dispatch(dependencies.loadDataSource(uid)); await dispatch(dependencies.loadDataSourceMeta(loadedDataSource)); - // have we already loaded the plugin then we can skip the steps below? - if (getState().dataSourceSettings.plugin) { - return; - } - - const dataSource = dependencies.getDataSource(getState().dataSources, pageId); + const dataSource = dependencies.getDataSource(getState().dataSources, uid); const dataSourceMeta = dependencies.getDataSourceMeta(getState().dataSources, dataSource!.type); const importedPlugin = await dependencies.importDataSourcePlugin(dataSourceMeta); @@ -133,16 +127,32 @@ export const testDataSource = ( export function loadDataSources(): ThunkResult { return async (dispatch) => { - const response = await getBackendSrv().get('/api/datasources'); + const response = await api.getDataSources(); dispatch(dataSourcesLoaded(response)); }; } export function loadDataSource(uid: string): ThunkResult> { return async (dispatch) => { - const dataSource = await getDataSourceUsingUidOrId(uid); + let dataSource = await api.getDataSourceByIdOrUid(uid); + + // Reload route to use UID instead + // ------------------------------- + // In case we were trying to fetch and reference a data-source with an old numeric ID + // (which can happen by referencing it with a "old" URL), we would like to automatically redirect + // to the new URL format using the UID. + // [Please revalidate the following]: Unfortunately we can update the location using react router, but need to fully reload the + // route as the nav model page index is not matching with the url in that case. + // And react router has no way to unmount remount a route. + if (uid !== dataSource.uid) { + window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${dataSource.uid}`); + + // Avoid a flashing error while the reload happens + dataSource = {} as DataSourceSettings; + } dispatch(dataSourceLoaded(dataSource)); + return dataSource; }; } @@ -164,80 +174,26 @@ export function loadDataSourceMeta(dataSource: DataSourceSettings): ThunkResult< }; } -/** - * Get data source by uid or id, if old id detected handles redirect - */ -export async function getDataSourceUsingUidOrId(uid: string | number): Promise { - // Try first with uid api - try { - const byUid = await lastValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `/api/datasources/uid/${uid}`, - params: accessControlQueryParam(), - showErrorAlert: false, - }) - ); - - if (byUid.ok) { - return byUid.data; - } - } catch (err) { - console.log('Failed to lookup data source by uid', err); - } - - // try lookup by old db id - const id = typeof uid === 'string' ? parseInt(uid, 10) : uid; - if (!Number.isNaN(id)) { - const response = await lastValueFrom( - getBackendSrv().fetch({ - method: 'GET', - url: `/api/datasources/${id}`, - params: accessControlQueryParam(), - showErrorAlert: false, - }) - ); - - // If the uid is a number, then this is a refresh on one of the settings tabs - // and we can return the response data - if (response.ok && typeof uid === 'number' && response.data.id === uid) { - return response.data; - } - - // Not ideal to do a full page reload here but so tricky to handle this - // otherwise We can update the location using react router, but need to - // fully reload the route as the nav model page index is not matching with - // the url in that case. And react router has no way to unmount remount a - // route - if (response.ok && response.data.id.toString() === uid) { - window.location.href = locationUtil.assureBaseUrl(`/datasources/edit/${response.data.uid}`); - return {} as DataSourceSettings; // avoids flashing an error - } - } - - throw Error('Could not find data source'); -} - export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult { return async (dispatch, getStore) => { await dispatch(loadDataSources()); const dataSources = getStore().dataSources.dataSources; - + const isFirstDataSource = dataSources.length === 0; const newInstance = { name: plugin.name, type: plugin.id, access: 'proxy', - isDefault: dataSources.length === 0, + isDefault: isFirstDataSource, }; if (nameExits(dataSources, newInstance.name)) { newInstance.name = findNewName(dataSources, newInstance.name); } - const result = await getBackendSrv().post('/api/datasources', newInstance); - await getDatasourceSrv().reload(); + const result = await api.createDataSource(newInstance); + await getDatasourceSrv().reload(); await contextSrv.fetchUserPermissions(); locationService.push(`/datasources/edit/${result.datasource.uid}`); @@ -247,7 +203,7 @@ export function addDataSource(plugin: DataSourcePluginMeta): ThunkResult { export function loadDataSourcePlugins(): ThunkResult { return async (dispatch) => { dispatch(dataSourcePluginsLoad()); - const plugins = await getBackendSrv().get('/api/plugins', { enabled: 1, type: 'datasource' }); + const plugins = await api.getDataSourcePlugins(); const categories = buildCategories(plugins); dispatch(dataSourcePluginsLoaded({ plugins, categories })); }; @@ -255,67 +211,19 @@ export function loadDataSourcePlugins(): ThunkResult { export function updateDataSource(dataSource: DataSourceSettings): ThunkResult { return async (dispatch) => { - await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource); // by UID not yet supported + await api.updateDataSource(dataSource); await getDatasourceSrv().reload(); return dispatch(loadDataSource(dataSource.uid)); }; } -export function deleteDataSource(): ThunkResult { +export function deleteLoadedDataSource(): ThunkResult { return async (dispatch, getStore) => { - const dataSource = getStore().dataSources.dataSource; + const { uid } = getStore().dataSources.dataSource; - await getBackendSrv().delete(`/api/datasources/${dataSource.id}`); + await api.deleteDataSource(uid); await getDatasourceSrv().reload(); locationService.push('/datasources'); }; } - -interface ItemWithName { - name: string; -} - -export function nameExits(dataSources: ItemWithName[], name: string) { - return ( - dataSources.filter((dataSource) => { - return dataSource.name.toLowerCase() === name.toLowerCase(); - }).length > 0 - ); -} - -export function findNewName(dataSources: ItemWithName[], name: string) { - // Need to loop through current data sources to make sure - // the name doesn't exist - while (nameExits(dataSources, name)) { - // If there's a duplicate name that doesn't end with '-x' - // we can add -1 to the name and be done. - if (!nameHasSuffix(name)) { - name = `${name}-1`; - } else { - // if there's a duplicate name that ends with '-x' - // we can try to increment the last digit until the name is unique - - // remove the 'x' part and replace it with the new number - name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`; - } - } - - return name; -} - -function nameHasSuffix(name: string) { - return name.endsWith('-', name.length - 1); -} - -function getLastDigit(name: string) { - return parseInt(name.slice(-1), 10); -} - -function incrementLastDigit(digit: number) { - return isNaN(digit) ? 1 : digit + 1; -} - -function getNewName(name: string) { - return name.slice(0, name.length - 1); -} diff --git a/public/app/features/datasources/state/buildCategories.test.ts b/public/app/features/datasources/state/buildCategories.test.ts index 70a615aae89..e8b86d228e0 100644 --- a/public/app/features/datasources/state/buildCategories.test.ts +++ b/public/app/features/datasources/state/buildCategories.test.ts @@ -1,6 +1,5 @@ import { DataSourcePluginMeta } from '@grafana/data'; - -import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks'; +import { getMockPlugin } from 'app/features/plugins/__mocks__/pluginMocks'; import { buildCategories } from './buildCategories'; diff --git a/public/app/features/datasources/state/hooks.ts b/public/app/features/datasources/state/hooks.ts new file mode 100644 index 00000000000..c9168dfca56 --- /dev/null +++ b/public/app/features/datasources/state/hooks.ts @@ -0,0 +1,161 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { DataSourcePluginMeta, DataSourceSettings, urlUtil } from '@grafana/data'; +import { cleanUpAction } from 'app/core/actions/cleanUp'; +import appEvents from 'app/core/app_events'; +import { contextSrv } from 'app/core/core'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { AccessControlAction, StoreState } from 'app/types'; +import { ShowConfirmModalEvent } from 'app/types/events'; + +import { DataSourceRights } from '../types'; + +import { + initDataSourceSettings, + testDataSource, + loadDataSource, + loadDataSources, + loadDataSourcePlugins, + addDataSource, + updateDataSource, + deleteLoadedDataSource, +} from './actions'; +import { getDataSourceLoadingNav, buildNavModel, getDataSourceNav } from './navModel'; +import { getDataSource, getDataSourceMeta } from './selectors'; + +export const useInitDataSourceSettings = (uid: string) => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(initDataSourceSettings(uid)); + + return function cleanUp() { + dispatch( + cleanUpAction({ + stateSelector: (state) => state.dataSourceSettings, + }) + ); + }; + }, [uid, dispatch]); +}; + +export const useTestDataSource = (uid: string) => { + const dispatch = useDispatch(); + + return () => dispatch(testDataSource(uid)); +}; + +export const useLoadDataSources = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(loadDataSources()); + }, [dispatch]); +}; + +export const useLoadDataSource = (uid: string) => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(loadDataSource(uid)); + }, [dispatch, uid]); +}; + +export const useLoadDataSourcePlugins = () => { + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(loadDataSourcePlugins()); + }, [dispatch]); +}; + +export const useAddDatasource = () => { + const dispatch = useDispatch(); + + return (plugin: DataSourcePluginMeta) => { + dispatch(addDataSource(plugin)); + }; +}; + +export const useUpdateDatasource = () => { + const dispatch = useDispatch(); + + return (dataSource: DataSourceSettings) => dispatch(updateDataSource(dataSource)); +}; + +export const useDeleteLoadedDataSource = () => { + const dispatch = useDispatch(); + const { name } = useSelector((state: StoreState) => state.dataSources.dataSource); + + return () => { + appEvents.publish( + new ShowConfirmModalEvent({ + title: 'Delete', + text: `Are you sure you want to delete the "${name}" data source?`, + yesText: 'Delete', + icon: 'trash-alt', + onConfirm: () => dispatch(deleteLoadedDataSource()), + }) + ); + }; +}; + +export const useDataSource = (uid: string) => { + return useSelector((state: StoreState) => getDataSource(state.dataSources, uid)); +}; + +export const useDataSourceExploreUrl = (uid: string) => { + const dataSource = useDataSource(uid); + const exploreState = JSON.stringify({ datasource: dataSource.name, context: 'explore' }); + const exploreUrl = urlUtil.renderUrl('/explore', { left: exploreState }); + + return exploreUrl; +}; + +export const useDataSourceMeta = (uid: string): DataSourcePluginMeta => { + return useSelector((state: StoreState) => getDataSourceMeta(state.dataSources, uid)); +}; + +export const useDataSourceSettings = () => { + return useSelector((state: StoreState) => state.dataSourceSettings); +}; + +export const useDataSourceSettingsNav = (dataSourceId: string, pageId: string | null) => { + const dataSource = useDataSource(dataSourceId); + const { plugin, loadError, loading } = useDataSourceSettings(); + const navIndex = useSelector((state: StoreState) => state.navIndex); + const navIndexId = pageId ? `datasource-page-${pageId}` : `datasource-settings-${dataSourceId}`; + + if (loadError) { + const node = { + text: loadError, + subTitle: 'Data Source Error', + icon: 'exclamation-triangle', + }; + + return { + node: node, + main: node, + }; + } + + if (loading || !plugin) { + return getNavModel(navIndex, navIndexId, getDataSourceLoadingNav('settings')); + } + + return getNavModel(navIndex, navIndexId, getDataSourceNav(buildNavModel(dataSource, plugin), pageId || 'settings')); +}; + +export const useDataSourceRights = (uid: string): DataSourceRights => { + const dataSource = useDataSource(uid); + const readOnly = dataSource.readOnly === true; + const hasWriteRights = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesWrite, dataSource); + const hasDeleteRights = contextSrv.hasPermissionInMetadata(AccessControlAction.DataSourcesDelete, dataSource); + + return { + readOnly, + hasWriteRights, + hasDeleteRights, + }; +}; diff --git a/public/app/features/datasources/state/index.ts b/public/app/features/datasources/state/index.ts new file mode 100644 index 00000000000..42fae7a84a7 --- /dev/null +++ b/public/app/features/datasources/state/index.ts @@ -0,0 +1,6 @@ +export * from './actions'; +export * from './buildCategories'; +export * from './hooks'; +export * from './navModel'; +export * from './reducers'; +export * from './selectors'; diff --git a/public/app/features/datasources/state/navModel.ts b/public/app/features/datasources/state/navModel.ts index 117ea026dca..9c735a34bc6 100644 --- a/public/app/features/datasources/state/navModel.ts +++ b/public/app/features/datasources/state/navModel.ts @@ -3,10 +3,10 @@ import { featureEnabled } from '@grafana/runtime'; import { ProBadge } from 'app/core/components/Upgrade/ProBadge'; import config from 'app/core/config'; import { contextSrv } from 'app/core/core'; +import { highlightTrial } from 'app/features/admin/utils'; import { AccessControlAction } from 'app/types'; -import { highlightTrial } from '../../admin/utils'; -import { GenericDataSourcePlugin } from '../settings/PluginSettings'; +import { GenericDataSourcePlugin } from '../types'; const loadingDSType = 'Loading'; diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts index f6d409fd5a6..f3adfbd78e2 100644 --- a/public/app/features/datasources/state/reducers.test.ts +++ b/public/app/features/datasources/state/reducers.test.ts @@ -3,8 +3,8 @@ import { reducerTester } from 'test/core/redux/reducerTester'; import { PluginMeta, PluginMetaInfo, PluginType, LayoutModes } from '@grafana/data'; import { DataSourceSettingsState, DataSourcesState } from 'app/types'; -import { getMockDataSource, getMockDataSources } from '../__mocks__/dataSourcesMocks'; -import { GenericDataSourcePlugin } from '../settings/PluginSettings'; +import { getMockDataSource, getMockDataSources } from '../__mocks__'; +import { GenericDataSourcePlugin } from '../types'; import { dataSourceLoaded, @@ -53,7 +53,7 @@ describe('dataSourcesReducer', () => { describe('when dataSourceLoaded is dispatched', () => { it('then state should be correct', () => { - const dataSource = getMockDataSource(); + const dataSource = getMockDataSource<{}>(); reducerTester() .givenReducer(dataSourcesReducer, initialState) diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 8410ec5aab6..1153c40962a 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -3,7 +3,7 @@ import { AnyAction, createAction } from '@reduxjs/toolkit'; import { DataSourcePluginMeta, DataSourceSettings, LayoutMode, LayoutModes } from '@grafana/data'; import { DataSourcesState, DataSourceSettingsState, TestingStatus } from 'app/types'; -import { GenericDataSourcePlugin } from '../settings/PluginSettings'; +import { GenericDataSourcePlugin } from '../types'; import { DataSourceTypesLoadedPayload } from './actions'; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts index 558dfa88a4e..36181f4e7c8 100644 --- a/public/app/features/datasources/state/selectors.ts +++ b/public/app/features/datasources/state/selectors.ts @@ -1,6 +1,5 @@ import { DataSourcePluginMeta, DataSourceSettings, UrlQueryValue } from '@grafana/data'; - -import { DataSourcesState } from '../../../types/datasources'; +import { DataSourcesState } from 'app/types/datasources'; export const getDataSources = (state: DataSourcesState) => { const regex = new RegExp(state.searchQuery, 'i'); @@ -10,7 +9,7 @@ export const getDataSources = (state: DataSourcesState) => { }); }; -export const getDataSourcePlugins = (state: DataSourcesState) => { +export const getFilteredDataSourcePlugins = (state: DataSourcesState) => { const regex = new RegExp(state.dataSourceTypeSearchQuery, 'i'); return state.plugins.filter((type: DataSourcePluginMeta) => { diff --git a/public/app/features/datasources/types.ts b/public/app/features/datasources/types.ts new file mode 100644 index 00000000000..59d8f4e1fb6 --- /dev/null +++ b/public/app/features/datasources/types.ts @@ -0,0 +1,9 @@ +import { DataQuery, DataSourceApi, DataSourceJsonData, DataSourcePlugin } from '@grafana/data'; + +export type GenericDataSourcePlugin = DataSourcePlugin>; + +export type DataSourceRights = { + readOnly: boolean; + hasWriteRights: boolean; + hasDeleteRights: boolean; +}; diff --git a/public/app/features/datasources/utils.test.ts b/public/app/features/datasources/utils.test.ts new file mode 100644 index 00000000000..0c41ef5c20e --- /dev/null +++ b/public/app/features/datasources/utils.test.ts @@ -0,0 +1,41 @@ +import { getMockPlugin, getMockPlugins } from 'app/features/plugins/__mocks__/pluginMocks'; + +import { nameExits, findNewName } from './utils'; + +describe('Datasources / Utils', () => { + describe('nameExists()', () => { + const plugins = getMockPlugins(5); + + it('should return TRUE if an existing plugin already has the same name', () => { + expect(nameExits(plugins, plugins[1].name)).toEqual(true); + }); + + it('should return FALSE if no plugin has the same name yet', () => { + expect(nameExits(plugins, 'unknown-plugin')); + }); + }); + + describe('findNewName()', () => { + it('should return with a new name in case an existing plugin already has the same name', () => { + const plugins = getMockPlugins(5); + const name = 'pretty cool plugin-1'; + + expect(findNewName(plugins, name)).toEqual('pretty cool plugin-6'); + }); + + it('should handle names without suffixes when name already exists', () => { + const name = 'prometheus'; + const plugin = getMockPlugin({ name }); + + expect(findNewName([plugin], name)).toEqual('prometheus-1'); + }); + + it('should handle names that end with a "-" when name does not exist yet', () => { + const plugin = getMockPlugin(); + const plugins = [plugin]; + const name = 'pretty cool plugin-'; + + expect(findNewName(plugins, name)).toEqual('pretty cool plugin-'); + }); + }); +}); diff --git a/public/app/features/datasources/utils.ts b/public/app/features/datasources/utils.ts new file mode 100644 index 00000000000..48dcb500fe2 --- /dev/null +++ b/public/app/features/datasources/utils.ts @@ -0,0 +1,47 @@ +interface ItemWithName { + name: string; +} + +export function nameExits(dataSources: ItemWithName[], name: string) { + return ( + dataSources.filter((dataSource) => { + return dataSource.name.toLowerCase() === name.toLowerCase(); + }).length > 0 + ); +} + +export function findNewName(dataSources: ItemWithName[], name: string) { + // Need to loop through current data sources to make sure + // the name doesn't exist + while (nameExits(dataSources, name)) { + // If there's a duplicate name that doesn't end with '-x' + // we can add -1 to the name and be done. + if (!nameHasSuffix(name)) { + name = `${name}-1`; + } else { + // if there's a duplicate name that ends with '-x' + // we can try to increment the last digit until the name is unique + + // remove the 'x' part and replace it with the new number + name = `${getNewName(name)}${incrementLastDigit(getLastDigit(name))}`; + } + } + + return name; +} + +function nameHasSuffix(name: string) { + return name.endsWith('-', name.length - 1); +} + +function getLastDigit(name: string) { + return parseInt(name.slice(-1), 10); +} + +function incrementLastDigit(digit: number) { + return isNaN(digit) ? 1 : digit + 1; +} + +function getNewName(name: string) { + return name.slice(0, name.length - 1); +} diff --git a/public/app/features/plugins/admin/components/PluginDashboards.tsx b/public/app/features/plugins/admin/components/PluginDashboards.tsx index 464a84f4e18..8abc3bc6177 100644 --- a/public/app/features/plugins/admin/components/PluginDashboards.tsx +++ b/public/app/features/plugins/admin/components/PluginDashboards.tsx @@ -4,7 +4,7 @@ import React, { PureComponent } from 'react'; import { AppEvents, PluginMeta, DataSourceApi } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { appEvents } from 'app/core/core'; -import DashboardsTable from 'app/features/datasources/DashboardsTable'; +import DashboardsTable from 'app/features/datasources/components/DashboardsTable'; import { PluginDashboard } from 'app/types'; interface Props { diff --git a/public/app/features/plugins/admin/state/reducer.ts b/public/app/features/plugins/admin/state/reducer.ts index 75bb63cac6c..ea169b474f0 100644 --- a/public/app/features/plugins/admin/state/reducer.ts +++ b/public/app/features/plugins/admin/state/reducer.ts @@ -22,25 +22,27 @@ const getOriginalActionType = (type: string) => { return type.substring(0, separator); }; +export const initialState: ReducerState = { + items: pluginsAdapter.getInitialState(), + requests: {}, + settings: { + displayMode: PluginListDisplayMode.Grid, + }, + // Backwards compatibility + // (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana) + // TODO + plugins: [], + errors: [], + searchQuery: '', + hasFetched: false, + dashboards: [], + isLoadingPluginDashboards: false, + panels: {}, +}; + const slice = createSlice({ name: 'plugins', - initialState: { - items: pluginsAdapter.getInitialState(), - requests: {}, - settings: { - displayMode: PluginListDisplayMode.Grid, - }, - // Backwards compatibility - // (we need to have the following fields in the store as well to be backwards compatible with other parts of Grafana) - // TODO - plugins: [], - errors: [], - searchQuery: '', - hasFetched: false, - dashboards: [], - isLoadingPluginDashboards: false, - panels: {}, - } as ReducerState, + initialState, reducers: { setDisplayMode(state, action: PayloadAction) { state.settings.displayMode = action.payload; diff --git a/public/app/features/plugins/plugin_loader.ts b/public/app/features/plugins/plugin_loader.ts index d3685cab5d2..1c296afe5c8 100644 --- a/public/app/features/plugins/plugin_loader.ts +++ b/public/app/features/plugins/plugin_loader.ts @@ -28,7 +28,7 @@ import * as flatten from 'app/core/utils/flatten'; import kbn from 'app/core/utils/kbn'; import * as ticks from 'app/core/utils/ticks'; -import { GenericDataSourcePlugin } from '../datasources/settings/PluginSettings'; +import { GenericDataSourcePlugin } from '../datasources/types'; import builtInPlugins from './built_in_plugins'; import { locateWithCache, registerPluginInCache } from './pluginCacheBuster'; diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index e81bd56ef65..09a144b5998 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -11,11 +11,10 @@ import { Props, TeamList } from './TeamList'; import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; import { setSearchQuery, setTeamsSearchPage } from './state/reducers'; -jest.mock('app/core/config', () => { - return { - featureToggles: { accesscontrol: false }, - }; -}); +jest.mock('app/core/config', () => ({ + ...jest.requireActual('app/core/config'), + featureToggles: { accesscontrol: false }, +})); const setup = (propOverrides?: object) => { const props: Props = { diff --git a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts b/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts index 1d63b322bbe..fb1d0d1fe6c 100644 --- a/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts +++ b/public/app/plugins/datasource/elasticsearch/configuration/mocks.ts @@ -1,19 +1,21 @@ import { DataSourceSettings } from '@grafana/data'; +import { getMockDataSource } from 'app/features/datasources/__mocks__'; -import { createDatasourceSettings } from '../../../../features/datasources/mocks'; import { ElasticsearchOptions } from '../types'; export function createDefaultConfigOptions( options?: Partial ): DataSourceSettings { - return createDatasourceSettings({ - timeField: '@time', - esVersion: '7.0.0', - interval: 'Hourly', - timeInterval: '10s', - maxConcurrentShardRequests: 300, - logMessageField: 'test.message', - logLevelField: 'test.level', - ...options, + return getMockDataSource({ + jsonData: { + timeField: '@time', + esVersion: '7.0.0', + interval: 'Hourly', + timeInterval: '10s', + maxConcurrentShardRequests: 300, + logMessageField: 'test.message', + logLevelField: 'test.level', + ...options, + }, }); } diff --git a/public/app/plugins/datasource/loki/mocks.ts b/public/app/plugins/datasource/loki/mocks.ts index bab7b758a9b..e8a33540a4b 100644 --- a/public/app/plugins/datasource/loki/mocks.ts +++ b/public/app/plugins/datasource/loki/mocks.ts @@ -1,6 +1,6 @@ import { DataSourceSettings } from '@grafana/data'; -import { createDatasourceSettings } from '../../../features/datasources/mocks'; +import { getMockDataSource } from '../../../features/datasources/__mocks__'; import { LokiDatasource } from './datasource'; import { LokiOptions } from './types'; @@ -51,7 +51,7 @@ export function makeMockLokiDatasource(labelsAndValues: Labels, series?: SeriesF } export function createDefaultConfigOptions(): DataSourceSettings { - return createDatasourceSettings({ - maxLines: '531', + return getMockDataSource({ + jsonData: { maxLines: '531' }, }); } diff --git a/public/app/plugins/datasource/prometheus/configuration/mocks.ts b/public/app/plugins/datasource/prometheus/configuration/mocks.ts index d741c1fe11e..8a78cdbd7dc 100644 --- a/public/app/plugins/datasource/prometheus/configuration/mocks.ts +++ b/public/app/plugins/datasource/prometheus/configuration/mocks.ts @@ -1,13 +1,15 @@ import { DataSourceSettings } from '@grafana/data'; -import { createDatasourceSettings } from '../../../../features/datasources/mocks'; +import { getMockDataSource } from '../../../../features/datasources/__mocks__'; import { PromOptions } from '../types'; export function createDefaultConfigOptions(): DataSourceSettings { - return createDatasourceSettings({ - timeInterval: '1m', - queryTimeout: '1m', - httpMethod: 'GET', - directUrl: 'url', + return getMockDataSource({ + jsonData: { + timeInterval: '1m', + queryTimeout: '1m', + httpMethod: 'GET', + directUrl: 'url', + }, }); } diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index 306e6dc9a7b..311668b10d4 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -91,28 +91,28 @@ export function getAppRoutes(): RouteDescriptor[] { { path: '/datasources', component: SafeDynamicImport( - () => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/DataSourcesListPage') + () => import(/* webpackChunkName: "DataSourcesListPage"*/ 'app/features/datasources/pages/DataSourcesListPage') ), }, { path: '/datasources/edit/:uid/', component: SafeDynamicImport( - () => - import( - /* webpackChunkName: "DataSourceSettingsPage"*/ '../features/datasources/settings/DataSourceSettingsPage' - ) + () => import(/* webpackChunkName: "EditDataSourcePage"*/ '../features/datasources/pages/EditDataSourcePage') ), }, { path: '/datasources/edit/:uid/dashboards', component: SafeDynamicImport( - () => import(/* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/DataSourceDashboards') + () => + import( + /* webpackChunkName: "DataSourceDashboards"*/ 'app/features/datasources/pages/DataSourceDashboardsPage' + ) ), }, { path: '/datasources/new', component: SafeDynamicImport( - () => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/NewDataSourcePage') + () => import(/* webpackChunkName: "NewDataSourcePage"*/ '../features/datasources/pages/NewDataSourcePage') ), }, { diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 10bd4f0471a..3b05d3a07cb 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -1,6 +1,6 @@ import { DataSourcePluginMeta, DataSourceSettings, LayoutMode } from '@grafana/data'; import { HealthCheckResultDetails } from '@grafana/runtime/src/utils/DataSourceWithBackend'; -import { GenericDataSourcePlugin } from 'app/features/datasources/settings/PluginSettings'; +import { GenericDataSourcePlugin } from 'app/features/datasources/types'; export interface DataSourcesState { dataSources: DataSourceSettings[]; diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index de839539c09..f2489d310ec 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -40,7 +40,10 @@ angular.module('grafana.directives', []); angular.module('grafana.filters', []); angular.module('grafana.routes', ['ngRoute']); -jest.mock('../app/core/core', () => ({ appEvents: testAppEvents })); +jest.mock('../app/core/core', () => ({ + ...jest.requireActual('../app/core/core'), + appEvents: testAppEvents, +})); jest.mock('../app/angular/partials', () => ({})); jest.mock('../app/features/plugins/plugin_loader', () => ({}));