input.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. import {Component} from 'react';
  2. import {Mention, MentionsInput, MentionsInputProps} from 'react-mentions';
  3. import {withTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import Button, {ButtonPropsWithoutAriaLabel} from 'sentry/components/button';
  6. import NavTabs from 'sentry/components/navTabs';
  7. import {IconMarkdown} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import ConfigStore from 'sentry/stores/configStore';
  10. import space from 'sentry/styles/space';
  11. import textStyles from 'sentry/styles/text';
  12. import {NoteType} from 'sentry/types/alerts';
  13. import marked from 'sentry/utils/marked';
  14. import {Theme} from 'sentry/utils/theme';
  15. import Mentionables from './mentionables';
  16. import mentionStyle from './mentionStyle';
  17. import {CreateError, Mentionable, MentionChangeEvent, Mentioned} from './types';
  18. const defaultProps = {
  19. placeholder: t('Add a comment.\nTag users with @, or teams with #'),
  20. minHeight: 140,
  21. busy: false,
  22. };
  23. type Props = {
  24. memberList: Mentionable[];
  25. teams: Mentionable[];
  26. theme: Theme;
  27. error?: boolean;
  28. errorJSON?: CreateError | null;
  29. /**
  30. * This is the id of the note object from the server
  31. * This is to indicate you are editing an existing item
  32. */
  33. modelId?: string;
  34. onChange?: (e: MentionChangeEvent, extra: {updating?: boolean}) => void;
  35. onCreate?: (data: NoteType) => void;
  36. onEditFinish?: () => void;
  37. onUpdate?: (data: NoteType) => void;
  38. /**
  39. * The note text itself
  40. */
  41. text?: string;
  42. } & typeof defaultProps;
  43. type State = {
  44. memberMentions: Mentioned[];
  45. preview: boolean;
  46. teamMentions: Mentioned[];
  47. value: string;
  48. };
  49. class NoteInputComponent extends Component<Props, State> {
  50. state: State = {
  51. preview: false,
  52. value: this.props.text || '',
  53. memberMentions: [],
  54. teamMentions: [],
  55. };
  56. get canSubmit() {
  57. return this.state.value.trim() !== '';
  58. }
  59. cleanMarkdown(text: string) {
  60. return text
  61. .replace(/\[sentry\.strip:member\]/g, '@')
  62. .replace(/\[sentry\.strip:team\]/g, '');
  63. }
  64. submitForm() {
  65. if (this.props.modelId) {
  66. this.update();
  67. return;
  68. }
  69. this.create();
  70. }
  71. create() {
  72. const {onCreate} = this.props;
  73. if (onCreate) {
  74. onCreate({
  75. text: this.cleanMarkdown(this.state.value),
  76. mentions: this.finalizeMentions(),
  77. });
  78. }
  79. }
  80. update() {
  81. const {onUpdate} = this.props;
  82. if (onUpdate) {
  83. onUpdate({
  84. text: this.cleanMarkdown(this.state.value),
  85. mentions: this.finalizeMentions(),
  86. });
  87. }
  88. }
  89. finish() {
  90. this.props.onEditFinish && this.props.onEditFinish();
  91. }
  92. finalizeMentions(): string[] {
  93. const {memberMentions, teamMentions} = this.state;
  94. // each mention looks like [id, display]
  95. return [...memberMentions, ...teamMentions]
  96. .filter(mention => this.state.value.indexOf(mention[1]) !== -1)
  97. .map(mention => mention[0]);
  98. }
  99. handleToggleEdit = () => {
  100. this.setState({preview: false});
  101. };
  102. handleTogglePreview = () => {
  103. this.setState({preview: true});
  104. };
  105. handleSubmit = (e: React.MouseEvent<HTMLFormElement>) => {
  106. e.preventDefault();
  107. this.submitForm();
  108. };
  109. handleChange: MentionsInputProps['onChange'] = e => {
  110. this.setState({value: e.target.value});
  111. if (this.props.onChange) {
  112. this.props.onChange(e, {updating: !!this.props.modelId});
  113. }
  114. };
  115. handleKeyDown: MentionsInputProps['onKeyDown'] = e => {
  116. // Auto submit the form on [meta,ctrl] + Enter
  117. if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && this.canSubmit) {
  118. this.submitForm();
  119. }
  120. };
  121. handleCancel = (e: React.MouseEvent<Element>) => {
  122. e.preventDefault();
  123. this.finish();
  124. };
  125. handleAddMember = (id: React.ReactText, display: string) => {
  126. this.setState(({memberMentions}) => ({
  127. memberMentions: [...memberMentions, [`${id}`, display]],
  128. }));
  129. };
  130. handleAddTeam = (id: React.ReactText, display: string) => {
  131. this.setState(({teamMentions}) => ({
  132. teamMentions: [...teamMentions, [`${id}`, display]],
  133. }));
  134. };
  135. render() {
  136. const {preview, value} = this.state;
  137. const {modelId, busy, placeholder, minHeight, errorJSON, memberList, teams, theme} =
  138. this.props;
  139. const existingItem = !!modelId;
  140. const btnText = existingItem ? t('Save Comment') : t('Post Comment');
  141. const errorMessage =
  142. (errorJSON &&
  143. (typeof errorJSON.detail === 'string'
  144. ? errorJSON.detail
  145. : (errorJSON.detail && errorJSON.detail.message) ||
  146. t('Unable to post comment'))) ||
  147. null;
  148. return (
  149. <NoteInputForm
  150. data-test-id="note-input-form"
  151. noValidate
  152. onSubmit={this.handleSubmit}
  153. >
  154. <NoteInputNavTabs>
  155. <NoteInputNavTab className={!preview ? 'active' : ''}>
  156. <NoteInputNavTabLink onClick={this.handleToggleEdit}>
  157. {existingItem ? t('Edit') : t('Write')}
  158. </NoteInputNavTabLink>
  159. </NoteInputNavTab>
  160. <NoteInputNavTab className={preview ? 'active' : ''}>
  161. <NoteInputNavTabLink onClick={this.handleTogglePreview}>
  162. {t('Preview')}
  163. </NoteInputNavTabLink>
  164. </NoteInputNavTab>
  165. <MarkdownTab>
  166. <IconMarkdown />
  167. <MarkdownSupported>{t('Markdown supported')}</MarkdownSupported>
  168. </MarkdownTab>
  169. </NoteInputNavTabs>
  170. <NoteInputBody>
  171. {preview ? (
  172. <NotePreview
  173. minHeight={minHeight}
  174. dangerouslySetInnerHTML={{__html: marked(this.cleanMarkdown(value))}}
  175. />
  176. ) : (
  177. <MentionsInput
  178. style={mentionStyle({theme, minHeight})}
  179. placeholder={placeholder}
  180. onChange={this.handleChange}
  181. onKeyDown={this.handleKeyDown}
  182. value={value}
  183. required
  184. autoFocus
  185. >
  186. <Mention
  187. trigger="@"
  188. data={memberList}
  189. onAdd={this.handleAddMember}
  190. displayTransform={(_id, display) => `@${display}`}
  191. markup="**[sentry.strip:member]__display__**"
  192. appendSpaceOnAdd
  193. />
  194. <Mention
  195. trigger="#"
  196. data={teams}
  197. onAdd={this.handleAddTeam}
  198. markup="**[sentry.strip:team]__display__**"
  199. appendSpaceOnAdd
  200. />
  201. </MentionsInput>
  202. )}
  203. </NoteInputBody>
  204. <Footer>
  205. <div>{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}</div>
  206. <div>
  207. {existingItem && (
  208. <FooterButton priority="danger" type="button" onClick={this.handleCancel}>
  209. {t('Cancel')}
  210. </FooterButton>
  211. )}
  212. <FooterButton
  213. error={errorMessage}
  214. type="submit"
  215. disabled={busy || !this.canSubmit}
  216. >
  217. {btnText}
  218. </FooterButton>
  219. </div>
  220. </Footer>
  221. </NoteInputForm>
  222. );
  223. }
  224. }
  225. const NoteInput = withTheme(NoteInputComponent);
  226. type NoteInputContainerProps = {
  227. projectSlugs: string[];
  228. } & Omit<Props, 'memberList' | 'teams' | 'theme'>;
  229. type MentionablesChildFunc = Parameters<
  230. React.ComponentProps<typeof Mentionables>['children']
  231. >[0];
  232. class NoteInputContainer extends Component<NoteInputContainerProps> {
  233. static defaultProps = defaultProps;
  234. renderInput = ({members, teams}: MentionablesChildFunc) => {
  235. const {projectSlugs: _, ...props} = this.props;
  236. return <NoteInput memberList={members} teams={teams} {...props} />;
  237. };
  238. render() {
  239. const {projectSlugs} = this.props;
  240. const me = ConfigStore.get('user');
  241. return (
  242. <Mentionables me={me} projectSlugs={projectSlugs}>
  243. {this.renderInput}
  244. </Mentionables>
  245. );
  246. }
  247. }
  248. export default NoteInputContainer;
  249. type NotePreviewProps = {
  250. minHeight: Props['minHeight'];
  251. theme: Props['theme'];
  252. };
  253. // This styles both the note preview and the note editor input
  254. const getNotePreviewCss = (p: NotePreviewProps) => {
  255. const {minHeight, padding, overflow, border} = mentionStyle(p)['&multiLine'].input;
  256. return `
  257. max-height: 1000px;
  258. max-width: 100%;
  259. ${(minHeight && `min-height: ${minHeight}px`) || ''};
  260. padding: ${padding};
  261. overflow: ${overflow};
  262. border: ${border};
  263. `;
  264. };
  265. const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => {
  266. if (!p.error) {
  267. return '';
  268. }
  269. return `
  270. color: ${p.theme.error};
  271. margin: -1px;
  272. border: 1px solid ${p.theme.error};
  273. border-radius: ${p.theme.borderRadius};
  274. &:before {
  275. display: block;
  276. content: '';
  277. width: 0;
  278. height: 0;
  279. border-top: 7px solid transparent;
  280. border-bottom: 7px solid transparent;
  281. border-right: 7px solid ${p.theme.red300};
  282. position: absolute;
  283. left: -7px;
  284. top: 12px;
  285. }
  286. &:after {
  287. display: block;
  288. content: '';
  289. width: 0;
  290. height: 0;
  291. border-top: 6px solid transparent;
  292. border-bottom: 6px solid transparent;
  293. border-right: 6px solid #fff;
  294. position: absolute;
  295. left: -5px;
  296. top: 12px;
  297. }
  298. `;
  299. };
  300. const NoteInputForm = styled('form')<{error?: string}>`
  301. font-size: 15px;
  302. line-height: 22px;
  303. transition: padding 0.2s ease-in-out;
  304. ${p => getNoteInputErrorStyles(p)}
  305. `;
  306. const NoteInputBody = styled('div')`
  307. ${textStyles}
  308. `;
  309. const Footer = styled('div')`
  310. display: flex;
  311. border-top: 1px solid ${p => p.theme.border};
  312. justify-content: space-between;
  313. transition: opacity 0.2s ease-in-out;
  314. padding-left: ${space(1.5)};
  315. `;
  316. interface FooterButtonProps extends ButtonPropsWithoutAriaLabel {
  317. error?: string | null;
  318. }
  319. const FooterButton = styled(Button)<FooterButtonProps>`
  320. font-size: 13px;
  321. margin: -1px -1px -1px;
  322. border-radius: 0 0 ${p => p.theme.borderRadius};
  323. ${p =>
  324. p.error &&
  325. `
  326. &, &:active, &:focus, &:hover {
  327. border-bottom-color: ${p.theme.error};
  328. border-right-color: ${p.theme.error};
  329. }
  330. `}
  331. `;
  332. const ErrorMessage = styled('span')`
  333. display: flex;
  334. align-items: center;
  335. height: 100%;
  336. color: ${p => p.theme.error};
  337. font-size: 0.9em;
  338. `;
  339. const NoteInputNavTabs = styled(NavTabs)`
  340. padding: ${space(1)} ${space(2)} 0;
  341. border-bottom: 1px solid ${p => p.theme.border};
  342. margin-bottom: 0;
  343. `;
  344. const NoteInputNavTab = styled('li')`
  345. margin-right: 13px;
  346. `;
  347. const NoteInputNavTabLink = styled('a')`
  348. .nav-tabs > li > & {
  349. font-size: 15px;
  350. padding-bottom: 5px;
  351. }
  352. `;
  353. const MarkdownTab = styled(NoteInputNavTab)`
  354. .nav-tabs > & {
  355. display: flex;
  356. align-items: center;
  357. margin-right: 0;
  358. color: ${p => p.theme.subText};
  359. float: right;
  360. }
  361. `;
  362. const MarkdownSupported = styled('span')`
  363. margin-left: ${space(0.5)};
  364. font-size: 14px;
  365. `;
  366. const NotePreview = styled('div')<{minHeight: Props['minHeight']}>`
  367. ${p => getNotePreviewCss(p)};
  368. padding-bottom: ${space(1)};
  369. `;