diff --git a/extensions/ql-vscode/src/model-editor/model-alerts/alert-processor.ts b/extensions/ql-vscode/src/model-editor/model-alerts/alert-processor.ts index 8aecfee24..402cb2662 100644 --- a/extensions/ql-vscode/src/model-editor/model-alerts/alert-processor.ts +++ b/extensions/ql-vscode/src/model-editor/model-alerts/alert-processor.ts @@ -29,17 +29,22 @@ export function calculateModelAlerts( } for (const [i, repoResult] of repoResults.entries()) { + const results = repoResult.interpretedResults || []; + const repository = { + id: repoResult.repositoryId, + fullName: repoMap.get(repoResult.repositoryId) || "", + }; + + const alerts = results.map(() => { + return { + alert: createMockAlert(), + repository, + }; + }); + modelAlerts.push({ model: createModeledMethod(i.toString()), - alerts: [ - { - alert: createMockAlert(), - repository: { - id: repoResult.repositoryId, - fullName: repoMap.get(repoResult.repositoryId) || "", - }, - }, - ], + alerts, }); } diff --git a/extensions/ql-vscode/src/model-editor/shared/model-alerts-filter-sort.ts b/extensions/ql-vscode/src/model-editor/shared/model-alerts-filter-sort.ts new file mode 100644 index 000000000..d00d67f32 --- /dev/null +++ b/extensions/ql-vscode/src/model-editor/shared/model-alerts-filter-sort.ts @@ -0,0 +1,75 @@ +import type { ModelAlerts } from "../model-alerts/model-alerts"; + +export enum SortKey { + Alphabetically = "alphabetically", + NumberOfResults = "numberOfResults", +} + +export type ModelAlertsFilterSortState = { + modelSearchValue: string; + repositorySearchValue: string; + sortKey: SortKey; +}; + +export const defaultFilterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "", + repositorySearchValue: "", + sortKey: SortKey.NumberOfResults, +}; + +export function filterAndSort( + modelAlerts: ModelAlerts[], + filterSortState: ModelAlertsFilterSortState, +): ModelAlerts[] { + if (!modelAlerts || modelAlerts.length === 0) { + return []; + } + + return modelAlerts + .filter((item) => matchesFilter(item, filterSortState)) + .sort((a, b) => { + switch (filterSortState.sortKey) { + case SortKey.Alphabetically: + return a.model.signature.localeCompare(b.model.signature); + case SortKey.NumberOfResults: + return (b.alerts.length || 0) - (a.alerts.length || 0); + default: + return 0; + } + }); +} + +function matchesFilter( + item: ModelAlerts, + filterSortState: ModelAlertsFilterSortState | undefined, +): boolean { + if (!filterSortState) { + return true; + } + + return ( + matchesRepository(item, filterSortState.repositorySearchValue) && + matchesModel(item, filterSortState.modelSearchValue) + ); +} + +function matchesRepository( + item: ModelAlerts, + repositorySearchValue: string, +): boolean { + // We may want to only return alerts that have a repository match + // but for now just return true if the model has any alerts + // with a matching repo. + + return item.alerts.some((alert) => + alert.repository.fullName + .toLowerCase() + .includes(repositorySearchValue.toLowerCase()), + ); +} + +function matchesModel(item: ModelAlerts, modelSearchValue: string): boolean { + return item.model.signature + .toLowerCase() + .includes(modelSearchValue.toLowerCase()); +} diff --git a/extensions/ql-vscode/src/stories/model-alerts/ModelAlerts.stories.tsx b/extensions/ql-vscode/src/stories/model-alerts/ModelAlerts.stories.tsx index 802dd930d..883a5a571 100644 --- a/extensions/ql-vscode/src/stories/model-alerts/ModelAlerts.stories.tsx +++ b/extensions/ql-vscode/src/stories/model-alerts/ModelAlerts.stories.tsx @@ -2,7 +2,9 @@ import type { Meta, StoryFn } from "@storybook/react"; import { ModelAlerts as ModelAlertsComponent } from "../../view/model-alerts/ModelAlerts"; import { createMockVariantAnalysis } from "../../../test/factories/variant-analysis/shared/variant-analysis"; +import { VariantAnalysisRepoStatus } from "../../variant-analysis/shared/variant-analysis"; import type { VariantAnalysisScannedRepositoryResult } from "../../variant-analysis/shared/variant-analysis"; +import { createMockAnalysisAlert } from "../../../test/factories/variant-analysis/shared/analysis-alert"; export default { title: "Model Alerts/Model Alerts", @@ -24,15 +26,79 @@ const variantAnalysis = createMockVariantAnalysis({ path: "/path/to/model-pack-2", }, ], + scannedRepos: [ + { + repository: { + id: 1, + fullName: "org/repo1", + private: false, + stargazersCount: 100, + updatedAt: new Date().toISOString(), + }, + analysisStatus: VariantAnalysisRepoStatus.InProgress, + resultCount: 0, + artifactSizeInBytes: 0, + }, + { + repository: { + id: 2, + fullName: "org/repo2", + private: false, + stargazersCount: 100, + updatedAt: new Date().toISOString(), + }, + analysisStatus: VariantAnalysisRepoStatus.Succeeded, + resultCount: 0, + artifactSizeInBytes: 0, + }, + { + repository: { + id: 3, + fullName: "org/repo3", + private: false, + stargazersCount: 100, + updatedAt: new Date().toISOString(), + }, + analysisStatus: VariantAnalysisRepoStatus.Succeeded, + resultCount: 1, + artifactSizeInBytes: 0, + }, + { + repository: { + id: 4, + fullName: "org/repo4", + private: false, + stargazersCount: 100, + updatedAt: new Date().toISOString(), + }, + analysisStatus: VariantAnalysisRepoStatus.Succeeded, + resultCount: 3, + artifactSizeInBytes: 0, + }, + ], }); -const repoResults: VariantAnalysisScannedRepositoryResult[] = ( - variantAnalysis.scannedRepos || [] -).map((repo) => ({ - variantAnalysisId: variantAnalysis.id, - repositoryId: repo.repository.id, - interpretedResults: [], -})); +const repoResults: VariantAnalysisScannedRepositoryResult[] = [ + { + variantAnalysisId: variantAnalysis.id, + repositoryId: 2, + interpretedResults: [createMockAnalysisAlert(), createMockAnalysisAlert()], + }, + { + variantAnalysisId: variantAnalysis.id, + repositoryId: 3, + interpretedResults: [ + createMockAnalysisAlert(), + createMockAnalysisAlert(), + createMockAnalysisAlert(), + ], + }, + { + variantAnalysisId: variantAnalysis.id, + repositoryId: 4, + interpretedResults: [createMockAnalysisAlert()], + }, +]; export const ModelAlerts = Template.bind({}); ModelAlerts.args = { diff --git a/extensions/ql-vscode/src/view/model-alerts/ModelAlerts.tsx b/extensions/ql-vscode/src/view/model-alerts/ModelAlerts.tsx index 7e862b756..77f364a3f 100644 --- a/extensions/ql-vscode/src/view/model-alerts/ModelAlerts.tsx +++ b/extensions/ql-vscode/src/view/model-alerts/ModelAlerts.tsx @@ -11,6 +11,12 @@ import { vscode } from "../vscode-api"; import { ModelAlertsResults } from "./ModelAlertsResults"; import type { ModelAlerts } from "../../model-editor/model-alerts/model-alerts"; import { calculateModelAlerts } from "../../model-editor/model-alerts/alert-processor"; +import { ModelAlertsSearchSortRow } from "./ModelAlertsSearchSortRow"; +import { + defaultFilterSortState, + filterAndSort, +} from "../../model-editor/shared/model-alerts-filter-sort"; +import type { ModelAlertsFilterSortState } from "../../model-editor/shared/model-alerts-filter-sort"; type Props = { initialViewState?: ModelAlertsViewState; @@ -53,6 +59,9 @@ export function ModelAlerts({ const [repoResults, setRepoResults] = useState(initialRepoResults); + const [filterSortValue, setFilterSortValue] = + useState(defaultFilterSortState); + useEffect(() => { const listener = (evt: MessageEvent) => { if (evt.origin === window.origin) { @@ -97,8 +106,10 @@ export function ModelAlerts({ return []; } - return calculateModelAlerts(variantAnalysis, repoResults); - }, [variantAnalysis, repoResults]); + const modelAlerts = calculateModelAlerts(variantAnalysis, repoResults); + + return filterAndSort(modelAlerts, filterSortValue); + }, [filterSortValue, variantAnalysis, repoResults]); if (viewState === undefined || variantAnalysis === undefined) { return <>; @@ -125,6 +136,10 @@ export function ModelAlerts({ >
Model alerts +
{modelAlerts.map((alerts, i) => ( // We're using the index as the key here which is not recommended. diff --git a/extensions/ql-vscode/src/view/model-alerts/ModelAlertsSearchSortRow.tsx b/extensions/ql-vscode/src/view/model-alerts/ModelAlertsSearchSortRow.tsx new file mode 100644 index 000000000..defa1baff --- /dev/null +++ b/extensions/ql-vscode/src/view/model-alerts/ModelAlertsSearchSortRow.tsx @@ -0,0 +1,87 @@ +import { useCallback } from "react"; +import type { Dispatch, SetStateAction } from "react"; +import { styled } from "styled-components"; +import type { + ModelAlertsFilterSortState, + SortKey, +} from "../../model-editor/shared/model-alerts-filter-sort"; +import { SearchBox } from "../common/SearchBox"; +import { ModelAlertsSort } from "./ModelAlertsSort"; + +type Props = { + filterSortValue: ModelAlertsFilterSortState; + onFilterSortChange: Dispatch>; +}; + +const Container = styled.div` + display: flex; + gap: 1em; + width: 100%; + margin-bottom: 1em; +`; + +const ModelsSearchColumn = styled(SearchBox)` + flex: 2; +`; + +const RepositoriesSearchColumn = styled(SearchBox)` + flex: 2; +`; + +const SortColumn = styled(ModelAlertsSort)` + flex: 1; +`; + +export const ModelAlertsSearchSortRow = ({ + filterSortValue, + onFilterSortChange, +}: Props) => { + const handleModelSearchValueChange = useCallback( + (searchValue: string) => { + onFilterSortChange((oldValue) => ({ + ...oldValue, + modelSearchValue: searchValue, + })); + }, + [onFilterSortChange], + ); + + const handleRepositorySearchValueChange = useCallback( + (searchValue: string) => { + onFilterSortChange((oldValue) => ({ + ...oldValue, + repositorySearchValue: searchValue, + })); + }, + [onFilterSortChange], + ); + + const handleSortKeyChange = useCallback( + (sortKey: SortKey) => { + onFilterSortChange((oldValue) => ({ + ...oldValue, + sortKey, + })); + }, + [onFilterSortChange], + ); + + return ( + + + + + + ); +}; diff --git a/extensions/ql-vscode/src/view/model-alerts/ModelAlertsSort.tsx b/extensions/ql-vscode/src/view/model-alerts/ModelAlertsSort.tsx new file mode 100644 index 000000000..043355a57 --- /dev/null +++ b/extensions/ql-vscode/src/view/model-alerts/ModelAlertsSort.tsx @@ -0,0 +1,37 @@ +import { useCallback } from "react"; +import { styled } from "styled-components"; +import { VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"; +import { SortKey } from "../../model-editor/shared/model-alerts-filter-sort"; +import { Codicon } from "../common"; + +const Dropdown = styled(VSCodeDropdown)` + width: 100%; +`; + +type Props = { + value: SortKey; + onChange: (value: SortKey) => void; + + className?: string; +}; + +export const ModelAlertsSort = ({ value, onChange, className }: Props) => { + const handleInput = useCallback( + (e: InputEvent) => { + const target = e.target as HTMLSelectElement; + + onChange(target.value as SortKey); + }, + [onChange], + ); + + return ( + + + Alphabetically + + Number of results + + + ); +}; diff --git a/extensions/ql-vscode/test/unit-tests/model-editor/shared/model-alerts-filter-sort.test.ts b/extensions/ql-vscode/test/unit-tests/model-editor/shared/model-alerts-filter-sort.test.ts new file mode 100644 index 000000000..faf432a32 --- /dev/null +++ b/extensions/ql-vscode/test/unit-tests/model-editor/shared/model-alerts-filter-sort.test.ts @@ -0,0 +1,158 @@ +import type { ModelAlerts } from "../../../../src/model-editor/model-alerts/model-alerts"; +import type { ModelAlertsFilterSortState } from "../../../../src/model-editor/shared/model-alerts-filter-sort"; +import { + SortKey, + filterAndSort, +} from "../../../../src/model-editor/shared/model-alerts-filter-sort"; +import { createSinkModeledMethod } from "../../../factories/model-editor/modeled-method-factories"; +import { createMockAnalysisAlert } from "../../../factories/variant-analysis/shared/analysis-alert"; +import { shuffle } from "../../../vscode-tests/utils/list-helpers"; + +describe("model alerts filter sort", () => { + const modelAlerts: ModelAlerts[] = [ + { + model: createSinkModeledMethod({ + signature: "foo.m1", + }), + alerts: [ + { + alert: createMockAnalysisAlert(), + repository: { + id: 1, + fullName: "r1", + }, + }, + { + alert: createMockAnalysisAlert(), + repository: { + id: 2, + fullName: "r2", + }, + }, + { + alert: createMockAnalysisAlert(), + repository: { + id: 3, + fullName: "r3", + }, + }, + { + alert: createMockAnalysisAlert(), + repository: { + id: 4, + fullName: "r4", + }, + }, + ], + }, + { + model: createSinkModeledMethod({ + signature: "foo.m2", + }), + alerts: [ + { + alert: createMockAnalysisAlert(), + repository: { + id: 1, + fullName: "r1", + }, + }, + { + alert: createMockAnalysisAlert(), + repository: { + id: 2, + fullName: "r2", + }, + }, + ], + }, + { + model: createSinkModeledMethod({ + signature: "bar.m1", + }), + alerts: [ + { + alert: createMockAnalysisAlert(), + repository: { + id: 1, + fullName: "r1", + }, + }, + ], + }, + ]; + + it("should return an empty array if no model alerts", () => { + const filterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "", + repositorySearchValue: "", + sortKey: SortKey.Alphabetically, + }; + + const result = filterAndSort([], filterSortState); + + expect(result).toEqual([]); + }); + + it("should filter model alerts based on the model search value", () => { + const filterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "m1", + repositorySearchValue: "", + sortKey: SortKey.Alphabetically, + }; + + const result = filterAndSort(modelAlerts, filterSortState); + + expect(result.includes(modelAlerts[0])).toBeTruthy(); + expect(result.includes(modelAlerts[2])).toBeTruthy(); + }); + + it("should filter model alerts based on the repository search value", () => { + const filterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "", + repositorySearchValue: "r2", + sortKey: SortKey.Alphabetically, + }; + + const result = filterAndSort(modelAlerts, filterSortState); + + expect(result.includes(modelAlerts[0])).toBeTruthy(); + expect(result.includes(modelAlerts[1])).toBeTruthy(); + }); + + it("should sort model alerts alphabetically", () => { + const filterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "", + repositorySearchValue: "", + sortKey: SortKey.Alphabetically, + }; + + const result = filterAndSort(shuffle([...modelAlerts]), filterSortState); + + expect(result).toEqual([modelAlerts[2], modelAlerts[0], modelAlerts[1]]); + }); + + it("should sort model alerts by number of results", () => { + const filterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "", + repositorySearchValue: "", + sortKey: SortKey.NumberOfResults, + }; + + const result = filterAndSort(shuffle([...modelAlerts]), filterSortState); + + expect(result).toEqual([modelAlerts[0], modelAlerts[1], modelAlerts[2]]); + }); + + it("should filter and sort model alerts", () => { + const filterSortState: ModelAlertsFilterSortState = { + modelSearchValue: "m1", + repositorySearchValue: "r1", + sortKey: SortKey.NumberOfResults, + }; + + const result = filterAndSort(shuffle([...modelAlerts]), filterSortState); + + expect(result).toEqual([modelAlerts[0], modelAlerts[2]]); + }); +});