diff --git a/.github/workflows/ui.yml b/.github/workflows/ui.yml index 8133ab0..b31e709 100644 --- a/.github/workflows/ui.yml +++ b/.github/workflows/ui.yml @@ -53,7 +53,6 @@ jobs: run: yarn build:${{ inputs.environment }} - name: Get AWS Creds - if: github.actor != 'dependabot[bot]' uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.ROLEARN }} @@ -61,5 +60,4 @@ jobs: aws-region: us-east-1 - name: AWS Sync - if: github.actor != 'dependabot[bot]' run: aws s3 sync ./dist s3://${{ secrets.BUCKET_NAME }}/ --delete diff --git a/src/components/ConfirmDialog/ConfirmDialog.tsx b/src/components/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 0000000..a2ed0dc --- /dev/null +++ b/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,46 @@ +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogTitle from '@mui/material/DialogTitle' +import { Box, IconButton, Typography, Button } from '@mui/material' +import { CONFIRMATION_MESSAGE } from '@/constants' +import { Button as CmsButton } from '@cmsgov/design-system' + +import CloseIcon from '@mui/icons-material/Close' +type ConfirmDialogTypes = { + open: boolean + onClose: () => void + confirmClick: (confirm: boolean) => void +} + +const ConfirmDialog = ({ open, onClose, confirmClick }: ConfirmDialogTypes) => { + const handleConfirm = () => { + confirmClick(true) + onClose() + } + const handleClose = () => { + confirmClick(false) + onClose() + } + return ( + + Unsaved Changes + + + + + + + {CONFIRMATION_MESSAGE} + + + + Confirm + + Cancel + + + ) +} + +export default ConfirmDialog diff --git a/src/components/DialogTitle/CustomDialogTitle.tsx b/src/components/DialogTitle/CustomDialogTitle.tsx new file mode 100644 index 0000000..fe4ff63 --- /dev/null +++ b/src/components/DialogTitle/CustomDialogTitle.tsx @@ -0,0 +1,20 @@ +import * as React from 'react' +import DialogTitle from '@mui/material/DialogTitle' +import Typography from '@mui/material/Typography' +/** + * Component that renders the title of the dialog. + * @param {string} title - The title to be displayed. + * @returns {JSX.Element} Component that renders the title of the dialog. + */ +type CustomDialogTitleProps = { + title: string +} +export default function CustomDialogTitle({ title }: CustomDialogTitleProps) { + return ( + +
+ {title} +
+
+ ) +} diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx index 1967527..3bc0824 100644 --- a/src/components/ErrorBoundary.test.tsx +++ b/src/components/ErrorBoundary.test.tsx @@ -28,27 +28,6 @@ afterEach(() => { jest.clearAllMocks() }) -test('renders auth session error and navigate to logout after 5 seconds', () => { - const navigateMock = jest.fn().mockImplementation(() => ({})) - useNavigateMock.mockReturnValue(navigateMock) - isRouteErrorResponseMock.mockReturnValue(true) - routeErrorMock.mockReturnValue({ - status: 401, - statusText: 'Unauthorized', - data: 'Unauthorized', - }) - - render() - - expect( - screen.getByText(/Your session has expired. Please login again./i) - ).toBeInTheDocument() - - jest.advanceTimersByTime(5000) - - expect(navigateMock).toHaveBeenCalledWith(Routes.AUTH_LOGOUT) -}) - test('renders generic error message', () => { routeErrorMock.mockReturnValue(new Error('Something went wrong')) render() diff --git a/src/constants.ts b/src/constants.ts index 6648918..f35755e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,7 @@ * Application-wide constants * @module constants */ +import { userData } from './types' //* Application Strings export const ORG_NAME = 'CMS' @@ -21,3 +22,71 @@ export const ERROR_MESSAGES = { error: 'An error occurred. Please log in and try again. If the error persists, please contact support.', } +export const EMPTY_USER: userData = { + userid: '', + email: '', + fullname: '', + role: '', + assignedfismasystems: [], +} +export const CONFIRMATION_MESSAGE = + 'Your changes will not be saved! Are you sure you want to close out of editing a Fisma system before saving your changes?' + +export const PILLAR_FUNCTION_MAP: { [key: string]: string[] } = { + Identity: [ + 'AccessManagement', + 'Identity-AutomationOrchestration', + 'Identity-Governance', + 'IdentityStores-Users', + 'RiskAssessment', + 'Authentication-Users', + 'Identity-VisibilityAnalytics', + ], + Devices: [ + 'AssetRiskManagement', + 'Device-AutomationOrchestration', + 'Device-Governance', + 'DeviceThreatProtection', + 'PolicyEnforcement', + 'Device-VisibilityAnalytics', + 'ResourceAccess', + ], + Networks: [ + 'Network-AutomationOrchestration', + 'Network-Encryption', + 'Network-Governance', + 'NetworkResilience', + 'NetworkSegmentation', + 'NetworkTrafficManagement', + 'Network-VisibilityAnalytics', + ], + Applications: [ + 'AccessibleApplications', + 'AccessAuthorization-Users', + 'Application-AutomationOrchestration', + 'Application-Governance', + 'SecureDevDeployWorkflow', + 'ApplicationSecurityTesting', + 'AppThreatProtection', + 'Application-VisibilityAnalytics', + ], + Data: [ + 'DataAccess', + 'Data-AutomationOrchestration', + 'DataAvailability', + 'DataCategorization', + 'DataEncryption', + 'Data-Governance', + 'DataInventoryManagement', + 'Data-VisibilityAnalytics', + ], + CrossCutting: [ + 'Cross-AutomationOrchestration', + 'Cross-Governance', + 'Cross-VisibilityAnalytics', + ], +} +export const TEXTFIELD_HELPER_TEXT = 'This field is required' + +export const INVALID_INPUT_TEXT = (key: string) => + `Please provide a valid ${key}` diff --git a/src/types.ts b/src/types.ts index d7e0f1d..4c35dd2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,7 +34,7 @@ export type RequestOptions = { } export type FismaSystemType = { fismasystemid: number - fismauid: string | number + fismauid: string fismaacronym: string fismaname: string fismasubsystem: string @@ -43,6 +43,10 @@ export type FismaSystemType = { fismaimpactlevel: string issoemail: string datacenterenvironment: string + datacallcontact?: string + groupacronym?: string + groupname?: string + divisionname?: string } export type FismaSystems = { fismaSystems: FismaSystemType[] @@ -89,6 +93,14 @@ export type SystemDetailsModalProps = { onClose: () => void system: FismaSystemType | null } +export type editSystemModalProps = { + title: string + open: boolean + onClose: (data: FismaSystemType) => void + system: FismaSystemType | null + mode: string +} + export type ScoreData = { datacallid: number fismasystemid: number @@ -104,6 +116,26 @@ export type users = { isNew?: boolean } +export type datacall = { + datacallid: number + datacall: string + datecreated: number + deadline: number +} + +export type FormValidType = { + [key: string]: boolean +} + +export type FormValidHelperText = { + [key: string]: string +} + +export type FismaTableProps = { + scores: Record + latestDataCallId: number +} + export type ThemeColor = | 'primary' | 'secondary' diff --git a/src/views/EditSystemModal/EditSystemModal.tsx b/src/views/EditSystemModal/EditSystemModal.tsx new file mode 100644 index 0000000..b602ba7 --- /dev/null +++ b/src/views/EditSystemModal/EditSystemModal.tsx @@ -0,0 +1,550 @@ +import * as React from 'react' +import TextField from '@mui/material/TextField' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import CustomDialogTitle from '../../components/DialogTitle/CustomDialogTitle' +import { Button as CmsButton } from '@cmsgov/design-system' +import { Box, Grid } from '@mui/material' +import { + editSystemModalProps, + FismaSystemType, + FormValidType, + FormValidHelperText, +} from '@/types' +import MenuItem from '@mui/material/MenuItem' +import ValidatedTextField from './ValidatedTextField' +import { emailValidator } from './validators' +import { EMPTY_SYSTEM } from './emptySystem' +import { datacenterenvironment } from './dataEnvironment' +import CircularProgress from '@mui/material/CircularProgress' +import ConfirmDialog from '@/components/ConfirmDialog/ConfirmDialog' +import _ from 'lodash' +import axiosInstance from '@/axiosConfig' +import { useNavigate } from 'react-router-dom' +import { Routes } from '@/router/constants' +import { + ERROR_MESSAGES, + TEXTFIELD_HELPER_TEXT, + INVALID_INPUT_TEXT, +} from '@/constants' +import { useSnackbar } from 'notistack' +/** + * Component that renders a modal to edit fisma systems. + * @param {boolean, function, FismaSystemType} editSystemModalProps - props to get populate dialog and function . + * @returns {JSX.Element} Component that renders a dialog to edit details of a fisma systems. + */ + +export default function EditSystemModal({ + title, + open, + onClose, + system, + mode, +}: editSystemModalProps) { + const [formValid, setFormValid] = React.useState({ + issoemail: false, + datacallcontact: false, + fismaname: false, + fismaacronym: false, + datacenterenvironment: false, + component: false, + fismauid: false, + }) + const isFormValid = (): boolean => { + return Object.values(formValid).every((value) => value === true) + } + const navigate = useNavigate() + const { enqueueSnackbar } = useSnackbar() + const [loading, setLoading] = React.useState(true) + const [openAlert, setOpenAlert] = React.useState(false) + const [formValidErrorText, setFormValidErrorText] = + React.useState({ + issoemail: TEXTFIELD_HELPER_TEXT, + datacallcontact: TEXTFIELD_HELPER_TEXT, + fismaname: TEXTFIELD_HELPER_TEXT, + fismaacronym: TEXTFIELD_HELPER_TEXT, + datacenterenvironment: TEXTFIELD_HELPER_TEXT, + component: TEXTFIELD_HELPER_TEXT, + fismauid: TEXTFIELD_HELPER_TEXT, + }) + const handleConfirmReturn = (confirm: boolean) => { + if (confirm) { + onClose(EMPTY_SYSTEM) + } + } + const handleInputChange = ( + e: React.ChangeEvent, + key: string + ) => { + const value = e.target.value + const isValid = value.length > 0 + + setEditedFismaSystem((prevState) => ({ + ...prevState, + [key]: value, + })) + setFormValid((prevState) => ({ + ...prevState, + [key]: isValid, + })) + if (!isValid) { + setFormValidErrorText((prevState) => ({ + ...prevState, + [key]: isValid ? '' : TEXTFIELD_HELPER_TEXT, + })) + } + } + const [editedFismaSystem, setEditedFismaSystem] = + React.useState(EMPTY_SYSTEM) + React.useEffect(() => { + if (system && open) { + setFormValid((prevState) => ({ + ...prevState, + issoemail: + system?.issoemail && system?.issoemail.length > 0 ? true : false, + datacallcontact: + system?.datacallcontact && system?.datacallcontact.length > 0 + ? true + : false, + fismaname: + system?.fismaname && system?.fismaname.length > 0 ? true : false, + fismaacronym: + system?.fismaacronym && system?.fismaacronym.length > 0 + ? true + : false, + datacenterenvironment: + system?.datacenterenvironment && + system?.datacenterenvironment.length > 0 + ? true + : false, + component: + system?.component && system?.component.length > 0 ? true : false, + fismauid: + system?.fismauid && system?.fismauid.length > 0 ? true : false, + })) + setEditedFismaSystem(system) + setLoading(false) + } + }, [system, open]) + const handleClose = () => { + if (_.isEqual(system, editedFismaSystem)) { + onClose(editedFismaSystem) + } else { + setOpenAlert(true) + } + return + } + const handleSave = async () => { + if (mode === 'edit') { + await axiosInstance + .put(`fismasystems/${editedFismaSystem.fismasystemid}`, { + fismauid: editedFismaSystem.fismauid, + fismaacronym: editedFismaSystem.fismaacronym, + fismaname: editedFismaSystem.fismaname, + fismasubsystem: editedFismaSystem.fismasubsystem, + component: editedFismaSystem.component, + groupacronym: editedFismaSystem.groupacronym, + groupname: editedFismaSystem.groupname, + divisionname: editedFismaSystem.divisionname, + datacenterenvironment: editedFismaSystem.datacenterenvironment, + datacallcontact: editedFismaSystem.datacallcontact, + issoemail: editedFismaSystem.issoemail, + }) + .then((res) => { + if (res.status !== 200 && res.status.toString()[0] === '4') { + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.expired, + }, + }) + } + enqueueSnackbar(`Saved`, { + variant: 'success', + anchorOrigin: { + vertical: 'top', + horizontal: 'left', + }, + autoHideDuration: 1500, + }) + onClose(editedFismaSystem) + }) + .catch((error) => { + if (error.response.status === 400) { + const data: { [key: string]: string } = error.response.data.data + Object.entries(data).forEach(([key]) => { + // formValid.current[key] = false + setFormValid((prevState) => ({ + ...prevState, + [key]: false, + })) + setFormValidErrorText((prevState) => ({ + ...prevState, + [key]: INVALID_INPUT_TEXT(key), + })) + }) + enqueueSnackbar(`Not Saved`, { + variant: 'error', + anchorOrigin: { + vertical: 'top', + horizontal: 'left', + }, + autoHideDuration: 1500, + }) + } else { + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.error, + }, + }) + } + }) + } else if (mode === 'create') { + await axiosInstance + .post(`fismasystems`, { + fismauid: editedFismaSystem.fismauid, + fismaacronym: editedFismaSystem.fismaacronym, + fismaname: editedFismaSystem.fismaname, + fismasubsystem: editedFismaSystem.fismasubsystem, + component: editedFismaSystem.component, + groupacronym: editedFismaSystem.groupacronym, + groupname: editedFismaSystem.groupname, + divisionname: editedFismaSystem.divisionname, + datacenterenvironment: editedFismaSystem.datacenterenvironment, + datacallcontact: editedFismaSystem.datacallcontact, + issoemail: editedFismaSystem.issoemail, + }) + .then((res) => { + if (res.status !== 200 && res.status.toString()[0] === '4') { + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.expired, + }, + }) + } + enqueueSnackbar(`Created`, { + variant: 'success', + anchorOrigin: { + vertical: 'top', + horizontal: 'left', + }, + autoHideDuration: 1500, + }) + onClose(editedFismaSystem) + }) + .catch((error) => { + if (error.response.status === 400) { + const data: { [key: string]: string } = error.response.data.data + Object.entries(data).forEach(([key]) => { + // formValid.current[key] = false + setFormValid((prevState) => ({ + ...prevState, + [key]: false, + })) + setFormValidErrorText((prevState) => ({ + ...prevState, + [key]: INVALID_INPUT_TEXT(key), + })) + }) + enqueueSnackbar(`Not Created`, { + variant: 'error', + anchorOrigin: { + vertical: 'top', + horizontal: 'left', + }, + autoHideDuration: 1500, + }) + } else { + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.error, + }, + }) + } + }) + } + } + if (open && system) { + if (loading) { + return ( + + + + ) + } + return ( + <> + + + + + + + { + handleInputChange(e, 'fismaname') + }} + /> + { + handleInputChange(e, 'fismaacronym') + }} + /> + { + setEditedFismaSystem((prevState) => ({ + ...prevState, + groupacronym: e.target.value, + })) + }} + /> + { + handleInputChange(e, 'component') + }} + /> + { + setEditedFismaSystem((prevState) => ({ + ...prevState, + groupname: e.target.value, + })) + }} + /> + + { + setEditedFismaSystem((prevState) => ({ + ...prevState, + divisionname: e.target.value, + })) + }} + /> + { + setEditedFismaSystem((prevState) => ({ + ...prevState, + fismasubsystem: e.target.value, + })) + }} + /> + + + { + setFormValid((prevState) => ({ + ...prevState, + datacallcontact: isValid, + })) + if (isValid) { + setEditedFismaSystem((prevState) => ({ + ...prevState, + datacallcontact: newValue, + })) + } + }} + /> + { + setFormValid((prevState) => ({ + ...prevState, + issoemail: isValid, + })) + if (isValid) { + setEditedFismaSystem((prevState) => ({ + ...prevState, + issoemail: newValue, + })) + } + }} + /> + + { + handleInputChange(e, 'fismauid') + }} + /> + { + handleInputChange(e, 'datacenterenvironment') + }} + > + {datacenterenvironment.map((option) => ( + + {option.label} + + ))} + + + + + + + + {mode === 'edit' ? 'Save' : 'Create'} + + + Close + + + + setOpenAlert(false)} + confirmClick={handleConfirmReturn} + /> + + ) + } + return <> +} diff --git a/src/views/EditSystemModal/ValidatedTextField.tsx b/src/views/EditSystemModal/ValidatedTextField.tsx new file mode 100644 index 0000000..131f39d --- /dev/null +++ b/src/views/EditSystemModal/ValidatedTextField.tsx @@ -0,0 +1,57 @@ +import { useState, ChangeEvent } from 'react' +import { TextField } from '@mui/material' +import { useEffect, useRef } from 'react' +type ValidatedTextFieldProps = { + label: string + dfValue?: string + isFullWidth?: boolean + validator: (value: string) => string | false + onChange: (isValid: boolean, value: string) => void +} +const ValidatedTextField: React.FC = ({ + label, + validator, + isFullWidth, + dfValue, + onChange, +}) => { + const [value, setValue] = useState(dfValue || '') + const [error, setError] = useState(false) + const isInitialMount = useRef(true) + + useEffect(() => { + if (isInitialMount.current) { + isInitialMount.current = false + const errorMessage = validator(dfValue || '') + setError(errorMessage) + onChange(!errorMessage, dfValue || '') + } + }, [dfValue, validator, onChange]) + const handleChange = (e: ChangeEvent) => { + const newValue = e.target.value + const errorMessage = validator(newValue) + setValue(newValue) + setError(errorMessage) + onChange(!errorMessage, newValue) + } + + return ( + + ) +} +export default ValidatedTextField diff --git a/src/views/EditSystemModal/dataEnvironment.ts b/src/views/EditSystemModal/dataEnvironment.ts new file mode 100644 index 0000000..8333ee0 --- /dev/null +++ b/src/views/EditSystemModal/dataEnvironment.ts @@ -0,0 +1,39 @@ +/** + * datacenterenvironment values for the select dropdown + * to edit a system + */ + +export const datacenterenvironment = [ + { + value: 'AWS', + label: 'AWS', + }, + { + value: 'CMS-Cloud-AWS', + label: 'CMS-Cloud-AWS', + }, + { + value: 'CMSDC', + label: 'CMSDC', + }, + { + value: 'CMS-Cloud-MAG', + label: 'CMS-Cloud-MAG', + }, + { + value: 'DECOMMISSIONED', + label: 'DECOMMISSIONED', + }, + { + value: 'OPDC', + label: 'OPDC', + }, + { + value: 'Other', + label: 'Other', + }, + { + value: 'SaaS', + label: 'SaaS', + }, +] diff --git a/src/views/EditSystemModal/emptySystem.ts b/src/views/EditSystemModal/emptySystem.ts new file mode 100644 index 0000000..c3c7713 --- /dev/null +++ b/src/views/EditSystemModal/emptySystem.ts @@ -0,0 +1,16 @@ +export const EMPTY_SYSTEM = { + fismauid: '', + fismaacronym: '', + fismaname: '', + fismasubsystem: '', + fismaimpactlevel: '', + mission: '', + fismasystemid: 0, + component: '', + groupacronym: '', + groupname: '', + divisionname: '', + datacenterenvironment: '', + datacallcontact: '', + issoemail: '', +} diff --git a/src/views/EditSystemModal/validators.ts b/src/views/EditSystemModal/validators.ts new file mode 100644 index 0000000..f86ea3e --- /dev/null +++ b/src/views/EditSystemModal/validators.ts @@ -0,0 +1,8 @@ +export const emailValidator = (value: string): string | false => { + if (value.length === 0) { + return 'This field is required' + } + if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]+$/.test(value)) + return 'Invalid email address' + return false +} diff --git a/src/views/FismaTable/FismaTable.tsx b/src/views/FismaTable/FismaTable.tsx index 7749a28..2c917bc 100644 --- a/src/views/FismaTable/FismaTable.tsx +++ b/src/views/FismaTable/FismaTable.tsx @@ -4,34 +4,43 @@ import { GridColDef, GridFooterContainer, GridSlotsComponentsProps, + GridRenderCellParams, + GridActionsCellItem, GridFooter, GridRowId, + useGridApiRef, } from '@mui/x-data-grid' import Tooltip from '@mui/material/Tooltip' import { Box, IconButton } from '@mui/material' import { useState } from 'react' import FileDownloadSharpIcon from '@mui/icons-material/FileDownloadSharp' -import Link from '@mui/material/Link' import QuestionnareModal from '../QuestionnareModal/QuestionnareModal' +import EditSystemModal from '../EditSystemModal/EditSystemModal' import CustomSnackbar from '../Snackbar/Snackbar' import axiosInstance from '@/axiosConfig' - -type FismaTable2Props = { - fismaSystems: FismaSystemType[] - scores: Record -} +import { useContextProp } from '../Title/Context' +import { EMPTY_USER } from '../../constants' +import { useNavigate } from 'react-router-dom' +import { Routes } from '@/router/constants' +import { ERROR_MESSAGES } from '../../constants' +import EditIcon from '@mui/icons-material/Edit' +import QuestionAnswerOutlinedIcon from '@mui/icons-material/QuestionAnswerOutlined' +import { FismaTableProps } from '@/types' type selectedRowsType = GridRowId[] declare module '@mui/x-data-grid' { interface FooterPropsOverrides { selectedRows: selectedRowsType fismaSystems: FismaSystemType[] + latestDataCallId: number } } + export function CustomFooterSaveComponent( props: NonNullable ) { const [openSnackbar, setOpenSnackbar] = useState(false) + const navigate = useNavigate() const handleCloseSnackbar = () => { setOpenSnackbar(false) } @@ -39,7 +48,7 @@ export function CustomFooterSaveComponent( if (props.selectedRows && props.selectedRows.length === 0) { setOpenSnackbar(true) } else { - let exportUrl = '/datacalls/2/export' + let exportUrl = `/datacalls/${props.latestDataCallId}/export` if ( props.selectedRows && props.fismaSystems && @@ -63,23 +72,33 @@ export function CustomFooterSaveComponent( }) .then((response) => { if (response.status !== 200) { - console.log('Error saving systems') - } else { - const [, filename] = - response.headers['content-disposition'].split('filename=') - const contentType = response.headers['content-type'] - const data = new Blob([response.data], { type: contentType }) - const url = window.URL.createObjectURL(data) - const tempLink = document.createElement('a') - tempLink.href = url - tempLink.setAttribute('download', filename) - tempLink.setAttribute('target', '_blank') - tempLink.click() - window.URL.revokeObjectURL(url) + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.expired, + }, + }) } + const [, filename] = + response.headers['content-disposition'].split('filename=') + const contentType = response.headers['content-type'] + const data = new Blob([response.data], { type: contentType }) + const url = window.URL.createObjectURL(data) + const tempLink = document.createElement('a') + tempLink.href = url + tempLink.setAttribute('download', filename) + tempLink.setAttribute('target', '_blank') + tempLink.click() + window.URL.revokeObjectURL(url) }) .catch((error) => { console.error('Error saving system answers: ', error) + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.error, + }, + }) }) } } @@ -112,10 +131,17 @@ export function CustomFooterSaveComponent( ) } -export default function FismaTable({ fismaSystems, scores }: FismaTable2Props) { +export default function FismaTable({ + scores, + latestDataCallId, +}: FismaTableProps) { + const apiRef = useGridApiRef() + const { fismaSystems } = useContextProp() const [open, setOpen] = useState(false) + const { userInfo } = useContextProp() || EMPTY_USER const [selectedRow, setSelectedRow] = useState(null) const [selectedRows, setSelectedRows] = useState([]) + const [openEditModal, setOpenEditModal] = useState(false) const handleOpenModal = (row: FismaSystemType) => { setSelectedRow(row) setOpen(true) @@ -124,6 +150,24 @@ export default function FismaTable({ fismaSystems, scores }: FismaTable2Props) { setOpen(false) setSelectedRow(null) } + const handleEditOpenModal = ( + event: React.MouseEvent, + row: FismaSystemType + ) => { + event.stopPropagation() + setSelectedRow(row) + setOpenEditModal(true) + } + const handleCloseEditModal = (newRowData: FismaSystemType) => { + if (selectedRow) { + const row = apiRef.current.getRow(selectedRow?.fismasystemid) + if (row) { + apiRef.current.updateRows([newRowData]) + } + } + setOpenEditModal(false) + setSelectedRow(null) + } const columns: GridColDef[] = [ { field: 'fismaname', @@ -146,8 +190,12 @@ export default function FismaTable({ fismaSystems, scores }: FismaTable2Props) { renderCell: (params) => { const name = params.row.issoemail.split('@') const fullName = name[0].replace(/[0-9]/g, '').split('.') - const firstName = fullName[0][0].toUpperCase() + fullName[0].slice(1) - const lastName = fullName[1][0].toUpperCase() + fullName[1].slice(1) + let firstName = '' + let lastName = '' + if (fullName.length > 1) { + firstName = fullName[0][0].toUpperCase() + fullName[0].slice(1) + lastName = fullName[1][0].toUpperCase() + fullName[1].slice(1) + } return fullName.length > 1 ? `${firstName} ${lastName}` : fullName[0] }, }, @@ -206,18 +254,35 @@ export default function FismaTable({ fismaSystems, scores }: FismaTable2Props) { headerName: 'Actions', headerAlign: 'center', align: 'center', - flex: 1, + flex: 0.5, hideable: false, sortable: false, disableColumnMenu: true, - renderCell: (params) => ( - handleOpenModal(params.row as FismaSystemType)} - > - View Questionnare - + renderCell: (params: GridRenderCellParams) => ( + <> + + } + key={`question-${params.row.fismasystemid}`} + label="View Questionnare" + className="textPrimary" + onClick={() => handleOpenModal(params.row as FismaSystemType)} + color="inherit" + /> + + {userInfo.role === 'ADMIN' && ( + } + key={`edit-${params.row.fismasystemid}`} + label="Edit" + className="textPrimary" + onClick={(event) => + handleEditOpenModal(event, params.row as FismaSystemType) + } + color="inherit" + /> + )} + ), }, ] @@ -228,13 +293,14 @@ export default function FismaTable({ fismaSystems, scores }: FismaTable2Props) { rows={fismaSystems} columns={columns} checkboxSelection + apiRef={apiRef} getRowId={(row) => row.fismasystemid} onRowSelectionModelChange={(ids) => { const selectedIDs = Array.from(ids) setSelectedRows(selectedIDs) }} slotProps={{ - footer: { selectedRows, fismaSystems }, + footer: { selectedRows, fismaSystems, latestDataCallId }, filterPanel: { sx: { '& .MuiFormLabel-root': { @@ -280,6 +346,13 @@ export default function FismaTable({ fismaSystems, scores }: FismaTable2Props) { onClose={handleCloseModal} system={selectedRow} /> + ) } diff --git a/src/views/Home/Home.tsx b/src/views/Home/Home.tsx index 560e2df..52eff55 100644 --- a/src/views/Home/Home.tsx +++ b/src/views/Home/Home.tsx @@ -16,6 +16,7 @@ export default function HomePageContainer() { const navigate = useNavigate() const [fismaSystems, setFismaSystems] = useState([]) const [scoreMap, setScoreMap] = useState>({}) + const [latestDataCallId, setLatestDataCallId] = useState(0) useEffect(() => { async function fetchFismaSystems() { try { @@ -81,14 +82,40 @@ export default function HomePageContainer() { } fetchScores() }, [navigate]) + useEffect(() => { + async function fetchLatestDatacall() { + try { + axiosInstance.get('/datacalls').then((res) => { + if (res.status !== 200 && res.status.toString()[0] === '4') { + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.expired, + }, + }) + } + setLatestDataCallId(res.data.data[0].datacallid) + }) + } catch (error) { + console.error(error) + navigate(Routes.SIGNIN, { + replace: true, + state: { + message: ERROR_MESSAGES.error, + }, + }) + } + } + fetchLatestDatacall() + }, [navigate]) if (loading) { return
Loading...
} return ( <>
- - + +
) diff --git a/src/views/QuestionnareModal/QuestionnareModal.tsx b/src/views/QuestionnareModal/QuestionnareModal.tsx index 94ecda0..99936c5 100644 --- a/src/views/QuestionnareModal/QuestionnareModal.tsx +++ b/src/views/QuestionnareModal/QuestionnareModal.tsx @@ -30,7 +30,7 @@ import { Routes } from '@/router/constants' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import IconButton from '@mui/material/IconButton' import Tooltip from '@mui/material/Tooltip' -import { ERROR_MESSAGES } from '@/constants' +import { ERROR_MESSAGES, PILLAR_FUNCTION_MAP } from '@/constants' const CssTextField = styled(TextField)({ '& label.Mui-focused': { color: 'rgb(13, 36, 153)', @@ -265,13 +265,7 @@ export default function QuestionnareModal({ } return res.data.data }) - let latestDataCallId = -Infinity - for (let i = 0; i < datacall.length; i++) { - latestDataCallId = Math.max( - latestDataCallId, - datacall[i].datacallid - ) - } + const latestDataCallId = datacall[0].datacallid setDatacallID(latestDataCallId) await axiosInstance .get(`/fismasystems/${system.fismasystemid}/questions`) @@ -300,13 +294,31 @@ export default function QuestionnareModal({ const sortedPillars = Object.keys(organizedData).sort( (a, b) => pillarOrder[a] - pillarOrder[b] ) + const sortSteps = ( + steps: FismaQuestion[], + order: string[] + ): FismaQuestion[] => { + return steps.sort( + (a, b) => + order.indexOf(a.function.function) - + order.indexOf(b.function.function) + ) + } const categoriesData: Category[] = sortedPillars.map( (pillar) => ({ name: pillar, - steps: organizedData[pillar], + steps: sortSteps( + organizedData[pillar], + PILLAR_FUNCTION_MAP[pillar] + ), }) ) - + // const categoriesData: Category[] = sortedPillars.map( + // (pillar) => ({ + // name: pillar, + // steps: organizedData[pillar], + // }) + // ) setCategories(categoriesData) if (data.length > 0) { setQuestionId(categoriesData[0]['steps'][0].function.functionid) diff --git a/src/views/QuestionnareModal/__test__/QuestionnareModal.test.tsx b/src/views/QuestionnareModal/__test__/QuestionnareModal.test.tsx new file mode 100644 index 0000000..55e9681 --- /dev/null +++ b/src/views/QuestionnareModal/__test__/QuestionnareModal.test.tsx @@ -0,0 +1,26 @@ +import mockAxios from 'axios' +jest.mock('axios') +const mAxios = mockAxios as jest.Mocked + +const mockResponse = [ + { + function: { + datacenterenvironment: 'test-center-environment', + description: 'test description', + function: 'test function', + functiondid: 1, + }, + pillar: { + order: 1, + pillar: 'test pillar', + pillarid: 1, + }, + noteprompt: 'test note prompt', + order: 'test-order none', + question: 'test question', + questionid: 'test-question-id', + }, +] +it('test axios fetch', async () => { + mAxios.get.mockResolvedValueOnce({ data: mockResponse }) +}) diff --git a/src/views/StatisticBlocks/StatisticsBlocks.tsx b/src/views/StatisticBlocks/StatisticsBlocks.tsx index 2a90cf0..416c8c8 100644 --- a/src/views/StatisticBlocks/StatisticsBlocks.tsx +++ b/src/views/StatisticBlocks/StatisticsBlocks.tsx @@ -3,7 +3,7 @@ import Box from '@mui/material/Box' import Paper from '@mui/material/Paper' import { Typography } from '@mui/material' import { styled } from '@mui/material/styles' -import { FismaSystemType } from '@/types' +import { useContextProp } from '../Title/Context' const StatisticsPaper = styled(Paper)(({ theme }) => ({ width: 120, height: 120, @@ -14,12 +14,11 @@ const StatisticsPaper = styled(Paper)(({ theme }) => ({ elevation: 3, })) export default function StatisticsBlocks({ - fismaSystems, scores, }: { - fismaSystems: FismaSystemType[] scores: Record }) { + const { fismaSystems } = useContextProp() const [totalSystems, setTotalSystems] = useState(0) const [avgSystemScore, setAvgSystemScore] = useState(0) const [maxSystemAcronym, setMaxSystemAcronym] = useState('') @@ -49,11 +48,17 @@ export default function StatisticsBlocks({ } } } + if (totalCount === 0) { + setAvgSystemScore(0) + setMinSystemScore(0) + } else { + setAvgSystemScore(Number((totalScores / totalCount).toFixed(2))) + setMinSystemScore(minScore) + } setTotalSystems(totalCount) - setAvgSystemScore(Number((totalScores / totalCount).toFixed(2))) setMaxSystemScore(maxScore) setMaxSystemAcronym(maxScoreSystem || '') - setMinSystemScore(minScore) + setMinSystemAcronym(minScoreSystem || '') setLoading(false) }, [fismaSystems, scores]) diff --git a/src/views/Title/Context.ts b/src/views/Title/Context.ts index 8ca0616..d5b7356 100644 --- a/src/views/Title/Context.ts +++ b/src/views/Title/Context.ts @@ -1,8 +1,11 @@ import { useOutletContext } from 'react-router-dom' -import { FismaSystemType } from '@/types' +import { FismaSystemType, userData } from '@/types' -type ContextType = { fismaSystems: FismaSystemType[] | [] } +type ContextType = { + fismaSystems: FismaSystemType[] | [] + userInfo: userData +} -export function useFismaSystems() { +export function useContextProp() { return useOutletContext() } diff --git a/src/views/Title/Title.tsx b/src/views/Title/Title.tsx index e26d0c5..7aa8966 100644 --- a/src/views/Title/Title.tsx +++ b/src/views/Title/Title.tsx @@ -17,10 +17,15 @@ import { FismaSystemType } from '@/types' import { Routes } from '@/router/constants' import axiosInstance from '@/axiosConfig' import LoginPage from '../LoginPage/LoginPage' +import { ERROR_MESSAGES } from '@/constants' +import EditSystemModal from '../EditSystemModal/EditSystemModal' +import { EMPTY_SYSTEM } from '../EditSystemModal/emptySystem' +import _ from 'lodash' /** * Component that renders the contents of the Dashboard view. * @returns {JSX.Element} Component that renders the dashboard contents. */ + const emptyUser: userData = { userid: '', email: '', @@ -41,6 +46,7 @@ export default function Title() { const [anchorEl, setAnchorEl] = useState(null) const [fismaSystems, setFismaSystems] = useState([]) const [titlePage, setTitlePage] = useState('Dashboard') + const [openModal, setOpenModal] = useState(false) useEffect(() => { async function fetchFismaSystems() { try { @@ -48,7 +54,7 @@ export default function Title() { if (fismaSystems.status !== 200) { navigate(Routes.SIGNIN, { replace: true, - state: { message: 'Please log in' }, + state: ERROR_MESSAGES.expired, }) } setFismaSystems(fismaSystems.data.data) @@ -70,6 +76,13 @@ export default function Title() { const handleClose = () => { setAnchorEl(null) } + const handleCloseModal = (newRowData: FismaSystemType) => { + if (!_.isEqual(EMPTY_SYSTEM, newRowData)) { + setFismaSystems((prevFismSystems) => [...prevFismSystems, newRowData]) + } + setOpenModal(false) + handleClose() + } return ( <> @@ -143,6 +156,9 @@ export default function Title() { Edit Users + setOpenModal(true)}> + Add Fisma System + ) : ( @@ -164,8 +180,15 @@ export default function Title() { {loaderData.status !== 200 ? ( ) : ( - + )} + ) diff --git a/src/views/UserTable/UserTable.tsx b/src/views/UserTable/UserTable.tsx index 7a66dad..185dbfa 100644 --- a/src/views/UserTable/UserTable.tsx +++ b/src/views/UserTable/UserTable.tsx @@ -32,7 +32,7 @@ import Tooltip from '@mui/material/Tooltip' import './UserTable.css' import axiosInstance from '@/axiosConfig' import { users } from '@/types' -import { useFismaSystems } from '../Title/Context' +import { useContextProp } from '../Title/Context' import Box from '@mui/material/Box' import CustomSnackbar from '../Snackbar/Snackbar' import AssignSystemModal from '../AssignSystemModal/AssignSystemModal' @@ -95,7 +95,7 @@ export default function UserTable() { } const [rows, setRows] = useState([]) const [userId, setUserId] = useState('') - const { fismaSystems } = useFismaSystems() + const { fismaSystems } = useContextProp() const [rowModesModel, setRowModesModel] = useState({}) const [open, setOpen] = useState(false) const [openModal, setOpenModal] = useState(false) @@ -135,7 +135,7 @@ export default function UserTable() { apiRef.current.setEditCellValue({ id: selectedRow.userid, field: 'role', - value: selectedRow.role, // Replace with the desired value + value: selectedRow.role, }) } setOpenAlert(false)