Просмотр исходного кода

feat(codeowners): Async query for teams/users (#26254)

* feat(codeowners): Async query for teams/users


* update comments

Co-authored-by: meredith <meredith@sentry.io>
Co-authored-by: sentry-internal-tools[bot] <66042841+sentry-internal-tools[bot]@users.noreply.github.com>
NisanthanNanthakumar 3 лет назад
Родитель
Сommit
6930e852bb

+ 15 - 0
src/sentry/api/endpoints/organization_member_index.py

@@ -14,6 +14,7 @@ from sentry.api.validators import AllowedEmailField
 from sentry.app import locks
 from sentry.models import (
     AuditLogEntryEvent,
+    ExternalActor,
     InviteStatus,
     OrganizationMember,
     OrganizationMemberTeam,
@@ -148,6 +149,20 @@ class OrganizationMemberIndexEndpoint(OrganizationEndpoint):
                         ).distinct()
                     else:
                         queryset = queryset.filter(user__authenticator__isnull=True)
+                elif key == "hasExternalUsers":
+                    hasExternalUsers = "true" in value
+                    if hasExternalUsers:
+                        queryset = queryset.filter(
+                            user__actor_id__in=ExternalActor.objects.filter(
+                                organization=organization
+                            ).values_list("actor_id")
+                        )
+                    else:
+                        queryset = queryset.exclude(
+                            user__actor_id__in=ExternalActor.objects.filter(
+                                organization=organization
+                            ).values_list("actor_id")
+                        )
 
                 elif key == "query":
                     value = " ".join(value)

+ 17 - 1
src/sentry/api/endpoints/organization_teams.py

@@ -10,6 +10,7 @@ from sentry.api.serializers import serialize
 from sentry.api.serializers.models import team as team_serializers
 from sentry.models import (
     AuditLogEntryEvent,
+    ExternalActor,
     OrganizationMember,
     OrganizationMemberTeam,
     Team,
@@ -86,7 +87,22 @@ class OrganizationTeamsEndpoint(OrganizationEndpoint):
         if query:
             tokens = tokenize_query(query)
             for key, value in tokens.items():
-                if key == "query":
+                if key == "hasExternalTeams":
+                    hasExternalTeams = "true" in value
+                    if hasExternalTeams:
+                        queryset = queryset.filter(
+                            actor_id__in=ExternalActor.objects.filter(
+                                organization=organization
+                            ).values_list("actor_id")
+                        )
+                    else:
+                        queryset = queryset.exclude(
+                            actor_id__in=ExternalActor.objects.filter(
+                                organization=organization
+                            ).values_list("actor_id")
+                        )
+
+                elif key == "query":
                     value = " ".join(value)
                     queryset = queryset.filter(Q(name__icontains=value) | Q(slug__icontains=value))
                 else:

+ 3 - 1
static/app/components/forms/selectAsyncControl.tsx

@@ -99,9 +99,11 @@ class SelectAsyncControl extends React.Component<Props> {
 
   render() {
     const {value, forwardedRef, ...props} = this.props;
-
     return (
       <SelectControl
+        // The key is used as a way to force a reload of the options:
+        // https://github.com/JedWatson/react-select/issues/1879#issuecomment-316871520
+        key={value}
         ref={forwardedRef}
         value={value}
         defaultOptions

+ 34 - 7
static/app/components/integrationExternalMappingForm.tsx

@@ -13,9 +13,11 @@ type Props = Pick<Form['props'], 'onSubmitSuccess' | 'onCancel'> &
     organization: Organization;
     integration: Integration;
     mapping?: ExternalActorMapping;
-    sentryNames: {id: string; name: string}[];
     type: 'user' | 'team';
     baseEndpoint?: string;
+    sentryNamesMapper: (v: any) => {id: string; name: string}[];
+    url: string;
+    onResults?: (data: any) => void;
   };
 
 export default class IntegrationExternalMappingForm extends Component<Props> {
@@ -34,8 +36,10 @@ export default class IntegrationExternalMappingForm extends Component<Props> {
   }
 
   get formFields(): Field[] {
-    const {sentryNames, type} = this.props;
-    const options = sentryNames.map(({name, id}) => ({value: id, label: name}));
+    const {type, sentryNamesMapper, url, mapping} = this.props;
+    const optionMapper = sentryNames =>
+      sentryNames.map(({name, id}) => ({value: id, label: name}));
+
     const fields: any[] = [
       {
         name: 'externalName',
@@ -48,21 +52,44 @@ export default class IntegrationExternalMappingForm extends Component<Props> {
     if (type === 'user') {
       fields.push({
         name: 'userId',
-        type: 'select',
+        type: 'select_async',
         required: true,
         label: tct('Sentry [type]', {type: capitalize(type)}),
         placeholder: t(`Choose your Sentry User`),
-        options,
+        url,
+        onResults: result => {
+          // For organizations with >100 users, we want to make sure their
+          // saved mapping gets populated in the results if it wouldn't have
+          // been in the inital 100 API results, which is why we add it here
+          if (mapping && !result.find(({id}) => id === mapping.userId)) {
+            result = [{id: mapping.userId, name: mapping.sentryName}, ...result];
+          }
+          this.props.onResults?.(result);
+          return optionMapper(sentryNamesMapper(result));
+        },
       });
     }
     if (type === 'team') {
       fields.push({
         name: 'teamId',
-        type: 'select',
+        type: 'select_async',
         required: true,
         label: tct('Sentry [type]', {type: capitalize(type)}),
         placeholder: t(`Choose your Sentry Team`),
-        options,
+        url,
+        onResults: result => {
+          // For organizations with >100 teams, we want to make sure their
+          // saved mapping gets populated in the results if it wouldn't have
+          // been in the inital 100 API results, which is why we add it here
+          if (mapping && !result.find(({id}) => id === mapping.teamId)) {
+            result = [{id: mapping.teamId, name: mapping.sentryName}, ...result];
+          }
+          // The team needs `this.props.onResults` so that we have team slug
+          // when a user submits a team mapping, the endpoint needs the slug
+          // as a path param: /teams/${organization.slug}/${team.slug}/external-teams/
+          this.props.onResults?.(result);
+          return optionMapper(sentryNamesMapper(result));
+        },
       });
     }
     return fields;

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

@@ -2086,7 +2086,7 @@ export type KeyValueListData = {
 export type ExternalActorMapping = {
   id: string;
   externalName: string;
-  memberId?: string;
+  userId?: string;
   teamId?: string;
   sentryName: string;
 };

+ 23 - 7
static/app/views/organizationIntegrations/integrationExternalTeamMappings.tsx

@@ -17,12 +17,27 @@ type Props = AsyncComponent['props'] & {
 
 type State = AsyncComponent['state'] & {
   teams: Team[];
+  queryResults: Team[];
 };
 
 class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
+  getDefaultState() {
+    return {
+      ...super.getDefaultState(),
+      teams: [],
+      queryResults: [],
+    };
+  }
+
   getEndpoints(): ReturnType<AsyncComponent['getEndpoints']> {
     const {organization} = this.props;
-    return [['teams', `/organizations/${organization.slug}/teams/`]];
+    return [
+      [
+        'teams',
+        `/organizations/${organization.slug}/teams/`,
+        {query: {query: 'hasExternalTeams:true'}},
+      ],
+    ];
   }
 
   handleDelete = async (mapping: ExternalActorMapping) => {
@@ -66,9 +81,8 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
     return externalTeamMappings.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
   }
 
-  get sentryNames() {
-    const {teams} = this.state;
-    return teams;
+  sentryNamesMapper(teams: Team[]) {
+    return teams.map(({id, name}) => ({id, name}));
   }
 
   handleSubmit = (
@@ -82,8 +96,8 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
     // We need to dynamically set the endpoint bc it requires the slug of the selected team in the form.
     try {
       const {organization} = this.props;
-      const {teams} = this.state;
-      const team = teams.find(item => item.id === data.teamId);
+      const {queryResults} = this.state;
+      const team = queryResults.find(item => item.id === data.teamId);
 
       if (!team) {
         throw new Error('Cannot find team slug.');
@@ -121,10 +135,12 @@ class IntegrationExternalTeamMappings extends AsyncComponent<Props, State> {
               closeModal();
             }}
             mapping={mapping}
-            sentryNames={this.sentryNames}
+            sentryNamesMapper={this.sentryNamesMapper}
             type="team"
+            url={`/organizations/${organization.slug}/teams/`}
             onCancel={closeModal}
             onSubmit={(...args) => this.handleSubmit(...args, mapping)}
+            onResults={results => this.setState({queryResults: results})}
           />
         </Body>
       </React.Fragment>

+ 10 - 8
static/app/views/organizationIntegrations/integrationExternalUserMappings.tsx

@@ -31,7 +31,7 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
       [
         'members',
         `/organizations/${organization.slug}/members/`,
-        {query: {expand: 'externalUsers'}},
+        {query: {query: 'hasExternalUsers:true', expand: 'externalUsers'}},
       ],
     ];
   }
@@ -74,12 +74,13 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
     return externalUserMappings.sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
   }
 
-  get sentryNames() {
-    const {members} = this.state;
-    return members.map(({user: {id}, email, name}) => {
-      const label = email !== name ? `${name} - ${email}` : `${email}`;
-      return {id, name: label};
-    });
+  sentryNamesMapper(members: Member[]) {
+    return members
+      .filter(member => member.user)
+      .map(({user: {id}, email, name}) => {
+        const label = email !== name ? `${name} - ${email}` : `${email}`;
+        return {id, name: label};
+      });
   }
 
   openModal = (mapping?: ExternalActorMapping) => {
@@ -96,8 +97,9 @@ class IntegrationExternalUserMappings extends AsyncComponent<Props, State> {
               closeModal();
             }}
             mapping={mapping}
-            sentryNames={this.sentryNames}
+            sentryNamesMapper={this.sentryNamesMapper}
             type="user"
+            url={`/organizations/${organization.slug}/members/`}
             onCancel={closeModal}
             baseEndpoint={`/organizations/${organization.slug}/external-users/`}
           />

+ 3 - 0
static/app/views/settings/components/forms/fieldFromConfig.tsx

@@ -14,6 +14,7 @@ import ProjectMapperField from './projectMapperField';
 import RadioField from './radioField';
 import RangeField from './rangeField';
 import RichListField from './richListField';
+import SelectAsyncField from './selectAsyncField';
 import SelectField from './selectField';
 import SentryProjectSelectorField from './sentryProjectSelectorField';
 import TableField from './tableField';
@@ -98,6 +99,8 @@ export default class FieldFromConfig extends Component<Props> {
         return <ProjectMapperField {...props} />;
       case 'sentry_project_selector':
         return <SentryProjectSelectorField {...props} />;
+      case 'select_async':
+        return <SelectAsyncField {...props} />;
       case 'custom':
         return field.Component(props);
       default:

+ 74 - 0
static/app/views/settings/components/forms/selectAsyncField.tsx

@@ -0,0 +1,74 @@
+import * as React from 'react';
+
+import SelectAsyncControl from 'app/components/forms/selectAsyncControl';
+import InputField from 'app/views/settings/components/forms/inputField';
+
+//projects can be passed as a direct prop as well
+type Props = Omit<InputField['props'], 'highlighted' | 'visible' | 'required'>;
+
+export type SelectAsyncFieldProps = React.ComponentPropsWithoutRef<
+  typeof SelectAsyncControl
+> &
+  Props;
+
+class SelectAsyncField extends React.Component<SelectAsyncFieldProps> {
+  state = {
+    results: [],
+  };
+  // need to map the option object to the value
+  // this is essentially the same code from ./selectField handleChange()
+  handleChange = (
+    onBlur: Props['onBlur'],
+    onChange: Props['onChange'],
+    optionObj: {value: string | any[]},
+    event: React.MouseEvent
+  ) => {
+    let {value} = optionObj;
+    if (!optionObj) {
+      value = optionObj;
+    } else if (this.props.multiple && Array.isArray(optionObj)) {
+      // List of optionObjs
+      value = optionObj.map(({value: val}) => val);
+    } else if (!Array.isArray(optionObj)) {
+      value = optionObj.value;
+    }
+    onChange?.(value, event);
+    onBlur?.(value, event);
+  };
+
+  findValue(propsValue) {
+    /**
+     * The propsValue is the `id` of the object (user, team, etc), and
+     * react-select expects a full value object: {value: "id", label: "name"}
+     *
+     * Returning {} here will show the user a dropdown with "No options".
+     **/
+    return this.state.results.find(({value}) => value === propsValue) || {};
+  }
+
+  render() {
+    const {...otherProps} = this.props;
+    return (
+      <InputField
+        {...otherProps}
+        field={({onChange, onBlur, required: _required, onResults, value, ...props}) => (
+          <SelectAsyncControl
+            {...props}
+            onChange={this.handleChange.bind(this, onBlur, onChange)}
+            onResults={data => {
+              const results = onResults(data);
+              this.setState({results});
+              return results;
+            }}
+            onSelectResetsInput
+            onCloseResetsInput={false}
+            onBlurResetsInput={false}
+            value={this.findValue(value)}
+          />
+        )}
+      />
+    );
+  }
+}
+
+export default SelectAsyncField;

+ 6 - 0
static/app/views/settings/components/forms/type.tsx

@@ -6,6 +6,7 @@ import {AvatarProject, Project} from 'app/types';
 import {ChoiceMapperProps} from 'app/views/settings/components/forms/choiceMapperField';
 import RangeSlider from 'app/views/settings/components/forms/controls/rangeSlider';
 import {RichListProps} from 'app/views/settings/components/forms/richListField';
+import {SelectAsyncFieldProps} from 'app/views/settings/components/forms/selectAsyncField';
 
 export const FieldType = [
   'array',
@@ -27,6 +28,7 @@ export const FieldType = [
   'table',
   'project_mapper',
   'sentry_project_selector',
+  'select_async',
 ] as const;
 
 export type FieldValue = any;
@@ -173,6 +175,9 @@ export type SentryProjectSelectorType = {
   avatarSize?: number;
 };
 
+export type SelectAsyncType = {
+  type: 'select_async';
+} & SelectAsyncFieldProps;
 /**
  * Json field configuration makes using generics hard.
  * This isn't the ideal type to use, but it will cover
@@ -200,6 +205,7 @@ export type Field = (
   | TableType
   | ProjectMapperType
   | SentryProjectSelectorType
+  | SelectAsyncType
   | RichListType
   | ChoiceMapperType
   | {type: typeof FieldType[number]}

Некоторые файлы не были показаны из-за большого количества измененных файлов