Browse Source

feat(app-platform): publish request modal (#14564)

Stephen Cefali 5 years ago
parent
commit
b4601ca0fb

+ 7 - 7
src/sentry/api/endpoints/sentry_app_publish_request.py

@@ -16,19 +16,19 @@ class SentryAppPublishRequestEndpoint(SentryAppBaseEndpoint):
         if sentry_app.is_internal:
             return Response({"detail": "Cannot publish internal integration"}, status=400)
 
-        # For now, just send an email that a request to publish has been amde
-        message = "User %s of organization %s wants to publish %s" % (
+        message = "User %s of organization %s wants to publish %s\n" % (
             request.user.email,
             sentry_app.owner.slug,
             sentry_app.slug,
         )
 
+        for question_pair in request.data.get("questionnaire"):
+            message += "\n\n>%s\n%s" % (question_pair["question"], question_pair["answer"])
+
+        subject = "Sentry Integration Publication Request from %s" % sentry_app.owner.slug
+
         email.send_mail(
-            "Sentry App Publication Request",
-            message,
-            options.get("mail.from"),
-            ["partners@sentry.io"],
-            fail_silently=False,
+            subject, message, options.get("mail.from"), ["partners@sentry.io"], fail_silently=False
         )
 
         return Response(status=201)

+ 2 - 6
src/sentry/static/sentry/app/actionCreators/modal.jsx

@@ -132,9 +132,7 @@ export function openIntegrationDetails(options = {}) {
   import(/* webpackChunkName: "IntegrationDetailsModal" */ 'app/components/modals/integrationDetailsModal')
     .then(mod => mod.default)
     .then(Modal => {
-      openModal(deps => <Modal {...deps} {...options} />, {
-        modalClassName: 'integration-details',
-      });
+      openModal(deps => <Modal {...deps} {...options} />);
     });
 }
 
@@ -160,9 +158,7 @@ export function openSentryAppDetailsModal(options = {}) {
   import(/* webpackChunkName: "SentryAppDetailsModal" */ 'app/components/modals/sentryAppDetailsModal')
     .then(mod => mod.default)
     .then(Modal => {
-      openModal(deps => <Modal {...deps} {...options} />, {
-        modalClassName: 'sentry-app-details',
-      });
+      openModal(deps => <Modal {...deps} {...options} />);
     });
 }
 

+ 0 - 20
src/sentry/static/sentry/app/actionCreators/sentryApps.jsx

@@ -28,23 +28,3 @@ export function removeSentryApp(client, app) {
   );
   return promise;
 }
-
-/**
- * Request a Sentry Application to be published
- *
- * @param {Object} client ApiClient
- * @param {Object} app SentryApp
- */
-export async function publishRequestSentryApp(client, app) {
-  addLoadingMessage();
-  try {
-    await client.requestPromise(`/sentry-apps/${app.slug}/publish-request/`, {
-      method: 'POST',
-    });
-    addSuccessMessage(t('Request to publish %s successful.', app.slug));
-  } catch (err) {
-    clearIndicators();
-    addErrorMessage(t('Request to publish %s fails.', app.slug));
-    throw err;
-  }
-}

+ 1 - 1
src/sentry/static/sentry/app/api.tsx

@@ -85,7 +85,7 @@ export function paramsToQueryArgs(params: ParamsType): QueryArgs {
 }
 
 // TODO: move this somewhere
-type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT';
+export type APIRequestMethod = 'POST' | 'GET' | 'DELETE' | 'PUT';
 
 type FunctionCallback<Args extends any[] = any[]> = (...args: Args) => void;
 

+ 1 - 0
src/sentry/static/sentry/app/components/forms/formField.jsx

@@ -34,6 +34,7 @@ export default class FormField extends React.PureComponent {
     onChange: PropTypes.func,
     error: PropTypes.string, // eslint-disable-line react/no-unused-prop-types
     value: PropTypes.any,
+    meta: PropTypes.any, // eslint-disable-line react/no-unused-prop-types
   };
 
   static defaultProps = {

+ 155 - 0
src/sentry/static/sentry/app/components/modals/sentryAppPublishRequestModal.tsx

@@ -0,0 +1,155 @@
+import {Body, Header} from 'react-bootstrap/lib/Modal';
+import styled from 'react-emotion';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {addErrorMessage, addSuccessMessage} from 'app/actionCreators/indicator';
+import _ from 'lodash';
+import {Location} from 'history';
+
+import {SentryApp} from 'app/types';
+import {t} from 'app/locale';
+import Form from 'app/views/settings/components/forms/form';
+import FormModel from 'app/views/settings/components/forms/model';
+import JsonForm from 'app/views/settings/components/forms/jsonForm';
+import space from 'app/styles/space';
+
+class PublishRequestFormModel extends FormModel {
+  getTransformedData() {
+    const data = this.getData();
+    //map object to list of questions
+    const questionnaire = Array.from(this.fieldDescriptor.values()).map(field => {
+      //we read the meta for the question that has a react node for the label
+      return {
+        question: field.meta || field.label,
+        answer: data[field.name],
+      };
+    });
+    return {questionnaire};
+  }
+}
+
+type Props = {
+  app: SentryApp;
+  location: Location;
+  closeModal: () => void;
+};
+
+export default class SentryAppPublishRequestModal extends React.Component<Props> {
+  static propTypes = {
+    app: PropTypes.object.isRequired,
+  };
+
+  form = new PublishRequestFormModel();
+
+  get formFields() {
+    const {app} = this.props;
+    //replace the : with a . so we can reserve the colon for the question
+    const scopes = app.scopes.map(scope => scope.replace(/:/, '-'));
+    const scopeQuestionBaseText =
+      'Please justify why you are requesting each of the following scopes: ';
+    const scopeQuestionPlainText = `${scopeQuestionBaseText}${scopes.join(', ')}.`;
+
+    const scopeLabel = (
+      <React.Fragment>
+        {scopeQuestionBaseText}
+        {scopes.map((scope, i) => (
+          <React.Fragment key={scope}>
+            {i > 0 && ', '} <code>{scope}</code>
+          </React.Fragment>
+        ))}
+        .
+      </React.Fragment>
+    );
+
+    //No translations since we need to be able to read this email :)
+    const baseFields = [
+      {
+        type: 'textarea',
+        required: true,
+        label: 'What does your integration do? Please be as detailed as possible.',
+        autosize: true,
+        rows: 1,
+        inline: false,
+      },
+      {
+        type: 'textarea',
+        required: true,
+        label: 'What value does it offer customers?',
+        autosize: true,
+        rows: 1,
+        inline: false,
+      },
+      {
+        type: 'textarea',
+        required: true,
+        label: scopeLabel,
+        autosize: true,
+        rows: 1,
+        inline: false,
+        meta: scopeQuestionPlainText,
+      },
+      {
+        type: 'textarea',
+        required: true,
+        label: 'Do you operate the web service your integration communicates with?',
+        autosize: true,
+        rows: 1,
+        inline: false,
+      },
+    ];
+    //dynamically generate the name based off the index
+    return baseFields.map((field, index) =>
+      Object.assign({name: `question${index}`}, field)
+    );
+  }
+
+  handleSubmitSuccess = () => {
+    addSuccessMessage(t('Request to publish %s successful.', this.props.app.slug));
+    this.props.closeModal();
+  };
+
+  handleSubmitError = () => {
+    addErrorMessage(t('Request to publish %s fails.', this.props.app.slug));
+  };
+
+  render() {
+    const {app} = this.props;
+    const endpoint = `/sentry-apps/${app.slug}/publish-request/`;
+    const forms = [
+      {
+        title: t('Questions to answer'),
+        fields: this.formFields,
+      },
+    ];
+    return (
+      <React.Fragment>
+        <Header>{t('Publish Request Questionnaire')}</Header>
+        <Body>
+          <Explanation>
+            {t(
+              `Please fill out this questionnaire in order to get your integration evaluated for publication.
+              Once your integration has been approved, users outside of your organization will be able to install it.`
+            )}
+          </Explanation>
+          <Form
+            allowUndo
+            apiMethod="POST"
+            apiEndpoint={endpoint}
+            onSubmitSuccess={this.handleSubmitSuccess}
+            onSubmitError={this.handleSubmitError}
+            model={this.form}
+            submitLabel={t('Request Publication')}
+            onCancel={() => this.props.closeModal()}
+          >
+            <JsonForm location={this.props.location} forms={forms} />
+          </Form>
+        </Body>
+      </React.Fragment>
+    );
+  }
+}
+
+const Explanation = styled('div')`
+  margin: ${space(1.5)} 0px;
+  font-size: 18px;
+`;

+ 28 - 0
src/sentry/static/sentry/app/types/index.tsx

@@ -335,3 +335,31 @@ export type Repository = {
   status: string;
   url: string;
 };
+
+export type WebhookEvents = 'issue' | 'error';
+
+export type SentryApp = {
+  status: string;
+  scopes: string[];
+  isAlertable: boolean;
+  verifyInstall: boolean;
+  slug: string;
+  name: string;
+  uuid: string;
+  author: string;
+  events: WebhookEvents[];
+  schema: {
+    elements?: object[]; //TODO(ts)
+  };
+  //possible null params
+  webhookUrl: string | null;
+  redirectUrl: string | null;
+  overview: string | null;
+  //optional params below
+  clientId?: string;
+  clientSecret?: string;
+  owner?: {
+    id: number;
+    slug: string;
+  };
+};

+ 59 - 22
src/sentry/static/sentry/app/views/settings/components/forms/model.jsx → src/sentry/static/sentry/app/views/settings/components/forms/model.tsx

@@ -1,17 +1,39 @@
-import {observable, computed, action} from 'mobx';
+import {observable, computed, action, ObservableMap} from 'mobx';
 import _ from 'lodash';
 
-import {Client} from 'app/api';
+import {Client, APIRequestMethod} from 'app/api';
 import {addErrorMessage, saveOnBlurUndoMessage} from 'app/actionCreators/indicator';
 import {defined} from 'app/utils';
 import {t} from 'app/locale';
 import FormState from 'app/components/forms/state';
 
+type Snapshot = Map<string, FieldValue>;
+type FieldValue = string | number | undefined; //is undefined valid here?
+type SaveSnapshot = (() => number) | null;
+
+type FormOptions = {
+  apiEndpoint?: string;
+  apiMethod?: APIRequestMethod;
+  allowUndo?: boolean;
+  resetOnError?: boolean;
+  saveOnBlur?: boolean;
+  onFieldChange?: (id: string, finalValue: FieldValue) => void;
+  onSubmitSuccess?: (
+    response: any,
+    instance: FormModel,
+    id?: string,
+    change?: {old: FieldValue; new: FieldValue}
+  ) => void;
+  onSubmitError?: (error: any, instance: FormModel, id?: string) => void;
+};
+
+type OptionsWithInitial = FormOptions & {initialData?: object};
+
 class FormModel {
   /**
    * Map of field name -> value
    */
-  @observable fields = new Map();
+  fields: ObservableMap<FieldValue> = observable.map();
 
   /**
    * Errors for individual fields
@@ -42,7 +64,7 @@ class FormModel {
   /**
    * Holds a list of `fields` states
    */
-  snapshots = [];
+  snapshots: Array<Snapshot> = [];
 
   /**
    * POJO of field name -> value
@@ -50,9 +72,14 @@ class FormModel {
    */
   initialData = {};
 
-  constructor({initialData, ...options} = {}) {
-    this.setFormOptions(options);
+  api: Client | null;
+
+  formErrors: any;
+
+  options: FormOptions;
 
+  constructor({initialData, ...options}: OptionsWithInitial = {}) {
+    this.options = options || {};
     if (initialData) {
       this.setInitialData(initialData);
     }
@@ -64,7 +91,7 @@ class FormModel {
    * Reset state of model
    */
   reset() {
-    this.api.clear();
+    this.api && this.api.clear();
     this.api = null;
     this.fieldDescriptor.clear();
     this.resetForm();
@@ -108,15 +135,11 @@ class FormModel {
    *
    * Also resets snapshots
    */
-  setInitialData(initialData, {noResetSnapshots} = {}) {
+  setInitialData(initialData) {
     this.fields.replace(initialData || {});
     this.initialData = this.fields.toJSON() || {};
 
-    if (noResetSnapshots) {
-      return;
-    }
-
-    this.snapshots = [new Map(this.fields)];
+    this.snapshots = [new Map(this.fields.entries())];
   }
 
   /**
@@ -166,7 +189,7 @@ class FormModel {
    * will save Map to `snapshots
    */
   createSnapshot() {
-    const snapshot = new Map(this.fields);
+    const snapshot = new Map(this.fields.entries());
     return () => this.snapshots.unshift(snapshot);
   }
 
@@ -245,11 +268,24 @@ class FormModel {
     return (this.getError(id) || []).length === 0;
   }
 
-  doApiRequest({apiEndpoint, apiMethod, data}) {
-    const endpoint = apiEndpoint || this.options.apiEndpoint;
+  doApiRequest({
+    apiEndpoint,
+    apiMethod,
+    data,
+  }: {
+    apiEndpoint?: string;
+    apiMethod?: APIRequestMethod;
+    data: object;
+  }) {
+    const endpoint = apiEndpoint || this.options.apiEndpoint || '';
     const method = apiMethod || this.options.apiMethod;
+
     return new Promise((resolve, reject) => {
-      this.api.request(endpoint, {
+      //should never happen but TS complains if we don't check
+      if (!this.api) {
+        return reject(new Error('Api not set'));
+      }
+      return this.api.request(endpoint, {
         method,
         data,
         success: response => resolve(response),
@@ -281,7 +317,7 @@ class FormModel {
   @action
   validateField(id) {
     const validate = this.getDescriptor(id, 'validate');
-    let errors = [];
+    let errors: any[] = [];
 
     if (typeof validate === 'function') {
       // Returns "tuples" of [id, error string]
@@ -298,6 +334,7 @@ class FormModel {
     errors = errors.length === 0 ? [[id, null]] : errors;
 
     errors.forEach(([field, errorMessage]) => this.setError(field, errorMessage));
+    return undefined;
   }
 
   @action
@@ -357,7 +394,7 @@ class FormModel {
     if (this.isError) {
       return null;
     }
-    let saveSnapshot = this.createSnapshot();
+    let saveSnapshot: SaveSnapshot = this.createSnapshot();
 
     const request = this.doApiRequest({
       data: this.getTransformedData(),
@@ -376,7 +413,7 @@ class FormModel {
           this.options.onSubmitSuccess(resp, this);
         }
       })
-      .catch((resp, ...args) => {
+      .catch(resp => {
         // should we revert field value to last known state?
         saveSnapshot = null;
         if (this.options.resetOnError) {
@@ -458,7 +495,7 @@ class FormModel {
     }
 
     // shallow clone fields
-    let saveSnapshot = this.createSnapshot();
+    let saveSnapshot: SaveSnapshot = this.createSnapshot();
 
     // Save field + value
     this.setSaving(id, true);
@@ -628,7 +665,7 @@ class FormModel {
   }
 
   @action
-  handleErrorResponse({responseJSON: resp} = {}) {
+  handleErrorResponse({responseJSON: resp}: {responseJSON?: any} = {}) {
     if (!resp) {
       return;
     }

+ 1 - 7
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/index.jsx

@@ -5,7 +5,7 @@ import AsyncView from 'app/views/asyncView';
 import Button from 'app/components/button';
 import EmptyMessage from 'app/views/settings/components/emptyMessage';
 import {Panel, PanelBody, PanelHeader} from 'app/components/panels';
-import {removeSentryApp, publishRequestSentryApp} from 'app/actionCreators/sentryApps';
+import {removeSentryApp} from 'app/actionCreators/sentryApps';
 import SentryTypes from 'app/sentryTypes';
 import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
 import SentryApplicationRow from 'app/views/settings/organizationDeveloperSettings/sentryApplicationRow';
@@ -33,11 +33,6 @@ class OrganizationDeveloperSettings extends AsyncView {
     );
   };
 
-  publishRequest = app => {
-    // TODO(scefali) May want to do some state change after the request to show that the publish request has been made
-    publishRequestSentryApp(this.api, app);
-  };
-
   renderApplicationRow = app => {
     const {organization} = this.props;
     return (
@@ -46,7 +41,6 @@ class OrganizationDeveloperSettings extends AsyncView {
         app={app}
         organization={organization}
         onRemoveApp={this.removeApp}
-        onPublishRequest={this.publishRequest}
         showInstallationStatus={false}
       />
     );

+ 12 - 19
src/sentry/static/sentry/app/views/settings/organizationDeveloperSettings/sentryApplicationRow.jsx

@@ -16,7 +16,8 @@ import space from 'app/styles/space';
 import {withTheme} from 'emotion-theming';
 import CircleIndicator from 'app/components/circleIndicator';
 import PluginIcon from 'app/plugins/components/pluginIcon';
-import {openSentryAppDetailsModal} from 'app/actionCreators/modal';
+import {openSentryAppDetailsModal, openModal} from 'app/actionCreators/modal';
+import SentryAppPublishRequestModal from 'app/components/modals/sentryAppPublishRequestModal';
 
 const INSTALLED = 'Installed';
 const NOT_INSTALLED = 'Not Installed';
@@ -30,7 +31,6 @@ export default class SentryApplicationRow extends React.PureComponent {
     onInstall: PropTypes.func,
     onUninstall: PropTypes.func,
     onRemoveApp: PropTypes.func,
-    onPublishRequest: PropTypes.func,
     showInstallationStatus: PropTypes.bool, //false if we are on the developer settings page where we don't show installation status
   };
 
@@ -105,23 +105,11 @@ export default class SentryApplicationRow extends React.PureComponent {
     );
   }
 
-  renderPublishRequest(app) {
-    const message = t(
-      `Sentry will evaluate your integration ${
-        app.slug
-      } and make it available to all users. \
-       Do you wish to continue?`
-    );
+  renderPublishRequest() {
     return (
-      <Confirm
-        message={message}
-        priority="primary"
-        onConfirm={() => this.props.onPublishRequest(app)}
-      >
-        <StyledButton icon="icon-upgrade" size="small">
-          {t('Publish')}
-        </StyledButton>
-      </Confirm>
+      <StyledButton icon="icon-upgrade" size="small" onClick={this.handlePublish}>
+        {t('Publish')}
+      </StyledButton>
     );
   }
 
@@ -174,6 +162,12 @@ export default class SentryApplicationRow extends React.PureComponent {
     return this.props.installs && this.props.installs.length > 0;
   }
 
+  handlePublish = () => {
+    const {app} = this.props;
+
+    openModal(deps => <SentryAppPublishRequestModal app={app} {...deps} />);
+  };
+
   get installationStatus() {
     if (this.props.installs && this.props.installs.length > 0) {
       return capitalize(this.props.installs[0].status);
@@ -248,7 +242,6 @@ export default class SentryApplicationRow extends React.PureComponent {
 
   render() {
     const {app, organization} = this.props;
-
     return (
       <SentryAppItem data-test-id={app.slug}>
         <StyledFlex>

Some files were not shown because too many files changed in this diff