Browse Source

feat(workflow): Replace `window.confirm` with `<Confirm>` (#6197)

* feat(react): Add a generic `<Confirm>` modal

* feat(workflow): Remove `window.confirm`, replace with `<Confirm>`

* build(storybook): upgrade addons (fixes actions)
Billy Vong 7 years ago
parent
commit
a121bab31b

+ 1 - 1
.eslintrc

@@ -68,7 +68,7 @@
     ], // http://eslint.org/docs/rules/no-cond-assign
     "no-console": 1, // http://eslint.org/docs/rules/no-console
     "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger
-    "no-alert": 1, // http://eslint.org/docs/rules/no-alert
+    "no-alert": 2, // http://eslint.org/docs/rules/no-alert
     "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition
     "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys
     "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case

+ 25 - 0
docs-ui/components/confirm.stories.js

@@ -0,0 +1,25 @@
+import React from 'react';
+import {storiesOf} from '@storybook/react';
+import {withInfo} from '@storybook/addon-info';
+import {action} from '@storybook/addon-actions';
+
+import Confirm from 'sentry-ui/confirm';
+import Button from 'sentry-ui/buttons/button';
+
+storiesOf('Confirm/Confirm', module).add(
+  'default',
+  withInfo({
+    text: 'Component whose child is rendered as the "action" component that when clicked opens the "Confirm Modal"',
+    propTablesExclude: [Button]
+  })(() => (
+    <div>
+      <Confirm
+        onConfirm={action('confirmed')}
+        message="Are you sure you want to do this?">
+        <Button priority="primary">
+          Confirm on Button click
+        </Button>
+      </Confirm>
+    </div>
+  ))
+);

+ 15 - 2
docs-ui/components/linkWithConfirmation.stories.js

@@ -5,10 +5,23 @@ import {withInfo} from '@storybook/addon-info';
 
 import LinkWithConfirmation from 'sentry-ui/linkWithConfirmation';
 
-// eslint-disable-next-line
 storiesOf('Links/LinkWithConfirmation', module).add(
   'default',
-  withInfo('A link that opens a confirmation modal.')(() => (
+  withInfo('A link (<a>) that opens a confirmation modal.')(() => (
+    <div>
+      <LinkWithConfirmation
+        message="Message"
+        title="Titlte"
+        onConfirm={action('confirmed')}>
+        Link With Confirmation
+      </LinkWithConfirmation>
+    </div>
+  ))
+);
+
+storiesOf('Confirm/LinkWithConfirmation', module).add(
+  'default',
+  withInfo('A link (<a>) that opens a confirmation modal.')(() => (
     <div>
       <LinkWithConfirmation
         message="Message"

+ 4 - 4
package.json

@@ -110,10 +110,10 @@
   },
   "devDependencies": {
     "@percy-io/react-percy-storybook": "^1.0.2",
-    "@storybook/addon-actions": "^3.2.0",
-    "@storybook/addon-info": "^3.2.0",
-    "@storybook/addon-knobs": "^3.2.0",
-    "@storybook/react": "^3.2.0",
+    "@storybook/addon-actions": "^3.2.11",
+    "@storybook/addon-info": "^3.2.11",
+    "@storybook/addon-knobs": "^3.2.10",
+    "@storybook/react": "^3.2.11",
     "babel-eslint": "7.1.1",
     "babel-jest": "^19.0.0",
     "chai": "3.4.1",

+ 93 - 0
src/sentry/static/sentry/app/components/confirm.jsx

@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Modal from 'react-bootstrap/lib/Modal';
+
+import Button from './buttons/button';
+
+class Confirm extends React.PureComponent {
+  static propTypes = {
+    disabled: PropTypes.bool,
+    message: PropTypes.string.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    priority: PropTypes.oneOf(['primary', 'danger']).isRequired,
+    confirmText: PropTypes.string.isRequired,
+    cancelText: PropTypes.string.isRequired
+  };
+
+  static defaultProps = {
+    priority: 'primary',
+    cancelText: 'Cancel',
+    confirmText: 'Confirm'
+  };
+
+  constructor(...args) {
+    super(...args);
+
+    this.state = {
+      isModalOpen: false,
+      disableConfirmButton: false
+    };
+    this.confirming = false;
+  }
+
+  handleConfirm = () => {
+    // `confirming` is used to make sure `onConfirm` is only called once
+    if (!this.confirming) {
+      this.props.onConfirm();
+    }
+
+    // Close modal
+    this.setState({
+      isModalOpen: false,
+      disableConfirmButton: true
+    });
+    this.confirming = true;
+  };
+
+  handleToggle = () => {
+    if (this.props.disabled) return;
+
+    // Toggle modal display state
+    // Also always reset `confirming` when modal visibility changes
+    this.setState(state => ({
+      isModalOpen: !state.isModalOpen,
+      disableConfirmButton: false
+    }));
+    this.confirming = false;
+  };
+
+  render() {
+    let {disabled, message, priority, confirmText, cancelText, children} = this.props;
+
+    const ConfirmModal = (
+      <Modal show={this.state.isModalOpen} animation={false} onHide={this.handleToggle}>
+        <div className="modal-body">
+          <p><strong>{message}</strong></p>
+        </div>
+        <div className="modal-footer">
+          <Button style={{marginRight: 10}} onClick={this.handleToggle}>
+            {cancelText}
+          </Button>
+          <Button
+            disabled={this.state.disableConfirmButton}
+            priority={priority}
+            onClick={this.handleConfirm}>
+            {confirmText}
+          </Button>
+        </div>
+      </Modal>
+    );
+
+    return (
+      <span>
+        {React.cloneElement(children, {
+          disabled,
+          onClick: this.handleToggle
+        })}
+        {ConfirmModal}
+      </span>
+    );
+  }
+}
+
+export default Confirm;

+ 24 - 55
src/sentry/static/sentry/app/components/linkWithConfirmation.jsx

@@ -1,74 +1,43 @@
-import PropTypes from 'prop-types';
 import React from 'react';
-import Modal from 'react-bootstrap/lib/Modal';
-import PureRenderMixin from 'react-addons-pure-render-mixin';
-import {t} from '../locale';
+import PropTypes from 'prop-types';
+
+import Confirm from './confirm';
 
-const LinkWithConfirmation = React.createClass({
-  propTypes: {
+/**
+ * <Confirm> is a more generic version of this component
+ */
+class LinkWithConfirmation extends React.PureComponent {
+  static propTypes = {
     disabled: PropTypes.bool,
     message: PropTypes.string.isRequired,
     title: PropTypes.string.isRequired,
     onConfirm: PropTypes.func.isRequired
-  },
+  };
 
-  mixins: [PureRenderMixin],
-
-  getInitialState() {
-    return {
+  constructor(...args) {
+    super(...args);
+    this.state = {
       isModalOpen: false
     };
-  },
-
-  onConfirm() {
-    this.setState({
-      isModalOpen: false
-    });
-
-    this.props.onConfirm();
-  },
-
-  onToggle() {
-    if (this.props.disabled) {
-      return;
-    }
-    this.setState({
-      isModalOpen: !this.state.isModalOpen
-    });
-  },
+  }
 
   render() {
-    let className = this.props.className;
+    let {className, disabled, title, children, ...otherProps} = this.props;
     if (this.props.disabled) {
       className += ' disabled';
     }
     return (
-      <a
-        className={className}
-        disabled={this.props.disabled}
-        onClick={this.onToggle}
-        title={this.props.title}>
-        {this.props.children}
-        <Modal
-          show={this.state.isModalOpen}
-          title={t('Please confirm')}
-          animation={false}
-          onHide={this.onToggle}>
-          <div className="modal-body">
-            <p><strong>{this.props.message}</strong></p>
-          </div>
-          <div className="modal-footer">
-            <button type="button" className="btn btn-default" onClick={this.onToggle}>
-              {t('Cancel')}
-            </button>
-            <button type="button" className="btn btn-primary" onClick={this.onConfirm}>
-              {t('Confirm')}
-            </button>
-          </div>
-        </Modal>
-      </a>
+      <Confirm {...otherProps} disabled={disabled}>
+        <a
+          className={className}
+          disabled={disabled}
+          onClick={this.onToggle}
+          title={title}>
+          {children}
+        </a>
+      </Confirm>
     );
   }
-});
+}
 
 export default LinkWithConfirmation;

+ 13 - 11
src/sentry/static/sentry/app/views/organizationIntegrations.jsx

@@ -1,12 +1,13 @@
 import React from 'react';
 
+import {sortArray} from '../utils';
+import {t} from '../locale';
 import AsyncView from './asyncView';
+import Confirm from '../components/confirm';
 import DropdownLink from '../components/dropdownLink';
 import IndicatorStore from '../stores/indicatorStore';
 import MenuItem from '../components/menuItem';
 import OrganizationHomeContainer from '../components/organizations/homeContainer';
-import {t} from '../locale';
-import {sortArray} from '../utils';
 
 export default class OrganizationIntegrations extends AsyncView {
   componentDidMount() {
@@ -54,9 +55,6 @@ export default class OrganizationIntegrations extends AsyncView {
   }
 
   deleteIntegration = integration => {
-    // eslint-disable-next-line no-alert
-    if (!confirm(t('Are you sure you want to remove this integration?'))) return;
-
     let indicator = IndicatorStore.add(t('Saving changes..'));
     this.api.request(
       `/organizations/${this.props.params.orgId}/integrations/${integration.id}/`,
@@ -144,7 +142,7 @@ export default class OrganizationIntegrations extends AsyncView {
             {this.state.config.providers.map(provider => {
               return (
                 <MenuItem noAnchor={true} key={provider.id}>
-                  <a onClick={this.launchAddIntegration.bind(this, provider)}>
+                  <a onClick={() => this.launchAddIntegration(provider)}>
                     {provider.name}
                   </a>
                 </MenuItem>
@@ -176,11 +174,15 @@ export default class OrganizationIntegrations extends AsyncView {
                           </small>
                         </td>
                         <td style={{width: 60}}>
-                          <button
-                            onClick={this.deleteIntegration.bind(this, integration)}
-                            className="btn btn-default btn-xs">
-                            <span className="icon icon-trash" />
-                          </button>
+                          <Confirm
+                            message={t(
+                              'Are you sure you want to remove this integration?'
+                            )}
+                            onConfirm={() => this.deleteIntegration(integration)}>
+                            <button className="btn btn-default btn-xs">
+                              <span className="icon icon-trash" />
+                            </button>
+                          </Confirm>
                         </td>
                       </tr>
                     );

+ 16 - 17
src/sentry/static/sentry/app/views/organizationRepositories.jsx

@@ -5,6 +5,8 @@ import React from 'react';
 import {FormState} from '../components/forms';
 import {sortArray, parseRepo} from '../utils';
 import {t, tct} from '../locale';
+import Button from '../components/buttons/button';
+import Confirm from '../components/confirm';
 import DropdownLink from '../components/dropdownLink';
 import IndicatorStore from '../stores/indicatorStore';
 import MenuItem from '../components/menuItem';
@@ -223,9 +225,6 @@ class OrganizationRepositories extends OrganizationSettingsView {
   }
 
   deleteRepo = repo => {
-    // eslint-disable-next-line no-alert
-    if (!confirm(t('Are you sure you want to remove this repository?'))) return;
-
     let indicator = IndicatorStore.add(t('Saving changes..'));
     this.api.request(`/organizations/${this.props.params.orgId}/repos/${repo.id}/`, {
       method: 'DELETE',
@@ -349,20 +348,22 @@ class OrganizationRepositories extends OrganizationSettingsView {
               <table className="table">
                 <tbody>
                   {itemList.map(repo => {
+                    let repoIsVisible = repo.status === 'visible';
+
                     return (
                       <tr key={repo.id}>
                         <td>
                           <strong>
                             {repo.name}
                           </strong>
-                          {repo.status !== 'visible' &&
+                          {!repoIsVisible &&
                             <small>
                               {' '}— {this.getStatusLabel(repo)}
                             </small>}
                           {repo.status === 'pending_deletion' &&
                             <small>
                               {' '}(
-                              <a onClick={this.cancelDelete.bind(this, repo)}>
+                              <a onClick={() => this.cancelDelete(repo)}>
                                 {t('Cancel')}
                               </a>
                               )
@@ -377,18 +378,16 @@ class OrganizationRepositories extends OrganizationSettingsView {
                             </small>}
                         </td>
                         <td style={{width: 60}}>
-                          {repo.status === 'visible'
-                            ? <button
-                                onClick={this.deleteRepo.bind(this, repo)}
-                                className="btn btn-default btn-xs">
-                                <span className="icon icon-trash" />
-                              </button>
-                            : <button
-                                onClick={this.deleteRepo.bind(this, repo)}
-                                disabled={true}
-                                className="btn btn-default btn-xs btn-disabled">
-                                <span className="icon icon-trash" />
-                              </button>}
+                          <Confirm
+                            disabled={!repoIsVisible}
+                            onConfirm={() => this.deleteRepo(repo)}
+                            message={t(
+                              'Are you sure you want to remove this repository?'
+                            )}>
+                            <Button size="xsmall">
+                              <span className="icon icon-trash" />
+                            </Button>
+                          </Confirm>
                         </td>
                       </tr>
                     );

+ 26 - 14
src/sentry/static/sentry/app/views/projectAlertRules.jsx

@@ -1,13 +1,16 @@
 import PropTypes from 'prop-types';
 import React from 'react';
 
+import {t} from '../locale';
 import ApiMixin from '../mixins/apiMixin';
+import Button from '../components/buttons/button';
+import Confirm from '../components/confirm';
 import Duration from '../components/duration';
 import IndicatorStore from '../stores/indicatorStore';
 import ListLink from '../components/listLink';
 import LoadingError from '../components/loadingError';
 import LoadingIndicator from '../components/loadingIndicator';
-import {t} from '../locale';
+import SpreadLayout from '../components/spreadLayout';
 
 const RuleRow = React.createClass({
   propTypes: {
@@ -27,8 +30,6 @@ const RuleRow = React.createClass({
   },
 
   onDelete() {
-    /* eslint no-alert:0*/
-    if (!confirm('Are you sure you want to remove this rule?')) return;
     if (this.state.loading) return;
 
     let loadingIndicator = IndicatorStore.add(t('Saving changes..'));
@@ -57,10 +58,17 @@ const RuleRow = React.createClass({
       <div className="box">
         <div className="box-header">
           <div className="pull-right">
-            <a className="btn btn-sm btn-default" href={editLink}>{t('Edit Rule')}</a>
-            <a className="btn btn-sm btn-default" onClick={this.onDelete}>
-              <span className="icon-trash" style={{marginRight: 3}} />
-            </a>
+            <Button style={{marginRight: 5}} size="small" href={editLink}>
+              {t('Edit Rule')}
+            </Button>
+
+            <Confirm
+              message={t('Are you sure you want to remove this rule?')}
+              onConfirm={this.onDelete}>
+              <Button size="small">
+                <span className="icon-trash" />
+              </Button>
+            </Confirm>
           </div>
           <h3><a href={editLink}>{data.name}</a></h3>
         </div>
@@ -202,13 +210,17 @@ const ProjectAlertRules = React.createClass({
     let {orgId, projectId} = this.props.params;
     return (
       <div>
-        <a
-          href={`/${orgId}/${projectId}/settings/alerts/rules/new/`}
-          className="btn pull-right btn-primary btn-sm">
-          <span className="icon-plus" />
-          {t('New Alert Rule')}
-        </a>
-        <h2>{t('Alerts')}</h2>
+        <SpreadLayout style={{marginBottom: 20}}>
+          <h2 style={{margin: 0}}>{t('Alerts')}</h2>
+          <Button
+            href={`/${orgId}/${projectId}/settings/alerts/rules/new/`}
+            priority="primary"
+            size="small"
+            className="pull-right">
+            <span className="icon-plus" />
+            {t('New Alert Rule')}
+          </Button>
+        </SpreadLayout>
 
         <ul className="nav nav-tabs" style={{borderBottom: '1px solid #ddd'}}>
           <ListLink to={`/${orgId}/${projectId}/settings/alerts/`} index={true}>

+ 13 - 7
src/sentry/static/sentry/app/views/projectAlertSettings.jsx

@@ -2,10 +2,12 @@ import PropTypes from 'prop-types';
 import React from 'react';
 
 import AsyncView from './asyncView';
+import Button from '../components/buttons/button';
 import ListLink from '../components/listLink';
 import PluginList from '../components/pluginList';
 import {ApiForm, RangeField, TextField} from '../components/forms';
 import {t, tct} from '../locale';
+import SpreadLayout from '../components/spreadLayout';
 
 class DigestSettings extends React.Component {
   static propTypes = {
@@ -180,13 +182,17 @@ export default class ProjectAlertSettings extends AsyncView {
     let {organization} = this.props;
     return (
       <div>
-        <a
-          href={`/${orgId}/${projectId}/settings/alerts/rules/new/`}
-          className="btn pull-right btn-primary btn-sm">
-          <span className="icon-plus" />
-          {t('New Alert Rule')}
-        </a>
-        <h2>{t('Alerts')}</h2>
+        <SpreadLayout style={{marginBottom: 20}}>
+          <h2 style={{margin: 0}}>{t('Alerts')}</h2>
+          <Button
+            href={`/${orgId}/${projectId}/settings/alerts/rules/new/`}
+            priority="primary"
+            size="small"
+            className="pull-right">
+            <span className="icon-plus" />
+            {t('New Alert Rule')}
+          </Button>
+        </SpreadLayout>
 
         <ul className="nav nav-tabs" style={{borderBottom: '1px solid #ddd'}}>
           <ListLink to={`/${orgId}/${projectId}/settings/alerts/`} index={true}>

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