avatarChooser.tsx 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {Client} from 'sentry/api';
  5. import Avatar from 'sentry/components/avatar';
  6. import {AvatarUploader} from 'sentry/components/avatarUploader';
  7. import {Button} from 'sentry/components/button';
  8. import RadioGroup from 'sentry/components/forms/controls/radioGroup';
  9. import ExternalLink from 'sentry/components/links/externalLink';
  10. import LoadingError from 'sentry/components/loadingError';
  11. import LoadingIndicator from 'sentry/components/loadingIndicator';
  12. import Panel from 'sentry/components/panels/panel';
  13. import PanelBody from 'sentry/components/panels/panelBody';
  14. import PanelHeader from 'sentry/components/panels/panelHeader';
  15. import Well from 'sentry/components/well';
  16. import {t} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import {AvatarUser, Organization, SentryApp, Team} from 'sentry/types';
  19. import withApi from 'sentry/utils/withApi';
  20. export type Model = Pick<AvatarUser, 'avatar'>;
  21. type AvatarType = Required<Model>['avatar']['avatarType'];
  22. type AvatarChooserType =
  23. | 'user'
  24. | 'team'
  25. | 'organization'
  26. | 'sentryAppColor'
  27. | 'sentryAppSimple'
  28. | 'docIntegration';
  29. type DefaultChoice = {
  30. allowDefault?: boolean;
  31. choiceText?: string;
  32. preview?: React.ReactNode;
  33. };
  34. type DefaultProps = {
  35. onSave: (model: Model) => void;
  36. allowGravatar?: boolean;
  37. allowLetter?: boolean;
  38. allowUpload?: boolean;
  39. defaultChoice?: DefaultChoice;
  40. type?: AvatarChooserType;
  41. };
  42. type Props = {
  43. api: Client;
  44. endpoint: string;
  45. model: Model;
  46. disabled?: boolean;
  47. help?: React.ReactNode;
  48. isUser?: boolean;
  49. savedDataUrl?: string;
  50. title?: string;
  51. } & DefaultProps;
  52. type State = {
  53. hasError: boolean;
  54. model: Model;
  55. dataUrl?: string | null;
  56. savedDataUrl?: string | null;
  57. };
  58. class AvatarChooser extends Component<Props, State> {
  59. static defaultProps: DefaultProps = {
  60. allowGravatar: true,
  61. allowLetter: true,
  62. allowUpload: true,
  63. type: 'user',
  64. onSave: () => {},
  65. defaultChoice: {
  66. allowDefault: false,
  67. },
  68. };
  69. state: State = {
  70. model: this.props.model,
  71. savedDataUrl: null,
  72. dataUrl: null,
  73. hasError: false,
  74. };
  75. componentDidUpdate(prevProps: Props) {
  76. const {model} = this.props;
  77. // Update local state if defined in props
  78. if (model !== undefined && model !== prevProps.model) {
  79. this.setState({model});
  80. }
  81. }
  82. getModelFromResponse(resp: any): Model {
  83. const {type} = this.props;
  84. const isSentryApp = type?.startsWith('sentryApp');
  85. // SentryApp endpoint returns all avatars, we need to return only the edited one
  86. if (!isSentryApp) {
  87. return resp;
  88. }
  89. const isColor = type === 'sentryAppColor';
  90. return {avatar: resp?.avatars?.find(({color}) => color === isColor) ?? undefined};
  91. }
  92. handleError(msg: string) {
  93. addErrorMessage(msg);
  94. }
  95. handleSuccess(model: Model) {
  96. const {onSave} = this.props;
  97. this.setState({model});
  98. onSave(model);
  99. addSuccessMessage(t('Successfully saved avatar preferences'));
  100. }
  101. handleSaveSettings = (ev: React.MouseEvent) => {
  102. const {endpoint, api, type} = this.props;
  103. const {model, dataUrl} = this.state;
  104. ev.preventDefault();
  105. const avatarType = model?.avatar?.avatarType;
  106. const avatarPhoto = dataUrl?.split(',')[1];
  107. const data: {
  108. avatar_photo?: string;
  109. avatar_type?: string;
  110. color?: boolean;
  111. } = {avatar_type: avatarType};
  112. // If an image has been uploaded, then another option is selected, we should not submit the uploaded image
  113. if (avatarType === 'upload') {
  114. data.avatar_photo = avatarPhoto;
  115. }
  116. if (type?.startsWith('sentryApp')) {
  117. data.color = type === 'sentryAppColor';
  118. }
  119. api.request(endpoint, {
  120. method: 'PUT',
  121. data,
  122. success: resp => {
  123. this.setState({savedDataUrl: this.state.dataUrl});
  124. this.handleSuccess(this.getModelFromResponse(resp));
  125. },
  126. error: resp => {
  127. const avatarPhotoErrors = resp?.responseJSON?.avatar_photo || [];
  128. avatarPhotoErrors.length
  129. ? avatarPhotoErrors.map(this.handleError)
  130. : this.handleError.bind(this, t('There was an error saving your preferences.'));
  131. },
  132. });
  133. };
  134. handleChange = (id: AvatarType) =>
  135. this.setState(state => ({
  136. model: {
  137. ...state.model,
  138. avatar: {avatarUuid: state.model.avatar?.avatarUuid ?? '', avatarType: id},
  139. },
  140. }));
  141. render() {
  142. const {
  143. allowGravatar,
  144. allowUpload,
  145. allowLetter,
  146. savedDataUrl,
  147. type,
  148. isUser,
  149. disabled,
  150. title,
  151. help,
  152. defaultChoice,
  153. } = this.props;
  154. const {hasError, model, dataUrl} = this.state;
  155. if (hasError) {
  156. return <LoadingError />;
  157. }
  158. if (!model) {
  159. return <LoadingIndicator />;
  160. }
  161. const {allowDefault, preview, choiceText: defaultChoiceText} = defaultChoice || {};
  162. const avatarType = model.avatar?.avatarType ?? 'letter_avatar';
  163. const isLetter = avatarType === 'letter_avatar';
  164. const isDefault = Boolean(preview && avatarType === 'default');
  165. const isTeam = type === 'team';
  166. const isOrganization = type === 'organization';
  167. const isSentryApp = type?.startsWith('sentryApp');
  168. const choices: [AvatarType, string][] = [];
  169. if (allowDefault && preview) {
  170. choices.push(['default', defaultChoiceText ?? t('Use default avatar')]);
  171. }
  172. if (allowLetter) {
  173. choices.push(['letter_avatar', t('Use initials')]);
  174. }
  175. if (allowUpload) {
  176. choices.push(['upload', t('Upload an image')]);
  177. }
  178. if (allowGravatar) {
  179. choices.push(['gravatar', t('Use Gravatar')]);
  180. }
  181. return (
  182. <Panel>
  183. <PanelHeader>{title || t('Avatar')}</PanelHeader>
  184. <PanelBody>
  185. <AvatarForm>
  186. <AvatarGroup inline={isLetter || isDefault}>
  187. <RadioGroup
  188. style={{flex: 1}}
  189. choices={choices}
  190. value={avatarType}
  191. label={t('Avatar Type')}
  192. onChange={this.handleChange}
  193. disabled={disabled}
  194. />
  195. {isLetter && (
  196. <Avatar
  197. gravatar={false}
  198. style={{width: 90, height: 90}}
  199. user={isUser ? (model as AvatarUser) : undefined}
  200. organization={isOrganization ? (model as Organization) : undefined}
  201. team={isTeam ? (model as Team) : undefined}
  202. sentryApp={isSentryApp ? (model as SentryApp) : undefined}
  203. />
  204. )}
  205. {isDefault && preview}
  206. </AvatarGroup>
  207. <AvatarUploadSection>
  208. {allowGravatar && avatarType === 'gravatar' && (
  209. <Well>
  210. {t('Gravatars are managed through ')}
  211. <ExternalLink href="http://gravatar.com">Gravatar.com</ExternalLink>
  212. </Well>
  213. )}
  214. {model.avatar && avatarType === 'upload' && (
  215. <AvatarUploader
  216. {...this.props}
  217. type={type!}
  218. model={model}
  219. savedDataUrl={savedDataUrl}
  220. updateDataUrlState={dataState => this.setState(dataState)}
  221. />
  222. )}
  223. <AvatarSubmit className="form-actions">
  224. {help && <AvatarHelp>{help}</AvatarHelp>}
  225. <Button
  226. priority="primary"
  227. onClick={this.handleSaveSettings}
  228. disabled={disabled || (avatarType === 'upload' && !dataUrl)}
  229. >
  230. {t('Save Avatar')}
  231. </Button>
  232. </AvatarSubmit>
  233. </AvatarUploadSection>
  234. </AvatarForm>
  235. </PanelBody>
  236. </Panel>
  237. );
  238. }
  239. }
  240. const AvatarHelp = styled('p')`
  241. margin-right: auto;
  242. color: ${p => p.theme.gray300};
  243. font-size: 14px;
  244. width: 50%;
  245. `;
  246. const AvatarGroup = styled('div')<{inline: boolean}>`
  247. display: flex;
  248. flex-direction: ${p => (p.inline ? 'row' : 'column')};
  249. `;
  250. const AvatarForm = styled('div')`
  251. line-height: ${space(3)};
  252. padding: ${space(1.5)} ${space(2)};
  253. margin: ${space(1.5)} ${space(1)} ${space(0.5)};
  254. `;
  255. const AvatarSubmit = styled('fieldset')`
  256. display: flex;
  257. align-items: center;
  258. margin-top: ${space(4)};
  259. padding-top: ${space(1.5)};
  260. `;
  261. const AvatarUploadSection = styled('div')`
  262. margin-top: ${space(1.5)};
  263. `;
  264. export default withApi(AvatarChooser);