From 17f1430094abbcecdcb5149155ce4fd0611c5c5c Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Mon, 13 Nov 2023 13:13:46 -0500 Subject: [PATCH] Render dependency versions as links Resolves: #1340 On the Dependencies page, detail drawer, applications table: render the version text as a link to the maven central repository. The link uses the dependency's sha as the key in the maven central search. Add component `ExternalLink` to standardize rendering links outside of the app opening in a new tab. Signed-off-by: Scott J Dickerson --- client/src/app/api/models.ts | 4 +- client/src/app/components/ExternalLink.tsx | 26 +++++++++++ .../dependencies/dependency-apps-table.tsx | 45 +++++++++++++++++-- client/src/app/utils/utils.test.ts | 45 +++++++++++++++++++ client/src/app/utils/utils.ts | 18 ++++++++ 5 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 client/src/app/components/ExternalLink.tsx diff --git a/client/src/app/api/models.ts b/client/src/app/api/models.ts index 5191e8aa8e..d082ddbc9a 100644 --- a/client/src/app/api/models.ts +++ b/client/src/app/api/models.ts @@ -519,10 +519,12 @@ export interface AnalysisAppDependency { businessService: string; dependency: { id: number; + provider: string; name: string; version: string; - provider: string; + rule: string; // TODO: rename to 'sha' with https://github.com/konveyor/tackle2-hub/issues/557 indirect: boolean; + labels: Ref[]; //TODO: Glean from labels somehow // management?: string; }; diff --git a/client/src/app/components/ExternalLink.tsx b/client/src/app/components/ExternalLink.tsx new file mode 100644 index 0000000000..3566361648 --- /dev/null +++ b/client/src/app/components/ExternalLink.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; +import { Flex, FlexItem, Icon, Text } from "@patternfly/react-core"; +import ExternalLinkAltIcon from "@patternfly/react-icons/dist/esm/icons/external-link-alt-icon"; + +/** + * Render a link open an external href in another tab with appropriate styling. + */ +export const ExternalLink: React.FC<{ + href: string; + children: React.ReactNode; +}> = ({ href, children }) => ( + + + + {children} + + + + + + + + +); + +export default ExternalLink; diff --git a/client/src/app/pages/dependencies/dependency-apps-table.tsx b/client/src/app/pages/dependencies/dependency-apps-table.tsx index 2107ca18eb..01776bfe10 100644 --- a/client/src/app/pages/dependencies/dependency-apps-table.tsx +++ b/client/src/app/pages/dependencies/dependency-apps-table.tsx @@ -1,10 +1,16 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; -import { Toolbar, ToolbarContent, ToolbarItem } from "@patternfly/react-core"; +import { + Text, + TextContent, + Toolbar, + ToolbarContent, + ToolbarItem, +} from "@patternfly/react-core"; import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table"; import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; import { useSelectionState } from "@migtools/lib-ui"; -import { AnalysisDependency } from "@app/api/models"; +import { AnalysisAppDependency, AnalysisDependency } from "@app/api/models"; import { useTableControlState, useTableControlProps, @@ -16,11 +22,13 @@ import { TableHeaderContentWithControls, TableRowContentWithControls, } from "@app/components/TableControls"; +import { ExternalLink } from "@app/components/ExternalLink"; import { SimplePagination } from "@app/components/SimplePagination"; import { FilterToolbar, FilterType } from "@app/components/FilterToolbar"; import { useFetchAppDependencies } from "@app/queries/dependencies"; import { useFetchBusinessServices } from "@app/queries/businessservices"; import { useFetchTagsWithTagItems } from "@app/queries/tags"; +import { extractFirstSha } from "@app/utils/utils"; export interface IDependencyAppsTableProps { dependency: AnalysisDependency; @@ -180,7 +188,7 @@ export const DependencyAppsTable: React.FC = ({ {currentPageAppDependencies?.map((appDependency, rowIndex) => ( = ({ modifier="nowrap" {...getTdProps({ columnKey: "version" })} > - {appDependency.dependency.version} + {/* = ({ ); }; + +const DependencyVersionColumn = ({ + appDependency: { + dependency: { + provider, + name, + version, + rule: sha, // TODO: rename to 'sha' with https://github.com/konveyor/tackle2-hub/issues/557 + }, + }, +}: { + appDependency: AnalysisAppDependency; +}) => { + const isJavaDependency = name && version && sha && provider === ""; + + const mavenCentralLink = isJavaDependency + ? `https://search.maven.org/search?q=1:${extractFirstSha(sha)}` + : undefined; + + return ( + + {mavenCentralLink ? ( + {version} + ) : ( + {version} + )} + + ); +}; diff --git a/client/src/app/utils/utils.test.ts b/client/src/app/utils/utils.test.ts index 92efa040b9..016eab1841 100644 --- a/client/src/app/utils/utils.test.ts +++ b/client/src/app/utils/utils.test.ts @@ -7,6 +7,7 @@ import { gitUrlRegex, standardURLRegex, formatPath, + extractFirstSha, } from "./utils"; import { Paths } from "@app/Paths"; @@ -157,6 +158,7 @@ describe("utils", () => { expect(standardURLRegex.test(url)).toBe(true); }); }); + describe("formatPath function", () => { it("should replace path parameters with values", () => { const path = Paths.applicationsImportsDetails; @@ -174,3 +176,46 @@ describe("formatPath function", () => { expect(result).toBe("/applications/assessment/:assessmentId"); }); }); + +describe("SHA extraction", () => { + it("empty string is undefined", () => { + const first = extractFirstSha(""); + expect(first).toBeUndefined(); + }); + + it("no SHA is undefined", () => { + const first = extractFirstSha( + "The quick brown fox jumps over the lazy dog." + ); + expect(first).toBeUndefined(); + }); + + it("a SHA is found", () => { + const tests = [ + "83cd2cd674a217ade95a4bb83a8a14f351f48bd0", + " 83cd2cd674a217ade95a4bb83a8a14f351f48bd0 ", + "83cd2cd674a217ade95a4bb83a8a14f351f48bd0 The quick brown fox jumps over the lazy dog.", + "The quick brown fox jumps over the lazy dog. 83cd2cd674a217ade95a4bb83a8a14f351f48bd0", + "The quick brown fox 83cd2cd674a217ade95a4bb83a8a14f351f48bd0 jumps over the lazy dog.", + ]; + + for (const test of tests) { + const first = extractFirstSha(test); + expect(first).toBe("83cd2cd674a217ade95a4bb83a8a14f351f48bd0"); + } + }); + + it("multiple SHAs are in the string, only the first is returned", () => { + const first = extractFirstSha( + "83cd2cd674a217ade95a4bb83a8a14f351f48bd0 9c04cd6372077e9b11f70ca111c9807dc7137e4b" + ); + expect(first).toBe("83cd2cd674a217ade95a4bb83a8a14f351f48bd0"); + }); + + it("multiple SHAs are in the string, only the first is returned even if it is shorter", () => { + const first = extractFirstSha( + "9c04cd6372077e9b11f70ca111c9807dc7137e4b 83cd2cd674a217ade95a4bb83a8a14f351f48bd0 b47cc0f104b62d4c7c30bcd68fd8e67613e287dc4ad8c310ef10cbadea9c4380" + ); + expect(first).toBe("9c04cd6372077e9b11f70ca111c9807dc7137e4b"); + }); +}); diff --git a/client/src/app/utils/utils.ts b/client/src/app/utils/utils.ts index ce1b98e0d1..e736820f2b 100644 --- a/client/src/app/utils/utils.ts +++ b/client/src/app/utils/utils.ts @@ -151,3 +151,21 @@ export const formatPath = (path: Paths, data: any) => { return url; }; + +/** + * Regular expression to match a SHA hash in a string. Different versions of the SHA + * hash have different lengths. Check in descending length order so the longest SHA + * string can be captured. + */ +const SHA_REGEX = + /([a-f0-9]{128}|[a-f0-9]{96}|[a-f0-9]{64}|[a-f0-9]{56}|[a-f0-9]{40})/g; + +/** + * In any given string, find the first thing that looks like a sha hash and return it. + * If nothing looks like a sha hash, return undefined. + */ +export const extractFirstSha = (str: string): string | undefined => { + const match = str.match(SHA_REGEX); + console.log("match:", match); + return match && match[0] ? match[0] : undefined; +};