Browse Source

feat(ui): More accurate bulk stream action counts (#8126)

Billy Vong 6 years ago
parent
commit
b24411948d

+ 3 - 0
src/sentry/static/sentry/app/__mocks__/api.jsx

@@ -130,5 +130,8 @@ class Client {
 
 Client.prototype.handleRequestError = RealClient.Client.prototype.handleRequestError;
 Client.prototype.uniqueId = RealClient.Client.prototype.uniqueId;
+Client.prototype.bulkUpdate = RealClient.Client.prototype.bulkUpdate;
+Client.prototype._chain = RealClient.Client.prototype._chain;
+Client.prototype._wrapRequest = RealClient.Client.prototype._wrapRequest;
 
 export {Client};

+ 82 - 42
src/sentry/static/sentry/app/views/stream/actions.jsx

@@ -20,22 +20,33 @@ import IgnoreActions from '../../components/actions/ignore';
 import ActionLink from '../../components/actions/actionLink';
 import Tooltip from '../../components/tooltip';
 
-const BULK_LIMIT_STR = '1,000';
+const BULK_LIMIT = 1000;
+const BULK_LIMIT_STR = BULK_LIMIT.toLocaleString();
+
+const getBulkConfirmMessage = (action, queryCount) => {
+  if (queryCount > BULK_LIMIT) {
+    return tct(
+      'Are you sure you want to [action] the first [bulkNumber] issues that match the search?',
+      {
+        action,
+        bulkNumber: BULK_LIMIT_STR,
+      }
+    );
+  }
 
-const getBulkConfirmMessage = action => {
   return tct(
-    'Are you sure you want to [action] the first [bulkNumber] issues that match the search?',
+    'Are you sure you want to [action] all [bulkNumber] issues that match the search?',
     {
       action,
-      bulkNumber: BULK_LIMIT_STR,
+      bulkNumber: queryCount,
     }
   );
 };
 
-const getConfirm = (numIssues, allInQuerySelected, query) => {
+const getConfirm = (numIssues, allInQuerySelected, query, queryCount) => {
   return function(action, canBeUndone, append = '') {
     let question = allInQuerySelected
-      ? getBulkConfirmMessage(`${action}${append}`)
+      ? getBulkConfirmMessage(`${action}${append}`, queryCount)
       : tn(
           `Are you sure you want to ${action} this %d issue${append}?`,
           `Are you sure you want to ${action} these %d issues${append}?`,
@@ -47,7 +58,11 @@ const getConfirm = (numIssues, allInQuerySelected, query) => {
         <p style={{marginBottom: '20px'}}>
           <strong>{question}</strong>
         </p>
-        <ExtraDescription all={allInQuerySelected} query={query} />
+        <ExtraDescription
+          all={allInQuerySelected}
+          query={query}
+          queryCount={queryCount}
+        />
         {!canBeUndone && <p>{t('This action cannot be undone.')}</p>}
       </div>
     );
@@ -69,7 +84,7 @@ const getLabel = (numIssues, allInQuerySelected) => {
   };
 };
 
-const ExtraDescription = ({all, query}) => {
+const ExtraDescription = ({all, query, queryCount}) => {
   if (!all) return null;
 
   if (query) {
@@ -83,12 +98,16 @@ const ExtraDescription = ({all, query}) => {
   return (
     <p className="error">
       <strong>
-        {tct(
-          'This will apply to the first [bulkNumber] issues matched in this project!',
-          {
-            bulkNumber: BULK_LIMIT_STR,
-          }
-        )}
+        {queryCount > BULK_LIMIT
+          ? tct(
+              'This will apply to the first [bulkNumber] issues matched in this project!',
+              {
+                bulkNumber: BULK_LIMIT_STR,
+              }
+            )
+          : tct('This will apply to all [bulkNumber] issues matched in this project!', {
+              bulkNumber: queryCount,
+            })}
       </strong>
     </p>
   );
@@ -97,6 +116,7 @@ const ExtraDescription = ({all, query}) => {
 ExtraDescription.propTypes = {
   all: PropTypes.bool,
   query: PropTypes.string,
+  queryCount: PropTypes.number,
 };
 
 const StreamActions = createReactClass({
@@ -112,6 +132,7 @@ const StreamActions = createReactClass({
     realtimeActive: PropTypes.bool.isRequired,
     statsPeriod: PropTypes.string.isRequired,
     query: PropTypes.string.isRequired,
+    queryCount: PropTypes.number,
     hasReleases: PropTypes.bool,
     latestRelease: PropTypes.object,
   },
@@ -261,24 +282,35 @@ const StreamActions = createReactClass({
 
   render() {
     // TODO(mitsuhiko): very unclear how to translate this
+    let {
+      allResultsVisible,
+      hasReleases,
+      latestRelease,
+      orgId,
+      projectId,
+      queryCount,
+      query,
+      realtimeActive,
+      statsPeriod,
+    } = this.props;
     let issues = this.state.selectedIds;
     let numIssues = issues.size;
-    let {allInQuerySelected, anySelected, multiSelected} = this.state;
-    let confirm = getConfirm(numIssues, allInQuerySelected, this.props.query);
+    let {allInQuerySelected, anySelected, multiSelected, pageSelected} = this.state;
+    let confirm = getConfirm(numIssues, allInQuerySelected, query, queryCount);
     let label = getLabel(numIssues, allInQuerySelected);
 
     return (
       <Sticky>
         <StyledFlex py={1}>
           <ActionsCheckbox pl={2}>
-            <Checkbox onChange={this.onSelectAll} checked={this.state.pageSelected} />
+            <Checkbox onChange={this.onSelectAll} checked={pageSelected} />
           </ActionsCheckbox>
           <ActionSet w={[8 / 12, 8 / 12, 6 / 12]} mx={1} flex="1">
             <ResolveActions
-              hasRelease={this.props.hasReleases}
-              latestRelease={this.props.latestRelease}
-              orgId={this.props.orgId}
-              projectId={this.props.projectId}
+              hasRelease={hasReleases}
+              latestRelease={latestRelease}
+              orgId={orgId}
+              projectId={projectId}
               onUpdate={this.onUpdate}
               shouldConfirm={this.shouldConfirm('resolve')}
               confirmMessage={confirm('resolve', true)}
@@ -355,12 +387,12 @@ const StreamActions = createReactClass({
                 <MenuItem noAnchor={true}>
                   <ActionLink
                     className="action-delete"
-                    disabled={!anySelected || this.state.allInQuerySelected}
+                    disabled={!anySelected || allInQuerySelected}
                     onAction={this.onDelete}
                     shouldConfirm={this.shouldConfirm('delete')}
                     message={confirm('delete', false)}
                     confirmLabel={label('delete')}
-                    selectAllActive={this.state.pageSelected}
+                    selectAllActive={pageSelected}
                   >
                     {t('Delete Issues')}
                   </ActionLink>
@@ -371,14 +403,14 @@ const StreamActions = createReactClass({
               <Tooltip
                 title={t(
                   '%s real-time updates',
-                  this.props.realtimeActive ? t('Pause') : t('Enable')
+                  realtimeActive ? t('Pause') : t('Enable')
                 )}
               >
                 <a
                   className="btn btn-default btn-sm hidden-xs realtime-control"
                   onClick={this.onRealtimeChange}
                 >
-                  {this.props.realtimeActive ? (
+                  {realtimeActive ? (
                     <span className="icon icon-pause" />
                   ) : (
                     <span className="icon icon-play" />
@@ -391,14 +423,14 @@ const StreamActions = createReactClass({
             <Flex>
               <StyledToolbarHeader>{t('Graph:')}</StyledToolbarHeader>
               <GraphToggle
-                active={this.props.statsPeriod === '24h'}
+                active={statsPeriod === '24h'}
                 onClick={this.selectStatsPeriod.bind(this, '24h')}
               >
                 {t('24h')}
               </GraphToggle>
 
               <GraphToggle
-                active={this.props.statsPeriod === '14d'}
+                active={statsPeriod === '14d'}
                 onClick={this.selectStatsPeriod.bind(this, '14d')}
               >
                 {t('14d')}
@@ -416,18 +448,22 @@ const StreamActions = createReactClass({
           </Box>
         </StyledFlex>
 
-        {!this.props.allResultsVisible &&
-          this.state.pageSelected && (
+        {!allResultsVisible &&
+          pageSelected && (
             <div className="row stream-select-all-notice">
               <div className="col-md-12">
-                {this.state.allInQuerySelected ? (
+                {allInQuerySelected ? (
                   <strong>
-                    {tct(
-                      'Selected up to the first [count] issues that match this search query.',
-                      {
-                        count: BULK_LIMIT_STR,
-                      }
-                    )}
+                    {queryCount >= BULK_LIMIT
+                      ? tct(
+                          'Selected up to the first [count] issues that match this search query.',
+                          {
+                            count: BULK_LIMIT_STR,
+                          }
+                        )
+                      : tct('Selected all [count] issues that match this search query.', {
+                          count: queryCount,
+                        })}
                   </strong>
                 ) : (
                   <span>
@@ -437,12 +473,16 @@ const StreamActions = createReactClass({
                       numIssues
                     )}
                     <a onClick={this.selectAll}>
-                      {tct(
-                        'Select the first [count] issues that match this search query.',
-                        {
-                          count: BULK_LIMIT_STR,
-                        }
-                      )}
+                      {queryCount >= BULK_LIMIT
+                        ? tct(
+                            'Select the first [count] issues that match this search query.',
+                            {
+                              count: BULK_LIMIT_STR,
+                            }
+                          )
+                        : tct('Select all [count] issues that match this search query.', {
+                            count: queryCount,
+                          })}
                     </a>
                   </span>
                 )}

+ 1 - 0
src/sentry/static/sentry/app/views/stream/stream.jsx

@@ -744,6 +744,7 @@ const Stream = createReactClass({
               hasReleases={projectFeatures.has('releases')}
               latestRelease={this.context.project.latestRelease}
               query={this.state.query}
+              queryCount={this.state.queryCount}
               onSelectStatsPeriod={this.onSelectStatsPeriod}
               onRealtimeChange={this.onRealtimeChange}
               realtimeActive={this.state.realtimeActive}

+ 1 - 1
src/sentry/static/sentry/less/stream.less

@@ -69,7 +69,7 @@
 .stream-select-all-notice {
   background: @header-bg-color;
   border: 1px solid #e7d796;
-  margin: 0 0 -1px;
+  margin: 0 -1px -1px;
   position: relative;
   z-index: 2;
 

+ 503 - 0
tests/js/spec/views/stream/__snapshots__/actions.spec.jsx.snap

@@ -0,0 +1,503 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`StreamActions Bulk Total results < bulk limit after checking "Select all" checkbox, displays bulk select message 1`] = `
+<div
+  className="row stream-select-all-notice"
+>
+  <div
+    className="col-md-12"
+  >
+    <span>
+      3 issues on this page selected.
+      <a
+        onClick={[Function]}
+      >
+        <span
+          key="5"
+        >
+          <span
+            key="0"
+          >
+            Select all 
+          </span>
+          <span
+            key="2"
+          >
+            600
+          </span>
+          <span
+            key="3"
+          >
+             issues that match this search query.
+          </span>
+        </span>
+      </a>
+    </span>
+  </div>
+</div>
+`;
+
+exports[`StreamActions Bulk Total results < bulk limit bulk resolves 1`] = `
+<ModalDialog
+  bsClass="modal"
+  className="in"
+  onClick={[Function]}
+  role="document"
+  style={Object {}}
+  tabIndex="-1"
+>
+  <div
+    className="in modal"
+    onClick={[Function]}
+    role="dialog"
+    style={
+      Object {
+        "display": "block",
+      }
+    }
+    tabIndex="-1"
+  >
+    <div
+      className="modal-dialog"
+    >
+      <div
+        className="modal-content"
+        role="document"
+      >
+        <div
+          className="modal-body"
+        >
+          <div>
+            <p
+              style={
+                Object {
+                  "marginBottom": "20px",
+                }
+              }
+            >
+              <strong>
+                <span
+                  key="8"
+                >
+                  <span
+                    key="0"
+                  >
+                    Are you sure you want to 
+                  </span>
+                  <span
+                    key="2"
+                  >
+                    resolve
+                  </span>
+                  <span
+                    key="3"
+                  >
+                     all 
+                  </span>
+                  <span
+                    key="5"
+                  >
+                    600
+                  </span>
+                  <span
+                    key="6"
+                  >
+                     issues that match the search?
+                  </span>
+                </span>
+              </strong>
+            </p>
+            <ExtraDescription
+              all={true}
+              query=""
+              queryCount={600}
+            >
+              <p
+                className="error"
+              >
+                <strong>
+                  <span
+                    key="5"
+                  >
+                    <span
+                      key="0"
+                    >
+                      This will apply to all 
+                    </span>
+                    <span
+                      key="2"
+                    >
+                      600
+                    </span>
+                    <span
+                      key="3"
+                    >
+                       issues matched in this project!
+                    </span>
+                  </span>
+                </strong>
+              </p>
+            </ExtraDescription>
+          </div>
+        </div>
+        <div
+          className="modal-footer"
+        >
+          <Button
+            disabled={false}
+            onClick={[Function]}
+            style={
+              Object {
+                "marginRight": 10,
+              }
+            }
+          >
+            <button
+              className="button button-default"
+              disabled={false}
+              onClick={[Function]}
+              role="button"
+              style={
+                Object {
+                  "marginRight": 10,
+                }
+              }
+            >
+              <Flex
+                align="center"
+                className="button-label"
+              >
+                <Base
+                  align="center"
+                  className="button-label css-5ipae5"
+                >
+                  <div
+                    className="button-label css-5ipae5"
+                    is={null}
+                  >
+                    Cancel
+                  </div>
+                </Base>
+              </Flex>
+            </button>
+          </Button>
+          <Button
+            autoFocus={true}
+            disabled={false}
+            onClick={[Function]}
+            priority="primary"
+          >
+            <button
+              autoFocus={true}
+              className="button button-primary"
+              disabled={false}
+              onClick={[Function]}
+              role="button"
+            >
+              <Flex
+                align="center"
+                className="button-label"
+              >
+                <Base
+                  align="center"
+                  className="button-label css-5ipae5"
+                >
+                  <div
+                    className="button-label css-5ipae5"
+                    is={null}
+                  >
+                    Bulk resolve issues
+                  </div>
+                </Base>
+              </Flex>
+            </button>
+          </Button>
+        </div>
+      </div>
+    </div>
+  </div>
+</ModalDialog>
+`;
+
+exports[`StreamActions Bulk Total results < bulk limit can bulk select 1`] = `
+<div
+  className="row stream-select-all-notice"
+>
+  <div
+    className="col-md-12"
+  >
+    <strong>
+      <span
+        key="5"
+      >
+        <span
+          key="0"
+        >
+          Selected all 
+        </span>
+        <span
+          key="2"
+        >
+          600
+        </span>
+        <span
+          key="3"
+        >
+           issues that match this search query.
+        </span>
+      </span>
+    </strong>
+  </div>
+</div>
+`;
+
+exports[`StreamActions Bulk Total results > bulk limit after checking "Select all" checkbox, displays bulk select message 1`] = `
+<div
+  className="row stream-select-all-notice"
+>
+  <div
+    className="col-md-12"
+  >
+    <span>
+      3 issues on this page selected.
+      <a
+        onClick={[Function]}
+      >
+        <span
+          key="5"
+        >
+          <span
+            key="0"
+          >
+            Select the first 
+          </span>
+          <span
+            key="2"
+          >
+            1,000
+          </span>
+          <span
+            key="3"
+          >
+             issues that match this search query.
+          </span>
+        </span>
+      </a>
+    </span>
+  </div>
+</div>
+`;
+
+exports[`StreamActions Bulk Total results > bulk limit bulk resolves 1`] = `
+<ModalDialog
+  bsClass="modal"
+  className="in"
+  onClick={[Function]}
+  role="document"
+  style={Object {}}
+  tabIndex="-1"
+>
+  <div
+    className="in modal"
+    onClick={[Function]}
+    role="dialog"
+    style={
+      Object {
+        "display": "block",
+      }
+    }
+    tabIndex="-1"
+  >
+    <div
+      className="modal-dialog"
+    >
+      <div
+        className="modal-content"
+        role="document"
+      >
+        <div
+          className="modal-body"
+        >
+          <div>
+            <p
+              style={
+                Object {
+                  "marginBottom": "20px",
+                }
+              }
+            >
+              <strong>
+                <span
+                  key="8"
+                >
+                  <span
+                    key="0"
+                  >
+                    Are you sure you want to 
+                  </span>
+                  <span
+                    key="2"
+                  >
+                    resolve
+                  </span>
+                  <span
+                    key="3"
+                  >
+                     the first 
+                  </span>
+                  <span
+                    key="5"
+                  >
+                    1,000
+                  </span>
+                  <span
+                    key="6"
+                  >
+                     issues that match the search?
+                  </span>
+                </span>
+              </strong>
+            </p>
+            <ExtraDescription
+              all={true}
+              query=""
+              queryCount={1500}
+            >
+              <p
+                className="error"
+              >
+                <strong>
+                  <span
+                    key="5"
+                  >
+                    <span
+                      key="0"
+                    >
+                      This will apply to the first 
+                    </span>
+                    <span
+                      key="2"
+                    >
+                      1,000
+                    </span>
+                    <span
+                      key="3"
+                    >
+                       issues matched in this project!
+                    </span>
+                  </span>
+                </strong>
+              </p>
+            </ExtraDescription>
+          </div>
+        </div>
+        <div
+          className="modal-footer"
+        >
+          <Button
+            disabled={false}
+            onClick={[Function]}
+            style={
+              Object {
+                "marginRight": 10,
+              }
+            }
+          >
+            <button
+              className="button button-default"
+              disabled={false}
+              onClick={[Function]}
+              role="button"
+              style={
+                Object {
+                  "marginRight": 10,
+                }
+              }
+            >
+              <Flex
+                align="center"
+                className="button-label"
+              >
+                <Base
+                  align="center"
+                  className="button-label css-5ipae5"
+                >
+                  <div
+                    className="button-label css-5ipae5"
+                    is={null}
+                  >
+                    Cancel
+                  </div>
+                </Base>
+              </Flex>
+            </button>
+          </Button>
+          <Button
+            autoFocus={true}
+            disabled={false}
+            onClick={[Function]}
+            priority="primary"
+          >
+            <button
+              autoFocus={true}
+              className="button button-primary"
+              disabled={false}
+              onClick={[Function]}
+              role="button"
+            >
+              <Flex
+                align="center"
+                className="button-label"
+              >
+                <Base
+                  align="center"
+                  className="button-label css-5ipae5"
+                >
+                  <div
+                    className="button-label css-5ipae5"
+                    is={null}
+                  >
+                    Bulk resolve issues
+                  </div>
+                </Base>
+              </Flex>
+            </button>
+          </Button>
+        </div>
+      </div>
+    </div>
+  </div>
+</ModalDialog>
+`;
+
+exports[`StreamActions Bulk Total results > bulk limit can bulk select 1`] = `
+<div
+  className="row stream-select-all-notice"
+>
+  <div
+    className="col-md-12"
+  >
+    <strong>
+      <span
+        key="5"
+      >
+        <span
+          key="0"
+        >
+          Selected up to the first 
+        </span>
+        <span
+          key="2"
+        >
+          1,000
+        </span>
+        <span
+          key="3"
+        >
+           issues that match this search query.
+        </span>
+      </span>
+    </strong>
+  </div>
+</div>
+`;

+ 2 - 0
tests/js/spec/views/stream/__snapshots__/stream.spec.jsx.snap

@@ -58,6 +58,7 @@ exports[`Stream render() displays the group list 1`] = `
         orgId="org-slug"
         projectId="project-slug"
         query="is:unresolved"
+        queryCount={0}
         realtimeActive={false}
         statsPeriod="24h"
       />
@@ -229,6 +230,7 @@ exports[`Stream toggles environment select all environments 1`] = `
         orgId="org-slug"
         projectId="project-slug"
         query="is:unresolved"
+        queryCount={0}
         realtimeActive={false}
         statsPeriod="24h"
       />

+ 120 - 1
tests/js/spec/views/stream/actions.spec.jsx

@@ -1,12 +1,15 @@
 import React from 'react';
-import {shallow} from 'enzyme';
+import {mount, shallow} from 'enzyme';
+import {ThemeProvider} from 'emotion-theming';
 
 import StreamActions from 'app/views/stream/actions';
 import SelectedGroupStore from 'app/stores/selectedGroupStore';
+import theme from 'app/utils/theme';
 
 describe('StreamActions', function() {
   let sandbox;
   let actions;
+  let wrapper;
 
   beforeEach(function() {
     sandbox = sinon.sandbox.create();
@@ -16,6 +19,122 @@ describe('StreamActions', function() {
     sandbox.restore();
   });
 
+  describe('Bulk', function() {
+    describe('Total results > bulk limit', function() {
+      beforeAll(function() {
+        SelectedGroupStore.records = {};
+        SelectedGroupStore.add([1, 2, 3]);
+        wrapper = mount(
+          <ThemeProvider theme={theme}>
+            <StreamActions
+              allResultsVisible={false}
+              query=""
+              queryCount={1500}
+              orgId="1337"
+              projectId="1"
+              groupIds={[1, 2, 3]}
+              onRealtimeChange={function() {}}
+              onSelectStatsPeriod={function() {}}
+              realtimeActive={false}
+              statsPeriod="24h"
+            />
+          </ThemeProvider>
+        );
+      });
+
+      it('after checking "Select all" checkbox, displays bulk select message', async function() {
+        wrapper.find('ActionsCheckbox Checkbox').simulate('change');
+        expect(wrapper.find('.stream-select-all-notice')).toMatchSnapshot();
+      });
+
+      it('can bulk select', function() {
+        wrapper.find('.stream-select-all-notice a').simulate('click');
+
+        expect(wrapper.find('.stream-select-all-notice')).toMatchSnapshot();
+      });
+
+      it('bulk resolves', async function() {
+        let apiMock = MockApiClient.addMockResponse({
+          url: '/projects/1337/1/issues/',
+          method: 'PUT',
+        });
+        wrapper
+          .find('ResolveActions ActionLink')
+          .first()
+          .simulate('click');
+
+        expect(wrapper.find('ModalDialog')).toMatchSnapshot();
+        wrapper.find('Button[priority="primary"]').simulate('click');
+        expect(apiMock).toHaveBeenCalledWith(
+          expect.anything(),
+          expect.objectContaining({
+            data: {status: 'resolved'},
+          })
+        );
+
+        await tick();
+        wrapper.update();
+      });
+    });
+
+    describe('Total results < bulk limit', function() {
+      beforeAll(function() {
+        SelectedGroupStore.records = {};
+        SelectedGroupStore.add([1, 2, 3]);
+        wrapper = mount(
+          <ThemeProvider theme={theme}>
+            <StreamActions
+              allResultsVisible={false}
+              query=""
+              queryCount={600}
+              orgId="1337"
+              projectId="1"
+              groupIds={[1, 2, 3]}
+              onRealtimeChange={function() {}}
+              onSelectStatsPeriod={function() {}}
+              realtimeActive={false}
+              statsPeriod="24h"
+            />
+          </ThemeProvider>
+        );
+      });
+
+      it('after checking "Select all" checkbox, displays bulk select message', async function() {
+        wrapper.find('ActionsCheckbox Checkbox').simulate('change');
+        expect(wrapper.find('.stream-select-all-notice')).toMatchSnapshot();
+      });
+
+      it('can bulk select', function() {
+        wrapper.find('.stream-select-all-notice a').simulate('click');
+
+        expect(wrapper.find('.stream-select-all-notice')).toMatchSnapshot();
+      });
+
+      it('bulk resolves', async function() {
+        let apiMock = MockApiClient.addMockResponse({
+          url: '/projects/1337/1/issues/',
+          method: 'PUT',
+        });
+        wrapper
+          .find('ResolveActions ActionLink')
+          .first()
+          .simulate('click');
+
+        expect(wrapper.find('ModalDialog')).toMatchSnapshot();
+        wrapper.find('Button[priority="primary"]').simulate('click');
+        expect(apiMock).toHaveBeenCalledWith(
+          expect.anything(),
+          expect.objectContaining({
+            data: {status: 'resolved'},
+          })
+        );
+
+        await tick();
+        wrapper.update();
+      });
+    });
+  });
+
   describe('actionSelectedGroups()', function() {
     beforeEach(function() {
       actions = shallow(