avatarChooser.tsx 8.3 KB

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