Files
2024-02-15 17:01:50 +00:00

283 lines
7.4 KiB
TypeScript

import {Component, Fragment} from 'react';
import styled from '@emotion/styled';
import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {Client} from 'sentry/api';
import {Button} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import TextArea from 'sentry/components/forms/controls/textarea';
import Panel from 'sentry/components/panels/panel';
import PanelBody from 'sentry/components/panels/panelBody';
import PanelHeader from 'sentry/components/panels/panelHeader';
import TimeSince from 'sentry/components/timeSince';
import {t} from 'sentry/locale';
import MemberListStore from 'sentry/stores/memberListStore';
import ProjectsStore from 'sentry/stores/projectsStore';
import type {Organization, Project, Team} from 'sentry/types';
import {defined} from 'sentry/utils';
import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
const defaultProps = {
urls: [] as string[],
paths: [] as string[],
disabled: false,
};
type Props = {
dateUpdated: string | null;
initialText: string;
onCancel: () => void;
organization: Organization;
/**
* Used for analytics
*/
page: 'issue_details' | 'project_settings';
project: Project;
onSave?: (text: string | null) => void;
} & typeof defaultProps;
type State = {
error: null | {
raw: string[];
};
hasChanges: boolean;
text: string | null;
};
class OwnerInput extends Component<Props, State> {
static defaultProps = defaultProps;
state: State = {
hasChanges: false,
text: null,
error: null,
};
parseError(error: State['error']) {
const text = error?.raw?.[0];
if (!text) {
return null;
}
if (text.startsWith('Invalid rule owners:')) {
return <InvalidOwners>{text}</InvalidOwners>;
}
return (
<SyntaxOverlay line={parseInt(text.match(/line (\d*),/)?.[1] ?? '', 10) - 1} />
);
}
handleUpdateOwnership = () => {
const {organization, project, onSave, page, initialText} = this.props;
const {text} = this.state;
this.setState({error: null});
const api = new Client();
const request = api.requestPromise(
`/projects/${organization.slug}/${project.slug}/ownership/`,
{
method: 'PUT',
data: {raw: text || ''},
}
);
request
.then(() => {
addSuccessMessage(t('Updated issue ownership rules'));
this.setState(
{
hasChanges: false,
text,
},
() => onSave?.(text)
);
trackIntegrationAnalytics('project_ownership.saved', {
page,
organization,
net_change:
(text?.split('\n').filter(x => x).length ?? 0) -
initialText.split('\n').filter(x => x).length,
});
})
.catch(error => {
this.setState({error: error.responseJSON});
if (error.status === 403) {
addErrorMessage(
t(
"You don't have permission to modify issue ownership rules for this project"
)
);
} else if (
error.status === 400 &&
error.responseJSON.raw &&
error.responseJSON.raw[0].startsWith('Invalid rule owners:')
) {
addErrorMessage(
t(
'Unable to save issue ownership rule changes: %s',
error.responseJSON.raw[0]
)
);
} else {
addErrorMessage(t('Unable to save issue ownership rule changes'));
}
});
return request;
};
mentionableUsers() {
return MemberListStore.getAll().map(member => ({
id: member.id,
display: member.email,
email: member.email,
}));
}
mentionableTeams() {
const {project} = this.props;
const projectWithTeams = ProjectsStore.getBySlug(project.slug);
if (!projectWithTeams) {
return [];
}
return projectWithTeams.teams.map((team: Team) => ({
id: team.id,
display: `#${team.slug}`,
email: team.id,
}));
}
handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
this.setState({
hasChanges: true,
text: e.target.value,
});
};
handleAddRule = (rule: string) => {
const {initialText} = this.props;
this.setState(
({text}) => ({
text: (text || initialText) + '\n' + rule,
}),
this.handleUpdateOwnership
);
};
render() {
const {disabled, initialText, dateUpdated} = this.props;
const {hasChanges, text, error} = this.state;
return (
<Fragment>
<div
style={{position: 'relative'}}
onKeyDown={e => {
if (e.metaKey && e.key === 'Enter') {
this.handleUpdateOwnership();
}
}}
>
<Panel>
<PanelHeader>
{t('Ownership Rules')}
{dateUpdated && (
<SyncDate>
{t('Last Edited')} <TimeSince date={dateUpdated} />
</SyncDate>
)}
</PanelHeader>
<PanelBody>
<StyledTextArea
aria-label={t('Ownership Rules')}
placeholder={
'#example usage\n' +
'path:src/example/pipeline/* [email protected] #infra\n' +
'module:com.module.name.example #sdks\n' +
'url:http://example.com/settings/* #product\n' +
'tags.sku_class:enterprise #enterprise'
}
monospace
onChange={this.handleChange}
disabled={disabled}
value={defined(text) ? text : initialText}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</PanelBody>
</Panel>
<ActionBar>
<div>{this.parseError(error)}</div>
<ButtonBar gap={1}>
<Button type="button" size="sm" onClick={this.props.onCancel}>
{t('Cancel')}
</Button>
<Button
size="sm"
priority="primary"
onClick={this.handleUpdateOwnership}
disabled={disabled || !hasChanges}
>
{t('Save')}
</Button>
</ButtonBar>
</ActionBar>
</div>
</Fragment>
);
}
}
const TEXTAREA_PADDING = 4;
const TEXTAREA_LINE_HEIGHT = 24;
const ActionBar = styled('div')`
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
`;
const SyntaxOverlay = styled('div')<{line: number}>`
position: absolute;
top: ${({line}) => TEXTAREA_PADDING + line * TEXTAREA_LINE_HEIGHT + 1}px;
width: 100%;
height: ${TEXTAREA_LINE_HEIGHT}px;
background-color: ${p => p.theme.error};
opacity: 0.1;
pointer-events: none;
`;
const StyledTextArea = styled(TextArea)`
min-height: 140px;
overflow: auto;
outline: 0;
width: 100%;
resize: none;
margin: 1px 0 0 0;
word-break: break-all;
white-space: pre-wrap;
padding-top: ${TEXTAREA_PADDING}px;
line-height: ${TEXTAREA_LINE_HEIGHT}px;
height: 450px;
border-width: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
`;
const InvalidOwners = styled('div')`
color: ${p => p.theme.error};
font-weight: bold;
margin-top: 12px;
`;
const SyncDate = styled('div')`
font-weight: normal;
text-transform: none;
`;
export default OwnerInput;