123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- import {Component} from 'react';
- import {Mention, MentionsInput, MentionsInputProps} from 'react-mentions';
- import {withTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import Button, {ButtonPropsWithoutAriaLabel} from 'sentry/components/button';
- import NavTabs from 'sentry/components/navTabs';
- import {IconMarkdown} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import ConfigStore from 'sentry/stores/configStore';
- import space from 'sentry/styles/space';
- import textStyles from 'sentry/styles/text';
- import {NoteType} from 'sentry/types/alerts';
- import marked from 'sentry/utils/marked';
- import {Theme} from 'sentry/utils/theme';
- import Mentionables from './mentionables';
- import mentionStyle from './mentionStyle';
- import {CreateError, Mentionable, MentionChangeEvent, Mentioned} from './types';
- const defaultProps = {
- placeholder: t('Add a comment.\nTag users with @, or teams with #'),
- minHeight: 140,
- busy: false,
- };
- type Props = {
- memberList: Mentionable[];
- teams: Mentionable[];
- theme: Theme;
- error?: boolean;
- errorJSON?: CreateError | null;
- /**
- * This is the id of the note object from the server
- * This is to indicate you are editing an existing item
- */
- modelId?: string;
- onChange?: (e: MentionChangeEvent, extra: {updating?: boolean}) => void;
- onCreate?: (data: NoteType) => void;
- onEditFinish?: () => void;
- onUpdate?: (data: NoteType) => void;
- /**
- * The note text itself
- */
- text?: string;
- } & typeof defaultProps;
- type State = {
- memberMentions: Mentioned[];
- preview: boolean;
- teamMentions: Mentioned[];
- value: string;
- };
- class NoteInputComponent extends Component<Props, State> {
- state: State = {
- preview: false,
- value: this.props.text || '',
- memberMentions: [],
- teamMentions: [],
- };
- get canSubmit() {
- return this.state.value.trim() !== '';
- }
- cleanMarkdown(text: string) {
- return text
- .replace(/\[sentry\.strip:member\]/g, '@')
- .replace(/\[sentry\.strip:team\]/g, '');
- }
- submitForm() {
- if (this.props.modelId) {
- this.update();
- return;
- }
- this.create();
- }
- create() {
- const {onCreate} = this.props;
- if (onCreate) {
- onCreate({
- text: this.cleanMarkdown(this.state.value),
- mentions: this.finalizeMentions(),
- });
- }
- }
- update() {
- const {onUpdate} = this.props;
- if (onUpdate) {
- onUpdate({
- text: this.cleanMarkdown(this.state.value),
- mentions: this.finalizeMentions(),
- });
- }
- }
- finish() {
- this.props.onEditFinish && this.props.onEditFinish();
- }
- finalizeMentions(): string[] {
- const {memberMentions, teamMentions} = this.state;
- // each mention looks like [id, display]
- return [...memberMentions, ...teamMentions]
- .filter(mention => this.state.value.indexOf(mention[1]) !== -1)
- .map(mention => mention[0]);
- }
- handleToggleEdit = () => {
- this.setState({preview: false});
- };
- handleTogglePreview = () => {
- this.setState({preview: true});
- };
- handleSubmit = (e: React.MouseEvent<HTMLFormElement>) => {
- e.preventDefault();
- this.submitForm();
- };
- handleChange: MentionsInputProps['onChange'] = e => {
- this.setState({value: e.target.value});
- if (this.props.onChange) {
- this.props.onChange(e, {updating: !!this.props.modelId});
- }
- };
- handleKeyDown: MentionsInputProps['onKeyDown'] = e => {
- // Auto submit the form on [meta,ctrl] + Enter
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && this.canSubmit) {
- this.submitForm();
- }
- };
- handleCancel = (e: React.MouseEvent<Element>) => {
- e.preventDefault();
- this.finish();
- };
- handleAddMember = (id: React.ReactText, display: string) => {
- this.setState(({memberMentions}) => ({
- memberMentions: [...memberMentions, [`${id}`, display]],
- }));
- };
- handleAddTeam = (id: React.ReactText, display: string) => {
- this.setState(({teamMentions}) => ({
- teamMentions: [...teamMentions, [`${id}`, display]],
- }));
- };
- render() {
- const {preview, value} = this.state;
- const {modelId, busy, placeholder, minHeight, errorJSON, memberList, teams, theme} =
- this.props;
- const existingItem = !!modelId;
- const btnText = existingItem ? t('Save Comment') : t('Post Comment');
- const errorMessage =
- (errorJSON &&
- (typeof errorJSON.detail === 'string'
- ? errorJSON.detail
- : (errorJSON.detail && errorJSON.detail.message) ||
- t('Unable to post comment'))) ||
- null;
- return (
- <NoteInputForm
- data-test-id="note-input-form"
- noValidate
- onSubmit={this.handleSubmit}
- >
- <NoteInputNavTabs>
- <NoteInputNavTab className={!preview ? 'active' : ''}>
- <NoteInputNavTabLink onClick={this.handleToggleEdit}>
- {existingItem ? t('Edit') : t('Write')}
- </NoteInputNavTabLink>
- </NoteInputNavTab>
- <NoteInputNavTab className={preview ? 'active' : ''}>
- <NoteInputNavTabLink onClick={this.handleTogglePreview}>
- {t('Preview')}
- </NoteInputNavTabLink>
- </NoteInputNavTab>
- <MarkdownTab>
- <IconMarkdown />
- <MarkdownSupported>{t('Markdown supported')}</MarkdownSupported>
- </MarkdownTab>
- </NoteInputNavTabs>
- <NoteInputBody>
- {preview ? (
- <NotePreview
- minHeight={minHeight}
- dangerouslySetInnerHTML={{__html: marked(this.cleanMarkdown(value))}}
- />
- ) : (
- <MentionsInput
- style={mentionStyle({theme, minHeight})}
- placeholder={placeholder}
- onChange={this.handleChange}
- onKeyDown={this.handleKeyDown}
- value={value}
- required
- autoFocus
- >
- <Mention
- trigger="@"
- data={memberList}
- onAdd={this.handleAddMember}
- displayTransform={(_id, display) => `@${display}`}
- markup="**[sentry.strip:member]__display__**"
- appendSpaceOnAdd
- />
- <Mention
- trigger="#"
- data={teams}
- onAdd={this.handleAddTeam}
- markup="**[sentry.strip:team]__display__**"
- appendSpaceOnAdd
- />
- </MentionsInput>
- )}
- </NoteInputBody>
- <Footer>
- <div>{errorMessage && <ErrorMessage>{errorMessage}</ErrorMessage>}</div>
- <div>
- {existingItem && (
- <FooterButton priority="danger" type="button" onClick={this.handleCancel}>
- {t('Cancel')}
- </FooterButton>
- )}
- <FooterButton
- error={errorMessage}
- type="submit"
- disabled={busy || !this.canSubmit}
- >
- {btnText}
- </FooterButton>
- </div>
- </Footer>
- </NoteInputForm>
- );
- }
- }
- const NoteInput = withTheme(NoteInputComponent);
- type NoteInputContainerProps = {
- projectSlugs: string[];
- } & Omit<Props, 'memberList' | 'teams' | 'theme'>;
- type MentionablesChildFunc = Parameters<
- React.ComponentProps<typeof Mentionables>['children']
- >[0];
- class NoteInputContainer extends Component<NoteInputContainerProps> {
- static defaultProps = defaultProps;
- renderInput = ({members, teams}: MentionablesChildFunc) => {
- const {projectSlugs: _, ...props} = this.props;
- return <NoteInput memberList={members} teams={teams} {...props} />;
- };
- render() {
- const {projectSlugs} = this.props;
- const me = ConfigStore.get('user');
- return (
- <Mentionables me={me} projectSlugs={projectSlugs}>
- {this.renderInput}
- </Mentionables>
- );
- }
- }
- export default NoteInputContainer;
- type NotePreviewProps = {
- minHeight: Props['minHeight'];
- theme: Props['theme'];
- };
- // This styles both the note preview and the note editor input
- const getNotePreviewCss = (p: NotePreviewProps) => {
- const {minHeight, padding, overflow, border} = mentionStyle(p)['&multiLine'].input;
- return `
- max-height: 1000px;
- max-width: 100%;
- ${(minHeight && `min-height: ${minHeight}px`) || ''};
- padding: ${padding};
- overflow: ${overflow};
- border: ${border};
- `;
- };
- const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => {
- if (!p.error) {
- return '';
- }
- return `
- color: ${p.theme.error};
- margin: -1px;
- border: 1px solid ${p.theme.error};
- border-radius: ${p.theme.borderRadius};
- &:before {
- display: block;
- content: '';
- width: 0;
- height: 0;
- border-top: 7px solid transparent;
- border-bottom: 7px solid transparent;
- border-right: 7px solid ${p.theme.red300};
- position: absolute;
- left: -7px;
- top: 12px;
- }
- &:after {
- display: block;
- content: '';
- width: 0;
- height: 0;
- border-top: 6px solid transparent;
- border-bottom: 6px solid transparent;
- border-right: 6px solid #fff;
- position: absolute;
- left: -5px;
- top: 12px;
- }
- `;
- };
- const NoteInputForm = styled('form')<{error?: string}>`
- font-size: 15px;
- line-height: 22px;
- transition: padding 0.2s ease-in-out;
- ${p => getNoteInputErrorStyles(p)}
- `;
- const NoteInputBody = styled('div')`
- ${textStyles}
- `;
- const Footer = styled('div')`
- display: flex;
- border-top: 1px solid ${p => p.theme.border};
- justify-content: space-between;
- transition: opacity 0.2s ease-in-out;
- padding-left: ${space(1.5)};
- `;
- interface FooterButtonProps extends ButtonPropsWithoutAriaLabel {
- error?: string | null;
- }
- const FooterButton = styled(Button)<FooterButtonProps>`
- font-size: 13px;
- margin: -1px -1px -1px;
- border-radius: 0 0 ${p => p.theme.borderRadius};
- ${p =>
- p.error &&
- `
- &, &:active, &:focus, &:hover {
- border-bottom-color: ${p.theme.error};
- border-right-color: ${p.theme.error};
- }
- `}
- `;
- const ErrorMessage = styled('span')`
- display: flex;
- align-items: center;
- height: 100%;
- color: ${p => p.theme.error};
- font-size: 0.9em;
- `;
- const NoteInputNavTabs = styled(NavTabs)`
- padding: ${space(1)} ${space(2)} 0;
- border-bottom: 1px solid ${p => p.theme.border};
- margin-bottom: 0;
- `;
- const NoteInputNavTab = styled('li')`
- margin-right: 13px;
- `;
- const NoteInputNavTabLink = styled('a')`
- .nav-tabs > li > & {
- font-size: 15px;
- padding-bottom: 5px;
- }
- `;
- const MarkdownTab = styled(NoteInputNavTab)`
- .nav-tabs > & {
- display: flex;
- align-items: center;
- margin-right: 0;
- color: ${p => p.theme.subText};
- float: right;
- }
- `;
- const MarkdownSupported = styled('span')`
- margin-left: ${space(0.5)};
- font-size: 14px;
- `;
- const NotePreview = styled('div')<{minHeight: Props['minHeight']}>`
- ${p => getNotePreviewCss(p)};
- padding-bottom: ${space(1)};
- `;
|