diff --git a/oxlint-baseline.json b/oxlint-baseline.json index 6cb932302e..9afb08ad9d 100644 --- a/oxlint-baseline.json +++ b/oxlint-baseline.json @@ -535,9 +535,9 @@ "code": "react(refs)", "message": "Cannot access refs during render", "location": { - "line": 112, + "line": 115, "column": 17, - "offset": 3135 + "offset": 3186 }, "sourceLine": "{children({show, hide, targetRef: setTarget})}", "fingerprint": "src/web/components/chart/base/ToolTip.tsx|react(refs)|Cannot access refs during render|17|{children({show, hide, targetRef: setTarget})}" @@ -547,9 +547,9 @@ "code": "typescript(no-useless-default-assignment)", "message": "Default value is useless because the property has type `ToString` (not nullish). This default assignment will never be used.", "location": { - "line": 44, + "line": 46, "column": 18, - "offset": 1059 + "offset": 1101 }, "sourceLine": "const {color = Theme.lightGray, toolTip} = data;", "fingerprint": "src/web/components/chart/donut/Arc2d.tsx|typescript(no-useless-default-assignment)|Default value is useless because the property has type `ToString` (not nullish). This default assignment will never be used.|18|const {color = Theme.lightGray, toolTip} = data;" @@ -686,18 +686,6 @@ "sourceLine": "if (permittedDisplaysSignatureRef.current !== permittedDisplaysSignature) {", "fingerprint": "src/web/components/dashboard/DashboardView.tsx|react(refs)|Cannot access refs during render|7|if (permittedDisplaysSignatureRef.current !== permittedDisplaysSignature) {" }, - { - "filename": "src/web/components/dashboard/display/Loader.tsx", - "code": "typescript(unbound-method)", - "message": "Avoid referencing unbound methods which may cause unintentional scoping of `this`.", - "location": { - "line": 147, - "column": 60, - "offset": 3934 - }, - "sourceLine": "this.subscriptions.push(subscribe(subscription, this.load));", - "fingerprint": "src/web/components/dashboard/display/Loader.tsx|typescript(unbound-method)|Avoid referencing unbound methods which may cause unintentional scoping of `this`.|60|this.subscriptions.push(subscribe(subscription, this.load));" - }, { "filename": "src/web/components/date/__tests__/DateTime.test.tsx", "code": "typescript(unbound-method)", @@ -3662,18 +3650,6 @@ "sourceLine": "...user,", "fingerprint": "src/web/pages/users/UsersDialog.tsx|typescript(no-misused-spread)|Using the spread operator on class instances will lose their class prototype.|5|...user," }, - { - "filename": "src/web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsDisplay.tsx", - "code": "typescript(no-useless-default-assignment)", - "message": "Default value is useless because the property has type `HostsFilterValue` (not nullish). This default assignment will never be used.", - "location": { - "line": 67, - "column": 28, - "offset": 2375 - }, - "sourceLine": "const {filterValue = {start: undefined, end: undefined}} = clickData;", - "fingerprint": "src/web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsDisplay.tsx|typescript(no-useless-default-assignment)|Default value is useless because the property has type `HostsFilterValue` (not nullish). This default assignment will never be used.|28|const {filterValue = {start: undefined, end: undefined}} = clickData;" - }, { "filename": "src/web/pages/vulnerabilities/VulnerabilitiesListPage.tsx", "code": "typescript(no-useless-default-assignment)", diff --git a/src/web/components/dashboard/display/DataDisplay.tsx b/src/web/components/dashboard/display/DataDisplay.tsx index 0459b64d64..c21b601dd1 100644 --- a/src/web/components/dashboard/display/DataDisplay.tsx +++ b/src/web/components/dashboard/display/DataDisplay.tsx @@ -22,9 +22,6 @@ import DisplayContainer, { DISPLAY_HEADER_HEIGHT, DISPLAY_BORDER_WIDTH, } from 'web/components/dashboard/display/DisplayContainer'; -import useDataTransform, { - type TransformFunc, -} from 'web/components/dashboard/display/useDataTransform'; import IconDivider from 'web/components/layout/IconDivider'; import Layout from 'web/components/layout/Layout'; import useTranslation from 'web/hooks/useTranslation'; @@ -69,15 +66,12 @@ type DataDisplayChildren< export type DataDisplayProps< TData extends object, - TTransformedData extends object, - TTransformProps extends object = object, TState extends DisplayState = DisplayState, - TChildren = DataDisplayChildren, + TChildren = DataDisplayChildren, > = Omit, 'children' | 'title'> & { - data?: TData; - dataRow?: DataRowFunc; + data: TData | undefined; + dataRow?: DataRowFunc; dataTitles?: DataTitles; - dataTransform: TransformFunc; filter?: FilterType; icons?: IconsRenderFunc; children?: TChildren; @@ -87,8 +81,8 @@ export type DataDisplayProps< showFilterString?: boolean; showSvgDownload?: boolean; showToggleLegend?: boolean; - title: ToString | TitleFunc; -} & TTransformProps; + title: ToString | TitleFunc; +}; const log = logger.getLogger('web.components.dashboard.display.DataDisplay'); @@ -168,21 +162,13 @@ const createSvgUrl = ( const DataDisplay = < TData extends object, - TProps extends DataDisplayProps< - TData, - TTransformedData, - TTransformProps, - TState - >, - TTransformedData extends object, - TTransformProps extends object = object, + TProps extends DataDisplayProps, TState extends DisplayState = DisplayState, >({ children, data, dataRow, dataTitles, - dataTransform, dragHandleRef, filter, height, @@ -206,10 +192,8 @@ const DataDisplay = < const downloadSvgUrlRef = useRef(undefined); const downloadCsvUrlRef = useRef(undefined); - const originalData = data; - const transformedData = useDataTransform(originalData, dataTransform); const title = isFunction(titleFunc) - ? titleFunc({data: transformedData, isLoading}) + ? titleFunc({data, isLoading}) : String(titleFunc); const getCurrentState = (newState: TState | undefined = state): TState => { @@ -274,7 +258,7 @@ const DataDisplay = < return; } - const rowData = dataRow(transformedData); + const rowData = dataRow(data); const csvData = [ escapeCsv(title), dataTitles.map(t => escapeCsv(String(t))).join(','), @@ -301,8 +285,6 @@ const DataDisplay = < height = height - DISPLAY_HEADER_HEIGHT; width = width - DISPLAY_BORDER_WIDTH; - isLoading = isLoading && !isDefined(originalData); - const showCsvDownload = isDefined(dataRow) && isDefined(dataTitles); showFilterString = showFilterString && isDefined(filter); @@ -328,7 +310,7 @@ const DataDisplay = < {isFunction(children) ? // oxlint-disable-next-line react/refs children({ - data: transformedData, + data, width, height, svgRef, diff --git a/src/web/components/dashboard/display/DataTableDisplay.tsx b/src/web/components/dashboard/display/DataTableDisplay.tsx index 769926a1e8..d25b2ca26a 100644 --- a/src/web/components/dashboard/display/DataTableDisplay.tsx +++ b/src/web/components/dashboard/display/DataTableDisplay.tsx @@ -12,63 +12,34 @@ import DataTable, { type DataTableProps, } from 'web/components/dashboard/display/DataTable'; -type DataTableDisplayRenderProps = - DataTableProps & { - data?: TTransformedData; +type DataTableDisplayRenderProps = + DataTableProps & { + data?: TData; }; -type DataTableDisplayChildren = ( - props: DataTableDisplayRenderProps, +type DataTableDisplayChildren = ( + props: DataTableDisplayRenderProps, ) => React.ReactNode; export type DataTableDisplayProps< TData extends object, - TTransformedData extends object, - TTransformProps extends object = object, TState extends DisplayState = DisplayState, -> = DataDisplayProps< - TData, - TTransformedData, - TTransformProps, - TState, - DataTableDisplayChildren ->; +> = DataDisplayProps>; type DataTableDisplayComponentProps< TData extends object, - TTransformedData extends object, - TTransformProps extends object, TState extends DisplayState, -> = DataTableDisplayProps & - TTransformProps; +> = DataTableDisplayProps; const DataTableDisplay = < TData extends object, - TTransformedData extends object, - TTransformProps extends object = object, TState extends DisplayState = DisplayState, >( - props: DataTableDisplayComponentProps< - TData, - TTransformedData, - TTransformProps, - TState - >, + props: DataTableDisplayComponentProps, ) => { const {children, dataRow, dataTitles} = props; return ( - , - TTransformedData, - TTransformProps, - TState - > + , TState> {...props} showSvgDownload={false} showToggleLegend={false} diff --git a/src/web/components/dashboard/display/Loader.tsx b/src/web/components/dashboard/display/Loader.tsx index bf5faacbdb..9377c52f7e 100644 --- a/src/web/components/dashboard/display/Loader.tsx +++ b/src/web/components/dashboard/display/Loader.tsx @@ -22,6 +22,7 @@ import withSubscription from 'web/utils/withSubscription'; export interface LoaderRenderProps { data?: TData; isLoading: boolean; + isFetching: boolean; } export interface DisplayLoaderProps { @@ -144,6 +145,7 @@ export class Loader extends React.Component< } for (const subscription of subscriptions) { + // oxlint-disable-next-line typescript/unbound-method this.subscriptions.push(subscribe(subscription, this.load)); } } @@ -167,7 +169,13 @@ export class Loader extends React.Component< render() { const {children, isLoading} = this.props; const {data} = this.state; - return isDefined(children) ? children({data, isLoading}) : null; + return isDefined(children) + ? children({ + data, + isLoading: !isDefined(data) && isLoading, + isFetching: isLoading, + }) + : null; } } diff --git a/src/web/components/dashboard/display/__tests__/DataDisplay.test.tsx b/src/web/components/dashboard/display/__tests__/DataDisplay.test.tsx index 91174f4dc0..7bb785ad3c 100644 --- a/src/web/components/dashboard/display/__tests__/DataDisplay.test.tsx +++ b/src/web/components/dashboard/display/__tests__/DataDisplay.test.tsx @@ -30,17 +30,16 @@ interface TestState extends DisplayState { showLegend?: boolean; } -type TestProps = DataDisplayProps & +type TestProps = DataDisplayProps & Pick; const createProps = (overrides: Partial = {}): TestProps => ({ children: testing.fn(({data}) => ( -
{data[0].value}
+
{data?.[0]?.value ?? 'empty'}
)), - data: {value: 'raw'}, + data: [{value: 'raw'}], dataRow: rows => rows?.map(row => [row.value]) ?? [], dataTitles: ['Value'], - dataTransform: data => [{value: `${data?.value}-transformed`}], height: 100, icons: () =>
, initialState: {showLegend: false}, @@ -66,21 +65,19 @@ describe('DataDisplay component tests', () => { testing.restoreAllMocks(); }); - test('should transform data and render the chart with its title', () => { + test('should render the data and chart with its title', () => { const props = createProps(); - render( - {...props} />, - ); + render( {...props} />); - expect(screen.getByText('chart-1: raw-transformed')).toBeInTheDocument(); - expect(screen.getByTestId('chart')).toHaveTextContent('raw-transformed'); + expect(screen.getByText('chart-1: raw')).toBeInTheDocument(); + expect(screen.getByTestId('chart')).toHaveTextContent('raw'); expect(screen.getByTestId('icons')).toBeInTheDocument(); }); test('should render a string title', () => { render( - + {...createProps({title: 'Static title'})} />, ); @@ -92,14 +89,22 @@ describe('DataDisplay component tests', () => { const children = testing.fn(() =>
); const props = createProps({ children, - data: undefined as unknown as TestData, - dataTransform: () => [], + data: undefined, isLoading: true, }); - render( - {...props} />, - ); + render( {...props} />); + + expect(children).not.toHaveBeenCalled(); + expect(screen.getByTestId('loading')).toBeVisible(); + expect(screen.queryByTestId('chart')).not.toBeInTheDocument(); + }); + + test('should not render chart children while loading when data is available', () => { + const children = testing.fn(() =>
); + const props = createProps({children, isLoading: true}); + + render( {...props} />); expect(children).not.toHaveBeenCalled(); expect(screen.getByTestId('loading')).toBeVisible(); @@ -110,17 +115,16 @@ describe('DataDisplay component tests', () => { const children = testing.fn(() =>
); const props = createProps({ children, - data: undefined as unknown as TestData, - dataTransform: () => [], + data: undefined, }); const {rerender} = render( - {...props} />, + {...props} />, ); expect(screen.getByTestId('chart')).toBeInTheDocument(); rerender( - + {...props} isLoading={true} />, @@ -139,9 +143,7 @@ describe('DataDisplay component tests', () => { showToggleLegend: false, }); - render( - {...props} />, - ); + render( {...props} />); expect(icons).toHaveBeenCalledTimes(1); expect(icons).toHaveBeenCalledWith( @@ -175,14 +177,14 @@ describe('DataDisplay component tests', () => { showFilterString: true, }); const {rerender} = render( - {...props} />, + {...props} />, ); expect(screen.getByText('First filter')).toBeInTheDocument(); expect(screen.getByText('foo=one')).toBeInTheDocument(); rerender( - + {...props} filter={secondFilter} showFilterString={true} @@ -200,27 +202,25 @@ describe('DataDisplay component tests', () => { terms: parseFilterTermsFromString('foo=one'), }); const children = testing.fn(({data}) => ( -
{data[0].value}
+
{data?.[0]?.value ?? 'empty'}
)); const props = createProps({children, filter: firstFilter}); const {rerender} = render( - {...props} />, + {...props} />, ); expect(children).toHaveBeenCalledTimes(1); - expect(screen.getByTestId('chart')).toHaveTextContent('raw-transformed'); + expect(screen.getByTestId('chart')).toHaveTextContent('raw'); rerender( - + {...props} - data={{value: 'changed'}} + data={[{value: 'changed'}]} />, ); expect(children).toHaveBeenCalledTimes(2); - expect(screen.getByTestId('chart')).toHaveTextContent( - 'changed-transformed', - ); + expect(screen.getByTestId('chart')).toHaveTextContent('changed'); }); test('should create a CSV download with escaped data', async () => { @@ -228,15 +228,13 @@ describe('DataDisplay component tests', () => {
), })); @@ -119,10 +122,12 @@ const renderDisplay = (component: ReactElement) => { describe('HostsModifiedHighDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsModifiedHighDisplay).toBeDefined(); - expect(typeof HostsModifiedHighDisplay).toBe('function'); expect(HostsModifiedHighDisplay.displayId).toBe( 'host-by-high-modification-time', ); + expect(HostsModifiedHighDisplay.displayName).toBe( + 'HostsModifiedHighDisplay', + ); }); test('should be registered with the correct title', () => { @@ -153,12 +158,45 @@ describe('HostsModifiedHighDisplay', () => { expect(screen.queryByText(/7\|12/)).toBeNull(); }); }); + + test('should call onFilterChanged with a modification date range filter', async () => { + const onFilterChanged = testing.fn(); + const filter = QueryFilter.fromString('severity=high'); + + renderDisplay( + , + ); + + const rangeButton = await screen.findByRole('button', { + name: 'select-range', + }); + fireEvent.click(rangeButton); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + const newFilter = onFilterChanged.mock.calls[0][0]; + expect(newFilter.toFilterString()).toContain('severity=high'); + expect(newFilter.toFilterString()).toContain('modified>2026-01-01t00:00'); + expect(newFilter.toFilterString()).toContain('modified<2026-01-03t00:00'); + }); + + test('should not throw when selecting a range without onFilterChanged', async () => { + renderDisplay(); + + const rangeButton = await screen.findByRole('button', { + name: 'select-range', + }); + expect(() => fireEvent.click(rangeButton)).not.toThrow(); + }); }); describe('HostsModifiedHighTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsModifiedHighTableDisplay).toBeDefined(); - expect(typeof HostsModifiedHighTableDisplay).toBe('function'); expect(HostsModifiedHighTableDisplay.displayId).toBe( 'host-by-high-modification-time-table', ); diff --git a/src/web/pages/hosts/dashboard/__tests__/HostSeverityClassDisplay.test.tsx b/src/web/pages/hosts/dashboard/__tests__/HostSeverityClassDisplay.test.tsx index 4834b9c013..a6a99befdb 100644 --- a/src/web/pages/hosts/dashboard/__tests__/HostSeverityClassDisplay.test.tsx +++ b/src/web/pages/hosts/dashboard/__tests__/HostSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -27,12 +28,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({data, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})}
); }, @@ -43,12 +41,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -57,6 +52,9 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -82,7 +80,6 @@ const renderDisplay = (component: ReactElement) => { describe('HostsSeverityClassDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsSeverityClassDisplay).toBeDefined(); - expect(typeof HostsSeverityClassDisplay).toBe('function'); expect(HostsSeverityClassDisplay.displayId).toBe('host-by-severity-class'); expect(HostsSeverityClassDisplay.displayName).toBe( 'HostsSeverityClassDisplay', @@ -110,7 +107,6 @@ describe('HostsSeverityClassDisplay', () => { describe('HostsSeverityClassTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsSeverityClassTableDisplay).toBeDefined(); - expect(typeof HostsSeverityClassTableDisplay).toBe('function'); expect(HostsSeverityClassTableDisplay.displayId).toBe( 'host-by-severity-class-table', ); diff --git a/src/web/pages/hosts/dashboard/__tests__/HostsCvssDisplay.test.tsx b/src/web/pages/hosts/dashboard/__tests__/HostsCvssDisplay.test.tsx index b36d26bbe6..a87b4cf88b 100644 --- a/src/web/pages/hosts/dashboard/__tests__/HostsCvssDisplay.test.tsx +++ b/src/web/pages/hosts/dashboard/__tests__/HostsCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,12 +26,9 @@ const loaderData = { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({data, title, yLabel}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {yLabel}
); @@ -39,12 +37,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -52,7 +47,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ })); const createGmp = () => ({ - settings: {severityRating: 'CVSSv3'}, + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -78,7 +75,6 @@ const renderDisplay = (component: ReactElement) => { describe('HostsCvssDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsCvssDisplay).toBeDefined(); - expect(typeof HostsCvssDisplay).toBe('function'); expect(HostsCvssDisplay.displayId).toBe('host-by-cvss'); expect(HostsCvssDisplay.displayName).toBe('HostsCvssDisplay'); }); @@ -105,7 +101,6 @@ describe('HostsCvssDisplay', () => { describe('HostsCvssTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsCvssTableDisplay).toBeDefined(); - expect(typeof HostsCvssTableDisplay).toBe('function'); expect(HostsCvssTableDisplay.displayId).toBe('host-by-cvss-table'); expect(HostsCvssTableDisplay.displayName).toBe('HostsCvssTableDisplay'); }); diff --git a/src/web/pages/hosts/dashboard/__tests__/HostsLoaders.test.tsx b/src/web/pages/hosts/dashboard/__tests__/HostsLoaders.test.tsx index 6f1327c5c5..a2183b09c1 100644 --- a/src/web/pages/hosts/dashboard/__tests__/HostsLoaders.test.tsx +++ b/src/web/pages/hosts/dashboard/__tests__/HostsLoaders.test.tsx @@ -19,7 +19,7 @@ import { HostsModifiedLoader, HostsSeverityLoader, HostsTopologyLoader, - HostsVulnScoreLoader, + HostsVulnerabilityScoreLoader, } from 'web/pages/hosts/dashboard/HostsLoaders'; const createGmp = (hosts: Record) => ({hosts}); @@ -68,7 +68,11 @@ describe('Hosts loaders', () => { await waitFor(() => { expect(getModifiedAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expect(subscribe).toHaveBeenCalledWith('hosts.timer', expect.any(Function)); @@ -96,7 +100,11 @@ describe('Hosts loaders', () => { await waitFor(() => { expect(getSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expect(subscribe).toHaveBeenCalledWith('hosts.timer', expect.any(Function)); @@ -126,7 +134,11 @@ describe('Hosts loaders', () => { expect(get).toHaveBeenCalledWith({ filter: QueryFilter.fromString('first=1 rows=1000'), }); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expect(subscribe).toHaveBeenCalledWith('hosts.timer', expect.any(Function)); @@ -148,13 +160,19 @@ describe('Hosts loaders', () => { gmp, subscribe, children: ( - {children} + + {children} + ), }); await waitFor(() => { expect(getVulnScoreAggregates).toHaveBeenCalledWith({filter, max: 10}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expect(subscribe).toHaveBeenCalledWith('hosts.timer', expect.any(Function)); diff --git a/src/web/pages/hosts/dashboard/__tests__/HostsTopologyDisplay.test.tsx b/src/web/pages/hosts/dashboard/__tests__/HostsTopologyDisplay.test.tsx index 5119007fc6..479489e0e4 100644 --- a/src/web/pages/hosts/dashboard/__tests__/HostsTopologyDisplay.test.tsx +++ b/src/web/pages/hosts/dashboard/__tests__/HostsTopologyDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -23,17 +24,16 @@ const loaderData = [ ]; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -59,7 +59,9 @@ vi.mock('web/components/chart/HostsTopologyChart', () => ({ })); const createGmp = () => ({ - settings: {severityRating: 'CVSSv3'}, + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -89,8 +91,8 @@ const renderDisplay = (component: ReactElement) => { describe('HostsTopologyDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(HostsTopologyDisplay).toBeDefined(); - expect(typeof HostsTopologyDisplay).toBe('function'); expect(HostsTopologyDisplay.displayId).toBe('host-by-topology'); + expect(HostsTopologyDisplay.displayName).toBe('HostsTopologyDisplay'); }); test('should be registered with the correct title', () => { @@ -107,6 +109,9 @@ describe('HostsTopologyDisplay', () => { await waitFor(() => { expect(screen.getByTestId('title')).toHaveTextContent('Hosts Topology'); + expect(screen.getByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); expect(screen.getByTestId('severity-rating')).toHaveTextContent('CVSSv3'); expect(screen.getByTestId('host-count')).toHaveTextContent('3'); expect(screen.getByTestId('link-count')).toHaveTextContent('2'); diff --git a/src/web/pages/hosts/dashboard/__tests__/HostsVulnScoreDisplay.test.tsx b/src/web/pages/hosts/dashboard/__tests__/HostsVulnScoreDisplay.test.tsx index 55338e59f7..76c692efbb 100644 --- a/src/web/pages/hosts/dashboard/__tests__/HostsVulnScoreDisplay.test.tsx +++ b/src/web/pages/hosts/dashboard/__tests__/HostsVulnScoreDisplay.test.tsx @@ -5,15 +5,16 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, type SubscribeFunc, } from 'web/components/provider/SubscriptionProvider'; import { - HostsVulnScoreDisplay, - HostsVulnScoreTableDisplay, + HostsVulnerabilityScoreDisplay, + HostsVulnerabilityScoreTableDisplay, } from 'web/pages/hosts/dashboard/HostsVulnScoreDisplay'; const loaderData = { @@ -37,7 +38,14 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, severityRating, title}) => { + default: ({ + children, + data, + dataTransform, + severityRating, + showToggleLegend, + title, + }) => { const transformedData = dataTransform ? dataTransform(data, {severityRating}) : data; @@ -45,6 +53,7 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ return (
{title?.({data: transformedData})} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, @@ -98,7 +107,9 @@ vi.mock('web/components/chart/BarChart', () => ({ })); const createGmp = () => ({ - settings: {severityRating: 'CVSSv3'}, + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -110,9 +121,12 @@ const createGmp = () => ({ }, }); -const renderDisplay = (component: ReactElement) => { +const renderDisplay = ( + component: ReactElement, + {showLocation}: {showLocation?: boolean} = {}, +) => { const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); - const {render} = rendererWith({gmp: createGmp()}); + const {render} = rendererWith({gmp: createGmp(), showLocation}); return render( @@ -123,26 +137,34 @@ const renderDisplay = (component: ReactElement) => { describe('HostsVulnScoreDisplay', () => { test('should export a valid component with the correct configuration', () => { - expect(HostsVulnScoreDisplay).toBeDefined(); - expect(HostsVulnScoreDisplay.displayId).toBe('host-by-most-vulnerable'); + expect(HostsVulnerabilityScoreDisplay).toBeDefined(); + expect(HostsVulnerabilityScoreDisplay.displayId).toBe( + 'host-by-most-vulnerable', + ); + expect(HostsVulnerabilityScoreDisplay.displayName).toBe( + 'HostsVulnScoreDisplay', + ); }); test('should be registered with the correct title', () => { - const registered = getDisplay(HostsVulnScoreDisplay.displayId); + const registered = getDisplay(HostsVulnerabilityScoreDisplay.displayId); - expect(registered?.component).toBe(HostsVulnScoreDisplay); + expect(registered?.component).toBe(HostsVulnerabilityScoreDisplay); expect(String(registered?.title)).toBe( 'Chart: Hosts by Vulnerability Score', ); }); test('should render only scored hosts in reverse score order', async () => { - renderDisplay(); + renderDisplay(); await waitFor(() => { expect(screen.getByTestId('title')).toHaveTextContent( 'Most Vulnerable Hosts', ); + expect(screen.getByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); expect( screen.getByRole('button', {name: 'bar-Host Three'}), ).toBeVisible(); @@ -152,18 +174,12 @@ describe('HostsVulnScoreDisplay', () => { }); test('should navigate to the selected host', async () => { - const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); - const {render} = rendererWith({gmp: createGmp(), showLocation: true}); - render( - - - , - ); + renderDisplay(, { + showLocation: true, + }); - await waitFor(() => - expect(screen.getByRole('button', {name: 'bar-Host One'})).toBeVisible(), - ); - screen.getByRole('button', {name: 'bar-Host One'}).click(); + const button = await screen.findByRole('button', {name: 'bar-Host One'}); + fireEvent.click(button); await waitFor(() => expect(screen.getByTestId('location-pathname')).toHaveTextContent( @@ -175,26 +191,30 @@ describe('HostsVulnScoreDisplay', () => { describe('HostsVulnScoreTableDisplay', () => { test('should export a valid component with the correct configuration', () => { - expect(HostsVulnScoreTableDisplay).toBeDefined(); - expect(HostsVulnScoreTableDisplay.displayId).toBe( + expect(HostsVulnerabilityScoreTableDisplay).toBeDefined(); + expect(HostsVulnerabilityScoreTableDisplay.displayName).toBe( 'HostsVulnScoreTableDisplay', ); - expect(HostsVulnScoreTableDisplay.displayName).toBe( + expect(HostsVulnerabilityScoreTableDisplay.displayId).toBe( 'host-by-most-vulnerable-table', ); }); test('should be registered with the correct title', () => { - const registered = getDisplay(HostsVulnScoreTableDisplay.displayId); + const registered = getDisplay( + HostsVulnerabilityScoreTableDisplay.displayId, + ); - expect(registered?.component).toBe(HostsVulnScoreTableDisplay); + expect(registered?.component).toBe(HostsVulnerabilityScoreTableDisplay); expect(String(registered?.title)).toBe( 'Table: Hosts by Vulnerability Score', ); }); test('should render the configured table data', async () => { - renderDisplay(); + renderDisplay( + , + ); await waitFor(() => { expect(screen.getByTestId('title')).toHaveTextContent( diff --git a/src/web/pages/hosts/dashboard/index.tsx b/src/web/pages/hosts/dashboard/index.tsx index 07e20f30e7..9fb5e2eb65 100644 --- a/src/web/pages/hosts/dashboard/index.tsx +++ b/src/web/pages/hosts/dashboard/index.tsx @@ -23,8 +23,8 @@ import { } from 'web/pages/hosts/dashboard/HostSeverityClassDisplay'; import HostsTopologyDisplay from 'web/pages/hosts/dashboard/HostsTopologyDisplay'; import { - HostsVulnScoreDisplay, - HostsVulnScoreTableDisplay, + HostsVulnerabilityScoreDisplay, + HostsVulnerabilityScoreTableDisplay, } from 'web/pages/hosts/dashboard/HostsVulnScoreDisplay'; interface HostsDashboardProps { @@ -40,12 +40,12 @@ export const HOSTS_DISPLAYS = [ HostsModifiedHighDisplay.displayId, HostsSeverityClassDisplay.displayId, HostsTopologyDisplay.displayId, - HostsVulnScoreDisplay.displayId, + HostsVulnerabilityScoreDisplay.displayId, HostsCvssTableDisplay.displayId, HostsSeverityClassTableDisplay.displayId, HostsModifiedTableDisplay.displayId, HostsModifiedHighTableDisplay.displayId, - HostsVulnScoreTableDisplay.displayId, + HostsVulnerabilityScoreTableDisplay.displayId, ]; const HostsDashboard = (props: HostsDashboardProps) => ( diff --git a/src/web/pages/notes/dashboard/NoteActiveDaysDisplay.tsx b/src/web/pages/notes/dashboard/NoteActiveDaysDisplay.tsx index e7a12ce04c..2c78cab6a8 100644 --- a/src/web/pages/notes/dashboard/NoteActiveDaysDisplay.tsx +++ b/src/web/pages/notes/dashboard/NoteActiveDaysDisplay.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {NOTES_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -15,14 +15,13 @@ import { import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import DonutChart from 'web/components/chart/DonutChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataDisplayIcons from 'web/components/dashboard/display/DataDisplayIcons'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import { totalCount, percent, @@ -34,7 +33,7 @@ import { NotesActiveDaysLoader, } from 'web/pages/notes/dashboard/NoteLoaders'; -interface TransformedNotesActiveDaysGroup { +interface TransformedNotesActiveDaysDataItem { bulked?: boolean; color: string; filterValue: string; @@ -43,16 +42,12 @@ interface TransformedNotesActiveDaysGroup { value: number; } -interface TransformedNotesActiveDaysData extends Array { +interface TransformedNotesActiveDaysData extends Array { total: number; } -type NotesActiveDaysDataDisplayProps = DataDisplayProps< - NotesActiveDaysData, - TransformedNotesActiveDaysData ->; - -type NotesActiveDaysDisplayProps = DashboardDisplayProps; +type NotesActiveDaysDataDisplayProps = + DataDisplayProps; const MAX_BINS = 10; // if this is changed, activeDaysColorScale needs adjustment @@ -112,7 +107,7 @@ const transformActiveDaysData = ( toolTip: `${label}: ${perc}% (${count})`, color: activeDaysColorScale(colorCounter++), filterValue: String(value), - } as TransformedNotesActiveDaysGroup; + } as TransformedNotesActiveDaysDataItem; }); const result = transformedData as unknown as TransformedNotesActiveDaysData; @@ -120,111 +115,89 @@ const transformActiveDaysData = ( return result; }; -export const NotesActiveDaysDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - onFilterChanged, - ...props -}: NotesActiveDaysDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: NOTES_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; +export const NotesActiveDaysDisplay = createDisplay({ + loaderComponent: NotesActiveDaysLoader, + displayComponent: ({data, onFilterChanged, filter, ...props}) => { + const transformedData = useDataTransform(data, transformActiveDaysData); + const handleDataClick = useCallback( + ({filterValue, bulked = false}: TransformedNotesActiveDaysDataItem) => { + if (!isDefined(onFilterChanged)) { + return; + } - const handleDataClick = data => { - const {filterValue, bulked = false} = data; + const activeDaysTerm = bulked + ? FilterTerm.fromString(`active_days>"${filterValue}"`) + : FilterTerm.fromString(`active_days="${filterValue}"`); - if (!isDefined(onFilterChanged)) { - return; - } + if (isDefined(filter) && filter.hasTerm(activeDaysTerm)) { + return; + } + const activeDaysFilter = QueryFilter.fromTerm(activeDaysTerm); - let activeDaysTerm; - if (bulked) { - activeDaysTerm = FilterTerm.fromString(`active_days>"${filterValue}"`); - } else { - activeDaysTerm = FilterTerm.fromString(`active_days="${filterValue}"`); - } + const newFilter = isDefined(filter) + ? filter.and(activeDaysFilter) + : activeDaysFilter; - if (isDefined(filter) && filter.hasTerm(activeDaysTerm)) { - return; - } - const activeDaysFilter = QueryFilter.fromTerm(activeDaysTerm); - - const newFilter = isDefined(filter) - ? filter.copy().and(activeDaysFilter) - : activeDaysFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformActiveDaysData} - filter={displayFilter} - icons={DataDisplayIcons} - initialState={{}} - title={({data}) => - _('Notes by Active Days (Total: {{count}})', { - count: data?.total ?? 0, - }) + onFilterChanged(newFilter); + }, + [onFilterChanged, filter], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + icons={DataDisplayIcons} + initialState={{}} + title={({data}) => + _('Notes by Active Days (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + > + {({width, height, data, svgRef, state}) => ( + - {({width, height, data, svgRef, state}) => ( - - )} - + /> )} - - {filterSelectionDialog} - - ); -}; - -NotesActiveDaysDisplay.displayId = 'note-by-active-days'; + + ); + }, + filtersFilter: NOTES_FILTER_FILTER, + displayId: 'note-by-active-days', + displayName: 'NotesActiveDaysDisplay', +}); export const NotesActiveDaysTableDisplay = createDisplay({ loaderComponent: NotesActiveDaysLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.value]) ?? [] - } - dataTitles={[_('Active'), _('# of Notes')]} - dataTransform={transformActiveDaysData} - title={({data}) => - _('Notes by Active Days (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformActiveDaysData); + return ( + + transformedData?.map(row => [row.label ?? '', row.value]) ?? [] + } + dataTitles={[_('Active'), _('# of Notes')]} + title={({data}) => + _('Notes by Active Days (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'note-by-active-days-table', displayName: 'NotesActiveDaysTableDisplay', filtersFilter: NOTES_FILTER_FILTER, diff --git a/src/web/pages/notes/dashboard/NoteCreatedDisplay.tsx b/src/web/pages/notes/dashboard/NoteCreatedDisplay.tsx index 8fdd1dbf7c..0473ba202f 100644 --- a/src/web/pages/notes/dashboard/NoteCreatedDisplay.tsx +++ b/src/web/pages/notes/dashboard/NoteCreatedDisplay.tsx @@ -9,31 +9,35 @@ import transformCreated from 'web/components/dashboard/display/created/created-t import CreatedDisplay from 'web/components/dashboard/display/created/CreatedDisplay'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import {NotesCreatedLoader} from 'web/pages/notes/dashboard/NoteLoaders'; import Theme from 'web/utils/theme'; export const NotesCreatedDisplay = createDisplay({ loaderComponent: NotesCreatedLoader, - displayComponent: props => ( - _('Notes by Creation Time')} - xAxisLabel={_('Time')} - y2AxisLabel={_('Total Notes')} - y2Line={{ - color: Theme.darkGreenTransparent, - dashArray: '3, 2', - label: _('Total Notes'), - }} - yAxisLabel={_('# of Created Notes')} - yLine={{ - color: Theme.darkGreenTransparent, - label: _('Created Notes'), - }} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformCreated); + return ( + _('Notes by Creation Time')} + xAxisLabel={_('Time')} + y2AxisLabel={_('Total Notes')} + y2Line={{ + color: Theme.darkGreenTransparent, + dashArray: '3, 2', + label: _('Total Notes'), + }} + yAxisLabel={_('# of Created Notes')} + yLine={{ + color: Theme.darkGreenTransparent, + label: _('Created Notes'), + }} + /> + ); + }, displayName: 'NotesCreatedDisplay', displayId: 'note-by-created', filtersFilter: NOTES_FILTER_FILTER, @@ -41,17 +45,20 @@ export const NotesCreatedDisplay = createDisplay({ export const NotesCreatedTableDisplay = createDisplay({ loaderComponent: NotesCreatedLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.y, row.y2]) ?? [] - } - dataTitles={[_('Creation Time'), _('# of Notes'), _('Total Notes')]} - dataTransform={transformCreated} - title={() => _('Notes by Creation Time')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformCreated); + return ( + + transformedData?.map(row => [row.label ?? '', row.y, row.y2]) ?? [] + } + dataTitles={[_('Creation Time'), _('# of Notes'), _('Total Notes')]} + title={() => _('Notes by Creation Time')} + /> + ); + }, displayName: 'NotesCreatedTableDisplay', displayId: 'note-by-created-table', filtersFilter: NOTES_FILTER_FILTER, diff --git a/src/web/pages/notes/dashboard/NoteWordCloudDisplay.tsx b/src/web/pages/notes/dashboard/NoteWordCloudDisplay.tsx index 890ea086e2..00a6af5d0b 100644 --- a/src/web/pages/notes/dashboard/NoteWordCloudDisplay.tsx +++ b/src/web/pages/notes/dashboard/NoteWordCloudDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {NOTES_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -11,13 +12,12 @@ import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import WordCloudChart from 'web/components/chart/WordCloudChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {randomColor} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -25,23 +25,21 @@ import { type WordCloudData, } from 'web/pages/notes/dashboard/NoteLoaders'; -interface TransformedWordCloudData { +interface TransformedWordCloudDataItem { value: number; label: string; color: string; filterValue: string; } -type NotesWordCloudDataDisplayProps = DataDisplayProps< - WordCloudData, - TransformedWordCloudData[] ->; +type TransformedWordCloudData = TransformedWordCloudDataItem[]; -type NotesWordCloudDisplayProps = DashboardDisplayProps; +type NotesWordCloudDataDisplayProps = + DataDisplayProps; const transformWordCountData = ( data: WordCloudData | undefined = {}, -): TransformedWordCloudData[] => { +): TransformedWordCloudDataItem[] => { const {groups = []} = data; const transformData = groups.map(group => { const {count, value} = group; @@ -55,95 +53,74 @@ const transformWordCountData = ( return transformData; }; -export const NotesWordCloudDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - onFilterChanged, - ...props -}: NotesWordCloudDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: NOTES_FILTER_FILTER, - onFilterIdChanged, - }); - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (filterValue: string) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const wordTerm = FilterTerm.fromString(`text~"${filterValue}"`); - - if (isDefined(displayFilter) && displayFilter.hasTerm(wordTerm)) { - return; - } - const wordFilter = QueryFilter.fromTerm(wordTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.and(wordFilter) - : wordFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformWordCountData} - filter={displayFilter} - showToggleLegend={false} - title={() => _('Notes Text Word Cloud')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const NotesWordCloudDisplay = createDisplay({ + loaderComponent: NotesWordCountLoader, + displayComponent: ({data, onFilterChanged, filter, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + const handleDataClick = useCallback( + (filterValue: string) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const wordTerm = FilterTerm.fromString(`text~"${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(wordTerm)) { + return; + } + const wordFilter = QueryFilter.fromTerm(wordTerm); + + const newFilter = isDefined(filter) + ? filter.and(wordFilter) + : wordFilter; + + onFilterChanged(newFilter); + }, + [onFilterChanged, filter], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + showToggleLegend={false} + title={() => _('Notes Text Word Cloud')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -NotesWordCloudDisplay.displayId = 'note-by-text-words'; + + ); + }, + displayId: 'note-by-text-words', + displayName: 'NotesWordCloudDisplay', + filtersFilter: NOTES_FILTER_FILTER, +}); export const NotesWordCloudTableDisplay = createDisplay({ loaderComponent: NotesWordCountLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.value]) ?? [] - } - dataTitles={[_('Text'), _('Count')]} - dataTransform={transformWordCountData} - title={() => _('Notes Text Word Cloud')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + return ( + + transformedData?.map(row => [row.label ?? '', row.value]) ?? [] + } + dataTitles={[_('Text'), _('Count')]} + title={() => _('Notes Text Word Cloud')} + /> + ); + }, displayId: 'note-by-text-words-table', displayName: 'NotesWordCloudTableDisplay', filtersFilter: NOTES_FILTER_FILTER, diff --git a/src/web/pages/notes/dashboard/__tests__/NoteActiveDaysDisplay.test.tsx b/src/web/pages/notes/dashboard/__tests__/NoteActiveDaysDisplay.test.tsx index 8b6c63cc8d..30a8869a17 100644 --- a/src/web/pages/notes/dashboard/__tests__/NoteActiveDaysDisplay.test.tsx +++ b/src/web/pages/notes/dashboard/__tests__/NoteActiveDaysDisplay.test.tsx @@ -5,7 +5,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,17 +25,15 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, state: {showLegend: true}, svgRef: {current: null}, }) @@ -46,16 +44,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData.map((row, index) => ( + {rowData.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -64,12 +61,16 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/DonutChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))}
), @@ -102,6 +103,7 @@ describe('NotesActiveDaysDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NotesActiveDaysDisplay).toBeDefined(); expect(NotesActiveDaysDisplay.displayId).toBe('note-by-active-days'); + expect(NotesActiveDaysDisplay.displayName).toBe('NotesActiveDaysDisplay'); }); test('should be registered with the correct title', () => { @@ -129,6 +131,38 @@ describe('NotesActiveDaysDisplay', () => { ); }); }); + + test('should call onFilterChanged with an active-days filter', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const activeDaysButton = await screen.findByRole('button', { + name: /Active \(unlimited\)/, + }); + fireEvent.click(activeDaysButton); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( + 'active_days="-2"', + ); + }); + + test('should not throw when clicking a data item without onFilterChanged', async () => { + renderDisplay(); + + const activeDaysButton = await screen.findByRole('button', { + name: /Active \(unlimited\)/, + }); + + expect(() => fireEvent.click(activeDaysButton)).not.toThrow(); + }); }); describe('NotesActiveDaysTableDisplay', () => { diff --git a/src/web/pages/notes/dashboard/__tests__/NoteCreatedDisplay.test.tsx b/src/web/pages/notes/dashboard/__tests__/NoteCreatedDisplay.test.tsx index 5e89eecbec..759c31fe31 100644 --- a/src/web/pages/notes/dashboard/__tests__/NoteCreatedDisplay.test.tsx +++ b/src/web/pages/notes/dashboard/__tests__/NoteCreatedDisplay.test.tsx @@ -75,7 +75,6 @@ const renderDisplay = (component: ReactElement) => { describe('NotesCreatedDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NotesCreatedDisplay).toBeDefined(); - expect(typeof NotesCreatedDisplay).toBe('function'); expect(NotesCreatedDisplay.displayId).toBe('note-by-created'); expect(NotesCreatedDisplay.displayName).toBe('NotesCreatedDisplay'); }); @@ -108,7 +107,6 @@ describe('NotesCreatedDisplay', () => { describe('NotesCreatedTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NotesCreatedTableDisplay).toBeDefined(); - expect(typeof NotesCreatedTableDisplay).toBe('function'); expect(NotesCreatedTableDisplay.displayId).toBe('note-by-created-table'); expect(NotesCreatedTableDisplay.displayName).toBe( 'NotesCreatedTableDisplay', diff --git a/src/web/pages/notes/dashboard/__tests__/NoteLoaders.test.tsx b/src/web/pages/notes/dashboard/__tests__/NoteLoaders.test.tsx index 592f96c2f4..1bcf7ae7cb 100644 --- a/src/web/pages/notes/dashboard/__tests__/NoteLoaders.test.tsx +++ b/src/web/pages/notes/dashboard/__tests__/NoteLoaders.test.tsx @@ -73,7 +73,11 @@ describe('Note loaders', () => { await waitFor(() => { expect(getActiveDaysAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNoteSubscriptions(subscribe); @@ -101,7 +105,11 @@ describe('Note loaders', () => { await waitFor(() => { expect(getCreatedAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNoteSubscriptions(subscribe); @@ -129,7 +137,11 @@ describe('Note loaders', () => { await waitFor(() => { expect(getWordCountsAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNoteSubscriptions(subscribe); diff --git a/src/web/pages/notes/dashboard/__tests__/NoteWordCloudDisplay.test.tsx b/src/web/pages/notes/dashboard/__tests__/NoteWordCloudDisplay.test.tsx index c843f1332d..b55066beed 100644 --- a/src/web/pages/notes/dashboard/__tests__/NoteWordCloudDisplay.test.tsx +++ b/src/web/pages/notes/dashboard/__tests__/NoteWordCloudDisplay.test.tsx @@ -5,7 +5,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -24,17 +24,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.()} + {title?.(data)} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -44,16 +43,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
{title?.()} {dataTitles?.join('|')} - {transformedData.map((row, index) => ( + {rowData.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -62,12 +60,16 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/WordCloudChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))}
), @@ -99,8 +101,8 @@ const renderDisplay = (component: ReactElement) => { describe('NotesWordCloudDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NotesWordCloudDisplay).toBeDefined(); - expect(typeof NotesWordCloudDisplay).toBe('function'); expect(NotesWordCloudDisplay.displayId).toBe('note-by-text-words'); + expect(NotesWordCloudDisplay.displayName).toBe('NotesWordCloudDisplay'); }); test('should be registered with the correct title', () => { @@ -125,12 +127,50 @@ describe('NotesWordCloudDisplay', () => { ); }); }); + + test('should set showToggleLegend to false', async () => { + renderDisplay(); + + expect(await screen.findByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + + test('should call onFilterChanged with a text filter when clicking a word', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const wordButton = await screen.findByRole('button', { + name: /security\|5\|security/, + }); + fireEvent.click(wordButton); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( + 'text~"security"', + ); + }); + + test('should not throw when clicking a word without onFilterChanged', async () => { + renderDisplay(); + + const wordButton = await screen.findByRole('button', { + name: /security\|5\|security/, + }); + expect(() => fireEvent.click(wordButton)).not.toThrow(); + }); }); describe('NotesWordCloudTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NotesWordCloudTableDisplay).toBeDefined(); - expect(typeof NotesWordCloudTableDisplay).toBe('function'); expect(NotesWordCloudTableDisplay.displayId).toBe( 'note-by-text-words-table', ); diff --git a/src/web/pages/nvts/dashboard/NvtCreatedDisplay.tsx b/src/web/pages/nvts/dashboard/NvtCreatedDisplay.tsx index e570a4bf12..0fcd06f246 100644 --- a/src/web/pages/nvts/dashboard/NvtCreatedDisplay.tsx +++ b/src/web/pages/nvts/dashboard/NvtCreatedDisplay.tsx @@ -9,31 +9,35 @@ import transformCreated from 'web/components/dashboard/display/created/created-t import CreatedDisplay from 'web/components/dashboard/display/created/CreatedDisplay'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import {NvtCreatedLoader} from 'web/pages/nvts/dashboard/NvtLoaders'; import Theme from 'web/utils/theme'; export const NvtsCreatedDisplay = createDisplay({ loaderComponent: NvtCreatedLoader, - displayComponent: props => ( - _('NVTs by Creation Time')} - xAxisLabel={_('Time')} - y2AxisLabel={_('Total NVTs')} - y2Line={{ - color: Theme.darkGreenTransparent, - dashArray: '3, 2', - label: _('Total NVTs'), - }} - yAxisLabel={_('# of created NVTs')} - yLine={{ - color: Theme.darkGreenTransparent, - label: _('Created NVTs'), - }} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformCreated); + return ( + _('NVTs by Creation Time')} + xAxisLabel={_('Time')} + y2AxisLabel={_('Total NVTs')} + y2Line={{ + color: Theme.darkGreenTransparent, + dashArray: '3, 2', + label: _('Total NVTs'), + }} + yAxisLabel={_('# of created NVTs')} + yLine={{ + color: Theme.darkGreenTransparent, + label: _('Created NVTs'), + }} + /> + ); + }, displayId: 'nvt-by-created', displayName: 'NvtCreatedDisplay', filtersFilter: NVTS_FILTER_FILTER, @@ -41,17 +45,20 @@ export const NvtsCreatedDisplay = createDisplay({ export const NvtsCreatedTableDisplay = createDisplay({ loaderComponent: NvtCreatedLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.y, row.y2]) ?? [] - } - dataTitles={[_('Creation Time'), _('# of NVTs'), _('Total NVTs')]} - dataTransform={transformCreated} - title={() => _('NVTs by Creation Time')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformCreated); + return ( + + transformedData?.map(row => [row.label ?? '', row.y, row.y2]) ?? [] + } + dataTitles={[_('Creation Time'), _('# of NVTs'), _('Total NVTs')]} + title={() => _('NVTs by Creation Time')} + /> + ); + }, displayId: 'nvt-by-created-table', displayName: 'nvtCreatedTableDisplay', filtersFilter: NVTS_FILTER_FILTER, diff --git a/src/web/pages/nvts/dashboard/NvtCvssDisplay.tsx b/src/web/pages/nvts/dashboard/NvtCvssDisplay.tsx index 51c34dd1dd..a64feebb3a 100644 --- a/src/web/pages/nvts/dashboard/NvtCvssDisplay.tsx +++ b/src/web/pages/nvts/dashboard/NvtCvssDisplay.tsx @@ -6,22 +6,32 @@ import {_, _l} from 'gmp/locale/lang'; import {NVTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformCvssData from 'web/components/dashboard/display/cvss/cvss-transform'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {NvtsSeverityLoader} from 'web/pages/nvts/dashboard/NvtLoaders'; export const NvtsCvssDisplay = createDisplay({ loaderComponent: NvtsSeverityLoader, - displayComponent: props => ( - - _('NVTs by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - yLabel={_('# of NVTs')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('NVTs by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + yLabel={_('# of NVTs')} + /> + ); + }, filtersFilter: NVTS_FILTER_FILTER, displayId: 'nvt-by-cvss', displayName: 'NvtsCvssDisplay', @@ -29,15 +39,22 @@ export const NvtsCvssDisplay = createDisplay({ export const NvtsCvssTableDisplay = createDisplay({ loaderComponent: NvtsSeverityLoader, - displayComponent: props => ( - - _('NVTs by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('NVTs by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, filtersFilter: NVTS_FILTER_FILTER, displayId: 'nvt-by-cvss-table', displayName: 'NvtsCvssTableDisplay', diff --git a/src/web/pages/nvts/dashboard/NvtFamilyDisplay.tsx b/src/web/pages/nvts/dashboard/NvtFamilyDisplay.tsx index 4acdf7040e..6e7f8dcb72 100644 --- a/src/web/pages/nvts/dashboard/NvtFamilyDisplay.tsx +++ b/src/web/pages/nvts/dashboard/NvtFamilyDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {NVTS_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -12,13 +13,12 @@ import {isDefined} from 'gmp/utils/identity'; import {DEFAULT_SEVERITY_RATING, type SeverityRating} from 'gmp/utils/severity'; import {isEmpty} from 'gmp/utils/string'; import BubbleChart from 'web/components/chart/BubbleChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {riskFactorColorScale} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import useGmp from 'web/hooks/useGmp'; @@ -46,13 +46,7 @@ interface TransformFamilyDataProps { severityRating?: SeverityRating; } -type NvtFamilyDataDisplayProps = DataDisplayProps< - NvtFamilyData, - TransformedNvtFamilyData, - TransformFamilyDataProps ->; - -type NvtsFamilyDisplayProps = DashboardDisplayProps; +type NvtFamilyDataDisplayProps = DataDisplayProps; const transformFamilyData = ( data: NvtFamilyData = {}, @@ -90,105 +84,87 @@ const transformFamilyData = ( return result; }; -export const NvtsFamilyDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - onFilterChanged, - ...props -}: NvtsFamilyDisplayProps) => { - const gmp = useGmp(); - - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: NVTS_FILTER_FILTER, - onFilterIdChanged, - }); - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = ({filterValue}: TransformedNvtFamilyDataItem) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const familyTerm = FilterTerm.fromString(`family="${filterValue}"`); - - if (isDefined(displayFilter) && displayFilter.hasTerm(familyTerm)) { - return; - } - const familyFilter = QueryFilter.fromTerm(familyTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.and(familyFilter) - : familyFilter; - - onFilterChanged(newFilter); - }; - - const severityRating = gmp.settings.severityRating; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformFamilyData} - filter={displayFilter} - severityRating={severityRating} - showToggleLegend={false} - title={({data}) => - _('NVTs by Family (Total: {{count}})', {count: data?.total ?? 0}) +export const NvtsFamilyDisplay = createDisplay({ + loaderComponent: NvtsFamilyLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformFamilyData, { + severityRating: gmp.settings.severityRating, + }); + const handleDataClick = useCallback( + ({filterValue}: TransformedNvtFamilyDataItem) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const familyTerm = FilterTerm.fromString(`family="${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(familyTerm)) { + return; + } + const familyFilter = QueryFilter.fromTerm(familyTerm); + + const newFilter = isDefined(filter) + ? filter.and(familyFilter) + : familyFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + showToggleLegend={false} + title={({data}) => + _('NVTs by Family (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + > + {({width, height, data, svgRef}) => ( + + data={data} + height={height} + svgRef={svgRef} + width={width} + onDataClick={ + isDefined(onFilterChanged) ? handleDataClick : undefined } - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - data={data} - height={height} - svgRef={svgRef} - width={width} - onDataClick={ - isDefined(onFilterChanged) ? handleDataClick : undefined - } - /> - )} - + /> )} - - {filterSelectionDialog} - - ); -}; - -NvtsFamilyDisplay.displayId = 'nvt-by-family'; + + ); + }, + displayId: 'nvt-by-family', + displayName: 'NvtsFamilyDisplay', + filtersFilter: NVTS_FILTER_FILTER, +}); export const NvtsFamilyTableDisplay = createDisplay({ loaderComponent: NvtsFamilyLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value, row.severity]) ?? [] - } - dataTitles={[_('NVT Family'), _('# of NVTs'), _('Severity')]} - dataTransform={transformFamilyData} - title={({data}) => - _('NVTs by Family (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformFamilyData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + transformedData?.map(row => [row.label, row.value, row.severity]) ?? + [] + } + dataTitles={[_('NVT Family'), _('# of NVTs'), _('Severity')]} + title={({data}) => + _('NVTs by Family (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'nvt-by-family-table', displayName: 'NvtsFamilyTableDisplay', filtersFilter: NVTS_FILTER_FILTER, diff --git a/src/web/pages/nvts/dashboard/NvtQodDisplay.tsx b/src/web/pages/nvts/dashboard/NvtQodDisplay.tsx index 1a8471321b..f12a4ddfcc 100644 --- a/src/web/pages/nvts/dashboard/NvtQodDisplay.tsx +++ b/src/web/pages/nvts/dashboard/NvtQodDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {NVTS_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -11,14 +12,13 @@ import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import DonutChart from 'web/components/chart/DonutChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataDisplayIcons from 'web/components/dashboard/display/DataDisplayIcons'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import { totalCount, percent, @@ -42,12 +42,7 @@ interface TransformedNvtQodData extends Array { total: number; } -type NvtQodDataDisplayProps = DataDisplayProps< - NvtQodData, - TransformedNvtQodData ->; - -type NvtsQodDisplayProps = DashboardDisplayProps; +type NvtQodDataDisplayProps = DataDisplayProps; const transformQodData = (data: NvtQodData = {}): TransformedNvtQodData => { const {groups = []} = data; @@ -72,101 +67,78 @@ const transformQodData = (data: NvtQodData = {}): TransformedNvtQodData => { return result; }; -export const NvtsQodDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - onFilterChanged, - ...props -}: NvtsQodDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: NVTS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = ({filterValue}) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const qodTerm = FilterTerm.fromString(`qod="${filterValue}"`); - - if (isDefined(displayFilter) && displayFilter.hasTerm(qodTerm)) { - return; - } - const qodFilter = QueryFilter.fromTerm(qodTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.and(qodFilter) - : qodFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformQodData} - icons={DataDisplayIcons} - initialState={{}} - title={({data}) => - _('NVTs by QoD (Total: {{count}})', {count: data?.total ?? 0}) +export const NvtsQodDisplay = createDisplay({ + loaderComponent: NvtsQodLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformQodData); + const handleDataClick = useCallback( + ({filterValue}) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const qodTerm = FilterTerm.fromString(`qod="${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(qodTerm)) { + return; + } + const qodFilter = QueryFilter.fromTerm(qodTerm); + + const newFilter = isDefined(filter) ? filter.and(qodFilter) : qodFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + icons={DataDisplayIcons} + initialState={{}} + title={({data}) => + _('NVTs by QoD (Total: {{count}})', {count: data?.total ?? 0}) + } + > + {({width, height, data, svgRef, state}) => ( + - {({width, height, data, svgRef, state}) => ( - - )} - + /> )} - - {filterSelectionDialog} - - ); -}; - -NvtsQodDisplay.displayId = 'nvt-by-qod'; + + ); + }, + filtersFilter: NVTS_FILTER_FILTER, + displayName: 'NvtsQodDisplay', + displayId: 'nvt-by-qod', +}); export const NvtsQodTableDisplay = createDisplay({ loaderComponent: NvtsQodLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.value]) ?? [] - } - dataTitles={[_('QoD'), _('# of NVTs')]} - dataTransform={transformQodData} - title={({data}) => - _('NVTs by QoD (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformQodData); + return ( + + transformedData?.map(row => [row.label ?? '', row.value]) ?? [] + } + dataTitles={[_('QoD'), _('# of NVTs')]} + title={({data}) => + _('NVTs by QoD (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'nvt-by-qod-table', displayName: 'NvtsQodTableDisplay', filtersFilter: NVTS_FILTER_FILTER, diff --git a/src/web/pages/nvts/dashboard/NvtQodTypeDisplay.tsx b/src/web/pages/nvts/dashboard/NvtQodTypeDisplay.tsx index 0ee6b87d4c..b5d6c6645d 100644 --- a/src/web/pages/nvts/dashboard/NvtQodTypeDisplay.tsx +++ b/src/web/pages/nvts/dashboard/NvtQodTypeDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {NVTS_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -10,14 +11,13 @@ import QueryFilter from 'gmp/models/filter/query-filter'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import DonutChart from 'web/components/chart/DonutChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataDisplayIcons from 'web/components/dashboard/display/DataDisplayIcons'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import { totalCount, percent, @@ -42,12 +42,7 @@ interface TransformedNvtQodTypeData extends Array total: number; } -type NvtQodTypeDataDisplayProps = DataDisplayProps< - NvtQodData, - TransformedNvtQodTypeData ->; - -type NvtsQodTypeDisplayProps = DashboardDisplayProps; +type NvtQodTypeDataDisplayProps = DataDisplayProps; const transformQodTypeData = ( data: NvtQodData = {}, @@ -73,102 +68,82 @@ const transformQodTypeData = ( return result; }; -export const NvtsQodTypeDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - onFilterChanged, - ...props -}: NvtsQodTypeDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: NVTS_FILTER_FILTER, - onFilterIdChanged, - }); - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = ({filterValue}) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const qodTypeTerm = FilterTerm.fromString(`qod_type="${filterValue}"`); - - if (isDefined(displayFilter) && displayFilter.hasTerm(qodTypeTerm)) { - return; - } - const qodTypeFilter = QueryFilter.fromTerm(qodTypeTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.copy().and(qodTypeFilter) - : qodTypeFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformQodTypeData} - icons={DataDisplayIcons} - initialState={{}} - title={({data}) => - _('NVTs by QoD-Type (Total: {{count}})', { - count: data?.total ?? 0, - }) +export const NvtsQodTypeDisplay = createDisplay({ + loaderComponent: NvtsQodTypeLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformQodTypeData); + const handleDataClick = useCallback( + ({filterValue}) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const qodTypeTerm = FilterTerm.fromString(`qod_type="${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(qodTypeTerm)) { + return; + } + const qodTypeFilter = QueryFilter.fromTerm(qodTypeTerm); + + const newFilter = isDefined(filter) + ? filter.and(qodTypeFilter) + : qodTypeFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + icons={DataDisplayIcons} + initialState={{}} + title={({data}) => + _('NVTs by QoD-Type (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + > + {({width, height, data, svgRef, state}) => ( + - {({width, height, data, svgRef, state}) => ( - - )} - + /> )} - - {filterSelectionDialog} - - ); -}; - -NvtsQodTypeDisplay.displayId = 'nvt-by-qod_type'; + + ); + }, + filtersFilter: NVTS_FILTER_FILTER, + displayId: 'nvt-by-qod_type', + displayName: 'NvtsQodTypeDisplay', +}); export const NvtsQodTypeTableDisplay = createDisplay({ loaderComponent: NvtsQodTypeLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.value]) ?? [] - } - dataTitles={[_('QoD-Type'), _('# of NVTs')]} - dataTransform={transformQodTypeData} - title={({data}) => - _('NVTs by QoD-Type (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformQodTypeData); + return ( + + transformedData?.map(row => [row.label ?? '', row.value]) ?? [] + } + dataTitles={[_('QoD-Type'), _('# of NVTs')]} + title={({data}) => + _('NVTs by QoD-Type (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'nvt-by-qod-type-table', displayName: 'NvtsQodTypeTableDisplay', filtersFilter: NVTS_FILTER_FILTER, diff --git a/src/web/pages/nvts/dashboard/NvtSeverityClassDisplay.tsx b/src/web/pages/nvts/dashboard/NvtSeverityClassDisplay.tsx index c073ee66cc..efe4ab6f55 100644 --- a/src/web/pages/nvts/dashboard/NvtSeverityClassDisplay.tsx +++ b/src/web/pages/nvts/dashboard/NvtSeverityClassDisplay.tsx @@ -6,23 +6,33 @@ import {_, _l} from 'gmp/locale/lang'; import {NVTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformSeverityData from 'web/components/dashboard/display/severity/severity-class-transform'; import SeverityClassDisplay from 'web/components/dashboard/display/severity/SeverityClassDisplay'; import SeverityClassTableDisplay from 'web/components/dashboard/display/severity/SeverityClassTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {NvtsSeverityLoader} from 'web/pages/nvts/dashboard/NvtLoaders'; export const NvtsSeverityClassDisplay = createDisplay({ loaderComponent: NvtsSeverityLoader, - displayComponent: props => ( - - _('NVTs by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('NVTs by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'nvt-by-severity-class', displayName: 'NvtsSeverityClassDisplay', filtersFilter: NVTS_FILTER_FILTER, @@ -30,17 +40,24 @@ export const NvtsSeverityClassDisplay = createDisplay({ export const NvtsSeverityClassTableDisplay = createDisplay({ loaderComponent: NvtsSeverityLoader, - displayComponent: props => ( - - _('NVTs by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('NVTs by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'nvt-by-severity-table', displayName: 'NvtsSeverityClassTableDisplay', filtersFilter: NVTS_FILTER_FILTER, diff --git a/src/web/pages/nvts/dashboard/__tests__/NvtCvssDisplay.test.tsx b/src/web/pages/nvts/dashboard/__tests__/NvtCvssDisplay.test.tsx index c1d6c72abb..f2efe262f6 100644 --- a/src/web/pages/nvts/dashboard/__tests__/NvtCvssDisplay.test.tsx +++ b/src/web/pages/nvts/dashboard/__tests__/NvtCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,12 +26,9 @@ const loaderData = { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({data, title, yLabel}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {yLabel}
); @@ -39,12 +37,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -52,6 +47,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, nvts: { getSeverityAggregates: testing.fn().mockResolvedValue({data: loaderData}), }, diff --git a/src/web/pages/nvts/dashboard/__tests__/NvtFamilyDisplay.test.tsx b/src/web/pages/nvts/dashboard/__tests__/NvtFamilyDisplay.test.tsx index 74a2f164da..13d96a7031 100644 --- a/src/web/pages/nvts/dashboard/__tests__/NvtFamilyDisplay.test.tsx +++ b/src/web/pages/nvts/dashboard/__tests__/NvtFamilyDisplay.test.tsx @@ -5,8 +5,9 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; import QueryFilter from 'gmp/models/filter/query-filter'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,17 +26,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -45,18 +45,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- - {title?.({data: transformedData, originalData: data})} - + {title?.({data, originalData: data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -77,10 +74,12 @@ vi.mock('web/components/chart/BubbleChart', () => ({ })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, nvts: { getFamilyAggregates: testing.fn().mockResolvedValue({data: loaderData}), }, - settings: {severityRating: 'CVSSv3'}, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -104,6 +103,7 @@ describe('NvtsFamilyDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NvtsFamilyDisplay).toBeDefined(); expect(NvtsFamilyDisplay.displayId).toBe('nvt-by-family'); + expect(NvtsFamilyDisplay.displayName).toBe('NvtsFamilyDisplay'); }); test('should be registered with the correct title', () => { @@ -122,6 +122,9 @@ describe('NvtsFamilyDisplay', () => { ); expect(screen.getByRole('button', {name: 'Linux'})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Windows'})).toBeInTheDocument(); + expect(screen.getByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); }); }); @@ -136,7 +139,7 @@ describe('NvtsFamilyDisplay', () => { ); const family = await screen.findByRole('button', {name: 'Linux'}); - family.click(); + fireEvent.click(family); expect(onFilterChanged).toHaveBeenCalledTimes(1); expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( @@ -156,10 +159,17 @@ describe('NvtsFamilyDisplay', () => { ); const family = await screen.findByRole('button', {name: 'Linux'}); - family.click(); + fireEvent.click(family); expect(onFilterChanged).not.toHaveBeenCalled(); }); + + test('should not throw when clicking a family without onFilterChanged', async () => { + renderDisplay(); + + const family = await screen.findByRole('button', {name: 'Linux'}); + expect(() => fireEvent.click(family)).not.toThrow(); + }); }); describe('NvtsFamilyTableDisplay', () => { diff --git a/src/web/pages/nvts/dashboard/__tests__/NvtLoaders.test.tsx b/src/web/pages/nvts/dashboard/__tests__/NvtLoaders.test.tsx index a92a55ba7f..d4d3107812 100644 --- a/src/web/pages/nvts/dashboard/__tests__/NvtLoaders.test.tsx +++ b/src/web/pages/nvts/dashboard/__tests__/NvtLoaders.test.tsx @@ -79,7 +79,11 @@ describe('NVT loaders', () => { await waitFor(() => { expect(getFamilyAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNvtSubscriptions(subscribe); @@ -105,7 +109,11 @@ describe('NVT loaders', () => { await waitFor(() => { expect(getSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNvtSubscriptions(subscribe); @@ -129,7 +137,11 @@ describe('NVT loaders', () => { await waitFor(() => { expect(getQodAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNvtSubscriptions(subscribe); @@ -155,7 +167,11 @@ describe('NVT loaders', () => { await waitFor(() => { expect(getQodTypeAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNvtSubscriptions(subscribe); @@ -183,7 +199,11 @@ describe('NVT loaders', () => { await waitFor(() => { expect(getCreatedAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectNvtSubscriptions(subscribe); diff --git a/src/web/pages/nvts/dashboard/__tests__/NvtQodDisplay.test.tsx b/src/web/pages/nvts/dashboard/__tests__/NvtQodDisplay.test.tsx index 07aedebd13..d8b474884a 100644 --- a/src/web/pages/nvts/dashboard/__tests__/NvtQodDisplay.test.tsx +++ b/src/web/pages/nvts/dashboard/__tests__/NvtQodDisplay.test.tsx @@ -5,7 +5,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { @@ -46,16 +46,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -102,6 +101,7 @@ describe('NvtsQodDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NvtsQodDisplay).toBeDefined(); expect(NvtsQodDisplay.displayId).toBe('nvt-by-qod'); + expect(NvtsQodDisplay.displayName).toBe('NvtsQodDisplay'); }); test('should be registered with the correct title', () => { @@ -133,7 +133,7 @@ describe('NvtsQodDisplay', () => { ); const qod = await screen.findByRole('button', {name: '80 %'}); - qod.click(); + fireEvent.click(qod); expect(onFilterChanged).toHaveBeenCalledTimes(1); expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe('qod="80"'); @@ -151,10 +151,17 @@ describe('NvtsQodDisplay', () => { ); const qod = await screen.findByRole('button', {name: '80 %'}); - qod.click(); + fireEvent.click(qod); expect(onFilterChanged).not.toHaveBeenCalled(); }); + + test('should not throw when clicking a QoD without onFilterChanged', async () => { + renderDisplay(); + + const qod = await screen.findByRole('button', {name: '80 %'}); + expect(() => fireEvent.click(qod)).not.toThrow(); + }); }); describe('NvtsQodTableDisplay', () => { diff --git a/src/web/pages/nvts/dashboard/__tests__/NvtQodTypeDisplay.test.tsx b/src/web/pages/nvts/dashboard/__tests__/NvtQodTypeDisplay.test.tsx index a44214cab8..bea81cfeb9 100644 --- a/src/web/pages/nvts/dashboard/__tests__/NvtQodTypeDisplay.test.tsx +++ b/src/web/pages/nvts/dashboard/__tests__/NvtQodTypeDisplay.test.tsx @@ -5,7 +5,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { @@ -25,17 +25,15 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, state: {showLegend: true}, svgRef: {current: null}, }) @@ -46,16 +44,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -101,8 +98,8 @@ const renderDisplay = (component: ReactElement) => { describe('NvtsQodTypeDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NvtsQodTypeDisplay).toBeDefined(); - expect(typeof NvtsQodTypeDisplay).toBe('function'); expect(NvtsQodTypeDisplay.displayId).toBe('nvt-by-qod_type'); + expect(NvtsQodTypeDisplay.displayName).toBe('NvtsQodTypeDisplay'); }); test('should be registered with the correct title', () => { @@ -136,7 +133,7 @@ describe('NvtsQodTypeDisplay', () => { ); const qodType = await screen.findByRole('button', {name: 'General note'}); - qodType.click(); + fireEvent.click(qodType); expect(onFilterChanged).toHaveBeenCalledTimes(1); expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( @@ -156,16 +153,22 @@ describe('NvtsQodTypeDisplay', () => { ); const qodType = await screen.findByRole('button', {name: 'General note'}); - qodType.click(); + fireEvent.click(qodType); expect(onFilterChanged).not.toHaveBeenCalled(); }); + + test('should not throw when clicking a QoD-type without onFilterChanged', async () => { + renderDisplay(); + + const qodType = await screen.findByRole('button', {name: 'General note'}); + expect(() => fireEvent.click(qodType)).not.toThrow(); + }); }); describe('NvtsQodTypeTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(NvtsQodTypeTableDisplay).toBeDefined(); - expect(typeof NvtsQodTypeTableDisplay).toBe('function'); expect(NvtsQodTypeTableDisplay.displayId).toBe('nvt-by-qod-type-table'); expect(NvtsQodTypeTableDisplay.displayName).toBe('NvtsQodTypeTableDisplay'); }); diff --git a/src/web/pages/nvts/dashboard/__tests__/NvtSeverityClassDisplay.test.tsx b/src/web/pages/nvts/dashboard/__tests__/NvtSeverityClassDisplay.test.tsx index a9873438cf..e9d4ff8601 100644 --- a/src/web/pages/nvts/dashboard/__tests__/NvtSeverityClassDisplay.test.tsx +++ b/src/web/pages/nvts/dashboard/__tests__/NvtSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -27,14 +28,7 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({data, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - - return ( -
- {title?.({data: {total}})} -
- ); + return
{title?.({data})}
; }, }), ); @@ -43,12 +37,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -57,10 +48,12 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, nvts: { getSeverityAggregates: testing.fn().mockResolvedValue({data: loaderData}), }, - settings: {severityRating: 'CVSSv3'}, filters: { get: testing.fn().mockResolvedValue({ data: [], diff --git a/src/web/pages/operatingsystems/dashboard/OperatingSystemCvssDisplay.tsx b/src/web/pages/operatingsystems/dashboard/OperatingSystemCvssDisplay.tsx index 25f2cfb26f..1401512808 100644 --- a/src/web/pages/operatingsystems/dashboard/OperatingSystemCvssDisplay.tsx +++ b/src/web/pages/operatingsystems/dashboard/OperatingSystemCvssDisplay.tsx @@ -6,24 +6,34 @@ import {_, _l} from 'gmp/locale/lang'; import {OS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformCvssData from 'web/components/dashboard/display/cvss/cvss-transform'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {OperatingSystemAverageSeverityLoader} from 'web/pages/operatingsystems/dashboard/OperatingSystemLoaders'; export const OperatingSystemCvssDisplay = createDisplay({ loaderComponent: OperatingSystemAverageSeverityLoader, - displayComponent: props => ( - - _('Operating Systems by CVSS (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - yLabel={_('# of Vulnerabilities')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Operating Systems by CVSS (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + yLabel={_('# of Vulnerabilities')} + /> + ); + }, filtersFilter: OS_FILTER_FILTER, displayId: 'os-by-cvss', displayName: 'OsCvssDisplay', @@ -31,17 +41,24 @@ export const OperatingSystemCvssDisplay = createDisplay({ export const OperatingSystemCvssTableDisplay = createDisplay({ loaderComponent: OperatingSystemAverageSeverityLoader, - displayComponent: props => ( - - _('Operating Systems by CVSS (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Operating Systems by CVSS (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, filtersFilter: OS_FILTER_FILTER, displayId: 'os-by-cvss-table', displayName: 'OsCvssTableDisplay', diff --git a/src/web/pages/operatingsystems/dashboard/OperatingSystemSeverityClassDisplay.tsx b/src/web/pages/operatingsystems/dashboard/OperatingSystemSeverityClassDisplay.tsx index 41cbb872ca..d6a493949b 100644 --- a/src/web/pages/operatingsystems/dashboard/OperatingSystemSeverityClassDisplay.tsx +++ b/src/web/pages/operatingsystems/dashboard/OperatingSystemSeverityClassDisplay.tsx @@ -6,23 +6,33 @@ import {_, _l} from 'gmp/locale/lang'; import {OS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformSeverityData from 'web/components/dashboard/display/severity/severity-class-transform'; import SeverityClassDisplay from 'web/components/dashboard/display/severity/SeverityClassDisplay'; import SeverityClassTableDisplay from 'web/components/dashboard/display/severity/SeverityClassTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {OperatingSystemAverageSeverityLoader} from 'web/pages/operatingsystems/dashboard/OperatingSystemLoaders'; export const OperatingSystemSeverityClassDisplay = createDisplay({ loaderComponent: OperatingSystemAverageSeverityLoader, - displayComponent: props => ( - - _('Operating Systems by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Operating Systems by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'os-by-severity-class', displayName: 'OsSeverityClassDisplay', filtersFilter: OS_FILTER_FILTER, @@ -30,17 +40,24 @@ export const OperatingSystemSeverityClassDisplay = createDisplay({ export const OperatingSystemSeverityClassTableDisplay = createDisplay({ loaderComponent: OperatingSystemAverageSeverityLoader, - displayComponent: props => ( - - _('Operating Systems by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Operating Systems by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'os-by-severity-table', displayName: 'OsSeverityClassTableDisplay', filtersFilter: OS_FILTER_FILTER, diff --git a/src/web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay.tsx b/src/web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay.tsx index 33e2ebd627..4b14da8044 100644 --- a/src/web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay.tsx +++ b/src/web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; +import React, {useCallback} from 'react'; import {useNavigate} from 'react-router'; import styled from 'styled-components'; import {_, _l} from 'gmp/locale/lang'; @@ -11,13 +11,12 @@ import {OS_FILTER_FILTER} from 'gmp/models/filter'; import {parseFloat, parseSeverity} from 'gmp/parser'; import {DEFAULT_SEVERITY_RATING, type SeverityRating} from 'gmp/utils/severity'; import BarChart from 'web/components/chart/BarChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {riskFactorColorScale} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import useGmp from 'web/hooks/useGmp'; @@ -45,13 +44,8 @@ interface TransformVulnerabilityScoreDataProps { severityRating?: SeverityRating; } -type OperatingSystemVulnerabilityScoreDataDisplayProps = DataDisplayProps< - OperatingSystemVulnerabilityScoreData, - TransformedVulnerabilityScoreData, - TransformVulnerabilityScoreDataProps ->; - -type OperatingSystemVulnerabilityScoreDisplayProps = DashboardDisplayProps; +type OperatingSystemVulnerabilityScoreDataDisplayProps = + DataDisplayProps; const ToolTip = styled.div` font-weight: normal; @@ -111,89 +105,75 @@ const transformVulnerabilityScoreData = ( return transformedData.reverse(); }; -export const OperatingSystemVulnerabilityScoreDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - ...props -}: OperatingSystemVulnerabilityScoreDisplayProps) => { - const navigate = useNavigate(); - const gmp = useGmp(); - - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: OS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (data: TransformedVulnerabilityScoreDataItem) => { - void navigate(ROUTES.operatingSystem.url(data.id)); - }; - - const severityRating = gmp.settings.severityRating; - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformVulnerabilityScoreData} - filter={displayFilter} - severityRating={severityRating} - showToggleLegend={false} - title={() => _('Most Vulnerable Operating Systems')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const OperatingSystemVulnerabilityScoreDisplay = createDisplay({ + loaderComponent: OperatingSystemVulnerabilityScoreLoader, + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const navigate = useNavigate(); + const handleDataClick = useCallback( + (data: TransformedVulnerabilityScoreDataItem) => { + void navigate(ROUTES.operatingSystem.url(data.id)); + }, + [navigate], + ); + const transformedData = useDataTransform( + data, + transformVulnerabilityScoreData, + {severityRating: gmp.settings.severityRating}, + ); + return ( + + {...props} + data={transformedData} + showToggleLegend={false} + title={() => _('Most Vulnerable Operating Systems')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -OperatingSystemVulnerabilityScoreDisplay.displayId = 'os-by-most-vulnerable'; + + ); + }, + filtersFilter: OS_FILTER_FILTER, + displayId: 'os-by-most-vulnerable', + displayName: 'OperatingSystemVulnerabilityScoreDisplay', +}); -export const OsVulnScoreTableDisplay = createDisplay({ +export const OperatingSystemVulnerabilityScoreTableDisplay = createDisplay({ loaderComponent: OperatingSystemVulnerabilityScoreLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.x, row.y]) ?? [] - } - dataTitles={[ - _('Operating System Name'), - _('Max. Average Severity Score'), - ]} - dataTransform={transformVulnerabilityScoreData} - title={() => _('Most Vulnerable Operating Systems')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform( + data, + transformVulnerabilityScoreData, + {severityRating: gmp.settings.severityRating}, + ); + return ( + + transformedData?.map(row => [row.x, row.y]) ?? [] + } + dataTitles={[ + _('Operating System Name'), + _('Max. Average Severity Score'), + ]} + title={() => _('Most Vulnerable Operating Systems')} + /> + ); + }, displayId: 'os-by-most-vulnerable-table', displayName: 'OsVulnScoreTableDisplay', filtersFilter: OS_FILTER_FILTER, @@ -205,6 +185,6 @@ registerDisplay( ); registerDisplay( - OsVulnScoreTableDisplay, + OperatingSystemVulnerabilityScoreTableDisplay, _l('Table: Operating Systems by Vulnerability Score'), ); diff --git a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemCvssDisplay.test.tsx b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemCvssDisplay.test.tsx index 7d1b7fa605..a02fbf841a 100644 --- a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemCvssDisplay.test.tsx +++ b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,12 +26,9 @@ const loaderData = { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({data, title, yLabel}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {yLabel}
); @@ -39,12 +37,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -52,6 +47,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, operatingsystems: { getAverageSeverityAggregates: testing.fn().mockResolvedValue({ data: loaderData, @@ -79,7 +77,6 @@ const renderDisplay = (component: ReactElement) => { describe('OperatingSystemCvssDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OperatingSystemCvssDisplay).toBeDefined(); - expect(typeof OperatingSystemCvssDisplay).toBe('function'); expect(OperatingSystemCvssDisplay.displayId).toBe('os-by-cvss'); expect(OperatingSystemCvssDisplay.displayName).toBe('OsCvssDisplay'); }); @@ -108,7 +105,6 @@ describe('OperatingSystemCvssDisplay', () => { describe('OperatingSystemCvssTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OperatingSystemCvssTableDisplay).toBeDefined(); - expect(typeof OperatingSystemCvssTableDisplay).toBe('function'); expect(OperatingSystemCvssTableDisplay.displayId).toBe('os-by-cvss-table'); expect(OperatingSystemCvssTableDisplay.displayName).toBe( 'OsCvssTableDisplay', diff --git a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemLoaders.test.tsx b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemLoaders.test.tsx index de30d2026b..6e9751c0a4 100644 --- a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemLoaders.test.tsx +++ b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemLoaders.test.tsx @@ -80,7 +80,11 @@ describe('Operating System loaders', () => { await waitFor(() => { expect(getAverageSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectOperatingSystemSubscriptions(subscribe); @@ -108,7 +112,11 @@ describe('Operating System loaders', () => { await waitFor(() => { expect(getVulnScoreAggregates).toHaveBeenCalledWith({filter, max: 10}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectOperatingSystemSubscriptions(subscribe); diff --git a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemSeverityClassDisplay.test.tsx b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemSeverityClassDisplay.test.tsx index fd585f1e10..f77a38c443 100644 --- a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemSeverityClassDisplay.test.tsx +++ b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -27,14 +28,7 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({data, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - - return ( -
- {title?.({data: {total}})} -
- ); + return
{title?.({data})}
; }, }), ); @@ -43,12 +37,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -57,6 +48,9 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, operatingsystems: { getAverageSeverityAggregates: testing.fn().mockResolvedValue({ data: loaderData, @@ -84,7 +78,6 @@ const renderDisplay = (component: ReactElement) => { describe('OperatingSystemSeverityClassDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OperatingSystemSeverityClassDisplay).toBeDefined(); - expect(typeof OperatingSystemSeverityClassDisplay).toBe('function'); expect(OperatingSystemSeverityClassDisplay.displayId).toBe( 'os-by-severity-class', ); @@ -120,7 +113,6 @@ describe('OperatingSystemSeverityClassDisplay', () => { describe('OperatingSystemSeverityClassTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OperatingSystemSeverityClassTableDisplay).toBeDefined(); - expect(typeof OperatingSystemSeverityClassTableDisplay).toBe('function'); expect(OperatingSystemSeverityClassTableDisplay.displayId).toBe( 'os-by-severity-table', ); diff --git a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemVulnerabilityScoreDisplay.test.tsx b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemVulnerabilityScoreDisplay.test.tsx index 74b2b070e0..1ee1711509 100644 --- a/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemVulnerabilityScoreDisplay.test.tsx +++ b/src/web/pages/operatingsystems/dashboard/__tests__/OperatingSystemVulnerabilityScoreDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -13,7 +14,7 @@ import { } from 'web/components/provider/SubscriptionProvider'; import { OperatingSystemVulnerabilityScoreDisplay, - OsVulnScoreTableDisplay, + OperatingSystemVulnerabilityScoreTableDisplay, } from 'web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay'; const loaderData = { @@ -46,19 +47,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, severityRating, title}) => { - const transformedData = dataTransform - ? dataTransform(data, {severityRating}) - : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -68,25 +66,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({ - data, - dataRow, - dataTitles, - dataTransform, - severityRating, - title, - }) => { - const transformedData = dataTransform - ? dataTransform(data, {severityRating}) - : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -107,7 +95,9 @@ vi.mock('web/components/chart/BarChart', () => ({ })); const createGmp = () => ({ - settings: {severityRating: 'CVSSv3'}, + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -121,9 +111,9 @@ const createGmp = () => ({ }, }); -const renderDisplay = (component: ReactElement) => { +const renderDisplay = (component: ReactElement, options = {}) => { const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); - const {render} = rendererWith({gmp: createGmp(), store: true}); + const {render} = rendererWith({gmp: createGmp(), store: true, ...options}); return render( @@ -132,13 +122,15 @@ const renderDisplay = (component: ReactElement) => { ); }; -describe('OperatingSystemVulnerabilityScoreDisplay', () => { +describe('OperatingSystemVulnerabilityScoreDisplay tests', () => { test('should export a valid component with the correct configuration', () => { expect(OperatingSystemVulnerabilityScoreDisplay).toBeDefined(); - expect(typeof OperatingSystemVulnerabilityScoreDisplay).toBe('function'); expect(OperatingSystemVulnerabilityScoreDisplay.displayId).toBe( 'os-by-most-vulnerable', ); + expect(OperatingSystemVulnerabilityScoreDisplay.displayName).toBe( + 'OperatingSystemVulnerabilityScoreDisplay', + ); }); test('should be registered with the correct title', () => { @@ -167,31 +159,65 @@ describe('OperatingSystemVulnerabilityScoreDisplay', () => { screen.getByRole('button', {name: 'Linux One'}), ).toBeInTheDocument(); expect(screen.queryByRole('button', {name: 'Linux Two'})).toBeNull(); + expect(screen.getByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + }); + + test('should navigate to the selected operating system', async () => { + renderDisplay( + , + {showLocation: true}, + ); + + const operatingSystem = await screen.findByRole('button', { + name: 'Linux One', + }); + fireEvent.click(operatingSystem); + + await waitFor(() => { + expect(screen.getByTestId('location-pathname')).toHaveTextContent( + '/operating-system/os-1', + ); }); }); }); -describe('OsVulnScoreTableDisplay', () => { +describe('OperatingSystemVulnerabilityScoreTableDisplay tests', () => { test('should export a valid component with the correct configuration', () => { - expect(OsVulnScoreTableDisplay).toBeDefined(); - expect(typeof OsVulnScoreTableDisplay).toBe('function'); - expect(OsVulnScoreTableDisplay.displayId).toBe( + expect(OperatingSystemVulnerabilityScoreTableDisplay).toBeDefined(); + expect(typeof OperatingSystemVulnerabilityScoreTableDisplay).toBe( + 'function', + ); + expect(OperatingSystemVulnerabilityScoreTableDisplay.displayId).toBe( 'os-by-most-vulnerable-table', ); - expect(OsVulnScoreTableDisplay.displayName).toBe('OsVulnScoreTableDisplay'); + expect(OperatingSystemVulnerabilityScoreTableDisplay.displayName).toBe( + 'OsVulnScoreTableDisplay', + ); }); test('should be registered with the correct title', () => { - const registered = getDisplay(OsVulnScoreTableDisplay.displayId); + const registered = getDisplay( + OperatingSystemVulnerabilityScoreTableDisplay.displayId, + ); - expect(registered?.component).toBe(OsVulnScoreTableDisplay); + expect(registered?.component).toBe( + OperatingSystemVulnerabilityScoreTableDisplay, + ); expect(String(registered?.title)).toBe( 'Table: Operating Systems by Vulnerability Score', ); }); test('should render the configured title and table headings', async () => { - renderDisplay(); + renderDisplay( + , + ); await waitFor(() => { expect(screen.getByTestId('title')).toHaveTextContent( diff --git a/src/web/pages/operatingsystems/dashboard/index.tsx b/src/web/pages/operatingsystems/dashboard/index.tsx index 8c4d68f455..19a54c9f7b 100644 --- a/src/web/pages/operatingsystems/dashboard/index.tsx +++ b/src/web/pages/operatingsystems/dashboard/index.tsx @@ -15,7 +15,7 @@ import { } from 'web/pages/operatingsystems/dashboard/OperatingSystemSeverityClassDisplay'; import { OperatingSystemVulnerabilityScoreDisplay, - OsVulnScoreTableDisplay, + OperatingSystemVulnerabilityScoreTableDisplay, } from 'web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay'; export const OS_DASHBOARD_ID = 'e93b51ed-5881-40e0-bc4f-7d3268a36177'; @@ -26,7 +26,7 @@ export const OS_DISPLAYS = [ OperatingSystemSeverityClassDisplay.displayId, OperatingSystemSeverityClassTableDisplay.displayId, OperatingSystemVulnerabilityScoreDisplay.displayId, - OsVulnScoreTableDisplay.displayId, + OperatingSystemVulnerabilityScoreTableDisplay.displayId, ]; const OsDashboard = props => ( diff --git a/src/web/pages/overrides/dashboard/OverrideActiveDaysDisplay.tsx b/src/web/pages/overrides/dashboard/OverrideActiveDaysDisplay.tsx index 7d9db99203..3220dd7ba2 100644 --- a/src/web/pages/overrides/dashboard/OverrideActiveDaysDisplay.tsx +++ b/src/web/pages/overrides/dashboard/OverrideActiveDaysDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {OVERRIDES_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -11,14 +12,13 @@ import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import DonutChart from 'web/components/chart/DonutChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataDisplayIcons from 'web/components/dashboard/display/DataDisplayIcons'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import { totalCount, percent, @@ -43,12 +43,8 @@ interface TransformedActiveDaysData extends Array; - -type OverrideActiveDaysDisplayProps = DashboardDisplayProps; +type OverrideActiveDaysDataDisplayProps = + DataDisplayProps; const MAX_BINS = 10; // if this is changed, activeDaysColorScale needs adjustment @@ -117,108 +113,89 @@ const transformActiveDaysData = ( return result; }; -export const OverridesActiveDaysDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: OverrideActiveDaysDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: OVERRIDES_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = ({filterValue, bulked = false}) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const activeDaysTerm = bulked - ? FilterTerm.fromString(`active_days>"${filterValue}"`) - : FilterTerm.fromString(`active_days="${filterValue}"`); - - if (isDefined(filter) && filter.hasTerm(activeDaysTerm)) { - return; - } - const activeDaysFilter = QueryFilter.fromTerm(activeDaysTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.copy().and(activeDaysFilter) - : activeDaysFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformActiveDaysData} - filter={displayFilter} - icons={DataDisplayIcons} - initialState={{}} - title={({data}) => - _('Overrides by Active Days (Total: {{count}})', { - count: data?.total ?? 0, - }) +export const OverridesActiveDaysDisplay = createDisplay({ + loaderComponent: OverridesActiveDaysLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformActiveDaysData); + const handleDataClick = useCallback( + ({filterValue, bulked = false}) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const activeDaysTerm = bulked + ? FilterTerm.fromString(`active_days>"${filterValue}"`) + : FilterTerm.fromString(`active_days="${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(activeDaysTerm)) { + return; + } + const activeDaysFilter = QueryFilter.fromTerm(activeDaysTerm); + + const newFilter = isDefined(filter) + ? filter.and(activeDaysFilter) + : activeDaysFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + icons={DataDisplayIcons} + initialState={{}} + title={({data}) => + _('Overrides by Active Days (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + > + {({width, height, data, svgRef, state}) => ( + - {({width, height, data, svgRef, state}) => ( - - )} - + /> )} - - {filterSelectionDialog} - - ); -}; - -OverridesActiveDaysDisplay.displayId = 'override-by-active-days'; + + ); + }, + filtersFilter: OVERRIDES_FILTER_FILTER, + displayId: 'override-by-active-days', + displayName: 'OverridesActiveDaysDisplay', +}); export const OverridesActiveDaysTableDisplay = createDisplay({ loaderComponent: OverridesActiveDaysLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Active'), _('# of Overrides')]} - dataTransform={transformActiveDaysData} - title={({data}) => - _('Overrides by Active Days (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformActiveDaysData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Active'), _('# of Overrides')]} + title={({data}) => + _('Overrides by Active Days (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayName: 'OverridesActiveDaysTableDisplay', displayId: 'override-by-active-days-table', filtersFilter: OVERRIDES_FILTER_FILTER, diff --git a/src/web/pages/overrides/dashboard/OverrideCreatedDisplay.tsx b/src/web/pages/overrides/dashboard/OverrideCreatedDisplay.tsx index 7a36922ada..5bbc6a7e77 100644 --- a/src/web/pages/overrides/dashboard/OverrideCreatedDisplay.tsx +++ b/src/web/pages/overrides/dashboard/OverrideCreatedDisplay.tsx @@ -9,31 +9,35 @@ import transformCreated from 'web/components/dashboard/display/created/created-t import CreatedDisplay from 'web/components/dashboard/display/created/CreatedDisplay'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import {OverridesCreatedLoader} from 'web/pages/overrides/dashboard/OverrideLoaders'; import Theme from 'web/utils/theme'; export const OverridesCreatedDisplay = createDisplay({ loaderComponent: OverridesCreatedLoader, - displayComponent: props => ( - _('Overrides by Creation Time')} - xAxisLabel={_('Time')} - y2AxisLabel={_('Total Overrides')} - y2Line={{ - color: Theme.darkGreenTransparent, - dashArray: '3, 2', - label: _('Total Overrides'), - }} - yAxisLabel={_('# of created Overrides')} - yLine={{ - color: Theme.darkGreenTransparent, - label: _('Created Overrides'), - }} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformCreated); + return ( + _('Overrides by Creation Time')} + xAxisLabel={_('Time')} + y2AxisLabel={_('Total Overrides')} + y2Line={{ + color: Theme.darkGreenTransparent, + dashArray: '3, 2', + label: _('Total Overrides'), + }} + yAxisLabel={_('# of created Overrides')} + yLine={{ + color: Theme.darkGreenTransparent, + label: _('Created Overrides'), + }} + /> + ); + }, displayId: 'override-by-created', displayName: 'OverridesCreatedDisplay', filtersFilter: OVERRIDES_FILTER_FILTER, @@ -46,21 +50,24 @@ registerDisplay( export const OverridesCreatedTableDisplay = createDisplay({ loaderComponent: OverridesCreatedLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label ?? '', row.y, row.y2]) ?? [] - } - dataTitles={[ - _('Creation Time'), - _('# of created Overrides'), - _('Total Overrides'), - ]} - dataTransform={transformCreated} - title={() => _('Overrides by Creation Time')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformCreated); + return ( + + transformedData?.map(row => [row.label ?? '', row.y, row.y2]) ?? [] + } + dataTitles={[ + _('Creation Time'), + _('# of created Overrides'), + _('Total Overrides'), + ]} + title={() => _('Overrides by Creation Time')} + /> + ); + }, displayName: 'OverridesCreatedTableDisplay', displayId: 'override-by-created-table', filtersFilter: OVERRIDES_FILTER_FILTER, diff --git a/src/web/pages/overrides/dashboard/OverrideWordCloudDisplay.tsx b/src/web/pages/overrides/dashboard/OverrideWordCloudDisplay.tsx index 325022d39b..3c95747ada 100644 --- a/src/web/pages/overrides/dashboard/OverrideWordCloudDisplay.tsx +++ b/src/web/pages/overrides/dashboard/OverrideWordCloudDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {OVERRIDES_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -11,13 +12,12 @@ import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import WordCloudChart from 'web/components/chart/WordCloudChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {randomColor} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -34,12 +34,8 @@ interface TransformedWordCloudDataItem { type TransformedWordCloudData = TransformedWordCloudDataItem[]; -type OverrideWordCloudDataDisplayProps = DataDisplayProps< - OverrideWordCloudData, - TransformedWordCloudData ->; - -type OverrideWordCloudDisplayProps = DashboardDisplayProps; +type OverrideWordCloudDataDisplayProps = + DataDisplayProps; const transformWordCountData = ( data: OverrideWordCloudData = {}, @@ -57,95 +53,73 @@ const transformWordCountData = ( return transformedData; }; -export const OverridesWordCloudDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: OverrideWordCloudDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: OVERRIDES_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (filterValue: string) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const wordTerm = FilterTerm.fromString(`text~"${filterValue}"`); - - if (isDefined(filter) && filter.hasTerm(wordTerm)) { - return; - } - const wordFilter = QueryFilter.fromTerm(wordTerm); - const newFilter = isDefined(displayFilter) - ? displayFilter.and(wordFilter) - : wordFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformWordCountData} - filter={displayFilter} - showToggleLegend={false} - title={() => _('Overrides Text Word Cloud')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const OverridesWordCloudDisplay = createDisplay({ + loaderComponent: OverridesWordCountLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + const handleDataClick = useCallback( + (filterValue: string) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const wordTerm = FilterTerm.fromString(`text~"${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(wordTerm)) { + return; + } + const wordFilter = QueryFilter.fromTerm(wordTerm); + const newFilter = isDefined(filter) + ? filter.and(wordFilter) + : wordFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + showToggleLegend={false} + title={() => _('Overrides Text Word Cloud')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -OverridesWordCloudDisplay.displayId = 'override-by-text-words'; + + ); + }, + displayId: 'override-by-text-words', + displayName: 'OverridesWordCloudDisplay', + filtersFilter: OVERRIDES_FILTER_FILTER, +}); export const OverridesWordCloudTableDisplay = createDisplay({ loaderComponent: OverridesWordCountLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Text'), _('Count')]} - dataTransform={transformWordCountData} - title={() => _('Overrides Text Word Cloud')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Text'), _('Count')]} + title={() => _('Overrides Text Word Cloud')} + /> + ); + }, displayId: 'override-by-text-words-table', displayName: 'OverridesWordCloudTableDisplay', diff --git a/src/web/pages/overrides/dashboard/__tests__/OverrideActiveDaysDisplay.test.tsx b/src/web/pages/overrides/dashboard/__tests__/OverrideActiveDaysDisplay.test.tsx index 7244ed2248..fd89d16469 100644 --- a/src/web/pages/overrides/dashboard/__tests__/OverrideActiveDaysDisplay.test.tsx +++ b/src/web/pages/overrides/dashboard/__tests__/OverrideActiveDaysDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,17 +26,15 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, state: {showLegend: true}, svgRef: {current: null}, }) @@ -47,15 +46,14 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -64,13 +62,23 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/DonutChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))} +
), })); @@ -104,6 +112,9 @@ describe('OverridesActiveDaysDisplay', () => { expect(OverridesActiveDaysDisplay.displayId).toBe( 'override-by-active-days', ); + expect(OverridesActiveDaysDisplay.displayName).toBe( + 'OverridesActiveDaysDisplay', + ); }); test('should be registered with the correct title', () => { @@ -131,6 +142,74 @@ describe('OverridesActiveDaysDisplay', () => { ); }); }); + + test('should call onFilterChanged with an active-days filter', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const activeDaysButton = await screen.findByRole('button', { + name: /Active \(unlimited\)/, + }); + fireEvent.click(activeDaysButton); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( + 'active_days="-2"', + ); + }); + + test('should not throw when clicking a data item without onFilterChanged', async () => { + renderDisplay(); + + const activeDaysButton = await screen.findByRole('button', { + name: /Active \(unlimited\)/, + }); + expect(() => fireEvent.click(activeDaysButton)).not.toThrow(); + }); + + test('should not call onFilterChanged when the filter value is empty', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const emptyItem = await screen.findByTestId('empty-filter-value'); + fireEvent.click(emptyItem); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should not call onFilterChanged when the filter already has the active-days term', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const activeDaysButton = await screen.findByRole('button', { + name: /Active \(unlimited\)/, + }); + fireEvent.click(activeDaysButton); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); }); describe('OverridesActiveDaysTableDisplay', () => { diff --git a/src/web/pages/overrides/dashboard/__tests__/OverrideCreatedDisplay.test.tsx b/src/web/pages/overrides/dashboard/__tests__/OverrideCreatedDisplay.test.tsx index f84e98db85..dccf75de4e 100644 --- a/src/web/pages/overrides/dashboard/__tests__/OverrideCreatedDisplay.test.tsx +++ b/src/web/pages/overrides/dashboard/__tests__/OverrideCreatedDisplay.test.tsx @@ -21,9 +21,9 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/created/CreatedDisplay', () => ({ - default: ({title, xAxisLabel, yAxisLabel, y2AxisLabel}) => ( + default: ({title, xAxisLabel, yAxisLabel, y2AxisLabel, data}) => (
- {title?.()} + {title?.(data)} {xAxisLabel} {yAxisLabel} {y2AxisLabel} @@ -32,16 +32,15 @@ vi.mock('web/components/dashboard/display/created/CreatedDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.()} + {title?.(data)} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -75,7 +74,6 @@ const renderDisplay = (component: ReactElement) => { describe('OverridesCreatedDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OverridesCreatedDisplay).toBeDefined(); - expect(typeof OverridesCreatedDisplay).toBe('function'); expect(OverridesCreatedDisplay.displayId).toBe('override-by-created'); expect(OverridesCreatedDisplay.displayName).toBe('OverridesCreatedDisplay'); }); @@ -108,7 +106,6 @@ describe('OverridesCreatedDisplay', () => { describe('OverridesCreatedTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OverridesCreatedTableDisplay).toBeDefined(); - expect(typeof OverridesCreatedTableDisplay).toBe('function'); expect(OverridesCreatedTableDisplay.displayId).toBe( 'override-by-created-table', ); diff --git a/src/web/pages/overrides/dashboard/__tests__/OverrideLoaders.test.tsx b/src/web/pages/overrides/dashboard/__tests__/OverrideLoaders.test.tsx new file mode 100644 index 0000000000..95aef7838e --- /dev/null +++ b/src/web/pages/overrides/dashboard/__tests__/OverrideLoaders.test.tsx @@ -0,0 +1,162 @@ +/* SPDX-FileCopyrightText: 2026 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {type ReactElement} from 'react'; +import {describe, expect, test, testing} from '@gsa/testing'; +import {rendererWith, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; +import { + SubscriptionContext, + type SubscribeFunc, +} from 'web/components/provider/SubscriptionProvider'; +import { + OVERRIDES_ACTIVE_DAYS, + OVERRIDES_CREATED, + OVERRIDES_WORD_COUNT, + OverridesActiveDaysLoader, + OverridesCreatedLoader, + OverridesWordCountLoader, +} from 'web/pages/overrides/dashboard/OverrideLoaders'; + +const createGmp = (overrides: Record) => ({overrides}); + +const renderWithSubscriptionContext = ({ + gmp, + subscribe, + children, +}: { + gmp: Record; + subscribe: SubscribeFunc; + children: ReactElement; +}) => { + const {render} = rendererWith({gmp, store: true}); + + return render( + + {children} + , + ); +}; + +const expectOverrideSubscriptions = (subscribe: SubscribeFunc) => { + expect(subscribe).toHaveBeenCalledWith( + 'overrides.timer', + expect.any(Function), + ); + expect(subscribe).toHaveBeenCalledWith( + 'overrides.changed', + expect.any(Function), + ); +}; + +describe('OverridesActiveDaysLoader', () => { + test('should export the active days data ID', () => { + expect(OVERRIDES_ACTIVE_DAYS).toBe('overrides-active-days'); + }); + + test('should load active days aggregates and render them', async () => { + const data = {groups: [{value: 1, count: 5}]}; + const getActiveDaysAggregates = testing.fn().mockResolvedValue({data}); + const gmp = createGmp({getActiveDaysAggregates}); + const filter = QueryFilter.fromString('first=1 rows=10'); + const subscribe = testing.fn().mockReturnValue(testing.fn()); + const children = testing.fn().mockReturnValue(null); + + renderWithSubscriptionContext({ + gmp, + subscribe, + children: ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getActiveDaysAggregates).toHaveBeenCalledWith({filter}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); + }); + + expectOverrideSubscriptions(subscribe); + }); +}); + +describe('OverridesCreatedLoader', () => { + test('should export the created data ID', () => { + expect(OVERRIDES_CREATED).toBe('overrides-created'); + }); + + test('should load created aggregates and render them', async () => { + const data = { + groups: [{value: '2026-01-01', count: '5', c_count: '10'}], + }; + const getCreatedAggregates = testing.fn().mockResolvedValue({data}); + const gmp = createGmp({getCreatedAggregates}); + const filter = QueryFilter.fromString('first=1 rows=10'); + const subscribe = testing.fn().mockReturnValue(testing.fn()); + const children = testing.fn().mockReturnValue(null); + + renderWithSubscriptionContext({ + gmp, + subscribe, + children: ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getCreatedAggregates).toHaveBeenCalledWith({filter}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); + }); + + expectOverrideSubscriptions(subscribe); + }); +}); + +describe('OverridesWordCountLoader', () => { + test('should export the word count data ID', () => { + expect(OVERRIDES_WORD_COUNT).toBe('overrides-wordcount'); + }); + + test('should load word count aggregates and render them', async () => { + const data = {groups: [{value: 'override', count: 5}]}; + const getWordCountsAggregates = testing.fn().mockResolvedValue({data}); + const gmp = createGmp({getWordCountsAggregates}); + const filter = QueryFilter.fromString('first=1 rows=10'); + const subscribe = testing.fn().mockReturnValue(testing.fn()); + const children = testing.fn().mockReturnValue(null); + + renderWithSubscriptionContext({ + gmp, + subscribe, + children: ( + + {children} + + ), + }); + + await waitFor(() => { + expect(getWordCountsAggregates).toHaveBeenCalledWith({filter}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); + }); + + expectOverrideSubscriptions(subscribe); + }); +}); diff --git a/src/web/pages/overrides/dashboard/__tests__/OverrideWordCloudDisplay.test.tsx b/src/web/pages/overrides/dashboard/__tests__/OverrideWordCloudDisplay.test.tsx index 6e5150db59..2757f529d4 100644 --- a/src/web/pages/overrides/dashboard/__tests__/OverrideWordCloudDisplay.test.tsx +++ b/src/web/pages/overrides/dashboard/__tests__/OverrideWordCloudDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -24,17 +25,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.()} + {title?.(data)} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -44,16 +44,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
{title?.()} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -62,13 +61,20 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/WordCloudChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))} +
), })); @@ -99,8 +105,10 @@ const renderDisplay = (component: ReactElement) => { describe('OverridesWordCloudDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OverridesWordCloudDisplay).toBeDefined(); - expect(typeof OverridesWordCloudDisplay).toBe('function'); expect(OverridesWordCloudDisplay.displayId).toBe('override-by-text-words'); + expect(OverridesWordCloudDisplay.displayName).toBe( + 'OverridesWordCloudDisplay', + ); }); test('should be registered with the correct title', () => { @@ -125,12 +133,86 @@ describe('OverridesWordCloudDisplay', () => { ); }); }); + + test('should set showToggleLegend to false', async () => { + renderDisplay(); + + expect(await screen.findByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + + test('should call onFilterChanged with a text filter when clicking a word', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const wordButton = await screen.findByRole('button', { + name: /security\|5\|security/, + }); + fireEvent.click(wordButton); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( + 'text~"security"', + ); + }); + + test('should not call onFilterChanged when the filter already has the word term', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + const wordButton = await screen.findByRole('button', { + name: /security\|5\|security/, + }); + fireEvent.click(wordButton); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should not call onFilterChanged when the word is empty', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('empty-filter-value')); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should not throw when clicking a word without onFilterChanged', async () => { + renderDisplay(); + + const wordButton = await screen.findByRole('button', { + name: /security\|5\|security/, + }); + expect(() => fireEvent.click(wordButton)).not.toThrow(); + }); }); describe('OverridesWordCloudTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(OverridesWordCloudTableDisplay).toBeDefined(); - expect(typeof OverridesWordCloudTableDisplay).toBe('function'); expect(OverridesWordCloudTableDisplay.displayId).toBe( 'override-by-text-words-table', ); diff --git a/src/web/pages/reports/auditdashboard/AuditReportStatusDisplay.tsx b/src/web/pages/reports/auditdashboard/AuditReportStatusDisplay.tsx index 760859cde2..63e12fd552 100644 --- a/src/web/pages/reports/auditdashboard/AuditReportStatusDisplay.tsx +++ b/src/web/pages/reports/auditdashboard/AuditReportStatusDisplay.tsx @@ -12,6 +12,7 @@ import {AUDIT_REPORTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; import StatusDisplay from 'web/components/dashboard/display/status/StatusDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import { complianceColorScale, totalCount, @@ -63,40 +64,47 @@ const transformStatusData = ( }; export const ReportComplianceDisplay = createDisplay({ - displayComponent: props => ( - - _('Audit Reports by Compliance (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + loaderComponent: ReportComplianceLoader, + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformStatusData); + return ( + + _('Audit Reports by Compliance (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'report-by-compliance', + displayName: 'ReportComplianceDisplay', filtersFilter: AUDIT_REPORTS_FILTER_FILTER, - loaderComponent: ReportComplianceLoader, }); export const ReportComplianceTableDisplay = createDisplay({ loaderComponent: ReportComplianceLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_l('Status'), _l('# of Reports')]} - dataTransform={transformStatusData} - title={({data}) => - _('Audit Reports by Compliance (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformStatusData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_l('Status'), _l('# of Reports')]} + title={({data}) => + _('Audit Reports by Compliance (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'report-by-compliance-table', displayName: 'ReportComplianceTableDisplay', filtersFilter: AUDIT_REPORTS_FILTER_FILTER, diff --git a/src/web/pages/reports/auditdashboard/__tests__/AuditReportLoaders.test.tsx b/src/web/pages/reports/auditdashboard/__tests__/AuditReportLoaders.test.tsx index b8d45e5059..47fa82a2ee 100644 --- a/src/web/pages/reports/auditdashboard/__tests__/AuditReportLoaders.test.tsx +++ b/src/web/pages/reports/auditdashboard/__tests__/AuditReportLoaders.test.tsx @@ -69,7 +69,11 @@ describe('Report loaders', () => { await waitFor(() => { expect(getComplianceAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expect(subscribe).toHaveBeenCalledWith( diff --git a/src/web/pages/reports/auditdashboard/__tests__/AuditReportStatusDisplay.test.tsx b/src/web/pages/reports/auditdashboard/__tests__/AuditReportStatusDisplay.test.tsx index 0025682ed2..9be0562d12 100644 --- a/src/web/pages/reports/auditdashboard/__tests__/AuditReportStatusDisplay.test.tsx +++ b/src/web/pages/reports/auditdashboard/__tests__/AuditReportStatusDisplay.test.tsx @@ -25,13 +25,11 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ - default: ({data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, title}) => { return (
- {title?.({data: transformedData})} - {transformedData?.map((row, index) => ( + {title?.({data})} + {data?.map((row, index) => ( {row.label}|{row.value}|{row.filterValue} @@ -42,16 +40,15 @@ vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -85,8 +82,8 @@ const renderDisplay = (component: ReactElement) => { describe('ReportComplianceDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportComplianceDisplay).toBeDefined(); - expect(typeof ReportComplianceDisplay).toBe('function'); expect(ReportComplianceDisplay.displayId).toBe('report-by-compliance'); + expect(ReportComplianceDisplay.displayName).toBe('ReportComplianceDisplay'); }); test('should be registered with the correct title', () => { @@ -117,7 +114,6 @@ describe('ReportComplianceDisplay', () => { describe('ReportComplianceTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportComplianceTableDisplay).toBeDefined(); - expect(typeof ReportComplianceTableDisplay).toBe('function'); expect(ReportComplianceTableDisplay.displayId).toBe( 'report-by-compliance-table', ); diff --git a/src/web/pages/reports/dashboard/ReportCvssDisplay.tsx b/src/web/pages/reports/dashboard/ReportCvssDisplay.tsx index ea67ae2205..5d07d413ef 100644 --- a/src/web/pages/reports/dashboard/ReportCvssDisplay.tsx +++ b/src/web/pages/reports/dashboard/ReportCvssDisplay.tsx @@ -6,22 +6,32 @@ import {_, _l} from 'gmp/locale/lang'; import {REPORTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformCvssData from 'web/components/dashboard/display/cvss/cvss-transform'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {ReportsSeverityLoader} from 'web/pages/reports/dashboard/ReportLoaders'; export const ReportsCvssDisplay = createDisplay({ loaderComponent: ReportsSeverityLoader, - displayComponent: props => ( - - _('Reports by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - yLabel={_('# of Reports')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Reports by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + yLabel={_('# of Reports')} + /> + ); + }, displayId: 'report-by-cvss', displayName: 'ReportsCvssDisplay', filtersFilter: REPORTS_FILTER_FILTER, @@ -29,15 +39,22 @@ export const ReportsCvssDisplay = createDisplay({ export const ReportsCvssTableDisplay = createDisplay({ loaderComponent: ReportsSeverityLoader, - displayComponent: props => ( - - _('Reports by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Reports by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'report-by-cvss-table', displayName: 'ReportsCvssTableDisplay', filtersFilter: REPORTS_FILTER_FILTER, diff --git a/src/web/pages/reports/dashboard/ReportHighResultsDisplay.tsx b/src/web/pages/reports/dashboard/ReportHighResultsDisplay.tsx index b0e4fc038d..08f59635ae 100644 --- a/src/web/pages/reports/dashboard/ReportHighResultsDisplay.tsx +++ b/src/web/pages/reports/dashboard/ReportHighResultsDisplay.tsx @@ -3,19 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {type Date} from 'gmp/models/date'; import {REPORTS_FILTER_FILTER} from 'gmp/models/filter'; import {parseInt, parseFloat, parseDate} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import LineChart, {type LineData} from 'web/components/chart/LineChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {createDateRangeFilter} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -34,12 +34,8 @@ interface TransformedReportHighResultsDataItem { type TransformedReportHighResultsData = TransformedReportHighResultsDataItem[]; -type ReportsHighResultsDataDisplayProps = DataDisplayProps< - ReportHighResultsData, - TransformedReportHighResultsData ->; - -type ReportHighResultsDisplayProps = DashboardDisplayProps; +type ReportsHighResultsDataDisplayProps = + DataDisplayProps; const transformHighResults = ( data: ReportHighResultsData = {}, @@ -56,108 +52,89 @@ const transformHighResults = ( }); }; -export const ReportsHighResultsDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: ReportHighResultsDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: REPORTS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleRangeSelect = (start: LineData, end: LineData) => { - if (!isDefined(onFilterChanged)) { - return; - } - - const startDate = start.x as Date; - const endDate = end.x as Date; - const dateFormat = 'YYYY-MM-DDTHH:mm'; - - onFilterChanged( - createDateRangeFilter({ - endDate, - field: 'date', - filter: displayFilter, - formatDate: date => date.format(dateFormat), - startDate, - }), +export const ReportsHighResultsDisplay = createDisplay({ + loaderComponent: ReportsHighResultsLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformHighResults); + const handleRangeSelect = useCallback( + (start: LineData, end: LineData) => { + if (!isDefined(onFilterChanged)) { + return; + } + + const startDate = start.x as Date; + const endDate = end.x as Date; + const dateFormat = 'YYYY-MM-DDTHH:mm'; + + onFilterChanged( + createDateRangeFilter({ + endDate, + field: 'date', + filter, + formatDate: date => date.format(dateFormat), + startDate, + }), + ); + }, + [filter, onFilterChanged], ); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformHighResults} - filter={displayFilter} - title={() => _('Reports with High Results')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef, state}) => ( - - )} - + return ( + + {...props} + data={transformedData} + filter={filter} + title={() => _('Reports with High Results')} + > + {({width, height, data, svgRef, state}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -ReportsHighResultsDisplay.displayId = 'report-by-high-results'; + + ); + }, + displayId: 'report-by-high-results', + displayName: 'ReportsHighResultsDisplay', + filtersFilter: REPORTS_FILTER_FILTER, +}); export const ReportsHighResultsTableDisplay = createDisplay({ loaderComponent: ReportsHighResultsLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.y, row.y2]) ?? [] - } - dataTitles={[_('Created Time'), _('Max High'), _('Max High per Host')]} - dataTransform={transformHighResults} - title={() => _('Reports with High Results')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformHighResults); + return ( + + transformedData?.map(row => [row.label, row.y, row.y2]) ?? [] + } + dataTitles={[_('Created Time'), _('Max High'), _('Max High per Host')]} + title={() => _('Reports with High Results')} + /> + ); + }, filtersFilter: REPORTS_FILTER_FILTER, displayName: 'ReportsHighResultsTableDisplay', displayId: 'report-by-high-results-table', diff --git a/src/web/pages/reports/dashboard/ReportSeverityClassDisplay.tsx b/src/web/pages/reports/dashboard/ReportSeverityClassDisplay.tsx index 57a3dc1534..ca8a977958 100644 --- a/src/web/pages/reports/dashboard/ReportSeverityClassDisplay.tsx +++ b/src/web/pages/reports/dashboard/ReportSeverityClassDisplay.tsx @@ -6,23 +6,33 @@ import {_, _l} from 'gmp/locale/lang'; import {REPORTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformSeverityData from 'web/components/dashboard/display/severity/severity-class-transform'; import SeverityClassDisplay from 'web/components/dashboard/display/severity/SeverityClassDisplay'; import SeverityClassTableDisplay from 'web/components/dashboard/display/severity/SeverityClassTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {ReportsSeverityLoader} from 'web/pages/reports/dashboard/ReportLoaders'; export const ReportsSeverityDisplay = createDisplay({ loaderComponent: ReportsSeverityLoader, - displayComponent: props => ( - - _('Reports by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Reports by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, filtersFilter: REPORTS_FILTER_FILTER, displayName: 'ReportsSeverityDisplay', displayId: 'report-by-severity-class', @@ -30,17 +40,24 @@ export const ReportsSeverityDisplay = createDisplay({ export const ReportsSeverityTableDisplay = createDisplay({ loaderComponent: ReportsSeverityLoader, - displayComponent: props => ( - - _('Reports by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Reports by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, filtersFilter: REPORTS_FILTER_FILTER, displayName: 'ReportsSeverityTableDisplay', displayId: 'report-by-severity-class-table', diff --git a/src/web/pages/reports/dashboard/__tests__/ReportCvssDisplay.test.tsx b/src/web/pages/reports/dashboard/__tests__/ReportCvssDisplay.test.tsx index 7920689c07..101b6c096d 100644 --- a/src/web/pages/reports/dashboard/__tests__/ReportCvssDisplay.test.tsx +++ b/src/web/pages/reports/dashboard/__tests__/ReportCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,12 +26,9 @@ const loaderData = { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({data, title, yLabel}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {String(yLabel)}
); @@ -39,12 +37,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -52,6 +47,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, reports: { getSeverityAggregates: testing.fn().mockResolvedValue({data: loaderData}), }, @@ -77,7 +75,6 @@ const renderDisplay = (component: ReactElement) => { describe('ReportsCvssDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportsCvssDisplay).toBeDefined(); - expect(typeof ReportsCvssDisplay).toBe('function'); expect(ReportsCvssDisplay.displayId).toBe('report-by-cvss'); expect(ReportsCvssDisplay.displayName).toBe('ReportsCvssDisplay'); }); @@ -104,7 +101,6 @@ describe('ReportsCvssDisplay', () => { describe('ReportsCvssTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportsCvssTableDisplay).toBeDefined(); - expect(typeof ReportsCvssTableDisplay).toBe('function'); expect(ReportsCvssTableDisplay.displayId).toBe('report-by-cvss-table'); expect(ReportsCvssTableDisplay.displayName).toBe('ReportsCvssTableDisplay'); }); diff --git a/src/web/pages/reports/dashboard/__tests__/ReportHighResultsDisplay.test.tsx b/src/web/pages/reports/dashboard/__tests__/ReportHighResultsDisplay.test.tsx index 814047bfc6..f08975ba6f 100644 --- a/src/web/pages/reports/dashboard/__tests__/ReportHighResultsDisplay.test.tsx +++ b/src/web/pages/reports/dashboard/__tests__/ReportHighResultsDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -36,17 +37,15 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, state: {showLegend: true}, svgRef: {current: null}, }) @@ -57,16 +56,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -75,7 +73,7 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/LineChart', () => ({ - default: ({data, xAxisLabel, yAxisLabel, y2AxisLabel}) => ( + default: ({data, onRangeSelected, xAxisLabel, yAxisLabel, y2AxisLabel}) => (
{xAxisLabel} {yAxisLabel} @@ -85,6 +83,9 @@ vi.mock('web/components/chart/LineChart', () => ({ {row.label}|{row.y}|{row.y2} ))} +
), })); @@ -117,8 +118,10 @@ const renderDisplay = (component: ReactElement) => { describe('ReportsHighResultsDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportsHighResultsDisplay).toBeDefined(); - expect(typeof ReportsHighResultsDisplay).toBe('function'); expect(ReportsHighResultsDisplay.displayId).toBe('report-by-high-results'); + expect(ReportsHighResultsDisplay.displayName).toBe( + 'ReportsHighResultsDisplay', + ); }); test('should be registered with the correct title', () => { @@ -144,12 +147,42 @@ describe('ReportsHighResultsDisplay', () => { expect(screen.getByTestId('data-point-1')).toHaveTextContent('|7|3.5'); }); }); + + test('should call onFilterChanged with a date range filter', async () => { + const onFilterChanged = testing.fn(); + const filter = QueryFilter.fromString('severity=high'); + + renderDisplay( + , + ); + + const button = await screen.findByRole('button', {name: 'select-range'}); + fireEvent.click(button); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + const newFilter = onFilterChanged.mock.calls[0][0]; + expect(newFilter.toFilterString()).toContain('severity=high'); + expect(newFilter.toFilterString()).toContain('date>2026-01-01t00:00'); + expect(newFilter.toFilterString()).toContain('date<2026-01-02t00:00'); + }); + + test('should not throw when selecting a range without onFilterChanged', async () => { + renderDisplay(); + + const button = await screen.findByRole('button', {name: 'select-range'}); + + expect(() => fireEvent.click(button)).not.toThrow(); + }); }); describe('ReportsHighResultsTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportsHighResultsTableDisplay).toBeDefined(); - expect(typeof ReportsHighResultsTableDisplay).toBe('function'); expect(ReportsHighResultsTableDisplay.displayId).toBe( 'report-by-high-results-table', ); diff --git a/src/web/pages/reports/dashboard/__tests__/ReportLoaders.test.tsx b/src/web/pages/reports/dashboard/__tests__/ReportLoaders.test.tsx index abd110b6c8..24a80dba50 100644 --- a/src/web/pages/reports/dashboard/__tests__/ReportLoaders.test.tsx +++ b/src/web/pages/reports/dashboard/__tests__/ReportLoaders.test.tsx @@ -73,7 +73,11 @@ describe('Report loaders', () => { await waitFor(() => { expect(getSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectReportSubscriptions(subscribe); @@ -111,7 +115,11 @@ describe('Report loaders', () => { await waitFor(() => { expect(getHighResultsAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectReportSubscriptions(subscribe); diff --git a/src/web/pages/reports/dashboard/__tests__/ReportSeverityClassDisplay.test.tsx b/src/web/pages/reports/dashboard/__tests__/ReportSeverityClassDisplay.test.tsx index 1667d7d95f..73b6f8ad38 100644 --- a/src/web/pages/reports/dashboard/__tests__/ReportSeverityClassDisplay.test.tsx +++ b/src/web/pages/reports/dashboard/__tests__/ReportSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -27,14 +28,7 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({data, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - - return ( -
- {title?.({data: {total}})} -
- ); + return
{title?.({data})}
; }, }), ); @@ -43,12 +37,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -57,6 +48,9 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, reports: { getSeverityAggregates: testing.fn().mockResolvedValue({data: loaderData}), }, @@ -82,7 +76,6 @@ const renderDisplay = (component: ReactElement) => { describe('ReportsSeverityDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportsSeverityDisplay).toBeDefined(); - expect(typeof ReportsSeverityDisplay).toBe('function'); expect(ReportsSeverityDisplay.displayId).toBe('report-by-severity-class'); expect(ReportsSeverityDisplay.displayName).toBe('ReportsSeverityDisplay'); }); @@ -108,7 +101,6 @@ describe('ReportsSeverityDisplay', () => { describe('ReportsSeverityTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ReportsSeverityTableDisplay).toBeDefined(); - expect(typeof ReportsSeverityTableDisplay).toBe('function'); expect(ReportsSeverityTableDisplay.displayId).toBe( 'report-by-severity-class-table', ); diff --git a/src/web/pages/results/dashboard/ResultCvssDisplay.tsx b/src/web/pages/results/dashboard/ResultCvssDisplay.tsx index 1fb59a2dd5..35c20b0a96 100644 --- a/src/web/pages/results/dashboard/ResultCvssDisplay.tsx +++ b/src/web/pages/results/dashboard/ResultCvssDisplay.tsx @@ -6,22 +6,32 @@ import {_, _l} from 'gmp/locale/lang'; import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformCvssData from 'web/components/dashboard/display/cvss/cvss-transform'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {ResultsSeverityLoader} from 'web/pages/results/dashboard/ResultLoaders'; export const ResultsCvssDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: props => ( - - _('Results by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - yLabel={_('# of Results')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Results by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + yLabel={_('# of Results')} + /> + ); + }, displayId: 'result-by-cvss', displayName: 'ResultsCvssDisplay', filtersFilter: RESULTS_FILTER_FILTER, @@ -29,15 +39,22 @@ export const ResultsCvssDisplay = createDisplay({ export const ResultsCvssTableDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: props => ( - - _('Results by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Results by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'result-by-cvss-table', displayName: 'ResultsCvssTableDisplay', filtersFilter: RESULTS_FILTER_FILTER, diff --git a/src/web/pages/results/dashboard/ResultDescriptionWordCloudDisplay.tsx b/src/web/pages/results/dashboard/ResultDescriptionWordCloudDisplay.tsx index 53407613d3..d50f5f3fa7 100644 --- a/src/web/pages/results/dashboard/ResultDescriptionWordCloudDisplay.tsx +++ b/src/web/pages/results/dashboard/ResultDescriptionWordCloudDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -11,13 +12,12 @@ import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import WordCloudChart from 'web/components/chart/WordCloudChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {randomColor} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -34,12 +34,8 @@ interface TransformResultWordCountDataItem { type TransformResultWordCountData = TransformResultWordCountDataItem[]; -type ResultWordCountDataDisplayProps = DataDisplayProps< - ResultWordCloudData, - TransformResultWordCountData ->; - -type ResultWordCountDisplayProps = DashboardDisplayProps; +type ResultWordCountDataDisplayProps = + DataDisplayProps; const transformWordCountData = ( data: ResultWordCloudData = {}, @@ -57,96 +53,77 @@ const transformWordCountData = ( return transformedData; }; -export const ResultsDescriptionWordCloudDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: ResultWordCountDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: RESULTS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (filterValue: string) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const wordTerm = FilterTerm.fromString(`description~"${filterValue}"`); - - if (isDefined(filter) && filter.hasTerm(wordTerm)) { - return; - } - const wordFilter = QueryFilter.fromTerm(wordTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.and(wordFilter) - : wordFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformWordCountData} - filter={displayFilter} - showToggleLegend={false} - title={() => _('Results Description Word Cloud')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const ResultsDescriptionWordCloudDisplay = createDisplay({ + loaderComponent: ResultsDescriptionWordCountLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + const handleDataClick = useCallback( + (filterValue: string) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const wordTerm = FilterTerm.fromString(`description~"${filterValue}"`); + + if (isDefined(filter) && filter.hasTerm(wordTerm)) { + return; + } + const wordFilter = QueryFilter.fromTerm(wordTerm); + + const newFilter = isDefined(filter) + ? filter.and(wordFilter) + : wordFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + showToggleLegend={false} + title={() => _('Results Description Word Cloud')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -ResultsDescriptionWordCloudDisplay.displayId = 'result-by-desc-words'; + + ); + }, + displayId: 'result-by-desc-words', + displayName: 'ResultsDescriptionWordCloudDisplay', + filtersFilter: RESULTS_FILTER_FILTER, +}); export const ResultsDescriptionWordCloudTableDisplay = createDisplay({ loaderComponent: ResultsDescriptionWordCountLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Description'), _('Word Count')]} - dataTransform={transformWordCountData} - title={() => _('Results Description Word Cloud')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Description'), _('Word Count')]} + title={() => _('Results Description Word Cloud')} + /> + ); + }, displayId: 'result-by-desc-words-table', displayName: 'ResultsDescriptionWordCloudTableDisplay', filtersFilter: RESULTS_FILTER_FILTER, diff --git a/src/web/pages/results/dashboard/ResultSeverityClassDisplay.tsx b/src/web/pages/results/dashboard/ResultSeverityClassDisplay.tsx index 30c67c21eb..1024baccf8 100644 --- a/src/web/pages/results/dashboard/ResultSeverityClassDisplay.tsx +++ b/src/web/pages/results/dashboard/ResultSeverityClassDisplay.tsx @@ -6,23 +6,33 @@ import {_, _l} from 'gmp/locale/lang'; import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformSeverityData from 'web/components/dashboard/display/severity/severity-class-transform'; import SeverityClassDisplay from 'web/components/dashboard/display/severity/SeverityClassDisplay'; import SeverityClassTableDisplay from 'web/components/dashboard/display/severity/SeverityClassTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {ResultsSeverityLoader} from 'web/pages/results/dashboard/ResultLoaders'; export const ResultsSeverityDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: props => ( - - _('Results by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Results by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, filtersFilter: RESULTS_FILTER_FILTER, displayId: 'result-by-severity-class', displayName: 'ResultsSeverityDisplay', @@ -30,17 +40,24 @@ export const ResultsSeverityDisplay = createDisplay({ export const ResultsSeverityTableDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: props => ( - - _('Results by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Results by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, filtersFilter: RESULTS_FILTER_FILTER, displayId: 'result-by-severity-class-table', displayName: 'ResultsSeverityTableDisplay', diff --git a/src/web/pages/results/dashboard/ResultWordCloudDisplay.tsx b/src/web/pages/results/dashboard/ResultWordCloudDisplay.tsx index d05004b3a8..c4ca317114 100644 --- a/src/web/pages/results/dashboard/ResultWordCloudDisplay.tsx +++ b/src/web/pages/results/dashboard/ResultWordCloudDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; @@ -11,13 +12,12 @@ import {parseFloat} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {isEmpty} from 'gmp/utils/string'; import WordCloudChart from 'web/components/chart/WordCloudChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {randomColor} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -34,12 +34,8 @@ interface TransformedResultWordCloudDataItem { type TransformedResultWordCloudData = TransformedResultWordCloudDataItem[]; -type ResultWordCloudDataDisplayProps = DataDisplayProps< - ResultWordCloudData, - TransformedResultWordCloudData ->; - -type ResultWordCloudDisplayProps = DashboardDisplayProps; +type ResultWordCloudDataDisplayProps = + DataDisplayProps; const transformWordCountData = ( data: ResultWordCloudData = {}, @@ -57,96 +53,79 @@ const transformWordCountData = ( return transformedData; }; -export const ResultsWordCloudDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterIdChanged, - onFilterChanged, - ...props -}: ResultWordCloudDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: RESULTS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (filterValue: string) => { - if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { - return; - } - - const wordTerm = FilterTerm.fromString(`vulnerability~"${filterValue}"`); - - if (isDefined(filter) && filter.hasTerm(wordTerm)) { - return; - } - const wordFilter = QueryFilter.fromTerm(wordTerm); - - const newFilter = isDefined(displayFilter) - ? displayFilter.and(wordFilter) - : wordFilter; - - onFilterChanged(newFilter); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformWordCountData} - filter={displayFilter} - showToggleLegend={false} - title={() => _('Results Vulnerability Word Cloud')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const ResultsWordCloudDisplay = createDisplay({ + loaderComponent: ResultsWordCountLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + const handleDataClick = useCallback( + (filterValue: string) => { + if (!isDefined(onFilterChanged) || isEmpty(filterValue)) { + return; + } + + const wordTerm = FilterTerm.fromString( + `vulnerability~"${filterValue}"`, + ); + + if (isDefined(filter) && filter.hasTerm(wordTerm)) { + return; + } + const wordFilter = QueryFilter.fromTerm(wordTerm); + + const newFilter = isDefined(filter) + ? filter.and(wordFilter) + : wordFilter; + + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + showToggleLegend={false} + title={() => _('Results Vulnerability Word Cloud')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -ResultsWordCloudDisplay.displayId = 'result-by-vuln-words'; + + ); + }, + filtersFilter: RESULTS_FILTER_FILTER, + displayId: 'result-by-vuln-words', + displayName: 'ResultsWordCloudDisplay', +}); export const ResultsWordCloudTableDisplay = createDisplay({ loaderComponent: ResultsWordCountLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Vulnerability'), _('Word Count')]} - dataTransform={transformWordCountData} - title={() => _('Results Vulnerability Word Cloud')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformWordCountData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Vulnerability'), _('Word Count')]} + title={() => _('Results Vulnerability Word Cloud')} + /> + ); + }, filtersFilter: RESULTS_FILTER_FILTER, displayId: 'result-by-vuln-words-table', displayName: 'ResultsWordCloudTableDisplay', diff --git a/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx index df56f88f73..1348f37278 100644 --- a/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx +++ b/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,12 +26,9 @@ const loaderData = { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({data, title, yLabel}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {yLabel}
); @@ -39,12 +37,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -52,6 +47,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -77,7 +75,6 @@ const renderDisplay = (component: ReactElement) => { describe('ResultsCvssDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsCvssDisplay).toBeDefined(); - expect(typeof ResultsCvssDisplay).toBe('function'); expect(ResultsCvssDisplay.displayId).toBe('result-by-cvss'); expect(ResultsCvssDisplay.displayName).toBe('ResultsCvssDisplay'); }); @@ -104,7 +101,6 @@ describe('ResultsCvssDisplay', () => { describe('ResultsCvssTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsCvssTableDisplay).toBeDefined(); - expect(typeof ResultsCvssTableDisplay).toBe('function'); expect(ResultsCvssTableDisplay.displayId).toBe('result-by-cvss-table'); expect(ResultsCvssTableDisplay.displayName).toBe('ResultsCvssTableDisplay'); }); diff --git a/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx index 32aba1def1..4159aa59e5 100644 --- a/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx +++ b/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -24,12 +25,13 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { + default: ({children, data, dataTransform, showToggleLegend, title}) => { const transformedData = dataTransform ? dataTransform(data) : data; return (
{title?.()} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, @@ -44,16 +46,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.()} + {title?.(data)} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -62,13 +63,23 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/WordCloudChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))} +
), })); @@ -101,10 +112,12 @@ const renderDisplay = (component: ReactElement) => { describe('ResultsDescriptionWordCloudDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsDescriptionWordCloudDisplay).toBeDefined(); - expect(typeof ResultsDescriptionWordCloudDisplay).toBe('function'); expect(ResultsDescriptionWordCloudDisplay.displayId).toBe( 'result-by-desc-words', ); + expect(ResultsDescriptionWordCloudDisplay.displayName).toBe( + 'ResultsDescriptionWordCloudDisplay', + ); }); test('should be registered with the correct title', () => { @@ -133,12 +146,84 @@ describe('ResultsDescriptionWordCloudDisplay', () => { ); }); }); + + test('should set showToggleLegend to false', async () => { + renderDisplay( + , + ); + + expect(await screen.findByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + + test('should call onFilterChanged with a description filter when clicking a word', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('data-word-0')); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( + 'description~"security issue"', + ); + }); + + test('should not add a description filter that already exists', async () => { + const onFilterChanged = testing.fn(); + const filter = QueryFilter.fromString('description~"security issue"'); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('data-word-0')); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should ignore an empty word', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('empty-filter-value')); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should not throw when clicking a word without onFilterChanged', async () => { + renderDisplay( + , + ); + + const button = await screen.findByTestId('data-word-0'); + + expect(() => fireEvent.click(button)).not.toThrow(); + }); }); describe('ResultsDescriptionWordCloudTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsDescriptionWordCloudTableDisplay).toBeDefined(); - expect(typeof ResultsDescriptionWordCloudTableDisplay).toBe('function'); expect(ResultsDescriptionWordCloudTableDisplay.displayId).toBe( 'result-by-desc-words-table', ); diff --git a/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx index 3b72ffef8f..0c21f7a9a8 100644 --- a/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx +++ b/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx @@ -76,7 +76,11 @@ describe('Result loaders', () => { await waitFor(() => { expect(getSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectResultSubscriptions(subscribe); @@ -104,7 +108,11 @@ describe('Result loaders', () => { await waitFor(() => { expect(getWordCountsAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectResultSubscriptions(subscribe); @@ -136,7 +144,11 @@ describe('Result loaders', () => { expect(getDescriptionWordCountsAggregates).toHaveBeenCalledWith({ filter, }); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectResultSubscriptions(subscribe); diff --git a/src/web/pages/results/dashboard/__tests__/ResultSeverityClassDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultSeverityClassDisplay.test.tsx index 9296d1fff2..26a03d1cf8 100644 --- a/src/web/pages/results/dashboard/__tests__/ResultSeverityClassDisplay.test.tsx +++ b/src/web/pages/results/dashboard/__tests__/ResultSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -27,14 +28,7 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({data, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - - return ( -
- {title?.({data: {total}})} -
- ); + return
{title?.({data})}
; }, }), ); @@ -43,12 +37,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -57,6 +48,9 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -82,7 +76,6 @@ const renderDisplay = (component: ReactElement) => { describe('ResultsSeverityDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsSeverityDisplay).toBeDefined(); - expect(typeof ResultsSeverityDisplay).toBe('function'); expect(ResultsSeverityDisplay.displayId).toBe('result-by-severity-class'); expect(ResultsSeverityDisplay.displayName).toBe('ResultsSeverityDisplay'); }); @@ -108,7 +101,6 @@ describe('ResultsSeverityDisplay', () => { describe('ResultsSeverityTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsSeverityTableDisplay).toBeDefined(); - expect(typeof ResultsSeverityTableDisplay).toBe('function'); expect(ResultsSeverityTableDisplay.displayId).toBe( 'result-by-severity-class-table', ); diff --git a/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx index dbf02219c6..684e7795e1 100644 --- a/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx +++ b/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -24,17 +25,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
{title?.()} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -44,16 +44,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
{title?.()} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -62,13 +61,23 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/WordCloudChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))} +
), })); @@ -99,8 +108,8 @@ const renderDisplay = (component: ReactElement) => { describe('ResultsWordCloudDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsWordCloudDisplay).toBeDefined(); - expect(typeof ResultsWordCloudDisplay).toBe('function'); expect(ResultsWordCloudDisplay.displayId).toBe('result-by-vuln-words'); + expect(ResultsWordCloudDisplay.displayName).toBe('ResultsWordCloudDisplay'); }); test('should be registered with the correct title', () => { @@ -127,12 +136,80 @@ describe('ResultsWordCloudDisplay', () => { ); }); }); + + test('should set showToggleLegend to false', async () => { + renderDisplay(); + + expect(await screen.findByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + + test('should call onFilterChanged with a vulnerability filter when clicking a word', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('data-word-0')); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + expect(onFilterChanged.mock.calls[0][0].toFilterString()).toBe( + 'vulnerability~"security"', + ); + }); + + test('should not add a vulnerability filter that already exists', async () => { + const onFilterChanged = testing.fn(); + const filter = QueryFilter.fromString('vulnerability~"security"'); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('data-word-0')); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should ignore an empty word', async () => { + const onFilterChanged = testing.fn(); + + renderDisplay( + , + ); + + fireEvent.click(await screen.findByTestId('empty-filter-value')); + + expect(onFilterChanged).not.toHaveBeenCalled(); + }); + + test('should not throw when clicking a word without onFilterChanged', async () => { + renderDisplay(); + + const button = await screen.findByTestId('data-word-0'); + + expect(() => fireEvent.click(button)).not.toThrow(); + }); }); describe('ResultsWordCloudTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(ResultsWordCloudTableDisplay).toBeDefined(); - expect(typeof ResultsWordCloudTableDisplay).toBe('function'); expect(ResultsWordCloudTableDisplay.displayId).toBe( 'result-by-vuln-words-table', ); diff --git a/src/web/pages/start/NewDashboardDialog.tsx b/src/web/pages/start/NewDashboardDialog.tsx index 3b66c57274..c89a443503 100644 --- a/src/web/pages/start/NewDashboardDialog.tsx +++ b/src/web/pages/start/NewDashboardDialog.tsx @@ -13,7 +13,7 @@ import {CvesCreatedDisplay} from 'web/pages/cves/dashboard/CveCreatedDisplay'; import {CvesSeverityClassDisplay} from 'web/pages/cves/dashboard/CveSeverityClassDisplay'; import {HostsModifiedDisplay} from 'web/pages/hosts/dashboard/HostModifiedDisplay'; import HostsTopologyDisplay from 'web/pages/hosts/dashboard/HostsTopologyDisplay'; -import {HostsVulnScoreDisplay} from 'web/pages/hosts/dashboard/HostsVulnScoreDisplay'; +import {HostsVulnerabilityScoreDisplay} from 'web/pages/hosts/dashboard/HostsVulnScoreDisplay'; import {NvtsSeverityClassDisplay} from 'web/pages/nvts/dashboard/NvtSeverityClassDisplay'; import {OperatingSystemSeverityClassDisplay} from 'web/pages/operatingsystems/dashboard/OperatingSystemSeverityClassDisplay'; import {OperatingSystemVulnerabilityScoreDisplay} from 'web/pages/operatingsystems/dashboard/OperatingSystemVulnerabilityScoreDisplay'; @@ -56,7 +56,7 @@ const SCAN_DEFAULT_DISPLAYS = [ const ASSET_DEFAULT_DISPLAYS = [ [ - HostsVulnScoreDisplay.displayId, + HostsVulnerabilityScoreDisplay.displayId, HostsTopologyDisplay.displayId, OperatingSystemVulnerabilityScoreDisplay.displayId, ], diff --git a/src/web/pages/tasks/dashboard/TaskCvssDisplay.tsx b/src/web/pages/tasks/dashboard/TaskCvssDisplay.tsx index b7e48063ca..799abf079a 100644 --- a/src/web/pages/tasks/dashboard/TaskCvssDisplay.tsx +++ b/src/web/pages/tasks/dashboard/TaskCvssDisplay.tsx @@ -6,22 +6,32 @@ import {_, _l} from 'gmp/locale/lang'; import {TASKS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformCvssData from 'web/components/dashboard/display/cvss/cvss-transform'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {TasksSeverityLoader} from 'web/pages/tasks/dashboard/TaskLoaders'; export const TasksCvssDisplay = createDisplay({ loaderComponent: TasksSeverityLoader, - displayComponent: props => ( - - _('Tasks by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - yLabel={_('# of Tasks')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Tasks by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + yLabel={_('# of Tasks')} + /> + ); + }, displayId: 'task-by-cvss', displayName: 'TasksCvssDisplay', filtersFilter: TASKS_FILTER_FILTER, @@ -29,15 +39,22 @@ export const TasksCvssDisplay = createDisplay({ export const TasksCvssTableDisplay = createDisplay({ loaderComponent: TasksSeverityLoader, - displayComponent: props => ( - - _('Tasks by CVSS (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformCvssData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Tasks by CVSS (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, filtersFilter: TASKS_FILTER_FILTER, displayId: 'task-by-cvss-table', displayName: 'TasksCvssTableDisplay', diff --git a/src/web/pages/tasks/dashboard/TaskHighResultsDisplay.tsx b/src/web/pages/tasks/dashboard/TaskHighResultsDisplay.tsx index e03b58ba03..e8d4584757 100644 --- a/src/web/pages/tasks/dashboard/TaskHighResultsDisplay.tsx +++ b/src/web/pages/tasks/dashboard/TaskHighResultsDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {format as d3format} from 'd3-format'; import {useNavigate} from 'react-router'; import {_, _l} from 'gmp/locale/lang'; @@ -11,13 +12,12 @@ import {parseFloat, parseSeverity} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; import {DEFAULT_SEVERITY_RATING, type SeverityRating} from 'gmp/utils/severity'; import BubbleChart from 'web/components/chart/BubbleChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {riskFactorColorScale} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import useGmp from 'web/hooks/useGmp'; @@ -43,13 +43,8 @@ interface TransformedTaskHighResultsDataItem { type TransformedTaskHighResultsData = TransformedTaskHighResultsDataItem[]; -type TaskHighResultsDataDisplayProps = DataDisplayProps< - TaskHighResultsData, - TransformedTaskHighResultsData, - TransformTaskHighResultsDataProps ->; - -type TaskHighResultsDisplayProps = DashboardDisplayProps; +type TaskHighResultsDataDisplayProps = + DataDisplayProps; const format = d3format('0.2f'); @@ -88,85 +83,67 @@ const transformHighResultsData = ( }); }; -export const TasksHighResultsDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: TaskHighResultsDisplayProps) => { - const navigate = useNavigate(); - const gmp = useGmp(); - - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: TASKS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (data: TransformedTaskHighResultsDataItem) => { - void navigate(`/task/${data.id}`); - }; - - const severityRating = gmp.settings.severityRating; - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformHighResultsData} - filter={displayFilter} - severityRating={severityRating} - showToggleLegend={false} - title={() => _('Tasks by High Results per Host')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const TasksHighResultsDisplay = createDisplay({ + loaderComponent: TasksHighResultsLoader, + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformHighResultsData, { + severityRating: gmp.settings.severityRating, + }); + const navigate = useNavigate(); + const handleDataClick = useCallback( + (data: TransformedTaskHighResultsDataItem) => { + void navigate(`/task/${data.id}`); + }, + [navigate], + ); + return ( + + {...props} + data={transformedData} + showToggleLegend={false} + title={() => _('Tasks by High Results per Host')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -TasksHighResultsDisplay.displayId = 'task-by-high-results'; + + ); + }, + displayId: 'task-by-high-results', + displayName: 'TasksHighResultsDisplay', + filtersFilter: TASKS_FILTER_FILTER, +}); export const TasksHighResultsTableDisplay = createDisplay({ loaderComponent: TasksHighResultsLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value, row.severity]) ?? [] - } - dataTitles={[_('Task Name'), _('High per Host'), _('Severity')]} - dataTransform={transformHighResultsData} - title={() => _('Tasks by High Results per Host')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformHighResultsData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + transformedData?.map(row => [row.label, row.value, row.severity]) ?? + [] + } + dataTitles={[_('Task Name'), _('High per Host'), _('Severity')]} + title={() => _('Tasks by High Results per Host')} + /> + ); + }, displayId: 'task-by-high-results-table', displayName: 'TasksHighResultsTableDisplay', filtersFilter: TASKS_FILTER_FILTER, diff --git a/src/web/pages/tasks/dashboard/TaskMostHighResultsDisplay.tsx b/src/web/pages/tasks/dashboard/TaskMostHighResultsDisplay.tsx index 8bf2808be9..645ea3ae27 100644 --- a/src/web/pages/tasks/dashboard/TaskMostHighResultsDisplay.tsx +++ b/src/web/pages/tasks/dashboard/TaskMostHighResultsDisplay.tsx @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {format as d3format} from 'd3-format'; import {useNavigate} from 'react-router'; import {_, _l} from 'gmp/locale/lang'; @@ -10,13 +11,12 @@ import {TASKS_FILTER_FILTER} from 'gmp/models/filter'; import {parseFloat, parseSeverity} from 'gmp/parser'; import {DEFAULT_SEVERITY_RATING, type SeverityRating} from 'gmp/utils/severity'; import BarChart from 'web/components/chart/BarChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {riskFactorColorScale} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import useGmp from 'web/hooks/useGmp'; @@ -41,13 +41,8 @@ interface TransformedTaskHighResultsDataItem { type TransformedTaskHighResultsData = TransformedTaskHighResultsDataItem[]; -type TaskMostHighResultsDataDisplayProps = DataDisplayProps< - TaskHighResultsData, - TransformedTaskHighResultsData, - TransformTasksMostHighResultsProps ->; - -type TaskMostHighResultsDisplayProps = DashboardDisplayProps; +type TaskMostHighResultsDataDisplayProps = + DataDisplayProps; const format = d3format('0.2f'); @@ -82,91 +77,72 @@ const transformHighResultsData = ( }); }; -export const TasksMostHighResultsDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: TaskMostHighResultsDisplayProps) => { - const navigate = useNavigate(); - const gmp = useGmp(); - - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: TASKS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = (data: TransformedTaskHighResultsDataItem) => { - void navigate(`/task/${data.id}`); - }; - - const severityRating = gmp.settings.severityRating; - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataRow={transformedData => - transformedData?.map(row => [row.x, row.y]) ?? [] - } - dataTitles={[_('Task Name'), _('Max. High per Host')]} - dataTransform={transformHighResultsData} - filter={displayFilter} - severityRating={severityRating} - showToggleLegend={false} - title={() => _('Tasks with most High Results per Host')} - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef}) => ( - - )} - +export const TasksMostHighResultsDisplay = createDisplay({ + loaderComponent: TasksHighResultsLoader, + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const navigate = useNavigate(); + const handleDataClick = useCallback( + (data: TransformedTaskHighResultsDataItem) => { + void navigate(`/task/${data.id}`); + }, + [navigate], + ); + const transformedData = useDataTransform(data, transformHighResultsData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + {...props} + data={transformedData} + dataRow={transformedData => + transformedData?.map(row => [row.x, row.y]) ?? [] + } + dataTitles={[_('Task Name'), _('Max. High per Host')]} + showToggleLegend={false} + title={() => _('Tasks with most High Results per Host')} + > + {({width, height, data, svgRef}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -TasksMostHighResultsDisplay.displayId = 'task-by-most-high-results'; + + ); + }, + displayId: 'task-by-most-high-results', + displayName: 'TasksMostHighResultsDisplay', + filtersFilter: TASKS_FILTER_FILTER, +}); export const TasksMostHighResultsTableDisplay = createDisplay({ loaderComponent: TasksHighResultsLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.x, row.y]) ?? [] - } - dataTitles={[_('Task Name'), _('Max. High per Host')]} - dataTransform={transformHighResultsData} - title={() => _('Tasks with most High Results per Host')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformHighResultsData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + transformedData?.map(row => [row.x, row.y]) ?? [] + } + dataTitles={[_('Task Name'), _('Max. High per Host')]} + title={() => _('Tasks with most High Results per Host')} + /> + ); + }, displayId: 'task-by-most-high-results-table', displayName: 'TasksMostHighResultsTableDisplay', filtersFilter: TASKS_FILTER_FILTER, diff --git a/src/web/pages/tasks/dashboard/TaskSchedulesDisplay.tsx b/src/web/pages/tasks/dashboard/TaskSchedulesDisplay.tsx index f659d61237..e90a851f78 100644 --- a/src/web/pages/tasks/dashboard/TaskSchedulesDisplay.tsx +++ b/src/web/pages/tasks/dashboard/TaskSchedulesDisplay.tsx @@ -14,6 +14,7 @@ import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import { type TaskScheduleData, @@ -69,20 +70,22 @@ const transformScheduleData = ( export const TasksSchedulesDisplay = createDisplay({ loaderComponent: TasksSchedulesLoader, - displayComponent: props => ( - , - TransformedTaskScheduleData - > - {...props} - dataTransform={transformScheduleData} - showToggleLegend={false} - title={() => _('Next Scheduled Tasks')} - > - {chartProps => } - - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformScheduleData); + return ( + + > + {...props} + data={transformedData} + showToggleLegend={false} + title={_('Next Scheduled Tasks')} + > + {chartProps => } + + ); + }, displayId: 'task-by-schedules', displayName: 'TasksScheduleDisplay', filtersFilter: TASKS_FILTER_FILTER, @@ -90,21 +93,23 @@ export const TasksSchedulesDisplay = createDisplay({ export const TasksSchedulesTableDisplay = createDisplay({ loaderComponent: TasksSchedulesLoader, - displayComponent: props => ( - - transformedData?.map(row => [ - row.label, - isDefined(row.nextStart) ? row.nextStart : '-', - ]) ?? [] - } - dataTitles={[_('Task Name'), _('Next Schedule Time')]} - dataTransform={transformScheduleData} - endDate={week} - title={() => _('Next Scheduled Tasks')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformScheduleData); + return ( + + {...props} + data={transformedData} + dataRow={transformedData => + transformedData?.map(row => [ + row.label, + isDefined(row.nextStart) ? row.nextStart : '-', + ]) ?? [] + } + dataTitles={[_('Task Name'), _('Next Schedule Time')]} + title={_('Next Scheduled Tasks')} + /> + ); + }, displayId: 'task-by-schedules-table', displayName: 'TasksSchedulesTableDisplay', filtersFilter: TASKS_FILTER_FILTER, diff --git a/src/web/pages/tasks/dashboard/TaskSeverityClassDisplay.tsx b/src/web/pages/tasks/dashboard/TaskSeverityClassDisplay.tsx index e98d4c76a0..f1e15e9126 100644 --- a/src/web/pages/tasks/dashboard/TaskSeverityClassDisplay.tsx +++ b/src/web/pages/tasks/dashboard/TaskSeverityClassDisplay.tsx @@ -6,23 +6,33 @@ import {_, _l} from 'gmp/locale/lang'; import {TASKS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformSeverityData from 'web/components/dashboard/display/severity/severity-class-transform'; import SeverityClassDisplay from 'web/components/dashboard/display/severity/SeverityClassDisplay'; import SeverityClassTableDisplay from 'web/components/dashboard/display/severity/SeverityClassTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {TasksSeverityLoader} from 'web/pages/tasks/dashboard/TaskLoaders'; export const TasksSeverityDisplay = createDisplay({ loaderComponent: TasksSeverityLoader, - displayComponent: props => ( - - _('Tasks by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Tasks by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'task-by-severity-class', displayName: 'TasksSeverityDisplay', filtersFilter: TASKS_FILTER_FILTER, @@ -30,17 +40,24 @@ export const TasksSeverityDisplay = createDisplay({ export const TasksSeverityTableDisplay = createDisplay({ loaderComponent: TasksSeverityLoader, - displayComponent: props => ( - - _('Tasks by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating: gmp.settings.severityRating, + }); + return ( + + _('Tasks by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'task-by-severity-class-table', displayName: 'TasksSeverityTableDisplay', filtersFilter: TASKS_FILTER_FILTER, diff --git a/src/web/pages/tasks/dashboard/TaskStatusDisplay.tsx b/src/web/pages/tasks/dashboard/TaskStatusDisplay.tsx index 312b7f9259..34b337fcef 100644 --- a/src/web/pages/tasks/dashboard/TaskStatusDisplay.tsx +++ b/src/web/pages/tasks/dashboard/TaskStatusDisplay.tsx @@ -15,6 +15,7 @@ import { import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; import StatusDisplay from 'web/components/dashboard/display/status/StatusDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {totalCount, percent} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -97,34 +98,41 @@ const transformStatusData = ( export const TasksStatusDisplay = createDisplay({ loaderComponent: TaskStatusLoader, - displayComponent: props => ( - - _('Tasks by Status (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformStatusData); + return ( + + _('Tasks by Status (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'task-by-status', + displayName: 'TasksStatusDisplay', filtersFilter: TASKS_FILTER_FILTER, }); export const TasksStatusTableDisplay = createDisplay({ loaderComponent: TaskStatusLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Status'), _('# of Tasks')]} - dataTransform={transformStatusData} - title={({data}) => - _('Tasks by Status (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformStatusData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Status'), _('# of Tasks')]} + title={({data}) => + _('Tasks by Status (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'task-by-status-table', displayName: 'TasksStatusTableDisplay', filtersFilter: TASKS_FILTER_FILTER, diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskCvssDisplay.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskCvssDisplay.test.tsx index 5bb82a4965..09bb86dd4c 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskCvssDisplay.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -25,12 +26,9 @@ const loaderData = { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({data, title, yLabel}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title({data})} {yLabel}
); @@ -39,12 +37,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title({data})} {dataTitles?.join('|')}
); @@ -52,6 +47,9 @@ vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -77,7 +75,6 @@ const renderDisplay = (component: ReactElement) => { describe('TasksCvssDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksCvssDisplay).toBeDefined(); - expect(typeof TasksCvssDisplay).toBe('function'); expect(TasksCvssDisplay.displayId).toBe('task-by-cvss'); expect(TasksCvssDisplay.displayName).toBe('TasksCvssDisplay'); }); @@ -104,7 +101,6 @@ describe('TasksCvssDisplay', () => { describe('TasksCvssTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksCvssTableDisplay).toBeDefined(); - expect(typeof TasksCvssTableDisplay).toBe('function'); expect(TasksCvssTableDisplay.displayId).toBe('task-by-cvss-table'); expect(TasksCvssTableDisplay.displayName).toBe('TasksCvssTableDisplay'); }); diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskHighResultsDisplay.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskHighResultsDisplay.test.tsx index 5ff127f0b2..b6153771bc 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskHighResultsDisplay.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskHighResultsDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -34,17 +35,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -54,16 +54,15 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
{title?.()} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -72,25 +71,31 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/BubbleChart', () => ({ - default: ({data}) => ( + default: ({data, onDataClick}) => (
{data.map((row, index) => ( - + ))}
), })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], meta: {filter: 'type=task', counts: {}}, }), }, - settings: {severityRating: 'CVSSv3'}, tasks: { getHighResultsAggregates: testing .fn() @@ -98,9 +103,16 @@ const createGmp = () => ({ }, }); -const renderDisplay = (component: ReactElement) => { +const renderDisplay = ( + component: ReactElement, + {showLocation}: {showLocation?: boolean} = {}, +) => { const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); - const {render} = rendererWith({gmp: createGmp(), store: true}); + const {render} = rendererWith({ + gmp: createGmp(), + showLocation, + store: true, + }); return render( @@ -112,8 +124,8 @@ const renderDisplay = (component: ReactElement) => { describe('TasksHighResultsDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksHighResultsDisplay).toBeDefined(); - expect(typeof TasksHighResultsDisplay).toBe('function'); expect(TasksHighResultsDisplay.displayId).toBe('task-by-high-results'); + expect(TasksHighResultsDisplay.displayName).toBe('TasksHighResultsDisplay'); }); test('should be registered with the correct title', () => { @@ -141,12 +153,34 @@ describe('TasksHighResultsDisplay', () => { expect(screen.queryByText(/Task Three/)).toBeNull(); }); }); + + test('should show the legend toggle as disabled', async () => { + renderDisplay(); + + expect(await screen.findByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + + test('should navigate to the selected task', async () => { + renderDisplay(, { + showLocation: true, + }); + + const button = await screen.findByRole('button', {name: /Task One/}); + fireEvent.click(button); + + await waitFor(() => + expect(screen.getByTestId('location-pathname')).toHaveTextContent( + '/task/task-1', + ), + ); + }); }); describe('TasksHighResultsTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksHighResultsTableDisplay).toBeDefined(); - expect(typeof TasksHighResultsTableDisplay).toBe('function'); expect(TasksHighResultsTableDisplay.displayId).toBe( 'task-by-high-results-table', ); diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskLoaders.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskLoaders.test.tsx index e92d57bf5e..f5193391da 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskLoaders.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskLoaders.test.tsx @@ -74,7 +74,11 @@ describe('Task loaders', () => { await waitFor(() => { expect(getStatusAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectTaskSubscriptions(subscribe); @@ -100,7 +104,11 @@ describe('Task loaders', () => { await waitFor(() => { expect(getSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectTaskSubscriptions(subscribe); @@ -133,7 +141,11 @@ describe('Task loaders', () => { no_filter_history: 1, schedules_only: 1, }); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectTaskSubscriptions(subscribe); @@ -171,7 +183,11 @@ describe('Task loaders', () => { filter, max: 10, }); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectTaskSubscriptions(subscribe); diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskMostHighResultsDisplay.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskMostHighResultsDisplay.test.tsx index af4b660419..650d39170f 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskMostHighResultsDisplay.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskMostHighResultsDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -34,19 +35,16 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, severityRating, title}) => { - const transformedData = dataTransform - ? dataTransform(data, {severityRating}) - : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.()} + {title?.(data)} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -57,15 +55,14 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + const rowData = dataRow(data); return (
- {title?.()} + {title?.(data)} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -74,26 +71,32 @@ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ })); vi.mock('web/components/chart/BarChart', () => ({ - default: ({data, xLabel}) => ( + default: ({data, onDataClick, xLabel}) => (
{xLabel} {data.map((row, index) => ( - + ))}
), })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], meta: {filter: 'type=task', counts: {}}, }), }, - settings: {severityRating: 'CVSSv3'}, tasks: { getHighResultsAggregates: testing .fn() @@ -101,9 +104,16 @@ const createGmp = () => ({ }, }); -const renderDisplay = (component: ReactElement) => { +const renderDisplay = ( + component: ReactElement, + {showLocation}: {showLocation?: boolean} = {}, +) => { const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); - const {render} = rendererWith({gmp: createGmp(), store: true}); + const {render} = rendererWith({ + gmp: createGmp(), + showLocation, + store: true, + }); return render( @@ -115,10 +125,12 @@ const renderDisplay = (component: ReactElement) => { describe('TasksMostHighResultsDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksMostHighResultsDisplay).toBeDefined(); - expect(typeof TasksMostHighResultsDisplay).toBe('function'); expect(TasksMostHighResultsDisplay.displayId).toBe( 'task-by-most-high-results', ); + expect(TasksMostHighResultsDisplay.displayName).toBe( + 'TasksMostHighResultsDisplay', + ); }); test('should be registered with the correct title', () => { @@ -149,12 +161,34 @@ describe('TasksMostHighResultsDisplay', () => { expect(screen.queryByText(/Task Three/)).toBeNull(); }); }); + + test('should show the legend toggle as disabled', async () => { + renderDisplay(); + + expect(await screen.findByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); + }); + + test('should navigate to the selected task', async () => { + renderDisplay(, { + showLocation: true, + }); + + const button = await screen.findByRole('button', {name: /Task One/}); + fireEvent.click(button); + + await waitFor(() => + expect(screen.getByTestId('location-pathname')).toHaveTextContent( + '/task/task-1', + ), + ); + }); }); describe('TasksMostHighResultsTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksMostHighResultsTableDisplay).toBeDefined(); - expect(typeof TasksMostHighResultsTableDisplay).toBe('function'); expect(TasksMostHighResultsTableDisplay.displayId).toBe( 'task-by-most-high-results-table', ); diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskSchedulesDisplay.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskSchedulesDisplay.test.tsx index 15d0044950..188432dbae 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskSchedulesDisplay.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskSchedulesDisplay.test.tsx @@ -33,21 +33,18 @@ const scheduledTask = { const loaderData = [scheduledTask, {name: 'Task Without Schedule'}]; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, endDate, title}) => { - const transformedData = dataTransform - ? dataTransform(data, {endDate}) - : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {isFunction(title) ? title({data: transformedData}) : title} + {isFunction(title) ? title({data}) : title} + {String(showToggleLegend)} {isFunction(children) ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -57,20 +54,17 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, endDate, title}) => { - const transformedData = dataTransform - ? dataTransform(data, {endDate}) - : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {isFunction(title) ? title({data: transformedData}) : title} + {isFunction(title) ? title({data}) : title} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -116,7 +110,6 @@ const renderDisplay = (component: ReactElement) => { describe('TasksSchedulesDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksSchedulesDisplay).toBeDefined(); - expect(typeof TasksSchedulesDisplay).toBe('function'); expect(TasksSchedulesDisplay.displayId).toBe('task-by-schedules'); expect(TasksSchedulesDisplay.displayName).toBe('TasksScheduleDisplay'); }); @@ -135,6 +128,9 @@ describe('TasksSchedulesDisplay', () => { expect(screen.getByTestId('title')).toHaveTextContent( 'Next Scheduled Tasks', ); + expect(screen.getByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); expect(screen.getByTestId('data-point-0')).toHaveTextContent( 'Scheduled Task|3600|0', ); @@ -146,7 +142,6 @@ describe('TasksSchedulesDisplay', () => { describe('TasksSchedulesTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksSchedulesTableDisplay).toBeDefined(); - expect(typeof TasksSchedulesTableDisplay).toBe('function'); expect(TasksSchedulesTableDisplay.displayId).toBe( 'task-by-schedules-table', ); diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskSeverityClassDisplay.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskSeverityClassDisplay.test.tsx index 15c41b96e5..aee1e08f6f 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskSeverityClassDisplay.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -27,14 +28,7 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({data, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - - return ( -
- {title?.({data: {total}})} -
- ); + return
{title?.({data})}
; }, }), ); @@ -43,12 +37,9 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({data, dataTitles, title}) => { - const total = - data?.groups?.reduce((sum, group) => sum + Number(group.count), 0) ?? 0; - return (
- {title?.({data: {total}})} + {title?.({data})} {dataTitles?.join('|')}
); @@ -57,6 +48,9 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -82,7 +76,6 @@ const renderDisplay = (component: ReactElement) => { describe('TasksSeverityDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksSeverityDisplay).toBeDefined(); - expect(typeof TasksSeverityDisplay).toBe('function'); expect(TasksSeverityDisplay.displayId).toBe('task-by-severity-class'); expect(TasksSeverityDisplay.displayName).toBe('TasksSeverityDisplay'); }); @@ -108,7 +101,6 @@ describe('TasksSeverityDisplay', () => { describe('TasksSeverityTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksSeverityTableDisplay).toBeDefined(); - expect(typeof TasksSeverityTableDisplay).toBe('function'); expect(TasksSeverityTableDisplay.displayId).toBe( 'task-by-severity-class-table', ); diff --git a/src/web/pages/tasks/dashboard/__tests__/TaskStatusDisplay.test.tsx b/src/web/pages/tasks/dashboard/__tests__/TaskStatusDisplay.test.tsx index 2432619924..d45d49a757 100644 --- a/src/web/pages/tasks/dashboard/__tests__/TaskStatusDisplay.test.tsx +++ b/src/web/pages/tasks/dashboard/__tests__/TaskStatusDisplay.test.tsx @@ -25,13 +25,11 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ - default: ({data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, title}) => { return (
- {title?.({data: transformedData})} - {transformedData?.map((row, index) => ( + {title?.({data})} + {data?.map((row, index) => ( {row.label}|{row.value}|{row.filterValue} @@ -42,16 +40,15 @@ vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -85,8 +82,8 @@ const renderDisplay = (component: ReactElement) => { describe('TasksStatusDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksStatusDisplay).toBeDefined(); - expect(typeof TasksStatusDisplay).toBe('function'); expect(TasksStatusDisplay.displayId).toBe('task-by-status'); + expect(TasksStatusDisplay.displayName).toBe('TasksStatusDisplay'); }); test('should be registered with the correct title', () => { @@ -119,7 +116,6 @@ describe('TasksStatusDisplay', () => { describe('TasksStatusTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TasksStatusTableDisplay).toBeDefined(); - expect(typeof TasksStatusTableDisplay).toBe('function'); expect(TasksStatusTableDisplay.displayId).toBe('task-by-status-table'); expect(TasksStatusTableDisplay.displayName).toBe('TasksStatusTableDisplay'); }); diff --git a/src/web/pages/tickets/dashboard/TicketCreatedDisplay.tsx b/src/web/pages/tickets/dashboard/TicketCreatedDisplay.tsx index 73dc33727c..fc7d1a8c86 100644 --- a/src/web/pages/tickets/dashboard/TicketCreatedDisplay.tsx +++ b/src/web/pages/tickets/dashboard/TicketCreatedDisplay.tsx @@ -8,10 +8,11 @@ import date from 'gmp/models/date'; import {TICKETS_FILTER_FILTER} from 'gmp/models/filter'; import {parseInt} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; -import {type CreatedDataPoint} from 'web/components/dashboard/display/created/created-transform'; +import {type TransformCreatedDataItem} from 'web/components/dashboard/display/created/created-transform'; import CreatedDisplay from 'web/components/dashboard/display/created/CreatedDisplay'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import { type TicketData, @@ -20,7 +21,7 @@ import { import Theme from 'web/utils/theme'; import {formattedUserSettingShortDate} from 'web/utils/user-setting-time-date-formatters'; -type TransformedTicketCreatedDataItem = CreatedDataPoint; +type TransformedTicketCreatedDataItem = TransformCreatedDataItem; type TransformedTicketCreatedData = TransformedTicketCreatedDataItem[]; @@ -52,25 +53,28 @@ const transformTicketCreated = ( export const TicketsCreatedDisplay = createDisplay({ loaderComponent: TicketsListLoader, - displayComponent: props => ( - - {...props} - dataTransform={transformTicketCreated} - title={() => _('Tickets by Creation Time')} - xAxisLabel={_('Time')} - y2AxisLabel={_('Total Tickets')} - y2Line={{ - color: Theme.darkGreenTransparent, - dashArray: '3, 2', - label: _('Total Tickets'), - }} - yAxisLabel={_('# of created Tickets')} - yLine={{ - color: Theme.darkGreenTransparent, - label: _('Created Tickets'), - }} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformTicketCreated); + return ( + + {...props} + data={transformedData} + title={() => _('Tickets by Creation Time')} + xAxisLabel={_('Time')} + y2AxisLabel={_('Total Tickets')} + y2Line={{ + color: Theme.darkGreenTransparent, + dashArray: '3, 2', + label: _('Total Tickets'), + }} + yAxisLabel={_('# of created Tickets')} + yLine={{ + color: Theme.darkGreenTransparent, + label: _('Created Tickets'), + }} + /> + ); + }, displayId: 'tickets-by-created', displayName: 'TicketsCreatedDisplay', filtersFilter: TICKETS_FILTER_FILTER, @@ -78,25 +82,28 @@ export const TicketsCreatedDisplay = createDisplay({ export const TicketsCreatedTableDisplay = createDisplay({ loaderComponent: TicketsListLoader, - displayComponent: props => ( - - {...props} - dataRow={transformedData => - transformedData?.map(row => [ - row.y, - row.y2, - formattedUserSettingShortDate(row.x) as string, - ]) ?? [] - } - dataTitles={[_l('Created Tickets'), _l('Total Tickets'), _l('Time')]} - dataTransform={transformTicketCreated} - title={({data}) => - _('Tickets by Creation Time (Total: {{count}})', { - count: data?.length ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformTicketCreated); + return ( + + {...props} + data={transformedData} + dataRow={transformedData => + transformedData?.map(row => [ + row.y, + row.y2, + formattedUserSettingShortDate(row.x) as string, + ]) ?? [] + } + dataTitles={[_l('Created Tickets'), _l('Total Tickets'), _l('Time')]} + title={({data}) => + _('Tickets by Creation Time (Total: {{count}})', { + count: data?.length ?? 0, + }) + } + /> + ); + }, displayId: 'tickets-by-created-table', displayName: 'TicketsCreatedTableDisplay', filtersFilter: TICKETS_FILTER_FILTER, diff --git a/src/web/pages/tickets/dashboard/TicketStatusDisplay.tsx b/src/web/pages/tickets/dashboard/TicketStatusDisplay.tsx index 603771670d..de47787a0f 100644 --- a/src/web/pages/tickets/dashboard/TicketStatusDisplay.tsx +++ b/src/web/pages/tickets/dashboard/TicketStatusDisplay.tsx @@ -14,6 +14,7 @@ import { import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; import StatusDisplay from 'web/components/dashboard/display/status/StatusDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {percent} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -75,34 +76,41 @@ const transformStatusData = ( export const TicketsStatusDisplay = createDisplay({ loaderComponent: TicketsListLoader, - displayComponent: props => ( - - _('Tickets by Status (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformStatusData); + return ( + + _('Tickets by Status (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, filtersFilter: TICKETS_FILTER_FILTER, displayId: 'tickets-by-status', + displayName: 'TicketsStatusDisplay', }); export const TicketsStatusTableDisplay = createDisplay({ loaderComponent: TicketsListLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Status'), _('# of Tickets')]} - dataTransform={transformStatusData} - title={({data}) => - _('Tickets by Status (Total: {{count}})', {count: data?.total ?? 0}) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformStatusData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Status'), _('# of Tickets')]} + title={({data}) => + _('Tickets by Status (Total: {{count}})', {count: data?.total ?? 0}) + } + /> + ); + }, displayId: 'tickets-by-status-table', displayName: 'TicketsStatusTableDisplay', filtersFilter: TICKETS_FILTER_FILTER, diff --git a/src/web/pages/tickets/dashboard/TicketUsersAssignedDisplay.tsx b/src/web/pages/tickets/dashboard/TicketUsersAssignedDisplay.tsx index 9e0c129622..c554b871fd 100644 --- a/src/web/pages/tickets/dashboard/TicketUsersAssignedDisplay.tsx +++ b/src/web/pages/tickets/dashboard/TicketUsersAssignedDisplay.tsx @@ -13,6 +13,7 @@ import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import { type TicketData, @@ -71,31 +72,28 @@ const transformUserAssignedData = ( return result; }; -type TicketUserAssignedDataDisplayProps = DataDisplayProps< - TicketData, - TransformedTicketUserAssignedData ->; - export const TicketsAssignedUsersDisplay = createDisplay({ loaderComponent: TicketsListLoader, - displayComponent: props => ( - - {...props} - dataTransform={transformUserAssignedData} - showToggleLegend={false} - title={({data}) => - _('Tickets by Assigned User (Total: {{total}})', { - total: data?.total ?? 0, - }) - } - > - {chartProps => } - - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformUserAssignedData); + return ( + + > + {...props} + data={transformedData} + showToggleLegend={false} + title={({data}) => + _('Tickets by Assigned User (Total: {{total}})', { + total: data?.total ?? 0, + }) + } + > + {chartProps => } + + ); + }, displayId: 'tickets-by-assigned-users', displayName: 'TicketsAssignedUsersDisplay', filtersFilter: TICKETS_FILTER_FILTER, @@ -103,21 +101,24 @@ export const TicketsAssignedUsersDisplay = createDisplay({ export const TicketsAssignedUsersTableDisplay = createDisplay({ loaderComponent: TicketsListLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_l('Assigned To'), _l('# of Tickets')]} - dataTransform={transformUserAssignedData} - title={({data}) => - _('Tickets by Assigned User (Total: {{total}})', { - total: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformUserAssignedData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_l('Assigned To'), _l('# of Tickets')]} + title={({data}) => + _('Tickets by Assigned User (Total: {{total}})', { + total: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'tickets-by-assigned-users-table', displayName: 'TicketsAssignedUsersTableDisplay', filtersFilter: TICKETS_FILTER_FILTER, diff --git a/src/web/pages/tickets/dashboard/__tests__/TicketCreatedDisplay.test.tsx b/src/web/pages/tickets/dashboard/__tests__/TicketCreatedDisplay.test.tsx index 286409495c..2282e7c20d 100644 --- a/src/web/pages/tickets/dashboard/__tests__/TicketCreatedDisplay.test.tsx +++ b/src/web/pages/tickets/dashboard/__tests__/TicketCreatedDisplay.test.tsx @@ -24,27 +24,14 @@ const loaderData = [ ]; vi.mock('web/components/dashboard/display/created/CreatedDisplay', () => ({ - default: ({ - data, - dataTransform, - title, - xAxisLabel, - yAxisLabel, - y2AxisLabel, - }) => { - if (!data) { - return null; - } - - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, title, xAxisLabel, yAxisLabel, y2AxisLabel}) => { return (
{title?.()} {xAxisLabel} {yAxisLabel} {y2AxisLabel} - {transformedData?.map((row, index) => ( + {data?.map((row, index) => ( {row.y}|{row.y2} @@ -55,20 +42,15 @@ vi.mock('web/components/dashboard/display/created/CreatedDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - if (!data) { - return null; - } - - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
{title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -156,7 +138,7 @@ describe('TicketsCreatedTableDisplay', () => { await waitFor(() => { expect(screen.getByTestId('title')).toHaveTextContent( - 'Tickets by Creation Time (Total: 3)', + 'Tickets by Creation Time (Total: 2)', ); expect(screen.getByTestId('data-titles')).toHaveTextContent( 'Created Tickets|Total Tickets|Time', diff --git a/src/web/pages/tickets/dashboard/__tests__/TicketLoaders.test.tsx b/src/web/pages/tickets/dashboard/__tests__/TicketLoaders.test.tsx index 6f8f974e8e..4a5a9238f9 100644 --- a/src/web/pages/tickets/dashboard/__tests__/TicketLoaders.test.tsx +++ b/src/web/pages/tickets/dashboard/__tests__/TicketLoaders.test.tsx @@ -52,7 +52,11 @@ describe('TicketsListLoader', () => { await waitFor(() => { expect(getAll).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expect(subscribe).toHaveBeenCalledWith( diff --git a/src/web/pages/tickets/dashboard/__tests__/TicketStatusDisplay.test.tsx b/src/web/pages/tickets/dashboard/__tests__/TicketStatusDisplay.test.tsx index 73eccc1293..e520023aec 100644 --- a/src/web/pages/tickets/dashboard/__tests__/TicketStatusDisplay.test.tsx +++ b/src/web/pages/tickets/dashboard/__tests__/TicketStatusDisplay.test.tsx @@ -26,13 +26,11 @@ const loaderData = [ ]; vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ - default: ({data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, title}) => { return (
- {title?.({data: transformedData})} - {transformedData?.map((row, index) => ( + {title?.({data})} + {data?.map((row, index) => ( {row.label}|{row.value}|{row.filterValue} @@ -43,16 +41,15 @@ vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -86,8 +83,8 @@ const renderDisplay = (component: ReactElement) => { describe('TicketsStatusDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TicketsStatusDisplay).toBeDefined(); - expect(typeof TicketsStatusDisplay).toBe('function'); expect(TicketsStatusDisplay.displayId).toBe('tickets-by-status'); + expect(TicketsStatusDisplay.displayName).toBe('TicketsStatusDisplay'); }); test('should be registered with the correct title', () => { @@ -120,7 +117,6 @@ describe('TicketsStatusDisplay', () => { describe('TicketsStatusTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TicketsStatusTableDisplay).toBeDefined(); - expect(typeof TicketsStatusTableDisplay).toBe('function'); expect(TicketsStatusTableDisplay.displayId).toBe('tickets-by-status-table'); expect(TicketsStatusTableDisplay.displayName).toBe( 'TicketsStatusTableDisplay', diff --git a/src/web/pages/tickets/dashboard/__tests__/TicketUsersAssignedDisplay.test.tsx b/src/web/pages/tickets/dashboard/__tests__/TicketUsersAssignedDisplay.test.tsx index 34c1a5e7e1..a3b2b541e2 100644 --- a/src/web/pages/tickets/dashboard/__tests__/TicketUsersAssignedDisplay.test.tsx +++ b/src/web/pages/tickets/dashboard/__tests__/TicketUsersAssignedDisplay.test.tsx @@ -26,17 +26,16 @@ const loaderData = [ ]; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, showToggleLegend, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} + {String(showToggleLegend)} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, state: {showLegend: true}, setState: testing.fn(), svgRef: {current: null}, @@ -60,16 +59,15 @@ vi.mock('web/components/chart/DonutChart', () => ({ })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -103,7 +101,6 @@ const renderDisplay = (component: ReactElement) => { describe('TicketsAssignedUsersDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TicketsAssignedUsersDisplay).toBeDefined(); - expect(typeof TicketsAssignedUsersDisplay).toBe('function'); expect(TicketsAssignedUsersDisplay.displayId).toBe( 'tickets-by-assigned-users', ); @@ -126,6 +123,9 @@ describe('TicketsAssignedUsersDisplay', () => { expect(screen.getByTestId('title')).toHaveTextContent( 'Tickets by Assigned User (Total: 6)', ); + expect(screen.getByTestId('show-toggle-legend')).toHaveTextContent( + 'false', + ); expect(screen.getByTestId('data-point-Alice')).toHaveTextContent( 'Alice|2|Alice', ); @@ -142,7 +142,6 @@ describe('TicketsAssignedUsersDisplay', () => { describe('TicketsAssignedUsersTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TicketsAssignedUsersTableDisplay).toBeDefined(); - expect(typeof TicketsAssignedUsersTableDisplay).toBe('function'); expect(TicketsAssignedUsersTableDisplay.displayId).toBe( 'tickets-by-assigned-users-table', ); diff --git a/src/web/pages/tlscertificates/dashboard/TlsCertificateModifiedDisplay.tsx b/src/web/pages/tlscertificates/dashboard/TlsCertificateModifiedDisplay.tsx index 2913672fe8..4191240451 100644 --- a/src/web/pages/tlscertificates/dashboard/TlsCertificateModifiedDisplay.tsx +++ b/src/web/pages/tlscertificates/dashboard/TlsCertificateModifiedDisplay.tsx @@ -3,19 +3,19 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {type Date} from 'gmp/models/date'; import {TLS_CERTIFICATES_FILTER_FILTER} from 'gmp/models/filter'; import {parseInt, parseDate} from 'gmp/parser'; import {isDefined} from 'gmp/utils/identity'; -import LineChart from 'web/components/chart/LineChart'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; +import LineChart, {type LineData} from 'web/components/chart/LineChart'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import { createDateRangeFilter, totalCount, @@ -39,12 +39,8 @@ interface TransformedTlsCertificateModifiedData extends Array; - -type TlsCertificateModifiedDisplayProps = DashboardDisplayProps; +type TlsCertificateModifiedDataDisplayProps = + DataDisplayProps; const transformModified = ( data: TlsCertificateModifiedData = {}, @@ -67,121 +63,101 @@ const transformModified = ( return result; }; -export const TlsCertificatesModifiedDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: TlsCertificateModifiedDisplayProps) => { - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: TLS_CERTIFICATES_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleRangeSelect = (start, end) => { - if (!isDefined(onFilterChanged)) { - return; - } - - const {x: startDate} = start; - const {x: endDate} = end; - const dateFormat = 'YYYY-MM-DDTHH:mm'; - - onFilterChanged( - createDateRangeFilter({ - endDate, - field: 'modified', - filter: displayFilter, - formatDate: date => date.format(dateFormat), - startDate, - }), +export const TlsCertificatesModifiedDisplay = createDisplay({ + loaderComponent: TlsCertificatesModifiedLoader, + displayComponent: ({data, filter, onFilterChanged, ...props}) => { + const transformedData = useDataTransform(data, transformModified); + const handleRangeSelect = useCallback( + (start: LineData, end: LineData) => { + if (!isDefined(onFilterChanged)) { + return; + } + + const startDate = start.x as Date; + const endDate = end.x as Date; + const dateFormat = 'YYYY-MM-DDTHH:mm'; + + onFilterChanged( + createDateRangeFilter({ + endDate, + field: 'modified', + filter, + formatDate: date => date.format(dateFormat), + startDate, + }), + ); + }, + [onFilterChanged, filter], ); - }; - - return ( - <> - - {loaderProps => ( - - {...props} - {...loaderProps} - dataTransform={transformModified} - filter={displayFilter} - title={({data}) => - _('TLS Certificates by Modification Time (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - onSelectFilterClick={showFilterSelection ? selectFilter : undefined} - > - {({width, height, data, svgRef, state}) => ( - - )} - + return ( + + {...props} + data={transformedData} + filter={filter} + title={({data}) => + _('TLS Certificates by Modification Time (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + > + {({width, height, data, svgRef, state}) => ( + )} - - {filterSelectionDialog} - - ); -}; - -TlsCertificatesModifiedDisplay.displayId = - 'tls-certificates-by-modification-time'; + + ); + }, + filtersFilter: TLS_CERTIFICATES_FILTER_FILTER, + displayId: 'tls-certificates-by-modification-time', + displayName: 'TlsCertificatesModifiedDisplay', +}); export const TlsCertificatesModifiedTableDisplay = createDisplay({ loaderComponent: TlsCertificatesModifiedLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.y, row.y2]) ?? [] - } - dataTitles={[ - _('Creation Time'), - _('# of Modified Certificates'), - _('Total Certificates'), - ]} - dataTransform={transformModified} - title={({data}) => - _('TLS Certificates by Modification Time (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformModified); + return ( + + transformedData?.map(row => [row.label, row.y, row.y2]) ?? [] + } + dataTitles={[ + _('Creation Time'), + _('# of Modified Certificates'), + _('Total Certificates'), + ]} + title={({data}) => + _('TLS Certificates by Modification Time (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, filtersFilter: TLS_CERTIFICATES_FILTER_FILTER, displayId: 'tls-certificates-by-modification-time-table', displayName: 'TlsCertificatesModifiedTableDisplay', diff --git a/src/web/pages/tlscertificates/dashboard/TlsCertificateTimeStatusDisplay.tsx b/src/web/pages/tlscertificates/dashboard/TlsCertificateTimeStatusDisplay.tsx index 50067b95cc..2887b2a800 100644 --- a/src/web/pages/tlscertificates/dashboard/TlsCertificateTimeStatusDisplay.tsx +++ b/src/web/pages/tlscertificates/dashboard/TlsCertificateTimeStatusDisplay.tsx @@ -15,6 +15,7 @@ import {isDefined} from 'gmp/utils/identity'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; import StatusDisplay from 'web/components/dashboard/display/status/StatusDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {percent} from 'web/components/dashboard/display/utils'; import {registerDisplay} from 'web/components/dashboard/registry'; import { @@ -79,39 +80,46 @@ const transformTimeStatusData = ( export const TlsCertificateTimeStatusDisplay = createDisplay({ loaderComponent: TlsCertificatesStatusLoader, - displayComponent: props => ( - - _('TLS Certificates by Status (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformTimeStatusData); + return ( + + _('TLS Certificates by Status (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'tls-certificates-by-status', + displayName: 'TlsCertificateTimeStatusDisplay', filtersFilter: TLS_CERTIFICATES_FILTER_FILTER, filterTerm: 'time_status', }); export const TlsCertificateTimeStatusTableDisplay = createDisplay({ loaderComponent: TlsCertificatesStatusLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.label, row.value]) ?? [] - } - dataTitles={[_('Status'), _('# of Certificates')]} - dataTransform={transformTimeStatusData} - title={({data}) => - _('TLS Certificates by Status (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformTimeStatusData); + return ( + + transformedData?.map(row => [row.label, row.value]) ?? [] + } + dataTitles={[_('Status'), _('# of Certificates')]} + title={({data}) => + _('TLS Certificates by Status (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'tls-certificates-by-status-table', displayName: 'TlsCertificateTimeStatusTableDisplay', filtersFilter: TLS_CERTIFICATES_FILTER_FILTER, diff --git a/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateModifiedDisplay.test.tsx b/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateModifiedDisplay.test.tsx index 09c93f3cbf..bba538f7bc 100644 --- a/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateModifiedDisplay.test.tsx +++ b/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateModifiedDisplay.test.tsx @@ -5,7 +5,8 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen, waitFor} from 'web/testing'; +import {fireEvent, rendererWith, screen, waitFor} from 'web/testing'; +import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -24,17 +25,15 @@ const loaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({children, data, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, state: {showLegend: false}, setState: testing.fn(), svgRef: {current: null}, @@ -46,7 +45,7 @@ vi.mock('web/components/dashboard/display/DataDisplay', () => ({ })); vi.mock('web/components/chart/LineChart', () => ({ - default: ({data, xAxisLabel, yAxisLabel, y2AxisLabel}) => ( + default: ({data, onRangeSelected, xAxisLabel, yAxisLabel, y2AxisLabel}) => (
{xAxisLabel} {yAxisLabel} @@ -56,21 +55,23 @@ vi.mock('web/components/chart/LineChart', () => ({ {row.label}|{row.y}|{row.y2} ))} +
), })); vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ - default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, dataRow, dataTitles, title}) => { + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -104,10 +105,12 @@ const renderDisplay = (component: ReactElement) => { describe('TlsCertificatesModifiedDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TlsCertificatesModifiedDisplay).toBeDefined(); - expect(typeof TlsCertificatesModifiedDisplay).toBe('function'); expect(TlsCertificatesModifiedDisplay.displayId).toBe( 'tls-certificates-by-modification-time', ); + expect(TlsCertificatesModifiedDisplay.displayName).toBe( + 'TlsCertificatesModifiedDisplay', + ); }); test('should be registered with the correct title', () => { @@ -141,12 +144,42 @@ describe('TlsCertificatesModifiedDisplay', () => { ); }); }); + + test('should call onFilterChanged with a modification date range filter', async () => { + const onFilterChanged = testing.fn(); + const filter = QueryFilter.fromString('severity=high'); + + renderDisplay( + , + ); + + const button = await screen.findByRole('button', {name: 'select-range'}); + fireEvent.click(button); + + expect(onFilterChanged).toHaveBeenCalledTimes(1); + const newFilter = onFilterChanged.mock.calls[0][0]; + expect(newFilter.toFilterString()).toContain('severity=high'); + expect(newFilter.toFilterString()).toContain('modified>2026-01-01t00:00'); + expect(newFilter.toFilterString()).toContain('modified<2026-01-02t00:00'); + }); + + test('should not throw when selecting a range without onFilterChanged', async () => { + renderDisplay(); + + const button = await screen.findByRole('button', {name: 'select-range'}); + + expect(() => fireEvent.click(button)).not.toThrow(); + }); }); describe('TlsCertificatesModifiedTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TlsCertificatesModifiedTableDisplay).toBeDefined(); - expect(typeof TlsCertificatesModifiedTableDisplay).toBe('function'); expect(TlsCertificatesModifiedTableDisplay.displayId).toBe( 'tls-certificates-by-modification-time-table', ); diff --git a/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateTimeStatusDisplay.test.tsx b/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateTimeStatusDisplay.test.tsx index 4fa92c1705..ca7b9bb4a7 100644 --- a/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateTimeStatusDisplay.test.tsx +++ b/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificateTimeStatusDisplay.test.tsx @@ -27,13 +27,11 @@ const loaderData = [ ]; vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ - default: ({data, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + default: ({data, title}) => { return (
- {title?.({data: transformedData})} - {transformedData?.map((row, index) => ( + {title?.({data})} + {data?.map((row, index) => ( {row.label}|{row.value}|{row.filterValue} @@ -45,15 +43,14 @@ vi.mock('web/components/dashboard/display/status/StatusDisplay', () => ({ vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ default: ({data, dataRow, dataTitles, dataTransform, title}) => { - const transformedData = dataTransform ? dataTransform(data) : data; - + const rowData = dataRow(data); return (
- {title?.({data: transformedData})} + {title?.({data})} {dataTitles?.join('|')} - {transformedData?.map((row, index) => ( + {rowData?.map((row, index) => ( - {dataRow(transformedData)?.[index]?.join('|')} + {row?.join('|')} ))}
@@ -87,10 +84,12 @@ const renderDisplay = (component: ReactElement) => { describe('TlsCertificateTimeStatusDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TlsCertificateTimeStatusDisplay).toBeDefined(); - expect(typeof TlsCertificateTimeStatusDisplay).toBe('function'); expect(TlsCertificateTimeStatusDisplay.displayId).toBe( 'tls-certificates-by-status', ); + expect(TlsCertificateTimeStatusDisplay.displayName).toBe( + 'TlsCertificateTimeStatusDisplay', + ); }); test('should be registered with the correct title', () => { @@ -123,7 +122,6 @@ describe('TlsCertificateTimeStatusDisplay', () => { describe('TlsCertificateTimeStatusTableDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(TlsCertificateTimeStatusTableDisplay).toBeDefined(); - expect(typeof TlsCertificateTimeStatusTableDisplay).toBe('function'); expect(TlsCertificateTimeStatusTableDisplay.displayId).toBe( 'tls-certificates-by-status-table', ); diff --git a/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificatesLoaders.test.tsx b/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificatesLoaders.test.tsx index 898858705c..02ba35d451 100644 --- a/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificatesLoaders.test.tsx +++ b/src/web/pages/tlscertificates/dashboard/__tests__/TlsCertificatesLoaders.test.tsx @@ -70,7 +70,11 @@ describe('TlsCertificatesStatusLoader', () => { await waitFor(() => { expect(getAll).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectTlsCertificateSubscriptions(subscribe); @@ -100,7 +104,11 @@ describe('TlsCertificatesModifiedLoader', () => { await waitFor(() => { expect(getModifiedAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectTlsCertificateSubscriptions(subscribe); diff --git a/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesCvssDisplay.tsx b/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesCvssDisplay.tsx index 096a95b71f..20444aa5f8 100644 --- a/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesCvssDisplay.tsx +++ b/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesCvssDisplay.tsx @@ -6,24 +6,35 @@ import {_, _l} from 'gmp/locale/lang'; import {VULNS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformCvssData from 'web/components/dashboard/display/cvss/cvss-transform'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {VulnerabilitiesSeverityLoader} from 'web/pages/vulnerabilities/dashboard/VulnerabilitiesLoaders'; export const VulnerabilitiesCvssDisplay = createDisplay({ loaderComponent: VulnerabilitiesSeverityLoader, - displayComponent: props => ( - - _('Vulnerabilities by CVSS (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - yLabel={_l('# of Vulnerabilities')} - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const severityRating = gmp.settings.severityRating; + const transformedData = useDataTransform(data, transformCvssData, { + severityRating, + }); + return ( + + _('Vulnerabilities by CVSS (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + yLabel={_l('# of Vulnerabilities')} + /> + ); + }, displayId: 'vuln-by-cvss', displayName: 'VulnerabilitiesCvssDisplay', filtersFilter: VULNS_FILTER_FILTER, @@ -31,17 +42,25 @@ export const VulnerabilitiesCvssDisplay = createDisplay({ export const VulnerabilitiesCvssTableDisplay = createDisplay({ loaderComponent: VulnerabilitiesSeverityLoader, - displayComponent: props => ( - - _('Vulnerabilities by CVSS (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const severityRating = gmp.settings.severityRating; + const transformedData = useDataTransform(data, transformCvssData, { + severityRating, + }); + return ( + + _('Vulnerabilities by CVSS (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'vuln-by-cvss-table', displayName: 'VulnerabilitiesCvssTableDisplay', filtersFilter: VULNS_FILTER_FILTER, diff --git a/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsDisplay.tsx b/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsDisplay.tsx index a15d7a3327..64b0b82bdd 100644 --- a/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsDisplay.tsx +++ b/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsDisplay.tsx @@ -3,178 +3,135 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import {useCallback, useRef} from 'react'; +import {useCallback} from 'react'; import {_, _l} from 'gmp/locale/lang'; import {VULNS_FILTER_FILTER} from 'gmp/models/filter'; import FilterTerm from 'gmp/models/filter/filter-term'; import QueryFilter from 'gmp/models/filter/query-filter'; import {isDefined} from 'gmp/utils/identity'; -import {type DashboardDisplayProps} from 'web/components/dashboard/DashboardView'; import createDisplay from 'web/components/dashboard/display/createDisplay'; import DataDisplay, { type DataDisplayProps, } from 'web/components/dashboard/display/DataDisplay'; import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import useFilterSelection from 'web/components/dashboard/display/useFilterSelection'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; import transformHostsData, { type TransformedVulnerabilitiesHostsDataItem, type TransformedVulnerabilitiesHostsData, - type VulnerabilitiesHostsData, } from 'web/pages/vulnerabilities/dashboard/hosts-transform'; import VulnerabilitiesHostsBarChart from 'web/pages/vulnerabilities/dashboard/VulnerabilitiesHostsBarChart'; import {VulnerabilitiesHostsLoader} from 'web/pages/vulnerabilities/dashboard/VulnerabilitiesLoaders'; -type VulnerabilitiesHostsDataDisplayProps = DataDisplayProps< - VulnerabilitiesHostsData, - TransformedVulnerabilitiesHostsData ->; +type VulnerabilitiesHostsDataDisplayProps = + DataDisplayProps; -type VulnerabilitiesHostsDisplayProps = DashboardDisplayProps; - -const computeTotal = (data: VulnerabilitiesHostsData = {}): number => { - const {groups = []} = data; - return groups.length > 0 ? Math.max(...groups.map(val => val.c_count)) : 0; -}; - -export const VulnerabilitiesHostsDisplay = ({ - filter, - filterId, - showFilterSelection, - onFilterChanged, - onFilterIdChanged, - ...props -}: VulnerabilitiesHostsDisplayProps) => { - const totalRef = useRef(0); - - const { - filter: selectedFilter, - selectFilter, - filterSelectionDialog, - } = useFilterSelection({ - filterId, - filtersFilter: VULNS_FILTER_FILTER, - onFilterIdChanged, - }); - - const displayFilter = showFilterSelection ? selectedFilter : filter; - - const handleDataClick = useCallback( - (clickData: TransformedVulnerabilitiesHostsDataItem) => { - if (!isDefined(onFilterChanged)) { - return; - } - const {filterValue = {start: undefined, end: undefined}} = clickData; - const {start, end} = filterValue; - let hostFilter: QueryFilter | undefined; - - if (isDefined(start) && start > 0) { - const startTerm = FilterTerm.fromString(`hosts>${start - 1}`); - const endTerm = FilterTerm.fromString(`hosts<${(end ?? 0) + 1}`); - if ( - isDefined(displayFilter) && - displayFilter.hasTerm(startTerm) && - displayFilter.hasTerm(endTerm) - ) { +export const VulnerabilitiesHostsDisplay = createDisplay({ + loaderComponent: VulnerabilitiesHostsLoader, + displayComponent: ({data, onFilterChanged, filter, ...props}) => { + const transformedData = useDataTransform(data, transformHostsData); + const handleDataClick = useCallback( + (clickData: TransformedVulnerabilitiesHostsDataItem) => { + if (!isDefined(onFilterChanged)) { return; } - hostFilter = QueryFilter.fromTerm(startTerm).and( - QueryFilter.fromTerm(endTerm), - ); - } else { - let hostTerm: FilterTerm | undefined; - if (isDefined(start) && start === 0) { - hostTerm = FilterTerm.fromString(`hosts=${start}`); - } else if (!isDefined(start)) { - hostTerm = FilterTerm.fromString(`hosts=""`); + const {filterValue} = clickData; + const {start, end} = filterValue; + let hostFilter: QueryFilter | undefined; + + if (isDefined(start) && start > 0) { + const startTerm = FilterTerm.fromString(`hosts>${start - 1}`); + const endTerm = FilterTerm.fromString(`hosts<${(end ?? 0) + 1}`); + if ( + isDefined(filter) && + filter.hasTerm(startTerm) && + filter.hasTerm(endTerm) + ) { + return; + } + hostFilter = QueryFilter.fromTerm(startTerm).and( + QueryFilter.fromTerm(endTerm), + ); + } else { + let hostTerm: FilterTerm | undefined; + if (isDefined(start) && start === 0) { + hostTerm = FilterTerm.fromString(`hosts=${start}`); + } else if (!isDefined(start)) { + hostTerm = FilterTerm.fromString(`hosts=""`); + } + if ( + isDefined(hostTerm) && + isDefined(filter) && + filter.hasTerm(hostTerm) + ) { + return; + } + if (isDefined(hostTerm)) { + hostFilter = QueryFilter.fromTerm(hostTerm); + } } - if ( - isDefined(hostTerm) && - isDefined(displayFilter) && - displayFilter.hasTerm(hostTerm) - ) { + + if (!isDefined(hostFilter)) { return; } - if (isDefined(hostTerm)) { - hostFilter = QueryFilter.fromTerm(hostTerm); - } - } - if (!isDefined(hostFilter)) { - return; - } - - const newFilter = isDefined(displayFilter) - ? displayFilter.and(hostFilter) - : hostFilter; - onFilterChanged(newFilter); - }, - [displayFilter, onFilterChanged], - ); - - const handleTransform = useCallback((data?: VulnerabilitiesHostsData) => { - totalRef.current = computeTotal(data); - return transformHostsData(data); - }, []); - - return ( - <> - - {loaderProps => ( - - {...props} - {...(loaderProps as { - data: VulnerabilitiesHostsData; - isLoading: boolean; - })} - dataTransform={handleTransform} - filter={displayFilter} - showToggleLegend={false} - title={() => - _('Vulnerabilities by Hosts (Total: {{count}})', { - count: totalRef.current, - }) + const newFilter = isDefined(filter) + ? filter.and(hostFilter) + : hostFilter; + onFilterChanged(newFilter); + }, + [filter, onFilterChanged], + ); + return ( + + {...props} + data={transformedData} + filter={filter} + showToggleLegend={false} + title={({data}) => + _('Vulnerabilities by Hosts (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + > + {({width, height, data, svgRef}) => ( + - {({width, height, data, svgRef}) => ( - - )} - + /> )} - - {filterSelectionDialog} - - ); -}; - -VulnerabilitiesHostsDisplay.displayId = 'vuln-by-hosts'; + + ); + }, + displayId: 'vuln-by-hosts', + displayName: 'VulnerabilitiesHostsDisplay', + filtersFilter: VULNS_FILTER_FILTER, +}); export const VulnerabilitiesHostsTableDisplay = createDisplay({ loaderComponent: VulnerabilitiesHostsLoader, - displayComponent: props => ( - - transformedData?.map(row => [row.x, String(row.y)]) ?? [] - } - dataTitles={[_('# of Hosts'), _('# of Vulnerabilities')]} - dataTransform={transformHostsData} - title={() => _('Vulnerabilities by Hosts')} - /> - ), + displayComponent: ({data, ...props}) => { + const transformedData = useDataTransform(data, transformHostsData); + return ( + + transformedData?.map(row => [row.x, String(row.y)]) ?? [] + } + dataTitles={[_('# of Hosts'), _('# of Vulnerabilities')]} + title={_('Vulnerabilities by Hosts')} + /> + ); + }, displayId: 'vuln-by-hosts-table', displayName: 'VulnerabilitiesHostsTableDisplay', filtersFilter: VULNS_FILTER_FILTER, diff --git a/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesSeverityClassDisplay.tsx b/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesSeverityClassDisplay.tsx index 5207966c83..e4a8685704 100644 --- a/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesSeverityClassDisplay.tsx +++ b/src/web/pages/vulnerabilities/dashboard/VulnerabilitiesSeverityClassDisplay.tsx @@ -6,24 +6,35 @@ import {_, _l} from 'gmp/locale/lang'; import {VULNS_FILTER_FILTER} from 'gmp/models/filter'; import createDisplay from 'web/components/dashboard/display/createDisplay'; +import transformSeverityData from 'web/components/dashboard/display/severity/severity-class-transform'; import SeverityClassDisplay from 'web/components/dashboard/display/severity/SeverityClassDisplay'; import SeverityClassTableDisplay from 'web/components/dashboard/display/severity/SeverityClassTableDisplay'; +import useDataTransform from 'web/components/dashboard/display/useDataTransform'; import {registerDisplay} from 'web/components/dashboard/registry'; +import useGmp from 'web/hooks/useGmp'; import {VulnerabilitiesSeverityLoader} from 'web/pages/vulnerabilities/dashboard/VulnerabilitiesLoaders'; export const VulnerabilitiesSeverityDisplay = createDisplay({ loaderComponent: VulnerabilitiesSeverityLoader, - displayComponent: props => ( - - _('Vulnerabilities by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const severityRating = gmp.settings.severityRating; + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating, + }); + return ( + + _('Vulnerabilities by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'vuln-by-severity-class', displayName: 'VulnerabilitiesSeverityDisplay', filtersFilter: VULNS_FILTER_FILTER, @@ -31,17 +42,25 @@ export const VulnerabilitiesSeverityDisplay = createDisplay({ export const VulnerabilitiesSeverityTableDisplay = createDisplay({ loaderComponent: VulnerabilitiesSeverityLoader, - displayComponent: props => ( - - _('Vulnerabilities by Severity Class (Total: {{count}})', { - count: data?.total ?? 0, - }) - } - /> - ), + displayComponent: ({data, ...props}) => { + const gmp = useGmp(); + const severityRating = gmp.settings.severityRating; + const transformedData = useDataTransform(data, transformSeverityData, { + severityRating, + }); + return ( + + _('Vulnerabilities by Severity Class (Total: {{count}})', { + count: data?.total ?? 0, + }) + } + /> + ); + }, displayId: 'vuln-by-severity-class-table', displayName: 'VulnerabilitiesSeverityTableDisplay', filtersFilter: VULNS_FILTER_FILTER, diff --git a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesCvssDisplay.test.tsx b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesCvssDisplay.test.tsx index 8066f5b004..9d4f2baeb5 100644 --- a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesCvssDisplay.test.tsx +++ b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesCvssDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -18,25 +19,20 @@ import { vi.mock('web/components/dashboard/display/cvss/CvssDisplay', () => ({ default: ({title, data}) => { - if (!data) { - return null; - } - return
{title?.({data})}
; }, })); vi.mock('web/components/dashboard/display/cvss/CvssTableDisplay', () => ({ default: ({title, data}) => { - if (!data) { - return null; - } - return
{title?.({data})}
; }, })); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -45,7 +41,12 @@ const createGmp = () => ({ }, vulns: { getSeverityAggregates: testing.fn().mockResolvedValue({ - data: {total: 42}, + data: { + groups: [ + {value: '2.0', count: 12}, + {value: '7.5', count: 30}, + ], + }, }), }, }); @@ -65,7 +66,7 @@ describe('VulnerabilitiesCvssDisplay', () => { test('should export a valid component with the correct configuration', () => { expect(VulnerabilitiesCvssDisplay).toBeDefined(); expect(VulnerabilitiesCvssDisplay.displayId).toBe('vuln-by-cvss'); - expect(VulnerabilitiesCvssDisplay.displayName).toContain( + expect(VulnerabilitiesCvssDisplay.displayName).toBe( 'VulnerabilitiesCvssDisplay', ); }); @@ -91,7 +92,7 @@ describe('VulnerabilitiesCvssTableDisplay', () => { expect(VulnerabilitiesCvssTableDisplay.displayId).toBe( 'vuln-by-cvss-table', ); - expect(VulnerabilitiesCvssTableDisplay.displayName).toContain( + expect(VulnerabilitiesCvssTableDisplay.displayName).toBe( 'VulnerabilitiesCvssTableDisplay', ); }); diff --git a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesHostsDisplay.test.tsx b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesHostsDisplay.test.tsx index bab197fa69..f6d1b90156 100644 --- a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesHostsDisplay.test.tsx +++ b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesHostsDisplay.test.tsx @@ -5,7 +5,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; -import {rendererWith, screen} from 'web/testing'; +import {fireEvent, rendererWith, screen} from 'web/testing'; import QueryFilter from 'gmp/models/filter/query-filter'; import {getDisplay} from 'web/components/dashboard/registry'; import { @@ -22,20 +22,15 @@ type LoaderData = { }; vi.mock('web/components/dashboard/display/DataDisplay', () => ({ - default: ({children, data, dataTransform, title}) => { - if (!data) { - return null; - } - - const transformedData = dataTransform ? dataTransform(data) : data; + default: ({children, data, title}) => { return (
- {title?.({data: transformedData})} + {title?.({data})} {typeof children === 'function' ? children({ width: 400, height: 300, - data: transformedData, + data, svgRef: {current: null}, }) : children} @@ -89,6 +84,9 @@ describe('VulnerabilitiesHostsDisplay', () => { test('should export VulnerabilitiesHostsDisplay', () => { expect(VulnerabilitiesHostsDisplay).toBeDefined(); expect(VulnerabilitiesHostsDisplay.displayId).toBe('vuln-by-hosts'); + expect(VulnerabilitiesHostsDisplay.displayName).toBe( + 'VulnerabilitiesHostsDisplay', + ); }); test('should be registered with the correct title', () => { @@ -129,7 +127,7 @@ describe('VulnerabilitiesHostsDisplay', () => { ); const bar = await screen.findByRole('button', {name: 'bar-4-5'}); - bar.click(); + fireEvent.click(bar); expect(onFilterChanged).toHaveBeenCalledTimes(1); const newFilter = onFilterChanged.mock.calls[0][0]; @@ -150,7 +148,7 @@ describe('VulnerabilitiesHostsDisplay', () => { ); const bar = await screen.findByRole('button', {name: 'bar-0'}); - bar.click(); + fireEvent.click(bar); expect(onFilterChanged).toHaveBeenCalledTimes(1); const newFilter = onFilterChanged.mock.calls[0][0]; @@ -172,7 +170,7 @@ describe('VulnerabilitiesHostsDisplay', () => { ); const bar = await screen.findByRole('button', {name: 'bar-0'}); - bar.click(); + fireEvent.click(bar); expect(onFilterChanged).not.toHaveBeenCalled(); }); @@ -185,24 +183,17 @@ describe('VulnerabilitiesHostsDisplay', () => { ); const bar = await screen.findByRole('button', {name: 'bar-0'}); - expect(() => bar.click()).not.toThrow(); + expect(() => fireEvent.click(bar)).not.toThrow(); }); }); describe('VulnerabilitiesHostsTableDisplay', () => { test('should export VulnerabilitiesHostsTableDisplay', () => { expect(VulnerabilitiesHostsTableDisplay).toBeDefined(); - expect(typeof VulnerabilitiesHostsTableDisplay).toBe('function'); - }); - - test('should have correct displayId', () => { expect(VulnerabilitiesHostsTableDisplay.displayId).toBe( 'vuln-by-hosts-table', ); - }); - - test('should have displayName', () => { - expect(VulnerabilitiesHostsTableDisplay.displayName).toContain( + expect(VulnerabilitiesHostsTableDisplay.displayName).toBe( 'VulnerabilitiesHostsTableDisplay', ); }); diff --git a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesLoaders.test.tsx b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesLoaders.test.tsx index 2be185af9e..c2ce9ee6d9 100644 --- a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesLoaders.test.tsx +++ b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesLoaders.test.tsx @@ -75,7 +75,11 @@ describe('Vulnerabilities Loaders', () => { await waitFor(() => { expect(getSeverityAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectVulnerabilitySubscriptions(subscribe); @@ -103,7 +107,11 @@ describe('Vulnerabilities Loaders', () => { await waitFor(() => { expect(getHostAggregates).toHaveBeenCalledWith({filter}); - expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + expect(children).toHaveBeenLastCalledWith({ + data, + isLoading: false, + isFetching: false, + }); }); expectVulnerabilitySubscriptions(subscribe); diff --git a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesSeverityClassDisplay.test.tsx b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesSeverityClassDisplay.test.tsx index 9f752e6672..f37ff87396 100644 --- a/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesSeverityClassDisplay.test.tsx +++ b/src/web/pages/vulnerabilities/dashboard/__tests__/VulnerabilitiesSeverityClassDisplay.test.tsx @@ -6,6 +6,7 @@ import {type ReactElement} from 'react'; import {describe, expect, test, testing} from '@gsa/testing'; import {rendererWith, screen, waitFor} from 'web/testing'; +import {SEVERITY_RATING_CVSS_3} from 'gmp/utils/severity'; import {getDisplay} from 'web/components/dashboard/registry'; import { SubscriptionContext, @@ -20,10 +21,6 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassDisplay', () => ({ default: ({title, data}) => { - if (!data) { - return null; - } - return
{title?.({data})}
; }, }), @@ -33,10 +30,6 @@ vi.mock( 'web/components/dashboard/display/severity/SeverityClassTableDisplay', () => ({ default: ({title, data}) => { - if (!data) { - return null; - } - return (
{title?.({data})}
); @@ -45,6 +38,9 @@ vi.mock( ); const createGmp = () => ({ + settings: { + severityRating: SEVERITY_RATING_CVSS_3, + }, filters: { get: testing.fn().mockResolvedValue({ data: [], @@ -53,7 +49,12 @@ const createGmp = () => ({ }, vulns: { getSeverityAggregates: testing.fn().mockResolvedValue({ - data: {total: 17}, + data: { + groups: [ + {value: '2.0', count: 12}, + {value: '7.5', count: 5}, + ], + }, }), }, }); @@ -75,7 +76,7 @@ describe('VulnerabilitiesSeverityDisplay', () => { expect(VulnerabilitiesSeverityDisplay.displayId).toBe( 'vuln-by-severity-class', ); - expect(VulnerabilitiesSeverityDisplay.displayName).toContain( + expect(VulnerabilitiesSeverityDisplay.displayName).toBe( 'VulnerabilitiesSeverityDisplay', ); }); @@ -102,7 +103,7 @@ describe('VulnerabilitiesSeverityTableDisplay', () => { expect(VulnerabilitiesSeverityTableDisplay.displayId).toBe( 'vuln-by-severity-class-table', ); - expect(VulnerabilitiesSeverityTableDisplay.displayName).toContain( + expect(VulnerabilitiesSeverityTableDisplay.displayName).toBe( 'VulnerabilitiesSeverityTableDisplay', ); }); diff --git a/src/web/pages/vulnerabilities/dashboard/__tests__/hosts-transform.test.ts b/src/web/pages/vulnerabilities/dashboard/__tests__/hosts-transform.test.ts index 3c27affe5f..fc262f8a96 100644 --- a/src/web/pages/vulnerabilities/dashboard/__tests__/hosts-transform.test.ts +++ b/src/web/pages/vulnerabilities/dashboard/__tests__/hosts-transform.test.ts @@ -8,11 +8,17 @@ import transformHostsData from 'web/pages/vulnerabilities/dashboard/hosts-transf describe('transformHostsData', () => { test('should return an empty array when no data is provided', () => { - expect(transformHostsData()).toEqual([]); + const result = transformHostsData(); + + expect(result).toHaveLength(0); + expect(result.total).toBe(0); }); test('should return no data for empty groups', () => { - expect(transformHostsData({groups: []})).toEqual([]); + const result = transformHostsData({groups: []}); + + expect(result).toHaveLength(0); + expect(result.total).toBe(0); }); test('should create a zero-host bucket for a single group', () => { @@ -21,6 +27,7 @@ describe('transformHostsData', () => { }); expect(result).toHaveLength(1); + expect(result.total).toBe(4); expect(result[0]).toMatchObject({ x: '0', y: 4, @@ -40,6 +47,7 @@ describe('transformHostsData', () => { }); expect(result).toHaveLength(3); + expect(result.total).toBe(5); expect(result.map(({x, y}) => ({x, y}))).toEqual([ {x: '0-1', y: 2}, {x: '2-3', y: 0}, @@ -60,6 +68,7 @@ describe('transformHostsData', () => { ], }); + expect(result.total).toBe(10); expect(result.map(({toolTip}) => toolTip)).toEqual([ '0 - 0: 2 (20.0%)', '1 - 1: 0 (0.0%)', diff --git a/src/web/pages/vulnerabilities/dashboard/hosts-transform.ts b/src/web/pages/vulnerabilities/dashboard/hosts-transform.ts index b2bf067e07..ee9c6e3a2b 100644 --- a/src/web/pages/vulnerabilities/dashboard/hosts-transform.ts +++ b/src/web/pages/vulnerabilities/dashboard/hosts-transform.ts @@ -41,8 +41,9 @@ export interface TransformedVulnerabilitiesHostsDataItem { y: number; } -export type TransformedVulnerabilitiesHostsData = - TransformedVulnerabilitiesHostsDataItem[]; +export interface TransformedVulnerabilitiesHostsData extends Array { + total: number; +} const format = d3format('0.1f'); @@ -82,7 +83,7 @@ const transformHostsData = ( const maxHosts = groups.length > 0 ? Math.max(...groups.map(val => val.value)) : 0; const bins = calculateBins(minHosts, maxHosts, totalVulns); - return bins.map(bin => { + const transformedData = bins.map(bin => { const {min, max, color, binWidth} = bin; const binWithAllMembers = groups.filter( group => group.value >= min && group.value <= max, @@ -103,6 +104,10 @@ const transformHostsData = ( filterValue, } as TransformedVulnerabilitiesHostsDataItem; }); + + const result = transformedData as TransformedVulnerabilitiesHostsData; + result.total = totalVulns; + return result; }; export default transformHostsData;