Browse Source

ref(ts): Convert BaseAvatar to a FC (#67670)

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Evan Purkhiser 11 months ago
parent
commit
dc9ebf2f29

+ 4 - 14
static/app/components/avatar/actorAvatar.tsx

@@ -3,24 +3,14 @@ import * as Sentry from '@sentry/react';
 import TeamAvatar from 'sentry/components/avatar/teamAvatar';
 import UserAvatar from 'sentry/components/avatar/userAvatar';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
-import type {TooltipProps} from 'sentry/components/tooltip';
 import MemberListStore from 'sentry/stores/memberListStore';
 import type {Actor} from 'sentry/types';
 import {useTeamsById} from 'sentry/utils/useTeamsById';
 
-interface ActorAvatarProps {
+import type {BaseAvatarProps} from './baseAvatar';
+
+interface Props extends BaseAvatarProps {
   actor: Actor;
-  className?: string;
-  default?: string;
-  gravatar?: boolean;
-  hasTooltip?: boolean;
-  onClick?: () => void;
-  round?: boolean;
-  size?: number;
-  suggested?: boolean;
-  title?: string;
-  tooltip?: React.ReactNode;
-  tooltipOptions?: Omit<TooltipProps, 'children' | 'title'>;
 }
 
 /**
@@ -40,7 +30,7 @@ function LoadTeamAvatar({
   return <TeamAvatar team={team} {...props} />;
 }
 
-function ActorAvatar({size = 24, hasTooltip = true, actor, ...props}: ActorAvatarProps) {
+function ActorAvatar({size = 24, hasTooltip = true, actor, ...props}: Props) {
   const otherProps = {
     size,
     hasTooltip,

+ 7 - 11
static/app/components/avatar/backgroundAvatar.tsx

@@ -4,25 +4,21 @@ import styled from '@emotion/styled';
 import {imageStyle} from 'sentry/components/avatar/styles';
 import theme from 'sentry/utils/theme';
 
-type Props = {
+interface Props extends React.ComponentProps<'svg'> {
   forwardedRef?: React.Ref<SVGSVGElement>;
   round?: boolean;
   suggested?: boolean;
-};
-
-type BackgroundAvatarProps = React.ComponentProps<'svg'> & Props;
+}
 
 /**
  * Creates an avatar placeholder that is used when showing multiple
  * suggested assignees
  */
-const BackgroundAvatar = styled(
-  ({round: _round, forwardedRef, ...props}: BackgroundAvatarProps) => (
-    <svg ref={forwardedRef} viewBox="0 0 120 120" {...props}>
-      <rect x="0" y="0" width="120" height="120" rx="15" ry="15" fill={theme.purple100} />
-    </svg>
-  )
-)<Props>`
+const BackgroundAvatar = styled(({round: _round, forwardedRef, ...props}: Props) => (
+  <svg ref={forwardedRef} viewBox="0 0 120 120" {...props}>
+    <rect x="0" y="0" width="120" height="120" rx="15" ry="15" fill={theme.purple100} />
+  </svg>
+))<Props>`
   ${imageStyle};
 `;
 

+ 107 - 213
static/app/components/avatar/baseAvatar.tsx

@@ -1,4 +1,4 @@
-import {Component} from 'react';
+import {useCallback, useState} from 'react';
 import styled from '@emotion/styled';
 import classNames from 'classnames';
 import * as qs from 'query-string';
@@ -13,73 +13,27 @@ import Gravatar from './gravatar';
 import type {ImageStyleProps} from './styles';
 import {imageStyle} from './styles';
 
-const DEFAULT_GRAVATAR_SIZE = 64;
-const ALLOWED_SIZES = [20, 32, 36, 48, 52, 64, 80, 96, 120];
-const DEFAULT_REMOTE_SIZE = 120;
+type AllowedSize = (typeof ALLOWED_SIZES)[number];
 
-// Note: Avatar will not always be a child of a flex layout, but this seems like a
-// sensible default.
-const StyledBaseAvatar = styled('span')<{
-  loaded: boolean;
-  round: boolean;
-  suggested: boolean;
-}>`
-  flex-shrink: 0;
-  border-radius: ${p => (p.round ? '50%' : '3px')};
-  border: ${p => (p.suggested ? `1px dashed ${p.theme.subText}` : 'none')};
-  background-color: ${p => (p.suggested ? p.theme.background : 'none')};
-`;
-
-const defaultProps: DefaultProps = {
-  // No default size to ease transition from CSS defined sizes
-  // size: 64,
-  style: {},
-  /**
-   * Enable to display tooltips.
-   */
-  hasTooltip: false,
-  /**
-   * The type of avatar being rendered.
-   */
-  type: 'letter_avatar',
-  /**
-   * Should avatar be round instead of a square
-   */
-  round: false,
-};
+const ALLOWED_SIZES = [20, 32, 36, 48, 52, 64, 80, 96, 120] as const;
+const DEFAULT_REMOTE_SIZE = 120 satisfies AllowedSize;
 
-type DefaultProps = {
+interface BaseAvatarProps extends React.HTMLAttributes<HTMLSpanElement> {
+  backupAvatar?: React.ReactNode;
+  className?: string;
+  forwardedRef?: React.Ref<HTMLSpanElement>;
+  gravatarId?: string;
   /**
    * Enable to display tooltips.
    */
   hasTooltip?: boolean;
+  letterId?: string;
   /**
    * Should avatar be round instead of a square
    */
   round?: boolean;
-  style?: React.CSSProperties;
-  suggested?: boolean;
-  /**
-   * The type of avatar being rendered.
-   */
-  type?: Avatar['avatarType'];
-};
-
-type BaseProps = DefaultProps & {
-  backupAvatar?: React.ReactNode;
-  className?: string;
-  /**
-   * Default gravatar to display
-   */
-  default?: string;
-  forwardedRef?: React.Ref<HTMLSpanElement>;
-  gravatarId?: string;
-  letterId?: string;
-  /**
-   * This is the size of the remote image to request.
-   */
-  remoteImageSize?: (typeof ALLOWED_SIZES)[number];
   size?: number;
+  suggested?: boolean;
   title?: string;
   /**
    * The content for the tooltip. Requires hasTooltip to display
@@ -89,177 +43,117 @@ type BaseProps = DefaultProps & {
    * Additional props for the tooltip
    */
   tooltipOptions?: Omit<TooltipProps, 'children' | 'title'>;
+  /**
+   * The type of avatar being rendered.
+   */
+  type?: Avatar['avatarType'];
   /**
    * Full URL to the uploaded avatar's image.
    */
   uploadUrl?: string | null | undefined;
-};
-
-type Props = BaseProps;
-
-type State = {
-  hasLoaded: boolean;
-  loadError: boolean;
-  showBackupAvatar: boolean;
-};
-
-class BaseAvatar extends Component<Props, State> {
-  static defaultProps = defaultProps;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      showBackupAvatar: false,
-      hasLoaded: props.type !== 'upload',
-      loadError: false,
-    };
-  }
-
-  getRemoteImageSize() {
-    const {remoteImageSize, size} = this.props;
-    // Try to make sure remote image size is >= requested size
-    // If requested size > allowed size then use the largest allowed size
-    const allowed =
-      size &&
-      (ALLOWED_SIZES.find(allowedSize => allowedSize >= size) ||
-        ALLOWED_SIZES[ALLOWED_SIZES.length - 1]);
-
-    return remoteImageSize || allowed || DEFAULT_GRAVATAR_SIZE;
-  }
-
-  buildUploadUrl() {
-    const {uploadUrl} = this.props;
-    if (!uploadUrl) {
-      return '';
-    }
-
-    return `${uploadUrl}?${qs.stringify({s: DEFAULT_REMOTE_SIZE})}`;
-  }
-
-  handleLoad = () => {
-    this.setState({showBackupAvatar: false, hasLoaded: true});
-  };
-
-  handleError = () => {
-    this.setState({showBackupAvatar: true, loadError: true, hasLoaded: true});
-  };
-
-  renderImg() {
-    if (this.state.loadError) {
-      return null;
-    }
-
-    const {type, round, gravatarId, suggested} = this.props;
-
-    const eventProps = {
-      onError: this.handleError,
-      onLoad: this.handleLoad,
-    };
-
-    if (type === 'gravatar') {
-      return (
-        <Gravatar
-          placeholder={this.props.default}
-          gravatarId={gravatarId}
-          round={round}
-          remoteSize={DEFAULT_REMOTE_SIZE}
-          suggested={suggested}
-          {...eventProps}
-        />
-      );
-    }
-
-    if (type === 'upload') {
-      return (
-        <Image
-          round={round}
-          src={this.buildUploadUrl()}
-          {...eventProps}
-          suggested={suggested}
-        />
-      );
-    }
-
-    if (type === 'background') {
-      return this.renderBackgroundAvatar();
-    }
-
-    return this.renderLetterAvatar();
-  }
-
-  renderLetterAvatar() {
-    const {title, letterId, round, suggested} = this.props;
-    const modifiedTitle = title === '[Filtered]' ? '?' : title;
+}
 
-    return (
-      <LetterAvatar
+function BaseAvatar({
+  backupAvatar,
+  className,
+  forwardedRef,
+  gravatarId,
+  letterId,
+  size,
+  style,
+  suggested,
+  title,
+  tooltip,
+  tooltipOptions,
+  uploadUrl,
+  hasTooltip = false,
+  round = false,
+  type = 'letter_avatar',
+  ...props
+}: BaseAvatarProps) {
+  const [hasError, setError] = useState<boolean | null>(null);
+
+  const handleError = useCallback(() => setError(true), []);
+  const handleLoad = useCallback(() => setError(false), []);
+
+  const resolvedUploadUrl = uploadUrl
+    ? `${uploadUrl}?${qs.stringify({s: DEFAULT_REMOTE_SIZE})}`
+    : '';
+
+  const letterAvatar = (
+    <LetterAvatar
+      round={round}
+      displayName={title === '[Filtered]' ? '?' : title}
+      identifier={letterId}
+      suggested={suggested}
+    />
+  );
+
+  const imageAvatar =
+    type === 'upload' ? (
+      <ImageAvatar
+        src={resolvedUploadUrl}
+        round={round}
+        suggested={suggested}
+        onLoad={handleLoad}
+        onError={handleError}
+      />
+    ) : type === 'gravatar' ? (
+      <Gravatar
+        gravatarId={gravatarId}
+        remoteSize={DEFAULT_REMOTE_SIZE}
         round={round}
-        displayName={modifiedTitle}
-        identifier={letterId}
         suggested={suggested}
+        onLoad={handleLoad}
+        onError={handleError}
       />
+    ) : type === 'background' ? (
+      <BackgroundAvatar round={round} suggested={suggested} />
+    ) : (
+      letterAvatar
     );
-  }
 
-  renderBackgroundAvatar() {
-    const {round, suggested} = this.props;
-    return <BackgroundAvatar round={round} suggested={suggested} />;
-  }
+  const backup = backupAvatar ?? letterAvatar;
 
-  renderBackupAvatar() {
-    const {backupAvatar} = this.props;
-    return backupAvatar ?? this.renderLetterAvatar();
-  }
-
-  render() {
-    const {
-      className,
-      style,
-      round,
-      hasTooltip,
-      size,
-      suggested,
-      tooltip,
-      tooltipOptions,
-      forwardedRef,
-      type,
-      ...props
-    } = this.props;
-    let sizeStyle = {};
-
-    if (size) {
-      sizeStyle = {
-        width: `${size}px`,
-        height: `${size}px`,
+  const sizeStyle: React.CSSProperties = !size
+    ? {}
+    : {
+        height: size,
+        width: size,
       };
-    }
 
-    return (
-      <Tooltip title={tooltip} disabled={!hasTooltip} {...tooltipOptions}>
-        <StyledBaseAvatar
-          data-test-id={`${type}-avatar`}
-          ref={forwardedRef}
-          loaded={this.state.hasLoaded}
-          className={classNames('avatar', className)}
-          round={!!round}
-          suggested={!!suggested}
-          style={{
-            ...sizeStyle,
-            ...style,
-          }}
-          {...props}
-        >
-          {this.state.showBackupAvatar && this.renderBackupAvatar()}
-          {this.renderImg()}
-        </StyledBaseAvatar>
-      </Tooltip>
-    );
-  }
+  return (
+    <Tooltip title={tooltip} disabled={!hasTooltip} {...tooltipOptions}>
+      <StyledBaseAvatar
+        data-test-id={`${type}-avatar`}
+        ref={forwardedRef}
+        className={classNames('avatar', className)}
+        round={!!round}
+        suggested={!!suggested}
+        style={{...sizeStyle, ...style}}
+        title={title}
+        {...props}
+      >
+        {hasError ? backup : imageAvatar}
+      </StyledBaseAvatar>
+    </Tooltip>
+  );
 }
 
-export default BaseAvatar;
+export {BaseAvatar, type BaseAvatarProps};
+
+// Note: Avatar will not always be a child of a flex layout, but this seems like a
+// sensible default.
+const StyledBaseAvatar = styled('span')<{
+  round: boolean;
+  suggested: boolean;
+}>`
+  flex-shrink: 0;
+  border-radius: ${p => (p.round ? '50%' : '3px')};
+  border: ${p => (p.suggested ? `1px dashed ${p.theme.subText}` : 'none')};
+  background-color: ${p => (p.suggested ? p.theme.background : 'none')};
+`;
 
-const Image = styled('img')<ImageStyleProps>`
+const ImageAvatar = styled('img')<ImageStyleProps>`
   ${imageStyle};
 `;

+ 4 - 4
static/app/components/avatar/docIntegrationAvatar.tsx

@@ -1,14 +1,14 @@
-import BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import {BaseAvatar, type BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import PluginIcon from 'sentry/plugins/components/pluginIcon';
 import type {DocIntegration} from 'sentry/types';
 
-type Props = {
+interface Props extends BaseAvatarProps {
   docIntegration?: DocIntegration;
-} & BaseAvatar['props'];
+}
 
 function DocIntegrationAvatar({docIntegration, ...props}: Props) {
   if (!docIntegration?.avatar) {
-    return <PluginIcon {...props} pluginId={docIntegration?.slug} />;
+    return <PluginIcon size={props.size} pluginId={docIntegration?.slug} />;
   }
   return (
     <BaseAvatar

+ 3 - 3
static/app/components/avatar/organizationAvatar.tsx

@@ -1,10 +1,10 @@
-import BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import {BaseAvatar, type BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import type {OrganizationSummary} from 'sentry/types';
 import {explodeSlug} from 'sentry/utils';
 
-type Props = {
+interface Props extends BaseAvatarProps {
   organization?: OrganizationSummary;
-} & BaseAvatar['props'];
+}
 
 function OrganizationAvatar({organization, ...props}: Props) {
   if (!organization) {

+ 3 - 3
static/app/components/avatar/projectAvatar.tsx

@@ -1,12 +1,12 @@
-import type BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import type {BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import PlatformList from 'sentry/components/platformList';
 import {Tooltip} from 'sentry/components/tooltip';
 import type {AvatarProject} from 'sentry/types';
 
-type Props = {
+interface Props extends BaseAvatarProps {
   project: AvatarProject;
   direction?: 'left' | 'right';
-} & BaseAvatar['props'];
+}
 
 function ProjectAvatar({project, hasTooltip, tooltip, ...props}: Props) {
   return (

+ 3 - 3
static/app/components/avatar/sentryAppAvatar.tsx

@@ -1,12 +1,12 @@
-import BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import {BaseAvatar, type BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import {IconGeneric} from 'sentry/icons';
 import type {AvatarSentryApp} from 'sentry/types';
 
-type Props = {
+interface Props extends BaseAvatarProps {
   isColor?: boolean;
   isDefault?: boolean;
   sentryApp?: AvatarSentryApp;
-} & BaseAvatar['props'];
+}
 
 function SentryAppAvatar({isColor = true, sentryApp, isDefault, ...props}: Props) {
   const avatarDetails = sentryApp?.avatars?.find(({color}) => color === isColor);

+ 5 - 4
static/app/components/avatar/suggestedAvatarStack.tsx

@@ -2,14 +2,15 @@ import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import ActorAvatar from 'sentry/components/avatar/actorAvatar';
-import type BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import type {BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import type {Actor} from 'sentry/types';
 
-type Props = {
+interface Props
+  extends BaseAvatarProps,
+    Omit<React.ComponentProps<typeof ActorAvatar>, 'actor' | 'hasTooltip'> {
   owners: Actor[];
   reverse?: boolean;
-} & BaseAvatar['props'] &
-  Omit<React.ComponentProps<typeof ActorAvatar>, 'actor' | 'hasTooltip'>;
+}
 
 // Constrain the number of visible suggestions
 const MAX_SUGGESTIONS = 3;

+ 4 - 4
static/app/components/avatar/teamAvatar.tsx

@@ -1,12 +1,12 @@
-import BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import {BaseAvatar, type BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import type {Team} from 'sentry/types';
 import {explodeSlug} from 'sentry/utils';
 
-type TeamAvatarProps = {
+interface Props extends BaseAvatarProps {
   team: Team | null | undefined;
-} & BaseAvatar['props'];
+}
 
-function TeamAvatar({team, tooltip: tooltipProp, ...props}: TeamAvatarProps) {
+function TeamAvatar({team, tooltip: tooltipProp, ...props}: Props) {
   if (!team) {
     return null;
   }

+ 3 - 3
static/app/components/avatar/userAvatar.tsx

@@ -1,15 +1,15 @@
-import BaseAvatar from 'sentry/components/avatar/baseAvatar';
+import {BaseAvatar, type BaseAvatarProps} from 'sentry/components/avatar/baseAvatar';
 import type {Actor, AvatarUser} from 'sentry/types';
 import {userDisplayName} from 'sentry/utils/formatters';
 import {isRenderFunc} from 'sentry/utils/isRenderFunc';
 
 type RenderTooltipFunc = (user: AvatarUser | Actor) => React.ReactNode;
 
-type Props = {
+interface Props extends BaseAvatarProps {
   gravatar?: boolean;
   renderTooltip?: RenderTooltipFunc;
   user?: Actor | AvatarUser;
-} & Omit<BaseAvatar['props'], 'uploadPath' | 'uploadId'>;
+}
 
 function isActor(maybe: AvatarUser | Actor): maybe is Actor {
   return typeof (maybe as AvatarUser).email === 'undefined';