From c97390caad63aed91eaca595e50d5cdcc97a5f01 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/.vscode/launch.json | 19 ++ 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 | 104 ++++++++++ pnpm-lock.yaml | 25 ++- 9 files changed, 429 insertions(+), 29 deletions(-) create mode 100644 packages/graph-explorer/.vscode/launch.json create mode 100644 packages/graph-explorer/src/modules/GraphViewer/renderNode.test.ts create mode 100644 packages/graph-explorer/src/modules/GraphViewer/renderNode.tsx create mode 100644 packages/graph-explorer/src/utils/testing/randomData.ts diff --git a/packages/graph-explorer/.vscode/launch.json b/packages/graph-explorer/.vscode/launch.json new file mode 100644 index 000000000..89be2edd8 --- /dev/null +++ b/packages/graph-explorer/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Jest current file", + "skipFiles": ["/**"], + "runtimeExecutable": "sh", + "program": "${workspaceFolder}/node_modules/.bin/jest", + "args": ["${relativeFile}", "--coverage=false"], + "console": "integratedTerminal", + "internalConsoleOptions": "openOnFirstSessionStart" + } + ] +} 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 ec8c22b09..e6a3c7ed8 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", @@ -148,7 +148,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 new file mode 100644 index 000000000..9b136fe82 --- /dev/null +++ b/packages/graph-explorer/src/utils/testing/randomData.ts @@ -0,0 +1,104 @@ +import { v4 } from "uuid"; +import { AttributeConfig, EdgeTypeConfig, VertexTypeConfig } from "../../core"; + +/* + +# Developer Note + +These helper functions are provided to allow for easier test data creation. + +When creating test data you should start with a random object, then set the values +that directly apply to the logic you are testing. + +The randomness of all the other values ensures that the logic under test is not +affected by those values, regardless of what they are. + +*/ + +/** + * Creates a random string with a prefix, if provided. + * @param prefix The prefix to prepend to the string. + * @returns A random string that will resemble "prefix-8d49f0". + */ +export function createRandomName(prefix: string = ""): string { + return `${prefix}${prefix.length > 0 ? "-" : ""}${v4().substring(0, 6)}`; +} + +/** + * Creates a random boolean. + * @returns A random boolean value. + */ +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. + */ +export function randomlyUndefined(value: T): T | undefined { + return createRandomBoolean() ? value : undefined; +} + +/** + * Creates an array containing values generated from the factory function with the given length. + * @param length The number of items to generate. + * @param factory A function to generate the desired value. + * @returns An array with items generated using the factory function. + */ +export function createArray(length: number, factory: () => T): T[] { + return Array.from({ length }, factory); +} + +/** + * Creates a random AttributeConfig object. + * @returns A random AttributeConfig object. + */ +export function createRandomAttributeConfig(): AttributeConfig { + return { + name: createRandomName("name"), + displayLabel: createRandomName("displayLabel"), + dataType: randomlyUndefined(createRandomName("dataType")), + hidden: randomlyUndefined(createRandomBoolean()), + searchable: randomlyUndefined(createRandomBoolean()), + }; +} + +/** + * Creates a random EdgeTypeConfig object. + * @returns A random EdgeTypeConfig object. + */ +export function createRandomEdgeTypeConfig(): EdgeTypeConfig { + return { + type: createRandomName("type"), + attributes: createArray(6, createRandomAttributeConfig), + displayLabel: randomlyUndefined(createRandomName("displayLabel")), + hidden: randomlyUndefined(createRandomBoolean()), + }; +} + +/** + * Creates a random VertexTypeConfig object. + * @returns A random VertexTypeConfig object. + */ +export function createRandomVertexTypeConfig(): VertexTypeConfig { + return { + type: createRandomName("type"), + attributes: createArray(6, createRandomAttributeConfig), + displayLabel: randomlyUndefined(createRandomName("displayLabel")), + hidden: randomlyUndefined(createRandomBoolean()), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b28b89eb..495ac06eb 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 @@ -424,6 +424,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: @@ -2391,6 +2394,7 @@ packages: /@emotion/memoize@0.7.4: resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true dev: false optional: true @@ -4690,7 +4694,7 @@ packages: /@swc/helpers@0.4.14: resolution: {integrity: sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==} dependencies: - tslib: 2.6.0 + tslib: 2.6.2 dev: false /@swc/helpers@0.4.36: @@ -5347,7 +5351,7 @@ packages: resolution: {integrity: sha512-pwXDog5nwwvSIzwrvYYmA2Ljcd/ZNlcsSG2Q9CNDBwnsd55UGAyr2doXtB5j+2uymRCnCfExlznzzSFbBRcoCg==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: - vite: ^4.2.0 + vite: '>=4.5.3' dependencies: '@babel/core': 7.23.2 '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.23.2) @@ -6971,7 +6975,7 @@ packages: /esbuild-loader@2.21.0(webpack@5.76.0): resolution: {integrity: sha512-k7ijTkCT43YBSZ6+fBCW1Gin7s46RrJ0VQaM8qA7lq7W+OLsGgtLyFV8470FzYi/4TeDexniTBTPTwZUnXXR5g==} peerDependencies: - webpack: ^4.40.0 || ^5.0.0 + webpack: '>=5.76.0' dependencies: esbuild: 0.16.17 joycon: 3.1.1 @@ -10852,7 +10856,7 @@ packages: '@swc/core': '*' esbuild: '*' uglify-js: '*' - webpack: ^5.1.0 + webpack: '>=5.76.0' peerDependenciesMeta: '@swc/core': optional: true @@ -11298,6 +11302,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 @@ -11441,6 +11450,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'}