Просмотр исходного кода

Merge pull request #13438 from grafana/plugin-list-to-react

Plugin list to react
Torkel Ödegaard 7 лет назад
Родитель
Сommit
5eede26a24
25 измененных файлов с 939 добавлено и 80 удалено
  1. 39 0
      public/app/core/components/LayoutSelector/LayoutSelector.tsx
  2. 31 0
      public/app/features/plugins/PluginActionBar.test.tsx
  3. 62 0
      public/app/features/plugins/PluginActionBar.tsx
  4. 25 0
      public/app/features/plugins/PluginList.test.tsx
  5. 32 0
      public/app/features/plugins/PluginList.tsx
  6. 33 0
      public/app/features/plugins/PluginListItem.test.tsx
  7. 39 0
      public/app/features/plugins/PluginListItem.tsx
  8. 32 0
      public/app/features/plugins/PluginListPage.test.tsx
  9. 56 0
      public/app/features/plugins/PluginListPage.tsx
  10. 59 0
      public/app/features/plugins/__mocks__/pluginMocks.ts
  11. 40 0
      public/app/features/plugins/__snapshots__/PluginActionBar.test.tsx.snap
  12. 210 0
      public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap
  13. 106 0
      public/app/features/plugins/__snapshots__/PluginListItem.test.tsx.snap
  14. 18 0
      public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
  15. 0 1
      public/app/features/plugins/all.ts
  16. 0 45
      public/app/features/plugins/partials/plugin_list.html
  17. 0 30
      public/app/features/plugins/plugin_list_ctrl.ts
  18. 51 0
      public/app/features/plugins/state/actions.ts
  19. 27 0
      public/app/features/plugins/state/reducers.ts
  20. 31 0
      public/app/features/plugins/state/selectors.test.ts
  21. 10 0
      public/app/features/plugins/state/selectors.ts
  22. 5 3
      public/app/routes/routes.ts
  23. 2 0
      public/app/store/configureStore.ts
  24. 3 1
      public/app/types/index.ts
  25. 28 0
      public/app/types/plugins.ts

+ 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;

+ 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;

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

@@ -5,6 +5,7 @@ import ServerStats from 'app/features/admin/ServerStats';
 import AlertRuleList from 'app/features/alerting/AlertRuleList';
 import TeamPages from 'app/features/teams/TeamPages';
 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';
 
@@ -245,9 +246,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

@@ -6,6 +6,7 @@ import alertingReducers from 'app/features/alerting/state/reducers';
 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';
 
 const rootReducer = combineReducers({
   ...sharedReducers,
@@ -13,6 +14,7 @@ const rootReducer = combineReducers({
   ...teamsReducers,
   ...foldersReducers,
   ...dashboardReducers,
+  ...pluginReducers,
 });
 
 export let store;

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

@@ -6,7 +6,7 @@ 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 { PluginMeta, Plugin, PluginsState } from './plugins';
 
 export {
   Team,
@@ -33,6 +33,8 @@ export {
   PermissionLevel,
   DataSource,
   PluginMeta,
+  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;
 }