Jelajahi Sumber

Merge remote-tracking branch 'origin/10289_user_picker'

Torkel Ödegaard 8 tahun lalu
induk
melakukan
7854f80f5a

+ 1 - 0
.gitignore

@@ -60,3 +60,4 @@ debug.test
 /vendor/**/*_test.go
 /vendor/**/.editorconfig
 /vendor/**/appengine*
+*.orig

+ 1 - 0
package.json

@@ -151,6 +151,7 @@
     "react-grid-layout": "^0.16.1",
     "react-popper": "^0.7.5",
     "react-highlight-words": "^0.10.0",
+    "react-select": "^1.1.0",
     "react-sizeme": "^2.3.6",
     "remarkable": "^1.7.1",
     "rxjs": "^5.4.3",

+ 13 - 11
public/app/core/angular_wrappers.ts

@@ -1,14 +1,16 @@
-import { react2AngularDirective } from "app/core/utils/react2angular";
-import { PasswordStrength } from "./components/PasswordStrength";
-import PageHeader from "./components/PageHeader/PageHeader";
-import EmptyListCTA from "./components/EmptyListCTA/EmptyListCTA";
-import LoginBackground from "./components/Login/LoginBackground";
-import { SearchResult } from "./components/search/SearchResult";
+import { react2AngularDirective } from 'app/core/utils/react2angular';
+import { PasswordStrength } from './components/PasswordStrength';
+import PageHeader from './components/PageHeader/PageHeader';
+import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
+import LoginBackground from './components/Login/LoginBackground';
+import { SearchResult } from './components/search/SearchResult';
+import UserPicker from './components/UserPicker/UserPicker';
 
 export function registerAngularDirectives() {
-  react2AngularDirective("passwordStrength", PasswordStrength, ["password"]);
-  react2AngularDirective("pageHeader", PageHeader, ["model", "noTabs"]);
-  react2AngularDirective("emptyListCta", EmptyListCTA, ["model"]);
-  react2AngularDirective("loginBackground", LoginBackground, []);
-  react2AngularDirective("searchResult", SearchResult, []);
+  react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
+  react2AngularDirective('pageHeader', PageHeader, ['model', 'noTabs']);
+  react2AngularDirective('emptyListCta', EmptyListCTA, ['model']);
+  react2AngularDirective('loginBackground', LoginBackground, []);
+  react2AngularDirective('searchResult', SearchResult, []);
+  react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
 }

+ 10 - 11
public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx

@@ -3,19 +3,18 @@ import renderer from 'react-test-renderer';
 import EmptyListCTA from './EmptyListCTA';
 
 const model = {
-    title: 'Title',
-    buttonIcon: 'ga css class',
-    buttonLink: 'http://url/to/destination',
-    buttonTitle: 'Click me',
-    proTip: 'This is a tip',
-    proTipLink: 'http://url/to/tip/destination',
-    proTipLinkTitle: 'Learn more',
-    proTipTarget: '_blank'
+  title: 'Title',
+  buttonIcon: 'ga css class',
+  buttonLink: 'http://url/to/destination',
+  buttonTitle: 'Click me',
+  proTip: 'This is a tip',
+  proTipLink: 'http://url/to/tip/destination',
+  proTipLinkTitle: 'Learn more',
+  proTipTarget: '_blank',
 };
 
-describe('CollorPalette', () => {
-
-    it('renders correctly', () => {
+describe('EmptyListCTA', () => {
+  it('renders correctly', () => {
     const tree = renderer.create(<EmptyListCTA model={model} />).toJSON();
     expect(tree).toMatchSnapshot();
   });

+ 1 - 1
public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap

@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`CollorPalette renders correctly 1`] = `
+exports[`EmptyListCTA renders correctly 1`] = `
 <div
   className="empty-list-cta"
 >

+ 20 - 0
public/app/core/components/UserPicker/UserPicker.jest.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import UserPicker from './UserPicker';
+
+const model = {
+  backendSrv: {
+    get: () => {
+      return new Promise((resolve, reject) => {});
+    },
+  },
+  refreshList: () => {},
+  teamId: '1',
+};
+
+describe('UserPicker', () => {
+  it('renders correctly', () => {
+    const tree = renderer.create(<UserPicker {...model} />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });
+});

+ 108 - 0
public/app/core/components/UserPicker/UserPicker.tsx

@@ -0,0 +1,108 @@
+import React, { Component } from 'react';
+import { debounce } from 'lodash';
+import Select from 'react-select';
+import UserPickerOption from './UserPickerOption';
+
+export interface IProps {
+  backendSrv: any;
+  teamId: string;
+  refreshList: any;
+}
+
+export interface User {
+  id: number;
+  name: string;
+  login: string;
+  email: string;
+}
+
+class UserPicker extends Component<IProps, any> {
+  debouncedSearchUsers: any;
+  backendSrv: any;
+  teamId: string;
+  refreshList: any;
+
+  constructor(props) {
+    super(props);
+    this.backendSrv = this.props.backendSrv;
+    this.teamId = this.props.teamId;
+    this.refreshList = this.props.refreshList;
+
+    this.searchUsers = this.searchUsers.bind(this);
+    this.handleChange = this.handleChange.bind(this);
+    this.addUser = this.addUser.bind(this);
+    this.toggleLoading = this.toggleLoading.bind(this);
+
+    this.debouncedSearchUsers = debounce(this.searchUsers, 300, {
+      leading: true,
+      trailing: false,
+    });
+
+    this.state = {
+      multi: false,
+      isLoading: false,
+    };
+  }
+
+  handleChange(user) {
+    this.addUser(user.id);
+  }
+
+  toggleLoading(isLoading) {
+    this.setState(prevState => {
+      return {
+        ...prevState,
+        isLoading: isLoading,
+      };
+    });
+  }
+
+  addUser(userId) {
+    this.toggleLoading(true);
+    this.backendSrv.post(`/api/teams/${this.teamId}/members`, { userId: userId }).then(() => {
+      this.refreshList();
+      this.toggleLoading(false);
+    });
+  }
+
+  searchUsers(query) {
+    this.toggleLoading(true);
+
+    return this.backendSrv.get(`/api/users/search?perpage=10&page=1&query=${query}`).then(result => {
+      const users = result.users.map(user => {
+        return {
+          id: user.id,
+          label: `${user.login} - ${user.email}`,
+          avatarUrl: user.avatarUrl,
+        };
+      });
+      this.toggleLoading(false);
+      return { options: users };
+    });
+  }
+
+  render() {
+    const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
+
+    return (
+      <div className="user-picker">
+        <AsyncComponent
+          valueKey="id"
+          multi={this.state.multi}
+          labelKey="label"
+          cache={false}
+          isLoading={this.state.isLoading}
+          loadOptions={this.debouncedSearchUsers}
+          loadingPlaceholder="Loading..."
+          noResultsText="No users found"
+          onChange={this.handleChange}
+          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          optionComponent={UserPickerOption}
+          placeholder="Choose"
+        />
+      </div>
+    );
+  }
+}
+
+export default UserPicker;

+ 22 - 0
public/app/core/components/UserPicker/UserPickerOption.jest.tsx

@@ -0,0 +1,22 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import UserPickerOption from './UserPickerOption';
+
+const model = {
+  onSelect: () => {},
+  onFocus: () => {},
+  isFocused: () => {},
+  option: {
+    title: 'Model title',
+    avatarUrl: 'url/to/avatar',
+    label: 'User picker label',
+  },
+  className: 'class-for-user-picker',
+};
+
+describe('UserPickerOption', () => {
+  it('renders correctly', () => {
+    const tree = renderer.create(<UserPickerOption {...model} />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });
+});

+ 52 - 0
public/app/core/components/UserPicker/UserPickerOption.tsx

@@ -0,0 +1,52 @@
+import React, { Component } from 'react';
+export interface IProps {
+  onSelect: any;
+  onFocus: any;
+  option: any;
+  isFocused: any;
+  className: any;
+}
+class UserPickerOption extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.handleMouseDown = this.handleMouseDown.bind(this);
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseMove = this.handleMouseMove.bind(this);
+  }
+
+  handleMouseDown(event) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.props.onSelect(this.props.option, event);
+  }
+
+  handleMouseEnter(event) {
+    this.props.onFocus(this.props.option, event);
+  }
+
+  handleMouseMove(event) {
+    if (this.props.isFocused) {
+      return;
+    }
+    this.props.onFocus(this.props.option, event);
+  }
+
+  render() {
+    const { option, children, className } = this.props;
+
+    return (
+      <button
+        onMouseDown={this.handleMouseDown}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseMove={this.handleMouseMove}
+        title={option.title}
+        className={`user-picker-option__button btn btn-link ${className}`}
+      >
+        <img src={option.avatarUrl} alt={option.label} className="user-picker-option__avatar" />
+        {children}
+      </button>
+    );
+  }
+}
+
+export default UserPickerOption;

+ 98 - 0
public/app/core/components/UserPicker/__snapshots__/UserPicker.jest.tsx.snap

@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`UserPicker renders correctly 1`] = `
+<div
+  className="user-picker"
+>
+  <div
+    className="Select width-8 gf-form-input is-clearable is-loading is-searchable Select--single"
+    style={undefined}
+  >
+    <div
+      className="Select-control"
+      onKeyDown={[Function]}
+      onMouseDown={[Function]}
+      onTouchEnd={[Function]}
+      onTouchMove={[Function]}
+      onTouchStart={[Function]}
+      style={undefined}
+    >
+      <span
+        className="Select-multi-value-wrapper"
+        id="react-select-2--value"
+      >
+        <div
+          className="Select-placeholder"
+        >
+          Loading...
+        </div>
+        <div
+          className="Select-input"
+          style={
+            Object {
+              "display": "inline-block",
+            }
+          }
+        >
+          <input
+            aria-activedescendant="react-select-2--value"
+            aria-describedby={undefined}
+            aria-expanded="false"
+            aria-haspopup="false"
+            aria-label={undefined}
+            aria-labelledby={undefined}
+            aria-owns=""
+            className={undefined}
+            id={undefined}
+            onBlur={[Function]}
+            onChange={[Function]}
+            onFocus={[Function]}
+            required={false}
+            role="combobox"
+            style={
+              Object {
+                "boxSizing": "content-box",
+                "width": "5px",
+              }
+            }
+            tabIndex={undefined}
+            value=""
+          />
+          <div
+            style={
+              Object {
+                "height": 0,
+                "left": 0,
+                "overflow": "scroll",
+                "position": "absolute",
+                "top": 0,
+                "visibility": "hidden",
+                "whiteSpace": "pre",
+              }
+            }
+          >
+
+          </div>
+        </div>
+      </span>
+      <span
+        aria-hidden="true"
+        className="Select-loading-zone"
+      >
+        <span
+          className="Select-loading"
+        />
+      </span>
+      <span
+        className="Select-arrow-zone"
+        onMouseDown={[Function]}
+      >
+        <span
+          className="Select-arrow"
+          onMouseDown={[Function]}
+        />
+      </span>
+    </div>
+  </div>
+</div>
+`;

+ 17 - 0
public/app/core/components/UserPicker/__snapshots__/UserPickerOption.jest.tsx.snap

@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`UserPickerOption renders correctly 1`] = `
+<button
+  className="user-picker-option__button btn btn-link class-for-user-picker"
+  onMouseDown={[Function]}
+  onMouseEnter={[Function]}
+  onMouseMove={[Function]}
+  title="Model title"
+>
+  <img
+    alt="User picker label"
+    className="user-picker-option__avatar"
+    src="url/to/avatar"
+  />
+</button>
+`;

+ 2 - 3
public/app/features/org/partials/team_details.html

@@ -26,11 +26,10 @@
   <div class="gf-form-group">
 
     <h3 class="page-heading">Team Members</h3>
-
-    <form name="ctrl.addMemberForm" class="gf-form-group">
+		<form name="ctrl.addMemberForm" class="gf-form-group">
       <div class="gf-form">
         <span class="gf-form-label width-10">Add member</span>
-        <user-picker user-picked="ctrl.userPicked($user)"></user-picker>
+				<select-user-picker backendSrv="ctrl.backendSrv" teamId="ctrl.$routeParams.id" refreshList="ctrl.get" teamMembers="ctrl.teamMembers"></select-user-picker>
       </div>
     </form>
 

+ 2 - 1
public/app/features/org/team_details_ctrl.ts

@@ -8,6 +8,7 @@ export default class TeamDetailsCtrl {
   /** @ngInject **/
   constructor(private $scope, private backendSrv, private $routeParams, navModelSrv) {
     this.navModel = navModelSrv.getNav('cfg', 'teams', 0);
+    this.get = this.get.bind(this);
     this.get();
   }
 
@@ -35,7 +36,7 @@ export default class TeamDetailsCtrl {
   }
 
   removeMemberConfirmed(teamMember: TeamMember) {
-    this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/members/${teamMember.userId}`).then(this.get.bind(this));
+    this.backendSrv.delete(`/api/teams/${this.$routeParams.id}/members/${teamMember.userId}`).then(this.get);
   }
 
   update() {

+ 2 - 0
public/sass/_grafana.scss

@@ -89,6 +89,8 @@
 @import 'components/dashboard_settings';
 @import 'components/empty_list_cta';
 @import 'components/popper';
+@import 'components/form_select_box';
+@import 'components/user-picker';
 
 // PAGES
 @import 'pages/login';

+ 73 - 0
public/sass/components/_form_select_box.scss

@@ -0,0 +1,73 @@
+$select-input-height: 35px;
+$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-color: $input-color;
+$select-noresults-color: $text-color;
+$select-input-bg: $input-bg;
+$select-input-border-color: $input-border-color;
+$select-menu-box-shadow: $menu-dropdown-shadow;
+
+@import '../../../node_modules/react-select/scss/default.scss';
+
+@mixin select-control() {
+  width: 100%;
+  margin-right: $gf-form-margin;
+  @include border-radius($input-border-radius-sm);
+  background-color: $input-bg;
+}
+
+@mixin select-control-focus() {
+  border-color: $input-border-focus;
+  outline: none;
+  $shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px $input-box-shadow-focus;
+  @include box-shadow($shadow);
+}
+
+.gf-form-input--form-dropdown {
+  padding: 0;
+  border: 0;
+  overflow: visible;
+
+  .Select-placeholder {
+    color: $gray-4;
+  }
+
+  > .Select-control {
+    @include select-control();
+    border-color: $dark-3;
+  }
+
+  &.is-open > .Select-control {
+    background: transparent;
+    border-color: $dark-3;
+  }
+
+  &.is-focused > .Select-control {
+    background-color: $input-bg;
+    @include select-control-focus();
+  }
+
+  .Select-menu-outer {
+    border: 0;
+    width: auto;
+  }
+
+  .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%);
+    }
+  }
+}

+ 12 - 0
public/sass/components/_user-picker.scss

@@ -0,0 +1,12 @@
+.user-picker-option__button {
+  position: relative;
+  text-align: left;
+  width: 100%;
+  display: block;
+  border-radius: 0;
+}
+.user-picker-option__avatar {
+  width: 20px;
+  display: inline-block;
+  margin-right: 10px;
+}

+ 22 - 8
yarn.lock

@@ -1706,7 +1706,7 @@ class-utils@^0.3.5:
     lazy-cache "^2.0.2"
     static-extend "^0.1.1"
 
-classnames@2.x, classnames@^2.2.5:
+classnames@2.x, classnames@^2.2.4, classnames@^2.2.5:
   version "2.2.5"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.5.tgz#fb3801d453467649ef3603c7d61a02bd129bde6d"
 
@@ -8131,13 +8131,6 @@ react-grid-layout@^0.16.1:
     react-draggable "^3.0.3"
     react-resizable "^1.7.5"
 
-react-popper@^0.7.5:
-  version "0.7.5"
-  resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-0.7.5.tgz#71c25946f291db381231281f6b95729e8b801596"
-  dependencies:
-    popper.js "^1.12.5"
-    prop-types "^15.5.10"
-
 react-highlight-words@^0.10.0:
   version "0.10.0"
   resolved "https://registry.yarnpkg.com/react-highlight-words/-/react-highlight-words-0.10.0.tgz#2e905c76c11635237f848ecad00600f1b6f6f4a8"
@@ -8145,6 +8138,19 @@ react-highlight-words@^0.10.0:
     highlight-words-core "^1.1.0"
     prop-types "^15.5.8"
 
+react-input-autosize@^2.1.2:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/react-input-autosize/-/react-input-autosize-2.2.1.tgz#ec428fa15b1592994fb5f9aa15bb1eb6baf420f8"
+  dependencies:
+    prop-types "^15.5.8"
+
+react-popper@^0.7.5:
+  version "0.7.5"
+  resolved "https://registry.yarnpkg.com/react-popper/-/react-popper-0.7.5.tgz#71c25946f291db381231281f6b95729e8b801596"
+  dependencies:
+    popper.js "^1.12.5"
+    prop-types "^15.5.10"
+
 react-resizable@^1.7.5:
   version "1.7.5"
   resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.7.5.tgz#83eb75bb3684da6989bbbf4f826e1470f0af902e"
@@ -8152,6 +8158,14 @@ react-resizable@^1.7.5:
     prop-types "15.x"
     react-draggable "^2.2.6 || ^3.0.3"
 
+react-select@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/react-select/-/react-select-1.2.0.tgz#4f91df941c4ecdb94701faca2533b60e31d7508e"
+  dependencies:
+    classnames "^2.2.4"
+    prop-types "^15.5.8"
+    react-input-autosize "^2.1.2"
+
 react-sizeme@^2.3.6:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/react-sizeme/-/react-sizeme-2.3.6.tgz#d60ea2634acc3fd827a3c7738d41eea0992fa678"