diff --git a/Changelog.md b/Changelog.md index 2a87dc20d..459ecc2b0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,10 @@ # graph-explorer Change Log +## Release 1.12.1 + +- **Fixed** issue where the edge's display name value was not being displayed + properly ([#716](https://github.com/aws/graph-explorer/pull/716)) + ## Release 1.12.0 This release is mostly a maintenance release, with a few new features and bug diff --git a/package.json b/package.json index 5f20a1882..3eacdf4e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-explorer", - "version": "1.12.0", + "version": "1.12.1", "description": "Graph Explorer", "author": "amazon", "license": "Apache-2.0", diff --git a/packages/graph-explorer-proxy-server/package.json b/packages/graph-explorer-proxy-server/package.json index 277b601e7..4019f8f6d 100644 --- a/packages/graph-explorer-proxy-server/package.json +++ b/packages/graph-explorer-proxy-server/package.json @@ -1,6 +1,6 @@ { "name": "graph-explorer-proxy-server", - "version": "1.12.0", + "version": "1.12.1", "description": "Server to facilitate communication between the browser and the supported graph database.", "main": "dist/node-server.js", "type": "module", diff --git a/packages/graph-explorer/package.json b/packages/graph-explorer/package.json index 063f39ae1..18ea8425d 100644 --- a/packages/graph-explorer/package.json +++ b/packages/graph-explorer/package.json @@ -1,6 +1,6 @@ { "name": "graph-explorer", - "version": "1.12.0", + "version": "1.12.1", "description": "Graph Explorer", "engines": { "node": ">=22.11.0" diff --git a/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts b/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts index 2202590be..c8bfdbfb9 100644 --- a/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts +++ b/packages/graph-explorer/src/core/ConfigurationProvider/useConfiguration.ts @@ -54,6 +54,24 @@ export const vertexTypeAttributesSelector = selectorFamily({ }, }); +export const edgeTypeAttributesSelector = selectorFamily({ + key: "edge-type-attributes", + get: + (edgeTypes: string[]) => + ({ 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 vertexTypeConfigSelector = selectorFamily({ key: "vertex-type-config", get: diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts index cf0d044de..525fa04c9 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.test.ts @@ -15,6 +15,7 @@ import { RawConfiguration, VertexTypeConfig } from "../ConfigurationProvider"; import { SchemaInference } from "./schema"; import { UserStyling } from "./userPreferences"; import { createRandomName } from "@shared/utils/testing"; +import { RESERVED_TYPES_PROPERTY } from "@/utils"; describe("mergedConfiguration", () => { it("should produce empty defaults when empty object is passed", () => { @@ -231,6 +232,32 @@ describe("mergedConfiguration", () => { expect(actualEtConfig?.displayLabel).toEqual(customDisplayLabel); }); + + it("should patch displayNameAttribute to be 'types' when it was 'type'", () => { + const etConfig = createRandomEdgeTypeConfig(); + + const config: RawConfiguration = createRandomRawConfiguration(); + const styling: UserStyling = { + edges: [ + { + type: etConfig.type, + displayNameAttribute: "type", + }, + ], + }; + const schema = createRandomSchema(); + schema.edges = [etConfig]; + + const result = mergeConfiguration(schema, config, styling); + + const actualEtConfig = result.schema?.edges.find( + e => e.type === etConfig.type + ); + + expect(actualEtConfig?.displayNameAttribute).toEqual( + RESERVED_TYPES_PROPERTY + ); + }); }); /** Sorts the configs by type name */ diff --git a/packages/graph-explorer/src/core/StateProvider/configuration.ts b/packages/graph-explorer/src/core/StateProvider/configuration.ts index 896b2a713..9e279d0ab 100644 --- a/packages/graph-explorer/src/core/StateProvider/configuration.ts +++ b/packages/graph-explorer/src/core/StateProvider/configuration.ts @@ -198,7 +198,7 @@ const mergeEdge = ( const et = preferences?.type || configEdge?.type || schemaEdge?.type || "unknown"; - return { + const config: EdgeTypeConfig = { // Defaults ...getDefaultEdgeTypeConfig(et), // Automatic schema override @@ -209,6 +209,15 @@ const mergeEdge = ( ...(preferences || {}), attributes, }; + + if (config.displayNameAttribute === "type") { + // Patch displayNameAttribute to be "types" when it was "type" ensuring + // backwards compatibility if the user had customized the + // displayNameAttribute to be the edge type prior to this release. + config.displayNameAttribute = RESERVED_TYPES_PROPERTY; + } + + return config; }; export const allVertexTypeConfigsSelector = selector({ @@ -267,6 +276,7 @@ export const defaultEdgeTypeConfig = { targetArrowStyle: "triangle", lineStyle: "solid", lineColor: "#b3b3b3", + displayNameAttribute: RESERVED_TYPES_PROPERTY, } satisfies Omit; export function getDefaultEdgeTypeConfig(edgeType: string): EdgeTypeConfig { diff --git a/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts b/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts new file mode 100644 index 000000000..48aa01e31 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/displayEdge.test.ts @@ -0,0 +1,241 @@ +import { Edge } from "@/@types/entities"; +import { + createRandomEdge, + createRandomEdgeTypeConfig, + createRandomRawConfiguration, + createRandomSchema, + createRandomVertex, + renderHookWithRecoilRoot, +} from "@/utils/testing"; +import { useDisplayEdgeFromEdge } from "./displayEdge"; +import { formatDate, sanitizeText } from "@/utils"; +import { createRandomDate } from "@shared/utils/testing"; +import { DisplayAttribute } from "./displayAttribute"; +import { mapToDisplayEdgeTypeConfig } from "./displayTypeConfigs"; +import { + activeConfigurationAtom, + configurationAtom, + getDefaultEdgeTypeConfig, +} from "./configuration"; +import { Schema } from "../ConfigurationProvider"; +import { MutableSnapshot } from "recoil"; +import { schemaAtom } from "./schema"; +import { ConnectionConfig } from "@shared/types"; + +describe("useDisplayEdgeFromEdge", () => { + it("should keep the same ID", () => { + const edge = createEdge(); + expect(act(edge).id).toEqual(edge.id); + }); + + it("should be an edge", () => { + const edge = createEdge(); + expect(act(edge).entityType).toEqual("edge"); + }); + + it("should have a display ID equal to the edge ID", () => { + const edge = createEdge(); + expect(act(edge).displayId).toEqual(edge.id); + }); + + it("should have the display name be the types", () => { + const edge = createEdge(); + expect(act(edge).displayName).toEqual(sanitizeText(edge.type)); + }); + + it("should have display name that matches the attribute value", () => { + const edge = createEdge(); + const schema = createRandomSchema(); + // Get the first attribute + const attribute = Object.entries(edge.attributes).map(([name, value]) => ({ + name, + value, + }))[0]; + + const etConfig = createRandomEdgeTypeConfig(); + etConfig.type = edge.type; + etConfig.displayNameAttribute = attribute.name; + schema.edges.push(etConfig); + + expect( + act(edge, withSchemaAndConnection(schema, "gremlin")).displayName + ).toEqual(`${attribute.value}`); + }); + + it("should have display name that matches the types when displayNameAttribute is 'type'", () => { + const edge = createEdge(); + const schema = createRandomSchema(); + + const etConfig = createRandomEdgeTypeConfig(); + delete etConfig.displayLabel; + etConfig.type = edge.type; + etConfig.displayNameAttribute = "type"; + schema.edges.push(etConfig); + + expect( + act(edge, withSchemaAndConnection(schema, "gremlin")).displayName + ).toEqual(sanitizeText(edge.type)); + }); + + it("should have the default type config when edge type is not in the schema", () => { + const edge = createEdge(); + const etConfig = getDefaultEdgeTypeConfig(edge.type); + const displayConfig = mapToDisplayEdgeTypeConfig(etConfig); + expect(act(edge).typeConfig).toEqual(displayConfig); + }); + + it("should use the type config from the merged schema", () => { + const edge = createEdge(); + const etConfig = createRandomEdgeTypeConfig(); + etConfig.type = edge.type; + const schema = createRandomSchema(); + schema.edges.push(etConfig); + + const expectedTypeConfig = mapToDisplayEdgeTypeConfig(etConfig); + + expect( + act(edge, withSchemaAndConnection(schema, "gremlin")).typeConfig + ).toEqual(expectedTypeConfig); + }); + + it("should have display types that list all types in gremlin", () => { + const edge = createEdge(); + const schema = createRandomSchema(); + + const etConfig = createRandomEdgeTypeConfig(); + delete etConfig.displayLabel; + etConfig.type = edge.type; + schema.edges.push(etConfig); + + edge.type = etConfig.type; + + expect( + act(edge, withSchemaAndConnection(schema, "gremlin")).displayTypes + ).toEqual(`${sanitizeText(etConfig.type)}`); + }); + + it("should have display types that list all types in sparql", () => { + const edge = createEdge(); + edge.type = "http://www.example.com/class#bar"; + const schema = createRandomSchema(); + schema.prefixes = [ + { + prefix: "example-class", + uri: "http://www.example.com/class#", + }, + ]; + + const etConfig = createRandomEdgeTypeConfig(); + delete etConfig.displayLabel; + etConfig.type = edge.type; + schema.edges.push(etConfig); + + edge.type = etConfig.type; + + expect( + act(edge, withSchemaAndConnection(schema, "sparql")).displayTypes + ).toEqual(`example-class:bar`); + }); + + it("should have sorted attributes", () => { + const edge = createEdge(); + const attributes: DisplayAttribute[] = Object.entries(edge.attributes) + .map(([key, value]) => ({ + name: key, + displayLabel: sanitizeText(key), + displayValue: String(value), + })) + .toSorted((a, b) => a.displayLabel.localeCompare(b.displayLabel)); + + expect(act(edge).attributes).toEqual(attributes); + }); + + it("should format date values in attribute when type is Date", () => { + const edge = createEdge(); + const schema = createRandomSchema(); + const etConfig = createRandomEdgeTypeConfig(); + etConfig.type = edge.type; + etConfig.attributes.push({ + name: "created", + displayLabel: sanitizeText("created"), + dataType: "Date", + }); + schema.edges.push(etConfig); + + edge.attributes = { + ...edge.attributes, + created: createRandomDate().toISOString(), + }; + + const actualAttribute = act(edge, withSchema(schema)).attributes.find( + attr => attr.name === "created" + ); + expect(actualAttribute?.displayValue).toEqual( + formatDate(new Date(edge.attributes.created)) + ); + }); + + it("should format date values in attribute when type is g:Date", () => { + const edge = createEdge(); + const schema = createRandomSchema(); + const etConfig = createRandomEdgeTypeConfig(); + etConfig.type = edge.type; + etConfig.attributes.push({ + name: "created", + displayLabel: sanitizeText("created"), + dataType: "g:Date", + }); + schema.edges.push(etConfig); + + edge.attributes = { + ...edge.attributes, + created: createRandomDate().toISOString(), + }; + + const actualAttribute = act(edge, withSchema(schema)).attributes.find( + attr => attr.name === "created" + ); + expect(actualAttribute?.displayValue).toEqual( + formatDate(new Date(edge.attributes.created)) + ); + }); + + // Helpers + + function createEdge() { + return createRandomEdge(createRandomVertex(), createRandomVertex()); + } + + function act( + edge: Edge, + initializeState?: (mutableSnapshot: MutableSnapshot) => void + ) { + const { result } = renderHookWithRecoilRoot( + () => useDisplayEdgeFromEdge(edge), + initializeState + ); + return result.current; + } + + function withSchema(schema: Schema) { + const config = createRandomRawConfiguration(); + return (snapshot: MutableSnapshot) => { + snapshot.set(configurationAtom, new Map([[config.id, config]])); + snapshot.set(schemaAtom, new Map([[config.id, schema]])); + snapshot.set(activeConfigurationAtom, config.id); + }; + } + + function withSchemaAndConnection( + schema: Schema, + queryEngine: ConnectionConfig["queryEngine"] + ) { + const config = createRandomRawConfiguration(); + config.connection!.queryEngine = queryEngine; + return (snapshot: MutableSnapshot) => { + snapshot.set(configurationAtom, new Map([[config.id, config]])); + snapshot.set(schemaAtom, new Map([[config.id, schema]])); + snapshot.set(activeConfigurationAtom, config.id); + }; + } +}); diff --git a/packages/graph-explorer/src/core/StateProvider/displayEdge.ts b/packages/graph-explorer/src/core/StateProvider/displayEdge.ts index ccdb9fb62..d494dc750 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayEdge.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayEdge.ts @@ -8,10 +8,10 @@ import { getSortedDisplayAttributes, edgesAtom, edgesSelectedIdsAtom, - vertexTypeAttributesSelector, vertexTypeConfigSelector, queryEngineSelector, edgeSelector, + edgeTypeAttributesSelector, } from "@/core"; import { MISSING_DISPLAY_VALUE, @@ -63,6 +63,10 @@ export function useSelectedDisplayEdges() { return useRecoilValue(selectedDisplayEdgesSelector); } +export function useDisplayEdgeFromEdge(edge: Edge) { + return useRecoilValue(displayEdgeSelector(edge)); +} + const displayEdgeSelector = selectorFamily({ key: "display-edge", get: @@ -84,7 +88,7 @@ const displayEdgeSelector = selectorFamily({ // For SPARQL, display the edge type as the ID const displayId = isSparql ? displayTypes : edge.id; - const typeAttributes = get(vertexTypeAttributesSelector(edgeTypes)); + const typeAttributes = get(edgeTypeAttributesSelector(edgeTypes)); const sortedAttributes = getSortedDisplayAttributes( edge, typeAttributes, @@ -115,7 +119,7 @@ const displayEdgeSelector = selectorFamily({ ) .join(", "); - // Get the display name and description for the vertex + // Get the display name and description for the edge function getDisplayAttributeValueByName(name: string | undefined) { if (name === RESERVED_ID_PROPERTY) { return displayId; diff --git a/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts b/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts index 43b2d7591..5866c35fe 100644 --- a/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/displayTypeConfigs.test.ts @@ -19,7 +19,7 @@ import { useDisplayVertexTypeConfig, } from "./displayTypeConfigs"; import { createRandomName } from "@shared/utils/testing"; -import { sanitizeText } from "@/utils"; +import { RESERVED_TYPES_PROPERTY, sanitizeText } from "@/utils"; describe("useDisplayVertexTypeConfig", () => { describe("when the vertex type is not in the schema", () => { @@ -130,6 +130,11 @@ describe("useDisplayEdgeTypeConfig", () => { expect(act(type).displayLabel).toBe(sanitizeText(type)); }); + it("should have display name attribute for types", () => { + const type = createRandomName("type"); + expect(act(type).displayNameAttribute).toBe(RESERVED_TYPES_PROPERTY); + }); + it("should have style matching the default config", () => { const type = createRandomName("type"); diff --git a/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx b/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx index d18934831..292416ab3 100644 --- a/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx +++ b/packages/graph-explorer/src/modules/EdgesStyling/SingleEdgeStyling.tsx @@ -24,9 +24,8 @@ import { import { LINE_STYLE_OPTIONS } from "./lineStyling"; import defaultStyles from "./SingleEdgeStyling.style"; import modalDefaultStyles from "./SingleEdgeStylingModal.style"; -import { useEdgeTypeConfig } from "@/core/ConfigurationProvider/useConfiguration"; import { useDebounceValue, usePrevious } from "@/hooks"; -import { cn } from "@/utils"; +import { cn, RESERVED_TYPES_PROPERTY } from "@/utils"; export type SingleEdgeStylingProps = { edgeType: string; @@ -49,7 +48,6 @@ export default function SingleEdgeStyling({ const [edgePreferences, setEdgePreferences] = useRecoilState( userStylingEdgeAtom(edgeType) ); - const etConfig = useEdgeTypeConfig(edgeType); const displayConfig = useDisplayEdgeTypeConfig(edgeType); const [displayAs, setDisplayAs] = useState(displayConfig.displayLabel); @@ -62,7 +60,7 @@ export default function SingleEdgeStyling({ options.unshift({ label: t("edges-styling.edge-type"), - value: "type", + value: RESERVED_TYPES_PROPERTY, }); return options; @@ -118,7 +116,7 @@ export default function SingleEdgeStyling({ centered={true} title={
- Customize {displayAs || edgeType} + Customize {displayConfig.displayLabel}
} className={styleWithTheme(modalDefaultStyles)} @@ -133,7 +131,7 @@ export default function SingleEdgeStyling({ { onUserPrefsChange({ displayNameAttribute: value as string }); }} @@ -183,7 +181,7 @@ export default function SingleNodeStyling({