diff --git a/package.json b/package.json index f528df1..8af7131 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@navi/dark-knight": "^1.0.13", - "@navi/web-ui": "^1.58.13", + "@navi/web-ui": "^1.59.2", "@reduxjs/toolkit": "^1.9.7", "@stoddabr/react-tableau-embed-live": "^0.3.26", "antd": "^5.9.4", diff --git a/src/Pages/Dashboard/partials/Date.tsx b/src/Pages/Dashboard/partials/Date.tsx index 405e54d..a3e6a11 100644 --- a/src/Pages/Dashboard/partials/Date.tsx +++ b/src/Pages/Dashboard/partials/Date.tsx @@ -55,6 +55,8 @@ const Date: React.FC = ({ const clearDate = () => { searchParams.delete('from'); searchParams.delete('to'); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); const updatedQuery = searchParams.toString(); updateURLAndFetchData(updatedQuery); }; @@ -70,6 +72,8 @@ const Date: React.FC = ({ searchParams.set('from', formattedStartDate); searchParams.set('to', formattedEndDate); searchParams.delete('incident_name'); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); clearSearchValue(); const updatedQuery = searchParams.toString(); updateURLAndFetchData(updatedQuery); diff --git a/src/Pages/Dashboard/partials/SmartSearch.tsx b/src/Pages/Dashboard/partials/SmartSearch.tsx index 9ada4a9..a2b965a 100644 --- a/src/Pages/Dashboard/partials/SmartSearch.tsx +++ b/src/Pages/Dashboard/partials/SmartSearch.tsx @@ -32,6 +32,8 @@ const SmartSearch: FC = ({ }; const clearSearch = () => { searchParams.delete('incident_name'); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); const updatedQuery = searchParams.toString(); updateURLAndFetchData(updatedQuery); }; @@ -41,6 +43,8 @@ const SmartSearch: FC = ({ clearSearch(); } else { searchParams.set('incident_name', searchValue); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); const updatedQuery = searchParams.toString(); updateURLAndFetchData(updatedQuery); } @@ -54,6 +58,8 @@ const SmartSearch: FC = ({ clearSearch(); } else { searchParams.set('incident_name', searchValue); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); const updatedQuery = searchParams.toString(); updateURLAndFetchData(updatedQuery); } diff --git a/src/Pages/Incidents/Incidents.module.scss b/src/Pages/Incidents/Incidents.module.scss index e55fe1a..309e996 100644 --- a/src/Pages/Incidents/Incidents.module.scss +++ b/src/Pages/Incidents/Incidents.module.scss @@ -183,3 +183,25 @@ .popup-style { display: inline; } + +.hint-text { + margin-top: 24px; + font-size: 12px; + color: var(--navi-color-blue-base); + border: none; + :hover { + cursor: pointer; + } +} + +.incident-input { + margin-top: 20px; + input { + color: var(--navi-color-gray-c2); + } +} + +.incident-label { + font-size: 13px; + font-weight: 400; +} diff --git a/src/Pages/Incidents/constants.ts b/src/Pages/Incidents/constants.ts index 91a6ce5..d030070 100644 --- a/src/Pages/Incidents/constants.ts +++ b/src/Pages/Incidents/constants.ts @@ -15,7 +15,7 @@ export const IncidentConstants = { incidentId: 'Incident Id', }; -const URL_PREFIX = createURL('/houston'); +export const URL_PREFIX = createURL('/houston'); export const FETCH_INCIDENT_DATA = (payload: any): string => { return `${URL_PREFIX}/incidents/${payload}`; @@ -38,6 +38,7 @@ export const FETCH_AUDIT_LOG = (incidentId: string): string => { return `${URL_PREFIX}/logs/incident/${incidentId}`; }; +export const incidentRegrex = /^_houston-0*[1-9][0-9]*$/; export interface ContentProps { incidentData: any; } @@ -64,7 +65,10 @@ export interface CreatedInfoProps { updatedAt: string; isLastItem: boolean; } - +export interface ResponseType { + data: ''; + status: number; +} export const actionTypes = { SET_INCIDENT_DATA: 'SET_INCIDENT_DATA', SET_HEADER_DATA: 'SET_HEADER_DATA', @@ -84,6 +88,10 @@ export const actionTypes = { SET_IS_SEVERITY_PICKER_OPEN: 'SET_IS_SEVERITY_PICKER_OPEN', SET_IS_STATUS_PICKER_OPEN: 'SET_IS_STATUS_PICKER_OPEN', SET_IS_TEAM_PICKER_OPEN: 'SET_IS_TEAM_PICKER_OPEN', + SET_DUPLICATE_DIALOG: 'SET_DUPLICATE_DIALOG', + SET_INCIDENT_NAME: 'SET_INCIDENT_NAME', + SET_ERROR_MSG: 'SET_ERROR_MSG', + RESET_DUPLICATE_DIALOG: 'RESET_DUPLICATE_DIALOG', }; export const reducer = (state, action) => { @@ -108,6 +116,8 @@ export const reducer = (state, action) => { return { ...state, totalLog: action.payload }; case actionTypes.SET_OPEN_DIALOG: return { ...state, openDialog: action.payload }; + case actionTypes.SET_DUPLICATE_DIALOG: + return { ...state, openDuplicateDialog: action.payload }; case actionTypes.SET_OPEN_CONFIRMATION_DIALOG: return { ...state, openConfirmationDialog: action.payload }; case actionTypes.SET_SELECTED_OPTION: @@ -124,6 +134,17 @@ export const reducer = (state, action) => { return { ...state, isStatusPickerOpen: action.payload }; case actionTypes.SET_IS_TEAM_PICKER_OPEN: return { ...state, isTeamPickerOpen: action.payload }; + case actionTypes.SET_INCIDENT_NAME: + return { ...state, incidentName: action.payload }; + case actionTypes.SET_ERROR_MSG: + return { ...state, errorMsg: action.payload }; + case actionTypes.RESET_DUPLICATE_DIALOG: + return { + ...state, + openDuplicateDialog: false, + incidentName: '', + errorMsg: '', + }; default: return state; } @@ -148,6 +169,9 @@ export const initialState = { isSeverityPickerOpen: false, isStatusPickerOpen: false, isTeamPickerOpen: false, + incidentName: '', + openDuplicateDialog: false, + errorMsg: '', }; export const RESOLVE_STATUS = '4'; diff --git a/src/Pages/Incidents/index.tsx b/src/Pages/Incidents/index.tsx index b543816..9cc61d1 100644 --- a/src/Pages/Incidents/index.tsx +++ b/src/Pages/Incidents/index.tsx @@ -1,12 +1,18 @@ import { FC, useEffect, useState, useReducer, MutableRefObject } from 'react'; import { useMatch } from 'react-router-dom'; import classnames from 'classnames'; +import { Tabs } from 'antd'; import { toast } from '@navi/web-ui/lib/primitives/Toast'; import { AlertOutlineIcon, ArrowDownIcon } from '@navi/web-ui/lib/icons'; -import TabItem from '@navi/web-ui/lib-esm/components/Tabs/TabItem'; -import Tabs from '@navi/web-ui/lib-esm/components/Tabs/Tabs'; import { SelectPicker } from '@navi/web-ui/lib/components'; -import { ModalDialog, Tooltip, Typography } from '@navi/web-ui/lib/primitives'; +import { + BorderedInput, + ModalDialog, + Tooltip, + Typography, + Button, +} from '@navi/web-ui/lib/primitives'; + import { SelectPickerOptionProps } from '@navi/web-ui/lib/components/SelectPicker/types'; import GoToLinkIcon from '@src/assets/GoToLinkIcon'; import { ApiService } from '@src/services/api'; @@ -34,15 +40,15 @@ import { StatusType, TeamType, SLACK_BASE_URL, + incidentRegrex, + ResponseType, } from './constants'; import styles from './Incidents.module.scss'; const Incident: FC = () => { const [state, dispatch] = useReducer(reducer, initialState); - const { fireEvent } = useClickStream(); const { EVENT_NAME, SCREEN_NAME } = CLICK_STREAM_EVENT_FACTORY; - const updatedSeverities = generateOptions(state.headerData?.severities); const updatedStatuses = generateOptions(state.headerData?.incidentStatuses); const updatedTeams = generateOptions(state.headerData?.teams); @@ -54,6 +60,8 @@ const Incident: FC = () => { path: '/incident/:incidentId', }); const incidentId = IncidentMatch?.params?.incidentId || ''; + const currentIncidentId = parseInt(incidentId, 10); + const incidentName = state.incidentName; const severityMap = state.headerData?.severities?.reduce((map, severity) => { map[severity.value] = severity.label; @@ -70,6 +78,7 @@ const Incident: FC = () => { map[Incidentteam.value] = Incidentteam.label; return map; }, {}); + const handleSevClickOutside = () => { dispatch({ type: actionTypes.SET_IS_SEVERITY_PICKER_OPEN, @@ -376,16 +385,6 @@ const Incident: FC = () => { payload: 'resolved', }); dispatch({ type: actionTypes.SET_OPEN_DIALOG, payload: true }); - } else if (value === DUPLICATE_STATUS) { - dispatch({ - type: actionTypes.SET_DIALOG_TEXT, - payload: 'Duplicate incident', - }); - dispatch({ - type: actionTypes.SET_DIALOG_BODY_TEXT, - payload: 'duplicate', - }); - dispatch({ type: actionTypes.SET_OPEN_DIALOG, payload: true }); } else { toast('Updating ticket. Please wait a moment.', { icon: , @@ -436,11 +435,16 @@ const Incident: FC = () => { payload: false, }); dispatch({ type: actionTypes.SET_IS_TEAM_PICKER_OPEN, payload: false }); - if ( - updateType === StatusType && - (selectedvalue === RESOLVE_STATUS || selectedvalue === DUPLICATE_STATUS) - ) { + if (updateType === StatusType && selectedvalue === RESOLVE_STATUS) { handleStatusSelectionChange(selectedOption); + } else if ( + updateType === StatusType && + selectedvalue === DUPLICATE_STATUS + ) { + dispatch({ + type: actionTypes.SET_DUPLICATE_DIALOG, + payload: true, + }); } else { dispatch({ type: actionTypes.SET_UPDATE_TYPE, payload: updateType }); dispatch({ @@ -508,6 +512,81 @@ const Incident: FC = () => { } `; } }; + const handleIncidentChange = ( + e: React.ChangeEvent, + ): void => { + const inputValue = e.target.value; + validateIncidentID(inputValue); + dispatch({ + type: actionTypes.SET_INCIDENT_NAME, + payload: inputValue, + }); + }; + const handleResetDialog = (): void => { + dispatch({ + type: actionTypes.RESET_DUPLICATE_DIALOG, + }); + }; + const validateIncidentID = (value: string): void => { + dispatch({ + type: actionTypes.SET_ERROR_MSG, + payload: !incidentRegrex.test(value) + ? 'Please enter a valid incident I.D.' + : '', + }); + }; + + const extractIncidentId = (incidentName: string): number | null => { + return (match => (match ? parseInt(match[1], 10) : null))( + incidentName.match(/_houston-(\d+)/), + ); + }; + const duplicateOfId = extractIncidentId(incidentName); + const disable = (): boolean => { + return !incidentName || !validate(incidentName); + }; + const goToIncident = (): void => { + if (state.incidentName) { + const incidentId = extractIncidentId(state.incidentName); + window.open(`/incident/${incidentId}`, '_blank'); + } + }; + const validate = (value: string): boolean => incidentRegrex.test(value); + const isDisabled = (): boolean => { + const incidentId = extractIncidentId(state.incidentName); + return !incidentId; + }; + + const markDuplicateIncident = (): void => { + const endPoint = UPDATE_INCIDENT; + toast('Updating ticket. Please wait a moment.', { + icon: , + }); + ApiService.post(endPoint, { + id: currentIncidentId, + status: DUPLICATE_STATUS, + duplicateOfId: duplicateOfId, + }) + .then((response: ResponseType) => { + if (response?.status === 200) { + const toastMessage = `This incident is marked as duplicate of _houston-${duplicateOfId}`; + toast.success(toastMessage); + startIncidentSearch(); + fetchIncidentLog(); + handleResetDialog(); + fetchParticipants(); + } + }) + .catch(error => { + const toastMessage = `${ + error?.response?.data?.error?.message + ? `${error?.response?.data?.error?.message}` + : 'Something went wrong. Please try again.' + }`; + toast.error(toastMessage); + startIncidentSearch(); + }); + }; return ( @@ -521,222 +600,232 @@ const Incident: FC = () => {
- -
-
-
-
- - UPDATE INCIDENT - - {!isUserParticipantList && ( -
- -
- -
-
-
- )} -
- -
-
-
+ defaultActiveKey="1" + items={[ + { + key: '1', + label: 'Details', + children: ( +
+
+
+
- Severity + UPDATE INCIDENT -
-
-
-
- + - {severityMap && state.severity?.value - ? severityMap[state.severity.value] - : '-'} - +
+ +
+
-
- -
-
-
- {state.isSeverityPickerOpen && ( -
- - handleOpenConfirmationDailog( - selectedOption, - SeverityType, - ) - } - options={updatedSeverities} - selectedValue={state.severity?.value.toString()} - /> -
- )} -
+ )}
-
-
-
- - Status - -
-
-
-
- - {incidentStatusMap && state.status?.value - ? incidentStatusMap[state.status.value] - : '-'} - -
-
- -
-
-
- {state.isStatusPickerOpen && ( -
- - handleOpenConfirmationDailog( - selectedOption, - StatusType, - ) - } - options={updatedStatuses} - selectedValue={state.status?.value.toString()} - /> -
- )} -
-
-
-
-
- - Team - -
-
-
-
- - {teamsMap && state.team?.value - ? teamsMap[state.team.value] - : '-'} - -
+
+
- + + Severity + +
+
+
+
+ + {severityMap && state.severity?.value + ? severityMap[state.severity.value] + : '-'} + +
+
+ +
+
+
+ {state.isSeverityPickerOpen && ( +
+ + handleOpenConfirmationDailog( + selectedOption, + SeverityType, + ) + } + options={updatedSeverities} + selectedValue={state.severity?.value.toString()} + /> +
+ )} +
- -
- {state.isTeamPickerOpen && ( -
+
+ - - handleOpenConfirmationDailog( - selectedOption, - TeamType, - ) - } - options={updatedTeams} - selectedValue={state.team?.value.toString()} - /> + Status + +
+
+
+
+ + {incidentStatusMap && state.status?.value + ? incidentStatusMap[state.status.value] + : '-'} + +
+
+ +
- )} +
+ {state.isStatusPickerOpen && ( +
+ + handleOpenConfirmationDailog( + selectedOption, + StatusType, + ) + } + options={updatedStatuses} + selectedValue={state.status?.value.toString()} + /> +
+ )} +
+
+
+
+
+ + Team + +
+
+
+
+ + {teamsMap && state.team?.value + ? teamsMap[state.team.value] + : '-'} + +
+ +
+ +
+
+ +
+ {state.isTeamPickerOpen && ( +
+ + handleOpenConfirmationDailog( + selectedOption, + TeamType, + ) + } + options={updatedTeams} + selectedValue={state.team?.value.toString()} + /> +
+ )} +
+
+ +
-
- { - - } +
- - -
-
- - + ), + }, + ]} + />
{open && ( { }, { label: 'Open Channel', - startAdornment: , + startAdornment: ( + + ), onClick: handleGoToSlackChannel, }, ]} @@ -761,6 +852,54 @@ const Incident: FC = () => { )} + {state.openDuplicateDialog ? ( + { + markDuplicateIncident(); + }, + }, + ]} + header={`Duplicate incident`} + onClose={handleResetDialog} + > + + Once marked as duplicate, this incident will be archived after 24 + hours. + +
+ } + /> +
+ + +
+ ) : null} {state.openDialog ? ( { { label: 'Go to slack channel', onClick: handleGoToSlackChannel, - startAdornment: , + startAdornment: ( + + ), }, ]} header={`${state.dialogText}`} diff --git a/src/Pages/JiraDashboard/Dashboard.module.scss b/src/Pages/JiraDashboard/Dashboard.module.scss new file mode 100644 index 0000000..b50c433 --- /dev/null +++ b/src/Pages/JiraDashboard/Dashboard.module.scss @@ -0,0 +1,73 @@ +.table-wrapper { + box-sizing: border-box; + padding-bottom: 24px; + margin-bottom: 70px; +} + +.dashboard-wrapper { + padding: 24px; +} + +.houston-id-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.id-wrapper { + display: flex; + align-items: center; + gap: 8px; + position: fixed; +} + +.houston-id-wrapper { + display: flex; + align-items: center; + gap: 8px; + + .hyperlink { + color: var(--navi-color-gray-c2); + text-decoration: none; + transition: color 0.3s; + } + + .go-to-link-icon { + display: none; + transition: opacity 0.3s; + } + + &:hover { + .hyperlink { + color: var(--navi-color-blue-base); + cursor: pointer; + } + + .go-to-link-icon { + display: inline-block; + opacity: 1; + padding-top: 8px; + } + } +} + +.alert-icon { + margin-top: 6px; +} + +.team-parent-style { + display: flex; + align-items: center; + position: fixed; +} + +.team-style { + display: flex; + align-items: center; + position: fixed; +} + +:export { + goToLinkColor: #0276fe; + alertIconRed: #e92c2c; +} diff --git a/src/Pages/JiraDashboard/SearchResultsTable.module.scss b/src/Pages/JiraDashboard/SearchResultsTable.module.scss new file mode 100644 index 0000000..11c2cbf --- /dev/null +++ b/src/Pages/JiraDashboard/SearchResultsTable.module.scss @@ -0,0 +1,59 @@ +.desktop-table-search-list { + & > * { + .pagination-wrapper-class { + overflow: visible; + + .incident-list-table-pagination { + border-radius: 0 0 8px 8px; + } + } + + .ag-cell-value { + overflow: visible; + } + + .ag-row { + z-index: 0; + + &:hover { + z-index: 1; + } + } + + .ag-row { + &.ag-row-focus { + z-index: 2; + } + } + + .ag-root-wrapper, + .ag-root, + .ag-body-viewport, + .ag-body-viewport-wrapper, + .ag-center-cols-clipper, + .ag-center-cols-viewport { + overflow: visible !important; + } + } +} + +.pagination-wrapper-class { + overflow: visible; + + .search-list-table-pagination { + border-radius: 0 0 8px 8px; + } +} + +.incident-search-list-table { + box-sizing: border-box; + margin-bottom: 56px; +} + +.drawer-wrapper { + width: 458px !important; +} + +:export { + sessionId: var(--navi-color-gray-c2); +} diff --git a/src/Pages/JiraDashboard/constants.ts b/src/Pages/JiraDashboard/constants.ts new file mode 100644 index 0000000..0072143 --- /dev/null +++ b/src/Pages/JiraDashboard/constants.ts @@ -0,0 +1,11 @@ +import { createURL } from '@src/services/globalUtils'; + +const URL_PREFIX = createURL('/houston'); + +export const DashboardHeaderConstants = { + title: 'JIRA tickets', +}; + +export const FETCH_JIRA_DATA = (payload: string): string => { + return `${URL_PREFIX}/get-jira-statuses?${payload}`; +}; diff --git a/src/Pages/JiraDashboard/index.tsx b/src/Pages/JiraDashboard/index.tsx new file mode 100644 index 0000000..1ec5e2f --- /dev/null +++ b/src/Pages/JiraDashboard/index.tsx @@ -0,0 +1,130 @@ +import { FC, useState, useEffect, useRef } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; +import { toast } from '@navi/web-ui/lib/primitives/Toast/index'; +import FallbackComponent from '@src/components/Fallback'; +import { + setJiraDashboardData, + setPageDetails, + setIsLoading, + setCurrentPageNumber, + setCurrentPageSize, +} from '@src/slices/jiraDashboardSlice'; +import { ApiService } from '@src/services/api'; +import { JiraDashboardData, JiraDashboardState } from '@src/types'; +import { FetchJiraDataProps } from './types'; +import { FETCH_JIRA_DATA } from './constants'; +import SearchResultsTable from './partials/SearchResultsTable'; +import DashboardHeader from './partials/DashboardHeader'; +import styles from './Dashboard.module.scss'; + +const JiraDashboard: FC = () => { + const data = useSelector( + (state: { jiraDashboard: { data: JiraDashboardData[] } }) => + state.jiraDashboard.data, + ); + const { pageDetails, isLoading, currentPageNumber, currentPageSize } = + useSelector( + (state: { jiraDashboard: JiraDashboardState }) => state.jiraDashboard, + ); + + const dispatch = useDispatch(); + const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); + + const startJiraSearch = (param: string): void => { + const endPoint = FETCH_JIRA_DATA(param); + dispatch(setIsLoading(true)); + ApiService.get(endPoint) + .then(response => { + dispatch(setIsLoading(false)); + dispatch(setJiraDashboardData(response?.data?.data)); + dispatch(setPageDetails(response?.data?.page)); + }) + .catch(error => { + const toastMessage = `${ + error?.response?.data?.error?.message + ? `${error?.response?.data?.error?.message},` + : '' + }`; + dispatch(setIsLoading(true)); + toast.error(toastMessage); + dispatch(setJiraDashboardData([])); + }); + }; + + useEffect(() => { + const pageNumberParam = searchParams.get('page_number'); + const pageSizeParam = searchParams.get('page_size'); + //TODO: creating a general util for below to be used across all both dashboards + if (pageNumberParam) { + dispatch(setCurrentPageNumber(Number(pageNumberParam))); + } else { + searchParams.set('page_number', '0'); + dispatch(setCurrentPageNumber(0)); + } + if (pageSizeParam) { + dispatch(setCurrentPageSize(Number(pageSizeParam))); + } else { + searchParams.set('page_size', '10'); + dispatch(setCurrentPageSize(10)); + } + const searchParam = searchParams.toString(); + updateURLAndFetchData(searchParam); + }, [searchParams]); + + const handlePageNumber = (pageNumber: number): void => { + searchParams.set('page_number', (pageNumber - 1).toString()); + const updatedQuery = searchParams.toString(); + updateURLAndFetchData(updatedQuery); + }; + + const handlePageSize = (pageSize: number): void => { + searchParams.set('page_size', pageSize.toString()); + const updatedQuery = searchParams.toString(); + updateURLAndFetchData(updatedQuery); + }; + + const fetchJiraData = (props: FetchJiraDataProps): void => { + const { filterQuery = '', isDrawer = false } = props; + const finalParams = filterQuery ? `${filterQuery}` : ''; + dispatch(setCurrentPageNumber(0)); + startJiraSearch(`${finalParams}`); + }; + + const updateURLAndFetchData = (updatedQuery: string): void => { + navigate({ + search: updatedQuery, + }); + fetchJiraData({ + filterQuery: updatedQuery, + }); + }; + + const returnTable = (): JSX.Element => { + if (isLoading) { + return ; + } + return ( +
+ +
+ ); + }; + + return ( +
+ + {returnTable()} +
+ ); +}; + +export default JiraDashboard; diff --git a/src/Pages/JiraDashboard/partials/DashboardHeader.module.scss b/src/Pages/JiraDashboard/partials/DashboardHeader.module.scss new file mode 100644 index 0000000..e36d964 --- /dev/null +++ b/src/Pages/JiraDashboard/partials/DashboardHeader.module.scss @@ -0,0 +1,50 @@ +.more-info-wrapper { + display: flex; + align-items: center; + gap: 12px; +} + +.filter-components-wrapper { + display: flex; + justify-content: space-between; +} + +.input-wrapper { + display: flex; + align-items: center; + width: 360px; + border-radius: 6px; + padding: 11px 0 11px 0; + &_search-title { + margin-right: 8px; + } + + &_on-error { + margin-top: -20px; + margin-right: 8px; + } + + input { + margin: 0 12px; + &:-ms-input-placeholder { + color: var(--navi-color-gray-c3) !important; + } + } +} + +.input-container { + min-width: 360px; + height: 36px; + &_disabled { + div { + cursor: not-allowed; + input { + cursor: not-allowed; + } + } + } + + * input::placeholder { + color: var(--navi-color-gray-c3) !important; + } +} diff --git a/src/Pages/JiraDashboard/partials/DashboardHeader.tsx b/src/Pages/JiraDashboard/partials/DashboardHeader.tsx new file mode 100644 index 0000000..c49700b --- /dev/null +++ b/src/Pages/JiraDashboard/partials/DashboardHeader.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Typography } from '@navi/web-ui/lib/primitives'; +import { DashboardHeaderConstants } from '../constants'; +import styles from './DashboardHeader.module.scss'; +import SmartSearch from './SmartSearch'; + +interface DashboardHeaderProps { + fetchJiraData: (payload: { filterQuery: string }) => void; +} + +const DashboardHeader: FC = ({ fetchJiraData }) => { + const { title } = DashboardHeaderConstants; + const [searchValue, setSearchValue] = useState(''); + const navigate = useNavigate(); + + const updateURLAndFetchData = (updatedQuery: string): void => { + navigate({ + search: updatedQuery, + }); + fetchJiraData({ + filterQuery: updatedQuery, + }); + }; + + return ( +
+
+ {title} +
+
+
+ +
+
+
+ ); +}; + +export default DashboardHeader; diff --git a/src/Pages/JiraDashboard/partials/HyperlinkCellRenderer.tsx b/src/Pages/JiraDashboard/partials/HyperlinkCellRenderer.tsx new file mode 100644 index 0000000..4becf79 --- /dev/null +++ b/src/Pages/JiraDashboard/partials/HyperlinkCellRenderer.tsx @@ -0,0 +1,32 @@ +import React, { FC } from 'react'; +import { useNavigate } from 'react-router-dom'; +import GoToLinkIcon from '@src/assets/GoToLinkIcon'; +import styles from '../Dashboard.module.scss'; + +interface HyperlinkCellRendererProps { + value: string; + id: number; +} + +const HyperlinkCellRenderer: FC = ({ + value, + id, +}) => { + const navigate = useNavigate(); + const handleClick = (): void => { + navigate(`/incident/${id}`); + }; + + return ( +
+
+ {value} +
+
+ +
+
+ ); +}; + +export default HyperlinkCellRenderer; diff --git a/src/Pages/JiraDashboard/partials/SearchResultsTable.tsx b/src/Pages/JiraDashboard/partials/SearchResultsTable.tsx new file mode 100644 index 0000000..b41f37f --- /dev/null +++ b/src/Pages/JiraDashboard/partials/SearchResultsTable.tsx @@ -0,0 +1,172 @@ +import { FC } from 'react'; +import { AgTable, Pagination } from '@navi/web-ui/lib/components'; +import { DropDownPosition } from '@navi/web-ui/lib/components/Pagination/constant'; +import { returnFormattedDate } from '@src/services/globalUtils'; +import { JiraDashboardData } from '@src/types'; +import cx from 'classnames'; +import { FetchJiraDataProps, JiraTableRow } from '../types'; +import HyperlinkCellRenderer from './HyperlinkCellRenderer'; +import TeamAssignedCellRenderer from './TeamAssignedCellRenderer'; +import TicketNameCell from './ticketNameCell'; +import styles from '../SearchResultsTable.module.scss'; + +interface SearchResultTableProps { + currentPageData: JiraDashboardData[]; + pageDetails: { + pageNumber: number; + totalElements: number; + }; + currentPageSize: number; + handlePageNumberChange: (pageNumber: number) => void; + handlePageSizeChange: (pageSize: number) => void; + fetchJiraData: (props: FetchJiraDataProps) => void; +} + +const defaultColDef = { + cellStyle: { lineHeight: 2 }, + wrapText: true, + autoHeight: true, + width: 200, +}; + +const SearchResultsTable: FC = ({ + currentPageData, + pageDetails, + currentPageSize, + handlePageNumberChange, + handlePageSizeChange, +}) => { + const { pageNumber, totalElements } = pageDetails; + + const cellStyle = { + 'text-overflow': 'ellipsis', + 'white-space': 'nowrap', + overflow: 'hidden', + enableColResize: true, + }; + + const rowData = currentPageData?.map((item: JiraTableRow, ind: number) => { + let index = 1; + + if (pageNumber === 0) { + index = 1; + } else if (pageNumber > 0) { + index = currentPageSize * pageNumber + 1; + } + return { + sNo: ind === 0 ? index : index + ind, + id: item?.incidentID, + incidentName: item?.incidentName, + ticketName: item?.jiraKey, + ticketType: item?.jiraType, + JiraStatus: item?.jiraStatus, + JiraDescription: item?.jiraSummary, + JiraAssignedTo: item?.teamsInvolved, + JiraCreatedOn: returnFormattedDate(item?.jiraCreateAt), + JiraLink: item?.jiraLink, + }; + }); + + const columnData = [ + { + field: 'incidentName', + headerName: 'Linked Houston I.D.', + suppressMovable: true, + cellRenderer: params => ( + + ), + width: 200, + }, + { + field: 'ticketName', + headerName: 'JIRA ticket I.D.', + suppressMovable: true, + cellRenderer: params => ( + + ), + }, + { + field: 'JiraStatus', + headerName: 'JIRA status', + suppressMovable: true, + }, + { + field: 'JiraDescription', + headerName: 'JIRA description', + suppressMovable: true, + width: 500, + cellStyle: { + 'text-overflow': 'ellipsis', + 'white-space': 'nowrap', + overflow: 'hidden', + }, + }, + { + field: 'JiraAssignedTo', + headerName: 'JIRA assigned to', + suppressMovable: true, + cellRenderer: params => ( + + ), + width: 300, + }, + { + field: 'JiraCreatedOn', + headerName: 'JIRA created on', + suppressMovable: true, + width: 200, + flex: 1, + }, + ]; + + const onGridSizeChanged = (params: { + api: { sizeColumnsToFit: () => void }; + }): void => { + if (params?.api?.sizeColumnsToFit) params.api.sizeColumnsToFit(); + }; + + return ( +
+ + } + columnDefs={columnData} + rowData={rowData} + theme="alpine" + sizeColumnsToFit={true} + domLayout="autoHeight" + paginationWrapperClasses={styles['pagination-wrapper-class']} + suppressCellFocus + defaultColDef={defaultColDef} + suppressRowHoverHighlight={true} + onGridSizeChanged={onGridSizeChanged} + suppressColumnMoveAnimation + rowHeight={54} + detailRowAutoHeight={true} + /> +
+ ); +}; + +export default SearchResultsTable; diff --git a/src/Pages/JiraDashboard/partials/SmartSearch.tsx b/src/Pages/JiraDashboard/partials/SmartSearch.tsx new file mode 100644 index 0000000..e686262 --- /dev/null +++ b/src/Pages/JiraDashboard/partials/SmartSearch.tsx @@ -0,0 +1,66 @@ +import React, { FC, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import cx from 'classnames'; +import { BorderedInput } from '@navi/web-ui/lib/primitives'; +import SearchIcon from '@navi/web-ui/lib/icons/SearchIcon/SearchIcon'; +import styles from './DashboardHeader.module.scss'; + +interface SmartSearchProps { + setSearchValue: (payload: string) => void; + searchValue: string; + updateURLAndFetchData: (payload: string) => void; +} + +const SmartSearch: FC = ({ + setSearchValue, + searchValue, + updateURLAndFetchData, +}) => { + const [searchParams] = useSearchParams(); + + useEffect(() => { + const searchParam = searchParams.get('incident_name'); + if (searchParam) { + setSearchValue(searchParam); + } else { + setSearchValue(''); + } + }, [searchParams]); + + const handleSearchInput = (e: React.ChangeEvent): void => { + setSearchValue(e.target.value); + }; + const clearSearch = () => { + searchParams.delete('incident_name'); + const updatedQuery = searchParams.toString(); + updateURLAndFetchData(updatedQuery); + }; + + const onKeyPressClickHandler = (event: React.KeyboardEvent): void => { + if (event.key === 'Enter') { + if (searchValue === '') { + clearSearch(); + } else { + searchParams.set('incident_name', searchValue); + const updatedQuery = searchParams.toString(); + updateURLAndFetchData(updatedQuery); + } + } + }; + + return ( +
+ } + fullWidth + value={searchValue} + onChange={handleSearchInput} + placeholder="Search by Houston I.D." + containerClassName={cx(styles['input-container'])} + onKeyPress={onKeyPressClickHandler} + /> +
+ ); +}; + +export default SmartSearch; diff --git a/src/Pages/JiraDashboard/partials/TeamAssignedCellRenderer.tsx b/src/Pages/JiraDashboard/partials/TeamAssignedCellRenderer.tsx new file mode 100644 index 0000000..cb56744 --- /dev/null +++ b/src/Pages/JiraDashboard/partials/TeamAssignedCellRenderer.tsx @@ -0,0 +1,45 @@ +import React, { FC } from 'react'; +import styles from '../Dashboard.module.scss'; +import { Tooltip, Typography } from '@navi/web-ui/lib/primitives'; + +interface TeamAssignedCellRendererProps { + teamsInvolved: string[]; +} + +const TeamAssignedCellRenderer: FC = ({ + teamsInvolved, +}) => { + const renderTeamNames = (): React.ReactNode => { + if (!teamsInvolved || teamsInvolved.length === 0) { + return null; + } + + const firstTeam = teamsInvolved[0]; + const remainingTeams = teamsInvolved.slice(1); + + if (remainingTeams.length === 0) { + return
{firstTeam}
; + } else { + const tooltipText = remainingTeams.join(', '); + + return ( +
+
+ {firstTeam} & +
+ + +  {remainingTeams.length} more + + +
+
+
+ ); + } + }; + + return
{renderTeamNames()}
; +}; + +export default TeamAssignedCellRenderer; diff --git a/src/Pages/JiraDashboard/partials/ticketNameCell.tsx b/src/Pages/JiraDashboard/partials/ticketNameCell.tsx new file mode 100644 index 0000000..faa4036 --- /dev/null +++ b/src/Pages/JiraDashboard/partials/ticketNameCell.tsx @@ -0,0 +1,75 @@ +import React, { FC } from 'react'; +import { AlertOutlineIcon } from '@navi/web-ui/lib/icons'; +import { Tooltip } from '@navi/web-ui/lib/primitives'; +import { + GoToLinkIcon, + StoryIcon, + SubTaskIcon, + TaskIcon, + EpicIcon, + BugIcon, + TechTaskIcon, +} from '@src/assets'; +import styles from '../Dashboard.module.scss'; + +const getTicketIcon = (ticketType: string): JSX.Element | null => { + switch (ticketType) { + case 'Story': + return ; + case 'Sub-task': + return ; + case 'Task': + return ; + case 'Epic': + return ; + case 'Bug': + return ; + case 'Tech Task': + return ; + default: + return ; + } +}; + +interface TicketNameCellProps { + tickerName: string; + ticketLink: string; + ticketType: string; +} + +const TicketNameCell: FC = ({ + tickerName, + ticketLink, + ticketType, +}) => { + const handleClick = (): void => { + window.open(ticketLink, '_blank'); + }; + return ( +
+
+ {ticketType ? ( + getTicketIcon(ticketType) + ) : ( + +
+ {getTicketIcon(ticketType)} +
+
+ )} +
+ {tickerName} +
+
+ +
+
+
+ ); +}; + +export default TicketNameCell; diff --git a/src/Pages/JiraDashboard/types.ts b/src/Pages/JiraDashboard/types.ts new file mode 100644 index 0000000..cf9fb4b --- /dev/null +++ b/src/Pages/JiraDashboard/types.ts @@ -0,0 +1,16 @@ +export type FetchJiraDataProps = { + filterQuery?: string; + isDrawer?: boolean; +}; + +export type JiraTableRow = { + incidentID: number; + incidentName: string; + jiraKey: string; + jiraLink: string; + jiraType: string; + jiraSummary: string; + jiraStatus: string; + teamsInvolved: string[]; + jiraCreateAt: string; +}; diff --git a/src/assets/JiraDashboardIcon.tsx b/src/assets/JiraDashboardIcon.tsx new file mode 100644 index 0000000..729cf1d --- /dev/null +++ b/src/assets/JiraDashboardIcon.tsx @@ -0,0 +1,64 @@ +import React, { FC } from 'react'; + +import { IconProps } from '@navi/web-ui/lib/icons/types'; + +const JiraDashboardIcon: FC = () => { + return ( + + + + + + + + + + + + + + + + + + + ); +}; + +export default JiraDashboardIcon; diff --git a/src/assets/JiraIcons/BugIcon.tsx b/src/assets/JiraIcons/BugIcon.tsx new file mode 100644 index 0000000..1542d73 --- /dev/null +++ b/src/assets/JiraIcons/BugIcon.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; + +import { IconProps } from '../types'; + +const BugIcon: FC = ({ + width = '16', + height = '16', + ...restProps +}) => { + return ( + + + + + + + + + + + ); +}; + +export default BugIcon; diff --git a/src/assets/JiraIcons/EpicIcon.tsx b/src/assets/JiraIcons/EpicIcon.tsx new file mode 100644 index 0000000..1ec527e --- /dev/null +++ b/src/assets/JiraIcons/EpicIcon.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; + +import { IconProps } from '../types'; + +const EpicIcon: FC = ({ + width = '16', + height = '16', + ...restProps +}) => { + return ( + + + + + + + + + + + ); +}; + +export default EpicIcon; diff --git a/src/assets/JiraIcons/StoryIcon.tsx b/src/assets/JiraIcons/StoryIcon.tsx new file mode 100644 index 0000000..05f941b --- /dev/null +++ b/src/assets/JiraIcons/StoryIcon.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; + +import { IconProps } from '../types'; + +const StoryIcon: FC = ({ + width = '16', + height = '16', + ...restProps +}) => { + return ( + + + + + + + + + + + ); +}; + +export default StoryIcon; diff --git a/src/assets/JiraIcons/SubTaskIcon.tsx b/src/assets/JiraIcons/SubTaskIcon.tsx new file mode 100644 index 0000000..cc13b4b --- /dev/null +++ b/src/assets/JiraIcons/SubTaskIcon.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; + +import { IconProps } from '../types'; + +const SubTaskIcon: FC = ({ + width = '16', + height = '16', + ...restProps +}) => { + return ( + + + + + + + + + + + ); +}; + +export default SubTaskIcon; diff --git a/src/assets/JiraIcons/TaskIcon.tsx b/src/assets/JiraIcons/TaskIcon.tsx new file mode 100644 index 0000000..b536156 --- /dev/null +++ b/src/assets/JiraIcons/TaskIcon.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; + +import { IconProps } from '../types'; + +const TaskIcon: FC = ({ + width = '16', + height = '16', + ...restProps +}) => { + return ( + + + + + + + + + + + ); +}; + +export default TaskIcon; diff --git a/src/assets/JiraIcons/TechTaskIcon.tsx b/src/assets/JiraIcons/TechTaskIcon.tsx new file mode 100644 index 0000000..750553f --- /dev/null +++ b/src/assets/JiraIcons/TechTaskIcon.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; + +import { IconProps } from '../types'; + +const TechTaskIcon: FC = ({ + width = '16', + height = '16', + ...restProps +}) => { + return ( + + + + + ); +}; + +export default TechTaskIcon; diff --git a/src/assets/index.ts b/src/assets/index.ts new file mode 100644 index 0000000..9bfc202 --- /dev/null +++ b/src/assets/index.ts @@ -0,0 +1,7 @@ +export { default as GoToLinkIcon } from './GoToLinkIcon'; +export { default as StoryIcon } from './JiraIcons/StoryIcon'; +export { default as SubTaskIcon } from './JiraIcons/SubTaskIcon'; +export { default as TaskIcon } from './JiraIcons/TaskIcon'; +export { default as EpicIcon } from './JiraIcons/EpicIcon'; +export { default as BugIcon } from './JiraIcons/BugIcon'; +export { default as TechTaskIcon } from './JiraIcons/TechTaskIcon'; diff --git a/src/components/FilterVerticalTabs/index.tsx b/src/components/FilterVerticalTabs/index.tsx index 36d4df0..9fd4d31 100644 --- a/src/components/FilterVerticalTabs/index.tsx +++ b/src/components/FilterVerticalTabs/index.tsx @@ -123,6 +123,8 @@ const FilterVerticalTabs: FC = ({ } selectedFilters.forEach(([key, value]) => { searchParams.set(key, [value].toString()); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); }); handleFetchData(searchParams.toString()); getSelectedFilterMap(selectedFiltersMap); @@ -227,6 +229,8 @@ const FilterVerticalTabs: FC = ({ searchParams.delete('team_ids'); searchParams.delete('statuses'); searchParams.delete('severity_ids'); + searchParams.set('page_number', '0'); + searchParams.set('page_size', '10'); handleFetchData(searchParams.toString()); }; diff --git a/src/components/LeftNav/index.tsx b/src/components/LeftNav/index.tsx index f8a7e0a..840cad3 100644 --- a/src/components/LeftNav/index.tsx +++ b/src/components/LeftNav/index.tsx @@ -10,11 +10,11 @@ import LeadIcon from '@navi/web-ui/lib/icons/LeadIcon'; import { NavItemType } from '@navi/web-ui/lib/components/Navbar/types'; import { Typography } from '@navi/web-ui/lib/primitives'; import { toast } from '@navi/web-ui/lib/primitives/Toast'; - +import JiraDashboardIcon from '@src/assets/JiraDashboardIcon'; import Dialog from '../Dialog'; import styles from './LeftNav.module.scss'; -import Footer from '../Footer'; import GroupIcon from '../../assets/GroupIcon'; + interface LeftNavProps { children?: React.ReactNode; } @@ -68,6 +68,13 @@ const LeftNav: React.FC = ({ children }) => { Icon: GroupIcon, handleNavigation: () => navigate('/metrics'), }, + { + itemType: 'simpleNavItem', + label: 'Jira dashboard', + route: '/jiraDashboard', + Icon: JiraDashboardIcon, + handleNavigation: () => navigate('/jiraDashboard'), + }, ]; const returnUserData = () => { diff --git a/src/router.tsx b/src/router.tsx index 3aa4847..f1c1761 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -5,6 +5,7 @@ const Incident = lazy(() => import('./Pages/Incidents/index')); const Team = lazy(() => import('./Pages/Team/index')); const Severity = lazy(() => import('./Pages/Severity/index')); const Tableau = lazy(() => import('./Pages/Tableau/index')); +const JiraDashboard = lazy(() => import('./Pages/JiraDashboard/index')); import { CustomRouteObject } from './types'; const routes: CustomRouteObject[] = [ @@ -33,6 +34,11 @@ const routes: CustomRouteObject[] = [ path: '/metrics', element: , }, + { + id: 'JIRA_DASHBOARD', + path: '/jiraDashboard', + element: , + }, ]; export default routes; diff --git a/src/services/globalUtils.ts b/src/services/globalUtils.ts index 52980a8..47eb489 100644 --- a/src/services/globalUtils.ts +++ b/src/services/globalUtils.ts @@ -24,7 +24,7 @@ export const convertToCamelCase = (input: string): string => { export const returnFormattedDate = (date: any): string => { if (!date) { - return '-'; + return ''; } const formattedDate = new Date(date); return `${formattedDate?.toLocaleDateString()} ${formattedDate?.toLocaleTimeString()}`; diff --git a/src/slices/jiraDashboardSlice.tsx b/src/slices/jiraDashboardSlice.tsx new file mode 100644 index 0000000..81d75a9 --- /dev/null +++ b/src/slices/jiraDashboardSlice.tsx @@ -0,0 +1,52 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { JiraDashboardState, JiraDashboardData, PageDetails } from '@src/types'; + +type SetJiraDashboardDataAction = PayloadAction; +type SetPageDetailsAction = PayloadAction; +type SetIsLoadingAction = PayloadAction; +type SetCurrentPageNumberAction = PayloadAction; +type SetCurrentPageSizeAction = PayloadAction; + +const initialState: JiraDashboardState = { + data: [], + pageDetails: { + pageNumber: 0, + pageSize: 10, + totalElements: 0, + }, + isLoading: false, + currentPageNumber: 0, + currentPageSize: 10, +}; + +const jiraDashboardSlice = createSlice({ + name: 'jiraDashboard', + initialState, + reducers: { + setJiraDashboardData: (state, action: SetJiraDashboardDataAction) => { + state.data = action.payload; + }, + setPageDetails: (state, action: SetPageDetailsAction) => { + state.pageDetails = action.payload; + }, + setIsLoading: (state, action: SetIsLoadingAction) => { + state.isLoading = action.payload; + }, + setCurrentPageNumber: (state, action: SetCurrentPageNumberAction) => { + state.currentPageNumber = action.payload; + }, + setCurrentPageSize: (state, action: SetCurrentPageSizeAction) => { + state.currentPageSize = action.payload; + }, + }, +}); + +export const { + setJiraDashboardData, + setPageDetails, + setIsLoading, + setCurrentPageNumber, + setCurrentPageSize, +} = jiraDashboardSlice.actions; + +export default jiraDashboardSlice.reducer; diff --git a/src/store/index.tsx b/src/store/index.tsx index 508e351..96c692b 100644 --- a/src/store/index.tsx +++ b/src/store/index.tsx @@ -2,11 +2,13 @@ import { configureStore } from '@reduxjs/toolkit'; import teamReducer from '../slices/teamSlice'; import severityReducer from '../slices/sevSlice'; import dashboardReducer from '../slices/dashboardSlice'; +import jiraDashboardReducer from '../slices/jiraDashboardSlice'; const store = configureStore({ reducer: { team: teamReducer, severity: severityReducer, dashboard: dashboardReducer, + jiraDashboard: jiraDashboardReducer, }, }); diff --git a/src/types/index.d.ts b/src/types/index.d.ts index d634852..d91b444 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -23,3 +23,33 @@ export interface CustomRouteObject { element: JSX.Element; } declare module '*.module.scss'; + +export interface JiraDashboardData { + incidentID: number; + incidentName: string; + jiraKey: string; + jiraLink: string; + jiraType: string; + jiraSummary: string; + jiraStatus: string; + teamsInvolved: string[]; + jiraCreateAt: string; +} + +export interface JiraDashboardState { + data: JiraDashboardData[]; + pageDetails: { + pageNumber: number; + pageSize: number; + totalElements: number; + }; + isLoading: boolean; + currentPageNumber: number; + currentPageSize: number; +} + +export interface PageDetails { + pageNumber: number; + pageSize: number; + totalElements: number; +}