Browse Source

feat(growth): sets up UI and hooks for disabled member in settings (#26248)

This PR shows disabled members in the settings pages and adds hooks for getsentry to add upsell functionality. Refer to this PR for more details
Stephen Cefali 3 years ago
parent
commit
0608d68cfa

+ 1 - 0
src/sentry/api/serializers/models/organization_member.py

@@ -112,6 +112,7 @@ class OrganizationMemberSerializer(Serializer):  # type: ignore
             "flags": {
                 "sso:linked": bool(getattr(obj.flags, "sso:linked")),
                 "sso:invalid": bool(getattr(obj.flags, "sso:invalid")),
+                "member-limit:restricted": bool(getattr(obj.flags, "member-limit:restricted")),
             },
             "dateCreated": obj.date_added,
             "inviteStatus": obj.get_invite_status_name(),

+ 2 - 0
static/app/stores/hookStore.tsx

@@ -15,9 +15,11 @@ const validHookNames = new Set<HookName>([
   'analytics:track-event',
   'analytics:log-experiment',
   'component:disabled-member',
+  'component:disabled-member-tooltip',
   'component:header-date-range',
   'component:header-selector-items',
   'component:global-notifications',
+  'component:member-list-header',
   'feature-disabled:alerts-page',
   'feature-disabled:alert-wizard-performance',
   'feature-disabled:configure-distributed-tracing',

+ 8 - 0
static/app/types/hooks.tsx

@@ -6,6 +6,7 @@ import SelectorItems from 'app/components/organizations/timeRangeSelector/dateRa
 import SidebarItem from 'app/components/sidebar/sidebarItem';
 import {
   IntegrationProvider,
+  Member,
   Organization,
   OrganizationSummary,
   Project,
@@ -57,6 +58,11 @@ type DateRangeProps = React.ComponentProps<typeof DateRange>;
 type SelectorItemsProps = React.ComponentProps<typeof SelectorItems>;
 type GlobalNotificationProps = {className: string; organization?: Organization};
 type DisabledMemberViewProps = {organization: OrganizationSummary};
+type MemberListHeaderProps = {
+  members: Member[];
+  organization: Organization;
+};
+type DisabledMemberTooltipProps = {children: React.ReactNode};
 
 /**
  * Component wrapping hooks
@@ -66,6 +72,8 @@ export type ComponentHooks = {
   'component:header-selector-items': () => React.ComponentType<SelectorItemsProps>;
   'component:global-notifications': () => React.ComponentType<GlobalNotificationProps>;
   'component:disabled-member': () => React.ComponentType<DisabledMemberViewProps>;
+  'component:member-list-header': () => React.ComponentType<MemberListHeaderProps>;
+  'component:disabled-member-tooltip': () => React.ComponentType<DisabledMemberTooltipProps>;
 };
 
 /**

+ 1 - 0
static/app/types/index.tsx

@@ -1107,6 +1107,7 @@ export type Member = {
   flags: {
     'sso:linked': boolean;
     'sso:invalid': boolean;
+    'member-limit:restricted': boolean;
   };
   id: string;
   inviteStatus: 'approved' | 'requested_to_be_invited' | 'requested_to_join';

+ 6 - 0
static/app/utils/isMemberDisabledFromLimit.tsx

@@ -0,0 +1,6 @@
+import {Member} from 'app/types';
+
+//check to see if a member has been disabled because of the member limit
+export default function isMemberDisabledFromLimit(member?: Member | null) {
+  return member?.flags['member-limit:restricted'] ?? false;
+}

+ 30 - 8
static/app/views/settings/organizationMembers/organizationMemberDetail.tsx

@@ -15,6 +15,7 @@ import Button from 'app/components/button';
 import Confirm from 'app/components/confirm';
 import DateTime from 'app/components/dateTime';
 import NotFound from 'app/components/errors/notFound';
+import HookOrDefault from 'app/components/hookOrDefault';
 import ExternalLink from 'app/components/links/externalLink';
 import {Panel, PanelBody, PanelHeader, PanelItem} from 'app/components/panels';
 import Tooltip from 'app/components/tooltip';
@@ -22,6 +23,7 @@ import {t, tct} from 'app/locale';
 import {inputStyles} from 'app/styles/input';
 import space from 'app/styles/space';
 import {Member, Organization, Team} from 'app/types';
+import isMemberDisabledFromLimit from 'app/utils/isMemberDisabledFromLimit';
 import recreateRoute from 'app/utils/recreateRoute';
 import withOrganization from 'app/utils/withOrganization';
 import AsyncView from 'app/views/asyncView';
@@ -53,6 +55,11 @@ type State = {
   member: Member | null;
 } & AsyncView['state'];
 
+const DisabledMemberTooltip = HookOrDefault({
+  hookName: 'component:disabled-member-tooltip',
+  defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
+});
+
 class OrganizationMemberDetail extends AsyncView<Props, State> {
   getDefaultState(): State {
     return {
@@ -203,6 +210,27 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
     return '';
   };
 
+  get memberDeactivated() {
+    return isMemberDisabledFromLimit(this.state.member);
+  }
+
+  renderMemberStatus(member: Member) {
+    if (this.memberDeactivated) {
+      return (
+        <em>
+          <DisabledMemberTooltip>{t('Deactivated')}</DisabledMemberTooltip>
+        </em>
+      );
+    }
+    if (member.expired) {
+      return <em>{t('Invitation Expired')}</em>;
+    }
+    if (member.pending) {
+      return <em>{t('Invitation Pending')}</em>;
+    }
+    return t('Active');
+  }
+
   renderBody() {
     const {organization} = this.props;
     const {member} = this.state;
@@ -213,7 +241,7 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
 
     const {access} = organization;
     const inviteLink = member.invite_link;
-    const canEdit = access.includes('org:write');
+    const canEdit = access.includes('org:write') && !this.memberDeactivated;
 
     const {email, expired, pending} = member;
     const canResend = !expired;
@@ -246,13 +274,7 @@ class OrganizationMemberDetail extends AsyncView<Props, State> {
                   <div>
                     <DetailLabel>{t('Status')}</DetailLabel>
                     <div data-test-id="member-status">
-                      {member.expired ? (
-                        <em>{t('Invitation Expired')}</em>
-                      ) : member.pending ? (
-                        <em>{t('Invitation Pending')}</em>
-                      ) : (
-                        t('Active')
-                      )}
+                      {this.renderMemberStatus(member)}
                     </div>
                   </div>
                   <div>

+ 27 - 12
static/app/views/settings/organizationMembers/organizationMemberRow.tsx

@@ -5,6 +5,7 @@ import styled from '@emotion/styled';
 import UserAvatar from 'app/components/avatar/userAvatar';
 import Button from 'app/components/button';
 import Confirm from 'app/components/confirm';
+import HookOrDefault from 'app/components/hookOrDefault';
 import Link from 'app/components/links/link';
 import LoadingIndicator from 'app/components/loadingIndicator';
 import {PanelItem} from 'app/components/panels';
@@ -12,6 +13,7 @@ import {IconCheckmark, IconClose, IconFlag, IconMail, IconSubtract} from 'app/ic
 import {t, tct} from 'app/locale';
 import space from 'app/styles/space';
 import {AvatarUser, Member} from 'app/types';
+import isMemberDisabledFromLimit from 'app/utils/isMemberDisabledFromLimit';
 import recreateRoute from 'app/utils/recreateRoute';
 
 type Props = {
@@ -34,6 +36,11 @@ type State = {
   busy: boolean;
 };
 
+const DisabledMemberTooltip = HookOrDefault({
+  hookName: 'component:disabled-member-tooltip',
+  defaultComponent: ({children}) => <Fragment>{children}</Fragment>,
+});
+
 export default class OrganizationMemberRow extends PureComponent<Props, State> {
   state: State = {
     busy: false,
@@ -71,6 +78,23 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
     onSendInvite(member);
   };
 
+  renderMemberRole() {
+    const {member} = this.props;
+    const {roleName, pending, expired} = member;
+    if (isMemberDisabledFromLimit(member)) {
+      return <DisabledMemberTooltip>{t('Deactivated')}</DisabledMemberTooltip>;
+    }
+    if (pending) {
+      return (
+        <InvitedRole>
+          <IconMail size="md" />
+          {expired ? t('Expired Invite') : tct('Invited [roleName]', {roleName})}
+        </InvitedRole>
+      );
+    }
+    return roleName;
+  }
+
   render() {
     const {
       params,
@@ -85,7 +109,7 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
       canAddMembers,
     } = this.props;
 
-    const {id, flags, email, name, roleName, pending, expired, user} = member;
+    const {id, flags, email, name, pending, user} = member;
 
     // if member is not the only owner, they can leave
     const needsSso = !flags['sso:linked'] && requireLink;
@@ -113,16 +137,7 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
           </MemberDescription>
         </MemberHeading>
 
-        <div data-test-id="member-role">
-          {pending ? (
-            <InvitedRole>
-              <IconMail size="md" />
-              {expired ? t('Expired Invite') : tct('Invited [roleName]', {roleName})}
-            </InvitedRole>
-          ) : (
-            roleName
-          )}
-        </div>
+        <div data-test-id="member-role">{this.renderMemberRole()}</div>
 
         <div data-test-id="member-status">
           {showResendButton ? (
@@ -132,7 +147,7 @@ export default class OrganizationMemberRow extends PureComponent<Props, State> {
                   <LoadingIndicator mini />
                 </LoadingContainer>
               )}
-              {isInviteSuccessful && <span>Sent!</span>}
+              {isInviteSuccessful && <span>{t('Sent!')}</span>}
               {!isInviting && !isInviteSuccessful && (
                 <Button
                   disabled={!canAddMembers}

+ 7 - 2
static/app/views/settings/organizationMembers/organizationMembersList.tsx

@@ -8,6 +8,7 @@ import {resendMemberInvite} from 'app/actionCreators/members';
 import {redirectToRemainingOrganization} from 'app/actionCreators/organizations';
 import Button from 'app/components/button';
 import DropdownMenu from 'app/components/dropdownMenu';
+import HookOrDefault from 'app/components/hookOrDefault';
 import Pagination from 'app/components/pagination';
 import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
 import {MEMBER_ROLES} from 'app/constants';
@@ -35,6 +36,11 @@ type State = AsyncView['state'] & {
   invited: {[key: string]: 'loading' | 'success' | null};
 };
 
+const MemberListHeader = HookOrDefault({
+  hookName: 'component:member-list-header',
+  defaultComponent: () => <PanelHeader>{t('Members')}</PanelHeader>,
+});
+
 class OrganizationMembersList extends AsyncView<Props, State> {
   getDefaultState() {
     return {
@@ -191,8 +197,7 @@ class OrganizationMembersList extends AsyncView<Props, State> {
           }
         </ClassNames>
         <Panel data-test-id="org-member-list">
-          <PanelHeader>{t('Members')}</PanelHeader>
-
+          <MemberListHeader members={members} organization={organization} />
           <PanelBody>
             {members.map(member => (
               <OrganizationMemberRow