123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- import styled from '@emotion/styled';
- import {addSuccessMessage} from 'sentry/actionCreators/indicator';
- import type {ExternalIssueFormErrors} from 'sentry/components/externalIssues/abstractExternalIssueForm';
- import AbstractExternalIssueForm from 'sentry/components/externalIssues/abstractExternalIssueForm';
- import type {FormProps} from 'sentry/components/forms/form';
- import ExternalLink from 'sentry/components/links/externalLink';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {IssueAlertRuleAction} from 'sentry/types/alerts';
- import type {Choices} from 'sentry/types/core';
- import type {IssueConfigField} from 'sentry/types/integrations';
- import type {Organization} from 'sentry/types/organization';
- import type DeprecatedAsyncView from 'sentry/views/deprecatedAsyncView';
- const IGNORED_FIELDS = ['Sprint'];
- type Props = {
- // Comes from the in-code definition of a `TicketEventAction`.
- formFields: {[key: string]: any};
- index: number;
- // The AlertRuleAction from DB.
- instance: IssueAlertRuleAction;
- link: string | null;
- onSubmitAction: (
- data: {[key: string]: string},
- fetchedFieldOptionsCache: Record<string, Choices>
- ) => void;
- organization: Organization;
- ticketType: string;
- } & AbstractExternalIssueForm['props'];
- type State = {
- issueConfigFieldsCache: IssueConfigField[];
- } & AbstractExternalIssueForm['state'];
- class TicketRuleModal extends AbstractExternalIssueForm<Props, State> {
- getDefaultState(): State {
- const {instance} = this.props;
- const issueConfigFieldsCache = Object.values(instance?.dynamic_form_fields || {});
- return {
- ...super.getDefaultState(),
- // fetchedFieldOptionsCache should contain async fields so we
- // need to filter beforehand. Only async fields have a `url` property.
- fetchedFieldOptionsCache: Object.fromEntries(
- issueConfigFieldsCache
- .filter(field => field.url)
- .map(field => [field.name, field.choices as Choices])
- ),
- issueConfigFieldsCache,
- };
- }
- getEndpoints(): ReturnType<DeprecatedAsyncView['getEndpoints']> {
- const {instance} = this.props;
- const query = (instance.dynamic_form_fields || [])
- .filter(field => field.updatesForm)
- .filter(field => instance.hasOwnProperty(field.name))
- .reduce(
- (accumulator, {name}) => {
- accumulator[name] = instance[name];
- return accumulator;
- },
- {action: 'create'}
- );
- return [['integrationDetails', this.getEndPointString(), {query}]];
- }
- handleReceiveIntegrationDetails = (integrationDetails: any) => {
- this.setState({
- issueConfigFieldsCache: integrationDetails[this.getConfigName()],
- });
- };
- /**
- * Get a list of formFields names with valid config data.
- */
- getValidAndSavableFieldNames = (): string[] => {
- const {issueConfigFieldsCache} = this.state;
- return (issueConfigFieldsCache || [])
- .filter(field => field.hasOwnProperty('name'))
- .map(field => field.name);
- };
- getEndPointString(): string {
- const {instance, organization} = this.props;
- return `/organizations/${organization.slug}/integrations/${instance.integration}/?ignored=${IGNORED_FIELDS}`;
- }
- /**
- * Clean up the form data before saving it to state.
- */
- cleanData = (data: {
- [key: string]: string;
- }): {
- [key: string]: any;
- integration?: string | number;
- } => {
- const {instance} = this.props;
- const {issueConfigFieldsCache} = this.state;
- const names: string[] = this.getValidAndSavableFieldNames();
- const formData: {
- [key: string]: any;
- integration?: string | number;
- } = {};
- if (instance?.hasOwnProperty('integration')) {
- formData.integration = instance.integration;
- }
- formData.dynamic_form_fields = issueConfigFieldsCache;
- for (const [key, value] of Object.entries(data)) {
- if (names.includes(key)) {
- formData[key] = value;
- }
- }
- return formData;
- };
- onFormSubmit: FormProps['onSubmit'] = (data, _success, _error, e, model) => {
- const {onSubmitAction, closeModal} = this.props;
- const {fetchedFieldOptionsCache} = this.state;
- // This is a "fake form", so don't actually POST to an endpoint.
- e.preventDefault();
- e.stopPropagation();
- if (model.validateForm()) {
- onSubmitAction(this.cleanData(data), fetchedFieldOptionsCache);
- addSuccessMessage(t('Changes applied.'));
- closeModal();
- }
- };
- getFormProps = (): FormProps => {
- const {closeModal} = this.props;
- return {
- ...this.getDefaultFormProps(),
- cancelLabel: t('Close'),
- onCancel: closeModal,
- onSubmit: this.onFormSubmit,
- submitLabel: t('Apply Changes'),
- };
- };
- /**
- * Set the initial data from the Rule, replace `title` and `description` with
- * disabled inputs, and use the cached dynamic choices.
- */
- cleanFields = (): IssueConfigField[] => {
- const {instance} = this.props;
- const fields: IssueConfigField[] = [
- {
- name: 'title',
- label: 'Title',
- type: 'string',
- default: 'This will be the same as the Sentry Issue.',
- disabled: true,
- } as IssueConfigField,
- {
- name: 'description',
- label: 'Description',
- type: 'string',
- default: 'This will be generated from the Sentry Issue details.',
- disabled: true,
- } as IssueConfigField,
- ];
- const cleanedFields = this.loadAsyncThenFetchAllFields()
- // Don't overwrite the default values for title and description.
- .filter(field => !fields.map(f => f.name).includes(field.name))
- .map(field => {
- // Overwrite defaults with previously selected values if they exist.
- // Certain fields such as priority (for Jira) have their options change
- // because they depend on another field such as Project, so we need to
- // check if the last selected value is in the list of available field choices.
- const prevChoice = instance?.[field.name];
- // Note that field.choices is an array of tuples, where each tuple
- // contains a numeric id and string label, eg. ("10000", "EX") or ("1", "Bug")
- if (!prevChoice) {
- return field;
- }
- let shouldDefaultChoice = true;
- if (field.choices) {
- shouldDefaultChoice = !!(Array.isArray(prevChoice)
- ? prevChoice.every(value => field.choices?.some(tuple => tuple[0] === value))
- : // Single-select fields have a single value, eg: 'a'
- field.choices?.some(item => item[0] === prevChoice));
- }
- if (shouldDefaultChoice) {
- field.default = prevChoice;
- }
- return field;
- });
- return [...fields, ...cleanedFields];
- };
- getErrors() {
- const errors: ExternalIssueFormErrors = {};
- for (const field of this.cleanFields()) {
- // If the field is a select and has a default value, make sure that the
- // default value exists in the choices. Skip check if the default is not
- // set, an empty string, or an empty array.
- if (
- field.type === 'select' &&
- field.default &&
- !(Array.isArray(field.default) && !field.default.length)
- ) {
- const fieldChoices = (field.choices || []) as Choices;
- const found = fieldChoices.find(([value, _]) =>
- Array.isArray(field.default)
- ? field.default.includes(value)
- : value === field.default
- );
- if (!found) {
- errors[field.name] = (
- <FieldErrorLabel>{`Could not fetch saved option for ${field.label}. Please reselect.`}</FieldErrorLabel>
- );
- }
- }
- }
- return errors;
- }
- renderBodyText = () => {
- // `ticketType` already includes indefinite article.
- const {ticketType, link} = this.props;
- let body: React.ReactNode;
- if (link) {
- body = tct(
- 'When this alert is triggered [ticketType] will be created with the following fields. It will also [linkToDocs:stay in sync] with the new Sentry Issue.',
- {
- linkToDocs: <ExternalLink href={link} />,
- ticketType,
- }
- );
- } else {
- body = tct(
- 'When this alert is triggered [ticketType] will be created with the following fields.',
- {
- ticketType,
- }
- );
- }
- return <BodyText>{body}</BodyText>;
- };
- render() {
- return this.renderForm(this.cleanFields(), this.getErrors());
- }
- }
- const BodyText = styled('div')`
- margin-bottom: ${space(3)};
- `;
- const FieldErrorLabel = styled('label')`
- padding-bottom: ${space(2)};
- color: ${p => p.theme.errorText};
- `;
- export default TicketRuleModal;
|