diff --git a/public/app/features/datasources/DataSourceList.test.tsx b/public/app/features/datasources/DataSourceList.test.tsx new file mode 100644 index 00000000000..6e097da2c53 --- /dev/null +++ b/public/app/features/datasources/DataSourceList.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DataSourcesList from './DataSourcesList'; +import { getMockDataSources } from './__mocks__/dataSourcesMocks'; +import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; + +const setup = () => { + const props = { + dataSources: getMockDataSources(3), + layoutMode: LayoutModes.Grid, + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesActionBar.test.tsx b/public/app/features/datasources/DataSourcesActionBar.test.tsx new file mode 100644 index 00000000000..8337271271e --- /dev/null +++ b/public/app/features/datasources/DataSourcesActionBar.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourcesActionBar, Props } from './DataSourcesActionBar'; +import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; + +const setup = (propOverrides?: object) => { + const props: Props = { + layoutMode: LayoutModes.Grid, + searchQuery: '', + setDataSourcesLayoutMode: jest.fn(), + setDataSourcesSearchQuery: jest.fn(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesActionBar.tsx b/public/app/features/datasources/DataSourcesActionBar.tsx new file mode 100644 index 00000000000..d28089b1f21 --- /dev/null +++ b/public/app/features/datasources/DataSourcesActionBar.tsx @@ -0,0 +1,62 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; +import { setDataSourcesLayoutMode, setDataSourcesSearchQuery } from './state/actions'; +import { getDataSourcesLayoutMode, getDataSourcesSearchQuery } from './state/selectors'; + +export interface Props { + searchQuery: string; + layoutMode: LayoutMode; + setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode; + setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery; +} + +export class DataSourcesActionBar extends PureComponent { + onSearchQueryChange = event => { + this.props.setDataSourcesSearchQuery(event.target.value); + }; + + render() { + const { searchQuery, layoutMode, setDataSourcesLayoutMode } = this.props; + + return ( + + + + + + + setDataSourcesLayoutMode(mode)} + /> + + + + + Add data source + + + ); + } +} + +function mapStateToProps(state) { + return { + searchQuery: getDataSourcesSearchQuery(state.dataSources), + layoutMode: getDataSourcesLayoutMode(state.dataSources), + }; +} + +const mapDispatchToProps = { + setDataSourcesLayoutMode, + setDataSourcesSearchQuery, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(DataSourcesActionBar); diff --git a/public/app/features/datasources/DataSourcesList.tsx b/public/app/features/datasources/DataSourcesList.tsx new file mode 100644 index 00000000000..4ed2203bf29 --- /dev/null +++ b/public/app/features/datasources/DataSourcesList.tsx @@ -0,0 +1,32 @@ +import React, { SFC } from 'react'; +import classNames from 'classnames/bind'; +import DataSourcesListItem from './DataSourcesListItem'; +import { DataSource } from 'app/types'; +import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; + +export interface Props { + dataSources: DataSource[]; + layoutMode: LayoutMode; +} + +const DataSourcesList: SFC = props => { + const { dataSources, layoutMode } = props; + + const listStyle = classNames({ + 'card-section': true, + 'card-list-layout-grid': layoutMode === LayoutModes.Grid, + 'card-list-layout-list': layoutMode === LayoutModes.List, + }); + + return ( + + + {dataSources.map((dataSource, index) => { + return ; + })} + + + ); +}; + +export default DataSourcesList; diff --git a/public/app/features/datasources/DataSourcesListItem.test.tsx b/public/app/features/datasources/DataSourcesListItem.test.tsx new file mode 100644 index 00000000000..138c71cb46a --- /dev/null +++ b/public/app/features/datasources/DataSourcesListItem.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import DataSourcesListItem from './DataSourcesListItem'; +import { getMockDataSource } from './__mocks__/dataSourcesMocks'; + +const setup = () => { + const props = { + dataSource: getMockDataSource(), + }; + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesListItem.tsx b/public/app/features/datasources/DataSourcesListItem.tsx new file mode 100644 index 00000000000..1bb612a299a --- /dev/null +++ b/public/app/features/datasources/DataSourcesListItem.tsx @@ -0,0 +1,34 @@ +import React, { SFC } from 'react'; +import { DataSource } from 'app/types'; + +export interface Props { + dataSource: DataSource; +} + +const DataSourcesListItem: SFC = props => { + const { dataSource } = props; + + return ( + + + + {dataSource.type} + + + + + + + + {dataSource.name} + {dataSource.isDefault && default} + + {dataSource.url} + + + + + ); +}; + +export default DataSourcesListItem; diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx new file mode 100644 index 00000000000..2cb6652ee13 --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { DataSourcesListPage, Props } from './DataSourcesListPage'; +import { DataSource, NavModel } from 'app/types'; +import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector'; +import { getMockDataSources } from './__mocks__/dataSourcesMocks'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dataSources: [] as DataSource[], + layoutMode: LayoutModes.Grid, + loadDataSources: jest.fn(), + navModel: {} as NavModel, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render action bar and datasources', () => { + const wrapper = setup({ + dataSources: getMockDataSources(5), + }); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/datasources/DataSourcesListPage.tsx b/public/app/features/datasources/DataSourcesListPage.tsx new file mode 100644 index 00000000000..a6ce1be7be9 --- /dev/null +++ b/public/app/features/datasources/DataSourcesListPage.tsx @@ -0,0 +1,72 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { hot } from 'react-hot-loader'; +import PageHeader from '../../core/components/PageHeader/PageHeader'; +import DataSourcesActionBar from './DataSourcesActionBar'; +import DataSourcesList from './DataSourcesList'; +import { loadDataSources } from './state/actions'; +import { getDataSources, getDataSourcesLayoutMode } from './state/selectors'; +import { getNavModel } from '../../core/selectors/navModel'; +import { DataSource, NavModel } from 'app/types'; +import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector'; +import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA'; + +export interface Props { + navModel: NavModel; + dataSources: DataSource[]; + layoutMode: LayoutMode; + loadDataSources: typeof loadDataSources; +} + +const emptyListModel = { + title: 'There are no data sources defined yet', + buttonIcon: 'gicon gicon-add-datasources', + 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 class DataSourcesListPage extends PureComponent { + componentDidMount() { + this.fetchDataSources(); + } + + async fetchDataSources() { + return await this.props.loadDataSources(); + } + + render() { + const { navModel, dataSources, layoutMode } = this.props; + + if (dataSources.length === 0) { + return ; + } + + return ( + + + + + + + + ); + } +} + +function mapStateToProps(state) { + return { + navModel: getNavModel(state.navIndex, 'datasources'), + dataSources: getDataSources(state.dataSources), + layoutMode: getDataSourcesLayoutMode(state.dataSources), + }; +} + +const mapDispatchToProps = { + loadDataSources, +}; + +export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourcesListPage)); diff --git a/public/app/features/datasources/__mocks__/dataSourcesMocks.ts b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts new file mode 100644 index 00000000000..97819a18c82 --- /dev/null +++ b/public/app/features/datasources/__mocks__/dataSourcesMocks.ts @@ -0,0 +1,45 @@ +import { DataSource } from 'app/types'; + +export const getMockDataSources = (amount: number): DataSource[] => { + const dataSources = []; + + for (let i = 0; i <= amount; i++) { + dataSources.push({ + access: '', + basicAuth: false, + database: `database-${i}`, + id: i, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: `dataSource-${i}`, + orgId: 1, + password: '', + readOnly: false, + type: 'cloudwatch', + typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', + url: '', + user: '', + }); + } + + return dataSources; +}; + +export const getMockDataSource = (): DataSource => { + return { + access: '', + basicAuth: false, + database: '', + id: 13, + isDefault: false, + jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' }, + name: 'gdev-cloudwatch', + orgId: 1, + password: '', + readOnly: false, + type: 'cloudwatch', + typeLogoUrl: 'public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png', + url: '', + user: '', + }; +}; diff --git a/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap new file mode 100644 index 00000000000..7167f59b048 --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourceList.test.tsx.snap @@ -0,0 +1,108 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + + + + + + + +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap new file mode 100644 index 00000000000..24f9f2126d0 --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesActionBar.test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + + + + + + + + + + + Add data source + + +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap new file mode 100644 index 00000000000..a424276cf32 --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap @@ -0,0 +1,45 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` + + + + + cloudwatch + + + + + + + + + gdev-cloudwatch + + + + + + +`; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap new file mode 100644 index 00000000000..837a8aceb24 --- /dev/null +++ b/public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap @@ -0,0 +1,152 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render action bar and datasources 1`] = ` + + + + + + + +`; + +exports[`Render should render component 1`] = ` + +`; diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts new file mode 100644 index 00000000000..297797f2e59 --- /dev/null +++ b/public/app/features/datasources/state/actions.ts @@ -0,0 +1,51 @@ +import { ThunkAction } from 'redux-thunk'; +import { DataSource, StoreState } from 'app/types'; +import { getBackendSrv } from '../../../core/services/backend_srv'; +import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector'; + +export enum ActionTypes { + LoadDataSources = 'LOAD_DATA_SOURCES', + SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY', + SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE', +} + +export interface LoadDataSourcesAction { + type: ActionTypes.LoadDataSources; + payload: DataSource[]; +} + +export interface SetDataSourcesSearchQueryAction { + type: ActionTypes.SetDataSourcesSearchQuery; + payload: string; +} + +export interface SetDataSourcesLayoutModeAction { + type: ActionTypes.SetDataSourcesLayoutMode; + payload: LayoutMode; +} + +const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({ + type: ActionTypes.LoadDataSources, + payload: dataSources, +}); + +export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({ + type: ActionTypes.SetDataSourcesSearchQuery, + payload: searchQuery, +}); + +export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({ + type: ActionTypes.SetDataSourcesLayoutMode, + payload: layoutMode, +}); + +export type Action = LoadDataSourcesAction | SetDataSourcesSearchQueryAction | SetDataSourcesLayoutModeAction; + +type ThunkResult = ThunkAction; + +export function loadDataSources(): ThunkResult { + return async dispatch => { + const response = await getBackendSrv().get('/api/datasources'); + dispatch(dataSourcesLoaded(response)); + }; +} diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts new file mode 100644 index 00000000000..15604fa8b53 --- /dev/null +++ b/public/app/features/datasources/state/reducers.ts @@ -0,0 +1,28 @@ +import { DataSource, DataSourcesState } from 'app/types'; +import { Action, ActionTypes } from './actions'; +import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector'; + +const initialState: DataSourcesState = { + dataSources: [] as DataSource[], + layoutMode: LayoutModes.Grid, + searchQuery: '', +}; + +export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => { + switch (action.type) { + case ActionTypes.LoadDataSources: + return { ...state, dataSources: action.payload }; + + case ActionTypes.SetDataSourcesSearchQuery: + return { ...state, searchQuery: action.payload }; + + case ActionTypes.SetDataSourcesLayoutMode: + return { ...state, layoutMode: action.payload }; + } + + return state; +}; + +export default { + dataSources: dataSourcesReducer, +}; diff --git a/public/app/features/datasources/state/selectors.ts b/public/app/features/datasources/state/selectors.ts new file mode 100644 index 00000000000..15ee88e715a --- /dev/null +++ b/public/app/features/datasources/state/selectors.ts @@ -0,0 +1,10 @@ +export const getDataSources = state => { + const regex = new RegExp(state.searchQuery, 'i'); + + return state.dataSources.filter(dataSource => { + return regex.test(dataSource.name) || regex.test(dataSource.database); + }); +}; + +export const getDataSourcesSearchQuery = state => state.searchQuery; +export const getDataSourcesLayoutMode = state => state.layoutMode; diff --git a/public/app/features/plugins/all.ts b/public/app/features/plugins/all.ts index 5be7593f68d..d164a6d4255 100644 --- a/public/app/features/plugins/all.ts +++ b/public/app/features/plugins/all.ts @@ -3,6 +3,5 @@ import './plugin_page_ctrl'; import './import_list/import_list'; import './ds_edit_ctrl'; import './ds_dashboards_ctrl'; -import './ds_list_ctrl'; import './datasource_srv'; import './plugin_component'; diff --git a/public/app/features/plugins/ds_list_ctrl.ts b/public/app/features/plugins/ds_list_ctrl.ts deleted file mode 100644 index 71c1a516842..00000000000 --- a/public/app/features/plugins/ds_list_ctrl.ts +++ /dev/null @@ -1,61 +0,0 @@ -import coreModule from '../../core/core_module'; -import _ from 'lodash'; - -export class DataSourcesCtrl { - datasources: any; - unfiltered: any; - navModel: any; - searchQuery: string; - - /** @ngInject */ - constructor(private $scope, private backendSrv, private datasourceSrv, private navModelSrv) { - this.navModel = this.navModelSrv.getNav('cfg', 'datasources', 0); - backendSrv.get('/api/datasources').then(result => { - this.datasources = result; - this.unfiltered = result; - }); - } - - onQueryUpdated() { - const regex = new RegExp(this.searchQuery, 'ig'); - this.datasources = _.filter(this.unfiltered, item => { - regex.lastIndex = 0; - return regex.test(item.name) || regex.test(item.type); - }); - } - - removeDataSourceConfirmed(ds) { - this.backendSrv - .delete('/api/datasources/' + ds.id) - .then( - () => { - this.$scope.appEvent('alert-success', ['Datasource deleted', '']); - }, - () => { - this.$scope.appEvent('alert-error', ['Unable to delete datasource', '']); - } - ) - .then(() => { - this.backendSrv.get('/api/datasources').then(result => { - this.datasources = result; - }); - this.backendSrv.get('/api/frontend/settings').then(settings => { - this.datasourceSrv.init(settings.datasources); - }); - }); - } - - removeDataSource(ds) { - this.$scope.appEvent('confirm-modal', { - title: 'Delete', - text: 'Are you sure you want to delete datasource ' + ds.name + '?', - yesText: 'Delete', - icon: 'fa-trash', - onConfirm: () => { - this.removeDataSourceConfirmed(ds); - }, - }); - } -} - -coreModule.controller('DataSourcesCtrl', DataSourcesCtrl); diff --git a/public/app/features/plugins/partials/ds_list.html b/public/app/features/plugins/partials/ds_list.html deleted file mode 100644 index fd537fc47d4..00000000000 --- a/public/app/features/plugins/partials/ds_list.html +++ /dev/null @@ -1,63 +0,0 @@ - - - - - - - - - - - - - - - - Add data source - - - - - - - - - - {{ds.type}} - - - - - - - - - {{ds.name}} - - default - - - - {{ds.url}} - - - - - - - - - - - - - diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index e4662c77367..8f17dce9757 100644 --- a/public/app/routes/routes.ts +++ b/public/app/routes/routes.ts @@ -8,6 +8,7 @@ import TeamList from 'app/features/teams/TeamList'; import PluginListPage from 'app/features/plugins/PluginListPage'; import FolderSettingsPage from 'app/features/folders/FolderSettingsPage'; import FolderPermissions from 'app/features/folders/FolderPermissions'; +import DataSourcesListPage from 'app/features/datasources/DataSourcesListPage'; /** @ngInject */ export function setupAngularRoutes($routeProvider, $locationProvider) { @@ -62,9 +63,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) { controllerAs: 'ctrl', }) .when('/datasources', { - templateUrl: 'public/app/features/plugins/partials/ds_list.html', - controller: 'DataSourcesCtrl', - controllerAs: 'ctrl', + template: '', + resolve: { + component: () => DataSourcesListPage, + }, }) .when('/datasources/edit/:id', { templateUrl: 'public/app/features/plugins/partials/ds_edit.html', diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 08d3d5bede0..6313bddfb3a 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -7,6 +7,7 @@ import teamsReducers from 'app/features/teams/state/reducers'; import foldersReducers from 'app/features/folders/state/reducers'; import dashboardReducers from 'app/features/dashboard/state/reducers'; import pluginReducers from 'app/features/plugins/state/reducers'; +import dataSourcesReducers from 'app/features/datasources/state/reducers'; const rootReducer = combineReducers({ ...sharedReducers, @@ -15,6 +16,7 @@ const rootReducer = combineReducers({ ...foldersReducers, ...dashboardReducers, ...pluginReducers, + ...dataSourcesReducers, }); export let store; diff --git a/public/app/types/datasources.ts b/public/app/types/datasources.ts index 78ff7b0724c..40266fbbc5a 100644 --- a/public/app/types/datasources.ts +++ b/public/app/types/datasources.ts @@ -1,7 +1,24 @@ +import { LayoutMode } from '../core/components/LayoutSelector/LayoutSelector'; + export interface DataSource { id: number; orgId: number; name: string; typeLogoUrl: string; type: string; + access: string; + url: string; + password: string; + user: string; + database: string; + basicAuth: false; + isDefault: false; + jsonData: { authType: string; defaultRegion: string }; + readOnly: false; +} + +export interface DataSourcesState { + dataSources: DataSource[]; + searchQuery: string; + layoutMode: LayoutMode; } diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 1dd11d73564..3dbef72ce17 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -5,7 +5,7 @@ import { NavModel, NavModelItem, NavIndex } from './navModel'; import { FolderDTO, FolderState, FolderInfo } from './folders'; import { DashboardState } from './dashboard'; import { DashboardAcl, OrgRole, PermissionLevel } from './acl'; -import { DataSource } from './datasources'; +import { DataSource, DataSourcesState } from './datasources'; import { PluginMeta, Plugin, PluginsState } from './plugins'; export { @@ -35,6 +35,7 @@ export { PluginMeta, Plugin, PluginsState, + DataSourcesState, }; export interface StoreState {