note.tsx 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import {useCallback, useMemo, useState} from 'react';
  2. import type {MentionsInputProps} from 'react-mentions';
  3. import {Mention, MentionsInput} from 'react-mentions';
  4. import type {Theme} from '@emotion/react';
  5. import {useTheme} from '@emotion/react';
  6. import styled from '@emotion/styled';
  7. import {mentionStyle} from 'sentry/components/activity/note/mentionStyle';
  8. import type {
  9. CreateError,
  10. MentionChangeEvent,
  11. Mentioned,
  12. } from 'sentry/components/activity/note/types';
  13. import {Button} from 'sentry/components/button';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. import type {NoteType} from 'sentry/types/alerts';
  17. import domId from 'sentry/utils/domId';
  18. import {useMembers} from 'sentry/utils/useMembers';
  19. import {useTeams} from 'sentry/utils/useTeams';
  20. type Props = {
  21. errorJSON?: CreateError | null;
  22. /**
  23. * This is the id of the server's note object and is meant to indicate that
  24. * you are editing an existing item
  25. */
  26. noteId?: string;
  27. onChange?: (e: MentionChangeEvent, extra: {updating?: boolean}) => void;
  28. onCreate?: (data: NoteType) => void;
  29. onUpdate?: (data: NoteType) => void;
  30. placeholder?: string;
  31. /**
  32. * The note text itself
  33. */
  34. text?: string;
  35. };
  36. function StreamlinedNoteInput({
  37. text,
  38. onCreate,
  39. onChange,
  40. onUpdate,
  41. noteId,
  42. errorJSON,
  43. placeholder,
  44. }: Props) {
  45. const theme = useTheme();
  46. const {members} = useMembers();
  47. const {teams} = useTeams();
  48. const suggestMembers = members.map(member => ({
  49. id: `user:${member.id}`,
  50. display: member.name,
  51. }));
  52. const suggestTeams = teams.map(team => ({
  53. id: `team:${team.id}`,
  54. display: `#${team.slug}`,
  55. }));
  56. const [value, setValue] = useState(text ?? '');
  57. const [memberMentions, setMemberMentions] = useState<Mentioned[]>([]);
  58. const [teamMentions, setTeamMentions] = useState<Mentioned[]>([]);
  59. const canSubmit = value.trim() !== '';
  60. const cleanMarkdown = value
  61. .replace(/\[sentry\.strip:member\]/g, '@')
  62. .replace(/\[sentry\.strip:team\]/g, '');
  63. const existingItem = !!noteId;
  64. // each mention looks like [id, display]
  65. const finalizedMentions = [...memberMentions, ...teamMentions]
  66. .filter(mention => value.includes(mention[1]))
  67. .map(mention => mention[0]);
  68. const submitForm = useCallback(
  69. () =>
  70. existingItem
  71. ? onUpdate?.({text: cleanMarkdown, mentions: finalizedMentions})
  72. : onCreate?.({text: cleanMarkdown, mentions: finalizedMentions}),
  73. [existingItem, onUpdate, cleanMarkdown, finalizedMentions, onCreate]
  74. );
  75. const handleSubmit = useCallback(
  76. (e: React.MouseEvent<HTMLFormElement>) => {
  77. e.preventDefault();
  78. submitForm();
  79. },
  80. [submitForm]
  81. );
  82. const handleAddMember = useCallback(
  83. (id: React.ReactText, display: string) =>
  84. setMemberMentions(existing => [...existing, [`${id}`, display]]),
  85. []
  86. );
  87. const handleAddTeam = useCallback(
  88. (id: React.ReactText, display: string) =>
  89. setTeamMentions(existing => [...existing, [`${id}`, display]]),
  90. []
  91. );
  92. const handleChange: MentionsInputProps['onChange'] = useCallback(
  93. e => {
  94. setValue(e.target.value);
  95. onChange?.(e, {updating: existingItem});
  96. },
  97. [existingItem, onChange]
  98. );
  99. const handleKeyDown: MentionsInputProps['onKeyDown'] = useCallback(
  100. e => {
  101. if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && canSubmit) {
  102. handleSubmit(e);
  103. }
  104. },
  105. [canSubmit, handleSubmit]
  106. );
  107. const errorId = useMemo(() => domId('note-error-'), []);
  108. const errorMessage =
  109. (errorJSON &&
  110. (typeof errorJSON.detail === 'string'
  111. ? errorJSON.detail
  112. : errorJSON.detail?.message || t('Unable to post comment'))) ||
  113. null;
  114. return (
  115. <NoteInputForm data-test-id="note-input-form" noValidate onSubmit={handleSubmit}>
  116. <MentionsInput
  117. aria-label={t('Add a comment')}
  118. aria-errormessage={errorMessage ? errorId : undefined}
  119. style={{
  120. ...mentionStyle({theme, minHeight: 14, streamlined: true}),
  121. width: '100%',
  122. }}
  123. placeholder={placeholder}
  124. onChange={handleChange}
  125. onKeyDown={handleKeyDown}
  126. value={value}
  127. required
  128. >
  129. <Mention
  130. trigger="@"
  131. data={suggestMembers}
  132. onAdd={handleAddMember}
  133. displayTransform={(_id, display) => `@${display}`}
  134. markup="**[sentry.strip:member]__display__**"
  135. appendSpaceOnAdd
  136. />
  137. <Mention
  138. trigger="#"
  139. data={suggestTeams}
  140. onAdd={handleAddTeam}
  141. markup="**[sentry.strip:team]__display__**"
  142. appendSpaceOnAdd
  143. />
  144. </MentionsInput>
  145. <Button
  146. priority="primary"
  147. size="xs"
  148. disabled={!canSubmit}
  149. aria-label={t('Submit comment')}
  150. type="submit"
  151. >
  152. {t('Comment')}
  153. </Button>
  154. </NoteInputForm>
  155. );
  156. }
  157. export {StreamlinedNoteInput};
  158. const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => {
  159. if (!p.error) {
  160. return '';
  161. }
  162. return `
  163. color: ${p.theme.error};
  164. margin: -1px;
  165. border: 1px solid ${p.theme.error};
  166. border-radius: ${p.theme.borderRadius};
  167. &:before {
  168. display: block;
  169. content: '';
  170. width: 0;
  171. height: 0;
  172. border-top: 7px solid transparent;
  173. border-bottom: 7px solid transparent;
  174. border-right: 7px solid ${p.theme.red300};
  175. position: absolute;
  176. left: -7px;
  177. top: 12px;
  178. }
  179. &:after {
  180. display: block;
  181. content: '';
  182. width: 0;
  183. height: 0;
  184. border-top: 6px solid transparent;
  185. border-bottom: 6px solid transparent;
  186. border-right: 6px solid #fff;
  187. position: absolute;
  188. left: -5px;
  189. top: 12px;
  190. }
  191. `;
  192. };
  193. const NoteInputForm = styled('form')<{error?: string}>`
  194. display: flex;
  195. flex-direction: column;
  196. gap: ${space(0.75)};
  197. align-items: flex-end;
  198. width: 100%;
  199. transition: padding 0.2s ease-in-out;
  200. ${p => getNoteInputErrorStyles(p)};
  201. `;