123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- 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';
- import RuleBuilder from './ruleBuilder';
- 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 && 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 {project, organization, disabled, urls, paths, initialText, dateUpdated} =
- this.props;
- const {hasChanges, text, error} = this.state;
- const hasStreamlineTargetingFeature = organization.features.includes(
- 'streamline-targeting-context'
- );
- return (
- <Fragment>
- {!hasStreamlineTargetingFeature && (
- <RuleBuilder
- urls={urls}
- paths={paths}
- organization={organization}
- project={project}
- onAddRule={this.handleAddRule.bind(this)}
- disabled={disabled}
- />
- )}
- <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/* person@sentry.io #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;
|