Skip to content
Open
407 changes: 178 additions & 229 deletions apps/hash-frontend/src/pages/shared/entities-visualizer.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { ignoreNoisySystemTypesFilter } from "@local/hash-isomorphic-utils/graph-queries";
import { systemPropertyTypes } from "@local/hash-isomorphic-utils/ontology-type-ids";

import type { EntitiesFilterState } from "./types";
import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system";
import type { Filter } from "@local/hash-graph-client";

const MATCH_NOTHING_WEB_ID = "00000000-0000-0000-0000-000000000000" as WebId;

const buildArchivedClauses = (includeArchived: boolean): Filter[] => {
if (includeArchived) {
return [];
}

return [
{
notEqual: [{ path: ["archived"] }, { parameter: true }],
},
{
any: [
{
exists: {
path: [
"properties",
systemPropertyTypes.archived.propertyTypeBaseUrl,
],
},
},
{
equal: [
{
path: [
"properties",
systemPropertyTypes.archived.propertyTypeBaseUrl,
],
},
{ parameter: false },
],
},
],
},
];
};

const buildWebClause = (
webState: EntitiesFilterState["web"],
internalWebIds: WebId[],
): Filter | null => {
if (!webState.includeOtherWebs) {
const selected = internalWebIds.filter((id) =>
webState.selectedInternalWebIds.has(id),
);

const webIdsToMatch = selected.length ? selected : [MATCH_NOTHING_WEB_ID];

return {
any: webIdsToMatch.map((webId) => ({
equal: [{ path: ["webId"] }, { parameter: webId }],
})),
};
}

const uncheckedInternalWebIds = internalWebIds.filter(
(id) => !webState.selectedInternalWebIds.has(id),
);

if (uncheckedInternalWebIds.length === 0) {
return null;
}

return {
all: uncheckedInternalWebIds.map((webId) => ({
notEqual: [{ path: ["webId"] }, { parameter: webId }],
})),
};
};

const buildTypeClause = ({
pinnedEntityTypeBaseUrl,
pinnedEntityTypeIds,
selectedTypeIds,
}: {
pinnedEntityTypeBaseUrl?: BaseUrl;
pinnedEntityTypeIds?: VersionedUrl[];
selectedTypeIds: Set<VersionedUrl> | null;
}): { clause: Filter | null; isPinned: boolean } => {
if (pinnedEntityTypeBaseUrl) {
return {
clause: {
equal: [
{ path: ["type", "baseUrl"] },
{ parameter: pinnedEntityTypeBaseUrl },
],
},
isPinned: true,
};
}

if (pinnedEntityTypeIds?.length) {
return {
clause: {
any: pinnedEntityTypeIds.map((entityTypeId) => ({
equal: [
{ path: ["type", "versionedUrl"] },
{ parameter: entityTypeId },
],
})),
},
isPinned: true,
};
}

if (selectedTypeIds === null) {
return { clause: null, isPinned: false };
}

const typeIds = Array.from(selectedTypeIds);

if (typeIds.length === 0) {
return {
clause: {
equal: [{ path: ["type", "versionedUrl"] }, { parameter: "" }],
},
isPinned: false,
};
}

return {
clause: {
any: typeIds.map((entityTypeId) => ({
equal: [
{ path: ["type", "versionedUrl"] },
{ parameter: entityTypeId },
],
})),
},
isPinned: false,
};
};

export const buildEntitiesFilter = ({
filterState,
internalWebIds,
pinnedEntityTypeBaseUrl,
pinnedEntityTypeIds,
}: {
filterState: EntitiesFilterState;
internalWebIds: WebId[];
pinnedEntityTypeBaseUrl?: BaseUrl;
pinnedEntityTypeIds?: VersionedUrl[];
}): Filter => {
const clauses: Filter[] = [];

clauses.push(...buildArchivedClauses(filterState.includeArchived));

const webClause = buildWebClause(filterState.web, internalWebIds);
if (webClause) {
clauses.push(webClause);
}

const { clause: typeClause, isPinned: isTypePinned } = buildTypeClause({
pinnedEntityTypeBaseUrl,
pinnedEntityTypeIds,
selectedTypeIds: filterState.type.selectedTypeIds,
});

if (typeClause) {
clauses.push(typeClause);
}

const userPickedSpecificTypes =
!isTypePinned && filterState.type.selectedTypeIds !== null;

if (!isTypePinned && !userPickedSpecificTypes) {
clauses.push(ignoreNoisySystemTypesFilter);
}

return { all: clauses };
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { VisualizerView } from "../../visualizer-views";
import type { TraversalPath } from "@local/hash-graph-client";

/**
* Graph view resolves links into and out of the displayed entities so link
* endpoints render even when the entity filter is narrow.
*/
const graphViewTraversalPaths: TraversalPath[] = [
{
edges: [
{ kind: "has-left-entity", direction: "incoming" },
Copy link
Copy Markdown

@augmentcode augmentcode Bot May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

apps/hash-frontend/src/pages/shared/entities-visualizer/data/traversal-paths.ts:11 β€” The Graph traversal only follows incoming has-left-entity -> outgoing has-right-entity, which seems to resolve links where the displayed entity is the left endpoint but not where it’s the right endpoint. That looks inconsistent with the comment about resolving links β€œinto and out of” displayed entities, and may cause some incoming links/endpoints to be missing in Graph view.

Severity: medium

Fix This in Augment

πŸ€– Was this useful? React with πŸ‘ or πŸ‘Ž, or πŸš€ if it prevented an incident/outage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CiaranMn this should be according to the requirements, right? Please resolve if so.

{ kind: "has-right-entity", direction: "outgoing" },
],
},
];

/**
* Table / Grid views only need to resolve a link entity's own source and
* target endpoints.
*/
const tableViewTraversalPaths: TraversalPath[] = [
{
edges: [
{ kind: "has-left-entity", direction: "outgoing" },
{ kind: "has-right-entity", direction: "outgoing" },
],
},
];

export const traversalPathsForView = (
view: VisualizerView,
): TraversalPath[] => {
return view === "Graph" ? graphViewTraversalPaths : tableViewTraversalPaths;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { VersionedUrl, WebId } from "@blockprotocol/type-system";

export type EntitiesFilterState = {
web: {
selectedInternalWebIds: Set<WebId>;
includeOtherWebs: boolean;
};
type: {
selectedTypeIds: Set<VersionedUrl> | null;
};
includeArchived: boolean;
};

export const createDefaultFilterState = (
internalWebIds: WebId[],
): EntitiesFilterState => ({
web: {
selectedInternalWebIds: new Set<WebId>(internalWebIds),
includeOtherWebs: false,
},
type: { selectedTypeIds: null },
includeArchived: false,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { useQuery } from "@apollo/client";
import { useMemo } from "react";

import { currentTimeInstantTemporalAxes } from "@local/hash-isomorphic-utils/graph-queries";

import { queryEntitySubgraphQuery } from "../../../../graphql/queries/knowledge/entity.queries";
import { buildEntitiesFilter } from "./build-filter";

import type {
QueryEntitySubgraphQuery,
QueryEntitySubgraphQueryVariables,
} from "../../../../graphql/api-types.gen";
import type { EntitiesFilterState } from "./types";
import type { BaseUrl, VersionedUrl, WebId } from "@blockprotocol/type-system";

export type AvailableType = {
entityTypeId: VersionedUrl;
title: string;
count: number;
};

export const useAvailableTypes = ({
filterState,
internalWebIds,
entityTypeBaseUrl,
entityTypeIds,
}: {
filterState: EntitiesFilterState;
internalWebIds: WebId[];
entityTypeBaseUrl?: BaseUrl;
entityTypeIds?: VersionedUrl[];
}): { types: AvailableType[]; loading: boolean } => {
const skip = !!entityTypeBaseUrl || !!entityTypeIds?.length;

const filterStateWithoutType = useMemo<EntitiesFilterState>(
() => ({
...filterState,
type: { selectedTypeIds: null },
}),
[filterState],
);

const filter = useMemo(
() =>
buildEntitiesFilter({
filterState: filterStateWithoutType,
internalWebIds,
}),
[filterStateWithoutType, internalWebIds],
);

const { data, loading } = useQuery<
QueryEntitySubgraphQuery,
QueryEntitySubgraphQueryVariables
>(queryEntitySubgraphQuery, {
skip,
fetchPolicy: "cache-and-network",
variables: {
request: {
limit: 1,
filter,
includeTypeIds: true,
includeTypeTitles: true,
temporalAxes: currentTimeInstantTemporalAxes,
includeDrafts: false,
includePermissions: false,
traversalPaths: [],
},
},
});

const types = useMemo<AvailableType[]>(() => {
if (skip || !data) {
return [];
}
const typeIds = data.queryEntitySubgraph.typeIds ?? {};
const typeTitles = data.queryEntitySubgraph.typeTitles ?? {};
return Object.entries(typeIds)
.map(([entityTypeId, count]) => {
const versionedUrl = entityTypeId as VersionedUrl;
return {
entityTypeId: versionedUrl,
title: typeTitles[versionedUrl] ?? entityTypeId,
count,
};
})
.sort((a, b) => a.title.localeCompare(b.title));
}, [data, skip]);

return { types, loading: skip ? false : loading };
};
Loading
Loading