From 8c2698dc842bfa1c14344073eabe6965c40591bb Mon Sep 17 00:00:00 2001 From: Kris McGinnes Date: Fri, 5 Apr 2024 11:36:18 -0500 Subject: [PATCH] Add renderNode and tests --- packages/graph-explorer/jest.config.ts | 1 + packages/graph-explorer/package.json | 5 +- .../modules/GraphViewer/renderNode.test.ts | 191 ++++++++++++++++++ .../src/modules/GraphViewer/renderNode.tsx | 83 ++++++++ .../src/modules/GraphViewer/useGraphStyles.ts | 22 +- packages/graph-explorer/src/setupTests.ts | 8 +- .../src/utils/testing/randomData.ts | 13 ++ pnpm-lock.yaml | 18 +- 8 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts create mode 100644 packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx diff --git a/packages/graph-explorer/jest.config.ts b/packages/graph-explorer/jest.config.ts index b8256cad6..a87a0346a 100644 --- a/packages/graph-explorer/jest.config.ts +++ b/packages/graph-explorer/jest.config.ts @@ -31,6 +31,7 @@ const config: Config = { "src/App.ts", "src/setupTests.ts", ], + setupFiles: ["/setupTests.ts"], coverageProvider: "v8", }; diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 46b0cb083..c38c42924 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -84,7 +84,7 @@ "recoil": "^0.7.7", "swiper": "^8.4.7", "use-deep-compare-effect": "^1.8.1", - "uuid": "^8.3.2" + "uuid": "^9.0.1" }, "devDependencies": { "@babel/core": "^7.23.2", @@ -149,7 +149,8 @@ "type-fest": "^2.19.0", "typescript": "^4.9.5", "vite": "^4.5.3", - "webpack": "^5.76.0" + "webpack": "^5.76.0", + "whatwg-fetch": "^3.6.20" }, "overrides": { "json5@>=2.0.0 <2.2.2": "2.2.2", diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts b/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts new file mode 100644 index 000000000..380b80574 --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts @@ -0,0 +1,191 @@ +/** + * @jest-environment jsdom + */ + +import { describe, it, expect, jest } from "@jest/globals"; +import { ICONS_CACHE, VertexIconConfig, renderNode } from "./renderNode"; +import { + createRandomColor, + createRandomName, +} from "../../utils/testing/randomData"; + +global.fetch = + jest.fn< + ( + input: RequestInfo | URL, + init?: RequestInit | undefined + ) => Promise + >(); + +describe("renderNode", () => { + beforeEach(() => { + ICONS_CACHE.clear(); + jest.resetAllMocks(); + }); + + it("should return undefined given no icon url", async () => { + const mockedFetch = jest.mocked(global.fetch); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: createRandomColor(), + iconUrl: undefined, + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(node); + + expect(result).toBeUndefined(); + expect(mockedFetch).not.toBeCalled(); + expect(ICONS_CACHE.size).toEqual(0); + }); + + it("should return undefined when error occurs in fetch", async () => { + const mockedFetch = jest + .mocked(global.fetch) + .mockRejectedValue(new Error("Failed")); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: createRandomColor(), + iconUrl: createRandomName("iconUrl"), + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(node); + + expect(mockedFetch).toBeCalledWith(node.iconUrl); + expect(result).toBeUndefined(); + expect(ICONS_CACHE.size).toEqual(0); + }); + + it("should return icon url given image type is not an SVG", async () => { + const mockedFetch = jest.mocked(global.fetch); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: createRandomColor(), + iconUrl: createRandomName("iconUrl"), + iconImageType: "image/png", + }; + + const result = await renderNode(node); + + expect(result).toBe(node.iconUrl); + expect(mockedFetch).not.toBeCalled(); + expect(ICONS_CACHE.size).toEqual(0); + }); + + it("should return processed SVG string keeping original color", async () => { + const originalColor = createRandomColor(); + const svgContent = ``; + const mockedFetch = jest + .mocked(global.fetch) + .mockResolvedValue(new Response(new Blob([svgContent]))); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: createRandomColor(), + iconUrl: createRandomName("iconUrl"), + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(node); + + expect(mockedFetch).toBeCalledWith(node.iconUrl); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedResult = decodeSvg(result); + expect(decodedResult).toEqual( + wrapExpectedSvg( + `` + ) + ); + }); + + it("should return processed SVG string replacing currentColor with default color when custom color not provided", async () => { + const svgContent = ``; + const mockedFetch = jest + .mocked(global.fetch) + .mockResolvedValue(new Response(new Blob([svgContent]))); + const iconUrl = createRandomName("iconUrl"); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: undefined, + iconUrl, + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(node); + + expect(mockedFetch).toBeCalledWith(iconUrl); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedResult = decodeSvg(result); + expect(decodedResult).toEqual( + wrapExpectedSvg( + `` + ) + ); + }); + + it("should return processed SVG string replacing currentColor with provided custom color", async () => { + const svgContent = ``; + const mockedFetch = jest + .mocked(global.fetch) + .mockResolvedValue(new Response(new Blob([svgContent]))); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: createRandomColor(), + iconUrl: createRandomName("iconUrl"), + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(node); + + expect(mockedFetch).toBeCalledWith(node.iconUrl); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedResult = decodeSvg(result); + expect(decodedResult).toEqual( + wrapExpectedSvg( + `` + ) + ); + }); + + it("should return processed SVG string modifying the width and height", async () => { + const originalColor = createRandomColor(); + const svgContent = ``; + const mockedFetch = jest + .mocked(global.fetch) + .mockResolvedValue(new Response(new Blob([svgContent]))); + const node: VertexIconConfig = { + type: createRandomName("vertex"), + color: createRandomColor(), + iconUrl: createRandomName("iconUrl"), + iconImageType: "image/svg+xml", + }; + + const result = await renderNode(node); + + expect(mockedFetch).toBeCalledWith(node.iconUrl); + expect(result).toBeDefined(); + expect(result?.slice(0, 24)).toEqual("data:image/svg+xml;utf8,"); + const decodedResult = decodeSvg(result); + expect(decodedResult).toEqual( + wrapExpectedSvg( + `` + ) + ); + }); +}); + +/** Wraps SVG string in another SVG element matching what is expected. */ +function wrapExpectedSvg(svgContent: string): string { + return ` + + ${svgContent} +`; +} + +/** Decodes the string and removes the data type URL prefix, returning only the SVG portion. */ +function decodeSvg(result: string | undefined) { + return decodeURIComponent(result!).replace("data:image/svg+xml;utf8,", ""); +} diff --git a/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx new file mode 100644 index 000000000..d01299947 --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx @@ -0,0 +1,83 @@ +import { VertexTypeConfig } from "../../core"; + +export type VertexIconConfig = Pick< + VertexTypeConfig, + "type" | "iconUrl" | "iconImageType" | "color" +>; + +export const ICONS_CACHE: Map = new Map(); + +export async function renderNode( + vtConfig: VertexIconConfig +): Promise { + if (!vtConfig.iconUrl) { + return; + } + + if (vtConfig.iconImageType !== "image/svg+xml") { + return vtConfig.iconUrl; + } + + // To avoid multiple requests, cache icons under the same URL + if (ICONS_CACHE.get(vtConfig.iconUrl)) { + return ICONS_CACHE.get(vtConfig.iconUrl); + } + + try { + const response = await fetch(vtConfig.iconUrl); + let iconText = await response.text(); + + iconText = updateSize(iconText); + iconText = embedSvgInsideCytoscapeSvgWrapper(iconText); + iconText = applyCurrentColor(iconText, vtConfig.color || "#128EE5"); + iconText = encodeSvg(iconText); + + // Save to the cache + ICONS_CACHE.set(vtConfig.iconUrl, iconText); + return iconText; + } catch (error) { + // Ignore the error and move on + console.error(`Failed to fetch the icon data for vertex ${vtConfig.type}`); + return; + } +} + +/** + * Embeds the given SVG content inside an SVG wrapper that is designed to work well with Cytoscape rendering. + * @param svgContent The SVG content to embed + * @returns SVG string + */ +function embedSvgInsideCytoscapeSvgWrapper(svgContent: string): string { + return ` + + ${svgContent} +`; +} + +/** + * Replaces `currentColor` with the given color to make sure the SVG applies the right color in Cytoscape. + * @param svgContent + * @param color + * @returns + */ +function applyCurrentColor(svgContent: string, color: string) { + return svgContent.replace(/currentColor/gm, color); +} + +function updateSize(svgContent: string): string { + const parser = new DOMParser(); + const serializer = new XMLSerializer(); + + const doc = parser.parseFromString(svgContent, "application/xml"); + + doc.documentElement.setAttribute("width", "100%"); + doc.documentElement.setAttribute("height", "100%"); + + const result = serializer.serializeToString(doc.documentElement); + + return result; +} + +function encodeSvg(svgContent: string): string { + return "data:image/svg+xml;utf8," + encodeURIComponent(svgContent); +} diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts index c672d15b5..6d49991e2 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts @@ -2,11 +2,10 @@ import Color from "color"; import { useEffect, useState } from "react"; import { EdgeData } from "../../@types/entities"; import type { GraphProps } from "../../components"; -import colorizeSvg from "../../components/utils/canvas/colorizeSvg"; import { useConfiguration } from "../../core"; import useTextTransform from "../../hooks/useTextTransform"; +import { renderNode } from "./renderNode"; -const ICONS_CACHE: Map = new Map(); const LINE_PATTERN = { solid: undefined, dashed: [5, 6], @@ -28,22 +27,11 @@ const useGraphStyles = () => { continue; } - // To avoid multiple requests, cache icons under the same URL - let iconText = vtConfig.iconUrl - ? ICONS_CACHE.get(vtConfig.iconUrl) - : undefined; - if (vtConfig.iconUrl && !iconText) { - const response = await fetch(vtConfig.iconUrl); - iconText = await response.text(); - ICONS_CACHE.set(vtConfig.iconUrl, iconText); - } + // Process the image data or SVG + const backgroundImage = await renderNode(vtConfig); styles[`node[type="${vt}"]`] = { - "background-image": - iconText && vtConfig.iconImageType === "image/svg+xml" - ? colorizeSvg(iconText, vtConfig.color || "#128EE5") || - "data(__iconUrl)" - : vtConfig.iconUrl, + "background-image": backgroundImage, "background-color": vtConfig.color, "background-opacity": vtConfig.backgroundOpacity, "border-color": vtConfig.borderColor, @@ -51,6 +39,8 @@ const useGraphStyles = () => { "border-opacity": vtConfig.borderWidth ? 1 : 0, "border-style": vtConfig.borderStyle, shape: vtConfig.shape, + width: 24, + height: 24, }; } diff --git a/packages/graph-explorer/src/setupTests.ts b/packages/graph-explorer/src/setupTests.ts index 1dd407a63..cd3698308 100644 --- a/packages/graph-explorer/src/setupTests.ts +++ b/packages/graph-explorer/src/setupTests.ts @@ -1,5 +1,3 @@ -// jest-dom adds custom jest matchers for asserting on DOM nodes. -// allows you to do things like: -// expect(element).toHaveTextContent(/react/i) -// learn more: https://github.com/testing-library/jest-dom -import "@testing-library/jest-dom"; +// Sets up `fetch` for JSDom environment. +// https://github.com/jsdom/jsdom/issues/1724#issuecomment-720727999 +import "whatwg-fetch"; diff --git a/packages/graph-explorer/src/utils/testing/randomData.ts b/packages/graph-explorer/src/utils/testing/randomData.ts index 2e87b9c95..0dc1576eb 100644 --- a/packages/graph-explorer/src/utils/testing/randomData.ts +++ b/packages/graph-explorer/src/utils/testing/randomData.ts @@ -37,6 +37,19 @@ export function createRandomBoolean(): boolean { return Math.random() < 0.5; } +/** + * Randomly creates a hex value for an RGB color. + * @returns The hex string of the random color. + */ +export function createRandomColor(): string { + const letters = "0123456789ABCDEF".split(""); + let color = "#"; + for (let i = 0; i < 6; i++) { + color += letters[Math.round(Math.random() * 15)]; + } + return color; +} + /** * Randomly returns the provided value or undefined. * @returns Either the value or undefined. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6af7f74f0..c5a9d0b09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,8 +238,8 @@ importers: specifier: ^1.8.1 version: 1.8.1(react@17.0.2) uuid: - specifier: ^8.3.2 - version: 8.3.2 + specifier: ^9.0.1 + version: 9.0.1 devDependencies: '@babel/core': specifier: ^7.23.2 @@ -427,6 +427,9 @@ importers: webpack: specifier: ^5.76.0 version: 5.76.0 + whatwg-fetch: + specifier: ^3.6.20 + version: 3.6.20 packages/graph-explorer-proxy-server: dependencies: @@ -5363,7 +5366,7 @@ packages: resolution: {integrity: sha512-pwXDog5nwwvSIzwrvYYmA2Ljcd/ZNlcsSG2Q9CNDBwnsd55UGAyr2doXtB5j+2uymRCnCfExlznzzSFbBRcoCg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: '>=4.5.2' + vite: '>=4.5.3' dependencies: '@babel/core': 7.23.2 '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.23.2) @@ -11314,6 +11317,11 @@ packages: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: false + /v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} dev: true @@ -11457,6 +11465,10 @@ packages: iconv-lite: 0.6.3 dev: true + /whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + dev: true + /whatwg-mimetype@3.0.0: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'}