From f3d984a288722ecef28c0a081114a9b58483f813 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan <37743469+CiaranMn@users.noreply.github.com> Date: Thu, 26 Sep 2024 17:06:57 +0100 Subject: [PATCH] H-3362: Support links which link to other links in the types graph visualization (#5234) --- .../persist-entities-action.ts | 2 +- .../migrate-ontology-types/util.ts | 2 +- .../src/pages/shared/entities-table.tsx | 1 + .../graph-container/graph-data-loader.tsx | 2 +- .../pages/shared/type-graph-visualizer.tsx | 38 ++++- .../[[...type-kind]].page/types-table.tsx | 140 ++++++++++-------- .../hash-frontend/src/shared/table-header.tsx | 1 + 7 files changed, 114 insertions(+), 72 deletions(-) diff --git a/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entities-action.ts b/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entities-action.ts index 366b080fc68..4ef9f758de9 100644 --- a/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entities-action.ts +++ b/apps/hash-ai-worker-ts/src/activities/flow-activities/persist-entities-action.ts @@ -40,7 +40,7 @@ export const persistEntitiesAction: FlowActionActivity = async ({ inputs }) => { * This assumes that there are no link entities which link to other link entities, which require being able to * create multiple entities at once in a single transaction (since they refer to each other). * - * @todo handle links pointing to other links via creating many entities at once, unblocked by H-1178 + * @todo handle links pointing to other links via creating many entities at once, unblocked by H-1178. See also entity-result-table */ if ( (a.sourceEntityId && b.sourceEntityId) || diff --git a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts index 06bc10c3fad..9551791cd9a 100644 --- a/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts +++ b/apps/hash-api/src/graph/ensure-system-graph-is-initialized/migrate-ontology-types/util.ts @@ -410,7 +410,7 @@ type BaseCreateTypeIfNotExistsParameters = { migrationState: MigrationState; }; -const generateSystemDataTypeSchema = ({ +export const generateSystemDataTypeSchema = ({ dataTypeId, ...rest }: ConstructDataTypeParams & { dataTypeId: VersionedUrl }): CustomDataType => { diff --git a/apps/hash-frontend/src/pages/shared/entities-table.tsx b/apps/hash-frontend/src/pages/shared/entities-table.tsx index 66f1c197f05..3f6666beb95 100644 --- a/apps/hash-frontend/src/pages/shared/entities-table.tsx +++ b/apps/hash-frontend/src/pages/shared/entities-table.tsx @@ -87,6 +87,7 @@ export const EntitiesTable: FunctionComponent<{ const [filterState, setFilterState] = useState({ includeGlobal: false, + limitToWebs: false, }); const [showSearch, setShowSearch] = useState(false); diff --git a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx index ee30f6a766a..fedd1688ae9 100644 --- a/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx +++ b/apps/hash-frontend/src/pages/shared/graph-visualizer/graph-container/graph-data-loader.tsx @@ -181,7 +181,7 @@ export const GraphDataLoader = ({ for (const edge of edges) { graph.addEdgeWithKey(edge.edgeId, edge.source, edge.target, { - color: "rgba(230, 230, 230, 1)", + color: "rgba(50, 50, 50, 0.5)", label: edge.label, size: edge.size, type: "arrow", diff --git a/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx b/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx index 2d1bc2057ae..d334ed9989b 100644 --- a/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx +++ b/apps/hash-frontend/src/pages/shared/type-graph-visualizer.tsx @@ -46,10 +46,17 @@ export const TypeGraphVisualizer = ({ size: 18, }; + /** + * Link types can appear multiple times in the visualization (one per each destination combination). + * We need to track all the occurrences of a link type, so that if we encounter a link A which links to a link B, + * we can link from link A to all the occurrences of link B. + */ + const linkNodesByEntityTypeId: Record = {}; + for (const { schema } of types) { if (schema.kind !== "entityType") { /** - * Don't yet add property or data types to the graph. + * We don't yet support visualizing property or data types to the graph. */ continue; } @@ -59,7 +66,7 @@ export const TypeGraphVisualizer = ({ const isLink = isSpecialEntityTypeLookup?.[entityTypeId]?.isLink; if (isLink) { /** - * We'll add the links as we process each entity type. + * We'll add the links as we process each entity type – this means that any link types which are unused won't appear in the graph. */ continue; } @@ -112,14 +119,29 @@ export const TypeGraphVisualizer = ({ }); addedNodeIds.add(linkNodeId); + linkNodesByEntityTypeId[linkTypeId] ??= []; + linkNodesByEntityTypeId[linkTypeId].push(linkNodeId); + if (destinationTypeIds) { for (const destinationTypeId of destinationTypeIds) { - edgesToAdd.push({ - edgeId: `${linkNodeId}~${destinationTypeId}`, - size: 3, - source: linkNodeId, - target: destinationTypeId, - }); + let targetNodeIds: string[] = [destinationTypeId]; + + if (isSpecialEntityTypeLookup?.[destinationTypeId]?.isLink) { + /** + * If the destination is itself a link, we need to account for the multiple places the destination link may appear. + */ + targetNodeIds = + linkNodesByEntityTypeId[destinationTypeId] ?? []; + } + + for (const targetNodeId of targetNodeIds) { + edgesToAdd.push({ + edgeId: `${linkNodeId}~${targetNodeId}`, + size: 3, + source: linkNodeId, + target: targetNodeId, + }); + } } } else { /** diff --git a/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx b/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx index 38bcb1ab266..00f2617ddb2 100644 --- a/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx +++ b/apps/hash-frontend/src/pages/types/[[...type-kind]].page/types-table.tsx @@ -122,6 +122,7 @@ export const TypesTable: FunctionComponent<{ const [filterState, setFilterState] = useState({ includeArchived: false, includeGlobal: false, + limitToWebs: false, }); const [selectedEntityTypeId, setSelectedEntityTypeId] = @@ -201,68 +202,85 @@ export const TypesTable: FunctionComponent<{ ]; }, [authenticatedUser]); + const filteredTypes = useMemo(() => { + const filtered: (( + | EntityTypeWithMetadata + | PropertyTypeWithMetadata + | DataTypeWithMetadata + ) & { isExternal: boolean; webShortname?: string; archived: boolean })[] = + []; + + for (const type of types ?? []) { + const isExternal = isExternalOntologyElementMetadata(type.metadata) + ? true + : !internalWebIds.includes(type.metadata.ownedById); + + const namespaceOwnedById = isExternalOntologyElementMetadata( + type.metadata, + ) + ? undefined + : type.metadata.ownedById; + + const webShortname = namespaces?.find( + (workspace) => extractOwnedById(workspace) === namespaceOwnedById, + )?.shortname; + + const isArchived = isTypeArchived(type); + + if ( + (filterState.includeGlobal ? true : !isExternal) && + (filterState.includeArchived ? true : !isArchived) && + (filterState.limitToWebs + ? webShortname && filterState.limitToWebs.includes(webShortname) + : true) + ) { + filtered.push({ + ...type, + isExternal, + webShortname, + archived: isArchived, + }); + } + } + + return filtered; + }, [types, filterState, namespaces, internalWebIds]); + const filteredRows = useMemo( () => - types - ?.map((type) => { - const isExternal = isExternalOntologyElementMetadata(type.metadata) - ? true - : !internalWebIds.includes(type.metadata.ownedById); - - const namespaceOwnedById = isExternalOntologyElementMetadata( - type.metadata, - ) - ? undefined - : type.metadata.ownedById; - - const webShortname = namespaces?.find( - (workspace) => extractOwnedById(workspace) === namespaceOwnedById, - )?.shortname; - - const lastEdited = format( - new Date( - type.metadata.temporalVersioning.transactionTime.start.limit, - ), - "yyyy-MM-dd HH:mm", - ); - - const lastEditedBy = actors?.find( - ({ accountId }) => - accountId === type.metadata.provenance.edition.createdById, - ); - - return { - rowId: type.schema.$id, - typeId: type.schema.$id, - title: type.schema.title, - lastEdited, - lastEditedBy, - kind: - type.schema.kind === "entityType" - ? isSpecialEntityTypeLookup?.[type.schema.$id]?.isFile - ? "link-type" - : "entity-type" - : type.schema.kind === "propertyType" - ? "property-type" - : "data-type", - external: isExternal, - webShortname, - archived: isTypeArchived(type), - } as const; - }) - .filter( - ({ external, archived }) => - (filterState.includeGlobal ? true : !external) && - (filterState.includeArchived ? true : !archived), - ), - [ - actors, - internalWebIds, - isSpecialEntityTypeLookup, - types, - namespaces, - filterState, - ], + filteredTypes.map((type) => { + const lastEdited = format( + new Date( + type.metadata.temporalVersioning.transactionTime.start.limit, + ), + "yyyy-MM-dd HH:mm", + ); + + const lastEditedBy = actors?.find( + ({ accountId }) => + accountId === type.metadata.provenance.edition.createdById, + ); + + return { + rowId: type.schema.$id, + typeId: type.schema.$id, + title: type.schema.title, + lastEdited, + lastEditedBy, + kind: + type.schema.kind === "entityType" + ? isSpecialEntityTypeLookup?.[type.schema.$id]?.isFile + ? "link-type" + : "entity-type" + : type.schema.kind === "propertyType" + ? "property-type" + : "data-type", + external: type.isExternal, + webShortname: type.webShortname, + archived: type.archived, + } as const; + }), + [actors, isSpecialEntityTypeLookup, filteredTypes], ); const sortRows = useCallback< @@ -513,7 +531,7 @@ export const TypesTable: FunctionComponent<{ )} diff --git a/apps/hash-frontend/src/shared/table-header.tsx b/apps/hash-frontend/src/shared/table-header.tsx index ecfa3916dde..43b6df5092e 100644 --- a/apps/hash-frontend/src/shared/table-header.tsx +++ b/apps/hash-frontend/src/shared/table-header.tsx @@ -109,6 +109,7 @@ const NoMaxWidthTooltip = styled(({ className, ...props }: TooltipProps) => ( export type FilterState = { includeArchived?: boolean; includeGlobal: boolean; + limitToWebs: string[] | false; }; export type GetAdditionalCsvDataFunction = () => Promise<{