Browse Source

ref(ui): Add invite members panel (#15040)

Megan Heskett 5 years ago
parent
commit
bce3e6e9f3

+ 1 - 3
src/sentry/static/sentry/app/components/modals/inviteMembersModal/index.tsx

@@ -226,9 +226,7 @@ class InviteMembersModal extends AsyncComponent<Props, State> {
           <InlineSvg src="icon-mail" size="36px" />
           {t('Invite New Members')}
         </Heading>
-        <Subtext>
-          {t('Invite new members by email invitation to join your Organization.')}
-        </Subtext>
+        <Subtext>{t('Invite new members by email to join your organization.')}</Subtext>
 
         <InviteeHeadings>
           <div>{t('Email addresses')}</div>

+ 57 - 21
src/sentry/static/sentry/app/views/settings/organizationMembers/index.jsx

@@ -1,5 +1,6 @@
 import PropTypes from 'prop-types';
 import React from 'react';
+import styled from 'react-emotion';
 import {debounce} from 'lodash';
 
 import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
@@ -9,9 +10,11 @@ import AsyncView from 'app/views/asyncView';
 import Button from 'app/components/button';
 import EmptyMessage from 'app/views/settings/components/emptyMessage';
 import ConfigStore from 'app/stores/configStore';
+import InlineSvg from 'app/components/inlineSvg';
 import Pagination from 'app/components/pagination';
 import SentryTypes from 'app/sentryTypes';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
+import space from 'app/styles/space';
 import routeTitleGen from 'app/utils/routeTitle';
 import {redirectToRemainingOrganization} from 'app/actionCreators/organizations';
 import {openInviteMembersModal} from 'app/actionCreators/modal';
@@ -224,7 +227,7 @@ class OrganizationMembersView extends AsyncView {
     const {organization} = this.context;
     const {orgId} = params || {};
     const {name: orgName, access} = organization;
-    const canAddMembers = access.indexOf('org:write') > -1;
+    const canAddMembers = access.indexOf('member:write') > -1;
     const canRemove = access.indexOf('member:admin') > -1;
     const currentUser = ConfigStore.get('user');
     // Find out if current user is the only owner
@@ -236,26 +239,30 @@ class OrganizationMembersView extends AsyncView {
 
     return (
       <div>
-        <SettingsPageHeader
-          title="Members"
-          action={
-            <Button
-              priority="primary"
-              size="small"
-              disabled={!canAddMembers}
-              title={
-                !canAddMembers
-                  ? t('You do not have enough permission to add new members')
-                  : undefined
-              }
-              onClick={openInviteMembersModal}
-              icon="icon-circle-add"
-              data-test-id="invite-member"
-            >
-              {t('Invite Member')}
-            </Button>
-          }
-        />
+        <SettingsPageHeader title="Members" />
+
+        <StyledPanel>
+          <InlineSvg src="icon-mail" size="36px" />
+          <TextContainer>
+            <Heading>{t('Invite new members')}</Heading>
+            <SubText>
+              {t('Invite new members by email to join your organization.')}
+            </SubText>
+          </TextContainer>
+          <Button
+            priority="primary"
+            size="small"
+            disabled={!canAddMembers}
+            title={
+              !canAddMembers
+                ? t('You do not have enough permission to add new members')
+                : undefined
+            }
+            onClick={openInviteMembersModal}
+          >
+            {t('Invite Members')}
+          </Button>
+        </StyledPanel>
 
         <OrganizationAccessRequests
           onApprove={this.handleApprove}
@@ -309,4 +316,33 @@ class OrganizationMembersView extends AsyncView {
   }
 }
 
+const StyledPanel = styled(Panel)`
+  padding: 18px;
+  margin-top: -14px;
+  margin-bottom: 40px;
+  background: none;
+  display: grid;
+  grid-template-columns: max-content auto max-content;
+  grid-gap: ${space(3)};
+  align-items: center;
+  align-content: center;
+`;
+
+const TextContainer = styled('div')`
+  display: inline-grid;
+  grid-gap: ${space(1)};
+`;
+
+const Heading = styled('h1')`
+  margin: 0;
+  font-weight: 400;
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+`;
+
+const SubText = styled('p')`
+  margin: 0;
+  color: ${p => p.theme.gray3};
+  font-size: 15px;
+`;
+
 export default OrganizationMembersView;

+ 1 - 1
src/sentry/templates/sentry/organization-login.html

@@ -83,7 +83,7 @@
             </form>
             {% if join_request_link %}
               <div class="auth-join-request">{% trans "No access? "%}
-                <a href="{{ join_request_link }}">{% trans "Request to Join." %}</a>
+                <a href="{{ join_request_link }}">{% trans "Request to join" %}</a>
               </div>
             {% endif %}
           </div>

+ 7 - 0
src/sentry/utils/pytest/selenium.py

@@ -90,6 +90,13 @@ class Browser(object):
         """
         return self.element_exists('[data-test-id="%s"]' % (selector))
 
+    def element_exists_by_aria_label(self, selector):
+        """
+        Check if an element exists on the page using the aria-label attribute.
+        This method will not wait for the element.
+        """
+        return self.element_exists('[aria-label="%s"]' % (selector))
+
     def click(self, selector):
         self.element(selector).click()
 

+ 1 - 1
tests/acceptance/test_member_list.py

@@ -26,5 +26,5 @@ class ListOrganizationMembersTest(AcceptanceTestCase):
         self.browser.get(u"/organizations/{}/members/".format(self.org.slug))
         self.browser.wait_until_not(".loading-indicator")
         self.browser.snapshot(name="list organization members")
-        assert self.browser.element_exists_by_test_id("invite-member")
+        assert self.browser.element_exists_by_aria_label("Invite Members")
         assert self.browser.element_exists_by_test_id("resend-invite")

+ 24 - 2
tests/js/spec/views/settings/organizationMembers/index.spec.jsx

@@ -3,6 +3,7 @@ import {browserHistory} from 'react-router';
 
 import {Client} from 'app/api';
 import {mount} from 'enzyme';
+import {openInviteMembersModal} from 'app/actionCreators/modal';
 import ConfigStore from 'app/stores/configStore';
 import OrganizationMembers from 'app/views/settings/organizationMembers';
 import OrganizationsStore from 'app/stores/organizationsStore';
@@ -10,6 +11,9 @@ import {addSuccessMessage, addErrorMessage} from 'app/actionCreators/indicator';
 
 jest.mock('app/api');
 jest.mock('app/actionCreators/indicator');
+jest.mock('app/actionCreators/modal', () => ({
+  openInviteMembersModal: jest.fn(),
+}));
 
 describe('OrganizationMembers', function() {
   const members = TestStubs.Members();
@@ -30,7 +34,7 @@ describe('OrganizationMembers', function() {
     location: {query: {}},
   };
   const organization = TestStubs.Organization({
-    access: ['member:admin', 'org:admin'],
+    access: ['member:admin', 'org:admin', 'member:write'],
     status: {
       id: 'active',
     },
@@ -87,6 +91,24 @@ describe('OrganizationMembers', function() {
     OrganizationsStore.load([organization]);
   });
 
+  it('can invite member with access', function() {
+    const wrapper = mount(
+      <OrganizationMembers
+        {...defaultProps}
+        params={{
+          orgId: 'org-id',
+        }}
+      />,
+      TestStubs.routerContext([{organization}])
+    );
+
+    const inviteButton = wrapper.find('StyledButton[aria-label="Invite Members"]');
+    expect(inviteButton.prop('disabled')).toBe(false);
+    inviteButton.simulate('click');
+
+    expect(openInviteMembersModal).toHaveBeenCalled();
+  });
+
   it('can remove a member', async function() {
     const deleteMock = MockApiClient.addMockResponse({
       url: `/organizations/org-id/members/${members[0].id}/`,
@@ -282,7 +304,7 @@ describe('OrganizationMembers', function() {
           orgId: 'org-id',
         }}
       />,
-      TestStubs.routerContext()
+      TestStubs.routerContext([{organization}])
     );
 
     expect(inviteMock).not.toHaveBeenCalled();