input.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import {useCallback, useMemo, useState} from 'react';
  2. import {Mention, MentionsInput, MentionsInputProps} from 'react-mentions';
  3. import {Theme, useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {Button} from 'sentry/components/button';
  6. import {TabList, TabPanels, Tabs} from 'sentry/components/tabs';
  7. import {IconMarkdown} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import MemberListStore from 'sentry/stores/memberListStore';
  10. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  11. import {space} from 'sentry/styles/space';
  12. import textStyles from 'sentry/styles/text';
  13. import {NoteType} from 'sentry/types/alerts';
  14. import domId from 'sentry/utils/domId';
  15. import marked from 'sentry/utils/marked';
  16. import useTeams from 'sentry/utils/useTeams';
  17. import mentionStyle from './mentionStyle';
  18. import {CreateError, MentionChangeEvent, Mentioned} from './types';
  19. type Props = {
  20. /**
  21. * Is the note saving?
  22. */
  23. busy?: boolean;
  24. /**
  25. * Display an error message
  26. */
  27. error?: boolean;
  28. errorJSON?: CreateError | null;
  29. /**
  30. * Minimum height of the edit area
  31. */
  32. minHeight?: number;
  33. /**
  34. * This is the id of the server's note object and is meant to indicate that
  35. * you are editing an existing item
  36. */
  37. noteId?: string;
  38. onChange?: (e: MentionChangeEvent, extra: {updating?: boolean}) => void;
  39. onCreate?: (data: NoteType) => void;
  40. onEditFinish?: () => void;
  41. onUpdate?: (data: NoteType) => void;
  42. placeholder?: string;
  43. /**
  44. * The note text itself
  45. */
  46. text?: string;
  47. };
  48. function NoteInput({
  49. text,
  50. onCreate,
  51. onChange,
  52. onUpdate,
  53. onEditFinish,
  54. noteId,
  55. errorJSON,
  56. busy = false,
  57. placeholder = t('Add a comment.\nTag users with @, or teams with #'),
  58. minHeight = 140,
  59. }: Props) {
  60. const theme = useTheme();
  61. const members = useLegacyStore(MemberListStore).map(member => ({
  62. id: `user:${member.id}`,
  63. display: member.name,
  64. email: member.email,
  65. }));
  66. const teams = useTeams().teams.map(team => ({
  67. id: `team:${team.id}`,
  68. display: `#${team.slug}`,
  69. email: team.id,
  70. }));
  71. const [value, setValue] = useState(text ?? '');
  72. const [memberMentions, setMemberMentions] = useState<Mentioned[]>([]);
  73. const [teamMentions, setTeamMentions] = useState<Mentioned[]>([]);
  74. const canSubmit = value.trim() !== '';
  75. const cleanMarkdown = value
  76. .replace(/\[sentry\.strip:member\]/g, '@')
  77. .replace(/\[sentry\.strip:team\]/g, '');
  78. const existingItem = !!noteId;
  79. // each mention looks like [id, display]
  80. const finalizedMentions = [...memberMentions, ...teamMentions]
  81. .filter(mention => value.includes(mention[1]))
  82. .map(mention => mention[0]);
  83. const submitForm = useCallback(
  84. () =>
  85. existingItem
  86. ? onUpdate?.({text: cleanMarkdown, mentions: finalizedMentions})
  87. : onCreate?.({text: cleanMarkdown, mentions: finalizedMentions}),
  88. [existingItem, onUpdate, cleanMarkdown, finalizedMentions, onCreate]
  89. );
  90. const handleCancel = useCallback(
  91. (e: React.MouseEvent<Element>) => {
  92. e.preventDefault();
  93. onEditFinish?.();
  94. },
  95. [onEditFinish]
  96. );
  97. const handleSubmit = useCallback(
  98. (e: React.MouseEvent<HTMLFormElement>) => {
  99. e.preventDefault();
  100. submitForm();
  101. },
  102. [submitForm]
  103. );
  104. const handleAddMember = useCallback(
  105. (id: React.ReactText, display: string) =>
  106. setMemberMentions(existing => [...existing, [`${id}`, display]]),
  107. []
  108. );
  109. const handleAddTeam = useCallback(
  110. (id: React.ReactText, display: string) =>
  111. setTeamMentions(existing => [...existing, [`${id}`, display]]),
  112. []
  113. );
  114. const handleChange: MentionsInputProps['onChange'] = useCallback(
  115. e => {
  116. setValue(e.target.value);
  117. onChange?.(e, {updating: existingItem});
  118. },
  119. [existingItem, onChange]
  120. );
  121. const handleKeyDown: MentionsInputProps['onKeyDown'] = useCallback(
  122. e => {
  123. // Auto submit the form on [meta,ctrl] + Enter
  124. if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
  125. submitForm();
  126. }
  127. },
  128. [canSubmit, submitForm]
  129. );
  130. const errorId = useMemo(() => domId('note-error-'), []);
  131. const errorMessage =
  132. (errorJSON &&
  133. (typeof errorJSON.detail === 'string'
  134. ? errorJSON.detail
  135. : (errorJSON.detail && errorJSON.detail.message) ||
  136. t('Unable to post comment'))) ||
  137. null;
  138. return (
  139. <NoteInputForm data-test-id="note-input-form" noValidate onSubmit={handleSubmit}>
  140. <Tabs>
  141. <StyledTabList>
  142. <TabList.Item key="edit">{existingItem ? t('Edit') : t('Write')}</TabList.Item>
  143. <TabList.Item key="preview">{t('Preview')}</TabList.Item>
  144. </StyledTabList>
  145. <NoteInputPanel>
  146. <TabPanels.Item key="edit">
  147. <MentionsInput
  148. aria-errormessage={errorMessage ? errorId : undefined}
  149. style={mentionStyle({theme, minHeight})}
  150. placeholder={placeholder}
  151. onChange={handleChange}
  152. onKeyDown={handleKeyDown}
  153. value={value}
  154. required
  155. autoFocus
  156. >
  157. <Mention
  158. trigger="@"
  159. data={members}
  160. onAdd={handleAddMember}
  161. displayTransform={(_id, display) => `@${display}`}
  162. markup="**[sentry.strip:member]__display__**"
  163. appendSpaceOnAdd
  164. />
  165. <Mention
  166. trigger="#"
  167. data={teams}
  168. onAdd={handleAddTeam}
  169. markup="**[sentry.strip:team]__display__**"
  170. appendSpaceOnAdd
  171. />
  172. </MentionsInput>
  173. </TabPanels.Item>
  174. <TabPanels.Item key="preview">
  175. <NotePreview
  176. minHeight={minHeight}
  177. dangerouslySetInnerHTML={{__html: marked(cleanMarkdown)}}
  178. />
  179. </TabPanels.Item>
  180. </NoteInputPanel>
  181. </Tabs>
  182. <Footer>
  183. {errorMessage ? (
  184. <div id={errorId}>
  185. {errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}
  186. </div>
  187. ) : (
  188. <MarkdownIndicator>
  189. <IconMarkdown /> {t('Markdown supported')}
  190. </MarkdownIndicator>
  191. )}
  192. <div>
  193. {existingItem && (
  194. <FooterButton priority="danger" onClick={handleCancel}>
  195. {t('Cancel')}
  196. </FooterButton>
  197. )}
  198. <FooterButton
  199. error={!!errorMessage}
  200. type="submit"
  201. disabled={busy || !canSubmit}
  202. >
  203. {existingItem ? t('Save Comment') : t('Post Comment')}
  204. </FooterButton>
  205. </div>
  206. </Footer>
  207. </NoteInputForm>
  208. );
  209. }
  210. export default NoteInput;
  211. type NotePreviewProps = {
  212. minHeight: Props['minHeight'];
  213. theme: Theme;
  214. };
  215. // This styles both the note preview and the note editor input
  216. const getNotePreviewCss = (p: NotePreviewProps) => {
  217. const {minHeight, padding, overflow, border} = mentionStyle(p)['&multiLine'].input;
  218. return `
  219. max-height: 1000px;
  220. max-width: 100%;
  221. ${(minHeight && `min-height: ${minHeight}px`) || ''};
  222. padding: ${padding};
  223. overflow: ${overflow};
  224. border: ${border};
  225. `;
  226. };
  227. const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => {
  228. if (!p.error) {
  229. return '';
  230. }
  231. return `
  232. color: ${p.theme.error};
  233. margin: -1px;
  234. border: 1px solid ${p.theme.error};
  235. border-radius: ${p.theme.borderRadius};
  236. &:before {
  237. display: block;
  238. content: '';
  239. width: 0;
  240. height: 0;
  241. border-top: 7px solid transparent;
  242. border-bottom: 7px solid transparent;
  243. border-right: 7px solid ${p.theme.red300};
  244. position: absolute;
  245. left: -7px;
  246. top: 12px;
  247. }
  248. &:after {
  249. display: block;
  250. content: '';
  251. width: 0;
  252. height: 0;
  253. border-top: 6px solid transparent;
  254. border-bottom: 6px solid transparent;
  255. border-right: 6px solid #fff;
  256. position: absolute;
  257. left: -5px;
  258. top: 12px;
  259. }
  260. `;
  261. };
  262. const StyledTabList = styled(TabList)`
  263. padding: 0 ${space(2)};
  264. padding-top: ${space(0.5)};
  265. `;
  266. const NoteInputForm = styled('form')<{error?: string}>`
  267. font-size: 15px;
  268. line-height: 22px;
  269. transition: padding 0.2s ease-in-out;
  270. ${p => getNoteInputErrorStyles(p)};
  271. `;
  272. const NoteInputPanel = styled(TabPanels)`
  273. ${textStyles}
  274. `;
  275. const Footer = styled('div')`
  276. display: flex;
  277. border-top: 1px solid ${p => p.theme.border};
  278. justify-content: space-between;
  279. padding-left: ${space(1.5)};
  280. `;
  281. const FooterButton = styled(Button)<{error?: boolean}>`
  282. font-size: 13px;
  283. margin: -1px -1px -1px;
  284. border-radius: 0 0 ${p => p.theme.borderRadius};
  285. ${p =>
  286. p.error &&
  287. `
  288. &, &:active, &:focus, &:hover {
  289. border-bottom-color: ${p.theme.error};
  290. border-right-color: ${p.theme.error};
  291. }
  292. `}
  293. `;
  294. const ErrorMessage = styled('span')`
  295. display: flex;
  296. align-items: center;
  297. height: 100%;
  298. color: ${p => p.theme.error};
  299. font-size: 0.9em;
  300. `;
  301. const MarkdownIndicator = styled('div')`
  302. display: flex;
  303. align-items: center;
  304. gap: ${space(1)};
  305. color: ${p => p.theme.subText};
  306. `;
  307. const NotePreview = styled('div')<{minHeight: Props['minHeight']}>`
  308. ${p => getNotePreviewCss(p)};
  309. padding-bottom: ${space(1)};
  310. `;