Browse Source

ref(ui): Map permissions to resource subscriptions (#11510)

* ref(ui): Map permissions to resource subscriptions
MeredithAnya 6 years ago
parent
commit
911f315e51

+ 9 - 0
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/constants.jsx

@@ -0,0 +1,9 @@
+export const EVENT_CHOICES = ['issue'];
+
+export const DESCRIPTIONS = {
+  issue: 'created, resolved, assigned',
+};
+
+export const PERMISSIONS_MAP = {
+  issue: 'Event',
+};

+ 7 - 19
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/permissionSelection.jsx

@@ -5,7 +5,6 @@ import {find, flatMap} from 'lodash';
 
 import {t} from 'app/locale';
 import {SENTRY_APP_PERMISSIONS} from 'app/constants';
-import ConsolidatedScopes from 'app/utils/consolidatedScopes';
 import SelectField from 'app/views/settings/components/forms/selectField';
 
 /**
@@ -84,31 +83,17 @@ export default class PermissionSelection extends React.Component {
   };
 
   static propTypes = {
-    scopes: PropTypes.arrayOf(PropTypes.string).isRequired,
+    permissions: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
   };
 
   constructor(...args) {
     super(...args);
     this.state = {
-      permissions: this.scopeListToPermissionState(),
+      permissions: this.props.permissions,
     };
   }
 
-  /**
-   * Converts the list of raw API scopes passed in to an object that can
-   * before stored and used via `state`. This object is structured by
-   * resource and holds "Permission" values. For example:
-   *
-   *    {
-   *      'Project': 'read',
-   *      ...,
-   *    }
-   *
-   */
-  scopeListToPermissionState() {
-    return new ConsolidatedScopes(this.props.scopes).toResourcePermissions();
-  }
-
   /**
    * Converts the "Permission" values held in `state` to a list of raw
    * API scopes we can send to the server. For example:
@@ -128,10 +113,13 @@ export default class PermissionSelection extends React.Component {
 
   onChange = (resource, choice) => {
     const {permissions} = this.state;
-
     permissions[resource] = choice;
+    this.save(permissions);
+  };
 
+  save = permissions => {
     this.setState({permissions});
+    this.props.onChange(permissions);
     this.context.form.setValue('scopes', this.permissionStateToList());
   };
 

+ 77 - 0
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/permissionsObserver.jsx

@@ -0,0 +1,77 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import ConsolidatedScopes from 'app/utils/consolidatedScopes';
+import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
+import {t} from 'app/locale';
+import PermissionSelection from 'app/views/settings/organizationDeveloperSettings/permissionSelection';
+import Subscriptions from 'app/views/settings/organizationDeveloperSettings/resourceSubscriptions';
+
+export default class PermissionsObserver extends React.Component {
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    form: PropTypes.object,
+  };
+
+  static propTypes = {
+    scopes: PropTypes.arrayOf(PropTypes.string).isRequired,
+    events: PropTypes.arrayOf(PropTypes.string).isRequired,
+  };
+
+  constructor(...args) {
+    super(...args);
+    this.state = {
+      permissions: this.scopeListToPermissionState(),
+      events: this.props.events,
+    };
+  }
+  /**
+   * Converts the list of raw API scopes passed in to an object that can
+   * before stored and used via `state`. This object is structured by
+   * resource and holds "Permission" values. For example:
+   *
+   *    {
+   *      'Project': 'read',
+   *      ...,
+   *    }
+   *
+   */
+  scopeListToPermissionState() {
+    return new ConsolidatedScopes(this.props.scopes).toResourcePermissions();
+  }
+
+  onPermissionChange = permissions => {
+    this.setState({permissions});
+  };
+
+  onEventChange = events => {
+    this.setState({events});
+  };
+
+  render() {
+    const {permissions, events} = this.state;
+    return (
+      <React.Fragment>
+        <Panel>
+          <PanelHeader>{t('Permissions')}</PanelHeader>
+          <PanelBody>
+            <PermissionSelection
+              permissions={permissions}
+              onChange={this.onPermissionChange}
+            />
+          </PanelBody>
+        </Panel>
+        <Panel>
+          <PanelHeader>{t('Resource Subscriptions')}</PanelHeader>
+          <PanelBody>
+            <Subscriptions
+              permissions={permissions}
+              events={events}
+              onChange={this.onEventChange}
+            />
+          </PanelBody>
+        </Panel>
+      </React.Fragment>
+    );
+  }
+}

+ 81 - 0
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/resourceSubscriptions.jsx

@@ -0,0 +1,81 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import SubscriptionBox from 'app/views/settings/organizationDeveloperSettings/subscriptionBox';
+import {
+  EVENT_CHOICES,
+  PERMISSIONS_MAP,
+} from 'app/views/settings/organizationDeveloperSettings/constants';
+import styled from 'react-emotion';
+
+export default class Subscriptions extends React.Component {
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+    form: PropTypes.object,
+  };
+
+  static propTypes = {
+    permissions: PropTypes.object.isRequired,
+    events: PropTypes.array.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  constructor(...args) {
+    super(...args);
+    this.state = {events: this.props.events};
+  }
+
+  componentDidUpdate(prevProps) {
+    const {events} = this.state;
+    const {permissions} = this.props;
+
+    const permittedEvents = events.filter(resource => {
+      return permissions[PERMISSIONS_MAP[resource]] !== 'no-access';
+    });
+
+    if (JSON.stringify(events) !== JSON.stringify(permittedEvents)) {
+      this.save(permittedEvents);
+    }
+  }
+
+  onChange = (resource, checked) => {
+    const events = new Set(this.state.events);
+    checked ? events.add(resource) : events.delete(resource);
+    this.save(Array.from(events));
+  };
+
+  save = events => {
+    this.setState({events});
+    this.props.onChange(events);
+    this.context.form.setValue('events', events);
+  };
+
+  render() {
+    const {permissions} = this.props;
+    const {events} = this.state;
+
+    return (
+      <SubscriptionGrid>
+        {EVENT_CHOICES.map(choice => {
+          const disabled = permissions[PERMISSIONS_MAP[choice]] === 'no-access';
+          return (
+            <React.Fragment key={choice}>
+              <SubscriptionBox
+                key={`${choice}${disabled}`}
+                disabled={disabled}
+                checked={events.includes(choice) && !disabled}
+                resource={choice}
+                onChange={this.onChange}
+              />
+            </React.Fragment>
+          );
+        })}
+      </SubscriptionGrid>
+    );
+  }
+}
+
+const SubscriptionGrid = styled('div')`
+  display: flex;
+  flex-wrap: wrap;
+`;

+ 3 - 32
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.jsx

@@ -3,23 +3,19 @@ import React from 'react';
 import {browserHistory} from 'react-router';
 
 import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
-import {defined} from 'app/utils';
 import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
 import {t} from 'app/locale';
 import AsyncView from 'app/views/asyncView';
 import Form from 'app/views/settings/components/forms/form';
 import FormModel from 'app/views/settings/components/forms/model';
 import FormField from 'app/views/settings/components/forms/formField';
-import MultipleCheckbox from 'app/views/settings/components/forms/controls/multipleCheckbox';
 import JsonForm from 'app/views/settings/components/forms/jsonForm';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
-import PermissionSelection from 'app/views/settings/organizationDeveloperSettings/permissionSelection';
+import PermissionsObserver from 'app/views/settings/organizationDeveloperSettings/permissionsObserver';
 import TextCopyInput from 'app/views/settings/components/forms/textCopyInput';
 import sentryApplicationForm from 'app/data/forms/sentryApplication';
 import getDynamicText from 'app/utils/getDynamicText';
 
-const EVENT_CHOICES = [['issue', 'Issue events']];
-
 class SentryAppFormModel extends FormModel {
   /**
    * Filter out Permission input field values.
@@ -94,6 +90,7 @@ export default class SentryApplicationDetails extends AsyncView {
     const {orgId} = this.props.params;
     const {app} = this.state;
     const scopes = (app && [...app.scopes]) || [];
+    const events = (app && this.normalize(app.events)) || [];
 
     let method = app ? 'PUT' : 'POST';
     let endpoint = app ? `/sentry-apps/${app.slug}/` : '/sentry-apps/';
@@ -112,33 +109,7 @@ export default class SentryApplicationDetails extends AsyncView {
         >
           <JsonForm location={this.props.location} forms={sentryApplicationForm} />
 
-          <Panel>
-            <PanelHeader>{t('Permissions')}</PanelHeader>
-            <PanelBody>
-              <PermissionSelection scopes={scopes} />
-            </PanelBody>
-          </Panel>
-
-          <Panel>
-            <PanelHeader>{t('Event Subscriptions')}</PanelHeader>
-            <PanelBody>
-              <FormField
-                name="events"
-                inline={false}
-                flexibleControlStateSize={true}
-                choices={EVENT_CHOICES}
-                getData={data => ({events: data})}
-              >
-                {({onChange, value}) => (
-                  <MultipleCheckbox
-                    choices={EVENT_CHOICES}
-                    onChange={onChange}
-                    value={this.normalize((defined(value.peek) && value.peek()) || [])}
-                  />
-                )}
-              </FormField>
-            </PanelBody>
-          </Panel>
+          <PermissionsObserver scopes={scopes} events={events} />
 
           {app && (
             <Panel>

+ 98 - 0
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/subscriptionBox.jsx

@@ -0,0 +1,98 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+
+import {t} from 'app/locale';
+import {DESCRIPTIONS} from 'app/views/settings/organizationDeveloperSettings/constants';
+import styled from 'react-emotion';
+import Checkbox from 'app/components/checkbox';
+import Tooltip from 'app/components/tooltip';
+import {Flex} from 'grid-emotion';
+
+export default class SubscriptionBox extends React.Component {
+  static propTypes = {
+    resource: PropTypes.string.isRequired,
+    disabled: PropTypes.bool.isRequired,
+    checked: PropTypes.bool.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  constructor(...args) {
+    super(...args);
+    this.state = {
+      checked: this.props.checked,
+    };
+  }
+
+  onChange = evt => {
+    let checked = evt.target.checked;
+    const {resource} = this.props;
+    this.setState({checked});
+    this.props.onChange(resource, checked);
+  };
+
+  render() {
+    const {resource, disabled} = this.props;
+    const {checked} = this.state;
+    const message = `Must have at least 'Read' permissions enabled for ${resource}`;
+    return (
+      <React.Fragment>
+        <SubscriptionGridItemWrapper key={resource}>
+          <Tooltip disabled={!disabled} title={message}>
+            <SubscriptionGridItem disabled={disabled}>
+              <SubscriptionInfo>
+                <SubscriptionTitle>{t(`${resource}`)}</SubscriptionTitle>
+                <SubscriptionDescription>
+                  {t(`${DESCRIPTIONS[resource]}`)}
+                </SubscriptionDescription>
+              </SubscriptionInfo>
+              <Checkbox
+                key={`${resource}${checked}`}
+                disabled={disabled}
+                id={resource}
+                value={resource}
+                checked={checked}
+                onChange={this.onChange}
+              />
+            </SubscriptionGridItem>
+          </Tooltip>
+        </SubscriptionGridItemWrapper>
+      </React.Fragment>
+    );
+  }
+}
+
+const SubscriptionInfo = styled(Flex)`
+  flex-direction: column;
+`;
+
+const SubscriptionGridItem = styled('div')`
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  background: ${p => p.theme.whiteDark};
+  opacity: ${p => (p.disabled ? 0.3 : 1)};
+  border-radius: 3px;
+  flex: 1;
+  padding: 12px;
+  height: 100%;
+`;
+
+const SubscriptionGridItemWrapper = styled('div')`
+  padding: 12px;
+  width: 33%;
+`;
+
+const SubscriptionDescription = styled('div')`
+  font-size: 12px;
+  line-height: 1;
+  color: ${p => p.theme.gray2};
+  white-space: nowrap;
+`;
+
+const SubscriptionTitle = styled('div')`
+  font-size: 16px;
+  line-height: 1;
+  color: ${p => p.theme.textColor};
+  white-space: nowrap;
+  margin-bottom: 5px;
+`;

File diff suppressed because it is too large
+ 593 - 817
tests/js/spec/views/settings/organizationDeveloperSettings/__snapshots__/sentryApplicationDetails.spec.jsx.snap


+ 76 - 0
tests/js/spec/views/settings/organizationDeveloperSettings/__snapshots__/subscriptionBox.spec.jsx.snap

@@ -0,0 +1,76 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SubscriptionBox renders resource checkbox 1`] = `
+<SubscriptionBox
+  checked={false}
+  disabled={false}
+  onChange={[MockFunction]}
+  resource="issue"
+>
+  <SubscriptionGridItemWrapper
+    key="issue"
+  >
+    <div
+      className="css-8xqq56-SubscriptionGridItemWrapper e139qogw2"
+    >
+      <Tooltip
+        disabled={true}
+        title="Must have at least 'Read' permissions enabled for issue"
+      >
+        <SubscriptionGridItem
+          disabled={false}
+        >
+          <div
+            className="css-6c04tk-SubscriptionGridItem e139qogw1"
+            disabled={false}
+          >
+            <SubscriptionInfo>
+              <Base
+                className="css-1fcst0e-SubscriptionInfo e139qogw0"
+              >
+                <div
+                  className="css-1fcst0e-SubscriptionInfo e139qogw0"
+                  is={null}
+                >
+                  <SubscriptionTitle>
+                    <div
+                      className="css-17mtfmy-SubscriptionTitle e139qogw4"
+                    >
+                      issue
+                    </div>
+                  </SubscriptionTitle>
+                  <SubscriptionDescription>
+                    <div
+                      className="css-1jp4ud-SubscriptionDescription e139qogw3"
+                    >
+                      created, resolved, assigned
+                    </div>
+                  </SubscriptionDescription>
+                </div>
+              </Base>
+            </SubscriptionInfo>
+            <Checkbox
+              checked={false}
+              disabled={false}
+              id="issue"
+              key="issuefalse"
+              onChange={[Function]}
+              value="issue"
+            >
+              <input
+                checked={false}
+                className="chk-select"
+                disabled={false}
+                id="issue"
+                onChange={[Function]}
+                type="checkbox"
+                value="issue"
+              />
+            </Checkbox>
+          </div>
+        </SubscriptionGridItem>
+      </Tooltip>
+    </div>
+  </SubscriptionGridItemWrapper>
+</SubscriptionBox>
+`;

+ 8 - 25
tests/js/spec/views/settings/organizationDeveloperSettings/permissionSelection.spec.jsx

@@ -14,7 +14,13 @@ describe('PermissionSelection', () => {
     onChange = jest.fn();
     wrapper = mount(
       <PermissionSelection
-        scopes={['project:read', 'project:write', 'project:releases', 'org:admin']}
+        permissions={{
+          Event: 'no-access',
+          Team: 'no-access',
+          Project: 'write',
+          Release: 'admin',
+          Organization: 'admin',
+        }}
         onChange={onChange}
       />,
       {
@@ -26,30 +32,6 @@ describe('PermissionSelection', () => {
     );
   });
 
-  it('defaults to no-access for all resources not passed', () => {
-    expect(wrapper.instance().state.permissions).toEqual(
-      expect.objectContaining({
-        Team: 'no-access',
-        Event: 'no-access',
-        Member: 'no-access',
-      })
-    );
-  });
-
-  it('converts a raw list of scopes into permissions', () => {
-    expect(wrapper.instance().state.permissions).toEqual(
-      expect.objectContaining({
-        Project: 'write',
-        Release: 'admin',
-        Organization: 'admin',
-      })
-    );
-  });
-
-  it('selects the highest ranking scope to convert to permission', () => {
-    expect(wrapper.instance().state.permissions.Project).toEqual('write');
-  });
-
   it('renders a row for each resource', () => {
     expect(wrapper.find('SelectField[key="Project"]')).toBeDefined();
     expect(wrapper.find('SelectField[key="Team"]')).toBeDefined();
@@ -112,6 +94,7 @@ describe('PermissionSelection', () => {
     selectByValue(wrapper, 'admin', {name: 'Release--permission'});
     selectByValue(wrapper, 'admin', {name: 'Event--permission'});
     selectByValue(wrapper, 'read', {name: 'Organization--permission'});
+    selectByValue(wrapper, 'no-access', {name: 'Member--permission'});
 
     expect(getStateValue('Project')).toEqual('write');
     expect(getStateValue('Team')).toEqual('read');

+ 49 - 0
tests/js/spec/views/settings/organizationDeveloperSettings/permissionsObserver.spec.jsx

@@ -0,0 +1,49 @@
+/*global global*/
+import React from 'react';
+
+import {mount} from 'enzyme';
+import FormModel from 'app/views/settings/components/forms/model';
+import PermissionsObserver from 'app/views/settings/organizationDeveloperSettings/permissionsObserver';
+
+describe('PermissionsObserver', () => {
+  let wrapper;
+
+  beforeEach(() => {
+    wrapper = mount(
+      <PermissionsObserver
+        scopes={['project:read', 'project:write', 'project:releases', 'org:admin']}
+        events={['issue']}
+      />,
+      {
+        context: {
+          router: TestStubs.routerContext(),
+          form: new FormModel(),
+        },
+      }
+    );
+  });
+
+  it('defaults to no-access for all resources not passed', () => {
+    expect(wrapper.instance().state.permissions).toEqual(
+      expect.objectContaining({
+        Team: 'no-access',
+        Event: 'no-access',
+        Member: 'no-access',
+      })
+    );
+  });
+
+  it('converts a raw list of scopes into permissions', () => {
+    expect(wrapper.instance().state.permissions).toEqual(
+      expect.objectContaining({
+        Project: 'write',
+        Release: 'admin',
+        Organization: 'admin',
+      })
+    );
+  });
+
+  it('selects the highest ranking scope to convert to permission', () => {
+    expect(wrapper.instance().state.permissions.Project).toEqual('write');
+  });
+});

Some files were not shown because too many files changed in this diff