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', () => ({}));