Browse Source

feat(sentryapps): Allow logo upload when editing integrations (#30084)

See API-2254

The main goal of this PR is to add a few fields to the SentryAppDetails page to let users upload logos. Along with the changes necessary for that view, there are a few other side-effects that this PR will change elsewhere:

    The upload component is more spacious and lets some new props change how it renders (e.g. custom title, preview)
    The 'default' option has been added as a new avatar type, specific to SentryAppAvatars
    The names on the page will reflect their action (Edit, Create, etc.)
Leander Rodrigues 3 years ago
parent
commit
eb12415514

+ 6 - 1
static/app/components/avatar/baseAvatar.tsx

@@ -69,7 +69,12 @@ type DefaultProps = {
   /**
    * Path to uploaded avatar (differs based on model type)
    */
-  uploadPath?: 'avatar' | 'team-avatar' | 'organization-avatar' | 'project-avatar';
+  uploadPath?:
+    | 'avatar'
+    | 'team-avatar'
+    | 'organization-avatar'
+    | 'project-avatar'
+    | 'sentry-app-avatar';
 };
 
 type BaseProps = DefaultProps & {

+ 33 - 2
static/app/components/avatar/index.tsx

@@ -2,18 +2,38 @@ import * as React from 'react';
 
 import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
 import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
+import SentryAppAvatar from 'sentry/components/avatar/sentryAppAvatar';
 import TeamAvatar from 'sentry/components/avatar/teamAvatar';
 import UserAvatar from 'sentry/components/avatar/userAvatar';
-import {AvatarProject, OrganizationSummary, Team} from 'sentry/types';
+import {AvatarProject, OrganizationSummary, SentryApp, Team} from 'sentry/types';
 
 type Props = {
   team?: Team;
   organization?: OrganizationSummary;
   project?: AvatarProject;
+  sentryApp?: SentryApp;
+  /**
+   * True if the Avatar is full color, rather than B&W (Used for SentryAppAvatar)
+   */
+  isColor?: boolean;
+  /**
+   * True if the rendered Avatar should be a static asset
+   */
+  isDefault?: boolean;
 } & UserAvatar['props'];
 
 const Avatar = React.forwardRef(function Avatar(
-  {hasTooltip = false, user, team, project, organization, ...props}: Props,
+  {
+    hasTooltip = false,
+    user,
+    team,
+    project,
+    organization,
+    sentryApp,
+    isColor = true,
+    isDefault = false,
+    ...props
+  }: Props,
   ref: React.Ref<HTMLSpanElement>
 ) {
   const commonProps = {hasTooltip, forwardedRef: ref, ...props};
@@ -30,6 +50,17 @@ const Avatar = React.forwardRef(function Avatar(
     return <ProjectAvatar project={project} {...commonProps} />;
   }
 
+  if (sentryApp) {
+    return (
+      <SentryAppAvatar
+        sentryApp={sentryApp}
+        isColor={isColor}
+        isDefault={isDefault}
+        {...commonProps}
+      />
+    );
+  }
+
   return <OrganizationAvatar organization={organization} {...commonProps} />;
 });
 

+ 19 - 23
static/app/components/avatar/organizationAvatar.tsx

@@ -1,5 +1,3 @@
-import {Component} from 'react';
-
 import BaseAvatar from 'sentry/components/avatar/baseAvatar';
 import {OrganizationSummary} from 'sentry/types';
 import {explodeSlug} from 'sentry/utils';
@@ -8,26 +6,24 @@ type Props = {
   organization?: OrganizationSummary;
 } & Omit<BaseAvatar['props'], 'uploadPath' | 'uploadId'>;
 
-class OrganizationAvatar extends Component<Props> {
-  render() {
-    const {organization, ...props} = this.props;
-    if (!organization) {
-      return null;
-    }
-    const slug = (organization && organization.slug) || '';
-    const title = explodeSlug(slug);
-
-    return (
-      <BaseAvatar
-        {...props}
-        type={(organization.avatar && organization.avatar.avatarType) || 'letter_avatar'}
-        uploadPath="organization-avatar"
-        uploadId={organization.avatar && organization.avatar.avatarUuid}
-        letterId={slug}
-        tooltip={slug}
-        title={title}
-      />
-    );
+const OrganizationAvatar = ({organization, ...props}: Props) => {
+  if (!organization) {
+    return null;
   }
-}
+  const slug = (organization && organization.slug) || '';
+  const title = explodeSlug(slug);
+
+  return (
+    <BaseAvatar
+      {...props}
+      type={(organization.avatar && organization.avatar.avatarType) || 'letter_avatar'}
+      uploadPath="organization-avatar"
+      uploadId={organization.avatar && organization.avatar.avatarUuid}
+      letterId={slug}
+      tooltip={slug}
+      title={title}
+    />
+  );
+};
+
 export default OrganizationAvatar;

+ 12 - 23
static/app/components/avatar/projectAvatar.tsx

@@ -1,5 +1,3 @@
-import {Component} from 'react';
-
 import BaseAvatar from 'sentry/components/avatar/baseAvatar';
 import PlatformList from 'sentry/components/platformList';
 import Tooltip from 'sentry/components/tooltip';
@@ -9,26 +7,17 @@ type Props = {
   project: AvatarProject;
 } & BaseAvatar['props'];
 
-class ProjectAvatar extends Component<Props> {
-  getPlatforms = (project: AvatarProject) => {
-    // `platform` is a user selectable option that is performed during the onboarding process. The reason why this
-    // is not the default is because there currently is no way to update it. Fallback to this if project does not
-    // have recent events with a platform.
-    if (project && project.platform) {
-      return [project.platform];
-    }
-
-    return [];
-  };
-
-  render() {
-    const {project, hasTooltip, tooltip, ...props} = this.props;
+const ProjectAvatar = ({project, hasTooltip, tooltip, ...props}: Props) => (
+  <Tooltip disabled={!hasTooltip} title={tooltip}>
+    <PlatformList
+      // `platform` is a user selectable option that is performed during the onboarding process. The reason why this
+      // is not the default is because there currently is no way to update it. Fallback to this if project does not
+      // have recent events with a platform.
+      platforms={project?.platform ? [project.platform] : []}
+      {...props}
+      max={1}
+    />
+  </Tooltip>
+);
 
-    return (
-      <Tooltip disabled={!hasTooltip} title={tooltip}>
-        <PlatformList platforms={this.getPlatforms(project)} {...props} max={1} />
-      </Tooltip>
-    );
-  }
-}
 export default ProjectAvatar;

+ 34 - 0
static/app/components/avatar/sentryAppAvatar.tsx

@@ -0,0 +1,34 @@
+import BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import {IconGeneric} from 'sentry/icons';
+import {SentryApp} from 'sentry/types';
+
+type Props = {
+  sentryApp?: SentryApp;
+  isColor?: boolean;
+  isDefault?: boolean;
+} & BaseAvatar['props'];
+
+const SentryAppAvatar = ({isColor = true, sentryApp, isDefault, ...props}: Props) => {
+  const avatarDetails = sentryApp?.avatars?.find(({color}) => color === isColor);
+  // Render the default if the prop is provided, there is no existing avatar, or it has been reverted to 'default'
+  if (isDefault || !avatarDetails || avatarDetails.avatarType === 'default') {
+    return (
+      <IconGeneric
+        size={`${props.size}`}
+        className={props.className}
+        data-test-id="default-sentry-app-avatar"
+      />
+    );
+  }
+  return (
+    <BaseAvatar
+      {...props}
+      type="upload"
+      uploadPath="sentry-app-avatar"
+      uploadId={avatarDetails?.avatarUuid}
+      title={sentryApp?.name}
+    />
+  );
+};
+
+export default SentryAppAvatar;

+ 60 - 13
static/app/components/avatarChooser.tsx

@@ -12,13 +12,23 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
 import Well from 'sentry/components/well';
 import {t} from 'sentry/locale';
-import {AvatarUser, Organization, Team} from 'sentry/types';
+import {AvatarUser, Organization, SentryApp, Team} from 'sentry/types';
 import withApi from 'sentry/utils/withApi';
 import RadioGroup from 'sentry/views/settings/components/forms/controls/radioGroup';
 
-type Model = Pick<AvatarUser, 'avatar'>;
+export type Model = Pick<AvatarUser, 'avatar'>;
 type AvatarType = Required<Model>['avatar']['avatarType'];
-type AvatarChooserType = 'user' | 'team' | 'organization';
+type AvatarChooserType =
+  | 'user'
+  | 'team'
+  | 'organization'
+  | 'sentryAppColor'
+  | 'sentryAppSimple';
+type DefaultChoice = {
+  preview?: React.ReactNode;
+  allowDefault?: boolean;
+  choiceText?: string;
+};
 
 type DefaultProps = {
   onSave: (model: Model) => void;
@@ -26,6 +36,7 @@ type DefaultProps = {
   allowLetter?: boolean;
   allowUpload?: boolean;
   type?: AvatarChooserType;
+  defaultChoice?: DefaultChoice;
 };
 
 type Props = {
@@ -35,6 +46,10 @@ type Props = {
   disabled?: boolean;
   savedDataUrl?: string;
   isUser?: boolean;
+  /**
+   * Title in the PanelHeader component (default: 'Avatar')
+   */
+  title?: string;
 } & DefaultProps;
 
 type State = {
@@ -51,6 +66,9 @@ class AvatarChooser extends React.Component<Props, State> {
     allowUpload: true,
     type: 'user',
     onSave: () => {},
+    defaultChoice: {
+      allowDefault: false,
+    },
   };
 
   state: State = {
@@ -71,6 +89,17 @@ class AvatarChooser extends React.Component<Props, State> {
     this.setState({model});
   }
 
+  getModelFromResponse(resp: any): Model {
+    const {type} = this.props;
+    const isSentryApp = type?.startsWith('sentryApp');
+    // SentryApp endpoint returns all avatars, we need to return only the edited one
+    if (!isSentryApp) {
+      return resp;
+    }
+    const isColor = type === 'sentryAppColor';
+    return {avatar: resp?.avatars?.find(({color}) => color === isColor) ?? undefined};
+  }
+
   handleError(msg: string) {
     addErrorMessage(msg);
   }
@@ -83,24 +112,33 @@ class AvatarChooser extends React.Component<Props, State> {
   }
 
   handleSaveSettings = (ev: React.MouseEvent) => {
-    const {endpoint, api} = this.props;
+    const {endpoint, api, type} = this.props;
     const {model, dataUrl} = this.state;
+    const isSentryApp = type?.startsWith('sentryApp');
+
     ev.preventDefault();
-    let data = {};
     const avatarType = model && model.avatar ? model.avatar.avatarType : undefined;
     const avatarPhoto = dataUrl ? dataUrl.split(',')[1] : undefined;
 
-    data = {
+    const data: {
+      avatar_photo: string | undefined;
+      avatar_type: string | undefined;
+      color?: boolean;
+    } = {
       avatar_photo: avatarPhoto,
       avatar_type: avatarType,
     };
 
+    if (isSentryApp) {
+      data.color = type === 'sentryAppColor';
+    }
+
     api.request(endpoint, {
       method: 'PUT',
       data,
       success: resp => {
         this.setState({savedDataUrl: this.state.dataUrl});
-        this.handleSuccess(resp);
+        this.handleSuccess(this.getModelFromResponse(resp));
       },
       error: this.handleError.bind(this, 'There was an error saving your preferences.'),
     });
@@ -121,6 +159,8 @@ class AvatarChooser extends React.Component<Props, State> {
       type,
       isUser,
       disabled,
+      title,
+      defaultChoice,
     } = this.props;
     const {hasError, model} = this.state;
 
@@ -130,14 +170,21 @@ class AvatarChooser extends React.Component<Props, State> {
     if (!model) {
       return <LoadingIndicator />;
     }
+    const {allowDefault, preview, choiceText: defaultChoiceText} = defaultChoice || {};
 
     const avatarType = model.avatar?.avatarType ?? 'letter_avatar';
     const isLetter = avatarType === 'letter_avatar';
+    const isDefault = Boolean(preview && avatarType === 'default');
 
     const isTeam = type === 'team';
     const isOrganization = type === 'organization';
+    const isSentryApp = type?.startsWith('sentryApp');
+
     const choices: [AvatarType, string][] = [];
 
+    if (allowDefault && preview) {
+      choices.push(['default', defaultChoiceText ?? t('Use default avatar')]);
+    }
     if (allowLetter) {
       choices.push(['letter_avatar', t('Use initials')]);
     }
@@ -147,13 +194,12 @@ class AvatarChooser extends React.Component<Props, State> {
     if (allowGravatar) {
       choices.push(['gravatar', t('Use Gravatar')]);
     }
-
     return (
       <Panel>
-        <PanelHeader>{t('Avatar')}</PanelHeader>
+        <PanelHeader>{title || t('Avatar')}</PanelHeader>
         <PanelBody>
           <AvatarForm>
-            <AvatarGroup inline={isLetter}>
+            <AvatarGroup inline={isLetter || isDefault}>
               <RadioGroup
                 style={{flex: 1}}
                 choices={choices}
@@ -169,10 +215,11 @@ class AvatarChooser extends React.Component<Props, State> {
                   user={isUser ? (model as AvatarUser) : undefined}
                   organization={isOrganization ? (model as Organization) : undefined}
                   team={isTeam ? (model as Team) : undefined}
+                  sentryApp={isSentryApp ? (model as SentryApp) : undefined}
                 />
               )}
+              {isDefault && preview}
             </AvatarGroup>
-
             <AvatarUploadSection>
               {allowGravatar && avatarType === 'gravatar' && (
                 <Well>
@@ -180,7 +227,6 @@ class AvatarChooser extends React.Component<Props, State> {
                   <ExternalLink href="http://gravatar.com">Gravatar.com</ExternalLink>
                 </Well>
               )}
-
               {model.avatar && avatarType === 'upload' && (
                 <AvatarCropper
                   {...this.props}
@@ -216,12 +262,13 @@ const AvatarGroup = styled('div')<{inline: boolean}>`
 const AvatarForm = styled('div')`
   line-height: 1.5em;
   padding: 1em 1.25em;
+  margin: 1em 0.5em 0;
 `;
 
 const AvatarSubmit = styled('fieldset')`
   display: flex;
   justify-content: flex-end;
-  margin-top: 1em;
+  margin-top: 1.25em;
 `;
 
 const AvatarUploadSection = styled('div')`

+ 7 - 1
static/app/components/avatarCropper.tsx

@@ -21,7 +21,13 @@ type Model = Pick<AvatarUser, 'avatar'>;
 type Props = {
   model: Model;
   updateDataUrlState: (opts: {savedDataUrl?: string | null; dataUrl?: string}) => void;
-  type: 'user' | 'team' | 'organization' | 'project';
+  type:
+    | 'user'
+    | 'team'
+    | 'organization'
+    | 'project'
+    | 'sentryAppColor'
+    | 'sentryAppSimple';
   savedDataUrl?: string;
 };
 

+ 2 - 0
static/app/constants/index.tsx

@@ -185,6 +185,8 @@ export const AVATAR_URL_MAP = {
   organization: 'organization-avatar',
   project: 'project-avatar',
   user: 'avatar',
+  sentryAppColor: 'sentry-app-avatar',
+  sentryAppSimple: 'sentry-app-avatar',
 };
 
 export const MENU_CLOSE_DELAY = 200;

+ 2 - 2
static/app/routes.tsx

@@ -843,7 +843,7 @@ function buildRoutes() {
           component={SafeLazyLoad}
         />
         <Route
-          name={t('New Public Integration')}
+          name={t('Create Integration')}
           path="new-public/"
           componentPromise={() =>
             import(
@@ -853,7 +853,7 @@ function buildRoutes() {
           component={SafeLazyLoad}
         />
         <Route
-          name={t('New Internal Integration')}
+          name={t('Create Integration')}
           path="new-internal/"
           componentPromise={() =>
             import(

+ 2 - 1
static/app/types/core.tsx

@@ -12,7 +12,8 @@ import {API_ACCESS_SCOPES, DEFAULT_RELATIVE_PERIODS} from 'sentry/constants';
  */
 export type Avatar = {
   avatarUuid: string | null;
-  avatarType: 'letter_avatar' | 'upload' | 'gravatar' | 'background';
+  avatarType: 'letter_avatar' | 'upload' | 'gravatar' | 'background' | 'default';
+  color?: boolean;
 };
 
 export type ObjectStatus =

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