From fa7ac42ac26584fed6a7889863401418de9d6405 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Ricks?= Date: Mon, 21 Sep 2026 12:55:45 +0200 Subject: [PATCH] Change: Use TypeScript for Result Chart Displays Ensure the result chart displays work as expected. --- oxlint-baseline.json | 48 ----- .../dashboard/DescriptionWordCloudDisplay.jsx | 133 ------------- ...{CvssDisplay.jsx => ResultCvssDisplay.tsx} | 28 ++- .../ResultDescriptionWordCloudDisplay.tsx | 161 ++++++++++++++++ .../{Loaders.tsx => ResultLoaders.tsx} | 30 ++- ...lay.jsx => ResultSeverityClassDisplay.tsx} | 26 ++- .../dashboard/ResultWordCloudDisplay.tsx | 161 ++++++++++++++++ .../results/dashboard/WordCloudDisplay.jsx | 133 ------------- .../__tests__/ResultCvssDisplay.test.tsx | 131 +++++++++++++ ...ResultDescriptionWordCloudDisplay.test.tsx | 181 ++++++++++++++++++ .../__tests__/ResultLoaders.test.tsx | 145 ++++++++++++++ .../ResultSeverityClassDisplay.test.tsx | 139 ++++++++++++++ .../__tests__/ResultWordCloudDisplay.test.tsx | 167 ++++++++++++++++ .../dashboard/{index.jsx => index.tsx} | 17 +- src/web/pages/start/NewDashboardDialog.tsx | 2 +- 15 files changed, 1160 insertions(+), 342 deletions(-) delete mode 100644 src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx rename src/web/pages/results/dashboard/{CvssDisplay.jsx => ResultCvssDisplay.tsx} (70%) create mode 100644 src/web/pages/results/dashboard/ResultDescriptionWordCloudDisplay.tsx rename src/web/pages/results/dashboard/{Loaders.tsx => ResultLoaders.tsx} (66%) rename src/web/pages/results/dashboard/{SeverityClassDisplay.jsx => ResultSeverityClassDisplay.tsx} (72%) create mode 100644 src/web/pages/results/dashboard/ResultWordCloudDisplay.tsx delete mode 100644 src/web/pages/results/dashboard/WordCloudDisplay.jsx create mode 100644 src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx create mode 100644 src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx create mode 100644 src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx create mode 100644 src/web/pages/results/dashboard/__tests__/ResultSeverityClassDisplay.test.tsx create mode 100644 src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx rename src/web/pages/results/dashboard/{index.jsx => index.tsx} (70%) diff --git a/oxlint-baseline.json b/oxlint-baseline.json index f3e3f7c5f1..2917a37df9 100644 --- a/oxlint-baseline.json +++ b/oxlint-baseline.json @@ -2966,54 +2966,6 @@ "sourceLine": "const reportsReloadInterval = ({entities = []}: {entities: Report[]}) =>", "fingerprint": "src/web/pages/reports/ReportListPage.tsx|typescript(no-useless-default-assignment)|Default value is useless because the property has type `Report[]` (not nullish). This default assignment will never be used.|44|const reportsReloadInterval = ({entities = []}: {entities: Report[]}) =>" }, - { - "filename": "src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx", - "code": "eslint(no-class-assign)", - "message": "Unexpected re-assignment of class ResultsDescriptionWordCloudDisplay", - "location": { - "line": 38, - "column": 14, - "offset": 1431 - }, - "sourceLine": "export class ResultsDescriptionWordCloudDisplay extends React.Component {", - "fingerprint": "src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx|eslint(no-class-assign)|Unexpected re-assignment of class ResultsDescriptionWordCloudDisplay|14|export class ResultsDescriptionWordCloudDisplay extends React.Component {" - }, - { - "filename": "src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx", - "code": "typescript(unbound-method)", - "message": "Avoid referencing unbound methods which may cause unintentional scoping of `this`.", - "location": { - "line": 91, - "column": 53, - "offset": 2885 - }, - "sourceLine": "isDefined(onFilterChanged) ? this.handleDataClick : undefined", - "fingerprint": "src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx|typescript(unbound-method)|Avoid referencing unbound methods which may cause unintentional scoping of `this`.|53|isDefined(onFilterChanged) ? this.handleDataClick : undefined" - }, - { - "filename": "src/web/pages/results/dashboard/WordCloudDisplay.jsx", - "code": "eslint(no-class-assign)", - "message": "Unexpected re-assignment of class ResultsWordCloudDisplay", - "location": { - "line": 38, - "column": 14, - "offset": 1420 - }, - "sourceLine": "export class ResultsWordCloudDisplay extends React.Component {", - "fingerprint": "src/web/pages/results/dashboard/WordCloudDisplay.jsx|eslint(no-class-assign)|Unexpected re-assignment of class ResultsWordCloudDisplay|14|export class ResultsWordCloudDisplay extends React.Component {" - }, - { - "filename": "src/web/pages/results/dashboard/WordCloudDisplay.jsx", - "code": "typescript(unbound-method)", - "message": "Avoid referencing unbound methods which may cause unintentional scoping of `this`.", - "location": { - "line": 91, - "column": 53, - "offset": 2856 - }, - "sourceLine": "isDefined(onFilterChanged) ? this.handleDataClick : undefined", - "fingerprint": "src/web/pages/results/dashboard/WordCloudDisplay.jsx|typescript(unbound-method)|Avoid referencing unbound methods which may cause unintentional scoping of `this`.|53|isDefined(onFilterChanged) ? this.handleDataClick : undefined" - }, { "filename": "src/web/pages/results/DetailsPage.jsx", "code": "typescript(unbound-method)", diff --git a/src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx b/src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx deleted file mode 100644 index c73e8f32b5..0000000000 --- a/src/web/pages/results/dashboard/DescriptionWordCloudDisplay.jsx +++ /dev/null @@ -1,133 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import React from 'react'; -import {_, _l} from 'gmp/locale/lang'; -import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; -import FilterTerm from 'gmp/models/filter/filter-term'; -import QueryFilter from 'gmp/models/filter/query-filter'; -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 createDisplay from 'web/components/dashboard/display/createDisplay'; -import DataDisplay from 'web/components/dashboard/display/DataDisplay'; -import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import {randomColor} from 'web/components/dashboard/display/utils'; -import withFilterSelection from 'web/components/dashboard/display/withFilterSelection'; -import {registerDisplay} from 'web/components/dashboard/registry'; -import {ResultsDescriptionWordCountLoader} from 'web/pages/results/dashboard/Loaders'; -import PropTypes from 'web/utils/prop-types'; - -const transformWordCountData = (data = {}) => { - const {groups = []} = data; - const tdata = groups.map(group => { - const {count, value} = group; - return { - value: parseFloat(count), - label: value, - color: randomColor(), - filterValue: value, - }; - }); - return tdata; -}; - -export class ResultsDescriptionWordCloudDisplay extends React.Component { - constructor(...args) { - super(...args); - - this.handleDataClick = this.handleDataClick.bind(this); - } - - handleDataClick(filterValue) { - const {onFilterChanged, filter} = this.props; - - if (!isDefined(onFilterChanged)) { - return; - } - - let wordFilter; - - if (!isEmpty(filterValue)) { - const wordTerm = FilterTerm.fromString(`description~"${filterValue}"`); - - if (isDefined(filter) && filter.hasTerm(wordTerm)) { - return; - } - wordFilter = QueryFilter.fromTerm(wordTerm); - } - - const newFilter = isDefined(filter) - ? filter.copy().and(wordFilter) - : wordFilter; - - onFilterChanged(newFilter); - } - - render() { - const {filter, onFilterChanged, ...props} = this.props; - - return ( - - {loaderProps => ( - _('Results Description Word Cloud')} - > - {({width, height, data: tdata, svgRef}) => ( - - )} - - )} - - ); - } -} - -ResultsDescriptionWordCloudDisplay.propTypes = { - filter: PropTypes.filter, - onFilterChanged: PropTypes.func, -}; - -ResultsDescriptionWordCloudDisplay = withFilterSelection({ - filtersFilter: RESULTS_FILTER_FILTER, -})(ResultsDescriptionWordCloudDisplay); - -ResultsDescriptionWordCloudDisplay.displayId = 'result-by-desc-words'; - -export const ResultsDescriptionWordCloudTableDisplay = createDisplay({ - loaderComponent: ResultsDescriptionWordCountLoader, - displayComponent: DataTableDisplay, - dataTransform: transformWordCountData, - dataTitles: [_l('Description'), _l('Word Count')], - dataRow: row => [row.label, row.value], - title: () => _('Results Description Word Cloud'), - displayId: 'result-by-desc-words-table', - displayName: 'ResultsDescriptionWordCloudTableDisplay', - filtersFilter: RESULTS_FILTER_FILTER, -}); - -registerDisplay( - ResultsDescriptionWordCloudDisplay, - _l('Chart: Results Description Word Cloud'), -); - -registerDisplay( - ResultsDescriptionWordCloudTableDisplay, - _l('Table: Results Description Word Cloud'), -); diff --git a/src/web/pages/results/dashboard/CvssDisplay.jsx b/src/web/pages/results/dashboard/ResultCvssDisplay.tsx similarity index 70% rename from src/web/pages/results/dashboard/CvssDisplay.jsx rename to src/web/pages/results/dashboard/ResultCvssDisplay.tsx index 089d55ffd1..27a0126932 100644 --- a/src/web/pages/results/dashboard/CvssDisplay.jsx +++ b/src/web/pages/results/dashboard/ResultCvssDisplay.tsx @@ -9,14 +9,19 @@ import createDisplay from 'web/components/dashboard/display/createDisplay'; import CvssDisplay from 'web/components/dashboard/display/cvss/CvssDisplay'; import CvssTableDisplay from 'web/components/dashboard/display/cvss/CvssTableDisplay'; import {registerDisplay} from 'web/components/dashboard/registry'; -import {ResultsSeverityLoader} from 'web/pages/results/dashboard/Loaders'; +import {ResultsSeverityLoader} from 'web/pages/results/dashboard/ResultLoaders'; export const ResultsCvssDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: CvssDisplay, - yLabel: _l('# of Results'), - title: ({data: tdata}) => - _('Results by CVSS (Total: {{count}})', {count: tdata.total}), + displayComponent: props => ( + + _('Results by CVSS (Total: {{count}})', {count: data.total}) + } + yLabel={_('# of Results')} + /> + ), displayId: 'result-by-cvss', displayName: 'ResultsCvssDisplay', filtersFilter: RESULTS_FILTER_FILTER, @@ -24,10 +29,15 @@ export const ResultsCvssDisplay = createDisplay({ export const ResultsCvssTableDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: CvssTableDisplay, - dataTitles: [_l('Severity'), _l('# of Results')], - title: ({data: tdata}) => - _('Results by CVSS (Total: {{count}})', {count: tdata.total}), + displayComponent: props => ( + + _('Results by CVSS (Total: {{count}})', {count: data.total}) + } + /> + ), 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 new file mode 100644 index 0000000000..3c0ed393da --- /dev/null +++ b/src/web/pages/results/dashboard/ResultDescriptionWordCloudDisplay.tsx @@ -0,0 +1,161 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {_, _l} from 'gmp/locale/lang'; +import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; +import FilterTerm from 'gmp/models/filter/filter-term'; +import QueryFilter from 'gmp/models/filter/query-filter'; +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 {randomColor} from 'web/components/dashboard/display/utils'; +import {registerDisplay} from 'web/components/dashboard/registry'; +import { + ResultsDescriptionWordCountLoader, + type ResultWordCloudData, +} from 'web/pages/results/dashboard/ResultLoaders'; + +interface TransformResultWordCountDataItem { + value: number; + label: string; + color: string; + filterValue: string; +} + +type TransformResultWordCountData = TransformResultWordCountDataItem[]; + +type ResultWordCountDataDisplayProps = DataDisplayProps< + ResultWordCloudData, + TransformResultWordCountData +>; + +type ResultWordCountDisplayProps = DashboardDisplayProps; + +const transformWordCountData = ( + data: ResultWordCloudData = {}, +): TransformResultWordCountData => { + const {groups = []} = data; + const transformedData = groups.map(group => { + const {count, value} = group; + return { + value: parseFloat(count), + label: value, + color: randomColor(), + filterValue: value, + } as TransformResultWordCountDataItem; + }); + 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}) => ( + + )} + + )} + + {filterSelectionDialog} + + ); +}; + +ResultsDescriptionWordCloudDisplay.displayId = 'result-by-desc-words'; + +export const ResultsDescriptionWordCloudTableDisplay = createDisplay({ + loaderComponent: ResultsDescriptionWordCountLoader, + displayComponent: props => ( + [row.label, row.value]} + dataTitles={[_('Description'), _('Word Count')]} + dataTransform={transformWordCountData} + title={() => _('Results Description Word Cloud')} + /> + ), + displayId: 'result-by-desc-words-table', + displayName: 'ResultsDescriptionWordCloudTableDisplay', + filtersFilter: RESULTS_FILTER_FILTER, +}); + +registerDisplay( + ResultsDescriptionWordCloudDisplay, + _l('Chart: Results Description Word Cloud'), +); + +registerDisplay( + ResultsDescriptionWordCloudTableDisplay, + _l('Table: Results Description Word Cloud'), +); diff --git a/src/web/pages/results/dashboard/Loaders.tsx b/src/web/pages/results/dashboard/ResultLoaders.tsx similarity index 66% rename from src/web/pages/results/dashboard/Loaders.tsx rename to src/web/pages/results/dashboard/ResultLoaders.tsx index 47dd5c675f..071fe00ca2 100644 --- a/src/web/pages/results/dashboard/Loaders.tsx +++ b/src/web/pages/results/dashboard/ResultLoaders.tsx @@ -3,7 +3,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import Loader, {createLoadFunc} from 'web/components/dashboard/display/Loader'; +import Loader, { + createLoadFunc, + type DisplayLoaderProps, +} from 'web/components/dashboard/display/Loader'; +import {type SeverityData} from 'web/components/dashboard/display/severity/severity-class-transform'; + +interface ResultWordCloudDataGroup { + count: number; + value: string; +} + +export interface ResultWordCloudData { + groups?: ResultWordCloudDataGroup[]; +} export const RESULTS_DESCRIPTION_WORDCOUNT = 'results-description-wordcount'; export const RESULTS_SEVERITY = 'results-severity'; @@ -15,7 +28,10 @@ export const resultsSeverityLoadFunc = createLoadFunc( RESULTS_SEVERITY, ); -export const ResultsSeverityLoader = ({filter, children}) => ( +export const ResultsSeverityLoader = ({ + filter, + children, +}: DisplayLoaderProps) => ( ( +export const ResultsWordCountLoader = ({ + filter, + children, +}: DisplayLoaderProps) => ( ( +export const ResultsDescriptionWordCountLoader = ({ + filter, + children, +}: DisplayLoaderProps) => ( - _('Results by Severity Class (Total: {{count}})', {count: tdata.total}), + displayComponent: props => ( + + _('Results by Severity Class (Total: {{count}})', {count: data.total}) + } + /> + ), filtersFilter: RESULTS_FILTER_FILTER, displayId: 'result-by-severity-class', displayName: 'ResultsSeverityDisplay', @@ -23,10 +28,15 @@ export const ResultsSeverityDisplay = createDisplay({ export const ResultsSeverityTableDisplay = createDisplay({ loaderComponent: ResultsSeverityLoader, - displayComponent: SeverityClassTableDisplay, - title: ({data: tdata}) => - _('Results by Severity Class (Total: {{count}})', {count: tdata.total}), - dataTitles: [_l('Severity Class'), _l('# of Results')], + displayComponent: props => ( + + _('Results by Severity Class (Total: {{count}})', {count: data.total}) + } + /> + ), 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 new file mode 100644 index 0000000000..4b2817db2b --- /dev/null +++ b/src/web/pages/results/dashboard/ResultWordCloudDisplay.tsx @@ -0,0 +1,161 @@ +/* SPDX-FileCopyrightText: 2024 Greenbone AG + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import {_, _l} from 'gmp/locale/lang'; +import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; +import FilterTerm from 'gmp/models/filter/filter-term'; +import QueryFilter from 'gmp/models/filter/query-filter'; +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 {randomColor} from 'web/components/dashboard/display/utils'; +import {registerDisplay} from 'web/components/dashboard/registry'; +import { + ResultsWordCountLoader, + type ResultWordCloudData, +} from 'web/pages/results/dashboard/ResultLoaders'; + +interface TransformedResultWordCloudDataItem { + value: number; + label: string; + color: string; + filterValue: string; +} + +type TransformedResultWordCloudData = TransformedResultWordCloudDataItem[]; + +type ResultWordCloudDataDisplayProps = DataDisplayProps< + ResultWordCloudData, + TransformedResultWordCloudData +>; + +type ResultWordCloudDisplayProps = DashboardDisplayProps; + +const transformWordCountData = ( + data: ResultWordCloudData = {}, +): TransformedResultWordCloudData => { + const {groups = []} = data; + const transformedData = groups.map(group => { + const {count, value} = group; + return { + value: parseFloat(count), + label: value, + color: randomColor(), + filterValue: value, + } as TransformedResultWordCloudDataItem; + }); + 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}) => ( + + )} + + )} + + {filterSelectionDialog} + + ); +}; + +ResultsWordCloudDisplay.displayId = 'result-by-vuln-words'; + +export const ResultsWordCloudTableDisplay = createDisplay({ + loaderComponent: ResultsWordCountLoader, + displayComponent: props => ( + [row.label, row.value]} + dataTitles={[_('Vulnerability'), _('Word Count')]} + dataTransform={transformWordCountData} + title={() => _('Results Vulnerability Word Cloud')} + /> + ), + filtersFilter: RESULTS_FILTER_FILTER, + displayId: 'result-by-vuln-words-table', + displayName: 'ResultsWordCloudTableDisplay', +}); + +registerDisplay( + ResultsWordCloudDisplay, + _l('Chart: Results Vulnerability Word Cloud'), +); + +registerDisplay( + ResultsWordCloudTableDisplay, + _l('Table: Results Vulnerability Word Cloud'), +); diff --git a/src/web/pages/results/dashboard/WordCloudDisplay.jsx b/src/web/pages/results/dashboard/WordCloudDisplay.jsx deleted file mode 100644 index 13e55cb28f..0000000000 --- a/src/web/pages/results/dashboard/WordCloudDisplay.jsx +++ /dev/null @@ -1,133 +0,0 @@ -/* SPDX-FileCopyrightText: 2024 Greenbone AG - * - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import React from 'react'; -import {_, _l} from 'gmp/locale/lang'; -import {RESULTS_FILTER_FILTER} from 'gmp/models/filter'; -import FilterTerm from 'gmp/models/filter/filter-term'; -import QueryFilter from 'gmp/models/filter/query-filter'; -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 createDisplay from 'web/components/dashboard/display/createDisplay'; -import DataDisplay from 'web/components/dashboard/display/DataDisplay'; -import DataTableDisplay from 'web/components/dashboard/display/DataTableDisplay'; -import {randomColor} from 'web/components/dashboard/display/utils'; -import withFilterSelection from 'web/components/dashboard/display/withFilterSelection'; -import {registerDisplay} from 'web/components/dashboard/registry'; -import {ResultsWordCountLoader} from 'web/pages/results/dashboard/Loaders'; -import PropTypes from 'web/utils/prop-types'; - -const transformWordCountData = (data = {}) => { - const {groups = []} = data; - const tdata = groups.map(group => { - const {count, value} = group; - return { - value: parseFloat(count), - label: value, - color: randomColor(), - filterValue: value, - }; - }); - return tdata; -}; - -export class ResultsWordCloudDisplay extends React.Component { - constructor(...args) { - super(...args); - - this.handleDataClick = this.handleDataClick.bind(this); - } - - handleDataClick(filterValue) { - const {onFilterChanged, filter} = this.props; - - if (!isDefined(onFilterChanged)) { - return; - } - - let wordFilter; - - if (!isEmpty(filterValue)) { - const wordTerm = FilterTerm.fromString(`vulnerability~"${filterValue}"`); - - if (isDefined(filter) && filter.hasTerm(wordTerm)) { - return; - } - wordFilter = QueryFilter.fromTerm(wordTerm); - } - - const newFilter = isDefined(filter) - ? filter.copy().and(wordFilter) - : wordFilter; - - onFilterChanged(newFilter); - } - - render() { - const {filter, onFilterChanged, ...props} = this.props; - - return ( - - {loaderProps => ( - _('Results Vulnerability Word Cloud')} - > - {({width, height, data: tdata, svgRef}) => ( - - )} - - )} - - ); - } -} - -ResultsWordCloudDisplay.propTypes = { - filter: PropTypes.filter, - onFilterChanged: PropTypes.func, -}; - -ResultsWordCloudDisplay = withFilterSelection({ - filtersFilter: RESULTS_FILTER_FILTER, -})(ResultsWordCloudDisplay); - -ResultsWordCloudDisplay.displayId = 'result-by-vuln-words'; - -export const ResultsWordCloudTableDisplay = createDisplay({ - loaderComponent: ResultsWordCountLoader, - displayComponent: DataTableDisplay, - dataTransform: transformWordCountData, - dataTitles: [_l('Vulnerability'), _l('Word Count')], - dataRow: row => [row.label, row.value], - title: () => _('Results Vulnerability Word Cloud'), - filtersFilter: RESULTS_FILTER_FILTER, - displayId: 'result-by-vuln-words-table', - displayName: 'ResultsWordCloudTableDisplay', -}); - -registerDisplay( - ResultsWordCloudDisplay, - _l('Chart: Results Vulnerability Word Cloud'), -); - -registerDisplay( - ResultsWordCloudTableDisplay, - _l('Table: Results Vulnerability Word Cloud'), -); diff --git a/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx new file mode 100644 index 0000000000..df56f88f73 --- /dev/null +++ b/src/web/pages/results/dashboard/__tests__/ResultCvssDisplay.test.tsx @@ -0,0 +1,131 @@ +/* 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, screen, waitFor} from 'web/testing'; +import {getDisplay} from 'web/components/dashboard/registry'; +import { + SubscriptionContext, + type SubscribeFunc, +} from 'web/components/provider/SubscriptionProvider'; +import { + ResultsCvssDisplay, + ResultsCvssTableDisplay, +} from 'web/pages/results/dashboard/ResultCvssDisplay'; + +const loaderData = { + groups: [ + {value: '0.2', count: 12}, + {value: '7.5', count: 30}, + ], +}; + +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}})} + {yLabel} +
+ ); + }, +})); + +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}})} + {dataTitles?.join('|')} +
+ ); + }, +})); + +const createGmp = () => ({ + filters: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: {filter: 'type=result', counts: {}}, + }), + }, + results: { + getSeverityAggregates: testing.fn().mockResolvedValue({data: loaderData}), + }, +}); + +const renderDisplay = (component: ReactElement) => { + const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); + const {render} = rendererWith({gmp: createGmp(), store: true}); + + return render( + + {component} + , + ); +}; + +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'); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsCvssDisplay.displayId); + + expect(registered?.component).toBe(ResultsCvssDisplay); + expect(String(registered?.title)).toBe('Chart: Results by CVSS'); + }); + + test('should render the configured title and y-axis label', async () => { + renderDisplay(); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results by CVSS (Total: 42)', + ); + expect(screen.getByTestId('y-label')).toHaveTextContent('# of Results'); + }); + }); +}); + +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'); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsCvssTableDisplay.displayId); + + expect(registered?.component).toBe(ResultsCvssTableDisplay); + expect(String(registered?.title)).toBe('Table: Results by CVSS'); + }); + + test('should render the configured title and table headings', async () => { + renderDisplay(); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results by CVSS (Total: 42)', + ); + expect(screen.getByTestId('data-titles')).toHaveTextContent( + 'Severity|# of Results', + ); + }); + }); +}); diff --git a/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx new file mode 100644 index 0000000000..4a6d1041c8 --- /dev/null +++ b/src/web/pages/results/dashboard/__tests__/ResultDescriptionWordCloudDisplay.test.tsx @@ -0,0 +1,181 @@ +/* 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, screen, waitFor} from 'web/testing'; +import {getDisplay} from 'web/components/dashboard/registry'; +import { + SubscriptionContext, + type SubscribeFunc, +} from 'web/components/provider/SubscriptionProvider'; +import { + ResultsDescriptionWordCloudDisplay, + ResultsDescriptionWordCloudTableDisplay, +} from 'web/pages/results/dashboard/ResultDescriptionWordCloudDisplay'; + +const loaderData = { + groups: [ + {value: 'security issue', count: 5}, + {value: 'network service', count: 3}, + ], +}; + +vi.mock('web/components/dashboard/display/DataDisplay', () => ({ + default: ({children, data, dataTransform, title}) => { + const transformedData = dataTransform ? dataTransform(data) : data; + + return ( +
+ {title?.()} + {typeof children === 'function' + ? children({ + width: 400, + height: 300, + data: transformedData, + svgRef: {current: null}, + }) + : children} +
+ ); + }, +})); + +vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ + default: ({data, dataRow, dataTitles, dataTransform, title}) => { + const transformedData = dataTransform ? dataTransform(data) : data; + + return ( +
+ {title?.()} + {dataTitles?.join('|')} + {transformedData?.map((row, index) => ( + + {dataRow(row).join('|')} + + ))} +
+ ); + }, +})); + +vi.mock('web/components/chart/WordCloudChart', () => ({ + default: ({data}) => ( +
+ {data.map((row, index) => ( + + {row.label}|{row.value}|{row.filterValue} + + ))} +
+ ), +})); + +const createGmp = () => ({ + filters: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: {filter: 'type=result', counts: {}}, + }), + }, + results: { + getDescriptionWordCountsAggregates: testing.fn().mockResolvedValue({ + data: loaderData, + }), + }, +}); + +const renderDisplay = (component: ReactElement) => { + const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); + const {render} = rendererWith({gmp: createGmp()}); + + return render( + + {component} + , + ); +}; + +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', + ); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsDescriptionWordCloudDisplay.displayId); + + expect(registered?.component).toBe(ResultsDescriptionWordCloudDisplay); + expect(String(registered?.title)).toBe( + 'Chart: Results Description Word Cloud', + ); + }); + + test('should render the loaded words', async () => { + renderDisplay( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results Description Word Cloud', + ); + expect(screen.getByTestId('data-word-0')).toHaveTextContent( + 'security issue|5|security issue', + ); + expect(screen.getByTestId('data-word-1')).toHaveTextContent( + 'network service|3|network service', + ); + }); + }); +}); + +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', + ); + expect(ResultsDescriptionWordCloudTableDisplay.displayName).toBe( + 'ResultsDescriptionWordCloudTableDisplay', + ); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay( + ResultsDescriptionWordCloudTableDisplay.displayId, + ); + + expect(registered?.component).toBe(ResultsDescriptionWordCloudTableDisplay); + expect(String(registered?.title)).toBe( + 'Table: Results Description Word Cloud', + ); + }); + + test('should render the configured table data', async () => { + renderDisplay( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results Description Word Cloud', + ); + expect(screen.getByTestId('data-titles')).toHaveTextContent( + 'Description|Word Count', + ); + expect(screen.getByTestId('data-row-0')).toHaveTextContent( + 'security issue|5', + ); + expect(screen.getByTestId('data-row-1')).toHaveTextContent( + 'network service|3', + ); + }); + }); +}); diff --git a/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx new file mode 100644 index 0000000000..3b72ffef8f --- /dev/null +++ b/src/web/pages/results/dashboard/__tests__/ResultLoaders.test.tsx @@ -0,0 +1,145 @@ +/* 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 { + ResultsDescriptionWordCountLoader, + ResultsSeverityLoader, + ResultsWordCountLoader, + RESULTS_DESCRIPTION_WORDCOUNT, + RESULTS_SEVERITY, + RESULTS_WORD_COUNT, +} from 'web/pages/results/dashboard/ResultLoaders'; + +const createGmp = (results: Record) => ({results}); + +const renderWithSubscriptionContext = ({ + gmp, + subscribe, + children, +}: { + gmp: Record; + subscribe: SubscribeFunc; + children: ReactElement; +}) => { + const {render} = rendererWith({gmp, store: true}); + + return render( + + {children} + , + ); +}; + +const expectResultSubscriptions = (subscribe: SubscribeFunc) => { + expect(subscribe).toHaveBeenCalledWith('results.timer', expect.any(Function)); + expect(subscribe).toHaveBeenCalledWith( + 'results.changed', + expect.any(Function), + ); +}; + +describe('Result loaders', () => { + test('should export the result data IDs', () => { + expect(RESULTS_DESCRIPTION_WORDCOUNT).toBe('results-description-wordcount'); + expect(RESULTS_SEVERITY).toBe('results-severity'); + expect(RESULTS_WORD_COUNT).toBe('results-wordcount'); + }); + + describe('ResultsSeverityLoader', () => { + test('should load severity aggregates and render them', async () => { + const data = {groups: [{value: '5.0', count: 10}]}; + const getSeverityAggregates = testing.fn().mockResolvedValue({data}); + const gmp = createGmp({getSeverityAggregates}); + 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(getSeverityAggregates).toHaveBeenCalledWith({filter}); + expect(children).toHaveBeenLastCalledWith({data, isLoading: false}); + }); + + expectResultSubscriptions(subscribe); + }); + }); + + describe('ResultsWordCountLoader', () => { + test('should load word-count aggregates and render them', async () => { + const data = {groups: [{value: 'security', 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}); + }); + + expectResultSubscriptions(subscribe); + }); + }); + + describe('ResultsDescriptionWordCountLoader', () => { + test('should load description word-count aggregates and render them', async () => { + const data = {groups: [{value: 'security issue', count: 5}]}; + const getDescriptionWordCountsAggregates = testing + .fn() + .mockResolvedValue({data}); + const gmp = createGmp({getDescriptionWordCountsAggregates}); + 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(getDescriptionWordCountsAggregates).toHaveBeenCalledWith({ + filter, + }); + expect(children).toHaveBeenLastCalledWith({data, isLoading: 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 new file mode 100644 index 0000000000..9296d1fff2 --- /dev/null +++ b/src/web/pages/results/dashboard/__tests__/ResultSeverityClassDisplay.test.tsx @@ -0,0 +1,139 @@ +/* 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, screen, waitFor} from 'web/testing'; +import {getDisplay} from 'web/components/dashboard/registry'; +import { + SubscriptionContext, + type SubscribeFunc, +} from 'web/components/provider/SubscriptionProvider'; +import { + ResultsSeverityDisplay, + ResultsSeverityTableDisplay, +} from 'web/pages/results/dashboard/ResultSeverityClassDisplay'; + +const loaderData = { + groups: [ + {value: '2.0', count: 12}, + {value: '7.5', count: 30}, + ], +}; + +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}})} +
+ ); + }, + }), +); + +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}})} + {dataTitles?.join('|')} +
+ ); + }, + }), +); + +const createGmp = () => ({ + filters: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: {filter: 'type=result', counts: {}}, + }), + }, + results: { + getSeverityAggregates: testing.fn().mockResolvedValue({data: loaderData}), + }, +}); + +const renderDisplay = (component: ReactElement) => { + const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); + const {render} = rendererWith({gmp: createGmp(), store: true}); + + return render( + + {component} + , + ); +}; + +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'); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsSeverityDisplay.displayId); + + expect(registered?.component).toBe(ResultsSeverityDisplay); + expect(String(registered?.title)).toBe('Chart: Results by Severity Class'); + }); + + test('should render the total loaded result count', async () => { + renderDisplay(); + + await waitFor(() => { + expect(screen.getByTestId('mock-severity-display')).toHaveTextContent( + 'Results by Severity Class (Total: 42)', + ); + }); + }); +}); + +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', + ); + expect(ResultsSeverityTableDisplay.displayName).toBe( + 'ResultsSeverityTableDisplay', + ); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsSeverityTableDisplay.displayId); + + expect(registered?.component).toBe(ResultsSeverityTableDisplay); + expect(String(registered?.title)).toBe('Table: Results by Severity Class'); + }); + + test('should render the total and table headings', async () => { + renderDisplay(); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results by Severity Class (Total: 42)', + ); + expect(screen.getByTestId('data-titles')).toHaveTextContent( + 'Severity Class|# of Results', + ); + }); + }); +}); diff --git a/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx b/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx new file mode 100644 index 0000000000..22dc6ef8fb --- /dev/null +++ b/src/web/pages/results/dashboard/__tests__/ResultWordCloudDisplay.test.tsx @@ -0,0 +1,167 @@ +/* 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, screen, waitFor} from 'web/testing'; +import {getDisplay} from 'web/components/dashboard/registry'; +import { + SubscriptionContext, + type SubscribeFunc, +} from 'web/components/provider/SubscriptionProvider'; +import { + ResultsWordCloudDisplay, + ResultsWordCloudTableDisplay, +} from 'web/pages/results/dashboard/ResultWordCloudDisplay'; + +const loaderData = { + groups: [ + {value: 'security', count: 5}, + {value: 'network', count: 3}, + ], +}; + +vi.mock('web/components/dashboard/display/DataDisplay', () => ({ + default: ({children, data, dataTransform, title}) => { + const transformedData = dataTransform ? dataTransform(data) : data; + + return ( +
+ {title?.()} + {typeof children === 'function' + ? children({ + width: 400, + height: 300, + data: transformedData, + svgRef: {current: null}, + }) + : children} +
+ ); + }, +})); + +vi.mock('web/components/dashboard/display/DataTableDisplay', () => ({ + default: ({data, dataRow, dataTitles, dataTransform, title}) => { + const transformedData = dataTransform ? dataTransform(data) : data; + + return ( +
+ {title?.()} + {dataTitles?.join('|')} + {transformedData?.map((row, index) => ( + + {dataRow(row).join('|')} + + ))} +
+ ); + }, +})); + +vi.mock('web/components/chart/WordCloudChart', () => ({ + default: ({data}) => ( +
+ {data.map((row, index) => ( + + {row.label}|{row.value}|{row.filterValue} + + ))} +
+ ), +})); + +const createGmp = () => ({ + filters: { + get: testing.fn().mockResolvedValue({ + data: [], + meta: {filter: 'type=result', counts: {}}, + }), + }, + results: { + getWordCountsAggregates: testing.fn().mockResolvedValue({data: loaderData}), + }, +}); + +const renderDisplay = (component: ReactElement) => { + const subscribe: SubscribeFunc = testing.fn().mockReturnValue(testing.fn()); + const {render} = rendererWith({gmp: createGmp()}); + + return render( + + {component} + , + ); +}; + +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'); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsWordCloudDisplay.displayId); + + expect(registered?.component).toBe(ResultsWordCloudDisplay); + expect(String(registered?.title)).toBe( + 'Chart: Results Vulnerability Word Cloud', + ); + }); + + test('should render the loaded words', async () => { + renderDisplay(); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results Vulnerability Word Cloud', + ); + expect(screen.getByTestId('data-word-0')).toHaveTextContent( + 'security|5|security', + ); + expect(screen.getByTestId('data-word-1')).toHaveTextContent( + 'network|3|network', + ); + }); + }); +}); + +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', + ); + expect(ResultsWordCloudTableDisplay.displayName).toBe( + 'ResultsWordCloudTableDisplay', + ); + }); + + test('should be registered with the correct title', () => { + const registered = getDisplay(ResultsWordCloudTableDisplay.displayId); + + expect(registered?.component).toBe(ResultsWordCloudTableDisplay); + expect(String(registered?.title)).toBe( + 'Table: Results Vulnerability Word Cloud', + ); + }); + + test('should render the configured table data', async () => { + renderDisplay(); + + await waitFor(() => { + expect(screen.getByTestId('title')).toHaveTextContent( + 'Results Vulnerability Word Cloud', + ); + expect(screen.getByTestId('data-titles')).toHaveTextContent( + 'Vulnerability|Word Count', + ); + expect(screen.getByTestId('data-row-0')).toHaveTextContent('security|5'); + expect(screen.getByTestId('data-row-1')).toHaveTextContent('network|3'); + }); + }); +}); diff --git a/src/web/pages/results/dashboard/index.jsx b/src/web/pages/results/dashboard/index.tsx similarity index 70% rename from src/web/pages/results/dashboard/index.jsx rename to src/web/pages/results/dashboard/index.tsx index a50e2eee66..e743186470 100644 --- a/src/web/pages/results/dashboard/index.jsx +++ b/src/web/pages/results/dashboard/index.tsx @@ -3,24 +3,29 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import React from 'react'; +import {type FilterType} from 'gmp/models/filter'; import Dashboard from 'web/components/dashboard/Dashboard'; import { ResultsCvssDisplay, ResultsCvssTableDisplay, -} from 'web/pages/results/dashboard/CvssDisplay'; +} from 'web/pages/results/dashboard/ResultCvssDisplay'; import { ResultsDescriptionWordCloudDisplay, ResultsDescriptionWordCloudTableDisplay, -} from 'web/pages/results/dashboard/DescriptionWordCloudDisplay'; +} from 'web/pages/results/dashboard/ResultDescriptionWordCloudDisplay'; import { ResultsSeverityDisplay, ResultsSeverityTableDisplay, -} from 'web/pages/results/dashboard/SeverityClassDisplay'; +} from 'web/pages/results/dashboard/ResultSeverityClassDisplay'; import { ResultsWordCloudDisplay, ResultsWordCloudTableDisplay, -} from 'web/pages/results/dashboard/WordCloudDisplay'; +} from 'web/pages/results/dashboard/ResultWordCloudDisplay'; + +interface ResultsDashboardProps { + filter?: FilterType; + onFilterChanged?: (filter: FilterType) => void; +} export const RESULTS_DASHBOARD_ID = '0b8ae70d-d8fc-4418-8a72-e65ac8d2828e'; @@ -35,7 +40,7 @@ export const RESULTS_DISPLAYS = [ ResultsWordCloudTableDisplay.displayId, ]; -const ResultsDashboard = props => ( +const ResultsDashboard = (props: ResultsDashboardProps) => (