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

- - )} -
+