diff --git a/backend/src/api/saved-searches.ts b/backend/src/api/saved-searches.ts index 1497e429..fb0d30ba 100644 --- a/backend/src/api/saved-searches.ts +++ b/backend/src/api/saved-searches.ts @@ -61,13 +61,6 @@ class NewSavedSearch { @IsArray() filters: { field: string; values: any[]; type: string }[]; - - @IsBoolean() - createVulnerabilities: boolean; - - @IsObject() - @IsOptional() - vulnerabilityTemplate: Partial; } const PAGE_SIZE = 20; diff --git a/backend/src/models/saved-search.ts b/backend/src/models/saved-search.ts index 91a9f63f..c8b42a07 100644 --- a/backend/src/models/saved-search.ts +++ b/backend/src/models/saved-search.ts @@ -42,17 +42,6 @@ export class SavedSearch extends BaseEntity { @Column() searchPath: string; - @Column({ - default: false - }) - createVulnerabilities: boolean; - - // Content of vulnerability when search is configured to create vulnerabilities from results - @Column({ type: 'jsonb', default: '{}' }) - vulnerabilityTemplate: Partial & { - title: string; - }; - @ManyToOne((type) => User, { onDelete: 'SET NULL', onUpdate: 'CASCADE' diff --git a/backend/src/tasks/saved-search.ts b/backend/src/tasks/saved-search.ts index cc63911b..8e8afca1 100644 --- a/backend/src/tasks/saved-search.ts +++ b/backend/src/tasks/saved-search.ts @@ -50,21 +50,6 @@ export const handler = async (commandOptions: CommandOptions) => { const hits: number = searchResults.body.hits.total.value; search.count = hits; search.save(); - - if (search.createVulnerabilities) { - const results = await fetchAllResults(filters, restrictions); - const vulnerabilities: Vulnerability[] = results.map((domain) => - plainToClass(Vulnerability, { - domain: domain, - lastSeen: new Date(Date.now()), - ...search.vulnerabilityTemplate, - state: 'open', - source: 'saved-search', - needsPopulation: false - }) - ); - await saveVulnerabilitiesToDb(vulnerabilities, false); - } } console.log(`Saved search finished for ${savedSearches.length} searches`); diff --git a/backend/test/__snapshots__/saved-searches.test.ts.snap b/backend/test/__snapshots__/saved-searches.test.ts.snap index efd26fcd..94c479ac 100644 --- a/backend/test/__snapshots__/saved-searches.test.ts.snap +++ b/backend/test/__snapshots__/saved-searches.test.ts.snap @@ -3,7 +3,6 @@ exports[`saved-search create create by user should succeed 1`] = ` Object { "count": 3, - "createVulnerabilities": false, "createdAt": Any, "createdBy": Object { "id": Any, @@ -16,6 +15,5 @@ Object { "sortDirection": "", "sortField": "", "updatedAt": Any, - "vulnerabilityTemplate": Object {}, } `; diff --git a/backend/test/saved-searches.test.ts b/backend/test/saved-searches.test.ts index 2158122a..fc87a624 100644 --- a/backend/test/saved-searches.test.ts +++ b/backend/test/saved-searches.test.ts @@ -45,9 +45,7 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }) .expect(200); expect(response.body).toMatchSnapshot({ @@ -71,14 +69,11 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }; const search = await SavedSearch.create(body).save(); body.name = 'test-' + Math.random(); body.searchTerm = '123'; - body.createVulnerabilities = true; const response = await request(app) .put(`/saved-searches/${search.id}`) .set( @@ -104,9 +99,7 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }; const search = await SavedSearch.create({ ...body, @@ -114,7 +107,6 @@ describe('saved-search', () => { }).save(); body.name = 'test-' + Math.random(); body.searchTerm = '123'; - body.createVulnerabilities = true; const response = await request(app) .put(`/saved-searches/${search.id}`) .set( @@ -128,9 +120,6 @@ describe('saved-search', () => { .expect(200); expect(response.body.name).toEqual(body.name); expect(response.body.searchTerm).toEqual(body.searchTerm); - expect(response.body.createVulnerabilities).toEqual( - body.createVulnerabilities - ); }); it('update by standard user without access should fail', async () => { const user = await User.create({ @@ -153,8 +142,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user }; const search = await SavedSearch.create(body).save(); @@ -179,9 +166,7 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }; const search = await SavedSearch.create(body).save(); const response = await request(app) @@ -206,9 +191,7 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }).save(); const response = await request(app) .delete(`/saved-searches/${search.id}`) @@ -235,8 +218,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user }).save(); const response = await request(app) @@ -272,8 +253,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user }).save(); const response = await request(app) @@ -309,8 +288,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user }).save(); const response = await request(app) @@ -335,9 +312,7 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }).save(); const response = await request(app) .get(`/saved-searches`) @@ -371,8 +346,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user }).save(); // this org should not show up in the response @@ -384,8 +357,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user1 }).save(); const response = await request(app) @@ -411,9 +382,7 @@ describe('saved-search', () => { sortField: '', searchTerm: '', searchPath: '', - filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {} + filters: [] }).save(); const response = await request(app) .get(`/saved-searches/${search.id}`) @@ -440,8 +409,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user }).save(); const response = await request(app) @@ -477,8 +444,6 @@ describe('saved-search', () => { searchTerm: '', searchPath: '', filters: [], - createVulnerabilities: false, - vulnerabilityTemplate: {}, createdBy: user1 }).save(); const response = await request(app) diff --git a/frontend/src/components/DrawerInterior.tsx b/frontend/src/components/DrawerInterior.tsx index 98642e90..3b43e432 100644 --- a/frontend/src/components/DrawerInterior.tsx +++ b/frontend/src/components/DrawerInterior.tsx @@ -67,8 +67,13 @@ export const DrawerInterior: React.FC = (props) => { } = props; const { apiGet, apiDelete } = useAuthContext(); - const { savedSearches, setSavedSearches, setSavedSearchCount } = - useSavedSearchContext(); + const { + savedSearches, + setSavedSearches, + setSavedSearchCount, + activeSearchId, + setActiveSearchId + } = useSavedSearchContext(); const deleteSearch = async (id: string) => { try { @@ -84,7 +89,6 @@ export const DrawerInterior: React.FC = (props) => { const displaySavedSearch = (id: string) => { const savedSearch = savedSearches.find((search) => search.id === id); if (savedSearch) { - localStorage.setItem('savedSearch', JSON.stringify(savedSearch)); setSearchTerm(savedSearch.searchTerm, { shouldClearFilters: true, autocompleteResults: false @@ -96,6 +100,7 @@ export const DrawerInterior: React.FC = (props) => { addFilter(filter.field, value, 'any'); }); }); + setActiveSearchId(id); }; const restoreInitialFilters = () => { initialFilters.forEach((filter) => { @@ -110,11 +115,12 @@ export const DrawerInterior: React.FC = (props) => { shouldClearFilters: true, autocompleteResults: false }); - localStorage.removeItem('savedSearch'); restoreInitialFilters(); + setActiveSearchId(''); }; const toggleSavedSearches = (id: string) => { const savedSearch = savedSearches.filter((search) => search.id === id); + if (savedSearch) { if (!isSavedSearchActive(id)) { displaySavedSearch(id); @@ -124,13 +130,14 @@ export const DrawerInterior: React.FC = (props) => { } }; - const isSavedSearchActive = (id: string) => { - const activeSearch = JSON.parse( - localStorage.getItem('savedSearch') || '{}' - ); - return activeSearch.id === id; + const isSavedSearchActive = (id: string): boolean => { + return activeSearchId === id; }; + const ascendingSavedSearches = savedSearches.sort((a, b) => + a.name.localeCompare(b.name) + ); + const filtersByColumn = useMemo( () => filters.reduce( @@ -405,9 +412,9 @@ export const DrawerInterior: React.FC = (props) => { Saved Searches - {savedSearches.length > 0 ? ( + {ascendingSavedSearches.length > 0 ? ( - {savedSearches.map((search) => ( + {ascendingSavedSearches.map((search) => ( = (props) => { + const { + searchTerm, + filters, + totalResults, + sortField, + sortDirection, + advancedFiltersReq + } = props; + const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + const [formErrors, setFormErrors] = useState({ + name: false, + duplicate: false + }); + const { apiGet, apiPost, apiPut } = useAuthContext(); + const { savedSearches, setSavedSearches, setSavedSearchCount, activeSearch } = + useSavedSearchContext(); + const [savedSearchValues, setSavedSearchValues] = useState< + Partial & { name: string } + >(activeSearch ? activeSearch : { name: '' }); + // API call to save/update saved searches + const handleSave = async (savedSearchValues: Partial) => { + const body = { + body: { + ...savedSearchValues, + searchTerm, + filters, + count: totalResults, + searchPath: window.location.search, + sortField, + sortDirection + } + }; + + try { + if (activeSearch) { + await apiPut('/saved-searches/' + activeSearch.id, body); + } else { + await apiPost('/saved-searches/', body); + } + const updatedSearches = await apiGet('/saved-searches'); // Get current saved searches + setSavedSearches(updatedSearches.result); // Update the saved searches + setSavedSearchCount(updatedSearches.result.length); // Update the count + } catch (e) { + console.error(e); + } + }; + + const handleCloseModal = () => { + setSaveDialogOpen(false); + savedSearchValues.name = ''; + }; + const handleOpenModal = () => { + setSaveDialogOpen(true); + }; + + const handleDialogClose = () => { + setUpdateDialogOpen(false); + savedSearchValues.name = ''; + }; + + const handleUpdate = () => { + if (activeSearch) { + savedSearchValues.name = activeSearch.name; + setUpdateDialogOpen(true); // Open dialog to confirm update + } else { + handleOpenModal(); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (formErrors.name) { + return; + } + handleSave(savedSearchValues); + handleCloseModal(); + }; + + // Validate Saved Search Name + const validation = (name: string): boolean => { + const nameRegex = /^(?=.*[A-Za-z0-9])[A-Za-z0-9\s'-]+$/; + return nameRegex.test(name); + }; + + const handleChange = (textInputName: string, textInput: string) => { + setSavedSearchValues((inputValues) => ({ + ...inputValues, + [textInputName]: textInput + })); + // Validation check for valid characters and duplicate names + if (textInputName === 'name' && textInput !== activeSearch?.name) { + const isValid = validation(textInput); + const isDuplicate = savedSearches.some( + (search) => search.name === textInput + ); + + setFormErrors((prev) => ({ + ...prev, + name: !isValid, + duplicate: isDuplicate + })); + } + }; + + return ( + <> + + setUpdateDialogOpen(false)} + aria-label="Save Search form" + aria-labelledby="save-search-form-title" + aria-describedby="save-search-form-description" + PaperProps={{ + component: 'form', + onSubmit: handleSubmit, + style: { width: '30%', minWidth: '300px' } + }} + > + + Update Saved Search + + + + handleChange(e.target.name, e.target.value)} + inputProps={{ + 'aria-label': 'Enter a name for your saved search' + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + } + }} + error={formErrors.name} + helperText={ + formErrors.name + ? 'Name is required and must contain only alphanumeric characters, spaces, hyphens, or apostrophes.' + : formErrors.duplicate + ? 'This name is already taken. Please choose a different name.' + : '' + } + /> + + + + + + + + + Save Search + + + + Name Your Search + + handleChange(e.target.name, e.target.value)} + inputProps={{ + 'aria-label': 'Enter a name for your saved search' + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + } + }} + error={formErrors.name} + helperText={ + formErrors.name + ? 'Name is required and must contain only alphanumeric characters, spaces, hyphens, or apostrophes.' + : formErrors.duplicate + ? 'This name is already taken. Please choose a different name.' + : '' + } + /> + + + + + + + + + ); +}; diff --git a/frontend/src/context/SavedSearchContext.ts b/frontend/src/context/SavedSearchContext.ts index e2225230..c34b0697 100644 --- a/frontend/src/context/SavedSearchContext.ts +++ b/frontend/src/context/SavedSearchContext.ts @@ -6,6 +6,9 @@ export interface SavedSearchContextType { setSavedSearches: (savedSearches: SavedSearch[]) => void; savedSearchCount: number; setSavedSearchCount: (savedSearchCount: number) => void; + activeSearchId: string; + setActiveSearchId: (activeSearchId: string) => void; + activeSearch: SavedSearch | undefined; } export const SavedSearchContext = React.createContext( diff --git a/frontend/src/context/SavedSearchContextProvider.tsx b/frontend/src/context/SavedSearchContextProvider.tsx index 2d418adc..15f9ae65 100644 --- a/frontend/src/context/SavedSearchContextProvider.tsx +++ b/frontend/src/context/SavedSearchContextProvider.tsx @@ -12,6 +12,7 @@ export const SavedSearchContextProvider: React.FC< > = ({ children }) => { const [savedSearches, setSavedSearches] = useState([]); const [savedSearchCount, setSavedSearchCount] = useState(0); + const [activeSearchId, setActiveSearchId] = useState(''); const { apiGet, user } = useAuthContext(); const fetchSearches = useCallback(async () => { @@ -24,6 +25,10 @@ export const SavedSearchContextProvider: React.FC< } }, [apiGet, setSavedSearches, setSavedSearchCount]); + const activeSearch = useMemo(() => { + return savedSearches.find((search) => search.id === activeSearchId); + }, [activeSearchId, savedSearches]); + useEffect(() => { if (user) fetchSearches(); }, [user, fetchSearches]); @@ -41,7 +46,10 @@ export const SavedSearchContextProvider: React.FC< savedSearches: memoizedSavedSearches, setSavedSearches, savedSearchCount: memoizedSavedSearchCount, - setSavedSearchCount + setSavedSearchCount, + activeSearchId, + setActiveSearchId, + activeSearch }} > {children} diff --git a/frontend/src/pages/Search/Inventory.tsx b/frontend/src/pages/Search/Inventory.tsx index 53eaf28b..84178da1 100644 --- a/frontend/src/pages/Search/Inventory.tsx +++ b/frontend/src/pages/Search/Inventory.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { classes, Root } from './Styling/dashboardStyle'; import { Subnav } from 'components'; import { ResultCard } from './ResultCard'; @@ -9,35 +9,18 @@ import { Select, MenuItem, Typography, - Checkbox, - FormControlLabel, - FormGroup, - TextareaAutosize, - ButtonGroup, - Box + Box, + Stack } from '@mui/material'; import { Pagination } from '@mui/material'; import { withSearch } from '@elastic/react-search-ui'; import { ContextType } from '../../context/SearchProvider'; import { SortBar } from './SortBar'; -import { - Modal, - TextInput, - Label, - Dropdown, - ModalFooter, - ModalHeading, - ModalRef -} from '@trussworks/react-uswds'; -import { ModalToggleButton } from 'components'; import { useAuthContext } from 'context'; -// import { useSavedSearchContext } from 'context/SavedSearchContext'; import { FilterTags } from './FilterTags'; -import { SavedSearch, Vulnerability } from 'types'; -import { useBeforeunload } from 'react-beforeunload'; import { NoResults } from 'components/NoResults'; import { exportCSV } from 'components/ImportExport'; -import { useHistory } from 'react-router-dom'; +import { SaveSearchModal } from 'components/SaveSearchModal/SaveSearchModal'; export const DashboardUI: React.FC = ( props @@ -62,74 +45,11 @@ export const DashboardUI: React.FC = ( const [selectedDomain, setSelectedDomain] = useState(''); const [resultsScrolled] = useState(false); - const { - apiPost, - apiPut, - setLoading, - showAllOrganizations, - currentOrganization - } = useAuthContext(); - - // Could be used for validation purposes in new dialogue - // const { savedSearches } = useSavedSearchContext(); + const { apiPost, setLoading, showAllOrganizations, currentOrganization } = + useAuthContext(); const advanceFiltersReq = filters.length > 1 || searchTerm !== ''; //Prevents a user from saving a search without advanced filters - const search: - | (SavedSearch & { - editing?: boolean; - }) - | undefined = localStorage.getItem('savedSearch') - ? JSON.parse(localStorage.getItem('savedSearch')!) - : undefined; - - const history = useHistory(); - const modalRef = useRef(null); - const [savedSearchValues, setSavedSearchValues] = useState< - Partial & { - name: string; - vulnerabilityTemplate: Partial; - } - >( - search - ? search - : { - name: '', - vulnerabilityTemplate: {}, - createVulnerabilities: false - } - ); - - const onTextChange: React.ChangeEventHandler< - HTMLInputElement | HTMLSelectElement - > = (e) => onChange(e.target.name, e.target.value); - - const onChange = (name: string, value: any) => { - setSavedSearchValues((values) => ({ - ...values, - [name]: value - })); - }; - - const onVulnerabilityTemplateChange = (e: any) => { - (savedSearchValues.vulnerabilityTemplate as any)[e.target.name] = - e.target.value; - setSavedSearchValues(savedSearchValues); - }; - - useEffect(() => { - if (props.location.search === '') { - // Search on initial load - } - return () => { - localStorage.removeItem('savedSearch'); - }; - }, [setSearchTerm, props.location.search]); - - useBeforeunload((event) => { - localStorage.removeItem('savedSearch'); - }); - const fetchDomainsExport = async (): Promise => { try { const body: any = { @@ -196,19 +116,28 @@ export const DashboardUI: React.FC = ( > - 0 || searchTerm - ? () => modalRef.current?.toggleModal(undefined, true) - : undefined - } - existingSavedSearch={search} - advancedFiltersReq={advanceFiltersReq} - /> + + + + = ( gap="1rem" alignItems="stretch" display="flex" - // overflow="scroll" position="relative" padding="0 0 2rem 0" sx={{ overflowY: 'auto' }} @@ -303,133 +231,6 @@ export const DashboardUI: React.FC = ( Export Results - { - // To-do: Implement a new MUI based Save Search Dialog to replace the existing USWDS based Modal. - } - - {search ? 'Update Search' : 'Save Search'} - - - -

When a new result is found:

- {/* - } - label="Email me" - /> */} - onChange(e.target.name, e.target.checked)} - id="createVulnerabilities" - name="createVulnerabilities" - /> - } - label="Create a vulnerability" - /> - {savedSearchValues.createVulnerabilities && ( - <> - - - - - - - - - - - - - - )} - {/*

Collaborators

-

- Collaborators can view vulnerabilities, and domains within - this search. Adding a team will make all members - collaborators. -

- */} -
- - - { - const body = { - body: { - ...savedSearchValues, - searchTerm, - filters, - count: totalResults, - searchPath: window.location.search, - sortField, - sortDirection - } - }; - if (search) { - await apiPut('/saved-searches/' + search.id, body); - history.push('/inventory'); - window.location.reload(); - } else { - await apiPost('/saved-searches/', body); - history.push('/inventory'); - window.location.reload(); - } - }} - > - Save - - - Cancel - - - -
); }; diff --git a/frontend/src/pages/Search/SortBar.tsx b/frontend/src/pages/Search/SortBar.tsx index 9fe1c06d..90b878c1 100644 --- a/frontend/src/pages/Search/SortBar.tsx +++ b/frontend/src/pages/Search/SortBar.tsx @@ -5,34 +5,22 @@ import { FormControl, MenuItem, SelectProps, - IconButton, - Button + IconButton } from '@mui/material'; import { ArrowUpward, ArrowDownward } from '@mui/icons-material'; import { ContextType } from 'context/SearchProvider'; -import { SavedSearch } from 'types'; interface Props { sortField: ContextType['sortField']; sortDirection?: ContextType['sortDirection']; setSort: ContextType['setSort']; - saveSearch?(): void; isFixed: boolean; - existingSavedSearch?: SavedSearch; children?: React.ReactNode; advancedFiltersReq?: boolean; } export const SortBar: React.FC = (props) => { - const { - sortField, - sortDirection, - setSort, - saveSearch, - children, - existingSavedSearch, - advancedFiltersReq - } = props; + const { sortField, sortDirection, setSort, children } = props; const toggleDirection = () => { setSort(sortField, sortDirection === 'asc' ? 'desc' : 'asc'); @@ -86,17 +74,6 @@ export const SortBar: React.FC = (props) => { {children} -
- {saveSearch && ( - - )} -
); }; diff --git a/frontend/src/types/saved-search.ts b/frontend/src/types/saved-search.ts index 74177cb2..14444f7a 100644 --- a/frontend/src/types/saved-search.ts +++ b/frontend/src/types/saved-search.ts @@ -1,5 +1,4 @@ import { User } from './user'; -import { Vulnerability } from './vulnerability'; export interface SavedSearch { id: string; @@ -13,6 +12,4 @@ export interface SavedSearch { searchPath: string; sortField: string; sortDirection: string; - createVulnerabilities: boolean; - vulnerabilityTemplate: Partial; }