diff --git a/Changelog.md b/Changelog.md index 720887c3c..f2d0239c9 100644 --- a/Changelog.md +++ b/Changelog.md @@ -20,7 +20,8 @@ ([#909](https://github.com/aws/graph-explorer/pull/909)) - **Updated** the state management layer to use Jotai instead of Recoil ([#896](https://github.com/aws/graph-explorer/pull/896), - [#920](https://github.com/aws/graph-explorer/pull/920)) + [#920](https://github.com/aws/graph-explorer/pull/920), + [#921](https://github.com/aws/graph-explorer/pull/921)) - **Updated** to use the React Compiler to improve performance and simplify code ([#916](https://github.com/aws/graph-explorer/pull/916)) - **Fixed** issue where a schema sync would not automatically run when a diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts b/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts index 08c240864..4cd642bf6 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts @@ -8,6 +8,7 @@ import { import type { ConfigurationContextProps } from "./types"; import { atomFamily } from "jotai/utils"; import { atom, useAtomValue } from "jotai"; +import { isEqual } from "lodash"; const assembledConfigSelector = atom(get => { const configuration = get(mergedConfigurationSelector); @@ -44,22 +45,25 @@ export const vertexTypeAttributesSelector = atomFamily( ); return attributesByNameMap.values().toArray(); - }) + }), + isEqual ); -export const edgeTypeAttributesSelector = atomFamily((edgeTypes: string[]) => - atom(get => { - const attributesByNameMap = new Map( - edgeTypes - .values() - .map(et => get(edgeTypeConfigSelector(et))) - .filter(et => et != null) - .flatMap(et => et.attributes) - .map(attr => [attr.name, attr]) - ); - - return attributesByNameMap.values().toArray(); - }) +export const edgeTypeAttributesSelector = atomFamily( + (edgeTypes: string[]) => + atom(get => { + const attributesByNameMap = new Map( + edgeTypes + .values() + .map(et => get(edgeTypeConfigSelector(et))) + .filter(et => et != null) + .flatMap(et => et.attributes) + .map(attr => [attr.name, attr]) + ); + + return attributesByNameMap.values().toArray(); + }), + isEqual ); export const vertexTypeConfigSelector = atomFamily((vertexType: string) => @@ -75,16 +79,18 @@ export function useVertexTypeConfig(vertexType: string) { return useAtomValue(vertexTypeConfigSelector(vertexType)); } -const vertexTypeConfigsSelector = atomFamily((vertexTypes?: string[]) => - atom(get => { - const allConfigs = get(allVertexTypeConfigsSelector); - if (!vertexTypes) { - return allConfigs.values().toArray(); - } - return vertexTypes.map( - type => allConfigs.get(type) ?? getDefaultVertexTypeConfig(type) - ); - }) +const vertexTypeConfigsSelector = atomFamily( + (vertexTypes?: string[]) => + atom(get => { + const allConfigs = get(allVertexTypeConfigsSelector); + if (!vertexTypes) { + return allConfigs.values().toArray(); + } + return vertexTypes.map( + type => allConfigs.get(type) ?? getDefaultVertexTypeConfig(type) + ); + }), + isEqual ); /** Gets the matching vertex type configs or the generated default values. */ @@ -105,16 +111,18 @@ export function useEdgeTypeConfig(edgeType: string) { return useAtomValue(edgeTypeConfigSelector(edgeType)); } -const edgeTypeConfigsSelector = atomFamily((edgeTypes?: string[]) => - atom(get => { - const allConfigs = get(allEdgeTypeConfigsSelector); - if (!edgeTypes) { - return allConfigs.values().toArray(); - } - return edgeTypes.map( - type => allConfigs.get(type) ?? getDefaultEdgeTypeConfig(type) - ); - }) +const edgeTypeConfigsSelector = atomFamily( + (edgeTypes?: string[]) => + atom(get => { + const allConfigs = get(allEdgeTypeConfigsSelector); + if (!edgeTypes) { + return allConfigs.values().toArray(); + } + return edgeTypes.map( + type => allConfigs.get(type) ?? getDefaultEdgeTypeConfig(type) + ); + }), + isEqual ); /** Gets the matching edge type configs or the generated default values. */ diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts index b737c517e..413db7e18 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts @@ -1,5 +1,5 @@ import { isEqual, uniq } from "lodash"; -import { atom } from "jotai"; +import { atom, useAtomValue } from "jotai"; import { sanitizeText } from "@/utils"; import DEFAULT_ICON_URL from "@/utils/defaultIconUrl"; import { @@ -205,11 +205,17 @@ export const allVertexTypeConfigsSelector = atom(get => { const configuration = get(mergedConfigurationSelector); return new Map(configuration?.schema?.vertices.map(vt => [vt.type, vt])); }); +export function useAllVertexTypeConfigs() { + return useAtomValue(allVertexTypeConfigsSelector); +} export const allEdgeTypeConfigsSelector = atom(get => { const configuration = get(mergedConfigurationSelector); return new Map(configuration?.schema?.edges.map(et => [et.type, et])); }); +export function useAllEdgeTypeConfigs() { + return useAtomValue(allEdgeTypeConfigsSelector); +} export const vertexTypesSelector = atom(get => { const configuration = get(mergedConfigurationSelector); diff --git a/packages/graph-explorer/src/core/StateProvider/displayEdge.ts b/packages/graph-explorer/src/core/StateProvider/displayEdge.ts index c607a5b85..9b1660092 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayEdge.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayEdge.ts @@ -23,6 +23,7 @@ import { } from "@/utils"; import { atom, useAtomValue } from "jotai"; import { atomFamily } from "jotai/utils"; +import { isEqual } from "lodash"; /** Represents an edge's display information after all transformations have been applied. */ export type DisplayEdge = { @@ -70,101 +71,103 @@ export function useDisplayEdgeFromEdge(edge: Edge) { return useAtomValue(displayEdgeSelector(edge)); } -const displayEdgeSelector = atomFamily((edge: Edge) => - atom(get => { - const textTransform = get(textTransformSelector); - const queryEngine = get(queryEngineSelector); - const isSparql = queryEngine === "sparql"; - - // One type config used for shape, color, icon, etc. - const typeConfig = get(displayEdgeTypeConfigSelector(edge.type)); - - // List all edge types for displaying - const edgeTypes = [edge.type]; - const displayTypes = edgeTypes - .map(type => get(displayEdgeTypeConfigSelector(type)).displayLabel) - .join(", "); - - // For SPARQL, display the edge type as the ID - const rawStringId = String(getRawId(edge.id)); - const displayId = isSparql ? displayTypes : rawStringId; - - const typeAttributes = get(edgeTypeAttributesSelector(edgeTypes)); - const sortedAttributes = getSortedDisplayAttributes( - edge, - typeAttributes, - textTransform - ); - - const sourceRawStringId = String(getRawId(edge.source)); - const targetRawStringId = String(getRawId(edge.target)); - const sourceDisplayId = isSparql - ? textTransform(sourceRawStringId) - : sourceRawStringId; - const targetDisplayId = isSparql - ? textTransform(targetRawStringId) - : targetRawStringId; - - const sourceDisplayTypes = edge.sourceTypes - .map( - type => - get(vertexTypeConfigSelector(type))?.displayLabel || - textTransform(type) - ) - .join(", "); - const targetDisplayTypes = edge.targetTypes - .map( - type => - get(vertexTypeConfigSelector(type))?.displayLabel || - textTransform(type) - ) - .join(", "); - - // Get the display name and description for the edge - function getDisplayAttributeValueByName(name: string | undefined) { - if (name === RESERVED_ID_PROPERTY) { - return displayId; - } else if (name === RESERVED_TYPES_PROPERTY) { - return displayTypes; - } else if (name) { - return ( - sortedAttributes.find(attr => attr.name === name)?.displayValue ?? - MISSING_DISPLAY_VALUE - ); +const displayEdgeSelector = atomFamily( + (edge: Edge) => + atom(get => { + const textTransform = get(textTransformSelector); + const queryEngine = get(queryEngineSelector); + const isSparql = queryEngine === "sparql"; + + // One type config used for shape, color, icon, etc. + const typeConfig = get(displayEdgeTypeConfigSelector(edge.type)); + + // List all edge types for displaying + const edgeTypes = [edge.type]; + const displayTypes = edgeTypes + .map(type => get(displayEdgeTypeConfigSelector(type)).displayLabel) + .join(", "); + + // For SPARQL, display the edge type as the ID + const rawStringId = String(getRawId(edge.id)); + const displayId = isSparql ? displayTypes : rawStringId; + + const typeAttributes = get(edgeTypeAttributesSelector(edgeTypes)); + const sortedAttributes = getSortedDisplayAttributes( + edge, + typeAttributes, + textTransform + ); + + const sourceRawStringId = String(getRawId(edge.source)); + const targetRawStringId = String(getRawId(edge.target)); + const sourceDisplayId = isSparql + ? textTransform(sourceRawStringId) + : sourceRawStringId; + const targetDisplayId = isSparql + ? textTransform(targetRawStringId) + : targetRawStringId; + + const sourceDisplayTypes = edge.sourceTypes + .map( + type => + get(vertexTypeConfigSelector(type))?.displayLabel || + textTransform(type) + ) + .join(", "); + const targetDisplayTypes = edge.targetTypes + .map( + type => + get(vertexTypeConfigSelector(type))?.displayLabel || + textTransform(type) + ) + .join(", "); + + // Get the display name and description for the edge + function getDisplayAttributeValueByName(name: string | undefined) { + if (name === RESERVED_ID_PROPERTY) { + return displayId; + } else if (name === RESERVED_TYPES_PROPERTY) { + return displayTypes; + } else if (name) { + return ( + sortedAttributes.find(attr => attr.name === name)?.displayValue ?? + MISSING_DISPLAY_VALUE + ); + } + + return MISSING_DISPLAY_VALUE; } - return MISSING_DISPLAY_VALUE; - } - - const displayName = getDisplayAttributeValueByName( - typeConfig.displayNameAttribute - ); - - const displayEdge: DisplayEdge = { - entityType: "edge", - id: edge.id, - displayId, - displayName, - displayTypes, - typeConfig, - source: { - id: edge.source, - displayId: sourceDisplayId, - displayTypes: sourceDisplayTypes, - types: edge.sourceTypes, - }, - target: { - id: edge.target, - displayId: targetDisplayId, - displayTypes: targetDisplayTypes, - types: edge.targetTypes, - }, - attributes: sortedAttributes, - // SPARQL does not have unique ID values for predicates, so the UI should hide them - hasUniqueId: isSparql === false, - }; - return displayEdge; - }) + const displayName = getDisplayAttributeValueByName( + typeConfig.displayNameAttribute + ); + + const displayEdge: DisplayEdge = { + entityType: "edge", + id: edge.id, + displayId, + displayName, + displayTypes, + typeConfig, + source: { + id: edge.source, + displayId: sourceDisplayId, + displayTypes: sourceDisplayTypes, + types: edge.sourceTypes, + }, + target: { + id: edge.target, + displayId: targetDisplayId, + displayTypes: targetDisplayTypes, + types: edge.targetTypes, + }, + attributes: sortedAttributes, + // SPARQL does not have unique ID values for predicates, so the UI should hide them + hasUniqueId: isSparql === false, + }; + return displayEdge; + }), + isEqual ); const displayEdgesInCanvasSelector = atom(get => { diff --git a/packages/graph-explorer/src/core/StateProvider/displayVertex.ts b/packages/graph-explorer/src/core/StateProvider/displayVertex.ts index 61102d43b..189da708a 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayVertex.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayVertex.ts @@ -7,7 +7,6 @@ import { DisplayVertexTypeConfig, displayVertexTypeConfigSelector, queryEngineSelector, - nodeSelector, getRawId, Vertex, VertexId, @@ -20,6 +19,7 @@ import { } from "@/utils"; import { atom, useAtomValue } from "jotai"; import { atomFamily } from "jotai/utils"; +import { isEqual } from "lodash"; /** Represents a vertex's display information after all transformations have been applied. */ export type DisplayVertex = { @@ -63,9 +63,10 @@ export function useDisplayVerticesFromVertices(vertices: Vertex[]) { const selectedDisplayVerticesSelector = atom(get => { const selectedIds = get(nodesSelectedIdsAtom); + const nodes = get(nodesAtom); return selectedIds .values() - .map(id => get(nodeSelector(id))) + .map(id => nodes.get(id)) .filter(n => n != null) .map(n => get(displayVertexSelector(n))) .filter(n => n != null) @@ -77,78 +78,82 @@ export function useSelectedDisplayVertices() { return useAtomValue(selectedDisplayVerticesSelector); } -const displayVertexSelector = atomFamily((vertex: Vertex) => - atom(get => { - const textTransform = get(textTransformSelector); - const queryEngine = get(queryEngineSelector); - const isSparql = queryEngine === "sparql"; - - const rawStringId = String(getRawId(vertex.id)); - const displayId = isSparql ? textTransform(rawStringId) : rawStringId; - - // One type config used for shape, color, icon, etc. - const typeConfig = get(displayVertexTypeConfigSelector(vertex.type)); - - // List all vertex types for displaying - const vertexTypes = - vertex.types && vertex.types.length > 0 ? vertex.types : [vertex.type]; - const displayTypes = vertexTypes - .map(type => get(displayVertexTypeConfigSelector(type)).displayLabel) - .join(", "); - - // Map all the attributes for displaying - const typeAttributes = get(vertexTypeAttributesSelector(vertexTypes)); - const sortedAttributes = getSortedDisplayAttributes( - vertex, - typeAttributes, - textTransform - ); - - // Get the display name and description for the vertex - function getDisplayAttributeValueByName(name: string | undefined) { - if (name === RESERVED_ID_PROPERTY) { - return displayId; - } else if (name === RESERVED_TYPES_PROPERTY) { - return displayTypes; - } else if (name) { - return ( - sortedAttributes.find(attr => attr.name === name)?.displayValue ?? - MISSING_DISPLAY_VALUE - ); +const displayVertexSelector = atomFamily( + (vertex: Vertex) => + atom(get => { + const textTransform = get(textTransformSelector); + const queryEngine = get(queryEngineSelector); + const isSparql = queryEngine === "sparql"; + + const rawStringId = String(getRawId(vertex.id)); + const displayId = isSparql ? textTransform(rawStringId) : rawStringId; + + // One type config used for shape, color, icon, etc. + const typeConfig = get(displayVertexTypeConfigSelector(vertex.type)); + + // List all vertex types for displaying + const vertexTypes = + vertex.types && vertex.types.length > 0 ? vertex.types : [vertex.type]; + const displayTypes = vertexTypes + .map(type => get(displayVertexTypeConfigSelector(type)).displayLabel) + .join(", "); + + // Map all the attributes for displaying + const typeAttributes = get(vertexTypeAttributesSelector(vertexTypes)); + const sortedAttributes = getSortedDisplayAttributes( + vertex, + typeAttributes, + textTransform + ); + + // Get the display name and description for the vertex + function getDisplayAttributeValueByName(name: string | undefined) { + if (name === RESERVED_ID_PROPERTY) { + return displayId; + } else if (name === RESERVED_TYPES_PROPERTY) { + return displayTypes; + } else if (name) { + return ( + sortedAttributes.find(attr => attr.name === name)?.displayValue ?? + MISSING_DISPLAY_VALUE + ); + } + + return MISSING_DISPLAY_VALUE; } - return MISSING_DISPLAY_VALUE; - } - - const displayName = getDisplayAttributeValueByName( - typeConfig.displayNameAttribute - ); - const displayDescription = getDisplayAttributeValueByName( - typeConfig.displayDescriptionAttribute - ); - - const result: DisplayVertex = { - entityType: "vertex", - id: vertex.id, - displayId, - displayTypes, - displayName, - displayDescription, - typeConfig, - attributes: sortedAttributes, - isBlankNode: vertex.__isBlank ?? false, - original: vertex, - }; - return result; - }) + const displayName = getDisplayAttributeValueByName( + typeConfig.displayNameAttribute + ); + const displayDescription = getDisplayAttributeValueByName( + typeConfig.displayDescriptionAttribute + ); + + const result: DisplayVertex = { + entityType: "vertex", + id: vertex.id, + displayId, + displayTypes, + displayName, + displayDescription, + typeConfig, + attributes: sortedAttributes, + isBlankNode: vertex.__isBlank ?? false, + original: vertex, + }; + return result; + }), + isEqual ); -const displayVerticesSelector = atomFamily((vertices: Vertex[]) => - atom(get => { - return new Map( - vertices.map(vertex => [vertex.id, get(displayVertexSelector(vertex))]) - ); - }) +const displayVerticesSelector = atomFamily( + (vertices: Vertex[]) => + atom(get => { + return new Map( + vertices.map(vertex => [vertex.id, get(displayVertexSelector(vertex))]) + ); + }), + isEqual ); const displayVerticesInCanvasSelector = atom(get => { diff --git a/packages/graph-explorer/src/core/StateProvider/neighbors.ts b/packages/graph-explorer/src/core/StateProvider/neighbors.ts index 810ee0525..92bc88346 100644 --- a/packages/graph-explorer/src/core/StateProvider/neighbors.ts +++ b/packages/graph-explorer/src/core/StateProvider/neighbors.ts @@ -12,6 +12,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { neighborsCountQuery } from "@/connector"; import { useExplorer } from "../connector"; import { useNotification } from "@/components/NotificationProvider"; +import { isEqual } from "lodash"; export type NeighborCounts = { all: number; @@ -252,8 +253,10 @@ export function useFetchedNeighborsCallback() { ); } -const allFetchedNeighborsSelector = atomFamily((ids: VertexId[]) => - atom(get => { - return new Map(ids.map(id => [id, get(fetchedNeighborsSelector(id))])); - }) +const allFetchedNeighborsSelector = atomFamily( + (ids: VertexId[]) => + atom(get => { + return new Map(ids.map(id => [id, get(fetchedNeighborsSelector(id))])); + }), + isEqual ); diff --git a/packages/graph-explorer/src/core/StateProvider/nodes.ts b/packages/graph-explorer/src/core/StateProvider/nodes.ts index 0dcbf6420..623401a60 100644 --- a/packages/graph-explorer/src/core/StateProvider/nodes.ts +++ b/packages/graph-explorer/src/core/StateProvider/nodes.ts @@ -1,5 +1,5 @@ import { atom, useAtomValue, useSetAtom } from "jotai"; -import { atomFamily, atomWithReset, RESET } from "jotai/utils"; +import { atomWithReset, RESET } from "jotai/utils"; import { createRenderedVertexId, getVertexIdFromRenderedVertexId, @@ -14,10 +14,6 @@ export function toNodeMap(nodes: Vertex[]): Map { export const nodesAtom = atomWithReset(new Map()); -export const nodeSelector = atomFamily((id: VertexId) => - atom(get => get(nodesAtom).get(id) ?? null) -); - export const nodesSelectedIdsAtom = atomWithReset(new Set()); export const nodesSelectedRenderedIdsAtom = atom( diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts new file mode 100644 index 000000000..b67e4e93c --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.test.ts @@ -0,0 +1,118 @@ +import { DbState, renderHookWithJotai } from "@/utils/testing"; +import { useEdgeStyling, useVertexStyling } from "./userPreferences"; +import { act } from "react"; + +describe("useVertexStyling", () => { + it("should return the vertex style if it exists", () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useVertexStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + expect(result.current.vertexStyle).toBeUndefined(); + }); + + it("should insert the vertex style when none exist", async () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useVertexStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + await act(() => result.current.setVertexStyle({ color: "red" })); + + expect(result.current.vertexStyle).toEqual({ type: "test", color: "red" }); + }); + + it("should update the existing style, merging new styles", async () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useVertexStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + await act(() => + result.current.setVertexStyle({ color: "red", borderColor: "green" }) + ); + await act(() => result.current.setVertexStyle({ borderColor: "blue" })); + + expect(result.current.vertexStyle).toEqual({ + type: "test", + color: "red", + borderColor: "blue", + }); + }); + + it("should reset the vertex style", async () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useVertexStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + await act(() => result.current.setVertexStyle({ borderColor: "blue" })); + await act(() => result.current.resetVertexStyle()); + + expect(result.current.vertexStyle).toBeUndefined(); + }); +}); + +describe("useEdgeStyling", () => { + it("should return the edge style if it exists", () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useEdgeStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + expect(result.current.edgeStyle).toBeUndefined(); + }); + + it("should insert the edge style when none exist", async () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useEdgeStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + await act(() => result.current.setEdgeStyle({ lineColor: "red" })); + + expect(result.current.edgeStyle).toEqual({ + type: "test", + lineColor: "red", + }); + }); + + it("should update the existing style, merging new styles", async () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useEdgeStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + await act(() => + result.current.setEdgeStyle({ lineColor: "red", labelColor: "green" }) + ); + await act(() => result.current.setEdgeStyle({ labelColor: "blue" })); + + expect(result.current.edgeStyle).toEqual({ + type: "test", + lineColor: "red", + labelColor: "blue", + }); + }); + + it("should reset the edge style", async () => { + const dbState = new DbState(); + const { result } = renderHookWithJotai( + () => useEdgeStyling("test"), + snapshot => dbState.applyTo(snapshot) + ); + + await act(() => result.current.setEdgeStyle({ labelColor: "blue" })); + await act(() => result.current.resetEdgeStyle()); + + expect(result.current.edgeStyle).toBeUndefined(); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts index 145dbbf16..c17865e39 100644 --- a/packages/graph-explorer/src/core/StateProvider/userPreferences.ts +++ b/packages/graph-explorer/src/core/StateProvider/userPreferences.ts @@ -1,7 +1,6 @@ import { atomWithLocalForage } from "./localForageEffect"; -import { atomFamily, RESET } from "jotai/utils"; -import { atom, useSetAtom } from "jotai"; -import { SetStateActionWithReset } from "@/utils/jotai"; +import { useAtom, useSetAtom } from "jotai"; +import { clone } from "lodash"; export type ShapeStyle = | "rectangle" @@ -123,93 +122,117 @@ export const userStylingAtom = atomWithLocalForage( "user-styling" ); -export const userStylingNodeAtom = atomFamily((nodeType: string) => - atom( - get => { - return get(userStylingAtom).vertices?.find( - node => node.type === nodeType - ); - }, +type UpdatedVertexStyle = Omit; - async ( - _get, - set, - update: SetStateActionWithReset - ) => { - await set(userStylingAtom, async prevUserStyling => { - let newNodes = Array.from((await prevUserStyling).vertices ?? []); - const existingIndex = newNodes.findIndex( - node => node.type === nodeType - ); - const prev = existingIndex !== -1 ? newNodes[existingIndex] : undefined; - - const newValue = typeof update === "function" ? update(prev) : update; - - if (newValue === RESET || !newValue) { - // Remove the entry from user styles - newNodes = newNodes.filter(node => node.type !== nodeType); - } else if (existingIndex === -1) { - // Add it because it doesn't exist - newNodes.push(newValue); - } else { - // Replace the existing entry - newNodes[existingIndex] = { - ...newNodes[existingIndex], - ...newValue, - }; - } - - return { - ...prev, - vertices: newNodes, - }; - }); - } - ) -); +/** + * Provides the necessary functions for managing vertex styles. + * + * @param type The vertex type + * @returns The vertex style if it exists, an update function, and a reset function + */ +export function useVertexStyling(type: string) { + const [allStyling, setAllStyling] = useAtom(userStylingAtom); -export const userStylingEdgeAtom = atomFamily((edgeType: string) => - atom( - get => { - return get(userStylingAtom).edges?.find(edge => edge.type === edgeType); - }, - async ( - _get, - set, - update: SetStateActionWithReset - ) => { - await set(userStylingAtom, async prev => { - let newEdges = Array.from((await prev).edges ?? []); - const existingIndex = newEdges.findIndex( - edge => edge.type === edgeType - ); - const prevValue = - existingIndex !== -1 ? newEdges[existingIndex] : undefined; - const newValue = - typeof update === "function" ? update(prevValue) : update; - - if (newValue === RESET || !newValue) { - // Remove the entry from user styles - newEdges = newEdges.filter(edge => edge.type !== edgeType); - } else if (existingIndex === -1) { - // Add it because it doesn't exist - newEdges.push(newValue); - } else { - // Replace the existing entry - newEdges[existingIndex] = { - ...newEdges[existingIndex], - ...newValue, - }; - } - - return { - ...prev, - edges: newEdges, - }; - }); - } - ) -); + const vertexStyle = allStyling.vertices?.find(v => v.type === type); + + const setVertexStyle = async (updatedStyle: UpdatedVertexStyle) => { + await setAllStyling(async prevPromise => { + // Shallow clone so React re-renders properly + const prev = clone(await prevPromise); + + const hasEntry = prev.vertices?.some(v => v.type === type); + if (hasEntry) { + // Update the existing entry, merging the updates with the existing style + prev.vertices = prev.vertices?.map(existing => { + if (existing.type === type) { + return { + ...existing, + ...updatedStyle, + }; + } + return existing; + }); + } else { + // Add the new entry + prev.vertices = (prev.vertices ?? []).concat({ + type, + ...updatedStyle, + }); + } + + return prev; + }); + }; + + const resetVertexStyle = async () => + await setAllStyling(async prevPromise => { + const prev = clone(await prevPromise); + prev.vertices = prev.vertices?.filter(v => v.type !== type); + return prev; + }); + + return { + vertexStyle, + setVertexStyle, + resetVertexStyle, + }; +} + +type UpdatedEdgeStyle = Omit; + +/** + * Provides the necessary functions for managing edge styles. + * + * @param type The edge type + * @returns The edge style if it exists, an update function, and a reset function + */ +export function useEdgeStyling(type: string) { + const [allStyling, setAllStyling] = useAtom(userStylingAtom); + + const edgeStyle = allStyling.edges?.find(v => v.type === type); + + const setEdgeStyle = async (updatedStyle: UpdatedEdgeStyle) => { + await setAllStyling(async prevPromise => { + // Shallow clone so React re-renders properly + const prev = clone(await prevPromise); + + const hasEntry = prev.edges?.some(v => v.type === type); + if (hasEntry) { + // Update the existing entry, merging the updates with the existing style + prev.edges = prev.edges?.map(existing => { + if (existing.type === type) { + return { + ...existing, + ...updatedStyle, + }; + } + return existing; + }); + } else { + // Add the new entry + prev.edges = (prev.edges ?? []).concat({ + type, + ...updatedStyle, + }); + } + + return prev; + }); + }; + + const resetEdgeStyle = async () => + await setAllStyling(async prevPromise => { + const prev = clone(await prevPromise); + prev.edges = prev.edges?.filter(v => v.type !== type); + return prev; + }); + + return { + edgeStyle, + setEdgeStyle, + resetEdgeStyle, + }; +} export const userLayoutAtom = atomWithLocalForage( { diff --git a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx index f49232792..d34cf0894 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/EdgeStyleDialog.tsx @@ -4,9 +4,8 @@ import ColorInput from "@/components/ColorInput/ColorInput"; import { useDisplayEdgeTypeConfig, useWithTheme } from "@/core"; import { ArrowStyle, - EdgePreferences, LineStyle, - userStylingEdgeAtom, + useEdgeStyling, } from "@/core/StateProvider/userPreferences"; import useTranslations from "@/hooks/useTranslations"; import { @@ -17,7 +16,6 @@ import { LINE_STYLE_OPTIONS } from "./lineStyling"; import modalDefaultStyles from "./SingleEdgeStylingModal.style"; import { RESERVED_TYPES_PROPERTY } from "@/utils"; import { atom, useAtom } from "jotai"; -import { useResetAtom } from "jotai/utils"; export const customizeEdgeTypeAtom = atom(undefined); @@ -60,9 +58,7 @@ function Content({ edgeType }: { edgeType: string }) { const displayConfig = useDisplayEdgeTypeConfig(edgeType); const t = useTranslations(); - const [edgePreferences, setEdgePreferences] = useAtom( - userStylingEdgeAtom(edgeType) - ); + const { edgeStyle, setEdgeStyle, resetEdgeStyle } = useEdgeStyling(edgeType); const selectOptions = (() => { const options = displayConfig.attributes.map(attr => ({ @@ -78,12 +74,6 @@ function Content({ edgeType }: { edgeType: string }) { return options; })(); - const onUserPrefsChange = (prefs: Omit) => { - setEdgePreferences({ type: edgeType, ...prefs }); - }; - - const onUserPrefsReset = useResetAtom(userStylingEdgeAtom(edgeType)); - return (
@@ -94,7 +84,7 @@ function Content({ edgeType }: { edgeType: string }) { labelPlacement="inner" value={displayConfig.displayNameAttribute} onValueChange={value => - onUserPrefsChange({ displayNameAttribute: value }) + setEdgeStyle({ displayNameAttribute: value }) } options={selectOptions} /> @@ -106,10 +96,8 @@ function Content({ edgeType }: { edgeType: string }) { - onUserPrefsChange({ labelColor: color }) - } + startColor={edgeStyle?.labelColor || "#17457b"} + onChange={(color: string) => setEdgeStyle({ labelColor: color })} /> - onUserPrefsChange({ labelBackgroundOpacity: value }) + setEdgeStyle({ labelBackgroundOpacity: value }) } />
@@ -130,9 +118,9 @@ function Content({ edgeType }: { edgeType: string }) { - onUserPrefsChange({ labelBorderColor: color }) + setEdgeStyle({ labelBorderColor: color }) } /> - onUserPrefsChange({ labelBorderWidth: value }) + setEdgeStyle({ labelBorderWidth: value }) } /> - onUserPrefsChange({ labelBorderStyle: value as LineStyle }) + setEdgeStyle({ labelBorderStyle: value as LineStyle }) } options={LINE_STYLE_OPTIONS} /> @@ -162,27 +150,23 @@ function Content({ edgeType }: { edgeType: string }) { - onUserPrefsChange({ lineColor: color }) - } + startColor={edgeStyle?.lineColor || "#b3b3b3"} + onChange={(color: string) => setEdgeStyle({ lineColor: color })} /> - onUserPrefsChange({ lineThickness: value }) - } + value={edgeStyle?.lineThickness || 2} + onChange={(value: number) => setEdgeStyle({ lineThickness: value })} /> - onUserPrefsChange({ lineStyle: value as LineStyle }) + setEdgeStyle({ lineStyle: value as LineStyle }) } options={LINE_STYLE_OPTIONS} /> @@ -194,25 +178,25 @@ function Content({ edgeType }: { edgeType: string }) { - onUserPrefsChange({ sourceArrowStyle: value as ArrowStyle }) + setEdgeStyle({ sourceArrowStyle: value as ArrowStyle }) } options={SOURCE_ARROW_STYLE_OPTIONS} /> - onUserPrefsChange({ targetArrowStyle: value as ArrowStyle }) + setEdgeStyle({ targetArrowStyle: value as ArrowStyle }) } options={TARGET_ARROW_STYLE_OPTIONS} />
- +
); diff --git a/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx b/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx index 8e498bbbd..08e6bd61e 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx @@ -1,10 +1,7 @@ -import { ComponentPropsWithRef, useCallback, useEffect, useState } from "react"; +import { ComponentPropsWithRef, useEffect, useState } from "react"; import { Button, FormItem, InputField, Label, StylingIcon } from "@/components"; import { useDisplayEdgeTypeConfig } from "@/core"; -import { - EdgePreferences, - userStylingEdgeAtom, -} from "@/core/StateProvider/userPreferences"; +import { useEdgeStyling } from "@/core/StateProvider/userPreferences"; import { useDebounceValue, usePrevious } from "@/hooks"; import { MISSING_DISPLAY_TYPE } from "@/utils"; import { customizeEdgeTypeAtom } from "./EdgeStyleDialog"; @@ -16,22 +13,14 @@ export type SingleEdgeStylingProps = { export default function SingleEdgeStyling({ edgeType, - ...rest }: SingleEdgeStylingProps) { - const setEdgePreferences = useSetAtom(userStylingEdgeAtom(edgeType)); + const { setEdgeStyle } = useEdgeStyling(edgeType); const setCustomizeEdgeType = useSetAtom(customizeEdgeTypeAtom); const displayConfig = useDisplayEdgeTypeConfig(edgeType); const [displayAs, setDisplayAs] = useState(displayConfig.displayLabel); - const onUserPrefsChange = useCallback( - (prefs: Omit) => { - setEdgePreferences({ type: edgeType, ...prefs }); - }, - [edgeType, setEdgePreferences] - ); - // Delayed update of display name to prevent input lag const debouncedDisplayAs = useDebounceValue(displayAs, 400); const prevDisplayAs = usePrevious(debouncedDisplayAs); @@ -40,8 +29,8 @@ export default function SingleEdgeStyling({ if (prevDisplayAs === null || prevDisplayAs === debouncedDisplayAs) { return; } - onUserPrefsChange({ displayLabel: debouncedDisplayAs }); - }, [debouncedDisplayAs, prevDisplayAs, onUserPrefsChange]); + void setEdgeStyle({ displayLabel: debouncedDisplayAs }); + }, [debouncedDisplayAs, prevDisplayAs, setEdgeStyle]); return ( diff --git a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts index 53130c399..539553470 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/useGraphStyles.ts @@ -3,15 +3,13 @@ import { useEffect, useState } from "react"; import { getEdgeIdFromRenderedEdgeId, RenderedEdgeId, + useAllEdgeTypeConfigs, + useAllVertexTypeConfigs, useDisplayEdgesInCanvas, } from "@/core"; import type { GraphProps } from "@/components"; import useTextTransform from "@/hooks/useTextTransform"; import { renderNode } from "./renderNode"; -import { - useEdgeTypeConfigs, - useVertexTypeConfigs, -} from "@/core/ConfigurationProvider/useConfiguration"; import { MISSING_DISPLAY_VALUE } from "@/utils/constants"; const LINE_PATTERN = { @@ -21,8 +19,8 @@ const LINE_PATTERN = { }; const useGraphStyles = () => { - const vtConfigs = useVertexTypeConfigs(); - const etConfigs = useEdgeTypeConfigs(); + const vtConfigs = useAllVertexTypeConfigs(); + const etConfigs = useAllEdgeTypeConfigs(); const textTransform = useTextTransform(); const [styles, setStyles] = useState({}); const displayEdges = useDisplayEdgesInCanvas(); @@ -31,7 +29,7 @@ const useGraphStyles = () => { (async () => { const styles: GraphProps["styles"] = {}; - for (const vtConfig of vtConfigs) { + for (const vtConfig of vtConfigs.values()) { const vt = vtConfig.type; // Process the image data or SVG @@ -51,7 +49,7 @@ const useGraphStyles = () => { }; } - for (const etConfig of etConfigs) { + for (const etConfig of etConfigs.values()) { const et = etConfig?.type; let label = textTransform(et); diff --git a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx index 890ee7793..b9cf806ae 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/NodeStyleDialog.tsx @@ -13,8 +13,7 @@ import { useDisplayVertexTypeConfig, useWithTheme } from "@/core"; import { LineStyle, ShapeStyle, - userStylingNodeAtom, - VertexPreferences, + useVertexStyling, } from "@/core/StateProvider/userPreferences"; import useTranslations from "@/hooks/useTranslations"; import { LINE_STYLE_OPTIONS } from "./lineStyling"; @@ -25,7 +24,6 @@ import { RESERVED_TYPES_PROPERTY, } from "@/utils/constants"; import { atom, useAtom } from "jotai"; -import { useResetAtom } from "jotai/utils"; export const customizeNodeTypeAtom = atom(undefined); @@ -79,9 +77,8 @@ function DialogTitle({ vertexType }: { vertexType: string }) { function Content({ vertexType }: { vertexType: string }) { const t = useTranslations(); - const [nodePreferences, setNodePreferences] = useAtom( - userStylingNodeAtom(vertexType) - ); + const { vertexStyle, setVertexStyle, resetVertexStyle } = + useVertexStyling(vertexType); const displayConfig = useDisplayVertexTypeConfig(vertexType); const selectOptions = (() => { @@ -102,12 +99,6 @@ function Content({ vertexType }: { vertexType: string }) { return options; })(); - const onUserPrefsChange = (prefs: Omit) => { - setNodePreferences({ type: vertexType, ...prefs }); - }; - - const reset = useResetAtom(userStylingNodeAtom(vertexType)); - const { enqueueNotification } = useNotification(); const convertImageToBase64AndSetNewIcon = async (file: File) => { if (file.size > 50 * 1024) { @@ -120,7 +111,7 @@ function Content({ vertexType }: { vertexType: string }) { } try { const result = await file2Base64(file); - onUserPrefsChange({ iconUrl: result, iconImageType: file.type }); + await setVertexStyle({ iconUrl: result, iconImageType: file.type }); } catch (error) { console.error("Unable to convert uploaded image to base64: ", error); } @@ -135,8 +126,8 @@ function Content({ vertexType }: { vertexType: string }) { label="Display Name Attribute" labelPlacement="inner" value={displayConfig.displayNameAttribute} - onValueChange={value => { - onUserPrefsChange({ displayNameAttribute: value }); + onValueChange={async value => { + await setVertexStyle({ displayNameAttribute: value }); }} options={selectOptions} /> @@ -144,8 +135,8 @@ function Content({ vertexType }: { vertexType: string }) { label="Display Description Attribute" labelPlacement="inner" value={displayConfig.displayDescriptionAttribute} - onValueChange={value => { - onUserPrefsChange({ + onValueChange={async value => { + await setVertexStyle({ longDisplayNameAttribute: value, }); }} @@ -159,9 +150,9 @@ function Content({ vertexType }: { vertexType: string }) { - onUserPrefsChange({ shape: value as ShapeStyle }) + value={vertexStyle?.shape || "ellipse"} + onValueChange={async value => + await setVertexStyle({ shape: value as ShapeStyle }) } options={NODE_SHAPE} className="grow" @@ -200,8 +191,8 @@ function Content({ vertexType }: { vertexType: string }) { onUserPrefsChange({ color })} + startColor={vertexStyle?.color || "#17457b"} + onChange={async (color: string) => await setVertexStyle({ color })} /> - onUserPrefsChange({ backgroundOpacity: value }) + value={vertexStyle?.backgroundOpacity ?? 0.4} + onChange={async (value: number) => + await setVertexStyle({ backgroundOpacity: value }) } /> @@ -222,9 +213,9 @@ function Content({ vertexType }: { vertexType: string }) { - onUserPrefsChange({ borderColor: color }) + startColor={vertexStyle?.borderColor || "#17457b"} + onChange={async (color: string) => + await setVertexStyle({ borderColor: color }) } /> - onUserPrefsChange({ borderWidth: value }) + value={vertexStyle?.borderWidth ?? 0} + onChange={async (value: number) => + await setVertexStyle({ borderWidth: value }) } /> - onUserPrefsChange({ borderStyle: value as LineStyle }) + value={vertexStyle?.borderStyle || "solid"} + onValueChange={async value => + await setVertexStyle({ borderStyle: value as LineStyle }) } options={LINE_STYLE_OPTIONS} />
- +
); diff --git a/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx b/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx index f7297be67..7b055af43 100644 --- a/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx +++ b/packages/graph-explorer/src/modules/NodesStyling/SingleNodeStyling.tsx @@ -1,10 +1,7 @@ -import { ComponentPropsWithRef, useCallback, useEffect, useState } from "react"; +import { ComponentPropsWithRef, useEffect, useState } from "react"; import { Button, FormItem, InputField, Label, StylingIcon } from "@/components"; import { useDisplayVertexTypeConfig } from "@/core"; -import { - userStylingNodeAtom, - VertexPreferences, -} from "@/core/StateProvider/userPreferences"; +import { useVertexStyling } from "@/core/StateProvider/userPreferences"; import { useDebounceValue, usePrevious } from "@/hooks"; import { MISSING_DISPLAY_TYPE } from "@/utils/constants"; import { customizeNodeTypeAtom } from "./NodeStyleDialog"; @@ -16,23 +13,15 @@ export type SingleNodeStylingProps = { export default function SingleNodeStyling({ vertexType, - ...rest }: SingleNodeStylingProps) { - const setNodePreferences = useSetAtom(userStylingNodeAtom(vertexType)); + const { setVertexStyle } = useVertexStyling(vertexType); const displayConfig = useDisplayVertexTypeConfig(vertexType); const [displayAs, setDisplayAs] = useState(displayConfig.displayLabel); const setCustomizeNodeType = useSetAtom(customizeNodeTypeAtom); - const onUserPrefsChange = useCallback( - (prefs: Omit) => { - setNodePreferences({ type: vertexType, ...prefs }); - }, - [setNodePreferences, vertexType] - ); - // Delayed update of display name to prevent input lag const debouncedDisplayAs = useDebounceValue(displayAs, 400); const prevDisplayAs = usePrevious(debouncedDisplayAs); @@ -41,8 +30,8 @@ export default function SingleNodeStyling({ if (prevDisplayAs === null || prevDisplayAs === debouncedDisplayAs) { return; } - onUserPrefsChange({ displayLabel: debouncedDisplayAs }); - }, [debouncedDisplayAs, prevDisplayAs, onUserPrefsChange]); + void setVertexStyle({ displayLabel: debouncedDisplayAs }); + }, [debouncedDisplayAs, prevDisplayAs, setVertexStyle]); return ( @@ -63,6 +52,7 @@ export default function SingleNodeStyling({