Prechádzať zdrojové kódy

Merge pull request #10719 from grafana/add_permissions_10676

Grafana 5.0: Add permissions modal for the permissions pages
Daniel Lee 8 rokov pred
rodič
commit
22a349051c

+ 1 - 0
package.json

@@ -153,6 +153,7 @@
     "react-popper": "^0.7.5",
     "react-select": "^1.1.0",
     "react-sizeme": "^2.3.6",
+    "react-transition-group": "^2.2.1",
     "remarkable": "^1.7.1",
     "rst2html": "github:thoward/rst2html#990cb89",
     "rxjs": "^5.4.3",

+ 20 - 2
public/app/containers/ManageDashboards/FolderPermissions.tsx

@@ -6,11 +6,14 @@ import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+import AddPermissions from 'app/core/components/Permissions/AddPermissions';
+import SlideDown from 'app/core/components/Animations/SlideDown';
 @inject('nav', 'folder', 'view', 'permissions')
 @observer
 export class FolderPermissions extends Component<IContainerProps, any> {
   constructor(props) {
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.loadStore();
   }
 
@@ -21,6 +24,11 @@ export class FolderPermissions extends Component<IContainerProps, any> {
     });
   }
 
+  handleAddPermission() {
+    const { permissions } = this.props;
+    permissions.toggleAddPermissions();
+  }
+
   render() {
     const { nav, folder, permissions, backendSrv } = this.props;
 
@@ -34,13 +42,23 @@ export class FolderPermissions extends Component<IContainerProps, any> {
       <div>
         <PageHeader model={nav as any} />
         <div className="page-container page-body">
-          <div className="page-sub-heading">
+          <div className="page-action-bar">
             <h2 className="d-inline-block">Folder Permissions</h2>
             <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
               <i className="gicon gicon-question gicon--has-hover" />
             </Tooltip>
+            <div className="page-action-bar__spacer" />
+            <button
+              className="btn btn-success pull-right"
+              onClick={this.handleAddPermission}
+              disabled={permissions.isAddPermissionsVisible}
+            >
+              <i className="fa fa-plus" /> Add Permission
+            </button>
           </div>
-
+          <SlideDown in={permissions.isAddPermissionsVisible}>
+            <AddPermissions permissions={permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
+          </SlideDown>
           <Permissions permissions={permissions} isFolder={true} dashboardId={dashboardId} backendSrv={backendSrv} />
         </div>
       </div>

+ 37 - 0
public/app/core/components/Animations/SlideDown.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import Transition from 'react-transition-group/Transition';
+
+const defaultMaxHeight = '200px'; // When animating using max-height we need to use a static value.
+// If this is not enough, pass in <SlideDown maxHeight="....
+const defaultDuration = 200;
+const defaultStyle = {
+  transition: `max-height ${defaultDuration}ms ease-in-out`,
+  overflow: 'hidden',
+};
+
+export default ({ children, in: inProp, maxHeight = defaultMaxHeight }) => {
+  // There are 4 main states a Transition can be in:
+  // ENTERING, ENTERED, EXITING, EXITED
+  // https://reactcommunity.org/react-transition-group/
+  const transitionStyles = {
+    exited: { maxHeight: 0 },
+    entering: { maxHeight: maxHeight },
+    entered: { maxHeight: maxHeight, overflow: 'visible' },
+    exiting: { maxHeight: 0 },
+  };
+
+  return (
+    <Transition in={inProp} timeout={defaultDuration}>
+      {state => (
+        <div
+          style={{
+            ...defaultStyle,
+            ...transitionStyles[state],
+          }}
+        >
+          {children}
+        </div>
+      )}
+    </Transition>
+  );
+};

+ 90 - 0
public/app/core/components/Permissions/AddPermissions.jest.tsx

@@ -0,0 +1,90 @@
+import React from 'react';
+import AddPermissions from './AddPermissions';
+import { RootStore } from 'app/stores/RootStore/RootStore';
+import { backendSrv } from 'test/mocks/common';
+import { shallow } from 'enzyme';
+
+describe('AddPermissions', () => {
+  let wrapper;
+  let store;
+  let instance;
+
+  beforeAll(() => {
+    backendSrv.get.mockReturnValue(
+      Promise.resolve([
+        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
+        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
+      ])
+    );
+
+    backendSrv.post = jest.fn();
+
+    store = RootStore.create(
+      {},
+      {
+        backendSrv: backendSrv,
+      }
+    );
+
+    wrapper = shallow(<AddPermissions permissions={store.permissions} backendSrv={backendSrv} dashboardId={1} />);
+    instance = wrapper.instance();
+    return store.permissions.load(1, true, false);
+  });
+
+  describe('when permission for a user is added', () => {
+    it('should save permission to db', () => {
+      const evt = {
+        target: {
+          value: 'User',
+        },
+      };
+      const userItem = {
+        id: 2,
+        login: 'user2',
+      };
+
+      instance.typeChanged(evt);
+      instance.userPicked(userItem);
+
+      wrapper.update();
+
+      expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
+
+      wrapper.find('form').simulate('submit', { preventDefault() {} });
+
+      expect(backendSrv.post.mock.calls.length).toBe(1);
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    });
+  });
+
+  describe('when permission for team is added', () => {
+    it('should save permission to db', () => {
+      const evt = {
+        target: {
+          value: 'Group',
+        },
+      };
+
+      const teamItem = {
+        id: 2,
+        name: 'ug1',
+      };
+
+      instance.typeChanged(evt);
+      instance.teamPicked(teamItem);
+
+      wrapper.update();
+
+      expect(wrapper.find('[data-save-permission]').prop('disabled')).toBe(false);
+
+      wrapper.find('form').simulate('submit', { preventDefault() {} });
+
+      expect(backendSrv.post.mock.calls.length).toBe(1);
+      expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
+    });
+  });
+
+  afterEach(() => {
+    backendSrv.post.mockClear();
+  });
+});

+ 158 - 0
public/app/core/components/Permissions/AddPermissions.tsx

@@ -0,0 +1,158 @@
+import React, { Component } from 'react';
+import { observer } from 'mobx-react';
+import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
+import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
+import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
+import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
+import { permissionOptions } from 'app/stores/PermissionsStore/PermissionsStore';
+
+export interface IProps {
+  permissions: any;
+  backendSrv: any;
+  dashboardId: any;
+}
+@observer
+class AddPermissions extends Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.userPicked = this.userPicked.bind(this);
+    this.teamPicked = this.teamPicked.bind(this);
+    this.permissionPicked = this.permissionPicked.bind(this);
+    this.typeChanged = this.typeChanged.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  componentWillMount() {
+    const { permissions } = this.props;
+    permissions.resetNewType();
+  }
+
+  typeChanged(evt) {
+    const { value } = evt.target;
+    const { permissions } = this.props;
+
+    // if (value === 'Viewer' || value === 'Editor') {
+    // //   permissions.addStoreItem({ permission: 1, role: value, dashboardId: dashboardId }, dashboardId);
+    // //   this.resetNewType();
+    //   return;
+    // }
+
+    permissions.setNewType(value);
+  }
+
+  userPicked(user: User) {
+    const { permissions } = this.props;
+    if (!user) {
+      permissions.newItem.setUser(null, null);
+      return;
+    }
+    return permissions.newItem.setUser(user.id, user.login);
+  }
+
+  teamPicked(team: Team) {
+    const { permissions } = this.props;
+    if (!team) {
+      permissions.newItem.setTeam(null, null);
+      return;
+    }
+    return permissions.newItem.setTeam(team.id, team.name);
+  }
+
+  permissionPicked(permission: OptionWithDescription) {
+    const { permissions } = this.props;
+    return permissions.newItem.setPermission(permission.value);
+  }
+
+  resetNewType() {
+    const { permissions } = this.props;
+    return permissions.resetNewType();
+  }
+
+  handleSubmit(evt) {
+    evt.preventDefault();
+    const { permissions } = this.props;
+    permissions.addStoreItem();
+  }
+
+  render() {
+    const { permissions, backendSrv } = this.props;
+    const newItem = permissions.newItem;
+    const pickerClassName = 'width-20';
+
+    const isValid = newItem.isValid();
+
+    return (
+      <div className="gf-form-inline cta-form">
+        <button className="cta-form__close btn btn-transparent" onClick={permissions.hideAddPermissions}>
+          <i className="fa fa-close" />
+        </button>
+        <form name="addPermission" onSubmit={this.handleSubmit}>
+          <h6>Add Permission For</h6>
+          <div className="gf-form-inline">
+            <div className="gf-form">
+              <div className="gf-form-select-wrapper">
+                <select className="gf-form-input gf-size-auto" value={newItem.type} onChange={this.typeChanged}>
+                  {aclTypes.map((option, idx) => {
+                    return (
+                      <option key={idx} value={option.value}>
+                        {option.text}
+                      </option>
+                    );
+                  })}
+                </select>
+              </div>
+            </div>
+
+            {newItem.type === 'User' ? (
+              <div className="gf-form">
+                <UserPicker
+                  backendSrv={backendSrv}
+                  handlePicked={this.userPicked}
+                  value={newItem.userId}
+                  className={pickerClassName}
+                />
+              </div>
+            ) : null}
+
+            {newItem.type === 'Group' ? (
+              <div className="gf-form">
+                <TeamPicker
+                  backendSrv={backendSrv}
+                  handlePicked={this.teamPicked}
+                  value={newItem.teamId}
+                  className={pickerClassName}
+                />
+              </div>
+            ) : null}
+
+            <div className="gf-form">
+              <DescriptionPicker
+                optionsWithDesc={permissionOptions}
+                handlePicked={this.permissionPicked}
+                value={newItem.permission}
+                disabled={false}
+                className={'gf-form-input--form-dropdown-right'}
+              />
+            </div>
+
+            <div className="gf-form">
+              <button data-save-permission className="btn btn-success" type="submit" disabled={!isValid}>
+                Save
+              </button>
+            </div>
+          </div>
+        </form>
+        {permissions.error ? (
+          <div className="gf-form width-17">
+            <span ng-if="ctrl.error" className="text-error p-l-1">
+              <i className="fa fa-warning" />
+              {permissions.error}
+            </span>
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+}
+
+export default AddPermissions;

+ 26 - 5
public/app/core/components/Permissions/DashboardPermissions.tsx

@@ -1,8 +1,11 @@
 import React, { Component } from 'react';
+import { observer } from 'mobx-react';
 import { store } from 'app/stores/store';
 import Permissions from 'app/core/components/Permissions/Permissions';
 import Tooltip from 'app/core/components/Tooltip/Tooltip';
 import PermissionsInfo from 'app/core/components/Permissions/PermissionsInfo';
+import AddPermissions from 'app/core/components/Permissions/AddPermissions';
+import SlideDown from 'app/core/components/Animations/SlideDown';
 
 export interface IProps {
   dashboardId: number;
@@ -11,26 +14,44 @@ export interface IProps {
   folderSlug: string;
   backendSrv: any;
 }
-
+@observer
 class DashboardPermissions extends Component<IProps, any> {
   permissions: any;
 
   constructor(props) {
     super(props);
+    this.handleAddPermission = this.handleAddPermission.bind(this);
     this.permissions = store.permissions;
   }
 
+  handleAddPermission() {
+    this.permissions.toggleAddPermissions();
+  }
+
   render() {
     const { dashboardId, folderTitle, folderSlug, folderId, backendSrv } = this.props;
 
     return (
       <div>
         <div className="dashboard-settings__header">
-          <h3 className="d-inline-block">Permissions</h3>
-          <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
-            <i className="gicon gicon-question gicon--has-hover" />
-          </Tooltip>
+          <div className="page-action-bar">
+            <h3 className="d-inline-block">Permissions</h3>
+            <Tooltip className="page-sub-heading-icon" placement="auto" content={PermissionsInfo}>
+              <i className="gicon gicon-question gicon--has-hover" />
+            </Tooltip>
+            <div className="page-action-bar__spacer" />
+            <button
+              className="btn btn-success pull-right"
+              onClick={this.handleAddPermission}
+              disabled={this.permissions.isAddPermissionsVisible}
+            >
+              <i className="fa fa-plus" /> Add Permission
+            </button>
+          </div>
         </div>
+        <SlideDown in={this.permissions.isAddPermissionsVisible}>
+          <AddPermissions permissions={this.permissions} backendSrv={backendSrv} dashboardId={dashboardId} />
+        </SlideDown>
         <Permissions
           permissions={this.permissions}
           isFolder={false}

+ 0 - 73
public/app/core/components/Permissions/Permissions.jest.tsx

@@ -1,73 +0,0 @@
-import React from 'react';
-import Permissions from './Permissions';
-import { RootStore } from 'app/stores/RootStore/RootStore';
-import { backendSrv } from 'test/mocks/common';
-import { shallow } from 'enzyme';
-
-describe('Permissions', () => {
-  let wrapper;
-
-  beforeAll(() => {
-    backendSrv.get.mockReturnValue(
-      Promise.resolve([
-        { id: 2, dashboardId: 1, role: 'Viewer', permission: 1, permissionName: 'View' },
-        { id: 3, dashboardId: 1, role: 'Editor', permission: 1, permissionName: 'Edit' },
-        {
-          id: 4,
-          dashboardId: 1,
-          userId: 2,
-          userLogin: 'danlimerick',
-          userEmail: 'dan.limerick@gmail.com',
-          permission: 4,
-          permissionName: 'Admin',
-        },
-      ])
-    );
-
-    backendSrv.post = jest.fn();
-
-    const store = RootStore.create(
-      {},
-      {
-        backendSrv: backendSrv,
-      }
-    );
-
-    wrapper = shallow(<Permissions backendSrv={backendSrv} isFolder={true} dashboardId={1} {...store} />);
-    return wrapper.instance().loadStore(1, true);
-  });
-
-  describe('when permission for a user is added', () => {
-    it('should save permission to db', () => {
-      const userItem = {
-        id: 2,
-        login: 'user2',
-      };
-
-      wrapper
-        .instance()
-        .userPicked(userItem)
-        .then(() => {
-          expect(backendSrv.post.mock.calls.length).toBe(1);
-          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-        });
-    });
-  });
-
-  describe('when permission for team is added', () => {
-    it('should save permission to db', () => {
-      const teamItem = {
-        id: 2,
-        name: 'ug1',
-      };
-
-      wrapper
-        .instance()
-        .teamPicked(teamItem)
-        .then(() => {
-          expect(backendSrv.post.mock.calls.length).toBe(1);
-          expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-        });
-    });
-  });
-});

+ 2 - 71
public/app/core/components/Permissions/Permissions.tsx

@@ -1,9 +1,6 @@
-import React, { Component } from 'react';
+import React, { Component } from 'react';
 import PermissionsList from './PermissionsList';
 import { observer } from 'mobx-react';
-import UserPicker, { User } from 'app/core/components/Picker/UserPicker';
-import TeamPicker, { Team } from 'app/core/components/Picker/TeamPicker';
-import { aclTypes } from 'app/stores/PermissionsStore/PermissionsStore';
 import { FolderInfo } from './FolderInfo';
 
 export interface DashboardAcl {
@@ -40,8 +37,6 @@ class Permissions extends Component<IProps, any> {
     this.permissionChanged = this.permissionChanged.bind(this);
     this.typeChanged = this.typeChanged.bind(this);
     this.removeItem = this.removeItem.bind(this);
-    this.userPicked = this.userPicked.bind(this);
-    this.teamPicked = this.teamPicked.bind(this);
     this.loadStore(dashboardId, isFolder, folderInfo && folderInfo.id === 0);
   }
 
@@ -77,28 +72,8 @@ class Permissions extends Component<IProps, any> {
     permissions.setNewType(value);
   }
 
-  userPicked(user: User) {
-    const { permissions, dashboardId } = this.props;
-    return permissions.addStoreItem({
-      userId: user.id,
-      userLogin: user.login,
-      permission: 1,
-      dashboardId: dashboardId,
-    });
-  }
-
-  teamPicked(team: Team) {
-    const { permissions, dashboardId } = this.props;
-    return permissions.addStoreItem({
-      teamId: team.id,
-      team: team.name,
-      permission: 1,
-      dashboardId: dashboardId,
-    });
-  }
-
   render() {
-    const { permissions, folderInfo, backendSrv } = this.props;
+    const { permissions, folderInfo } = this.props;
 
     return (
       <div className="gf-form-group">
@@ -109,50 +84,6 @@ class Permissions extends Component<IProps, any> {
           fetching={permissions.fetching}
           folderInfo={folderInfo}
         />
-        <div className="gf-form-inline">
-          <form name="addPermission" className="gf-form-group">
-            <h6 className="muted">Add Permission For</h6>
-            <div className="gf-form-inline">
-              <div className="gf-form">
-                <div className="gf-form-select-wrapper">
-                  <select
-                    className="gf-form-input gf-size-auto"
-                    value={permissions.newType}
-                    onChange={this.typeChanged}
-                  >
-                    {aclTypes.map((option, idx) => {
-                      return (
-                        <option key={idx} value={option.value}>
-                          {option.text}
-                        </option>
-                      );
-                    })}
-                  </select>
-                </div>
-              </div>
-
-              {permissions.newType === 'User' ? (
-                <div className="gf-form">
-                  <UserPicker backendSrv={backendSrv} handlePicked={this.userPicked} />
-                </div>
-              ) : null}
-
-              {permissions.newType === 'Group' ? (
-                <div className="gf-form">
-                  <TeamPicker backendSrv={backendSrv} handlePicked={this.teamPicked} />
-                </div>
-              ) : null}
-            </div>
-          </form>
-          {permissions.error ? (
-            <div className="gf-form width-17">
-              <span ng-if="ctrl.error" className="text-error p-l-1">
-                <i className="fa fa-warning" />
-                {permissions.error}
-              </span>
-            </div>
-          ) : null}
-        </div>
       </div>
     );
   }

+ 19 - 0
public/app/core/components/Picker/TeamPicker.jest.tsx

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

+ 7 - 2
public/app/core/components/Picker/TeamPicker.tsx

@@ -9,6 +9,8 @@ export interface IProps {
   isLoading: boolean;
   toggleLoading: any;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 
 export interface Team {
@@ -54,7 +56,7 @@ class TeamPicker extends Component<IProps, any> {
 
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
+    const { isLoading, handlePicked, value, className } = this.props;
 
     return (
       <div className="user-picker">
@@ -66,10 +68,13 @@ class TeamPicker extends Component<IProps, any> {
           isLoading={isLoading}
           loadOptions={this.debouncedSearch}
           loadingPlaceholder="Loading..."
+          noResultsText="No teams found"
           onChange={handlePicked}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
       </div>
     );

+ 6 - 3
public/app/core/components/Picker/UserPicker.tsx

@@ -9,6 +9,8 @@ export interface IProps {
   isLoading: boolean;
   toggleLoading: any;
   handlePicked: (user) => void;
+  value?: string;
+  className?: string;
 }
 
 export interface User {
@@ -53,8 +55,7 @@ class UserPicker extends Component<IProps, any> {
 
   render() {
     const AsyncComponent = this.state.creatable ? Select.AsyncCreatable : Select.Async;
-    const { isLoading, handlePicked } = this.props;
-
+    const { isLoading, handlePicked, value, className } = this.props;
     return (
       <div className="user-picker">
         <AsyncComponent
@@ -67,9 +68,11 @@ class UserPicker extends Component<IProps, any> {
           loadingPlaceholder="Loading..."
           noResultsText="No users found"
           onChange={handlePicked}
-          className="width-8 gf-form-input gf-form-input--form-dropdown"
+          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
           optionComponent={PickerOption}
           placeholder="Choose"
+          value={value}
+          autosize={true}
         />
       </div>
     );

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

@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TeamPicker renders correctly 1`] = `
+<div
+  className="user-picker"
+>
+  <div
+    className="Select gf-form-input gf-form-input--form-dropdown  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>
+`;

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

@@ -5,7 +5,7 @@ exports[`UserPicker renders correctly 1`] = `
   className="user-picker"
 >
   <div
-    className="Select width-8 gf-form-input gf-form-input--form-dropdown is-clearable is-loading is-searchable Select--single"
+    className="Select gf-form-input gf-form-input--form-dropdown  is-clearable is-loading is-searchable Select--single"
     style={undefined}
   >
     <div

+ 2 - 0
public/app/core/components/Picker/withPicker.tsx

@@ -3,6 +3,8 @@
 export interface IProps {
   backendSrv: any;
   handlePicked: (data) => void;
+  value?: string;
+  className?: string;
 }
 
 export default function withPicker(WrappedComponent) {

+ 31 - 48
public/app/stores/PermissionsStore/PermissionsStore.jest.ts

@@ -1,4 +1,4 @@
-import { PermissionsStore } from './PermissionsStore';
+import { PermissionsStore, aclTypeValues } from './PermissionsStore';
 import { backendSrv } from 'test/mocks/common';
 
 describe('PermissionsStore', () => {
@@ -47,21 +47,6 @@ describe('PermissionsStore', () => {
     expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
   });
 
-  it('should save newly added permissions automatically', () => {
-    expect(store.items.length).toBe(3);
-
-    const newItem = {
-      userId: 10,
-      userLogin: 'tester1',
-      permission: 1,
-    };
-    store.addStoreItem(newItem);
-
-    expect(store.items.length).toBe(4);
-    expect(backendSrv.post.mock.calls.length).toBe(1);
-    expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
-  });
-
   it('should save removed permissions automatically', () => {
     expect(store.items.length).toBe(3);
 
@@ -72,35 +57,22 @@ describe('PermissionsStore', () => {
     expect(backendSrv.post.mock.calls[0][0]).toBe('/api/dashboards/id/1/acl');
   });
 
-  describe('when duplicate user permissions are added', () => {
-    beforeEach(() => {
-      const newItem = {
-        userId: 10,
-        userLogin: 'tester1',
-        permission: 1,
-        dashboardId: 1,
-      };
-      store.addStoreItem(newItem);
-      store.addStoreItem(newItem);
-    });
-
-    it('should return a validation error', () => {
-      expect(store.items.length).toBe(4);
-      expect(store.error).toBe('This permission exists already.');
-      expect(backendSrv.post.mock.calls.length).toBe(1);
-    });
-  });
-
   describe('when duplicate team permissions are added', () => {
     beforeEach(() => {
       const newItem = {
-        teamId: 1,
-        teamName: 'testerteam',
+        teamId: 10,
+        team: 'tester-team',
         permission: 1,
         dashboardId: 1,
       };
-      store.addStoreItem(newItem);
-      store.addStoreItem(newItem);
+      store.resetNewType();
+      store.newItem.setTeam(newItem.teamId, newItem.team);
+      store.newItem.setPermission(newItem.permission);
+      store.addStoreItem();
+
+      store.newItem.setTeam(newItem.teamId, newItem.team);
+      store.newItem.setPermission(newItem.permission);
+      store.addStoreItem();
     });
 
     it('should return a validation error', () => {
@@ -110,16 +82,23 @@ describe('PermissionsStore', () => {
     });
   });
 
-  describe('when duplicate role permissions are added', () => {
+  describe('when duplicate user permissions are added', () => {
     beforeEach(() => {
+      expect(store.items.length).toBe(3);
       const newItem = {
-        team: 'MyTestTeam',
-        teamId: 1,
+        userId: 10,
+        userLogin: 'tester1',
         permission: 1,
         dashboardId: 1,
       };
-      store.addStoreItem(newItem);
-      store.addStoreItem(newItem);
+      store.setNewType(aclTypeValues.USER.value);
+      store.newItem.setUser(newItem.userId, newItem.userLogin);
+      store.newItem.setPermission(newItem.permission);
+      store.addStoreItem();
+      store.setNewType(aclTypeValues.USER.value);
+      store.newItem.setUser(newItem.userId, newItem.userLogin);
+      store.newItem.setPermission(newItem.permission);
+      store.addStoreItem();
     });
 
     it('should return a validation error', () => {
@@ -131,20 +110,24 @@ describe('PermissionsStore', () => {
 
   describe('when one inherited and one not inherited team permission are added', () => {
     beforeEach(() => {
-      const teamItem = {
+      const overridingItemForChildDashboard = {
         team: 'MyTestTeam',
         dashboardId: 1,
         teamId: 1,
         permission: 2,
       };
-      store.addStoreItem(teamItem);
+
+      store.resetNewType();
+      store.newItem.setTeam(overridingItemForChildDashboard.teamId, overridingItemForChildDashboard.team);
+      store.newItem.setPermission(overridingItemForChildDashboard.permission);
+      store.addStoreItem();
     });
 
-    it('should not throw a validation error', () => {
+    it('should allowing overriding the inherited permission and not throw a validation error', () => {
       expect(store.error).toBe(null);
     });
 
-    it('should add both permissions', () => {
+    it('should add new overriding permission', () => {
       expect(store.items.length).toBe(4);
     });
   });

+ 143 - 51
public/app/stores/PermissionsStore/PermissionsStore.ts

@@ -1,4 +1,4 @@
-import { types, getEnv, flow } from 'mobx-state-tree';
+import { types, getEnv, flow } from 'mobx-state-tree';
 import { PermissionsStoreItem } from './PermissionsStoreItem';
 
 const duplicateError = 'This permission exists already.';
@@ -13,15 +13,62 @@ export const permissionOptions = [
   },
 ];
 
-export const aclTypes = [
-  { value: 'Group', text: 'Team' },
-  { value: 'User', text: 'User' },
-  { value: 'Viewer', text: 'Everyone With Viewer Role' },
-  { value: 'Editor', text: 'Everyone With Editor Role' },
-];
+export const aclTypeValues = {
+  GROUP: { value: 'Group', text: 'Team' },
+  USER: { value: 'User', text: 'User' },
+  VIEWER: { value: 'Viewer', text: 'Everyone With Viewer Role' },
+  EDITOR: { value: 'Editor', text: 'Everyone With Editor Role' },
+};
+
+export const aclTypes = Object.keys(aclTypeValues).map(item => aclTypeValues[item]);
 
 const defaultNewType = aclTypes[0].value;
 
+export const NewPermissionsItem = types
+  .model('NewPermissionsItem', {
+    type: types.optional(
+      types.enumeration(Object.keys(aclTypeValues).map(item => aclTypeValues[item].value)),
+      defaultNewType
+    ),
+    userId: types.maybe(types.number),
+    userLogin: types.maybe(types.string),
+    teamId: types.maybe(types.number),
+    team: types.maybe(types.string),
+    permission: types.optional(types.number, 1),
+  })
+  .views(self => ({
+    isValid: () => {
+      switch (self.type) {
+        case aclTypeValues.GROUP.value:
+          return self.teamId && self.team;
+        case aclTypeValues.USER.value:
+          return !!self.userId && !!self.userLogin;
+        case aclTypeValues.VIEWER.value:
+        case aclTypeValues.EDITOR.value:
+          return true;
+        default:
+          return false;
+      }
+    },
+  }))
+  .actions(self => ({
+    setUser(userId: number, userLogin: string) {
+      self.userId = userId;
+      self.userLogin = userLogin;
+      self.teamId = null;
+      self.team = null;
+    },
+    setTeam(teamId: number, team: string) {
+      self.userId = null;
+      self.userLogin = null;
+      self.teamId = teamId;
+      self.team = team;
+    },
+    setPermission(permission: number) {
+      self.permission = permission;
+    },
+  }));
+
 export const PermissionsStore = types
   .model('PermissionsStore', {
     fetching: types.boolean,
@@ -31,6 +78,8 @@ export const PermissionsStore = types
     error: types.maybe(types.string),
     originalItems: types.optional(types.array(PermissionsStoreItem), []),
     newType: types.optional(types.string, defaultNewType),
+    newItem: types.maybe(NewPermissionsItem),
+    isAddPermissionsVisible: types.optional(types.boolean, false),
     isInRoot: types.maybe(types.boolean),
   })
   .views(self => ({
@@ -38,7 +87,6 @@ export const PermissionsStore = types
       const dupe = self.items.find(it => {
         return isDuplicate(it, item);
       });
-
       if (dupe) {
         self.error = duplicateError;
         return false;
@@ -47,50 +95,94 @@ export const PermissionsStore = types
       return true;
     },
   }))
-  .actions(self => ({
-    load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) {
-      const backendSrv = getEnv(self).backendSrv;
-      self.fetching = true;
-      self.isFolder = isFolder;
-      self.isInRoot = isInRoot;
-      self.dashboardId = dashboardId;
-      const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
-      const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
-      self.items = items;
-      self.originalItems = items;
-      self.fetching = false;
-      self.error = null;
-    }),
-    addStoreItem: flow(function* addStoreItem(item) {
+  .actions(self => {
+    const resetNewType = () => {
       self.error = null;
-      if (!self.isValid(item)) {
-        return undefined;
-      }
+      self.newItem = NewPermissionsItem.create();
+    };
 
-      self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
-      return updateItems(self);
-    }),
-    removeStoreItem: flow(function* removeStoreItem(idx: number) {
-      self.error = null;
-      self.items.splice(idx, 1);
-      return updateItems(self);
-    }),
-    updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
-      idx: number,
-      permission: number,
-      permissionName: string
-    ) {
-      self.error = null;
-      self.items[idx].updatePermission(permission, permissionName);
-      return updateItems(self);
-    }),
-    setNewType(newType: string) {
-      self.newType = newType;
-    },
-    resetNewType() {
-      self.newType = defaultNewType;
-    },
-  }));
+    return {
+      load: flow(function* load(dashboardId: number, isFolder: boolean, isInRoot: boolean) {
+        const backendSrv = getEnv(self).backendSrv;
+        self.fetching = true;
+        self.isFolder = isFolder;
+        self.isInRoot = isInRoot;
+        self.dashboardId = dashboardId;
+        const res = yield backendSrv.get(`/api/dashboards/id/${dashboardId}/acl`);
+        const items = prepareServerResponse(res, dashboardId, isFolder, isInRoot);
+        self.items = items;
+        self.originalItems = items;
+        self.fetching = false;
+        self.error = null;
+      }),
+      addStoreItem: flow(function* addStoreItem() {
+        self.error = null;
+        let item = {
+          type: self.newItem.type,
+          permission: self.newItem.permission,
+          dashboardId: self.dashboardId,
+          team: undefined,
+          teamId: undefined,
+          userLogin: undefined,
+          userId: undefined,
+          role: undefined,
+        };
+        switch (self.newItem.type) {
+          case aclTypeValues.GROUP.value:
+            item.team = self.newItem.team;
+            item.teamId = self.newItem.teamId;
+            break;
+          case aclTypeValues.USER.value:
+            item.userLogin = self.newItem.userLogin;
+            item.userId = self.newItem.userId;
+            break;
+          case aclTypeValues.VIEWER.value:
+          case aclTypeValues.EDITOR.value:
+            item.role = self.newItem.type;
+            break;
+          default:
+            throw Error('Unknown type: ' + self.newItem.type);
+        }
+
+        if (!self.isValid(item)) {
+          return undefined;
+        }
+
+        self.items.push(prepareItem(item, self.dashboardId, self.isFolder, self.isInRoot));
+        resetNewType();
+        return updateItems(self);
+      }),
+      removeStoreItem: flow(function* removeStoreItem(idx: number) {
+        self.error = null;
+        self.items.splice(idx, 1);
+        return updateItems(self);
+      }),
+      updatePermissionOnIndex: flow(function* updatePermissionOnIndex(
+        idx: number,
+        permission: number,
+        permissionName: string
+      ) {
+        self.error = null;
+        self.items[idx].updatePermission(permission, permissionName);
+        return updateItems(self);
+      }),
+      setNewType(newType: string) {
+        self.newItem = NewPermissionsItem.create({ type: newType });
+      },
+      resetNewType() {
+        resetNewType();
+      },
+      toggleAddPermissions() {
+        self.isAddPermissionsVisible = !self.isAddPermissionsVisible;
+      },
+      showAddPermissions() {
+        self.isAddPermissionsVisible = true;
+      },
+      hideAddPermissions() {
+        self.isAddPermissionsVisible = false;
+      },
+    };
+  });
 
 const updateItems = self => {
   self.error = null;
@@ -116,7 +208,7 @@ const updateItems = self => {
       items: updated,
     });
   } catch (error) {
-    console.error(error);
+    self.error = error;
   }
 
   return res;

+ 4 - 0
public/sass/components/_buttons.scss

@@ -113,6 +113,10 @@
   //border: 1px solid $tight-form-func-highlight-bg;
 }
 
+.btn-transparent {
+  background-color: transparent;
+}
+
 .btn-outline-primary {
   @include button-outline-variant($btn-primary-bg);
 }

+ 18 - 0
public/sass/components/_gf-form.scss

@@ -274,6 +274,10 @@ $input-border: 1px solid $input-border-color;
     }
   }
 
+  .gf-form-input {
+    margin-right: 0;
+  }
+
   select.gf-form-input {
     text-indent: 0.01px;
     text-overflow: '';
@@ -392,3 +396,17 @@ select.gf-form-input ~ .gf-form-help-icon {
   top: 10px;
   color: $text-muted;
 }
+
+.cta-form {
+  position: relative;
+  padding: 1rem;
+  background-color: $empty-list-cta-bg;
+  margin-bottom: 1rem;
+  border-top: 3px solid $green;
+}
+
+.cta-form__close {
+  position: absolute;
+  right: 0;
+  top: 0;
+}

+ 25 - 0
yarn.lock

@@ -1604,6 +1604,10 @@ center-align@^0.1.1:
     align-text "^0.1.3"
     lazy-cache "^1.0.3"
 
+chain-function@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.0.tgz#0d4ab37e7e18ead0bdc47b920764118ce58733dc"
+
 chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3, chalk@~1.1.0, chalk@~1.1.1:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@@ -2801,6 +2805,10 @@ dom-converter@~0.1:
   dependencies:
     utila "~0.3"
 
+dom-helpers@^3.2.0:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6"
+
 dom-serialize@^2.2.0:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
@@ -8318,6 +8326,17 @@ react-test-renderer@^16.0.0, react-test-renderer@^16.0.0-0:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
+react-transition-group@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
+  dependencies:
+    chain-function "^1.0.0"
+    classnames "^2.2.5"
+    dom-helpers "^3.2.0"
+    loose-envify "^1.3.1"
+    prop-types "^15.5.8"
+    warning "^3.0.0"
+
 react@^16.2.0:
   version "16.2.0"
   resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
@@ -10355,6 +10374,12 @@ walker@~1.0.5:
   dependencies:
     makeerror "1.0.x"
 
+warning@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
+  dependencies:
+    loose-envify "^1.0.0"
+
 watch@~0.18.0:
   version "0.18.0"
   resolved "https://registry.yarnpkg.com/watch/-/watch-0.18.0.tgz#28095476c6df7c90c963138990c0a5423eb4b986"