瀏覽代碼

Merge branch 'master' into 13411-react-api-key

Peter Holmberg 7 年之前
父節點
當前提交
3081e0f84a
共有 38 個文件被更改,包括 1035 次插入106 次删除
  1. 1 0
      CHANGELOG.md
  2. 1 1
      pkg/tsdb/cloudwatch/metric_find_query.go
  3. 39 0
      public/app/core/components/LayoutSelector/LayoutSelector.tsx
  4. 2 1
      public/app/features/dashboard/submenu/submenu.html
  5. 5 4
      public/app/features/explore/Explore.tsx
  6. 31 0
      public/app/features/plugins/PluginActionBar.test.tsx
  7. 62 0
      public/app/features/plugins/PluginActionBar.tsx
  8. 25 0
      public/app/features/plugins/PluginList.test.tsx
  9. 32 0
      public/app/features/plugins/PluginList.tsx
  10. 33 0
      public/app/features/plugins/PluginListItem.test.tsx
  11. 39 0
      public/app/features/plugins/PluginListItem.tsx
  12. 32 0
      public/app/features/plugins/PluginListPage.test.tsx
  13. 56 0
      public/app/features/plugins/PluginListPage.tsx
  14. 59 0
      public/app/features/plugins/__mocks__/pluginMocks.ts
  15. 40 0
      public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap
  16. 210 0
      public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap
  17. 106 0
      public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap
  18. 18 0
      public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
  19. 0 1
      public/app/features/plugins/all.ts
  20. 0 45
      public/app/features/plugins/partials/plugin_list.html
  21. 0 30
      public/app/features/plugins/plugin_list_ctrl.ts
  22. 51 0
      public/app/features/plugins/state/actions.ts
  23. 27 0
      public/app/features/plugins/state/reducers.ts
  24. 31 0
      public/app/features/plugins/state/selectors.test.ts
  25. 10 0
      public/app/features/plugins/state/selectors.ts
  26. 58 0
      public/app/features/templating/TextBoxVariable.ts
  27. 2 0
      public/app/features/templating/all.ts
  28. 8 0
      public/app/features/templating/partials/editor.html
  29. 1 1
      public/app/plugins/datasource/cloudwatch/partials/config.html
  30. 5 3
      public/app/routes/routes.ts
  31. 2 0
      public/app/store/configureStore.ts
  32. 3 1
      public/app/types/index.ts
  33. 28 0
      public/app/types/plugins.ts
  34. 6 12
      public/sass/components/_form_select_box.scss
  35. 1 1
      public/sass/pages/_explore.scss
  36. 4 1
      public/views/index.template.html
  37. 5 4
      scripts/grunt/default_task.js
  38. 2 1
      scripts/grunt/options/exec.js

+ 1 - 0
CHANGELOG.md

@@ -14,6 +14,7 @@
 * **Prometheus**: Adhoc-filtering for Prometheus dashboards [#13212](https://github.com/grafana/grafana/issues/13212)
 * **Singlestat**: Fix gauge display accuracy for percents [#13270](https://github.com/grafana/grafana/issues/13270), thx [@tianon](https://github.com/tianon)
 * **Dashboard**: Prevent auto refresh from starting when loading dashboard with absolute time range [#12030](https://github.com/grafana/grafana/issues/12030)
+* **Templating**: New templating variable type `Text box` that allows free text input [#3173](https://github.com/grafana/grafana/issues/3173)
 
 # 5.3.0 (unreleased)
 

+ 1 - 1
pkg/tsdb/cloudwatch/metric_find_query.go

@@ -235,7 +235,7 @@ func parseMultiSelectValue(input string) []string {
 func (e *CloudWatchExecutor) handleGetRegions(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) {
 	regions := []string{
 		"ap-northeast-1", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-south-1", "ca-central-1", "cn-north-1", "cn-northwest-1",
-		"eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2",
+		"eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3", "sa-east-1", "us-east-1", "us-east-2", "us-gov-west-1", "us-west-1", "us-west-2", "us-isob-east-1", "us-iso-east-1",
 	}
 
 	result := make([]suggestData, 0)

+ 39 - 0
public/app/core/components/LayoutSelector/LayoutSelector.tsx

@@ -0,0 +1,39 @@
+import React, { SFC } from 'react';
+
+export type LayoutMode = LayoutModes.Grid | LayoutModes.List;
+
+export enum LayoutModes {
+  Grid = 'grid',
+  List = 'list',
+}
+
+interface Props {
+  mode: LayoutMode;
+  onLayoutModeChanged: (mode: LayoutMode) => {};
+}
+
+const LayoutSelector: SFC<Props> = props => {
+  const { mode, onLayoutModeChanged } = props;
+  return (
+    <div className="layout-selector">
+      <button
+        onClick={() => {
+          onLayoutModeChanged(LayoutModes.List);
+        }}
+        className={mode === LayoutModes.List ? 'active' : ''}
+      >
+        <i className="fa fa-list" />
+      </button>
+      <button
+        onClick={() => {
+          onLayoutModeChanged(LayoutModes.Grid);
+        }}
+        className={mode === LayoutModes.Grid ? 'active' : ''}
+      >
+        <i className="fa fa-th" />
+      </button>
+    </div>
+  );
+};
+
+export default LayoutSelector;

+ 2 - 1
public/app/features/dashboard/submenu/submenu.html

@@ -4,7 +4,8 @@
       <label class="gf-form-label template-variable" ng-hide="variable.hide === 1">
         {{variable.label || variable.name}}
       </label>
-      <value-select-dropdown ng-if="variable.type !== 'adhoc'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
+      <value-select-dropdown ng-if="variable.type !== 'adhoc' && variable.type !== 'textbox'" variable="variable" on-updated="ctrl.variableUpdated(variable)"></value-select-dropdown>
+      <input type="text" ng-if="variable.type === 'textbox'" ng-model="variable.query" class="gf-form-input width-12"  ng-blur="variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ng-keydown="$event.keyCode === 13 && variable.current.value != variable.query && variable.updateOptions() && ctrl.variableUpdated(variable);" ></input>
     </div>
     <ad-hoc-filters ng-if="variable.type === 'adhoc'" variable="variable"></ad-hoc-filters>
   </div>

+ 5 - 4
public/app/features/explore/Explore.tsx

@@ -528,10 +528,11 @@ export class Explore extends React.Component<any, ExploreState> {
           {!datasourceMissing ? (
             <div className="navbar-buttons">
               <Select
-                className="datasource-picker"
                 clearable={false}
+                className="gf-form-input gf-form-input--form-dropdown datasource-picker"
                 onChange={this.onChangeDatasource}
                 options={datasources}
+                isOpen={true}
                 placeholder="Loading datasources..."
                 value={selectedDatasource}
               />
@@ -586,17 +587,17 @@ export class Explore extends React.Component<any, ExploreState> {
             />
             <div className="result-options">
               {supportsGraph ? (
-                <button className={`btn navbar-button ${graphButtonActive}`} onClick={this.onClickGraphButton}>
+                <button className={`btn toggle-btn ${graphButtonActive}`} onClick={this.onClickGraphButton}>
                   Graph
                 </button>
               ) : null}
               {supportsTable ? (
-                <button className={`btn navbar-button ${tableButtonActive}`} onClick={this.onClickTableButton}>
+                <button className={`btn toggle-btn ${tableButtonActive}`} onClick={this.onClickTableButton}>
                   Table
                 </button>
               ) : null}
               {supportsLogs ? (
-                <button className={`btn navbar-button ${logsButtonActive}`} onClick={this.onClickLogsButton}>
+                <button className={`btn toggle-btn ${logsButtonActive}`} onClick={this.onClickLogsButton}>
                   Logs
                 </button>
               ) : null}

+ 31 - 0
public/app/features/plugins/PluginActionBar.test.tsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { PluginActionBar, Props } from './PluginActionBar';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    searchQuery: '',
+    layoutMode: LayoutModes.Grid,
+    setLayoutMode: jest.fn(),
+    setPluginsSearchQuery: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<PluginActionBar {...props} />);
+  const instance = wrapper.instance() as PluginActionBar;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 62 - 0
public/app/features/plugins/PluginActionBar.tsx

@@ -0,0 +1,62 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import LayoutSelector, { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+import { setLayoutMode, setPluginsSearchQuery } from './state/actions';
+import { getPluginsSearchQuery, getLayoutMode } from './state/selectors';
+
+export interface Props {
+  searchQuery: string;
+  layoutMode: LayoutMode;
+  setLayoutMode: typeof setLayoutMode;
+  setPluginsSearchQuery: typeof setPluginsSearchQuery;
+}
+
+export class PluginActionBar extends PureComponent<Props> {
+  onSearchQueryChange = event => {
+    this.props.setPluginsSearchQuery(event.target.value);
+  };
+
+  render() {
+    const { searchQuery, layoutMode, setLayoutMode } = this.props;
+
+    return (
+      <div className="page-action-bar">
+        <div className="gf-form gf-form--grow">
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-20"
+              value={searchQuery}
+              onChange={this.onSearchQueryChange}
+              placeholder="Filter by name or type"
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+          <LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
+        </div>
+        <div className="page-action-bar__spacer" />
+        <a
+          className="btn btn-success"
+          href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
+          target="_blank"
+        >
+          Find more plugins on Grafana.com
+        </a>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    searchQuery: getPluginsSearchQuery(state.plugins),
+    layoutMode: getLayoutMode(state.plugins),
+  };
+}
+
+const mapDispatchToProps = {
+  setPluginsSearchQuery,
+  setLayoutMode,
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(PluginActionBar);

+ 25 - 0
public/app/features/plugins/PluginList.test.tsx

@@ -0,0 +1,25 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import PluginList from './PluginList';
+import { getMockPlugins } from './__mocks__/pluginMocks';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      plugins: getMockPlugins(5),
+      layoutMode: LayoutModes.Grid,
+    },
+    propOverrides
+  );
+
+  return shallow(<PluginList {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 32 - 0
public/app/features/plugins/PluginList.tsx

@@ -0,0 +1,32 @@
+import React, { SFC } from 'react';
+import classNames from 'classnames/bind';
+import PluginListItem from './PluginListItem';
+import { Plugin } from 'app/types';
+import { LayoutMode, LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+interface Props {
+  plugins: Plugin[];
+  layoutMode: LayoutMode;
+}
+
+const PluginList: SFC<Props> = props => {
+  const { plugins, layoutMode } = props;
+
+  const listStyle = classNames({
+    'card-section': true,
+    'card-list-layout-grid': layoutMode === LayoutModes.Grid,
+    'card-list-layout-list': layoutMode === LayoutModes.List,
+  });
+
+  return (
+    <section className={listStyle}>
+      <ol className="card-list">
+        {plugins.map((plugin, index) => {
+          return <PluginListItem plugin={plugin} key={`${plugin.name}-${index}`} />;
+        })}
+      </ol>
+    </section>
+  );
+};
+
+export default PluginList;

+ 33 - 0
public/app/features/plugins/PluginListItem.test.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import PluginListItem from './PluginListItem';
+import { getMockPlugin } from './__mocks__/pluginMocks';
+
+const setup = (propOverrides?: object) => {
+  const props = Object.assign(
+    {
+      plugin: getMockPlugin(),
+    },
+    propOverrides
+  );
+
+  return shallow(<PluginListItem {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render has plugin section', () => {
+    const mockPlugin = getMockPlugin();
+    mockPlugin.hasUpdate = true;
+    const wrapper = setup({
+      plugin: mockPlugin,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 39 - 0
public/app/features/plugins/PluginListItem.tsx

@@ -0,0 +1,39 @@
+import React, { SFC } from 'react';
+import { Plugin } from 'app/types';
+
+interface Props {
+  plugin: Plugin;
+}
+
+const PluginListItem: SFC<Props> = props => {
+  const { plugin } = props;
+
+  return (
+    <li className="card-item-wrapper">
+      <a className="card-item" href={`plugins/${plugin.id}/edit`}>
+        <div className="card-item-header">
+          <div className="card-item-type">
+            <i className={`icon-gf icon-gf-${plugin.type}`} />
+            {plugin.type}
+          </div>
+          {plugin.hasUpdate && (
+            <div className="card-item-notice">
+              <span bs-tooltip="plugin.latestVersion">Update available!</span>
+            </div>
+          )}
+        </div>
+        <div className="card-item-body">
+          <figure className="card-item-figure">
+            <img src={plugin.info.logos.small} />
+          </figure>
+          <div className="card-item-details">
+            <div className="card-item-name">{plugin.name}</div>
+            <div className="card-item-sub-name">{`By ${plugin.info.author.name}`}</div>
+          </div>
+        </div>
+      </a>
+    </li>
+  );
+};
+
+export default PluginListItem;

+ 32 - 0
public/app/features/plugins/PluginListPage.test.tsx

@@ -0,0 +1,32 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { PluginListPage, Props } from './PluginListPage';
+import { NavModel, Plugin } from '../../types';
+import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    plugins: [] as Plugin[],
+    layoutMode: LayoutModes.Grid,
+    loadPlugins: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<PluginListPage {...props} />);
+  const instance = wrapper.instance() as PluginListPage;
+
+  return {
+    wrapper,
+    instance,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 56 - 0
public/app/features/plugins/PluginListPage.tsx

@@ -0,0 +1,56 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+import PageHeader from '../../core/components/PageHeader/PageHeader';
+import PluginActionBar from './PluginActionBar';
+import PluginList from './PluginList';
+import { NavModel, Plugin } from '../../types';
+import { loadPlugins } from './state/actions';
+import { getNavModel } from '../../core/selectors/navModel';
+import { getLayoutMode, getPlugins } from './state/selectors';
+import { LayoutMode } from '../../core/components/LayoutSelector/LayoutSelector';
+
+export interface Props {
+  navModel: NavModel;
+  plugins: Plugin[];
+  layoutMode: LayoutMode;
+  loadPlugins: typeof loadPlugins;
+}
+
+export class PluginListPage extends PureComponent<Props> {
+  componentDidMount() {
+    this.fetchPlugins();
+  }
+
+  async fetchPlugins() {
+    await this.props.loadPlugins();
+  }
+
+  render() {
+    const { navModel, plugins, layoutMode } = this.props;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        <div className="page-container page-body">
+          <PluginActionBar />
+          {plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
+        </div>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    navModel: getNavModel(state.navIndex, 'plugins'),
+    plugins: getPlugins(state.plugins),
+    layoutMode: getLayoutMode(state.plugins),
+  };
+}
+
+const mapDispatchToProps = {
+  loadPlugins,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(PluginListPage));

+ 59 - 0
public/app/features/plugins/__mocks__/pluginMocks.ts

@@ -0,0 +1,59 @@
+import { Plugin } from 'app/types';
+
+export const getMockPlugins = (amount: number): Plugin[] => {
+  const plugins = [];
+
+  for (let i = 0; i <= amount; i++) {
+    plugins.push({
+      defaultNavUrl: 'some/url',
+      enabled: false,
+      hasUpdate: false,
+      id: `${i}`,
+      info: {
+        author: {
+          name: 'Grafana Labs',
+          url: 'url/to/GrafanaLabs',
+        },
+        description: 'pretty decent plugin',
+        links: ['one link'],
+        logos: { small: 'small/logo', large: 'large/logo' },
+        screenshots: `screenshot/${i}`,
+        updated: '2018-09-26',
+        version: '1',
+      },
+      latestVersion: `1.${i}`,
+      name: `pretty cool plugin-${i}`,
+      pinned: false,
+      state: '',
+      type: '',
+    });
+  }
+
+  return plugins;
+};
+
+export const getMockPlugin = () => {
+  return {
+    defaultNavUrl: 'some/url',
+    enabled: false,
+    hasUpdate: false,
+    id: '1',
+    info: {
+      author: {
+        name: 'Grafana Labs',
+        url: 'url/to/GrafanaLabs',
+      },
+      description: 'pretty decent plugin',
+      links: ['one link'],
+      logos: { small: 'small/logo', large: 'large/logo' },
+      screenshots: 'screenshot/1',
+      updated: '2018-09-26',
+      version: '1',
+    },
+    latestVersion: '1',
+    name: 'pretty cool plugin 1',
+    pinned: false,
+    state: '',
+    type: '',
+  };
+};

+ 40 - 0
public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap

@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="page-action-bar"
+>
+  <div
+    className="gf-form gf-form--grow"
+  >
+    <label
+      className="gf-form--has-input-icon"
+    >
+      <input
+        className="gf-form-input width-20"
+        onChange={[Function]}
+        placeholder="Filter by name or type"
+        type="text"
+        value=""
+      />
+      <i
+        className="gf-form-input-icon fa fa-search"
+      />
+    </label>
+    <LayoutSelector
+      mode="grid"
+      onLayoutModeChanged={[Function]}
+    />
+  </div>
+  <div
+    className="page-action-bar__spacer"
+  />
+  <a
+    className="btn btn-success"
+    href="https://grafana.com/plugins?utm_source=grafana_plugin_list"
+    target="_blank"
+  >
+    Find more plugins on Grafana.com
+  </a>
+</div>
+`;

+ 210 - 0
public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap

@@ -0,0 +1,210 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<section
+  className="card-section card-list-layout-grid"
+>
+  <ol
+    className="card-list"
+  >
+    <PluginListItem
+      key="pretty cool plugin-0-0"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "0",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/0",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.0",
+          "name": "pretty cool plugin-0",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-1-1"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "1",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/1",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.1",
+          "name": "pretty cool plugin-1",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-2-2"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "2",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/2",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.2",
+          "name": "pretty cool plugin-2",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-3-3"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "3",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/3",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.3",
+          "name": "pretty cool plugin-3",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-4-4"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "4",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/4",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.4",
+          "name": "pretty cool plugin-4",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+    <PluginListItem
+      key="pretty cool plugin-5-5"
+      plugin={
+        Object {
+          "defaultNavUrl": "some/url",
+          "enabled": false,
+          "hasUpdate": false,
+          "id": "5",
+          "info": Object {
+            "author": Object {
+              "name": "Grafana Labs",
+              "url": "url/to/GrafanaLabs",
+            },
+            "description": "pretty decent plugin",
+            "links": Array [
+              "one link",
+            ],
+            "logos": Object {
+              "large": "large/logo",
+              "small": "small/logo",
+            },
+            "screenshots": "screenshot/5",
+            "updated": "2018-09-26",
+            "version": "1",
+          },
+          "latestVersion": "1.5",
+          "name": "pretty cool plugin-5",
+          "pinned": false,
+          "state": "",
+          "type": "",
+        }
+      }
+    />
+  </ol>
+</section>
+`;

+ 106 - 0
public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap

@@ -0,0 +1,106 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<li
+  className="card-item-wrapper"
+>
+  <a
+    className="card-item"
+    href="plugins/1/edit"
+  >
+    <div
+      className="card-item-header"
+    >
+      <div
+        className="card-item-type"
+      >
+        <i
+          className="icon-gf icon-gf-"
+        />
+      </div>
+    </div>
+    <div
+      className="card-item-body"
+    >
+      <figure
+        className="card-item-figure"
+      >
+        <img
+          src="small/logo"
+        />
+      </figure>
+      <div
+        className="card-item-details"
+      >
+        <div
+          className="card-item-name"
+        >
+          pretty cool plugin 1
+        </div>
+        <div
+          className="card-item-sub-name"
+        >
+          By Grafana Labs
+        </div>
+      </div>
+    </div>
+  </a>
+</li>
+`;
+
+exports[`Render should render has plugin section 1`] = `
+<li
+  className="card-item-wrapper"
+>
+  <a
+    className="card-item"
+    href="plugins/1/edit"
+  >
+    <div
+      className="card-item-header"
+    >
+      <div
+        className="card-item-type"
+      >
+        <i
+          className="icon-gf icon-gf-"
+        />
+      </div>
+      <div
+        className="card-item-notice"
+      >
+        <span
+          bs-tooltip="plugin.latestVersion"
+        >
+          Update available!
+        </span>
+      </div>
+    </div>
+    <div
+      className="card-item-body"
+    >
+      <figure
+        className="card-item-figure"
+      >
+        <img
+          src="small/logo"
+        />
+      </figure>
+      <div
+        className="card-item-details"
+      >
+        <div
+          className="card-item-name"
+        >
+          pretty cool plugin 1
+        </div>
+        <div
+          className="card-item-sub-name"
+        >
+          By Grafana Labs
+        </div>
+      </div>
+    </div>
+  </a>
+</li>
+`;

+ 18 - 0
public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap

@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(PluginActionBar) />
+    <PluginList
+      layoutMode="grid"
+      plugins={Array []}
+    />
+  </div>
+</div>
+`;

+ 0 - 1
public/app/features/plugins/all.ts

@@ -1,6 +1,5 @@
 import './plugin_edit_ctrl';
 import './plugin_page_ctrl';
-import './plugin_list_ctrl';
 import './import_list/import_list';
 import './ds_edit_ctrl';
 import './ds_dashboards_ctrl';

+ 0 - 45
public/app/features/plugins/partials/plugin_list.html

@@ -1,45 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <div class="page-action-bar">
-		<div class="gf-form gf-form--grow">
-			<label class="gf-form--has-input-icon">
-				<input type="text" class="gf-form-input width-20" ng-model="ctrl.searchQuery" ng-change="ctrl.onQueryUpdated()" placeholder="Filter by name or type" />
-				<i class="gf-form-input-icon fa fa-search"></i>
-			</label>
-			<layout-selector />
-		</div>
-		<div class="page-action-bar__spacer"></div>
-		<a class="btn btn-success" href="https://grafana.com/plugins?utm_source=grafana_plugin_list" target="_blank">
-			Find more plugins on Grafana.com
-		</a>
-	</div>
-
-	<section class="card-section" layout-mode>
-
-		<ol class="card-list" >
-			<li class="card-item-wrapper" ng-repeat="plugin in ctrl.plugins">
-				<a class="card-item" href="plugins/{{plugin.id}}/edit">
-					<div class="card-item-header">
-						<div class="card-item-type">
-							<i class="icon-gf icon-gf-{{plugin.type}}"></i>
-							{{plugin.type}}
-						</div>
-						<div class="card-item-notice" ng-show="plugin.hasUpdate">
-							<span bs-tooltip="plugin.latestVersion">Update available!</span>
-						</div>
-					</div>
-					<div class="card-item-body">
-						<figure class="card-item-figure">
-							<img ng-src="{{plugin.info.logos.small}}">
-						</figure>
-						<div class="card-item-details">
-							<div class="card-item-name">{{plugin.name}}</div>
-							<div class="card-item-sub-name">By {{plugin.info.author.name}}</div>
-						</div>
-					</div>
-				</a>
-			</li>
-		</ol>
-	</section>
-</div>

+ 0 - 30
public/app/features/plugins/plugin_list_ctrl.ts

@@ -1,30 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-
-export class PluginListCtrl {
-  plugins: any[];
-  tabIndex: number;
-  navModel: any;
-  searchQuery: string;
-  allPlugins: any[];
-
-  /** @ngInject */
-  constructor(private backendSrv: any, $location, navModelSrv) {
-    this.tabIndex = 0;
-    this.navModel = navModelSrv.getNav('cfg', 'plugins', 0);
-
-    this.backendSrv.get('api/plugins', { embedded: 0 }).then(plugins => {
-      this.plugins = plugins;
-      this.allPlugins = plugins;
-    });
-  }
-
-  onQueryUpdated() {
-    const regex = new RegExp(this.searchQuery, 'ig');
-    this.plugins = _.filter(this.allPlugins, item => {
-      return regex.test(item.name) || regex.test(item.type);
-    });
-  }
-}
-
-angular.module('grafana.controllers').controller('PluginListCtrl', PluginListCtrl);

+ 51 - 0
public/app/features/plugins/state/actions.ts

@@ -0,0 +1,51 @@
+import { Plugin, StoreState } from 'app/types';
+import { ThunkAction } from 'redux-thunk';
+import { getBackendSrv } from '../../../core/services/backend_srv';
+import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
+
+export enum ActionTypes {
+  LoadPlugins = 'LOAD_PLUGINS',
+  SetPluginsSearchQuery = 'SET_PLUGIN_SEARCH_QUERY',
+  SetLayoutMode = 'SET_LAYOUT_MODE',
+}
+
+export interface LoadPluginsAction {
+  type: ActionTypes.LoadPlugins;
+  payload: Plugin[];
+}
+
+export interface SetPluginsSearchQueryAction {
+  type: ActionTypes.SetPluginsSearchQuery;
+  payload: string;
+}
+
+export interface SetLayoutModeAction {
+  type: ActionTypes.SetLayoutMode;
+  payload: LayoutMode;
+}
+
+export const setLayoutMode = (mode: LayoutMode): SetLayoutModeAction => ({
+  type: ActionTypes.SetLayoutMode,
+  payload: mode,
+});
+
+export const setPluginsSearchQuery = (query: string): SetPluginsSearchQueryAction => ({
+  type: ActionTypes.SetPluginsSearchQuery,
+  payload: query,
+});
+
+const pluginsLoaded = (plugins: Plugin[]): LoadPluginsAction => ({
+  type: ActionTypes.LoadPlugins,
+  payload: plugins,
+});
+
+export type Action = LoadPluginsAction | SetPluginsSearchQueryAction | SetLayoutModeAction;
+
+type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
+
+export function loadPlugins(): ThunkResult<void> {
+  return async dispatch => {
+    const result = await getBackendSrv().get('api/plugins', { embedded: 0 });
+    dispatch(pluginsLoaded(result));
+  };
+}

+ 27 - 0
public/app/features/plugins/state/reducers.ts

@@ -0,0 +1,27 @@
+import { Action, ActionTypes } from './actions';
+import { Plugin, PluginsState } from 'app/types';
+import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelector';
+
+export const initialState: PluginsState = {
+  plugins: [] as Plugin[],
+  searchQuery: '',
+  layoutMode: LayoutModes.Grid,
+};
+
+export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
+  switch (action.type) {
+    case ActionTypes.LoadPlugins:
+      return { ...state, plugins: action.payload };
+
+    case ActionTypes.SetPluginsSearchQuery:
+      return { ...state, searchQuery: action.payload };
+
+    case ActionTypes.SetLayoutMode:
+      return { ...state, layoutMode: action.payload };
+  }
+  return state;
+};
+
+export default {
+  plugins: pluginsReducer,
+};

+ 31 - 0
public/app/features/plugins/state/selectors.test.ts

@@ -0,0 +1,31 @@
+import { getPlugins, getPluginsSearchQuery } from './selectors';
+import { initialState } from './reducers';
+import { getMockPlugins } from '../__mocks__/pluginMocks';
+
+describe('Selectors', () => {
+  const mockState = initialState;
+
+  it('should return search query', () => {
+    mockState.searchQuery = 'test';
+    const query = getPluginsSearchQuery(mockState);
+
+    expect(query).toEqual(mockState.searchQuery);
+  });
+
+  it('should return plugins', () => {
+    mockState.plugins = getMockPlugins(5);
+    mockState.searchQuery = '';
+
+    const plugins = getPlugins(mockState);
+
+    expect(plugins).toEqual(mockState.plugins);
+  });
+
+  it('should filter plugins', () => {
+    mockState.searchQuery = 'plugin-1';
+
+    const plugins = getPlugins(mockState);
+
+    expect(plugins.length).toEqual(1);
+  });
+});

+ 10 - 0
public/app/features/plugins/state/selectors.ts

@@ -0,0 +1,10 @@
+export const getPlugins = state => {
+  const regex = new RegExp(state.searchQuery, 'i');
+
+  return state.plugins.filter(item => {
+    return regex.test(item.name) || regex.test(item.info.author.name) || regex.test(item.info.description);
+  });
+};
+
+export const getPluginsSearchQuery = state => state.searchQuery;
+export const getLayoutMode = state => state.layoutMode;

+ 58 - 0
public/app/features/templating/TextBoxVariable.ts

@@ -0,0 +1,58 @@
+import { Variable, assignModelProperties, variableTypes } from './variable';
+
+export class TextBoxVariable implements Variable {
+  query: string;
+  current: any;
+  options: any[];
+  skipUrlSync: boolean;
+
+  defaults = {
+    type: 'textbox',
+    name: '',
+    hide: 2,
+    label: '',
+    query: '',
+    current: {},
+    options: [],
+    skipUrlSync: false,
+  };
+
+  /** @ngInject */
+  constructor(private model, private variableSrv) {
+    assignModelProperties(this, model, this.defaults);
+  }
+
+  getSaveModel() {
+    assignModelProperties(this.model, this, this.defaults);
+    return this.model;
+  }
+
+  setValue(option) {
+    this.variableSrv.setOptionAsCurrent(this, option);
+  }
+
+  updateOptions() {
+    this.options = [{ text: this.query.trim(), value: this.query.trim() }];
+    this.current = this.options[0];
+    return Promise.resolve();
+  }
+
+  dependsOn(variable) {
+    return false;
+  }
+
+  setValueFromUrl(urlValue) {
+    this.query = urlValue;
+    return this.variableSrv.setOptionFromUrl(this, urlValue);
+  }
+
+  getValueForUrl() {
+    return this.current.value;
+  }
+}
+
+variableTypes['textbox'] = {
+  name: 'Text box',
+  ctor: TextBoxVariable,
+  description: 'Define a textbox variable, where users can enter any arbitrary string',
+};

+ 2 - 0
public/app/features/templating/all.ts

@@ -9,6 +9,7 @@ import { DatasourceVariable } from './datasource_variable';
 import { CustomVariable } from './custom_variable';
 import { ConstantVariable } from './constant_variable';
 import { AdhocVariable } from './adhoc_variable';
+import { TextBoxVariable } from './TextBoxVariable';
 
 coreModule.factory('templateSrv', () => {
   return templateSrv;
@@ -22,4 +23,5 @@ export {
   CustomVariable,
   ConstantVariable,
   AdhocVariable,
+  TextBoxVariable,
 };

+ 8 - 0
public/app/features/templating/partials/editor.html

@@ -155,6 +155,14 @@
 			</div>
 		</div>
 
+		<div ng-if="current.type === 'textbox'" class="gf-form-group">
+			<h5 class="section-heading">Text options</h5>
+			<div class="gf-form">
+				<span class="gf-form-label">Default value</span>
+				<input type="text" class="gf-form-input" ng-model='current.query' ng-blur="runQuery()" placeholder="default value, if any"></input>
+			</div>
+		</div>
+
 		<div ng-if="current.type === 'query'" class="gf-form-group">
 			<h5 class="section-heading">Query Options</h5>
 

+ 1 - 1
public/app/plugins/datasource/cloudwatch/partials/config.html

@@ -39,7 +39,7 @@
   <div class="gf-form">
     <label class="gf-form-label width-13">Default Region</label>
     <div class="gf-form-select-wrapper max-width-18 gf-form-select-wrapper--has-help-icon">
-      <select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ca-central-1', 'cn-north-1', 'cn-northwest-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'sa-east-1', 'us-east-1', 'us-east-2', 'us-gov-west-1', 'us-west-1', 'us-west-2']"></select>
+      <select class="gf-form-input" ng-model="ctrl.current.jsonData.defaultRegion" ng-options="region for region in ['ap-northeast-1', 'ap-northeast-2', 'ap-southeast-1', 'ap-southeast-2', 'ap-south-1', 'ca-central-1', 'cn-north-1', 'cn-northwest-1', 'eu-central-1', 'eu-west-1', 'eu-west-2', 'eu-west-3', 'sa-east-1', 'us-east-1', 'us-east-2', 'us-gov-west-1', 'us-west-1', 'us-west-2', 'us-isob-east-1', 'us-iso-east-1']"></select>
       <info-popover mode="right-absolute">
         Specify the region, such as for US West (Oregon) use ` us-west-2 ` as the region.
       </info-popover>

+ 5 - 3
public/app/routes/routes.ts

@@ -6,6 +6,7 @@ import AlertRuleList from 'app/features/alerting/AlertRuleList';
 import TeamPages from 'app/features/teams/TeamPages';
 import TeamList from 'app/features/teams/TeamList';
 import ApiKeys from 'app/features/api-keys/ApiKeysPage';
+import PluginListPage from 'app/features/plugins/PluginListPage';
 import FolderSettingsPage from 'app/features/folders/FolderSettingsPage';
 import FolderPermissions from 'app/features/folders/FolderPermissions';
 
@@ -249,9 +250,10 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
       controllerAs: 'ctrl',
     })
     .when('/plugins', {
-      templateUrl: 'public/app/features/plugins/partials/plugin_list.html',
-      controller: 'PluginListCtrl',
-      controllerAs: 'ctrl',
+      template: '<react-container />',
+      resolve: {
+        component: () => PluginListPage,
+      },
     })
     .when('/plugins/:pluginId/edit', {
       templateUrl: 'public/app/features/plugins/partials/plugin_edit.html',

+ 2 - 0
public/app/store/configureStore.ts

@@ -7,6 +7,7 @@ import teamsReducers from 'app/features/teams/state/reducers';
 import apiKeysReducers from 'app/features/api-keys/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';
 
 const rootReducer = combineReducers({
   ...sharedReducers,
@@ -15,6 +16,7 @@ const rootReducer = combineReducers({
   ...apiKeysReducers,
   ...foldersReducers,
   ...dashboardReducers,
+  ...pluginReducers,
 });
 
 export let store;

+ 3 - 1
public/app/types/index.ts

@@ -6,9 +6,9 @@ import { FolderDTO, FolderState, FolderInfo } from './folders';
 import { DashboardState } from './dashboard';
 import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
 import { DataSource } from './datasources';
-import { PluginMeta } from './plugins';
 import { ApiKey, ApiKeysState, NewApiKey } from './apiKeys';
 import { User } from './user';
+import { PluginMeta, Plugin, PluginsState } from './plugins';
 
 export {
   Team,
@@ -39,6 +39,8 @@ export {
   ApiKeysState,
   NewApiKey,
   User,
+  Plugin,
+  PluginsState,
 };
 
 export interface StoreState {

+ 28 - 0
public/app/types/plugins.ts

@@ -12,8 +12,36 @@ export interface PluginInclude {
 }
 
 export interface PluginMetaInfo {
+  author: {
+    name: string;
+    url: string;
+  };
+  description: string;
+  links: string[];
   logos: {
     large: string;
     small: string;
   };
+  screenshots: string;
+  updated: string;
+  version: string;
+}
+
+export interface Plugin {
+  defaultNavUrl: string;
+  enabled: boolean;
+  hasUpdate: boolean;
+  id: string;
+  info: PluginMetaInfo;
+  latestVersion: string;
+  name: string;
+  pinned: boolean;
+  state: string;
+  type: string;
+}
+
+export interface PluginsState {
+  plugins: Plugin[];
+  searchQuery: string;
+  layoutMode: string;
 }

+ 6 - 12
public/sass/components/_form_select_box.scss

@@ -3,7 +3,7 @@ $select-menu-max-height: 300px;
 $select-item-font-size: $font-size-base;
 $select-item-bg: $dropdownBackground;
 $select-item-fg: $input-color;
-$select-option-bg: $dropdownBackground;
+$select-option-bg: $menu-dropdown-bg;
 $select-option-color: $input-color;
 $select-noresults-color: $text-color;
 $select-input-bg: $input-bg;
@@ -82,20 +82,14 @@ $select-option-selected-bg: $dropdownLinkBackgroundActive;
     width: auto;
   }
 
+  .Select-option {
+    border-left: 2px solid transparent;
+  }
+
   .Select-option.is-focused {
     background-color: $dropdownLinkBackgroundHover;
     color: $dropdownLinkColorHover;
-
-    &::before {
-      position: absolute;
-      left: 0;
-      top: 0;
-      height: 100%;
-      width: 2px;
-      display: block;
-      content: '';
-      background-image: linear-gradient(to bottom, #ffd500 0%, #ff4400 99%, #ff4400 100%);
-    }
+    @include left-brand-border-gradient();
   }
 }
 

+ 1 - 1
public/sass/pages/_explore.scss

@@ -69,7 +69,7 @@
   }
 
   .datasource-picker {
-    min-width: 10rem;
+    min-width: 200px;
   }
 
   .timepicker {

+ 4 - 1
public/views/index.template.html

@@ -275,7 +275,10 @@
     document.head.insertBefore(myCSS, document.head.childNodes[document.head.childNodes.length - 1].nextSibling);
     // switch loader to show all has loaded
     window.onload = function() {
-      document.getElementsByClassName("preloader")[0].className = "preloader preloader--done";
+      var preloader = document.getElementsByClassName("preloader");
+      if (preloader.length) {
+        preloader[0].className = "preloader preloader--done";
+      }
     };
   </script>
 

+ 5 - 4
scripts/grunt/default_task.js

@@ -1,5 +1,5 @@
 // Lint and build CSS
-module.exports = function(grunt) {
+module.exports = function (grunt) {
   'use strict';
 
   grunt.registerTask('default', [
@@ -18,15 +18,16 @@ module.exports = function(grunt) {
   grunt.registerTask('precommit', [
     'sasslint',
     'exec:tslint',
+    'exec:tsc',
     'no-only-tests'
   ]);
 
-  grunt.registerTask('no-only-tests', function() {
+  grunt.registerTask('no-only-tests', function () {
     var files = grunt.file.expand('public/**/*_specs\.ts', 'public/**/*_specs\.js');
 
-    files.forEach(function(spec) {
+    files.forEach(function (spec) {
       var rows = grunt.file.read(spec).split('\n');
-      rows.forEach(function(row) {
+      rows.forEach(function (row) {
         if (row.indexOf('.only(') > 0) {
           grunt.log.errorlns(row);
           grunt.fail.warn('found only statement in test: ' + spec)

+ 2 - 1
scripts/grunt/options/exec.js

@@ -1,8 +1,9 @@
-module.exports = function(config, grunt) {
+module.exports = function (config, grunt) {
   'use strict';
 
   return {
     tslint: 'node ./node_modules/tslint/lib/tslintCli.js -c tslint.json --project ./tsconfig.json',
+    tsc: 'yarn tsc --noEmit',
     jest: 'node ./node_modules/jest-cli/bin/jest.js --maxWorkers 2',
     webpack: 'node ./node_modules/webpack/bin/webpack.js --config scripts/webpack/webpack.prod.js',
   };