Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions static/app/views/settings/project/projectOwnership/modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,6 @@ function ProjectOwnershipModal({
organization={organization}
project={project}
initialText={ownership?.raw || ''}
urls={urls}
paths={paths}
dateUpdated={ownership.lastUpdated}
onCancel={onCancel}
page="issue_details"
Expand Down
256 changes: 101 additions & 155 deletions static/app/views/settings/project/projectOwnership/ownerInput.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component, Fragment} from 'react';
import {Fragment, useState} from 'react';
import styled from '@emotion/styled';

import {Button} from '@sentry/scraps/button';
Expand All @@ -12,20 +12,12 @@ 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 {IssueOwnership} from 'sentry/types/group';
import type {Organization, Team} from 'sentry/types/organization';
import type {Organization} from 'sentry/types/organization';
import type {Project} from 'sentry/types/project';
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;
Expand All @@ -36,44 +28,40 @@ type Props = {
*/
page: 'issue_details' | 'project_settings';
project: Project;
disabled?: boolean;
onSave?: (ownership: IssueOwnership) => 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,
};
type InputError = {raw: string[]};

parseError(error: State['error']) {
const text = error?.raw?.[0];
if (!text) {
return null;
}
function parseError(error: InputError | null) {
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} />
);
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});
function OwnerInput({
dateUpdated,
disabled = false,
initialText,
onCancel,
onSave,
organization,
page,
project,
}: Props) {
const [hasChanges, setHasChanges] = useState(false);
const [text, setText] = useState<string | null>(null);
const [error, setError] = useState<InputError | null>(null);

const handleUpdateOwnership = () => {
setError(null);

const api = new Client();
const request = api.requestPromise(
Comment on lines 66 to 67
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we convert this to using useMutation with fetchMutation as a fn. Example:

mutationFn: (data: AutofixAutomationUpdate) => {
return fetchMutation({
method: 'POST',
url: getApiUrl(
`/organizations/$organizationIdOrSlug/autofix/automation-settings/`,
{
path: {organizationIdOrSlug: organization.slug},
}
),
data,
});
},

Expand All @@ -87,13 +75,9 @@ class OwnerInput extends Component<Props, State> {
request
.then(ownership => {
addSuccessMessage(t('Updated issue ownership rules'));
this.setState(
{
hasChanges: false,
text,
},
() => onSave?.(ownership)
);
setHasChanges(false);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when we use a mutation, I feel like this becomes derived state by comparing the mutation data with the stored text:

const hasChanges = mutation.data !== text

setText(text);
onSave?.(ownership);
trackIntegrationAnalytics('project_ownership.saved', {
page,
organization,
Expand All @@ -102,22 +86,22 @@ class OwnerInput extends Component<Props, State> {
initialText.split('\n').filter(x => x).length,
});
})
.catch(error => {
this.setState({error: error.responseJSON});
if (error.status === 403) {
.catch(caught => {
setError(caught.responseJSON);
if (caught.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?.[0].startsWith('Invalid rule owners:')
caught.status === 400 &&
caught.responseJSON.raw?.[0].startsWith('Invalid rule owners:')
) {
addErrorMessage(
t(
'Unable to save issue ownership rule changes: %s',
error.responseJSON.raw[0]
caught.responseJSON.raw[0]
)
);
} else {
Expand All @@ -128,109 +112,71 @@ class OwnerInput extends Component<Props, State> {
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
);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setHasChanges(true);
setText(e.target.value);
};

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/* 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>
<Grid flow="column" align="center" gap="md">
<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>
</Grid>
</ActionBar>
</div>
</Fragment>
);
}
return (
<Fragment>
<div
style={{position: 'relative'}}
onKeyDown={e => {
if (e.metaKey && e.key === 'Enter') {
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={handleChange}
disabled={disabled}
value={defined(text) ? text : initialText}
spellCheck="false"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
</PanelBody>
</Panel>
<ActionBar>
<div>{parseError(error)}</div>
<Grid flow="column" align="center" gap="md">
<Button type="button" size="sm" onClick={onCancel}>
{t('Cancel')}
</Button>
<Button
size="sm"
priority="primary"
onClick={handleUpdateOwnership}
disabled={disabled || !hasChanges}
>
{t('Save')}
</Button>
</Grid>
</ActionBar>
</div>
</Fragment>
);
}

const TEXTAREA_PADDING = 4;
Expand Down
Loading