diff --git a/README.md b/README.md
index 2140a2fa..7ab80a7b 100644
--- a/README.md
+++ b/README.md
@@ -73,16 +73,16 @@ To prepare PagerDuty Live for release, the current workflow should be carried ou
1. Checkout to `develop` branch and verify if it's stable - i.e. no test and linting failures.
2. Update version information in `package.json` using `npm version` - example commands given below:
- - Bumping patch version for alpha release
+ - Bumping patch version for beta release
```
- $ npm --no-git-tag-version version prepatch --preid alpha
- v0.0.1-alpha.0
+ $ npm --no-git-tag-version version preminor --preid beta
+ v0.1.0-beta.0
```
- Bumping minor version for main release
```
$ npm --no-git-tag-version version minor
- v0.1.0
+ v0.2.0
```
3. Update application code version using `$ yarn genversion`
diff --git a/cypress/integration/Query/query.spec.js b/cypress/integration/Query/query.spec.js
index 38ccdf28..417c1c38 100644
--- a/cypress/integration/Query/query.spec.js
+++ b/cypress/integration/Query/query.spec.js
@@ -14,6 +14,7 @@ import {
checkIncidentCellIconAllRows,
manageIncidentTableColumns,
priorityNames,
+ selectIncident,
} from '../../support/util/common';
registerLocale('en-GB', gb);
@@ -67,9 +68,45 @@ describe('Query Incidents', { failFast: { enabled: false } }, () => {
// Reset query for next test
activateButton('query-urgency-low-button');
+ });
+
+ it('Query for incidents exceeding MAX_INCIDENTS_LIMIT; Cancel Request', () => {
+ // Update since date to T-2
+ const queryDate = moment()
+ .subtract(2, 'days')
+ .set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
+ cy.get('#query-date-input').clear().type(queryDate.format('DD/MM/yyyy')).type('{enter}');
+
+ // Cancel request from modal
+ cy.get('#cancel-incident-query-button').click();
+ cy.get('div.query-cancelled-ctr')
+ .should('be.visible')
+ .should('contain.text', 'Query has been cancelled by user');
+ cy.get('div.selected-incidents-ctr').should('be.visible').should('contain.text', 'N/A');
+
+ // Reset query for next test
deactivateButton('query-status-resolved-button');
});
+ it('Query for incidents exceeding MAX_INCIDENTS_LIMIT; Accept Request', () => {
+ // Accept request from modal
+ activateButton('query-status-resolved-button');
+ cy.get('#retrieve-incident-query-button').click();
+ cy.get('div.query-active-ctr')
+ .should('be.visible')
+ .should('contain.text', 'Querying PagerDuty API');
+ cy.get('div.selected-incidents-ctr').should('be.visible').should('contain.text', 'Querying');
+ waitForIncidentTable();
+
+ // Reset query for next test
+ deactivateButton('query-status-resolved-button');
+ const queryDate = moment()
+ .subtract(1, 'days')
+ .set({ hour: 0, minute: 0, second: 0, millisecond: 0 });
+ cy.get('#query-date-input').clear().type(queryDate.format('DD/MM/yyyy')).type('{enter}');
+ waitForIncidentTable();
+ });
+
it('Query for triggered incidents only', () => {
activateButton('query-status-triggered-button');
deactivateButton('query-status-acknowledged-button');
@@ -79,6 +116,11 @@ describe('Query Incidents', { failFast: { enabled: false } }, () => {
});
it('Query for acknowledged incidents only', () => {
+ // Ensure at least one incident is acknowledged for test
+ selectIncident(0);
+ cy.get('#incident-action-acknowledge-button').click();
+ cy.get('.action-alerts-modal').type('{esc}');
+
deactivateButton('query-status-triggered-button');
activateButton('query-status-acknowledged-button');
deactivateButton('query-status-resolved-button');
diff --git a/cypress/integration/Search/search.spec.js b/cypress/integration/Search/search.spec.js
index c3d7bd48..569099cd 100644
--- a/cypress/integration/Search/search.spec.js
+++ b/cypress/integration/Search/search.spec.js
@@ -4,6 +4,7 @@ import {
waitForIncidentTable,
activateButton,
priorityNames,
+ selectIncident,
} from '../../support/util/common';
describe('Search Incidents', { failFast: { enabled: false } }, () => {
@@ -33,6 +34,19 @@ describe('Search Incidents', { failFast: { enabled: false } }, () => {
});
});
+ it('Search for 2nd selected incident returns exactly 1 incident only', () => {
+ const incidentIdx = 1;
+ selectIncident(incidentIdx);
+ cy.get(`@selectedIncidentId_${incidentIdx}`).then((incidentId) => {
+ cy.get('#global-search-input').clear().type(incidentId);
+ });
+ cy.wait(1000);
+ cy.get('.selected-incidents-badge').then(($el) => {
+ const text = $el.text();
+ expect(text).to.equal('1/1');
+ });
+ });
+
it('Search for `zzzzzz` returns no incidents', () => {
cy.get('#global-search-input').clear().type('zzzzzz');
cy.wait(5000);
diff --git a/jest.config.js b/jest.config.js
index 1b3df822..d4a00021 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,6 +1,7 @@
module.exports = {
testEnvironment: 'jsdom',
testPathIgnorePatterns: ['./cypress/'],
+ setupFiles: ['dotenv/config'],
setupFilesAfterEnv: ['./setupTests.js'],
moduleDirectories: ['node_modules', 'src'],
moduleNameMapper: {
diff --git a/package.json b/package.json
index 319b9cc1..1c61869b 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "pd-live-react",
"homepage": "https://giranm.github.io/pd-live-react",
- "version": "0.0.17-alpha.0",
+ "version": "0.1.0-beta.0",
"private": true,
"dependencies": {
"@craco/craco": "7.0.0-alpha.3",
@@ -28,7 +28,7 @@
"immer": "^9.0.6",
"lodash": "^4.17.21",
"mezr": "^0.6.2",
- "moment": "^2.29.2",
+ "moment": "^2.29.3",
"node-sass": "^6.0.1",
"react": "^17.0.2",
"react-bootstrap": "^1.6.4",
@@ -45,7 +45,7 @@
"redux-persist": "^6.0.0",
"redux-saga": "^1.1.3",
"styled-components": "^5.3.5",
- "use-debounce": "^7.0.0",
+ "use-debounce": "^8.0.1",
"web-vitals": "^1.1.2"
},
"resolutions": {
@@ -90,6 +90,7 @@
"@babel/preset-react": "^7.16.7",
"@cypress/react": "5.12.4",
"@cypress/webpack-dev-server": "^1.8.0",
+ "@faker-js/faker": "^7.1.0",
"cy2": "^1.3.0",
"cypress": "^9.2.1",
"cypress-fail-fast": "^3.4.1",
@@ -98,16 +99,17 @@
"eslint-config-prettier": "^8.3.0",
"eslint-config-react-app": "^7.0.0",
"eslint-plugin-cypress": "^2.12.1",
- "eslint-plugin-import": "^2.24.2",
+ "eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.4.1",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-react-hooks": "^4.2.0",
"genversion": "^3.0.2",
"gh-pages": "^3.2.3",
- "html-webpack-plugin": "4",
+ "html-webpack-plugin": "5",
"identity-obj-proxy": "^3.0.0",
- "prettier": "^2.5.1",
+ "jest-location-mock": "^1.0.9",
+ "prettier": "^2.6.2",
"prettier-eslint": "^10.1.0",
"prettier-eslint-cli": "^5.0.1",
"redux-mock-store": "^1.5.4",
diff --git a/src/App.js b/src/App.js
index ba3f8ef4..390a4e87 100644
--- a/src/App.js
+++ b/src/App.js
@@ -9,6 +9,7 @@ import {
} from 'react-bootstrap';
import moment from 'moment';
+import AuthComponent from 'components/Auth/AuthComponent';
import UnauthorizedModalComponent from 'components/UnauthorizedModal/UnauthorizedModalComponent';
import DisclaimerModalComponent from 'components/DisclaimerModal/DisclaimerModalComponent';
import NavigationBarComponent from 'components/NavigationBar/NavigationBarComponent';
@@ -22,11 +23,8 @@ import AddNoteModalComponent from 'components/AddNoteModal/AddNoteModalComponent
import ReassignModalComponent from 'components/ReassignModal/ReassignModalComponent';
import AddResponderModalComponent from 'components/AddResponderModal/AddResponderModalComponent';
import MergeModalComponent from 'components/MergeModal/MergeModalComponent';
+import ConfirmQueryModalComponent from 'components/ConfirmQueryModal/ConfirmQueryModalComponent';
-import {
- getIncidentsAsync as getIncidentsAsyncConnected,
- getAllIncidentNotesAsync as getAllIncidentNotesAsyncConnected,
-} from 'redux/incidents/actions';
import {
getLogEntriesAsync as getLogEntriesAsyncConnected,
cleanRecentLogEntriesAsync as cleanRecentLogEntriesAsyncConnected,
@@ -64,12 +62,10 @@ import {
store,
} from 'redux/store';
-import PDOAuth from 'util/pdoauth';
-
import {
- PD_REQUIRED_ABILITY,
PD_OAUTH_CLIENT_ID,
PD_OAUTH_CLIENT_SECRET,
+ PD_REQUIRED_ABILITY,
LOG_ENTRIES_POLLING_INTERVAL_SECONDS,
LOG_ENTRIES_CLEARING_INTERVAL_SECONDS,
} from 'config/constants';
@@ -90,24 +86,24 @@ const App = ({
getEscalationPoliciesAsync,
getExtensionsAsync,
getResponsePlaysAsync,
- getIncidentsAsync,
- getAllIncidentNotesAsync,
getLogEntriesAsync,
cleanRecentLogEntriesAsync,
}) => {
// Verify if session token is present
const token = sessionStorage.getItem('pd_access_token');
if (!token) {
- useEffect(() => {
- PDOAuth.login(PD_OAUTH_CLIENT_ID, PD_OAUTH_CLIENT_SECRET);
- }, []);
- return null;
+ return (
+
+ );
}
// Begin monitoring and load core objects from API
const {
userAuthorized, userAcceptedDisclaimer, currentUserLocale,
} = state.users;
+ const queryError = state.querySettings.error;
useEffect(() => {
userAuthorize();
if (userAuthorized) {
@@ -120,8 +116,7 @@ const App = ({
getExtensionsAsync();
getResponsePlaysAsync();
getPrioritiesAsync();
- getIncidentsAsync();
- getAllIncidentNotesAsync();
+ // NB: Get Incidents and Notes are implicitly done from query now
checkConnectionStatus();
}
}, [userAuthorized]);
@@ -139,7 +134,7 @@ const App = ({
const {
abilities,
} = store.getState().connection;
- if (userAuthorized && abilities.includes(PD_REQUIRED_ABILITY)) {
+ if (userAuthorized && abilities.includes(PD_REQUIRED_ABILITY) && !queryError) {
const lastPolledDate = moment()
.subtract(2 * LOG_ENTRIES_POLLING_INTERVAL_SECONDS, 'seconds')
.toDate();
@@ -147,7 +142,7 @@ const App = ({
}
}, LOG_ENTRIES_POLLING_INTERVAL_SECONDS * 1000);
return () => clearInterval(pollingInterval);
- }, [userAuthorized]);
+ }, [userAuthorized, queryError]);
// Setup log entry clearing
useEffect(() => {
@@ -191,6 +186,7 @@ const App = ({
+
);
@@ -210,8 +206,6 @@ const mapDispatchToProps = (dispatch) => ({
getEscalationPoliciesAsync: () => dispatch(getEscalationPoliciesAsyncConnected()),
getExtensionsAsync: () => dispatch(getExtensionsAsyncConnected()),
getResponsePlaysAsync: () => dispatch(getResponsePlaysAsyncConnected()),
- getIncidentsAsync: () => dispatch(getIncidentsAsyncConnected()),
- getAllIncidentNotesAsync: () => dispatch(getAllIncidentNotesAsyncConnected()),
getLogEntriesAsync: (since) => dispatch(getLogEntriesAsyncConnected(since)),
cleanRecentLogEntriesAsync: () => dispatch(cleanRecentLogEntriesAsyncConnected()),
});
diff --git a/src/components/Auth/AuthComponent.js b/src/components/Auth/AuthComponent.js
new file mode 100644
index 00000000..6cd0f8b6
--- /dev/null
+++ b/src/components/Auth/AuthComponent.js
@@ -0,0 +1,87 @@
+/* eslint-disable no-unused-vars */
+import React, {
+ useState, useEffect,
+} from 'react';
+
+import {
+ Form, Button, Dropdown, Spinner, Row,
+} from 'react-bootstrap';
+
+import {
+ createCodeVerifier, getAuthURL, exchangeCodeForToken,
+} from 'util/auth';
+
+import './AuthComponent.scss';
+
+const AuthComponent = (props) => {
+ const [authURL, setAuthURL] = useState('');
+ const urlParams = new URLSearchParams(window.location.search);
+ const accessToken = sessionStorage.getItem('pd_access_token');
+ const code = urlParams.get('code');
+ let codeVerifier = sessionStorage.getItem('code_verifier');
+ let {
+ redirectURL,
+ } = props;
+ const {
+ clientId, clientSecret,
+ } = props;
+
+ if (!redirectURL) {
+ // assume that the redirect URL is the current page
+ redirectURL = `${window.location.protocol}//${window.location.host}${window.location.pathname}`;
+ }
+
+ useEffect(() => {
+ if (code && codeVerifier && !accessToken) {
+ exchangeCodeForToken(clientId, clientSecret, redirectURL, codeVerifier, code).then(
+ (token) => {
+ sessionStorage.removeItem('code_verifier');
+ sessionStorage.setItem('pd_access_token', token);
+ window.location.assign(redirectURL);
+ },
+ );
+ } else if (!accessToken) {
+ codeVerifier = createCodeVerifier();
+ sessionStorage.setItem('code_verifier', codeVerifier);
+ getAuthURL(clientId, clientSecret, redirectURL, codeVerifier).then((url) => {
+ setAuthURL(url);
+ });
+ }
+ }, []);
+
+ if (code && codeVerifier) {
+ return (
+
+
+
+
+
+ Signing into PagerDuty Live
+
+
+
+ );
+ }
+ return (
+
+ );
+};
+
+export default AuthComponent;
diff --git a/src/components/Auth/AuthComponent.scss b/src/components/Auth/AuthComponent.scss
new file mode 100644
index 00000000..8118c290
--- /dev/null
+++ b/src/components/Auth/AuthComponent.scss
@@ -0,0 +1,34 @@
+@import 'assets/styles/pagerduty.scss';
+
+#pd-login-form {
+ position: relative;
+ top: 80px;
+ z-index: 0;
+ max-width: 30em;
+ margin: 0 auto;
+ padding: 2em 0 2em 0;
+ border-radius: 5px;
+ border: 1px solid $pd-light;
+ box-shadow: 0px 0px 10px 1px $pd-gray-medium;
+ background-color: $pd-white;
+}
+
+#pd-login-logo {
+ display: block;
+ text-indent: -9999px;
+ overflow: hidden;
+ width: 175px;
+ height: 60px;
+ background-image: url(https://pd-static-assets.pagerduty.com/logos/main.svg);
+ background-repeat: no-repeat;
+ background-position: center left;
+ background-size: 175px 60px;
+}
+
+#pd-login-description {
+ padding: 1em 0 1em 0;
+}
+
+#pd-login-button {
+ width: 80%;
+}
diff --git a/src/components/Auth/AuthComponent.test.js b/src/components/Auth/AuthComponent.test.js
new file mode 100644
index 00000000..d06d8639
--- /dev/null
+++ b/src/components/Auth/AuthComponent.test.js
@@ -0,0 +1,39 @@
+import 'jest-location-mock';
+
+import {
+ mount,
+} from 'enzyme';
+
+import {
+ Button,
+} from 'react-bootstrap';
+
+import 'mocks/pdoauth';
+
+import {
+ waitForComponentToPaint,
+} from 'mocks/store.test';
+
+import AuthComponent from './AuthComponent';
+
+describe('AuthComponent', () => {
+ it('should render component correctly', () => {
+ const wrapper = mount();
+ waitForComponentToPaint(wrapper);
+ expect(wrapper.find('h1').getElement(0).props.children).toEqual('Live Incidents Console');
+ expect(wrapper.find('p').getElement(0).props.children).toEqual(
+ 'Connect using PagerDuty OAuth to use this app',
+ );
+ expect(wrapper.find(Button).getElement(0).props.variant).toEqual('primary');
+ expect(wrapper.find(Button).getElement(0).props.children).toEqual('Sign In');
+ });
+
+ it('should invoke window.location.assign when "Sign In" is clicked', () => {
+ const wrapper = mount();
+ waitForComponentToPaint(wrapper);
+ wrapper.find(Button).simulate('click');
+ expect(window.location.assign).toBeCalled();
+ // FIX ME: This assertion doesn't work within Jest for some reason
+ // expect(window.location.href).toContain('https://app.pagerduty.com/global/authn/authentication');
+ });
+});
diff --git a/src/components/ConfirmQueryModal/ConfirmQueryModalComponent.js b/src/components/ConfirmQueryModal/ConfirmQueryModalComponent.js
new file mode 100644
index 00000000..7d5a78c0
--- /dev/null
+++ b/src/components/ConfirmQueryModal/ConfirmQueryModalComponent.js
@@ -0,0 +1,79 @@
+import {
+ connect,
+} from 'react-redux';
+
+import {
+ Modal, Button,
+} from 'react-bootstrap';
+
+import {
+ toggleDisplayConfirmQueryModal as toggleDisplayConfirmQueryModalConnected,
+ confirmIncidentQuery as confirmIncidentQueryConnected,
+} from 'redux/query_settings/actions';
+
+import {
+ MAX_INCIDENTS_LIMIT,
+} from 'config/constants';
+
+const ConfirmQueryModalComponent = ({
+ querySettings,
+ toggleDisplayConfirmQueryModal,
+ confirmIncidentQuery,
+}) => {
+ const {
+ displayConfirmQueryModal, totalIncidentsFromQuery,
+ } = querySettings;
+
+ const handleCancel = () => {
+ confirmIncidentQuery(false);
+ toggleDisplayConfirmQueryModal();
+ };
+
+ return (
+
+
+
+ Max Incidents Limit Reached
+
+
+ Current query parameters match
+ {totalIncidentsFromQuery}
+ incidents.
+
+ Only the first
+ {MAX_INCIDENTS_LIMIT}
+ incidents will be retrieved.
+
+
+ Continue?
+
+
+
+
+
+
+
+ );
+};
+
+const mapStateToProps = (state) => ({
+ querySettings: state.querySettings,
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ toggleDisplayConfirmQueryModal: () => dispatch(toggleDisplayConfirmQueryModalConnected()),
+ confirmIncidentQuery: (confirm) => dispatch(confirmIncidentQueryConnected(confirm)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConfirmQueryModalComponent);
diff --git a/src/components/ConfirmQueryModal/ConfirmQueryModalComponent.test.js b/src/components/ConfirmQueryModal/ConfirmQueryModalComponent.test.js
new file mode 100644
index 00000000..138cc6d3
--- /dev/null
+++ b/src/components/ConfirmQueryModal/ConfirmQueryModalComponent.test.js
@@ -0,0 +1,39 @@
+import {
+ mockStore, componentWrapper,
+} from 'mocks/store.test';
+
+import {
+ MAX_INCIDENTS_LIMIT,
+} from 'config/constants';
+
+import {
+ generateRandomInteger,
+} from 'util/helpers';
+
+import ConfirmQueryModalComponent from './ConfirmQueryModalComponent';
+
+describe('ConfirmQueryModalComponent', () => {
+ it('should render modal noting max incident limit has been reached', () => {
+ const totalIncidentsFromQuery = generateRandomInteger(
+ MAX_INCIDENTS_LIMIT + 1,
+ MAX_INCIDENTS_LIMIT * 2,
+ );
+ const store = mockStore({
+ querySettings: {
+ displayConfirmQueryModal: true,
+ totalIncidentsFromQuery,
+ },
+ });
+
+ const wrapper = componentWrapper(store, ConfirmQueryModalComponent);
+ expect(wrapper.find('.modal-title').at(0).getDOMNode().textContent).toEqual(
+ 'Max Incidents Limit Reached',
+ );
+ expect(wrapper.find('.modal-body').at(0).getDOMNode().textContent).toEqual(
+ [
+ `Current query parameters match\u00A0${totalIncidentsFromQuery}\u00A0incidents.`,
+ `Only the first\u00A0${MAX_INCIDENTS_LIMIT}\u00A0incidents will be retrieved.Continue?`,
+ ].join(''),
+ );
+ });
+});
diff --git a/src/components/DisclaimerModal/DisclaimerModalComponent.js b/src/components/DisclaimerModal/DisclaimerModalComponent.js
index 647037be..a4f70140 100644
--- a/src/components/DisclaimerModal/DisclaimerModalComponent.js
+++ b/src/components/DisclaimerModal/DisclaimerModalComponent.js
@@ -10,8 +10,6 @@ import {
Modal, Form, Button,
} from 'react-bootstrap';
-import PDOAuth from 'util/pdoauth';
-
import {
userAcceptDisclaimer as userAcceptDisclaimerConnected,
userUnauthorize as userUnauthorizeConnected,
@@ -256,8 +254,9 @@ const DisclaimerModalComponent = ({
id="disclaimer-decline-button"
variant="danger"
onClick={() => {
- PDOAuth.logout();
userUnauthorize();
+ sessionStorage.removeItem('pd_access_token');
+ window.location.reload();
}}
>
Decline
diff --git a/src/components/IncidentActions/IncidentActionsComponent.js b/src/components/IncidentActions/IncidentActionsComponent.js
index 73a2c16e..355ea986 100644
--- a/src/components/IncidentActions/IncidentActionsComponent.js
+++ b/src/components/IncidentActions/IncidentActionsComponent.js
@@ -9,14 +9,12 @@ import {
import {
Container,
- Badge,
Row,
Col,
Button,
Dropdown,
DropdownButton,
ButtonGroup,
- Spinner,
} from 'react-bootstrap';
import Select from 'react-select';
import makeAnimated from 'react-select/animated';
@@ -75,11 +73,12 @@ import {
getObjectsFromListbyKey,
} from 'util/helpers';
+import SelectedIncidentsComponent from './subcomponents/SelectedIncidentsComponent';
+
const animatedComponents = makeAnimated();
const IncidentActionsComponent = ({
incidentTable,
- incidents,
priorities,
escalationPolicies,
extensions,
@@ -98,9 +97,6 @@ const IncidentActionsComponent = ({
runResponsePlayAsync,
syncWithExternalSystem,
}) => {
- const {
- fetchingIncidents, filteredIncidentsByQuery,
- } = incidents;
const {
selectedCount, selectedRows,
} = incidentTable;
@@ -222,26 +218,7 @@ const IncidentActionsComponent = ({
-
- {fetchingIncidents ? (
- <>
-
-
Querying
- >
- ) : (
- <>
-
-
- {`${selectedCount}/${filteredIncidentsByQuery.length}`}
-
-
-
Selected
- >
- )}
-
+