diff --git a/.github/workflows/cd-workflow.yml b/.github/workflows/cd-workflow.yml index 538c84e0..72102772 100644 --- a/.github/workflows/cd-workflow.yml +++ b/.github/workflows/cd-workflow.yml @@ -55,7 +55,7 @@ jobs: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - name: Install project dependencies (via cache) - run: yarn --prefer-offline + run: yarn --frozen-lockfile --prefer-offline --ignore-optional - name: Build application bundle run: yarn build - name: Deploy diff --git a/.github/workflows/lint-workflow.yml b/.github/workflows/lint-workflow.yml index 82bf5ce8..ee887e1d 100644 --- a/.github/workflows/lint-workflow.yml +++ b/.github/workflows/lint-workflow.yml @@ -21,6 +21,6 @@ jobs: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - name: Install project dependencies (via cache) - run: yarn --prefer-offline + run: yarn --frozen-lockfile --prefer-offline --ignore-optional - name: Run ESLint run: npx eslint src --ext .js,.jsx,.ts,.tsx --exit-on-fatal-error diff --git a/.github/workflows/test-workflow.yml b/.github/workflows/test-workflow.yml index 1cac379c..d12d54c5 100644 --- a/.github/workflows/test-workflow.yml +++ b/.github/workflows/test-workflow.yml @@ -31,7 +31,7 @@ jobs: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - name: Install project dependencies (via cache) - run: yarn --prefer-offline + run: yarn --frozen-lockfile --prefer-offline --ignore-optional jest: needs: install @@ -54,7 +54,7 @@ jobs: path: ${{ steps.yarn-cache-dir-path.outputs.dir }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} - name: Install project dependencies (via cache) - run: yarn --prefer-offline + run: yarn --frozen-lockfile --prefer-offline --ignore-optional - name: Run Jest Tests run: yarn jest @@ -62,7 +62,7 @@ jobs: needs: install runs-on: ubuntu-latest container: - image: cypress/browsers:node-20.5.0-chrome-114.0.5735.133-1-ff-114.0.2-edge-114.0.1823.51-1 + image: cypress/browsers:node-20.14.0-chrome-125.0.6422.141-1-ff-126.0.1-edge-125.0.2535.85-1 options: --user 1001 strategy: fail-fast: false @@ -102,7 +102,7 @@ jobs: # Starts web server for E2E tests - replace with your own server invocation # https://docs.cypress.io/guides/continuous-integration/introduction#Boot-your-server start: yarn start - wait-on: 'http://localhost:3000' # Waits for above + wait-on: 'http://127.0.0.1:3000' # Waits for above browser: chrome # Records to Cypress Cloud # https://docs.cypress.io/guides/cloud/projects#Set-up-a-project-to-record @@ -111,6 +111,7 @@ jobs: spec: cypress/e2e/${{ matrix.e2e }} parallel: false # Don't use Cypress in-built parallelization install: true + install-command: yarn --frozen-lockfile --prefer-offline --ignore-optional cache-key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} env: # For recording and parallelization to work you must set your CYPRESS_RECORD_KEY diff --git a/.tool-versions b/.tool-versions index da05de7b..0751e80c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ # MUST match .node-version -nodejs 20.5.1 -yarn 1.22.19 +nodejs 20.14.0 +yarn 1.22.22 diff --git a/README.md b/README.md index 9701f339..12e6421a 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ If you wish to maintain + deploy your own version of PagerDuty Live, we recommen #### Local Development -1. Install [NodeJS v20.5.1](https://nodejs.org/en/blog/release/v20.5.1/) via [`asdf install`](https://github.com/asdf-vm/asdf) / [`nvm`](https://github.com/nvm-sh/nvm) +1. Install [NodeJS v20.14.0](https://nodejs.org/en/blog/release/v20.14.0/) via [`asdf install`](https://github.com/asdf-vm/asdf) / [`nvm`](https://github.com/nvm-sh/nvm) 2. `$ git clone` repo to desired destination and `$ cd pd-live-react` into directory diff --git a/cypress.config.js b/cypress.config.js index 5be05f0c..78b83e79 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -20,7 +20,7 @@ module.exports = defineConfig({ config.env.PD_USER_TOKEN = process.env.VITE_PD_USER_TOKEN; return config; }, - baseUrl: 'http://localhost:3000/pd-live-react', + baseUrl: 'http://127.0.0.1:3000/pd-live-react', specPattern: 'cypress/e2e/**/*.spec.{js,ts,jsx,tsx}', testIsolation: true, }, diff --git a/cypress/e2e/Search/search.spec.js b/cypress/e2e/Search/search.spec.js index 900df9ac..41b63d7e 100644 --- a/cypress/e2e/Search/search.spec.js +++ b/cypress/e2e/Search/search.spec.js @@ -113,4 +113,12 @@ describe('Search Incidents', { failFast: { enabled: true } }, () => { cy.get('#service-filter-icon').realHover(); cy.get('button[aria-label="Clear Filter"]').filter(':visible').click(); }); + + it('Column filtering on Service column for `zzzzzz` returns no incidents and clear filters button', () => { + cy.get('#service-filter-icon').realHover(); + cy.get('input[placeholder="Filter"]').filter(':visible').click().type('zzzzzz'); + cy.get('.empty-incidents-badge').should('be.visible'); + cy.get('#clear-filters-button').filter(':visible').click(); + waitForIncidentTable(); + }); }); diff --git a/src/components/IncidentTable/IncidentTableComponent.jsx b/src/components/IncidentTable/IncidentTableComponent.jsx index 6649d9cf..192f14fd 100644 --- a/src/components/IncidentTable/IncidentTableComponent.jsx +++ b/src/components/IncidentTable/IncidentTableComponent.jsx @@ -46,6 +46,7 @@ import { import { selectIncidentTableRows as selectIncidentTableRowsConnected, updateIncidentTableState as updateIncidentTableStateConnected, + CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED, } from 'src/redux/incident_table/actions'; import EmptyIncidentsComponent from './subcomponents/EmptyIncidentsComponent'; @@ -120,7 +121,7 @@ const doCsvExport = (tableData) => { const IncidentTableComponent = () => { const { - incidentTableState, incidentTableColumns, + incidentTableState, incidentTableColumns, status: incidentTableStatus, } = useSelector((state) => state.incidentTable); const { status: incidentActionsStatus, @@ -221,7 +222,8 @@ const IncidentTableComponent = () => { // Debouncing for table state const debouncedUpdateIncidentTableState = useDebouncedCallback((state, action) => { // Only update store with sorted and column resizing state - if (action.type === 'toggleSortBy' || action.type === 'columnDoneResizing') { + // and filter state + if (action.type === 'toggleSortBy' || action.type === 'columnDoneResizing' || action.type === 'setFilter') { updateIncidentTableState(state); } }, 100); @@ -251,7 +253,7 @@ const IncidentTableComponent = () => { // Set initial state from store initialState: incidentTableState, // Handle updates to table - stateReducer: (newState, action) => debouncedUpdateIncidentTableState(newState, action), + stateReducer: debouncedUpdateIncidentTableState, }, // Plugins useFilters, @@ -327,19 +329,17 @@ const IncidentTableComponent = () => { }, ); - // save filters when the user changes them - useEffect(() => { - updateIncidentTableState({ - ...incidentTableState, - filters: tableInstance.state.filters, - }); - }, [tableInstance.state.filters]); - // Update table filters when columns change useEffect(() => { tableInstance.setAllFilters(incidentTableState.filters); }, [columns]); + useEffect(() => { + if (incidentTableStatus === CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED) { + tableInstance.setAllFilters(incidentTableState.filters); + } + }, [incidentTableStatus]); + const { getTableProps, getTableBodyProps, @@ -536,19 +536,26 @@ const IncidentTableComponent = () => { - - rows[index].id} - itemData={rows} - width={totalColumnsWidth + scrollBarSize} - > - {MyIncidentRow} - - + { rows.length > 0 && ( + + rows[index].id} + itemData={rows} + width={totalColumnsWidth + scrollBarSize} + > + {MyIncidentRow} + + + )} + { rows.length === 0 && ( + + )} setDisplayGetAllModal(false)} diff --git a/src/components/QuerySettings/QuerySettingsComponent.jsx b/src/components/QuerySettings/QuerySettingsComponent.jsx index 36d5da8b..8146eae9 100644 --- a/src/components/QuerySettings/QuerySettingsComponent.jsx +++ b/src/components/QuerySettings/QuerySettingsComponent.jsx @@ -1,4 +1,6 @@ -import React from 'react'; +import React, { + useMemo, +} from 'react'; import { useSelector, useDispatch, @@ -28,6 +30,7 @@ import DatePickerComponent from './subcomponents/DatePickerComponent'; import StatusQueryComponent from './subcomponents/StatusQueryComponent'; import UrgencyQueryComponent from './subcomponents/UrgencyQueryComponent'; import PriorityQueryComponent from './subcomponents/PriorityQueryComponent'; +import ColumnFilterIndicatorComponent from './subcomponents/ColumnFilterIndicatorComponent'; import './QuerySettingsComponent.scss'; @@ -52,6 +55,10 @@ const QuerySettingsComponent = () => { } = useSelector( (state) => state.querySettings, ); + const { + filters, + } = useSelector((state) => state.incidentTable.incidentTableState); + const dispatch = useDispatch(); const updateQuerySettingsServices = (newServiceIds) => { dispatch(updateQuerySettingsServicesConnected(newServiceIds)); @@ -66,6 +73,13 @@ const QuerySettingsComponent = () => { dispatch(updateQuerySettingsTeamsConnected(newTeamIds)); }; + const filterCount = useMemo(() => { + if (filters instanceof Array) { + return filters.length; + } + return 0; + }, [filters]); + return ( { isMulti /> - {/* */} + { filterCount > 0 && ( + + + + )} ); diff --git a/src/components/QuerySettings/subcomponents/ColumnFilterIndicatorComponent.jsx b/src/components/QuerySettings/subcomponents/ColumnFilterIndicatorComponent.jsx new file mode 100644 index 00000000..01cc8002 --- /dev/null +++ b/src/components/QuerySettings/subcomponents/ColumnFilterIndicatorComponent.jsx @@ -0,0 +1,63 @@ +import React, { + useMemo, +} from 'react'; + +import { + useSelector, + useDispatch, +} from 'react-redux'; + +import { + useTranslation, +} from 'react-i18next'; + +import { + Flex, + Button, + Tag, +} from '@chakra-ui/react'; + +import { + clearIncidentTableFilters as clearIncidentTableFiltersConnected, +} from 'src/redux/incident_table/actions'; + +const ColumnFilterIndicatorComponent = () => { + const { + t, + } = useTranslation(); + const { + filters, + } = useSelector((state) => state.incidentTable.incidentTableState); + + const dispatch = useDispatch(); + const clearIncidentTableFilters = () => { + dispatch(clearIncidentTableFiltersConnected()); + }; + + const filterCount = useMemo(() => { + if (filters instanceof Array) { + return filters.length; + } + return 0; + }, [filters]); + + return ( + + + {filterCount} + + + + ); +}; + +export default ColumnFilterIndicatorComponent; diff --git a/src/redux/incident_table/actions.js b/src/redux/incident_table/actions.js index f96e0f48..6895c487 100644 --- a/src/redux/incident_table/actions.js +++ b/src/redux/incident_table/actions.js @@ -12,6 +12,9 @@ export const UPDATE_INCIDENT_TABLE_STATE_COMPLETED = 'UPDATE_INCIDENT_TABLE_STAT export const SELECT_INCIDENT_TABLE_ROWS_REQUESTED = 'SELECT_INCIDENT_TABLE_ROWS_REQUESTED'; export const SELECT_INCIDENT_TABLE_ROWS_COMPLETED = 'SELECT_INCIDENT_TABLE_ROWS_COMPLETED'; +export const CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED = 'CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED'; +export const CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED = 'CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED'; + // Define Actions export const saveIncidentTable = (updatedIncidentTableColumns) => ({ @@ -35,3 +38,7 @@ export const selectIncidentTableRows = (allSelected, selectedCount, selectedRows selectedCount, selectedRows, }); + +export const clearIncidentTableFilters = () => ({ + type: CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED, +}); diff --git a/src/redux/incident_table/reducers.js b/src/redux/incident_table/reducers.js index 652115ad..a7f8d3f8 100644 --- a/src/redux/incident_table/reducers.js +++ b/src/redux/incident_table/reducers.js @@ -12,6 +12,8 @@ import { UPDATE_INCIDENT_TABLE_STATE_COMPLETED, SELECT_INCIDENT_TABLE_ROWS_REQUESTED, SELECT_INCIDENT_TABLE_ROWS_COMPLETED, + CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED, + CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED, } from './actions'; const defaultColumns = [ @@ -70,6 +72,18 @@ const incidentTable = produce( draft.status = SELECT_INCIDENT_TABLE_ROWS_COMPLETED; break; + case CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED: + draft.status = CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED; + break; + + case CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED: + draft.incidentTableState = { + ...draft.incidentTableState, + filters: [], + }; + draft.status = CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED; + break; + default: break; } diff --git a/src/redux/incident_table/sagas.js b/src/redux/incident_table/sagas.js index 36641803..dcf69ada 100644 --- a/src/redux/incident_table/sagas.js +++ b/src/redux/incident_table/sagas.js @@ -16,6 +16,8 @@ import { UPDATE_INCIDENT_TABLE_STATE_COMPLETED, SELECT_INCIDENT_TABLE_ROWS_REQUESTED, SELECT_INCIDENT_TABLE_ROWS_COMPLETED, + CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED, + CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED, } from './actions'; import selectIncidentTable from './selectors'; @@ -122,3 +124,13 @@ export function* selectIncidentTableRowsImpl(action) { selectedRows, }); } + +export function* clearIncidentTableFilters() { + yield takeLatest(CLEAR_INCIDENT_TABLE_FILTERS_REQUESTED, clearIncidentTableFiltersImpl); +} + +export function* clearIncidentTableFiltersImpl() { + yield put({ + type: CLEAR_INCIDENT_TABLE_FILTERS_COMPLETED, + }); +} diff --git a/src/redux/incidents/reducers.js b/src/redux/incidents/reducers.js index edd6a2d2..4a6114bf 100644 --- a/src/redux/incidents/reducers.js +++ b/src/redux/incidents/reducers.js @@ -56,6 +56,7 @@ const incidents = produce( case FETCH_INCIDENTS_REQUESTED: draft.fetchingIncidents = true; draft.status = FETCH_INCIDENTS_REQUESTED; + draft.error = null; break; case FETCH_INCIDENTS_COMPLETED: diff --git a/src/redux/log_entries/sagas.js b/src/redux/log_entries/sagas.js index 5125af1f..089d9f12 100644 --- a/src/redux/log_entries/sagas.js +++ b/src/redux/log_entries/sagas.js @@ -169,9 +169,17 @@ export function* pollLogEntriesTask() { }, incidents: { fetchingIncidents, + error: incidentsError, }, } = yield select(); - if (userAuthorized && userAcceptedDisclaimer && !fetchingIncidents && !DEBUG_DISABLE_POLLING) { + + const tooManyIncidentsError = ( + incidentsError + && typeof incidentsError === 'string' + && incidentsError.startsWith('Too many records') + ); + + if (userAuthorized && userAcceptedDisclaimer && !fetchingIncidents && !DEBUG_DISABLE_POLLING && !tooManyIncidentsError) { const lastPollStarted = new Date(); yield put({ type: UPDATE_LOG_ENTRIES_POLLING, @@ -199,7 +207,7 @@ export function* pollLogEntriesTask() { yield delay((LOG_ENTRIES_POLLING_INTERVAL_SECONDS * 1000) - timeTaken); } else { // eslint-disable-next-line no-console - console.log('skipping poll', { userAuthorized, userAcceptedDisclaimer, fetchingIncidents, DEBUG_DISABLE_POLLING }); + console.log('skipping poll', { userAuthorized, userAcceptedDisclaimer, fetchingIncidents, DEBUG_DISABLE_POLLING, tooManyIncidentsError }); yield delay(LOG_ENTRIES_POLLING_INTERVAL_SECONDS * 1000); } } diff --git a/src/redux/rootSaga.js b/src/redux/rootSaga.js index 629370c5..cc38aa32 100644 --- a/src/redux/rootSaga.js +++ b/src/redux/rootSaga.js @@ -48,6 +48,7 @@ import { updateIncidentTableColumns, updateIncidentTableState, selectIncidentTableRows, + clearIncidentTableFilters, } from './incident_table/sagas'; import { @@ -185,6 +186,7 @@ export default function* rootSaga() { updateIncidentTableColumns(), updateIncidentTableState(), selectIncidentTableRows(), + clearIncidentTableFilters(), // Incident Actions doAction(), diff --git a/src/util/auth.test.js b/src/util/auth.test.js index 04c21100..022cba78 100644 --- a/src/util/auth.test.js +++ b/src/util/auth.test.js @@ -22,7 +22,7 @@ const unmockedFetch = global.fetch; describe('Authentication Helper Suite', () => { const clientId = PD_OAUTH_CLIENT_ID; const clientSecret = PD_OAUTH_CLIENT_SECRET; - const redirectURL = 'http://localhost:3000/'; + const redirectURL = 'http://127.0.0.1:3000/'; const code = 'SOME_REDIRECT_CODE'; const mockAccessToken = faker.string.alphanumeric(); let codeVerifier; diff --git a/vite.config.js b/vite.config.js index 1dfa1df9..8b0f5f1e 100644 --- a/vite.config.js +++ b/vite.config.js @@ -25,6 +25,7 @@ function fixAcceptHeader404() { export default defineConfig(() => ({ base: '/pd-live-react', server: { + host: '127.0.0.1', port: 3000, }, build: {