diff --git a/.gitignore b/.gitignore index 12e7bed3f46..deb1c1f882a 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,4 @@ debug.test /vendor/**/*_test.go /vendor/**/.editorconfig /vendor/**/appengine* +*.orig \ No newline at end of file diff --git a/package.json b/package.json index 2f79acc41df..be753596a50 100644 --- a/package.json +++ b/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", diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 839ff011d5a..6e68e7c8d2f 100644 --- a/public/app/core/angular_wrappers.ts +++ b/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']); } diff --git a/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx b/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx index d62ae892a0a..4af60f3c839 100644 --- a/public/app/core/components/EmptyListCTA/EmptyListCTA.jest.tsx +++ b/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().toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap b/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap index 0da3d94aaa8..6d47c984d5e 100644 --- a/public/app/core/components/EmptyListCTA/__snapshots__/EmptyListCTA.jest.tsx.snap +++ b/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`] = `
diff --git a/public/app/core/components/UserPicker/UserPicker.jest.tsx b/public/app/core/components/UserPicker/UserPicker.jest.tsx new file mode 100644 index 00000000000..0e3c672dcbe --- /dev/null +++ b/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().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/UserPicker/UserPicker.tsx b/public/app/core/components/UserPicker/UserPicker.tsx new file mode 100644 index 00000000000..12aca15e510 --- /dev/null +++ b/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 { + 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 ( +
+ +
+ ); + } +} + +export default UserPicker; diff --git a/public/app/core/components/UserPicker/UserPickerOption.jest.tsx b/public/app/core/components/UserPicker/UserPickerOption.jest.tsx new file mode 100644 index 00000000000..6903d648870 --- /dev/null +++ b/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().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/public/app/core/components/UserPicker/UserPickerOption.tsx b/public/app/core/components/UserPicker/UserPickerOption.tsx new file mode 100644 index 00000000000..42a079802a8 --- /dev/null +++ b/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 { + 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 ( + + ); + } +} + +export default UserPickerOption; diff --git a/public/app/core/components/UserPicker/__snapshots__/UserPicker.jest.tsx.snap b/public/app/core/components/UserPicker/__snapshots__/UserPicker.jest.tsx.snap new file mode 100644 index 00000000000..26e331b7403 --- /dev/null +++ b/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`] = ` +
+
+
+ +
+ Loading... +
+
+ +
+ +
+
+
+
+
+
+`; diff --git a/public/app/core/components/UserPicker/__snapshots__/UserPickerOption.jest.tsx.snap b/public/app/core/components/UserPicker/__snapshots__/UserPickerOption.jest.tsx.snap new file mode 100644 index 00000000000..3b9351b2338 --- /dev/null +++ b/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`] = ` + +`; diff --git a/public/app/features/org/partials/team_details.html b/public/app/features/org/partials/team_details.html index 4835facb785..e2666697869 100644 --- a/public/app/features/org/partials/team_details.html +++ b/public/app/features/org/partials/team_details.html @@ -26,11 +26,10 @@

Team Members

- -
+
Add member - +
diff --git a/public/app/features/org/team_details_ctrl.ts b/public/app/features/org/team_details_ctrl.ts index 0147e4d3d28..e7ccef8f3f5 100644 --- a/public/app/features/org/team_details_ctrl.ts +++ b/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() { diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index 06b3a374e34..ceae2438533 100644 --- a/public/sass/_grafana.scss +++ b/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'; diff --git a/public/sass/components/_form_select_box.scss b/public/sass/components/_form_select_box.scss new file mode 100644 index 00000000000..880f6d4c647 --- /dev/null +++ b/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%); + } + } +} diff --git a/public/sass/components/_user-picker.scss b/public/sass/components/_user-picker.scss new file mode 100644 index 00000000000..35a2a242746 --- /dev/null +++ b/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; +} diff --git a/yarn.lock b/yarn.lock index aa477878e4d..feba691190e 100644 --- a/yarn.lock +++ b/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"