Преглед на файлове

Added Loading state on org pages

Peter Holmberg преди 7 години
родител
ревизия
02e7d713a1
променени са 31 файла, в които са добавени 244 реда и са изтрити 166 реда
  1. 17 0
      public/app/core/components/PageLoader/PageLoader.tsx
  2. 2 0
      public/app/features/api-keys/ApiKeysPage.test.tsx
  3. 41 29
      public/app/features/api-keys/ApiKeysPage.tsx
  4. 5 26
      public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap
  5. 2 1
      public/app/features/api-keys/state/reducers.ts
  6. 2 2
      public/app/features/api-keys/state/selectors.test.ts
  7. 2 0
      public/app/features/datasources/DataSourcesListPage.test.tsx
  8. 9 6
      public/app/features/datasources/DataSourcesListPage.tsx
  9. 2 13
      public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap
  10. 2 1
      public/app/features/datasources/state/reducers.ts
  11. 11 8
      public/app/features/plugins/PluginListPage.test.tsx
  12. 18 2
      public/app/features/plugins/PluginListPage.tsx
  13. 27 0
      public/app/features/plugins/__snapshots__/PluginListPage.test.tsx.snap
  14. 2 1
      public/app/features/plugins/state/reducers.ts
  15. 2 0
      public/app/features/teams/TeamList.test.tsx
  16. 15 2
      public/app/features/teams/TeamList.tsx
  17. 3 18
      public/app/features/teams/__snapshots__/TeamList.test.tsx.snap
  18. 2 2
      public/app/features/teams/state/reducers.ts
  19. 2 2
      public/app/features/teams/state/selectors.test.ts
  20. 9 0
      public/app/features/users/UsersListPage.test.tsx
  21. 22 11
      public/app/features/users/UsersListPage.tsx
  22. 20 1
      public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap
  23. 4 3
      public/app/features/users/state/reducers.ts
  24. 1 0
      public/app/types/apiKeys.ts
  25. 1 0
      public/app/types/datasources.ts
  26. 1 0
      public/app/types/plugins.ts
  27. 1 0
      public/app/types/teams.ts
  28. 1 0
      public/app/types/user.ts
  29. 0 37
      public/app/types/users.ts
  30. 2 1
      public/sass/_grafana.scss
  31. 16 0
      public/sass/components/_page_loader.scss

+ 17 - 0
public/app/core/components/PageLoader/PageLoader.tsx

@@ -0,0 +1,17 @@
+import React, { SFC } from 'react';
+
+interface Props {
+  pageName: string;
+}
+
+const PageLoader: SFC<Props> = ({ pageName }) => {
+  const loadingText = `Loading ${pageName}...`;
+  return (
+    <div className="page-loader-wrapper">
+      <i className="page-loader-wrapper__spinner fa fa-spinner fa-spin" />
+      <div className="page-loader-wrapper__text">{loadingText}</div>
+    </div>
+  );
+};
+
+export default PageLoader;

+ 2 - 0
public/app/features/api-keys/ApiKeysPage.test.tsx

@@ -9,6 +9,7 @@ const setup = (propOverrides?: object) => {
     navModel: {} as NavModel,
     apiKeys: [] as ApiKey[],
     searchQuery: '',
+    hasFetched: false,
     loadApiKeys: jest.fn(),
     deleteApiKey: jest.fn(),
     setSearchQuery: jest.fn(),
@@ -35,6 +36,7 @@ describe('Render', () => {
   it('should render API keys table', () => {
     const { wrapper } = setup({
       apiKeys: getMultipleMockKeys(5),
+      hasFetched: true,
     });
 
     expect(wrapper).toMatchSnapshot();

+ 41 - 29
public/app/features/api-keys/ApiKeysPage.tsx

@@ -8,6 +8,7 @@ import { getApiKeys } from './state/selectors';
 import { loadApiKeys, deleteApiKey, setSearchQuery, addApiKey } from './state/actions';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import SlideDown from 'app/core/components/Animations/SlideDown';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
 import ApiKeysAddedModal from './ApiKeysAddedModal';
 import config from 'app/core/config';
 import appEvents from 'app/core/app_events';
@@ -16,6 +17,7 @@ export interface Props {
   navModel: NavModel;
   apiKeys: ApiKey[];
   searchQuery: string;
+  hasFetched: boolean;
   loadApiKeys: typeof loadApiKeys;
   deleteApiKey: typeof deleteApiKey;
   setSearchQuery: typeof setSearchQuery;
@@ -99,9 +101,45 @@ export class ApiKeysPage extends PureComponent<Props, any> {
     });
   };
 
+  renderTable() {
+    const { apiKeys } = this.props;
+
+    return [
+      <h3 key="header" className="page-heading">
+        Existing Keys
+      </h3>,
+      <table key="table" className="filter-table">
+        <thead>
+          <tr>
+            <th>Name</th>
+            <th>Role</th>
+            <th style={{ width: '34px' }} />
+          </tr>
+        </thead>
+        {apiKeys.length > 0 && (
+          <tbody>
+            {apiKeys.map(key => {
+              return (
+                <tr key={key.id}>
+                  <td>{key.name}</td>
+                  <td>{key.role}</td>
+                  <td>
+                    <a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
+                      <i className="fa fa-remove" />
+                    </a>
+                  </td>
+                </tr>
+              );
+            })}
+          </tbody>
+        )}
+      </table>,
+    ];
+  }
+
   render() {
     const { newApiKey, isAdding } = this.state;
-    const { navModel, apiKeys, searchQuery } = this.props;
+    const { hasFetched, navModel, searchQuery } = this.props;
 
     return (
       <div>
@@ -170,34 +208,7 @@ export class ApiKeysPage extends PureComponent<Props, any> {
               </form>
             </div>
           </SlideDown>
-
-          <h3 className="page-heading">Existing Keys</h3>
-          <table className="filter-table">
-            <thead>
-              <tr>
-                <th>Name</th>
-                <th>Role</th>
-                <th style={{ width: '34px' }} />
-              </tr>
-            </thead>
-            {apiKeys.length > 0 ? (
-              <tbody>
-                {apiKeys.map(key => {
-                  return (
-                    <tr key={key.id}>
-                      <td>{key.name}</td>
-                      <td>{key.role}</td>
-                      <td>
-                        <a onClick={() => this.onDeleteApiKey(key)} className="btn btn-danger btn-mini">
-                          <i className="fa fa-remove" />
-                        </a>
-                      </td>
-                    </tr>
-                  );
-                })}
-              </tbody>
-            ) : null}
-          </table>
+          {hasFetched ? this.renderTable() : <PageLoader pageName="Api keys" />}
         </div>
       </div>
     );
@@ -209,6 +220,7 @@ function mapStateToProps(state) {
     navModel: getNavModel(state.navIndex, 'apikeys'),
     apiKeys: getApiKeys(state.apiKeys),
     searchQuery: state.apiKeys.searchQuery,
+    hasFetched: state.apiKeys.hasFetched,
   };
 }
 

+ 5 - 26
public/app/features/api-keys/__snapshots__/ApiKeysPage.test.tsx.snap

@@ -138,11 +138,13 @@ exports[`Render should render API keys table 1`] = `
     </Component>
     <h3
       className="page-heading"
+      key="header"
     >
       Existing Keys
     </h3>
     <table
       className="filter-table"
+      key="table"
     >
       <thead>
         <tr>
@@ -404,32 +406,9 @@ exports[`Render should render component 1`] = `
         </form>
       </div>
     </Component>
-    <h3
-      className="page-heading"
-    >
-      Existing Keys
-    </h3>
-    <table
-      className="filter-table"
-    >
-      <thead>
-        <tr>
-          <th>
-            Name
-          </th>
-          <th>
-            Role
-          </th>
-          <th
-            style={
-              Object {
-                "width": "34px",
-              }
-            }
-          />
-        </tr>
-      </thead>
-    </table>
+    <PageLoader
+      pageName="Api keys"
+    />
   </div>
 </div>
 `;

+ 2 - 1
public/app/features/api-keys/state/reducers.ts

@@ -4,12 +4,13 @@ import { Action, ActionTypes } from './actions';
 export const initialApiKeysState: ApiKeysState = {
   keys: [],
   searchQuery: '',
+  hasFetched: false,
 };
 
 export const apiKeysReducer = (state = initialApiKeysState, action: Action): ApiKeysState => {
   switch (action.type) {
     case ActionTypes.LoadApiKeys:
-      return { ...state, keys: action.payload };
+      return { ...state, hasFetched: true, keys: action.payload };
     case ActionTypes.SetApiKeysSearchQuery:
       return { ...state, searchQuery: action.payload };
   }

+ 2 - 2
public/app/features/api-keys/state/selectors.test.ts

@@ -7,7 +7,7 @@ describe('API Keys selectors', () => {
     const mockKeys = getMultipleMockKeys(5);
 
     it('should return all keys if no search query', () => {
-      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '' };
+      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '', hasFetched: false };
 
       const keys = getApiKeys(mockState);
 
@@ -15,7 +15,7 @@ describe('API Keys selectors', () => {
     });
 
     it('should filter keys if search query exists', () => {
-      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5' };
+      const mockState: ApiKeysState = { keys: mockKeys, searchQuery: '5', hasFetched: false };
 
       const keys = getApiKeys(mockState);
 

+ 2 - 0
public/app/features/datasources/DataSourcesListPage.test.tsx

@@ -15,6 +15,7 @@ const setup = (propOverrides?: object) => {
     searchQuery: '',
     setDataSourcesSearchQuery: jest.fn(),
     setDataSourcesLayoutMode: jest.fn(),
+    hasFetched: false,
   };
 
   Object.assign(props, propOverrides);
@@ -33,6 +34,7 @@ describe('Render', () => {
     const wrapper = setup({
       dataSources: getMockDataSources(5),
       dataSourcesCount: 5,
+      hasFetched: true,
     });
 
     expect(wrapper).toMatchSnapshot();

+ 9 - 6
public/app/features/datasources/DataSourcesListPage.tsx

@@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
 import { connect } from 'react-redux';
 import { hot } from 'react-hot-loader';
 import PageHeader from '../../core/components/PageHeader/PageHeader';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
 import OrgActionBar from '../../core/components/OrgActionBar/OrgActionBar';
 import EmptyListCTA from '../../core/components/EmptyListCTA/EmptyListCTA';
 import DataSourcesList from './DataSourcesList';
@@ -22,6 +23,7 @@ export interface Props {
   dataSourcesCount: number;
   layoutMode: LayoutMode;
   searchQuery: string;
+  hasFetched: boolean;
   loadDataSources: typeof loadDataSources;
   setDataSourcesLayoutMode: typeof setDataSourcesLayoutMode;
   setDataSourcesSearchQuery: typeof setDataSourcesSearchQuery;
@@ -56,6 +58,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
       searchQuery,
       setDataSourcesSearchQuery,
       setDataSourcesLayoutMode,
+      hasFetched,
     } = this.props;
 
     const linkButton = {
@@ -67,10 +70,10 @@ export class DataSourcesListPage extends PureComponent<Props> {
       <div>
         <PageHeader model={navModel} />
         <div className="page-container page-body">
-          {dataSourcesCount === 0 ? (
-            <EmptyListCTA model={emptyListModel} />
-          ) : (
-            [
+          {!hasFetched && <PageLoader pageName="Data sources" />}
+          {hasFetched && dataSourcesCount === 0 && <EmptyListCTA model={emptyListModel} />}
+          {hasFetched &&
+            dataSourcesCount > 0 && [
               <OrgActionBar
                 layoutMode={layoutMode}
                 searchQuery={searchQuery}
@@ -80,8 +83,7 @@ export class DataSourcesListPage extends PureComponent<Props> {
                 key="action-bar"
               />,
               <DataSourcesList dataSources={dataSources} layoutMode={layoutMode} key="list" />,
-            ]
-          )}
+            ]}
         </div>
       </div>
     );
@@ -95,6 +97,7 @@ function mapStateToProps(state) {
     layoutMode: getDataSourcesLayoutMode(state.dataSources),
     dataSourcesCount: getDataSourcesCount(state.dataSources),
     searchQuery: getDataSourcesSearchQuery(state.dataSources),
+    hasFetched: state.dataSources.hasFetched,
   };
 }
 

+ 2 - 13
public/app/features/datasources/__snapshots__/DataSourcesListPage.test.tsx.snap

@@ -155,19 +155,8 @@ exports[`Render should render component 1`] = `
   <div
     className="page-container page-body"
   >
-    <EmptyListCTA
-      model={
-        Object {
-          "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",
-          "title": "There are no data sources defined yet",
-        }
-      }
+    <PageLoader
+      pageName="Data sources"
     />
   </div>
 </div>

+ 2 - 1
public/app/features/datasources/state/reducers.ts

@@ -9,12 +9,13 @@ const initialState: DataSourcesState = {
   dataSourcesCount: 0,
   dataSourceTypes: [] as Plugin[],
   dataSourceTypeSearchQuery: '',
+  hasFetched: false,
 };
 
 export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
   switch (action.type) {
     case ActionTypes.LoadDataSources:
-      return { ...state, dataSources: action.payload, dataSourcesCount: action.payload.length };
+      return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
 
     case ActionTypes.SetDataSourcesSearchQuery:
       return { ...state, searchQuery: action.payload };

+ 11 - 8
public/app/features/plugins/PluginListPage.test.tsx

@@ -13,22 +13,25 @@ const setup = (propOverrides?: object) => {
     setPluginsLayoutMode: jest.fn(),
     layoutMode: LayoutModes.Grid,
     loadPlugins: jest.fn(),
+    hasFetched: false,
   };
 
   Object.assign(props, propOverrides);
 
-  const wrapper = shallow(<PluginListPage {...props} />);
-  const instance = wrapper.instance() as PluginListPage;
-
-  return {
-    wrapper,
-    instance,
-  };
+  return shallow(<PluginListPage {...props} />);
 };
 
 describe('Render', () => {
   it('should render component', () => {
-    const { wrapper } = setup();
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render list', () => {
+    const wrapper = setup({
+      hasFetched: true,
+    });
 
     expect(wrapper).toMatchSnapshot();
   });

+ 18 - 2
public/app/features/plugins/PluginListPage.tsx

@@ -3,6 +3,7 @@ import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
 import PluginList from './PluginList';
 import { NavModel, Plugin } from 'app/types';
 import { loadPlugins, setPluginsLayoutMode, setPluginsSearchQuery } from './state/actions';
@@ -15,6 +16,7 @@ export interface Props {
   plugins: Plugin[];
   layoutMode: LayoutMode;
   searchQuery: string;
+  hasFetched: boolean;
   loadPlugins: typeof loadPlugins;
   setPluginsLayoutMode: typeof setPluginsLayoutMode;
   setPluginsSearchQuery: typeof setPluginsSearchQuery;
@@ -30,12 +32,21 @@ export class PluginListPage extends PureComponent<Props> {
   }
 
   render() {
-    const { navModel, plugins, layoutMode, setPluginsLayoutMode, setPluginsSearchQuery, searchQuery } = this.props;
+    const {
+      hasFetched,
+      navModel,
+      plugins,
+      layoutMode,
+      setPluginsLayoutMode,
+      setPluginsSearchQuery,
+      searchQuery,
+    } = this.props;
 
     const linkButton = {
       href: 'https://grafana.com/plugins?utm_source=grafana_plugin_list',
       title: 'Find more plugins on Grafana.com',
     };
+
     return (
       <div>
         <PageHeader model={navModel} />
@@ -47,7 +58,11 @@ export class PluginListPage extends PureComponent<Props> {
             setSearchQuery={query => setPluginsSearchQuery(query)}
             linkButton={linkButton}
           />
-          {plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />}
+          {hasFetched ? (
+            plugins && <PluginList plugins={plugins} layoutMode={layoutMode} />
+          ) : (
+            <PageLoader pageName="Plugins" />
+          )}
         </div>
       </div>
     );
@@ -60,6 +75,7 @@ function mapStateToProps(state) {
     plugins: getPlugins(state.plugins),
     layoutMode: getLayoutMode(state.plugins),
     searchQuery: getPluginsSearchQuery(state.plugins),
+    hasFetched: state.plugins.hasFetched,
   };
 }
 

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

@@ -1,6 +1,33 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <OrgActionBar
+      layoutMode="grid"
+      linkButton={
+        Object {
+          "href": "https://grafana.com/plugins?utm_source=grafana_plugin_list",
+          "title": "Find more plugins on Grafana.com",
+        }
+      }
+      onSetLayoutMode={[Function]}
+      searchQuery=""
+      setSearchQuery={[Function]}
+    />
+    <PageLoader
+      pageName="Plugins"
+    />
+  </div>
+</div>
+`;
+
+exports[`Render should render list 1`] = `
 <div>
   <PageHeader
     model={Object {}}

+ 2 - 1
public/app/features/plugins/state/reducers.ts

@@ -6,12 +6,13 @@ export const initialState: PluginsState = {
   plugins: [] as Plugin[],
   searchQuery: '',
   layoutMode: LayoutModes.Grid,
+  hasFetched: false,
 };
 
 export const pluginsReducer = (state = initialState, action: Action): PluginsState => {
   switch (action.type) {
     case ActionTypes.LoadPlugins:
-      return { ...state, plugins: action.payload };
+      return { ...state, hasFetched: true, plugins: action.payload };
 
     case ActionTypes.SetPluginsSearchQuery:
       return { ...state, searchQuery: action.payload };

+ 2 - 0
public/app/features/teams/TeamList.test.tsx

@@ -13,6 +13,7 @@ const setup = (propOverrides?: object) => {
     setSearchQuery: jest.fn(),
     searchQuery: '',
     teamsCount: 0,
+    hasFetched: false,
   };
 
   Object.assign(props, propOverrides);
@@ -36,6 +37,7 @@ describe('Render', () => {
     const { wrapper } = setup({
       teams: getMultipleMockTeams(5),
       teamsCount: 5,
+      hasFetched: true,
     });
 
     expect(wrapper).toMatchSnapshot();

+ 15 - 2
public/app/features/teams/TeamList.tsx

@@ -4,6 +4,7 @@ import { hot } from 'react-hot-loader';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import DeleteButton from 'app/core/components/DeleteButton/DeleteButton';
 import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
 import { NavModel, Team } from '../../types';
 import { loadTeams, deleteTeam, setSearchQuery } from './state/actions';
 import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors';
@@ -14,6 +15,7 @@ export interface Props {
   teams: Team[];
   searchQuery: string;
   teamsCount: number;
+  hasFetched: boolean;
   loadTeams: typeof loadTeams;
   deleteTeam: typeof deleteTeam;
   setSearchQuery: typeof setSearchQuery;
@@ -125,13 +127,23 @@ export class TeamList extends PureComponent<Props, any> {
     );
   }
 
+  renderList() {
+    const { teamsCount } = this.props;
+
+    if (teamsCount > 0) {
+      return this.renderTeamList();
+    } else {
+      return this.renderEmptyList();
+    }
+  }
+
   render() {
-    const { navModel, teamsCount } = this.props;
+    const { hasFetched, navModel } = this.props;
 
     return (
       <div>
         <PageHeader model={navModel} />
-        {teamsCount > 0 ? this.renderTeamList() : this.renderEmptyList()}
+        {hasFetched ? this.renderList() : <PageLoader pageName="Teams" />}
       </div>
     );
   }
@@ -143,6 +155,7 @@ function mapStateToProps(state) {
     teams: getTeams(state.teams),
     searchQuery: getSearchQuery(state.teams),
     teamsCount: getTeamsCount(state.teams),
+    hasFetched: state.teams.hasFetched,
   };
 }
 

+ 3 - 18
public/app/features/teams/__snapshots__/TeamList.test.tsx.snap

@@ -5,24 +5,9 @@ exports[`Render should render component 1`] = `
   <PageHeader
     model={Object {}}
   />
-  <div
-    className="page-container page-body"
-  >
-    <EmptyListCTA
-      model={
-        Object {
-          "buttonIcon": "fa fa-plus",
-          "buttonLink": "org/teams/new",
-          "buttonTitle": " New team",
-          "proTip": "Assign folder and dashboard permissions to teams instead of users to ease administration.",
-          "proTipLink": "",
-          "proTipLinkTitle": "",
-          "proTipTarget": "_blank",
-          "title": "You haven't created any teams yet.",
-        }
-      }
-    />
-  </div>
+  <PageLoader
+    pageName="Teams"
+  />
 </div>
 `;
 

+ 2 - 2
public/app/features/teams/state/reducers.ts

@@ -1,7 +1,7 @@
 import { Team, TeamGroup, TeamMember, TeamsState, TeamState } from 'app/types';
 import { Action, ActionTypes } from './actions';
 
-export const initialTeamsState: TeamsState = { teams: [], searchQuery: '' };
+export const initialTeamsState: TeamsState = { teams: [], searchQuery: '', hasFetched: false };
 export const initialTeamState: TeamState = {
   team: {} as Team,
   members: [] as TeamMember[],
@@ -12,7 +12,7 @@ export const initialTeamState: TeamState = {
 export const teamsReducer = (state = initialTeamsState, action: Action): TeamsState => {
   switch (action.type) {
     case ActionTypes.LoadTeams:
-      return { ...state, teams: action.payload };
+      return { ...state, hasFetched: true, teams: action.payload };
 
     case ActionTypes.SetSearchQuery:
       return { ...state, searchQuery: action.payload };

+ 2 - 2
public/app/features/teams/state/selectors.test.ts

@@ -7,7 +7,7 @@ describe('Teams selectors', () => {
     const mockTeams = getMultipleMockTeams(5);
 
     it('should return teams if no search query', () => {
-      const mockState: TeamsState = { teams: mockTeams, searchQuery: '' };
+      const mockState: TeamsState = { teams: mockTeams, searchQuery: '', hasFetched: false };
 
       const teams = getTeams(mockState);
 
@@ -15,7 +15,7 @@ describe('Teams selectors', () => {
     });
 
     it('Should filter teams if search query', () => {
-      const mockState: TeamsState = { teams: mockTeams, searchQuery: '5' };
+      const mockState: TeamsState = { teams: mockTeams, searchQuery: '5', hasFetched: false };
 
       const teams = getTeams(mockState);
 

+ 9 - 0
public/app/features/users/UsersListPage.test.tsx

@@ -22,6 +22,7 @@ const setup = (propOverrides?: object) => {
     updateUser: jest.fn(),
     removeUser: jest.fn(),
     setUsersSearchQuery: jest.fn(),
+    hasFetched: false,
   };
 
   Object.assign(props, propOverrides);
@@ -41,6 +42,14 @@ describe('Render', () => {
 
     expect(wrapper).toMatchSnapshot();
   });
+
+  it('should render List page', () => {
+    const { wrapper } = setup({
+      hasFetched: true,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
 });
 
 describe('Functions', () => {

+ 22 - 11
public/app/features/users/UsersListPage.tsx

@@ -3,8 +3,9 @@ import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 import Remarkable from 'remarkable';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
 import UsersActionBar from './UsersActionBar';
-import UsersTable from 'app/features/users/UsersTable';
+import UsersTable from './UsersTable';
 import InviteesTable from './InviteesTable';
 import { Invitee, NavModel, OrgUser } from 'app/types';
 import appEvents from 'app/core/app_events';
@@ -18,6 +19,7 @@ export interface Props {
   users: OrgUser[];
   searchQuery: string;
   externalUserMngInfo: string;
+  hasFetched: boolean;
   loadUsers: typeof loadUsers;
   loadInvitees: typeof loadInvitees;
   setUsersSearchQuery: typeof setUsersSearchQuery;
@@ -87,8 +89,24 @@ export class UsersListPage extends PureComponent<Props, State> {
     }));
   };
 
+  renderTable() {
+    const { invitees, users } = this.props;
+
+    if (this.state.showInvites) {
+      return <InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />;
+    } else {
+      return (
+        <UsersTable
+          users={users}
+          onRoleChange={(role, user) => this.onRoleChange(role, user)}
+          onRemoveUser={user => this.onRemoveUser(user)}
+        />
+      );
+    }
+  }
+
   render() {
-    const { invitees, navModel, users } = this.props;
+    const { navModel, hasFetched } = this.props;
     const externalUserMngInfoHtml = this.externalUserMngInfoHtml;
 
     return (
@@ -99,15 +117,7 @@ export class UsersListPage extends PureComponent<Props, State> {
           {externalUserMngInfoHtml && (
             <div className="grafana-info-box" dangerouslySetInnerHTML={{ __html: externalUserMngInfoHtml }} />
           )}
-          {this.state.showInvites ? (
-            <InviteesTable invitees={invitees} onRevokeInvite={code => this.onRevokeInvite(code)} />
-          ) : (
-            <UsersTable
-              users={users}
-              onRoleChange={(role, user) => this.onRoleChange(role, user)}
-              onRemoveUser={user => this.onRemoveUser(user)}
-            />
-          )}
+          {hasFetched ? this.renderTable() : <PageLoader pageName="Users" />}
         </div>
       </div>
     );
@@ -121,6 +131,7 @@ function mapStateToProps(state) {
     searchQuery: getUsersSearchQuery(state.users),
     invitees: getInvitees(state.users),
     externalUserMngInfo: state.users.externalUserMngInfo,
+    hasFetched: state.users.hasFetched,
   };
 }
 

+ 20 - 1
public/app/features/users/__snapshots__/UsersListPage.test.tsx.snap

@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Render should render component 1`] = `
+exports[`Render should render List page 1`] = `
 <div>
   <PageHeader
     model={Object {}}
@@ -20,3 +20,22 @@ exports[`Render should render component 1`] = `
   </div>
 </div>
 `;
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <Connect(UsersActionBar)
+      onShowInvites={[Function]}
+      showInvites={false}
+    />
+    <PageLoader
+      pageName="Users"
+    />
+  </div>
+</div>
+`;

+ 4 - 3
public/app/features/users/state/reducers.ts

@@ -1,6 +1,6 @@
 import { Invitee, OrgUser, UsersState } from 'app/types';
 import { Action, ActionTypes } from './actions';
-import config from '../../../core/config';
+import config from 'app/core/config';
 
 export const initialState: UsersState = {
   invitees: [] as Invitee[],
@@ -10,15 +10,16 @@ export const initialState: UsersState = {
   externalUserMngInfo: config.externalUserMngInfo,
   externalUserMngLinkName: config.externalUserMngLinkName,
   externalUserMngLinkUrl: config.externalUserMngLinkUrl,
+  hasFetched: false,
 };
 
 export const usersReducer = (state = initialState, action: Action): UsersState => {
   switch (action.type) {
     case ActionTypes.LoadUsers:
-      return { ...state, users: action.payload };
+      return { ...state, hasFetched: true, users: action.payload };
 
     case ActionTypes.LoadInvitees:
-      return { ...state, invitees: action.payload };
+      return { ...state, hasFetched: true, invitees: action.payload };
 
     case ActionTypes.SetUsersSearchQuery:
       return { ...state, searchQuery: action.payload };

+ 1 - 0
public/app/types/apiKeys.ts

@@ -14,4 +14,5 @@ export interface NewApiKey {
 export interface ApiKeysState {
   keys: ApiKey[];
   searchQuery: string;
+  hasFetched: boolean;
 }

+ 1 - 0
public/app/types/datasources.ts

@@ -25,4 +25,5 @@ export interface DataSourcesState {
   layoutMode: LayoutMode;
   dataSourcesCount: number;
   dataSourceTypes: Plugin[];
+  hasFetched: boolean;
 }

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

@@ -44,4 +44,5 @@ export interface PluginsState {
   plugins: Plugin[];
   searchQuery: string;
   layoutMode: string;
+  hasFetched: boolean;
 }

+ 1 - 0
public/app/types/teams.ts

@@ -23,6 +23,7 @@ export interface TeamGroup {
 export interface TeamsState {
   teams: Team[];
   searchQuery: string;
+  hasFetched: boolean;
 }
 
 export interface TeamState {

+ 1 - 0
public/app/types/user.ts

@@ -41,4 +41,5 @@ export interface UsersState {
   externalUserMngLinkUrl: string;
   externalUserMngLinkName: string;
   externalUserMngInfo: string;
+  hasFetched: boolean;
 }

+ 0 - 37
public/app/types/users.ts

@@ -1,37 +0,0 @@
-export interface Invitee {
-  code: string;
-  createdOn: string;
-  email: string;
-  emailSent: boolean;
-  emailSentOn: string;
-  id: number;
-  invitedByEmail: string;
-  invitedByLogin: string;
-  invitedByName: string;
-  name: string;
-  orgId: number;
-  role: string;
-  status: string;
-  url: string;
-}
-
-export interface User {
-  avatarUrl: string;
-  email: string;
-  lastSeenAt: string;
-  lastSeenAtAge: string;
-  login: string;
-  orgId: number;
-  role: string;
-  userId: number;
-}
-
-export interface UsersState {
-  users: User[];
-  invitees: Invitee[];
-  searchQuery: string;
-  canInvite: boolean;
-  externalUserMngLinkUrl: string;
-  externalUserMngLinkName: string;
-  externalUserMngInfo: string;
-}

+ 2 - 1
public/sass/_grafana.scss

@@ -95,7 +95,8 @@
 @import 'components/user-picker';
 @import 'components/description-picker';
 @import 'components/delete_button';
-@import 'components/_add_data_source.scss';
+@import 'components/add_data_source.scss';
+@import 'components/page_loader';
 
 // PAGES
 @import 'pages/login';

+ 16 - 0
public/sass/components/_page_loader.scss

@@ -0,0 +1,16 @@
+.page-loader-wrapper {
+  padding-top: 100px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+
+  &__spinner {
+    font-size: 32px;
+    margin-bottom: $panel-margin;
+  }
+
+  &__text {
+    font-size: 14px;
+  }
+}