From d7f6c60a359d21eb7f00fdf074d9faead4476936 Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Mon, 30 Jun 2025 16:26:11 -0400 Subject: [PATCH 1/7] Update importexport styling to match MUI --- .../components/ImportExport/ImportExport.tsx | 174 ++++++++++-------- 1 file changed, 98 insertions(+), 76 deletions(-) diff --git a/frontend/src/components/ImportExport/ImportExport.tsx b/frontend/src/components/ImportExport/ImportExport.tsx index 8e6069c80..61d2558dd 100644 --- a/frontend/src/components/ImportExport/ImportExport.tsx +++ b/frontend/src/components/ImportExport/ImportExport.tsx @@ -1,66 +1,69 @@ import React, { useRef, useState } from 'react'; -import { Button, Label, FormGroup } from '@trussworks/react-uswds'; -import { FileInput } from 'components'; -import { useAuthContext } from 'context'; +import { + Button, + Paper, + Typography, + FormGroup, + FormLabel, + Box +} from '@mui/material'; import Papa from 'papaparse'; import * as FileSaver from 'file-saver'; -import { Paper } from '@mui/material'; +import { FileInput } from 'components'; +import { useAuthContext } from 'context'; interface ImportProps { - // Plural name of the model. name: string; - - // Callback that handles data on import (usually saves the data). onImport: (e: T[]) => void; - fieldsToImport: string[]; } export interface ExportProps { - // Plural name of the model. name: string; - - // List of fields to export. fieldsToExport?: string[]; - - // Return data to be exported. getDataToExport: () => Partial[] | Promise[]> | Promise; } -// interface ImportExportProps extends ImportProps, ExportProps {} - export const Import = (props: ImportProps) => { const { setLoading } = useAuthContext(); const { name, onImport, fieldsToImport } = props; - const [selectedFile, setSelectedFile] = React.useState(null); - const [results, setResults] = React.useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [results, setResults] = useState(null); const fileInputRef = useRef(null); - const [key, setKey] = useState(Math.random().toString()); const parseCSV = async (event: React.ChangeEvent) => { - if (!event.target.files || !event.target.files.length) { - return; - } + if (!event.target.files?.length) return; + const file = event.target.files[0]; - setSelectedFile(file); // Store the selected file in state. + setSelectedFile(file); setLoading((l) => l + 1); - const parsedResults: T[] = await new Promise((resolve, reject) => - Papa.parse(event.target.files![0], { - header: true, - dynamicTyping: true, - complete: ({ data, errors }) => - errors.length ? reject(errors) : resolve(data as T[]) - }) - ); - setLoading((l) => l - 1); - setResults(parsedResults); // Store the parsed CSV in state. - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + const reader = new FileReader(); + reader.onload = async () => { + const text = (reader.result as string).replace(/^\uFEFF/, ''); // strip BOM + try { + const parsedResults: T[] = await new Promise((resolve, reject) => + Papa.parse(text, { + header: true, + dynamicTyping: true, + skipEmptyLines: true, + complete: ({ data, errors }) => + errors.length ? reject(errors) : resolve(data) + }) + ); + setResults(parsedResults); + console.log('Parsed Results:', parsedResults); + } catch (err) { + console.error('Parse error:', err); + } finally { + setLoading((l) => l - 1); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + reader.readAsText(file); }; - // Handle submission of uploaded CSV. + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (results) { @@ -69,54 +72,72 @@ export const Import = (props: ImportProps) => { } }; - // Clear uploaded file. const deleteFile = () => { setSelectedFile(null); setResults(null); - setKey(Math.random().toString()); // Change the key. - - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + setKey(Math.random().toString()); + if (fileInputRef.current) fileInputRef.current.value = ''; }; return (
-

Import {name}

+ + Import {name} + - - parseCSV(e)} - tabIndex={0} - aria-label={'Import CSV file'} - /> + + + The file must be in CSV format. + + + The header must be on the first line and include the following + fields: + + + {fieldsToImport.join(', ')} + + + + parseCSV(e)} + /> + {selectedFile && ( - -

+ + Selected file: {selectedFile.name} - -

+ +
)} -
@@ -131,15 +152,18 @@ export const exportCSV = async ( const filename = `${props.name}-${new Date().toISOString()}`; setLoading((l) => l + 1); const data = await props.getDataToExport(); + if (typeof data === 'string') { setLoading((l) => l - 1); window.open(data); return; } + const csv = Papa.unparse({ fields: props.fieldsToExport ?? [], - data: data + data }); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); FileSaver.saveAs(blob, `${filename}.csv`); setLoading((l) => l - 1); @@ -148,8 +172,6 @@ export const exportCSV = async ( export const ImportExport = (props: ImportProps) => { const { name, onImport, fieldsToImport } = props; return ( - <> - - + ); }; From e90f3e807c56274756b2f8de6711b26e70f0bff3 Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Mon, 30 Jun 2025 16:27:22 -0400 Subject: [PATCH 2/7] Remove import org via csv capability in the frontend --- .../src/pages/Organizations/Organizations.tsx | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/frontend/src/pages/Organizations/Organizations.tsx b/frontend/src/pages/Organizations/Organizations.tsx index 0a3679b7c..bcc133b2c 100644 --- a/frontend/src/pages/Organizations/Organizations.tsx +++ b/frontend/src/pages/Organizations/Organizations.tsx @@ -69,59 +69,6 @@ export const Organizations: React.FC = () => { ) : null} - {user?.user_type === 'globalAdmin' && ( - <> - - name="organizations" - fieldsToImport={[ - 'name', - 'acronym', - 'root_domains', - 'ip_blocks', - 'is_passive', - 'tags', - 'country', - 'state', - 'state_fips', - 'state_name', - 'county', - 'county_fips' - ]} - onImport={async (results) => { - // TODO: use a batch call here instead. - const createdOrganizations = []; - for (const result of results) { - try { - createdOrganizations.push( - await apiPost('/organizations_upsert/', { - body: { - ...result, - // These fields are initially parsed as strings, so they need - // to be converted to arrays. - ip_blocks: ( - (result.ip_blocks as unknown as string) || '' - ).split(','), - root_domains: ( - (result.root_domains as unknown as string) || '' - ).split(','), - tags: ((result.tags as unknown as string) || '') - .split(',') - .map((tag) => ({ - name: tag - })) - } - }) - ); - } catch (e: any) { - console.error('Error uploading Entry: ' + e); - } - } - setOrganizations(organizations.concat(...createdOrganizations)); - window.location.reload(); - }} - /> - - )} ); From e798c6916913fad929f0a1b0c337db5021099130 Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Tue, 1 Jul 2025 15:24:24 -0400 Subject: [PATCH 3/7] Clean up components folder --- .../AutoCompletedResults.tsx | 0 frontend/src/components/MTable.tsx | 80 --------- .../OrganizationList/OrganizationList.tsx | 159 ------------------ .../src/components/TablePaginationActions.tsx | 97 ----------- frontend/src/components/index.ts | 4 +- 5 files changed, 1 insertion(+), 339 deletions(-) rename frontend/src/components/{ => FilterDrawer}/AutoCompletedResults.tsx (100%) delete mode 100644 frontend/src/components/MTable.tsx delete mode 100644 frontend/src/components/OrganizationList/OrganizationList.tsx delete mode 100644 frontend/src/components/TablePaginationActions.tsx diff --git a/frontend/src/components/AutoCompletedResults.tsx b/frontend/src/components/FilterDrawer/AutoCompletedResults.tsx similarity index 100% rename from frontend/src/components/AutoCompletedResults.tsx rename to frontend/src/components/FilterDrawer/AutoCompletedResults.tsx diff --git a/frontend/src/components/MTable.tsx b/frontend/src/components/MTable.tsx deleted file mode 100644 index 3419cb37c..000000000 --- a/frontend/src/components/MTable.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { styled } from '@mui/material/styles'; -import { TableInstance } from 'react-table'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableRow, - TableFooter, - TableProps -} from '@mui/material'; - -const PREFIX = 'MTable'; - -const classes = { - head: `${PREFIX}-head`, - cell: `${PREFIX}-cell` -}; - -const StyledTable = styled(Table)(({ theme }) => ({ - [`& .${classes.head}`]: { - backgroundColor: '#E8EAEC' - }, - - [`& .${classes.cell}`]: { - fontSize: '1rem' - } -})); - -interface Props extends TableProps { - instance: TableInstance; - footerRows?: React.ReactNode; -} - -export const MTable = (props: Props) => { - const { instance, footerRows, ...rest } = props; - - return ( - - - {instance.headerGroups.map((group) => ( - - {group.headers.map((column) => ( - - {column.render('Header')} - - ))} - - ))} - - - {instance.rows.map((row) => { - instance.prepareRow(row); - const { key, ...rest } = row.getRowProps(); - return ( - - - {row.cells.map((cell) => ( - - {cell.render('Cell')} - - ))} - - - ); - })} - - {footerRows && {footerRows}} - - ); -}; diff --git a/frontend/src/components/OrganizationList/OrganizationList.tsx b/frontend/src/components/OrganizationList/OrganizationList.tsx deleted file mode 100644 index c7e9db0e1..000000000 --- a/frontend/src/components/OrganizationList/OrganizationList.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import EditNoteOutlinedIcon from '@mui/icons-material/EditNoteOutlined'; -import { Organization } from 'types'; -import { Button, IconButton, Paper, Typography } from '@mui/material'; -import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; -import { useHistory } from 'react-router-dom'; -import { Add, CheckCircleOutline } from '@mui/icons-material'; -import { OrganizationForm } from 'components/OrganizationForm'; -import { useAuthContext } from 'context'; -import CustomToolbar from 'components/DataGrid/CustomToolbar'; -import InfoDialog from 'components/Dialog/InfoDialog'; - -export const OrganizationList: React.FC<{ - parent?: Organization; -}> = ({ parent }) => { - const { apiPost, apiGet, setFeedbackMessage, user } = useAuthContext(); - const [organizations, setOrganizations] = useState([]); - const [dialogOpen, setDialogOpen] = useState(false); - const [infoDialogOpen, setInfoDialogOpen] = useState(false); - const [chosenTags, setChosenTags] = useState([]); - const history = useHistory(); - const region_id = user?.region_id; - - const getOrgsUrl = () => { - if (user?.user_type === 'regionalAdmin') { - return `/organizations/region_id/${region_id}`; - } else { - return `/v2/organizations`; - } - }; - - const orgsUrl = getOrgsUrl() as string; - - const orgCols: GridColDef[] = [ - { field: 'name', headerName: 'Organization', minWidth: 100, flex: 2 }, - { field: 'state', headerName: 'State', minWidth: 100, flex: 1 }, - { field: 'region_id', headerName: 'Region', minWidth: 100, flex: 1 }, - { - field: 'view', - headerName: 'View/Edit', - minWidth: 100, - flex: 1, - disableExport: true, - renderCell: (cellValues: GridRenderCellParams) => { - const ariaLabel = `View or edit organization ${cellValues.row.name}`; - const descriptionId = `description-${cellValues.row.id}`; - return ( - <> - - {`Edit details for organization ${cellValues.row.name}`} - - - history.push('/organizations/' + cellValues.row.id) - } - > - - - - ); - } - } - ]; - - const onSubmit = async (body: Object) => { - try { - const org = await apiPost('/organizations', { - body - }); - setOrganizations(organizations.concat(org)); - setInfoDialogOpen(true); - } catch (e: any) { - setFeedbackMessage({ - message: - e.status === 422 - ? 'Error when submitting organization entry.' - : (e.message ?? e.toString()), - type: 'error' - }); - setChosenTags([]); - console.error(e); - } - }; - - const fetchOrganizations = useCallback(async () => { - try { - const rows = await apiGet(orgsUrl); - setOrganizations(rows); - } catch (e) { - console.error(e); - } - }, [apiGet, orgsUrl]); - - useEffect(() => { - if (!parent) fetchOrganizations(); - else { - setOrganizations(parent.children); - } - }, [fetchOrganizations, parent]); - - const addOrgButton = user?.user_type === 'globalAdmin' && ( - - ); - - return ( - <> - - - - - { - setInfoDialogOpen(false); - setChosenTags([]); - }} - icon={} - title={Success } - content={ - - The new organization was successfully added. - - } - /> - - ); -}; diff --git a/frontend/src/components/TablePaginationActions.tsx b/frontend/src/components/TablePaginationActions.tsx deleted file mode 100644 index d14affb68..000000000 --- a/frontend/src/components/TablePaginationActions.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { styled } from '@mui/material/styles'; -import { IconButton } from '@mui/material'; -import { - KeyboardArrowLeft, - KeyboardArrowRight, - LastPage as LastPageIcon, - FirstPage as FirstPageIcon -} from '@mui/icons-material'; - -const PREFIX = 'TablePaginationActions'; - -const classes = { - root: `${PREFIX}-root` -}; - -const Root = styled('div')(({ theme }) => ({ - [`&.${classes.root}`]: { - flexShrink: 0, - marginLeft: theme.spacing(2.5) - } -})); - -interface Props { - count: number; - page: number; - rowsPerPage: number; - onChangePage: ( - event: React.MouseEvent, - newPage: number - ) => void; -} - -export const TablePaginationActions = (props: Props) => { - const { count, page, rowsPerPage, onChangePage } = props; - - const handleFirstPageButtonClick = ( - event: React.MouseEvent - ) => { - onChangePage(event, 0); - }; - - const handleBackButtonClick = ( - event: React.MouseEvent - ) => { - onChangePage(event, page - 1); - }; - - const handleNextButtonClick = ( - event: React.MouseEvent - ) => { - onChangePage(event, page + 1); - }; - - const handleLastPageButtonClick = ( - event: React.MouseEvent - ) => { - onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); - }; - - return ( - - - - - - - - = Math.ceil(count / rowsPerPage) - 1} - aria-label="next page" - size="large" - > - - - = Math.ceil(count / rowsPerPage) - 1} - aria-label="last page" - size="large" - > - - - - ); -}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index 021f25338..6e98c2525 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -11,11 +11,9 @@ export * from './FileInput'; export * from './ImportExport'; export * from './RouteGuard'; export * from './FilterDrawer/SearchBar'; -export * from './MTable'; -export * from './TablePaginationActions'; export * from './TaggedInput'; export * from '../pages/Domains/DomainDetails'; -export * from './AutoCompletedResults'; +export * from './FilterDrawer/AutoCompletedResults'; export * from './FilterDrawer/FacetFilter'; export * from './FindingsLibrary/Subnav'; export * from './ModalToggleButton'; From db63756f625740b2b50285ca7fc9733f77bfd4ca Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Tue, 1 Jul 2025 15:25:32 -0400 Subject: [PATCH 4/7] Remove add organization in orgs table --- .../src/components/OrganizationList/index.ts | 1 - .../src/pages/Organizations/Organizations.tsx | 203 ++++++++++++++---- 2 files changed, 157 insertions(+), 47 deletions(-) delete mode 100644 frontend/src/components/OrganizationList/index.ts diff --git a/frontend/src/components/OrganizationList/index.ts b/frontend/src/components/OrganizationList/index.ts deleted file mode 100644 index 0f38a0cd7..000000000 --- a/frontend/src/components/OrganizationList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './OrganizationList'; diff --git a/frontend/src/pages/Organizations/Organizations.tsx b/frontend/src/pages/Organizations/Organizations.tsx index bcc133b2c..ea1e7064a 100644 --- a/frontend/src/pages/Organizations/Organizations.tsx +++ b/frontend/src/pages/Organizations/Organizations.tsx @@ -1,20 +1,47 @@ -import React, { useCallback, useState } from 'react'; -import { ImportExport } from 'components'; +import React, { useCallback, useEffect, useState } from 'react'; +import EditNoteOutlinedIcon from '@mui/icons-material/EditNoteOutlined'; import { Organization } from 'types'; import { useAuthContext } from 'context'; -import { OrganizationList } from 'components/OrganizationList'; -import { Alert, Box, Button, Paper, Stack, Typography } from '@mui/material'; +import { + Alert, + Box, + Button, + IconButton, + Paper, + Stack, + Typography +} from '@mui/material'; +import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { useHistory } from 'react-router-dom'; +import { CheckCircleOutline } from '@mui/icons-material'; +import { OrganizationForm } from 'components/OrganizationForm'; +import CustomToolbar from 'components/DataGrid/CustomToolbar'; +import InfoDialog from 'components/Dialog/InfoDialog'; export const Organizations: React.FC = () => { - const { user, apiGet, apiPost } = useAuthContext(); + const { apiGet, apiPost, setFeedbackMessage, user } = useAuthContext(); const [organizations, setOrganizations] = useState([]); const [loadingError, setLoadingError] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [dialogOpen, setDialogOpen] = useState(false); + const [infoDialogOpen, setInfoDialogOpen] = useState(false); + const [chosenTags, setChosenTags] = useState([]); + const history = useHistory(); + const region_id = user?.region_id; + + const getOrgsUrl = () => { + if (user?.user_type === 'regionalAdmin') { + return `/organizations/region_id/${region_id}`; + } + return `/v2/organizations`; + }; + const orgsUrl = getOrgsUrl(); const fetchOrganizations = useCallback(async () => { setIsLoading(true); + setLoadingError(false); try { - const rows = await apiGet('/v2/organizations'); + const rows = await apiGet(orgsUrl); setOrganizations(rows); } catch (e) { console.error(e); @@ -22,54 +49,138 @@ export const Organizations: React.FC = () => { } finally { setIsLoading(false); } - }, [apiGet]); + }, [apiGet, orgsUrl]); - React.useEffect(() => { + useEffect(() => { fetchOrganizations(); - }, [apiGet, fetchOrganizations]); + }, [fetchOrganizations]); + + const orgCols: GridColDef[] = [ + { field: 'name', headerName: 'Organization', minWidth: 100, flex: 2 }, + { field: 'state', headerName: 'State', minWidth: 100, flex: 1 }, + { field: 'region_id', headerName: 'Region', minWidth: 100, flex: 1 }, + { + field: 'view', + headerName: 'View/Edit', + minWidth: 100, + flex: 1, + disableExport: true, + renderCell: (cellValues: GridRenderCellParams) => { + const ariaLabel = `View or edit organization ${cellValues.row.name}`; + const descriptionId = `description-${cellValues.row.id}`; + return ( + <> + + {`Edit details for organization ${cellValues.row.name}`} + + + history.push('/organizations/' + cellValues.row.id) + } + > + + + + ); + } + } + ]; + + const onSubmit = async (body: Object) => { + try { + const org = await apiPost('/organizations', { body }); + setOrganizations((prev) => [...prev, org]); + setInfoDialogOpen(true); + } catch (e: any) { + setFeedbackMessage({ + message: + e.status === 422 + ? 'Error when submitting organization entry.' + : (e.message ?? e.toString()), + type: 'error' + }); + setChosenTags([]); + console.error(e); + } + }; return ( - - + - - Organizations - - - {isLoading ? ( + Organizations + + + {isLoading ? ( + + Loading Organizations... + + ) : loadingError ? ( + - Loading Organizations.. + Error Loading Organizations! - ) : isLoading === false && loadingError === true ? ( - - - Error Loading Organizations! - - - - ) : isLoading === false && loadingError === false ? ( - - ) : null} - + + + ) : ( + + + + )} + + { + setInfoDialogOpen(false); + setChosenTags([]); + }} + icon={} + title={Success } + content={ + + The new organization was successfully added. + + } + /> ); }; From 10dba9065dcca999da07982ff750628bd26c1dc5 Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Tue, 1 Jul 2025 15:57:18 -0400 Subject: [PATCH 5/7] Update add org tag functionality --- frontend/src/pages/Organization/ListInput.tsx | 114 +++++ .../src/pages/Organization/OrgSettings.tsx | 447 ++++++++---------- 2 files changed, 309 insertions(+), 252 deletions(-) create mode 100644 frontend/src/pages/Organization/ListInput.tsx diff --git a/frontend/src/pages/Organization/ListInput.tsx b/frontend/src/pages/Organization/ListInput.tsx new file mode 100644 index 000000000..11eaf9531 --- /dev/null +++ b/frontend/src/pages/Organization/ListInput.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Grid, Chip, Typography } from '@mui/material'; +import { PendingDomain, OrganizationTag } from 'types'; + +export interface ListInputProps { + type: 'root_domains' | 'ip_blocks' | 'tags'; + label: string; + organization: any; + userType?: string; + setOrganization: Function; + setDialog: (dialog: any) => void; + setInputValue: (val: string) => void; + setIsSaveDisabled: (val: boolean) => void; + chosenTags?: string[]; + setChosenTags?: (val: string[]) => void; + localTags?: string[]; + setLocalTags?: (tags: string[]) => void; +} + +const ListInput: React.FC = ({ + type, + label, + organization, + userType, + setOrganization, + setDialog, + setInputValue, + setIsSaveDisabled, + chosenTags, + setChosenTags, + localTags, + setLocalTags +}) => { + if (!organization) return null; + + const values: (string | OrganizationTag)[] = organization[type]; + + const handleDelete = (index: number) => { + const updated = [...values]; + updated.splice(index, 1); + setOrganization({ ...organization, [type]: updated }); + + if ( + type === 'tags' && + chosenTags && + setChosenTags && + localTags && + setLocalTags + ) { + const updatedTags = [...chosenTags]; + updatedTags.splice(index, 1); + setChosenTags(updatedTags); + setLocalTags(updatedTags); + } + + setIsSaveDisabled(false); + }; + + return ( + + + {label} + + {values.map((value, index) => ( + + handleDelete(index)} + disabled={userType === 'globalView'} + /> + + ))} + {type === 'root_domains' && + organization.pending_domains?.map( + (domain: PendingDomain, index: number) => ( + + { + const updated = organization.pending_domains.filter( + (_: any, i: number) => i !== index + ); + setOrganization({ + ...organization, + pending_domains: updated + }); + }} + onClick={() => { + setInputValue(domain.name); + setDialog({ open: true, type, label, stage: 1 }); + }} + disabled={userType === 'globalView'} + /> + + ) + )} + {(type === 'root_domains' || userType === 'globalAdmin') && ( + + setDialog({ open: true, type, label, stage: 0 })} + disabled={userType === 'globalView'} + /> + + )} + + ); +}; + +export default ListInput; diff --git a/frontend/src/pages/Organization/OrgSettings.tsx b/frontend/src/pages/Organization/OrgSettings.tsx index 1f9f59f69..15b2577e5 100644 --- a/frontend/src/pages/Organization/OrgSettings.tsx +++ b/frontend/src/pages/Organization/OrgSettings.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useAuthContext } from 'context'; import { PendingDomain, @@ -8,6 +8,7 @@ import { import { Alert, Autocomplete, + Box, Button, Chip, Dialog, @@ -23,6 +24,7 @@ import { } from '@mui/material'; import { CheckCircleOutline, Place, Public } from '@mui/icons-material'; import InfoDialog from 'components/Dialog/InfoDialog'; +import ListInput from './ListInput'; interface AutocompleteType extends Partial { title?: string; @@ -49,23 +51,24 @@ export const OrgSettings: React.FC = ({ tags }) => { const { apiPut, apiPost, user, setFeedbackMessage } = useAuthContext(); - const [inputValue, setInputValue] = React.useState(''); - const [dialog, setDialog] = React.useState<{ + const [inputValue, setInputValue] = useState(''); + const [dialog, setDialog] = useState<{ open: boolean; - type?: 'root_domains' | 'ip_blocks' | 'tags'; + type?: string; label?: string; stage?: number; domainVerificationStatusMessage?: string; }>({ open: false }); - const [isSaveDisabled, setIsSaveDisabled] = React.useState(true); - const [infoDialogOpen, setInfoDialogOpen] = React.useState(false); - const [chosenTags, setChosenTags] = React.useState( - organization.tags ? organization.tags.map((tag) => tag.name) : [] + const [isSaveDisabled, setIsSaveDisabled] = useState(true); + const [infoDialogOpen, setInfoDialogOpen] = useState(false); + const [chosenTags, setChosenTags] = useState( + () => organization.tags?.map((t) => t.name) || [] ); + const [localTags, setLocalTags] = useState(chosenTags); - const updateOrganization = async (body: any) => { + const updateOrganization = async () => { try { - const org = await apiPut('/organizations/' + organization?.id, { + const org = await apiPut(`/organizations/${organization.id}`, { body: organization }); setOrganization(org); @@ -79,7 +82,7 @@ export const OrgSettings: React.FC = ({ message: e.status === 422 ? 'Error updating organization' - : (e.message ?? e.toString()), + : e.message || e.toString(), type: 'error' }); console.error(e); @@ -88,12 +91,9 @@ export const OrgSettings: React.FC = ({ const initiateDomainVerification = async (domain: string) => { try { - if (!organization) return; - const pending_domains: PendingDomain[] = await apiPost( - `/organizations/${organization?.id}/initiateDomainVerification`, - { - body: { domain } - } + const pending_domains = await apiPost( + `/organizations/${organization.id}/initiateDomainVerification`, + { body: { domain } } ); setOrganization({ ...organization, pending_domains }); } catch (e: any) { @@ -101,7 +101,7 @@ export const OrgSettings: React.FC = ({ message: e.status === 422 ? 'Error creating domain' - : (e.message ?? e.toString()), + : e.message || e.toString(), type: 'error' }); console.error(e); @@ -110,280 +110,194 @@ export const OrgSettings: React.FC = ({ const checkDomainVerification = async (domain: string) => { try { - if (!organization) return; - const resp: { success: boolean; organization?: OrganizationType } = - await apiPost( - `/organizations/${organization?.id}/checkDomainVerification`, - { - body: { domain } - } - ); + const resp = await apiPost( + `/organizations/${organization.id}/checkDomainVerification`, + { body: { domain } } + ); if (resp.success && resp.organization) { setOrganization(resp.organization); setDialog({ open: false }); setFeedbackMessage({ - message: 'Domain ' + inputValue + ' successfully verified!', + message: `Domain ${inputValue} successfully verified!`, type: 'success' }); } else { - setDialog({ - ...dialog, + setDialog((prev) => ({ + ...prev, domainVerificationStatusMessage: - 'Record not yet found. Note that DNS records may take up to 72 hours to propagate. You can come back later to check the verification status.' - }); + 'Record not yet found. DNS records may take up to 72 hours to propagate.' + })); } } catch (e: any) { setFeedbackMessage({ message: e.status === 422 ? 'Error verifying domain' - : (e.message ?? e.toString()), + : e.message || e.toString(), type: 'error' }); console.error(e); } }; - const handleTagChange = (event: any, new_value: any) => { - setChosenTags(new_value); - setOrganization((prevValues: any) => ({ - ...prevValues, - tags: new_value.map((tag: any) => ({ name: tag })) - })); + const handleDialogSubmit = async () => { + if (dialog.type === 'root_domains' && user?.user_type !== 'globalAdmin') { + if (dialog.stage === 0) { + await initiateDomainVerification(inputValue); + setDialog({ ...dialog, stage: 1 }); + } else { + await checkDomainVerification(inputValue); + } + return; + } + if (dialog.type === 'tags') { + setChosenTags(localTags); + setOrganization((prev: any) => ({ + ...prev, + tags: localTags.map((name: any) => ({ name })) + })); + setIsSaveDisabled(false); + setLocalTags(localTags); + } else if (dialog.type && inputValue) { + const key = dialog.type as keyof OrgSettingsType; + const updated = [ + ...(organization[key] as string[]), + ...inputValue.split(',').map((e) => e.trim()) + ]; + setOrganization({ ...organization, [key]: updated }); + } + setDialog({ open: false }); + setInputValue(''); setIsSaveDisabled(false); }; - const ListInput = (props: { - type: 'root_domains' | 'ip_blocks' | 'tags'; - label: string; - }) => { - if (!organization) return null; - const elements: (string | OrganizationTag)[] = organization[props.type]; - return ( - - - {props.label} - - {elements && - elements.map((value: string | OrganizationTag, index: number) => ( - - { - organization[props.type].splice(index, 1); - setOrganization({ ...organization }); - if (chosenTags.length > 0) { - chosenTags.splice(index, 1); - setChosenTags(chosenTags); - } - setIsSaveDisabled(false); - }} - disabled={user?.user_type === 'globalView'} - > - - ))} - {props.type === 'root_domains' && - organization.pending_domains.map((domain, index: number) => ( - - { - organization.pending_domains.splice(index, 1); - setOrganization({ ...organization }); - }} - onClick={() => { - setInputValue(domain.name); - setDialog({ - open: true, - type: props.type, - label: props.label, - stage: 1 - }); - }} - disabled={user?.user_type === 'globalView'} - > - - ))} - {(props.type === 'root_domains' || - user?.user_type === 'globalAdmin') && ( - - { - setDialog({ - open: true, - type: props.type, - label: props.label, - stage: 0 - }); + const renderDialogContent = () => { + switch (dialog.type) { + case 'tags': + return ( + <> + + Use the dropdown to select or deselect existing tags. Type and + press enter to add new ones. + + { + setLocalTags( + value.filter((v): v is string => typeof v === 'string') + ); }} - disabled={user?.user_type === 'globalView'} + multiple + freeSolo + options={tags.map((t) => t.name).filter(Boolean)} + renderValue={(selected) => ( + + {selected.map((tag, i) => { + if (tag) { + return ( + + ); + } + return <>; + })} + + )} + renderInput={(params) => ( + + )} + sx={{ mt: 1 }} /> - - )} - - ); + + ); + case 'root_domains': + if (dialog.stage === 1) { + return ( + <> + + Add the TXT record below to {inputValue}'s DNS and click + Verify. + + d.name === inputValue + )?.token || '' + } + onFocus={(e) => e.target.select()} + /> + {dialog.domainVerificationStatusMessage && ( + + {dialog.domainVerificationStatusMessage} + + )} + + ); + } + return ( + <> + + Enter a domain to begin verification. + + setInputValue(e.target.value)} + /> + + ); + default: + return ( + setInputValue(e.target.value)} + /> + ); + } }; - if (!organization) return null; return ( <> setDialog({ open: false })} + onClose={(event, reason) => { + if (reason !== 'backdropClick') { + setDialog({ open: false }); + } + }} + disableEscapeKeyDown aria-labelledby="form-dialog-title" maxWidth="xs" fullWidth > - + {dialog.type === 'tags' ? 'Update ' : 'Add '} - {dialog.label && dialog.label.slice(0, -1)}(s) + {dialog.label?.slice(0, -1)}(s) - - {dialog.type === 'tags' ? ( - <> - - Use the dropdown to select or deselect existing tags. -
- -- OR -- -
- Type and then press enter to add a new tag. -
- - value.map((option: string, index: number) => { - const { key, ...tagProps } = getTagProps({ index }); - return ( - - ); - }) - } - sx={{ mt: 1 }} - multiple - options={tags - .map((option) => option.name) - .filter((name): name is string => name !== undefined)} - freeSolo - renderInput={(params) => ( - - )} - /> - - ) : dialog.type === 'root_domains' && dialog.stage === 1 ? ( - <> - - Add the following TXT record to {inputValue}'s DNS - configuration and click Verify. - - domain.name === inputValue - )?.token - } - onFocus={(event) => { - event.target.select(); - }} - /> - {dialog.domainVerificationStatusMessage && ( - <> - - {dialog.domainVerificationStatusMessage} - - - )} - - ) : user?.user_type === 'globalAdmin' ? ( - <> - - Separate multiple entries by commas. - - setInputValue(e.target.value)} - /> - - ) : dialog.type === 'root_domains' && dialog.stage === 0 ? ( - <> - - In order to add a root domain, you will need to verify ownership - of the domain. - - setInputValue(e.target.value)} - /> - - ) : ( - <> - )} -
+ {renderDialogContent()} - +
+ { @@ -391,11 +305,9 @@ export const OrgSettings: React.FC = ({ setIsSaveDisabled(true); }} icon={} - title={Success } + title={Success} content={ - - {organization.name} was successfully updated. - + {organization.name} was successfully updated. } /> @@ -408,7 +320,7 @@ export const OrgSettings: React.FC = ({ InputProps={{ sx: { fontSize: '18px', fontWeight: 400 } }} - >
+ /> @@ -431,14 +343,45 @@ export const OrgSettings: React.FC = ({ - + - + {user?.user_type === 'globalAdmin' && ( - + )} From 6babc55e0dcf52df735ead0c314bd9a228bf199d Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Tue, 1 Jul 2025 16:03:13 -0400 Subject: [PATCH 6/7] Move org form to appropriate folder --- frontend/src/components/OrganizationForm/index.ts | 1 - .../Organizations}/OrganizationForm.tsx | 0 frontend/src/pages/Organizations/Organizations.tsx | 2 +- .../OrganizationForm => pages/Organizations}/orgFormStyle.ts | 0 .../OrganizationForm => pages/Organizations}/styles.module.scss | 0 5 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 frontend/src/components/OrganizationForm/index.ts rename frontend/src/{components/OrganizationForm => pages/Organizations}/OrganizationForm.tsx (100%) rename frontend/src/{components/OrganizationForm => pages/Organizations}/orgFormStyle.ts (100%) rename frontend/src/{components/OrganizationForm => pages/Organizations}/styles.module.scss (100%) diff --git a/frontend/src/components/OrganizationForm/index.ts b/frontend/src/components/OrganizationForm/index.ts deleted file mode 100644 index 275f1deb3..000000000 --- a/frontend/src/components/OrganizationForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './OrganizationForm'; diff --git a/frontend/src/components/OrganizationForm/OrganizationForm.tsx b/frontend/src/pages/Organizations/OrganizationForm.tsx similarity index 100% rename from frontend/src/components/OrganizationForm/OrganizationForm.tsx rename to frontend/src/pages/Organizations/OrganizationForm.tsx diff --git a/frontend/src/pages/Organizations/Organizations.tsx b/frontend/src/pages/Organizations/Organizations.tsx index ea1e7064a..1aa01f821 100644 --- a/frontend/src/pages/Organizations/Organizations.tsx +++ b/frontend/src/pages/Organizations/Organizations.tsx @@ -14,7 +14,7 @@ import { import { DataGrid, GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; import { useHistory } from 'react-router-dom'; import { CheckCircleOutline } from '@mui/icons-material'; -import { OrganizationForm } from 'components/OrganizationForm'; +import { OrganizationForm } from './OrganizationForm'; import CustomToolbar from 'components/DataGrid/CustomToolbar'; import InfoDialog from 'components/Dialog/InfoDialog'; diff --git a/frontend/src/components/OrganizationForm/orgFormStyle.ts b/frontend/src/pages/Organizations/orgFormStyle.ts similarity index 100% rename from frontend/src/components/OrganizationForm/orgFormStyle.ts rename to frontend/src/pages/Organizations/orgFormStyle.ts diff --git a/frontend/src/components/OrganizationForm/styles.module.scss b/frontend/src/pages/Organizations/styles.module.scss similarity index 100% rename from frontend/src/components/OrganizationForm/styles.module.scss rename to frontend/src/pages/Organizations/styles.module.scss From 46b1fbc7e34c0ffcb29ba6cfd4effaa876bc1295 Mon Sep 17 00:00:00 2001 From: Amelia Vance Date: Tue, 1 Jul 2025 17:56:45 -0400 Subject: [PATCH 7/7] Fix Org members bug --- .../src/pages/Organization/OrgMembers.tsx | 67 +++++++++---------- 1 file changed, 30 insertions(+), 37 deletions(-) diff --git a/frontend/src/pages/Organization/OrgMembers.tsx b/frontend/src/pages/Organization/OrgMembers.tsx index 0dc8cf98c..6eb8a0fe4 100644 --- a/frontend/src/pages/Organization/OrgMembers.tsx +++ b/frontend/src/pages/Organization/OrgMembers.tsx @@ -14,13 +14,21 @@ type OrgMemberProps = { setUserRoles: Function; }; -type MemberRow = { - row: { - approved?: Boolean; - role?: String; - user: { full_name?: String; email?: String; invite_pending?: String }; - }; -}; +const flattenUserRoles = (data: any[]) => + data.map((item) => { + const nestedUser = item.user?.user || item.user || {}; + return { + id: item.id, + role: item.role, + approved: item.approved, + user_id: nestedUser.id, + email: nestedUser.email, + first_name: nestedUser.first_name, + last_name: nestedUser.last_name, + full_name: nestedUser.full_name, + invite_pending: nestedUser.invite_pending + }; + }); export const OrgMembers: React.FC = ({ organization, @@ -33,45 +41,35 @@ export const OrgMembers: React.FC = ({ const [selectedRow, setSelectedRow] = React.useState(); const [hasError, setHasError] = React.useState(''); + const flatUserRoles = flattenUserRoles(userRoles); + const userRoleColumns: GridColDef[] = [ { - headerName: 'Name', field: 'full_name', - valueGetter: (params: MemberRow) => params.row?.user?.full_name, + headerName: 'Name', flex: 1 }, { - headerName: 'Email', field: 'email', - valueGetter: (params: MemberRow) => params.row?.user?.email, + headerName: 'Email', flex: 1.5 }, { - headerName: 'Role', field: 'role', - valueGetter: (params: MemberRow) => { - if (params.row?.approved) { - if (params.row?.user?.invite_pending) { - return 'Invite pending'; - } else if (params.row?.role === 'admin') { - return 'Administrator'; - } else { - return 'Member'; - } - } - }, + headerName: 'Role', flex: 1 }, { - headerName: 'Remove', field: 'remove', - disableExport: true, + headerName: 'Remove', flex: 0.5, - renderCell: (cellValues: GridRenderCellParams) => { - const descriptionId = `description-${cellValues.row.id}`; - const description = `Remove ${cellValues.row.user?.full_name} from ${organization?.name}`; + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => { + const descriptionId = `description-${params.row.id}`; + const description = `Remove ${params.row.full_name}`; return ( - + <> {description} @@ -80,18 +78,13 @@ export const OrgMembers: React.FC = ({ aria-label={description} aria-describedby={descriptionId} onClick={() => { - const userRole = userRoles.find( - (role: { user: { id: String } }) => - role.user.id === cellValues.row.user.id - ); - setSelectedRow(userRole); + setSelectedRow(params.row); setRemoveUserDialogOpen(true); }} - disabled={user?.user_type === 'globalView'} > - + ); } } @@ -122,7 +115,7 @@ export const OrgMembers: React.FC = ({